程序停在某个函数里,你往往还想知道:是谁调用了它?调用链上每一层当时的参数是多少? 一个函数被十个地方调用,出错的是哪一次?回答这些问题要靠调用栈(call stack)。这一篇用一个递归求阶乘的程序,把 backtrace、切换栈帧、查看栈帧信息,以及「finish 作用于选中帧」这些细节讲清楚。

这是「C++ 调试实战」系列的第 4 篇。前面几篇的命令都是在「当前函数」里操作,这一篇开始把视野扩展到整条调用链。

一、示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// fact.cpp
#include <cstdio>

int factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}

int main() {
int result = factorial(5);
std::printf("5! = %d\n", result);
return 0;
}
行号 代码
4 if (n <= 1) {
7 return n * factorial(n - 1);
11 int result = factorial(5);
1
2
g++ -std=c++20 -g -O0 fact.cpp -o fact && ./fact
# 5! = 120

factorial(5) 会递归调用 factorial(4)、factorial(3)……一直到 factorial(1)。我们在最深的那一层停下来:

1
2
3
4
5
6
(gdb) break factorial if n == 1
Breakpoint 1 at 0x7b4: file fact.cpp, line 4.
(gdb) run

Breakpoint 1, factorial (n=1) at fact.cpp:4
4 if (n <= 1) {

二、栈帧是什么?

每调用一次函数,程序就在栈上分配一块区域,存放这次调用的参数、局部变量、返回地址等,这块区域叫栈帧(stack frame)。函数返回时,它的栈帧被弹出。现在程序停在 factorial(1) 里,栈上一共压着 6 个帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
        ┌────────────────────────┐
#0 │ factorial(n=1) │ ← 当前正在执行(栈顶)
├────────────────────────┤
#1 │ factorial(n=2) │ 停在第 7 行,等着 #0 返回
├────────────────────────┤
#2 │ factorial(n=3) │
├────────────────────────┤
#3 │ factorial(n=4) │
├────────────────────────┤
#4 │ factorial(n=5) │
├────────────────────────┤
#5 │ main() │ 停在第 11 行,等着 #4 返回
└────────────────────────┘

每一帧都有自己独立的 n。这就是为什么递归函数里「同一个变量」在不同层有不同的值。

三、backtrace:打印调用栈

1
2
3
4
5
6
7
(gdb) backtrace
#0 factorial (n=1) at fact.cpp:4
#1 0x0000aaaaaaaa07d4 in factorial (n=2) at fact.cpp:7
#2 0x0000aaaaaaaa07d4 in factorial (n=3) at fact.cpp:7
#3 0x0000aaaaaaaa07d4 in factorial (n=4) at fact.cpp:7
#4 0x0000aaaaaaaa07d4 in factorial (n=5) at fact.cpp:7
#5 0x0000aaaaaaaa07f8 in main () at fact.cpp:11

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
2
3
4
5
6
7
8
9
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: 0x00000001000004a8 fact.mac`factorial(n=1) at fact.cpp:4:9
frame #1: 0x00000001000004d8 fact.mac`factorial(n=2) at fact.cpp:7:16
frame #2: 0x00000001000004d8 fact.mac`factorial(n=3) at fact.cpp:7:16
frame #3: 0x00000001000004d8 fact.mac`factorial(n=4) at fact.cpp:7:16
frame #4: 0x00000001000004d8 fact.mac`factorial(n=5) at fact.cpp:7:16
frame #5: 0x0000000100000518 fact.mac`main at fact.cpp:11:18
frame #6: 0x00000001884a84e4 dyld`start + 6992

LLDB 多了两样东西:行首的 * 标记当前选中的帧;最底下还有一帧 dyldstart,这是 macOS 动态链接器里调用 main的代码(GDB 默认在main处停止回溯,不显示main` 之外的帧)。

3.1 只看几层

调用栈很深的时候(比如带框架的程序动辄几十层),可以只看一部分:

1
2
3
4
5
6
7
(gdb) bt 2
#0 factorial (n=1) at fact.cpp:4
#1 0x0000aaaaaaaa07d4 in factorial (n=2) at fact.cpp:7
(gdb) bt -3
#3 0x0000aaaaaaaa07d4 in factorial (n=4) at fact.cpp:7
#4 0x0000aaaaaaaa07d4 in factorial (n=5) at fact.cpp:7
#5 0x0000aaaaaaaa07f8 in main () at fact.cpp:11

bt N 看最内层的 N 帧,bt -N 看最外层的 N 帧。LLDB 的 bt 2 同样可用(完整写法 thread backtrace -c 2)。

3.2 连局部变量一起打印

1
2
3
4
5
(gdb) bt -full 2
#0 factorial (n=1) at fact.cpp:4
No locals.
#1 0x0000aaaaaaaa07d4 in factorial (n=2) at fact.cpp:7
No locals.

bt full(新版 GDB 也写作 bt -full)会把每一帧的局部变量都打出来。factorial 没有局部变量所以显示 No locals.。这条命令在分析 core dump、写 bug 报告时特别有用:一条命令就把整个现场记录下来了。

四、切换栈帧

print n 默认看的是当前选中帧(一开始是 #0)的 n。想看别的帧,要先切过去。

4.1 frame N:跳到指定帧

1
2
3
4
5
(gdb) frame 2
#2 0x0000aaaaaaaa07d4 in factorial (n=3) at fact.cpp:7
7 return n * factorial(n - 1);
(gdb) print n
$1 = 3

4.2 up / down:相对移动

1
2
3
4
5
6
7
8
9
10
(gdb) up
#3 0x0000aaaaaaaa07d4 in factorial (n=4) at fact.cpp:7
7 return n * factorial(n - 1);
(gdb) print n
$2 = 4
(gdb) down 2
#1 0x0000aaaaaaaa07d4 in factorial (n=2) at fact.cpp:7
7 return n * factorial(n - 1);
(gdb) print n
$3 = 2

方向容易记反:up 是往调用者方向走(帧编号变大),down 是往被调用者方向走(帧编号变小)。bt 的输出里 #0 排在最上面,up 却是往下面的行走,看起来正好相反。别管输出的排版,记住 up = 回到调用我的那一层 就不会错。

两者都可以带步数:up 2、down 3。

4.3 按函数名切换

栈很深、只想直接跳到某个函数时:

1
2
3
4
5
(gdb) frame function main
#5 0x0000aaaaaaaa07f8 in main () at fact.cpp:11
11 int result = factorial(5);
(gdb) info locals
result = 0

main 里的 result 还是 0——当然,factorial(5) 还没返回呢。

4.4 LLDB 的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(lldb) frame select 2
frame #2: 0x00000001000004d8 fact.mac`factorial(n=3) at fact.cpp:7:16
(lldb) v n
(int) n = 3
(lldb) up
frame #3: 0x00000001000004d8 fact.mac`factorial(n=4) at fact.cpp:7:16
(lldb) v n
(int) n = 4
(lldb) down 2
frame #1: 0x00000001000004d8 fact.mac`factorial(n=2) at fact.cpp:7:16
(lldb) bt 2
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x00000001000004a8 fact.mac`factorial(n=1) at fact.cpp:4:9
* frame #1: 0x00000001000004d8 fact.mac`factorial(n=2) at fact.cpp:7:16

frame select 2 可以缩写为 f 2。注意最后的 bt 2:* 标在了 frame #1 上,表示当前选中的是第 1 帧。

切换栈帧不会让程序执行任何代码,只是改变「从哪一帧的视角看变量」。程序仍然停在 #0 那里。实测 GDB 和 LLDB 在 up 2 之后执行 next,都是从 #0(n=1)的第 4 行走到第 5 行,并不会在第 2 帧里单步。finish 则不同,见第六节。

五、查看栈帧的详细信息

1
2
3
4
5
6
7
8
9
10
(gdb) frame 2
(gdb) info frame
Stack level 2, frame at 0xfffffffffae0:
pc = 0xaaaaaaaa07d4 in factorial (fact.cpp:7); saved pc = 0xaaaaaaaa07d4
called by frame at 0xfffffffffb00, caller of frame at 0xfffffffffac0
source language c++.
Arglist at 0xfffffffffac0, args: n=3
Locals at 0xfffffffffac0, Previous frame's sp is 0xfffffffffae0
Saved registers:
x29 at 0xfffffffffac0, x30 at 0xfffffffffac8
字段 含义
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
2
3
4
(gdb) finish
0x0000aaaaaaaa07d4 in factorial (n=3) at fact.cpp:7
7 return n * factorial(n - 1);
Value returned is $4 = 2

程序跑完了 #0(n=1)和 #1(n=2)两层,回到了 n=3 这一层,返回值是 factorial(2) = 2。再 finish 一次:

1
2
3
4
(gdb) finish
0x0000aaaaaaaa07d4 in factorial (n=4) at fact.cpp:7
7 return n * factorial(n - 1);
Value returned is $5 = 6

factorial(3) = 6。每次 finish 往外退一层,就能看到每一层递归的返回值:1、2、6、24、120。

LLDB 的行为一样:

1
2
3
4
5
6
(lldb) finish
Process 5160 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step out
Return value: (int) $0 = 2

frame #0: 0x00000001000004d8 fact.mac`factorial(n=3) at fact.cpp:7:16

这个特性在实际中很有用:停在一个很深的库函数里,想直接回到自己的代码,就先 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 篇完。下一篇:观察点——变量被偷偷改了?让调试器在它被修改的那一刻停下来。