查看原文
其他

Android D8编译器“bug”导致Crash的问题排查

风荷举 西瓜技术团队 2021-10-12

摘要

这篇文章主要是分析了一个因为 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.
我们知道虚拟机在加载一个类的时候会先对其字节码进行校验,以检查其是否满足规范(具体约束可以参考 jvms $4.9 Constraints on Java Virtual Machine Code),虽然 dalvik 字节码规范有所不同,但是也有类似的校验逻辑。从上面的错误信息可以看出是在校验方法调用指令时发现其 long/double 参数的格式有问题,因为上面的代码看不出有啥问题,所以我们直接从报错的位置开始查一查。

分析

报错的原因

上面的错误信息很明确,是 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,这也就是为啥非所有机型必现。

不过虽然没有 crash,但是数据被破坏了,其实问题更严重。上面那段代码可以确定 JSONObject 里面的那几个参数都应该是-1,我们用 Android 5.0 跑了下代码后,得到如下数据:
{"ad":-1,"sv":-1,"tt":-1,"fd":-1,"md":1353591864258723839}

很明显 md 的值错了,因为 v0 的值每次运行都会变,所以多次运行,md 的值都会变,不过也都是错误的。

Dalvik 字节码为何出错

然后我们再来分析下 Dalvik 字节码为何会出错?

  1. 由于西瓜编译时对字节码的改动非常多,比如简单方法及常量内联,热修插桩,运行时不可见注解的移除,kotlin 字节码精简,字节码 hook 等等,再加上2个月前对字节码 hook 框架重构过,所以率先把所有字节码处理逻辑全部去除看看,然后发现问题依然在(这就不慌了😄);

  2. 反编译看 Java 字节码,没有问题, 并且我们 hook 编译过程,将有问题的寄存器(v15, v0)改为(v2, v3,此处的v2 v3是基于当前 case 分析来的,当然此时可用的寄存器还有其它的)后,正常 work。所以确定是在将 java 字节码转换为 Dalvik 字节码时出的错。

大致看下它将 Java 字节码转换成 Dalvik 字节码的过程:

java字节码 -> IR code -> optimized IR code -> dalvik 字节码
先看下生成的 IR,没问题(IR里面是不区分 invoke-virtual 和 invoke-virtual/range 的,并且也还没进行寄存器分配优化,所以此处看到 Invoke-Virtual 使用 v44 这样的寄存器不是错误):
# 省略...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 肯定是不对的。

我们暂时先等下看 v16 是怎么来的,因为我们上面也说了,反编译 apk 看到使用的寄存器是 v15 和 v0,不连续,所以才报错的,这儿生成的指令用的是 v16,怎么变成 v0 了呢?
这个原因其实比较简单:对于像 VA 格式的寄存器,它的编号由4比特表示,所以范围是 0 ~ 15,我们看看将它 encode 成二进制的关键代码:
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。

在了解了 v16 怎么变成 v0 之后,我们再来看看 v16 是怎么来的,既然v16在此处不合法,为什么 D8 依然生成了这样的错误指令呢?
从 IR 到生成 Dalvik 字节码的过程中有个重要的步骤:寄存器分配,这个算法很复杂(复杂到 D8 分配的寄存器不是最优的,而且还会出错),此处跳过它分析的过程,直接看分配的结果:对于 md 这个 long 类型的参数,它分配的寄存器是 v15,然后我们来看一段关键代码:
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。

其实看到这块代码还解释了我分析问题之前有的一个疑惑:既然 Android 7 之后 ART 增加了这个连续寄存器的检查,为啥 D8 没有这方面的处理,还会生成错误代码呢?其实是有的,就是第14行,只是默认情况下 Java 会忽略 assert 语句!!所以我们开发中也一直建议不要用 assert,而是抛异常,这也是为啥标题中 “bug” 是带引号的。

问题解决

比较优雅的解决方式当然是能在编译时解决这个分配不当的问题,但优化寄存器分配比较复杂,我们尝试了另一个方案:

通过插入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, v14move-object/from16 v21, v0move-wide/from16 v22, v15invoke-virtual/range {v20 .. v23}, Lorg/json/JSONObject;->put(Ljava/lang/String;J)Lorg/json/JSONObject;

不过对于上面的那个问题最终我们并没有采用这个方案去解决,主要是因为这里也会涉及到寄存器分配,要想分配较优,使用寄存器数较少且不出现问题还是比较难的。

同时对于上面提到的问题,其实碰到的机率是很小的,刻意想模拟出一个这样的 demo 都不容易,我们最后采用的方案是:
  1. 简单调整代码,绕过这个寄存器分配不当的问题,比如将 DEFAULT_VALUE 改为 final,或者调整一下变量定义顺序等等很多方式都可以规避这个问题。
  2. 同时将这种问题改为编译时报错,提早发现问题。

以上就是 Android D8 编译器导致异常 Crash 问题排查的全部过程,如果有遇到类似问题的,可供参考。

欢迎关注「西瓜技术团队」
: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存