iOS 如何抓取執行緒的“方法呼叫棧”?
場景:
在一些 “效能監控” 的工具中,在檢測到App主執行緒卡頓的時候,可以通過子執行緒抓取當前時刻所有執行緒的方法呼叫堆疊(儲存卡頓現場),並在合適的時機(WiFi環境&網路環境較好的時候)把堆疊資訊上傳到我們的服務端。服務端將堆疊資訊過濾分析後,交給客戶端做優化處理。 這樣,就能較好的提高使用者的體驗,並及時發現線上環境下的問題。
同時,也可以及時發現問題,及時優化我們的程式碼質量和執行效率。
(一個比較好的開發迴圈)
那麼,在App發生卡頓時候,我們該如何抓取方法呼叫棧呢?堆疊資訊又是什麼樣的呢?
本文將通過一個具體的 demo
,闡述如何進行抓棧操作。
在此之前,首先要感謝我偶像@bestswifter的部落格:
《獲取任意執行緒呼叫棧的那些事》,對我有很大的啟發與幫助。
接下來,進入我們今天的正題:
- 什麼是呼叫棧?
- 如何抓取執行緒當前的呼叫棧?
- 如何符號化解析?
- 一些特殊的呼叫棧
- (補充)如何檢測App卡頓?
一、什麼是呼叫棧?
呼叫棧(
call stack
):
是電腦科學中儲存有關正在執行的子程式的訊息的棧。—— 維基百科
在我們程式執行中,通常存在一個函式呼叫另一個函式的情況。
例如,在某個執行緒中,呼叫了 func A
。在 func A
執行過程中,呼叫了 func B
。
那麼,在計算機程式底層需要做哪些事呢?
-
轉移控制 :暫停
func A
,並開始執行func B
,並在func B
func A
繼續執行。 -
轉移資料 :
func A
要能把引數傳遞給func B
,並且func B
如果有返回值的話,要把返回值還給func A
。 -
分配和釋放記憶體 :在
func B
開始執行時,給需要用到區域性變數分配記憶體。在func B
執行完後,釋放這部分記憶體。
舉個例子,
我宣告瞭兩個函式:foo
、bar
。
同時,在函式foo
中呼叫了函式bar
。
- (void)foo {
[self bar];
}
- (void)bar {
NSLog(@"QiShare");
}
複製程式碼
在模擬器(x86
)下,會轉換成如下彙編:
QiStackFrameLogger`-[ViewController foo]:
0x105a1f0d0 <+0>: pushq %rbp
0x105a1f0d1 <+1>: movq %rsp,%rbp
0x105a1f0d4 <+4>: subq $0x10,%rsp
0x105a1f0d8 <+8>: movq %rdi,-0x8(%rbp)
0x105a1f0dc <+12>: movq %rsi,-0x10(%rbp)
0x105a1f0e0 <+16>: movq -0x8(%rbp),%rax
0x105a1f0e4 <+20>: movq 0x64a5(%rip),%rsi ; "bar"
0x105a1f0eb <+27>: movq %rax,%rdi
0x105a1f0ee <+30>: callq *0x3f1c(%rip) ; (void *)0x00007fff50ad3400: objc_msgSend
-> 0x105a1f0f4 <+36>: addq $0x10,%rsp
0x105a1f0f8 <+40>: popq %rbp
0x105a1f0f9 <+41>: retq
QiStackFrameLogger`-[ViewController bar]:
0x105a1f100 <+0>: pushq %rbp
0x105a1f101 <+1>: movq %rsp,%rbp
0x105a1f104 <+4>: subq $0x10,%rsp
0x105a1f108 <+8>: leaq 0x3f61(%rip),%rax ; @"QiShare"
0x105a1f10f <+15>: movq %rdi,-0x8(%rbp)
0x105a1f113 <+19>: movq %rsi,-0x10(%rbp)
-> 0x105a1f117 <+23>: movq %rax,%rdi
0x105a1f11a <+26>: movb $0x0,%al
0x105a1f11c <+28>: callq 0x105a20cd4 ; symbol stub for: NSLog
0x105a1f121 <+33>: jmp 0x105a1f121 ; <+33> at ViewController.m:24:5
複製程式碼
在我的真機(arm64
)下,會轉換成如下彙編:
QiStackFrameLogger`-[ViewController foo]:
0x10443833c <+0>: sub sp,sp,#0x20 ; =0x20
0x104438340 <+4>: stp x29,x30,[sp,#0x10]
0x104438344 <+8>: add x29,#0x10 ; =0x10
0x104438348 <+12>: adrp x8,9
0x10443834c <+16>: add x8,x8,#0x5a8 ; =0x5a8
0x104438350 <+20>: str x0,#0x8]
0x104438354 <+24>: str x1,[sp]
0x104438358 <+28>: ldr x9,#0x8]
0x10443835c <+32>: ldr x1,[x8]
0x104438360 <+36>: mov x0,x9
0x104438364 <+40>: bl 0x10443a0ac ; symbol stub for: objc_msgSend
-> 0x104438368 <+44>: ldp x29,#0x10]
0x10443836c <+48>: add sp,#0x20 ; =0x20
0x104438370 <+52>: ret
QiStackFrameLogger`-[ViewController bar]:
0x104438374 <+0>: sub sp,#0x20 ; =0x20
0x104438378 <+4>: stp x29,#0x10]
0x10443837c <+8>: add x29,#0x10 ; =0x10
0x104438380 <+12>: str x0,#0x8]
0x104438384 <+16>: str x1,[sp]
-> 0x104438388 <+20>: adrp x0,4
0x10443838c <+24>: add x0,x0,#0x58 ; =0x58
0x104438390 <+28>: bl 0x104439fe0 ; symbol stub for: NSLog
0x104438394 <+32>: b 0x104438394 ; <+32> at ViewController.m:24:5
複製程式碼
再轉換成更直觀的圖解,就變成了這樣:
目前,絕大部分iOS裝置都是基於arm64
架構的(iPhone 5s
及之後釋出的所有裝置)。
通過查詢 arm的官方檔案 ,我們可以得知:
地址 | 名稱 | 作用 |
---|---|---|
sp | 棧指標(stack pointer) | 存放當前函式的地址。 |
x30 | 連結暫存器(link register) | 儲存函式的返回地址。 |
x29 | 幀指標(frame pointer) | 上一級函式的地址(與x30一致)。 |
x19~x28 | Callee-saved registers | 被呼叫這儲存暫存器。 |
x18 | The Platform Register | 平臺保留,作業系統自身使用。 |
x17、x16 | Intra-procedure-call temporary registers | 臨時暫存器。 |
x9~x15 | Temporary registers | 臨時暫存器,用來儲存本地變數。 |
x8 | Indirect result location register | 間接返回地址,返回地址過大時使用。 |
x0~x7 | Parameter/result registers | 引數/返回值暫存器。 |
其中,比較重要的是棧指標(stack pointer
,下面簡稱sp
)與幀指標(frame pointer
,下面簡稱fp
)。sp
會儲存當前函式的棧頂地址,fp
會儲存上一級函式的sp
。
二、如何抓取執行緒當前的呼叫棧?
剛才,我們已經知道了通過fp
就能找到上一級函式的地址。
通過不停的找上一級fp
就能找到當前所有方法呼叫棧的地址。(回溯法)
Talk is easy,show me code.
- 第一步:
首先,我們宣告一個結構體,用來儲存鏈式的棧指標資訊。(sp
+fp
)
// 棧幀結構體:
typedef struct QiStackFrameEntry {
const struct QiStackFrameEntry *const previouts; //!< 上一個棧幀
const uintptr_t return_address; //!< 當前棧幀的地址
} QiStackFrameEntry;
複製程式碼
沒錯,是個連結串列。
- 第二步:
取出thread
裡的machine context
。
_STRUCT_MCONTEXT machineContext; // 先宣告一個context,再從thread中取出context
if(![self qi_fillThreadStateFrom:thread intoMachineContext:&machineContext]) {
return [NSString stringWithFormat:@"Fail to get machineContext from thread: %u\n",thread];
}
複製程式碼
具體實現:
/*!
@brief 將machineContext從thread中提取出來
@param thread 當前執行緒
@param machineContext 所要賦值的machineContext
@return 是否獲取成功
*/
+ (BOOL) qi_fillThreadStateFrom:(thread_t) thread intoMachineContext:(_STRUCT_MCONTEXT *)machineContext {
mach_msg_type_number_t state_count = Qi_THREAD_STATE_COUNT;
kern_return_t kr = thread_get_state(thread,Qi_THREAD_STATE,(thread_state_t)&machineContext->__ss,&state_count);
return kr == KERN_SUCCESS;
}
複製程式碼
- 第三步:
獲取machineContext
裡,在棧幀的指標地址。
再通過fp
的回溯,將所有的方法地址儲存在backtraceBuffer
陣列中。
直到找到最底層,沒有上一級地址就break
。
uintptr_t backtraceBuffer[50];
int i = 0;
NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:\n",thread];
const uintptr_t instructionAddress = qi_mach_instructionAddress(&machineContext);
backtraceBuffer[i++] = instructionAddress;
uintptr_t linkRegister = qi_mach_linkRegister(&machineContext);
if (linkRegister) {
backtraceBuffer[i++] = linkRegister;
}
if (instructionAddress == 0) {
return @"Fail to get instructionAddress.";
}
QiStackFrameEntry frame = {0};
const uintptr_t framePointer = qi_mach_framePointer(&machineContext);
if (framePointer == 0 || qi_mach_copyMem((void *)framePointer,&frame,sizeof(frame)) != KERN_SUCCESS) {
return @"Fail to get frame pointer";
}
// 對frame進行賦值
for (; i<50; i++) {
backtraceBuffer[i] = frame.return_address; // 把當前的地址儲存
if (backtraceBuffer[i] == 0 || frame.previouts == 0 || qi_mach_copyMem(frame.previouts,sizeof(frame)) != KERN_SUCCESS) {
break; // 找到原始幀,就break
}
}
複製程式碼
這樣,backtraceBuffer
這個陣列中,就存了當前時刻執行緒的方法呼叫地址(fp
的集合)
但backtraceBuffer
這個陣列,目前只是一堆方法的地址。
我們並不知道它具體指的是哪個方法?
那就需要接下來的 “符號化解析” 操作。
將每個地址與對應符號名(函式/方法名)一一對應上。
三、如何符號化解析?
我們通過回溯幀指標(fp
),就能拿到執行緒下的所有函式呼叫地址。
我們怎麼把地址與對應的符號(函式/方法名)對應上呢?
這就需要符號化解析步驟。
符號化解析:“地址” => “符號”。
- 預備:
這次不用我們自己宣告瞭,系統幫我們準備好了結構體dl_info
。
專門用來儲存當前的符號資訊。
/*
* Structure filled in by dladdr().
*/
typedef struct dl_info {
const char *dli_fname; /* Pathname of shared object */
void *dli_fbase; /* Base address of shared object */
const char *dli_sname; /* Name of nearest symbol */
void *dli_saddr; /* Address of nearest symbol */
} Dl_info;
複製程式碼
- 第一步:
根據backtraceBuffer
陣列的大小,宣告一個同樣大小的dl_info[]
陣列來存符號資訊。
int backtraceLength = i;
Dl_info symbolicated[backtraceLength];
qi_symbolicate(backtraceBuffer,symbolicated,backtraceLength,0); //!< 符號化
複製程式碼
- 第二步:
通過address
找到符號所在的image
。
下面的方法,可以拿到對應image
的index
(編號)。
// 找出address所對應的image編號
uint32_t qi_getImageIndexContainingAddress(const uintptr_t address) {
const uint32_t imageCount = _dyld_image_count(); // dyld中image的個數
const struct mach_header *header = 0;
for (uint32_t i = 0; i < imageCount; i++) {
header = _dyld_get_image_header(i);
if (header != NULL) {
// 在提供的address範圍內,尋找segment command
uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(i); //!< ASLR
uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
if (cmdPointer == 0) {
continue;
}
for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command *loadCmd = (struct load_command*)cmdPointer;
if (loadCmd->cmd == LC_SEGMENT) {
const struct segment_command *segCmd = (struct segment_command*)cmdPointer;
if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
// 命中!
return i;
}
}
else if (loadCmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *segCmd = (struct segment_command_64*)cmdPointer;
if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
// 命中!
return i;
}
}
cmdPointer += loadCmd->cmdsize;
}
}
}
return UINT_MAX; // 沒找到就返回UINT_MAX
}
複製程式碼
- 第三步:
我們拿到了address
所對應的image
的index
。
我們就可以通過一些系統方法與計算,得到header
、虛擬記憶體地址、ASLR偏移量(安全性考慮,為了防黑客入侵。iOS 5
、Android 4
後引入)。
以及,比較關鍵的segmentBase
(通過baseAddress
+ASLR
得到)。
const struct mach_header *header = _dyld_get_image_header(index); // 根據index找到header
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虛擬記憶體地址
const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量
const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根據index + ASLR得到的
if (segmentBase == 0) {
return false;
}
info->dli_fname = _dyld_get_image_name(index);
info->dli_fbase = (void *)header;
複製程式碼
- 第四步:
通過查詢符號表,找到對應的符號,並賦值給dl_info
陣列。
// 查詢符號表,找到對應的符號
const Qi_NLIST* bestMatch = NULL;
uintptr_t bestDistace = ULONG_MAX;
uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
if (cmdPointer == 0) {
return false;
}
for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPointer;
if (loadCmd->cmd == LC_SYMTAB) {
const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer;
const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
/*
*
struct symtab_command {
uint32_t cmd; / LC_SYMTAB /
uint32_t cmdsize; / sizeof(struct symtab_command) /
uint32_t symoff; / symbol table offset 符號表偏移 /
uint32_t nsyms; / number of symbol table entries 符號表條目的數量 /
uint32_t stroff; / string table offset 字串表偏移 /
uint32_t strsize; / string table size in bytes 字串表的大小(以位元組為單位) /
};
*/
for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
// 如果n_value為0,則該符號引用一個外部物件。
if (symbolTable[iSym].n_value != 0) {
uintptr_t symbolBase = symbolTable[iSym].n_value;
uintptr_t currentDistance = addressWithSlide - symbolBase;
if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) {
bestMatch = symbolTable + iSym;
bestDistace = currentDistance;
}
}
}
if (bestMatch != NULL) {
info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
if (*info->dli_sname == '_') {
info->dli_sname++;
}
//如果所有的符號都被刪除,就會發生這種情況。
if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
info->dli_sname = NULL;
}
break;
}
}
cmdPointer += loadCmd->cmdsize;
}
複製程式碼
- 第五步:
遍歷backtraceBuffer
陣列,並把符號資訊賦值dl_info
陣列。
// 符號化:將backtraceBuffer(地址陣列)轉成symbolsBuffer(符號陣列)。
void qi_symbolicate(const uintptr_t* const backtraceBuffer,Dl_info* const symbolsBuffer,const int numEntries,const int skippedEntries) {
int i = 0;
if(!skippedEntries && i < numEntries) {
qi_dladdr(backtraceBuffer[i],&symbolsBuffer[i]);
i++;
}
for (; i < numEntries; i++) {
qi_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]),&symbolsBuffer[i]); //!< 通過回溯得到的棧幀,找到對應的符號名。
}
}
複製程式碼
- 小結:
符號化解析,完整程式碼如下:
#pragma mark - Symbolicate
// 符號化:將backtraceBuffer(地址陣列)轉成symbolsBuffer(符號陣列)。
void qi_symbolicate(const uintptr_t* const backtraceBuffer,&symbolsBuffer[i]); //!< 通過回溯得到的棧幀,找到對應的符號名。
}
}
// 通過address得到當前函式info資訊,包括:dli_fname、dli_fbase、dli_saddr、dli_sname.
bool qi_dladdr(const uintptr_t address,Dl_info* const info) {
info->dli_fname = NULL;
info->dli_fbase = NULL;
info->dli_saddr = NULL;
info->dli_sname = NULL;
const uint32_t index = qi_getImageIndexContainingAddress(address); // 根據地址找到image中的index。
if (index == UINT_MAX) {
return false; // 沒找到就返回UINT_MAX
}
/*
Header
------------------
Load commands
Segment command 1 -------------|
Segment command 2 |
------------------ |
Data |
Section 1 data |segment 1 <----|
Section 2 data | <----|
Section 3 data | <----|
Section 4 data |segment 2
Section 5 data |
... |
Section n data |
*/
/*----------Mach Header---------*/
const struct mach_header *header = _dyld_get_image_header(index); // 根據index找到header
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虛擬記憶體地址
const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量
const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根據index + ASLR得到的
if (segmentBase == 0) {
return false;
}
info->dli_fname = _dyld_get_image_name(index);
info->dli_fbase = (void *)header;
// 查詢符號表,找到對應的符號
const Qi_NLIST* bestMatch = NULL;
uintptr_t bestDistace = ULONG_MAX;
uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
if (cmdPointer == 0) {
return false;
}
for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPointer;
if (loadCmd->cmd == LC_SYMTAB) {
const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer;
const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
/*
*
struct symtab_command {
uint32_t cmd; / LC_SYMTAB /
uint32_t cmdsize; / sizeof(struct symtab_command) /
uint32_t symoff; / symbol table offset 符號表偏移 /
uint32_t nsyms; / number of symbol table entries 符號表條目的數量 /
uint32_t stroff; / string table offset 字串表偏移 /
uint32_t strsize; / string table size in bytes 字串表的大小(以位元組為單位) /
};
*/
for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
// 如果n_value為0,則該符號引用一個外部物件。
if (symbolTable[iSym].n_value != 0) {
uintptr_t symbolBase = symbolTable[iSym].n_value;
uintptr_t currentDistance = addressWithSlide - symbolBase;
if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) {
bestMatch = symbolTable + iSym;
bestDistace = currentDistance;
}
}
}
if (bestMatch != NULL) {
info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
if (*info->dli_sname == '_') {
info->dli_sname++;
}
//如果所有的符號都被刪除,就會發生這種情況。
if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
info->dli_sname = NULL;
}
break;
}
}
cmdPointer += loadCmd->cmdsize;
}
return true;
}
複製程式碼
四、一些特殊的呼叫棧
看似,我們的抓取方案和抓棧策略都無懈可擊。
但在release
環境中,由於編譯器幫我們做了優化,有一些特殊的呼叫棧是抓不到的。
1. 尾呼叫
尾呼叫優化的本質,是 “棧幀” 的複用。
因此,每次壓棧都會複用原來的棧幀。
這時候,我們抓到的堆疊永遠只有最下層的棧,而中間的呼叫棧全都丟失了。
PS:關於尾呼叫優化,我之前實習的時候寫了一篇部落格。
可供參考:《iOS objc_msgSend尾呼叫優化詳解》
2. 函式內聯
這個也比較好理解,因為行內函式會在編譯時期展開。
直接複製程式碼塊,從而節省了呼叫函式帶來的額外時間開支。
並且,有的編譯器會自動幫我們把一些邏輯簡單的函式優化為行內函式。
因此,被編譯器優化成行內函式的函式,我們也是沒有辦法抓到呼叫棧的。
補:關於如何檢測App卡頓?
可參考我之前寫的部落格:《iOS 效能監控(二)—— 主執行緒卡頓監控》。
我們能感知到的App卡頓,是由於主執行緒出現卡頓,造成UI更新不及時,從而發生丟幀等情況。(正常情況下,iPhone的螢幕都是60fps
,即一秒重新整理60次。)
那麼,目前比較好的監控方案就是利用runloop
原理去監控App狀態,
方案如下:
-
第一步:開啟一個子執行緒,並開啟子執行緒的
runloop
,讓該子執行緒常駐在App
中。 -
第二步:建立一個
RunloopObserver
(Runloop
觀察者),將RunloopObserver
新增到主執行緒runloop
的commonModes
下觀察。同時,子執行緒的runloop
開始監聽。 -
第三步:每當主執行緒
runloop
的狀態發生變化時,就會通知該RunloopObserver
。並通過發GCD訊號量保證同步操作。同時,子執行緒的runloop
持續監聽。 -
第四步:當主執行緒的
runloop
的狀態長時間卡在BeforeSources
、AfterWaiting
時,就代表當前主執行緒卡頓。 -
第五步:檢測到卡頓,抓棧,保留現場。 同時,將呼叫棧資訊儲存在本地,在合適的時機上報服務端。
Q1:為什麼是主執行緒的
CommonModes
?
主執行緒的runloop有DefaultMode
、UITrackingMode
、UIInitializationMode
、GSEventReceiveMode
、CommonModes
。
其中,CommonModes
是DefaultMode
、UITrackingMode
的集合。
正常情況,也是在這兩個mode
下切換。
Q2:為什麼是
BeforeSources
、AfterWaiting
這兩個狀態?
這就要說到runloop
的執行順序,BeforeSources
之後,主要是處理Source0
事件(響應UIEvent
)。如果卡在這個狀態過久,說明當前App無法響應點選事件。AfterWaiting
之後,說明當前執行緒剛從休眠中喚醒,準備執行timer
事件。但又卡在這個狀態,沒有去執行。也能說明當前App卡頓。
這裡,感謝“鬆的冬天”在評論區的留言與解答:
看runloop
的執行流程,因為真正做事情的通知就是這兩個其他的通知後邊都是緊跟著別的通知BeforeSources
,會阻塞的並不一定是通知後緊跟著的那一件事,比如結束休眠後緊跟著的是處理timer
,接下來的處理GCD Async To Main Queue
,接下來是處理Source1
。其真正的原因是各種要處理的事情阻止了runloop
進入休眠,如果不休眠就會卡頓。
PS:更詳細監控方案過程,可檢視我之前寫的部落格。
可供參考:《iOS 效能監控(二)—— 主執行緒卡頓監控》。
原始碼:
GitHub地址:QiStackFrameLogger
參考與致謝:
1.《獲取任意執行緒呼叫棧的那些事》—— bestswifter
2.《iOS開發高手課》—— 戴銘老師
3.《呼叫棧》—— 維基百科
4.《Call Stack(呼叫棧)是什麼?》—— 知乎
5.《Virtual Memory(虛擬記憶體)是什麼?》
6.《arm64官方檔案》