引言
開發者對語言層面的異常應該都不會陌生。在 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 架構舉例。
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,但是……有點存疑
因此,當發生退棧操作時,需要在運行時將這些寄存器的狀態設置正確。更具體地說,需要解決兩類問題:
- 正確設置四個重要的寄存器 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 的源碼,講解?;赝颂D時 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 中的寄存器信息,完成一層?;赝?/h3>
__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 大小的??臻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
或其他操作時的狀態正確。
-
設置備份在棧內存上的 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 塊中執行業務邏輯(正如例子中的情況),也可能在幫某一棧幀完成析構等清理工作。