内存安全:越界、泄漏与悬空指针
动态内存的语法并不难,难的是维护一组始终成立的约束:访问不能越界、对象必须仍然存活、资源只能释放一次,并且最终必须有人释放。
先建立"合法访问"的四个条件
通过指针访问对象之前,至少确认:
- 指针不是空指针;
- 指针指向的对象仍在生命周期内;
- 访问范围没有越过对象边界;
- 访问方式与对象类型、对齐要求相容。
只检查 p != NULL 远远不够。非空地址也可能悬空、越界或类型错误。
未初始化读取
int value;
printf("%d\n", value); // 未定义行为
自动对象不会自动变成零。编译器可能把未初始化读取当作"不可能发生",从而做出令初学者意外的优化。最稳妥的习惯是让对象在第一次读取前拥有明确值。
危害与如何发现:
- 危害:读取到的值是内存中残留的随机数据。这个值可能看起来正常(比如碰巧是 0),让程序继续执行并产生错误的结果;也可能是一个很大的负数或正数,导致后续运算溢出、数组访问越界或逻辑判断错误。最麻烦的是程序在 Debug 模式下每次运行结果一致,Release 模式下却行为诡异——因为编译器对未初始化操作的优化不同。
- 如何发现:用
-fsanitize=undefined编译,检测到未初始化读取时会报告。开启-Wall编译器会对某些未初始化变量给出警告。用调试器逐步跟踪并观察变量值是否有反常的大数也能辅助定位。
数组越界
int data[4] = {0};
data[4] = 10; // 合法下标只有 0、1、2、3
C 通常不会自动检查边界。写出的地址可能覆盖另一个局部变量、返回信息或尚未分配的页面。程序"这次没有崩溃"不等于代码正确。
统一使用半开区间可以减少边界错误:长度为 n 的数组,其合法范围是 [0, n)。
危害与如何发现:
- 危害:覆盖相邻内存中的其他变量会导致数据静默损坏——你以为是正确的结果,其实已经是篡改过的值。如果越界写入覆盖了函数的返回地址(参见第 18 篇的缓冲区溢出章节),攻击者可以利用它执行任意代码。越界写入还可能导致后续的
free操作崩溃,因为堆管理信息被破坏了。 - 如何发现:用
-fsanitize=address编译,程序越界访问时会立即报错并打印堆栈信息,精确指明是哪一行、访问了什么地址。Valgrind 也能检测堆上的越界。对比 Debug 和 Release 版本的不同行为也是一个线索。
缓冲区溢出
char name[8];
scanf("%s", name); // 输入过长会越界
至少要限制宽度:
scanf("%7s", name);
但更推荐用 fgets 读取一整行,再解析:
char line[64];
if (fgets(line, sizeof line, stdin) == NULL) {
/* 处理 EOF 或读取错误 */
}
fgets 可能保留换行符;如果一行长于缓冲区,还要识别并丢弃剩余部分。
危害与如何发现:
- 危害:缓冲区溢出是最经典的网络安全漏洞之一。攻击者可以精心构造超长输入,覆盖栈上的返回地址,让程序跳转到攻击者指定的代码(shellcode),从而获得系统的控制权。历史上 Morrus 蠕虫、SQL Slammer 等著名攻击都利用了缓冲区溢出。即使在非网络环境下,溢出也会破坏相邻数据,导致程序崩溃或产生错误结果。
- 如何发现:
-fsanitize=address能检测栈上的缓冲区溢出。使用fgets或指定宽度的scanf可以从源头上避免问题。如果程序在特定输入下崩溃,尝试输入超长字符串看是否触发,也可以作为排查方向。
使用已释放内存
int *p = malloc(sizeof *p);
if (p == NULL) return 1;
free(p);
printf("%d\n", *p); // use-after-free
free 不会把所有指针副本自动改成 NULL,也不保证立即清空内容。释放后,分配器可以把同一块存储交给别的对象。
把当前变量设成 NULL 有助于阻止它被再次使用:
free(p);
p = NULL;
但如果还有 alias = p 之类的别名,它们仍然悬空。真正的解决方案是明确所有权,避免到处保存可释放资源的裸别名。
危害与如何发现:
- 危害:释放后的内存可能已经被分配给其他变量,读取会得到错误的数据;写入则会破坏其他正常对象,导致难以追溯的 bug。最危险的是,如果释放后分配器把这块内存交给了某个安全敏感的对象(如密码缓冲区),旧的指针还能读到它,造成信息泄露。
- 如何发现:
-fsanitize=address对 use-after-free 有极高的检测精度,几乎能 100% 捕获。Valgrind 也能检测。如果程序在看似无关的代码位置崩溃,或者在malloc/free操作附近出问题,应怀疑有悬空指针。
重复释放
free(p);
free(p); // 未定义行为
free(NULL) 是安全的,但释放同一个非空分配两次不是。一个资源应有一个明确拥有者,拥有者负责恰好释放一次。
危害与如何发现:
- 危害:重复释放会破坏堆内存管理器的内部状态。第二次
free时,分配器会尝试操作一块已经不包含有效管理信息的区域,导致程序立即崩溃(有时是段错误,有时是free(): double free detected in tcache这样的信息)。更糟糕的是,某些环境下它不会立即崩溃,而是悄声破坏堆结构,等到后续的malloc或free时才崩,让你查不清根因。 - 如何发现:运行时会收到
free(): double free detected或类似的消息(glibc 会主动检测并报错)。-fsanitize=address也能检测。释放指针后立即将其置为NULL,可以避免同一个变量被二次释放。
内存泄漏
void leak(void) {
int *data = malloc(100 * sizeof *data);
if (data == NULL) return;
/* 忘记 free(data) */
}
函数返回后,指针变量消失了,但那块动态存储仍然被占用,并且程序已经失去地址。短命令行工具结束时系统会回收进程资源,但长时间运行的服务、循环调用或库代码会不断累积。
危害与如何发现:
- 危害:短小程序问题不大——进程退出时操作系统会收回所有内存。但对于服务器、游戏引擎、嵌入式设备等需要长时间运行的程序,泄漏会持续消耗内存,最终导致:内存耗尽、系统 OOM(Out of Memory)Killer 杀掉进程、频繁的交换分区读写(性能雪崩)、程序最终被操作系统强制终止。
- 如何发现:用
-fsanitize=address编译,程序退出时会打印泄漏报告,包含泄漏的大小和分配时的调用栈。Valgrind 的--leak-check=full也能做同样的事。运行时观察进程内存占用(Windows 任务管理器、Linuxtop/htop、ps aux中的 RSS 列)持续上涨而不回落,也是典型泄漏信号。
realloc 的正确失败路径
int *new_data = realloc(data, new_count * sizeof *data);
if (new_data == NULL) {
// 原来的 data 仍然有效,不能丢掉
free(data);
return 1;
}
data = new_data;
不要直接写:
data = realloc(data, new_size); // 失败时会覆盖唯一地址,造成泄漏
当请求大小为零时,不同标准版本和实现细节容易让代码难以阅读。想释放就直接 free,不要把 realloc(ptr, 0) 当作通用释放方式。
分配大小也会溢出
size_t bytes = count * sizeof(int);
如果 count 太大,乘法可能按 size_t 回绕,得到一个很小的结果。随后循环仍按原 count 写入,就会严重越界。
#include <stdint.h>
if (count > SIZE_MAX / sizeof(int)) {
return 1;
}
int *data = malloc(count * sizeof *data);
SIZE_MAX 是 size_t 能表示的最大值,常由 <stdint.h> 提供。如果希望不依赖该宏,也可以直接用 (size_t)-1 得到同一类型的最大值:
if (count != 0 && sizeof *data > (size_t)-1 / count) {
return 1;
}
危害与如何发现:
- 危害:乘法溢出导致申请到比预期小得多的内存,后续按原
count循环写入会造成严重的堆溢出,破坏其他对象或堆管理信息。这种漏洞同样可以被攻击者利用。 - 如何发现:
-fsanitize=address会捕获后续的越界写入。-fsanitize=undefined可以检测size_t乘法溢出。在分配前做溢出检查是更主动的防御。
返回局部变量地址
int *bad(void) {
int value = 42;
return &value;
}
函数返回后 value 的生命周期结束。这个错误与 free 后使用本质相同:指针还保存着一个数值,但对象已经不存在。
危害与如何发现:
- 危害:拿到的指针指向的是一块已经被回收的栈空间。在调用者读取之前,这块内存可能被后续的函数调用覆盖(包括
printf或其他库函数),产生随机值。更糟糕的是,如果你通过这个指针写入数据,会破坏调用链上的其他栈帧,导致程序在完全无关的位置崩溃。 - 如何发现:大多数编译器会对返回局部变量地址给出警告(开启
-Wall)。如果你在 Debug 版本中看到函数返回的指针能正确工作,但在 Release 版本中失败,这就是典型嫌疑。
未定义行为为什么危险
未定义行为不只是"结果随机"。编译器可以假定正确程序不会触发它,并据此删除分支、重排访问或推导出更强结论。因此:
- Debug 版本正常、Release 版本崩溃并不奇怪;
- 加一行打印后错误消失,不表示修好了;
- 换台机器结果不同,也不表示某台机器"兼容"。
用工具尽早发现错误
GCC 或 Clang 的学习阶段命令:
gcc demo.c -std=c11 -Wall -Wextra -Wpedantic \
-fsanitize=address,undefined -fno-omit-frame-pointer -g -o demo
- AddressSanitizer 常用于发现越界、释放后使用和重复释放,程序退出时还会报告内存泄漏。
- UndefinedBehaviorSanitizer 可发现许多整数溢出、对齐和非法操作问题。
- 警告和 Sanitizer 不能证明程序完全安全,但能显著缩短定位时间。
所有权表
对动态资源写代码前,先列清楚:
| 问题 | 示例答案 |
|---|---|
| 谁创建? | list_create |
| 谁拥有? | 返回值接收者 |
| 谁可以借用? | 遍历函数,只在调用期间 |
| 谁释放? | list_destroy |
| 释放后怎样表示? | 拥有者指针设为 NULL |
只要所有权无法用一句话说清楚,接口就值得重新设计。
内存中的数据只在当前进程生命期内有效。下一篇学习文件输入输出,让程序能够保存结果、读取配置,并处理来自外部世界的不可信数据。