写点什么

[译] 规避供应商以及特定版本的 VM Bugs

用户头像
Antway
关注
发布于: 2021 年 06 月 16 日

本文原文出自 jakewharton 关于 D8 和 R8 系列文章第三篇。



在前两篇文章中介绍了 D8 使用脱糖来兼容 Java 语言新特性。脱糖是很有趣的功能,但它是 D8 的次要功能。D8 的主要职责是将基于堆栈的 Java 字节码转换为基于寄存器的 Dalvik 字节码,以便它可以在 AndroidVM 上运行。


Android 的执行期间,我们认为这种转换(称为 dexing)是一个可以解决的问题。然而,在构建和推出 D8 的过程中,发现了特定供应商或特定版本上的虚拟机中的 bug,本文将对此进行探讨。

1. Not A Not

D8Java 字节码编译为 Dalvik 字节码的过程,我们可以通过简单的示例来看:


class Not {  static void print(int value) {    System.out.println(~value);  }}
复制代码


我们通过 javac 编译查看。


$ javac *.java
$ javap -c *.classclass Not { static void print(int); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: iload_0 4: iconst_m1 5: ixor 6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 9: return}
复制代码


在上面的字节码中,下标位置 3 处是将参数值进栈,下标位置 4 处是将常量 -1 进栈,下标位置 5 处 是将栈顶两元素进行异或操作。在二进制中 -1 是由一串 1 组成的,异或操作的规则是二进制的每一位不同时该位为 1,相同为 0


00010100  (value) xor11111111  (-1) =11101011
复制代码


通过上面的结果可以看到,一个数经过异或操作,二进制的很多位都变为 1 了,经过二进制运算,一个数已经和原来发生很大变化。


如果我们通过 D8 去执行上面的 .class 文件,会发现和 Dalvik 字节码没有太大差别。


$ java -jar d8.jar \    --lib $ANDROID_HOME/platforms/android-28/android.jar \    --release \    --output . \    *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000134] Not.print:(I)V0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: xor-int/lit8 v1, v1, #int -10004: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V0007: return-void
复制代码


0002 位置处同样针对我们输入的参数 v1-1 进行异或操作,并把结果存到 v1 中。这是一个非常简单的 Java 运算,如果你不知道更好,就不会再为此考虑。但在这篇文章中应该会告诉你还有更多的这类内容。


所有的 Dalvik 字节码都可以在 Android 的开发指导网站上获取,如果你仔细看,能发现在一元运算中包含一个 not-in 的字节码,这是一种更高效的方式来替代参数与 -1 的位运算操作,为什么没有使用呢?


答案就在于老版本的 dx 工具中,它没有使用 not-in 指令。


$ $ANDROID_HOME/build-tools/28.0.3/dx \      --dex \      --output=classes.dex \      *.class[000130] Not.print:(I)V0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: xor-int/lit8 v1, v2, #int -10004: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V0007: return-void
复制代码


老版本的 dxdalvik/dx/ 目录下,如果我们对它的代码进行 grep 过滤,就可以找到哪些常量使用了 not int 指令。


$ grep -r -C 1 'not-int' src/com/android/dx/ioOpcodeInfo.java-522-    public static final Info NOT_INT =OpcodeInfo.java:523:        new Info(Opcodes.NOT_INT, "not-int",OpcodeInfo.java-524-            InstructionCodec.FORMAT_12X, IndexType.NONE);
复制代码


所以 dx 工具中是有 not-in 指令的,我们也在代码中过滤出来了,但是当编译为 class 文件时就没有了。为了作对比,我还在过滤的时候包含了 if-eq 指令。


$ grep -r -C 1 'NOT_INT' src/com/android/dx/cf
$ grep -r -C 1 'IF_EQ' src/com/android/dx/cfcode/RopperMachine.java-885- case ByteOps.IFNULL: {code/RopperMachine.java:886: return RegOps.IF_EQ;code/RopperMachine.java-887- }
复制代码


通过对比,发现无论使用什么 Java 字节码,dx 工具都不会使用 not-in 指令。这很不幸,但归根结底没什么大不了的。


问题的原因是源于这样一个事实:因为字节码从来没有被标准的 dexing 工具使用过,一些供应商他们不会费心在他们的 dalvik-vmjit 中支持它!一旦 D8 出现并开始使用完整的字节码集,在这些特定的手机上运行的 JIT 编译的应用程序就会崩溃。因此,在这种情况下,即使 D8 希望这样做,但是为了防止崩溃也不能使用 NOT INT 指令。


随着 API 21 版本的 ART VM 环境发布,所有的手机现在已经支持 not-in 指令,因此使用 D8 时添加 --min-api 21 将会使字节码使用 not-in 指令。


$ java -jar d8.jar \    --lib $ANDROID_HOME/platforms/android-28/android.jar \    --release \    --min-api 21 \    --output . \    *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000134] Not.print:(I)V0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: not-int v1, v10003: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V0006: return-void
复制代码


0002 处看到了我们期望的 not-in 指令。


Android 兼容其它语言特性类似,D8 可以改变单个字节码的格式以确保兼容性。随着生态系统和最低 API 级别的提高,D8 将自动使用效率更高的字节码。

2. Long Compare

即使所有使用中的字节码指令都受支持,但特定供应商的 JIT 与其他任何类型的软件一样,也可能包含错误。这在 OKHTTPOKIO 中的代码中就发生了。


两个库都有移动和统计字节的处理操作。他们的方法经常从检查负计数(这是无效的)开始,然后是零计数(没有工作要做)。


class LongCompare {  static void somethingWithBytes(long byteCount) {    if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0");    if (byteCount == 0) return; // Nothing to do!    // Do something…  }}
复制代码


我们查看编译后的字节码发现 0 被加载到堆栈中,并且进行了两次比较。


$ javac *.java
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000138] LongCompare.somethingWithBytes:(J)V0000: const-wide/16 v0, #int 00002: cmp-long v2, v3, v00004: if-ltz v2, 000b0006: cmp-long v2, v3, v00008: if-nez v2, 000a
复制代码


结合上面的字节码,cmp-long 会产生一个小于 0等于 0大于 0 的数。在每次比较之后,分别进行小于零的检查和非零的检查。但是,如果单个 cmp-long 产生比较结果,那么为什么 index 0006 会再次执行它呢?


这是因为如果在小于零的检查之后立即执行非零检查,则一些特定供应商的 JIT 会崩溃。这将导致程序在只处理 long 时看到不可能的异常,例如 NullPointerException


还是以上面的例子为例,在 API 21ART 虚拟机的引入解决了这个问题。通过指定 ——min api 21 生成只执行单个 cmp-long 操作的字节码。


$ java -jar d8.jar \    --lib $ANDROID_HOME/platforms/android-28/android.jar \    --release \    --min-api 21 \    --output . \    *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000138] LongCompare.somethingWithBytes:(J)V0000: const-wide/16 v0, #int 00002: cmp-long v2, v2, v00004: if-ltz v2, 00090006: if-nez v2, 0008
复制代码


通常 D8 为了兼容性而修改优化字节码的格式。所以当你的应用程序不再支持那些有缺陷供应商实现的 Android 版本时,字节码会变得效率更高。但是,尽管 ART 在整个生态系统中为虚拟机带来了规范化,消除(或至少减少)这些特定于供应商的缺陷,但它并不能免除缺陷本身。

3. Recursion(递归)

供应商提供的 ART 本身有 bug 会影响特定的 Android 版本,随着 D8 的普及,会突然让一些 ARTbug 暴露出来。


毫无疑问,下面演示的 bug 示例是精心设计的,但是代码是从一个实际应用程序抽取出来的,并被提炼成一个独立的示例。


 import java.util.List;
class Recursion { private void f(int x, double y, double u, double v, List<String> w) { f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); f(x, y, u, v, w); w.add(g(y, u, v)); }
private String g(double y, double u, double v) { return null; }}
复制代码


Android 6.0(API 23)ARTAOT 编译器上添加了调用分析用于执行内联方法。上面的函数 f 包含了大量的递归方法调用,所以 dex2oat 编译器编译的时候消耗了设备上的所有内存引起 crash。幸运的是针对这种情况的递归调用,在 Android 7.0(API 24)上进行了修复。


在低于 API 24 的版本上,D8 会改变 dex 文件从而引起这个崩溃。所以在研究解决方案前,我们重现一下这个崩溃。


$ javac *.java
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 24 \ --output . \ *.class
复制代码


我们给 D8 指定 --min-api 24 来编译一个 dex 文件,并把编译的 dex 文件放到一个 API 23 的设备上,会看 dex2oat 拒绝编译该 dex 文件。


$ adb shell push classes.dex /sdcard
$ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
$ adb logcat11-29 13:57:08.303 4508 4508 I dex2oat : dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat11-29 13:57:08.306 4508 4508 W dex2oat : Failed to open .dex from file '/sdcard/classes.dex': Failed to open dex file '/sdcard/classes.dex' from memory: Unrecognized version number in /sdcard/classes.dex: 0 3 711-29 13:57:08.306 4508 4508 E dex2oat : Failed to open some dex files: 111-29 13:57:08.309 4508 4508 I dex2oat : dex2oat took 7.440ms (threads: 4)
复制代码


dex 文件格式规范中dex 文件的头 8 个字节应该是 DEX 字符,然后下一行是版本号,接着是一个空字节。因为我们指定了 --min-api 24,所以 dex 文件的版本号就是 037,我们现在来查看确认下。


$ xxd classes.dex | head -100000000: 6465 780a 3033 3700 e595 2d8c 49b5 d6b6  dex.037...-.I...
复制代码


为了能在这台旧设备中安装,我们必须指定版本号为 035,这个很简单,我们通过任何 16 进制编辑器都可以进行修改,我使用的是 xxd 完成转换操作。


$ xxd -p classes.dex > classes.hex
$ nano classes.hex # Change 303337 to 303335
$ xxd -p -r classes.hex > classes.dex
复制代码


通过改变版本号,这个 dex 文件可以在 Android 6.0 的设备上编译了。


$ adb shell push classes.dex /sdcard
$ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oatSegmentation fault
复制代码


如上面所示,我们重现了 ART 的这个 crash。如我们所料,如果我们在 Android 7.0 的设备上运行这个 dex 文件就不会出现这个 crash


下面我们将 dex 文件名称进行修改,并且删除 --min-api 24 指定重新进行编译。


$ mv classes.dex classes_api24.dex
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
复制代码


查看 dex 字节码看看差异。


$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes_api24.dex[000190] Recursion.f:(IDDDLjava/util/List;)V0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V001b: move-object v0, v7001c: move-wide v1, v9001d: move-wide v3, v11001e: move-wide v5, v13001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion;.g:(DDD)Ljava/lang/String;0022: move-result-object v80023: invoke-interface {v15, v8}, Ljava/util/List;.add:(Ljava/lang/Object;)Z0026: return-void  catches       : (none)
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000198] Recursion.f:(IDDDLjava/util/List;)V0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V001b: move-object v0, v7001c: move-wide v1, v9001d: move-wide v3, v11001e: move-wide v5, v13001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion;.g:(DDD)Ljava/lang/String;0022: move-result-object v80023: invoke-interface {v15, v8}, Ljava/util/List;.add:(Ljava/lang/Object;)Z0026: return-void0027: move-exception v80028: throw v8 catches : 1 0x0018 - 0x001b Ljava/lang/Throwable; -> 0x0027
复制代码


通过对比编译的两个 dex 文件,有问题的 dex 文件中包含了额外的字节码 move-exceptionthrow,以及所有的 catches 条目。通过插入这个 try-catch 块,AOT 编译器禁用对方法内联的调用分析,try-catch 模块作用的范围是从 0x00180x001b。如果我们在源码中删除一个 f 的递归调用,就不会引起 AOT 的这个编译错误,因为量还不足够大。


同样的代码,如果我们使用旧的 dx 编译器编译并不会在 Android 6.0 上引起崩溃,因为通过旧的 dx 编译器效率不高同时使用寄存器来禁止内联分析。

4. 总结

上面的三个例子是 Android 虚拟机中的一些特定供应商版本的错误。正如前面文章中介绍的语言功能消除一样,D8 仅在必要时根据您的最低 API 级别为这些 bug 应用兼容解决方案。VMbug 不仅在老版本中出现,新版本同样也有 bug。重要的是要记住,所有这些问题都不是由 D8 引起的。与 dx 相比,D8 以更有效地使用寄存器和更高效地排序字节码。为了进一步优化 dex,我们必须求助于 D8 的优化兄弟 R8,我们将在下一篇文章中开始研究它。

用户头像

Antway

关注

持续精进,尽管很慢 2019.05.27 加入

专注开源库

评论

发布
暂无评论
[译] 规避供应商以及特定版本的 VM Bugs