场景:
在一些 “性能监控” 的工具中,在检测到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, 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
架构的(iPhone5s
及之后发布的所有设备)。
通过查询 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, &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
环境中,由于编译器帮我们做了优化,有一些特殊的调用栈是抓不到的。
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卡顿。
PS:更详细监控方案过程,可查看我之前写的博客。
可供参考:《iOS 性能监控(二)—— 主线程卡顿监控》。
源码:
GitHub地址:QiStackFrameLogger
参考与致谢:
1.《获取任意线程调用栈的那些事》—— bestswifter
2.《iOS开发高手课》—— 戴铭老师
3.《调用栈》—— 维基百科
4.《Call Stack(调用栈)是什么?》—— 知乎
5.《Virtual Memory(虚拟内存)是什么?》
6.《arm64官方文档》