場景:
在一些 「性能監控」 的工具中,在檢測到App主線程卡頓的時候,能夠經過子線程抓取當前時刻全部線程的方法調用堆棧(保存卡頓現場),並在合適的時機(WiFi環境&網絡環境較好的時候)把堆棧信息上傳到咱們的服務端。服務端將堆棧信息過濾分析後,交給客戶端作優化處理。 這樣,就能較好的提升用戶的體驗,並及時發現線上環境下的問題。
同時,也能夠及時發現問題,及時優化咱們的代碼質量和執行效率。
(一個比較好的開發循環)git
那麼,在App發生卡頓時候,咱們該如何抓取方法調用棧呢?堆棧信息又是什麼樣的呢?
本文將經過一個具體的 demo
,闡述如何進行抓棧操做。github
在此以前,首先要感謝我偶像@bestswifter的博客:《獲取任意線程調用棧的那些事》,對我有很大的啓發與幫助。swift
接下來,進入咱們今天的正題:數組
調用棧(
call stack
):
是計算機科學中存儲有關正在運行的子程序的消息的棧。—— 維基百科安全
在咱們程序運行中,一般存在一個函數調用另外一個函數的狀況。
例如,在某個線程中,調用了 func A
。在 func A
執行過程當中,調用了 func B
。bash
那麼,在計算機程序底層須要作哪些事呢?網絡
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
)下,會轉換成以下彙編:app
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
)下,會轉換成以下彙編:ide
QiStackFrameLogger`-[ViewController foo]:
0x10443833c <+0>: sub sp, sp, #0x20 ; =0x20
0x104438340 <+4>: stp x29, x30, [sp, #0x10]
0x104438344 <+8>: add x29, sp, #0x10 ; =0x10
0x104438348 <+12>: adrp x8, 9
0x10443834c <+16>: add x8, x8, #0x5a8 ; =0x5a8
0x104438350 <+20>: str x0, [sp, #0x8]
0x104438354 <+24>: str x1, [sp]
0x104438358 <+28>: ldr x9, [sp, #0x8]
0x10443835c <+32>: ldr x1, [x8]
0x104438360 <+36>: mov x0, x9
0x104438364 <+40>: bl 0x10443a0ac ; symbol stub for: objc_msgSend
-> 0x104438368 <+44>: ldp x29, x30, [sp, #0x10]
0x10443836c <+48>: add sp, sp, #0x20 ; =0x20
0x104438370 <+52>: ret
QiStackFrameLogger`-[ViewController bar]:
0x104438374 <+0>: sub sp, sp, #0x20 ; =0x20
0x104438378 <+4>: stp x29, x30, [sp, #0x10]
0x10443837c <+8>: add x29, sp, #0x10 ; =0x10
0x104438380 <+12>: str x0, [sp, #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 | 平臺保留,操做系統自身使用。 |
x1七、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, &frame, 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,
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]); //!< 經過回溯獲得的棧幀,找到對應的符號名。
}
}
// 經過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
環境中,因爲編譯器幫咱們作了優化,有一些特殊的調用棧是抓不到的。
尾調用優化的本質,是 「棧幀」 的複用。
所以,每次壓棧都會複用原來的棧幀。
這時候,咱們抓到的堆棧永遠只有最下層的棧,而中間的調用棧全都丟失了。
PS:關於尾調用優化,我以前實習的時候寫了一篇博客。
可供參考:《iOS objc_msgSend尾調用優化詳解》
這個也比較好理解,由於內聯函數會在編譯時期展開。
直接複製代碼塊,從而節省了調用函數帶來的額外時間開支。
而且,有的編譯器會自動幫咱們把一些邏輯簡單的函數優化爲內聯函數。
所以,被編譯器優化成內聯函數的函數,咱們也是沒有辦法抓到調用棧的。
可參考我以前寫的博客:《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官方文檔》