如何实现函数栈回退跳转 -- 结合 exception handling 流程的 libunwind 源码学习

引言

开发者对语言层面的异常应该都不会陌生。在 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 架构举例。

image

根据 Procedure Call Standard

  • 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,但是……有点存疑

因此,当发生退栈操作时,需要在运行时将这些寄存器的状态设置正确。更具体地说,需要解决两类问题:

  1. 正确设置四个重要的寄存器 pc、lr、fp、sp
  2. 如何恢复 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 中。

  1. __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

  1. __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 做栈回退操作时被使用。

  1. __unw_step:改变备份在 cursor 中的寄存器信息,完成一层栈回退

__unw_step 函数会改变备份在 cursor 中的寄存器信息,完成一层退栈操作。发生退栈操作时,需要解决两类问题:

  1. 恢复 x19 - x28、d8 - d15 这几个 callee saved register
  2. 正确设置四个重要的寄存器 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 或其他操作时的状态正确。

  1. 设置备份在栈内存上的 PC 寄存器,使其指向目标地址

寻找目标地址的工作,是 __gxx_personality_v0 函数实现的,它属于 exception handling 的 Level 2 API,我们也可以找到源码 。对于 libunwind 来说,这里,它的 _Unwind_SetIP 函数被调用了。这个函数修改了备份在 cursor 中的 PC,使其指向跳转的目标地址,即例子中的 catch handler。

  1. __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 块中执行业务逻辑(正如例子中的情况),也可能在帮某一栈帧完成析构等清理工作。

参考资料

libunwind 源码

C++ exception handling ABI

AArch64 Instruction Set Architecture

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容