热修复原理学习(2)底层替换原理和突破底层差异的方法

原因是这样的,在App启动到一半的时候,所有需要发生变更的分类已经被加载过了,在Android系统中是无法对一个分类进行卸载的。而腾讯系的方案是让Classloader去加载新的类,如果不重启App,原有的类还在虚拟机中,就无法加载新类。因此,只有在下次App重启的时候,在还没有运行到业务逻辑之前抢先加载补丁中的新类,这样在后续访问这个类时,就会解析为新的类。从而达到热修复的目的。

Andfix采用的方法是直接在已经加载的类中native层替换掉原有的方法,是在原有类的基础上进行修改的。来看下 Andfix的具体实现,其核心在于 replaceMethod()

// AndFix/src/com/alipay/euler/andfix/AndFix.java

private static native void replaceMethod(Method dest, Method src);

这是一个native方法, 它的参数是在 Java层通过反射机制得到的 Method, src对应被替换的原有方法,而dest对应的就是新方法,新方法存在于补丁包的新类中,也就是补丁方法。

// AndFix/jni/andfix.cpp

static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,

jobject dest) {

if (isArt) {

art_replaceMethod(env, src, dest);

} else {

dalvik_replaceMethod(env, src, dest);

}

}

这个方法对当前Android版本做了判断,4.4以下版本用的是Dalvik虚拟机,而在 4.4以上则用的是ART虚拟机。

在Art上调用的方法如下:

// AndFix/jni/art/art_method_replace.cpp

extern void attribute ((visibility (“hidden”))) art_replaceMethod(

JNIEnv* env, jobject src, jobject dest) {

if (apilevel > 23) {

replace_7_0(env, src, dest);

} else if (apilevel > 22) {

replace_6_0(env, src, dest);

} else if (apilevel > 21) {

replace_5_1(env, src, dest);

} else if (apilevel > 19) {

replace_5_0(env, src, dest);

}else{

replace_4_4(env, src, dest);

}

}

我们以 Art虚拟机为例,对于不同Android版本的Art虚拟机,底层Java对象的数据结构是不同的,因而会进一步区分出不同的替换函数,这里我们以Android 6.0版本为例子,对应的就是 replace_6_0()

// AndFix/jni/art/art_method_replace_6_0.cpp

void replace_6_0(JNIEnv* env, jobject src, jobject dest) {

art::mirror::ArtMethod* smeth =

(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1

art::mirror::ArtMethod* dmeth =

(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2

// 3

smeth->declaring_class_ = dmeth->declaring_class_;

smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;

smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;

smeth->access_flags_ = dmeth->access_flags_ | 0x0001;

smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;

smeth->dex_method_index_ = dmeth->dex_method_index_;

smeth->method_index_ = dmeth->method_index_;

smeth->ptr_sized_fields_.entry_point_from_interpreter_ =

dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

smeth->ptr_sized_fields_.entry_point_from_jni_ =

dmeth->ptr_sized_fields_.entry_point_from_jni_;

smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =

dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

LOGD(“replace_6_0: %d , %d”,

smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,

dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);

}

注释1、2:通过Method对象得到底层Java函数对应ArtMethod的真实地址。

注释3:把就函数的所有成员变量都替换为新的。(通过ArtMehtod结构体)(注意注意!这里相当于是替换了原结构体中的字段信息)

每一个Java方法在Art虚拟机中都对应着一个 ArtMethod,ArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等。

通过 env->FromReflectedMethod,可以由 Method对象得到这个方法所对应的 ArtMethod的真正其实地址,然后就可以把它强制转化为ArtMethod指针,从而对其包含的所有成员进行修改。

这样全部替换完之后就完成了热修复功能。以后调用方法时就会直接运行到新方法中实现。

1.2 虚拟机调用方法的原理


为什么这样替换完就可以实现热修复呢?这需要从虚拟机调用方法的原理说起。

在Android6.0版本中,Art虚拟机中的 Art虚拟机中ArtMethod的结构如下:

// art/runtime/art_method.h

class ArtMethod FINAL {

protected:

GcRootmirror::Class declaring_class_;

GcRootmirror::PointerArray dex_cache_resolved_methods_;

GcRoot<mirror::ObjectArraymirror::Class> dex_cache_resolved_types_;

uint32_t access_flags_;

uint32_t dex_code_item_offset_;

uint32_t dex_method_index_;

uint32_t method_index_;

struct PACKED(4) PtrSizedFields {

void* entry_point_from_interpreter_; // 1

void* entry_point_from_jni_;

void* entry_point_from_quick_compiled_code_; //2

} ptr_sized_fields_;

}

在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。

我们知道,Java代码在Android中会被编译为 Dex Code。

Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code

  • 解释模式

就是去除Dex Code,逐条解释执行。

如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_,然后跳转执行。

  • AOT模式

就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_中执行。

那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢?

并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用ArtMethod中的其他成员字段。

就以AOT模式为例,虽然Dex Code已经被编译成了机器码。但是机器码并非可以脱离虚拟机而单独运行,以下面这段简单的代码为例。

public class MainActivity extends Activity {

protected void onCreate(Bundle saveInstanceState) {

super.onCreate(saveInstanceState);

}

}

编译为 AOT机器码后,是这样的:

7:void com.rikkatheworld.demo.MainActivity.onCreate(android.os.Bundle) (dex_method_idx)

DEX CODE:

0x0000: 6f20 4600 1000 | invoke-super {v0, v1}, void anroid.App.Activity.onCreate(android.os.Bundle)

0x0003: 0e00 | return-void

CODE: (code_offset=0x006fdbac size_offset=0x006fdba8 size=96)

… …

0x006fdbe0: f94003e0 ldr x0, [sp]

;x0 = MainActivity.onCreate 对应的 ArtMethod指针

0x006fdbe4: b9400400 ldr w0, [x0, #4]

;w0 = [x0 + 4] = dex_cache_resolved_methods_ 字段

0x006fdbe8: f9412000 ldr x0, [w0, #576]

;x0 = [x0 + 576]; dex_cache_resolved_methods_ 数组的第72(=576/8)个元素,即对应 Activity.onCreate的 ArtMethod指针

0x006fdbec: f940181e ldr lr, [x0, #48]

;lr = [x0 + 48] = Activity.onCreate 的ArtMethod成员的执行入口点

;即 entry_point_from_quick_compiled_code_

0x006fdbf0: d63f03c0 blr lr

;调用 Activity.onCreate

这里去掉了一些校验之类的无关代码,可以看到,在调用一个方法时,获取了 ArtMethod中的 dex_cache_resolved_methods文件,这是一个存放 ArtMethod* 的指针数组,通过它就可以访问到这个 Method所在Dex中所有的 Method所对应的 ArtMethod*

Activity.onCreate() 的方法索引是70,由于是64位系统,因此每个指针的大小为8kb,又由于ArtMethod*元素时从这个数组的第0×2 个位置开始存放的,因此偏移量为 (70 + 2) * 8 = 576的位置正是 Activity.onCreate() 方法的 ArtMethod指针。

这只是一个比较简单的例子,而在实际代码中,有许多更为复杂的调用情况,很多情况下还需要调用 dex_code_item_offset_字段。由此可以看出,AOT机器码的执行过程,还是会有对虚拟机以及ArtMethod成员字段的依赖。

因此,当把一个旧方法的所有成员字段都换为新方法的成员字段后,执行时所有的数据就可以保持和新方法的数据一致。这样在所有执行到旧方法的地方,会获取新方法的执行入口、所属类型、方法索引号以及所属dex信息,然后像调用旧方法一样去执行新方法的逻辑。

1.3 兼容性问题的根源


然而,目前市场上几乎所有的native替换方案,比如 Andfix和其他安全界的Hook方案,ArtMethod结构体的结构都是固定的,这回带来巨大的兼容性问题。

从刚才的分析可以看到,虽然Andfix是把底层结构强转为 art::mirror::ArtMethod,但这里的 art::mirror::ArtMethod并非等同于App运行时所在设备虚拟机底层的 art::mirror::ArtMethod,而是 Andfix自己构造的 art::mirror::ArtMethod

由于 Android的源码是开源的,所以各个手机厂商都可以对代码进行改造,而Andfix里ArtMethod的结构是根据公开的Android源码中的结构编写的。如果某个厂商对这个 ArtMethod结构体进行了更改,就和原有的开源代码里的结构不一致,那么在这个修改过ArtMethod结构体的设备上,替换机制就会出现问题。

举个例子,在Andfix替换 declaring_class_ 的地方:

//这里是设备本身的ArtMethod和AndFix的ArtMethod一样的情况下

smeth->declaring_class_ = demth->declaring_class;

由于 declaring_class_是 Andfix里 ArtMethod的第一个成员,因此它和以下这行代码等价:

(uint32_t) (smth + 0) = (uint32_t) (dmeth + 0)

如果某个手机厂商在 ArtMethod结构体的 declaring_class_前面添加了一个字段 additional_,那么 additional_就成为了 ArtMethod的第一个成员,所以 smeth + 0 这个位置在这台设备上实际就变成了 additional_,所以这行代码的真正含义就变成了:

//这里是设备本身的ArtMethod和AndFix的ArtMethod不一样的情况下

smeth->additional_ = dmeth->additional_;

这样就和原有的替换逻辑不一致了。

这也正是Andfix不支持所有机型的原因,很大的可能,是因为这些手机机型修改了底层的虚拟机结构

2.突破底层差异的方法

============================================================================

2.1 突破底层结构差异


知道了 native替换方式兼容性问题的原因,我们是否有办法寻求一种新的方式,不依赖ROM底层方法结构的实现而达到替换效果呢?

我们发现,这样的native层面替换思路,其实就是替换 ArtMethod的所有成员,那么,并不需要构造出ArtMethod具体的各个成员字段,只要把 ArtMethod作为整体进行替换,这样不就可以了吗?

因此 Andfix这一系列烦琐的替换:

smeth->declaring_class_ = dmeth->declaring_class_;

smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;

smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;

可以缩写成:

memcpy(smeth, dmeth, sizeof(ArtMethod));

这正是 Sophix为了解决Andfix的问题,而研发出的新的方案。

刚才提到过,不同的手机厂商都可以对 ArtMethod进行替换,只要像这样吧 ArtMethod整个结构体完整替换,就能够把所有旧方法成员自动对应的换成新方法的成员。

但这其中最关键的地方在于,sizeof(ArtMethod)的计算结果,如果计算结果有偏差,导致部分成员没有被替换,或者替换区域超出了边界,都会导致严重的问题。

对于ROM开发者而言,是在 Art源代码中开发,所以一个简单的 sizeof(ArtMethod)就行了,因为这是在编译器就可以决定的。

但对于上层开发者,App会被下发给各式各样的Android设备,所以需要在运行时动态地获取App运行设备中的底层 ArtMethod大小的,就没有那么简单了。

想要忽略ArtMethod具体结构成员直接获取其size的精确值,还是需要从虚拟机的源码入手,从底层的数据结构以及排列特点探寻答案。

在 Art中,初始化一个类的时候会给这个类的所有方法分配内存空间,我们可以看到这个分配内存空间的地方(Android 8.0代码):

// art/runtime/class_linker.cc

void ClassLinker::LoadClassMembers(Thread* self,

const DexFile& dex_file,

const uint8_t* class_data,

Handlemirror::Class klass) {

{

klass->SetMethodsPtr(

AllocArtMethodArray(self, allocator, it.NumDirectMethods() + it.NumVirtualMethods()),

it.NumDirectMethods(),

it.NumVirtualMethods());

}

类的方法中有 direct方法和 virtual方法。

direct方法包含static方法和所有不可继承的对象方法。而 virtual方法包含了所有可以继承的对象方法

AllocArtMethodArray() 函数用来分配他们的方法所在区域,第三个参数传的是所有的direct方法和virtual的总数:

// class-linker.cc

LengthPrefixedArray* ClassLinker::AllocArtMethodArray(Thread* self,

LinearAlloc* allocator,

size_t length) {

if (length == 0) {

return nullptr;

}

const size_t method_alignment = ArtMethod::Alignment(image_pointer_size_);

const size_t method_size = ArtMethod::Size(image_pointer_size_);

const size_t storage_size =

LengthPrefixedArray::ComputeSize(length, method_size, method_alignment);

void* array_storage = allocator->Alloc(self, storage_size);

auto* ret = new (array_storage) LengthPrefixedArray(length);

CHECK(ret != nullptr);

// 1

for (size_t i = 0; i < length; ++i) {

new(reinterpret_cast<void*>(&ret->At(i, method_size, method_alignment))) ArtMethod;

}

return ret;

}

注释1中,建立所有方法总数和的一个循环。每次循环使用 ret->(i, method_size, method_alignment)创建出一个ArtMethod。

而这个方法的作用就是分配空间,因为i是连续的,所以分配出来的空间也是连续的。

这是只是分配出内存空间,还没有对ArtMethod的各个成员赋值,不过这并不影响观察ArtMethod的空间结构,Artmethod空间结构如下图所示:

在这里插入图片描述

这里给了我们启示,ArtMethod是紧密排列的,所以一个ArtMethod的大小,不就是相邻两个ArtMethod的起始地址的差值吗?

正是如此,我们就从这个排列特点入手,自己构造一个类,以一种巧妙的方式获取到这个差值:

public class NativeStructsModel {

final public static void f1() {}

final public static void f2() {}

}

由于 f1和f2都是static方法,所以都属于 direct ArtMethod Array。由于 NativeStructsModel类中只存这两个方法,因此它们肯定是相邻的。

那么就可以在JNI层取得它们的地址差值:

size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, “f1”, “()V”); // 计算出 f1()的地址

size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, “f2”, “()V”); // 计算出 f2()的地址

size_t methSize = secMid - firMid; //因为f2和f1是相邻的,所以 两者相减就是一个 ArtMethod的大小

memcpy(smeth, dmeth, methSize); //将size代入,完成替换

**值得一提的是,由于忽略了底层ArtMethod的结构差异,对于所有Android版本都不需要区分,而统一以 memcpy()实现即可,代码量大大减少。

即使以后的Android版本不断修改ArtMethod的成员,只要保证ArtMethod数组仍是以线性结构排列,就无须再做适配。**

2.2 访问权限的问题


(1)方法调用时的权限检查

看到这里,你可能会产生疑惑:我们只是替换了ArtMethod的内容,但替换方案的所属类,和原有方法的所属类,是不同的类型,被替换的方法有权限访问这个类的其他private方法吗?

以这段简单的代码为例子:

public class Demo {

Demo() {

func();

}

private void func() {

}

}

假如我们想要替换func()方法,会不会因为它是private的关系,而在构造函数中访问不了。

来看看 Demo()这个构造函数的Dex Code和Native Code,看看它是怎么调用 func()d的

void com.rikkatheworld.demo.Demo.() (dex_method_idx=20628)
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

学习路线+知识梳理

花了很长时间,就为了整理这张详细的知识路线脑图。当然由于时间有限、能力也都有限,毕竟嵌入式全体系实在太庞大了,包括我那做嵌入式的同学,也不可能什么都懂,有些东西可能没覆盖到,不足之处,还希望小伙伴们一起交流补充,一起完善进步。

这次就分享到这里吧,下篇见

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

[外链图片转存中…(img-mQtozQeq-1713683824676)]

[外链图片转存中…(img-o8FjgCjL-1713683824678)]

[外链图片转存中…(img-zeM2Xl9M-1713683824679)]

[外链图片转存中…(img-URAmOgZO-1713683824680)]

[外链图片转存中…(img-iyVQT8LT-1713683824681)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

学习路线+知识梳理

花了很长时间,就为了整理这张详细的知识路线脑图。当然由于时间有限、能力也都有限,毕竟嵌入式全体系实在太庞大了,包括我那做嵌入式的同学,也不可能什么都懂,有些东西可能没覆盖到,不足之处,还希望小伙伴们一起交流补充,一起完善进步。

这次就分享到这里吧,下篇见

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 8
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值