为什么符号化时选择blazesym库

这个项目选择 blazesym,本质上是因为它正好解决了“采样拿到的是地址,怎么把地址稳定、较快地还原成符号”这个核心问题,而且它和当前项目的技术栈很匹配。

这个项目在 eBPF 里通过 bpf_get_stack 拿到的其实只是地址数组,不是函数名。真正把这些地址变成 main__GI___writedo_syscall_64 这种可读符号的,是用户态的符号化器。这里在 src/blaze.h 里封装了 blazesym,在 src/event.cpp 里分别对内核地址和进程地址做符号化。


为何选择

适合按地址批量符号化的场景

这里不是偶尔查一个地址,而是每条样本里有一串用户栈和内核栈,频率还可能比较高。blazesym 提供的接口就是面向“给我一批地址,我返回一批符号”,很贴合 EventHandler::show_stack_traceEventHandler::symbolize_stack_to_vec 这种用法。

如果自己写,第一步就要先把 ELF、DWARF、内核符号、进程地址空间这些问题都啃下来,工程量会非常大。

它同时支持用户态和内核态符号化

这个项目既抓用户栈,也抓内核栈,所以需要一个库同时处理:

  • 某个 PID 的用户态地址
  • kernel 的绝对地址

src/blaze.h 里可以看到,作者就是用一个 variant 区分 processkernel source,再统一走 symbolize。这让用户态代码很整洁。

如果换一些更通用但更底层的库,通常你要自己拼用户符号解析和内核符号解析两套逻辑,接口层会更碎。

已经封装好许多细节

符号化不是“读个符号表”这么简单,真正麻烦的是:

  • 地址归属到哪个映射文件
  • 符号表和调试信息怎么查
  • 进程地址空间和内核地址空间怎么区分
  • inline frame 怎么处理
  • 失败时怎么降级

src/event.cpp 里,作者已经能直接拿到 nameaddroffset,甚至还能打印 inlined 函数信息。

如果自己写,不但开发成本高,正确性风险也高,尤其是不同二进制格式、优化级别、strip 情况下会出各种边角问题。

和 eBPF / profiling 生态比较近

这个项目不是通用调试器,而是一个 Linux profiler。blazesym 本身就比较偏 observability / profiling 场景,所以它的接口设计、性能目标、内核支持,都比一些传统符号库更对路。

也就是说,作者不是在选“最全能的符号库”,而是在选“最适合这个任务的符号库”。

然后说,为什么不用别的库。

如果用 libbfdlibdwlibelf 这一类库,也不是不能做,但问题是它们更偏“底层能力库”。

它们给你零件,不直接给你一个好用的 profiler 级 symbolizer。你需要自己处理更多对象生命周期、地址归一化、模块映射关系,代码复杂度会上去,维护成本也更高。对于这个项目这种“小而完整”的工具来说,不划算。

如果用 addr2line 这种外部工具,也不合适。

因为它更像离线单次查询工具,不适合高频、批量、运行时调用。你每次采样都去起进程或者频繁 shell out,开销会很难看,也不利于 benchmark。

如果完全自己写,那最大问题不是“能不能写出来”,而是投入和收益极不对称

这个项目真正想体现的价值在于:

  • eBPF 采样链路
  • perf event 挂载
  • ring buffer 传输
  • 输出与 benchmark 设计

符号化并不是这里最值得重复造轮子的部分。自己写只会把大量时间花在 ELF/DWARF 解析和边界条件上,反而偏离项目重点。


代价

不过,选 blazesym 也不是没有代价,我会主动补这一点,这样显得不是只会夸。

它的代价主要有两个:

  1. 引入 Rust 构建链

    README 里专门写了要先构建 blazesym C API,这会增加依赖复杂度。

  2. 符号化本身仍然是重操作

    项目专门提供了 --no-symbolize,在 src/main.cpp 里会切到 RawCount 模式,只统计样本数不逐条输出。采样和符号化应该分开看,符号化成本不低。


这一类库的原理

这类库,也就是 blazesymaddr2linelibdwlibbfd 这类“符号化库”,核心原理可以概括成一句话:

把运行时采样拿到的地址,映射回它所属的二进制文件,再在这个文件的符号表和调试信息里查出函数名、偏移、源码位置。

拆开讲就是几步。

1. 先确定这个地址属于哪里

采样时我们拿到的是一个虚拟地址,比如:

1
0x7f55ce0921fe

这个地址本身没有语义。符号化库要先判断它属于哪个映射区。对用户态进程来说,通常会读:

1
/proc/<pid>/maps

里面会告诉我们某段地址范围对应哪个文件,比如 /usr/lib64/libc.so.6/usr/bin/yes、某个 .so

所以第一步是:运行时地址 -> 对应的 ELF 文件和映射偏移

2. 把运行时地址转换成文件内地址

因为程序运行时会有 ASLR,动态库加载地址每次可能不同,所以不能直接拿运行时地址去 ELF 文件里查。需要做一次归一化,大概是:

1
文件内地址 = 运行时地址 - 映射起始地址 + 文件映射偏移

例如某个函数在 libc 里,运行时地址是 0x7f55ce0921fe,但 libc 在这次进程里加载到哪里是不固定的。符号化库要把它还原成 libc 文件内部的相对地址,才能继续查。

3. 在符号表里查函数名

ELF 文件里可能有 .symtab.dynsym 符号表。符号化库会找最接近这个地址、并且范围覆盖它的函数符号。

所以它能把:

1
0x7f55ce0921fe

解析成类似:

1
__GI___libc_write + 0x1e

这里的 +0x1e 是说采样点落在函数入口之后的偏移位置。

4. 如果有调试信息,再查源码文件和行号

函数名通常来自符号表,但源码行号来自 DWARF 调试信息,比如 .debug_info.debug_line 等 section。

如果二进制没有 debug info,可能只能得到函数名,甚至只能得到地址。

如果有 debuginfo,库就可以进一步解析成:

1
write.c:26

这一步比查函数名更复杂,也更慢。

5. 处理 inline 函数

现代编译器优化会把函数内联掉,采样地址可能实际对应一层或多层 inline 调用关系。

所以比较完整的符号化库会读取 DWARF 里的 inline 信息,把它还原成类似:

1
2
outer()
  inlined inner()

这个项目里 src/event.cppshow_stack_trace 就有打印 inlined 信息的逻辑。

对于内核符号化,原理类似,但数据来源不太一样。

用户态一般看 /proc/<pid>/maps 和 ELF 文件;内核态则通常依赖:

1
2
3
4
/proc/kallsyms
/sys/kernel/kallsyms
vmlinux
kernel debuginfo

还要考虑 KASLR,也就是内核地址随机化。符号化库需要知道内核符号的实际加载地址,才能把采样地址对应到 do_syscall_64ksys_write 这类内核函数。

所以这类库真正做的事情不是“简单查表”,而是:

1
2
3
4
5
6
采样地址
  -> 判断属于进程/内核/哪个动态库
  -> 根据加载基址做地址归一化
  -> 读取 ELF 符号表
  -> 可选读取 DWARF 调试信息
  -> 返回函数名、偏移、源码位置、inline 信息

放到这个项目里,BPF 端只负责采地址:

1
2
bpf_get_stack(ctx, event->kstack, ...)
bpf_get_stack(ctx, event->ustack, ..., BPF_F_USER_STACK)

然后用户态的 blazesym 负责把地址变成人能看懂的调用栈。

这就是为什么 profiler 通常会把“采样”和“符号化”分成两个阶段:采样要求轻,符号化要求信息完整,两者关注点不一样。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计