10、动态内存管理:malloc/calloc/realloc 的使用错误、内存泄漏、双重释放、释放后使用(UAF)

动态内存管理,说白了就是 C 语言里最让人又爱又恨的东西。爱它,是因为没有它,很多程序根本写不出来;恨它,是因为一旦用错,bug 能让你调试到怀疑人生。我做了十几年嵌入式开发,敢说至少一半的线上崩溃,根因都在堆内存上。

今天咱们就把这块硬骨头啃下来。我会把常见的陷阱一个个拆开,配上我踩过的坑,希望能帮你少走弯路。

10.1 malloc、calloc、realloc 的正确用法

这三个函数是动态内存的基石。但很多人用了一辈子,也没用对。

10.1.1 malloc:最基础,也最容易出错

malloc 分配一块连续内存,返回 void*。原型很简单:

void *malloc(size_t size);

但这里有个坑——它不会初始化内存。分配出来的内存里是随机值,可能是 0,也可能是 0xDEADBEEF。我见过有人直接拿 malloc 出来的内存当零初始化用,结果程序跑起来时好时坏,查了两天才发现是这里的问题。

警告:malloc 返回的内存内容是未定义的。不要假设它是 0!

正确的做法是:

int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
    // 处理分配失败
    return -1;
}
// 手动初始化
memset(p, 0, 10 * sizeof(int));

10.1.2 calloc:自动清零,但小心性能

callocmalloc 的区别在于:它会自动把内存清零。原型:

void *calloc(size_t nmemb, size_t size);

我个人习惯在分配数组或结构体时优先用 calloc。为什么?因为清零能避免很多“野指针”和“未初始化”的问题。尤其是嵌入式环境里,内存残留数据可能来自上一次任务,不清零的话后果很严重。

但要注意:callocmalloc 慢,因为它多了一步清零操作。如果你分配的内存马上会被覆盖,用 malloc 更合适。

10.1.3 realloc:扩容有风险,使用需谨慎

realloc 用来调整已分配内存的大小。原型:

void *realloc(void *ptr, size_t new_size);

这里有个经典陷阱:realloc 失败会返回 NULL,但原来的内存不会被释放。很多人这样写:

p = (int *)realloc(p, new_size * sizeof(int));

如果 realloc 失败,p 变成 NULL,原来的内存就泄漏了!正确的写法是:

int *new_p = (int *)realloc(p, new_size * sizeof(int));
if (new_p == NULL) {
    // 处理失败,但 p 仍然有效
    free(p);
    return -1;
}
p = new_p;
提示:realloc 可能会移动内存块,所以原来的指针会失效。一定要用返回值更新指针。

10.2 内存泄漏:看不见的杀手

内存泄漏,说白了就是“借了不还”。你 malloc 了一块内存,用完了没 free,这块内存就再也找不回来了。程序跑得越久,泄漏越多,最后系统崩溃。

我在项目中遇到过最离谱的一次:一个通信模块,每次收到消息都 malloc 一个缓冲区,但处理完忘了 free。测试时跑几分钟没问题,上线后跑了三天,系统内存耗尽,直接死机。查日志才发现,内存占用曲线一直在缓慢上升。

避免内存泄漏的几个原则:

  • 谁分配,谁释放:模块内部 malloc 的内存,由模块内部 free。
  • 成对出现:写 malloc 的时候,就把对应的 free 写好。
  • 使用工具:Valgrind、AddressSanitizer 都能帮你检测泄漏。
核心原则:每一条 malloc/calloc/realloc 路径,都必须有对应的 free 路径。

10.3 双重释放:崩溃的元凶

双重释放,就是同一块内存被 free 了两次。后果很严重——轻则程序崩溃,重则被攻击者利用。

为什么会发生双重释放?最常见的原因是:多个指针指向同一块内存,然后各自 free 了一次。

int *p = (int *)malloc(sizeof(int));
int *q = p;
free(p);
free(q);  // 双重释放!

解决方法是:free 之后立即把指针置为 NULL。这样第二次 free 时,free(NULL) 是安全的。

free(p);
p = NULL;
free(q);  // q 还是指向原来的地址,但 p 已经是 NULL 了
// 不过 q 仍然有问题,所以最好也置 NULL
警告:free 后不置 NULL,是双重释放的头号原因。养成习惯,free 完立刻置 NULL。

10.4 释放后使用(UAF):最隐蔽的 bug

UAF,全称 Use-After-Free。就是内存已经 free 了,但你还通过原来的指针去读写它。这是最隐蔽、最难调试的 bug 之一。

为什么难?因为 free 之后,那块内存可能已经被系统回收,也可能还在原地。你读写它,有时候能成功,有时候会崩溃,完全看运气。我见过一个 UAF 的 bug,在开发机上跑了一周都没复现,上线后客户那边一天崩三次。

典型的 UAF 场景:

int *p = (int *)malloc(sizeof(int));
*p = 42;
free(p);
// 这里 p 变成了悬空指针
*p = 100;  // UAF!

避免 UAF 的方法:

  • free 后立即置 NULL:这样后续的读写会立即崩溃,而不是随机崩溃。
  • 使用智能指针:C 语言没有,但可以自己封装引用计数。
  • 静态分析工具:Coverity、Clang Static Analyzer 能检测出部分 UAF。
我的经验:UAF 一旦出现,不要靠“读代码”来找。用 AddressSanitizer 跑一遍,它能在崩溃时精确告诉你哪行代码访问了已释放的内存。

10.5 知识体系图

下面这张图总结了动态内存管理的核心陷阱和规避方法。你可以把它当作一张“避坑地图”。

动态内存管理核心陷阱与规避 malloc calloc realloc 陷阱1:内存泄漏 malloc 后忘记 free 程序越跑越慢,最终崩溃 陷阱2:双重释放 同一块内存 free 两次 立即崩溃或安全漏洞 陷阱3:释放后使用 (UAF) free 后继续读写指针 最隐蔽、最难调试 陷阱4:realloc 误用 失败时覆盖原指针 导致内存泄漏 规避方法:free 后置 NULL | 谁分配谁释放 | 使用 Valgrind / AddressSanitizer | 静态分析

10.6 总结

动态内存管理,说白了就是三个字:责任心。你分配了,就要负责释放;你释放了,就要忘记它。别偷懒,别侥幸。

最后送你一句话:malloc 和 free 是夫妻,别让它们离婚。

避坑指南:我曾经在一个项目中,因为一个 UAF 的 bug 连续加班三天。后来我养成了一个习惯——每次 free 完,立刻在下一行写 p = NULL。这个习惯救了我无数次。

公众号:蓝海资料掘金营,微信 deep3321