【译】使用ASM对Java字节码打桩

在本篇文章中,你将学会如何使用ASM框架对Java的class文件进行打桩(Instrument)。Part 1介绍Java字节码相关知识并展示如何阅读class文件,Part 2介绍ASM中频繁用到的访问者模式(Visitor),最后在Part 3我们将会使用ASM搭建一个简单的调用跟踪打桩样例。

Part 1:Java字节码

ASM是一个Java字节码操作框架。首先我们先弄清楚什么是“Java字节码”,Java字节码是Java虚拟机中的指令集。每条指令由一个单字节的操作码加上零或多个操作数组成。例如,“iadd”需要接受两个整数作为操作数,然后该指令将它们加起来。对于指令集的详细信息可以参考这里。下面这个分组列表会帮助你快速了解Java字节码包含哪些:

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

Java虚拟机

在深入字节码之前,我们先来弄清楚在字节码执行过程中java虚拟机(JVM)是怎么工作的。JVM是一个平台无关的执行环境,它将Java字节码转换成机器语言并且执行。并且,JVM是一个基于栈的虚拟机,每个线程都有一个JVM栈,这个栈由栈帧(Frame)组成。每次调用一个方法时都会创建一个栈帧,这个栈帧由操作数栈、本地变量表和指向运行时常量池的引用组成。

jvm-stack-frame

关于JVM更多内容详见这里

基于栈的虚拟机

为了更好的理解Java字节码,我们需要知道一点关于基于栈的VM。对于一个基于栈的虚拟机来说,存放着操作数的内存结构是。操作数以后进先出(LIFO)的方式从栈中弹出,然后处理,最后再把结果push回去。举个例子,两个数相加的行为如下所示:

add

如果你对这部分比较感兴趣,那么可以参考这里获取更多关于基于栈的虚拟机和基于寄存器的虚拟机的相关知识。

下面来看一个Java代码的例子:

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”来编译这段程序生成class文件,然后使用“javap -c”解析class文件来得到字节码,如下所示:

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        
}
  • 首先来看Test()构造方法,构造方法的字节码包含三条字节码指令。第一条字节码指令aload_0将本地变量表中下标为0的变量push进操作数栈中,该变量为构造方法的隐含方法参数this。第二条指令invokespecial调用父类的构造器。所有不显示继承的类都隐式继承于java.lang.Object,编译器添加了必要的字节码来调用基类的构造器。在这条指令执行中,操作数栈的顶部值会被弹出。可以看到字节码中左边的索引值不连续,这是因为一些字节码需要有参数,参数在字节码数组中占用位置。
  • #number是常量池中的常量索引,常量池是一个表,它包含字符串常量,类和接口名称,字段名称和其他在Class文件结构中用到的常量。我们可以使用“javap -c -v”来看整个常量池。
  • Java中有两种类型的方法:实例方法(invokevirtual)和类方法(invokestatic)。当Java虚拟机调用类方法,它基于对象引用类型来调用方法,这是编译时就可以确定的;而虚拟机执行实例方法时,它基于对象实际类型来调用方法,这是运行时确定的;
  • 关于更多Java字节码的内容可以看这篇牛逼的文章

Part 2:访问者模式

在面向对象编程中,访问者模式是一种分离对象结构和操作算法的模式,这种分离能够让我们在不修改原结构的情况下添加新的操作。

考虑两个对象,它们的类不同;一个称为元素(Element),另一个称为访问者(Visitor)。元素有一个accept方法,该方法接收访问者作为参数;accept()方法调用访问者的visit()方法,并且将元素自身作为参数传递给访问者。

代码样例如下。在这个例子中,我们将会依照ASM在字节码操作中使用的访问者模式来编写,因此代码结构会和ASM有点类似。

  1. 添加一个accept(Visitor)方法到元素类中
  2. 创建一个访问者基类,基类中包含每一种元素类的visit()方法
  3. 创建一个访问者派生类,派生类实现基类的各种visit方法
  4. Client创建访问者对象,调用元素accept()方法并传递访问者对象
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 );
      }
   }
}

运行结果如下:

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中,元素(Element)为ClassReader类、MethodNode类等等,访问者接口则包含ClassVisitor,AnnotationVisitor,FieldVisitor和MethodVisitor。MethodNode类中的accept方法有如下方法签名:

void accept(ClassVisitor cv)

void accept(MethodVisitor mv)

ClassVisitor中的visit方法族如下:

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)

asm-flow

Part 3:调用链跟踪

在本节中,我们将会使用ASM实现一个调用链跟踪,代码中将会打印每个方法调用和返回。待会可以看到,打印出来的日志可以被很容易处理成一个上下文调用树

在继续之前,你需要安装JDK环境,同时下载ASM 5.0.3 binary distribution。另外,样例代码可以在这里找到。解压asm和样例包,将asm-all-5.0.3.jar复制到样例代码目录下:

$ 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:复制类文件

为了熟悉ASM使用方法,我们第一个ASM程序只是简单的复制一个class文件。后面我们会做更有意思的事情,但其实和这个例子的结构差不多。我们的Copy.java代码如下:

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();
    }
}

这个Copy程序需要接收两个命令行参数,args[0]是原class文件名称,args[1]是目标class名称。

我们在例子中使用了两个ASM类:ClassReader从文件中读取Java字节码,ClassWriter写字节码到文件中。ASM使用上面提到的访问者模式:ClassWriter实现了ClassVisitor,然后通过cr.accept(cw, 0)来使得ClassReader在遍历字节码过程中不断调用cw的visit方法,最终产生相同的字节码序列。

ClassWriter构造方法中的ClassWriter.COMPUTE_FRAMES参数是可选的,它使得ClassWriter自动计算栈帧大小。cr.accept方法的第二个参数同样为可选参数,0表示默认行为。具体请参考ClassReaderClassWriter的JavaDocs。

# 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

调用链跟踪

我们已经熟悉了ASM的基本使用,那么现在来实现调用链跟踪。我们将会使用stderr打印方法的调用和返回,假设原始程序如下:

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。对Test.class打桩后的效果如下所示:

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做适配。大部分情况下,ClassAdapter中继承于ClassVisitor的方法只是简单调用被适配的ClassWriter的相同方法;我们只覆盖ClassWriter.visitMethod方法,这个方法在遇到类方法声明时会被调用。visitMethod的返回值是一个MethodVisitor对象,这个对象会被用来处理方法体。ClassWriter.visitMethod返回一个MethodVisitor,而MethodVisitor会产生方法的字节码。我们需要对ClassWriter.visitMethod返回的MethodVisitor做适配,插入额外的指令来打印调用链。

class 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类没有添加任何的打桩代码,它只是简单调用被包装的MethodVisitor——mv。我们知道怎么使用Java语法来打桩,但我们不知道怎么用ASM的API来实现。我们可以用ASM中自带的ASMifier这个工具来帮助我们分析。

我们可以使用ASMifier来将TestInstrumented转换成ASM API调用。为了简洁,这里省略了一些无关代码:

$ 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 **/

ASMifier的输出是一个ASM程序,这段程序可以用来运行产生TestInstrumented.class。其中我们想知道的是如何调用System.err.println:

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);

现在我们知道怎么调用System.err.println,我们可以完成MethodAdapter的实现了:

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代码样例的调用链跟踪吧:

# 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来进行代码打桩了,关于更多内容请参考:

原文链接

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

Written on July 2, 2017