如何实现函数栈回退跳转 -- 结合 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

推荐阅读更多精彩内容

  • Linux 中,当外设触发中断后,大体处理流程如下: a -- 具体CPU architecture相关的模块会进...
    呼啦啦的爱阅读 242评论 0 0
  • 一,apk以进程的形式运行,进程的创建是由zygote。 参考文章《深入理解Dalvik虚拟机- Android应...
    Kevin_Junbaozi阅读 2,336评论 0 12
  • 1.runtime简介 Runtime分为两个版本,legacy和modern,分别对应Objective-C 1...
    北京_小海阅读 1,207评论 0 4
  • SimplePerf C++ 承接上文,本文主要记录simpleperf C++部分的代码的阅读笔记。 Main ...
    骆驼骑士阅读 206评论 0 0
  • 古器合尺度,法物应矩规。--苏洵 一、什么是函数 可执行程序是为了实现某个功能而由不同机器指令按特定规则进行组合排...
    欧阳大哥2013阅读 5,541评论 8 15
  • 原文链接 https://azeria-labs.com/functions-and-the-stack-part...
    Arnow117阅读 2,108评论 0 9
  • 在高级语言中,函数调用很简单,直接调用并传入相关的参数即可。在汇编语言中除了传参外,还要有当前数据入栈、申请新函数...
    秦砖阅读 4,336评论 1 5
  • 最近在写一些东西需要获取任意线程调用栈,然后看了现有的一些开源框架,写的比较复杂而且对Swift的支持不是很好,所...
    小凉介阅读 4,566评论 4 15
  • 程序的栈空间有什么特点呢?首先会想到的就是,栈空间是往低地址增长的,当调用一个函数时,先开辟栈空间,用来存放当前函...
    傻傻木阅读 1,712评论 1 11
  • 程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续...
    哦呵呵y阅读 2,591评论 0 1
  • 1. 前言 本文将结合u-boot的“board—>machine—>arch—>cpu”框架,介绍u-boot中...
    OpenJetson阅读 920评论 0 3
  • 注意:我们使用的是ARM64框架,所以要使用真机,而不是模拟器,也不能使用命令行工程。 栈 栈:是一种具有特殊的访...
    Jax_YD阅读 150评论 0 0
  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,030评论 1 19
  • 一弹指六十刹那,一刹那九百生灭。 --《仁王经》 组件 计算机是一种数据处理设备,它由CPU和内存以及外部设备组成...
    欧阳大哥2013阅读 19,205评论 15 141
  • 在平时开发和调试中,经常遇到C调用栈和汇编,所以这里来统一的了解下这部分内容,本章需要一定的汇编基础才能更好的理解...
    码农苍耳阅读 2,671评论 1 3
  • ![Flask](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAW...
    极客学院Wiki阅读 5,790评论 0 3
  • 不知不觉易趣客已经在路上走了快一年了,感觉也该让更多朋友认识知道易趣客,所以就谢了这篇简介,已做创业记事。 易趣客...
    Physher阅读 2,418评论 0 2
  • 双胎妊娠有家族遗传倾向,随母系遗传。有研究表明,如果孕妇本人是双胎之一,她生双胎的机率为1/58;若孕妇的父亲或母...
    邺水芙蓉hibiscus阅读 2,813评论 0 2
  • 今天理好了行李,看到快要九点了,就很匆忙的洗头洗澡,(心存一份念想,你总会打给我的🐶)然后把洗头液当成沐浴液了😨,...
    bevil阅读 2,194评论 1 1
  • 那年我们15,像阳光一样温暖的年纪。每天我都会骑自行车上学,路过田野,工厂,医院,村庄,有微风,有阳光,有绿...
    木偶说爱你阅读 1,898评论 0 3