堆栈获取以及符号化

我们日常工作中,只要是涉及到监控工具的开发,或者一些问题的排查,总是绕不开堆栈,堆栈是在问题发生时最有效的上下文信息。今天就跟大家聊一聊运行时获取堆栈的那些事儿。

获取堆栈

从 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。对于这块内容比较感兴趣的可以敬请关注后续的博客更新。

(辛苦读到这里的各位观众老爷们点点关注点赞收藏,您的肯定就是我不断更新的动力,有任何问题都可以在评论区交流)

相关推荐
理想不理想v3 分钟前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
sszmvb12341 小时前
测试开发 | 电商业务性能测试: Jmeter 参数化功能实现注册登录的数据驱动
jmeter·面试·职场和发展
测试杂货铺1 小时前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
王佑辉1 小时前
【redis】redis缓存和数据库保证一致性的方案
redis·面试
真忒修斯之船1 小时前
大模型分布式训练并行技术(三)流水线并行
面试·llm·aigc
ZL不懂前端2 小时前
Content Security Policy (CSP)
前端·javascript·面试
测试界萧萧2 小时前
外包干了4年,技术退步太明显了。。。。。
自动化测试·软件测试·功能测试·程序人生·面试·职场和发展
百事老饼干3 小时前
Java[面试题]-真实面试
java·开发语言·面试
DisonTangor3 小时前
苹果发布iOS 18.2首个公测版:Siri接入ChatGPT、iPhone 16拍照按钮有用了
ios·chatgpt·iphone
- 羊羊不超越 -3 小时前
App渠道来源追踪方案全面分析(iOS/Android/鸿蒙)
android·ios·harmonyos