這人啊,上了年紀就是比較懶,繼上一篇寫完后,就一直懶得寫這篇,拖著拖著2021年都快結束了。當我準備動手寫這篇文章時,才發現這里涉及到的知識很多,限于篇幅,我也只能寫出關鍵點,就不敢再稱史上最全拉。
本文是 Crash 系列的第二篇,著重在于如何獲取 crash 時候的堆棧。
這個系列的目錄如下:
- Crash 的監聽
- 堆棧分析
- KSCrash 源碼解析
一 函數調用棧
盡管每次看到計算機底層的文章都有種讓人望而生畏的感覺,但是對于想搞清楚函數調用棧是怎么獲取的,就必須了解這個機制。大學時候《計算機組成原理》的高分大佬可以忽略此章節。
這里為了讓大家對函數調用棧有個大致的印象,先放出一張棧幀(x86架構,下同)的簡單結構圖:
在上圖中,是2個函數的調用,Caller
調用了Callee
,其中綠色和藍色就分別代表了2個棧幀,多個棧幀就組合了我們的函數調用棧。如上圖所示,不管是較早的幀,還是調用者的幀,還是當前幀,它們的結構是完全一樣的,因為每個幀都是基于一個函數,幀伴隨著函數的生命周期一起產生、發展和消亡。
1. 寄存器
不了解計算機底層的同學看到上面的 eip、ebp、esp 那是一個頭大,現在我先做一個簡單的介紹,要真正了解這些寄存器的作用,需要結合合后面的內容。
寄存器是和CPU聯系非常緊密的一小塊內存,經常用于存儲一些正在使用的數據。ARM64 有34個寄存器,包括31個通用寄存器、SP、PC、CPSR。調用約定指定他們其中的一些寄存器有特殊的用途,例如:
- x0-x28:通用寄存器,如果有需要可以當做32bit使用:WO-W30(兼容32位)
- x29(FP):通常用作楨指針fp(frame pointer寄存器),棧幀基址寄存器,指向當前函數棧幀的棧底
- x30(LR):是鏈接寄存器lr(link register)。它保存了當目前函數返回時下一個函數的地址;
- SP:棧指針sp(stack pointer)。在計算機科學內棧是非常重要的術語。寄存器存放了一個指向棧頂的指針。使用 SP/WSP來進行對SP寄存器的訪問。
- PC:是程序計數器pc(program counter)。它存放了當前執行指令的地址。在每個指令執行完成后會自動增加;
- CPSR: 狀態寄存器
可能有人會疑惑,為什么我講的是 fp、sp 等,ebp、esp 是啥含義怎么沒有說呢?這是因為不同的指令集下使用了不同的名字。從開源庫BSBacktraceLogger
的宏定義,我們看到部分對應關系:
#if defined(__arm64__)
#define DETAG_INSTRUCTION_ADDRESS(A) ((A) & ~(3UL))
#define BS_THREAD_STATE_COUNT ARM_THREAD_STATE64_COUNT
#define BS_THREAD_STATE ARM_THREAD_STATE64
#define BS_FRAME_POINTER __fp
#define BS_STACK_POINTER __sp
#define BS_INSTRUCTION_ADDRESS __pc
#elif defined(__arm__)
#define DETAG_INSTRUCTION_ADDRESS(A) ((A) & ~(1UL))
#define BS_THREAD_STATE_COUNT ARM_THREAD_STATE_COUNT
#define BS_THREAD_STATE ARM_THREAD_STATE
#define BS_FRAME_POINTER __r[7]
#define BS_STACK_POINTER __sp
#define BS_INSTRUCTION_ADDRESS __pc
#elif defined(__x86_64__)
#define DETAG_INSTRUCTION_ADDRESS(A) (A)
#define BS_THREAD_STATE_COUNT x86_THREAD_STATE64_COUNT
#define BS_THREAD_STATE x86_THREAD_STATE64
#define BS_FRAME_POINTER __rbp
#define BS_STACK_POINTER __rsp
#define BS_INSTRUCTION_ADDRESS __rip
#elif defined(__i386__)
#define DETAG_INSTRUCTION_ADDRESS(A) (A)
#define BS_THREAD_STATE_COUNT x86_THREAD_STATE32_COUNT
#define BS_THREAD_STATE x86_THREAD_STATE32
#define BS_FRAME_POINTER __ebp
#define BS_STACK_POINTER __esp
#define BS_INSTRUCTION_ADDRESS __eip
#endif
而在 Xcode,我們在__mcontext.h
和_structs.h
也可以看到不同平臺下_STRUCT_MCONTEXT64
的定義(不同架構不同目錄)。
2. 函數調用過程
下圖是一個即將調用callee
函數的堆棧模擬圖,我們將從這個模擬圖中了解函數調用的過程。
-
調用者函數把被調函數所需要的參數按照與被調函數的形參順序相反的順序壓入棧中,即:從右向左依次把被調函數所需要的參數壓入棧;
1.11.2 -
調用者函數使用call指令調用被調函數,并把call指令的下一條指令的地址當成返回地址壓入棧中(這個壓棧操作隱含在call指令中);
2 -
在被調函數中,被調函數會先保存調用者函數的棧底地址(push fp),然后再保存調用者函數的棧頂地址,即:當前被調函數的棧底地址(mov fp,sp);
3 -
為被調函數
callee
開辟空間,也就是將 SP下移 N 個字節(sub $N, sp),N 的大小取決于calle
的代碼。4 -
在被調函數中,從fp的位置處開始存放被調函數中的局部變量(如下圖的 x/y)和臨時變量,并且這些變量的地址按照定義時的順序依次減小,即這些變量的地址是按照棧的延伸方向排列的,先定義的變量先入棧,后定義的變量后入棧。
5
3. 棧幀
我們都知道棧是一種后進先出的數據結構,函數調用棧既指具體實現,也是一個抽象的概念——由“棧幀”組成的棧。從上面的函數調用模擬過程,我們知道一個函數調用棧的大致結構如下:
在每個棧幀中,fp 保存調用者函數的地址,總是指向函數棧棧底;sp總是指向函數棧棧頂。以 fp 地址為基準,向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數的局部變量值。
一般規律,[fp+4]處為被調函數的返回地址,[fp+8]處為傳遞給被調函數的第一個參數(最后一個入棧的參數,此處假設其占用4字節內存)的值,[fp-4]處為被調函數中的第一個局部變量,[fp]處為上一層fp值;由于fp中的地址處總是上一層函數調用時的fp,而在每一層函數調用中,都能通過當時的fp值向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取被調函數的局部變量值。lr總是在上一個棧幀(也就是調用當前棧幀的棧幀)的頂部,而棧幀之間是連續存儲的,所以lr也就是當前棧幀底部的上一個地址,以此類推就可以推出所有函數的調用順序。
4. x86 架構和 arm64 架構的差異
我們前面所講的棧幀結構以及棧幀間的關系是基于 x86 架構,也就是模擬器的場景。但是 app 正常是運行在移動設備上的,也就是 arm 架構上。抽象的結構上看兩者的棧幀結構類似,但是畢竟有些差異會影響我們獲取函數調用棧。(參數傳遞的方式也有不同,但因不是本文重點,暫不談及)
上圖分別是 x86架構和 arm64架構下棧幀架構差異。限于篇幅,因 arm32位的設備已經很少,不做討論。這兩個平臺在函數調用上的比較如下:
- 在 x86 架構下,調用函數會先把下一個指令的地址壓棧,作為返回地址。而 arm64 架構則是先把下一個指令的地址存到 LR。下一個函數會在函數一開始的時候,就將FP、LR 壓棧,然后把 FP 重設。
- 由于在 x86 架構下靠把返回地址壓棧的方式來實現棧幀之間的聯系,所以在 x86 架構下 LR 地址并無使用。而在 arm64 下如果棧頂函數沒有對其他函數的調用,那么編譯器可能會把將 FP/LR的壓棧操作移除。也即是說并不是所有棧幀的結構一致。
- 無論是 x86 還是 arm 架構,fp 指針指向棧底,sp 指針指向棧頂。(部分文章說 arm64 架構下的 fp sp 都是棧頂指針是錯的,可以在 xcode 下斷點驗證)
二 獲取所有線程的函數調用棧
在上一章節,我們花了很長的篇幅講述了函數調用棧的結構。那么在具備這些知識后,我們就可以討論如何獲取當前所有線程的堆棧。
1. Mach/pthead 的關系
讓我們思考一下,我們要分析出崩潰堆棧,需要什么信息?首先是要有辦法獲取所有的線程,其次要能獲取每個線程的堆棧,最后是要能拿到每個棧幀的寄存器的具體內容(fp/sp/lr...)。那么讓我們先看看如何獲取當前的所有線程。
上圖是XNU架構。iOS 是基于 Apple Darwin 內核,由 kernel、XNU 和 Runtime 組成,而 XNU 是 Darwin 的內核,它是“X is not UNIX”的縮寫,是一個混合內核,由 Mach 微內核和 BSD 組成。Mach 內核是輕量級的平臺,只能完成操作系統最基本的職責,比如:進程和線程、虛擬內存管理、任務調度、進程通信和消息傳遞機制。關于 Mach 的詳細信息可以參考官方文檔和翻譯。
簡單的說,Task 就是一個容器對象,虛擬內存空間和其他資源都是通過這個容器對象管理的。一個 Task 包含了多個 Thread,而每一個 BSD 進程(也就是OS X進程)都在底層關聯了一個Mach任務對象。Task 沒有自己的生命周期,只有他包含的 Thread執行指令。當我們說 Task 執行某個任務時,實際上是 Task 下的某個 Thread 執行這個任務。
Task 和 Thread 相關的 API (<mach/mach.h>)
-
獲取當前的 Task
mach_task_self()
-
獲取 Task 下的 Thread 列表
kern_return_t task_threads ( task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt );
2. 實現函數調用棧的回溯
好吧,廢話了那么多,終于到了我們的第一個重頭戲。讓我們看看如何代碼實現函數調用棧的回溯。在這里,為了方便,我會以BSBacktraceLogger
的代碼來做講解。(BSBacktraceLogger
是一個輕量的獲取線程堆棧的庫)
+ (NSString *)bs_backtraceOfAllThread {
thread_act_array_t threads;
mach_msg_type_number_t thread_count = 0;
//獲取當前的 Task
const task_t this_task = mach_task_self();
//獲取 Task 下的所有 pThread
kern_return_t kr = task_threads(this_task, &threads, &thread_count);
if(kr != KERN_SUCCESS) {
return @"Fail to get information of all threads";
}
NSMutableString *resultString = [NSMutableString stringWithFormat:@"Call Backtrace of %u threads:\n", thread_count];
for(int i = 0; i < thread_count; i++) {
[resultString appendString:_bs_backtraceOfThread(threads[i])];
}
return [resultString copy];
}
這段代碼很簡單,就是獲取所有的 thread 而已。
NSString *_bs_backtraceOfThread(thread_t thread) {
uintptr_t backtraceBuffer[50];
int i = 0;
NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:\n", thread];
_STRUCT_MCONTEXT machineContext;
//1.獲取 Thread 執行狀態信息,將之反填到machineContext
if(!bs_fillThreadStateIntoMachineContext(thread, &machineContext)) {
return [NSString stringWithFormat:@"Fail to get information about thread: %u", thread];
}
//2.獲取 pc寄存器 的值,也就是當前指令執行的位置
const uintptr_t instructionAddress = bs_mach_instructionAddress(&machineContext);
backtraceBuffer[i] = instructionAddress;
++I;
//3.獲取 lr寄存器 的值,作為第二棧的位置
uintptr_t linkRegister = bs_mach_linkRegister(&machineContext);
if (linkRegister) {
backtraceBuffer[i] = linkRegister;
I++;
}
if(instructionAddress == 0) {
return @"Fail to get instruction address";
}
//4.用來獲取 返回地址/上一棧幀FP 的結構體
BSStackFrameEntry frame = {0};
const uintptr_t framePtr = bs_mach_framePointer(&machineContext);
if(framePtr == 0 ||
//5.從函數調用棧中拷貝出 FP 指針往上(高地址)sizeof(frame) 字節數的內容
bs_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) {
return @"Fail to get frame pointer";
}
//上限50,如果是死循環,棧的數量可能很龐大,所以要限制
for(; i < 50; i++) {
backtraceBuffer[i] = frame.return_address;
if(backtraceBuffer[i] == 0 ||
//沒有前棧幀的時候說明已經結束
frame.previous == 0 ||
//6. 根據BSStackFrameEntry的結構循環獲取每一個棧幀的 FP
bs_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
break;
}
}
int backtraceLength = I;
Dl_info symbolicated[backtraceLength];
bs_symbolicate(backtraceBuffer, symbolicated, backtraceLength, 0);
for (int i = 0; i < backtraceLength; ++i) {
[resultString appendFormat:@"%@", bs_logBacktraceEntry(i, backtraceBuffer[i], &symbolicated[i])];
}
[resultString appendFormat:@"\n"];
return [resultString copy];
}
這段代碼要結合前面所講的函數調用棧來理解。如 x86架構,前面我們講到在函數調用棧中,執行器是靠把返回地址壓棧來實現函數調用和恢復的。那么同理,我們也可以通過逆向獲取每一棧幀的返回地址和 FP 來拿到每一棧幀的指令執行位置。
但是,即便如此,我們在閱讀這段代碼,還是可能會有一些疑問。
Thread 信息是怎樣的?(1)
考慮到一個線程被掛起時,后續繼續執行需要恢復現場,所以在掛起時相關現場需要被保存起來,比如當前執行到哪條指令了。
那么就要有相關的結構體來為線程保存運行時的狀態:
//獲取線程執行狀態的方法
kern_return_t thread_get_state
(thread_act_t target_thread,
thread_state_flavor_t flavor,
thread_state_t old_state,
mach_msg_type_number_t old_state_count);
//代表線程執行狀態的結構體
_STRUCT_MCONTEXT64
{
_STRUCT_ARM_EXCEPTION_STATE64 __es;
_STRUCT_ARM_THREAD_STATE64 __ss;
_STRUCT_ARM_NEON_STATE64 __ns;
};
_STRUCT_ARM_THREAD_STATE64
{
__uint64_t __x[29]; /* General purpose registers x0-x28 */
void* __opaque_fp; /* Frame pointer x29 */
void* __opaque_lr; /* Link register x30 */
void* __opaque_sp; /* Stack pointer x31 */
void* __opaque_pc; /* Program counter */
__uint32_t __cpsr; /* Current program status register */
__uint32_t __opaque_flags; /* Flags describing structure format */
};
而我們關心的 fp/sp/lr 等信息,就是從這里獲得的。
BSStackFrameEntry 的定義是什么?是如何得到的?(4/5)
首先我介紹一個函數,他可以讓我們從函數調用棧中 copy 出一塊數據:
kern_return_t vm_read_overwrite
(
vm_map_read_t target_task,
vm_address_t address,
vm_size_t size,
vm_address_t data,
vm_size_t *outsize
);
//我們封裝一個方法為了更方便使用
kern_return_t bs_mach_copyMem(const void *const src, void *const dst, const size_t numBytes){
vm_size_t bytesCopied = 0;
return vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied);
}
//從 framePtr 往高位拷貝數據
bs_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS);
接著讓我們回顧一下前面 x86架構 和 arm64架構 的棧幀對比圖:
我們可以看到,雖然 x86 和 arm64 在棧幀上存在些差異,但是通過 FP 指針往高位讀取16字節都能得到前棧幀的 FP 和 LR(圖中紅框部分)。那么我們可以將BSStackFrameEntry定義為:
typedef struct BSStackFrameEntry{
//8 字節
const struct BSStackFrameEntry *const previous;
//8 字節
const uintptr_t return_address;
} BSStackFrameEntry;
在 C 語言中,struct 的內存分布就是其屬性一次按占用內存大小的內存分布。由于我們是從 FP 指針往高位讀取,所以在順序上,是先定義 Pre FP,其次是 返回地址。通過這個精巧的結構,我們就可以輕松循環迭代得到所有堆棧。
為什么要讀取 LR寄存器 的值?(3)
從前面的內容,我們知道僅靠BSStackFrameEntry
循環迭代就可以拿到除第一層外所有棧幀的數據。那么為啥還要讀取 lr寄存器 的值呢?我們知道 lr 寄存器的值是返回地址。也就是說按照這個代碼邏輯,可能會出現重復的堆棧。
其實在前面我們比較 x86 和 arm64 的時候,我就已經說了 arm64 下編譯器可能會做一個優化:當某個函數內部沒有調用其他方法時,其方法內部不會有 FP/LR 的壓棧。這就帶來一個問題:這個時候我們獲得的 FP 其實是調用方的 FP,如果按照BSStackFrameEntry
這個思路,將會丟失第二層堆棧的內容。
但是雖然不會有 FP/LR 的壓棧,但是在函數調用的時候,依然會將 LR寄存器 的值設為返回地址。所以這個時候我們就需要讀取 LR 寄存器 來得到第二棧幀的指令位置。
細心的讀者可能會發現上面的代碼有個 bug,如果當前指令所在的函數存在其他函數的調用,也即存在 FP/LR 的堆棧,那么就還是會出現重復的堆棧。實際也確實如此。
三 如何獲取崩潰地址
讓我們回歸到這篇文章的最終目的——拿到Crash 的現場信息,確定崩潰位置。在我剛開始研究這個主題的時候,我以為在崩潰事件的回調(參見上篇文章)中,當前的線程就是崩潰所在的線程,但實際并不是的。
1. Mach 異常的位置
回顧一下攔截 Mach 異常的代碼:
typedef struct {
mach_msg_header_t Head;
/* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t thread;
mach_msg_port_descriptor_t task;
/* end of the kernel processed data */
NDR_record_t NDR;
exception_type_t exception;
mach_msg_type_number_t codeCnt;
integer_t code[2];
int flavor;
mach_msg_type_number_t old_stateCnt;
natural_t old_state[144];
} Request;
Request exc;
// ....
for(;;) {
rc = mach_msg( &exc.Head,
MACH_RCV_MSG|MACH_RCV_LARGE,
0,
sizeof(Request),
server_port, // Remember this was global – that's why.
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if(rc != MACH_MSG_SUCCESS) {
/*... */
break ;
};
//.....
}
這段代碼的核心在于結構體 Request
。我們注意到 Request
的結構體有個關鍵的屬性thread
,他的定義是:
typedef struct{
mach_port_t name;
// Pad to 8 bytes everywhere except the K64 kernel where mach_port_t is 8 bytes
mach_msg_size_t pad1;
unsigned int pad2 : 16;
mach_msg_type_name_t disposition : 8;
mach_msg_descriptor_type_t type : 8;
} mach_msg_port_descriptor_t;
在我們這個場景下,name 的值就是我們要的崩潰 thread_t。
2. Signal 異常的位置
對于 Signal 信號的異常信息,事件回調時所在的 thread 就是崩潰發生的 thread。但是如果我們沿用前面獲取Thread 執行狀態的方法就會出現問題。這種情況下,我們需要改用一下方法:
//Signal 信號的回調,Signal 的監控需要使用sigaction,參考上面文章的備用信號棧,要使用action.sa_sigaction
//來指定處理方法
static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext)
{
//這里僅以 arm64 為例
_STRUCT_MCONTEXT64 context;
//直接獲取崩潰時的線程執行狀態
_STRUCT_MCONTEXT64* sourceContext = ((ucontext64_t*)userContext)->uc_mcontext64;
memcpy(&context, sourceContext, sizeof(context));
//拿到context后, 我們就可以根據前面說的_STRUCT_MCONTEXT64中的__ss來拿到 fp 等信息
//...參考BSBacktraceLogger的代碼來獲取堆棧信息
}
//ucontext64_t 的定義,可以看到里面有線程執行狀態的結構體
_STRUCT_UCONTEXT64
{
int uc_onstack;
__darwin_sigset_t uc_sigmask; /* signal mask used by this context */
_STRUCT_SIGALTSTACK uc_stack; /* stack used by this context */
_STRUCT_UCONTEXT64 *uc_link; /* pointer to resuming context */
__darwin_size_t uc_mcsize; /* size of the machine context passed in */
_STRUCT_MCONTEXT64 *uc_mcontext64; /* pointer to machine specific context */
};
typedef _STRUCT_UCONTEXT64 ucontext64_t; /* [???] user context */
3. NSException 異常的位置
在上一篇的時候,我們就多次強調了 NSException 的異?;卣{時,遍歷所有 Thread 并不能得到崩潰堆棧。那么應該怎么做呢?
//雖然指針是64位,但是 app 使用的虛擬地址空間是遠小于64位的,所以在地址指針里面會留有一些沒用的位。
//在 Arm64e 架構中,蘋果把這些多余的位用戶做指針校驗(有興趣的可以搜一下 PAC)。所以需要通過掩碼只取低位的有效值。
#define KSPACStrippingMask_ARM64e 0x0000000fffffffff
static void handleException(NSException* exception, BOOL currentSnapshotUserReported) {
//從 exception 中我們能拿到原始的崩潰堆棧地址列表,這個時候我們就完全不需要循環獲取 FP
//如果是 arm64 就需要把得到的地址和KSPACStrippingMask_ARM64e做按位與
//這個線程所在的線程就是 crash 所在的線程
NSArray* addresses = [exception callStackReturnAddresses];
//其他線程信息仍然按照前面介紹的方式獲取
}
4. C++ 異常的位置
C++異常的處理需要參考上篇文章,重寫__cxa_throw
方法,然后通過backtrace
方法獲得當前的堆棧地址數組并保存下來。這里不做詳細說明。
四 符號化堆棧
由于這部分的知識網上講的很多,我就不浪費篇幅再講一次了,感興趣的朋友可以看看后面的參考文章。
五 補充
這里補充一下我在研究這塊領域時遇到的一些輔助判斷問題的方法。
-
獲取寄存器的值
在 Xcode 斷點時輸入
p/x $lr
之類的命令。 -
打開匯編模式
在嘗試定位或者了解棧幀結構的時候,斷點時如果出現的是代碼文件,對我們閱讀匯編指令比較困難。可以通過
Debug->Debug Workflow->Always Show Disassembly
來打開匯編模式 如果遇到網上提供的信息不清晰時,可以通過閱讀匯編指令以及寄存器的值來輔助了解。篇幅所限,這里就不詳細介紹匯編指令,請自行上網查找。
六 參考文章
- 談談iOS堆棧那些事
- 淺談函數調用棧
- 函數棧的實現原理
- NSThead和內核線程的轉換
- 函數調用棧 剖析+圖解[轉]
- iOS開發同學的arm64匯編入門
- iOS底層系統:Mach調度原理之調度原語
- 再看CVE-2016-1757---淺析mach message的使用
- 談談msgSend為什么不會出現在堆棧中
- 函數調用棧
- mach 源碼
- arm 架構堆棧官方文檔
總結
這篇文章寫得很啰嗦,但請相信,這些信息都是我在學習這個領域知識的時候所遇到的各種困惑。為了能真正了解以及不提供錯誤的信息,我花了很多的時間,力求準確。并且我希望通過大量的圖片,降低大家學習的門檻。希望閱讀完這篇文章,能解開你在這塊遇到的所有困惑。
最后,如果此文對你有幫助,求大家輕輕一個點贊。