这人啊,上了年纪就是比较懒,继上一篇写完后,就一直懒得写这篇,拖着拖着2021年都快结束了。当我准备动手写这篇文章时,才发现这里涉及到的知识很多,限于篇幅,我也只能写出关键点,就不敢再称史上最全拉。
本文是 Crash 系列的第二篇,着重在于如何获取 crash 时候的堆栈。
这个系列的目录如下:
- Crash 的监听
- 堆栈分析
- KSCrash 源码解析
一 函数调用栈
尽管每次看到计算机底层的文章都有种让人望而生畏的感觉,但是对于想搞清楚函数调用栈是怎么获取的,就必须了解这个机制。大学时候《计算机组成原理》的高分大佬可以忽略此章节。
这里为了让大家对函数调用栈有个大致的印象,先放出一张栈帧(x86架构,下同)的简单结构图:
在上图中,是2个函数的调用,Caller
调用了Callee
,其中绿色和蓝色就分别代表了2个栈帧,多个栈帧就组合了我们的函数调用栈。如上图所示,不管是较早的帧,还是调用者的帧,还是当前帧,它们的结构是完全一样的,因为每个帧都是基于一个函数,帧伴随着函数的生命周期一起产生、发展和消亡。
1. 寄存器
不了解计算机底层的同学看到上面的 eip、ebp、esp 那是一个头大,现在我先做一个简单的介绍,要真正了解这些寄存器的作用,需要结合合后面的内容。
寄存器是和CPU联系非常紧密的一小块内存,经常用于存储一些正在使用的数据。ARM64 有34个寄存器,包括31个通用寄存器、SP、PC、CPSR。调用约定指定他们其中的一些寄存器有特殊的用途,例如:
- x0-x28:通用寄存器,如果有需要可以当做32bit使用:WO-W30(兼容32位)
- x29(FP):通常用作桢指针fp(frame pointer寄存器),栈帧基址寄存器,指向当前函数栈帧的栈底
- x30(LR):是链接寄存器lr(link register)。它保存了当目前函数返回时下一个函数的地址;
- SP:栈指针sp(stack pointer)。在计算机科学内栈是非常重要的术语。寄存器存放了一个指向栈顶的指针。使用 SP/WSP来进行对SP寄存器的访问。
- PC:是程序计数器pc(program counter)。它存放了当前执行指令的地址。在每个指令执行完成后会自动增加;
- CPSR: 状态寄存器
可能有人会疑惑,为什么我讲的是 fp、sp 等,ebp、esp 是啥含义怎么没有说呢?这是因为不同的指令集下使用了不同的名字。从开源库BSBacktraceLogger
的宏定义,我们看到部分对应关系:
#if defined(__arm64__)
#define DETAG_INSTRUCTION_ADDRESS(A) ((A) & ~(3UL))
#define BS_THREAD_STATE_COUNT ARM_THREAD_STATE64_COUNT
#define BS_THREAD_STATE ARM_THREAD_STATE64
#define BS_FRAME_POINTER __fp
#define BS_STACK_POINTER __sp
#define BS_INSTRUCTION_ADDRESS __pc
#elif defined(__arm__)
#define DETAG_INSTRUCTION_ADDRESS(A) ((A) & ~(1UL))
#define BS_THREAD_STATE_COUNT ARM_THREAD_STATE_COUNT
#define BS_THREAD_STATE ARM_THREAD_STATE
#define BS_FRAME_POINTER __r[7]
#define BS_STACK_POINTER __sp
#define BS_INSTRUCTION_ADDRESS __pc
#elif defined(__x86_64__)
#define DETAG_INSTRUCTION_ADDRESS(A) (A)
#define BS_THREAD_STATE_COUNT x86_THREAD_STATE64_COUNT
#define BS_THREAD_STATE x86_THREAD_STATE64
#define BS_FRAME_POINTER __rbp
#define BS_STACK_POINTER __rsp
#define BS_INSTRUCTION_ADDRESS __rip
#elif defined(__i386__)
#define DETAG_INSTRUCTION_ADDRESS(A) (A)
#define BS_THREAD_STATE_COUNT x86_THREAD_STATE32_COUNT
#define BS_THREAD_STATE x86_THREAD_STATE32
#define BS_FRAME_POINTER __ebp
#define BS_STACK_POINTER __esp
#define BS_INSTRUCTION_ADDRESS __eip
#endif
而在 Xcode,我们在__mcontext.h
和_structs.h
也可以看到不同平台下_STRUCT_MCONTEXT64
的定义(不同架构不同目录)。
2. 函数调用过程
下图是一个即将调用callee
函数的堆栈模拟图,我们将从这个模拟图中了解函数调用的过程。
-
调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
-
调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
-
在被调函数中,被调函数会先保存调用者函数的栈底地址(push fp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov fp,sp);
-
为被调函数
callee
开辟空间,也就是将 SP下移 N 个字节(sub $N, sp),N 的大小取决于calle
的代码。 -
在被调函数中,从fp的位置处开始存放被调函数中的局部变量(如下图的 x/y)和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈。
3. 栈帧
我们都知道栈是一种后进先出的数据结构,函数调用栈既指具体实现,也是一个抽象的概念——由“栈帧”组成的栈。从上面的函数调用模拟过程,我们知道一个函数调用栈的大致结构如下:
在每个栈帧中,fp 保存调用者函数的地址,总是指向函数栈栈底;sp总是指向函数栈栈顶。以 fp 地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数的局部变量值。
一般规律,[fp+4]处为被调函数的返回地址,[fp+8]处为传递给被调函数的第一个参数(最后一个入栈的参数,此处假设其占用4字节内存)的值,[fp-4]处为被调函数中的第一个局部变量,[fp]处为上一层fp值;由于fp中的地址处总是上一层函数调用时的fp,而在每一层函数调用中,都能通过当时的fp值向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值。lr总是在上一个栈帧(也就是调用当前栈帧的栈帧)的顶部,而栈帧之间是连续存储的,所以lr也就是当前栈帧底部的上一个地址,以此类推就可以推出所有函数的调用顺序。
4. x86 架构和 arm64 架构的差异
我们前面所讲的栈帧结构以及栈帧间的关系是基于 x86 架构,也就是模拟器的场景。但是 app 正常是运行在移动设备上的,也就是 arm 架构上。抽象的结构上看两者的栈帧结构类似,但是毕竟有些差异会影响我们获取函数调用栈。(参数传递的方式也有不同,但因不是本文重点,暂不谈及)
上图分别是 x86架构和 arm64架构下栈帧架构差异。限于篇幅,因 arm32位的设备已经很少,不做讨论。这两个平台在函数调用上的比较如下:
- 在 x86 架构下,调用函数会先把下一个指令的地址压栈,作为返回地址。而 arm64 架构则是先把下一个指令的地址存到 LR。下一个函数会在函数一开始的时候,就将FP、LR 压栈,然后把 FP 重设。
- 由于在 x86 架构下靠把返回地址压栈的方式来实现栈帧之间的联系,所以在 x86 架构下 LR 地址并无使用。而在 arm64 下如果栈顶函数没有对其他函数的调用,那么编译器可能会把将 FP/LR的压栈操作移除。也即是说并不是所有栈帧的结构一致。
- 无论是 x86 还是 arm 架构,fp 指针指向栈底,sp 指针指向栈顶。(部分文章说 arm64 架构下的 fp sp 都是栈顶指针是错的,可以在 xcode 下断点验证)
二 获取所有线程的函数调用栈
在上一章节,我们花了很长的篇幅讲述了函数调用栈的结构。那么在具备这些知识后,我们就可以讨论如何获取当前所有线程的堆栈。
1. Mach/pthead 的关系
让我们思考一下,我们要分析出崩溃堆栈,需要什么信息?首先是要有办法获取所有的线程,其次要能获取每个线程的堆栈,最后是要能拿到每个栈帧的寄存器的具体内容(fp/sp/lr...)。那么让我们先看看如何获取当前的所有线程。
上图是XNU架构。iOS 是基于 Apple Darwin 内核,由 kernel、XNU 和 Runtime 组成,而 XNU 是 Darwin 的内核,它是“X is not UNIX”的缩写,是一个混合内核,由 Mach 微内核和 BSD 组成。Mach 内核是轻量级的平台,只能完成操作系统最基本的职责,比如:进程和线程、虚拟内存管理、任务调度、进程通信和消息传递机制。关于 Mach 的详细信息可以参考官方文档和翻译。
简单的说,Task 就是一个容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的。一个 Task 包含了多个 Thread,而每一个 BSD 进程(也就是OS X进程)都在底层关联了一个Mach任务对象。Task 没有自己的生命周期,只有他包含的 Thread执行指令。当我们说 Task 执行某个任务时,实际上是 Task 下的某个 Thread 执行这个任务。
Task 和 Thread 相关的 API (<mach/mach.h>)
-
获取当前的 Task
mach_task_self()
-
获取 Task 下的 Thread 列表
kern_return_t task_threads ( task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt );
2. 实现函数调用栈的回溯
好吧,废话了那么多,终于到了我们的第一个重头戏。让我们看看如何代码实现函数调用栈的回溯。在这里,为了方便,我会以BSBacktraceLogger
的代码来做讲解。(BSBacktraceLogger
是一个轻量的获取线程堆栈的库)
+ (NSString *)bs_backtraceOfAllThread {
thread_act_array_t threads;
mach_msg_type_number_t thread_count = 0;
//获取当前的 Task
const task_t this_task = mach_task_self();
//获取 Task 下的所有 pThread
kern_return_t kr = task_threads(this_task, &threads, &thread_count);
if(kr != KERN_SUCCESS) {
return @"Fail to get information of all threads";
}
NSMutableString *resultString = [NSMutableString stringWithFormat:@"Call Backtrace of %u threads:\n", thread_count];
for(int i = 0; i < thread_count; i++) {
[resultString appendString:_bs_backtraceOfThread(threads[i])];
}
return [resultString copy];
}
这段代码很简单,就是获取所有的 thread 而已。
NSString *_bs_backtraceOfThread(thread_t thread) {
uintptr_t backtraceBuffer[50];
int i = 0;
NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:\n", thread];
_STRUCT_MCONTEXT machineContext;
//1.获取 Thread 执行状态信息,将之反填到machineContext
if(!bs_fillThreadStateIntoMachineContext(thread, &machineContext)) {
return [NSString stringWithFormat:@"Fail to get information about thread: %u", thread];
}
//2.获取 pc寄存器 的值,也就是当前指令执行的位置
const uintptr_t instructionAddress = bs_mach_instructionAddress(&machineContext);
backtraceBuffer[i] = instructionAddress;
++I;
//3.获取 lr寄存器 的值,作为第二栈的位置
uintptr_t linkRegister = bs_mach_linkRegister(&machineContext);
if (linkRegister) {
backtraceBuffer[i] = linkRegister;
I++;
}
if(instructionAddress == 0) {
return @"Fail to get instruction address";
}
//4.用来获取 返回地址/上一栈帧FP 的结构体
BSStackFrameEntry frame = {0};
const uintptr_t framePtr = bs_mach_framePointer(&machineContext);
if(framePtr == 0 ||
//5.从函数调用栈中拷贝出 FP 指针往上(高地址)sizeof(frame) 字节数的内容
bs_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) {
return @"Fail to get frame pointer";
}
//上限50,如果是死循环,栈的数量可能很庞大,所以要限制
for(; i < 50; i++) {
backtraceBuffer[i] = frame.return_address;
if(backtraceBuffer[i] == 0 ||
//没有前栈帧的时候说明已经结束
frame.previous == 0 ||
//6. 根据BSStackFrameEntry的结构循环获取每一个栈帧的 FP
bs_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
break;
}
}
int backtraceLength = I;
Dl_info symbolicated[backtraceLength];
bs_symbolicate(backtraceBuffer, symbolicated, backtraceLength, 0);
for (int i = 0; i < backtraceLength; ++i) {
[resultString appendFormat:@"%@", bs_logBacktraceEntry(i, backtraceBuffer[i], &symbolicated[i])];
}
[resultString appendFormat:@"\n"];
return [resultString copy];
}
这段代码要结合前面所讲的函数调用栈来理解。如 x86架构,前面我们讲到在函数调用栈中,执行器是靠把返回地址压栈来实现函数调用和恢复的。那么同理,我们也可以通过逆向获取每一栈帧的返回地址和 FP 来拿到每一栈帧的指令执行位置。
但是,即便如此,我们在阅读这段代码,还是可能会有一些疑问。
Thread 信息是怎样的?(1)
考虑到一个线程被挂起时,后续继续执行需要恢复现场,所以在挂起时相关现场需要被保存起来,比如当前执行到哪条指令了。
那么就要有相关的结构体来为线程保存运行时的状态:
//获取线程执行状态的方法
kern_return_t thread_get_state
(thread_act_t target_thread,
thread_state_flavor_t flavor,
thread_state_t old_state,
mach_msg_type_number_t old_state_count);
//代表线程执行状态的结构体
_STRUCT_MCONTEXT64
{
_STRUCT_ARM_EXCEPTION_STATE64 __es;
_STRUCT_ARM_THREAD_STATE64 __ss;
_STRUCT_ARM_NEON_STATE64 __ns;
};
_STRUCT_ARM_THREAD_STATE64
{
__uint64_t __x[29]; /* General purpose registers x0-x28 */
void* __opaque_fp; /* Frame pointer x29 */
void* __opaque_lr; /* Link register x30 */
void* __opaque_sp; /* Stack pointer x31 */
void* __opaque_pc; /* Program counter */
__uint32_t __cpsr; /* Current program status register */
__uint32_t __opaque_flags; /* Flags describing structure format */
};
而我们关心的 fp/sp/lr 等信息,就是从这里获得的。
BSStackFrameEntry 的定义是什么?是如何得到的?(4/5)
首先我介绍一个函数,他可以让我们从函数调用栈中 copy 出一块数据:
kern_return_t vm_read_overwrite
(
vm_map_read_t target_task,
vm_address_t address,
vm_size_t size,
vm_address_t data,
vm_size_t *outsize
);
//我们封装一个方法为了更方便使用
kern_return_t bs_mach_copyMem(const void *const src, void *const dst, const size_t numBytes){
vm_size_t bytesCopied = 0;
return vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied);
}
//从 framePtr 往高位拷贝数据
bs_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS);
接着让我们回顾一下前面 x86架构 和 arm64架构 的栈帧对比图:
我们可以看到,虽然 x86 和 arm64 在栈帧上存在些差异,但是通过 FP 指针往高位读取16字节都能得到前栈帧的 FP 和 LR(图中红框部分)。那么我们可以将BSStackFrameEntry定义为:
typedef struct BSStackFrameEntry{
//8 字节
const struct BSStackFrameEntry *const previous;
//8 字节
const uintptr_t return_address;
} BSStackFrameEntry;
在 C 语言中,struct 的内存分布就是其属性一次按占用内存大小的内存分布。由于我们是从 FP 指针往高位读取,所以在顺序上,是先定义 Pre FP,其次是 返回地址。通过这个精巧的结构,我们就可以轻松循环迭代得到所有堆栈。
为什么要读取 LR寄存器 的值?(3)
从前面的内容,我们知道仅靠BSStackFrameEntry
循环迭代就可以拿到除第一层外所有栈帧的数据。那么为啥还要读取 lr寄存器 的值呢?我们知道 lr 寄存器的值是返回地址。也就是说按照这个代码逻辑,可能会出现重复的堆栈。
其实在前面我们比较 x86 和 arm64 的时候,我就已经说了 arm64 下编译器可能会做一个优化:当某个函数内部没有调用其他方法时,其方法内部不会有 FP/LR 的压栈。这就带来一个问题:这个时候我们获得的 FP 其实是调用方的 FP,如果按照BSStackFrameEntry
这个思路,将会丢失第二层堆栈的内容。
但是虽然不会有 FP/LR 的压栈,但是在函数调用的时候,依然会将 LR寄存器 的值设为返回地址。所以这个时候我们就需要读取 LR 寄存器 来得到第二栈帧的指令位置。
细心的读者可能会发现上面的代码有个 bug,如果当前指令所在的函数存在其他函数的调用,也即存在 FP/LR 的堆栈,那么就还是会出现重复的堆栈。实际也确实如此。
三 如何获取崩溃地址
让我们回归到这篇文章的最终目的——拿到Crash 的现场信息,确定崩溃位置。在我刚开始研究这个主题的时候,我以为在崩溃事件的回调(参见上篇文章)中,当前的线程就是崩溃所在的线程,但实际并不是的。
1. Mach 异常的位置
回顾一下拦截 Mach 异常的代码:
typedef struct {
mach_msg_header_t Head;
/* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t thread;
mach_msg_port_descriptor_t task;
/* end of the kernel processed data */
NDR_record_t NDR;
exception_type_t exception;
mach_msg_type_number_t codeCnt;
integer_t code[2];
int flavor;
mach_msg_type_number_t old_stateCnt;
natural_t old_state[144];
} Request;
Request exc;
// ....
for(;;) {
rc = mach_msg( &exc.Head,
MACH_RCV_MSG|MACH_RCV_LARGE,
0,
sizeof(Request),
server_port, // Remember this was global – that's why.
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if(rc != MACH_MSG_SUCCESS) {
/*... */
break ;
};
//.....
}
这段代码的核心在于结构体 Request
。我们注意到 Request
的结构体有个关键的属性thread
,他的定义是:
typedef struct{
mach_port_t name;
// Pad to 8 bytes everywhere except the K64 kernel where mach_port_t is 8 bytes
mach_msg_size_t pad1;
unsigned int pad2 : 16;
mach_msg_type_name_t disposition : 8;
mach_msg_descriptor_type_t type : 8;
} mach_msg_port_descriptor_t;
在我们这个场景下,name 的值就是我们要的崩溃 thread_t。
2. Signal 异常的位置
对于 Signal 信号的异常信息,事件回调时所在的 thread 就是崩溃发生的 thread。但是如果我们沿用前面获取Thread 执行状态的方法就会出现问题。这种情况下,我们需要改用一下方法:
//Signal 信号的回调,Signal 的监控需要使用sigaction,参考上面文章的备用信号栈,要使用action.sa_sigaction
//来指定处理方法
static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext)
{
//这里仅以 arm64 为例
_STRUCT_MCONTEXT64 context;
//直接获取崩溃时的线程执行状态
_STRUCT_MCONTEXT64* sourceContext = ((ucontext64_t*)userContext)->uc_mcontext64;
memcpy(&context, sourceContext, sizeof(context));
//拿到context后, 我们就可以根据前面说的_STRUCT_MCONTEXT64中的__ss来拿到 fp 等信息
//...参考BSBacktraceLogger的代码来获取堆栈信息
}
//ucontext64_t 的定义,可以看到里面有线程执行状态的结构体
_STRUCT_UCONTEXT64
{
int uc_onstack;
__darwin_sigset_t uc_sigmask; /* signal mask used by this context */
_STRUCT_SIGALTSTACK uc_stack; /* stack used by this context */
_STRUCT_UCONTEXT64 *uc_link; /* pointer to resuming context */
__darwin_size_t uc_mcsize; /* size of the machine context passed in */
_STRUCT_MCONTEXT64 *uc_mcontext64; /* pointer to machine specific context */
};
typedef _STRUCT_UCONTEXT64 ucontext64_t; /* [???] user context */
3. NSException 异常的位置
在上一篇的时候,我们就多次强调了 NSException 的异常回调时,遍历所有 Thread 并不能得到崩溃堆栈。那么应该怎么做呢?
//虽然指针是64位,但是 app 使用的虚拟地址空间是远小于64位的,所以在地址指针里面会留有一些没用的位。
//在 Arm64e 架构中,苹果把这些多余的位用户做指针校验(有兴趣的可以搜一下 PAC)。所以需要通过掩码只取低位的有效值。
#define KSPACStrippingMask_ARM64e 0x0000000fffffffff
static void handleException(NSException* exception, BOOL currentSnapshotUserReported) {
//从 exception 中我们能拿到原始的崩溃堆栈地址列表,这个时候我们就完全不需要循环获取 FP
//如果是 arm64 就需要把得到的地址和KSPACStrippingMask_ARM64e做按位与
//这个线程所在的线程就是 crash 所在的线程
NSArray* addresses = [exception callStackReturnAddresses];
//其他线程信息仍然按照前面介绍的方式获取
}
4. C++ 异常的位置
C++异常的处理需要参考上篇文章,重写__cxa_throw
方法,然后通过backtrace
方法获得当前的堆栈地址数组并保存下来。这里不做详细说明。
四 符号化堆栈
由于这部分的知识网上讲的很多,我就不浪费篇幅再讲一次了,感兴趣的朋友可以看看后面的参考文章。
五 补充
这里补充一下我在研究这块领域时遇到的一些辅助判断问题的方法。
-
获取寄存器的值
在 Xcode 断点时输入
p/x $lr
之类的命令。 -
打开汇编模式
在尝试定位或者了解栈帧结构的时候,断点时如果出现的是代码文件,对我们阅读汇编指令比较困难。可以通过
Debug->Debug Workflow->Always Show Disassembly
来打开汇编模式 如果遇到网上提供的信息不清晰时,可以通过阅读汇编指令以及寄存器的值来辅助了解。篇幅所限,这里就不详细介绍汇编指令,请自行上网查找。
六 参考文章
- 谈谈iOS堆栈那些事
- 浅谈函数调用栈
- 函数栈的实现原理
- NSThead和内核线程的转换
- 函数调用栈 剖析+图解[转]
- iOS开发同学的arm64汇编入门
- iOS底层系统:Mach调度原理之调度原语
- 再看CVE-2016-1757---浅析mach message的使用
- 谈谈msgSend为什么不会出现在堆栈中
- 函数调用栈
- mach 源码
- arm 架构堆栈官方文档
总结
这篇文章写得很啰嗦,但请相信,这些信息都是我在学习这个领域知识的时候所遇到的各种困惑。为了能真正了解以及不提供错误的信息,我花了很多的时间,力求准确。并且我希望通过大量的图片,降低大家学习的门槛。希望阅读完这篇文章,能解开你在这块遇到的所有困惑。
最后,如果此文对你有帮助,求大家轻轻一个点赞。