@qidiandasheng
2022-08-29T09:21:36.000000Z
字数 2766
阅读 644
iOS运行时
如果你之前了解过函数调用时栈空间的栈分配情况,你应该见过类似下面的栈图:
在这里,我们需要明确两块内存空间:一块是“栈空间”,上图中蓝色框中的内存属于这里,fp、sp 之中存储的通常是这个空间的地址;一块是“代码空间”,是用于存放我们所编写的函数的汇编指令的,上图中的 IMP A/B/C 存放在这里。你可以这样理解他们之间的关系:我们写的代码被编译成汇编指令后,被 runtime 加载进“代码空间”并开始执行,汇编执行过程中,所产生的中间变量(函数传参、值类型局部变量等),被存储在“栈空间”。当然,为了实现函数跳转、返回、堆栈捕获等功能,栈空间还额外存储了 fp
值以及 lr
值(注意是值!)。
@implementation AppDelegate
- (void)simple
{
[self count:1];
}
- (int)count:(int)i
{
return i;
}
@end
查看 -[AppDelegate simple]
的汇编:
w2 代表只用 2 号寄存器的 32 位。
bl、blr 指令在跳转的同时,会将 lr 寄存器的值设置为当前 pc+4byte。
从上面的汇编结合一点点思考,我们得出了一些 非常非常重要 的结论:
“不给别人添麻烦”:每个函数都有自己的一块操作空间,我们称其为“栈帧(stack frame
)”。寄存器 fp
、sp
的值是栈帧范围的唯一标识,作为 simple 函数,为了保证自己 return 之后,调用者能继续正常执行,函数体需要自己负责维护 fp/sp 的状态,保证进出时一致。而正是因为大家都遵循这样的规则,在后续代码中,我们才能放心的在调用完 count: 之后,继续使用 fp/sp 寄存器。
“自己的事情自己做”:一个函数有多少参数、多少局部变量,进而需要多大的栈空间,只有函数自己知道,所以函数内部需要自己“挪动” fp、sp 的指向,来“声明”自己所需要的空间。
“一切行动听指挥”:我们知道,lr
寄存器存储着返回地址,当函数内部又有函数调用时,我们不可避免的需要改变 lr
寄存器的值,那么为了保证自己能成功回到上一级,函数自己需要将 lr
地址存好,并在执行结束之前重新设置给 lr
寄存器。
在上面的代码中,我们看到 simple 的汇编代码将参数值 1 放进了 2 号寄存器中,那 count: 如何知道我将参数放在了这里呢?我们再看看 count: 方法的汇编代码:
从 count:
的汇编,我们得出了另外一个重要结论:
函数参数的传递并不是口耳相传,而是依赖强类型约束和汇编规范的硬编码!不但寄存器的顺序有严格规定,实际的读写操作也和数据类型强相关(int 类型,寄存器选择用 w 而非 x,存储到栈空间偏移量也不是 8 的整数倍)。但是寄存器的数量终归是有限的,当函数内部需要调用多参方法的时候,调用者的会扩大其栈帧的大小,将寄存器塞不下的参数按一定顺序放置在栈帧中。而被调用方,同样是通过硬编码 offset 的方式,一个一个的从前一个函数的栈帧中读取出来。
#define call(b, value) \
__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
__asm volatile ("mov x12, %0\n" :: "r"(value)); \
__asm volatile ("ldp x8, x9, [sp], #16\n"); \
__asm volatile (#b " x12\n");
#define save() \
__asm volatile ( \
"stp x8, x9, [sp, #-16]!\n" \
"stp x6, x7, [sp, #-16]!\n" \
"stp x4, x5, [sp, #-16]!\n" \
"stp x2, x3, [sp, #-16]!\n" \
"stp x0, x1, [sp, #-16]!\n");
#define load() \
__asm volatile ( \
"ldp x0, x1, [sp], #16\n" \
"ldp x2, x3, [sp], #16\n" \
"ldp x4, x5, [sp], #16\n" \
"ldp x6, x7, [sp], #16\n" \
"ldp x8, x9, [sp], #16\n" );
__attribute__((__naked__)) static void hook_Objc_msgSend() {
// Save parameters.
save()
__asm volatile("mov x2, lr\n");
__asm volatile("mov x3, x4\n");
// Call our before_objc_msgSend.
call(blr, &before_objc_msgSend)
// Load parameters.
load()
// Call through to the original objc_msgSend.
call(blr, orig_objc_msgSend)
// Save original objc_msgSend return value.
save()
// Call our after_objc_msgSend.
call(blr, &after_objc_msgSend)
// restore lr
__asm volatile("mov lr, x0\n");
// Load original objc_msgSend return value.
load()
// return
ret()
}
hook调用流程:
1. 和传统函数不同,函数起头没有声明属于自己的栈空间:fp、sp 和上一栈帧一致。
2. 把所有寄存器的值存在低址,并下移 sp
,保证后续函数调用不会修改到这部分栈空间。
3. 把 lr 寄存器的值当做参数传递给 before_objc_msgSend
,后者将其存至内存中(放到堆区的结构体里面了)
4. 在调用 orig_objc_msgSend
之前,恢复寄存器状态以及 sp
指向,使栈空间的状态恢复到与第一步一致,此时调用 orig_objc_msgSend
,所有寄存器、栈帧内容均和原始调用方执行跳转时一致。
5. after_objc_msgSend
将原有 lr
的值作为返回值返回,并设置至 lr
寄存器之中,确保能正确返回。
整套方案最巧妙的点在于,“复用”了调用方的整个栈帧,站在栈空间的角度来看,hook 方法对于调用和被调用方都是透明的;而为了达到 100% 还原栈帧,lr 的值便只能传递出去存至堆区了。
在几个关键点,堆栈和寄存器状态如图:
可以看到,在调用原实现时,唯一的不同点就是 lr
寄存器指向了 hook
方法,使原方法执行完后回到 hook
方法中。在汇编指令执行时,位于当前 sp
寄存器下面(低址)的内存属于“无人认领”的内存,可以任意使用。这就是为什么第二步我们需要下移 sp
,而第三步我们也不需要清理低址残留的寄存器值。