探究iOS線程調用棧及符號化

概念

調用棧,也稱爲執行棧、控制棧、運行時棧與機器棧,是計算機科學中存儲運行子程序的重要的數據結構,主要存放返回地址、本地變量、參數及環境傳遞,用於跟蹤每一個活動的子例程在完成執行後應該返回控制的點。html

一個線程的調用棧如上圖所示,它分爲若干棧幀(frame),每一個棧幀對應一個函數調用,如藍色部分是DrawSquare函數的棧幀,它在運行過程當中調用了DrawLine函數,棧幀爲綠色部分表示。棧幀主要包含三部分組成函數參數、返回地址、幀內的本地變量,如上圖中的函數DrawLine調用時首先把函數參數入棧,而後把返回地址入棧(表示當前函數執行完後上一棧幀的幀指針),最後是函數內部本地變量(包含函數執行完後繼續執行的程序地址)。git

大部分操做系統棧的增加方向都是從上往下(包括iOS),Stack Pointer指向棧頂部,Frame Pointer指向上一棧幀的Stack Pointer值,經過Frame Pointer就能夠遞歸回溯獲取整個調用棧。github

ARM調用棧

首先ARM架構(64位arm64指令集)下的用於調用棧的各個寄存器,以下: web

32位armv7指令集寄存器以下;數組

  • r15PC(The Program Counter),指令寄存器,也稱爲程序計數器,保存的是下一條將要執行的指令的內存地址;
  • r14LR(The Link Register),連接寄存器,保存着當前函數返回時調用函數的指令的內存地址;
  • r13SP(The Stack Pointer),堆棧指針,保存着棧頂的指針;
  • r12IP( The Intra-Procedure-call scratch register),可簡單的認爲暫存SP。
  • r7FP(The Frame Pointer),棧幀指針,保存着上一棧幀的指針;
  • R9:操做系統保留
  • R4-R6, R8, R10-R11:沒有特殊規定,就是普通的通用寄存器
  • r0-r3,用於存放傳遞給函數的參數與返回值;

典型的棧幀以下圖所示:安全

main stack frame爲調用函數的棧幀,func1 stack frame爲當前函數(被調用者)的棧幀,棧底在高地址,棧向下增加。圖中FP就是棧基址,它指向函數的棧幀起始地址;SP則是函數的棧指針,它指向棧頂的位置。ARM壓棧的順序非常規矩,依次爲當前函數指針PC、返回指針LR、棧指針SP、棧基址FP、傳入參數個數及指針、本地變量和臨時變量。若是函數準備調用另外一個函數,跳轉以前臨時變量區先要保存另外一個函數的參數。數據結構

上圖的調用棧對應的彙編代碼以下。架構

  1. 8514行將當前的sp保存在ip中(ip只是個通用寄存器,用來在函數間分析和調用時暫存數據,一般爲r12);
  2. 8518行將4個寄存器從右向左依次壓棧。
  3. 851c行將保存的ip減4,獲得當前被調用函數的fp地址,即指向棧裏的pc位置。
  4. 8520行將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}
複製代碼

咱們能夠根據FPSP寄存器回溯函數調用過程,以上圖爲例:函數func1的棧中保存了main函數的棧信息(綠色部分的SPFP),經過這兩個值,咱們能夠知道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指定sdkclang編譯指定編譯架構-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

線程棧

每一個線程都有本身的線程棧來保存線程的執行調用狀況,經過上述調用棧寄存器SPFP能夠肯定棧信息,具體如何獲取到線程的棧信息呢?

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_tthread_tpthread_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鏡像文件(如上圖所示),且鏡像會映射到惟一的地址段,所以獲取的調用棧函數地址就能夠肯定所屬的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++;
        }
    }
}
複製代碼

Reference

關於函數調用棧(call stack)的我的理解

調用棧

獲取任意一個線程的Call Stack

iOS獲取任意線程調用棧

談談iOS獲取調用鏈

運行時獲取函數調用棧

線程 Call Stack 的捕獲和解析

iOS 之 Thread調用棧學習

C語言在ARM中函數調用時,棧是如何變化的?

iOS 逆向之ARM彙編

ARM64指令簡易手冊

iOS彙編快速入門

thread_get_state

pthread.c

《深刻解析Mac OSX & iOS操做系統》

vm_read_overwrite

KSCrash

相關文章
相關標籤/搜索