如何获取iOS的线程调用栈
我们的开发调试阶段, 有很多场景需要获取方法的调用栈:
- 在启动优化中, 可以在编译的时候指定
order.file
文件, 指定符号表顺序进行二进制重排 - 在卡顿监控中, 需要在检测到runloop执行超过阈值时, 能快速打印主线程的调用栈, 并获取指定的执行时间
- 在Crash监控中, 需要在APP发生Crash时, 能够快速保留所有线程的函数调用栈情况, 用于后续Crash分析
获取调用栈整体思路可以有如下思路:
- 根据ARM64汇编中的PC寄存器以及函数调用栈帧的信息, 然后通过地址反向查询符号表中的符号
- 仅仅监控OC方法调用流程, 所有OC发送消息的方法都能在底层转化成
objc_msgSend()/objc_msgSendSuper()
方法, hook系统的方法, 插入自己的内容,记录OC方法调用的self
和sel
名称
目前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字节对齐的!!
常见的函数调用开辟和恢复的栈空间
sub sp, sp, #0x40 ; 拉伸 0x40(64字节)空间
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");
}
复制代码
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
的虚拟内存中, 这里涉及的内容比较多, 不再展开, 大概流程如下, 可以参考开源库实现:
- 根据
Image Mach-O Header
信息以及segment load command
判断pc
的地址是否落在改image - 根据image的MachO的
LC_SYMTAB
以及LC_SEGMENT(__LINKEDIT)
符号表查找具体符号 - 此外, 还需要遍历符号需找最佳匹配符号
使用pc的地址进行符号化的过程与iOS 优化篇 - 启动优化之Clang插桩实现二进制重排有异曲同工之妙