本文主要讲述纯Swift代码的热更方案调研
原理分析
先从Swift的方法派发原理进行分析,是否存在hook纯Swift方法的可能性
直接派发
编译时确定方法地址,场景如下:
- 值类型(Struct 和 Enum):值类型的方法默认使用直接派发。
- final 方法和类:被 final 修饰的方法或类无法被重写或继承,因此使用直接派发。
- 全局函数和静态方法:这些方法在编译时就能确定实现,使用直接派发
如下代码:
1 | public class STestViewController: UIViewController { |
调用animal.eat()的时候,访问地址0x109ade990,
因为是直接确定了方法地址,所以理论上不可能运行时修改这个地址改为我们的补丁方法,但是可以采用插桩方案(查看下面的”方案选型“)
函数表派发
运行时通过虚函数表(vtable)动态查找方法地址,场景:类的非 final 实例方法。
简单例子:
1 | class Person { |
对应的汇编代码如下
一层层读取发现r13寄存器是Person对象,传递给rax寄存器
*0x78(%rax): 从 %rax 指向的地址加上偏移量 0x78 的位置读取一个地址,并调用该地址指向的函数。
0x78偏移量指向的是虚表中的walk方法的地址
这里的0x600000255778就是walk方法地址。
由此可以确定,要是能提前修改虚表里面的方法地址,就可以做到方法替换
所以尝试,虚表地址替换方案
消息派发
即objc_msgSend,场景:@objc dynamic 方法、继承自 NSObject 的类、与 Objective-C 交互的代码
业界有很多成熟方案如JSPatch、MongoFix等,这里不展开讨论
方案选型
插桩方案
参考SOT的方案,因为官网已经挂了,所以我们从网上收集到一些他曾经存在的证据和一些截图来分析他的实现方案
文章1:https://zhuanlan.zhihu.com/p/654079130 该文章提到“SOT的工作原理是在编译流程中进行代码注入,为函数增加跳板逻辑代码,这样就能够根据需要跳转到虚拟机中运行补丁代码。”
截图1:从该截图可以看出,SOT会给所有Swift函数插桩,在函数最前面插入判断语句 (从cmp、jz汇编指令可以看出),判断当前方法是否存在于数组中,如果存在就跳转到插桩函数执行,如果不存在就跳转到1007079F3执行原函数,最后调用1007079FB出栈。
中间的call j__sotdlg_475573651764395585应该就是跳转到虚拟机执行补丁方法
所以我们先看插桩实现是否可行,是否真的可以一次性给所有方法(OC、Swift)插桩,先通过LLVM自带的代码覆盖率插桩进行快速验证(LLVM文档)
Other C Flags添加参数-sanitize-coverage=func,trace-pc-guard
Other Swift Flags添加参数-sanitize-coverage=func
和 -sanitize=undefined
找个地方实现插桩方法
1 | void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) { |
然后我们随便写个Demo, 比如写个tableView, 然后再didSelect回调里面打断点,进入汇编页面,看看插桩是否成功
可以看出,插桩成功,程序在调用didSelect之前,会先调用__sanitizer_cov_trace_pc_guard
简单分析汇编代码插入函数位置,可以得到每个“代码块”都有对应的插桩函数
再对Swift插桩进行验证,同样发现了插桩代码__sanitizer_cov_trace_pc_guard
控制台输出
由此可以得出结论,OC与Swift都可以通过插桩方式拿到回调
当然代码覆盖率这个插得太多了,实际实现的时候我们自己写clang插件在头尾插桩就可
于是可以做如下尝试,结合JSPATCH实现原理进行操作
1 | struct Animal { |
处理补丁方法void返回值可以参考以下方法,参考文档https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html
1 | const char* retType = getReturnType(); |
但是这种方案不是真正的热修复方案,反而是作为全埋点方案更为合适。暂时保留方案待定
虚表地址替换
以此为例
1 | class Person { |
首先拿到二进制文件进行Mach-O分析,或者通过nm命令,拿到walk方法的名字
编写代码
1 |
|
成功替换实现
可以使用dlsym或者symdl进行C函数指针生成
Todo: 考虑结合MongoFix动态下发解释能力,实现热修复能力
总结
以上方案均无法过审,只能本地调试使用
- 插桩方案可以结合JSPatch实现原理进行替换
- 虚表地址替换方案可以结合MongoFix能力进行实现
其他
调研一些其他的Swift动态替换方案,为热修方案实现思路
@_dynamicReplacement
Swift5新增的方法交换装饰器, 前提是被替换方法需要被标记为dynamic
1 | class Person: NSObject { |
以上代码可以打印出被替换的代码
本质是使用了虚函数表派发,通过替换表内函数指针实现替换
对应解释如下:
movq (%r13), %rax
:加载对象的类型信息(指向元类型的指针)。movq 0x12c8f(%rip), %rcx
:获取swift_isaMask
(用于掩码操作)。andq (%rcx), %rax
:应用掩码得到真正的元类型地址。callq *0x80(%rax)
:从虚表中偏移0x80处取出函数指针并调用。
值类型的方法替换
Swift里面的值类型,会有一个隐含的super class, 最终都是继承自NSObject,可以通过如下代码打印出来
所以可以通过给这些值类型添加objc运行时方法,有了运行时方法就能进行方法替换
Method_override
参考https://github.com/rentzsch/mach_override的实现方案,具体思路是在原始函数的汇编里面加个jmp指令,jmp指令会跳到指定函数
执行完再跳回来。
示例代码:
1 | class TestClass { |
hook代码
1 | void override_instance_method(void) |
mach_override_ptr 的三个参数:
- 要覆盖函数的指针;
- 去覆盖函数的指针;
- 可以设置为原函数的指针地址,待mach_override_ptr返回成功,就可以调原函数。