我们日常工作中,只要是涉及到监控工具的开发,或者一些问题的排查,总是绕不开堆栈,堆栈是在问题发生时最有效的上下文信息。今天就跟大家聊一聊运行时获取堆栈的那些事儿。
获取堆栈
从 backtrace 说起,backtrace 是系统提供的 api,也是获取堆栈最快速的方式,但是获取的信息有限,只能取到函数的返回地址,使用 libunwind 提供的接口还可以额外获取到寄存器的信息,不过性能和 backtrace 无法相提并论。backtrace 的代码是开源的,原理是根据 fp 寄存器回溯。
arduino
int backtrace(void**,int) __OSX_AVAILABLE_STARTING(__MAC_10_5, __IPHONE_2_0);
backtrace 源码:
arduino
int backtrace(void** buffer, int size) {
unsigned int num_frames;
_thread_stack_pcs((vm_address_t*)buffer, size, &num_frames, 1, NULL);
while (num_frames >= 1 && buffer[num_frames-1] == NULL) num_frames -= 1;
return num_frames;
}
_thread_stack_pcs 源码:
ini
_thread_stack_pcs(vm_address_t *buffer, unsigned max, unsigned *nb,
unsigned skip, void *startfp)
{
void *frame, *next;
pthread_t self = pthread_self();
void *stacktop = pthread_get_stackaddr_np(self);
void *stackbot = stacktop - pthread_get_stacksize_np(self);
*nb = 0;
/* make sure return address is never out of bounds */
stacktop -= (FP_LINK_OFFSET + 1) * sizeof(void *);
frame = __builtin_frame_address(0);
if(!INSTACK(frame) || !ISALIGNED(frame))
return;
/***************** 第一个循环 *****************/
while ((startfp && startfp >= *(void **)frame) || skip--) {
next = *(void **)frame;
if(!INSTACK(next) || !ISALIGNED(next) || next <= frame)
return;
frame = next;
}
/***************** 第二个循环 *****************/
while (max--) {
void *retaddr = (void *)*(vm_address_t *)
(((void **)frame) + FP_LINK_OFFSET);
buffer[*nb] = retaddr;
(*nb)++;
next = *(void **)frame;
if(!INSTACK(next) || !ISALIGNED(next) || next <= frame)
return;
frame = next;
}
}
接下来逐行解释下 _thread_stack_pcs 的实现。
第一步:
使用 pthread_get_stackaddr_np 获取栈的起始地址, 使用pthread_get_stacksize_np 获取栈的大小。栈空间向下增长,因此栈的区间是 [起始地址 - 栈大小,起始地址)。
ini
pthread_t self = pthread_self();
void *stacktop = pthread_get_stackaddr_np(self);
void *stackbot = stacktop - pthread_get_stacksize_np(self);
我们需要使用栈区间,判断 fp 是否在有效范围内,fp 地址 +1 存放的是 lr,也就是函数的返回地址,获取堆栈我们最终是要取 lr,为了保证存储 lr 的地址(fp + 1)在有效范围内,判断 fp 时需要对栈顶地址 - 2。
scss
/* make sure return address is never out of bounds */
stacktop -= (FP_LINK_OFFSET + 1) * sizeof(void *);
第二步:
获取当前函数的 fp。__builtin_frame_address 是 GCC 和一些兼容的编译器提供的一个内建函数,用于获取当前函数调用栈中指定帧的地址。__builtin_frame_address(0) 表示当前函数的 fp,__builtin_frame_address(1) 表示调用者的 fp,依次类推。不过,值得注意的是这个方法只是个简单的寄存器取值,并不会判断 fp 是否在有效范围内,当指定的栈帧序列超出了当前函数栈的长度就有可能会触发崩溃。
ini
frame = __builtin_frame_address(0);
编译器也提供了另外一个内建函数 __builtin_return_address(0),用于获取指定栈帧函数的返回地址 lr。
第三步:
进入第一个 while 循环,这个循环服务于 backtrace_from_fp 方法,这个方法比 backtrace 多了一个起始的 fp 参数。
scss
API_AVAILABLE(macosx(10.14), ios(12.0), tvos(12.0), watchos(5.0))
OS_EXPORT
int backtrace_from_fp(void *startfp, void **array, int size);
backtrace_from_fp 用于跳过栈顶的一些函数,这些函数可能是无意义的也可能是固定的。这个循环会递归的取 fp 存储的指针,先找到 startfp。
ini
next = *(void **)frame;
fp 地址 + 1 存放的是 lr,而 fp 本身存储的是上一个调用函数的 fp。假设 3 个函数 A、B、C,函数 A 调用 B,B 调用 C。在 C 函数内取 fp,fp + 1 表示 lr 指向函数 B,取 fp 存储的地址值 *(void **)fp,为 B 方法的 fp。依次类推,就可以取到完整的调用链 A -> B -> C。实际上 fp 上面存储的不只是 lr 还有一些参数,局部变量,不过在运行时获取 fp + x 所表示的具体含义,需要依赖 dwarf 文件,而线上包不会包含 dwarf 文件,因此通常做线下的解析。
第四步:
第二个 while 循环,fp 回溯的核心逻辑,有之前的铺垫,这里理解应该是相对简单了。对 frame 取值,递归获取上一层函数的 fp,对 fp + 1 获取函数返回地址,最终将返回地址保存到数组里面。
堆栈解析
以下述代码为例,取 viewDidLoad 方法调用栈。
c
- (void)viewDidLoad {
[super viewDidLoad];
void *stack[128];
int count = backtrace(stack, sizeof(stack)/sizeof(stack[0]));
for (int i = 0; i < count; i++) {
printf("%d %p\n", i, stack[i]);
}
}
stack 内存储的是地址,如何将这些地址转换为函数的名称,是我们这一节讨论的内容。
iOS 开发同学很多都接触过 bugly,bugly 符号解析依赖我们上传的 dsym 文件,dsym 内包含了一个 dwarf 文件,这个文件里面存储了地址和符号的映射关系,当然也有其他的 debug 信息,关于 dwarf 格式的介绍,可以参考官方的文档,这里不做过多的阐述。
dwarf 里面存储的地址实际是相对于可执行文件起始地址的偏移量。stack 内存储的是虚拟地址,虚拟地址转换为 dwarf 内的 offset 需要两个关键的信息,虚拟地址所在的 dylib,dylib 的加载地址。另外我们需要 dylib 的唯一标识符 uuid,用于在线下关联具体的 dwarf 文件。下面是一个大概的流程:
第一步:
获取所有的 dylib 加载地址以及对应的 uuid,系统提供了一些 api 可以非常容易获取到这些信息。其中使用_dyld_get_image_header 接口获取到的 header 地址也就是 dylib 的加载地址。
ini
uint32_t image_count = _dyld_image_count();
for (int index = 0; index < image_count; index++) {
const struct mach_header_64* header
= (struct mach_header_64 *)_dyld_get_image_header(index);
const char *name =
_dyld_get_image_name(index);
struct uuid_command* uuidCmd = nullptr;
uintptr_t cmdPtr = (uintptr_t)(header + 1);
for (uint32_t index = 0; index < header->ncmds; index++) {
struct load_command* command = (struct load_command*)cmdPtr;
if (command->cmd == LC_UUID) {
uuidCmd = (struct uuid_command*)cmdPtr;
break;
}
cmdPtr += command->cmdsize;
}
ImageInfo *info = new ImageInfo(name,
(uint64_t)static_cast<const void *>(header),
uuidCmd->uuid);
_images.push_back(info);
}
第二步:
二分查找 stack 内的 pc 所在的 dylib。
ini
auto imagesIt = std::lower_bound(_images.begin(),
_images.end(),
pc, [](ImageInfo *first, uintptr_t second){
bool rv = first > (second);
return rv;
});
pc - dylib 的加载地址 = dwarf 内的 offset。当获取到 offset 和 uuid 之后如何做线下的符号解析呢?我们还是以 viewDidLoad 这个方法为例。
viewDidLoad 在主可执行文件 Kwai 内,对应的加载地址为 0x0000000104cc8000,uuid 为 AC58D77F-5F09-35EF-AF14-A1F163D38F99。
bash
[ 0] AC58D77F-5F09-35EF-AF14-A1F163D38F99 0x0000000104cc8000 /Users/yuencong/Library/Developer/Xcode/DerivedData/Kwai-fadabaveapyesuaqiyqeywfcssxe/Build/Products/Debug-iphoneos/Kwai.app/Kwai
对应 viewDidLoad 的返回地址为 0x104ccc074,计算 offset = 0x0000000000004074
Kwai 对应的 dwarf 文件获取 uuid,这个 uuid 和运行时获取的是一致的:
使用 dwarf 解析符号:
bash
➜ ~ dwarfdump --arch arm64 "/Users/yuencong/Library/Developer/Xcode/DerivedData/Kwai-fadabaveapyesuaqiyqeywfcssxe/Build/Products/Debug-iphoneos/Kwai.app.dSYM/Contents/Resources/DWARF/Kwai" --lookup="0x0000000100004073"
输出结果,依赖 dwarf 文件我们不仅可以取到函数名,还可以获取到具体的文件和行号。
使用 dwarf 指令时我们查找的地址是 0x0000000100004073 = 0x4074 - 1 + 0x0000000100000000。
-1: backtrace 获取的是返回地址,-1 是为了获取上一个函数执行跳转时的 pc,-1 不是精确的值,但对于符号解析而言已经够用了。
0x0000000100000000: dwarf 的 vmaddr, debug 包通常是固定的值,也可以通过 otool 工具获取。
perl
otool -l "dwarf_path" | grep __TEXT -m 2 -A 1 | grep vmaddr
总结
以上只是获取堆栈以及符号化的大概流程,在真正实践的过程中,对于堆栈获取通常会做一些减法,自己实现 fp 的回溯。而线下的符号解析也不会依赖原始的 dwarf 文件,通常是把 dwarf 文件解析为 offset 和函数信息的映射关系,在解析时只需要一次查表就可以实现符号化。
整个链路中,把 dwarf 解析为 offset 和函数信息的映射是其中的难点和核心,快手的 dwarf 解析工具已经在线上高效运行了一年多,超过 1 G 的 dwarf 文件耗时也可以控制在秒级内,并且还支持了内联展开、 swift demangle。对于这块内容比较感兴趣的可以敬请关注后续的博客更新。
(辛苦读到这里的各位观众老爷们点点关注点赞收藏,您的肯定就是我不断更新的动力,有任何问题都可以在评论区交流)