0x00 背景
最近整体升级了项目的工具链。 使用了 D8 作为项目的主力。
在 Release 包在 5.1 上出现了 java.lang.VerifyError 异常。
0x01 问题定位
VerifyError 错误一般出现的 5.0 以下。通常由分包导致的。但是这次发生的机子是 5.1 。
我们将问题代码进行简化如下。
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) {
//忽略
}
}
运行奔溃如下:
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)
往往单纯的奔溃信息是不足以发现问题的。查找上下文日志获取更多信息。
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)
发现两个异常信息:
- isInMultiWindowMode 方法未找到 :
找不到 isInMultiWindowMode 方法。 这个方法是在 api 24 上加入的, 确实在 android 5.1 ( api 22) 上不存在。 但就这? - 寄存器类型匹配失败:
java 虚拟机检验类合法性的时候会匹配栈帧。 对应 android 虚拟机校验寄存器注册表。
根源问题在寄存器类型匹配失败。 导致校验方法失败从而校验类失败。
比较吊诡的是这个问题只出现在 android 5.1 上。 并且只在 Release 包上出现。 据其原因我们使用 dexduup 工具 查看该方法在 Debug 和 Release 包生成的 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()
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);
}
校验方法主要以下几个方面
- 校验指令大小是否超过声明大小。
- 校验方法指令使用的寄存器是否越界。
- 校验跳转指令是否越界或错误
- 校验指令引用的元素在 Dex 位置是否正确
- 校验寄存器注册表否正确。即从寄存器读取的类型是否匹配声明的类型。
- 锁 是否被正确释放。
这次这个错误是在校验寄存器注册表出现的。
寄存注册表校验流程如下:
为每个指令设置一个 insn_flags 标记。当对应的 insn_flags 设置为 Changed。 那么该指令需要被校验。art 会从第一个指令开始校验 。 校验指令的同时会设置其他的指令设置 Changed。如操作指令
会设置下一个指令为 Changed。分支指令
因为存在多个分支的指令。 会对多个分支的第一个指令设置 Changed。回值指令
则不会为任何指令设置。 通过检查是否还存在 Changed 标记位来检查是否完成校验工作。
关于指令的类型定义都 dex_instruction_list.h
kContinue
为操作指令
kBranch
为分支指令
kReturn
为回值指令
指令在运行的时候还存在一个寄存器注册表。寄存器注册表很大一部分体现了当前运行的环境。 当遇到分支指令的时候, 由于存在分支跳转。还需要把寄存器注册表状态转移到所有的分支上。 一个指令多次被执行的时候。就会存在多张寄存器注册表,需要合并这些表。当合并不兼容的时候, 需要重新校验该分支的代码。
从字节码流程中观察寄存器注册表的变化。来定位问题
|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
第一步
该方法声明寄存器5个,初始化寄存器注册表 V0~V4: xxxL1L2
x: 未定义
L1 :this 对象类型
L2 :第一个入参第二步
校验第一个指令 0000 sget V0
设置指令 0002 的 insn_flags 为 Changed
寄存器注册表 IxxL1L2第三步
校验指令 0002 const/4 v1, #int 0
设置下一个指令 0003 的 insn_flags 为 Changed
寄存器注册表 IIxL1L2第四步
校验指令 0003 const/16 v2, #int 24
设置下一个指令 0005 的 insn_flags 为 Changed
寄存器注册表 IIIL1L2第五步
校验分支指令 0005: if-lt v0, v2, 000e
设置下一个指令 0007 的 insn_flags 为 Changed
设置下个分支第一个指令 000e 的 insn_flags 为 Changed
寄存器注册表 IIIL1L2
复制寄存注册表到 000e 上第六步
校验指令 0007: invoke-virtual {v4}, Landroid/app/Activity;.isInMultiWindowMode:()Z
检验发现 isInMultiWindowMode 方法不存在。该异常会导致出现运行期异常。 该条链路以下的指令不再校验。 不再为任何指令设置 Changed 。
当前寄存器注册表 IIIL1L2第七步
由于 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第八步
检验 001a: return v1。 检验寄存器1
由于当前寄存器注册表未赋值为 xxxxx
校验失败。结束校验。抛出异常
异常现场复现。
0x03 总结
Bug 如何出现 ?
这个 Bug 是一套组合。
- 一个运行期异常。
- 紧跟一个 try catch 代码块
- try catch 第一个指令运行不会发生异常
- catch异常处理第一个指令是一个从寄存器读的操作。
如何解决这个 Bug ?
弃用 D8 使用 dx 来转化 Dex (历史的倒退)
弃用 release 模式的 D8 来生成 Dex(优化力度变小)
规避特定的排序。 (看天吃饭)
节点1 去除 isInMultiWindowMode 方法调用。
节点2 关闭强转。
节点3 处理异常。
节点3 return 非 0 。对 D8 进行干预。 关闭 move-exception 指令的优化
MoveException.java
Bug 影响范围 ?
问题存在在 api 21-22 在 api 23 被修复。
修复的 commit 如下:
saved_line_ 正确被赋值
d7f8d059 diffhave_pending_runtime_throw_failure_ 状态及时重置。
3ae8da0 diff