翻译自 http://web.cs.ucla.edu/~msb/cs239-tutorial/

在这篇文章中,你将学会如何使用ASM框架对java class文件插桩。文章第一部分介绍Java字节码以及如何阅读class文件。第二部分介绍贯穿ASM框架的vistor模式。第三部分我们将用ASM构建一个简单的调用链跟踪插桩。

第一部分:Java字节码

ASM是一个Java字节码操作框架,首先介绍一下Java字节码。Java字节码是Java虚拟机(JVM)的指令集。每一条指令由一个字节的操作码和0个或者多个操作数组成。例如”iadd”,它接收两个整数作为操作数。你可以在这里查看操作指令和操作数的细节。下面有一个简单的列表帮助我们更好地了解java字节码:

  • 加载和存储(例如:aload_0, istore)
  • 数学和逻辑运算(例如:ladd, fcmpl)
  • 类型转换(例如:i2b, d2i)
  • 对象创建和操作(例如:new, putfield)
  • 操作数栈管理(例如:swap, dup2)
  • 控制转移(例如:ifeq, goto)
  • 方法调用和返回(例如:invokespecial, areturn)

Java虚拟机:

要理解java字节码的细节,我们需要了解JVM如何执行字节码。JVM是一个与平台无关的执行环境,它将java字节码转换为机器语言,然后执行。JVM是基于栈的虚拟机,每个线程都有一个JVM栈,这个栈由栈帧(Stack Frame)组成。每次方法调用会创建一个栈帧,一个栈帧由一个操作数栈、一个本地变量数组和指向当方法所在的类的运行时常量池的指针组成。

171227_01.gif

这里可以更深入了解JVM

基于栈的虚拟机:

我们需要一些基于栈的虚拟机的背景知识以便更好的了解Java字节码。对于一个基于栈的虚拟机来说,它存储操作数的内存结构是栈。操作数以LIFO(Last In First Out)的方式从栈顶弹出,被操作之后被压入栈中。在基于栈的虚拟机中,两个数相加的操作如下所示(20、7和结果是操作数)

171227_02.png

如果你对这部分感兴趣,你可以看这里获取更多关于基于栈和基于寄存器的虚拟机的更多细节

例如如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test
{
public static void main(String[] args) {
printOne();
printOne();
printTwo();
}

public static void printOne() {
System.out.println("Hello World");
}

public static void printTwo() {
printOne();
printOne();
}
}

我们用javac来编译java程序,然后使用javap -c来反编译class文件。Java class文件(即后缀是.class的文件)包含JVM中运行的java字节码,Java class文件是由java编译器对java源代码编译之后产生的。反编译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
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return

public static void main(java.lang.String[]);
Code:
0: invokestatic #2 // Method printOne:()V
3: invokestatic #2 // Method printOne:()V
6: invokestatic #3 // Method printTwo:()V
9: return

public static void printOne();
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String Hello World
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

public static void printTwo();
Code:
0: invokestatic #2 // Method printOne:()V
3: invokestatic #2 // Method printOne:()V
6: return
}

分析:

  • 首先看一下public Test()这个构造函数,它包含三个操作指令,第一个是aload_0,它把本地变量表中索引为0的值压入操作数栈。本地变量表是用来传递函数参数的。下一个操作指令invokespecial调用这个类父类的构造器,所有没有明确指明父类的类都继承自java.lang.Object,编译器提供了必要的字节码来调用这个基类的构造器。在这条操作指令执行时,操作数栈最顶部的值this被弹出。最后一条指令return返回应该被返回的值。操作指令的索引不连续是因为有的操作指令有参数,需要在字节码数组中占用位置。
  • #number 是常量池中的索引。常量池是一个包含字符串常量、类和接口名字、变量名字和其他常量的表。我们可以使用”javap -c -v”来查看整个常量池。
  • java语言设置了两种方法,实例方法(invokevirtual)和类方法(invokestatic)。当Java虚拟机调用一个类方法时,它根据对象的引用类型来选择方法,这是在编译时就确定的。而实例方法的调用取决于对象的真正的类,这可能只在运行时才能知道。
  • 可以从这篇优秀的文章了解到关于更多java字节码的知识。

第二部分 访问者模式

在面向对象编程和软件工程中,访问者模式是一种把数据结构和操作这个数据结构的算法分开的模式。这种分离能方便的添加新的操作而无需更改数据结构。

实质上,访问者允许一个类族添加新的虚函数而不修改类本身。但是,创建一个访问者类可以实现虚函数所有的特性。访问者接收实例引用作为输入,使用双重调用实现这个目标。

访问者模式要求程序语言支持单一调用。在这一条件下,我们创建两个不同类的对象,一个是element,另一个是vistor。element有一个accept()方法接收vistor实例作为参数。accept()方法调用vistor的visit()方法;element类将它自己作为参数传入visit方法。

在下面这个例子中,我们使用的访问者模式和ASM来操作字节码的模式相似。

  1. 添加void accept(Vistor v)到Element类
  2. 创建一个vistor基类,基类中包含每一种元素类的visit()方法
  3. 创建一个vistor派生类,实现基类对Element的所有操作。
  4. 使用者创建vistor对象,调用元素的accept方法并传递vistor实例作为参数。
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
interface Element {
// 1. accept(Visitor) interface
public void accept( Visitor v ); // first dispatch
}

class This implements Element {
// 1. accept(Visitor) implementation
public void accept( Visitor v ) {
v.visit( this );
}
public String thiss() {
return "This";
}
}

class That implements Element {
public void accept( Visitor v ) {
v.visit( this );
}
public String that() {
return "That";
}
}

class TheOther implements Element {
public void accept( Visitor v ) {
v.visit( this );
}
public String theOther() {
return "TheOther";
}
}

// 2. Create a "visitor" base class with a visit() method for every "element" type
interface Visitor {
public void visit( This e ); // second dispatch
public void visit( That e );
public void visit( TheOther e );
}

// 3. Create a "visitor" derived class for each "operation" to perform on "elements"
class UpVisitor implements Visitor {
public void visit( This e ) {
System.out.println( "do Up on " + e.thiss() );
}
public void visit( That e ) {
System.out.println( "do Up on " + e.that() );
}
public void visit( TheOther e ) {
System.out.println( "do Up on " + e.theOther() );
}
}

class DownVisitor implements Visitor {
public void visit( This e ) {
System.out.println( "do Down on " + e.thiss() );
}
public void visit( That e ) {
System.out.println( "do Down on " + e.that() );
}
public void visit( TheOther e ) {
System.out.println( "do Down on " + e.theOther() );
}
}

class VisitorDemo {
public static Element[] list = { new This(), new That(), new TheOther() };

// 4. Client creates "visitor" objects and passes each to accept() calls
public static void main( String[] args ) {
UpVisitor up = new UpVisitor();
DownVisitor down = new DownVisitor();
for (int i=0; i < list.length; i++) {
list[i].accept( up );
}
for (int i=0; i < list.length; i++) {
list[i].accept( down );
}
}
}

输出如下:

1
2
3
do Up on This                do Down on This
do Up on That do Down on That
do Up on TheOther do Down on TheOther

ASM中的访问者模式

ASM使用访问者模式,ClassReader类和MethodNode类都是被访问的,访问者接口包括ClassVistor,AnnotationVisitor,FieldVistor和MethodVistor。
accept()方法属于MethodNode,它的定义如下:

1
2
3
void accept(ClassVisitor cv)

void accept(MethodVisitor mv)

visit()方法族,例如visitField(),是ClassVistor的一部分,他们的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void visit(int version, int access, String name, String signature, String superName, String[] interfaces)

AnnotationVisitor visitAnnotation(String desc, boolean visible)

void visitAttribute(Attribute attr)

void visitEnd()

FieldVisitor visitField(int access, String name, String desc, String signature, Object value)

void visitInnerClass(String name, String outerName, String innerName, int access)

MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

void visitOuterClass(String owner, String name, String desc)

void visitSource(String source, String debug)

171227_03.gif

实现调用链跟踪插桩

在这一部分我们会使用ASM实现一个调用链跟踪。插桩代码会打印出每个方法调用和返回,用这些输出很容易就能解析成上下文调用树。

配置

你需要安装好JDK和Apache Ant,实例代码在这里,还需要这里的asm库。把它们解压,复制asm-all-5.0.3.jar到tutorial文件夹

1
2
3
$ unzip ASM-tutorial.zip
$ unzip asm-5.0.3-bin.zip
$ cp asm-5.0.3/lib/all/asm-all-5.0.3.jar ASM-tutorial/

Hello ASM:class文件复制

我们的第一个ASM程序是简单的复制.class文件,这个项目可以作为后面更复杂有趣的实现的样板。Copy.java如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.io.FileInputStream;
import java.io.FileOutputStream;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

public class Copy {
public static void main(final String args[]) throws Exception {
FileInputStream is = new FileInputStream(args[0]);

ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, 0);

FileOutputStream fos = new FileOutputStream(args[1]);
fos.write(cw.toByteArray());
fos.close();
}
}

复制程序接收两个命令行参数,args[0]是要复制的源文件文件名,args[1]是复制后生成的文件名。

我们使用了两个ASM类,ClassReader从文件中读入Java字节码,ClassWriter把字节码写入文件。ASM使用访问者模式:ClassWriter实现ClassVistor接口,然后通过cr.accept(cw,0)使ClassReader在遍历字节码的过程中调用cw,生成同样的字节码。

ClassWriter.COMPUTE_FRAMES参数表示ClassWriter自动计算栈帧的长度。cr.accept()的第二个参数表示默认行为,详情可以查看ClassReader和ClassWriter的java文档。

1
2
3
4
5
# Compile Copy
$ javac -cp asm-all-5.0.3.jar Copy.java

# Use Copy to copy itself
$ java -cp .:asm-all-5.0.3.jar Copy Copy.class Copy2.class

译者注:Linux中环境变量的分隔符是:,Windows底下的是;。

调用链跟踪

现在我们熟悉了ASM的基本用法,下面我们来实现调用链跟踪。每个函数被调用前后,调用信息会输出到stderr。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test
{
public static void main(String[] args) {
printOne();
printOne();
printTwo();
}

public static void printOne() {
System.out.println("Hello World");
}

public static void printTwo() {
printOne();
printOne();
}
}

我们会在函数调用前后把调用信息输出到stderr,插桩完毕后上述代码应该等同于下面的代码

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

public class TestInstrumented
{
public static void main(String[] args) {
System.err.println("CALL printOne");
printOne();
System.err.println("RETURN printOne");

System.err.println("CALL printOne");
printOne();
System.err.println("RETURN printOne");

System.err.println("CALL printTwo");
printTwo();
System.err.println("RETURN printTwo");
}

public static void printOne() {
System.err.println("CALL println");
System.out.println("Hello World");
System.err.println("RETURN println");
}

public static void printTwo() {
System.err.println("CALL printOne");
printOne();
System.err.println("RETURN printOne");

System.err.println("CALL printOne");
printOne();
System.err.println("RETURN printOne");
}
}

我们通过修改上面的Copy来实现,为了修改class文件,我们需要在ClassReader和ClassWriter中间插入某些代码。我们会用适配器模式。适配器包裹一个对象,重写一些方法。这让我们修改被包裹的对象的行为变得很简单。在这里我们会适配ClassWriter,在方法调用前后插入打印代码。

鉴于方法的调用在方法中,我们的插桩会在方法声明处。这会有点复杂,因为方法声明在类中,所以我们需要遍历一个类来对它的方法插桩。

我们第一步用下面的ClassAdapter类适配ClassWriter。默认情况下,继承自ClassVistor的ClassAdapter的方法只会调用ClassWriter中的同一个方法。大多数情况我们想要这样。我们只会覆盖ClassWriter.visitMethod方法,每声明一个方法visitMethod就会被调用一次。它的返回值是处理方法体的MethodVistor对象。我们会适配ClassWriter.visitMethod返回的MethodVisitor,插入其他的指令来输出调用信息。

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
lass ClassAdapter extends ClassVisitor implements Opcodes {

public ClassAdapter(final ClassVisitor cv) {
super(ASM5, cv);
}

@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
return mv == null ? null : new MethodAdapter(mv);
}
}

class MethodAdapter extends MethodVisitor implements Opcodes {

public MethodAdapter(final MethodVisitor mv) {
super(ASM5, mv);
}

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
/* TODO: System.err.println("CALL" + name); */

/* do call */
mv.visitMethodInsn(opcode, owner, name, desc, itf);

/* TODO: System.err.println("RETURN" + name); */
}
}

目前为止,我们的MethodAdapter没有加任何指令。我们很清楚用Java语法如何实现我们的功能,但是我们不知道怎么用ASM API实现它,因此,我们需要使用ASMifier工具。

我们可以使用ASMifiter将TestInstrumented中的代码转换成ASM API调用。为了简介,我们省略了一些代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ javac TestInstrumented.java
$ java -cp .:asm-all-5.0.3.jar org.objectweb.asm.util.ASMifier TestInstrumented
/** WARNING: THINGS ARE ELIDED **/
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "printOne", "()V", null, null);
mv.visitCode();

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello World");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("RETURN println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitInsn(RETURN);
mv.visitMaxs(2, 0);
mv.visitEnd();
}
/** WARNING: MORE THINGS ARE ELIDED **/

ASMfiter的输出是与TestInstrumented功能相同的ASM程序。从这个程序中,我们可以知道”System.err.println”的ASM API如下:

1
2
3
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

现在我们可以完成MethodAdapter的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MethodAdapter extends MethodVisitor implements Opcodes {

public MethodAdapter(final MethodVisitor mv) {
super(ASM5, mv);
}

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
/* System.err.println("CALL" + name); */
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL " + name);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

/* do call */
mv.visitMethodInsn(opcode, owner, name, desc, itf);

/* System.err.println("RETURN" + name); */
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("RETURN " + name);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}

搞定了,在例子Test上试验一下:

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
# Build Instrumenter
$ javac -cp asm-all-5.0.3.jar Instrumenter.java

# Build Example
$ javac Test.java

# Move Test.class out of the way
$ cp Test.class Test.class.bak

# Instrument Test
$ java -cp .:asm-all-5.0.3.jar Instrumenter Test.class.bak Test.class

# Run!
$ java Test
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printTwo
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
RETURN printTwo

输出正如我们期望。

更多信息

现在你知道如何初步使用ASM了,更多内容可以参考

译者注:官方文档是最好的文档。