Android字节码处理-ASM入门开胃菜

322 阅读8分钟

Android的如何编译流程

Java代码编译成字节码在Java虚拟机中运行。Android的Java代码首先编译成Java字节码,然后通过dx工具编译成单个dex格式的文件,然后运行在Android的ART虚拟机中。Android Gradle插件3.4.0以及以后的版本,可以使用R8,可以在一个步骤中完成脱糖、压缩、混淆、优化和 dex 处理 (D8),如下图所示。

image.png

字节码修改

字节码修改其实可以在两个阶段执行,可以在.class字节码阶段修改,同时也可以在.dex阶段修改。dex可以理解为.class字节码的压缩版本,Java jar文件有许多类文件,而每个APK文件只有一个classes.dex文件,如下所示。根据Google称,出于性能和安全原因,APK格式与类文件格式不同。

image.png dex更加紧凑,字节码修改的难度更大,在Android Gradle插件3.4.0插件之后的版本使用R8,dex是已经混淆,共用常量,共用变量等,牵一发而动全身,增加dex字节码修改的难度。

ASM简介

ASM是一个通用的Java字节码操作和分析框架。它可用于修改现有类或动态生成类,直接以二进制形式。ASM 提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具。ASM提供与其他Java 字节码框架类似的功能,但更注重性能。由于它的设计和实现尽可能小巧和快速,因此非常适合在动态系统中使用(但当然也可以以静态方式使用,例如在编译器中)。

注意:ASM 这个名字没有任何意义:它只是对 C 中__asm__关键字的引用,它允许用汇编语言实现某些函数。

ASM的两种模式

ASM库提供了两种用于生成和转换已编译类的API:核心API提供基于事件的类表示,而树API提供基于对象的表示。使用基于事件的模型,类由一系列事件表示,每个事件代表类的一个元素,基于事件的API定义了可能事件的集合及其必须发生的顺序。使用基于对象的模型,类由对象树表示,每个对象代表类的一部分。这两种模式有点像Android中XML解析的两种模式SAX模式和DOM模式。核心API基于事件驱动,占用内存空间小,如果需要遇到复杂的场景,可能需要遍历两次;树API基于对象驱动,内存占用空间大,多数情况下遍历一次就可以,方法使用简单,效率相对较低。

ASM执行模型

在介绍字节码指令之前,有必要介绍一下Java虚拟机执行模型。众所周知,Java代码是在线程内执行的。每个线程都有自己的执行堆栈,由栈帧组成。每个栈帧代表一个方法调用:每次调用方法时,都会在当前线程的执行堆栈上推送一个新栈帧。当方法返回时(正常或由于异常),此栈帧将从执行堆栈中弹出,并继续执行调用方法(其框架现在位于堆栈顶部)。每个栈帧包含两个部分:局部变量部分和操作数堆栈部分。局部变量部分包含可以通过其索引以随机顺序访问的变量。操作数堆栈部分,顾名思义,是字节码指令用作操作数的值堆栈。这意味着只能按后进先出的顺序访问此堆栈中的值。不要混淆操作数堆栈和线程的执行堆栈:执行堆栈中的每个栈帧都包含自己的操作数堆栈。局部变量和操作数堆栈部分的大小取决于方法的代码。它是在编译时计算的,并与字节码指令一起存储在编译的类中。因此,与给定方法的调用相对应的所有栈帧都具有相同的大小,但与不同方法相对应的框架的局部变量和操作数堆栈部分的大小可能不同。

image.png

上图显示了具有3个战争的示例执行堆栈。第一个栈帧包含3个局部变量,其操作数堆栈的最大大小为 4,并且包含两个值。第二个栈帧包含2个局部变量,其操作数堆栈中包含两个值。最后,执行堆栈顶部的第三个栈帧包含 4个局部变量和两个操作数。

注意:如果方法调用是实例方法,局部变量的第一个参数就是this,然后局部变量按照方法的参数顺序依次映射(局部变量的个数是方法参数的个数加1);如果方法调用的是类方法,局部变量与方法的参数依次映射。(局部变量的个数与方法参数的个数相同)

局部变量和操作堆栈以槽作为存储数据的单位,槽占用的空间是32位,也就是4个字节,除了long和double专用两个槽以外,其余的类型占用一个槽,这再计算操作数栈的最大值的时候会用到。

类结构

类作为属性和方法的封装结构而存在。

image.png 确切的结构在Java虚拟机规范第4节中描述。

描述符

类的内部名称就是该类的完全限定名称,其中的点被斜线替换。例如,String的内部名称是java/lang/String。内部名称表示用于类型描述符。

image.png

原始类型的描述符是单个字符:Z表示布尔值,C表示字符,B表示字节,S表示短整型,I表示整数,F表示浮点型,J表示长整型,D表示双精度型。类类型的描述符是该类的内部名称,以L开头,后跟分号(例如java.lang.String的类型描述符是Ljava/lang/String;)。类型描述符在JNI同样适用。

方法描述符

方法描述符是一系列类型描述符,它们以单个字符串的形式描述方法的参数类型和返回类型。方法描述符以左括号开头,后跟每个形式参数的类型描述符,后跟右括号,后跟返回类型的类型描述符,如果方法返回void(方法描述符不包含方法的名称或参数名称),则为V。

image.png 例如上面第一行例子,void m(int i, float f)的方法描述符是(IF)V,省略方法的名称和参数的名称。于此同时,方法的参数列表在前面,参数用括号包围,方法的返回值在括号的外面。方法的描述符的参数和返回值的数据与Kotlin方法声明的顺序是一致的,而Java方法声明的顺序不太一致。

ASM核心组件

ASM基于ClassVisitorAPI 提供了三个核心组件来生成和转换类:

  • ClassReader类解析以字节数组形式给出的已编译类,并在作为参数传递给其accept方法ClassVisitor 实例上调用相应的visitXxx方法。它可以被视为事件生成器。
  • ClassWriter类是ClassVisitor抽象类的子类,它直接以二进制形式构建已编译的类。它生成一个包含已编译类的字节数组作为输出,可以使用toByteArray方法检索该数组。它可以被视为事件消费者。
  • ClassVisitor类将其收到的所有方法调用委托给另一个ClassVisitor实例。它可以看作是一个事件过滤器。

生成类

ClassVisitor类的方法必须按照以下顺序调用,该顺序在此类的Javadoc中指定:

image.png

这意味着必须首先调用visit,然后最多调用一次visitSource,然后最多调用一次visitOuterClass,然后以任意顺序调用任意次数visitAnnotation和visitAttribute,然后以任意顺序调用任意次数visitInnerClass、visitField和visitMethod,最后以一次visitEnd调用结束。类的生成顺序,示例代码如下:

     val cw: ClassWriter = ClassWriter(0)
     cw.visit(
         V1_8, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE,
         "org/exmple/Comparable", null, "java/lang/Object",
         null
     )
     cw.visitField(
         ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "LESS", "I",
         null, -1
     ).visitEnd()
     cw.visitField(
         ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "EQUAL", "I",
         null, 0
     ).visitEnd()
     cw.visitField(
         ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "GREATER", "I",
         null, 1
     ).visitEnd()
     cw.visitMethod(
         ACC_PUBLIC + ACC_ABSTRACT, "compareTo",
         "(Ljava/lang/Object;)I", null, null
     ).visitEnd()
     cw.visitEnd()
     val b: ByteArray = cw.toByteArray()

ASM开胃菜

ASM的学习曲线相对较陡峭,因为它直接操作字节码,需要大量的字节码知识和相关的JVM虚拟机的执行,需要对Java虚拟机的工作原理有一定了解。但一旦掌握,ASM能为开发者提供极大的灵活性和性能优势。中文翻译的水平和译者的水平有很大关系,我个人还是推荐查阅ASM的官方文档和示例代码以加深理解。

参考资源

ASM官网:asm.ow2.io/