简介

ASM官方地址:http://asm.ow2.org/

这里使用的是asm-debug-all-5.0.2.jar,其它版本基本类似。

ASM是一个用于操作或分析bytecode的框架,ASM可以新增,读取甚至修改class文件。通过ASM,我们可以根据需要修改class文件,如增加日志、删除无用代码、分析程序行为等等。

ASM中的常用类

  • ClassReader:类读取工具。
  • ClassVisitor:类访问者接口,ASM的设计主要基于访问者模式以及基于对象树,此接口中包含与操作该类相关的接口。
  • MethodVisitor:方法访问者接口,此接口中包含与操作方法相关的接口。
  • FieldVisitor:属性访问者接口,此接口相对比较少用。
  • ClassWriter:类写入工具,继承自ClassVisitor。
  • MethodWriter:方法写入工具,继承自MethodVisitor,此类非公开类,由ClassWriter来使用。
  • ClassNode:代表一个类结点的结构,继承自ClassVisitor。
  • MethodNode:代表一个方法结点的结构,继承自MethodVisitor。
  • FieldNode:代表一个属性结点的结构,继承自FieldVisitor。
  • AbstractInsnNode:代表一条指令的抽象。

其它类请大家自行摸索,如Type类。

实例

先举个最简单的例子,下面功能实现了从A.class文件中读取类并写回到A.class中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) throws IOException {
// 指定从A.class中读入
ClassReader cr = new ClassReader(new FileInputStream("A.class"));
// 用于接收需要写入的内容,ClassWriter继承自ClassVisitor
ClassWriter cw = new ClassWriter(0);
// ClassReader.accept方法接收一个ClassVisitor的对象
// 该方法会把读入的内容依次写入到相应的ClassVisitor对象中
cr.accept(cw, 0);
// 把ClassWriter接收到的内容转换成字节数组
byte[] byteArray = cw.toByteArray();
// 把相应的内容写回到A.class中
FileOutputStream fos = new FileOutputStream("A.class");
fos.write(byteArray);
fos.close();
}
}

上面代码中有个非常关键的方法:accept。这个方法接收一个ClassVisitor,并把自身的内容依次写到ClassVisitor中。除了ClassReader有这个accept外,ClassNode和MethodNode也有此方法。大家可以这样理解,这三个类中都是有数据的,ClassReader的数据来源于文件,而ClassNode和MethodNode的数据来源于内存,而accept方法是用于向ClassVisitor写入数据的。

前面说过ClassWriter与ClassNode都继承自ClassVisitor,ClassWriter接收到的内容会保存到一个字节数组里,而ClassNode接收到的内容则保存到ClassNode自身的数据结构中。

ClassNode可以理解为一个普通的Model类,其里面包含了类的版本、访问权限、类名、类签名、父类、父接口、注解、拥有的方法(MethodNode)、拥有的属性(FieldNode)等等。

类似的还有MethodNode与FieldNode。

下面的例子是把A类及其方法名+方法签名都打印出来,然后把类以及所有方法的访问权限都改为public的,并重新写回到A.class中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 这里实现的Opcodes,主要是为了方便使用ASM中的一些常量,如ACC_PUBLIC
public class Main implements Opcodes {
public static void main(String[] args) throws IOException {
ClassReader cr = new ClassReader(new FileInputStream("A.class"));
ClassNode cn = new ClassNode();
// 把A.class的内容写入到ClassNode结构中
cr.accept(cn, 0);
// 打印类名
System.out.println(cn.name);
// 更改类的权限为public
cn.access = changeAccessToPublic(cn.access);
if(cn.methods != null) {
for(MethodNode mn : cn.methods) {
// 打印方法名和方法签名
System.out.println("\t" + mn.name + mn.desc);
// 更改类的权限为public
mn.access = changeAccessToPublic(mn.access);
}
}
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
byte[] byteArray = cw.toByteArray();
FileOutputStream fos = new FileOutputStream("A.class");
fos.write(byteArray);
fos.close();
}
public static int changeAccessToPublic(int access) {
// public protected private只能取其一
return (access & (~ACC_PRIVATE) & (~ACC_PROTECTED)) | ACC_PUBLIC;
}
}

我们看到,ClassNode结构已经包含了整个类中所有内容,因此我们只要修改此结构的内容,就可以很方便地修改此类。

这个例子用到的方式可以称作基于对象树的模式,上面提到ASM的设计还包括基于访问者模式,那么我们看下如何使用访问者模式来实现。为了不与上面重复,这里实现的功能是修改类名及方法名为一些无意义的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
ClassReader cr = new ClassReader(new FileInputStream("A.class"));
ClassNode cn = new ClassNode();
cr.accept(cn, 0);
ClassWriter cw = new ClassWriter(0);
// ClassVisitor接收一个ClassVisitor的参数,并存放在其成员变量mv中
cn.accept(new ClassVisitor(ASM5, cw) {
String name = "a";
// 生成一个不重复的名字
private String getUniqueName() {
int l = name.length() - 1;
if(name.charAt(l) == 'z') {
name += "a";
} else {
name = name.substring(0, l) + (char)(name.charAt(l)+1);
}
return name;
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
// super的实现很简单,就是直接调用mv.visit方法,而这里的mv即为cw
// 这里调用了getUniqueName()来新到一个新的名字
// 最终的效果是,ClassWriter所拿到的类名为我们生成的名字
super.visit(version, access, getUniqueName(), signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
// 这里与上面类似,不解释
return super.visitMethod(access, getUniqueName(), desc, signature, exceptions);
}
});
byte[] byteArray = cw.toByteArray();
FileOutputStream fos = new FileOutputStream("A.class");
fos.write(byteArray);
fos.close();

我想更多人关心的是应该如何修改代码吧,上面说过AbstractInsnNode对应于指令的抽象。通过查看AbstractInsnNode的子类,可以发现很多个以Node结尾的类,每一个AbstractInsnNode的子类都与MethodVisitor中以visit开头的方法相对应。例如InsnNode与MethodVisitor.visitInsn方法相对应。

ASM的分类方式与我之前介绍的分类方式有些不一样,因为ASM是个开源库,是给程序员使用的,因此它的划分方式,当然要为了让我们更容易更方便地编写代码。这里我只介绍一部分一会需要用到的类,其它的大家自行研究。

InsnNode:简单指令结点,代表所有不需要额外参数的指令。这点从它只接收一个opcode的参数就可以看出来。

LdcInsnNode:任意常量加载指令结点,其只接收一个Object类型的对象。这条指令的作用是加载各种常量,至于是什么常量,是根据该参数的实际类型决定。这些类型必须为以下类型之一:Integer、Float、Long、Double、String、Type、Handler。Handler类型为ASM自带类型,目前我还没发现有什么代码可以产生出来,大家知道的跟我说下。Type同样为ASM自带类型,用于加载Class对象,如加载Object.class可使用如下代码new LdcInsnNode(Type.getType(Object.class))。

MethodInsnNode:方法调用指令结点,这个没什么好说的,大家看下就懂,注意不要与MethodNode混淆了。

接下来我们实现一下通过对象树的方式来在每个方法返回时增加一条日志打印当前方法的日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
ClassReader cr = new ClassReader(new FileInputStream("A.class"));
final ClassNode cn = new ClassNode();
cr.accept(cn, 0);
if(cn.methods != null) {
for(MethodNode mn : cn.methods) {
InsnList insns = mn.instructions;
// 过滤掉抽象方法、本地方法
if(insns == null || insns.size() == 0) {
continue;
}
InsnList newInsns = new InsnList();
ListIterator<AbstractInsnNode> iter = insns.iterator();
while(iter.hasNext()) {
AbstractInsnNode node = iter.next();
int opcode = node.getOpcode();
// 这里其实没必要判断,这样写想说明这些opcode都是属于InsnNode的
// 这样的写法等价于node instanceof InsnNode
if(node.getType() == AbstractInsnNode.INSN) {
if(opcode >= IRETURN && opcode <= RETURN) {
// 增加获取System.out属性的指令
newInsns.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
// 增加加载字符串常量的指令,这里加载的字符串是类名.方法声明
newInsns.add(new LdcInsnNode(cn.name + "." + mn.name + mn.desc));
// 增加调用java/io/PrintStream.println方法的指令
newInsns.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
}
}
newInsns.add(node);
}
mn.instructions = newInsns;
}
}
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
byte[] byteArray = cw.toByteArray();
FileOutputStream fos = new FileOutputStream("A.class");
fos.write(byteArray);
fos.close();

下面实现的功能是通过visitor的方式在每个方法中开头增加一条日志来打印出当前调用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ClassReader cr = new ClassReader(new FileInputStream("A.class"));
final ClassNode cn = new ClassNode();
cr.accept(cn, 0);
ClassWriter cw = new ClassWriter(0);
cn.accept(new ClassVisitor(ASM5, cw) {
@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, String signature, String[] exceptions) {
return new MethodVisitor(ASM5, super.visitMethod(access, name, desc, signature, exceptions)) {
@Override
public void visitCode() {
super.visitCode();
// 本地方法和抽象方法都没代码,不能增加。
// java中有两个特殊的方法<clinit>和<init>
// <clinit>为静态构造方法,可能会有加载时机问题,需要小心处理,为了简单,这里不处理
// <init>为构造方法,还记得在Java构造方法中,在调用父类的构造方法前,是不能调用其它方法的
// 因此构造方法的日志记录需要放在调用父类的构造方法之后,这里不处理,当留给大家的作业吧
if(checkAccess(ACC_NATIVE) || checkAccess(ACC_ABSTRACT) || name.contains("<")) {
return;
}
// 增加获取System.out属性的指令
super.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
// 增加加载字符串常量的指令,这里加载的字符串是类名.方法声明
super.visitLdcInsn(cn.name + "." + name + desc);
// 增加调用java/io/PrintStream.println方法的指令
super.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
private boolean checkAccess(int flag) {
return (access & flag) == flag;
}
};
}
});
byte[] byteArray = cw.toByteArray();
FileOutputStream fos = new FileOutputStream("A.class");
fos.write(byteArray);
fos.close();

大家对照一下上面对象树的写法与visitor的写法,理解一下ASM的设计,这样写起代码来会更加的得心应手。

排错技巧

ASM的修改涉及到bytecode的内容,写错一点,都会导致各种各样奇怪的问题。因此这里介绍一下排错的技巧,帮助大家更好地定位。

所有编程语言的开发中,都会分为两种错误。一是编译时错误,二是运行时错误。通过编写ASM代码来生成bytecode的过程,其实就相当于编写bytecode代码,因此也会有类似的编译时错误和运行时错误。

这里所说bytecode的编译时错误指的是JVM会对bytecode进行校验,校验失败则拒绝运行。而运行时错误时就很好理解了,是指运行过程中,运行的结果不符合我们的预期。

编译时错误如果还需要等JVM进行校验,那么排错起来也非常麻烦,ASM框架中提供了分析器的功能,能够帮助我们在生成bytecode前找出编译时错误。为了方便,这里我提供了封装好分析器的AsmVerify类,源码见:

https://github.com/noverguo/jdtools/blob/master/merger/src/com/tencent/jazz/util/AsmVerify.java

声明一下,此代码借鉴于dex2jar中的AsmVerify类,实现原理大家自行摸索。

下面我来说明一下如何使用此类来找到编译时的错误。

假设我们有一个Tool类,Tool类中有多个静态方法,其中有一个打印日志的方法。现在我希望能够把Tool类中除日志方法外的所有静态方法都通过日志方法来打印当前所调用的方法。

我们先看下Tool类是怎样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.tencent.asm;
import java.util.Date;
public class Tool {
public static int add(int a, int b) {
return a+b;
}
public static String subString(String value, int start, int end) {
return value.substring(start, end);
}
/**
* 打印日志,如果为空就不打印
* @param info
* @return 是否打印
*/
public static boolean Log(String info) {
if(info == null || info.trim().isEmpty()) {
return false;
}
System.out.println(new Date().toString() + ": " + info);
return true;
}
}

接下来看下我们期待修改后的Tool类应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.tencent.asm;
import java.util.Date;
public class Tool {
public static int add(int a, int b) {
Log("add");
return a+b;
}
public static String subString(String value, int start, int end) {
Log("subString");
return value.substring(start, end);
}
/**
* 打印日志,如果为空就不打印
* @param info
* @return 是否打印
*/
public static boolean Log(String info) {
if(info == null || info.trim().isEmpty()) {
return false;
}
System.out.println(new Date().toString() + ": " + info);
return true;
}
}

实现这个功能非常简单,前面已经介绍过类似的例子了,下面给我一个错误的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
ClassReader cr = new ClassReader(new FileInputStream("Tool.class"));
final ClassNode cn = new ClassNode();
cr.accept(cn, 0);
if(cn.methods != null) {
List<MethodNode> modifyMethodNodes = new ArrayList<MethodNode>();
Iterator<MethodNode> iter = cn.methods.iterator();
while(iter.hasNext()) {
MethodNode mn = iter.next();
// 日志方法、构造方法不处理
if(mn.name.equals("Log") || mn.name.contains("<")) {
continue;
}
modifyMethodNodes.add(mn);
iter.remove();
}
for(final MethodNode mn : modifyMethodNodes) {
// MethodNode newMethodNode = new MethodNode(mn.access, mn.name, mn.desc, mn.signature, mn.exceptions == null ? null : mn.exceptions.toArray(new String[0]));
// cn.methods.add(newMethodNode);
// 这一行代码等价于上面注释的两行代码
MethodNode newMethodNode = (MethodNode) cn.visitMethod(mn.access, mn.name, mn.desc, mn.signature, mn.exceptions == null ? null : mn.exceptions.toArray(new String[0]));
mn.accept(new MethodVisitor(ASM5, newMethodNode) {
@Override
public void visitInsn(int opcode) {
if(opcode >= IRETURN && opcode <= RETURN) {
// 把方法名放入栈顶
super.visitLdcInsn(mn.name);
// 调用日志方法打印日志记录
super.visitMethodInsn(INVOKESTATIC, "com/tencent/asm/Tool", "Log", "(Ljava/lang/String;)Z", false);
}
super.visitInsn(opcode);
}
});
}
}
// COMPUTE_MAXS代表重新计算本地变量及栈的大小
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cn.accept(cw);
byte[] code = cw.toByteArray();
// 使用封装好的AsmVerify来检查编译时错误
AsmVerify.check(code);
FileOutputStream fos = new FileOutputStream("Tool.class");
fos.write(code);
fos.close();

大家先看下这种写法有什么问题,然后自己跑写试试,看会出现什么情况。

大家关注一下AsmVerify.check(code);这一行,这里使用了刚刚给大家提供的AsmVerify类对生成的bytecode进行检验。接下来我们看下这段代码运行后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
org.objectweb.asm.tree.analysis.AnalyzerException: Error at instruction 6: Expected an object reference, but found I

at org.objectweb.asm.tree.analysis.Analyzer.analyze(Analyzer.java:294)

at com.tencent.asm.AsmVerify.check(AsmVerify.java:47)

at com.tencent.asm.AsmVerify.check(AsmVerify.java:34)

at com.tencent.asm.Main.main(Main.java:59)

Caused by: org.objectweb.asm.tree.analysis.AnalyzerException: Expected an object reference, but found I

at org.objectweb.asm.tree.analysis.BasicVerifier.unaryOperation(BasicVerifier.java:171)

at org.objectweb.asm.tree.analysis.BasicVerifier.unaryOperation(BasicVerifier.java:47)

at org.objectweb.asm.tree.analysis.Frame.execute(Frame.java:582)

at org.objectweb.asm.tree.analysis.Analyzer.analyze(Analyzer.java:199)

... 3 more

00000 RII : | ALOAD 0

00001 RII : R | ILOAD 1

00002 RII : RI | ILOAD 2

00003 RII : RII | INVOKEVIRTUAL java/lang/String.substring (II)Ljava/lang/String;

00004 RII : R | LDC "subString"

00005 RII : RR | INVOKESTATIC com/tencent/asm/Tool.Log (Ljava/lang/String;)Z

00006 RII : RI | ARETURN

Exception in thread "main" java.lang.RuntimeException: Error verify method com/tencent/asm/Tool.subString (Ljava/lang/String;II)Ljava/lang/String;

at com.tencent.asm.AsmVerify.check(AsmVerify.java:53)

at com.tencent.asm.AsmVerify.check(AsmVerify.java:34)

at com.tencent.asm.Main.main(Main.java:59)

Caused by: org.objectweb.asm.tree.analysis.AnalyzerException: Error at instruction 6: Expected an object reference, but found I

at org.objectweb.asm.tree.analysis.Analyzer.analyze(Analyzer.java:294)

at com.tencent.asm.AsmVerify.check(AsmVerify.java:47)

... 2 more

Caused by: org.objectweb.asm.tree.analysis.AnalyzerException: Expected an object reference, but found I

at org.objectweb.asm.tree.analysis.BasicVerifier.unaryOperation(BasicVerifier.java:171)

at org.objectweb.asm.tree.analysis.BasicVerifier.unaryOperation(BasicVerifier.java:47)

at org.objectweb.asm.tree.analysis.Frame.execute(Frame.java:582)

at org.objectweb.asm.tree.analysis.Analyzer.analyze(Analyzer.java:199)
... 3 more

这报错信息中,我们先要关注的是Error at instruction 6: Expected an object reference, but found I这段内容。这段内容的意思是出错的地方在第6条指令,希望得到一个对象的引用,但得到的是整型。

此外,我们看到它打印了一些指令相关的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
00000 RII :       |    ALOAD 0

00001 RII : R | ILOAD 1

00002 RII : RI | ILOAD 2

00003 RII : RII | INVOKEVIRTUAL java/lang/String.substring (II)Ljava/lang/String;

00004 RII : R | LDC "subString"

00005 RII : RR | INVOKESTATIC com/tencent/asm/Tool.Log (Ljava/lang/String;)Z

00006 RII : RI | ARETURN

刚刚的报错信息说了,出错地方在第6条指令,我们看下这里第6条指令是ARETURN,而RII : IIRI这东西又是什么?这里解释一下,前面的RII代表的是前一条指令执行后locals的内容,后面的IIRI代表的是前一条指令执行后stack的内容。R代表对象或数组(Java中数组其实也是个对象),I代表int。

再往前看看,LDC “substring”以及 INVOKESTATIC com/tencent/asm/Tool.Log…这两条指令不正是我们加的吗?大家仔细看代码,发现为什么会出错了吗?原来是因为Log方法有返回值,执行完Log方法后,返回值就自动压到栈顶了,而ARETURN则使用了这个int值进行返回,但ARETURN需要的是对象类型。

那么怎么修正呢?最简单的方式,当然是加一条POP指令,把Log方法的返回值从栈中弹出,如加入如下代码:
super.visitInsn(POP);

上面的代码中调用日志方法是放在返回指令之前的,如果有多处返回,那么就会增加多条指令,当然这种做法并没什么错误,毕竟我们的要求是打印当前调用的方法,没说什么时候打印(其实返回的方式不止*RETURN这类指令,这里只是为了简单起见)。

但是最好的方式,当然是放在代码的最前面,这种方式之前的例子已经有了,大家自己试一试吧。

运行时错误的定位与平时编程一样,只能依靠自身的经验,根据报错信息或者不符合预期的行为来在反编译后的代码中进行查找定位。Jar包的反编译可使用JD-GUI、javap这类工具。