C++ 调试实战(04):调用栈与栈帧——我是怎么走到这里的
程序停在某个函数里,你往往还想知道:是谁调用了它?调用链上每一层当时的参数是多少? 一个函数被十个地方调用,出错的是哪一次?回答这些问题要靠调用栈(call stack)。这一篇用一个递归求阶乘的程序,把 backtrace、切换栈帧、查看栈帧信息,以及「finish 作用于选中帧」这些细节讲清楚。
这是「C++ 调试实战」系列的第 4 篇。前面几篇的命令都是在「当前函数」里操作,这一篇开始把视野扩展到整条调用链。
一、示例程序
1 | // fact.cpp |
| 行号 | 代码 |
|---|---|
| 4 | if (n <= 1) { |
| 7 | return n * factorial(n - 1); |
| 11 | int result = factorial(5); |
1 | g++ -std=c++20 -g -O0 fact.cpp -o fact && ./fact |
factorial(5) 会递归调用 factorial(4)、factorial(3)……一直到 factorial(1)。我们在最深的那一层停下来:
1 | (gdb) break factorial if n == 1 |
二、栈帧是什么?
每调用一次函数,程序就在栈上分配一块区域,存放这次调用的参数、局部变量、返回地址等,这块区域叫栈帧(stack frame)。函数返回时,它的栈帧被弹出。现在程序停在 factorial(1) 里,栈上一共压着 6 个帧:
1 | ┌────────────────────────┐ |
每一帧都有自己独立的 n。这就是为什么递归函数里「同一个变量」在不同层有不同的值。
三、backtrace:打印调用栈
1 | (gdb) backtrace |
backtrace 缩写 bt,GDB 里也可以写 where。从上往下读:
| 部分 | 含义 |
|---|---|
#0、#1…… |
帧编号,#0 是最内层(当前执行的函数),数字越大越靠外层 |
0x0000aaaaaaaa07d4 in |
这一帧的返回地址:下层函数返回后从这里继续执行。#0 没有这个部分,因为它就在当前 PC 上 |
factorial (n=2) |
函数名和参数值 |
at fact.cpp:7 |
这一帧当前停在哪一行(对外层帧来说,就是「调用下一层的那一行」) |
#1 到 #4 的返回地址完全相同(都是 0x...07d4),因为它们都是从同一条语句 return n * factorial(n - 1); 调用下去的。
LLDB:
1 | (lldb) bt |
LLDB 多了两样东西:行首的 * 标记当前选中的帧;最底下还有一帧 dyldstart,这是 macOS 动态链接器里调用 main的代码(GDB 默认在main处停止回溯,不显示main` 之外的帧)。
3.1 只看几层
调用栈很深的时候(比如带框架的程序动辄几十层),可以只看一部分:
1 | (gdb) bt 2 |
bt N 看最内层的 N 帧,bt -N 看最外层的 N 帧。LLDB 的 bt 2 同样可用(完整写法 thread backtrace -c 2)。
3.2 连局部变量一起打印
1 | (gdb) bt -full 2 |
bt full(新版 GDB 也写作 bt -full)会把每一帧的局部变量都打出来。factorial 没有局部变量所以显示 No locals.。这条命令在分析 core dump、写 bug 报告时特别有用:一条命令就把整个现场记录下来了。
四、切换栈帧
print n 默认看的是当前选中帧(一开始是 #0)的 n。想看别的帧,要先切过去。
4.1 frame N:跳到指定帧
1 | (gdb) frame 2 |
4.2 up / down:相对移动
1 | (gdb) up |
方向容易记反:up 是往调用者方向走(帧编号变大),down 是往被调用者方向走(帧编号变小)。bt 的输出里 #0 排在最上面,up 却是往下面的行走,看起来正好相反。别管输出的排版,记住 up = 回到调用我的那一层 就不会错。
两者都可以带步数:up 2、down 3。
4.3 按函数名切换
栈很深、只想直接跳到某个函数时:
1 | (gdb) frame function main |
main 里的 result 还是 0——当然,factorial(5) 还没返回呢。
4.4 LLDB 的写法
1 | (lldb) frame select 2 |
frame select 2 可以缩写为 f 2。注意最后的 bt 2:* 标在了 frame #1 上,表示当前选中的是第 1 帧。
切换栈帧不会让程序执行任何代码,只是改变「从哪一帧的视角看变量」。程序仍然停在 #0 那里。实测 GDB 和 LLDB 在 up 2 之后执行 next,都是从 #0(n=1)的第 4 行走到第 5 行,并不会在第 2 帧里单步。finish 则不同,见第六节。
五、查看栈帧的详细信息
1 | (gdb) frame 2 |
| 字段 | 含义 |
|---|---|
frame at 0xfffffffffae0 |
这一帧在栈上的地址 |
pc |
这一帧当前执行到的指令地址 |
saved pc |
这一帧返回后要跳去的地址(它的调用者里) |
called by / caller of |
上一帧、下一帧的地址。每层递归栈地址相差 0x20(32 字节),就是一个 factorial 栈帧的大小 |
Saved registers |
保存在栈上的寄存器。ARM64 上 x29 是帧指针、x30 是返回地址(链接寄存器) |
平时用得不多,但排查栈溢出(无限递归)、栈被踩坏时,这些地址能帮你判断栈帧是否正常。LLDB 里 frame info 只显示一行摘要,想看寄存器用 register read。
六、finish 作用于选中帧
第 02 篇说过 finish 是「跑完当前函数」。更准确的说法是:跑完当前选中的那一帧。
接着上面的操作,此时选中的是 #1(n=2),执行 finish:
1 | (gdb) finish |
程序跑完了 #0(n=1)和 #1(n=2)两层,回到了 n=3 这一层,返回值是 factorial(2) = 2。再 finish 一次:
1 | (gdb) finish |
factorial(3) = 6。每次 finish 往外退一层,就能看到每一层递归的返回值:1、2、6、24、120。
LLDB 的行为一样:
1 | (lldb) finish |
这个特性在实际中很有用:停在一个很深的库函数里,想直接回到自己的代码,就先 up 到自己代码所在的那一帧,再 finish,一步到位。
七、用调用栈解决实际问题
调用栈在下面这些场景里几乎是第一个要看的东西:
| 场景 | 怎么用调用栈 |
|---|---|
| 一个函数被很多地方调用,只有某次出错 | 在函数里下(条件)断点,停下后 bt 看是谁调的 |
| 程序崩溃 | 崩溃后第一件事就是 bt,找到第一个属于你自己代码的帧(第 06 篇) |
| 程序卡死 | 中断程序或 attach 上去,看每个线程的 bt,看它们在等什么(第 07 篇) |
| 无限递归导致栈溢出 | bt 会显示成千上万个相同的帧,用 bt -5 看最外层是从哪里开始递归的 |
| 异常从哪里抛出 | GDB 用 catch throw、LLDB 用 breakpoint set -E c++,在异常抛出时停下再看 bt |
一个读栈的习惯:从 #0 往下找,跳过标准库和第三方库的帧,第一个出现你自己源文件名的帧,通常就是问题所在。 用 frame N 切过去,再看变量。
八、小结
| 需求 | GDB | LLDB |
|---|---|---|
| 打印调用栈 | bt / where |
bt |
| 只看内层 N 帧 | bt N |
bt N |
| 只看外层 N 帧 | bt -N |
—— |
| 带局部变量 | bt full |
——(逐帧 frame select N + v) |
| 跳到第 N 帧 | frame N / f N |
frame select N / f N |
| 往调用者方向 | up [N] |
up [N] |
| 往被调用者方向 | down [N] |
down [N] |
| 按函数名跳 | frame function main |
—— |
| 栈帧详情 | info frame |
frame info / register read |
C++ 调试实战系列第 4 篇完。下一篇:观察点——变量被偷偷改了?让调试器在它被修改的那一刻停下来。









