如何獲取iOS的執行緒呼叫棧

語言: CN / TW / HK

如何獲取iOS的執行緒呼叫棧

我們的開發除錯階段, 有很多場景需要獲取方法的呼叫棧:

  1. 在啟動優化中, 可以在編譯的時候指定order.file檔案, 指定符號表順序進行二進位制重排
  2. 在卡頓監控中, 需要在檢測到runloop執行超過閾值時, 能快速列印主執行緒的呼叫棧, 並獲取指定的執行時間
  3. 在Crash監控中, 需要在APP發生Crash時, 能夠快速保留所有執行緒的函式呼叫棧情況, 用於後續Crash分析

獲取呼叫棧整體思路可以有如下思路:

  • 根據ARM64彙編中的PC暫存器以及函式呼叫棧幀的資訊, 然後通過地址反向查詢符號表中的符號
  • 僅僅監控OC方法呼叫流程, 所有OC傳送訊息的方法都能在底層轉化成objc_msgSend()/objc_msgSendSuper()方法, hook系統的方法, 插入自己的內容,記錄OC方法呼叫的selfsel名稱

目前Crash相關肯定直接通過函式棧幀方式來搞定所有的函式呼叫棧, 如果是啟動優化, 或者監控OC方法耗時, 可以用第二種.

另外也可以直接在LLVM編譯時, 靜態插裝方式直接替換objc_msgSend方法

本文主要總結歸納第一種方式 -- iOS的執行緒呼叫棧

1. 函式呼叫棧的解釋

目前iOS中底層執行的是機器碼指令, 通常可以使用反彙編方式用匯編重寫, 目前基本是ARM64彙編,如圖所示:

  • sp暫存器在任意時刻會儲存我們棧頂的地址.

  • fp暫存器也稱為x29暫存器屬於通用暫存器,但是在某些時刻我們利用它儲存棧底的地址!(非葉子節點函式儲存)

  • lr暫存器, 也稱為x30暫存器, 儲存返回返回的地址

注意:ARM64開始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp ARM64裡面 對棧的操作是16位元組對齊的!!

15468434153501.jpg

常見的函式呼叫開闢和恢復的棧空間

objc sub sp, sp, #0x40 ; 拉伸 0x40(64位元組)空間 stp x29, x30, [sp, #0x30] ; x29\x30 暫存器入棧保護 add x29, sp, #0x30 ; x29指向棧幀的底部 ... ldp x29, x30, [sp, #0x30] ; 恢復 x29/x30 暫存器的值 add sp, sp, #0x40 ; 棧平衡 ret

因此當有多個函式巢狀呼叫時, 需要用棧來儲存函式執行的Context資訊, 比如區域性變數, 函式引數等等, 使用一個例項來說沒如下:

``` - (void)foo { [self bar]; }

  • (void)bar { NSLog(@"hello world"); } ```

img

2. 執行緒中的函式呼叫棧

作業系統給每個執行緒都在核心中維護了一個函式呼叫棧, 在iOS中上面的lr, sp, fp等暫存器的內容都能通過指定的API獲取到, arm64中的結構如下:

```c _STRUCT_ARM_THREAD_STATE64 // arm64 { __uint64_t __x[29]; / General purpose registers x0-x28 / __uint64_t __fp; / Frame pointer x29 / __uint64_t __lr; / Link register x30 / __uint64_t __sp; / Stack pointer x31 / __uint64_t __pc; / Program counter / __uint32_t __cpsr; / Current program status register / __uint32_t __pad; / Same size for 32-bit or 64-bit clients / };

if defined(arm64)

typedef _STRUCT_MCONTEXT64 *mcontext_t;

define _STRUCT_MCONTEXT _STRUCT_MCONTEXT64

else

typedef _STRUCT_MCONTEXT32 *mcontext_t;

define _STRUCT_MCONTEXT _STRUCT_MCONTEXT32

endif

```

因此, 只要我們能獲取上面提到的 lr 和 fp暫存器的內容, 然後遞迴查詢函式地址, 然後對地址進行符號化, 就能獲取呼叫棧.

參考Matrix中的實現邏輯, 主要分成如下流程:

1. 首先查詢APP的程序中所有的task_threads, matrix中的邏輯如下:

```c++ void xxx() { // 資源獲取 thread_act_array_t threads; mach_msg_type_number_t thread_count; if (task_threads(mach_task_self(), &threads, &thread_count) != KERN_SUCCESS) { return 0; }

// threads 中是當前程序APP中所有的執行緒! 其中第一個應該是主執行緒
    //thread_t mainThread = threads[0];
    //currentThread = pthread_mach_thread_np(pthread_self());
//if (mainThread == currentThread) {
//   return 0;
//}
// 其他核心程式碼邏輯 ...

// 資源釋放
for (mach_msg_type_number_t i = 0; i < thread_count; i++) {
    mach_port_deallocate(mach_task_self(), threads[i]);
}
vm_deallocate(mach_task_self(), (vm_address_t)threads, sizeof(thread_t) * thread_count);

} ```

一個Mach Task包含它的執行緒列表。核心提供了task_threads API 呼叫獲取指定 task 的執行緒列表,然後可以通過thread_info API呼叫來查詢指定執行緒的資訊,在 thread_act.h 中有相關定義。

2. 使用thread_get_state 獲取執行緒上下文ctx

c++ _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);

3. 通過_STRUCT_MCONTEXT中的變數ctx獲取需要的暫存器內容

uint64_t pc = ctx.__ss.__pc; uint64_t sp = ctx.__ss.__sp; uint64_t fp = ctx.__ss.__fp;

然後可以根據fp和pc的內容, 遞迴迴圈獲取呼叫棧, 網上有一個簡單的實現, 該實現會從下到上依次打印出呼叫棧函式中的地址, 也可以參考matrix或者BSBacktraceLogger:

do { // print symbol of (pc); pc = *((uint64_t *)fp + 1); fp = *((uint64_t *)fp); } while (fp);

或者

```c++ void* t_fp[2];

vm_size_t len = sizeof(record); vm_read_overwrite(mach_task_self(), (vm_address_t)(fp),len, (vm_address_t)t_fp, &len);

do { pc = (long)t_fp[1] // lr總是在fp的上一個地址 // 依次記錄pc的值,這裡先只是打印出來 printf(pc)

vm_read_overwrite(mach_task_self(),(vm_address_t)m_cursor.fp[0], len, (vm_address_t)m_cursor.fp,&len);

} while (fp); ```

此時, 我們擁有了每個執行緒的呼叫鏈上的關鍵pc地址, 此時還剩下最重要的一步, 就是還原符號表!!!

4. 根據pc的地址還原符號表

一般來說APP中會有載入非常多的動態庫, 因此可能會涉及很多個不同的MachO, 這些MachO都會被對映到虛擬記憶體中, 而pc中的地址可能在動態庫的MachO Images中, 在開發中可以使用如下方式列印所有Images:

c uint64_t count = _dyld_image_count(); for (uint32_t i = 0; i < count; i++) { const struct mach_header *header = _dyld_get_image_header(i); const char *name = _dyld_get_image_name(i); uint64_t slide = _dyld_get_image_vmaddr_slide(i); }

因此, 我們需要判斷pc中的地址具體會落在哪個MachO的虛擬記憶體中, 這裡涉及的內容比較多, 不再展開, 大概流程如下, 可以參考開源庫實現:

  1. 根據Image Mach-O Header資訊以及segment load command判斷pc的地址是否落在改image
  2. 根據image的MachO的LC_SYMTAB以及LC_SEGMENT(__LINKEDIT)符號表查詢具體符號
  3. 此外, 還需要遍歷符號需找最佳匹配符號

使用pc的地址進行符號化的過程與iOS 優化篇 - 啟動優化之Clang插樁實現二進位制重排有異曲同工之妙

參考

https://juejin.cn/post/6844904149109178376

https://juejin.cn/post/6854573209879216136

https://juejin.cn/post/6844904080393912327

https://github.com/bestswifter/BSBacktraceLogger

https://github.com/Tencent/matrix

https://www.jianshu.com/p/3b83193ff851

https://github.com/kstenerud/KSCrash

https://juejin.cn/post/6844903760917954568

https://www.jianshu.com/p/4aadb4fd00c7

https://juejin.cn/post/6844904130406793224