Android D8编译器“bug”导致Crash的问题排查
摘要
这篇文章主要是分析了一个因为 Android D8 编译器分配的寄存器不太合理,导致运行时 ART 抛出了 VerifyError 而 Crash 的问题,下文简单分析了下问题产生的原因以及我们采用的修复方案。
背景
前两天西瓜 Android 组里有同学遇到一个较为神奇的问题,代码非常简单,几个同学都 review 过了,编译也没问题,但是运行时会发生 VerifyError 的 Crash,并且据反馈还不是必现的。
public class Test {
private static long DEFAULT_VALUE = -1L;
private static HashMap<String, Time> cache = new HashMap<>();
public static void test() {
Time ap = cache.get("AP");
long ad = ap != null ? ap.d : DEFAULT_VALUE;
Time sp = cache.get("SP");
long sv = sp != null ? sp.d : DEFAULT_VALUE;
long tt = DEFAULT_VALUE;
if (ap != null && sp != null) {
tt = sp.e - ap.s;
}
Time fp = cache.get("FP");
long fd = fp != null ? fp.d : DEFAULT_VALUE;
Time mp = cache.get("MP");
long md = mp != null ? mp.d : DEFAULT_VALUE;
long sd = DEFAULT_VALUE;
if (sp != null && mp != null) {
sd = mp.s - sp.s;
}
if (isTestChannel()) {
Data.INSTANCE.setTt(tt);
Data.INSTANCE.setAd(ad);
Data.INSTANCE.setFd(fd);
Data.INSTANCE.setMd(md);
Data.INSTANCE.setSv(sv);
Data.INSTANCE.setSd(sd);
}
JSONObject params = new JSONObject();
try {
params.put("ad", ad);
params.put("sv", sv);
params.put("tt", tt);
params.put("fd", fd);
params.put("md", md);
} catch (JSONException e) {
e.printStackTrace();
}
Log.d("params", params.toString());
}
public static class Time {
public long s;
public long e;
public long d;
}
private static boolean isTestChannel() {
return true;
}
private static class Data {
static Data INSTANCE = new Data();
void setTt(long l) {}
void setAd(long l) {}
void setFd(long l) {}
void setMd(long l) {}
void setSv(long l) {}
void setSd(long l) {}
}
}
这段代码编译没有问题,但是一运行就会发生如下 crash:
java.lang.VerifyError: Verifier rejected class com.liyang.myapplication.Test: void com.liyang.myapplication.Test.test() failed to verify: void com.liyang.myapplication.Test.test(): [0xB4] Rejecting invocation, long or double parameter at index 2 is not a pair: 15 + 0.
分析
报错的原因
上面的错误信息很明确,是 verify 方法体的时候报错的,我们可以看一下报错的位置:
// 省略...
if (reg_type.IsLongOrDoubleTypes()) {
// Check that registers are consecutive (for non-range invokes). Invokes are the only
// instructions not specifying register pairs by the first component, but require them
// nonetheless. Only check when there's an actual register in the parameters. If there's
// none, this will fail below.
if (!is_range && sig_registers + 1 < expected_args) {
uint32_t second_reg = arg[sig_registers + 1];
if (second_reg != get_reg + 1) {
Fail(VERIFY_ERROR_BAD_CLASS_HARD) << "Rejecting invocation, long or double parameter "
"at index " << sig_registers << " is not a pair: " << get_reg << " + "
<< second_reg << ".";
return nullptr;
}
}
}
// 省略...
这段代码目的是检查寄存器使用是否正确。Dalvik 中寄存器是32位的,像 long、double 这样64位的数据需要2个寄存器来存储(这里所说的寄存器是指虚拟寄存器,Dalvik字节码支持65536个寄存器:v0~v65535,实际的 cpu 不会有这么多个寄存器的,虚拟机负责将其映射到真实的寄存器或者内存)。上面的那段代码就是用来检查用于存储 long 或者 double 的寄存器是否是连续的。
我们反编译看看上面的那段代码:
# 省略...
const-string v0, "md"
invoke-virtual {v14, v0, v15, v0}, Lorg/json/JSONObject;->put(Ljava/lang/String;J)Lorg/json/JSONObject;
# 省略...
此处的 invoke virtual 对应代码:params.put("md", md); 我们可以看到,v15 和 v0 用于存放 long 类型的参数,显然这两个寄存器不是连续的,所以verify报错:is not a pair: 15 + 0.这里的15和0就是对应的 v15,v0,并且参数列表中 v14 是 JSONObject this,v0 是 String 参数 "md",这个 long 是第三个参数,所以 at index 2。
存 long 和 double 的寄存器为什么要连续
在进一步分析crash之前,我们先来看看为什么虚拟机要求存储64位数据时必须使用两个连续的寄存器,因为正是有这个要求,虚拟机才会有上面的 verify 逻辑,我们才会 crash 的。
理论上讲,只要有足够的空间能放下64位的数据就能够满足需求,只要读写数据的时候都使用这两个指定的寄存器就 ok 了,但是 Dalvik 中有不少指令(-wide)在存取64位数据时是只指定一个寄存器(例如 VA),另一个寄存器则默认为下一个(VA+1)。
iget-wide v10, v9, Lcom/liyang/Data;->longData:J # 这个就是将Data中的longData 存储到 v10 v11 2个寄存器中
iget v2, v9, Lcom/liyang/Data;->intData:I # 这个就是将Data中的intData 存储到 v2 寄存器中
上面我们可以知道有些指令在存取数据的时候默认就是使用的连续寄存器,所以在使用 invoke-xxx 的时候,我们也需要按照这样的规则来(invoke-xxx 是没有 -wide 版本的,因为调用参数可能有多个,没法指明到底哪个是 wide,所以涉及到的寄存器都得指定)。
为啥非必现呢?
上面的信息已经说明了存放64位数据必须要使用连续的寄存器,所以我们上面的那个 apk 确实是有问题的,verify 应该报错,至于非必现原因是这个 verify 逻辑是 Android 7.0 才加上的,所以在 7.0 以下不会 crash,这也就是为啥非所有机型必现。
{"ad":-1,"sv":-1,"tt":-1,"fd":-1,"md":1353591864258723839}
很明显 md 的值错了,因为 v0 的值每次运行都会变,所以多次运行,md 的值都会变,不过也都是错误的。
Dalvik 字节码为何出错
然后我们再来分析下 Dalvik 字节码为何会出错?
由于西瓜编译时对字节码的改动非常多,比如简单方法及常量内联,热修插桩,运行时不可见注解的移除,kotlin 字节码精简,字节码 hook 等等,再加上2个月前对字节码 hook 框架重构过,所以率先把所有字节码处理逻辑全部去除看看,然后发现问题依然在(这就不慌了😄);
反编译看 Java 字节码,没有问题, 并且我们 hook 编译过程,将有问题的寄存器(v15, v0)改为(v2, v3,此处的v2 v3是基于当前 case 分析来的,当然此时可用的寄存器还有其它的)后,正常 work。所以确定是在将 java 字节码转换为 Dalvik 字节码时出的错。
大致看下它将 Java 字节码转换成 Dalvik 字节码的过程:
java字节码
-> IR code
-> optimized IR code
-> dalvik 字节码
# 省略...
Invoke-Virtual v120 <- v53(params), v117, v44(md); method: org.json.JSONObject org.json.JSONObject.put(java.lang.String, long)
# 省略...
然后我们看下根据上面的 IR 生成的指令:
# 省略...
InvokeVirtual { v14 v0 v15 v16 } Lorg/json/JSONObject;->put(Ljava/lang/String;J)Lorg/json/JSONObject;
# 省略...
此时很明显这个指令有问题:invoke-virtual 的指令格式为:invoke-*kind* {vC, vD, vE, vF, vG}, meth@BBBB,它所使用的寄存器只能为 v0 ~ v15,所以上面的 v16 肯定是不对的。
protected static int makeByte(int high, int low) {
return (high & 15) << 4 | low & 15;
}
(16 & 15) << 4 | 15 & 15 = 15。因此我们反编译以及虚拟机解析时,得到的 high 是 0, low 是 15,也就是我们看到的 v15 和 v0。
protected int fillArgumentRegisters(DexBuilder builder, int[] registers) {
assert !this.needsRangedInvoke(builder);
int i = 0;
Iterator var4 = this.arguments().iterator();
while(var4.hasNext()) {
Value value = (Value)var4.next();
int register = builder.argumentOrAllocateRegister(value, this.getNumber());
if (register + value.requiredRegisters() - 1 > 15) {
register = builder.allocatedRegister(value, this.getNumber());
}
assert register + value.requiredRegisters() - 1 <= 15;
for(int j = 0; j < value.requiredRegisters(); ++j) {
assert i < 5;
registers[i++] = register++;
}
}
return i;
}
对于 long 类型的数据,requiredRegisters 是 2,这个前面也提到过。在第10行我们可以看到如果分配的 register 序号过大,那么会尝试重新分配,不过遗憾的是对于我们遇到的这个case,它尝试重新分配的时候依然分配的是15(其实此时有好些寄存器是可用的,比如我们上面hook中使用的 v2, v3)。因为此处需要2个寄存器,所以会进入到第16行的for循环,然后可以看到下一个寄存器是直接通过 ++ 得来的 -> v16。
问题解决
比较优雅的解决方式当然是能在编译时解决这个分配不当的问题,但优化寄存器分配比较复杂,我们尝试了另一个方案:
通过插入move指令配合invoke-xxx/range来实现,编译和运行都是 OK 的。
以我们上面那个方法为例,简单看看我们的指令处理(以 smali 格式展示指令修改的效果):
// 备注:修复中没有考虑寄存器分配的优化,所以在这个方法的修复例子中,我们多使用了4个寄存器
// 关键指令:
1. 不生成上述有问题的指令:invoke-virtual {v14, v0, v15, v0}, Lorg/json/JSONObject;->put(Ljava/lang/String;J)Lorg/json/JSONObject;
2. 新指令:
move-object/from16 v20, v14
move-object/from16 v21, v0
move-wide/from16 v22, v15
invoke-virtual/range {v20 .. v23}, Lorg/json/JSONObject;->put(Ljava/lang/String;J)Lorg/json/JSONObject;
不过对于上面的那个问题最终我们并没有采用这个方案去解决,主要是因为这里也会涉及到寄存器分配,要想分配较优,使用寄存器数较少且不出现问题还是比较难的。
简单调整代码,绕过这个寄存器分配不当的问题,比如将 DEFAULT_VALUE 改为 final,或者调整一下变量定义顺序等等很多方式都可以规避这个问题。 同时将这种问题改为编译时报错,提早发现问题。
以上就是 Android D8 编译器导致异常 Crash 问题排查的全部过程,如果有遇到类似问题的,可供参考。