引言
开发者对语言层面的异常应该都不会陌生。在 iOS 平台,许多崩溃都源自 uncaught exception。Exception handling 的流程和细节较多,此文将结合 libunwind 源码重点描述其中的一个话题:如何实现函数栈回退。其原理既可以应用在 exception handling 流程,又可以给其他“黑科技”带来灵感。
以这样的代码片段为例:
- (void)throwFunction2 {
@throw [NSException exceptionWithName:@"Exception" reason:@"" userInfo:nil];
}
- (void)throwFunction1 {
[self throwFunction2];
}
- (void)catchFunction {
@try {
[self throwFunction1];
} @catch (NSException *exception) {
NSLog(@"catch");
} @finally {
}
}
异常抛出以后,函数会从 throw 处跳转到 @catch
这段代码中。如何实现这个退栈跳转,就是此文要学习的部分。
背景知识
Exception Handling 流程
关于 exception handling 的资料较多,此处我们划一下重点:当一个 exception 被抛出的时候,异常处理逻辑会进行两次调用栈遍历的操作:
Search Phase:检查调用栈中是否有匹配的 catch handler
Cleanup Phase:进行栈回退操作,期间可能会跳转到若干个 non-catch handler 做析构,最后跳转到 catch handler(文中的例子会直接跳转到 catch handler)
这些操作都是在同一个线程,即抛出异常的线程中完成的。
这里的栈回退操作,就需要改变当前的 PC 指针,从异常处理的函数中,最终跳转到 catch handler。这个跳转并不是一个简单的 branch 指令修改完 PC 就可以完成的,因为它跨越了函数,所以需要处理好上下文信息。如何处理好这些上下文信息,就是此文将学习的内容。
栈回退跳转需要解决哪些问题?
“线程的本质是一组寄存器的状态。”因此,在跨函数跳转时,这“一组寄存器的状态”是需要重点处理的信息。这里我们补充一下 calling convention 的知识。由于 iOS 的大部分设备使用 arm64 架构,因此此处和下文都以 arm64 架构举例。
x0-x7 寄存器用于参数和返回值的传递
x8 用于简介保存返回值,当返回较大结构体、x0-x7 无法承担的时候,返回值会被写入内存,x8 寄存器会保存返回值的地址
x9-x15 是 caller saved registers,可以被 calle 修改,所以当 caller 希望保留这些寄存器的值时,需要在调用 callee 之前将它们存起来
x16-x17 是 intra-procedure-call corruptible register,它可能在函数被调用后、执行第一行指令前被改变,常常被链接器用于在 caller 和 callee 之间插入代码片段
x18 是平台保留寄存器
x19-x28 是 callee-saved register,也就是说,当一个函数需要使用 x19-x23 时,需要先将它们原来的值保存起来,在函数返回前将 x19-x23 恢复到原来的值。
x29 是 fp,frame pointer,帧指针寄存器
x30 是 lr,linker register,链接寄存器,在进行函数调用时,lr 寄存器会更新为当前指令的下一条指令地址,也就是函数返回后需要继续执行的指令
sp,stack pointer,指向函数分配栈空间的栈顶
pc,program counter,存储 cpu 当前执行的指令地址
d0-d7 浮点寄存器用于参数和返回值的传递
d8-d15 浮点寄存器都是 callee-saved register,需要在函数返回前恢复
d16-d31 浮点寄存器,在 arm64 文档中写的是 callee-saved register,但是……有点存疑
因此,当发生退栈操作时,需要在运行时将这些寄存器的状态设置正确。更具体地说,需要解决两类问题:
- 正确设置四个重要的寄存器 pc、lr、fp、sp
- 如何恢复 x19 - x28、d8 - d15 这几个 callee saved register
libunwind 的做法
根据 Itanium C++ ABI: Exception Handling,异常处理的 ABI 分为两层,其中 Base ABI 是语言无关的,负责 stack unwinding,也就是栈回退操作,C++ ABI 则和 C++ 语言相关。libunwind 是 Base ABI 的实现。可以在 LLVM Project 中找到开源实现并做构建和调试。OC 的异常处理实际和 C++ 一样,从源码中可以发现,objc_exception_throw 函数只是对 C++ 异常处理函数(__cxa_throw
)的封装。
下面我们将结合 libunwind 的源码,讲解栈回退跳转时 libunwind 的处理方式。由于跳转只发生在 phase2 中,因此我们按照 phase 2 的调用顺序来理解。核心的代码都位于 unwind_phase2 中。
-
__unw_getcontext
:抛出异常时,将寄存器状态备份到内存
__unw_getcontext
函数是在 _Unwind_RaiseException
函数中被调用的。也就是异常处理的入口。当业务层 OC 代码执行到 @throw
时,会依次调用 objc_exception_throw
、__cxa_throw
和 _Unwind_RaiseException
。
__unw_getcontext
有一个参数,我们称它为 &context
。__unw_getcontext
的作用,是将当前的寄存器状态保存到 context
这块内存中。
这个函数是汇编实现的,它做的事情,是将当前的 x0-x30,sp,d0-d31 寄存器存入内存中。其中,x30(即 lr) 寄存器被存了两次,第一次它作为 lr 寄存器被存入,第二次则是代替 pc 寄存器被存入。因为当前的 pc 是 __unw_getcontext
函数中某条指令的地址,所以当前的 pc 本身没有意义。
//
// extern int __unw_getcontext(unw_context_t* thread_state)
//
// On entry:
// thread_state pointer is in x0
//
.p2align 2
DEFINE_LIBUNWIND_FUNCTION(__unw_getcontext)
stp x0, x1, [x0, #0x000]
stp x2, x3, [x0, #0x010]
stp x4, x5, [x0, #0x020]
stp x6, x7, [x0, #0x030]
stp x8, x9, [x0, #0x040]
stp x10,x11, [x0, #0x050]
stp x12,x13, [x0, #0x060]
stp x14,x15, [x0, #0x070]
stp x16,x17, [x0, #0x080]
stp x18,x19, [x0, #0x090]
stp x20,x21, [x0, #0x0A0]
stp x22,x23, [x0, #0x0B0]
stp x24,x25, [x0, #0x0C0]
stp x26,x27, [x0, #0x0D0]
stp x28,x29, [x0, #0x0E0]
str x30, [x0, #0x0F0]
mov x1,sp
str x1, [x0, #0x0F8]
str x30, [x0, #0x100] // store return address as pc
// skip cpsr
stp d0, d1, [x0, #0x110]
stp d2, d3, [x0, #0x120]
stp d4, d5, [x0, #0x130]
stp d6, d7, [x0, #0x140]
stp d8, d9, [x0, #0x150]
stp d10,d11, [x0, #0x160]
stp d12,d13, [x0, #0x170]
stp d14,d15, [x0, #0x180]
stp d16,d17, [x0, #0x190]
stp d18,d19, [x0, #0x1A0]
stp d20,d21, [x0, #0x1B0]
stp d22,d23, [x0, #0x1C0]
stp d24,d25, [x0, #0x1D0]
stp d26,d27, [x0, #0x1E0]
stp d28,d29, [x0, #0x1F0]
str d30, [x0, #0x200]
str d31, [x0, #0x208]
mov x0, #0 // return UNW_ESUCCESS
ret
-
__unw_init_local
:读取 unwind_info,获取 callee saved register 信息
__unw_init_local
函数有两个参数,分别是 &context
和 &cursor
,它做的事情是将 context
的信息拷贝一份到 cursor
这块内存里,同时读取 MachO 中 __TEXT, __unwind_info
中的信息,找到当前 PC 对应的 frame info 信息。这些信息用一个 [unw_proc_info_t](https://www.nongnu.org/libunwind/man/unw_get_proc_info(3).html)
结构体表示,包含函数的起始与结束地址、lsda、personality_routine 函数指针(用于寻找异常对应的 landing pad),还有包含了如何恢复 callee saved register 的 compact unwind encoding 信息。
unw_proc_info_t
也会被存到 cursor
中,在后面 __unw_step
做栈回退操作时被使用。
-
__unw_step
:改变备份在 cursor 中的寄存器信息,完成一层栈回退
__unw_step
函数会改变备份在 cursor 中的寄存器信息,完成一层退栈操作。发生退栈操作时,需要解决两类问题:
- 恢复 x19 - x28、d8 - d15 这几个 callee saved register
- 正确设置四个重要的寄存器 pc、lr、fp、sp
这也是 __unw_step
中重点体现的。
3.1 恢复 caller saved register
__unw_init_local
时已经读取了用 [unw_proc_info_t](https://www.nongnu.org/libunwind/man/unw_get_proc_info(3).html)
结构体表示的 frame info 信息,其中包含了如何恢复 callee saved register 的 compact unwind encoding 信息。
通过 unwind_info 中的信息,还原 x19 至 x28,d8 - d15 的 saved register。
3.2 处理四个特殊寄存器
处理特殊寄存器的源码分成了两种情况,分别是:
uint64_t fp = registers.getFP();
// fp points to old fp
registers.setFP(addressSpace.get64(fp));
// old sp is fp less saved fp and lr
registers.setSP(fp + 16);
// pop return address into pc
registers.setIP(addressSpace.get64(fp + 8));
// subtract stack size off of sp
registers.setSP(savedRegisterLoc);
// set pc to be value in lr
registers.setIP(registers.getRegister(UNW_ARM64_LR));
为了理解这两段代码,我们需要首先需要理解函数调用过程中 pc、lr、sp、fp 这几个寄存器的变化。我们用一个例子总结一下:
考虑函数 A 调用函数 B,函数 B 调用函数 C 的场景。
void funcC(void) {
printf("Hello World");
}
void funcB(void) {
funcC();
}
void funcA(void) {
funcB();
}
这几个函数的反汇编代码是:
_funcC:
sub sp, sp, #0x20 ; =0x20
stp x29, x30, [sp, #0x10]
add x29, sp, #0x10 ; =0x10
stur wzr, [x29, #-0x4]
adrp x0, 1
add x0, x0, #0xd83 ; =0xd83
bl 0x100c3d968 ; symbol stub for: printf
ldp x29, x30, [sp, #0x10]
add sp, sp, #0x20 ; =0x20
ret
_funcB:
sub sp, sp, #0x20 ; =0x20
stp x29, x30, [sp, #0x10]
add x29, sp, #0x10 ; =0x10
stur wzr, [x29, #-0x4]
bl 0x100c3c9e4 ; funcC at ViewController.m:425
ldp x29, x30, [sp, #0x10]
add sp, sp, #0x20 ; =0x20
ret
_funcA:
sub sp, sp, #0x20 ; =0x20
stp x29, x30, [sp, #0x10]
add x29, sp, #0x10 ; =0x10
stur wzr, [x29, #-0x4]
bl 0x100c3ca0c ; funcB at ViewController.m:430
ldp x29, x30, [sp, #0x10]
add sp, sp, #0x20 ; =0x20
ret
我们重点看一下 B 调用 C 时做了什么,也就是高亮标注的几条指令:
首先,
**bl**
_funcC
这条指令,会将当前的 lr 寄存器设置为 C 应该返回的地址,即**bl**
_funcC
的下一条指令。同时,pc 寄存器也设置为了 C 函数第一条指令的地址,完成了跳转。然后,
sub sp, sp, #0x20 ; =0x20
stp x29, x30, [sp, #0x10]
这两条指令,首先开辟了 0x20 大小的栈空间,然后将 x29(fp),x30(lr) 寄存器的值,分别写入了栈内存(占用 0x10),剩下 0x10 给 C 函数的剩余部分使用。
- 然后,
add x29, sp, #0x10 ; =0x10
这条指令,将 sp + 0x10 的值写入到 fp。这时 fp 到 sp 的这段内存,就是 C 函数的函数栈空间。在这个例子中,
此时栈内的状态为下图所示:
当 C 函数正常返回时,执行的操作是:
-
**ldp x29, x30, [sp, #0x10]**
将之前存在栈内存中的 x29(fp),x30(lr) 寄存器的值,恢复给 x29(fp),x30(lr) 寄存器 -
add sp, sp, #0x20 ; =0x20
恢复 sp 寄存器,即图中示意的 SP(B) 的位置。
所以,如果要通过修改这四个寄存器状态,在执行 C 的时候,达到“退栈”的目的,也就是回到 B 执行时的状态,需要这么设置:
newPC = LR(C) = LR
newSP = SP(B) = *FP(C)+0x10 = *FP+0x10
newFP = FP(B) = *FP(C) = *FP
newLR = LR(B) = *(FP(B)+0x08) = ((FP(C))+0x08) = (FP+0x08),但是由于 B 函数在返回前会从内存中读出 LR(B) 的值加载到 LR 寄存器中,所以这里不做设置也可以。
所以,这个设置方案和 __unw_step
中的代码也可以对上了:
uint64_t fp = registers.getFP();
// fp points to old fp
registers.setFP(addressSpace.get64(fp));
// old sp is fp less saved fp and lr
registers.setSP(fp + 16);
// pop return address into pc
registers.setIP(addressSpace.get64(fp + 8));
如果 C 函数是一个叶子函数(即 C 不再调用其他函数),那么情况就又有点变化。由于 C 不会调用其他函数,所以 C 的执行过程中,LR 和 FP 寄存器不会再改变了,因此,在 C 函数的开头,它不再需要将 LR 和 FP 寄存器备份在栈内存里。此时 C 的反汇编代码是:
void funcC(void) {
int c = 0;
}
_funcC:
sub sp, sp, #0x10 ; =0x10
str wzr, [sp, #0xc]
add sp, sp, #0x10 ; =0x10
ret
此时的栈内的状态为:
如果在 C 的执行过程中,要“退栈”到 B 的状态,需要这么设置:
newPC = LR(C) = LR
newSP = SP(B) = ?
FP 和 LR 由于在调用 C 时都没有发生变化,因此不需要设置。
这里 newSP 的值看似无法计算,但实际上编译器知道 C 调用期间 SP 需要发生什么样的变化,编译器会把这个信息记录在 unwind_info 中,libunwind 通过 unwind_info 中记录的信息可以算出 newSP 的值。
// subtract stack size off of sp
registers.setSP(savedRegisterLoc);
// set pc to be value in lr
registers.setIP(registers.getRegister(UNW_ARM64_LR));
3.3 更新 PC 的同时更新 frame info 信息
在调用 registers.setIP
更新 cursor 中的 PC 寄存器时,还会触发一个隐藏操作:将 frame info 更新成新 PC 对应的 frame info([unw_proc_info_t](https://www.nongnu.org/libunwind/man/unw_get_proc_info(3).html)
)。确保下一次 __unw_step
或其他操作时的状态正确。
-
设置备份在栈内存上的 PC 寄存器,使其指向目标地址
寻找目标地址的工作,是 __gxx_personality_v0 函数实现的,它属于 exception handling 的 Level 2 API,我们也可以找到源码 。对于 libunwind 来说,这里,它的 _Unwind_SetIP
函数被调用了。这个函数修改了备份在 cursor 中的 PC,使其指向跳转的目标地址,即例子中的 catch handler。
-
__unw_resume
:恢复寄存器,完成跳转
至此,我们已经在 cursor 中准备好了跳转后的寄存器状态。接下来就是将这些暂时存在内存中的值,重新加载到寄存器上。
__unw_resume
函数是跳转的最后一步,它最终调用了一个用汇编写的函数 jumpto
,这个函数不断用 load 指令将暂存在内存中的值重新加载到寄存器上。
//
// extern "C" void __libunwind_Registers_arm64_jumpto(Registers_arm64 *);
//
// On entry:
// thread_state pointer is in x0
//
.p2align 2
DEFINE_LIBUNWIND_FUNCTION(__libunwind_Registers_arm64_jumpto)
// skip restore of x0,x1 for now
ldp x2, x3, [x0, #0x010]
ldp x4, x5, [x0, #0x020]
ldp x6, x7, [x0, #0x030]
ldp x8, x9, [x0, #0x040]
ldp x10,x11, [x0, #0x050]
ldp x12,x13, [x0, #0x060]
ldp x14,x15, [x0, #0x070]
// x16 and x17 were clobbered by the call into the unwinder, so no point in
// restoring them.
ldp x18,x19, [x0, #0x090]
ldp x20,x21, [x0, #0x0A0]
ldp x22,x23, [x0, #0x0B0]
ldp x24,x25, [x0, #0x0C0]
ldp x26,x27, [x0, #0x0D0]
ldp x28,x29, [x0, #0x0E0]
ldr x30, [x0, #0x100] // restore pc into lr
ldp d0, d1, [x0, #0x110]
ldp d2, d3, [x0, #0x120]
ldp d4, d5, [x0, #0x130]
ldp d6, d7, [x0, #0x140]
ldp d8, d9, [x0, #0x150]
ldp d10,d11, [x0, #0x160]
ldp d12,d13, [x0, #0x170]
ldp d14,d15, [x0, #0x180]
ldp d16,d17, [x0, #0x190]
ldp d18,d19, [x0, #0x1A0]
ldp d20,d21, [x0, #0x1B0]
ldp d22,d23, [x0, #0x1C0]
ldp d24,d25, [x0, #0x1D0]
ldp d26,d27, [x0, #0x1E0]
ldp d28,d29, [x0, #0x1F0]
ldr d30, [x0, #0x200]
ldr d31, [x0, #0x208]
// Finally, restore sp. This must be done after the the last read from the
// context struct, because it is allocated on the stack, and an exception
// could clobber the de-allocated portion of the stack after sp has been
// restored.
ldr x16, [x0, #0x0F8]
ldp x0, x1, [x0, #0x000] // restore x0,x1
mov sp,x16 // restore sp
ret x30 // jump to pc
其中有一些特殊处理的地方:
- x16 和 x17 由于是临时寄存器,所以不需要恢复,x16 寄存器还在最后关头被用于恢复 sp 寄存器
- x0 由于
jumpto
函数的参数,所以它需要在最后被恢复。 - 内存中为 PC 寄存器准备的值被赋值给了 lr(x30),这样当
jumpto
返回的时候,就直接跳转到了设计好的 PC 处,即例子中的 catch handler。
当这一行 ret 执行过后,程序就完成了穿越。此刻,它可能正在某个 catch 块中执行业务逻辑(正如例子中的情况),也可能在帮某一栈帧完成析构等清理工作。