8. 动态内存管理安全:malloc/calloc/realloc/free的正确使用
动态内存管理,说白了就是C语言里最让人又爱又恨的东西。爱它是因为灵活,恨它是因为一旦出错,bug能让你调试到怀疑人生。我做了十几年嵌入式安全开发,可以负责任地告诉你:内存相关的漏洞,占了C程序安全漏洞的半壁江山。
今天我们就来聊聊malloc、calloc、realloc和free的正确用法,以及那些常见的坑——内存泄漏、双重释放、Use-After-Free。嗯,这些名字你肯定听过,但真正能完全避开的,说实话不多。
核心原则:谁分配,谁释放;释放后立即置空;不要访问已释放的内存。
8.1 malloc:最基础的分配器
malloc 从堆上分配一块连续内存,返回 void*。用法很简单,但坑不少。
// 正确写法
int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
// 处理分配失败
return -1;
}
// 使用 p...
free(p);
p = NULL; // 释放后置空,防止悬空指针
我个人习惯是:每次 malloc 后面紧跟 NULL 检查。别嫌麻烦,我在项目中遇到过因为忘记检查返回值,结果在内存耗尽时程序直接崩溃的情况。那次是在一个嵌入式设备上,跑了三天才复现,调试过程简直噩梦。
小技巧:malloc(0) 的行为是未定义的,不同平台表现不同。别这么写,没意义。
8.2 calloc:带初始化的分配
calloc 和 malloc 的区别在于:它会将分配的内存全部清零。适合分配数组或结构体。
// 分配并初始化为0
int *arr = (int *)calloc(10, sizeof(int));
if (arr == NULL) {
return -1;
}
// arr[0] 到 arr[9] 都是 0
你想想看,如果你用 malloc 分配了一个结构体,里面有个指针成员没初始化,后面直接拿来用——嗯,这就是典型的未初始化内存访问漏洞。calloc 能帮你避免这类问题。
注意:calloc 的零初始化只保证所有位为0,对于指针来说就是 NULL,但对于浮点数,全0位不一定等于 0.0(虽然大多数平台是)。
8.3 realloc:调整内存大小
realloc 可以扩大或缩小已分配的内存块。它可能会移动内存,所以原来的指针会失效。
int *p = (int *)malloc(5 * sizeof(int));
if (p == NULL) return -1;
// 扩大到10个int
int *tmp = (int *)realloc(p, 10 * sizeof(int));
if (tmp == NULL) {
// realloc 失败,原内存还在,不要覆盖 p
free(p);
p = NULL;
return -1;
}
p = tmp; // 成功后才赋值
这里有个经典错误:p = realloc(p, new_size);。如果 realloc 失败返回 NULL,原来的 p 就丢了——内存泄漏。我见过太多人这么写了,包括一些号称有五年经验的工程师。
黄金法则:永远不要直接用原指针接收 realloc 的返回值。先用临时变量,检查成功后再赋值。
8.4 free:释放的艺术
释放内存看似简单,但双重释放和 Use-After-Free 都跟它有关。
int *p = (int *)malloc(10 * sizeof(int));
// 使用...
free(p);
p = NULL; // 关键一步
// 再次 free(p) 是安全的,因为 p 是 NULL
// free(NULL) 是标准允许的,什么也不做
我曾经在一个多线程项目中,两个线程同时释放同一个指针——双重释放。堆管理器直接崩溃,程序挂掉。从那以后,我养成了释放后立即置 NULL 的习惯,而且要求团队所有人都这么做。
8.5 三大内存漏洞详解
8.5.1 内存泄漏
分配了内存,但忘记释放,或者丢失了指针导致无法释放。长期运行的程序(比如服务器、嵌入式设备)会慢慢耗尽内存。
void leak_example() {
int *p = (int *)malloc(100 * sizeof(int));
// 忘记 free(p)
// 函数返回后,p 丢失,内存永远无法释放
}
// 正确的做法
void no_leak() {
int *p = (int *)malloc(100 * sizeof(int));
if (p == NULL) return;
// 使用...
free(p);
p = NULL;
}
检测工具:Valgrind 是 Linux 下检测内存泄漏的神器。Windows 下可以用 Visual Studio 的 CRT 调试堆。别问我怎么知道的——我靠 Valgrind 救过无数次场。
8.5.2 双重释放
对同一块内存调用两次 free。后果是堆结构被破坏,程序可能崩溃,也可能被攻击者利用。
int *p = (int *)malloc(10 * sizeof(int));
free(p);
// 中间有其他代码...
free(p); // 双重释放!未定义行为
为什么释放后置 NULL 能解决?因为 free(NULL) 是安全的,标准保证它什么都不做。所以养成习惯:free(p); p = NULL;
8.5.3 Use-After-Free
释放内存后,仍然通过原来的指针访问它。这是最危险的漏洞之一,常被用于 exploit。
int *p = (int *)malloc(10 * sizeof(int));
free(p);
// p 现在是悬空指针
*p = 42; // Use-After-Free!未定义行为
释放后,那块内存可能已经被重新分配给其他用途。你写入的数据可能破坏其他对象的数据,或者被攻击者用来执行任意代码。
严重警告:Use-After-Free 是 CVE 漏洞库中的常客。很多浏览器、内核的远程代码执行漏洞都源于此。这不是小问题。
8.6 避坑指南与最佳实践
| 场景 | 推荐做法 | 常见错误 |
|---|---|---|
| 分配内存 | 检查返回值是否为 NULL | 直接使用不检查 |
| 调整大小 | 用临时变量接收 realloc | p = realloc(p, ...) |
| 释放内存 | free 后立即置 NULL | 忘记置空 |
| 多线程 | 加锁保护分配/释放操作 | 无保护并发操作 |
| 复杂数据结构 | 编写专门的释放函数 | 散落在各处手动释放 |
我的个人清单:
- 每次 malloc/calloc/realloc 后,立即写对应的 free
- 释放后指针置 NULL
- 函数返回前,确保所有分配的内存都已释放
- 使用静态分析工具(如 Coverity、Clang Static Analyzer)
- 定期用 Valgrind 跑测试
说实话,动态内存管理没有银弹。你只能靠良好的编码习惯和工具辅助来减少错误。我见过太多项目因为内存问题延期、崩溃甚至被攻破。嗯,希望今天的分享能帮你少踩几个坑。
最后一句:如果你在写库代码或者内核模块,考虑使用内存池或引用计数来管理生命周期。这些高级话题我们后面再聊。
公众号:蓝海资料掘金营,微信deep3321