Lecture 8: 程序设计与调试方法论 (Design and Debugging)
Lecture 8: 程序设计与调试方法论 (Design and Debugging)
讲义对应:CMU 15-213 Lecture 8 — Design and Debugging(素材:
F25-08-design-debugging.txt;配套F25-gdb-and-assembly.txt、lab2_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);不超过四个词;避免缩写;用代码评审改进;读出声检查;然后真的去改名。只用字典里的词(
FileCpy→FileCopy);避免单字母名(i、Unix 惯用的fd、str例外);用问题域术语(cachelab 用 line、element);反义词成对一致(first/end→first/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;
}
【代码做什么?】
make_table(n)分配n个int、填入i*i,返回首地址。sum_wrong(t, n)用i <= n,多读一个元素——局部越界读。use_after_free()释放后仍读*p——释放后使用。read_uninit()只写a[0]却读a[1]、a[2]——读未初始化堆内存。main把make_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 忘记把 next 置 NULL。
/* 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;
}
【代码做什么?】
node_t把next放偏移 0、value放偏移 8,使未初始化值恰落在next上。make_node分配节点、写value,不写next。main先把同尺寸内存memset(..., 0xFF, ...)涂满再free,随后make_node(30)复用该块,于是third->next是未定义字节。sum_list靠next == 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 的教科书式代码:局部量全落在栈帧上(total 在 rbp-0x4、p 在 rbp-0x10、head 在 rbp-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 说明第三个节点被当成有效节点;回 main 帧 p *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 *ptr、p &arr) |
| 查看数据 | p/x / p/d / p/t | 以十六/十/二进制打印 |
| 查看数据 | p *(long*)ptr | 按指定类型解释内存 |
| 查看数据 | x/8d &arr | 从 arr 起打印 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 语法更易读) |
| TUI | gdb -tui ./prog | 启动 TUI 界面 |
| TUI | layout {src,asm,regs,split} | 切换源码/汇编/寄存器/分栏布局 |
| TUI | Ctrl+L | 重绘花屏;bomblab 下 TUI 偶不兼容 |
| 其它 | list / l(可接 func 或行号) | 列出源码 |
| 其它 | gdb --args p a1 | 带参数启动;gdb -p PID 附着进程 |
8.3.3 从 core dump 做尸检(post-mortem)
若程序不在自己终端里崩、别人只给你一个 core 文件,流程如下(本机 core_pattern 由 systemd-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 rbp 在 0x7fffffffb680、saved rip 在 0x7fffffffb688,与 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 也会把两个小函数内联进 main,perf 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...、disas、info registers、x/s、x/40c、stepi。炸弹无符号信息,只能按地址下断点、靠x/s读字符串比对;隐藏关卡入口要从phase_defused的sscanf格式串反汇编里找。坑:TUI 与 bomblab 偶有兼容问题;Ctrl+C后重跑要按y确认。 - L3 Attack:目标是把
getbuf的返回地址改成touch1/2/3。GDB 是唯一能让你看到”%rsp在getbuf内指向哪、返回地址在第几字节”的工具,x/16gx $rsp与info frame是核心。坑:栈地址在 GDB 里与直接运行时可能不同,重定向%rsp(ROP 关卡)时要用nopsled 或相对偏移。 - L4 Cache / L5 Malloc:Valgrind 与 ASan 是主要验收工具。L5 的
mm.c必须做到valgrind --leak-check=full零definitely lost且所有块在退出前free;mdriver的 trace 重放本身就是”可复现的测试”。坑:Valgrind 拖慢 20–50 倍,超时的 trace 先用-O2跑通再上。 - L6 Shell / L7 Proxy / L8 SFS:多进程/多线程 bug 换武器:
strace -f -e trace=process,network(看fork/exec/wait与socket/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”;gdb看p f、x/8gx $rsp。 - 越界读写:多写一个字节,可能很久以后才在
free时崩(堆元数据被破坏)。调试:valgrind报Invalid write ... 0 bytes after a block of size M alloc'd;-fsanitize=address给出红区与 shadow bytes;gdb配x/16gx ptr-16看块头。 - use-after-free:
free后仍读指针,读到的是 tcache 的key/next。调试:-Wall在-O2下给-Wuse-after-free;valgrind报Invalid 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 lost;still reachable多半是 libc 的 stdout 缓冲。 - 盲目加
printf调试:输出成”信息海啸”,而且会改变程序行为使 bug 消失。调试:改用gdb的display 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 == NULL、len == 0——第 1000 个记录从未被正确初始化,而 load_all 无条件把 1000 个记录全送进 copy_record。所以缺陷在 load_all(db.c:140 附近)缺少有效性检查,或更靠前的初始化漏掉最后一个元素;memcpy 只是失效的暴露点,不是根因——正是讲义说的”正确代码原样传播错误状态”。它偶发,是因为该记录若未被复用而恰是全零页,src 就是 NULL 直接崩;若残留合法旧指针,memcpy 会成功复制垃圾数据而不崩(缺陷潜伏、错误被掩蔽)。要一次抓住它应用 AddressSanitizer(gcc -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_AS(ulimit -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 说明并给出正确做法。
答:错在把”编译器放大了缺陷”误认成”编译器制造了缺陷”。badfib 里 int 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 ./badfib 报 Conditional 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”。