調用棧,也稱爲執行棧、控制棧、運行時棧與機器棧,是計算機科學中存儲運行子程序的重要的數據結構,主要存放返回地址、本地變量、參數及環境傳遞,用於跟蹤每一個活動的子例程在完成執行後應該返回控制的點。html
一個線程的調用棧如上圖所示,它分爲若干棧幀(frame
),每一個棧幀對應一個函數調用,如藍色部分是DrawSquare
函數的棧幀,它在運行過程當中調用了DrawLine
函數,棧幀爲綠色部分表示。棧幀主要包含三部分組成函數參數、返回地址、幀內的本地變量,如上圖中的函數DrawLine
調用時首先把函數參數入棧,而後把返回地址入棧(表示當前函數執行完後上一棧幀的幀指針),最後是函數內部本地變量(包含函數執行完後繼續執行的程序地址)。git
大部分操做系統棧的增加方向都是從上往下(包括iOS),Stack Pointer
指向棧頂部,Frame Pointer
指向上一棧幀的Stack Pointer
值,經過Frame Pointer
就能夠遞歸回溯獲取整個調用棧。github
首先ARM架構(64位arm64指令集)下的用於調用棧的各個寄存器,以下: web
32位armv7
指令集寄存器以下;數組
r15
,PC(The Program Counter)
,指令寄存器,也稱爲程序計數器,保存的是下一條將要執行的指令的內存地址;r14
,LR(The Link Register)
,連接寄存器,保存着當前函數返回時調用函數的指令的內存地址;r13
,SP(The Stack Pointer)
,堆棧指針,保存着棧頂的指針;r12
,IP( The Intra-Procedure-call scratch register)
,可簡單的認爲暫存SP。r7
,FP(The Frame Pointer)
,棧幀指針,保存着上一棧幀的指針;R9
:操做系統保留R4-R6, R8, R10-R11
:沒有特殊規定,就是普通的通用寄存器r0-r3
,用於存放傳遞給函數的參數與返回值;典型的棧幀以下圖所示:安全
main stack frame
爲調用函數的棧幀,func1 stack frame
爲當前函數(被調用者)的棧幀,棧底在高地址,棧向下增加。圖中FP
就是棧基址,它指向函數的棧幀起始地址;SP
則是函數的棧指針,它指向棧頂的位置。ARM壓棧的順序非常規矩,依次爲當前函數指針PC
、返回指針LR
、棧指針SP
、棧基址FP
、傳入參數個數及指針、本地變量和臨時變量。若是函數準備調用另外一個函數,跳轉以前臨時變量區先要保存另外一個函數的參數。數據結構
上圖的調用棧對應的彙編代碼以下。架構
sp
保存在ip
中(ip
只是個通用寄存器,用來在函數間分析和調用時暫存數據,一般爲r12
);ip
減4,獲得當前被調用函數的fp
地址,即指向棧裏的pc
位置。sp
減8,爲棧空間開闢出8個字節的大小,用於存放局部便令。00008514 <func1>:
8514: e1a0c00d mov ip, sp
8518: e92dd800 push {fp, ip, lr, pc}
851c: e24cb004 sub fp, ip, #4
8520: e24dd008 sub sp, sp, #8
8524: e3a03000 mov r3, #0
8528: e50b3010 str r3, [fp, #-16]
852c: e30805dc movw r0, #34268 ; 0x85dc
8530: e3400000 movt r0, #0
8534: ebffff9d bl 83b0 <puts@plt>
8538: e51b3010 ldr r3, [fp, #-16]
853c: e12fff33 blx r3
8540: e3a03000 mov r3, #0
8544: e1a00003 mov r0, r3
8548: e24bd00c sub sp, fp, #12
854c: e89da800 ldm sp, {fp, sp, pc}
複製代碼
咱們能夠根據FP
和SP
寄存器回溯函數調用過程,以上圖爲例:函數func1的棧中保存了main函數的棧信息(綠色部分的SP
和FP
),經過這兩個值,咱們能夠知道main函數的棧起始地址(也就是FP
寄存器的值), 以及棧頂(也就是SP
寄存器的值)。獲得了main函數的棧幀,就很容易從裏面提取LR
寄存器的值了(FP
向下偏移4個字節即爲LR
),也就知道了誰調用了main函數。以此類推,能夠獲得一個完整的函數調用鏈(通常回溯到 main函數或者線程入口函數就不必繼續了)。實際上,回溯過程當中咱們並不須要知道棧頂SP
,只要FP
就夠了。app
實例代碼以下:less
#include <stdio.h>
int add(int a, int b){
return a + b;
}
int main(){
int a = 10;
int b = 20;
int c = add(a, b);
printf("add ret:%d \n", c);
return 0;
}
複製代碼
經過xcrun
指定sdk
並clang
編譯指定編譯架構-arch
,結果以下:
// -arch 表示要編譯的架構 包括armv7 armv7s arm64 // -isysroot 指定頭文件的根路徑 $ clang -S -arch armv64 -o hello hello.c –isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.4.sdk
//也可使用xcrun,xcrun -sdk 會使用最新的sdk去編譯 $ xcrun -sdk iphoneos clang -S -arch armv64 -o hello hello.c
每一個線程都有本身的線程棧來保存線程的執行調用狀況,經過上述調用棧寄存器SP
和FP
能夠肯定棧信息,具體如何獲取到線程的棧信息呢?
NSThread
提供了[NSThread callstackSymbols]
來獲取當前線程的調用棧,也能夠經過backtrace/backtrace_symbols
接口獲取,但只能獲取當前線程的調用棧,沒法獲取其餘線程的調用棧。所幸Mach
內核提供了獲取線程上下文的接口thread_get_state
以及獲取全部線程task_threads
,具體定義以下:
kern_return_t thread_get_state
(
thread_act_t target_act,
thread_state_flavor_t flavor,
thread_state_t old_state,
mach_msg_type_number_t *old_stateCnt
);
#if defined(__x86_64__)
_STRUCT_MCONTEXT ctx;
mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT;
thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);
uint64_t pc = ctx.__ss.__rip;
uint64_t sp = ctx.__ss.__rsp;
uint64_t fp = ctx.__ss.__rbp;
#elif defined(__arm64__)
_STRUCT_MCONTEXT ctx;
mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT;
thread_get_state(thread, ARM_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);
uint64_t pc = ctx.__ss.__pc;
uint64_t sp = ctx.__ss.__sp;
uint64_t fp = ctx.__ss.__fp;
#endif
//task_threads 將 target_task 任務中的全部線程保存在 act_list 數組中,數組中包含 act_listCnt 個線程,這裏使用mach_task_self()獲取當前進程標記 target_task
kern_return_t task_threads
(
task_inspect_t target_task,
thread_act_array_t *act_list,
mach_msg_type_number_t *act_listCnt
);
複製代碼
經過 act_list
數組能夠讀取該任務的全部線程,獲取線程以後,對於每個線程,能夠用 thread_get_state
方法獲取它的全部信息,信息填充在 _STRUCT_MCONTEXT
類型的參數中。這個方法中有兩個參數隨着 CPU 架構的不一樣而改變,所以須要注意不一樣 CPU 之間的區別。在 _STRUCT_MCONTEXT
類型的結構體中,存儲了當前線程的 Stack Pointer
和最頂部棧幀的 Frame Pointer
,從而獲取到了整個線程的調用棧。
注意:任務與進程的概念是一一對應的,即iOS系統進程(對應應用)都在底層關聯了一個
Mach
任務對象,所以能夠經過mach_task_self()
來獲取當前進程對應的任務對象;這裏的線程爲最底層的
mach
內核線程,posix
接口中的線程pthread
與內核線程一一對應,是內核線程的抽象,NSThread
線程是對pthread
的面向對象的封裝。
對於函數調用過程當中可能存在着異常狀況致使棧幀損壞,所以當前現成的棧幀地址在不被容許訪問的地址空間,若直接經過thread_get_state
獲取線程棧幀而獲取整個調用棧,存在指針訪問錯誤,致使程序異常崩潰。可以使用vm_red_overwrite
函數來安全獲取線程調用棧,該函數會詢問內核是否有權限訪問指定的內存,避免指針訪問異常。具體的函數使用以下:
typedef struct StackFrameEntry{
const struct StackFrameEntry * const previous;//前一個棧幀地址
const uintptr_t return_address;//函數地址
} StackFrameEntry;
//mach_task_self:task對象
//src:fp棧幀指針
//numBytes:sizeof(StackFrameEntry)
//dst:StackFrameEntry指針
//bytesCopied://cpye字節大小
kern_return_t vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied)
複製代碼
獲取線程名稱
每一個內核線程由thread_t
類型的id
來惟一標識,phread
的惟一標識類型爲pthread_t
,thread_t
與pthread_t
轉換相對容易,但NSThread
沒有存儲pthread_t
的標識,不過NSThread
可以獲取線程名稱,而pthread
接口提供了pthread_getname_np
來獲取線程名稱,二者名稱是一致的,其中np
是指not posix
(不是跨平臺接口)。可是主線程沒法經過pthread_getname_np
獲取名稱,因此須要在load
方法裏獲取線程的thread_t
;
//具體的thread_t與pthread_t相互轉換接口
pthread_t pthread = pthread_from_mach_thread_np((thread_t)thread);
//獲取主線程的thread_t
static mach_port_t main_thread_id;
+ (void)load {
main_thread_id = mach_thread_self();
}
複製代碼
獲取全部線程的調用棧地址後,如何將函數地址進行符號化進而轉化爲可讀信息,便於排查定位問題。
對於應用會存在多個Image
鏡像文件(如上圖所示),且鏡像會映射到惟一的地址段,所以獲取的調用棧函數地址就能夠肯定所屬的Image
,具體獲取鏡像相關的信息包括鏡像數量、鏡像名稱、鏡像Mach-O
頭部信息及偏移等信息,可經過dyld
提供的相關接口獲取,以下:
uint64_t count = _dyld_image_count();//image數量
const struct mach_header *header = _dyld_get_image_header(index);//image mach-o header
const char *name = _dyld_get_image_name(index);//image name
uint64_t slide = _dyld_get_image_vmaddr_slide(index);//ALSR偏移地址
複製代碼
經過遍歷獲取Image Mach-O Header
頭部信息及其加載命令來獲取所屬的地址空間範圍來判斷是否位於當前Image
(Mach-O相關的知識點可見探究Mach-O文件),具體的代碼邏輯以下:
static uint32_t imageIndexContainingAddress(const uintptr_t address) {
const uint32_t imageCount = _dyld_image_count();
const struct mach_header* header = 0;
for(uint32_t iImg = 0; iImg < imageCount; iImg++)
{
header = _dyld_get_image_header(iImg);
if(header != NULL)
{
// Look for a segment command with this address within its range.
uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(iImg);
uintptr_t cmdPtr = firstCmdAfterHeader(header);
if(cmdPtr == 0)
{
continue;
}
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++)
{
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SEGMENT)
{
const struct segment_command* segCmd = (struct segment_command*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize)
{
return iImg;
}
}
else if(loadCmd->cmd == LC_SEGMENT_64)
{
const struct segment_command_64* segCmd = (struct segment_command_64*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize)
{
return iImg;
}
}
cmdPtr += loadCmd->cmdsize;
}
}
}
return UINT_MAX;
}
複製代碼
符號表儲存在 Mach-O 文件的 LC_SEGMENT(__LINKEDIT)
段中,涉及其中的符號表(Symbol Table)
和字符串表(String Table)
。符號表在 Mach-O目標文件中的地址能夠經過LC_SYMTAB
加載命令指定的 symoff
找到,對應的符號名稱在stroff
,總共有nsyms
條符號信息;也就是說,經過LC_SYMTAB
來找存儲在__LINKEDIT
中的符號地址地址。
符號表是一個連續的列表,其中每一項都是struct nlist
,以下:
truct nlist {
union {
uint32_t n_strx;//符號名在字符串表中的偏移量
} n_un;
uint8_t n_type;
uint8_t n_sect;
int16_t n_desc;
uint32_t n_value;//符號在內存中的地址,相似於函數指針
};
複製代碼
經過符號表項中的n_un.n_strx
來獲取符號名在字符串表String Table
中的偏移量,進而獲取符號名即函數名;經過n_value
來獲取符號在內存中的地址,即函數指針;所以就清楚了符號名和內存地址之間的對應關係。具體的獲取符號表及字符串表的代碼以下:
//獲取Mach-O Header
const struct mach_header* header = _dyld_get_image_header(index);
//經過header遍歷Load Commands獲取_LINKEDIT 及 LC_SYMTAB
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++)
{
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SYMTAB){
symtabCmd = loadCmd;
} else if(loadCmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64* segmentCmd = (struct segment_command_64*)cmdPtr;
if(strcmp(segmentCmd->segname, SEG_LINKEDIT) == 0)
{
linkeditSegment = segmentCmd;
}
}
}
//基址 = 偏移量 + _LINKEDIT段虛擬地址 - _LINKEDIT段文件偏移地址
uintptr_t linkeditBase = (uintptr_t)slide + linkeditSegment->vmaddr - linkeditSegment->fileoff;
//符號表的地址 = 基址 + 符號表偏移量
const nlist_t *symbolTable = (nlist_t *)(linkeditBase + symtabCmd->symoff);
//字符串表的地址 = 基址 + 字符串表偏移量
char *stringTab = (char *)(linkeditBase + symtabCmd->stroff);
//符號數量
uint32_t symNum = symtabCmd->nsyms;
複製代碼
上述查找符號是獲取的真正的符號內存地址和函數名,而經過函數調用棧獲取的是函數內部執行指令的地址,不過該地址與真正的函數地址偏離不大,所以能夠經過遍歷符號的內存地址與調用棧函數地址比較獲得離符號內存地址最近的最佳匹配符號,便是當前調用棧的符號,具體代碼以下:
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
const uintptr_t addressWithSlide = address - imageVMAddrSlide;//address爲調用棧內存地址
//遍歷符號需找最佳匹配符號
for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++)
{
// If n_value is 0, the symbol refers to an external object.
if(symbolTable[iSym].n_value != 0)
{
uintptr_t symbolBase = symbolTable[iSym].n_value;//獲取符號的內存地址(函數指針)
uintptr_t currentDistance = addressWithSlide - symbolBase;
if((addressWithSlide >= symbolBase) &&
(currentDistance <= bestDistance))
{
bestMatch = symbolTable + iSym;//最佳匹配符號地址
bestDistance = currentDistance;//調用棧內存地址與當前符號內存地址距離
}
}
}
if(bestMatch != NULL)
{
info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
if(bestMatch->n_desc == 16)
{
// This image has been stripped. The name is meaningless, and
// almost certainly resolves to "_mh_execute_header"
info->dli_sname = NULL;
}
else
{
//獲取符號名
info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
if(*info->dli_sname == '_')
{
info->dli_sname++;
}
}
}
複製代碼