12. 调试器
在为 wordfreq 扩充“按最长单词宽度对齐”的报表格式化功能后,在终端执行 wordfreq -n 10 tests/data/sample.txt,程序却突兀地中断,仅留下一行简短的 Segmentation fault,甚至未曾说明出错源文件与行号。这次功能迭代所涉及的改动范围极其有限,仅仅触及了 src/report.c 中的排版循环以及 app/main.c 中的一处接口调用。单纯依靠静态肉眼审阅这两处修改,往往极难察觉症结所在:排版循环在逻辑结构上挑不出破绽,主函数中传递的参数也符合常理。
在以往传统的排查手法中,开发者通常会在心存疑虑的代码段穿插几条 printf,重新编译、再次复现、查阅日志,确认后再将调试代码逐一抹去。这种“打印式调试”在数十行的小脚本中尚能应付,但随着工程规模逐步膨胀,每次插入打印都需要重走编译与链接流程;若遇到仅在特定稀有输入或深层调用链下才会偶发崩溃的缺陷,反复埋点不仅耗时费力,更会破坏原本的代码节奏。调试器(Debugger)则提供了截然不同的交互范式:它能够在不改动源码分毫的前提下,让正在执行的程序精确暂停在任意指定的机器指令或源码行上,赋予开发者随时检视当前作用域内的内存对象、回溯多层调用栈帧以及透视底层内存字节排布的完全控制权。
本章将以 GDB 为核心工具,在注入了实际缺陷的 wordfreq 崩溃版本上完整演示故障现场的诊断与逆向定位路径;随后系统梳理断点控制、单步执行、条件断点以及硬件观察点的工程用法;进而深入底层探讨内存字节与 CPU 寄存器的巡检技巧;在此基础上,还将演示如何借助调试器攻克上一章自动化测试抛出的未通过断言,并剖析编译器优化级别对调试体验带来的实际影响。
1. 准备与基本交互
调试器能够将底层的机器码地址逆向映射回人类可读的源码行号与符号名称,完全依赖于二进制产物中附带的调试信息(Debug Information)。因此,在使用 GCC 编译源代码时,必须显式附加 -g 编译选项;同时,应当显式关闭编译器优化,采用 -O0 或专为调试设计的 -Og。
第 3 章曾详细解析过 -g 生成 DWARF 调试节区的内部机制。若编译时遗漏了 -g,GDB 依然能够拉起进程并停留在底层地址断点上,但此时读取对象值、展示源码上下文、解析结构体定义以及映射精准行号的功能将全线瘫痪,调试器将被迫退化为纯汇编指令级别的单步跟踪器。
$ gdb --version | head -1
GNU gdb (GDB for MinGW-W64 x86_64, built by Brecht Sanders, r8) 14.22
主流 Linux 发行版通常可通过包管理器直接安装 gdb,MinGW-w64 工具链大多开箱自带,macOS 平台则原生搭载 LLDB(后文第 8 节将提供完整的对应命令表)。本章的实操会话录制于搭载 MinGW-w64 GDB 14.2 与 GCC 13.2 的环境,被调试的目标即为注入了经典缺陷的 wordfreq 二进制程序。
在命令行使用 GDB 拉起带有运行参数的可执行文件时,有两种常见的启动方式。一种是先执行 gdb ./prog 进入 GDB 提示符界面,随后在内部通过 run 参数1 参数2 注入参数;另一种更为高效的方式是使用 --args 选项一步到位:
gdb --args ./build/wordfreq -n 10 tests/data/sample.txt在 --args 之后出现的所有字符串都会被完整透传给被调试程序,诸如 -n 10 这样的标记绝不会被 GDB 错误解析为其自身的命令行选项。进入 GDB 交互式 Shell 后,通过 show args 可以核实已捕获的入参清单,输入 run(可简写为 r)即可启动被测程序,调试完毕后输入 quit(简写为 q)安全退出。启动一个行为完全正常的程序,其交互过程如下:
$ gdb --args ./build/wordfreq -n 3 tests/data/sample.txt
Reading symbols from ./build/wordfreq...
(gdb) show args
Argument list to give program being debugged when it is started is "-n 3 tests/data/sample.txt".
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 10504.0x1dc4]
4 the
2 on
2 sat
[Thread 10504.0x1dc4 exited with code 0]
[Inferior 1 (process 10504) exited normally]
(gdb) quit2
3
4
5
6
7
8
9
10
11
12
13
本章的会话统一在纯英文路径 C:\proj 下展开录制,以防 Windows 终端在特定代码页下转义中文路径时产生乱码。在输出信息中,Windows 平台所出现的 [New Thread …] 是 GDB 捕获系统底层线程创建的通知;而 inferior 则是 GDB 体系中对正在被跟踪的被调试目标进程的通用技术称谓。
在此处理清调试器与打印语句的工程定位极有必要。Brian Kernighan 与 Rob Pike 在软件工程经典《The Practice of Programming》中写道:最有效的调试工具始终是周密的理性思考,辅以审慎放置的打印诊断。静态审阅能够一眼看透的逻辑问题,不必大费周章拉起调试器;但当需要在上千次循环迭代中捕获某一次边界异动,或是需要沿着曲折的多层调用栈追踪一个动态指针的生命周期时,熟练运用调试器所节省的排查时间将远远超越其学习成本。
2. 定位崩溃
本章所涉及的两组缺陷实现统一存放在 code/开发与工程基础/12_调试器/bugs/ 目录下。每个缺陷都以模块化的替换源文件呈现,将其复制覆盖进项目并重新编译即可稳定重现,还原原文件即可恢复如初。为了与干净的工程基线形成鲜明对照,本章在不同测试目录间分步推进。
$ cp bugs/report_alignment.c src/report.c
$ cp bugs/main_passes_n.c app/main.c
$ gcc -std=c17 -Wall -Wextra -g -O0 -D__USE_MINGW_ANSI_STDIO=1 \
-Iinclude src/*.c app/main.c -o build/wordfreq_crash
$ ./build/wordfreq_crash -n 10 tests/data/sample.txt
Segmentation fault2
3
4
5
6
在这个崩溃版本中,src/report.c 中的 wf_report_print 被改写为先扫描全量条目获取最大单词字符宽度、再统一定宽对齐输出的实现:
void wf_report_print(FILE *out, const wf_entry *entries, size_t n) {
size_t width = 0;
for (size_t i = 0; i < n; i++) {
size_t len = 0;
for (const char *p = entries[i].word; *p != '\0'; p++) {
len++;
}
if (len > width) {
width = len;
}
}
for (size_t i = 0; i < n; i++) {
fprintf(out, "%7zu %-*s\n", entries[i].count, (int)width,
entries[i].word);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
而在主调用方 app/main.c 的 print_top 函数中,调用处传递的条目数直接选用了用户在命令行中请求的数字 n:
size_t written = wf_counter_top(counter, entries, n);
int status = 0;
if (written == 0 && wf_counter_size(counter) > 0) {
status = out_of_memory();
} else {
wf_report_print(stdout, entries, n);
}2
3
4
5
6
7
size_t written = wf_counter_top(counter, entries, n);
int status = 0;
if (written == 0 && wf_counter_size(counter) > 0) {
status = out_of_memory();
} else {
wf_report_print(stdout, entries, written);
}2
3
4
5
6
7
在示例文件 sample.txt 中,全文实际上总共仅有 7 个不同的英文单词。当命令行指定 -n 10 时,动态分配的条目数组中只有前 7 个条目被 wf_counter_top 赋予了合法的词频数据,后 3 个条目依然保持着 calloc 分配时的全零状态。此时对齐循环盲目遍历前 10 项,在第 8 项首次解引用空指针时,操作系统便立即拦截非法内存访问并触发段错误。实测在 -n 7 时程序运行平稳,而一旦输入 -n 8 及以上便瞬间崩溃。
面对这处突发崩溃,我们无须靠猜测去反推,直接让 GDB 接管运行并捕获异常现场:
$ gdb --args ./build/wordfreq_crash -n 10 tests/data/sample.txt
Reading symbols from ./build/wordfreq_crash...
(gdb) run
Starting program: C:\proj\wordfreq-crash\build\wordfreq_crash.exe -n 10 tests/data/sample.txt
[New Thread 3692.0x5b94]
Thread 1 received signal SIGSEGV, Segmentation fault.
0x00007ff701be1c7a in wf_report_print (out=0x7ffc519c8c98, entries=0x7c4c70,
n=10) at src/report.c:12
12 for (const char *p = entries[i].word; *p != '\0'; p++) {
(gdb) bt
#0 0x00007ff701be1c7a in wf_report_print (out=0x7ffc519c8c98,
entries=0x7c4c70, n=10) at src/report.c:12
#1 0x00007ff701be232f in print_top (counter=0x7c1ed0, n=10) at app/main.c:120
#2 0x00007ff701be24a3 in main (argc=4, argv=0x7bb9b0) at app/main.c:1512
3
4
5
6
7
8
9
10
11
12
13
14
15
在发生非法的内存解引用时,操作系统会向进程发送 SIGSEGV 信号,并将控制权交还给挂载在此进程上的调试器。GDB 能够立即捕获该信号并暂停程序,控制台清晰展示了崩溃发生的函数名、当前入参、源码相对路径与行号(Linux 平台通常显示为 Program received signal SIGSEGV, Segmentation fault.,输出结构完全一致)。
命令 bt(即 backtrace,回溯)用于展开当前线程完整的调用栈(Call Stack):栈帧编号 #0 代表发生崩溃的最内层函数,#1 代表它的直接调用者,#2 则再向外追溯一层,直至程序的最初入口 main。每一层栈帧都忠实呈现了帧编号、运行时指令指针地址、函数名称与当前实参、以及对应的源文件与行号。单从 #0 的信息即可判定,程序是在 wf_report_print 内部计算单词长度的循环中发生了异常解引用。
通过 frame 帧号 可以自如切换当前的观测上下文,up 与 down 则支持在相邻层级的调用帧之间逐层移动。我们将视角切换至外层调用帧 #1,此时使用 print 指令即可检视上层函数 print_top 内部的局部对象:
(gdb) frame 1
#1 0x00007ff701be232f in print_top (counter=0x7c1ed0, n=10) at app/main.c:120
120 wf_report_print(stdout, entries, n);
(gdb) info locals
entries = 0x7c4c70
written = 7
status = 0
(gdb) print written
$1 = 7
(gdb) print n
$2 = 10
(gdb) up
#2 0x00007ff701be24a3 in main (argc=4, argv=0x7bb9b0) at app/main.c:151
151 status = print_top(counter, n);
(gdb) print n
$3 = 10
(gdb) down
#1 0x00007ff701be232f in print_top (counter=0x7c1ed0, n=10) at app/main.c:1202
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
指令 info locals 能够一次性列出当前活动帧中声明的所有局部对象。仔细比对输出可以发现关键出入:written 是由底层哈希表提取函数 wf_counter_top 实际填充并返回的有效词项数量,其值为 7;而 n 则是用户从外部命令行传来的期望排行榜长度 10。两个数值并不一致,但在第 120 行的代码中,传给打印函数 wf_report_print 的第三个参数却是请求数量 n。重新切换回 #0 帧,直接审阅那个越界的条目内容:
(gdb) frame 0
#0 0x00007ff701be1c7a in wf_report_print (out=0x7ffc519c8c98,
entries=0x7c4c70, n=10) at src/report.c:12
12 for (const char *p = entries[i].word; *p != '\0'; p++) {
(gdb) print i
$4 = 7
(gdb) print entries[i]
$5 = {word = 0x0, count = 0}2
3
4
5
6
7
8
当前索引 i 恰好是 7,即数组中第 8 个(自 0 起算)未经填写的条目。该条目内部的 word 成员是一个空指针 0x0,频次计数值也为 0。calloc 在分配内存时将全部分配块清零,而 wf_counter_top 仅覆写了前 7 项,后 3 项保留为全零。打印模块的对齐循环在设计时未对 count 或 word 做空指针守卫,直接把未初始化的 word 传给内层循环执行 *p != '\0' 的字符求值,解引用空指针瞬间引爆了段错误。
故障根因因此昭然若揭:主调用方传递给报表模块的数组长度,应当是实际写入项数 written,而非外部盲目传入的上限 n。修正这一调用参数后重新编译运行,-n 10 能够稳定输出全部 7 行统计,进程以标准退出码 0 顺利结案。
调用栈的每一帧对应一次尚未返回的调用
在程序运行期间,每次进入新的函数调用,操作系统与调用约定都会在调用栈上开辟出一块独立的栈帧(Stack Frame),用于保存调用返回地址、输入实参、函数内部的局部对象以及被调用者保护寄存器。当函数执行完毕返回时,该栈帧随即被出栈销毁。调试器通过逆向遍历栈帧链表重构出回溯视图。使用 frame N 可以自由跃迁至指定的调用上下文,后续的 print 与 info locals 操作也会严格绑定在所选栈帧的作用域内。在程序发生崩溃时,最内层的 #0 帧即为事发现场,而外层栈帧则完好封存了引发该次调用的业务输入。
至此,崩溃定位已完整闭环。在真实的工业级 C 语言工程中,引发 SIGSEGV 的源头千差万别:野指针悬空、堆内存越界写入、栈缓冲区溢出等故障最终往往都表现为同一种崩溃信号。调用栈回溯能够迅速剥除表面迷雾,直接指出事发点代码行;而深层次的成因推演,依然需要依靠清晰的架构理解与严谨的代码逻辑梳理。
3. 断点与单步
崩溃通常发生在程序跑飞的最极端边缘;而在绝大多数日常开发中,程序往往能够“正常”跑完,只是最终输出的业务数据完全偏离了预期。此时盲目等待崩溃毫无意义,我们必须让程序在关键阶段受控停驻,动态巡视其状态变迁。
指令 break(简写为 b)用于在指定的代码锚点设置断点(Breakpoint)。锚点位置既可以指定为精准的 文件名:行号,也可以直接指定为函数符号名:
break src/counter.c:85
break wf_counter_add2
以函数名形式设置断点时,若工程中存在多处同名符号,GDB 会在所有匹配符号上均挂接断点;而 文件名:行号 的形式则更为严谨。即使是带有 static 修饰的内部函数,只要编译时附带了调试符号,GDB 均可直接解析并下断。
$ gdb --args ./build/wordfreq -n 3 tests/data/sample.txt
Reading symbols from ./build/wordfreq...
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 16404.0x64c0]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x7f1e10, word=0x5ffd70 "the")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
(gdb) print c->size
$1 = 02
3
4
5
6
7
8
9
10
11
12
13
断点成功截获了进程的执行流,并在函数体的第一条有效语句执行前停驻。此时 c->size 尚且为 0,因为这是分词流水线送来的首个词条 the,内部的查找函数 find_entry 尚未被正式调用。
在断点暂停后,常用的单步执行指令分为两类:next 会单步执行当前代码行,若当前行包含函数调用,它会等待被调函数完整执行完毕,停在当前函数的下一行;而 step 则会直接步入被调函数内部的第一条有效语句:
(gdb) next
86 if (e != NULL) {
(gdb) next
91 if (reserve_one(c) != 0) {
(gdb) print e
$2 = (struct item *) 0x0
(gdb) next
94 char *copy = dup_string(word);
(gdb) step
dup_string (s=0x5ffd70 "the") at src/counter.c:25
25 size_t len = strlen(s);
(gdb) bt 2
#0 dup_string (s=0x5ffd70 "the") at src/counter.c:25
#1 0x00007ff65ef51978 in wf_counter_add (c=0x7f1e10, word=0x5ffd70 "the")
at src/counter.c:94
(More stack frames follow...)
(gdb) finish
Run till exit from #0 dup_string (s=0x5ffd70 "the") at src/counter.c:25
0x00007ff65ef51978 in wf_counter_add (c=0x7f1e10, word=0x5ffd70 "the")
at src/counter.c:94
94 char *copy = dup_string(word);
Value returned is $3 = 0x7f2710 "the"
(gdb) next
95 if (copy == NULL) {
(gdb) info locals
e = 0x0
copy = 0x7f2710 "the"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
常用控制执行流的单步指令语义如下:
| 命令 | 作用 |
|---|---|
next(缩写 n) | 单步执行当前行,若遇函数调用则将其作为一个整体跨过 |
step(缩写 s) | 单步执行当前行,若遇函数调用则步入被调函数内部 |
finish | 持续执行,直至当前函数调用返回,并在终端打印该函数的返回值 |
continue(缩写 c) | 恢复全速运行,直至命中下一个断点或整个进程执行完毕 |
until 行号 | 持续执行直到抵达当前函数内的指定行号,常用于跳出冗长循环 |
nexti、stepi | 以单条机器汇编指令为步长执行,在缺乏源码或排查编译器优化时极为实用 |
在实际操作中,next 与 step 的语义分野只在当前行存在函数调用时才会体现。在上述会话中,我们在第 94 行使用 step 步入了内部的字符串复制辅助函数 dup_string;在确认其逻辑无碍后,输入 finish 指令即可让程序跑完该辅助函数并返回上一层,GDB 还会主动捕获并打印函数返回的指针地址 $3 = 0x7f2710 "the"。这比手动增加临时打印去观测返回值要优雅且高效得多。
next 越过调用,step 进入调用
next 与 step 都以高级语言的源码语句为步长推进行程。当执行行包含函数调用时,next 将整个函数调用视作一个不可分割的单步,待其内部全部跑完后停在紧随其后的下一条语句;而 step 则会主动钻入被调函数的实现中,停留在该函数内部的第一条语句。在工程排查中,面对自己正在调试的核心业务代码通常使用 step 步入追踪,而面对成熟稳定的标准库函数(如 strlen、memcpy)或经充分验证的基础组件时,则应当使用 next 干净利落地越过。
停在 x = f(a); 这一行时执行 next,会发生什么?
通过 break 设立的断点在 GDB 内部统一分配自增的数值编号进行索引维护。使用 info breakpoints(可缩写为 i b)可以一览当前处于激活状态的全部断点清单;使用 delete 编号(可简写为 d 编号)能够移除指定的断点;若直接执行不带任何参数的 delete,GDB 则会提示清空所有断点:
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
(gdb) break src/counter.c:100
Breakpoint 2 at 0x1400019c5: file src/counter.c, line 100.
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x000000014000191c in wf_counter_add
at src/counter.c:85
2 breakpoint keep y 0x00000001400019c5 in wf_counter_add
at src/counter.c:100
(gdb) delete 2
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x000000014000191c in wf_counter_add
at src/counter.c:852
3
4
5
6
7
8
9
10
11
12
13
14
15
若某些代码段只需在初次抵达时拦截一次,后续循环无需再次打扰,可以使用 tbreak 设置临时断点(Temporary Breakpoint)。临时断点在被触发命中一次后便会自动销毁。我们将上述流程串联起来:在断点命中后单步推进至字符串复制完成、内部计数即将递增的位置,并在函数退出前打入临时断点:
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
(gdb) break src/counter.c:100
Breakpoint 2 at 0x1400019c5: file src/counter.c, line 100.
(gdb) delete 2
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 20768.0x4cc4]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x6f1e10, word=0x5ffd70 "the")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
(gdb) next
86 if (e != NULL) {
(gdb) next
91 if (reserve_one(c) != 0) {
(gdb) next
94 char *copy = dup_string(word);
(gdb) next
95 if (copy == NULL) {
(gdb) tbreak src/counter.c:102
Temporary breakpoint 3 at 0x7ff65ef519de: file src/counter.c, line 102.
(gdb) continue
Continuing.
Thread 1 hit Temporary breakpoint 3, wf_counter_add (c=0x6f1e10,
word=0x5ffd70 "the") at src/counter.c:102
102 }
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x00007ff65ef5191c in wf_counter_add
at src/counter.c:85
breakpoint already hit 1 time2
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
28
29
30
31
32
33
临时断点 3 命中并停驻后,再次执行 info breakpoints 就会发现它已被 GDB 自动清理,仅保留长期的断点 1。临时断点在穿透冗长的前置初始化阶段时表现得尤为利落。
指令 until 行号(可缩写为 u)在底层逻辑上等同于“在目标行号注入临时断点并立即执行 continue”。这一技巧在跳出繁冗复杂的循环体时格外得心应手:当程序停留在循环体内部时,只需执行 until 配合循环结束后的行号,执行流便会持续运转直至循环终结:
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 24220.0x58e4]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x7d1e10, word=0x5ffd70 "the")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
(gdb) until 94
wf_counter_add (c=0x7d1e10, word=0x5ffd70 "the") at src/counter.c:94
94 char *copy = dup_string(word);
(gdb) next
95 if (copy == NULL) {
(gdb) print copy
$1 = 0x7d2710 "the"
(gdb) until 102
wf_counter_add (c=0x7d1e10, word=0x5ffd70 "the") at src/counter.c:102
102 }
(gdb) info locals
e = 0x0
copy = 0x7d2710 "the"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
指令 print(简写为 p)能够依据对象在 C 语言源码中的类型声明完成自适应的格式化渲染,而 ptype 则专门用于解构并打印类型本身的定义规范。面对指针对象,print *p 能够自动解引用指向的结构体,ptype 则能清晰展开结构体字段成员:
(gdb) break print_top
Breakpoint 1 at 0x140002224: file app/main.c, line 117.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 3192.0x654c]
Thread 1 hit Breakpoint 1, print_top (counter=0x6b1e10, n=3) at app/main.c:117
117 wf_entry *entries = calloc(n, sizeof *entries);
(gdb) print/x counter->size
$1 = 0x7
(gdb) print *counter
$2 = {items = 0x6b4970, size = 7, capacity = 16}
(gdb) next
118 if (entries == NULL) {
(gdb) next
121 size_t written = wf_counter_top(counter, entries, n);
(gdb) next
122 int status = 0;
(gdb) next
123 if (written == 0 && wf_counter_size(counter) > 0) {
(gdb) print written
$3 = 3
(gdb) print entries[0]@3
$4 = {{word = 0x6b2710 "the", count = 4}, {word = 0x6b4b20 "on", count = 2}, {
word = 0x6b4ae0 "sat", count = 2}}
(gdb) print *entries
$5 = {word = 0x6b2710 "the", count = 4}
(gdb) ptype wf_entry
type = struct {
const char *word;
size_t count;
}
(gdb) list
118 if (entries == NULL) {
119 return out_of_memory();
120 }
121 size_t written = wf_counter_top(counter, entries, n);
122 int status = 0;
123 if (written == 0 && wf_counter_size(counter) > 0) {
124 status = out_of_memory(); /* 有单词却一项也取不出来 */
125 } else {
126 wf_report_print(stdout, entries, written);
127 }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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
指令 print entries[0]@3 采用了 GDB 原生的动态数组切片语法:以 entries[0] 为基准地址,连续向下排版 3 个连续元素,这种语法对裸指针同样完全有效。而在输出进制控制上,print/x 强制按十六进制呈现,print/d 采用有符号十进制,print/c 解释为 ASCII 单字符,print/t 则按纯二进制位串展示。
若希望在每一次单步或命中时都能自动呈现某个特定对象或表达式,可以使用 display。它在跟踪循环状态迁移时非常便利:
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 23624.0x4704]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x7d1e10, word=0x5ffd70 "the")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
(gdb) display word
1: word = 0x5ffd70 "the"
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x7d1e10, word=0x5ffd70 "cat")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
1: word = 0x5ffd70 "cat"
(gdb) display c->size
2: c->size = 2
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x7d1e10, word=0x5ffd70 "sat")
at src/counter.c:85
85 struct item *e = find_entry(c, word);
1: word = 0x5ffd70 "sat"
2: c->size = 22
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
28
使用 undisplay 编号 可以清理指定的常驻展示项。配置好的展示项清单会跨越单次运行周期持续保留。
此外,指令 set var 对象 = 新值 可以在运行时直接重写内存或寄存器中的值。借助此功能,我们无需修改源文件并重跑编译,就能在内存中即时推演“假如此处的状态改变,后续逻辑是否正常”:
(gdb) break print_top
Breakpoint 1 at 0x140002224: file app/main.c, line 117.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 23852.0x5f38]
Thread 1 hit Breakpoint 1, print_top (counter=0x681e10, n=3) at app/main.c:117
117 wf_entry *entries = calloc(n, sizeof *entries);
(gdb) print n
$1 = 3
(gdb) set var n = 2
(gdb) next
118 if (entries == NULL) {
(gdb) next
121 size_t written = wf_counter_top(counter, entries, n);
(gdb) next
122 int status = 0;
(gdb) print written
$2 = 2
(gdb) continue
Continuing.
4 the
2 on
[Inferior 1 (process 23852) exited normally]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
通过在运行时强行把请求的条目数 n 从 3 改写为 2,wf_counter_top 实际截取的数量与最终的标准输出均同步缩减为前两项。需要明确的是,set var 只是动态修改了当前进程生命周期内的内存数据,磁盘上的 C 源代码毫发无损,重新拉起 run 后一切依然从源码基线重新初始化。
停在 wf_counter_add(counter, word); 上执行 step,与执行 next 有什么不同?
4. 条件断点与观察点
相比于显而易见的崩溃,逻辑层面的静默偏差往往更为棘手。我们在第二组缺陷中模拟了一种更为隐蔽的逻辑缺陷:src/counter.c 中的内部查找函数 find_entry 被错误地换成了前缀比对实现。编译配置保持不变,仅替换该文件:
static struct item *find_entry(const wf_counter *c, const char *word) {
for (size_t i = 0; i < c->size; i++) {
struct item *e = &c->items[i];
if (strncmp(word, e->word, strlen(word)) == 0) {
return e;
}
}
return NULL;
}2
3
4
5
6
7
8
9
标准库函数 strncmp 仅比对前 strlen(word) 个字节。当一个较短的词恰好是已有长词的前缀时,比对逻辑就会得出两者相同的荒谬判定。在测试目录中应用这一改动,向程序投喂序列 then the then the the:按照正常逻辑,输出应为 the 出现 3 次、then 出现 2 次;但在前缀缺陷版下,全部 5 次计数都被错误归并到了 then 名下:
$ cp bugs/counter_prefix_match.c src/counter.c
$ gcc -std=c17 -Wall -Wextra -g -O0 -D__USE_MINGW_ANSI_STDIO=1 \
-Iinclude src/*.c app/main.c -o build/wordfreq
$ printf 'then the then the the\n' > input.txt
$ ./build/wordfreq -n 5 input.txt
5 then2
3
4
5
6
程序全程执行平稳,未抛出任何操作系统信号或控制台警告,只有最终的统计报表出现了逻辑偏差。若试图通过在代码中加入 printf 去排查,面对成百上千次词汇哈希或数组线性遍历,繁冗的打印输出会迅速淹没排查者的视线,很难精准判定究竟是哪一次比对产生了偏航。
语法 break 目标位置 if 条件表达式 能够设立条件断点(Conditional Breakpoint):GDB 仅在条件判定为真(非 0)时才会真正挂起进程。若我们希望洞察“当单词 the 到来时,find_entry 究竟比对到了什么已有条目”,可以精准配置仅在 word 等于 "the" 时触发的中断:
(gdb) break find_entry if (int)strcmp(word, "the") == 0
Breakpoint 1 at 0x14000173c: file src/counter.c, line 34.
(gdb) run
Starting program: C:\proj\wordfreq-prefix\build\wordfreq.exe -n 5 input.txt
[New Thread 18904.0x6650]
Thread 1 hit Breakpoint 1, find_entry (c=0x731e80, word=0x5ffd70 "the")
at src/counter.c:34
34 for (size_t i = 0; i < c->size; i++) {
(gdb) next
35 struct item *e = &c->items[i];
(gdb) next
36 if (strncmp(word, e->word, strlen(word)) == 0) {
(gdb) next
37 return e;
(gdb) print i
$1 = 0
(gdb) print e->word
$2 = 0x734ac0 "then"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
条件断点在首次遍历 the 时如期拦截。此时循环索引 i 为 0,而当前被比对的条目 e->word 则是此前收录的 "then"。单步推进越过 strncmp 判断,函数竟直接进入了返回分支 return e,将短词 the 错误映射为了条目 then 的引用。在测试输入中 the 总共出现了 3 次,配合 continue 指令,开发者能够清晰复核每一次分词是如何被引流至错误条目中的。
在编写条件表达式时,若调用了 strcmp 等 C 标准库函数,GDB 有时会提示 'strcmp' has unknown return type; cast the call to its declared return type。这是由于调试符号库中可能缺乏该 libc 函数的原型声明,只需在函数调用前附加显式强制类型转换 (int)strcmp(...) 即可顺利求值。
需要指出的是,条件断点的内部开销显著高于无条件断点:CPU 每次命中该代码物理地址时都会产生软中断中断进程,随后由 GDB 负责计算并解析条件表达式。在循环内部若迭代次数极多,频繁求值会导致程序运行变慢。
命中次数太多的条件换一种写法
当一段深层循环需要迭代数十万次、而故障仅在第 1000 次左右才会触发时,让 GDB 逐次评估条件开销极大。一种高效的做法是在代码中寻找仅在特定阶段才会被穿透的邻近路径设立断点;另一种方式是使用 ignore 断点编号 触发次数,指示 GDB 直接跳过前 N 次命中,全速执行至目标窗口附近。这种方式完全由调试器底层的指令计数器接管,不改变程序的正常运行节奏。
条件断点回答了“在特定逻辑条件发生时停在何处”的问题,而观察点(Watchpoint)则专攻另一个经典难题:“究竟是谁在什么时机篡改了这个对象的内存值”。通过 watch 表达式,GDB 会在监视对象发生数值写入改变的瞬间中断执行,并精确打印出旧值、新值、引发写入的源文件行以及完整的调用栈帧。
为了锁定究竟是哪次调用把 then 的词频给加错了,我们可以直接监视其频次对象。在样本输入中,第一个词正是 then,第二项才出现 the。我们通过 ignore 跳过第一次正常录入:
(gdb) break wf_counter_add
Breakpoint 1 at 0x14000192e: file src/counter.c, line 83.
(gdb) ignore 1 1
Will ignore next crossing of breakpoint 1.
(gdb) run
Starting program: C:\proj\wordfreq-prefix\build\wordfreq.exe -n 5 input.txt
[New Thread 23400.0x50f0]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x681e80, word=0x5ffd70 "the")
at src/counter.c:83
83 struct item *e = find_entry(c, word);
(gdb) print c->items[0]
$1 = {word = 0x682740 "then", count = 1}
(gdb) watch -l c->items[0].count
Hardware watchpoint 2: -location c->items[0].count
(gdb) continue
Continuing.
Thread 1 hit Hardware watchpoint 2: -location c->items[0].count
Old value = 1
New value = 2
wf_counter_add (c=0x681e80, word=0x5ffd70 "the") at src/counter.c:86
86 return 0;
(gdb) bt 2
#0 wf_counter_add (c=0x681e80, word=0x5ffd70 "the") at src/counter.c:86
#1 0x00007ff7a2a9213b in count_stream (counter=0x681e80, in=0x67be00,
name=0x67bdc0 "input.txt") at app/main.c:95
(More stack frames follow...)
(gdb) print word
$2 = 0x5ffd70 "the"
(gdb) print c->items[0]
$3 = {word = 0x682740 "then", count = 2}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
28
29
30
31
32
33
ignore 1 1 使得断点 1 忽略了第一次正常初始化 then 的流程,程序直接停留在处理第二个词条 the 的时刻,此时条目 0 的词频依然维持初始的 1。随后我们对 c->items[0].count 挂接观察点;执行 continue 后,GDB 在计数值被改写为 2 的瞬间立即中断。调用栈回溯与 print word 清晰证明,正是分词为 the 的调用链路错误写入了该项。
命令 watch -l 表达式 中的 -l 代表按内存物理地址监控(Location):GDB 在下断瞬间先计算出目标对象所在的具体虚拟内存地址,随后持续盯紧该固定地址范围的写入事件。若省略 -l,GDB 会在每个单步周期尝试重新计算整个表达式树,当表达式中涉及指针解引用、且指针可能被释放或移动时,监控便会意外失效。因此,在监视结构体内部深层成员时,使用 watch -l 具有更高的鲁棒性。
需要提醒的是,涉及指针解引用的监视表达式必须在其指向的内存已经完成分配后才能设立,否则 GDB 会因无法解析底层地址而拒绝请求。
现代主流 CPU 架构(如 x86-64 的 DR0~DR3 调试寄存器、ARM Cortex-M 的 DWT 数据观察点单元)通常在芯片层面提供了 4 个专用的硬件观察点寄存器。GDB 会优先启用这些硬件寄存器实现无损的零开销监控(控制台显示为 Hardware watchpoint)。若设立的观察点数量超出了硬件支持上限,GDB 只能被迫退化为软件单步比对模式,此时程序的执行性能会出现断崖式下跌。
按地址设观察点
当对象被隐匿在多层复杂的不透明结构体背后、缺乏直接的类型定义信息时,可以直接将目标成员的物理内存地址强制转换为对应类型的指针并解引用:
(gdb) watch *(size_t *)&c->items[0].count
Hardware watchpoint 2: *(size_t *)&c->items[0].count
(gdb) continue
Continuing.
Thread 1 hit Hardware watchpoint 2: *(size_t *)&c->items[0].count
Old value = 1
New value = 2
wf_counter_add (c=0x6c1e80, word=0x5ffd70 "the") at src/counter.c:86
86 return 0;2
3
4
5
6
7
8
9
10
11
采用 *(类型 *)地址 的写法,能够精确约束硬件观察点监视的数据宽度(如 1、2、4 或 8 字节),在面对紧凑二进制协议与位字段布局时极具实战价值。
5. 查看内存与寄存器
指令 print 严格依据编译期绑定的类型信息将内存还原为可读的数据;但在底层调试、排查内存损坏或跨平台对齐故障时,直接检视物理内存的原始字节往往能提供最确凿的证据。GDB 提供了原生的内存读取指令 x(即 examine),其标准语法格式为 x/数量格式单位 内存地址:
| 格式符 | 呈现含义 |
|---|---|
x | 十六进制整数 |
d | 有符号十进制整数 |
c | ASCII 字符常量 |
s | 以空字符结尾的 C 字符串 |
i | 机器汇编指令(反汇编) |
| 宽度单位 | 对应物理字节跨度 |
|---|---|
b | 1 字节(Byte) |
h | 2 字节(Halfword) |
w | 4 字节(Word) |
g | 8 字节(Giant word) |
为了审视分词函数 wf_tokenizer_next 写入单词缓冲区的真实物理状态,我们可以使用 x/16xb word 逐字节展开查看连续 16 个字节的十六进制排布,或者使用 x/s word 直接按字符串规则求值:
(gdb) break app/main.c:95
Breakpoint 1 at 0x14000211b: file app/main.c, line 95.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 26740.0x63b4]
Thread 1 hit Breakpoint 1, count_stream (counter=0x711e10, in=0x70bda0,
name=0x70bd50 "tests/data/sample.txt") at app/main.c:95
95 if (wf_counter_add(counter, word) != 0) {
(gdb) x/16xb word
0x5ffd70: 0x74 0x68 0x65 0x00 0x00 0x00 0x00 0x00
0x5ffd78: 0x00 0x80 0x00 0x00 0x01 0x00 0x00 0x00
(gdb) x/s word
0x5ffd70: "the"2
3
4
5
6
7
8
9
10
11
12
13
14
前四个字节 0x74 0x68 0x65 0x00 准确对应着 ASCII 字符 t、h、e 以及起终止符作用的 \0;其后连续的几个 0x00 是定长数组 char word[WF_WORD_MAX] 中本次写入未触及的填充余量,更远处的离散数据则是栈帧中留存的其他局部状态。由于 word 是数组名,在表达式中求值自动退化为首元素地址;若手中持有的是裸指针对象,则可先通过 print 指针 获得其指向的虚拟地址,再传入 x/16xb 0x... 展开。
当审视复合数据结构时,x 命令能够穿透抽象层,将结构体内部各字段在内存中的真实对齐与填充状况完整暴露出来:
(gdb) break print_top
Breakpoint 1 at 0x140002224: file app/main.c, line 117.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 28548.0x5a0c]
Thread 1 hit Breakpoint 1, print_top (counter=0x6d1e10, n=3) at app/main.c:117
117 wf_entry *entries = calloc(n, sizeof *entries);
(gdb) print *counter
$1 = {items = 0x6d4970, size = 7, capacity = 16}
(gdb) x/6xw counter->items
0x6d4970: 0x006d2710 0x00000000 0x00000004 0x00000000
0x6d4980: 0x006d4aa0 0x000000002
3
4
5
6
7
8
9
10
11
12
13
在 64 位系统架构(x86-64)下,每个 struct item 占用 16 个字节:前 8 字节为指针类型成员 char *word(值为 0x00000000006d2710,在小端序下列印,指向字符串 "the"),后 8 字节为 size_t count(值为 4)。终端中每行按 4 个 32 位机器字排布,恰好一行完整映射一个结构体实例。这类对底层内存布局的直接观测,在判定内存对齐陷阱、结构体填充开销以及缓冲区溢出越界时是不可替代的坚固基石。
而在 CPU 架构层面,程序计数器(PC,Program Counter)与栈指针(SP,Stack Pointer)是状态排查中最核心的寄存器。通过 info registers 可以倾倒所有通用寄存器的实时快照,通过 info registers pc sp 可以精简列出关键状态,而通过 $pc 则可以在表达式中像使用局部对象一样直接引用寄存器:
(gdb) info registers pc sp
pc 0x7ff65ef5211b 0x7ff65ef5211b <count_stream+184>
sp 0x5ffd50 0x5ffd50
(gdb) print $pc
$1 = (void (*)()) 0x7ff65ef5211b <count_stream+184>
(gdb) print/x $pc
$2 = 0x7ff65ef5211b
(gdb) x/i $pc
=> 0x7ff65ef5211b <count_stream+184>: lea -0x70(%rbp),%rdx2
3
4
5
6
7
8
9
在 x86-64 体系下,$pc 对应物理寄存器 rip,$sp 对应 rsp;而在 ARM Cortex-M 体系下,GDB 统一映射为 pc 与 sp。x/i $pc 指令能够将当前指令指针所指的物理内存解码为反汇编指令。上面截获的 lea 指令正在将 word 缓冲区的栈偏置计算至寄存器 rdx 中,为即将调用的 wf_counter_add 准备传参。在汇编层面比对指令流,能清晰核验编译器究竟对源码做了何种变换,本章的这一技巧在后续嵌入式片上调试(SWD/JTAG 调试)中将完全通用。
6. 调试失败的测试
在持续集成与日常自动化测试中,当测试套件亮起红灯时,框架日志通常仅给出一行极为简短的报警,例如:tests/test_token.c:44: word is "The", expected "the"。这是第 11 章我们手工构建的测试框架,宏 CHECK_STR_EQ 在比对失败时调用了底层的格式化打印函数 check_fail。若希望沉浸式复查断言未通过瞬间的全景上下文,只需在 check_fail 函数入口处下断:
$ gcc -std=c17 -Wall -Wextra -g -O0 -D__USE_MINGW_ANSI_STDIO=1 \
-Iinclude -Itests tests/test_token.c tests/check.c src/token.c \
-o build/test_token
$ ./build/test_token
tests/test_token.c:44: word is "The", expected "the"
tests/test_token.c:46: word is "The", expected "the"
test_token: 27 checks, 2 failed
$ gdb --args ./build/test_token
Reading symbols from ./build/test_token...
(gdb) break check_fail
Breakpoint 1 at 0x140002638: file tests/check.c, line 14.
(gdb) run
Starting program: C:\proj\wordfreq-test\build\test_token.exe
[New Thread 18380.0x1dd0]
Thread 1 hit Breakpoint 1, check_fail (
file=0x7ff77f8cb09a "tests/test_token.c", line=44,
fmt=0x7ff77f8cb0bd "%s is \"%s\", expected \"%s\"") at tests/check.c:14
14 total_checks++;
(gdb) bt
#0 check_fail (
file=0x7ff77f8cb09a "tests/test_token.c", line=44,
fmt=0x7ff77f8cb0bd "%s is \"%s\", expected \"%s\"") at tests/check.c:14
#1 0x00007ff77f8c1f5e in test_lowercase_folding () at tests/test_token.c:44
#2 0x00007ff77f8c2519 in main () at tests/test_token.c:77
(gdb) frame 1
#1 0x00007ff77f8c1f5e in test_lowercase_folding () at tests/test_token.c:44
44 CHECK_STR_EQ(word, "the");
(gdb) print (char *)word
$1 = 0x5ffd90 "The"
(gdb) print text
$2 = 0x7ff77f8cb109 "The THE tHe"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
28
29
30
31
32
函数 check_fail 自身的作用域内并不包含业务对象 word。通过执行 frame 1 将观察点回溯至上层的具体测试函数 test_lowercase_folding,当前测试用例中参与校验的缓冲区 word 的内容("The")与原始输入测试样本 text("The THE tHe")便一览无余。两相结合,维护者能够瞬间断定:分词器并未将首字母转换为小写。将转换循环的初始下标纠正为 0 后重新运行测试,27 项检查全线标绿通过。
将断点挂接在全局的 check_fail 报错汇聚点,所有测试用例中的每一次失败都会在此主动暂停。维护者既可以通过 continue 逐项审视后续差错,也可以直接将断点设立在特定可疑的 CHECK_* 源码行上。自动化测试程序与普通程序在底层完全对等:它不过是一个由 main 顺序调度若干子测试函数的常规 C 语言二进制程序。
测试程序的参数
本章编写的单元测试程序均不接受外部参数,在 GDB 中直接执行 run 即可。若工程采用 CTest 组织调度,排查时可以先借由 ctest -R 用例名 快速筛出失败的独立可执行文件,随后单独启动 GDB 针对该单一程序挂载调试。
7. 优化与调试信息
在现代工具链体系中,生成调试信息与开启编译器优化是两项正交独立的工程决策。选项 -g 的核心作用是在二进制产物中织入 DWARF 映射表,建立起“某段机器汇编指令对应哪一份源文件的哪一行代码、某个局部对象此刻被安放在哪一个物理寄存器或栈内存偏移处”的映射关系;而编译器优化选项(-O1、-O2、-O3)则会大刀阔斧地重排指令流水线、将短寿命对象尽可能固定在通用寄存器中、展开深层循环、实施跨函数内联并对编译期常量提前计算求值。当两者同时开启时,尽管调试映射关系依然尽力维系,但底层机器码的执行拓扑与高级源码的文本行序已不再呈现简单的线性对应。
为了直观体会优化对调试体验的剧烈改动,我们将同一份 wordfreq 源码分别以 -O0、-Og 与 -O2 编译,并在同一断点行 app/main.c:123 尝试使用 print n 观察状态。在关闭优化的 -O0 下:
(gdb) break app/main.c:123
Breakpoint 1 at 0x140002269: file app/main.c, line 123.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq.exe -n 3 tests/data/sample.txt
[New Thread 28892.0x4d24]
Thread 1 hit Breakpoint 1, print_top (counter=0x101e10, n=3) at app/main.c:123
123 if (written == 0 && wf_counter_size(counter) > 0) {
(gdb) print n
$1 = 3
(gdb) info locals
entries = 0x104c20
written = 3
status = 02
3
4
5
6
7
8
9
10
11
12
13
14
而在切换至 -O2 的二进制产物后,在完全相同的代码位置断下:
(gdb) break app/main.c:123
Breakpoint 1 at 0x1400094d2: file app/main.c, line 123.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq_o2.exe -n 3 tests/data/sample.txt
[New Thread 26704.0x57c4]
Thread 1 hit Breakpoint 1, print_top (n=<optimized out>, counter=0x71f480)
at app/main.c:123
123 if (written == 0 && wf_counter_size(counter) > 0) {
(gdb) print n
$1 = <optimized out>
(gdb) info locals
entries = 0x726fa0
written = 3
status = 0
entries = <optimized out>
written = <optimized out>
status = <optimized out>
(gdb) next
126 wf_report_print(stdout, entries, written);
(gdb) next
128 free(entries);
(gdb) info locals
entries = 0x726fa0
written = 3
status = 0
entries = <optimized out>
written = <optimized out>
status = <optimized out>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
28
29
在 -O2 级别下,打印 n 时终端提示了 <optimized out>。这是由于在执行流到达该分支之前,参数 n 参与的两次核心运算(calloc 分配条目数组与 wf_counter_top 提取词频)已经圆满结束,后续控制流已无处需要再次引用 n。编译器在活跃性分析中识别到其生命周期完结,立即释放了原先装载 n 的寄存器供其他运算复用,导致该对象的瞬时值彻底从物理上下文中蒸发。与此同时,info locals 对同一个局部对象输出了多行不同状态(部分有值,部分显示 <optimized out>),这是由于深度优化导致同一源码对象在不同指令片段被映射至分散的寄存器或栈切片中,GDB 在符号重组时将所有生命周期记录全量呈现在控制台。
优化的介入同样会打破单步执行的直观心智模型。在 -O2 下执行 next,光标经常会在源码文本中忽前忽后地来回跳跃,或者单步一次直接跨越了数行无关代码;而在使用 step 步入时,若目标函数已被完全内联至调用点,GDB 只能在合成的虚拟内联栈帧中勉力还原上下文。当函数在多处被同时内联时,针对该函数名设置的断点甚至会直接分裂为多个地址位置:
(gdb) break find_entry
Breakpoint 1 at 0x1400017a9: find_entry. (2 locations)
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq_o2.exe -n 3 tests/data/sample.txt
Thread 1 hit Breakpoint 1.1, find_entry (c=0x7f1e90, word=0x5ffd90 "the")
at src/counter.c:35
35 for (size_t i = 0; i < c->size; i++) {2
3
4
5
6
7
8
由于 find_entry 源码体积短小,编译器在两处不同的调用点分别将其代码就地铺开,GDB 只能在两个物理机器码位置分别下断,断点编号被编码为分层形式 1.1 与 1.2。
为了化解性能优化与源码级可调试性之间的剧烈撕裂,GCC 专门提供了 -Og 优化级别。其设计宗旨在于保留基本冗余消除与常量传播等安全优化的同时,坚决抑制一切会导致源码行与汇编指令对应混乱的激进重排与内联:
(gdb) break app/main.c:123
Breakpoint 1 at 0x140001e2e: file app/main.c, line 123.
(gdb) run
Starting program: C:\proj\wordfreq\build\wordfreq_og.exe -n 3 tests/data/sample.txt
[New Thread 19332.0x18e8]
Thread 1 hit Breakpoint 1, print_top (counter=counter@entry=0x79f480,
n=<optimized out>) at app/main.c:123
123 if (written == 0 && wf_counter_size(counter) > 0) {
(gdb) print n
$1 = <optimized out>
(gdb) info locals
entries = 0x7a6fa0
written = 3
status = 02
3
4
5
6
7
8
9
10
11
12
13
14
15
综合横向比对可知:在功能开发与故障攻坚期,-O0 -g 能够保留最真实无缺的栈帧对象与最纯粹的单步流向,排查体验最为顺畅;-Og -g 则在逼近实际运行性能的同时维系了绝大部分单步可读性。而在面对生产发布构建时,工程规范强烈建议采用带调试符号的优化配置(如 CMake 内置的 RelWithDebInfo 构建类型,对应 -O2 -g):-g 所附加的调试节区只占据物理磁盘空间,完全不会增加程序运行时的内存常驻开销或削弱任何 CPU 指令执行性能,在生产环境突发崩溃时,这份符号将成为线上事后归因的救命稻草。
源码改了要重新编译
当磁盘上的 C 源文件发生了更新而未重新执行编译链接时,GDB 在启动或加载符号时会警示 warning: Source file is more recent than executable.。此时调试器呈现的代码行与内存中实际跑飞的汇编指令可能出现不可调和的错位,单步断点与打印对象均会产生虚假读数。在每次调整功能源码后,必须先确认构建编译通过,再行启动调试会话。
8. 其他前端与 LLDB
命令行终端交互是深入掌控 GDB 的最底座基石。在当今的图形化开发工具中,诸如 VS Code、CLion、Qt Creator 等现代集成开发环境(IDE),其底层原理均为在后台静默派生 GDB 或 LLDB 子进程,并通过机器接口协议(Machine Interface,即 GDB/MI)与调试引擎进行结构化通信,最终将断点标记、局部对象、调用栈帧映射至交互式可视化面板中。
以 VS Code 的 C/C++ 官方扩展为例,调试会话的启动与参数编排由 .vscode/launch.json 文件统一定义:
{
"version": "0.2.0",
"configurations": [
{
"name": "debug wordfreq",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/build/wordfreq",
"args": ["-n", "10", "tests/data/sample.txt"],
"cwd": "${workspaceFolder}",
"MIMode": "gdb",
"miDebuggerPath": "gdb",
"setupCommands": [
{ "description": "pretty printing", "text": "set print pretty on" }
]
}
]
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
配置项中的 program 指向待调试的目标二进制路径,args 数组保存启动时透传的命令行参数,cwd 决定了程序执行时相对路径的解析根目录,MIMode 指定底层通信协议,而 setupCommands 允许在会话建立初始执行原生 GDB 命令。其中 set print pretty on 能够指示 GDB 将嵌套的复杂结构体按多行缩进分层格式化排版,极大提升人工审阅效率。
对于由 JetBrains 推出的专业 C/C++ IDE CLion,开发者无须手动维护类似配置:IDE 能够自动解析 CMakeLists.txt 构建图谱,界面上的调试按钮可直接驱动后端的 GDB 或 LLDB 引擎展开工作。
在 LLVM 编译器工具链生态中,原生的调试工具是 LLDB。尽管两者的命令行词汇存在差异,但其架构核心理念高度一致:
| 调试操作 | GDB 指令 | LLDB 对应指令 |
|---|---|---|
| 启动并执行 | run | run(或 r) |
| 按文件行号设断点 | break 文件:行 | breakpoint set -f 文件 -l 行 |
| 按函数名称设断点 | break 函数名 | breakpoint set -n 函数名 |
| 设置条件断点 | break 位置 if 条件 | breakpoint set -n 函数 -c 条件 |
| 设立内存观察点 | watch 表达式 | watchpoint set expression 表达式 |
| 恢复全速继续运行 | continue | continue(或 c) |
| 单步越过函数调用 | next | next(或 n) |
| 单步步入函数内部 | step | step(或 s) |
| 执行至当前函数返回 | finish | finish |
| 打印完整调用栈回溯 | bt | bt |
| 跃迁活动栈帧 | frame N | frame select N |
| 格式化求值并打印 | print 表达式 | expression 表达式(p 亦可) |
| 列出当前栈帧所有局部对象 | info locals | frame variable |
| 检视原始内存字节 | x/16xb 地址 | memory read -f x -c 16 -s 1 地址 |
在 macOS 平台上,基于操作系统内核权限与代码签名的约束,LLDB 是当之无愧的首选默认工具;而在主流 Linux 服务端与嵌入式交叉编译场景中,GDB 则凭借极其广泛的体系架构支持依然占据着不可动摇的中流砥柱地位。
在 Linux 运维与系统编程体系中,还有一项极为强大的事后归因机制:核心转储(Core Dump)。当未受捕获的非法内存信号击穿进程生命周期时,Linux 内核能够将该时刻整个进程的用户态虚拟内存映射全量镜像固化为磁盘文件。开发者随后可在完全不依赖原始运行环境的情况下,使用 GDB 静态加载该转储文件,完美重现崩溃事发那一瞬的完整现场:
$ ulimit -c unlimited
$ ./build/wordfreq_crash -n 10 tests/data/sample.txt
Segmentation fault (core dumped)
$ gdb ./build/wordfreq_crash core
(gdb) bt2
3
4
5
命令 ulimit -c unlimited 解除了当前终端对转储文件容量的限制。生成的 .core 文件在 GDB 中装载后,bt、frame、print 等全套排查指令皆可如在实机般顺畅运转。而在 Windows 操作系统上,对应的故障转储形式为 MiniDump(.dmp 文件),具体抓取与解析方案将在后续的 Windows 系统编程拓展中深入剖析。
用 -batch 把会话记录下来
在向开源社区提交缺陷复现报告或在自动化持续集成中留存调试凭据时,交互式的提示符会阻断脚本。GDB 提供了批处理选项:通过附加 -batch 与若干 -ex "GDB指令",可以在单行 Shell 指令中驱动 GDB 执行全套诊断并自动退出销毁:
$ gdb -q -batch -ex "break wf_counter_add" -ex "run" \
-ex "print word" -ex "bt 2" --args ./build/wordfreq -n 3 tests/data/sample.txt
Breakpoint 1 at 0x14000191c: file src/counter.c, line 85.
[New Thread 20456.0x18f4]
Thread 1 hit Breakpoint 1, wf_counter_add (c=0x101e10, word=0x5ffd70 "the") at src/counter.c:85
85 struct item *e = find_entry(c, word);
$1 = 0x5ffd70 "the"
#0 wf_counter_add (c=0x101e10, word=0x5ffd70 "the") at src/counter.c:85
#1 0x00007ff65ef5212b in count_stream (counter=0x101e10, in=0xfbda0, name=0xfbd50 "tests/data/sample.txt") at app/main.c:952
3
4
5
6
7
8
9
10
若将命令组织在独立文本文件中,还可以直接使用 -x 脚本文件 进行批处理注入,为工程团队沉淀下高价值的确定性排查资产。
9. 习题
9.1 巩固
用 code/开发与工程基础/12_调试器/bugs/ 下的两个文件构造崩溃版本,编译时加 -g -O0,然后不读正文,按下面的顺序做一遍:
run之后记录 GDB 停在哪一行、bt输出的每一帧;frame 1,info locals列出哪些对象,其中哪两个的数值对不上;frame 0,print i与print entries[i],说明i为什么正好是那个值;- 只改调用处一个标识符,重新编译,确认
-n 10输出 7 行、退出状态 0。
把每一步的命令与输出抄进答案,最后写一句话:根因在哪个函数。
下面这段程序编译时加 -g -O0,在 sum_values 上设断点:
#include <stdio.h>
static int sum_values(const int *values, int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += values[i];
}
return total;
}
int main(void) {
int values[] = {3, 1, 4};
printf("%d\n", sum_values(values, 3));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
(1) 写出从断点处开始,只用 next、print、until 三个命令,让程序停在 return total; 那一行并打印出 total == 8 的命令序列。
(2) 换成 nexti,从 total += values[i]; 开始会经过几条机器指令?用 x/8i $pc 观察并说明 nexti 与 next 在这条语句上的差别从哪里来。
(3) finish 与 until 8 都能离开循环,两者停下的位置不同:一个在 main 里、一个在 return total; 那一行。分别跑一次,记录各自的停点,并说明 finish 额外打印的那一行是什么。
display 记录一个表达式,之后每次停下都打印。用下面这段程序验证它的行为:
#include <stdio.h>
static int acc = 0;
static void step(int i) {
acc += i * i;
}
int main(void) {
for (int i = 1; i <= 5; i++) {
step(i);
}
printf("%d\n", acc);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
(1) 在 step 上设断点,命中之后输入 display i,再连续 continue。记录五次停下时终端各打印了几行、每行是什么,并解释第一次停下时为什么一条 i = ... 都没有。
(2) 在第一次停下时再加一条 display acc,把之后四次停下的完整输出抄下来,说明 acc 显示的值是「进入函数时」还是「执行完当前行后」的。
(3) undisplay 1 之后继续,验证 i 不再显示而 acc 仍然显示;info display 列出的编号与 undisplay 用的是不是同一套?
(4) 用 display/i $pc 让每次停下都打印当前指令,说明这条显示对定位「程序在一条语句上反复停」有什么用处。
用 gdb -batch -ex ... --args 把一次排查写成可重复的命令行:
- 对
wordfreq正常版本,在wf_counter_add上设断点,run -n 3 tests/data/sample.txt,打印第一个单词和c->size,用一条命令完成; - 把同样的命令写进一个
investigate.gdb脚本,用-x执行,比较两次输出; - 给脚本加上
set logging file session.log、set logging enabled on,运行结束后检查日志文件的内容与终端输出是否一致; - 说明
-batch与不加它的区别:脚本执行完后加上-batch会直接退出,不加时 GDB 停在(gdb)提示符上,把脚本写进 CI 之后这一点意味着什么(会挂住、需要额外的超时或输入);实测两种写法,记录退出状态。
9.2 编程
写一个 gdbwatch.sh:给定一个程序、一个函数名和一个「关心的事件」脚本,生成一份 GDB 命令文件并执行,最后输出一张按时间排序的事件表。
要求:
- 命令文件里用
break、commands、silent、printf、continue实现跟踪,不进入交互; printf的格式串里带上一个自增的计数器(set $n = $n + 1),每条记录形如#3 func arg=... file:line;- 在
wordfreq上跟踪wf_counter_add,用样例文件跑-n 3,事件表应当有 12 条记录,单词依次是the、cat、sat、on、the、mat、the、dog、sat、on、the、log(分词模块已经转成小写);用脚本核对记录数与最后一条记录; - 脚本要能在 Linux 与 Windows 的 GDB 下都跑,路径用参数传入,不要把平台相关的写法写死。
本章第 4 节的观察点会话依赖 ignore 1 1 让程序先停在第二个单词上。把它改成一个不依赖 ignore 的版本:
- 用
break wf_counter_add if (int)strcmp(word, "the") == 0只在the进来时停; - 在这个条件断点上用
commands打印c->size与c->items[0]; - 输入
then the then the the,跑缺陷版本的counter_prefix_match.c,记录条件断点命中了几次、每次c->size是多少; - 解释命中次数为什么是 3 而不是 5,这个数字和缺陷的行为有什么关系;
- 用
ignore+watch的写法再实现一遍,比较两种写法在「需要观察的是计数变化而不是进入次数」时的优劣。
下面这段程序在一次运行里对同一个数组做了越界写,结果 count 变成了 4 而不是 0:
#include <stdio.h>
struct record {
int values[4];
int count;
};
int main(void) {
struct record r = {{0, 0, 0, 0}, 0};
for (int i = 0; i <= 4; i++) {
r.values[i] = i;
}
printf("%d\n", r.count);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
(1) 用 watch -l r.count 找出是第几次循环改掉了 count,记录 Old value、New value 与此时 i 的值。
(2) 在设观察点之前,print &r.values[4] 与 print &r.count 的地址是否相同?用 ptype /o struct record 与 x/24xb &r 核对布局。
(3) 换一种写法:不用 watch -l,而是 watch *(int *)&r.values[4],确认结果相同,并说明两种写法在求值时机上的差别。
(4) 观察点数量不够时 GDB 会退化成软件观察点,速度明显变慢。用 info watchpoints 看本例用的是硬件还是软件观察点,一次最多能设几个。
wordfreq 的 src/counter.c 替换成前缀比较的缺陷版本后,跑 printf 'then the then the the\n' | ./wordfreq -n 5 得到 5 then。
(1) [2.2] 用 break wf_counter_add + ignore 1 1 停在第二个单词上,print c->items[0] 看第一个条目的内容(then,计数为 1);再设 watch -l c->items[0].count,continue 之后记录 Old value、New value、bt 2 和此时的 word。
(2) [2.2] 把断点条件改成 if (int)strcmp(word, "then") == 0,重复一遍。这一次计数变化发生在哪一次调用上?与 (1) 的结论是否一致?
(3) [3.3] 不看源码,只用调试器给出的信息写一段结论:the 被记到了哪一项上、为什么只有第一个单词 then 的计数在涨。结论里要引用至少两处 print 的实际输出。
(4) [3.3*] 在第一个 the 进来、停住之后,用 set var c->items[0].count = 2 把 then 的计数改成 2,再 continue 让程序跑完,观察最终输出与未修改时有什么不同。说明为什么这种「验证性修改」能确认缺陷的位置,却不能代替修源码。
本章第 6 节用 check_fail 上的断点调试失败的测试。把这一做法扩展成一个可复用的排查脚本:
- 编出带缺陷的
test_token(用第 11 章的token_lowercase_starts_at_1.c),在check_fail上设断点,用条件if line == 44只停在第一个失败上;记录bt、frame 1之后的text与word; - 写一个 GDB 脚本,在
check_fail的commands里只打印file与line然后continue,一次跑完列出全部失败位置。对照test_token自己的汇总输出,两者是否一致? - 把断点改到
CHECK_STR_EQ展开出的那一行上(tests/test_token.c里对应的CHECK_STR_EQ行),说明为什么这一次只能停在「你关心的那一条检查」上;与设在check_fail上相比,两种做法各适合什么场合; - 用
-O2重编同一个测试程序再跑一遍脚本,记录哪些对象显示<optimized out>,解释为什么优化会影响到调试一个测试程序——测试程序不是应该用-O0编吗?
本章第 7 节比较了 -O0、-Og、-O2 下的调试体验。把这一比较做成一个可量化的实验,对象是 wordfreq 的 print_top 与 find_entry:
- 三个优化级别各编一份,在每个函数的入口、中部、返回前各设一个断点,记录每个断点上能成功
print出的对象数; find_entry在-O2下被内联到两个调用点,info breakpoints显示(2 locations),命中的编号是1.1、1.2。把-O2下break find_entry的完整输出与-O0下的对比,说明多出来的那一处是哪里;- 用
info line app/main.c:126与info line app/main.c:121比较三个级别下同一行源码对应的指令范围(输出形如starts at 0x… <print_top+119> and ends at 0x… <print_top+155>),把三条指令范围的起点、终点与跨过的字节数抄成一张表。两个方向都要解释:-O0下为什么一行能占几十个字节,-O2下为什么有些行只剩几条指令、另一些行又把多个语句合在一起; - 写一份推荐表:什么场合用
-O0 -g、什么场合用-Og -g、什么场合用-O2 -g(后者对应 CMake 的RelWithDebInfo),每一条给出理由与代价。
结论里要出现真实测量数字,不接受「大概是」这类描述。
10. 参考资料
- GDB 官方手册,Breakpoints、Continuing and Stepping、Backtrace、Data、Memory、Set Watchpoints、Registers 各节:https://sourceware.org/gdb/current/onlinedocs/gdb.html/
- Beej's Quick Guide to GDB,初学者常用的命令一览:https://beej.us/guide/bggdb/
- MIT Missing Semester 2026「Debugging and Profiling」,调试与性能分析的入门课程:https://missing.csail.mit.edu/2026/debugging-profiling/
- LLDB 官方文档《GDB to LLDB command map》,两套调试器的命令对照:https://lldb.llvm.org/use/map.html
- VS Code 文档《Configure C/C++ debugging》与
launch.json参考,program、args、MIMode、setupCommands各字段:https://code.visualstudio.com/docs/cpp/launch-json-reference - GCC 文档《Options for Debugging Your Program》,
-g、-Og与优化级别的相互影响:https://gcc.gnu.org/onlinedocs/gcc/Debugging-Options.html - Brian W. Kernighan、Rob Pike,《The Practice of Programming》,Addison-Wesley,1999,ISBN 978-0-201-61586-9。第 5 章「Debugging」讲调试器与打印的分工、二分定位,以及仔细思考这条被反复引用的建议。