# C 语言内存安全问题的排查与思考
C 语言内存安全问题的排查与思考
摘要:内存安全问题是 C/C++ 工程实践中最难定位的一类缺陷。与 Java、Go 这类带运行时检查的语言不同,C 语言在编译期不做数组边界检查,对未定义行为也不提供任何保证。一段存在越界写入、悬空指针或资源泄漏的代码,往往可以编译通过、通过功能测试,甚至在长期运行中表现稳定,然后在某个不确定的时刻以难以复现的方式失效。本文从一段命令行通讯录程序出发,梳理了它在文件读写、序列化存取、资源管理三个方面暴露出的 11 类缺陷,也一并讨论了链表宏实现本身带来的使用约束,逐一给出标准依据与实测证据,并进一步讨论了 AddressSanitizer 这类动态检测工具的能力边界与使用注意事项。
本文分为四个部分:第一部分介绍背景与基本概念;第二部分逐个剖析四类问题;第三部分讨论消毒剂工具的检测边界与一套可复用的排查方法;第四部分进行总结并列出实践中的常见误区。
说明:本文案例取自一个完整的命令行通讯录程序,出于篇幅考虑,文中只保留与主题相关的代码片段。文中所有结论均在 Linux 6.8 / gcc 11.4.0 / x86_64 环境下实测验证,输出原样引用。
1 背景
1.1 问题的来源
这份通讯录程序的功能很朴素:用双向链表保存联系人,支持增删查,并能把链表写进文本文件、再从文件读回。它通过了所有的功能性自测——每条记录都能正确插入、查找、删除,保存文件里确实有内容,读取之后链表也确实不为空。
但它同时存在 11 类缺陷,其中若干条会让功能直接失效。这些缺陷有一个共同点:编译器不报错(只有一处例外),sanitizer 也不一定报,而常规的功能测试完全覆盖不到。
促使我系统梳理这份代码的,是一次存取往返测试中发现的现象:把一条记录保存到文件、再读回来,电话号码的前面会多出一个 phone: 前缀;反复存取,前缀会不断累积。这个过程中没有任何越界、没有任何崩溃、没有触发任何 sanitizer——但数据已经错了。
由此引出一个值得展开的问题:在 C 语言里,"能跑"和"正确"之间的距离究竟在哪里?
1.2 为什么要专门讨论内存安全
C 语言把内存管理的责任完全交给了程序员。这既是它能在操作系统内核、嵌入式固件、高性能中间件等领域长期占据主导的原因,也是这些领域缺陷密度偏高的原因。具体来说,C 语言在以下三个方面不设保护:
- 不做边界检查。 数组下标、指针运算、字符串写入,编译器都不验证是否落在对象范围内。
- 不追踪对象生命周期。 对象被释放之后,指向它的指针不会失效,继续使用是未定义行为。
- 不定义越界与未定义行为的后果。 C11 只声明这类行为"未定义",具体表现取决于编译器、优化级别、运行时内存布局,可能是静默的数据损坏,可能是崩溃,也可能看起来一切正常。
这三个特征叠加起来,使得内存安全缺陷的现场往往远离成因。一个被字符串覆写坏掉的链表指针,可能在几十次操作之后才被解引用,此时栈上早已没有留下任何有用的线索。
1.3 本文的组织
本文按缺陷的分布层次组织,从 I/O 边界,到持久化约定,再到资源管理,最后讨论检测工具的能力边界:
| 章节 | 讨论范围 | 缺陷数 |
|---|---|---|
| 第 3 章 | 双向链表的宏实现(机制与使用约束) | —— |
| 第 4 章 | 文件读写循环与缓冲区边界 | 5 |
| 第 5 章 | 序列化存取不对称 | 2 |
| 第 6 章 | 返回值与内存所有权 | 4 |
第 3 章讨论的是链表实现方式本身带来的使用约束(属于设计层面的注意事项,不计入缺陷清单),第 7 章讨论验证工具的能力边界与排查方法论,不单独对应缺陷。
2 基本概念
在展开具体问题之前,先统一定义本文反复使用的几个概念。
2.1 内存安全(Memory Safety)
内存安全指程序在运行期间只访问属于它的、并且仍然有效的内存区域。与之相对的是内存安全缺陷,常见形态包括:
- 缓冲区溢出(buffer overflow):写入超出目标对象容量。
- 释放后使用(use-after-free):对象释放之后继续通过指针访问。
- 重复释放(double free):同一块内存被释放两次。
- 内存泄漏(memory leak):对象失去所有引用但未被释放。
C11 未对上述行为定义语义,它们统一落入"未定义行为(undefined behavior)"的范畴。C11 3.4.3 对未定义行为的定义是:使用了一个不可移植或错误的程序构造,或错误的数据时,标准不施加任何要求。Annex J.2 中列举了大量属于未定义行为的情形,越界访问与解引用无效指针均在列。
2.2 数组容量与字符串长度
这是后面多个缺陷的根源,需要严格区分:
- 数组容量是编译期确定的字节数量。
char name[16]的容量恒为 16 字节,不随内容变化。 - 字符串长度是运行期由第一个空字符(
'\0')的位置决定的量。
C 语言的字符串处理函数(printf("%s")、strlen、strcpy)只信任终止空字符,不信任数组长度。这意味着写入方必须自行保证:在容量范围内为终止空字符留出一个字节。也就是——
数组的最大有效字符串长度 = 容量 − 1
NAME_LENGTH 定义为 16,可实际能容纳的姓名上限是 15 个字符。这个"减一"如果被遗忘,后果不是一个字符的截断,而是字符串失去终点:读取方会越过数组边界一直读下去。
2.3 对象边界与字段边界
这个区分在讨论检测工具时至关重要。
- 对象指一次分配产生的内存块。栈上的一个局部变量、堆上的一次
malloc、一个结构体实例,都是独立的对象。 - 字段指结构体内部的成员。
对下面这个结构体:
struct person {
char name[16]; /* 偏移 0 */
char phone[32]; /* 偏移 16 */
struct person *next; /* 偏移 48 */
struct person *prev; /* 偏移 56 */
};sizeof(struct person) 为 64 字节(实测确认)。从 name 的第 17 个字节开始写入,在字段层面是越界——它踩坏了 phone;但在对象层面它仍在 64 字节范围之内。第 7 章会看到,这个差别直接决定了 AddressSanitizer 能否发现该缺陷。
2.4 所有权(Ownership)
所有权指"谁负责释放一块内存"这一约定。本文案例中涉及的所有权关系是:
| 对象 | 分配点 | 释放点(应有) |
|---|---|---|
struct contacts | main | main 退出前 |
struct person | insert_entry / load_file | 链表所有者(struct contacts)统一释放 |
FILE * | fopen | 同一函数内的 fclose |
所有权一旦不明确,就会同时导致泄漏和重复释放。第 6.2 节会给出这两种情况的实测结果。
3 链表实现:宏带来的收益与代价
3.1 双向链表与头插法
链表的插入与删除被实现为两个宏:
#define LIST_INSERT(item, list) do { \
(item)->prev = NULL; \
(item)->next = (list); \
if ((list) != NULL) (list)->prev = (item); \
(list) = (item); \
} while(0)
#define LIST_REMOVE(item, list) do { \
if (item->prev != NULL) item->prev->next = item->next; \
if (item->next != NULL) item->next->prev = item->prev; \
if (list == item) list = item->next; \
item->prev = item->next = NULL; \
} while(0)选择双向链表而非单链表,动机在删除操作上。删除一个已知节点时,单链表需要从头遍历找到它的前驱才能修改前驱的 next,时间复杂度 O(n);双向链表通过 item->prev 直接拿到前驱,可以做到 O(1)。通讯录这类以删除为高频操作的数据结构,这个差异是实质性的。
LIST_INSERT 采用的是头插法:(list) = (item); 让新节点成为链表头。头插法不需要维护尾指针,插入代价恒为 O(1)。但它的可见后果是——遍历输出顺序与插入顺序相反。实测印证:
head->id = 2, head->next->id = 1(头插法,顺序反过来)LIST_REMOVE 完整覆盖了四种情形:被删节点位于表头(prev == NULL,跳过第一句,链表头改为 item->next)、位于表中(前后指针都需修改)、位于表尾(next == NULL,跳过第二句)、以及链表仅剩一个节点(两个条件均不成立,链表头置为 NULL)。
值得单独指出的是最后一句 item->prev = item->next = NULL;。它保证一旦发生"删除后再次访问"的错误,访问到的是空指针,而不是一个已经脱离链表、却仍指向原邻居的节点。后者会导致链表结构被意外重新连上,问题更难定位。不过需要明确:该宏只做指针摘除,不释放内存,free 由上层负责,这样职责划分才清晰。
3.2 为什么用宏,以及 do { } while(0) 的用途
用宏而不是函数的理由:函数参数是强类型的。要用函数实现通用的链表插入,只有两条路——为每种节点类型各写一个专用函数,或把参数退化为 void * 并额外传递成员偏移量。宏在预处理阶段做纯文本替换,不参与类型检查,因此同一套逻辑可以同时服务于 struct person、struct task、struct worker 等任意节点类型。这是用宏换取泛型能力。
do { } while(0) 的用途:这两个宏都是多语句宏。如果直接写成 { ... },下面的代码会出错:
if (cond)
LIST_INSERT(p, head); /* 展开为大括号块 */
else
do_something(); /* 编译错误 */展开后是 if (cond) { ... }; else ...,多出的分号提前结束了 if 语句,else 失去配对的 if。用 do { ... } while(0) 包裹,整体加上末尾分号后恰好构成一条完整语句,在任何上下文都不会被拆散。编译器在优化阶段会消除 while(0) 判断,不产生运行时开销。这是 C 语言中多语句宏的标准写法。
3.3 宏的固有代价:参数重复求值
宏参数在展开时是纯文本替换,参数名在宏体中出现几次,传入的表达式就会被求值几次。统计 LIST_INSERT 中 (list) 的出现位置:
(item)->next = (list);if ((list) != NULL)(list)->prev = (item);(list) = (item);
共四处。若实参是普通变量名,读取四次不产生副作用;若实参是带副作用的表达式,问题立即显现。构造一个返回链表头地址并同时计数的函数,可以量化这一现象:
static int eval_count = 0;
static struct node **head_slot(void) {
eval_count++;
return &head;
}
LIST_INSERT(&a, *head_slot());实测输出:
第 1 次插入:实参被求值 3 次
第 2 次插入:实参被求值 4 次同一行调用,求值次数随运行状态变化。 原因在于第 2 和第 3 处位于同一个 if 内:链表为空时 (list) != NULL 为假,短路求值使第 3 处被跳过,因此是 3 次;链表非空时两处都要求值,变为 4 次。
这一实验的结论不是"宏不可用",而是一条使用约束:
宏的实参不应包含带副作用的表达式——自增、自减、函数调用、赋值都属于此类。
若必须在宏中保证单次求值,常见做法有二:改用 static inline 函数;或在 GNU C 中使用语句表达式 ({ ... }) 先将实参存入临时变量。
3.4 选型建议
| 方案 | 泛型能力 | 参数求值次数 | 类型检查 | 调试友好度 |
|---|---|---|---|---|
| 宏 | 强(不区分类型) | 多次(取决于宏体) | 无 | 差(调试器看不到宏内变量) |
static inline 函数 | 弱(需固定类型) | 一次 | 有 | 好 |
void * + 偏移量 | 中 | 一次 | 弱 | 一般 |
C11 _Generic | 中 | 一次 | 有 | 一般 |
实践中的取舍是:若节点类型固定、且宏体较复杂,优先用 static inline 函数;只有在需要跨多种节点类型复用、且实参确无副作用时,才使用宏。
4 文件读写:循环条件与缓冲区边界
4.1 feof 的语义
读文件时最经典的一种写法:
while (!feof(fp)) {
char buffer[BUFFER_LENGTH] = {0};
fgets(buffer, BUFFER_LENGTH, fp);
/* 处理 buffer */
}C11 7.21.10.2 对 feof 的规定是:当且仅当流的文件结束指示器(end-of-file indicator)被设置时,返回非零值。而该指示器由一次失败的读取操作来设置。
因此 feof 是一个事后标志:它回答的是"上一次读取是否撞到了文件末尾",而不是"下一次读取是否会读到内容"。把它放在循环条件里,等价于用后视镜判断前方路况。
4.2 实测:2 行的文件跑了 3 轮
构造一个只有 2 行的数据文件,观察循环行为:
数据文件共 2 行:
name: aa, phone: 111
name: bb, phone: 222
第 1 轮: fgets 返回 非NULL strlen(buffer) = 21 buffer = "name: aa, phone: 111
"
第 2 轮: fgets 返回 非NULL strlen(buffer) = 21 buffer = "name: bb, phone: 222
"
第 3 轮: fgets 返回 NULL strlen(buffer) = 0 buffer = ""
实际执行 3 轮,文件只有 2 行 —— 最后一轮读的是空内容。时序是:进入第 3 轮 → fgets 尝试读取、失败、返回 NULL、同时设置文件结束指示器 → buffer 保持初始化时的全零状态 → 处理逻辑拿到一个空缓冲区。
4.3 为什么这个缺陷在测试中隐身
如果"多跑一轮"只是多处理一次空数据,它算不上严重问题。它真正值得警惕的地方在于会与另一个缺陷互相掩盖。
本例的解析函数开头有这样一句:
if (length < MIN_TOKEN_LENGTH) return -2; /* MIN_TOKEN_LENGTH = 5 */第 3 轮的 buffer 全零,strlen 得到 0,小于 5,函数返回 -2,调用方 continue 跳过。空轮被这道长度检查挡住了。数据文件中的空行(长度为 1)同样是被这一句跳过的。
于是两个缺陷叠加的结果是:程序看起来完全正常。但只要调整或删除这句长度检查,隐藏的空轮问题立刻现形。
这给出一条工程经验:不能仅凭"当前测试通过"判断某段代码可以删除。 一个防御性检查可能正在掩盖另一个缺陷,删除它之前需要先确认它拦截的到底是哪些输入。
4.4 正确写法
把读取本身作为循环条件,让循环次数由成功读取的次数决定:
while (fgets(buffer, BUFFER_LENGTH, fp) != NULL) {
/* 处理 buffer */
}fgets 的返回值既是控制条件,也是有效性证明,循环体只在真正读到内容时执行。这是一个通用模式,读取二进制数据时同理:
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
/* 处理 n 字节 */
}4.5 无界读入:scanf("%s")
struct person *p = (struct person*)malloc(sizeof(struct person));
memset(p, 0, sizeof(struct person));
scanf("%s", p->name); /* name 只有 16 字节 */C11 7.21.6.2 对 %s 转换说明符的定义是:匹配一段非空白字符的最长序列,将其加上终止空字符写入对应参数指向的数组;标准同时要求"该数组必须足以容纳这段序列和终止空字符"。
也就是说,容量是否足够由调用方负责,scanf 不做任何检查。不写宽度限制,%s 会一直读到空白字符为止。
输入 100 个字符时的实测结果:
==ERROR: AddressSanitizer: heap-buffer-overflow
0x506000000060 is located 0 bytes to the right of 64-byte region [0x506000000020,0x506000000060)
#1 ... in insert_entry contacts.c:186
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in scanf_common被破坏的是 insert_entry 中 malloc 得到的 64 字节对象,写入位置正好在该对象右侧边界外 0 字节处。
修法是限定宽度,让 scanf 最多读取 容量 − 1 个字符:
scanf("%15s", p->name); /* 16 字节数组,最多写 15 个字符 */需要注意 %15s 中的 15 是字符数而非字节数,处理多字节编码时不能直接套用数组长度。更稳妥的做法是先读入一个足够大的缓冲区再截断:
char buf[256];
if (fgets(buf, sizeof(buf), stdin) != NULL) {
buf[strcspn(buf, "\n")] = '\0'; /* 去掉换行符 */
strncpy(p->name, buf, sizeof(p->name) - 1);
p->name[sizeof(p->name) - 1] = '\0'; /* 兜底:确保终止 */
}至少 fgets 的容量参数是显式写出的,不会遗漏。
4.6 手写解析器的两处边界错误
解析一行 name: liaoqf,telephone: 23456543789 的函数如下:
int parser_token(char *buffer, int length, char *name, char *phone) {
if (buffer == NULL) return -1;
if (length < MIN_TOKEN_LENGTH) return -2;
int i = 0, j = 0, status = 0;
for (i = 0; buffer[i] != ','; i++) { /* 缺陷 1 */
if (buffer[i] == ' ') {
status = 1;
} else if (status == 1) {
name[j++] = buffer[i]; /* 缺陷 2 */
}
}
status = 0;
j = 0;
for (; i < length; i++) {
if (buffer[i] == ' ') {
status = 1;
} else if (status == 1) {
phone[j++] = buffer[i];
}
}
return 0;
}缺陷 1:循环终止条件只认逗号。 for (i = 0; buffer[i] != ','; i++) 只检查字符是否为逗号。如果该行不含逗号,'\0' 不等于 ',',循环不会在字符串末尾停止,而是继续向后读取。
单独复现这一条需要一点技巧——构造一行既无逗号也无空格的数据,使写入路径保持安静,从而单独暴露读取问题:
==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1
#0 ... in parser_token contacts.c:119
#1 ... in load_file contacts.c:160
[128, 256) 'buffer' (line 151) <== Memory access at offset 256 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:119 in parser_tokenbuffer 是 load_file 中的 char buffer[128]。报告显示访问发生在偏移 256 处,即越过整个 128 字节数组后又前进了 128 字节。
修法是把长度纳入条件:
for (i = 0; i < length && buffer[i] != ','; i++)length 由调用方通过 strlen(buffer) 计算并传入,本身是正确的,问题只在于循环没有使用它。
缺陷 2:写入时不比较目标容量。 name[j++] = buffer[i]; 中 j 只增不减,也从未与 NAME_LENGTH 比较。调用方传入的缓冲区:
char name[NAME_LENGTH] = {0}; /* 16 字节 */
char phone[PHONE_LENGTH] = {0}; /* 32 字节 */只要文件中某一行的姓名字段超过 15 字节,就会写穿这 16 字节。实测:
==ERROR: AddressSanitizer: stack-buffer-overflow
#0 ... in parser_token contacts.c:123
#1 ... in load_file contacts.c:160
[32, 48) 'name' (line 157) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:123 in parser_token这里有两点值得注意。
其一,两处缺陷存在先后关系。 写越界(第 123 行)在多数输入下先触发,把读越界(第 119 行)挡住。这解释了为什么 ASan 报告的往往不是第一个缺陷,而是执行顺序上最先到达的那一个——修完一处必须重跑,才能看到后面藏着的另一处。
其二,length 参数在这里帮不上忙。 它约束的是源缓冲区的长度,而缺陷在于目标缓冲区的容量,两者是独立的量,必须分别检查:
if (status == 1) {
if (j >= NAME_LENGTH - 1) return -3; /* 留一个字节给终止空字符 */
name[j++] = buffer[i];
}4.7 终止符缺失引发的连锁后果
越界写入最直观的后果是"覆盖了相邻变量",但实际影响往往更隐蔽。将 40 个 A 从标准输入喂入 insert_entry(p->name 仅 16 字节),ASan 未报告任何错误,而程序打印出:
name: AAAAAAAAAAAAAAAA13900001111,phone: 13900001111对照正常输入(alice):
name: alice,phone: 13900001111分析其成因。在 64 字节的结构体中,偏移 0–15 属于 name,16–47 属于 phone。scanf 从 name 起始处写入 40 个 A,随后写入终止空字符,于是:
name的 16 字节被完全填满,终止空字符落在name之外,进入phone所在区域;- 接下来
scanf("%s", p->phone)又把电话号码写入phone,覆盖了那个终止符; - 结果
name数组中不含任何 0 字节。
由于 C 语言的字符串以终止空字符界定,printf("%s", item->name) 会从 name[0] 逐字节输出,直到遇见 0 字节为止——而 0 字节此时已经在 phone 内。因此它先输出 16 个 A,随后越过数组边界继续输出,把紧邻的 phone 内容一并印出。
这个案例把 2.2 节的结论具体化了:数组容量与字符串长度是两个不同的量,写入方必须保证容量范围内留有一个字节给终止空字符。 忘记"减一"的后果不是截断一个字符,而是让字符串失去终点。
4.8 缓冲区操作的检查清单
综合以上各点,凡涉及缓冲区写入的地方,建议逐项确认:
- [ ] 目标缓冲区容量是否已知(
sizeof(array)或显式传入)? - [ ] 写入长度是否在写入前与容量比较,而不是写入之后?
- [ ] 是否为终止空字符预留了位置(最大长度 = 容量 − 1)?
- [ ] 源数据的长度与目标缓冲区的容量是否分别检查?
- [ ]
scanf("%s")、strcpy、sprintf、gets这类无界接口是否已替换? - [ ] 循环中是否存在依赖输入内容的终止条件(如"直到遇见某字符")?是否已加上长度上界?
5 持久化:存取不对称
5.1 格式约定的隐性依赖
保存函数写出的是这样的格式:
fprintf(fp, "name: %s, phone: %s\n", item->name, item->phone);注意逗号后面带有一个空格。
而解析函数的规则是:以逗号切分两段,然后在每一段中取第一个空格之后的全部内容作为值。
这条规则本身不难理解,问题在于它对格式极为敏感——"第一个空格"出现的位置,取决于逗号后面有没有空格。对仓库中三份数据文件的实测结果:
| 文件 | 行格式 | 实测解析结果 |
|---|---|---|
a.txt | name: liaoqf,telephone: 23456543789 | liaoqf --> 23456543789(正确) |
b.txt | name: qiuxiang,telephone: 98765678123 | qiuxiang --> 98765678123(正确) |
c.txt | name: king, phone: 344567754 | king --> phone:344567754(错误) |
a.txt / b.txt 中逗号紧跟字段名,第一个空格出现在字段名冒号之后,取到的值是纯数字;c.txt 中逗号后面先有一个空格,这个空格被当作字段名与值的分隔符,于是字段名 phone: 连同冒号一起被保留在值里。
差别只在一个空格,解析结果却完全不同——这说明格式约定存在隐性依赖,而这类依赖通常不会被写进文档。
5.2 实测:一次往返就损坏数据
把上面的格式问题与保存格式对应起来,结果就不只是"某份测试数据格式不对"了。保存时写出的是逗号加空格——恰恰是解析会出错的那一种。实测一次完整的存取往返:
保存进 rt.txt 的内容:
name: alice, phone: 13900001111
读回时解析结果:
file token : alice --> phone:13900001111
遍历输出:
name: alice,phone: phone:13900001111 ← 从文件读回的记录
name: alice,phone: 13900001111 ← 内存中原有的记录每执行一次"保存再读取",电话号码前面就会多一个 phone: 前缀。 反复存取会不断累积。这个缺陷不会触发任何 sanitizer,因为从内存安全的角度看,代码一行越界都没有。
同时还可以看到两个相关问题:
- 加载是追加语义。
load_file将读到的节点逐个头插进链表,调用方已有的节点一个都不动。因此输出中alice出现两次——一次是内存中原有的,一次是从文件读入的。cts->count也随之累加,对同一文件加载两次,记录数会翻倍。 - 顺序被反转两次。
save_file按链表顺序逐行写出,load_file用头插法读回,两个方向相反,因此"保存 → 读取"之后链表顺序与原状态相反。
5.3 往返测试:拦截这类缺陷的唯一手段
这类缺陷不越界、不崩溃、不产生编译警告,动态检测工具完全无力。能守住它的只有"往返测试"——把序列化与反序列化作为一对操作来验证:
构造数据 → 序列化 → 清空内存 → 反序列化 → 逐字段比较实现成本极低,却能覆盖一整类缺陷。需要注意的是,仅验证"保存后文件非空"和"读取后链表非空"是远远不够的:
/* 不足够的断言 */
assert(file_size > 0);
assert(list_is_empty() == false);
/* 有效的断言 */
save(list, path);
clear(list);
load(&list, path);
assert(list_count() == original_count);
for (i = 0; i < original_count; i++)
assert(strcmp(list[i].name, original[i].name) == 0 &&
strcmp(list[i].phone, original[i].phone) == 0);只有比较内容本身,才能发现"值里多了个字段名前缀"这类错误。
6 资源管理:返回值与所有权
6.1 隐式返回值
int save_file(struct person *people, const char *filename) {
FILE *fp = fopen(filename, "w");
if (fp == NULL) return -1;
/* ... */
fclose(fp);
} /* 函数结束,但没有 return 语句 */本例中 save_file、print_entry、save_entry、load_entry 四个函数都是这种形态:声明返回 int,正常路径上却没有任何 return。
C11 6.9.1p12 规定:若执行到终结函数的 },且调用方使用了该次调用的返回值,则行为未定义。注意构成未定义行为需要同时满足两个条件——"到达函数末尾"与"调用方使用返回值"。当前 main 中:
case OPER_SAVE:
save_entry(cts); /* 返回值被丢弃 */
break;返回值确实未被使用,因此按标准字面,当前代码不构成未定义行为。但这是一个脆弱的巧合:只要有人将调用改为 if (save_entry(cts) != 0) { ... },未定义行为立即成立,而届时编译器未必还会警告——尤其是当该函数被编译在另一个翻译单元、-Wreturn-type 看不到函数体时。
好消息是,GCC 在 -Wall 下会直接指出(-Wreturn-type 属于 -Wall):
contacts.c: In function 'save_file':
contacts.c:111:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'print_entry':
contacts.c:218:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'save_entry':
contacts.c:274:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'load_entry':
contacts.c:284:1: warning: control reaches end of non-void function [-Wreturn-type]四个函数的位置被逐行列出。也就是说这类问题在零成本的条件下就能被发现。之所以长期存在,只是因为警告没有被当作需要处理的问题。
由此得到一条实践建议:编译带上 -Wall -Wextra,并要求零警告。 警告数量一旦被允许存在,人就会习惯性地忽略它们,而忽略的代价是放弃了一层免费的静态检查。若希望更严格,可叠加 -Werror 将警告升级为错误,或使用 -fanalyzer(GCC 10 起提供)做过程间分析。
6.2 内存泄漏实测
main 退出前的清理只有一句:
exit:
free(cts);
return 0;cts 是 malloc 得到的 struct contacts,释放它是正确的。但 cts->people 指向的整条链表——每个节点都由 insert_entry 单独 malloc——一个都没有被释放。用 LeakSanitizer 运行完整流程(插入一条记录后退出):
==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 64 byte(s) in 1 object(s) allocated from:
#0 ... in __interceptor_malloc
#1 ... in insert_entry contacts.c:186
#2 ... in main contacts.c:317
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).泄漏 64 字节,正是 sizeof(struct person);分配点在 insert_entry 第 186 行。插入 N 条记录即泄漏 64 × N 字节。
6.3 释放顺序
正确的清理函数如下:
void contacts_destroy(struct contacts *cts) {
struct person *item = cts->people;
while (item != NULL) {
struct person *next = item->next; /* 先保存后继,再释放当前节点 */
free(item);
item = next;
}
cts->people = NULL;
cts->count = 0;
}next 必须在 free(item) 之前取出。 若写成:
free(item);
item = item->next; /* 错误:释放后使用 */就是典型的释放后使用(use-after-free)。free 之后那块内存的内容可能已被分配器改写,再读 item->next 得到的可能是任意地址。使用 -fsanitize=address 时,这类访问会以 heap-use-after-free 的形式报出,报告中会注明该区域"已被释放"。
此外,load_file 中还有一处错误路径泄漏:
struct person *p = (struct person*)malloc(sizeof(struct person));
if (p == NULL) return -2; /* 直接返回,fp 没有 fclose */分配失败时函数提前返回,fopen 得到的文件句柄永不关闭。修法是让失败路径统一在 fclose(fp) 之后返回,即每个函数只保留一个出口;或者用 goto cleanup 集中处理。
这一点容易被忽略,因为"分配失败"在测试中几乎不会出现——64 字节的分配失败通常意味着进程早已无法继续。但在内存受限的嵌入式环境中,这恰恰是最需要正确处理的分支。
7 消毒剂的能力边界
7.1 影子内存与红区机制
前文多处越界之所以能被发现,依靠的是 AddressSanitizer。其原理是:为每个内存对象两侧添加红区(redzone),并用一段影子内存(shadow memory)记录每个字节是否可访问;编译阶段在每次内存访问前插入检查,命中不可访问区域即报告并打印调用栈。
影子内存的粒度可以从 ASan 自身的输出中读出:
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack right redzone: f3
Intra object redzone: bb一个影子字节对应 8 个应用字节。红区的分类中,f1 / f3 是理解检测边界的关键:红区加在栈变量之间,而不是加在结构体字段之间。
7.2 实测:同一个越界,两种结果
下面这段对比程序,在 struct person 的 name[16] 上做越界写入,分别放在结构体内部和独立局部数组中:
__attribute__((noinline))
static void write_into_struct(const char *src, int n) {
struct person p;
memset(&p, 0, sizeof(p));
for (int i = 0; i < n; i++) p.name[i] = src[i]; /* A:溢出落在结构体内部 */
printf(" [A] phone 字段读到: %.32s\n", p.phone);
}
__attribute__((noinline))
static void write_into_local(const char *src, int n) {
char name[16];
memset(name, 0, sizeof(name));
for (int i = 0; i < n; i++) name[i] = src[i]; /* B:溢出踩到相邻栈对象 */
printf(" [B] name 前 16 字节: %.16s\n", name);
}以 -fsanitize=address 编译,改变写入长度,实测结果如下:
| 写入长度 | 情形 A(写在 struct person 内部,对象共 64 字节) | 情形 B(写在独立的 char name[16] 之外) |
|---|---|---|
| 17 | 未报告,phone 被污染 1 字节 | 报告 stack-buffer-overflow |
| 32 | 未报告 | 报告 |
| 64 | 未报告(刚好写满整个结构体) | 报告 |
| 80 | 报告 stack-buffer-overflow | 报告 |
情形 A 在写入 32 字节时的完整输出:
情形 A:越界写在 struct person 内部
[A] 写入 32 字节后,phone 字段读到: XXXXXXXXXXXXXXXX16 个 X 恰好对应从 name 溢出的 16 个字节——数据已经写坏,ASan 却未报告。原因在于,从 ASan 的视角看,p 是一个 64 字节的对象,偏移 0 至 63 全部可访问;至于程序员认为 name 只应占前 16 字节,影子内存无从得知。只有当写入越过整个 64 字节对象时(情形 A 的 80 字节),红区才会被踩到。
7.3 这一边界对实际代码的影响
回到 4.5 节的代码:
struct person *p = (struct person*)malloc(sizeof(struct person)); /* 64 字节 */
scanf("%s", p->name); /* 写入 17~64 字节之间:静默损坏 phone / next / prev */输入长度在 17 到 64 之间时,越界发生在对象内部,ASan 不报告;只有超过 64 字节才会报 heap-buffer-overflow。而 next / prev 两个指针一旦被字符串覆写,链表结构会被破坏为任意值,之后任何一次遍历都是解引用野指针。这类缺陷的现场往往远离成因,定位难度极高。
这正是 ASan 最需要覆盖、却恰恰覆盖不到的区域。
能否让 ASan 也检测对象内部越界?可以,Clang 提供 -fsanitize-address-field-padding=<n>,在字段之间插入填充与红区。但这是 Clang 独有的选项:本文验证环境仅有 gcc 11.4.0,加上该参数直接报错——
gcc: error: unrecognized command-line option '-fsanitize-address-field-padding=2'因此这一条未能在 GCC 上实测,只能从 ASan 输出中 Intra object redzone: bb 这一项确认该机制存在。若工程中使用 GCC 并希望覆盖对象内部越界,需依赖代码审查与显式的边界检查。
7.4 一个实践细节:泄漏检测会吞掉标准输出
用同一程序演示内存泄漏时,容易遇到一个会造成误判的现象。将程序的标准输出接入管道,输出会是完全空的:
$ printf "1\nalice\n13900001111\n2\n0\n" | ./contacts_asan 2>/dev/null
(无输出)原因在 LeakSanitizer 的退出方式:检测到泄漏后,它走的是 _exit 一类不执行清理的终止路径,不会刷新 stdio 缓冲区。标准输出接终端时是行缓冲,每次换行即落盘;接入管道时变为全缓冲,内容仍留在缓冲区中,进程直接终止便一并丢失。
要看正常输出,显式关闭泄漏检测即可:
ASAN_OPTIONS=detect_leaks=0 ./contacts_asan这不是程序的缺陷,但排查时若不了解,很容易把"输出为空"误判为"程序未运行"。
8 排查方法论
8.1 缺陷全景
将本文涉及的全部缺陷汇总如下,标注各自的可见性:
| # | 问题 | 编译警告 | ASan 捕获 | 修法要点 |
|---|---|---|---|---|
| 1 | 保存格式与读取规则不匹配 | 否 | 否 | 统一格式,写往返测试 |
| 2 | 加载采用追加而非替换 | 否 | 否 | 明确语义,同步 count |
| 3 | scanf("%s") 无宽度限制 | 否 | 仅越过整个对象时 | 加宽度限制或改用 fgets |
| 4 | 解析循环终止条件只认逗号 | 否 | 是(越界读) | 加入长度上界 |
| 5 | 解析写入不检查目标容量 | 否 | 对象内部不报 | 源长度与目标容量分别检查 |
| 6 | while (!feof(fp)) 多跑一轮 | 否 | 否 | 改用 while (fgets(...) != NULL) |
| 7 | 错误路径文件句柄泄漏 | 否 | 部分(LSan 可报) | 统一出口 |
| 8 | 四个函数缺少 return | 是 | 否 | 编译零警告 |
| 9 | 退出时未释放链表 | 否 | 是(LeakSanitizer) | 实现 destroy,注意释放顺序 |
| 10 | 调用方不检查返回值 | 否 | 否 | 检查并反馈 |
| 11 | 循环内 fflush 增加 I/O 次数 | 否 | 否 | 交给 fclose 统一刷新 |
可以看到,11 类缺陷中只有 1 类会产生编译警告,3 类能被 ASan 捕获。其余 7 类需要依靠代码审查、往返测试与边界检查清单来覆盖。
8.2 建议的排查流程
结合以上经验,对涉及内存操作与 I/O 的模块,可以采用如下流程:
- 零警告编译。
gcc -Wall -Wextra -O0 -g,要求零警告。这一步成本最低,收益最直接。 - 静态审查缓冲区。 按 4.8 节的清单逐项核对,重点是所有无界接口与依赖输入内容的循环条件。
- 接入 ASan 运行。
-fsanitize=address,跑通全部功能路径。注意它的检测边界(第 7 章):对象内部的越界不会报告,需要人工补位。 - 补写往返测试。 凡涉及序列化与反序列化的模块,必须验证"写出去再读回来,内容相等"。
- 检查所有权。 每个
malloc都要能对应到一处free,每个fopen都要能对应到一处fclose(包括所有错误路径)。 - 逐个修复,逐个重跑。 ASan 通常只报告执行顺序上最先到达的缺陷——修完一处必须重跑,才能暴露后面隐藏的问题。
8.3 关于"缺陷互相掩盖"
第 4.3 节提到的现象值得单独强调:load_file 的长度检查(length < MIN_TOKEN_LENGTH)恰好掩盖了 feof 多跑一轮的后果,使缺陷在测试中隐身。
缺陷之间存在互相伪装的可能。 一份通过了全部测试的代码,未必是"没有缺陷的代码",也可能是"缺陷恰好被彼此抵消或掩盖的代码"。这也是为什么修复应当逐个进行、每改一处就重跑一次验证——批量修改会让新的问题难以归因。
9 总结
本文从一段功能上"完全正常"的通讯录程序出发,梳理了 11 类内存安全与资源管理缺陷,覆盖双向链表的宏实现、文件读写循环、缓冲区边界、序列化存取与内存所有权五个方面。主要结论如下:
第一,feof 是事后标志,不是预测。 C11 7.21.10.2 规定它查询的是文件结束指示器,而该指示器仅在一次失败的读取之后才被设置。任何形如 while (!feof(fp)) 的循环都会多执行一轮,正确做法是让读取操作的返回值充当循环条件。
第二,数组容量与字符串长度是两个独立的量。 容量决定可写入的字节数,字符串长度由终止空字符界定。写入方必须预留终止空字符的位置(最大长度 = 容量 − 1);读取方只信任那个 0 字节。忘记这一条,后果不是截断,而是字符串失去终点、越界读取。
第三,宏的参数会被求值多次。 实测同一个宏在链表为空时求值 3 次、非空时 4 次,差异来自宏体内 if 的短路求值。宏的实参不应包含带副作用的表达式。
第四,ASan 的检测边界是"对象"而非"字段"。 红区加在变量之间而不加在结构体字段之间,因此结构体内部的越界写入不会报告,只有越过整个对象才会。而越是容易破坏链表结构的越界(覆写 next / prev),越落在这个盲区内。GCC 不支持 Clang 的字段填充选项,无法用它在 GCC 上补齐。
第五,序列化必须成对设计,并配往返测试。 本例中 save 写出 ", ",load 却把逗号后的空格当成分隔符,每存取一次就给电话号码加上一个字段名前缀。这类缺陷不触发任何 sanitizer,只能靠"写出去再读回来、比较内容"的测试发现。
第六,编译警告应当清零。 四个缺少 return 的函数没有被人工发现,而 GCC 早已逐行指出。允许警告存在,等于放弃了一层免费的静态检查。
9.1 常见误区
实践中,有几个容易出现的认识偏差:
误区一:程序能跑通就说明没有内存问题。 未定义行为的特征恰恰是"可能看起来正常"。越界写入未必落在关键字段上,释放后使用未必立刻崩溃,泄漏在内存充足时完全无症状。
误区二:接入了 sanitizer 就万事大吉。 ASan 有明确的检测边界(第 7 章)。它覆盖不到对象内部的越界,也覆盖不到语义错误(如第 5 章的存取不对称)。工具是必要而非充分条件。
误区三:用增加检查的方式保证安全,不考虑检查本身的正确性。 本例中 if (length < MIN_TOKEN_LENGTH) return -2; 就属于这类"看似周全"的检查——它在挡住空行的同时,也掩盖了 feof 的缺陷。检查的语义必须与它要拦截的输入精确对应。
误区四:把编译警告当作噪声。 -Wall 报出的是标准明确要求提示的内容。长期忽略警告,等于让编译器这层免费的检查失效。
误区五:批量修复所有缺陷。 ASan 一次通常只报告最先到达的问题。批量修改会让验证结果无法归因,反而延长定位时间。逐个修复、逐个验证才是更快的路径。
参考文献
- ISO/IEC 9899:2011(C11)6.9.1p12 —— 函数返回值与到达函数末尾的未定义行为
- ISO/IEC 9899:2011(C11)7.21.6.2 ——
fscanf系列对%s转换说明符的容量要求 - ISO/IEC 9899:2011(C11)7.21.10.2 ——
feof函数语义 - ISO/IEC 9899:2011(C11)3.4.3、Annex J.2 —— 未定义行为的定义与情形列举
- AddressSanitizer 官方文档 —— 影子内存布局与红区类型
- GCC 手册 ——
-Wreturn-type、-fsanitize=address、-fanalyzer