如何實現函數?;赝颂D -- 結合 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 就可以完成的,因為它跨越了函數,所以需要處理好上下文信息。如何處理好這些上下文信息,就是此文將學習的內容。

?;赝颂D需要解決哪些問題?

“線程的本質是一組寄存器的狀態?!币虼?,在跨函數跳轉時,這“一組寄存器的狀態”是需要重點處理的信息。這里我們補充一下 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,指向函數分配??臻g的棧頂

  • 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 的源碼,講解?;赝颂D時 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 中的寄存器信息,完成一層?;赝?/h3>

__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 大小的??臻g,然后將 x29(fp),x30(lr) 寄存器的值,分別寫入了棧內存(占用 0x10),剩下 0x10 給 C 函數的剩余部分使用。

  • 然后,add x29, sp, #0x10 ; =0x10 這條指令,將 sp + 0x10 的值寫入到 fp。這時 fp 到 sp 的這段內存,就是 C 函數的函數??臻g。在這個例子中,

此時棧內的狀態為下圖所示:

當 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閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容