C++ 调试实战(02):运行与单步——next、step、finish 与函数返回值
程序停在断点之后,下一步就是「往前走」:一行一行地走(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 | sum_orders(): line_total(): |
二、start:从 main 开始
不想先下断点,就想从头开始一步步看:
1 | (gdb) start |
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 | (gdb) break sum_orders |
next 每次执行一行源码。第 22 行里有一次 line_total() 调用,如果这里再 next,整个函数调用会一步执行完,停在下一行——你看不到 line_total 内部发生了什么。
LLDB 的输出更长一点,同一段过程(LLDB 的断点直接停在了第 20 行):
1 | (lldb) b sum_orders |
小技巧:连续单步时,敲一次 next 后面直接按回车,调试器会重复上一条命令。也可以带次数:next 3 一次走三行。
四、step:钻进函数
停在第 22 行时改用 step,就进入了 line_total:
1 | (gdb) step |
进入函数后,GDB 先打印新的函数名和参数(line_total (o=...)),然后停在函数体第一行。继续 next 两次到 return 这一行,打印 total 是 7.5(apple:3 × 2.5,没打折,所以跳过了第 14 行)。
LLDB:
1 | (lldb) step |
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 | (gdb) step |
走进了迭代器的 operator++ 和 operator==,这显然不是我们想看的。几种应对办法:
办法 1:进去了就 finish 出来。 最简单通用,下一节就讲。
办法 2:在该用 next 的地方用 next。 在 for 这一行本来就没有你想进的函数,用 next 即可。
办法 3:让 GDB 跳过某些文件或函数。 GDB 的 skip 命令可以告诉它「单步时别进这些地方」:
1 | (gdb) skip file /usr/include/c++/15/bits/stl_iterator.h |
第一条跳过整个头文件,第二条用正则跳过所有 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 | (lldb) settings show target.process.thread.step-avoid-regexp |
在 macOS 上用 LLDB 对第 21 行连续 step,只会在第 21、22 行之间移动,再 step 就直接进入 line_total,不会进入 libc++ 的迭代器(libc++ 的这些实现都在 std:: 命名空间里)。
五、finish:跑到函数返回,看返回值
在 line_total 里已经看够了,想直接回到调用者——这就是 finish。它还有一个非常有用的附带效果:打印函数的返回值。
1 | (gdb) finish |
在交互式 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 | (lldb) finish |
LLDB 连列号都给了:22:16,^ 指向 sum += 的位置,说明函数调用已经完成、接下来要做加法。finish 在 LLDB 里的完整命令是 thread step-out。
5.1 「查看返回值」为什么重要?
很多代码写成这样:
1 | return compute_a(x) + compute_b(y); |
返回值没有存进任何变量,print 不到。用 step 进入 validate,然后 finish,就能直接看到它返回了 true 还是 false。这是 printf 调试做不到的。
5.2 finish 作用于「当前选中的帧」
finish 退出的是当前选中的栈帧,不一定是最内层那一帧。第 04 篇讲调用栈时会看到,用 up 切换到上层帧后再 finish,退出的就是那一层。
六、until 与 advance:跑到指定行
回到 sum_orders 之后,再 next 一下就回到了循环头:
1 | (gdb) next |
第一轮循环结束,sum 是 7.5。剩下两轮不想一轮一轮地走了,直接跑到循环后面的第 24 行:
1 | (gdb) until 24 |
until 24 一口气跑完了剩下的循环。sum 显示为 38.299999999999997 而不是 38.3,这不是 bug:print 会把 double 显示到足够的精度,而 38.3 本身无法用二进制浮点精确表示;std::cout 默认只显示 6 位有效数字,所以程序输出是 38.3。
LLDB 的对应命令是 thread until:
1 | (lldb) thread until 24 |
最后再 finish,拿到 sum_orders 的返回值:
1 | (gdb) finish |
6.1 until 和 advance 的区别
GDB 里两者都能「跑到某个位置」,区别是:
| 命令 | 行为 |
|---|---|
until 位置 |
跑到指定位置,或者当前函数返回时停下(不会跑出当前函数) |
advance 位置 |
跑到指定位置,可以跨函数,相当于「临时断点 + continue」 |
advance 可以直接跑进别的函数:
1 | (gdb) start |
从 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 件打九折」这条逻辑有没有生效:
break line_total if o.quantity >= 10:只停在 banana(第 01 篇的条件断点);run,停在第 12 行;next,第 12 行执行完,print total应该是 12;next,进入if,停在第 14 行(说明条件成立);next,打折完成,停在第 16 行;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、修改变量和调用函数。








