谈谈iOS获取调用链

本文由云+社区发表html

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

1、栈帧等几个概念

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

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

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来说述ui

以下图所示,不论是较早的帧,仍是调用者的帧,仍是当前帧,它们的结构是彻底同样的,由于每一个帧都是基于一个函数,帧伴随着函数的生命周期一块儿产生、发展和消亡。在这个过程当中用到了上面说的寄存器,fp帧指针,它老是指向当前帧的底部;sp栈指针,它老是指向当前帧的顶部。这两个寄存器用来定位当前帧中的全部空间。编译器须要根据指令集的规则当心翼翼地调整这两个寄存器的值,一旦出错,参数传递、函数返回均可能出现问题。spa

其实这里这几个寄存器会知足必定规则,好比:

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

而由此咱们能够进一步想到,经过sp和fp所指出的栈帧能够恢复出母函数的栈帧,不断递归恢复便恢复除了调用堆栈。向下面代码同样,每次递归pc存储的*(fp + 1)其实就是返回的地址,它在调用者的函数内,利用这个地址咱们能够经过符号表还原出对应的方法名称。

while(fp) {
  pc = *(fp + 1);
  fp = *fp;
}

2、汇编解释下

若是你非要问为何会这样,咱们能够从汇编角度看下函数是怎么调用的,从而更深入理解为何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等。

3、获取调用栈步骤

其实上面的几个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,而后递归去查找母函数的地址,最后将其符号化,便可还原出调用栈。

总结概括了下,获取调用栈须要下面几步:

一、挂起线程

thread_suspend(main_thread);

二、获取当前线程状态上下文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

三、获取当前帧的帧指针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

四、递归遍历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);

上面代码便会从下到上依次打印出调用栈函数中的地址,这个地址老是在函数调用地方的下一个地址,咱们就须要拿这个地址还原出对应的符号名称。

五、恢复线程thread_resume

thread_resume(main_thread);

六、还原符号表

这一步主要是将已经得到的调用链上的地址分别解析出对应的符号。主要是参考了运行时获取函数调用栈 的方法,其中用到的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 操做系统 学习笔记

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

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

相关文章
相关标签/搜索