栈回溯 backtrace:帧指针、DWARF CFI 与崩溃时打印调用栈
更新时间:2026-09-24。本文是
languages/c/主题专家层的一篇。程序崩溃时 GDB 能打出一串#0 #1 #2 ...的调用栈,崩溃处理器也能在临死前自己吐出调用链——这背后的机制就是 C 栈回溯。本篇讲清楚这条链是怎么被还原出来的,以及为什么有时候还原不出来。
本文要回答的问题:C 栈回溯是怎么还原出来的
- 调试器凭什么从当前函数一路找到
main?栈帧之间靠什么串起来? - 现代编译默认
-fomit-frame-pointer,帧指针链表不存在了,回溯还怎么做? - 程序自己能不能在崩溃瞬间打印调用栈?
backtrace()返回的地址怎么变成函数名和源码行? - 为什么信号处理器里不能用
printf/malloc?strip 之后、开了-O2之后回溯为什么会缺帧、错位?
一、最朴素的办法:帧指针链表
x86-64 的 System V ABI 里,%rbp 传统上用作帧指针(frame pointer)。函数序言(prologue)固定执行两条指令:push %rbp 把调用者的帧指针压栈,再 mov %rsp, %rbp 让 %rbp 指向当前帧。于是栈上自然形成一条链表:每个帧的 [rbp] 位置保存着上一帧的 rbp,而它上方的 [rbp+8] 保存着返回地址——也就是 call 指令自动压入的、函数执行完要跳回去的那条指令地址。
有了这条链,回溯就是重复两步:读 [rbp+8] 拿到本帧的返回地址(这标识了"谁调用了我"),再读 [rbp] 跳到上一帧;直到链表尽头(通常 saved rbp 为 0)。GDB 的 bt、内核的 dump_stack()、历史上各种 frame pointer based unwinder 走的都是这条链。

这张图就是回溯器眼中的栈:地址从高到低是 main → deep1 → deep2 → deep3 的调用顺序,而回溯方向相反,沿每个帧头里的 saved rbp 指针逐级向上走。返回地址序列 deep2+0x12、deep1+0x12、main+0x25 就是最终要交给符号化工具的原始数据。栈帧与调用约定的完整布局见 32 调用约定与栈帧。
用 gcc -S -fno-omit-frame-pointer 看生成的汇编,每个函数开头几乎都是同一个模子,返回地址则由 call 指令在跳转前自动压入:
pushq %rbp # 保存调用者的帧指针 = 链表的一环
movq %rsp, %rbp # %rbp 锚定当前帧
...
popq %rbp # 尾声(epilgue)恢复
ret顺带解释一个看栈时常见的"假帧":崩溃发生在信号处理器里时,内核会在用户栈上额外压一个信号帧(保存被中断现场,对应 __restore_rt/rt_sigreturn 跳板),所以回溯里处理器和出事函数之间常夹着这个没有源码对应的帧,它不是 bug,是信号投递的物理痕迹。
帧指针法的代价是一个通用寄存器被永久占用。x86-64 只有 16 个通用寄存器,少一个对热点函数压力不小,所以从 GCC 时代起就有了 -fomit-frame-pointer:在 -O1 以上默认省略帧指针,把 %rbp 还给编译器当普通寄存器用。链表从此消失。
二、没有帧指针怎么办:DWARF CFI 与 .eh_frame
现代程序的回溯不靠链表,靠展开信息(unwind info)。编译器在生成机器码的同时,额外用 DWARF 格式描述"在函数的每条指令位置上,如何从当前寄存器状态恢复出调用者的寄存器"。这套规则叫 CFI(Call Frame Information,调用帧信息),数据放在 ELF 的 .eh_frame 节里(位置无关的紧凑版,C++ 异常展开也复用它);为了快速按 PC 查表,还有一个 .eh_frame_hdr 索引节。
每条规则回答两类问题:一是 CFA(Canonical Frame Address,规范帧地址,大致等于本函数调用前那一刻的栈顶)怎么算,比如"当前 PC 在 foo+0x20 时,CFA = rsp + 32";二是各寄存器存在 CFA 之下的哪个偏移,比如"返回地址存在 CFA - 8,调用者的 rbx 存在 CFA - 32"。展开器拿到当前 %rsp 和 PC,查 FDE(Frame Description Entry,帧描述项)算出 CFA,取出返回地址和调用者寄存器,然后以上一帧的规则重复,直到栈顶。

图中的回路是关键:CFI 不是一条静态链表,而是一组按 PC 查表解释执行的规则,每展开一级就得到上一级的 PC,再用它查下一张 FDE。GDB、perf 的内核态调用链(--call-graph dwarf)、libunwind、C++ 异常运行时,都是这张图的不同实现。.eh_frame 的节结构和指令编码见 C 栈回溯依赖的 .eh_frame,ELF 节全貌见 ELF 与二进制基础。
规则本身在编译器生成的汇编里肉眼可见,.cfi_* 伪指令与机器码一一对应:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16 # 此 PC 处 CFA = rsp + 16
.cfi_offset 6, -16 # rbp(编号6) 保存在 CFA-16
movq %rsp, %rbp
.cfi_def_cfa_register 6 # 此后 CFA 改由 rbp 表达
...readelf -wF a.out(或 objdump --dwarf=frames)能反查这些行:每条规则都带一个生效的 PC 区间,展开器正是按崩溃地址落在哪个区间来决定算法。注意 .eh_frame 与 DWARF 标准里的 .debug_frame 是两套近亲节——前者紧凑、默认随发布件保留、还要服务异常展开;后者只在 -g 时生成、信息更全。strip 通常只动后者。
| 维度 | 帧指针链表 | DWARF CFI(.eh_frame) |
|---|---|---|
| 信息存在哪里 | 运行时栈上的 saved rbp | ELF 文件中的静态展开表 |
| 寄存器代价 | %rbp 被永久占用 | 无,%rbp 可作普通寄存器 |
| 回溯方式 | 沿指针走链,几条指令搞定 | 按 PC 查表并解释规则,较慢 |
| 编译条件 | 需 -fno-omit-frame-pointer | GCC/Clang 默认生成,需未被 strip |
| 典型用户 | 老调试器、内核 dump_stack、火焰图 fp 模式 | GDB、libunwind、perf dwarf 模式、异常展开 |
| 失败模式 | 省略帧指针即链断 | 手工汇编无 CFI、表被裁剪时无法展开 |
三、在程序里自己抓栈:API 与工具全景
线上服务不可能每次崩溃都等人挂 GDB,常见做法是让程序自己抓下调用地址,落成日志或 minicore,事后离线符号化。glibc 自带 <execinfo.h> 三个函数,更底层、可跨进程展开远程栈的是 libunwind;拿到地址之后的符号化则是另一组工具的事。
| API / 工具 | 作用 | 关键限制 |
|---|---|---|
backtrace(void **buf, int n) | 把当前调用链的返回地址填入 buf | 依赖帧指针或 CFI;看不到被内联/尾调用消掉的帧 |
backtrace_symbols() | 把地址转成 "prog(func+0x1f)[0x40118a]" 字符串数组 | 内部 malloc,不能进信号处理器 |
backtrace_symbols_fd() | 同样的格式化结果直接 write 到 fd | 不调用 malloc,可用于信号处理器 |
libunwind (unw_step 等) | 可编程的展开器,支持展开别的进程/别的机器 | 需链接 -lunwind,接口略繁琐 |
addr2line -f -C -e bin <地址> | 地址 → 函数名 + 源码文件行号 | 二进制需带 -g 调试信息 |
eu-stack(elfutils) | 直接对进程或 core 打印所有线程栈 | 依赖 CFI 与符号文件 |
gdb ... -ex bt | 对 core 文件交互式回溯 | 见 core dump 排查 |
注意 backtrace_symbols_fd 给出的函数名常常只有 (+0x118a) 这样的裸偏移:静态函数和 main 默认不导出动态符号表,加 -rdynamic 只能导出非 static 的全局符号。所以工程上的标准姿势是只记录地址,符号化全部推迟到线下用 addr2line 完成。
四、可运行 demo:SIGSEGV 时自己打印调用栈
下面这个程序有四层调用,在最深层解引用空指针;我们安装一个 SIGSEGV 处理器,崩溃瞬间把调用栈打到标准错误,然后用 addr2line 把裸地址解析成源码行。完整的信号背景见 导致 Crash 的信号。
#define _GNU_SOURCE
#include <execinfo.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
static void crash_handler(int sig) {
void *frames[64];
int n = backtrace(frames, 64);
/* backtrace_symbols_fd 内部只做 write,不碰 malloc */
backtrace_symbols_fd(frames, n, STDERR_FILENO);
_exit(128 + sig);
}
__attribute__((noinline)) static void deep3(void) {
volatile int *p = (void *)0;
*p = 42; /* 必崩:写空指针 */
}
__attribute__((noinline)) static void deep2(void) { deep3(); }
__attribute__((noinline)) static void deep1(void) { deep2(); }
int main(void) {
struct sigaction sa;
sa.sa_handler = crash_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGSEGV, &sa, NULL);
deep1();
return 0;
}编译运行。-no-pie 让打印出来的就是文件内绝对地址,addr2line 可直接吃;-g 保留行号;-O0 配合 noinline 保证四层帧一个不少:
$ gcc -g -O0 -no-pie -rdynamic bt_demo.c -o bt_demo
$ ./bt_demo
./bt_demo(crash_handler+0x2f)[0x40117f]
./bt_demo(+0x11a4)[0x4011a4]
./bt_demo(+0x11bd)[0x4011bd]
./bt_demo(+0x11d0)[0x4011d0]
./bt_demo(+0x121c)[0x40121c]
/lib/x86_64-linux-gnu/libc.so.6(+0x29d90)[0x7f...]deep3/deep2/deep1 因为是 static 函数只显示裸偏移,逐个解析:
$ addr2line -f -C -e ./bt_demo 0x4011a4 0x4011bd 0x4011d0
deep3
/home/me/bt_demo.c:21
deep2
/home/me/bt_demo.c:24
deep1
/home/me/bt_demo.c:25一条完整的崩溃链就此还原:crash_handler ← deep3(bt_demo.c:21,正是 *p = 42) ← deep2 ← deep1 ← main。输出最底下那个 libc 帧是信号投递跳板,对照第一节说的信号帧看就不陌生。这正是生产崩溃上报系统(Breakpad/Crashpad 及各类自研 minicore)的最小内核:抓地址、落盘、线下符号化。
移植到 ARM/AArch64 时还有一个编译开关坑:x86 默认就生成展开表,而部分平台的裸 C 程序需要显式加 -funwind-tables,否则 backtrace() 只返回一级。工程上为了让最早期启动崩溃也能抓到栈,常配合 -rdynamic -fasynchronous-unwind-tables 一起用。
五、信号处理器里的红线:异步信号安全
为什么 demo 里坚持用 backtrace_symbols_fd 而不是先 printf 美化一下?信号可能打断进程正在执行的任意指令,包括 malloc 正拿着堆锁的时候。如果处理器里再调 malloc(backtrace_symbols、asprintf、syslog 内部都会),就要去抢同一把还没释放的锁,立刻自锁死锁。printf 同理,它内部要分配缓冲区。这类函数不是异步信号安全(async-signal-safe)的。
| 操作 | 能否进信号处理器 | 原因 |
|---|---|---|
write、_exit、sigaction | 可以 | POSIX 明确列出的异步信号安全函数 |
backtrace_symbols_fd | 基本可以 | glibc 实现只走 write,文档明确说明 malloc 失败场景也能用 |
printf、backtrace_symbols、malloc、syslog | 禁止 | 内部有堆锁/非重入状态,可能与被打断的代码死锁 |
backtrace() 本身 | 谨慎 | 不 malloc,但依赖 dl 迭代加载模块,在动态链接器持锁的极早崩溃期可能卡住 |
更刁钻的细节:SIGSEGV 处理器返回(而不是 _exit)后,内核会重新执行那条出错指令,于是再次触发 SIGSEGV,形成无限递归。所以 demo 里处理完直接 _exit。处理器还应尽量短:先 write 出栈就走,格式化留给线下。信号重入的完整讨论见 10 信号重入。
六、strip、分离调试文件,以及优化后的回溯失真
线上二进制通常要 strip。要分清 strip 掉的是两种东西:.symtab/.dynsym(函数符号名)和 .debug_*(DWARF 调试信息,行号、变量位置),而 .eh_frame 一般默认保留——所以 strip 之后展开仍然成立,你拿得到一串准确地址,只是地址对不上名字和行号。标准解法是符号分离:构建后用 objcopy --only-keep-debug 把调试信息抽成独立的 bt_demo.debug,线上发 strip 版,分析 core 时再用 .gnu_debuglink 或 debug-file-directory 把符号"接"回去。完整流程见 生产环境符号分离实践。
另一类问题不是缺符号,而是栈本身变了。编译器优化会重排调用关系:
- 内联:
deep3被内联进deep2后,世界上不存在 deep3 的返回地址,回溯里直接没有这一级——但 GDB 借助 DWARF 的内联记录可以显示inlined into,裸backtrace()做不到。 - 尾调用:
deep2末尾return deep3()被编译成跳转后,两个函数共用一帧,deep2从链上消失。原理见 20 尾调用优化。 - 过程间优化、链接时优化(LTO)还会合并函数、消除帧,使地址与源码行的对应进一步模糊。
这也是为什么 perf 剖析在 -O2 下偶尔出现栈断裂:调试器靠完整 DWARF 还能补救,而采样时刻只拿到寄存器和栈内存的展开器,遇到缺 CFI 的手写汇编、JIT 代码或过深的内联就只能停在半路。对应到 perf 的两种调用链模式,--call-graph fp 最省但要求系统二进制带帧指针,发行版为兼容它常以 -fno-omit-frame-pointer 重编核心库;--call-graph dwarf 每点转储一段栈内存带回用户态离线展开,准但开销大数倍。选型取舍见 perf 使用指南。
这一篇在知识体系里的位置
- 栈帧与调用约定(参数、返回地址、saved rbp 的物理布局)是本篇前置:32 调用约定与栈帧。
.eh_frame/CFI 的节格式与 FDE 编码:ELF 之 .eh_frame;ELF 节与符号表全貌:ELF 与二进制基础。- 崩溃现场的完整工作流(core 生成、GDB 回溯):core dump 排查;信号本身:导致 Crash 的信号;处理器重入:10 信号重入。
- strip 与调试文件分离:生产环境符号分离实践;帧消失的两种优化:20 尾调用优化、反汇编视角:23 GDB 反汇编。
- 下一篇:模糊测试 fuzzing。
总结
C 栈回溯的本质是把"函数怎么回到调用者"这条信息重走一遍:有帧指针就沿 rbp 链表走,没有就让展开器按 PC 逐条解释 .eh_frame 里的 CFI 规则;拿到返回地址后用 addr2line 和分离调试文件离线符号化,在信号处理器里则只许用 write 系的异步信号安全写法,并对内联、尾调用造成的缺帧保持预期——构建时留住展开信息和调试符号,是这一切成立的前提。