1. 序言
android在5.0开始正式用art虚拟机取代了dalvik虚拟机,不同版本的art虚拟机差别很大,android N开始又引入了混合编译模式。在这里我们只针对android N之前的art版本进行分析,至于art和dalvik的区别,这里就不多说了,最大的区别是art在安装时存在aot过程,用于生成oat文件,这个oat文件既包含转化前的dex文件,又包含机器指令,所以我们的应用在运行的时候可以免去[解释]这一层而直接加载机器指令。最后说一点,art是可以通过开启解释模式进行解释执行代码的,此外,有一些情况,比如动态生成的方法等也是需要解释执行的。文末会有参考文章的链接,建议大家对我略过的内容不太清楚时,可以去参考文章中学习,毕竟侧重点是不同的。
2. 问题的引入
下面开始说重点。
其实对于classloader热修复方案的地址错乱问题早有耳闻,最早是在腾讯的一篇文章Android_N混合编译与对热补丁影响解析看到的这个说法,但是作者后续没有进行进一步的解释。后来看了看其他文章基本上是这样解释的。假设app中有一个Test类:
public class Test {
public String showTest1(){
return "art address error";
}
public String showText(){
return "I am an showText";
}
}
我们在MainActivity中的OnCreate方法中去调用new Test().showText()
public class MainActivity extends Activity {
@Override
protected void onCreate( Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final TextView textView=(TextView) findViewById(R.id.mytv);
textView.setText(new Test().showText());
}
我们反编译生成的oat文件,说明一下,我们接下来的分析过程都以android5.1的源码进行,后面会进行汇编代码的分析,x86指令集比ARM指令集阅读起来复杂的多,如果大家想分析oat文件,最好还是进行ARM的手机进行。android5.x默认情况下生成的oat文件是放在/data/dalvik-cache 目录下,如果是ARM指令集的话,是会在/data/dalvik-cache/arm下的,名字为
data@app@[package name]-1@base.apk@classes.dex(可能名字上略有差异)。使用oatdump我们可以得到反编译后的oat文件dump.txt。
oatdump --file-name=data@app@[package name]-1@base.apk@classes.dex --output=dump.txt
我们看一下结果。
至于这段汇编的含义先不用管,后面会介绍。我们只需要关注标红的字眼,method@21,可以理解为showText()方法在dex中的位置。#96是该方法在ArtMethod中的偏移位置。
接下来我们给Test类添加一个showTest2()方法。
public class Test {
public String showTest1(){
Log.i("ljj", "showTest1: ");
Log.i("ljj", "showTest2: ");
Log.i("ljj", "showTest3: ");
return "art address error";
}
public String showTest2(){
return "art address error";
}
public String showText(){
return "I am an showText";
}
}
再次查看oat文件。
发现同样调用的showText(),方法id已经变成了method@22,偏移也变成了#100.那问题就来了。我们简单分析下:
假设Test类需要修复,我们打补丁时在Test类中增加了一个showTest2方法,导致showText的方法的偏移由#96变成了#100,而此时#96指向的是showTest2方法,那么当我们调用到showtext方法时会调用到showTest2方法,从而导致了地址错乱,这是网上大家关于地址错乱的解释,不知道读到这里大家有没有发现问题?
我们仔细来梳理一下,看看有没有问题。首先当我们安装app时,会生成一个oat文件,我们称为host.oat。这个oat文件中有一个Test类,是待修复的,在这个oat文件中,showText()的方法是method@21,调用处的偏移是#96。接着我们下发补丁,通过DexClassLoader加载的patch.dex,在动态加载的过程中,patch.dex也会生成一个oat文件,这里我们为了区分,称为patch.oat。在patch.oat中方法的编号显然是和host.oat没有一点关系的。而我们加载Test类时,毫无疑问加载到的是patch.oat中的Test类,否则热修复就不会成功了,那么怎么可能存在#100的问题,这种说法明显是在同一个dex中进行调用考虑的,和热修复(不考虑全量合成)是不符合的,因为热修复生效的时候,运行起来是跨oat或者说是跨dex的。如果真是按照机器码执行时写入的地址进行跨dex调用,感觉很容易跑飞啊,即使我不插入showTest2方法,两个oat文件的类和方法的偏移都不是同一个基准的,按说机器码应该都不能找到patch.dex中的showText方法。到底怎么回事呢?
3. 探索答案
我们分以下几个步骤来探索。
- art下调用者是怎么通过机器码找到我们patch中的方法的(art下跨dex方法调用的实现)
- art下像上文中增加方法,真的会地址错乱吗?
我们先来看一下第一个问题,这里说明一下,因为对汇编等底层的知识不太了解,以下的解释可能有些地方表述不到位,甚至可能有错误,如果有问题,希望大家一起探讨学习。
3.1 如何找到的Test类(跨dex查找类)
我们首先看MainActivity的调用处
textView.setText(new Test().showText());
0x000020f8: f8d9e124 ldr.w lr, [r9, #292] ; pAllocObject
0x000020fc: 1c29 mov r1, r5
0x000020fe: 2010 movs r0, #16
0x00002100: 47f0 blx lr
suspend point dex PC: 0x0003
GC map objects: v1 (r7), v2 (r8)
0x00002102: 1c06 mov r6, r0
0x00002104: 1c28 mov r0, r5
0x00002106: 68c0 ldr r0, [r0, #12]
0x00002108: 1c31 mov r1, r6
0x0000210a: 6d80 ldr r0, [r0, #88]
0x0000210c: f8d0e02c ldr.w lr, [r0, #44]
0x00002110: 47f0 blx lr
suspend point dex PC: 0x0005
GC map objects: v0 (r6), v1 (r7), v2 (r8)
0x00002112: 1c28 mov r0, r5
0x00002114: 68c0 ldr r0, [r0, #12]
0x00002116: 1c31 mov r1, r6
0x00002118: 6e40 ldr r0, [r0, #100]
0x0000211a: f8d0e02c ldr.w lr, [r0, #44]
0x0000211e: 47f0 blx lr
suspend point dex PC: 0x0008
省略了无关的机器码,这段汇编主要是new Test().showText()相关的代码。这里只针对关键的指令进行分析,把握住我们的主线,如果扩展开来,一篇文章肯定是写不下的,大家可以参考文末老罗的相关文章,我也是参照老罗的文章来查看的。
首先看第一行,pAllocObject很明显是在创建对象,在art虚拟机启动进行初始化的过程中,会注册很多的Trampoline,称为跳转表,pAllocObject就是其中的一个,Trampoline指向的是一段汇编代码,在汇编中会去执行相关的方法调用。
代码位置:art/runtime/thread.cc
void InitEntryPoints(InterpreterEntryPoints* ipoints, JniEntryPoints* jpoints,
PortableEntryPoints* ppoints, QuickEntryPoints* qpoints) {
// Interpreter
ipoints->pInterpreterToInterpreterBridge = artInterpreterToInterpreterBridge;
ipoints->pInterpreterToCompiledCodeBridge = artInterpreterToCompiledCodeBridge;
// JNI
jpoints->pDlsymLookup = art_jni_dlsym_lookup_stub;
// Portable
ppoints->pPortableResolutionTrampoline = art_portable_resolution_trampoline;
ppoints->pPortableToInterpreterBridge = art_portable_to_interpreter_bridge;
// Alloc
qpoints->pAllocArray = art_quick_alloc_array;
qpoints->pAllocArrayWithAccessCheck = art_quick_alloc_array_with_access_check;
qpoints->pAllocObject = art_quick_alloc_object;
qpoints->pAllocObjectWithAccessCheck = art_quick_alloc_object_with_access_check;
qpoints->pCheckAndAllocArray = art_quick_check_and_alloc_array;
qpoints->pCheckAndAllocArrayWithAccessCheck = art_quick_check_and_alloc_array_with_access_check;
....
};
位置:art/runtime/arch/arm/quick_entrypoints_arm.S
/*
* Called by managed code to allocate an object
*/
.extern artAllocObjectFromCode
ENTRY art_quick_alloc_object
SETUP_REF_ONLY_CALLEE_SAVE_FRAME @ save callee saves in case of GC
mov r2, r9 @ pass Thread::Current
mov r3, sp @ pass SP
bl artAllocObjectFromCode @ (uint32_t type_idx, Method* method, Thread*, SP)
RESTORE_REF_ONLY_CALLEE_SAVE_FRAME
RETURN_IF_RESULT_IS_NON_ZERO
DELIVER_PENDING_EXCEPTION
END art_quick_alloc_object
接着会调用artAllocObjectFromCode方法来创建对象。这里需要知道,每个dex都会生成一个DexCache,会缓存对应dex中加载解析过的所有类和方法,每个类会被分配一个typeId,每个方法会被分配一个methodId。这个方法首先会根据typeId在调用者的DexCache中查看该类是否加载过,如果没加载过,则会调用ClassLinker的ResolveType方法进行类的查找解析。
代码位置:art/runtime/class_linker.cc
mirror::Class* ClassLinker::ResolveType(const DexFile& dex_file, uint16_t type_idx,
mirror::Class* referrer) {
StackHandleScope<2> hs(Thread::Current());
Handle<mirror::DexCache> dex_cache(hs.NewHandle(referrer->GetDexCache()));
Handle<mirror::ClassLoader> class_loader(hs.NewHandle(referrer->GetClassLoader()));
return ResolveType(dex_file, type_idx, dex_cache, class_loader);
}
mirror::Class* ClassLinker::ResolveType(const DexFile& dex_file, uint16_t type_idx,
Handle<mirror::DexCache> dex_cache,
Handle<mirror::ClassLoader> class_loader) {
DCHECK(dex_cache.Get() != nullptr);
mirror::Class* resolved = dex_cache->GetResolvedType(type_idx);
if (resolved == nullptr) {
Thread* self = Thread::Current();
const char* descriptor = dex_file.StringByTypeIdx(type_idx);
resolved = FindClass(self, descriptor, class_loader);
if (resolved != nullptr) {
dex_cache->SetResolvedType(type_idx, resolved);
} else {
CHECK(self->IsExceptionPending())
<< "Expected pending exception for failed resolution of: " << descriptor;
// Convert a ClassNotFoundException to a NoClassDefFoundError.
StackHandleScope<1> hs(self);
Handle<mirror::Throwable> cause(hs.NewHandle(self->GetException(nullptr)));
if (cause->InstanceOf(GetClassRoot(kJavaLangClassNotFoundException))) {
DCHECK(resolved == nullptr); // No Handle needed to preserve resolved.
self->ClearException();
ThrowNoClassDefFoundError("Failed resolution of: %s", descriptor);
self->GetException(nullptr)->SetCause(cause.Get());
}
}
}
DCHECK((resolved == nullptr) || resolved->IsResolved() || resolved->IsErroneous())
<< PrettyDescriptor(resolved) << " " << resolved->GetStatus();
return resolved;
}
我们针对这段代码进行详细分析,首先dex_cache是通过referrer->GetDexCache()拿到的,这个referrer是caller,也就是调用者所在的类,在我们的例子中就是指的MainActivity,所以这个dex_cache我们可以认为是主dex的cache。dex_file相当于是主dex。在主dex的cache中根据type_id去查找,因为是首次加载,所以
mirror::Class* resolved = dex_cache->GetResolvedType(type_idx);
返回nullptr。接着会从主dex中根据type_id拿到类的名字,继续调用FindClass去class_loader中查找类,这块就涉及到类的加载机制了,我们就不多说了,会优先找到我们patch.dex中的Test类。类加载及方法调用的调用链为:
FindClass->FindInClassPath->DefineClass->LoadClass->LoadClassMembers->LoadMethod->LinkCode
加载的每个类对应一个oatClass,class的每个field会用一个ArtField表示,每个method都会对应一个ArtMethod对象。loadMethod方法会对创建的ArtMethod进行赋值。这里我们只需要知道ArtMethod的dex_cache_resolved_methods_数组指向的是所在class对应的DexCache中被resolved了的方法。在这里也就是在patch.dex中被resolved的方法,和主dexCache没任何关系。
LinkCode过程会对ArtMethod的执行入口进行设置,是compiled_code方式还是interpreter解释执行,不想扯的太远。回到ResolveType方法中,注意这一句很关键,当我们通过FindClass方法找到了对应的class后,此时的dex_cache是主dex的,也就是从patch.dex中拿到了class后,同时填充到了主dex的dexcache中对应的位置上了。
dex_cache->SetResolvedType(type_idx, resolved);
好了至此我们分析完了pAllocObject的过程,完成了Test类的加载解析。到此我们了解了,这段汇编执行过后是通过类的签名在patch.dex中拿到的Test类,同时缓存到了自己的dexCache中。
0x000020f8: f8d9e124 ldr.w lr, [r9, #292] ; pAllocObject
0x000020fc: 1c29 mov r1, r5
0x000020fe: 2010 movs r0, #16
0x00002100: 47f0 blx lr
3.2 如何找到的showText方法(跨dex查找类方法)
接下来更关键,我们已经找到了Test类,我们如何查找showText方法呢?
0x0003: new-instance v0, com.ljj.fixtest.Test // type@16
0x0005: invoke-direct {v0}, void com.ljj.fixtest.Test.<init>() // method@19
0x0008: invoke-virtual {v0}, java.lang.String com.ljj.fixtest.Test.showText() // method@22
0x000020f8: f8d9e124 ldr.w lr, [r9, #292] ; pAllocObject
0x000020fc: 1c29 mov r1, r5
0x000020fe: 2010 movs r0, #16
0x00002100: 47f0 blx lr
suspend point dex PC: 0x0003
GC map objects: v1 (r7), v2 (r8)
0x00002102: 1c06 mov r6, r0
0x00002104: 1c28 mov r0, r5
0x00002106: 68c0 ldr r0, [r0, #12]
0x00002108: 1c31 mov r1, r6
0x0000210a: 6d80 ldr r0, [r0, #88]
0x0000210c: f8d0e02c ldr.w lr, [r0, #44]
0x00002110: 47f0 blx lr
suspend point dex PC: 0x0005
GC map objects: v0 (r6), v1 (r7), v2 (r8)
0x00002112: 1c28 mov r0, r5
0x00002114: 68c0 ldr r0, [r0, #12]
0x00002116: 1c31 mov r1, r6
0x00002118: 6e40 ldr r0, [r0, #100]
0x0000211a: f8d0e02c ldr.w lr, [r0, #44]
0x0000211e: 47f0 blx lr
suspend point dex PC: 0x0008
我们直接来分析最后一段汇编代码,也就是对应的showText调用部分。r
0x00002112: 1c28 mov r0, r5
5寄存器一开始是由r0寄存器赋值过来的,而调用时r0指向的就是调用者的ArtMethod地址,也就是MainActivity 的onCreate方法对应的ArtMethod。所以下面代码就是将r0指向调用者的ArtMethod。
0x00002114: 68c0 ldr r0, [r0, #12]
这个#12是什么意思呢?在/art/runtime/asm_support.h中定义了ArtMethod的相关结构地址跳转的常量。不同版本这个值是不一样的,android 5.1对应的是12,6.0对应的4。那么这条指令的意思就是将r0指向ArtMethod的dex_cache_resolved_methods_位置。
#define METHOD_DEX_CACHE_METHODS_OFFSET 12
r6寄存器的值是由 movs r0, #16赋值给r0寄存器,然后由r0赋值给r6的,Test类的type是16,这行的意思就是将this参数赋值给r1。
0x00002116: 1c31 mov r1, r6
这行不太好分析。首先要知道此时r0指向的artMethod的dex_cache_resolved_methods_,那么#100是什么呢,通过不断的观察,发现每个方法调用都是从dex_cache_resolved_methods_的第三个位置开始计算的,阿里的深入探索android热修复技术中写到查找都是从数组的0x2开始的,这点我比较疑惑,我编译出来的机器码都是从0x3开始的,可能不同的版本不一样吧,我在源码中也没有找到相关的定义。showText的methodId是22,这里的oat文件是在32位的机器上编译的,每个指针占4个字节,(22+3)*4=100。所以#100指向的就是showText所对应的ArtMethod。
0x00002118: 6e40 ldr r0, [r0, #100]
44是什么意思呢?这个同样是是在/art/runtime/asm_support.h中定义的。对应于ArtMethod的entry_point_from_quick_compiled_code_字段。
#define METHOD_QUICK_CODE_OFFSET_32 44
这行代码的意思就是找到showText的机器码执行入口。准备执行。
0x0000211a: f8d0e02c ldr.w lr, [r0, #44]
好了,到此我们分析完了这段汇编代码的含义,可能有人会有疑问,#100上放的是showText对应的ArtMethod的吗?什么时候放上去的呢?
这里大致说一下Art下方法调用的过程。
- 当一个dex的Dex_cache被初始化的时候,resolved_methods数组里面的ArtMethod都是指向同一个名为Resolution Method,这个ArtMethod的特点是index为kDexNoIndex,表明它不代表任何的类方法。
- 启动OAT文件的OAT头部包含有一个quick_resolution_trampoline_offset_字段。这个quick_resolution_trampoline_offset_字段指向一小段Trampoline代码。这一小段Trampoline代码的作用是找到当前线程类型为Quick的函数跳转表中的pQuickResolutionTrampoline项,并且跳到这个pQuickResolutionTrampoline项指向的函数art_quick_resolution_trampoline去执行。
- 当方法首次加载时,如果判断出来方法的index是kDexNoIndex,则表明是一个运行时方法,就会执行蹦床函数执行去查找真正的方法,找到后会填充到DexCache中对应的ArtMethod中。下次运行时就可以直接从DexCache中找到该方法了。
蹦床函数art_quick_resolution_trampoline会调用到artQuickResolutionTrampoline,此时如果发现是Runtime方法,会触发classLinker的resolveMethod方法去查找。ok,终于绕出来了,我们去看看resolveMethod的实现:
mirror::ArtMethod* ClassLinker::ResolveMethod(const DexFile& dex_file, uint32_t method_idx,
Handle<mirror::DexCache> dex_cache,
Handle<mirror::ClassLoader> class_loader,
Handle<mirror::ArtMethod> referrer,
InvokeType type) {
//1. 第一步
mirror::ArtMethod* resolved = dex_cache->GetResolvedMethod(method_idx);
if (resolved != nullptr && !resolved->IsRuntimeMethod()) {
return resolved;
}
const DexFile::MethodId& method_id = dex_file.GetMethodId(method_idx);
//2. 第二步
mirror::Class* klass = ResolveType(dex_file, method_id.class_idx_, dex_cache, class_loader);
if (klass == nullptr) {
DCHECK(Thread::Current()->IsExceptionPending());
return nullptr;
}
switch (type) {
case kDirect: // Fall-through.
case kStatic:
// 第三步
resolved = klass->FindDirectMethod(dex_cache.Get(), method_idx);
break;
case kInterface:
resolved = klass->FindInterfaceMethod(dex_cache.Get(), method_idx);
DCHECK(resolved == nullptr || resolved->GetDeclaringClass()->IsInterface());
break;
case kSuper: // Fall-through.
case kVirtual:
resolved = klass->FindVirtualMethod(dex_cache.Get(), method_idx);
break;
default:
LOG(FATAL) << "Unreachable - invocation type: " << type;
}
if (resolved == nullptr) {
// Search by name, which works across dex files.
//第四步
const char* name = dex_file.StringDataByIdx(method_id.name_idx_);
const Signature signature = dex_file.GetMethodSignature(method_id);
switch (type) {
case kDirect: // Fall-through.
case kStatic:
resolved = klass->FindDirectMethod(name, signature);
break;
case kInterface:
resolved = klass->FindInterfaceMethod(name, signature);
DCHECK(resolved == nullptr || resolved->GetDeclaringClass()->IsInterface());
break;
case kSuper: // Fall-through.
case kVirtual:
resolved = klass->FindVirtualMethod(name, signature);
break;
}
}
....
}
}
按照代码中步骤标示来解释:
- 第一步:注意此时dex_cache是主dex,先去dex_cache中去查找,很显然,查找不到,因为resolveType时将方法保存在了patch.dex的dex_cache中了,主dex_cache是找不到的。
- 第二步:会根据method_id.class_idx去主dex_cache中查找class,很明显是可以找到的,还记得上面加载类时在resolveType时将class也放到了主dex中了。
- 第三步:找到了class后,会在class的directMethods数组中查找,按道理是可以找到的,但是在class.cc中会进行dexCache验证,在/art/runtime/mirror/class.cc的FindDeclaredDirectMethod方法。而此时GetDexCache其实获取的是patch.dex的dexCache,而传入的dex_cache是主dex_cache,所以依然获取不到,进入第四步。
if (GetDexCache() == dex_cache) {
....
}
- 第四步我们也看到了源码中注释,“Search by name, which works across dex files.” 到此,彻底明白了,跨dex是通过name和签名来调用方法的。既然跨dex是通过name和签名来进行查找的,那么在patch.dex中增加一个showTest2()
按道理来讲是不会找错方法的。
4.结论验证
这里先说一下结论:当我们在app中调用patch中的方法,是通过name和签名去查找的,如果patch增加方法改变了类结构,是不会出现地址错乱的。
下面进行验证:在android5.1,android6.0上进行验证,发现果然没问题,随意增加方法,都能打patch成功,然而android7.0却会找错方法,继续研究一下。
dump出android7.0的oat文件查看一下:
由于android7.0引入了混合编译模式,oat文件中默认并不会生成机器码,但是进行了指令优化,invoke-virtual已经被优化成了invoke-virtual-quick指令。后面直接跟上了vtable索引号,两个指令有什么区别呢?具体源码定义在
/art/runtime/interpreter/interpreter_switch_impl.cc中,具体实现在/art/runtime/interpreter/interpreter_common.h中
- 对于invoke-virtual指令
static inline bool DoInvoke(Thread* self, ShadowFrame& shadow_frame, const Instruction* inst,
uint16_t inst_data, JValue* result) {
ArtMethod* const method = FindMethodFromCode<type, do_access_check>(
method_idx, &receiver, &sf_method, self);
}
会进行方法的查找,FindMethodFromCode会进入resolveMethod方法去查找真正的方法。
- invoke-virtual-quick指令
static inline bool DoInvokeVirtualQuick(Thread* self, ShadowFrame& shadow_frame,
const Instruction* inst, uint16_t inst_data,
JValue* result) {
ArtMethod* const method = receiver->GetClass()->GetEmbeddedVTableEntry(vtable_idx);
}
}
可以看到,quick指令是直接在class的vtable中按照index查找,那问题就来了,安装时方法的index是针对class的,即在class的vtable中是写死的,此时我下发patch时增加了showTest2方法,那么很明显showTest2会占用原来的showText的位置,从而出现地址错乱。
至此,我们彻底分析完了所有的问题,可能内容有点多,这里简单的总结下:
结论一:
art(android N之前)下本地机器码跨dex进行方法调用是通过方法的name和签名进行的,在patch中通过增加方法改变类的结构并不会导致地址错乱。需要说明两点:
- 第一,art生成oat文件可以指定多种方式。
- 第二,我只是针对在修复方法前增加方法等改变,注意其他情形没有研究和验证。
- 第三,只分析了主dex跨dex访问补丁dex的情形,至于补丁中访问到主dex情形没有深入研究,但是从patch.dexde的机器码中可以看出是通过pAllocObjectWithAccessCheck等Alloc类蹦床函数来回调的。
结论二:
- android N增加方法后导致地址错乱是因为解释执行时进行指令优化导致的,和本地机器码没什么大的关系。其实在dalvik上应该也存在此问题,只是我们禁止了进行dexopt,所以指令没有优化,从而屏蔽了该问题而已。
PS:我只是利用热修复来进行学习,并没有深入的做热修复的相关工作,只是出于对art虚拟机下地址错乱问题的好奇而进行的,可能有很多地方解释的不到位或者有误,欢迎指正。感觉这篇文章需要点基础,如果直接看本文的话,不一定看得懂,可以参考我之前写的热修复相关的文章结合文末的参考文章来看,参考文章都是比较有价值的,但是针对热修复下art的跨dex调用问题都没有阐述的很清楚,有问题欢迎留言讨论。
参考文章:
1.老罗的Android运行时ART加载OAT文件的过程分析
2.老罗的Android运行时ART加载类和方法的过程分析
3.老罗的Android运行时ART执行类方法的过程分析
4.老罗的ART运行时为新创建对象分配内存的过程分析
5.滴滴Android热修复探索
6.蘑菇街Android热修复探索之路
7.Android中的类加载-查找和在hotpatch上的问题