乐趣区

谈谈iOS获取调用链

本文由云 + 社区发表 iOS 开发过程中难免会遇到卡顿等性能问题或者死锁之类的问题,此时如果有调用堆栈将对解决问题很有帮助。那么在应用中如何来实时获取函数的调用堆栈呢?本文参考了网上的一些博文,讲述了使用 mach thread 的方式来获取调用栈的步骤,其中会同步讲述到栈帧的基本概念,并且通过对一个 demo 的汇编代码的讲解来方便理解获取调用链的原理。

一、栈帧等几个概念
先抛出一个栈帧的概念,解释下什么是栈帧。
应用中新创建的每个线程都有专用的栈空间,栈可以在线程期间自由使用。而线程中有千千万万的函数调用,这些函数共享进程的这个栈空间,那么问题就来了,函数运行过程中会有非常多的入栈出栈的过程,当函数返回 backtrace 的时候怎样能精确定位到返回地址呢?还有子函数所保存的一些寄存器的内容?这样就有了栈帧的概念,即每个函数所使用的栈空间是一个栈帧,所有的栈帧就组成了这个线程完整的栈。
栈帧
下面再抛出几个概念:
寄存器中的 fp,sp,lr,pc。
寄存器是和 CPU 联系非常紧密的一小块内存,经常用于存储一些正在使用的数据。对于 32 位架构 armv7 指令集的 ARM 处理器有 16 个寄存器,从 r0 到 r15,每一个都是 32 位比特。调用约定指定他们其中的一些寄存器有特殊的用途,例如:

r0-r3:用于存放传递给函数的参数;
r4-r11:用于存放函数的本地参数;
r11:通常用作桢指针 fp(frame pointer 寄存器),栈帧基址寄存器,指向当前函数栈帧的栈底,它提供了一种追溯程序的方式,来反向跟踪调用的函数。
r12:是内部程序调用暂时寄存器。这个寄存器很特别是因为可以通过函数调用来改变它;
r13:栈指针 sp(stack pointer)。在计算机科学内栈是非常重要的术语。寄存器存放了一个指向栈顶的指针。看这里了解更多关于栈的信息;
r14:是链接寄存器 lr(link register)。它保存了当目前函数返回时下一个函数的地址;
r15:是程序计数器 pc(program counter)。它存放了当前执行指令的地址。在每个指令执行完成后会自动增加;

不同指令集的寄存器数量可能会不同,pc、lr、sp、fp 也可能使用其中不同的寄存器。后面我们先忽略 r11 等寄存器编号,直接用 fp,sp,lr 来讲述
如下图所示,不管是较早的帧,还是调用者的帧,还是当前帧,它们的结构是完全一样的,因为每个帧都是基于一个函数,帧伴随着函数的生命周期一起产生、发展和消亡。在这个过程中用到了上面说的寄存器,fp 帧指针,它总是指向当前帧的底部;sp 栈指针,它总是指向当前帧的顶部。这两个寄存器用来定位当前帧中的所有空间。编译器需要根据指令集的规则小心翼翼地调整这两个寄存器的值,一旦出错,参数传递、函数返回都可能出现问题。
其实这里这几个寄存器会满足一定规则,比如:

fp 指向的是当面栈帧的底部,该地址存的值是调用当前栈帧的上一个栈帧的 fp 的地址。
lr 总是在上一个栈帧(也就是调用当前栈帧的栈帧)的顶部,而栈帧之间是连续存储的,所以 lr 也就是当前栈帧底部的上一个地址,以此类推就可以推出所有函数的调用顺序。这里注意,栈底在高地址,栈向下增长

而由此我们可以进一步想到,通过 sp 和 fp 所指出的栈帧可以恢复出母函数的栈帧,不断递归恢复便恢复除了调用堆栈。向下面代码一样,每次递归 pc 存储的 *(fp + 1) 其实就是返回的地址,它在调用者的函数内,利用这个地址我们可以通过符号表还原出对应的方法名称。
while(fp) {
pc = *(fp + 1);
fp = *fp;
}
二、汇编解释下
如果你非要问为什么会这样,我们可以从汇编角度看下函数是怎么调用的,从而更深刻理解为什么 fp 总是存储了上一个栈帧的 fp 的地址,而 fp 向前一个地址为什么总是 lr?
写如下一个 demo 程序,由于我是在 mac 上做实验,所以直接使用 clang 来编译出可执行程序,然后再用 hopper 工具反汇编查看汇编代码,当然也可直接使用 clang 的
- S 参数指定生产汇编代码。
demo 源码
#import <Foundation/Foundation.h>

int func(int a);

int main (void)
{
int a = 1;
func(a);
return 0;
}

int func (int a)
{
int b = 2;
return a + b;
}
汇编语言
; ================ B E G I N N I N G O F P R O C E D U R E ================

; Variables:
; var_4: -4
; var_8: -8
; var_C: -12

_main:
0000000100000f70 push rbp
0000000100000f71 mov rbp, rsp
0000000100000f74 sub rsp, 0x10
0000000100000f78 mov dword [rbp+var_4], 0x0
0000000100000f7f mov dword [rbp+var_8], 0x1
0000000100000f86 mov edi, dword [rbp+var_8] ; argument #1 for method _func
0000000100000f89 call _func
0000000100000f8e xor edi, edi
0000000100000f90 mov dword [rbp+var_C], eax
0000000100000f93 mov eax, edi
0000000100000f95 add rsp, 0x10
0000000100000f99 pop rbp
0000000100000f9a ret
; endp
0000000100000f9b nop dword [rax+rax]

; ================ B E G I N N I N G O F P R O C E D U R E ================

; Variables:
; var_4: -4
; var_8: -8

_func:
0000000100000fa0 push rbp ; CODE XREF=_main+25
0000000100000fa1 mov rbp, rsp
0000000100000fa4 mov dword [rbp+var_4], edi
0000000100000fa7 mov dword [rbp+var_8], 0x2
0000000100000fae mov edi, dword [rbp+var_4]
0000000100000fb1 add edi, dword [rbp+var_8]
0000000100000fb4 mov eax, edi
0000000100000fb6 pop rbp
0000000100000fb7 ret
需要注意,由于是在 mac 上编译出可执行程序,指令集已经是 x86-64,所以上文的 fp、sp、lr、pc 名称和使用的寄存器发生了变化,但含义基本一致,对应关系如下:

fp—-rbp
sp—-rsp
pc—-rip

接下来我们看下具体的汇编代码,可以看到在 main 函数中在经过预处理和参数初始化后,通过 call _func 来调用了 func 函数,这里 call _func 其实等价于两个汇编命令:
Pushl %rip // 保存下一条指令(第 41 行的代码地址)的地址,用于函数返回继续执行
Jmp _func // 跳转到函数 foo
于是,当 main 函数调用了 func 函数后,会将下一行地址 push 进栈,至此,main 函数的栈帧已经结束,然后跳转到 func 的代码处开始继续执行。可以看出,rip 指向的函数下一条地址,即上文中所说的 lr 已经入栈,在栈帧的顶部。
而从 func 的代码可以看到,首先使用 push rbp 将帧指针保存起来,而由于刚跳转到 func 函数,此时 rbp 其实是上一个栈帧的帧指针,即它的值其实还是上一个栈帧的底部地址,所以此步骤其实是将上一个帧底部地址保存了下来。
下一句汇编语句 mov rbp, rsp 将栈顶部地址 rsp 更新给了 rbp,于是此时 rbp 的值就成了栈的顶部地址,也是当前栈帧的开始,即 fp。而栈顶部又正好是刚刚 push 进去的存储上一个帧指针地址的地址,所以 rbp 指向的时当前栈帧的底部,但其中保存的值是上一个栈帧底部的地址。
至此,也就解释了为什么 fp 指向的地址存储的内容是上一个栈帧的 fp 的地址,也解释了为什么 fp 向前一个地址就正好是 lr。
另外一个比较重要的东西就是出入栈的顺序,在 ARM 指令系统中是地址递减栈,入栈操作的参数入栈顺序是从右到左依次入栈,而参数的出栈顺序则是从左到右的你操作。包括 push/pop 和 LDMFD/STMFD 等。
三、获取调用栈步骤
其实上面的几个 fp、lr、sp 在 mach 内核提供的 api 中都有定义,我们可以使用对应的 api 拿到对应的值。如下便是 64 位和 32 位的定义
_STRUCT_ARM_THREAD_STATE64
{
__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 */
};
_STRUCT_ARM_THREAD_STATE
{
__uint32_t r[13]; /* General purpose register r0-r12 */
__uint32_t sp; /* Stack pointer r13 */
__uint32_t lr; /* Link register r14 */
__uint32_t pc; /* Program counter r15 */
__uint32_t cpsr; /* Current program status register */
};
于是,我们只要拿到对应的 fp 和 lr,然后递归去查找母函数的地址,最后将其符号化,即可还原出调用栈。
总结归纳了下,获取调用栈需要下面几步:
1、挂起线程
thread_suspend(main_thread);
2、获取当前线程状态上下文 thread_get_state
_STRUCT_MCONTEXT ctx;

#if defined(__x86_64__)

mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT;
thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);

#elif defined(__arm64__)
_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);

#endif
3、获取当前帧的帧指针 fp
#if defined(__x86_64__)
uint64_t pc = ctx.__ss.__rip;
uint64_t sp = ctx.__ss.__rsp;
uint64_t fp = ctx.__ss.__rbp;
#elif defined(__arm64__)
uint64_t pc = ctx.__ss.__pc;
uint64_t sp = ctx.__ss.__sp;
uint64_t fp = ctx.__ss.__fp;
#endif
4、递归遍历 fp 和 lr,依次记录 lr 的地址
while(fp) {
pc = *(fp + 1);
fp = *fp;
}
这一步我们其实就是使用上面的方法来依次迭代出调用链上的函数地址,代码如下
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);
上面代码便会从下到上依次打印出调用栈函数中的地址,这个地址总是在函数调用地方的下一个地址,我们就需要拿这个地址还原出对应的符号名称。
5、恢复线程 thread_resume
thread_resume(main_thread);
6、还原符号表
这一步主要是将已经获得的调用链上的地址分别解析出对应的符号。主要是参考了运行时获取函数调用栈 的方法,其中用到的 dyld 链接 mach- o 文件的基础知识,后续会专门针对这里总结一篇文章。
enumerateSegment(header, [&](struct load_command *command) {
if (command->cmd == LC_SYMTAB) {
struct symtab_command *symCmd = (struct symtab_command *)command;

uint64_t baseaddr = 0;
enumerateSegment(header, [&](struct load_command *command) {
if (command->cmd == LC_SEGMENT_64) {
struct segment_command_64 *segCmd = (struct segment_command_64 *)command;
if (strcmp(segCmd->segname, SEG_LINKEDIT) == 0) {
baseaddr = segCmd->vmaddr – segCmd->fileoff;
return true;
}
}
return false;
});

if (baseaddr == 0) return false;

nlist_64 *nlist = (nlist_64 *)(baseaddr + slide + symCmd->symoff);
uint64_t strTable = baseaddr + slide + symCmd->stroff;

uint64_t offset = UINT64_MAX;
int best = -1;
for (int k = 0; k < symCmd->nsyms; k++) {
nlist_64 &sym = nlist[k];
uint64_t d = pcSlide – sym.n_value;
if (offset >= d) {
offset = d;
best = k;
}
}
if (best >= 0) {
nlist_64 &sym = nlist[best];
std::cout << “SYMBOL: ” << (char *)(strTable + sym.n_un.n_strx) << std::endl;
}

return true;
}
return false;
});
参考
函数调用栈空间以及 fp 寄存器
函数调用栈
也谈栈和栈帧
运行时获取函数调用栈
深入解析 Mac OS X & iOS 操作系统 学习笔记
此文已由作者授权腾讯云 + 社区在各渠道发布
获取更多新鲜技术干货,可以关注我们腾讯云技术社区 - 云加社区官方号及知乎机构号

退出移动版