ART 下的方法内联策略及其对 Android 热修复方案的影响分析

作者:tomystang

导语

为了解决ART模式下的占用Rom空间问题,Tinker曾经花了一个半月时间实现分平台合成。Android N后对内联的新发现,似乎再一次认证了"热补丁不是请客吃饭"这句话。

研究或填坑的路可能永远不会停,但Tinker团队有决心与信心可以陪大家一起走下去。

0x00 背景

ART(Android Runtime)是Android在4.4版本中引入的新虚拟机环境,在5.0版本正式取代了Dalvik VM。ART环境下,App安装时其包含的Dex文件将被dex2oat预编译成目标平台的机器码,从而提高了App的运行效率。在这个预编译过程中,dex2oat对目标代码的优化过程与Dalvik VM下的dexopt有较大区别,尤其是在5.0版本以后ART环境下新增的方法内联优化。由于方法内联改变了原本的方法分布和调用流程,对热修复方案势必会带来影响,本文将分析ART下方法内联策略,并总结方法内联对现有的主流热修复方案造成的影响。

浏览Android源码可知,Android用来生成oat文件的Compiler有多种实现,各Android版本中存在的实现类型和默认使用的类型如下:

Android版本

Compiler类型

默认使用

备注

4.4

kQuick

kQuick

此版kQuick未引入方法内联优化

5.x

kQuick kOptimizing kPortable

kQuick

kPortable是半成品,未实际使用

6.x

kQuick kOptimizing

kQuick

从此版本开始,kOptimizing加入下文归纳的新内联特性

7.0

kOptimizing

kOptimizing

7.1

kOptimizing

kOptimizing

其中Quick Compiler的方法内联条件可以从art/runtime/quick/inline_method_analyser.cc里的InlineMethodAnalyser::AnalyseMethodCode方法开始分析,篇幅关系这里直接给出结论。对于Quick Compiler,当以下条件均满足时被调用的方法将被inline:

  • 1.App不是Debug版本的;
  • 2.被调用方法的实现满足下列条件之一:
  • 2.1. 空方法;
  • 2.2. 仅返回方法参数;
  • 2.3. 仅返回一个方法内声明的常量或null;
  • 2.4. 从被调用方法所在类的非静态成员获取并返回获取的值;(注意,static final成员会被优化成常量,此时要参照2.3)
  • 2.5. 仅设置了被调用方法所在类的非静态成员的值;
  • 2.6. 仅设置了被调用方法所在类的非静态成员的值,并返回一个方法内声明的常量或null。

注:条件2隐含了一个条件,就是被调用的方法的字节码不超过2条。

Optimizing Compiler的内联条件可以从art/compiler/optimizing/inliner.cc里的HInliner::Run()方法开始分析,篇幅关系这里同样直接给出结论。对于Optimizing Compiler,当以下条件均满足时被调用的方法将被inline:

1、App不是Debug版本的;

2、被调用的方法所在的类与调用者所在的类位于同一个Dex;(注意,符合Class N命名规则的多个Dex要看成同一个Dex)

3、被调用的方法的字节码条数不超过dex2oat通过--inline-max-code-units指定的值,6.x默认为100,7.x默认为32;

4、被调用的方法不含try块;

5、被调用的方法不含非法字节码;

6、对于7.x版本,被调用方法还不能包含对接口方法的调用。(invoke-interface指令)

此外,Optimizing Compiler的方法内联可以跨多级方法调用进行,若有这样的调用链:method1->method2->method3->method4,则在四个方法都满足内联条件的情况下,最终内联的结果将是method1包含method2,method3,method4的代码,method2包含method3,method4的代码,以此类推。但这种跨调用链内联会受到调用dex2oat时通过--inline-depth-limit参数指定的值的限制,默认为5,即超过5层的调用就不会再被内联到当前方法了。

0x01 主流热修复方案

目前主流热修复方案可分为Native派和Java派,Native派的做法大致有以下两种:

用新方法的Native描述结构体覆盖旧方法的Native描述结构体,从而替换旧方法的逻辑。

将旧方法修改为Native类型,并将其实现指向一个公共分发函数,由该函数负责调用新方法

Java派的做法也有两种:

将修改过的类汇集成一个Dex,插入到BaseDexClassLoader的DexPathList的最前面,这样在加载类时就会优先加载修改过的类

对每个方法插一段逻辑,此逻辑判断方法是否被打补丁,是则执行新逻辑

显然,对Native派而言,如果修改的方法被内联到了调用者的代码里,则修改将不会生效,因为内联的代码不再需要方法调用,也就不会涉及到额外的Native层方法描述结构体。对于Java派而言,插入一段逻辑的做法基本不受影响,因为内联时会将被修改的方法连同插入的那段逻辑一起复制到调用者的代码里,结果和内联之前是等价的。但通过优先加载补丁Dex里的类来取代旧类的做法会受到怎样的影响呢?这就需要进一步分析了。

0x02 方法内联对Tinker的影响

Tinker通过让VM优先加载补丁Dex里的类来使补丁生效,即Java派里的第一种方案。在没有考虑到内联的影响之前,这套方案有些兼容性问题已得到解决,但在最近的灰度过程中,我们又发现了这样的Crash:

<center>诡异的Crash, 前面有P的行表示补丁Dex里包含该类。</center>

h.getExternalStorageDirectory方法的字节码,报错的为iget-object,因为compatible.d.p.cdl为空

其中compatible.d.j的实例在类compatible.d.p里是定义时赋值给cdl字段的,按理说不该为Null,看着好像又是地址错乱的问题了,于是按照之前的经验,dump了栈顶附近几个方法的机器码,发现f.sC方法内联了h.getExternalStorageDirectory方法,不过现在还不能肯定是不是内联带来的问题,于是跟着f.sC方法的机器码走一段:

<center>f.sC方法的机器码</center>

图里(12288+1292)/4 = 3395,对应compatible.util.h的typeid;(12288+1120) / 4 = 3352,对应compatible.d.p的typeid。图里提到的DexCache是一个缓存数组,用来存放resolve过的类的地址。由机器码的这一行

<center>0x036e3c80: 6985 ldr r5, [r0, #24]</center>

可知,用来检查compatible.d.p是否resolve过的DexCache是从compatible.util.h的Native结构里的某个字段获取的,这样我们只要知道compatible.util.h的DexCache是OldDex的还是NewDex的就可以了。于是回到上面判断compatible.util.h那部分,我们跟着机器码跳到0x036e40ea的位置:

这个pInitializeStaticStorage实际上就是artInitializeStaticStorageFromCode方法,看art/runtime/arch/arm/quick_entrypoints_arm.S就知道了。从artInitializeStaticStorageFromCode方法开始又依次调用了ResolveVerifyAndClinit -> ClassLinker::ResolveType,经过几个不同原型的ResolveType之后最终调到了ClassLinker::FindClass,从FindClass开始就是经典的ART加载类的流程了,大家可以参考老罗的这篇文章:Android运行时ART加载类和方法的过程分析。根据这个流程,最后被加载的compatible.util.h来自NewDex,所以保存在它的Native描述结构体里的DexCache就是NewDex的了。

搞清楚这个问题之后,我们再来看为何会报NPE。回到f.sC方法的机器码,我们看第二部分,在从r0+24这个位置拿到DexCache之后,接着就会判断compatible.d.p是否被resolve,然后问题就来了,既然这个DexCache是NewDex的,那机器码里用3352这个OldDex里的typeid来访问究竟靠谱吗?和之前那个地址错乱的问题类似,这里会遇到三种情况:

情况

后果

oldDex里的typeid恰好就是newDex里的typeid

没事,补丁正常生效

oldDex里的typeid >= newDex的DexCache的长度

被SIGABORT干掉,Abort Message为数组下标越界

oldDex里的typeid在newDex的DexCache里指向了其他类

无法预料,可能崩溃,可能导致程序流程异常

如果你的补丁变更规模很小,一般会大概率命中第二种情况;如果你的补丁变更规模很大,则会有很大概率命中第三种情况。微信这次灰度报的crash对应的就是第三种情况了,所以最后通过offset去拿成员的值的时候会拿到0,于是就报了NPE。

0x03 可能的应对方案

参考0x01里的内联条件,如果阻止方法内联,就可以避免出现机器码里用旧typeid去新DexCache里查找类的情况了。对我们来说比较方便的条件就是在每个方法前面插入一个空try块,这样这些方法就不会参与内联了。不过考虑到ART的内联触发条件随时都在更新,保险起见Tinker并没有这样做。

另外一个思路是把修改类的整个调用链(调用修改类的类,与调用调用修改类的类的类,一直递归下去)都放到补丁中,需要包括所有可能被影响的类。这套规则的主要问题在于整个完整调用链的类会非常庞大,很有可能与全量差别不大,其次不排除Android未来有新的优化导致这样的方式会失效。

Tinker最终采用的应对方案是去掉ART环境下的合成增量Dex的逻辑,直接合成全量的NewDex,这样除了loader类,所有方法统一都用了NewDex里的,也就不怕有方法被内联了。至于全量新Dex在系统OTA之后触发dex2oat可能导致App启动时ANR的问题,Tinker是通过在进入ApplicationLike之前判断fingerprint是否变化来得知系统是否进行过OTA,然后根据判断结果手动触发多线程dex2oat加以缓解的。

0x04 总结

方法内联之所以会导致优先加载补丁Dex的方案出现上述问题,本质上是因为补丁Dex只覆盖了旧Dex里的一部分类,一旦被覆盖的类的方法被内联到了调用者里,则加载类的过程还是正常的,即从补丁Dex里加载了新版本的类。但由于内联,执行流程并未跳转到新的方法里,于是所有关于新版本的类的方法、成员、字符串的查找用的就都是旧方法里的索引了。这里“用旧索引找新目标”的场景又出现了,App自然可能出现异常。

ART 6.0及之后的Optimizing Compiler通过相对激进的内联策略,进一步提升了App的运行效率,但这也为各类热修复方案带来了一些麻烦。通过分析,理论上我们确实可以通过破坏内联条件强行让所有的方法都不参与内联,但这样一来App的运行时性能就会受到较大影响,而且今后如果内联标准更新了,我们还需要持续跟进,这也是不太现实的。因此通过这次灰度发现的问题,我们最终还是用了相对保守的方案来解决。

最后,通过分析这个问题,我们也更加深刻地认识到了ART的复杂性。时间仓促,关于方法内联的策略及其带来的问题还有许多细节并没有在这篇文章里提及,如果有补充的知识或者更好的应对方案,欢迎一同交流学习。

本文来源于:WeMobileDev 微信公众号

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏java学习

java每日一练(2017/8/20)

最新通知 ●回复"每日一练"获取以前的题目! ●【新】Android视频更新了!(回复【安卓视频】获取下载链接) ●【新】Ajax知识点视频更新了!(回复【学习...

2506
来自专栏Urahara Blog

Web For Pentester - Code injection & Commands injection Part Tips

1122
来自专栏技术小黑屋

你的Java代码对JIT编译友好么?

本文为 InfoQ 中文站特供稿件,首发地址为:你的Java代码对JIT编译友好么?。如需转载,请与 InfoQ 中文站联系。

642
来自专栏安恒网络空间安全讲武堂

记一次JS木马分析

记一次JS木马分析 0x00 前言 随着 javascript 这类脚本语言的快速发展,它能展现的内容越来越多样,所运用的功能变得越来越强大。当运用在 web ...

2008
来自专栏向治洪

Android热修复技术总结

插件化和热修复技术是Android开发中比较高级的知识点,是中级开发人员通向高级开发中必须掌握的技能,插件化的知识可以查我我之前的介绍:Android插件化。本...

2037
来自专栏风中追风

java类的加载过程和类加载器的分析

我们知道,我们写的java代码保存的格式是 .java, java文件被编译后会转换为字节码,字节码可以在任何平台通过java虚拟机来运行,这也是java能够跨...

5238
来自专栏Golang语言社区

GoStub框架使用指南

序言 要写出好的测试代码,必须精通相关的测试框架。对于Golang的程序员来说,至少需要掌握下面四个测试框架: GoConvey GoStub GoMock M...

4017
来自专栏玄魂工作室

CTF实战28 Windows缓冲区溢出

该培训中提及的技术只适用于合法CTF比赛和有合法授权的渗透测试,请勿用于其他非法用途,如用作其他非法用途与本文作者无关

681
来自专栏技术之路

[原创翻译]Protocol Buffer Basics: C#

Protocol Buffer 基础知识:c#    原文地址:https://developers.google.com/protocol-buffers/d...

1919
来自专栏企鹅号快讯

Bruce.Wang-记一次对JS木马分析

0×00 前言 随着 javascript 这类脚本语言的快速发展,它能展现的内容越来越多样,所运用的功能变得越来越强大。当运用在 web 开发上时,增加了更多...

2006

扫码关注云+社区