场景:
在一些 “性能监控” 的工具中,在检测到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官方文档》