阅读 2927

一文读懂iOS线程调用栈原理

高级程序员都深入到汇编、寄存器、内核排查bug,你还在看日志嘛?本文带你窥探汇编、函数调用过程及寄存器的使用,并深入到内核,掌握KSCrash崩溃收集框架最重要的:线程调用栈原理。其实原理很简单,走起~

简介

使用场景

说到线程调用栈大家肯定都不陌生,比如我们经常debug调试时断点查看调用栈,如下图所示:

还有应用排除bug的崩溃日志报告或者使用崩溃日志收集工具来收集整个线程的调用栈,如下图:

还要一个重要的用途就是应用性能优化中的“卡顿检测”,需要获取主线程的调用栈,来检测具体的函数调用过程分析耗时来优化性能。

开源框架

  • 堆栈信息收集开源框架,如PLCrashReporterKSCrash等;
  • 友盟BuglySDK不仅提供Crash捕获和堆栈信息收集,还会集成分析,统计等服务,非常完善;
  • 卡顿检测获取线程堆栈信息,如微信开源性能探测工具Matrix,检测包括崩溃、卡顿及爆内存,目前包含两个插件:WCCrashBlockMonitorPluginWCMemoryStatPlugin,其中卡顿检测基于KSCrash框架开发,通过检查RunLoop运行状态判断应用是否卡顿,具备耗时堆栈提取能力,可获取最近时间最耗时的主线程堆栈。

这些开源框架最基本的功能就是获取线程调用栈,并获取调用栈函数符号信息,比如函数名称、函数地址、函数所在的库等。

什么是调用栈

概念

调用栈,也称为执行栈、控制栈、运行时栈与机器栈,是计算机科学中存储运行子程序的重要的数据结构,主要存放返回地址、本地变量、参数及环境传递,用于跟踪每个活动的子例程在完成执行后应该返回控制的点。

来看张图就明白了:

调用栈示意图

它分为若干栈帧(frame),每个栈帧对应一个函数调用,如蓝色部分是DrawSquare函数的栈帧,它在运行过程中调用了DrawLine函数,栈帧为绿色部分表示。栈帧主要包含三部分组成函数参数、返回地址、帧内的本地变量,如上图中的函数DrawLine调用时首先把函数参数入栈,然后把返回地址入栈(表示当前函数执行完后上一栈帧的帧指针),最后是函数内部本地变量(包含函数执行完后继续执行的程序地址)。

大部分操作系统栈的增长方向都是从上往下(包括iOS/Mac),Stack Pointer指向栈顶部,Frame Pointer指向上一栈帧的Stack Pointer值,通过Frame Pointer就可以递归回溯获取整个调用栈。

x86_64

这里以x86_64为例说明,ARM寄存器存在差异,但大同小异。

x86-64架构处理器寄存器如下图所示:

img

上图可以看出帧指针保存的为“上一栈帧地址+返回地址”,其中返回地址就是调用者执行调用函数后的下一条指令的地址;

实战演练

#include <stdio.h>

int add(int a, int b){
    return a + b;
}

int main(){
    int a = 10;
    int b = 20;
    int c = add(a, b);
    printf("add ret:%d \n", c);

    return 0;
}
复制代码

使用x86-64架构编译,汇编实现如下:

具体使用ARM64架构编译,可使用xcrun -sdk iphoneos clang -S -arch armv64 xxx.c -o xxx

main函数调用add函数前使用callq汇编指令,该指令会自动将当前函数的下一条指令地址压入栈,即add函数返回后会自动出栈来执行的该指令地址内的指令。进入add函数后,会自动压入上一函数的rbp帧指针入栈,进而指向当前函数的rsp栈指针,即此时帧指针指向当前函数add的栈低。帧指针fp保存的为上一栈帧的地址,fp+1保存的为pc上一函数的指令地址。

我们这里实现来验证下:

上图中的0x100000f68就是调用add函数入栈的函数返回后继续执行的指令地址,调用add函数首先要压入帧指针地址push %rbp,所以,帧指针fp保存的为上一栈帧的地址,fp+1保存的为pc上一函数的指令地址。

因此,可以通过调用栈信息获取整个函数调用关系,如下图所示:

调用处理如下:

while(FP) {
  PC = *(FP + 1);
  FP = *FP;
}
复制代码

通过遍历调用栈就可以获取整个函数调用关系,即获取函数指令地址FP,就可以获取函数的真实地址,进而获取函数名称及其镜像相关信息,若存在dSYM符号表就可以找到函数所在的行号信息。

其中当前函数的地址可通过当前rip寄存器来获取;

注意:该处的函数地址为接近真实函数地址!

在执行代码区域,每个符号之间是连续的(比如前面的demo中的函数),而且符号会全部保存在符号表中,那么我们可以遍历符号表,查找到小于函数地址位置,并且距离函数地址最近的一个符号,那么我们就可以认为我们的函数跳转发生在该函数内部,进而来确定函数符号最终的地址。

如何获取线程调用栈

当前函数调用栈

熟悉的是使用[NSThread callstackSymbols]来获取当前函数调用栈,如下图所示:

但若符号被strip裁剪后,无法通过该方法获取当前线程的完整的调用栈信息(如函数符号名称);且若应用于卡顿检测时,卡顿检测结合runloop,若使用dispatch_async/performSelector方法,其方法也是添加至runloop,因此无法实时获取主线程调用栈。通过其他线程通信方式来与主线程通信,如信号/Mach Port方式,需要在指向线程添加额外的代码,若需要获取所有线程调用栈时,该方式不通用且繁琐,且无法处理线程崩溃的情况,因此需要一种通用的获取所有线程的调用栈方式。

所有线程调用栈

首先要获取所有线程,那如何获取当前进程的所有线程?

所幸Mach内核提供了用户态接口,如下:

//task_threads 将 target_task 任务中的所有线程保存在 act_list 数组中,数组中包含 act_listCnt 个线程,这里使用mach_task_self()获取当前进程标记 target_task
kern_return_t task_threads
(
    task_inspect_t target_task,
    thread_act_array_t *act_list,
    mach_msg_type_number_t *act_listCnt
);
复制代码

从名称上看是”从任务中获取所有线程“, 那为啥是任务?这里做个小插曲来阐述下iOS/Mac系统XNU内核中的任务和线程与我们熟知的进程和线程之间的关系,如下图:

一句话解释:内核中的进程和线程底层实现都是基于Mach任务和线程,其中任务是线程的容器来管理资源,比如文件、I/O设备句柄等,我们熟知的进程和线程都有对应的Mach底层的任务和线程。因此,可以通过底层任务来获取所有包含的线程。

那获取到所有线程后就需要获取线程的调用栈那如何获取呢?

这里Mach内核在用户态层暴露了相应的接口,如下:

//获取线程状态信息
kern_return_t thread_get_state
(
    thread_act_t target_act,                            //目标线程,通过task_threads接口来获取
    thread_state_flavor_t flavor,                   //线程状态类型,如[ARM/x86]_THREAD_STATE64
    thread_state_t old_state,                           //线程状态信息,可获取线程调用栈寄存器信息
    mach_msg_type_number_t *old_stateCnt    //线程状态信息成员数目
);
复制代码

其中thread_state_t成员就保存了线程调用栈信息,如下:

_STRUCT_X86_THREAD_STATE64
{
    __uint64_t  __rax;
    __uint64_t  __rbx;
    __uint64_t  __rcx;
    __uint64_t  __rdx;
    __uint64_t  __rdi;
    __uint64_t  __rsi;
    __uint64_t  __rbp;  //帧指针
    __uint64_t  __rsp;  //栈指针
    __uint64_t  __r8;
    __uint64_t  __r9;
    __uint64_t  __r10;
    __uint64_t  __r11;
    __uint64_t  __r12;
    __uint64_t  __r13;
    __uint64_t  __r14;
    __uint64_t  __r15;
    __uint64_t  __rip;  //当前线程指令地址
    __uint64_t  __rflags;
    __uint64_t  __cs;
    __uint64_t  __fs;
    __uint64_t  __gs;
};
复制代码

通过上述接口就可以获取到前面所说的关键调用栈寄存器,如rbp帧指针、rsp栈指针及rip当前线程指令地址,因此就可以通过rbp来获取整个调用栈及其函数调用函数地址。

通过上述两个接口就可以获取所有线程及线程的调用栈的整个调用函数地址信息,因此,就可以通过函数地址来获取相关的函数符号名称。

函数地址符号化

如何通过函数地址来获取函数相关的信息,如所在的镜像名称及其地址,符号地址及其名称,先上图:

步骤如下:

  • 获取函数地址

    通过task_threads获取所有线程,并通过thread_get_state获取线程调用栈,进而获取当前线程所有的函数地址;

  • 定位镜像

    dyld提供了镜像相关的接口,如获取镜像数量_dyld_image_count、名称_dyld_get_image_name及其地址_dyld_get_image_header,通过镜像地址就可以获取Mach-O相关的信息,如下图所示:

    一句话概述:Mach-O中的Mach64 Header中包含了Load Commands数量,Load Commands加载命令中包含了LC_SEGMENT_64,该加载命令数据结构包含了命令名称Command、虚拟地址VM Address及其大小VM Size,因此可以通过遍历获取LC_SEGMENT_64中的各个段的虚拟起始地址及其范围,就可以比较来定位是否在该段中,进而就可以确定是否在该镜像中。具体代码如下:

    static uint32_t imageIndexContainingAddress(const uintptr_t address)
    {
        const uint32_t imageCount = _dyld_image_count();
        const struct mach_header* header = 0;
        
        for(uint32_t iImg = 0; iImg < imageCount; iImg++)
        {
            header = _dyld_get_image_header(iImg);
            if(header != NULL)
            {
                // Look for a segment command with this address within its range.
                uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(iImg);
                uintptr_t cmdPtr = firstCmdAfterHeader(header);
                if(cmdPtr == 0)
                {
                    continue;
                }
                for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++)
                {
                    const struct load_command* loadCmd = (struct load_command*)cmdPtr;
                    if(loadCmd->cmd == LC_SEGMENT)
                    {
                        const struct segment_command* segCmd = (struct segment_command*)cmdPtr;
                        if(addressWSlide >= segCmd->vmaddr &&
                           addressWSlide < segCmd->vmaddr + segCmd->vmsize)
                        {
                            return iImg;
                        }
                    }
                    else if(loadCmd->cmd == LC_SEGMENT_64)
                    {
                        const struct segment_command_64* segCmd = (struct segment_command_64*)cmdPtr;
                        if(addressWSlide >= segCmd->vmaddr &&
                           addressWSlide < segCmd->vmaddr + segCmd->vmsize)
                        {
                            return iImg;
                        }
                    }
                    cmdPtr += loadCmd->cmdsize;
                }
            }
        }
        return UINT_MAX;
    }
    复制代码
  • 查找符号

    通过LC_SYMTAB加载命令获取符号表及字符串表的信息,如地址、数量及大小,就可以获取符号表中的所有符号及字符串表中对应的函数名称,具体代码如下:

    //获取Mach-O Header
    const struct mach_header* header = _dyld_get_image_header(index);
    //通过header遍历Load Commands获取_LINKEDIT 及 LC_SYMTAB
    for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++)
    {
            const struct load_command* loadCmd = (struct load_command*)cmdPtr;
        if(loadCmd->cmd == LC_SYMTAB){
          symtabCmd = loadCmd;
        } else if(loadCmd->cmd == LC_SEGMENT_64) {
            const struct segment_command_64* segmentCmd = (struct segment_command_64*)cmdPtr;
            if(strcmp(segmentCmd->segname, SEG_LINKEDIT) == 0)
            {
                linkeditSegment = segmentCmd;
            }
        }
    }
    
    //基址 = 偏移量 + _LINKEDIT段虚拟地址 - _LINKEDIT段文件偏移地址
    uintptr_t linkeditBase = (uintptr_t)slide + linkeditSegment->vmaddr - linkeditSegment->fileoff;
    //符号表的地址 = 基址 + 符号表偏移量 
    const nlist_t *symbolTable = (nlist_t *)(linkeditBase + symtabCmd->symoff);
    //字符串表的地址 = 基址 + 字符串表偏移量 
    char *stringTab = (char *)(linkeditBase + symtabCmd->stroff);
    //符号数量
    uint32_t symNum = symtabCmd->nsyms;
    复制代码
  • 定位符号

    通过遍历符号表中的所有符号地址来匹配与当前函数地址最接近的,即为要寻找的函数符号,并通过符号表中的String Table Index字符串表偏移量来获取函数符号名称,具体代码如下:

    const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
    const uintptr_t addressWithSlide = address - imageVMAddrSlide;//address为调用栈内存地址
    //遍历符号需找最佳匹配符号
    for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++)
    {
        // If n_value is 0, the symbol refers to an external object.
        if(symbolTable[iSym].n_value != 0)
        {
            uintptr_t symbolBase = symbolTable[iSym].n_value;//获取符号的内存地址(函数指针)
            uintptr_t currentDistance = addressWithSlide - symbolBase;
            if((addressWithSlide >= symbolBase) &&
            (currentDistance <= bestDistance))
            {
                bestMatch = symbolTable + iSym;//最佳匹配符号地址
                bestDistance = currentDistance;//调用栈内存地址与当前符号内存地址距离
            }
        }
    }
    ÅÇ
    if(bestMatch != NULL)
    {
        info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
        if(bestMatch->n_desc == 16)
        {
            // This image has been stripped. The name is meaningless, and
            // almost certainly resolves to "_mh_execute_header"
            info->dli_sname = NULL;
        }
        else
        {
            //获取符号名
            info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
            if(*info->dli_sname == '_')
            {
                info->dli_sname++;
            }
        }
    }
    复制代码

思考与探索

  • 如何获取线程的名称
  • 如何通过dSYM调试符号表来获取函数符号所对应的文件行号