Android - ASM 插桩你所需要知道的基础

·  阅读 1396

概述

ASM 是个啥?

ASM 就是个对字节码文件进行增删改查的工具包,主要用它来做下面三件事情:

image.png ASM 是在编译期处理字节码的,可以认为是一种编译期的 AOP 技术,很多大厂都用来来做 APM、hook 代码、无侵入埋点等功能。

ASM 支持很多 JVM 语言,下面是官网的描述:

ASM is used in many projects, including:

  • the OpenJDK, to generate the lambda call sites, and also in the Nashorn compiler,
  • the Groovy compiler and the Kotlin compiler,
  • Cobertura and Jacoco, to instrument classes in order to measure code coverage,
  • CGLIB, to dynamically generate proxy classes (which are used in other projects such as Mockito and EasyMock),
  • Gradle, to generate some classes at runtime.

要使用 ASM 必须需要了解一些字节码的基础,所以下面会先讲一些字节码基础再讲 ASM 的使用。

字节码

什么是字节码

Java 是平台无关的语言,但 JVM 却不是跨平台的,不同平台的 JVM 帮我们屏蔽了平台的差异。不同平台的 JVM 加载和执行同一种平台无关的字节码:

image.png 字节码可以简单的理解为可以在 JVM 上执行的二进制文件,我们平常写的 .java 文件经过 javac 编译为 .class 文件,.class 就是字节码文件。

之所以被称之为字节码,是因为字节码文件由十六进制值组成,而 JVM 以两个十六进制值为一组,即以字节为单位进行读取。

下面定义一个简单的 Java 类:

class SimpleClass {

    private int c = 1;

    public int add() {
        int a = 1;
        int b = 2;
        return a + b;
    }

    public int sub(int a, int b){
        int result = a + b - c;
        System.out.println(result);
        return result;
    }
}
复制代码

执行编译命令 javac -g SimpleClass.java 后到的 .class 文件,用二进制文件打开内容如下:

cafe babe 0000 0034 0028 0a00 0600 1a09

0005 001b 0900 1c00 1d0a 001e 001f 0700

2007 0021 0100 0163 0100 0149 0100 063c

696e 6974 3e01 0003 2829 5601 0004 436f

6465 0100 0f4c 696e 654e 756d 6265 7254

6162 6c65 0100 124c 6f63 616c 5661 7269

6162 6c65 5461 626c 6501 0004 7468 6973

0100 144c 777a 2f72 756e 2f53 696d 706c

...
复制代码

然后会发现无法阅读,此时需要根据字节码的字节定义来解析二进制文件,比如:

  • 魔数:0 - 3 字节,值固定为 cafe babe,它代表当前文件是 .class 文件,加载器加载 class 文件时会校验该部分
  • 版本号:之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version),0000 0034,34 转换成 10 进制为 52,对应版本是 1.8.0
  • 常量池:版本号之后的字节为常量池,常量池整体上分为两部分:常量池计数器(cp_info_count)以及常量池数据区(cp_info 集合)
  • ...

逐步解析字节太不方便,可以借助 javap 命令来翻译一下二进制文件,执行命令 javap -c -l -v -p SimpleClass.class

Last modified 2021-8-22; size 702 bytes
  MD5 checksum 86c824b56f7eef2ec5cc5275232b93eb
  Compiled from "SimpleClass.java"
class wz.run.SimpleClass
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#26         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#27         // wz/run/SimpleClass.c:I
   #3 = Fieldref           #28.#29        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #30.#31        // java/io/PrintStream.println:(I)V
   #5 = Class              #32            // wz/run/SimpleClass
   #6 = Class              #33            // java/lang/Object
   #7 = Utf8               c
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               Lwz/run/SimpleClass;
  #16 = Utf8               add
  #17 = Utf8               ()I
  #18 = Utf8               a
  #19 = Utf8               b
  #20 = Utf8               sub
  #21 = Utf8               (II)I
  #22 = Utf8               result
  #23 = Utf8               MethodParameters
  #24 = Utf8               SourceFile
  #25 = Utf8               SimpleClass.java
  #26 = NameAndType        #9:#10         // "<init>":()V
  #27 = NameAndType        #7:#8          // c:I
  #28 = Class              #34            // java/lang/System
  #29 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #30 = Class              #37            // java/io/PrintStream
  #31 = NameAndType        #38:#39        // println:(I)V
  #32 = Utf8               wz/run/SimpleClass
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/System
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Utf8               java/io/PrintStream
  #38 = Utf8               println
  #39 = Utf8               (I)V
{
  private int c;
    descriptor: I
    flags: ACC_PRIVATE

  wz.run.SimpleClass();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field c:I
         9: return
      LineNumberTable:
        line 6: 0
        line 8: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   Lwz/run/SimpleClass;

  public int add();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: ireturn
      LineNumberTable:
        line 11: 0
        line 12: 2
        line 13: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lwz/run/SimpleClass;
            2       6     1     a   I
            4       4     2     b   I

  public int sub(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: aload_0
         4: getfield      #2                  // Field c:I
         7: isub
         8: istore_3
         9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        12: iload_3
        13: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        16: iload_3
        17: ireturn
      LineNumberTable:
        line 17: 0
        line 18: 9
        line 19: 16
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  this   Lwz/run/SimpleClass;
            0      18     1     a   I
            0      18     2     b   I
            9       9     3 result   I
    MethodParameters:
      Name                           Flags
      a
      b
}
SourceFile: "SimpleClass.java"
复制代码

这时感觉好一点了,至少可以读懂不少信息,但是却更懵逼了,不要慌,下面将逐一介绍。

ClassFile 组成

每一个 .class 文件的内容组成如下:

ClassFile {

u4 magic; // 魔数

u2 minor_version; // 版本号

u2 major_version; // 版本号

u2 constant_pool_count; // 常量池长度

cp_info constant_pool[constant_pool_count-1]; // 常量池具体内容

u2 access_flags; // 类访问标记

u2 this_class; // 类索引

u2 super_class; // 超类索引

u2 interfaces_count; // 接口表索引

u2 interfaces[interfaces_count]; // 接口表索引

u2 fields_count; // 字段表

field_info fields[fields_count]; // 字段表

u2 methods_count; // 方法表

method_info methods[methods_count]; // 方法表

u2 attributes_count; // 属性表

attribute_info attributes[attributes_count]; // 属性表

}
复制代码

除了常量池,其他的基本不用解释就能理解,那么常量池又是什么呢?

常量池

常量池可以简单的认为是一个数组,包含运行这个类代码所需的常量,比如从代码引用的变量,方法,接口和类的名称。

常量池整体上分为两部分:常量池计数器(cp_info_count)以及常量池数据区(cp_info 集合),如下图所示:

image.png 常量池的每一行的结构都是上图中的 cp_info,更多 cp_info 信息见附录中的 cp_info 类型。

以上面 SimpleClass.class 为例,大致可以分为下面几类:

  • 文本字符串

  • 字段名称及其类型描述符,比如:

    #2 = Fieldref #5.#27 // wz/run/SimpleClass.c:I
    #5 = Class #32 // wz/run/SimpleClass
    #7 = Utf8 c
    #8 = Utf8 I
    #27 = NameAndType #7:#8 // c:I
    #32 = Utf8 wz/run/SimpleClass
    复制代码

    #2 是一个 FieldRef 类型,定义如下(更多类型说明见附录):

image.png 它的 tag 固定为 9,包含两个 index,index 的含义见上图,index 的值指向常量池中的 #5 和 #27

  • 方法中的局部变量及其类型描述符,比如:
    #18 = Utf8 a
    #19 = Utf8 b
复制代码
  • 方法名称及其描述符
    #16 = Utf8 add
    #17 = Utf8 ()I
复制代码
  • 类和接口的全限定名
    #5 = Class #32 // wz/run/SimpleClass
    #6 = Class #33 // java/lang/Object
    #28 = Class #34 // java/lang/System
    #30 = Class #37 // java/io/PrintStream
复制代码

栈帧

JVM 是一个基于栈的虚拟机,每个线程都有一个虚拟机栈用来存储栈帧( stack frame ),栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,栈帧随着方法调用而创建,随着方法结束而销毁。

image.png 每个栈帧可以简单的认为由三部分组成:

image.png 局部变量表的大小在编译期间就已经确定,对应 Code 属性中的 locals 字段

局部变量区一般用来缓存一些临时数据,比如计算的结果。实际上,JVM 会把局部变量区当成一个 数组,里面会依次缓存 this 指针(非静态方法)、参数、局部变量。

看一下下面这段 java 代码的局部变量表:

class Test1 {

    public static void staticFoo(int id, String name){
        int tempId = 1;
    }

    public void foo(int id, String name){
        int tempId = 2;
    }
}
复制代码

先看一下非静态方法 foo 的字节码:

public void foo(int, java.lang.String);
    descriptor: (ILjava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=4, args_size=3
         0: iconst_2
         1: istore_3
         2: return
      LineNumberTable:
        line 13: 0
        line 14: 2
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       3     0  this   Lcom/keep/plugin/Test1;
            0       3     1    id   I
            0       3     2  name   Ljava/lang/String;
            2       1     3 tempId   I
复制代码

foo 方法参数只有 2 个,但是 args_size = 3,这是因为非静态方法被调用时,第 0 个局部变量是当前的 this。 locals = 4 代表当前局部变量表的长度是 4,LocalVariableTable 中就是变量表中的内容,它表示局部表量表中有 4 个槽(slot):

image.png staticFoo 的字节码和 foo 类似,只不过 static 方法中的局部变量表中不包含 this:

public static void staticFoo(int, java.lang.String);
    descriptor: (ILjava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=3, args_size=2
         0: iconst_1
         1: istore_2
         2: return
      LineNumberTable:
        line 23: 0
        line 24: 2
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       3     0    id   I
            0       3     1  name   Ljava/lang/String;
            2       1     2 tempId   I
复制代码

操作数栈

操作数栈是一个 后进先出(LIFO)栈,在方法调用时,操作数栈用于准备调用方法的参数和接收方法返回的结果。 栈帧工作流程 操作数栈和局部变量表通信 Java虚拟机提供的很多字节码指令用于操作数栈和本地变量表通信:

  • load:从局部变量表或者对象实例的字段中复制常量或者变量到操作数栈
  • store:从操作数栈取走数据、操作数据和把操作结果重新入栈

以下面的 add 方法为例来看一下 JVM 是如何通过栈帧来执行指令的:

public int add() {
        int a = 1;
        int b = 2;
        return a + b;
 }
复制代码

Add 方法对应的字节码如下:

public int add();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_1 // push 常量 1 到操作数栈顶
         1: istore_1 // 将栈顶元素出栈并存到局部变量表 slot1 处
         2: iconst_2 // push 常量 2 到操作数栈顶
         3: istore_2 // 将栈顶元素出栈并存到局部变量表 slot2 处
         4: iload_1 // 加载局部变量表 slot1 处元素到栈顶
         5: iload_2 // 加载局部变量表 slot2 处元素到栈顶
         6: iadd // 将操作数栈栈顶两个元素出栈,相加后将结果入栈
         7: ireturn // 返回栈顶元素,方法结束
      LineNumberTable:
        line 11: 0
        line 12: 2
        line 13: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lwz/run/SimpleClass;
            2       6     1     a   I
            4       4     2     b   I
复制代码

下面画图来描述一下过程,首先要确认局部变量表的长度为 3,操作数栈的的深度为 2:

image.png 下面画图来描述一下过程,首先要确认局部变量表的长度为 3,操作数栈的的深度为 2:

操作数栈和常量池通信

来看一下 sub 方法:

class SimpleClass {

    private int c = 1;
    
    public int sub(int a, int b){
        int result = a + b - c;
        System.out.println(result);
        return result;
    }
}
复制代码
public int sub(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: aload_0
         4: getfield      #2                  // Field c:I
         7: isub
         8: istore_3
         9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        12: iload_3
        13: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        16: iload_3
        17: ireturn
复制代码

通过字节码可以看到可以通过 getfield 加载常量池中的值到操作数栈。

再仔细看 System.``out`` .println(result); 方法调用:

9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
  12: iload_3
  13: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
复制代码

通过 invokevirtual 调用了 PrintStreamprintln 方法,println 方法是一个实例的方法,调用的时候需要先将实例 this 和 参数压入操作数栈中,具体的流程如下:

  • 通过 getstatic#3 压入了一个 PrintStream 对象
  • 通过 iload_3 将参数压入了操作数栈
  • 调用 invokevirtual #4 时将实例 this 和参数出栈并调用 println 方法
小结

整个 JVM 指令执行的过程就是局部变量表与操作数栈之间不断加载、存储,操作数栈访问常量池字段和方法的过程。

image.png

字节码指令

每一个字节码指令都包含一个 opcode 和 若干个参数,格式如下:

[, ]

其中 opcode 占用一个字节,也就是说最多支持 256 个指令,目前已有 200 多个指令,指令描述参考:

Chapter 6. The Java Virtual Machine Instruction Set

指令大致可以分为下面几类:

image.png

简单了解只需要先掌握下面常用的指令,其他指令用的时候再查:

加载指令(load)

oad类指令是将局部变量表中指定位置的变量加载到操作数栈中,比如 iload_0 将局部变量表中下标(slot)为 0 的 int 型变量加载到操作数栈上。

指令作用
load n将局部变量表中指定位置的类型 T 的变量加载到操作数栈上,T 可以为 i(int),l(long),f(float),d(double),a(引用类型)
load_n将局部变量表中下标为n(0-3)的类型为T的变量加载到栈上

以 int 值为例,iload 0 的作用和 iload_0 的作用一样,但是两者是有区别的

iload 指令的编码是 21 (0x15),后面需要一个 index,比如:

image.png

而 iload_n 都是不同的指令

iload_0 = 26 (0x1a)

iload_1 = 27 (0x1b)

iload_2 = 28 (0x1c)

iload_3 = 29 (0x1d)

针对头4个局部变量,iload_就可以只用一个字节的 opcode 来表达整条指令,比使用完整版的iload要少一个字节

加载常量

指令opcode描述
iconst_m12 (0x2)将 int 值 -1 入栈到栈顶
iconst_03 (0x3)将 int 值 0 入栈到栈顶
iconst_14 (0x4)将 int 值 1 入栈到栈顶
iconst_25 (0x5)将 int 值 2 入栈到栈顶
iconst_36 (0x6)将 int 值 3 入栈到栈顶
iconst_47 (0x7)将 int 值 4 入栈到栈顶
iconst_58 (0x8)将 int 值 5 入栈到栈顶
需要压入值 n 的范围使用指令描述
[-1,5]iconst_n[-1,5] 之间常量进栈
[-128,127]bipush nbyte型常量进栈
[-32768,32767]sipush nshort型常量进栈
其他范围ldc #i用于加载常量池中的常量值,如 int、long、float、double、String、Class 类型的常量。

存储指令(store)

store 类指令是将操作数栈栈顶的数据存储到局部变量表中,比如 istore_0 将操作数栈顶的元素存储到局部变量表中下标为 0 的位置,这个位置的元素类型为 int,store 指令和 load 指令用法类似:

指令作用
store将栈顶类型为T的数据存储到局部变量表的指定位置, T可以为 i,l,f, d,a
store_n将栈顶类型为T的数据存储到局部变量表下标为n(0-3)的位置, T可以为i,l,f, d,a

访问 Filed

  • getFiled

比如 getfield #2 ,会获取常量池中的 #2 字段压入栈顶,同时将 this 出栈

  • putFiled

设置字段的值

访问方法

指令介绍
invokestatic用于调用静态方法
invokespecial用于调用私有实例方法、构造器方法以及使用super关键字调用父类的实例方法等
invokevirtual用于调用非私有实例方法
invokeinterface用于调用接口方法
invokedynamic用于调用动态方法,比如 lambda
invokedynamic 原理

invokedynamic 是 JVM 为了增强对动态语言的支持而加的。 比如在代码中写了 lambda 之后,通过 javac 编译成 class 文件会发现字节码中多了一个编译生成的静态方法:

public static void main(String[] args) {
        Runnable runnable = () -> {
            int i = 1;
        };
        runnable.run();
}
复制代码
private static void lambda$main$0();
    descriptor: ()V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=1, args_size=0
         0: iconst_1
         1: istore_0
         2: return
      LineNumberTable:
        line 17: 0
        line 18: 2
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            2       1     0     i   I
复制代码

编译器会把 lamda 中的代码生成一个静态方法,那 JVM 是怎么调用 lambdamainmain0 的呢? 先来看一下完整的字节码:

Constant pool:
   #1 = Methodref          #5.#24         // java/lang/Object."<init>":()V
   #2 = InvokeDynamic      #0:#29         // #0:run:()Ljava/lang/Runnable;
   #3 = InterfaceMethodref #30.#31        // java/lang/Runnable.run:()V
   #4 = Class              #32            // wz/sample/LambdaTest
   #5 = Class              #33            // java/lang/Object
   ...
  
{
  ...

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         5: astore_1
         6: aload_1
         7: invokeinterface #3,  1            // InterfaceMethod java/lang/Runnable.run:()V
        12: return
      LineNumberTable:
        line 16: 0
        line 19: 6
        line 20: 12
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  args   [Ljava/lang/String;
            6       7     1 runnable   Ljava/lang/Runnable;

  private static void lambda$main$0();
    descriptor: ()V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=1, args_size=0
         0: iconst_1
         1: istore_0
         2: return
      LineNumberTable:
        line 17: 0
        line 18: 2
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            2       1     0     i   I
}
SourceFile: "LambdaTest.java"
InnerClasses:
     public static final #45= #44 of #48; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
  0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #27 ()V
      #28 invokestatic wz/sample/LambdaTest.lambda$main$0:()V
      #27 ()V
复制代码
  • Lambda 表达式声明的地方会生成一个 invokdynamic 指令:invokedynamic #2, 0 ,#2 在常量池的定义是:#2 = InvokeDynamic #0:#29 , 但是发现常量池中没有 #0,#0 是定义在哪里的呢?
  • 编译器生成一个对应的引导方法(Bootstrap Method),对应字节码中的
BootstrapMethods:
  0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #27 ()V
      #28 invokestatic wz/sample/LambdaTest.lambda$main$0:()V
      #27 ()V
复制代码

#0 就对应 BootstrapMethods 的 0 处,它指向 LambdaMetafactory.metafactory ,LambdaMetafactory.metafactory 会返回一个 CallSite 对象,CallSite 绑定了 MethodHandle,MethodHandle 代表方法句柄,指向真正执行的方法,JVM 在第一次调用 lamda 的时候会查找对应的 CallSite 并记录起来,后续用的话会直接从缓存中取,简单流程如下:

image.png

ASM

ASM 工作流程

image.png

一段模板代码如下:

// .class -> byte[]
byte[] classByteArray = FileUtils.readBytes(filePath);
// 创建一个 ClassReader 来解析字节数组
ClassReader cr = new ClassReader(classByteArray);
// 创建一个 ClassWriter 来保存修改
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
// 创建 N 个 ClassVisitor 来做不同的修改
ClassVisitor cv1 = new CustomClassVisitor1(Opcodes.ASM9,cw);
ClassVisitor cv2 = new CustomClassVisitor1(Opcodes.ASM9,cv2);
// 将 ClassReader 和 ClassVisitor 关联在一起
cr.accept(cv2, parsingOptions);
// byte[] -> .class
FileUtils.writeBytes(filePath, cw.toByteArray());
复制代码

ClassReader

ClassReader 用来读取一个字节码二进制流,将类的所有信息加载到内存中。

使用 ClassReader 读取 ProcessMan.class:

class ProcessMan {

    private String name;

    public void printName(){
        System.out.println(name);
    }
}
复制代码
class ProcessTest {

    private static final String parentPath = "/Users/wangzhenm1/code/asm/asm_learn/learn-java-asm/target/classes/wz/sample/";
    
    private static String classPath = parentPath + "ProcessMan.class";

    private static void testReader(){
        byte[] bytes = FileUtils.readBytes(classPath);
        ClassReader cr = new ClassReader(bytes);
        String name = cr.getClassName();
        String superName = cr.getSuperName();
        System.out.println("name: " + name + ", superName: "+ superName);
    }
}
复制代码

如上面代码所示,空有一个 ClassReader 只能获取类的基本信息,比如类名,超类名,接口等信息,无法获取 class 中的字段、方法等详细信息。

因为 class 中的信息是不变的,但是访问者的逻辑可能千奇百怪,所以 ClassReader 采用了访问者模式来让外部访问 class 的详细信息,我们通过添加不同的 ClassVisitor 来访问 class 中的详细信息 。

ClassVisitor

ClassVisitor 的数据结构很简单,它相当于一个链表,每个 ClassVisitor 又指向了下一个 ClassVisitor:

public abstract class ClassVisitor {
    protected final int api;
    protected ClassVisitor cv;
}
复制代码

ClassVisitor 详细的生命周期方法如下:

visit
[visitSource][visitModule][visitNestHost][visitPermittedSubclass][visitOuterClass]
(
 visitAnnotation |
 visitTypeAnnotation |
 visitAttribute
)*
(
 visitNestMember |
 visitInnerClass |
 visitRecordComponent |
 visitField |
 visitMethod
)* 
visitEnd
复制代码

看起来很复杂,但是可以简化一下:

visit
(
 visitField |
 visitMethod
)* 
visitEnd
复制代码

下面举例访问一下 ProcessMan 的信息:

class ProcessTest {

    private static final String parentPath =
        "/Users/wangzhenm1/code/asm/asm_learn/learn-java-asm/target/classes/wz/sample/";
    private static String classPath = parentPath + "ProcessMan.class";
    private static int parsingOptions = ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;

    private static void testReader() {
        byte[] bytes = FileUtils.readBytes(classPath);
        ClassReader cr = new ClassReader(bytes);
        String name = cr.getClassName();
        String superName = cr.getSuperName();
        System.out.println("name: " + name + ", superName: " + superName);
    }

    private static void testClassVisitor() {
        byte[] bytes = FileUtils.readBytes(classPath);
        ClassReader cr = new ClassReader(bytes);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM9) {

            @Override
            public FieldVisitor visitField(
                int access, String name, String descriptor, String signature, Object value
            ) {
                System.out.println("field name: " + name + " ,desc: " + descriptor);
                return super.visitField(access, name, descriptor, signature, value);
            }

            @Override
            public MethodVisitor visitMethod(
                int access, String name, String descriptor, String signature, String[] exceptions
            ) {
                System.out.println("method name: " + name + " ,desc: " + descriptor);
                return super.visitMethod(access, name, descriptor, signature, exceptions);
            }
        };
        cr.accept(cv, parsingOptions);
    }
}
复制代码

打印结果如下:

image.png

ClassReader 通过 accept 方法来接受 ClassVisitor,在 accept 中 ClassReader 会 read class 的各种信息并回调 ClassVisitor 的相关方法。

ClassWriter

ClassWriter 可以将 class 信息生成对应的字节数组,无论是新的 class 还是被需改过的 class。

在创建 ClassWriter 对象的时候,要指定一个 flags参数:

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
复制代码

Flags 的取值如下:

-   `0`:

ASM不会自动计算max stacks和max locals,也不会自动计算stack map frames。

-   `ClassWriter.COMPUTE_MAXS`:

ASM会自动计算max stacks和max locals,但不会自动计算stack map frames。

-   `ClassWriter.COMPUTE_FRAMES`(**推荐使用**):

ASM会自动计算max stacks和max locals,也会自动计算stack map frames。

推荐使用的原因是我们修改了字节码后,局部变量表或操作数栈可能也需要做相应的改变,手动计算的逻辑容易出错,不如直接交给 ASM,当然编译的效率会有一定的下降。
复制代码

创建一个新的类

可以使用 ClassWriter 创建一个新的类,比如这里想创建一个这样的类:

package wz.sample;

public class HelloWorld {
    public HelloWorld() {
    }

    public String toString() {
        return "This is a HelloWorld object.";
    }
}
复制代码

创建的代码如下:

public static void createClass(){
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        // 规定类的访问权限,类名,超类名
        cw.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "wz/sample/HelloWorld", null, "java/lang/Object", null);
        
        // 创建默认的构造方法
        MethodVisitor mv1 = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
        mv1.visitCode();
        mv1.visitVarInsn(ALOAD, 0);
        mv1.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
        mv1.visitInsn(RETURN);
        mv1.visitMaxs(1, 1);
        mv1.visitEnd();
        
        // 创建 toString 方法
        MethodVisitor mv2 = cw.visitMethod(ACC_PUBLIC, "toString", "()Ljava/lang/String;", null, null);
        mv2.visitCode();
        mv2.visitLdcInsn("This is a HelloWorld object.");
        mv2.visitInsn(ARETURN);
        mv2.visitMaxs(1, 1);
        mv2.visitEnd();
        cw.visitEnd();
        
        FileUtils.writeBytes(
            "/Users/wangzhenm1/code/asm/asm_learn/learn-java-asm/src/main/java/wz/sample/HelloWorld.class",
            cw.toByteArray());
    }
复制代码

在创建方法的时候调用了 cw.visitMethod ,这个方法内部会创建一个 MethodWriter,MethodWriter 是 MethodVisitor 的子类:

public final MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
    MethodWriter methodWriter = new MethodWriter(this.symbolTable, access, name, descriptor, signature, exceptions, this.compute);
    if (this.firstMethod == null) {
        this.firstMethod = methodWriter;
    } else {
        this.lastMethod.mv = methodWriter;
    }

    return this.lastMethod = methodWriter;
}
复制代码

当调用 MethodWriter 的 visitXXX 方法时会把调用的 opcode 保存起来,当最后调用 cw.toByteArray() 的时候, ClassWriter 会遍历所有的 MethodWriter 和 FieldWriter 将其数据保存到字节数组中:

public byte[] toByteArray(){
    ...
    for(fieldWriter = this.firstField; fieldWriter != null; fieldWriter = (FieldWriter)fieldWriter.fv) {
        fieldWriter.putFieldInfo(result);
    }

    ...

    for(methodWriter = this.firstMethod; methodWriter != null; methodWriter = (MethodWriter)methodWriter.mv) {
        hasFrames |= methodWriter.hasFrames();
        hasAsmInstructions |= methodWriter.hasAsmInstructions();
        methodWriter.putMethodInfo(result);
    }
    ...
}
复制代码

MethodVisitor

MethodVisitor 用来对方法内的指令进行访问,和 ClassVisitor 类似它也有很多生命周期方法,可以简化为:

[
    visitCode
    (
        visitFrame |
        visitXxxInsn |
        visitLabel |
        visitTryCatchBlock
    )*
    visitMaxs
]
visitEnd
复制代码
  • visitCode()方法,调用一次,标志着方法体(method body)的开始。
  • visitXxxInsn()方法,对应方法体(method body)本身,可以调用多次,是对字节码指令的访问(参数,方法调用等)。
  • visitMaxs()方法,调用一次,标志着方法体(method body)的结束,包括 return 指令。
  • visitEnd()方法,调用一次,方法结束时调用

其中 visitXxxInsn 在修改方法内容的时候很有用,它可以遍历字节码中调用的指令,下面以 visitMethodInsn 为例讲解一下。

class MethodVisitorTemplate {
    public int add(int a, int b) {
        System.out.println("a:" + a);
        return a + b;
    }
}
复制代码

写一个 MthodVisitor 来打印 add 方法的 visitMethodInsn 回调,然后与 add 方法的字节码做一下比较,发现是可以一一对应的:

image.png

对应的打印代码参考附录中的“打印 visitMethodInsn 源码”。

MthodVisitor 是利用 ASM 实现 hook 功能的重要一环,可以通过 visitXxxInsn 方法找到你需要查找的方法从而进行 hook。

Stateless transformation

打印方法耗时

我们想用通过 ASM 打印下面 add 和 sub 方法的耗时:

class TimerTest {

    public int add(int a, int b) throws InterruptedException {
        int c = a + b;
        Random rand = new Random(System.currentTimeMillis());
        int num = rand.nextInt(300);
        Thread.sleep(100 + num);
        return c;
    }

    public int sub(int a, int b) throws InterruptedException {
        int c = a - b;
        Random rand = new Random(System.currentTimeMillis());
        int num = rand.nextInt(400);
        Thread.sleep(100 + num);
        return c;
    }
}
复制代码

也就是把上面的代码转换为下面的代码:

package wz.run;

import java.util.Random;

class TimerTest {
    public static long timer;

    TimerTest() {
    }

    public int add(int var1, int var2) throws InterruptedException {
        timer = 0L;
        timer -= System.currentTimeMillis();
        int var3 = var1 + var2;
        Random var4 = new Random(System.currentTimeMillis());
        int var5 = var4.nextInt(300);
        Thread.sleep((long)(100 + var5));
        timer += System.currentTimeMillis();
        System.out.println("wz/run/TimerTest.add cost time:" + timer);
        return var3;
    }

    public int sub(int var1, int var2) throws InterruptedException {
        timer = 0L;
        timer -= System.currentTimeMillis();
        int var3 = var1 - var2;
        Random var4 = new Random(System.currentTimeMillis());
        int var5 = var4.nextInt(400);
        Thread.sleep((long)(100 + var5));
        timer += System.currentTimeMillis();
        System.out.println("wz/run/TimerTest.sub cost time:" + timer);
        return var3;
    }
}
复制代码

插桩的思路如下:

  • 创建 ClassVisitor 新增一个 timer 字段
  • 创建 MethodVisitor 遍历所有的方法
  • 在每个方法开始插入代码:
timer = 0L;
timer -= System.currentTimeMillis();
复制代码
  • 在每个方法结束插入代码:
timer += System.currentTimeMillis();
System.out.println("wz/run/TimerTest.sub cost time:" + timer);
复制代码

接下来看一下代码如何实现。

  1. 首先定义一个 ClassVisitor:
private static class MethodCostTimeClassVisitor extends ClassVisitor {

    private String owner;
    private boolean isInterface;
    private boolean hasTimer;

    public MethodCostTimeClassVisitor(int api, ClassVisitor classVisitor) {
        super(api, classVisitor);
    }

    @Override
    public void visit(
        int version, int access, String name, String signature, String superName, String[] interfaces
    ) {
        super.visit(version, access, name, signature, superName, interfaces);
        owner = name;
        isInterface = (access & ACC_INTERFACE) != 0;
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        if ((access & ACC_STATIC) != 0 && "timer".equals(name) && "J".equals(descriptor)){
            hasTimer = true;
        }
        return super.visitField(access, name, descriptor, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(
        int access, String name, String descriptor, String signature, String[] exceptions
    ) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        if (!isInterface && mv != null && !"<init>".equals(name) && !"<clinit>".equals(name)) {
            boolean isAbstractMethod = (access & ACC_ABSTRACT) != 0;
            boolean isNativeMethod = (access & ACC_NATIVE) != 0;
            if (!isAbstractMethod && !isNativeMethod) {
                mv = new MethodCostTimeAdapter(api, mv, access, name, descriptor, owner,hasTimer);
            }
        }
        return mv;
    }

    @Override
    public void visitEnd() {
        if (!isInterface && !hasTimer){
            FieldVisitor fv = super.visitField(ACC_PUBLIC | ACC_STATIC,"timer","J",null,null);
            if (fv != null){
                fv.visitEnd();
            }
        }
        super.visitEnd();
    }
}
复制代码
  • 在 visitField 方法中先判断是否有一个 static long 类型的 timer 字段,防止重复添加字段
  • 在 visitEnd 中添加一个 static long 类型的 timer 字段,添加字段调用的是 super.visitField 方法,最终调用 ClassWriter 的 visitField 创建一个 FiledWriter 来添加字段信息。
  • 在 visitMethod 中排除掉构造方法(),静态代码款(),抽象方法,native 方法后,返回自定义的 MethodVisitor(相当于覆盖了原来的 MethodVisitor)
  1. 接下来定义一个 MethodVisitor

为了和源码风格保持一致,MethodVisitor 一般取名为 xxxAdapter

MethodVisitor 首先要做的是找到方法开始和结束的时机,根据上面对 MethodVisit 的生命周期方法的介绍,可以把 visitCode 作为方法的开始,但是不能把 visitMaxs 当做方法的结束,因为 visitMaxs 包含了 return 语句,那么换个思路,只要在 visitInsn 找到了方法退出的指令就可以了,方法退出的指令有 return(正常退出) 和 throw(异常退出) 。实现代码如下:

MethodVisitor 首先要做的是找到方法开始和结束的时机,根据上面对 MethodVisit 的生命周期方法的介绍,可以把 visitCode 作为方法的开始,但是不能把 visitMaxs 当做方法的结束,因为 visitMaxs 包含了 return 语句,那么换个思路,只要在 visitInsn 找到了方法退出的指令就可以了,方法退出的指令有 return(正常退出) 和 throw(异常退出)。实现代码如下:
 private static class MethodEnterAndExitAdapter extends MethodVisitor {

        public MethodEnterAndExitAdapter(int api, MethodVisitor methodVisitor) {
            super(api, methodVisitor);
        }

        /**
         * 方法开始访问时
         */
        @Override
        public void visitCode() {
            // 1.首先处理自己的代码逻辑
            // MethodEnter...
            // 2.然后调用父类的方法实现
            super.visitCode();
        }

        @Override
        public void visitInsn(int opcode) {
            // 1.首先处理自己的代码逻辑
            if (opcode == Opcodes.ATHROW || (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
                // MethodExit...
            }
            // 2.然后调用父类的方法实现
            super.visitInsn(opcode);
        }
    }
}
复制代码

对于方法的进入退出的监听还有更简单的方案,使用 ASM 提供的 AdviceAdapter,它是官方为我们提供的监听方法进入退出的一个 MethodVisitor:

private static class MethodCostTimeAdapter extends AdviceAdapter {

    private String owner;
    private Boolean hasInjectCode;

    protected MethodCostTimeAdapter(
        int api, MethodVisitor methodVisitor, int access, String name, String descriptor, String owner,Boolean hasInjectCode
    ) {
        super(api, methodVisitor, access, name, descriptor);
        this.owner = owner;
        this.hasInjectCode = hasInjectCode;
    }

    @Override
    protected void onMethodEnter() {
        if (hasInjectCode){
            return;
        }
        super.visitInsn(LCONST_0);
        super.visitFieldInsn(PUTSTATIC,owner,"timer","J");
        super.visitFieldInsn(GETSTATIC, owner,"timer","J");
        super.visitMethodInsn(INVOKESTATIC,"java/lang/System","currentTimeMillis","()J",false);
        super.visitInsn(LSUB);
        super.visitFieldInsn(PUTSTATIC,owner,"timer","J");
    }

    @Override
    protected void onMethodExit(int opcode) {
        if (hasInjectCode){
            return;
        }
        super.visitFieldInsn(GETSTATIC, owner,"timer","J");
        super.visitMethodInsn(INVOKESTATIC,"java/lang/System","currentTimeMillis","()J",false);
        super.visitInsn(LADD);
        super.visitFieldInsn(PUTSTATIC,owner,"timer","J");

        super.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        // 创建一个 StringBuilder
        super.visitTypeInsn(NEW, "java/lang/StringBuilder");
        super.visitInsn(DUP);
        super.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
        // append name
        super.visitLdcInsn(owner + "." + getName() + " cost time:");
        super.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append",
            "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
        // append timer value
        super.visitFieldInsn(GETSTATIC, owner,"timer","J");
        super.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;",
            false);

        super.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
        super.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }
}
复制代码

替换指定方法

替换方法包括替换静态方法和替换非静态方法。

下面有一个简单的类:

class ReplaceInsMan {

    public void test(int a, int b){
        int c = Math.max(a,b);
        System.out.println(c);
    }
}
复制代码

接下来要做的是:

  • 替换静态方法:把 Math.max 替换成 Math.min
  • 替换非静态方法:System.out.println(c) 替换成我们自己的打印方法 PrinterUtil.println
class PrinterUtil {
    public static void println(PrintStream printStream, int msg) {
        printStream.println("MyPrinter: " + msg);
    }
}
复制代码

最终变成了:

class ReplaceInsMan {
    public void test(int var1, int var2) {
        int var3 = Math.min(var1, var2);
        PrinterUtil.println(System.out, var3);
    }
}
复制代码

那么该如何替换方法调用呢?

从字节码的角度考虑,调用方法的过程就是将方法的参数压入操作数栈,然后调用 invokeXXX 指令,比如调用静态方法使用 invokestatic 指令,调用普通方法使用 invokevirtual 指令。只要找到目标方法的调用的地方替换成新的方法就可以。

image.png 为了描述老方法和新方法需要抽一个模型来描述方法信息:

private static class MethodInfo {
    /**
     * 调用方法的指令码
     */
    int opcode;
    /**
     * 方法所属类名
     */
    String owner;
    /**
     * 方法名
     */
    String name;
    /**
     * 方法描述符
     */
    String desc;
    /**
     * 是否是接口方法
     */
    boolean isInterface;

    public MethodInfo(String owner, String name, String desc) {
        this.owner = owner;
        this.name = name;
        this.desc = desc;
    }

    public MethodInfo(int opcode, String owner, String name, String desc, boolean isInterface) {
        this.opcode = opcode;
        this.owner = owner;
        this.name = name;
        this.desc = desc;
        this.isInterface = isInterface;
    }
}
复制代码

自定义 ClassVisitor 和 MethodVisitor:

private static class ReplaceInsClassVisitor extends ClassVisitor {

    private MethodInfo oldMethodInfo;

    private MethodInfo newMethodInfo;

    public ReplaceInsClassVisitor(int api, ClassVisitor cv, MethodInfo oldMethodInfo, MethodInfo newMethodInfo) {
        super(api, cv);
        this.oldMethodInfo = oldMethodInfo;
        this.newMethodInfo = newMethodInfo;
    }

    @Override
    public MethodVisitor visitMethod(
        int access, String name, String desc, String signature, String[] exceptions
    ) {
        MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
        if (mv != null && !"<init>".equals(name) && !"<clinit>".equals(name)) {
            boolean isAbstractMethod = (access & ACC_ABSTRACT) != 0;
            boolean isNativeMethod = (access & ACC_NATIVE) != 0;
            if (!isAbstractMethod && !isNativeMethod) {
                mv = new ReplaceInsAdapter(api, mv, oldMethodInfo, newMethodInfo);
            }
        }
        return mv;
    }
}


private static class ReplaceInsAdapter extends MethodVisitor {

    private MethodInfo oldMethodInfo;

    private MethodInfo newMethodInfo;

    public ReplaceInsAdapter(int api, MethodVisitor mv, MethodInfo oldMethodInfo, MethodInfo newMethodInfo) {
        super(api, mv);
        this.oldMethodInfo = oldMethodInfo;
        this.newMethodInfo = newMethodInfo;
    }
    
    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
        if (oldMethodInfo.owner.equals(owner) && oldMethodInfo.name.equals(name) && oldMethodInfo.desc.equals(
            desc)) {
            super.visitMethodInsn(
                newMethodInfo.opcode,
                newMethodInfo.owner,
                newMethodInfo.name,
                newMethodInfo.desc,
                newMethodInfo.isInterface
            );
        } else {
            super.visitMethodInsn(opcode, owner, name, desc, itf);
        }
    }
}
复制代码

重点看 ReplaceInsAdaptervisitMethodInsn 方法,它把目标方法的替换成了新的方法。

  • 替换静态方法
private static void replaceStaticIns(){
    MethodInfo oldMethod = new MethodInfo(
        "java/lang/Math",
        "max",
        "(II)I"
    );

    MethodInfo newMethod = new MethodInfo(
        Opcodes.INVOKESTATIC,
        "java/lang/Math",
        "min",
        "(II)I",
        false
    );
    String filePath = targetPath + "ReplaceInsMan.class";
    byte[] classByteArray = FileUtils.readBytes(filePath);
    ClassReader cr = new ClassReader(classByteArray);
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    ClassVisitor cv = new ReplaceInsClassVisitor(Opcodes.ASM9,cw,oldMethod,newMethod);
    cr.accept(cv, parsingOptions);
    FileUtils.writeBytes(filePath, cw.toByteArray());
}
复制代码
  • 替换非静态方法
private static  void replaceNonStaticIns(){
    MethodInfo oldMethod = new MethodInfo(
        "java/io/PrintStream",
        "println",
        "(I)V"
    );

    // 注意到 newMethod 的 desc 和 oldMethod 的不同了,因为 oldMethod 是 nonStatic,操作栈里会有额外的 this,也就是 Ljava/io/PrintStream
    // newMethod 是个静态的,不需要这个 this,所以要主动消耗掉 this,所以 newMethod 的方法定义为需要两个参数,第一个就是 Ljava/io/PrintStream 类型
    MethodInfo newMethod = new MethodInfo(
        Opcodes.INVOKESTATIC,
        "wz/run/PrinterUtil",
        "println",
        "(Ljava/io/PrintStream;I)V",
        false
    );
    String filePath = targetPath + "ReplaceInsMan.class";
    byte[] classByteArray = FileUtils.readBytes(filePath);
    ClassReader cr = new ClassReader(classByteArray);
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    ClassVisitor cv = new ReplaceInsClassVisitor(Opcodes.ASM9,cw,oldMethod,newMethod);
    cr.accept(cv, parsingOptions);
    FileUtils.writeBytes(filePath, cw.toByteArray());
}
复制代码

看到这里大家会有个疑问,问什么 PrinterUtil.println 需要传一个 PrintStream 对象,这是因为非静态的方法都会有一个 this,比如 System.out.println(c); 对应的字节码是:

6: getstatic     #22    // Field java/lang/System.out:Ljava/io/PrintStream;
9: iload_3
10: invokevirtual #28  // Method java/io/PrintStream.println:(I)V
复制代码

执行的时候操作数栈的元素如下:

invokevirtual 指令会消耗掉参数 c 和 this , this 就是 PrintStream 对象。

如果想要让字节码正常工作,替换字节码后要保持操作数栈保持不变,所以我们需要消耗掉操作数栈中的 c 和 PrintStream 对象,替换方法的参数类型必须是 (PrintStream, int)

Statefull transformation

上面“打印方法耗时” 和 “替换制定方法”例子在修改字节码的时候不需要考虑它前面的字节码是什么,这种转换称为无状态转换, 是一种简单的转换

如果在修改一个指令时需要考虑这条指令前面若干条指令,就需要记住之前调用了哪些指令,记录之前使用的状态可以使用一个状态机,这种转化称为有状态转换,比如删除指令。

举个例子,我们想去掉代码中加 0 这种无意义的操作,也就是需要找到下面的字节码:

ICONST_0
IADD
复制代码

想要去掉 IADD 指令时,它的前一条指令必须是 ICONST_0。

这时候可以定义定义一个状态机:

SEEN_NOTHING = 0
SEEN_ICONST_0 = 1
SEEN_ICONST_0_IADD = 2 (这个状态其实可以省略)
复制代码

在编写 ASM 代码的时候发现调用 ICONST_0 指令就把 state 转换为 SEEN_ICONST_0,当调用 IADD 指令时,如果 state == STATE_ICONST_0 就说明找到了目标指令,具体的流程图如下:

首先需要定义一个有状态的 MethodVisitor,比如:

public abstract class MethodPatternAdapter extends MethodVisitor {
    
    protected final static int SEEN_NOTHING = 0;
    
    protected int state;

    public MethodPatternAdapter(int api, MethodVisitor methodVisitor) {
        super(api, methodVisitor);
    }

    @Override
    public void visitInsn(int opcode) {
        visitInsn();
        super.visitInsn(opcode);
    }

    @Override
    public void visitIntInsn(int opcode, int operand) {
        visitInsn();
        super.visitIntInsn(opcode, operand);
    }

   ...

    protected abstract void visitInsn();
}
复制代码

然后实现一个去除加 0 操作的子类:

private static class RemoveAddZeroAdapter extends MethodPatternAdapter {

    private static int SEEN_ICONST_0 = 1;

    public RemoveAddZeroAdapter(int api, MethodVisitor methodVisitor) {
        super(api, methodVisitor);
    }

    @Override
    public void visitInsn(int opcode) {
        if (state == SEEN_ICONST_0) {
            if (opcode == Opcodes.IADD) {
                state = SEEN_NOTHING;
                return;
            }
        }
        
         visitInsn();
         
         if(opcode == ICONST_0){
             state = SEEN_ICONST_0;
             return;
         }

        mv.visitInsn(opcode);
    }

    @Override
    protected void visitInsn() {
        if (state == SEEN_ICONST_0) {
            mv.visitInsn(Opcodes.ICONST_0);
        }
        state = SEEN_NOTHING;
    }
}
复制代码
  • 代码第 20 行:发现 opcode 是 ICONST_0, 改变状态然后 return, 直接 return 代表不在将指令往下传递,也就是不执行该指令
  • 代码第 13 行:如果 state == SEEN_ICONST_0 **并且 **opcode == IADD 说明找到了目标, 此时直接 return,也就是直接放弃了 ICONST_0IADD 指令。
  • 代码第 18 行:如果state == SEEN_ICONST_0 但是下一个 opcode != IADD,此时需要还原之前的 ICONST_0 指令,同时还原 state 的状态, 比如连续遇到两个 ICONST_0 指令的时候,会回复第一次指令,缓存第二次指令。

测试代码如下:

  • 目标 class
public class StatefulTransformMan {

    public void test1(int a, int b) {
        int c = a + b;
        int d = c + 0;
        System.out.println(d);
    }

    public void test2(int a, int b) {
        int c = a + b;
        int d = 0 + c;
        System.out.println(d);
    }
}
复制代码
  • 测试代码
class StatefulTransformation {

    private static final String targetPath =
        "/Users/wangzhenm1/code/asm/asm_learn/learn-java-asm/target/classes/wz/sample/";

    private static int parsingOptions = ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;

    public static void main(String[] args) {
        String filePath = targetPath + "StatefulTransformMan.class";
        byte[] classByteArray = FileUtils.readBytes(filePath);
        ClassReader cr = new ClassReader(classByteArray);
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        ClassVisitor cv = new RemoveAddClassVisitor(Opcodes.ASM9, cw);
        cr.accept(cv, parsingOptions);
        FileUtils.writeBytes(filePath, cw.toByteArray());

        StatefulTransformMan transformation = new StatefulTransformMan();
        transformation.test1(1,2);
        transformation.test2(2,3);
    }

    private static class RemoveAddClassVisitor extends ClassVisitor {

        public RemoveAddClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }

        @Override
        public MethodVisitor visitMethod(
            int access, String name, String descriptor, String signature, String[] exceptions
        ) {
            MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new RemoveAddZeroAdapter(Opcodes.ASM9, mv);
        }
    }

    private static class RemoveAddZeroAdapter extends MethodPatternAdapter {
       ...
    }
}
复制代码
  • 删除前后字节码对比

ASM 在 Android 上的应用

Transform API

Android 编译的部分流程如下:

其中有一步时间是将 .class 转换为 dex 文件,如果想用 ASM 修改字节码就可以在这一步修改。

Gradle 引入了 Transform API,语序第三方插件在 .class 被转换为 dex 文件之前对 .class 文件进行处理。每一个 transform 都是一个 Gradle Task,有用输入和输出,上一个 transform 的输出就是下一个 transform 的输入:

Transform API 在 gradle 7.0 被废弃了,但是官方提供了对应的解决方案,可以参考这篇博客

自定义插件

使用 Transform API 需要自定义 gradle 插件,自定义插件有三种方式,其中开发效率最高的是 buildSrc 方式,创建步骤很简单:

  1. 在一个现有的项目中创建一个新的 module(类型为 java lib),名称必须为buildSrc
  2. 在 src/main 下创建 groovy 或者 java 文件夹,然后创建一个包名(随便定义),然后创建一个 .groovy 或者 .java 文件,内容如下:
package com.keep.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project

class PageMonitor implements Plugin<Project>{

    @Override
    void apply(Project target) {
        println("this is PageMonitor")
    }
}
复制代码
  1. 在 src/main 下创建 resources/META-INFO/gradle-plugins目录,然后再该目录下创建xxx.properties 文件,这个文件的名称就是你的 plugin 的名称,比如这里是 page-monitor.properties,文件的内容指向 plugin 所指向的类,文件内容如下:
implementation-class=com.keep.plugin.PageMonitor
复制代码
  1. 修改 buildSrc 下的 .gradle 文件如下:
apply plugin: 'groovy'  //必须
apply plugin: 'maven'

dependencies {
    implementation gradleApi() //必须
    implementation localGroovy() //必须
    //如果要使用 android 的 API,需要引用这个,实现 Transform 的时候会用到
    //implementation 'com.android.tools.build:gradle:3.3.0'
    // 如果使用 kotlin 
    // implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 
}

repositories {
    jcenter()
    mavenCentral() //必须
}

复制代码
  1. 最后再需要使用该 plugin 的工程的 gradle 下使用:
apply plugin 'page-monitor' # 名称就是上面的 properties 文件的名称
复制代码

使用 transform

class DemoTransform extends Transform {

    /**
     * 执行这个 Transform 的 task 时,会以这个名字为基础生成 task 名称
     * */
    @Override
    String getName() {
        return "BaseTransform"
    }

    /**
     * 表示要处理的数据类型是什么,CLASSES 表示要处理编译后的字节码(可能是 jar 包也可能是目录),RESOURCES 表示要处理的是标准的 java 资源
     * */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 表示 Transform 的作用域,这里设置的 SCOPE_FULL_PROJECT 代表作用域是全工程
     **/
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 表示是否支持增量编译,false 不支持
     * */
    @Override
    boolean isIncremental() {
        return true
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        // Transform 的 inputs 有两种类型,一种是目录,一种是jar包,要分开遍历
        inputs.each { TransformInput input ->
            //对类型为“文件夹”的 input 进行遍历
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //文件夹里面包含的是我们手写的类以及 R.class、BuildConfig.class 以及 R$XXX.class等
                // 获取 output 目录
                def dest = outputProvider.getContentLocation(directoryInput.name,
                    directoryInput.contentTypes, directoryInput.scopes,
                    Format.DIRECTORY)
                File dir = directoryInput.file
                if (dir) {

                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) { File file ->

                        // 使用 ASM
                        ClassReader classReader = new ClassReader(file.bytes)
                        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                        ClassVisitor cv1 = new ClassVisitor1(classWriter)
                        ClassVisitor cv2 = new ClickVisitor2(cv1)
                        classReader.accept(cv2, ClassReader.EXPAND_FRAMES)
                        byte[] bytes = classWriter.toByteArray()
                        //通过文件流写入方式覆盖原先的内容,完成 class 文件的修改
                        FileOutputStream outputStream = new FileOutputStream(file.path)
                        outputStream.write(bytes)
                        outputStream.flush()
                        outputStream.close()
                    }
                }

                // 将input的目录复制到output指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }
            //对类型为jar文件的input进行遍历
            input.jarInputs.each { JarInput jarInput ->
                //jar文件一般是第三方依赖库jar文件
                // 重命名输出文件(同目录copyFile会冲突)
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                //生成输出路径
                def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
                //将输入内容复制到输出
//                FileUtils.copyFile(jarInput.file, dest)
                weaveJar(jarInput.getFile(),dest)
            }
        }
    }
 ...
}
复制代码

但是不太建议自己写这些模板代码,可以直接使用 Hunter

举 🌰

防止按钮频繁点击

public class ClickVisitor extends ClassVisitor {

    public ClickVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM7, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String methodName, String descriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, methodName, descriptor, signature, exceptions);
        if (isViewOnclickMethod(access,methodName,descriptor)){
            return new ClickMethodVisitor(methodVisitor);
        }
        return methodVisitor;
    }
    
    private boolean isViewOnclickMethod(int access, String name, String desc) {
        return ((access & ACC_PUBLIC) != 0 && (access & ACC_STATIC) == 0 & (access & ACC_ABSTRACT) == 0)
                && name.equals("onClick") 
                && desc.equals("(Landroid/view/View;)V");
    }
}
复制代码
public class ClickMethodVisitor extends MethodVisitor {

    private String monitorName = "com.keep.twist.asm.ClickMonitor".replace(".", "/");

    public ClickMethodVisitor(MethodVisitor methodVisitor) {
        super(Opcodes.ASM7, methodVisitor);
    }

    @Override
    public void visitCode() {
        super.visitCode();
        mv.visitVarInsn(ALOAD, 1);
        mv.visitMethodInsn(INVOKESTATIC, monitorName,
                "shouldDoClick", "(Landroid/view/View;)Z", false);
        Label label = new Label();
        mv.visitJumpInsn(IFNE, label);
        mv.visitInsn(RETURN);
        mv.visitLabel(label);
    }
}
复制代码
public class ClickMonitor {

    public static long FROZEN_MILLIS = 1000L;

    public static long preClickTime = 0;

    public static boolean shouldDoClick(View targetView) {
        final long now = System.currentTimeMillis();
        if (now - preClickTime > FROZEN_MILLIS) {
            preClickTime = now;
            return true;
        }
        return false;
    }
}
复制代码

上面的 ClickMethodVisitor 代码用到了 Label,Label类可以用于实现跳转语句,比如选择(if、switch)、循环(for、while)和try-catch语句,可以把 Label 当做一个锚点,使用步骤如下:

// 创建一个 Label
Label label = new Label(); 
// 设置锚点位置
mv.visitLabel(label);
// 锚点执行的代码放到 visitLabel 下面

...
// 跳转到制定锚点
 mv.visitJumpInsn(opcode, label);
复制代码

上面 ClickMethodVisitor 中的代码:

// 定义 label
Label label = new Label(); 
// 如果栈顶元素不等于 0 也就是 true 就跳转到第四行,也就是跳过了 return 语句
mv.visitJumpInsn(IFNE, label); 
mv.visitInsn(RETURN);
mv.visitLabel(label);
复制代码

附录

IDEA 搭建 ASM 开发环境

使用 IDEA 搭建一个方便学习的环境,步骤如下:

  • 新建一个 Maven 仓库
  • 在 pom.xml 中添加 ASM 依赖
<properties>
    ...
    <asm.version>9.0</asm.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.ow2.asm</groupId>
        <artifactId>asm</artifactId>
        <version>${asm.version}</version>
    </dependency>
    <dependency>
        <groupId>org.ow2.asm</groupId>
        <artifactId>asm-commons</artifactId>
        <version>${asm.version}</version>
    </dependency>
    <dependency>
        <groupId>org.ow2.asm</groupId>
        <artifactId>asm-util</artifactId>
        <version>${asm.version}</version>
    </dependency>
    <dependency>
        <groupId>org.ow2.asm</groupId>
        <artifactId>asm-tree</artifactId>
        <version>${asm.version}</version>
    </dependency>
    <dependency>
        <groupId>org.ow2.asm</groupId>
        <artifactId>asm-analysis</artifactId>
        <version>${asm.version}</version>
    </dependency>
</dependencies>
复制代码

javap 命令解析

javap 命令:

常用选项包括:

-help --help -? 输出此用法消息

-v -verbose 输出附加信息

-l 输出行号和本地变量表

-p -private 显示所有类和成员

-c 对代码进行反汇编

-s 输出内部类型签名

常量池 cp_info 类型

引用类型映射

跳转指令一览表

打印 visitMethodInsn 源码

class MethodVisitorTest {

    private static final String parentPath =
        "/Users/wangzhenm1/code/asm/asm_learn/learn-java-asm/src/main/java/wz/sample/";

    public static void main(String[] args) {
        byte[] classByteArray = FileUtils.readBytes(parentPath + "MethodVisitorTemplate.class");
        ClassReader cr = new ClassReader(classByteArray);
        ClassVisitor cv = new MyClassVisitor(Opcodes.ASM9);
        cr.accept(cv, ClassReader.SKIP_DEBUG);
    }

    static class MyClassVisitor extends ClassVisitor {

        private String className;

        public MyClassVisitor(int api) {
            super(api);
        }

        public MyClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }

        @Override
        public void visit(
            int version, int access, String name, String signature, String superName, String[] interfaces
        ) {
            super.visit(version, access, name, signature, superName, interfaces);
            className = name;
            System.out.println("className:" + className);
        }

        @Override
        public MethodVisitor visitMethod(
            int access, String name, String descriptor, String signature, String[] exceptions
        ) {
            MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new MyMethodVisitor(Opcodes.ASM9, mv, name);
        }
    }

    static class MyMethodVisitor extends MethodVisitor {

        private String methodName;

        public MyMethodVisitor(int api) {
            super(api);
        }

        public MyMethodVisitor(int api, MethodVisitor methodVisitor, String methodName) {
            super(api, methodVisitor);
            this.methodName = methodName;
        }

        @Override
        public void visitCode() {
            super.visitCode();
            System.out.println(methodName + ": visitCode");
        }

        @Override
        public void visitFieldInsn(int opcode, String owner, String name, String descriptor) {
            super.visitFieldInsn(opcode, owner, name, descriptor);
            System.out.println("visitFieldInsn, name:" + owner + "." + name);
        }

        @Override
        public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
            super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
            System.out.println("visitMethodInsn, name:" + owner + "." + name);
        }

        @Override
        public void visitMaxs(int maxStack, int maxLocals) {
            super.visitMaxs(maxStack, maxLocals);
            System.out.println("visitMaxs");
        }

        @Override
        public void visitEnd() {
            super.visitEnd();
            System.out.println(methodName + ": visitEnd");
        }
    }
}
复制代码
分类:
Android
标签: