谈谈iOS获取调用链

6,151 阅读11分钟

本文由云+社区发表

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

一、栈帧等几个概念

先抛出一个栈帧的概念,解释下什么是栈帧。

应用中新创建的每个线程都有专用的栈空间,栈可以在线程期间自由使用。而线程中有千千万万的函数调用,这些函数共享进程的这个栈空间,那么问题就来了,函数运行过程中会有非常多的入栈出栈的过程,当函数返回backtrace的时候怎样能精确定位到返回地址呢?还有子函数所保存的一些寄存器的内容?这样就有了栈帧的概念,即每个函数所使用的栈空间是一个栈帧,所有的栈帧就组成了这个线程完整的栈

img
栈帧

下面再抛出几个概念:

寄存器中的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 操作系统 学习笔记

此文已由作者授权腾讯云+社区在各渠道发布

获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号