译使用 ASM 对 Java 字节码打桩

发布于 2023-11-01 15:46:05 字数 16574 浏览 28 评论 0

在本篇文章中,你将学会如何使用 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 更多内容详见 这里

基于栈的虚拟机

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

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

下面来看一个 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)​

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 来进行代码打桩了。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

我不会写诗

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

13886483628

文章 0 评论 0

流年已逝

文章 0 评论 0

℡寂寞咖啡

文章 0 评论 0

笑看君怀她人

文章 0 评论 0

wkeithbarry

文章 0 评论 0

素手挽清风

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文