Lecture 8: 程序设计与调试方法论 (Design and Debugging)

目录 · ← l7 · l9 →

Lecture 8: 程序设计与调试方法论 (Design and Debugging)

讲义对应:CMU 15-213 Lecture 8 — Design and Debugging(素材:F25-08-design-debugging.txt;配套 F25-gdb-and-assembly.txtlab2_slides.txt教材对应:CS:APP3e 第 3 章/第 9 章延伸 + 第 5 章”程序性能”方法论(教材无专章,属课程补充) 关联 Lab:全部 lab(尤其 L2 Bomb / L3 Attack 的 GDB 使用)

8.1 概述

前七讲一路向下:从位与整数(Lecture 2)、机器级程序与栈帧(Lecture 3–6)到链接(Lecture 7),我们学会了”程序在机器上变成了什么”。本讲把视角拉回:如何写出别人(以及未来的自己)能读懂的代码,以及当代码行为与规格不符时,如何系统性地找到根因而非靠猜。

核心问题有两个:其一,设计(design)——程序复杂度早已超出人脑可一次容纳的规模,靠什么把它切成可管理的块?其二,调试(debugging)——从”看到失效”到”定位缺陷”之间,存在一条由观察、假设、实验、诊断构成的科学方法链,工具(GDB、Valgrind、Sanitizer)只是链上的仪器。本讲是后续所有 lab 的方法论底座:L2 Bomb 与 L3 Attack 全靠 GDB 逐指令推进,L5 Malloc 靠 Valgrind/ASan 抓堆错误,L4/L5 的性能依赖 perf 定位热点。

8.2 核心概念与底层机制图解

8.2.1 缺陷、错误与失效链(Defect → Error → Failure)

  • 定义与目的:把”程序坏了”拆成可分别讨论的四步链条,是调试方法论的起点。讲义给出的链条是:①程序员制造 defect(缺陷);②缺陷可能引起 error(错误状态)——数据值或控制信号出错;③错误传播(propagate);④错误最终导致 failure(失效)——某部件在某接口上未产生预期结果。
  • 直观解释:像一栋楼里一根钢筋少绑了一道。少绑(defect)不一定马上出问题;某天它让某层楼板轻微变形(error);变形被后续装修掩盖;直到某次地震才塌下来(failure)。缺陷与失效之间隔着很长的因果链,这正是调试困难的根源。
  • 底层机制图解
        程序员写代码
             |
             v
   +-------------------+      不执行这段代码 -> 永远不触发(latent 潜伏)
   |  defect(缺陷)   |-----> 执行了但结果被掩盖 -> error 被 mask
   +---------+---------+            例: ECC 内存把 1 bit 翻转纠回来
             | 某个执行路径上被触发
             v
   +-------------------+      "正确"的代码也可能原样传播这个错误状态
   |  error(错误状态)|-----> 例: 越界写入的坏值被 memcpy 搬到别处
   +---------+---------+
             | 继续传播
             v
   +-------------------+      只有到了某个"接口"上才被观察到
   | failure(失效)   |-----> 例: printf 打出 fib(1)=134513905
   +-------------------+

   调试 = 沿着 failure 逆流而上,找到那条链上唯一的 defect
  • 与机器码/硬件的对应latent defect 在汇编层面就是一条从未被执行的指令(如 -O3 优化掉的死分支);masked error 有真实硬件对应——ECC 内存用校验位纠正单位翻转,使 error 不上升为 failure。讲义特意点出:error 未必是 failure,因为它可被掩蔽或检测

8.2.2 科学调试(Scientific Debugging)

  • 定义与目的:调试不是”盯着代码猜”,而是把每次修改当作受控实验。讲义给出的循环是:Problem Description → Hypothesis → Prediction → Experiment → Observation & Conclusion,最后收敛到 Diagnosis → Fix → Confirm
  • 直观解释:像医生看病。病人说”肚子疼”(failure);医生提出”可能是阑尾炎”(hypothesis);预测”按压右下腹会痛”(prediction);做该动作(experiment);观察反应;预测落空就换假设。绝不因”最近流行阑尾炎”就直接开刀。讲义引用 Occam 剃刀:若多个假设都能解释现象,选最简单、离当前工作最近的那个
  • 底层机制图解(调试工作流决策树):
+----------------------------------------------------------------+
| 0. 复现 (Reproduce)  固定输入, 可重复看到同一 failure          |
+--------------------------------+-------------------------------+
                                 |
                                 v
+----------------------------------------------------------------+
| 1. 观察 (Observe)  记录: 什么输入? 输出? 栈回溯?               |
+--------------------------------+-------------------------------+
                                 |
                                 v
+----------------------------------------------------------------+
| 2. 假设 (Hypothesis)  哪个 defect 能解释该 failure?             |
|    Occam 剃刀: 选最简单 / 离刚改过的代码最近的                  |
+--------------------------------+-------------------------------+
                                 |
                                 v
+----------------------------------------------------------------+
| 3. 实验 (Experiment)  只改一个条件, 并先写下预测                |
+--------------------------------+-------------------------------+
                                 |
                                 v
                  +-----------------------------+
                  | 4. 观察结果吻合预测吗?      |
                  +------+---------------+------+
                         | 否            | 是
                         v               v
     +--------------------------+  +-----------------------------+
     | 修正或推翻假设, 回第 2 步 |  | 5. 诊断 (Diagnosis)         |
     | 一次只改一个条件         |  |    只修根因, 不修症状       |
     +--------------------------+  +--------------+--------------+
                                                  |
                                                  v
                                   +-----------------------------+
                                   | 6. 确认 (Confirm)           |
                                   |    断言 + 回归测试 + 提交   |
                                   +-----------------------------+
  • 与机器码/硬件的对应:实验的”条件”在底层就是程序输入、调试器里改写的内存/寄存器、改源码重编译三类。讲义提醒:”观察”本身可能干扰实验——printf() 改变栈布局与寄存器使用,对内存越界、未初始化读这类 bug 加 printf 会让它消失,就像量子物理里观测影响被观测对象。故内存类 bug 应优先用不侵入的手段(Valgrind / Sanitizer / 硬件 watchpoint)。

8.2.3 用设计管理复杂度(Managing Complexity)

  • 定义与目的:好的设计要同时满足性能、可用性、可修改性、可移植性、扩展性、安全性、可测试性与成本,但讲义的口号是 “above all else: it must be readable”(最重要是可读)——可读性是其余属性长期维持的前提。
  • 直观解释:复杂度管理就像把杂物间改成有标签的抽屉柜——分离关注点(separation of concerns)是”每个抽屉只放一类东西”;封装(encapsulation)是”抽屉关上就看不见乱”;抽象(abstraction)/信息隐藏(information hiding)是”只需知道抽屉外写了什么”;DRY 是”同一件东西别放两个抽屉”。
  • 底层机制图解:讲义用一个例子说明”把大任务拆成可测试的小块”——缓存访问:
   一次 cache 访问(不可直接测试的一大坨)
   |
   +-- 1. 把地址拆成 tag / set index / block offset    <- 纯函数, 可单测
   +-- 2. 用 set index 查组                            <- 数据结构访问
   +-- 3. 组内 tag 比对 -> hit?                        <- 判定逻辑
   +-- 4. 未命中: 找 LRU 行 -> 驱逐 -> 从内存读入新行   <- 策略
   +-- 5. 更新 LRU 计数                                <- 状态维护
   +-- 6. 若是 store: 置 dirty 位                      <- 状态维护

拆开后步骤 1 是纯函数(地址 → 三个数),可用断言直接验证;步骤 3 可拿构造好的组内容单独测。这就是”Designs need to be testable / Testable design is modular“。

  • 与机器码/硬件的对应:讲义有幻灯片 “Trust the Compiler!”:多写临时变量、多拆函数,让编译器做内联与寄存器分配——函数拆分几乎无代价。但 lab 里要防止编译器把被测函数优化掉(内联后热点函数名消失),需用 __attribute__((noinline))(见 8.3.4)。

8.2.4 沟通:命名与注释(Communication)

  • 定义与目的:写代码是向四种读者沟通:机器、同事、代码评审者、未来的自己。手段包括测试、命名、注释、commit message、代码评审与设计模式。
  • 直观解释:命名即理解——讲义引用 Sam Gardiner:”如果你不知道一个东西该叫什么,你就不知道它是什么;不知道它是什么,你就坐不下来写代码。”命名规则:从意义与意图出发;用含义精确的词(避免 data、info、perform);不超过四个词;避免缩写;用代码评审改进;读出声检查;然后真的去改名。只用字典里的词(FileCpyFileCopy);避免单字母名(i、Unix 惯用的 fdstr 例外);用问题域术语(cachelab 用 line、element);反义词成对一致(first/endfirst/last)。
  • 底层机制图解:讲义用一个真实对比例子说明”注释不该说代码做什么,而该说代码为什么存在”:
   不要写(代码已自述在做什么):
       // 把 bp 的前驱的后继指向 bp 的后继
       (*(void **)((*(void **)(bp)) + DSIZE)) = (*(void **)(bp + DSIZE));

   应该写(代码自己的表达力):
       bp->prev->next = bp->next;

   应该写(解释 why / 何时用):
       // 每个地址是 64 位, 即 16 个十六进制字符, 加结束符共 17 字节
       const int MAX_ADDRESS_LENGTH = 17;

这对例子直接对应 L5 Malloc 的显式空闲链表删除:先把代码改清楚,而不是给晦涩代码补注释。讲义”Code by commenting”四步:①先写单行短注释描述将做什么(如快排的 // 初始化局部量 / 选主元 / 按主元重排 / 递归);②照注释写代码;③修订;④持续维护。

8.3 代码示例与底层机制分析

8.3.1 一个”五处缺陷”的程序:Valgrind 与 AddressSanitizer 实测

⚠️ 仅供演示,请勿模仿——buggy.c 故意写错 4 类内存缺陷。

/* buggy.c — 刻意含四类经典内存缺陷
 * gcc -g -O0 -Wall -std=c11 buggy.c -o buggy
 * valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./buggy
 */
#include <stdio.h>
#include <stdlib.h>

#define LEN 5

static int *make_table(int n) {
    int *t = malloc((size_t)n * sizeof(int));   /* (1) 只分 n 个 int */
    for (int i = 0; i < n; i++)
        t[i] = i * i;
    return t;
}

static int sum_wrong(int *t, int n) {
    int s = 0;
    for (int i = 0; i <= n; i++)                /* (2) 越界: i==n 时多读一个 */
        s += t[i];
    return s;
}

static int use_after_free(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    return *p;                                  /* (3) use-after-free */
}

static int read_uninit(void) {
    int *a = malloc(3 * sizeof(int));
    a[0] = 1;
    return a[1] + a[2];                         /* (4) 读未初始化内存 */
}

int main(void) {
    int *tab = make_table(LEN);
    printf("sum = %d\n", sum_wrong(tab, LEN));
    free(tab);

    printf("leaked block sum = %d\n", sum_wrong(make_table(3), 3));  /* (5) 指针丢失=泄漏 */
    printf("uaf value = %d\n", use_after_free());
    printf("uninit value = %d\n", read_uninit());
    return 0;
}

【代码做什么?】

  1. make_table(n) 分配 nint、填入 i*i,返回首地址。
  2. sum_wrong(t, n)i <= n,多读一个元素——局部越界读
  3. use_after_free() 释放后仍读 *p——释放后使用
  4. read_uninit() 只写 a[0] 却读 a[1]a[2]——读未初始化堆内存
  5. mainmake_table(3) 的返回值直接当实参、不存指针——泄漏

【底层机制透视】

  • 越界读能”跑出结果”,是因为 malloc 为 20 字节请求返回了 24 字节对齐块(glibc chunk 头 + 对齐),多出的 4 字节是堆元数据或上一块数据,读它不触发缺页,于是静默拿到垃圾值。
  • use_after_free 读到 42 而不崩,是因为 free 后小块进入 tcache(thread cache),前 8 字节被改写成 key、第二 8 字节写成 next 指针,而 *p 读偏移 0 处 4 字节——恰好未被覆盖,于是拿到旧值。缺陷在逻辑上,机器状态却完全合法,这正是”error 被掩蔽”的典型。
  • read_uninit 读到 0,是因为该内存刚由 brk/mmap 从内核获得,页表项指向全零物理页

【内存布局 / 数据结构图解】(64 位下 glibc chunk 与 tcache 复用):

   用户看到               实际堆块 (malloc(20))
   +-------------+ 0x4a6f040 <-- make_table 返回的 t
   | t[0] = 0    |   \
   | t[1] = 1    |    |  20 字节用户区
   | t[2] = 4    |    |
   | t[3] = 9    |    |
   | t[4] = 16   |   /
   +-------------+ 0x4a6f054 <-- 越界读的就是这 4 字节
   | 元数据/相邻块 |   Valgrind: 0 bytes after a block of size 20
   +-------------+
   free(p) 后小块进入 tcache:
   +---------------------+ <-- tcache 链表头
   | key (8 字节)        |    分配器写的"已释放"标记
   +---------------------+
   | next (8 字节)       |    指向下一个空闲块
   +---------------------+
   | 残留数据 (4 字节)   |    use_after_free() 读到的 42
   +---------------------+

【实测验证】(本机真实运行结果)

$ gcc -g -O0 -Wall -std=c11 buggy.c -o buggy          # -Wall 已能抓到两处
buggy.c:28:12: warning: pointer 'p' used after 'free' [-Wuse-after-free]
$ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./buggy
==3514292== Invalid read of size 4
==3514292==    at 0x4011CC: sum_wrong (buggy.c:20)
==3514292==    by 0x401274: main (buggy.c:39)
==3514292==  Address 0x4a6f054 is 0 bytes after a block of size 20 alloc'd
==3514292==    at 0x484486F: malloc (vg_replace_malloc.c:381)
==3514292==    by 0x401161: make_table (buggy.c:11)
==3514292==    by 0x40125F: main (buggy.c:38)
==3514292==
==3514292== Invalid read of size 4
==3514292==    at 0x401212: use_after_free (buggy.c:28)
==3514292==  Address 0x4a70130 is 0 bytes inside a block of size 4 free'd
==3514292==    by 0x40120D: use_after_free (buggy.c:27)
==3514292==
==3514292== Conditional jump or move depends on uninitialised value(s)
==3514292==    by 0x4012E5: main (buggy.c:44)
==3514292==  Uninitialised value was created by a heap allocation
==3514292==    by 0x401227: read_uninit (buggy.c:32)
...
==3514292== HEAP SUMMARY:
==3514292==     in use at exit: 4,120 bytes in 3 blocks
==3514292==   total heap usage: 5 allocs, 2 frees, 4,144 bytes allocated
==3514292==
==3514292== 12 bytes in 1 blocks are definitely lost in loss record 1 of 3
==3514292==    by 0x401161: make_table (buggy.c:11)
==3514292==    by 0x40129B: main (buggy.c:42)
==3514292== 12 bytes in 1 blocks are definitely lost in loss record 2 of 3
==3514292==    by 0x401227: read_uninit (buggy.c:32)
==3514292== 4,096 bytes in 1 blocks are still reachable in loss record 3 of 3
==3514292==    by 0x48EE693: _IO_file_doallocate (in /usr/lib64/libc.so.6)
==3514292== LEAK SUMMARY:
==3514292==    definitely lost: 24 bytes in 2 blocks
==3514292==    indirectly lost: 0 bytes in 0 blocks
==3514292==      possibly lost: 0 bytes in 0 blocks
==3514292==    still reachable: 4,096 bytes in 1 blocks
==3514292== ERROR SUMMARY: 10 errors from 10 contexts (suppressed: 0 from 0)

输出解读四件事:①定位源码行sum_wrong (buggy.c:20));②区分 Invalid read/write(非法访问)与 Conditional jump depends on uninitialised value(未初始化值参与判断);③读泄漏分类——definitely lost(找不到指针)必须修still reachable(仍被指向,本例是 libc 的 stdout 缓冲)通常无害;④只修 definitely lost 这两处

AddressSanitizer 的对应输出(本机为 32 位构建,见下面”补充说明”):

$ gcc -fsanitize=address -g -O0 buggy.c -o buggy_asan && ./buggy_asan
=================================================================
==3744690==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf4e00b94
READ of size 4 at 0xf4e00b94 thread T0
    #0 0x80492d1 in sum_wrong /tmp/csapp08/buggy.c:20
    #1 0x8049488 in main /tmp/csapp08/buggy.c:39
0xf4e00b94 is located 0 bytes to the right of 20-byte region [0xf4e00b80,0xf4e00b94)
allocated by thread T0 here:
    #1 0x8049204 in make_table /tmp/csapp08/buggy.c:11
    #2 0x8049475 in main /tmp/csapp08/buggy.c:38
SUMMARY: AddressSanitizer: heap-buffer-overflow /tmp/csapp08/buggy.c:20 in sum_wrong
Shadow bytes around the buggy address:
  0x3e9c0160: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x3e9c0170: 00 00[04]fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend: Addressable: 00   Partially addressable: 01-07
  Heap left redzone: fa   Freed heap region: fd

ASan 的 04 表示该 8 字节 granule 只有前 4 字节可寻址,fa堆红区(redzone)。若程序只含泄漏,退出时由 LeakSanitizer 报告:

$ ./leakonly_asan
==3755933==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 40 byte(s) in 1 object(s) allocated from:
    #1 0x80491e8 in main /tmp/csapp08/leakonly.c:5
SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).

未定义行为由 UBSan 抓(只报告、不中止,程序继续跑完):

$ gcc -fsanitize=undefined -g -O0 ovf.c -o ovf_ub && ./ovf_ub
ovf.c:5:5:  runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ovf.c:10:32: runtime error: left shift of 1 by 31 places cannot be represented in type 'int'
x+1 = -2147483648
u-2 = 4294967295          <- unsigned 回绕是"有定义"行为, UBSan 不报
y/2 = 0
(1<<31) = -2147483648

补充说明:ASan 需预留约 1/8 影子内存:64 位用户空间 47 位(128 TB)对应约 16 TB 影子区;本机 ulimit -v 限为 32 GB,64 位 ASan 无法预留而报 ReserveShadowMemoryRange failed,故本章改用 32 位 ASan(影子区仅数百 MB)取得真实输出。遇到同样报错就查 ulimit -v

8.3.2 用 GDB 从一个段错误找到根因

⚠️ 仅供演示,请勿模仿——make_node 忘记把 nextNULL

/* crashdemo.c — GDB 演示: 链表尾节点的 next 从未初始化
 * gcc -g -O0 -Wall -std=c11 crashdemo.c -o crashdemo
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct node {
    struct node *next;      /* 偏移 0 */
    int value;              /* 偏移 8 */
} node_t;

static node_t *make_node(int value)
{
    node_t *n = malloc(sizeof(node_t));
    if (n == NULL) { perror("malloc"); exit(1); }
    n->value = value;
    return n;                       /* 缺陷: 缺少 n->next = NULL */
}

static int node_value(const node_t *n)
{
    return n->value;                /* 野指针解引用在此发生 */
}

static int sum_list(const node_t *head)
{
    int total = 0;
    const node_t *p = head;
    while (p != NULL) {             /* 依赖 next == NULL 终止 */
        total += node_value(p);
        p = p->next;
    }
    return total;
}

int main(void)
{
    node_t *first  = make_node(10);
    node_t *second = make_node(20);

    /* 让第三个节点复用刚释放的堆内存, 内容已被分配器写成垃圾 */
    void *poison = malloc(sizeof(node_t));
    memset(poison, 0xFF, sizeof(node_t));
    free(poison);
    node_t *third = make_node(30);

    first->next  = second;
    second->next = third;           /* third->next 仍是垃圾值 */
    printf("first=%p second=%p third=%p third->next=%p\n",
           (void *)first, (void *)second, (void *)third, (void *)third->next);
    printf("sum = %d\n", sum_list(first));
    return 0;
}

【代码做什么?】

  1. node_tnext 放偏移 0、value 放偏移 8,使未初始化值恰落在 next 上。
  2. make_node 分配节点、写 value不写 next
  3. main 先把同尺寸内存 memset(..., 0xFF, ...) 涂满再 free,随后 make_node(30) 复用该块,于是 third->next 是未定义字节。
  4. sum_listnext == NULL 终止遍历,拿到垃圾指针后解引用 → SIGSEGV。

【底层机制透视】 free 后的块进入 tcache 时,分配器只改写头部 8 字节的 key 字段(变成 0x405,即 tcache 结构地址的低位),而偏移 0 的 next 恰读这 8 字节,于是遍历拿到的”下一节点”是 0x405——几乎必然未映射。危险在于它”有时不崩”:若残留字节凑巧为 0,程序会静默少统计一个节点,即讲义所说的 latent defect。

【内存布局 / 数据结构图解】(崩溃瞬间 bt 与真实栈内容的对应,地址取自本机实测)

 bt 帧号              地址               内容            归属
                     (高地址在上)
  #2  main    +-----------------------+
              0x7fffffffb6b8 | 0x00007ffff7c3feb0 | main 返回地址
              0x7fffffffb6b0 | 0x0000000000000001 | <- main rbp
              0x7fffffffb6a8 | 0x00000000004052a0 | first
              0x7fffffffb6a0 | 0x00000000004052e0 | third
              0x7fffffffb698 | 0x00000000004052c0 | second
  #1 sum_list +-----------------------+
              0x7fffffffb688 | 0x00000000004012ba | ret -> main (:52)
              0x7fffffffb680 | 0x00007fffffffb6b0 | <- sum_list rbp
              0x7fffffffb678 | 0x0000003cf7ffe220 | 高 4 字节 0x3c = 60 = total
              0x7fffffffb670 | 0x0000000000000405 | 局部量 p <== 野指针!
              0x7fffffffb668 | 0x00000000004052a0 | head 的副本
  #0 node_value +---------------------+
              0x7fffffffb660 | 0x00000000004011f4 | ret -> sum_list (:31)
              0x7fffffffb658 | 0x00007fffffffb680 | <- node_value rbp == rsp
              +-----------------------+ (低地址)

  规则: 每帧首条 `push %rbp` 压入调用者的 rbp,
        紧随的 `call` 压入返回地址 —— 二者在栈上相邻,
        这就是 `bt` 沿 rbp 链走回 main 的物理基础。

【与汇编 / 硬件的对应】objdump -d -M intel,节选)

00000000004011cb <sum_list>:
  4011cf:  48 83 ec 18        sub    rsp,0x18
  4011d3:  48 89 7d e8        mov    QWORD PTR [rbp-0x18],rdi   ; head -> [rbp-0x18]
  4011d7:  c7 45 fc 00 00 00 00  mov DWORD PTR [rbp-0x4],0x0      ; total = 0
  4011e2:  48 89 45 f0        mov    QWORD PTR [rbp-0x10],rax   ; p = head -> [rbp-0x10]
  4011e8:  48 8b 45 f0        mov    rax,QWORD PTR [rbp-0x10]   ; rax = p
  4011ec:  48 89 c7           mov    rdi,rax
  4011ef:  e8 c6 ff ff ff     call   4011ba <node_value>        ; 返回地址 0x4011f4 入栈
  4011f4:  01 45 fc           add    DWORD PTR [rbp-0x4],eax    ; total += ret
  4011fb:  48 8b 00           mov    rax,QWORD PTR [rax]        ; rax = p->next <== 读偏移 0
  401202:  48 83 7d f0 00     cmp    QWORD PTR [rbp-0x10],0x0
  401207:  75 df              jne    4011e8                     ; 非 NULL 继续

这是 -O0 的教科书式代码:局部量全落在栈帧上totalrbp-0x4prbp-0x10headrbp-0x18),与上面的栈图逐格对应。node_value 的崩溃指令是 mov 0x8(%rax),%eax

【实测验证】(完整 GDB 会话)

$ gcc -g -O0 -Wall -std=c11 crashdemo.c -o crashdemo
$ ./crashdemo
Segmentation fault (core dumped)          # 退出码 139 = 128 + SIGSEGV(11)
$ gdb -q ./crashdemo
(gdb) break crashdemo.c:51
Breakpoint 1 at 0x401286: file crashdemo.c, line 51.
(gdb) run
Breakpoint 1, main () at crashdemo.c:51
(gdb) p first
$1 = (node_t *) 0x4052a0
(gdb) p third
$2 = (node_t *) 0x4052e0
(gdb) p *third
$3 = {next = 0x405, value = 30}          <== 一眼看出 next 是垃圾值
(gdb) p/x third->next
$4 = 0x405
(gdb) x/6gx first
0x4052a0:  0x00000000004052c0  0x000000000000000a   <-- first: next, value=10
0x4052b0:  0x0000000000000000  0x0000000000000021   <-- 块头/尾, 0x21=33
0x4052c0:  0x00000000004052e0  0x0000000000000014   <-- second: next, value=20
(gdb) continue
Program received signal SIGSEGV, Segmentation fault.
0x00000000004011c6 in node_value (n=0x405) at crashdemo.c:23
23          return n->value;
(gdb) bt
#0  0x00000000004011c6 in node_value (n=0x405) at crashdemo.c:23
#1  0x00000000004011f4 in sum_list (head=0x4052a0) at crashdemo.c:31
#2  0x00000000004012ba in main () at crashdemo.c:52
(gdb) p n
$5 = (const node_t *) 0x405
(gdb) frame 1
#1  0x00000000004011f4 in sum_list (head=0x4052a0) at crashdemo.c:31
(gdb) p p
$6 = (const node_t *) 0x405              <== 野指针的来源
(gdb) p total
$7 = 60                                  <== 前两个节点 10+20 已累加, 说明崩在第三个
(gdb) info registers rip rsp rbp
rip  0x4011f4   0x4011f4 <sum_list+41>
rsp  0x7fffffffb668
rbp  0x7fffffffb680

推理链条bt 说明崩在 node_value、调用者是 sum_list 第 32 行;total = 60 说明 10 与 20 已正确处理;p = 0x405 说明第三个节点被当成有效节点;回 mainp *third 看到 next = 0x405;而 make_node 从未给 next 赋值 → 根因是初始化缺失,不是循环写错。修复只需一行 n->next = NULL;,并把不变量写成断言:

/* fixed.c 关键片段: 把未初始化从运行期隐患变成设计期约束 */
static node_t *make_node(int value)
{
    node_t *n = malloc(sizeof(node_t));
    if (n == NULL) { perror("make_node: malloc"); exit(EXIT_FAILURE); }
    n->next  = NULL;                    /* 修复: 显式初始化 */
    n->value = value;
    return n;
}

static int node_value(const node_t *n)
{
    assert(n != NULL);                  /* 断言只做检查, 无副作用 */
    return n->value;
}

修好后 third->next = (nil)sum = 60,Valgrind 只剩 still reachable(libc 的 stdout 缓冲),definitely lost: 0 bytes

【GDB 命令速查表】x/nfu addr:n 个数、f 格式、u 单位)

分类命令作用与提示
断点b func函数入口下断点
断点b file.c:80源码第 80 行下断点
断点b *0x4011c6指令地址下断点(无符号时唯一手段)
断点b func if n == 3条件断点,只在关心那次停下
断点tbreak ...临时断点,命中一次即删
断点delete [N] / d删第 N 个断点;无参删全部
断点disable N / enable N停用/启用第 N 个(dis 是 disable,非 disas
断点info b列出断点与命中次数
断点watch expr值改变时停下
断点watch *(int *)0x600850观察指定地址变化
执行run / r / r a1 a2从头(重)运行;默认沿用上次参数
执行continue / c继续到下一断点或信号
执行next / n / n X单步 C 一行,不进入函数
执行step / s / s X单步一行,进入函数
执行nexti / ni单步一条汇编,不进入 call
执行stepi / si单步一条汇编,进入 call
执行finish / f跑到函数返回并打印返回值
执行until 38跑到第 38 行或跳出当前帧(跳循环利器)
执行Ctrl+C中断运行中的程序,回到提示符
查看数据p expr求值任意 C 表达式(p *ptrp &arr
查看数据p/x / p/d / p/t以十六/十/二进制打印
查看数据p *(long*)ptr按指定类型解释内存
查看数据x/8d &arrarr 起打印 8 个十进制整数
查看数据x/16gx $rsp栈顶 16 个 8 字节字(看栈帧标准手法)
查看数据x/s addr / x/2s按 C 字符串打印
查看数据x/6cb addr按字符打印 6 字节
查看数据display expr每次停下自动打印该表达式
查看数据set var x = expr改写变量/内存做实验
查看数据call func(args)在调试器里调程序自己的函数
栈与帧bt / backtrace打印调用栈(崩溃后第一件事)
栈与帧frame N / f N切到第 N 帧(N 即 bt 左边编号)
栈与帧up N / down N沿调用链上/下移 N 帧
栈与帧info frame当前帧基址、保存的 rip、局部量位置
栈与帧info args / info locals打印当前帧形参与局部量
栈与帧info registers [r...]打印全部寄存器或只看指定几个
栈与帧thread apply all bt死锁排查核心:打印各线程栈
反汇编disas反汇编当前函数
反汇编disas func反汇编指定函数
反汇编disas /r func同时显示机器码字节
反汇编x/i $rip / x/4i $rip反汇编当前指令及后几条
反汇编objdump -d -M intel p外部反汇编(Intel 语法更易读)
TUIgdb -tui ./prog启动 TUI 界面
TUIlayout {src,asm,regs,split}切换源码/汇编/寄存器/分栏布局
TUICtrl+L重绘花屏;bomblab 下 TUI 偶不兼容
其它list / l(可接 func 或行号)列出源码
其它gdb --args p a1带参数启动;gdb -p PID 附着进程

8.3.3 从 core dump 做尸检(post-mortem)

若程序不在自己终端里崩、别人只给你一个 core 文件,流程如下(本机 core_patternsystemd-coredump 接管,故用 generate-core-file 生成等价文件):

$ ulimit -c unlimited                 # 允许产生 core
$ ./crashdemo                         # Segmentation fault (core dumped)
$ gdb -q ./crashdemo cd.core          # 直接读 core, 无需重跑程序
Core was generated by `/tmp/csapp08/verify/crashdemo'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00000000004011c6 in node_value (n=0x405) at crashdemo.c:23
#1  0x00000000004011f4 in sum_list (head=0x4052a0) at crashdemo.c:31
#2  0x00000000004012ba in main () at crashdemo.c:52
(gdb) info registers rip
rip   0x4011c6   0x4011c6 <node_value+12>
(gdb) frame 1
(gdb) info frame
Stack level 1, frame at 0x7fffffffb690:
 rip = 0x4011f4 in sum_list (crashdemo.c:31); saved rip = 0x4012ba
 Arglist at 0x7fffffffb680, args: head=0x4052a0
 Locals at 0x7fffffffb680, Previous frame's sp is 0x7fffffffb690
 Saved registers:
  rbp at 0x7fffffffb680, rip at 0x7fffffffb688
(gdb) info locals
total = 60
p = 0x405

info frame 把”bt 里抽象的一帧”翻译成具体地址saved rbp0x7fffffffb680saved rip0x7fffffffb688,与 8.3.2 的栈图吻合。该技巧在 L3 Attack 同样关键。

8.3.4 性能调试:找到热点(hot spot)

/* hot.c — 制造明显热点, 供 perf / gprof / callgrind 定位 */
#include <stdio.h>
#include <stdlib.h>

#define N 20000

__attribute__((noinline))
static long slow_path(long n) {          /* 热点: O(n^2) 双层循环 */
    long s = 0;
    for (long i = 0; i < n; i++)
        for (long j = 0; j < n; j++)
            s += (i ^ j) & 1;
    return s;
}

__attribute__((noinline))
static long fast_path(long n) {          /* 同结果的 O(n) 版本 */
    return (n * n) / 2;
}

int main(void) {
    long a = slow_path(N);
    long b = fast_path(N);
    printf("slow=%ld fast=%ld\n", a, b);
    return 0;
}

__attribute__((noinline)) 在此是必要条件:不加它,-O1 也会把两个小函数内联进 mainperf report 只见 main 占 100%,热点函数名消失(本次实验一开始正是如此)。真实输出:

$ gcc -O1 -g -fno-omit-frame-pointer hot.c -o hot && time ./hot
slow=200000000 fast=200000000
real  0m0.263s
$ perf stat -e instructions,branches,branch-misses ./hot
   3,200,308,202      instructions:u
     400,082,708      branches:u
          22,870      branch-misses:u    #    0.01% of all branches
$ perf record -q -g -o perf.data ./hot && perf report -i perf.data --stdio --no-children
    99.97%  hot      hot    [.] slow_path
            |
            ---slow_path
               __libc_start_call_main
$ gprof -b ./hot_pg gmon.out          # gcc -pg -O1 -g -fno-omit-frame-pointer
  %   cumulative   self          calls  ms/call  ms/call  name
100.00      0.22     0.22           1   220.00   220.00  slow_path
  0.00      0.22     0.00           1     0.00     0.00  fast_path
$ valgrind --tool=callgrind ./hot && callgrind_annotate cg.out
3,200,322,637 (100.0%)  PROGRAM TOTALS
Ir                      file:function
3,200,120,004 (99.99%)  hot.c:slow_path [/tmp/csapp08/hot]
      100,001 ( 0.00%)      for (long i = 0; i < n; i++)
1,600,020,000 (50.00%)          for (long j = 0; j < n; j++)
1,600,000,000 (49.99%)              s += (i ^ j) & 1;

三者差异要记住:perf 是采样式(sampling),开销极小(约 1%),适合真机长跑;gprof-pg 重编译,靠 mcount 插桩,短程序分辨率差;Callgrind 是确定性指令级模拟,能精确到源码行给出 Ir(指令计数),但慢 20–100 倍且报的是指令数而非时间。找出热点后,L4 靠局部性/分块优化,L5 靠减少 malloc 次数,都必须先定位热点再动手,否则优化的只是 1% 的代码。

8.4 实验关联

本讲直接服务全部 lab——它是唯一讲”怎么把 bug 找出来”的讲次。

  • L0 / L1(C 编程、Data Lab):第一天就用 -Wall -Wextra -Werror 编译,并用 assert 把每题的规格(specification)写成可执行断言——讲义称之为”executable documentation”。
  • L2 Bomb:GDB 主战场。必须熟练 b *0x...disasinfo registersx/sx/40cstepi。炸弹无符号信息,只能按地址下断点、靠 x/s 读字符串比对;隐藏关卡入口要从 phase_defusedsscanf 格式串反汇编里找。:TUI 与 bomblab 偶有兼容问题;Ctrl+C 后重跑要按 y 确认。
  • L3 Attack:目标是把 getbuf 的返回地址改成 touch1/2/3。GDB 是唯一能让你看到”%rspgetbuf 内指向哪、返回地址在第几字节”的工具,x/16gx $rspinfo frame 是核心。:栈地址在 GDB 里与直接运行时可能不同,重定向 %rsp(ROP 关卡)时要用 nop sled 或相对偏移。
  • L4 Cache / L5 Malloc:Valgrind 与 ASan 是主要验收工具。L5 的 mm.c 必须做到 valgrind --leak-check=fulldefinitely lost 且所有块在退出前 freemdriver 的 trace 重放本身就是”可复现的测试”。:Valgrind 拖慢 20–50 倍,超时的 trace 先用 -O2 跑通再上。
  • L6 Shell / L7 Proxy / L8 SFS:多进程/多线程 bug 换武器:strace -f -e trace=process,network(看 fork/exec/waitsocket/connect/accept 时序)、gdb -p PID + thread apply all bt(死锁)、valgrind --tool=helgrind(数据竞争、加锁顺序)。本讲实测的死锁现场:
$ gcc -g -O0 -pthread deadlock.c -o deadlock && ./deadlock &   # 两把锁加锁顺序相反
$ gdb -q -batch -p $PID -ex 'thread apply all bt'
Thread 3 ... "deadlock":
#2  0x00000000004011ef in worker2 (arg=0x0) at deadlock.c:24   <== 等 lock_a
Thread 2 ... "deadlock":
#2  0x00000000004011a0 in worker1 (arg=0x0) at deadlock.c:13   <== 等 lock_b
Thread 1 ... "deadlock":
#2  0x0000000000401263 in main () at deadlock.c:35              <== 卡在 pthread_join

两个工作线程各卡在对方的锁上,主线程卡在 join——死锁的”标准指纹”。

  • git bisect:定位”哪次提交引入的 bug”。本讲实测的真实输出(8 个提交、其中第 6 个引入缺陷):
$ git bisect start <bad> <good>
$ git bisect run bash -c 'gcc -O0 prog.c -o /tmp/bp && /tmp/bp | grep -q 100'
a4897244961705e38e0dd7660533365b34907b9c is the first bad commit
    rev 6: speed up clamp (introduces bug)
bisect found first bad commit

要点是测试脚本以退出码表示好/坏grep -q 命中即 0 = good),git bisect run 才能在 $O(\log n)$ 次编译内收敛——把”调试”变成”实验自动化”的范例。

8.5 常见错误与调试技巧

  • -O0 行为当成真相-O0-O3 表现不同(讲义中 badfib-O0 打出 fib(1)=2-O3 打出 fib(1)=0),在某一层”正常”就以为修好,等于把 defect 藏得更深。调试至少在 -O0-O3 各跑一遍,两种优化级别下 valgrind 都要跑(讲义:”Valgrind is not perfect,-O3 下它可能一个错误都报不出来”)。
  • 未初始化变量int f; 未赋值就返回,值取决于栈上残留字节。调试-Wall -Werror 先抓(error: 'f' may be used uninitialized);valgrind --track-origins=yes 指出”Uninitialised value was created by a stack allocation”;gdbp fx/8gx $rsp
  • 越界读写:多写一个字节,可能很久以后才在 free 时崩(堆元数据被破坏)。调试valgrindInvalid write ... 0 bytes after a block of size M alloc'd-fsanitize=address 给出红区与 shadow bytes;gdbx/16gx ptr-16 看块头。
  • use-after-freefree 后仍读指针,读到的是 tcache 的 key/next调试-Wall-O2 下给 -Wuse-after-freevalgrindInvalid read ... inside a block of size 4 free'd 并列出 Block was alloc'd at;ASan 报 heap-use-after-free。习惯写法:free(p); p = NULL;
  • 泄漏分类读错:看到 still reachable: 4,096 bytes 就慌。调试valgrind --leak-check=full --show-leak-kinds=all 后只看 definitely lost/indirectly loststill reachable 多半是 libc 的 stdout 缓冲。
  • 盲目加 printf 调试:输出成”信息海啸”,而且会改变程序行为使 bug 消失。调试:改用 gdbdisplay expr(自动打印、不改源码)、watch expr(只在值变化时停),或 tbreak + until 精确停在关注点。
  • assert 里有副作用:写成 assert(p = malloc(n))assert(++count > 0),一旦用 -DNDEBUG 关掉断言,malloc 就再也不会被调用。调试:断言只做检查——assert(p != NULL); 单独一行;可用 -DNDEBUG 关闭,但绝不能因关断言改变程序语义
  • 改了多处一起验证:一次改三个地方,跑通了不知哪个 fix 起作用,跑不通也不知哪个改动引入新问题。调试:严格一次一个变量(讲义:”Update the conditions incrementally…“),不要在第一个 bug 前继续往下走(”Do NOT ever proceed past first bug”)。>60 分钟无进展就按讲义时间盒:0 分钟上 -Wall/valgrind,1–10 分钟非正式调试,10–60 分钟科学调试,>60 分钟休息或求助。

8.6 关键要点

  • 缺陷 ≠ 错误 ≠ 失效:缺陷只在特定路径上才变成错误,错误常被掩蔽或传播很远才变成失效;测试只能证明缺陷的存在,不能证明其不存在(Dijkstra 1972),故调试必须沿因果链逆流而上找 defect。
  • 调试是科学方法,不是猜测:观察 → 假设 → 预测 → 实验 → 结论,一次只改一个条件,并在实验前写下预测;诊断(diagnosis)的判据是”能解释当前观察并对未来条件做出正确预测”,而非”改完不崩”——小心 post hoc ergo propter hoc
  • 工具按 bug 类型选择printf/display 管逻辑路径;gdb 管机器状态(寄存器、栈、指令);valgrind/ASan 管堆越界、泄漏、未初始化;UBSan 管 UB;perf/gprof/Callgrind 管热点;strace 管系统调用;thread apply all bt + Helgrind 管并发。
  • 设计的第一目标是可读:分离关注点、模块化、封装、抽象、信息隐藏、DRY 都是为了把复杂度切到人能一次容纳的规模;”Trust the Compiler”——多写临时变量、多拆函数;命名即理解,注释写 why 不写 what
  • 修根因,不修症状;修完要固化:定位 defect 后用断言把不变量写进代码,把这次复现条件加入测试套件,提交时用一行 commit message 记下”做了什么、性能如何、什么还没解决”——测试与契约都是可执行的文档
  • gdb 是唯一能”打破程序与机器之间抽象”的工具bt + info frame + x/16gx $rsp 把抽象的调用栈变成可读的地址与字节,这是 L2/L3 及一切崩溃、栈破坏问题的通用解法。

8.7 思考题(带答案)

题 1(推理题:给定崩溃现象与栈回溯,判断 bug 在哪) 某程序偶发崩溃,gdb 给出:

Program received signal SIGSEGV, Segmentation fault.
0x00000000004012a1 in copy_record (dst=0x61a260, src=0x0) at db.c:88
88          memcpy(dst->name, src->name, src->len);
#0  0x00000000004012a1 in copy_record (dst=0x61a260, src=0x0) at db.c:88
#1  0x0000000000401503 in load_all (n=1000) at db.c:140
#2  0x00000000004017c0 in main () at db.c:201
(gdb) frame 1
(gdb) p *records[999]
$1 = {name = 0x0, len = 0, id = 0}

请判断:bug 在哪、属哪类缺陷?为什么它”偶发”?要一次抓住它该用什么工具?

frame 1 显示 records[999]name == NULLlen == 0——第 1000 个记录从未被正确初始化,而 load_all 无条件把 1000 个记录全送进 copy_record。所以缺陷在 load_all(db.c:140 附近)缺少有效性检查,或更靠前的初始化漏掉最后一个元素;memcpy 只是失效的暴露点,不是根因——正是讲义说的”正确代码原样传播错误状态”。它偶发,是因为该记录若未被复用而恰是全零页,src 就是 NULL 直接崩;若残留合法旧指针,memcpy 会成功复制垃圾数据而不崩(缺陷潜伏、错误被掩蔽)。要一次抓住它应用 AddressSanitizergcc -fsanitize=address,undefined -g):第一次非法访问就停下并给出完整调用栈与分配历史;valgrind --track-origins=yes 亦可。修复:在 make_record 显式初始化所有字段,并在 copy_record 入口加 assert(src != NULL && src->name != NULL);

题 2(计算题:ASan 影子内存与地址空间预算) AddressSanitizer 为每 8 字节应用内存维护 1 字节影子内存(shadow = (addr >> 3) + offset)。设 x86-64 用户态可用地址空间为 47 位($2^{47}$ 字节 = 128 TiB)。请计算:(a)影子内存需预留多少地址空间?(b)为何本机 ulimit -v 为 32 GB 时 64 位 ASan 直接失败,而 32 位 ASan 正常?(c)把 64 位 ASan 的报错 failed to allocate 0xdfff0001000 bytes 换算成 TiB 是多少?

:(a)影子区 = 应用地址空间的 $1/8$,即 $2^{47}/8 = 2^{44}$ 字节 = 16 TiB(”预留”、按需提交,非真占 16 TiB 物理内存)。(b)64 位 ASan 启动时要用 mmap(PROT_NONE) 预留这 16 TiB 的连续虚拟地址区间,占用 RLIMIT_ASulimit -v,按虚拟地址空间而非物理内存算)的额度;32 GB 远小于 16 TiB,mmap 直接失败并报 ReserveShadowMemoryRange failed。32 位进程地址空间仅 4 GiB,影子区 512 MiB,稳落在 32 GB 内。(c)$0xdfff0001000 \approx 1.539\times10^{13}$ 字节,除以 $2^{40}$ 得约 14 TiB,与理论值同量级(ASan 预留 [0x00007fff8000, 0x10007fff8000))。这也解释了”内存充足却跑不了 ASan”——瓶颈是虚拟地址空间上限

题 3(”直觉但错误”题) 同学 A 说:”我的程序 -O3 下算错,-O0 下算对,错误只出现在 -O3,所以这是 GCC 的优化 bug。解法是 -O0 编译交作业。”这个想法错在哪?请结合 badfib 说明并给出正确做法。

:错在把”编译器放大了缺陷”误认成”编译器制造了缺陷”badfibint f, f0 = 1, f1 = 1;f 从未初始化,fib(1) 直接 return f——标准的未定义行为(UB)。本讲实测:-O3-O1 都输出 fib(1)=0-O0 输出 fib(1)=2,讲义原始机器上 -O1 给出 fib(1)=9同一份源码在不同优化级别下结果不同,正是 UB 的指纹-O3 只是把 f 放在恰好为 0 的位置。更危险的是 -O0 下的”正确”只是巧合。正确做法:①gcc -Wall -Werror -O3 直接报 error: 'f' may be used uninitialized;②valgrind --track-origins=yes ./badfibConditional jump or move depends on uninitialised value(s) 并指出取值来自 fib 的栈分配;③修根因——改为 int f = 1;;④-O0-O3跑一遍;⑤加入回归测试。“在某一层优化下不崩”从来不是修好的证据。

题 4(设计题:把不可测的一大坨拆成可测模块) 下面是个”一句话完成一切”的函数,请按讲义”把 wordle 工具拆成三个以上可测组件”的思路拆成若干可单测模块,并说明每块怎么测:

int handle(char *line) {           /* 读一行 "guess GYBBY" 并更新状态 */
    char *tok = strtok(line, " \n");
    if (!tok) return -1;
    char *pat = strtok(NULL, " \n");
    for (int i = 0; i < 5; i++)
        if (pat[i] == 'G') fixed[i] = tok[i];
        else if (pat[i] == 'Y') present[i] = tok[i];
    for (int w = 0; w < NWORDS; w++)
        if (matches(words[w])) { score(w); }
    return 0;
}

:按”分离关注点 + 每个函数只做一件事”拆成四块,每块都能独立单测(关键:输入输出都是纯数据,不依赖全局状态): ①parse_feedback(line, guess[6], pattern[6]) —— 只做词法解析。测试:"guess GYBBY\n" 应得 guess="guess"pattern="GYBBY";缺 pattern、长度错、NULL 都返回明确错误码。 ②update_constraints(guess, pattern, c) —— 只做状态更新(纯函数)。测试:喂入 GYBBY 后断言 c->fixed[0]=='g'c->present[1]=='u',并 assert 检查不变量(fixed 与 present 同位置不冲突)。 ③word_satisfies(word, c) —— 只做判定(纯谓词)。测试:对每条约束构造必真/必假的单词穷举验证。 ④rank_candidates(...) —— 只做排序。测试:断言输出顺序稳定(排序键相同要有稳定 tie-break,否则测试会闪烁)。 handle 退化成 5 行”解析 → 更新 → 过滤 → 排序”的编排代码,几乎不可能出错;原版把解析、判定、状态修改、I/O 副作用缠在一起,任一环节出问题都得整体调试。这正是讲义结论:“Design code to be testable / Try to reuse testable chunks”