Dalvik Optimization and Verification With dexopt
Dalvik 是专门设计用于Android手机平台的虚拟机。主要目标系统是 内存较小,读写存储速度比较慢,机器性能普片比较差的桌面系统。这些系统通常运行在提供有虚拟内存,进程和线程管理,UID安全机制 的Linux系统之上。
在一些条件限制和特性要求下,我们主要关注以下几个目标:
- 类数据,尤其是字节码, 必须能够在多个进程间共享,以最大限度地减少系统总内存使用量。
- 启动一个新App的开销必须要尽量少,使设备保持交互的灵敏性
- 每个类的信息保存在各自独立的文件中会造成大量的冗余,尤其是字符串。为了节省空间,我们需要将这点考虑进去。
- 在类载入的过程中解析类数据字段会增加不必要的开销。像C语言一样直接访问类数据(例如:数值和字符串)会比较好。
- 字节码的校验是必不可少,但它比较耗时,我们尽可能在App运行以外的时间执行这个步骤。
- 字节码的优化(quickened instructions, method pruning)对运行速度和电池续航非常重要。
- 出于安全的考虑,进程将不允许编辑共享的代码。
一般的虚拟机实现会从归档(zip、jar)文件中解压出每一个类并将他们保存在堆内存中。这意味着在每个进程中都会有每个类的副本,并且会减慢应用的启动速度,因为代码需要解压(或者至少也要从存储上读取很多的小片)。另一方面,将字节码保存在本地内存堆中更容易在第一次使用的时候进行指令重写和执行一系列的优化。
居于以上的目标使我们作出以下的决定:
- 多个类文件会被整合到唯一的一个“DEX”文件中。
- DEX文件会被映射成只读模式并且在进程中共享。
- 根据本地系统进行直接排序和字对齐操作。
- 对所有类来说字节码校验是必不可少的,但我们想尽可能的进行 预校验。
- 需要重写字节码的优化步骤要提前完成。
以下的部分将介绍这些决定的结果。
VM Operation
应用代码是添加在.jar或者.apk中再传给系统。这些文件都是zip格式的归档文件,并添加了一些Meta的文件。Dex 数据文件始终命名为 classes.dex。
保存在zip文件中的字节码无法直接进行内存映射和执行,因为数据是被压缩的并且文件的开头不能保证是字对齐的。这些问题可以通过存储没有压缩的classes.dex和填充zip文件来解决, 但这会增加网络传输时包打大小。
我们在使用classes.dex文件时,需要从zip文件中解压出来。我们需要执行一些步骤(重新对齐,优化,校验)。然而这又提出了一个新的问题:谁来负责做这些事情,应该将输出的文件保存在哪里。
Preparation
至少有3种不同方式可以创建“prepared”的dex文件,也叫作“ODEX”(优化后的DEX文件):
- 虚拟机采用准实时(“just in time”)的方式。输出文件保存在“dalvik-cache”目录中。这种方式适用于桌面或者开发机器,因为“dalvik-cache”目录的权限是不受限的。这在正式发布的产品上是不允许的。
- 系统安装程序在应用第一次被安装的时候创建。它具备写入dalvik-cache”目录的权限。
- 构建系统在构建的时候提前处理。相关的 jar/apk文件仍然存在,但是classes.dex文件被删除了。优化后的DEX文件保存在和 jar/apk 文件的相同目录,并非 dalvik-cache中, 是系统镜像文件的一部分。
“dalvik-cache”目录准确的讲应该是 “$ANDROID_DATA/data/dalvik-cache”。目录中的文件名称来源于原始DEX文件的完整路径。设备上该目录所有者为 system/system拥有771的权限(drwxrwx--x system system 2019-09-09 09:50 dalvik-cache
), 存储在该目录中优化后的DEX文件拥有者为system和具有0644权限的应用组(-rw-r--r-- system u0_a46 819976 2018-05-22 02:29 data@app@ApiDemos.apk@classes.dex
)。DRM-locked程序将使用640的权限来防止其他用户程序访问这些文件。最低限度允许你读取本应用和其他应用的DEX文件,但是你不能创建,修改和删除。
“just in time”和“system installer”在处理DEX文件的方式上主要有3个步骤:
第一,创建 dalvik-cache目录。这必须要让具有相应权限的进程来完成,所以 “system installer” 以root的身份运行在 installd
进程中。
第二,将classes.dex从zip归档文件中解压出来, 并在文件头部开始的位置预留一定的空间用于保存ODEX头部信息。
第三,文件使用内存映射的方式方便访问并针对当前系统进行调整。这些调整包括字节交换和结构调整,但对DEX文件没有实质性的修改。同时我们也做一些基本的结构检查,例如:确保文件的偏移量和数据索引在有效的范围内。
构建系统使用进程,强制对所有的DEX文件进行优化,然后从dalvik-cache
目录中提取出来。 这么做的原因是,在进行优化分析的时候会比在桌面运行工具更加容易明白。
当代码完成字节调整和对齐,我们的准备工作就做好了。我们在ODEX文件的头部添加一些提前计算好的数据,然后开始执行优化。虽然我们对校验和优化更很感兴趣,但是我们能需要在初始化准备后插入一步。
dexopt
我们打算校验和优化DEX文件中的所有类。最简单和安全的方式是将所有的类导入虚拟机中并且全部运行。任何加载失败的类将不会被校验和优化。不幸的是,这会导致一些分配的资源难以被回收(例如:已经加载的本地共享库SO),因此我们不想在运行应用的同一个虚拟机内执行这个步骤。
解决方案是通过运行一个叫做dexopt
的程序,它仅是虚拟机的一个后门。它通过执行一个简化的虚拟机初始化,从系统引导类路径中加载0个或者几个DEX文件,然后根据目标DEX文件来设置校验和优化信息的相关参数。一旦任务完成进程就会退出释放所有的资源。
可能出现多个虚拟机在同一个时间需要同一个DEX文件。可以通过文件锁来确保dexop只会执行一次。
Verification
字节码的校验过程会扫描每一个类的每一个方法中的所有指令。目的是为了识别出所有非法的指令,这样我们就不必在App运行的时候检查他们。大量的计算会对于精确的垃圾回收来说是必须的。更多相关的详细信息 Dalvik Bytecode Verifier Notes。
由于性能的原因,优化器(将会在下一个章节介绍)会假设校验的执行时成功的,并且会进行一些潜在不安全的假设。默认情况下,Dalvik虚拟机会仍然会校验所有的类,仅优化那些被校验通过的类。如果你想禁用校验器,你可以通过命令行参数来实现。更多Android应用框架中相关特性的命令Controlling the Embedded VM。
上报校验错误是一个相当麻烦的问题。例如,对其他包中方法访问级别为仅包内的调用是非法的并且会被校验器捕获。我们并不一定要在校验的过程中进行错误上报,实际上我们更希望在方法调用的时候抛出一个异常。检查每一个方法的访问权限的代价是
昂贵的。Controlling the Embedded VM 中解决了这个问题。
那些被校验成功的类文件在ODEX文件中会被设置一个标志。在加载的时候它们将不会再被校验。Linux的访问权限会阻止他们被篡改;如果你能绕过这些访问机制,安装错误的字节码远非最容易的攻击方式。ODEX文件拥有一个32位的检查码,但它仅是为了快速的校验文件是否损坏。
Optimization
虚拟机解释器通常会在代码被第一次使用的时候执行优化。常量池引用被替换成内部数据结构的指针,总是会执行成功的操作或者经过某些方式能正常工作的会被替换成更简单的方式。一些替换需要的信息仅在运行时才能获取,另一些信息则可以通过一定假设进行静态推断。
Dalvik 优化器主要执行以下步骤:
- 对于虚方法的调用,将方法索引替换成
vtable
方法表中的索引。 - 对于对象属性的 get/put,将属性索引成对象内存地址的字节偏移量。同时将
boolean / byte / chat / short
合并到同一个32位单元中(解析器中的代码也少,则CPU缓存中的空间就也大)。 - 将那些会大量被调用的方法进行内联,例如:
String.length()
。这就减少了方法调用所需要的开销,直接从解释器切换到native的实现。 - 剔除空方法的调用。最简单的例子就是
Object.<init>
,这个方法本身不执行任何代码,但是在任何对象创建的时候都会被调用。 - 附加预先计算好的数据。例如,虚拟机需要拥有一个查询类名的哈希表。我们可以现在计算好数据,而不是在DEX文件被加载的时候才做,这样可以使得每一个加载该DEX文件的虚拟机节省内存空间和计算时间。
所有的指令修改都会涉及到替换成一个没有在定义Dalivk虚拟机规范中定义的操作码。这允许我们可以自由的组合优化的指令和没有优化的指令。这些优化指令的具体表现是和虚拟机版本紧密相关的。
大多数的指令优化是显然是有益的。使用原生的索引和直接偏移量不仅使得运行更加快速,并且我们可以跳过初始化符号解析。预先计算数据需要占用存储空间,因此我需要适度的进行。
这些指令优化也是很多潜在麻烦的根源。
第一,vtable
表索引和字节偏移量在虚拟机更新的时候可能会发生变化。
第二,如果父类是在不同的DEX文件中,当父类的DEX文件更新的时候,我们需要确保我们优化的索引和字节偏移量也正常的更新。当用户使用自定义的类加载器的时候会引起一个相似但更加棘手的问题:实际我们调用的类可能并不是我们期望的。
以上的问题可以通过来增加 依赖列表 和 优化限制 来解决。
Dependencies and Limitations
优化的DEX文件中包含对其他DEX文件依赖的列表,以及原始归档文件中classes.dex
节点的CRC-32和文件的最后修改时间。依赖列表包含dalvik-cache
目录中文件的完整路径和文件的 SHA-1摘要。在某些设备上,文件的时间戳是不可靠,不能使用的。依赖信息还包含了虚拟机的版本号。
一个优化的DEX会依赖引导类路径上所有的DEX文件。而引导类路径的DEX文件又会依赖更底层的DEX文件。为了确保依赖列表以外的DEX文件是不可用的,dexopt
仅加载引导类路径上的类。对其他DEX文件里面的类引用失败,会导致类加载和建议的失败,并且类引用的外部依赖是没有被优化的。
这意味着将代码拆分到多个DEX文件中会有一个缺点:对非引导类路径上DEX文件的虚方法调用和实例属性查找将不会给优化。由于校验的成功或者失败是类级别的,因此类中的方法依赖了外部DEX文件中的类将不会被优化。这可能有点过重,但这是确保当外部依赖文件更新的时候不会出错的唯一方式。
另一个不好的结果:任何一个引导类路径上的DEX文件变更将会导致所有优化后的DEX文件失效。这使得难以保持系统的小更新。
尽管我们很谨慎,但由于用户自定义类加器加载的DEX文件中的类仍有可能请求加载引导类路径的类(例如:String),然后返回一个相同名字的不同于引导类路径下的类实例。如果一个正在被处理DEX文件中的类和引导类路径下的DEX里面的类拥有相同的名字,这个类会被被标记成有歧义的,并且引用这个类的其他类在校验和优化阶段将不会被解析。这个类在虚拟机链接代码时会额外的检查,详细信息可以查看VM源中的详细描述(vm/oo/Class.c)。
如果其中一个依赖的DEX文件更新了,我们需要重新校验和优化DEX文件。如果我们可以实时调用dexopt
优化,那将会变得很简单。如果我们不得不依赖安装服务或者DEX文件仅在ODEX文件中提供,那虚拟机将不得不拒绝这个文件。
dexopt
的输出文件是根据主机信息进行字节调整和结构对齐的,并且包含了高度虚拟机(版本和平台)定制的索引和偏移量。编写一个能够在桌面运行生成适合所有机器的dexopt
版本是很困难的。最安全的方式是在目标机器或者在设备的模拟器上运行。
Generated DEX
一些语言和框架依赖于生成字节码并执行它的能力。繁重的dexopt
校验和优化模式使得这些语言和框架难以正常工作。
我们打算在将来的版本中提供支持,但是具体的实现方法还未确定。我们可能允许添加独立的类或者整个DEX文件;可能允许指令中含有Java字节码或者Dalvik字节码;可能会执行通用的优化或者使用独立的解释器在代码第一次执行时进行优化(这些优化将不会被映射成read-only, 因为它是一种本地定义)。