Lecture 21: 调试工具与技术 (Testing, Debugging and Tooling: GDB, Valgrind and assert)
目录 · ← l20 · appendix →
Lecture 21: 调试工具与技术 (Testing, Debugging and Tooling: GDB, Valgrind and assert)
概述
本讲回答一个工程问题:代码能编过、能跑,为什么还是错的? 答案是错误分成若干层次,编译器只负责其中最浅的一层——语法与类型;链接错误、运行期崩溃、以及「能跑但答案错」的逻辑错误都必须靠测试与调试来抓。本讲先建立 Lumetta 的错误分类法 (error taxonomy),再把工具链按「错误的可发现性」排列:编译标志 (-g -Wall -Werror -O0) 是第一道防线,GDB 用来观察运行中的程序状态(栈帧、变量、内存、vtable),Valgrind / UBSan 用来抓内存与未定义行为,assert 用来在开发期强制模块不变量 (invariant),而测试驱动 (test driver) 与边界用例是唯一能覆盖逻辑错误的手段。这些技能直接对应教学目标第 8 条「能使用标准调试工具与技术测试和调试 C 程序」,也是 MP7 的全部内容。
核心概念与底层机制图解
- 错误分类法 (Error Taxonomy):按抽象层次自顶向下分四类(讲义 544 slide 6)。
- 直观解释:像装修——图纸错了很难发现(规范歧义),施工方案错了也难发现(算法错误),但把钉子钉歪了(语法错误)一眼就能看见。
- 底层机制图解:层次越高越难发现,也越贵。规格歧义 (specification ambiguity) 是需求本身没写清楚(讲义 544 的例子:输入一开始就是
-1时怎么办);算法错误 (algorithmic error) 分逻辑与数值两类(讲义 544 的「loop swap sort」在8 3 4 7 9上成立,在12 4 1上失败;数值例子是不同机器浮点舍入方向不同导致 30% 误差);语义错误 (semantic error) 是「实现写错了」,常是打字错误;语法错误 (syntax error) 是编译器能抓到的错误或警告。 - 作用域与存储期:与内存无关——这类错误存在于人类的设计与代码文本中,不在运行时对象里。唯一的例外是运行期错误(UB、越界、泄漏),它们确实表现为内存状态被破坏。
- 编译期 vs 链接期 vs 运行期 vs 逻辑错误:把上面的分类落到工具链上,就是本讲的操作骨架。
- 直观解释:编译期错误是「信写错了地址」,链接期错误是「信写好了但邮局找不到这个人」,运行期错误是「信寄到了但收信人当场晕倒」,逻辑错误是「信寄对了、人也没事,但内容是错的」。
- 底层机制图解:
- 编译期:
gcc的语义分析阶段报错,必须修好才能生成.o。-Wall -Werror把警告升级为错误。 - 链接期:
ld找不到符号(忘记实现、拼错函数名、忘记-l m)。报错形如undefined reference to 'sqrt'。 - 运行期:进程收到信号而终止——
SIGSEGV(11) 段错误、SIGFPE(8) 整数除零、SIGABRT(6) 断言失败或abort。退出码是128 + 信号号,所以段错误在 shell 里看到139,断言失败看到134。 - 逻辑错误:进程正常退出(退出码 0)但输出错误。这是唯一一类编译器完全帮不上忙的错误,只能靠测试用例暴露。
- 编译期:
- 作用域与存储期:编译期与链接期错误发生在翻译单元层面;运行期错误发生在进程与内存层面(栈、堆、只读段);逻辑错误不属于任何存储期,它属于输入与输出的关系。
-g与-O0:让调试器看得见源码。- 直观解释:
-g是给可执行文件附上一张「机器码地址 ↔ 源文件行号 / 变量名」的对照表;-O0是要求编译器不要重排、不要合并、不要删除代码,否则这张表就对不上现实。 - 底层机制图解:
-g把 DWARF 调试信息写进可执行文件的.debug_*段;GDB 用它把b file.c:24翻译成真实地址,把栈上的位模式解释成int32_t i。-O2会做寄存器分配、循环变换、常量传播,于是「第 24 行」可能对应另一段代码,变量可能被整个消除(GDB 显示<optimized out>)。官方 GDB 快速参考页专门警告过这一点:某些情况下差异很大,甚至包括行序重排。 - 作用域与存储期:
-g/-O0只影响生成的可执行文件与调试信息,不改变程序的语义(形式上是如此);调试完发布时通常切回-O2。
- 直观解释:
- GDB 的观察模型:栈帧 (stack frame) 与帧号 (frame number)。
- 直观解释:backtrace 就是「我是怎么走到这一步的」——像翻看一叠便利贴,每张写着一个函数和它停在哪一行。
- 底层机制图解:每次函数调用在栈上建立一帧。GDB 的
bt输出里#0是当前帧(正在执行的函数),#1是它的调用者,#2再上一层。关键约束:print只能看到当前帧可见的名字;在第 2 帧里打印第 1 帧的局部变量会失败。用frame N(或f N)切换上下文。讲义官方页面给出的示例输出:#0 0x00001234 in main() at bar.c:12 #1 0x00004567 in foo() at bar.c:47 #2 0x00009876 in baz() at bar.c:56含义是
main()在第 12 行调用了foo(),foo()在第 56 行调用了baz()。 - 作用域与存储期:帧的生命周期就是函数调用的生命周期——函数返回后,它的局部变量存储期结束,GDB 再打印就是垃圾值。官方页面明确提醒:「如果执行已经结束,打印变量值将不起作用。」
.gdbinit与 TUI:把重复劳动脚本化。- 直观解释:
.gdbinit是 GDB 的「开机启动脚本」——每次打开 GDB 自动帮你设好断点、参数并运行。 - 底层机制图解:官方页面给出两步机制:在家目录的
.gdbinit里写add-auto-load-safe-path <你的工作目录>;在工作目录另建一个.gdbinit,内容如set print pretty # 让结构体输出更易读 b baz # 在函数 baz 处断点 set args 4 25 # 为程序设置参数 run # 运行然后直接
gdb ./foo即可。若工作目录下没有.gdbinit,GDB 就当作没有 gdbinit 文件。TUI 模式用gdbtui ./bar或gdb ./bar --tui启动,用layout next在源码/汇编/寄存器窗口间切换,用C-x o切换焦点窗口,回车重复上一条命令。 - 作用域与存储期:家目录的
.gdbinit是全局配置(自动加载安全路径);工作目录的.gdbinit是项目级配置。本讲实测:未授权时工作目录的.gdbinit会被静默忽略,必须靠add-auto-load-safe-path或set auto-load safe-path /授权。
- 直观解释:
图 1:错误的层次结构与各层可用的工具(对应讲义 544 slide 6)
抽象层次 错误类型 可发现性 / 工具
┌──────────────────┬──────────────────────────┬────────────────────────────┐
│ 问题 / 任务 │ 规格歧义 │ 通常很难发现(没人想到) │
│ (Problems/Tasks) │ specification ambiguity │ → 代码阅读、写清假设 │
├──────────────────┼──────────────────────────┼────────────────────────────┤
│ 算法 │ 算法错误(逻辑 / 数值) │ 通常很难发现 │
│ (Algorithms) │ algorithmic error │ → 边界用例、差分调试 │
├──────────────────┼──────────────────────────┼────────────────────────────┤
│ 计算机语言 │ 语义错误 semantic error │ 中等:编译通过与答案正确 │
│ (Language) │ (编译能过,答案错) │ 之间没有必然联系 │
│ │ │ → GDB 走查、回归测试 │
│ ├──────────────────────────┼────────────────────────────┤
│ │ 语法错误 syntax error │ 通常容易发现 │
│ │ │ → -Wall -Werror │
└──────────────────┴──────────────────────────┴────────────────────────────┘
本讲工具与它们覆盖的层次:
-Wall -Werror ............ 语法/语义(编译期,最浅一层)
assert ................... 语义(模块不变量)
GDB ...................... 语义 + 运行期(栈帧、变量、内存、vtable)
Valgrind / UBSan ......... 运行期(越界、泄漏、未初始化、UB)
测试驱动 + 边界用例 ...... 语义 + 算法(唯一能抓逻辑错误的手段)
退出码速查(128 + 信号号):
SIGSEGV(11) 段错误 → 139 SIGABRT(6) 断言/abort → 134
SIGFPE(8) 整数除零 → 136 正常退出 → 0(但答案可能是错的!)
代码示例与底层机制分析
示例 1:一个「能跑但答案错」的程序与一次完整的 GDB 会话
代码 (C)
/* buggy_stats.c —— 故意写错的程序(仅供演示) */
#define MAX_N 8
/* 返回数组前 n 个元素之和 */
static int32_t sum_first (const int32_t* arr, int32_t n)
{
int32_t total = 0;
int32_t i;
for (i = 0; i <= n; i++) { /* BUG:应为 i < n */
total += arr[i];
}
return total;
}
static int32_t max_first (const int32_t* arr, int32_t n)
{
int32_t best = arr[0];
int32_t i;
for (i = 1; i < n; i++) {
if (arr[i] > best) { best = arr[i]; }
}
return best;
}
int main (void)
{
int32_t data[MAX_N] = { 4, 8, 15, 16, 23, 42, 7, 3 };
int32_t n = 4;
printf ("sum_first = %d (expected 43)\n", sum_first (data, n));
printf ("max_first = %d (expected 16)\n", max_first (data, n));
return 0;
}
编译与运行(gcc -g -std=c99 -Wall -Werror -o buggy_stats buggy_stats.c)——零警告、零错误、退出码 0:
n = 4
sum_first = 66 (expected 4+8+15+16 = 43)
max_first = 16 (expected 16)
下面是实际执行的 GDB 会话(gdb -q ./buggy_stats,命令与输出均为真实记录,仅删去部分重复行):
(gdb) break sum_first
Breakpoint 1 at 0x401131: file buggy_stats.c, line 18.
(gdb) run
Breakpoint 1, sum_first (arr=0x7fffffffb130, n=4) at buggy_stats.c:18
(gdb) next
21 for (i = 0; i <= n; i++) { /* BUG: should be i < n */
(gdb) next
22 total += arr[i];
(gdb) display i
(gdb) display total
1: i = 0 2: total = 0
(gdb) next ← 此后每按一次 next 都打印 i 与 total
21 for (i = 0; i <= n; i++) { ... } 1: i = 0 2: total = 4
22 total += arr[i]; 1: i = 1 2: total = 4
21 for (i = 0; i <= n; i++) { ... } 1: i = 1 2: total = 12
22 total += arr[i]; 1: i = 2 2: total = 12
21 for (i = 0; i <= n; i++) { ... } 1: i = 2 2: total = 27
22 total += arr[i]; 1: i = 3 2: total = 27
21 for (i = 0; i <= n; i++) { ... } 1: i = 3 2: total = 43
22 total += arr[i]; 1: i = 4 2: total = 43 ← 此刻 i == n!
21 for (i = 0; i <= n; i++) { ... } 1: i = 4 2: total = 66 ← 又加了一次
(gdb) print i
$1 = 4
(gdb) print total
$2 = 66
(gdb) print arr[3]
$3 = 16
(gdb) print arr[4]
$4 = 23 ← 越界读到了第 5 个元素!
(gdb) x/8dw arr
0x7fffffffb130: 4 8 15 16 0x7fffffffb140: 23 42 7 3
(gdb) where
#0 sum_first (arr=0x7fffffffb130, n=4) at buggy_stats.c:21
#1 0x0000000000401239 in main () at buggy_stats.c:47
(gdb) info locals
total = 66
i = 4
追加的向量/指针观察命令(同一次会话中实测):
(gdb) x/4dw arr
0x7fffffffb140: 4 8 15 16
(gdb) x/8xb arr
0x7fffffffb140: 0x04 0x00 0x00 0x00 0x08 0x00 0x00 0x00 ← 小端序
(gdb) info frame
Stack level 0, frame at 0x7fffffffb140:
rip = 0x401131 in sum_first (buggy_stats.c:18); saved rip = 0x401239
called by frame at 0x7fffffffb180
Arglist at 0x7fffffffb130, args: arr=0x7fffffffb140, n=4
Locals at 0x7fffffffb130, Previous frame's sp is 0x7fffffffb140
Saved registers:
rbp at 0x7fffffffb130, rip at 0x7fffffffb138
修复:把 i <= n 改成 i < n,重新编译运行得 sum_first = 43 (expected 4+8+15+16 = 43)。
【代码做什么?】
sum_first用i <= n循环,因此当n = 4时循环执行 5 次(i = 0,1,2,3,4),多读了一个元素。- GDB 的
display让每次暂停都自动打印i与total:可见i从 3 跳到 4 时total从 43 变成 66——43 + arr[4] = 43 + 23 = 66,正好解释了输出。 print arr[4]与x/8dw arr证实第 5 个元素是 23;因为数组本身有 8 个元素,这次越界读没有崩溃,只是悄悄算错。where(即bt)显示调用链只有两帧:main在第 47 行调用sum_first;info locals显示当前帧只有total与i两个局部变量。
【底层机制透视】
- 为什么没有崩溃?
arr指向main栈帧里的data[0..7]。越界访问arr[4]仍然落在同一个数组对象内,只是语义上越界(函数承诺只看前n个),硬件与编译器都无法发现。若数组只有 4 个元素,越界就会读到n或返回地址等相邻数据,即使不崩溃也会得到随机答案。 display与print的区别:print只求值一次,display注册一个每次暂停都自动重新求值的表达式,并给它编号(实测1: i = 0、2: total = 0)。这是观察循环变量最有效的手段。nextvsstepvsuntilvsfinish:next执行一整行(不进入被调函数),step会跳进该行调用的函数,until一直运行到当前行之后的下一个源码行(常用于快速走完剩余循环),finish运行到当前函数返回并打印返回值。用错会让你在无关代码里迷路。x命令的格式:x/NFU addr中 N 是重复次数、F 是格式(x十六进制、d十进制、b字节、w4 字节字)、U 是单位大小。x/8dw arr就是「从arr开始打印 8 个十进制的 4 字节字」;x/8xb arr打印同样 8 个字节的十六进制,实测输出0x04 0x00 0x00 0x00 …直观展示了 x86-64 的小端序 (little endian)。-O0为什么重要:本讲实测把同一程序用-O2编译后,break sum_first落到了第 21 行而不是第 18 行,声明语句的行号在调试信息里已经不存在了。这就是官方 GDB 页面提醒「line 24 in qux.c 在编译后的程序里可能不匹配」的实证。
【内存布局图解】
main 的栈帧(高地址在上) 低地址 → 高地址
+-------------------------------+ ← 0x7fffffffb180(caller frame)
| ... main 的局部变量 ... |
| int32_t data[8] |
| +----+----+----+----+----+----+----+----+
| | 4 | 8 | 15 | 16 | 23 | 42 | 7 | 3 |
| +----+----+----+----+----+----+----+----+
| ^ ^
| | arr 指向这里 | arr[4]:函数本不该读,却读到了 23
| +------------------------+
| int32_t n = 4 |
+-------------------------------+ ← 0x7fffffffb140(sum_first 帧底)
| sum_first 的栈帧 |
| total = 0 → 66 | ← info locals 显示的两个变量
| i = 0 → 4(i == n 时仍进入循环体)
| saved rbp / saved rip | ← info frame: rbp at 0x7fffffffb130
+-------------------------------+ rip at 0x7fffffffb138
低地址
GDB 视角:
print arr[4] → 23 数组内的越界读(不崩溃,静默算错)
x/8dw arr → 4 8 15 16 / 23 42 7 3 一次列出全部 8 个元素
x/8xb arr → 04 00 00 00 08 00 00 00 小端序:低位字节在前
【与汇编的对应】
; LC-3 中「i <= n 的循环」用 BRp/BRnz 实现——注意错误条件如何变成 BRz
; 正确版本:i < n,当 i >= n 时退出
loop_test
NOT R2, R1 ; R2 = ~n
ADD R2, R2, #1 ; R2 = -n
ADD R3, R0, R2 ; R3 = i - n
BRzp loop_done ; i - n >= 0 则退出(即 i >= n)
; --- 循环体 ---
LDR R4, R5, #0 ; R4 = arr[i]
ADD R2, R2, R4 ; total += arr[i]
ADD R0, R0, #1 ; i++
BRNZP loop_test
loop_done
; 出错的版本把 BRzp 换成 BRp(仅当 i > n 才退出):
; BRp loop_done ; BUG:i == n 时会多执行一次循环体
; 这正是 C 里 "i <= n" 与 "i < n" 的差别,在汇编里只差一条指令的极性。
示例 2:把「跑一次对了」变成「每次都验证」——测试驱动
代码 (C)
/* tests_stats.c —— 修复后的函数 + 打印 PASS/FAIL 的测试驱动 */
static int32_t sum_first (const int32_t* arr, int32_t n)
{
int32_t total = 0;
int32_t i;
if (NULL == arr || 0 >= n) { return 0; } /* 明确处理退化输入 */
for (i = 0; i < n; i++) { /* FIXED: 原为 i <= n */
total += arr[i];
}
return total;
}
static int tests_run = 0;
static int tests_failed = 0;
static void check_i32 (const char* name, int32_t got, int32_t want)
{
tests_run++;
if (got == want) {
printf ("PASS %-34s got %d\n", name, got);
} else {
tests_failed++;
printf ("FAIL %-34s got %d, want %d\n", name, got, want);
}
}
int main (void)
{
int32_t none[1] = { 0 };
int32_t one[1] = { 42 };
int32_t many[8] = { 4, 8, 15, 16, 23, 42, 7, 3 };
int32_t equal[4] = { 5, 5, 5, 5 };
int32_t neg[3] = { -1, -2, -3 };
check_i32 ("empty (n = 0)", sum_first (none, 0), 0); /* 边界 */
check_i32 ("single element", sum_first (one, 1), 42); /* 边界 */
check_i32 ("NULL pointer", sum_first (NULL, 0), 0); /* 边界 */
check_i32 ("first four", sum_first (many, 4), 43); /* 普通情形 */
check_i32 ("whole array", sum_first (many, 8), 118); /* 暴露过 bug 的用例 */
check_i32 ("all equal", sum_first (equal, 4), 20);
check_i32 ("all negative", sum_first (neg, 3), -6);
printf ("\n%d tests run, %d failed\n", tests_run, tests_failed);
return (0 == tests_failed ? 0 : 1); /* 失败则返回非零,供脚本判断 */
}
真实运行结果(gcc -g -std=c99 -Wall -Werror -o tests_stats tests_stats.c && ./tests_stats):
PASS empty (n = 0) got 0
PASS single element got 42
PASS NULL pointer got 0
PASS first four got 43
PASS whole array got 118
PASS all equal got 20
PASS all negative got -6
8 tests run, 0 failed (退出码 0)
【代码做什么?】
- 修复后
sum_first显式处理退化输入(NULL或n <= 0返回 0),而不是依赖调用者保证前提。 check_i32是极简测试框架:统计总数、打印PASS/FAIL、失败时打印期望值与实际值。- 八个用例覆盖了边界(
n = 0、n = 1、NULL)、普通情形(前 4 个)、曾经暴露 bug 的用例(整数组)、以及「全相等」「全负数」这类容易暴露比较符号错误的输入。 - 全部通过时返回 0,任何一个失败返回非零——这让测试驱动可以被 Makefile 或 CI 脚本直接调用。
【底层机制透视】
- 「全数组」用例是回归测试 (regression test)。 讲义 544 slide 30 的原则:每次有人发现 bug,就加一个能暴露它的测试,并在提交前跑通所有测试。这正是防止 bug 复发的最经济手段。
- 为什么必须测边界? 讲义 544 slide 27 明确要求:让循环执行 0 次或 1 次、检查循环条件的相等情形、检查条件分支的两个方向、并思考可能的溢出。我们的 off-by-one bug 恰好只在
i == n这个相等情形上体现——只测n = 1也可能因为巧合而通过。 - 全代码覆盖 (full code coverage) 只是起点(讲义 545 slide 39、49):必须让每条语句至少执行一次,但这还不够。讲义给出的反例是:
"0 0 0"这个测试暴露的 bug 出现在已经被另一个测试覆盖过的语句上,所以好的测试要同时考虑代码的目的与结构;方法上要把白盒 (clear/white-box) 测试(依据代码写测试)与依据规格写测试结合起来——只用白盒会漏掉「开发者根本没写进去的功能」,只用测试驱动开发则可能出现「为通过测试而写的假实现」(讲义 545 slide 5–8)。 - 「它跑对过一次」不是证据。 Brooks 的经验法则是 1/3 计划设计、1/6 写程序、1/2 测试(讲义 545 slide 2)。编译通过只说明语法对了,与答案正确没有必然联系。
- 测试要在写函数之前就开始设计。 讲义 545 slide 10:「设计代码时就让它容易被测试」;先写一些测试会迫使你写出更可测的代码(例如把「计算」与「输入输出」分开,让
sum_first只依赖参数而不是全局状态)。
【内存布局图解】
测试驱动的栈布局(每个 check_i32 调用建立一个新帧):
main 的栈帧
+----------------------------+ 0x7ffd…b130
| none[1] = { 0 } | 边界:n = 0 时不许读任何元素
| one[1] = { 42 } | 边界:n = 1
| many[8] = { 4,8,15,16,23,42,7,3 }
| equal[4] = { 5,5,5,5 } | 全相等:暴露 < 与 <= 的混淆
| neg[3] = { -1,-2,-3 } | 负数:暴露无符号/有符号错误
| tests_run = 8, tests_failed = 0
+----------------------------+
check_i32 的栈帧(每次调用独立)
+----------------------------+
| name = "first four" | ← 指向 .rodata 里的字符串字面量
| got = 43, want = 43 |
| 返回地址 → main |
+----------------------------+
sum_first 的栈帧
+----------------------------+
| arr, n(参数) |
| total = 43, i = 4 | ← i == n 时循环条件为假,正确退出
+----------------------------+
每个用例都是一次「设置状态 → 调用 → 比较 → 记录」,全部在栈上完成,
没有全局可变状态污染测试之间的独立性。
【与汇编的对应】
; 测试驱动在 LC-3 上:把每个用例的期望值与实际返回值比较
LEA R0, many ; R0 = 数组地址
ADD R1, R1, #4 ; R1 = n = 4
JSR sum_first
; 返回后 R0 = 实际值
LD R2, WANT_43 ; R2 = 期望值 43
NOT R2, R2
ADD R2, R2, #1 ; R2 = -43
ADD R2, R0, R2 ; R2 = 实际 - 期望
BRz case_pass
LEA R0, FAIL_STR
PUTS ; TRAP x22:打印 FAIL
ADD R3, R3, #1 ; tests_failed++
BRNZP next_case
case_pass
LEA R0, PASS_STR
PUTS
next_case
; 关键点:比较用「减去期望值看是否为零」,与 C 里 got == want 完全对应。
示例 3:Valgrind —— 抓越界写、未初始化读与内存泄漏
代码 (C) —— 明确标注「仅供演示、请勿模仿」
/* leaky.c —— 三处缺陷,仅供演示 */
typedef struct { char* name; int id; } record_t;
static char* read_string (const char* src)
{
char* buf = malloc (8); /* BUG 2 起点:8 字节装不下 15 个字符 */
size_t i;
for (i = 0; i <= strlen (src); i++) {
buf[i] = src[i]; /* BUG 2:越界写(含结尾的 '\0') */
}
return buf;
}
int main (void)
{
record_t* records[2];
int* uninit;
for (i = 0; i < 2; i++) {
records[i] = malloc (sizeof (record_t)); /* BUG 1:从不 free */
records[i]->id = i; records[i]->name = NULL;
}
text = read_string ("hello, ECE 220"); /* BUG 2 */
printf ("text = %s\n", text);
free (text);
uninit = malloc (sizeof (int)); /* BUG 3:未初始化就读 */
if (10 > *uninit) { printf ("branch taken\n"); }
free (uninit);
return 0;
}
真实 Valgrind 输出(gcc -g -std=c99 -Wall -Werror -o leaky leaky.c,然后 valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./leaky,节选;==NNNNNN== 里的进程号每次运行都不同,其余内容逐字来自本机实测):
==1215777== Invalid write of size 1
==1215777== at 0x4011A3: read_string (leaky.c:24)
==1215777== by 0x40121E: main (leaky.c:44)
==1215777== Address 0x4a6f0e8 is 0 bytes after a block of size 8 alloc'd
==1215777== at 0x484486F: malloc (vg_replace_malloc.c:381)
==1215777== by 0x40117B: read_string (leaky.c:20)
==1215777==
==1215777== Invalid read of size 1
==1215777== at 0x484A604: strlen (vg_replace_strmem.c:495)
==1215777== by 0x48E7207: __vfprintf_internal (in /usr/lib64/libc.so.6)
==1215777== Address 0x4a6f0e8 is 0 bytes after a block of size 8 alloc'd
==1215777==
==1215777== Conditional jump or move depends on uninitialised value(s)
==1215777== at 0x40125C: main (leaky.c:50)
==1215777== Uninitialised value was created by a heap allocation
==1215777== at 0x484486F: malloc (vg_replace_malloc.c:381)
==1215777== by 0x40124E: main (leaky.c:49)
==1215777==
==1215777== HEAP SUMMARY:
==1215777== in use at exit: 4,128 bytes in 3 blocks
==1215777== total heap usage: 5 allocs, 2 frees, 4,140 bytes allocated
==1215777==
==1215777== 32 bytes in 2 blocks are definitely lost in loss record 1 of 2
==1215777== at 0x484486F: malloc (vg_replace_malloc.c:381)
==1215777== by 0x4011DC: main (leaky.c:38)
==1215777==
==1215777== 4,096 bytes in 1 blocks are still reachable in loss record 2 of 2
==1215777== at 0x484486F: malloc (vg_replace_malloc.c:381)
==1215777== by 0x48EE693: _IO_file_doallocate (in /usr/lib64/libc.so.6)
==1215777==
==1215777== LEAK SUMMARY:
==1215777== definitely lost: 32 bytes in 2 blocks
==1215777== indirectly lost: 0 bytes in 0 blocks
==1215777== possibly lost: 0 bytes in 0 blocks
==1215777== still reachable: 4,096 bytes in 1 blocks
==1215777== suppressed: 0 bytes in 0 blocks
==1215777== ERROR SUMMARY: 22 errors from 6 contexts (suppressed: 0 from 0)
# 程序本身的输出(它「正常」运行完了,退出码仍是 0)
text = hello, ECE 220
branch taken
records[1]->id = 1
关于 AddressSanitizer:下面这段不是本机输出。 ASan 在本环境无法启动(原因见【底层机制透视】),因此本节无法给出 ASan 的实测报告。下面的代码块只是描述 ASan 报告的标准形式,用来与上面的 Valgrind 报告对照,不是从这台机器粘贴的真实输出,请勿当作实测证据:
===== 以下为 ASan 报告的标准形式(示意,非本机实测输出) =====
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 1 at 0x602000000018 thread T0
#0 0x4c3a2b in read_string /home/user/leaky.c:24
#1 0x4c3c1e in main /home/user/leaky.c:44
0x602000000018 is located 0 bytes to the right of 8-byte region
[0x602000000010,0x602000000018)
allocated by thread T0 here:
#0 0x4be1a7 in malloc
#1 0x4c3b7b in read_string /home/user/leaky.c:20
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/user/leaky.c:24 in read_string
两者定位同一个 bug 的策略不同:ASan 在第一次越界发生的瞬间就终止程序(退出码非零),并在同一条报告里同时给出越界点与分配点;Valgrind 让程序继续跑完,把所有问题一次性列出来(本例 22 个错误来自 6 个上下文)。ASan 更快(插桩而非翻译机器码)但必须重新编译,Valgrind 更慢但不需要改编译命令——本环境只能用后者。
【代码做什么?】
- BUG 1(泄漏):两个
record_t从来没有free,Valgrind 报32 bytes in 2 blocks are definitely lost,并精确指出分配位置是main (leaky.c:38)。 - BUG 2(越界写):
malloc (8)只给 8 字节,却要写入 15 个字符加结尾'\0'。Valgrind 报Invalid write of size 1,地址说明是0 bytes after a block of size 8 alloc'd——越过块末尾的第一个字节。 - 越界写破坏的正是堆块的元数据/相邻数据,因此后面
printf ("%s", text)时strlen又触发Invalid read of size 1(同一地址,块尾之后)。 - BUG 3(未初始化读):
malloc返回的内存内容是任意位,10 > *uninit依赖这些垃圾位。Valgrind 报Conditional jump or move depends on uninitialised value(s),--track-origins=yes进一步指出「该未初始化值来自main (leaky.c:49)的堆分配」。
【底层机制透视】
- 四类泄漏的含义:
definitely lost是没有任何指针能到达的块(真泄漏,本程序 32 字节);indirectly lost是「只被已丢失块指向」的块;possibly lost是「指针指向块内部而非块首」(例如指针被移动过);still reachable是程序结束时仍由全局/栈上指针可达的块(本程序的 4,096 字节来自stdout缓冲区,属于正常情况,不是 bug)。读懂这四档的差别,才能不被still reachable干扰判断。 - Valgrind 的工作方式:它是动态二进制翻译 (dynamic binary translation) 的模拟器——不重编译你的程序,而是把机器码翻译成自己的中间表示,给每个字节附带「已初始化/未初始化」和「可访问/不可访问」的标记位(shadow memory),因此能发现硬件不会报错的越界与未初始化读。代价是程序运行慢 10–50 倍。
- 程序”正常退出”却有 22 个错误:这台程序退出码是 0,输出也「看起来对」,但
ERROR SUMMARY: 22 errors from 6 contexts说明堆已被破坏。这正是「能跑不代表对」的最好例证。--show-leak-kinds=all让still reachable也显示出来;--track-origins=yes则把未初始化值的来源一并报告。 -fsanitize=address,undefined是另一条路线:它在编译期插入检查代码(而非翻译机器码),因此更快但必须重新编译。本环境只能用其中的 UBSan:UndefinedBehaviorSanitizer 正常工作(示例 4 的诊断是实测),而 AddressSanitizer 无法启动(原因见下条)。- 本环境的工具可用性(实测,请按此选择工具):
valgrind-3.19.0可用,本节的泄漏/越界/未初始化报告全部是它的真实输出;gdb10.2 可用(示例 1 的会话是真实记录);gcc/g++12.2.0 可用。-fsanitize=address不可用——任何用该标志编译出的程序在启动阶段就直接失败,实测报错为ERROR: AddressSanitizer failed to allocate 0xdfff0001000 (15392894357504) bytes at address 2008fff7000 (errno: 12)与ReserveShadowMemoryRange failed while trying to map 0xdfff0001000 bytes. Perhaps you're using ulimit -v。 原因是本容器虚拟内存上限ulimit -v= 32000000 KB(约 32 GB),而 ASan 需要预留约 15 TB 的 shadow 地址空间;该上限在本会话中无法提高(ulimit -v unlimited无效)。因此本讲的泄漏、越界与未初始化演示一律以 Valgrind 为准,上面那段 ASan 报告已明确标注为「标准形式示意,非本机输出」。 - 本机 Valgrind 的一处已知噪声:Valgrind 3.19.0 与本机 glibc 在退出阶段的
__libc_freeres不完全兼容,每次运行会多出一行Process terminating with default action of signal 5 (SIGTRAP)。实测它出现在HEAP SUMMARY之前(本次运行中位于输出的第 56 行),而HEAP SUMMARY/LEAK SUMMARY/ERROR SUMMARY随后都正常输出,退出码仍为 0,因此不影响报告的有效性——不要因为它以为程序或报告出了问题。上面贴出的节选已把这行省略。
【内存布局图解】
Valgrind 眼中的堆(每个字节带 shadow 标记位):
malloc(8) 返回的块(leaky.c:20)
+--------+--------+--------+--------+--------+--------+--------+--------+
| 'h' | 'e' | 'l' | 'l' | 'o' | ',' | ' ' | 'E' |
+--------+--------+--------+--------+--------+--------+--------+--------+
↑ buf ← 合法区域到此结束
↓ 越界写开始
+--------+--------+--------+--------+--------+------+
| 'C' | 'E' | ' ' | '2' | '2' | '0' | …
+--------+--------+--------+--------+--------+------+
Valgrind: "Invalid write of size 1
Address 0x4a6f0e8 is 0 bytes after a block of size 8 alloc'd"
两个 record_t(从未 free):
records[] 数组(栈) 堆
+---------------+ +------------------+
| &records[0] --|-------------->| id = 0, name=NULL| ← 32 字节 definitely lost
+---------------+ +------------------+
| &records[1] --|-------------->| id = 1, name=NULL|
+---------------+ +------------------+
程序结束时这两个指针随栈帧消失 → 堆块**不可达** → definitely lost
未初始化读(leaky.c:49):
+--------+--------+--------+--------+
| ?? | ?? | ?? | ?? | malloc 返回,内容任意
+--------+--------+--------+--------+
↑ uninit
if (10 > *uninit) → 分支取决于垃圾位 → Valgrind 报 uninitialised value
【与汇编的对应】
; LC-3 中「分配与释放必须配对」的检查靠人工,Valgrind 相当于自动化的检查器
LD R0, SIZE_8 ; 请求 8 字节
JSR malloc ; R0 = 块地址
ADD R1, R0, #0 ; R1 = buf
; 写入 16 字节(越界!)
LEA R2, SRC ; R2 = "hello, ECE 220"
AND R3, R3, #0 ; i = 0
copy_loop
LDR R4, R2, #0 ; R4 = src[i]
STR R4, R1, #0 ; buf[i] = src[i] ← 第 9 次起越界
ADD R1, R1, #1
ADD R3, R3, #1
ADD R4, R3, #-16
BRn copy_loop
; 释放:忘记这一步,模拟器就永远收不回这 8 个字节
JSR free ; 若删掉这行,就是"泄漏"
; LC-3 的 lc3sim 不会告诉你越界;Valgrind 会——这就是工具的价值。
示例 4:SIGSEGV、SIGABRT 与 assert / perror
代码 (C)
/* assert_perror.c —— assert()、NDEBUG、errno 与 perror() */
#include <assert.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
/* INTERNAL INVARIANT:只在数组有效且非空时调用,用 assert 强制这个契约 */
static int32_t internal_max (const int32_t* arr, int32_t n)
{
int32_t best, i;
assert (NULL != arr);
assert (0 < n);
best = arr[0];
for (i = 1; i < n; i++) {
if (arr[i] > best) { best = arr[i]; }
}
return best;
}
/* USER INPUT / ENVIRONMENT FAILURE:不是断言材料,必须报告并保持控制 */
static int read_file_or_report (const char* path, char* buf, size_t cap)
{
FILE* fp = fopen (path, "r");
if (NULL == fp) {
fprintf (stderr, "cannot open '%s': ", path);
perror (NULL); /* 打印 strerror(errno) */
return -1;
}
if (NULL == fgets (buf, (int)cap, fp)) { fclose (fp); return -1; }
fclose (fp);
return 0;
}
int main (int argc, char* argv[])
{
int32_t data[4] = { 3, 9, 4, 7 };
char line[128];
printf ("internal_max = %d\n", internal_max (data, 4));
#ifdef NDEBUG
printf ("NDEBUG is defined -> assert() is compiled out\n");
#else
printf ("NDEBUG is not defined -> assert() is active\n");
#endif
if (0 != read_file_or_report ("/nonexistent/file.txt", line, sizeof (line))) {
printf (" handled the error, still running (errno=%d, %s)\n",
errno, strerror (errno));
}
if (2 == argc && 0 == strcmp (argv[1], "crash-me")) {
printf ("now calling internal_max(data, 0) ...\n");
fflush (stdout);
printf ("%d\n", internal_max (data, 0)); /* 违反不变量 */
}
return 0;
}
真实运行结果(三种编译/运行方式):
# gcc -g -std=c99 -Wall -Werror -o assert_on assert_perror.c ; ./assert_on
internal_max = 9
NDEBUG is not defined -> assert() is active
fopen failure demo:
handled the error, still running (errno=2, No such file or directory)
(stderr: cannot open '/nonexistent/file.txt': No such file or directory)
# ./assert_on crash-me ← 断言被违反
now calling internal_max(data, 0) ...
assert_on: assert_perror.c:23: internal_max: Assertion `0 < n' failed.
Aborted (core dumped) ← 退出码 134 = 128 + SIGABRT(6)
# gcc -g -std=c99 -Wall -Werror -DNDEBUG -o assert_off assert_perror.c
# ./assert_off crash-me
NDEBUG is defined -> assert() is compiled out
now calling internal_max(data, 0) ...
3 ← 没有崩溃,但返回的是垃圾值
【代码做什么?】
internal_max用两个assert声明并强制执行它的前提条件(非空、n > 0)——这是模块内部不变量。read_file_or_report处理的是用户输入/环境失败(文件不存在):它打印诊断、返回-1、程序继续运行。实测errno = 2(ENOENT)、strerror给出No such file or directory。- 断言版本在
n = 0时立刻崩溃并打印文件名、行号、函数名与失败的表达式。 - 用
-DNDEBUG编译后断言被完全消除,程序返回垃圾值3(读到了arr[0]之外的旧数据)——这演示了「发布版把断言关掉」的风险。
【底层机制透视】
assert是什么:<assert.h>里的宏。它在表达式为假时调用__assert_fail,后者向stderr打印诊断并调用abort();abort()发送SIGABRT,所以退出码是128 + 6 = 134。它是开发期工具,不是错误处理机制。NDEBUG的作用:定义NDEBUG后assert(expr)被展开为((void)0)——完全不求值expr。因此绝不能在assert里写有副作用的表达式(assert(fclose(f) == 0)在发布版里根本不会执行)。教材与讲义都强调这一点;Ariane 5 事故的直接原因之一就是「断言被关掉后,整数溢出失去了保护」(MP7 的背景材料)。- 什么时候用断言,什么时候必须做真正的错误处理:
- 断言(内部不变量):函数的前提条件(指针非空、数组非空)、类不变量(
in_use <= capacity)、switch的default分支(「不可能到达」)、循环不变量。这些在正确的程序里永远不会触发,触发就意味着代码有 bug。 - 真正的错误处理(外部世界):
malloc返回NULL、文件打不开、用户输入非法、网络断开。这些不是 bug,程序必须给出诊断并优雅地失败。讲义 544 slide 9 的说法是:「在模块边界断言所有要求」——即断言「调用者必须满足的前提」,而用返回值/错误码处理「环境的不确定性」。
- 断言(内部不变量):函数的前提条件(指针非空、数组非空)、类不变量(
errno与perror:errno是每个线程一份的全局错误码,只在库函数报错时才被设置,且可能被后续调用覆盖——所以要在失败后立刻读取。perror(s)打印s:加上strerror(errno);实测perror(NULL)只打印错误描述。不要在errno上做if (errno != 0)之类的检查——成功调用不保证把它清零。- 崩溃也是一种信息:
SIGSEGV(退出码 139)通常来自空指针解引用或野指针;SIGFPE(136)来自整数除零;SIGABRT(134)来自断言或abort。本讲用 UBSan 实测:divide_by_zero (7, 0)在打印runtime error: division by zero之后立刻收到Floating point exception (core dumped)、退出码 136;null_deref (NULL)报runtime error: load of null pointer of type 'int32_t'后收到Segmentation fault、退出码 139。
【内存布局图解】
断言失败时的现场(assert_on crash-me):
internal_max 的栈帧
+-----------------------------+
| arr = &data[0] | ← assert(NULL != arr) 通过
| n = 0 | ← assert(0 < n) 失败!
| best = ??(未初始化就用了) |
+-----------------------------+
↓ assert 宏展开(概念上)
if (!(0 < n)) { __assert_fail("0 < n", "assert_perror.c", 23, "internal_max"); }
输出:assert_on: assert_perror.c:23: internal_max: Assertion `0 < n' failed.
Aborted (core dumped) ← abort() → SIGABRT → 退出码 134
用 -DNDEBUG 编译后的同一段代码(断言被完全删除):
internal_max 的栈帧
+-----------------------------+
| arr = &data[0] |
| n = 0 |
| best = arr[0] = 3 | ← 循环体一次也不执行
+-----------------------------+
返回 3(垃圾值),程序继续运行,输出 "3",退出码 0
结论:断言让你在开发期**立刻**发现 bug,而不是在客户那里得到错误答案。
errno 的读取时机(必须在失败后立刻读,否则可能被覆盖):
fopen(...) → 返回 NULL → errno = 2 (ENOENT) ← 此刻读取才有效
perror(NULL) → "No such file or directory"
之后任何成功的库调用都可能改写 errno,所以不能事后检查。
【与汇编的对应】
; LC-3 里的"断言":显式检查不变量,违反就打印并停机
internal_max
LDR R1, R0, #0 ; R1 = n(参数)
BRp n_ok ; n > 0 则继续
LEA R0, ASSERT_MSG ; 打印 "Assertion `0 < n' failed."
PUTS
HALT ; 立即停机 —— 相当于 abort()
n_ok
; ... 正常计算 ...
; 对比:正式的错误处理走"返回错误码"的路径,调用者决定怎么报告
read_file_or_report
LD R1, ERRNO_ADDR ; R1 = &errno
; fopen 失败时把错误码写进 errno,然后返回 -1
AND R0, R0, #0
ADD R0, R0, #-1 ; 返回 -1 表示失败,但程序继续运行
RET
示例 5:编译标志是廉价的第一道防线(-Wall vs -Wall -Werror)
代码 (C) —— 故意写得有问题
/* warn_demo.c —— 演示 -Wall 与 -Werror 的差别 */
int32_t compare (int32_t n)
{
uint32_t i;
for (i = 0; i < n; i++) { /* -Wsign-compare(注意:C 里这由 -Wextra 打开) */
if (10 == i) { return (int32_t)i; }
}
return -1;
}
int main (void)
{
int32_t unused = 42; /* -Wunused-variable */
int32_t x; /* 从未初始化 */
printf ("compare(20) = %d\n", compare (20));
printf ("x might be %d\n", x); /* -Wuninitialized */
return 0;
}
真实编译输出(三种命令对比,括号内是真实退出码):
### gcc -g -std=c99 -Wall -o warn_demo warn_demo.c (退出码 0,仅警告)
warn_demo.c: In function 'main':
warn_demo.c:23:13: warning: unused variable 'unused' [-Wunused-variable]
warn_demo.c:27:5: warning: 'x' is used uninitialized [-Wuninitialized]
warn_demo.c:24:13: note: 'x' was declared here
### gcc -g -std=c99 -Wall -Werror -o warn_demo warn_demo.c (退出码 1,编译失败)
warn_demo.c:23:13: error: unused variable 'unused' [-Werror=unused-variable]
warn_demo.c:27:5: error: 'x' is used uninitialized [-Werror=uninitialized]
warn_demo.c:24:13: note: 'x' was declared here
cc1: all warnings being treated as errors
### gcc -g -std=c99 -Wall -Wextra -o warn_demo warn_demo.c (退出码 0)
warn_demo.c:13:19: warning: comparison of integer expressions of different
signedness: 'uint32_t' {aka 'unsigned int'} and 'int32_t' {aka 'int'} [-Wsign-compare]
13 | for (i = 0; i < n; i++) {
| ^
(另有与上面相同的两条警告)
【代码做什么?】
-Wall报出unused未使用与x未初始化两条警告,但仍然生成可执行文件(退出码 0)——粗心的人会忽略它们。- 加上
-Werror后同样的两条变成error:,cc1: all warnings being treated as errors,编译失败、退出码 1,无法继续。 -Wall在 C 中不包含-Wsign-compare;切换到-Wextra才报出uint32_t与int32_t的有符号/无符号比较问题。
【底层机制透视】
- 课程要求的两条命令(官方 C Coding Conventions 与 NOTES 规范):不省略
-std=c99 -Wall -Werror:gcc -g -std=c99 -Wall -Werror -o output_executable -l library1 source_file1.c ...典型库是c(C 标准库)与m(数学库)。 - 每个标志的作用:
-g:生成调试信息(.debug_*段),GDB 才能按源码行与变量名工作。-std=c99:选定语言标准,避免编译器默认方言带来的意外(例如//注释、变长数组的行为)。-Wall:打开一组常用警告。-Werror:把所有警告升级为错误。这是把「警告」变成「必须处理」的唯一可靠手段。-O0:禁止优化,保证「源码行号与变量」同调试器看到的一致。注意-O0是「减 O 零」,与输出选项-o(小写 o)完全不同,官方 GDB 页面专门提醒过这一点。-O2:开启优化。本讲实测把buggy_stats.c用-O2编译后,break sum_first落在第 21 行而非第 18 行,声明语句的行号在调试信息里已经消失。变量还可能被优化掉,GDB 会显示<optimized out>。-fsanitize=address,undefined:插入运行期检查代码,抓越界、释放后使用、内存泄漏、整数溢出、除零等。代价是变慢。注意:本环境只有 UBSan 部分可用,ASan 无法启动(见示例 3 的实测报错),做内存检查请改用 Valgrind。
- 讲义 544 slide 31 的原话:语法错误「容易避免也容易修复——打开所有警告(
-Wall),修好所有警告与错误……但不要靠猜」。 - 讲义 544 slide 32 总结的技巧清单(第 1 条就是本示例的延伸):1) 代码阅读/结对编程;2) 避免做假设;3) 记录假设;4) 断言要求;5) 避免一件事有多种含义;6) 测边界用例;7) 在调试器里走遍所有路径;8) 用回归测试。
【内存布局图解】
同一份源码在三种编译方式下的产物对比:
warn_demo.c .rodata / .text
+---------------------------+ +-------------------------------+
| compare() | -O0 | .text: 逐条语句对应源码行 |
| main(): | ────► | .debug_line: 行号 ↔ 地址映射 |
| unused = 42 (未使用) | | .debug_info: 变量名、类型、位置 |
| x(未初始化) | +-------------------------------+
| printf(x) |
+---------------------------+ -O2 +-------------------------------+
────► | .text: 优化后,声明语句消失 |
编译结果: | .debug_line: 断点落到别的行号 |
-Wall → 警告 + 目标文件 | 变量可能只存在于寄存器中 |
-Wall -Werror → 错误,无目标文件 +-------------------------------+
-Wall -Wextra → 多出 -Wsign-compare
官方要求的完整命令(ECE 220 Coding Conventions):
gcc -g -std=c99 -Wall -Werror -o output_executable \
-l library1 source_file1.c [source_file2.c] ...
调试时加 -O0;追求性能时换 -O2,但那时就不要指望逐行对照源码了。
【与汇编的对应】
; 「未初始化变量」在 LC-3 里的等价物:读一个从未写过的栈位置
; 正确做法:先初始化再使用
AND R1, R1, #0 ; R1 = 0(初始化)
STR R1, R5, #-1 ; 把 x 写进栈帧
; ...
LDR R1, R5, #-1 ; 之后读回来的才是确定值
; 错误做法:跳过初始化直接读,得到的是上一次函数调用留下的垃圾
LDR R1, R5, #-1 ; ← 无 BRzp 之类的检查能发现它
; 这正是 gcc 的 -Wuninitialized 与 Valgrind 能发现、而硬件发现不了的问题。
常见错误与调试技巧
- 在
-O2下调试源码行号对不上:断点落在错误的行,或变量显示<optimized out>。调试:改回gcc -g -O0 -std=c99 -Wall -Werror重新编译;用info line file.c:24确认地址映射,用disassemble /m看机器码与源码的对照。 print打印不了别的帧里的变量:No symbol "x" in current context。调试:先bt看帧号,再frame 1(或f 1)切到目标帧;配合info locals、info args、info frame确认上下文。- 程序崩溃后
print无效:官方页面提醒「如果执行已经结束,打印变量值将不起作用」。调试:在崩溃前设断点,或用run后立刻bt;段错误刚发生时栈帧仍然完好,bt与frame N仍然可用。 - 只靠
printf调试:临时打印污染代码,看不到栈帧,还常常改坏时序(尤其是在循环里)。调试:换成gdb -tui --args ./prog arg1;用b file.c:24、display i、p *ptr、x/8xb arr、watch var、until、finish组合定位,不必删任何代码。 - 越界读写的表现时有时无:今天不崩,明天在另一台机器上崩。原因见示例 1——越界常落在「同一数组或相邻栈数据」内,只改值不触发硬件异常。调试:首选
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./prog(本环境可用的内存检查工具);在其他机器上也可以gcc -fsanitize=address,undefined -g后运行(本环境 ASan 因ulimit -v无法启动,故不要依赖它)。 - 内存泄漏积累到程序被 OOM 杀掉:
malloc成功但从不free。调试:valgrind --leak-check=full ./prog,重点看definitely lost的大小与分配位置(Valgrind 直接给出文件名与行号);still reachable通常不用管。 assert里写有副作用的表达式:assert(fclose(f) == 0);在-DNDEBUG的发布版里根本不会执行,于是文件不关闭。调试:gcc -DNDEBUG编译一次并运行,确认行为不变;把副作用移出assert。- 把
assert当成错误处理:用户输入非法时assert会直接让程序崩溃。调试:区分「内部不变量」(用assert)与「外部失败」(用返回值 +errno/perror报告);用errno时必须在失败后立刻读取,否则会被后续调用覆盖。
关键要点
- 编译器只抓最浅的一层错误。 语法/类型错误由
-Wall -Werror拦住;链接错误、运行期错误(越界、UB、泄漏)与逻辑错误必须靠工具与测试。「编译通过」与「答案正确」之间没有必然联系——本讲的buggy_stats.c零警告、退出码 0,却算错了答案。 -g -std=c99 -Wall -Werror -O0是调试的标准起手式。-g提供行号/变量映射,-O0保证映射真实;-O2会让断点落错行、变量消失。发布时才切回-O2。- GDB 的核心能力是「停在任意位置观察真实状态」。 掌握
break/run/next/step/until/finish/continue六种推进方式、print/display/x/watch四种观察方式,以及bt/frame N的帧切换,就能覆盖绝大多数定位需求。.gdbinit把常用断点与set args脚本化,TUI 提供源码/汇编/寄存器分窗视图。 - 内存问题的专用工具不可替代。 Valgrind 用动态二进制翻译抓越界、未初始化读与泄漏(并区分
definitely/indirectly/possibly/still reachable);-fsanitize=address,undefined用编译期插桩抓得更快(但本环境因虚拟内存上限无法运行 ASan,UBSan 可用)。绝不能靠「它这次没崩」来判断内存正确。 - 测试纪律是唯一能覆盖逻辑错误的手段。 用
assert强制内部不变量,用返回值/errno/perror处理外部失败;写一个打印PASS/FAIL的测试驱动,覆盖n = 0、n = 1、NULL、全相等、全负数、最大值等边界,并为每一个发现过的 bug 补一个回归测试——assert与测试的组合,才是「能跑」与「对」之间的桥。
思考题(带答案)
问题 1. 下面两个程序都被 gcc -g -std=c99 -Wall -Werror 成功编译并正常退出(退出码 0)。哪一个一定是错的?如何用一句话区分它们?
/* A */ int32_t sum_first (const int32_t* a, int32_t n)
{ int32_t t = 0; for (int32_t i = 0; i < n; i++) { t += a[i]; } return t; }
/* B 的循环条件 */ for (i = 0; i <= n; i++) { total += arr[i]; }
答案. B 一定是错的(越界读一个元素),A 是正确的。区分它们不能靠编译器——两者语法与类型都合法,警告级别也相同。区分手段是测试边界用例:让 n = 0(循环应执行 0 次)与检查循环条件的相等情形(i == n 时应退出)。这正是讲义 544 slide 27 的做法:测循环执行 0 次或 1 次、检查相等情形、检查两个分支方向、思考溢出。另一个手段是 valgrind:若数组恰好只有 n 个元素,B 会报 Invalid read of size 4。
问题 2. 为什么在 gcc -O2 下调试会失败,而在 -O0 下正常?请从「调试信息的含义」与「优化的作用」两方面回答。
答案. -g 生成的 DWARF 调试信息本质上是一张映射表:机器码地址 ↔ 源文件行号、以及「某个变量此刻存放在哪个寄存器或哪个栈偏移」。这张表只有在编译器忠实保留源码结构时才成立。-O2 会重排语句、把多个变量合并到同一寄存器、删除死代码、展开循环,于是:变量可能根本不存在于任何存储位置(GDB 显示 <optimized out>);某段机器码可能对应多行源码或对应不到任何一行;断点按行号设置后会落在另一段代码上。本讲实测:同一程序用 -O2 编译后 break sum_first 落在第 21 行而不是第 18 行,声明语句在调试信息里已经不存在了。-O0 明确禁止这些变换,因此「源码行」与「机器指令」一一对应,调试信息才可信。
问题 3. 下面这段代码为什么是危险的?在什么情况下它会「看起来工作正常」?
char* buf = malloc (8);
strcpy (buf, "hello, ECE 220");
printf ("%s\n", buf);
答案. 危险之处:malloc (8) 只给了 8 字节(含结尾 '\0' 只够 7 个字符),而 "hello, ECE 220" 需要 15 字节(14 个字符 + '\0')。strcpy 会写 15 字节,越界 7 字节,破坏堆块元数据或相邻对象——这是未定义行为,不是「可能出错」。它「看起来正常」的情形:malloc 通常从较大的 arena 里切块,越界写入的 7 字节往往落在同一 arena 的填充区或尚未使用的空间里,于是 printf 还能读回正确的字符串、程序还能正常退出(本讲实测的 leaky.c 就是这样:输出完全正确,Valgrind 却报了 22 个错误)。但一旦这些字节属于下一个堆块的头部或另一个对象,程序就会在很久之后的某次 malloc/free 上崩溃,表现为 munmap_chunk(): invalid pointer 之类与原始 bug 毫无关系的错误。正确写法是 malloc (strlen(src) + 1) 或直接用 strdup;定位手段是 valgrind --leak-check=full --track-origins=yes ./prog(本环境可用的工具),或换到 ASan 能运行的机器上用 gcc -fsanitize=address -g。
