iOS开发--探究iOS线程调用栈及符号化

概念

调用栈,也称为执行栈、控制栈、运行时栈与机器栈,是计算机科学中存储运行子程序的重要的数据结构,主要存放返回地址、本地变量、参数及环境传递,用于跟踪每个活动的子例程在完成执行后应该返回控制的点。 一个线程的调用栈如上图所示,它分为若干栈帧(frame),每个栈帧对应一个函数调用,如蓝色部分是DrawSquare函数的栈帧,它在运行过程中调用了DrawLine函数,栈帧为绿色部分表示。栈帧主要包含三部分组成函数参数、返回地址、帧内的本地变量,如上图中的函数DrawLine调用时首先把函数参数入栈,然后把返回地址入栈(表示当前函数执行完后上一栈帧的帧指针),最后是函数内部本地变量(包含函数执行完后继续执行的程序地址)。

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

ARM调用栈

首先ARM架构(64位arm64指令集)下的用于调用栈的各个寄存器,如下: 32位armv7指令集寄存器如下;

  • r15,PC(The Program Counter),指令寄存器,也称为程序计数器,保存的是下一条将要执行的指令的内存地址;
  • r14,LR(The Link Register),链接寄存器,保存着当前函数返回时调用函数的指令的内存地址;
  • r13,SP(The Stack Pointer),堆栈指针,保存着栈顶的指针;
  • r12,IP( The Intra-Procedure-call scratch register),可简单的认为暂存SP。
  • r7,FP(The Frame Pointer),栈帧指针,保存着上一栈帧的指针;
  • R9:操作系统保留
  • R4-R6, R8, R10-R11:没有特殊规定,就是普通的通用寄存器
  • r0-r3,用于存放传递给函数的参数与返回值;

典型的栈帧如下图所示:

main stack frame为调用函数的栈帧,func1 stack frame为当前函数(被调用者)的栈帧,栈底在高地址,栈向下增长。图中FP就是栈基址,它指向函数的栈帧起始地址;SP则是函数的栈指针,它指向栈顶的位置。ARM压栈的顺序很是规矩,依次为当前函数指针PC、返回指针LR、栈指针SP、栈基址FP、传入参数个数及指针、本地变量和临时变量。如果函数准备调用另一个函数,跳转之前临时变量区先要保存另一个函数的参数。

上图的调用栈对应的汇编代码如下。

1.8514行将当前的sp保存在ip中(ip只是个通用寄存器,用来在函数间分析和调用时暂存数据,通常为r12);

2.8518行将4个寄存器从右向左依次压栈。

3.851c行将保存的ip减4,得到当前被调用函数的fp地址,即指向栈里的pc位置。

4.8520行将sp减8,为栈空间开辟出8个字节的大小,用于存放局部便令。

00008514 <func1>:
     8514:   e1a0c00d    mov ip, sp
     8518:   e92dd800    push    {fp, ip, lr, pc}
     851c:   e24cb004    sub fp, ip, #4
     8520:   e24dd008    sub sp, sp, #8
     8524:   e3a03000    mov r3, #0
     8528:   e50b3010    str r3, [fp, #-16]
     852c:   e30805dc    movw    r0, #34268  ; 0x85dc
     8530:   e3400000    movt    r0, #0
     8534:   ebffff9d    bl  83b0 <puts@plt>
     8538:   e51b3010    ldr r3, [fp, #-16]
     853c:   e12fff33    blx r3
     8540:   e3a03000    mov r3, #0
     8544:   e1a00003    mov r0, r3
     8548:   e24bd00c    sub sp, fp, #12
     854c:   e89da800    ldm sp, {fp, sp, pc}

我们可以根据FP和SP寄存器回溯函数调用过程,以上图为例:函数func1的栈中保存了main函数的栈信息(绿色部分的SP和FP),通过这两个值,我们可以知道main函数的栈起始地址(也就是FP寄存器的值), 以及栈顶(也就是SP寄存器的值)。得到了main函数的栈帧,就很容易从里面提取LR寄存器的值了(FP向下偏移4个字节即为LR),也就知道了谁调用了main函数。以此类推,可以得到一个完整的函数调用链(一般回溯到 main函数或者线程入口函数就没必要继续了)。实际上,回溯过程中我们并不需要知道栈顶SP,只要FP就够了。

实例代码如下:

#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 
", c);

    return 0;
}

通过xcrun指定sdk并clang编译指定编译架构-arch,结果如下:

// -arch 表示要编译的架构 包括armv7 armv7s arm64 // -isysroot 指定头文件的根路径 clangSarcharmv64ohellohello.cisysroot/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.4.sdk//也可以使用xcrunxcrunsdk会使用最新的sdk去编译clang -S -arch armv64 -o hello hello.c –isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.4.sdk //也可以使用xcrun,xcrun -sdk 会使用最新的sdk去编译 xcrun -sdk iphoneos clang -S -arch armv64 -o hello hello.c

线程栈

每个线程都有自己的线程栈来保存线程的执行调用情况,通过上述调用栈寄存器SP和FP可以确定栈信息,具体如何获取到线程的栈信息呢?

NSThread提供了[NSThread callstackSymbols]来获取当前线程的调用栈,也可以通过backtrace/backtrace_symbols接口获取,但只能获取当前线程的调用栈,无法获取其他线程的调用栈。所幸Mach内核提供了获取线程上下文的接口thread_get_state以及获取所有线程task_threads,具体定义如下:

kern_return_t thread_get_state
(
    thread_act_t target_act,
    thread_state_flavor_t flavor,
    thread_state_t old_state,
    mach_msg_type_number_t *old_stateCnt
);

#if defined(__x86_64__)
    _STRUCT_MCONTEXT ctx;
    mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT;
    thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);

    uint64_t pc = ctx.__ss.__rip;
    uint64_t sp = ctx.__ss.__rsp;
    uint64_t fp = ctx.__ss.__rbp;
#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);

    uint64_t pc = ctx.__ss.__pc;
    uint64_t sp = ctx.__ss.__sp;
    uint64_t fp = ctx.__ss.__fp;
#endif

//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
);

通过 act_list 数组可以读取该任务的所有线程,获取线程之后,对于每一个线程,可以用 thread_get_state 方法获取它的所有信息,信息填充在 _STRUCT_MCONTEXT 类型的参数中。这个方法中有两个参数随着 CPU 架构的不同而改变,因此需要注意不同 CPU 之间的区别。在 _STRUCT_MCONTEXT 类型的结构体中,存储了当前线程的 Stack Pointer 和最顶部栈帧的 Frame Pointer,从而获取到了整个线程的调用栈。

注意:任务与进程的概念是一一对应的,即iOS系统进程(对应应用)都在底层关联了一个Mach任务对象,因此可以通过mach_task_self()来获取当前进程对应的任务对象;这里的线程为最底层的mach内核线程,posix接口中的线程pthread与内核线程一一对应,是内核线程的抽象,NSThread线程是对pthread的面向对象的封装。

对于函数调用过程中可能存在着异常情况导致栈帧损坏,因此当前现成的栈帧地址在不被允许访问的地址空间,若直接通过thread_get_state获取线程栈帧而获取整个调用栈,存在指针访问错误,导致程序异常崩溃。可使用vm_red_overwrite函数来安全获取线程调用栈,该函数会询问内核是否有权限访问指定的内存,避免指针访问异常。具体的函数使用如下:

typedef struct StackFrameEntry{
    const struct StackFrameEntry * const previous;//前一个栈帧地址
    const uintptr_t return_address;//函数地址
} StackFrameEntry;

//mach_task_self:task对象
//src:fp栈帧指针
//numBytes:sizeof(StackFrameEntry)
//dst:StackFrameEntry指针
//bytesCopied://cpye字节大小
kern_return_t vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied)

获取线程名称

每个内核线程由thread_t类型的id来唯一标识,phread的唯一标识类型为pthread_t,thread_t与pthread_t转换相对容易,但NSThread没有存储pthread_t的标识,不过NSThread能够获取线程名称,而pthread接口提供了pthread_getname_np来获取线程名称,两者名称是一致的,其中np是指not posix(不是跨平台接口)。但是主线程无法通过pthread_getname_np获取名称,所以需要在load方法里获取线程的thread_t;

//具体的thread_t与pthread_t相互转换接口
pthread_t pthread = pthread_from_mach_thread_np((thread_t)thread);

//获取主线程的thread_t
static mach_port_t main_thread_id;
+ (void)load {
    main_thread_id = mach_thread_self();
}

函数符号化

获取所有线程的调用栈地址后,如何将函数地址进行符号化进而转化为可读信息,便于排查定位问题。

定位Image

对于应用会存在多个Image镜像文件(如上图所示),且镜像会映射到唯一的地址段,因此获取的调用栈函数地址就可以确定所属的Image,具体获取镜像相关的信息包括镜像数量、镜像名称、镜像Mach-O头部信息及偏移等信息,可通过dyld提供的相关接口获取,如下:

uint64_t count = _dyld_image_count();//image数量
const struct mach_header *header = _dyld_get_image_header(index);//image mach-o header
const char *name = _dyld_get_image_name(index);//image name
uint64_t slide = _dyld_get_image_vmaddr_slide(index);//ALSR偏移地址

通过遍历获取Image Mach-O Header头部信息及其加载命令来获取所属的地址空间范围来判断是否位于当前Image,具体的代码逻辑如下:

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;
}

查找符号

符号表储存在 Mach-O 文件的 LC_SEGMENT(__LINKEDIT) 段中,涉及其中的符号表(Symbol Table)和字符串表(String Table)。符号表在 Mach-O目标文件中的地址可以通过LC_SYMTAB加载命令指定的 symoff找到,对应的符号名称在stroff,总共有nsyms条符号信息;也就是说,通过LC_SYMTAB来找存储在__LINKEDIT中的符号地址地址。

符号表是一个连续的列表,其中每一项都是struct nlist,如下:

truct nlist {
  union {
    uint32_t n_strx;//符号名在字符串表中的偏移量
  } n_un;
  uint8_t n_type;
  uint8_t n_sect;
  int16_t n_desc;
  uint32_t n_value;//符号在内存中的地址,类似于函数指针
};

通过符号表项中的n_un.n_strx来获取符号名在字符串表String Table中的偏移量,进而获取符号名即函数名;通过n_value来获取符号在内存中的地址,即函数指针;因此就清楚了符号名和内存地址之间的对应关系。具体的获取符号表及字符串表的代码如下:

//获取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;

定位符号

上述查找符号是获取的真正的符号内存地址和函数名,而通过函数调用栈获取的是函数内部执行指令的地址,不过该地址与真正的函数地址偏离不大,因此可以通过遍历符号的内存地址与调用栈函数地址比较得到离符号内存地址最近的最佳匹配符号,即是当前调用栈的符号,具体代码如下:

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++;
        }
    }
}

原文作者:三吉i 原文链接:iOS开发--探究iOS线程调用栈及符号化