这个项目选择 blazesym,本质上是因为它正好解决了“采样拿到的是地址,怎么把地址稳定、较快地还原成符号”这个核心问题,而且它和当前项目的技术栈很匹配。
这个项目在 eBPF 里通过 bpf_get_stack 拿到的其实只是地址数组,不是函数名。真正把这些地址变成 main、__GI___write、do_syscall_64 这种可读符号的,是用户态的符号化器。这里在 src/blaze.h 里封装了 blazesym,在 src/event.cpp 里分别对内核地址和进程地址做符号化。
为何选择
适合按地址批量符号化的场景
这里不是偶尔查一个地址,而是每条样本里有一串用户栈和内核栈,频率还可能比较高。blazesym 提供的接口就是面向“给我一批地址,我返回一批符号”,很贴合 EventHandler::show_stack_trace 和 EventHandler::symbolize_stack_to_vec 这种用法。
如果自己写,第一步就要先把 ELF、DWARF、内核符号、进程地址空间这些问题都啃下来,工程量会非常大。
它同时支持用户态和内核态符号化
这个项目既抓用户栈,也抓内核栈,所以需要一个库同时处理:
- 某个 PID 的用户态地址
- kernel 的绝对地址
在 src/blaze.h 里可以看到,作者就是用一个 variant 区分 process 和 kernel source,再统一走 symbolize。这让用户态代码很整洁。
如果换一些更通用但更底层的库,通常你要自己拼用户符号解析和内核符号解析两套逻辑,接口层会更碎。
已经封装好许多细节
符号化不是“读个符号表”这么简单,真正麻烦的是:
- 地址归属到哪个映射文件
- 符号表和调试信息怎么查
- 进程地址空间和内核地址空间怎么区分
- inline frame 怎么处理
- 失败时怎么降级
在 src/event.cpp 里,作者已经能直接拿到 name、addr、offset,甚至还能打印 inlined 函数信息。
如果自己写,不但开发成本高,正确性风险也高,尤其是不同二进制格式、优化级别、strip 情况下会出各种边角问题。
和 eBPF / profiling 生态比较近
这个项目不是通用调试器,而是一个 Linux profiler。blazesym 本身就比较偏 observability / profiling 场景,所以它的接口设计、性能目标、内核支持,都比一些传统符号库更对路。
也就是说,作者不是在选“最全能的符号库”,而是在选“最适合这个任务的符号库”。
然后说,为什么不用别的库。
如果用 libbfd、libdw、libelf 这一类库,也不是不能做,但问题是它们更偏“底层能力库”。
它们给你零件,不直接给你一个好用的 profiler 级 symbolizer。你需要自己处理更多对象生命周期、地址归一化、模块映射关系,代码复杂度会上去,维护成本也更高。对于这个项目这种“小而完整”的工具来说,不划算。
如果用 addr2line 这种外部工具,也不合适。
因为它更像离线单次查询工具,不适合高频、批量、运行时调用。你每次采样都去起进程或者频繁 shell out,开销会很难看,也不利于 benchmark。
如果完全自己写,那最大问题不是“能不能写出来”,而是投入和收益极不对称。
这个项目真正想体现的价值在于:
- eBPF 采样链路
- perf event 挂载
- ring buffer 传输
- 输出与 benchmark 设计
符号化并不是这里最值得重复造轮子的部分。自己写只会把大量时间花在 ELF/DWARF 解析和边界条件上,反而偏离项目重点。
代价
不过,选 blazesym 也不是没有代价,我会主动补这一点,这样显得不是只会夸。
它的代价主要有两个:
引入 Rust 构建链
README 里专门写了要先构建
blazesymC API,这会增加依赖复杂度。符号化本身仍然是重操作
项目专门提供了
--no-symbolize,在src/main.cpp里会切到RawCount模式,只统计样本数不逐条输出。采样和符号化应该分开看,符号化成本不低。
这一类库的原理
这类库,也就是 blazesym、addr2line、libdw、libbfd 这类“符号化库”,核心原理可以概括成一句话:
把运行时采样拿到的地址,映射回它所属的二进制文件,再在这个文件的符号表和调试信息里查出函数名、偏移、源码位置。
拆开讲就是几步。
1. 先确定这个地址属于哪里
采样时我们拿到的是一个虚拟地址,比如:
| |
这个地址本身没有语义。符号化库要先判断它属于哪个映射区。对用户态进程来说,通常会读:
| |
里面会告诉我们某段地址范围对应哪个文件,比如 /usr/lib64/libc.so.6、/usr/bin/yes、某个 .so。
所以第一步是:运行时地址 -> 对应的 ELF 文件和映射偏移。
2. 把运行时地址转换成文件内地址
因为程序运行时会有 ASLR,动态库加载地址每次可能不同,所以不能直接拿运行时地址去 ELF 文件里查。需要做一次归一化,大概是:
| |
例如某个函数在 libc 里,运行时地址是 0x7f55ce0921fe,但 libc 在这次进程里加载到哪里是不固定的。符号化库要把它还原成 libc 文件内部的相对地址,才能继续查。
3. 在符号表里查函数名
ELF 文件里可能有 .symtab 或 .dynsym 符号表。符号化库会找最接近这个地址、并且范围覆盖它的函数符号。
所以它能把:
| |
解析成类似:
| |
这里的 +0x1e 是说采样点落在函数入口之后的偏移位置。
4. 如果有调试信息,再查源码文件和行号
函数名通常来自符号表,但源码行号来自 DWARF 调试信息,比如 .debug_info、.debug_line 等 section。
如果二进制没有 debug info,可能只能得到函数名,甚至只能得到地址。
如果有 debuginfo,库就可以进一步解析成:
| |
这一步比查函数名更复杂,也更慢。
5. 处理 inline 函数
现代编译器优化会把函数内联掉,采样地址可能实际对应一层或多层 inline 调用关系。
所以比较完整的符号化库会读取 DWARF 里的 inline 信息,把它还原成类似:
| |
这个项目里 src/event.cpp 的 show_stack_trace 就有打印 inlined 信息的逻辑。
对于内核符号化,原理类似,但数据来源不太一样。
用户态一般看 /proc/<pid>/maps 和 ELF 文件;内核态则通常依赖:
| |
还要考虑 KASLR,也就是内核地址随机化。符号化库需要知道内核符号的实际加载地址,才能把采样地址对应到 do_syscall_64、ksys_write 这类内核函数。
所以这类库真正做的事情不是“简单查表”,而是:
| |
放到这个项目里,BPF 端只负责采地址:
| |
然后用户态的 blazesym 负责把地址变成人能看懂的调用栈。
这就是为什么 profiler 通常会把“采样”和“符号化”分成两个阶段:采样要求轻,符号化要求信息完整,两者关注点不一样。