程序停在断点之后,下一步就是「往前走」:一行一行地走(next),钻进函数里走(step),在函数里看够了就一口气跑到函数返回并看看返回值(finish),或者直接跳出一个很长的循环(until)。这几个命令组合起来,就是调试器里最常用的「方向盘」。

这是「C++ 调试实战」系列的第 2 篇。示例程序仍然是第 00 篇的 orders.cpp。上一篇讲了断点,这一篇讲停下来之后怎么走。

关键行号回顾:

行号 代码
12 / 14 / 16 line_total():计算小计 / 打九折 / return total;
21 / 22 / 24 sum_orders():for 循环 / sum += line_total(o); / return sum;
33 main():double total = sum_orders(orders);

一、先把几个命令放在一起看

命令 GDB LLDB 一句话说明
启动 run / start run start = 在 main 下临时断点再 run
继续 continue / c continue / c 一直跑到下一个断点
单步跳过 next / n next / n 执行当前行,遇到函数调用不进去
单步进入 step / s step / s 执行当前行,遇到函数调用钻进去
跑到返回 finish finish 跑完当前函数,回到调用者,显示返回值
跑到某行 until 24 / advance 24 thread until 24 一直跑到指定行(常用于跳出循环)

用一张图记住 next、step、finish 的关系:

1
2
3
4
5
6
7
8
sum_orders():                            line_total():
21 for (const auto& o : orders) {
22 sum += line_total(o); ──step──▶ 12 double total = ...;
│ 13 if (o.quantity >= 10) {
next 16 return total;
(整个调用一步走完) │
▼ finish(跑完函数,带回返回值)
21 (下一轮循环) ◀─────────────────┘

二、start:从 main 开始

不想先下断点,就想从头开始一步步看:

1
2
3
4
5
(gdb) start
Temporary breakpoint 1 at 0x1b10: file orders.cpp, line 27.

Temporary breakpoint 1, main () at orders.cpp:27
27 int main() {

start 就是 tbreak main + run。LLDB 没有 start,写 tbreak main 再 run,或者用 process launch --stop-at-entry(这个会停得更早,在动态链接器里,一般用不上)。

细心的话你会发现,GDB 停在第 27 行 int main() {,LLDB 停在第 28 行。这是 GCC 和 Clang 生成的行号信息不同造成的。不同编译器、不同优化级别下单步的「落点」会有细微差别,这很正常,以你自己机器上的输出为准。

三、next:一行一行走

在 sum_orders 下断点,然后连续 next:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) break sum_orders
Breakpoint 1 at 0x1a10: file orders.cpp, line 19.
(gdb) run

Breakpoint 1, sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:19
19 double sum_orders(const std::vector<Order>& orders) {
(gdb) next
20 double sum = 0.0;
(gdb) next
21 for (const auto& o : orders) {
(gdb) next
22 sum += line_total(o);

next 每次执行一行源码。第 22 行里有一次 line_total() 调用,如果这里再 next,整个函数调用会一步执行完,停在下一行——你看不到 line_total 内部发生了什么。

LLDB 的输出更长一点,同一段过程(LLDB 的断点直接停在了第 20 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(lldb) b sum_orders
Breakpoint 1: where = orders.mac`sum_orders(std::__1::vector<Order, std::__1::allocator<Order>> const&) + 16 at orders.cpp:20:12, address = 0x0000000100000564
(lldb) run
Process 3632 launched: '/tmp/cppdebug/orders.mac' (arm64)
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100000564 orders.mac`sum_orders(orders=size=3) at orders.cpp:20:12
19 double sum_orders(const std::vector<Order>& orders) {
-> 20 double sum = 0.0;
^
21 for (const auto& o : orders) {
(lldb) next
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x000000010000056c orders.mac`sum_orders(orders=size=3) at orders.cpp:21:26
20 double sum = 0.0;
-> 21 for (const auto& o : orders) {
^
22 sum += line_total(o);
(lldb) next
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x00000001000005b0 orders.mac`sum_orders(orders=size=3) at orders.cpp:22:27
21 for (const auto& o : orders) {
-> 22 sum += line_total(o);
^
23 }

小技巧:连续单步时,敲一次 next 后面直接按回车,调试器会重复上一条命令。也可以带次数:next 3 一次走三行。

四、step:钻进函数

停在第 22 行时改用 step,就进入了 line_total:

1
2
3
4
5
6
7
8
9
(gdb) step
line_total (o=...) at orders.cpp:12
12 double total = o.quantity * o.price;
(gdb) next
13 if (o.quantity >= 10) {
(gdb) next
16 return total;
(gdb) print total
$1 = 7.5

进入函数后,GDB 先打印新的函数名和参数(line_total (o=...)),然后停在函数体第一行。继续 next 两次到 return 这一行,打印 total 是 7.5(apple:3 × 2.5,没打折,所以跳过了第 14 行)。

LLDB:

1
2
3
4
5
6
7
8
(lldb) step
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
frame #0: 0x00000001000004f0 orders.mac`line_total(o=0x00000001005d5740) at orders.cpp:12:20
11 double line_total(const Order& o) {
-> 12 double total = o.quantity * o.price;
^
13 if (o.quantity >= 10) {

stop reason = step in 说明这是一次「步入」。

4.1 next 和 step 什么时候没区别?

当前行没有函数调用时,两者效果一样。区别只在有函数调用的那一行。另外,step 只会进入有调试信息的函数:printf、operator new 这类系统库函数通常没带调试信息,step 会自动跨过去。

4.2 一不小心钻进了 STL

C++ 的麻烦在于,很多「看起来不像函数调用」的代码其实是函数调用:范围 for 里的迭代器 ++、!=,vector 的构造函数,std::string 的拷贝……标准库的模板代码都在头文件里,是带着调试信息编译进你的程序的,于是 step 会一头扎进去。在第 21 行的 for 上 step:

1
2
3
4
5
6
7
8
9
(gdb) step
0x0000aaaaaaaa1a7c in __gnu_cxx::__normal_iterator<Order const*, std::vector<Order, std::allocator<Order> > >::operator++ (this=<optimized out>) at /usr/include/c++/15/bits/stl_iterator.h:1104
1104 return *this;
(gdb) step
sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:21
21 for (const auto& o : orders) {
(gdb) step
operator==<Order const*, std::vector<Order> > (__lhs=..., __rhs=...) at /usr/include/c++/15/bits/stl_iterator.h:1206
1206 { return __lhs.base() == __rhs.base(); }

走进了迭代器的 operator++ 和 operator==,这显然不是我们想看的。几种应对办法:

办法 1:进去了就 finish 出来。 最简单通用,下一节就讲。

办法 2:在该用 next 的地方用 next。 在 for 这一行本来就没有你想进的函数,用 next 即可。

办法 3:让 GDB 跳过某些文件或函数。 GDB 的 skip 命令可以告诉它「单步时别进这些地方」:

1
2
3
(gdb) skip file /usr/include/c++/15/bits/stl_iterator.h
(gdb) skip -rfu ^std::
Function(s) ^std:: will be skipped when stepping.

第一条跳过整个头文件,第二条用正则跳过所有 std:: 开头的函数。要注意两点:

  • 上面的迭代器叫 __gnu_cxx::__normal_iterator,operator== 甚至是个不带命名空间前缀的函数模板,^std:: 规则管不到它们,按文件跳过更靠谱;
  • 我在本文环境里试过用通配符按文件跳过(skip -gfi /usr/include/c++/*),结果 step 连 line_total 都不进了,所以建议写具体的文件路径,并用 info skip 确认规则列表。

加完规则后,在第 21 行 step 就会直接走到第 22 行、再进入 line_total,不会再绕进迭代器。此外,较新的 GCC 附带的 libstdc++ 调试脚本在程序加载标准库之后,已经自动加了几条规则(std::move、std::forward、容器的 begin / size / operator[] 等),可以用 info skip 看到。

LLDB 默认就会避开 std:::

1
2
(lldb) settings show target.process.thread.step-avoid-regexp
target.process.thread.step-avoid-regexp (regex) = ^std::

在 macOS 上用 LLDB 对第 21 行连续 step,只会在第 21、22 行之间移动,再 step 就直接进入 line_total,不会进入 libc++ 的迭代器(libc++ 的这些实现都在 std:: 命名空间里)。

五、finish:跑到函数返回,看返回值

在 line_total 里已经看够了,想直接回到调用者——这就是 finish。它还有一个非常有用的附带效果:打印函数的返回值。

1
2
3
4
(gdb) finish
0x0000aaaaaaaa1a5c in sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:22
22 sum += line_total(o);
Value returned is $2 = 7.5

在交互式 GDB 里,finish 会先多打印一行 Run till exit from #0 line_total (o=...) at orders.cpp:16,告诉你正在从哪一帧退出。

几个细节:

  • 回到了第 22 行,而且地址前面带着 0x...a5c in——这表示停在了这一行的中间:line_total(o) 已经返回,但 sum += ... 这个加法还没做。再 next 一次才会完成这行;
  • Value returned is $2 = 7.5`:返回值被存进了值历史 `$2,之后可以直接 print $2 或者在表达式里用它。

LLDB 的输出是 Return value:

1
2
3
4
5
6
7
8
9
10
(lldb) finish
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step out
Return value: (double) $0 = 7.5

frame #0: 0x00000001000005b8 orders.mac`sum_orders(orders=size=3) at orders.cpp:22:16
21 for (const auto& o : orders) {
-> 22 sum += line_total(o);
^
23 }

LLDB 连列号都给了:22:16,^ 指向 sum += 的位置,说明函数调用已经完成、接下来要做加法。finish 在 LLDB 里的完整命令是 thread step-out。

5.1 「查看返回值」为什么重要?

很多代码写成这样:

1
2
return compute_a(x) + compute_b(y);
if (validate(input)) { ... }

返回值没有存进任何变量,print 不到。用 step 进入 validate,然后 finish,就能直接看到它返回了 true 还是 false。这是 printf 调试做不到的。

5.2 finish 作用于「当前选中的帧」

finish 退出的是当前选中的栈帧,不一定是最内层那一帧。第 04 篇讲调用栈时会看到,用 up 切换到上层帧后再 finish,退出的就是那一层。

六、until 与 advance:跑到指定行

回到 sum_orders 之后,再 next 一下就回到了循环头:

1
2
3
4
(gdb) next
21 for (const auto& o : orders) {
(gdb) print sum
$3 = 7.5

第一轮循环结束,sum 是 7.5。剩下两轮不想一轮一轮地走了,直接跑到循环后面的第 24 行:

1
2
3
4
5
(gdb) until 24
sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:24
24 return sum;
(gdb) print sum
$4 = 38.299999999999997

until 24 一口气跑完了剩下的循环。sum 显示为 38.299999999999997 而不是 38.3,这不是 bug:print 会把 double 显示到足够的精度,而 38.3 本身无法用二进制浮点精确表示;std::cout 默认只显示 6 位有效数字,所以程序输出是 38.3。

LLDB 的对应命令是 thread until:

1
2
3
4
5
6
7
8
9
10
(lldb) thread until 24
Process 3632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step until
frame #0: 0x00000001000005d8 orders.mac`sum_orders(orders=size=3) at orders.cpp:24:12
23 }
-> 24 return sum;
^
25 }
(lldb) p sum
(double) 38.299999999999997

最后再 finish,拿到 sum_orders 的返回值:

1
2
3
4
(gdb) finish
0x0000aaaaaaaa1cac in main () at orders.cpp:33
33 double total = sum_orders(orders);
Value returned is $5 = 38.299999999999997

6.1 until 和 advance 的区别

GDB 里两者都能「跑到某个位置」,区别是:

命令 行为
until 位置 跑到指定位置,或者当前函数返回时停下(不会跑出当前函数)
advance 位置 跑到指定位置,可以跨函数,相当于「临时断点 + continue」

advance 可以直接跑进别的函数:

1
2
3
4
5
6
7
8
9
10
11
(gdb) start
...
27 int main() {
(gdb) advance line_total
line_total (o=...) at orders.cpp:12
12 double total = o.quantity * o.price;
(gdb) advance 16
line_total (o=...) at orders.cpp:16
16 return total;
(gdb) print total
$1 = 7.5

从 main 开头一步到 line_total,再一步到第 16 行。LLDB 里没有 advance,用 tbreak line_total 加 continue 达到同样效果。

不带参数的 until 还有一个用途:在循环末尾的那一行执行 until,它会一直执行到行号比当前大的地方,也就是跳出循环。不过这依赖编译器生成的行号顺序,不如直接写目标行号可靠。

七、指令级单步

偶尔需要看汇编层面的执行,比如一行代码里有好几个函数调用、你想精确地一条一条指令走:

命令 GDB LLDB
单条指令(进入 call) stepi / si thread step-inst / si
单条指令(跳过 call) nexti / ni thread step-inst-over / ni
显示当前指令 x/i $pc` / `display/i $pc disassemble --pc

日常调 C++ 用得不多,知道有这回事就行。

八、实战:用单步验证打折逻辑

把本篇的命令串起来,验证「banana 买 12 件打九折」这条逻辑有没有生效:

  1. break line_total if o.quantity >= 10:只停在 banana(第 01 篇的条件断点);
  2. run,停在第 12 行;
  3. next,第 12 行执行完,print total 应该是 12;
  4. next,进入 if,停在第 14 行(说明条件成立);
  5. next,打折完成,停在第 16 行;
  6. finish,看返回值是否为 10.8(GDB 实测显示 Value returned is $2 = 10.800000000000001,原因同上面的 38.3)。

如果第 4 步直接跳到了第 16 行,就说明 if 条件没成立——比如有人把 >= 写成了 >,而数量刚好是 10。单步最擅长抓这类「程序走了哪条分支」的问题。

九、小结

需求 GDB LLDB
从 main 开始 start tbreak main + run
继续运行到下一个断点 continue continue
单步,不进函数 next / n next / n(thread step-over)
单步,进入函数 step / s step / s(thread step-in)
跑完当前函数、看返回值 finish finish(thread step-out)
跑到指定行(不出函数) until 24 thread until 24
跑到任意位置 advance line_total tbreak line_total + continue
单步时跳过标准库 skip file 头文件路径 / skip -rfu 正则 默认避开 ^std::

记住三个原则:

  • 停下的那一行还没执行;
  • 有函数调用的那一行,才需要在 next 和 step 之间做选择;
  • 误入标准库,finish 出来。

C++ 调试实战系列第 2 篇完。下一篇:查看与修改变量——print、格式化输出、STL 容器、display、修改变量和调用函数。