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 ./bargdb ./bar --tui 启动,用 layout next 在源码/汇编/寄存器窗口间切换,用 C-x o 切换焦点窗口,回车重复上一条命令。

    • 作用域与存储期:家目录的 .gdbinit全局配置(自动加载安全路径);工作目录的 .gdbinit项目级配置。本讲实测:未授权时工作目录的 .gdbinit 会被静默忽略,必须靠 add-auto-load-safe-pathset 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)

【代码做什么?】

  1. sum_firsti <= n 循环,因此当 n = 4 时循环执行 5 次(i = 0,1,2,3,4),多读了一个元素。
  2. GDB 的 display 让每次暂停都自动打印 itotal:可见 i 从 3 跳到 4 时 total 从 43 变成 66——43 + arr[4] = 43 + 23 = 66,正好解释了输出。
  3. print arr[4]x/8dw arr 证实第 5 个元素是 23;因为数组本身有 8 个元素,这次越界读没有崩溃,只是悄悄算错。
  4. where(即 bt)显示调用链只有两帧:main 在第 47 行调用 sum_firstinfo locals 显示当前帧只有 totali 两个局部变量。

【底层机制透视】

  • 为什么没有崩溃? arr 指向 main 栈帧里的 data[0..7]。越界访问 arr[4] 仍然落在同一个数组对象内,只是语义上越界(函数承诺只看前 n 个),硬件与编译器都无法发现。若数组只有 4 个元素,越界就会读到 n 或返回地址等相邻数据,即使不崩溃也会得到随机答案。
  • displayprint 的区别print 只求值一次,display 注册一个每次暂停都自动重新求值的表达式,并给它编号(实测 1: i = 02: total = 0)。这是观察循环变量最有效的手段。
  • next vs step vs until vs finishnext 执行一整行(不进入被调函数),step跳进该行调用的函数,until 一直运行到当前行之后的下一个源码行(常用于快速走完剩余循环),finish 运行到当前函数返回并打印返回值。用错会让你在无关代码里迷路。
  • x 命令的格式x/NFU addr 中 N 是重复次数、F 是格式(x 十六进制、d 十进制、b 字节、w 4 字节字)、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)

【代码做什么?】

  1. 修复后 sum_first 显式处理退化输入NULLn <= 0 返回 0),而不是依赖调用者保证前提。
  2. check_i32 是极简测试框架:统计总数、打印 PASS/FAIL、失败时打印期望值与实际值。
  3. 八个用例覆盖了边界(n = 0n = 1NULL)、普通情形(前 4 个)、曾经暴露 bug 的用例(整数组)、以及「全相等」「全负数」这类容易暴露比较符号错误的输入。
  4. 全部通过时返回 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 更慢但不需要改编译命令——本环境只能用后者。

【代码做什么?】

  1. BUG 1(泄漏):两个 record_t 从来没有 free,Valgrind 报 32 bytes in 2 blocks are definitely lost,并精确指出分配位置main (leaky.c:38)
  2. BUG 2(越界写)malloc (8) 只给 8 字节,却要写入 15 个字符加结尾 '\0'。Valgrind 报 Invalid write of size 1,地址说明是 0 bytes after a block of size 8 alloc'd——越过块末尾的第一个字节
  3. 越界写破坏的正是堆块的元数据/相邻数据,因此后面 printf ("%s", text)strlen 又触发 Invalid read of size 1(同一地址,块尾之后)。
  4. 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=allstill reachable 也显示出来;--track-origins=yes 则把未初始化值的来源一并报告。
  • -fsanitize=address,undefined 是另一条路线:它在编译期插入检查代码(而非翻译机器码),因此更快但必须重新编译。本环境只能用其中的 UBSan:UndefinedBehaviorSanitizer 正常工作(示例 4 的诊断是实测),而 AddressSanitizer 无法启动(原因见下条)。
  • 本环境的工具可用性(实测,请按此选择工具)valgrind-3.19.0 可用,本节的泄漏/越界/未初始化报告全部是它的真实输出gdb 10.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                                  ← 没有崩溃,但返回的是垃圾值

【代码做什么?】

  1. internal_max 用两个 assert 声明并强制执行它的前提条件(非空、n > 0)——这是模块内部不变量
  2. read_file_or_report 处理的是用户输入/环境失败(文件不存在):它打印诊断、返回 -1、程序继续运行。实测 errno = 2ENOENT)、strerror 给出 No such file or directory
  3. 断言版本在 n = 0立刻崩溃并打印文件名、行号、函数名与失败的表达式。
  4. -DNDEBUG 编译后断言被完全消除,程序返回垃圾值 3(读到了 arr[0] 之外的旧数据)——这演示了「发布版把断言关掉」的风险。

【底层机制透视】

  • assert 是什么<assert.h> 里的宏。它在表达式为假时调用 __assert_fail,后者向 stderr 打印诊断并调用 abort()abort() 发送 SIGABRT,所以退出码是 128 + 6 = 134它是开发期工具,不是错误处理机制。
  • NDEBUG 的作用:定义 NDEBUGassert(expr) 被展开为 ((void)0)——完全不求值 expr。因此绝不能在 assert 里写有副作用的表达式(assert(fclose(f) == 0) 在发布版里根本不会执行)。教材与讲义都强调这一点;Ariane 5 事故的直接原因之一就是「断言被关掉后,整数溢出失去了保护」(MP7 的背景材料)。
  • 什么时候用断言,什么时候必须做真正的错误处理
    • 断言(内部不变量):函数的前提条件(指针非空、数组非空)、类不变量(in_use <= capacity)、switchdefault 分支(「不可能到达」)、循环不变量。这些在正确的程序里永远不会触发,触发就意味着代码有 bug
    • 真正的错误处理(外部世界)malloc 返回 NULL、文件打不开、用户输入非法、网络断开。这些不是 bug,程序必须给出诊断并优雅地失败。讲义 544 slide 9 的说法是:「在模块边界断言所有要求」——即断言「调用者必须满足的前提」,而用返回值/错误码处理「环境的不确定性」。
  • errnoperrorerrno 是每个线程一份的全局错误码,只在库函数报错时才被设置,且可能被后续调用覆盖——所以要在失败后立刻读取。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++) {
      |                   ^
(另有与上面相同的两条警告)

【代码做什么?】

  1. -Wall 报出 unused 未使用与 x 未初始化两条警告,但仍然生成可执行文件(退出码 0)——粗心的人会忽略它们。
  2. 加上 -Werror 后同样的两条变成 error:cc1: all warnings being treated as errors编译失败、退出码 1,无法继续。
  3. -WallC不包含 -Wsign-compare;切换到 -Wextra 才报出 uint32_tint32_t 的有符号/无符号比较问题。

【底层机制透视】

  • 课程要求的两条命令(官方 C Coding Conventions 与 NOTES 规范):不省略 -std=c99 -Wall -Werrorgcc -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 localsinfo argsinfo frame 确认上下文。
  • 程序崩溃后 print 无效:官方页面提醒「如果执行已经结束,打印变量值将不起作用」。调试:在崩溃前设断点,或用 run 后立刻 bt;段错误刚发生时栈帧仍然完好,btframe N 仍然可用。
  • 只靠 printf 调试:临时打印污染代码,看不到栈帧,还常常改坏时序(尤其是在循环里)。调试:换成 gdb -tui --args ./prog arg1;用 b file.c:24display ip *ptrx/8xb arrwatch varuntilfinish 组合定位,不必删任何代码。
  • 越界读写的表现时有时无:今天不崩,明天在另一台机器上崩。原因见示例 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 = 0n = 1NULL、全相等、全负数、最大值等边界,并为每一个发现过的 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