阅读 213

如何获取iOS的线程调用栈

如何获取iOS的线程调用栈

我们的开发调试阶段, 有很多场景需要获取方法的调用栈:

  1. 在启动优化中, 可以在编译的时候指定order.file文件, 指定符号表顺序进行二进制重排
  2. 在卡顿监控中, 需要在检测到runloop执行超过阈值时, 能快速打印主线程的调用栈, 并获取指定的执行时间
  3. 在Crash监控中, 需要在APP发生Crash时, 能够快速保留所有线程的函数调用栈情况, 用于后续Crash分析

获取调用栈整体思路可以有如下思路:

  • 根据ARM64汇编中的PC寄存器以及函数调用栈帧的信息, 然后通过地址反向查询符号表中的符号
  • 仅仅监控OC方法调用流程, 所有OC发送消息的方法都能在底层转化成objc_msgSend()/objc_msgSendSuper()方法, hook系统的方法, 插入自己的内容,记录OC方法调用的selfsel名称

目前Crash相关肯定直接通过函数栈帧方式来搞定所有的函数调用栈, 如果是启动优化, 或者监控OC方法耗时, 可以用第二种.

另外也可以直接在LLVM编译时, 静态插装方式直接替换objc_msgSend方法

本文主要总结归纳第一种方式 -- iOS的线程调用栈

1. 函数调用栈的解释

目前iOS中底层执行的是机器码指令, 通常可以使用反汇编方式用汇编重写, 目前基本是ARM64汇编,如图所示:

  • sp寄存器在任意时刻会保存我们栈顶的地址.

  • fp寄存器也称为x29寄存器属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址!(非叶子节点函数保存)

  • lr寄存器, 也称为x30寄存器, 存储返回返回的地址

    注意:ARM64开始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp ARM64里面 对栈的操作是16字节对齐的!!

15468434153501.jpg

常见的函数调用开辟和恢复的栈空间

sub    sp, sp, #0x40             ; 拉伸 0x4064字节)空间
stp    x29, x30, [sp, #0x30]     ; x29\x30 寄存器入栈保护
add    x29, sp, #0x30            ; x29指向栈帧的底部
... 
ldp    x29, x30, [sp, #0x30]     ; 恢复 x29/x30 寄存器的值
add    sp, sp, #0x40             ; 栈平衡
ret
复制代码

因此当有多个函数嵌套调用时, 需要用栈来保存函数执行的Context信息, 比如局部变量, 函数参数等等, 使用一个实例来说没如下:

- (void)foo {
    [self bar];
}

- (void)bar {
    NSLog(@"hello world");
}
复制代码

img

2. 线程中的函数调用栈

操作系统给每个线程都在内核中维护了一个函数调用栈, 在iOS中上面的lr, sp, fp等寄存器的内容都能通过指定的API获取到, arm64中的结构如下:

_STRUCT_ARM_THREAD_STATE64  // arm64
{
	__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 */
};

#if defined(__arm64__)
typedef _STRUCT_MCONTEXT64      *mcontext_t;
#define _STRUCT_MCONTEXT _STRUCT_MCONTEXT64
#else
typedef _STRUCT_MCONTEXT32      *mcontext_t;
#define _STRUCT_MCONTEXT        _STRUCT_MCONTEXT32
#endif
复制代码

因此, 只要我们能获取上面提到的 lr 和 fp寄存器的内容, 然后递归查询函数地址, 然后对地址进行符号化, 就能获取调用栈.

参考Matrix中的实现逻辑, 主要分成如下流程:

1. 首先查询APP的进程中所有的task_threads, matrix中的逻辑如下:

void xxx() {
    // 资源获取
    thread_act_array_t threads;
    mach_msg_type_number_t thread_count;
    if (task_threads(mach_task_self(), &threads, &thread_count) != KERN_SUCCESS) {
        return 0;
    }
    
    // threads 中是当前进程APP中所有的线程! 其中第一个应该是主线程
		//thread_t mainThread = threads[0];
		//currentThread = pthread_mach_thread_np(pthread_self());
    //if (mainThread == currentThread) {
    //   return 0;
    //}
    // 其他核心代码逻辑 ... 
    
    // 资源释放
    for (mach_msg_type_number_t i = 0; i < thread_count; i++) {
        mach_port_deallocate(mach_task_self(), threads[i]);
    }
    vm_deallocate(mach_task_self(), (vm_address_t)threads, sizeof(thread_t) * thread_count);
}
复制代码

一个Mach Task包含它的线程列表。内核提供了task_threads API 调用获取指定 task 的线程列表,然后可以通过thread_info API调用来查询指定线程的信息,在 thread_act.h 中有相关定义。

2. 使用thread_get_state 获取线程上下文ctx

_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);
复制代码

3. 通过_STRUCT_MCONTEXT中的变量ctx获取需要的寄存器内容

    uint64_t pc = ctx.__ss.__pc;
    uint64_t sp = ctx.__ss.__sp;
    uint64_t fp = ctx.__ss.__fp;
复制代码

然后可以根据fp和pc的内容, 递归循环获取调用栈, 网上有一个简单的实现, 该实现会从下到上依次打印出调用栈函数中的地址, 也可以参考matrix或者BSBacktraceLogger:

do {
    // print symbol of (pc);
    pc = *((uint64_t *)fp + 1);
    fp = *((uint64_t *)fp);
} while (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);
复制代码

此时, 我们拥有了每个线程的调用链上的关键pc地址, 此时还剩下最重要的一步, 就是还原符号表!!!

4. 根据pc的地址还原符号表

一般来说APP中会有加载非常多的动态库, 因此可能会涉及很多个不同的MachO, 这些MachO都会被映射到虚拟内存中, 而pc中的地址可能在动态库的MachO Images中, 在开发中可以使用如下方式打印所有Images:

uint64_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
    const struct mach_header *header = _dyld_get_image_header(i);
    const char *name = _dyld_get_image_name(i);
    uint64_t slide = _dyld_get_image_vmaddr_slide(i);
}
复制代码

因此, 我们需要判断pc中的地址具体会落在哪个MachO的虚拟内存中, 这里涉及的内容比较多, 不再展开, 大概流程如下, 可以参考开源库实现:

  1. 根据Image Mach-O Header信息以及segment load command判断pc的地址是否落在改image
  2. 根据image的MachO的LC_SYMTAB以及LC_SEGMENT(__LINKEDIT)符号表查找具体符号
  3. 此外, 还需要遍历符号需找最佳匹配符号

使用pc的地址进行符号化的过程与iOS 优化篇 - 启动优化之Clang插桩实现二进制重排有异曲同工之妙

参考

juejin.cn/post/684490…

juejin.cn/post/685457…

juejin.cn/post/684490…

github.com/bestswifter…

github.com/Tencent/mat…

www.jianshu.com/p/3b83193ff…

github.com/kstenerud/K…

juejin.cn/post/684490…

www.jianshu.com/p/4aadb4fd0…

juejin.cn/post/684490…

文章分类
iOS
文章标签