0x00 背景

最近整体升级了项目的工具链。 使用了 D8 作为项目的主力。
在 Release 包在 5.1 上出现了 java.lang.VerifyError 异常。

0x01 问题定位

VerifyError 错误一般出现的 5.0 以下。通常由分包导致的。但是这次发生的机子是 5.1 。

我们将问题代码进行简化如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class A {

// 方法调用入口
public int method1(Activity activity) {
if (Build.VERSION.SDK_INT >= 24 && activity.isInMultiWindowMode()) {
// 节点1
return 0;
}
try {
// 节点2
Point screenSize = method2((Runnable) activity);
method3(activity, screenSize);
return 1;
} catch (Exception e) {
// 节点3
return 0;
}
}

private Point method2(Runnable activity) {
return new Point();
}

private void method3(Activity activity, Point screenSize) {
//忽略
}
}

运行奔溃如下:

1
java.lang.VerifyError: Verifier rejected class com.dim.A due to bad method int com.dim.A.method1(android.app.Activity) (declaration of 'com.dim.A' appears in /data/app/com.dim-2/base.apk)

往往单纯的奔溃信息是不足以发现问题的。查找上下文日志获取更多信息。

1
2
3
4
I/art: Verification error in int com.dim.A.method1(android.app.Activity)
I/art: int com.dim.A.method1(android.app.Activity): [0x7] couldn't find method android.app.Activity.isInMultiWindowMode ()Z
I/art: int com.dim.A.method1(android.app.Activity) failed to verify: int com.dim.A.method1(android.app.Activity): [0x1A] register v1 has type Undefined but expected Integer return-1nr on invalid register v1
E/art: Verification failed on class com.dim.A in /data/app/com.dim-2/base.apk because: Verifier rejected class com.dim.A due to bad method int com.dim.A.method1(android.app.Activity)

发现两个异常信息:

  1. isInMultiWindowMode 方法未找到 :
    找不到 isInMultiWindowMode 方法。 这个方法是在 api 24 上加入的, 确实在 android 5.1 ( api 22) 上不存在。 就这?
  2. 寄存器类型匹配失败:
    java 虚拟机检验类合法性的时候会匹配栈帧。 对应 android 虚拟机校验寄存器注册表。

根源问题在寄存器类型匹配失败。 导致校验方法失败从而校验类失败。

比较吊诡的是这个问题只出现在 android 5.1 上。 并且只在 Release 包上出现。 据其原因我们使用 dexduup 工具 查看该方法在 Debug 和 Release 包生成的 Dex 字节码的异同。

Dex字节码异同
可以看出方法使用的寄存器 5 个。一个 catch 异常处理。参数2个。 Debug 包仅仅比 Release 包在异常处理处多个一个 move-exception 指令。

字节码的异同是因为项目中使用 D8 。D8 生成 Dex 的时候会做一些优化。如字符串优化, new-array 指令优化,分支指令优化等。 其中包含一些无效指令的删除。 比如一个异常被 catch。 但并没有对异常进行操作。在 Release 模式下那么 D8 认为 move-exception 指令是一个无意义的操作,该指令将会被移除。

至此我们已经知道了出现问题的大概。
因为 D8 对 Dex 优化。生成特定的指令排列导致在部分虚拟机校验失败。

0x02 问题回溯

查看 art 相关代码
art 方法校验入口在 MethodVerifier::Verify()

1
2
3
4
5
6
7
8
9
10
11
12
13
14

insn_flags_.reset(new InstructionFlags[code_item_->insns_size_in_code_units_]());
// Run through the instructions and see if the width checks out.
bool result = ComputeWidthsAndCountOps();
// Flag instructions guarded by a "try" block and check exception handlers.
result = result && ScanTryCatchBlocks();
// Perform static instruction verification.
result = result && VerifyInstructions();
// Perform code-flow analysis and return.
result = result && VerifyCodeFlow();
// Compute information for compiler.
if (result && Runtime::Current()->IsCompiler()) {
result = Runtime::Current()->GetCompilerCallbacks()->MethodVerified(this);
}

校验方法主要以下几个方面

  1. 校验指令大小是否超过声明大小。
  2. 校验方法指令使用的寄存器是否越界。
  3. 校验跳转指令是否越界或错误。
  4. 校验指令引用的元素在 Dex 位置是否正确。
  5. 校验寄存器注册表否正确。即从寄存器读取的类型是否匹配声明的类型。
  6. 锁 是否被正确释放。

这次这个错误是在校验寄存器注册表出现的。

寄存注册表校验流程如下:

为每个指令设置一个 insn_flags 标记。当对应的 insn_flags 设置为 Changed。 那么该指令需要被校验。art 会从第一个指令开始校验 。 校验指令的同时会设置其他的指令设置 Changed。如操作指令会设置下一个指令为 Changed。分支指令因为存在多个分支的指令。 会对多个分支的第一个指令设置 Changed。回值指令 则不会为任何指令设置。 通过检查是否还存在 Changed 标记位来检查是否完成校验工作。
关于指令的类型定义都 dex_instruction_list.h

kContinue操作指令
kBranch分支指令
kReturn回值指令

指令在运行的时候还存在一个寄存器注册表。寄存器注册表很大一部分体现了当前运行的环境。 当遇到分支指令的时候, 由于存在分支跳转。还需要把寄存器注册表状态转移到所有的分支上。 一个指令多次被执行的时候。就会存在多张寄存器注册表,需要合并这些表。当合并不兼容的时候, 需要重新校验该分支的代码。

从字节码流程中观察寄存器注册表的变化。来定位问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|0000: sget v0, Landroid/os/Build$VERSION;.SDK_INT:I // field@0000
|0002: const/4 v1, #int 0 // #0
|0003: const/16 v2, #int 24 // #18
|0005: if-lt v0, v2, 000e // +0009
|0007: invoke-virtual {v4}, Landroid/app/Activity;.isInMultiWindowMode:()Z // method@0001
|000a: move-result v0
|000b: if-eqz v0, 000e // +0003
|000d: return v1
|000e: move-object v0, v4
|000f: check-cast v0, Ljava/lang/Runnable; // type@001c
|0011: invoke-virtual {v3, v0}, Lcom/dim/A;.method2:(Ljava/lang/Runnable;)Landroid/graphics/Point; // method@0008
|0014: move-result-object v0
|0015: invoke-direct {v3, v4, v0}, Lcom/dim/A;.method3:(Landroid/app/Activity;Landroid/graphics/Point;)V //
|0018: const/4 v1, #int 1 // #1
|0019: return v1
|001a: return v1
catches : 1
0x000e - 0x0018
Ljava/lang/Exception; -> 0x001a

  1. 第一步
    该方法声明寄存器5个,初始化寄存器注册表 V0~V4: xxxL1L2
    x: 未定义
    L1 :this 对象类型
    L2 :第一个入参
  2. 第二步
    校验第一个指令 0000 sget V0
    设置指令 0002 的 insn_flags 为 Changed
    寄存器注册表 IxxL1L2
  3. 第三步
    校验指令 0002 const/4 v1, #int 0
    设置下一个指令 0003 的 insn_flags 为 Changed
    寄存器注册表 IIxL1L2
  4. 第四步
    校验指令 0003 const/16 v2, #int 24
    设置下一个指令 0005 的 insn_flags 为 Changed
    寄存器注册表 IIIL1L2
  5. 第五步
    校验分支指令 0005: if-lt v0, v2, 000e
    设置下一个指令 0007 的 insn_flags 为 Changed
    设置下个分支第一个指令 000e 的 insn_flags 为 Changed
    寄存器注册表 IIIL1L2
    复制寄存注册表到 000e 上

  6. 第六步
    校验指令 0007: invoke-virtual {v4}, Landroid/app/Activity;.isInMultiWindowMode:()Z
    检验发现 isInMultiWindowMode 方法不存在。该异常会导致出现运行期异常。 该条链路以下的指令不再校验。 不再为任何指令设置 Changed 。
    当前寄存器注册表 IIIL1L2

  7. 第七步
    由于 000e 的 insn_flags 还是 Changed。还需要校验指令 000e 指令
    校验指令 000e: move-object v0, v4
    0x00e - 0x0018 是位于 try catch 里面的指令。 try catch 里所有可能发生异常的指令。都会走到 catch 的处理逻辑中。 所以需要把进入该指令前的寄存器注册表状态转移到 0x001a 中。进入前的寄存器注册表保存在 saved_line_ 变量上。理论上 move-object 指令是不会发生异常的。 但是 api 22 存在的一个 bug 。 由于第六步的异常导致所有的指令都强制设置为会发生异常。 导致 art 错误的把一个未赋值的 saved_line_ 寄存器注册表赋值给 0x001a ,同时设置 0x001a 的 insn_flags 设置为 Changed 。
    执行指令是否会发生异常查看 dex_instruction_list.h kThrow

  8. 第八步
    检验 001a: return v1。 检验寄存器1
    由于当前寄存器注册表未赋值为 xxxxx
    校验失败。结束校验。抛出异常。

异常现场复现。

0x03 总结

Bug 如何出现 ?

这个 Bug 是一套组合拼凑起来的。

  1. 一个运行期异常。
  2. 紧跟一个 try catch 代码块。
  3. try catch 第一个指令运行不会发生异常。
  4. catch异常处理第一个指令是一个从寄存器读的操作。

如何解决这个 Bug ?

  1. 弃用 D8 使用 dx 来转化 Dex。 (历史的倒退)
  2. 弃用 release 模式的 D8 来生成 Dex(优化力度变小)
  3. 规避特定的排序。 (看天吃饭)
    节点1 去除 isInMultiWindowMode 方法调用。
    节点2 关闭强转。
    节点3 处理异常。
    节点3 return 非 0 。

  4. 对 D8 进行干预。 关闭 move-exception 指令的优化
    MoveException.java

image.png

Bug 影响范围 ?

问题存在在 api 21-22 在 api 23 被修复。
修复的 commit 如下:

  1. saved_line_ 正确被赋值。
    d7f8d059 diff

  2. have_pending_runtime_throw_failure_ 状态及时重置。
    3ae8da0 diff