11、内存管理:malloc/calloc/realloc、free与内存泄漏、Valgrind检测、内存池设计
内存管理,是C语言里绕不开的一道坎。
说实话,我见过太多人栽在这上面。写Java写Python写习惯了,觉得内存是无限取用的自来水。但C不一样——你申请了,就得还。不还?程序迟早崩给你看。
这一章,咱们就把内存管理这摊事彻底捋清楚。
11.1 malloc、calloc、realloc——三种申请方式
先说说最基础的三个函数。它们都声明在 <stdlib.h> 里,功能都是向堆区要内存,但用法和细节有区别。
| 函数 | 原型 | 特点 |
|---|---|---|
malloc |
void *malloc(size_t size) |
分配 size 字节,内容随机(未初始化) |
calloc |
void *calloc(size_t nmemb, size_t size) |
分配 nmemb * size 字节,并全部清零 |
realloc |
void *realloc(void *ptr, size_t size) |
调整已分配内存的大小,保留原有数据 |
malloc 是最常用的。但有个坑——它不初始化内存。你拿到的是一块"脏"内存,里面可能残留着别的程序用过的数据。我早期写网络协议栈时,就因为这个踩过雷:malloc 出来的缓冲区没清零,结果解析报文时读到了垃圾数据,排查了整整一下午。
calloc 的好处是自动清零。如果你需要分配数组或结构体,并且希望初始值都是0,用 calloc 更安全。不过它比 malloc 稍慢一点点,因为多了一步清零操作。但说实话,这点性能损失在绝大多数场景下可以忽略。
realloc 是个灵活的工具。它可以扩大或缩小已有的内存块。如果当前内存块后面有足够的空闲空间,realloc 就直接在原地扩展;如果不够,它会重新找一块连续空间,把旧数据拷贝过去,然后释放旧内存。
ptr = realloc(ptr, new_size); —— 如果 realloc 失败返回 NULL,原来的 ptr 就丢了,造成内存泄漏。正确做法是用临时变量接返回值。
// 正确的 realloc 用法
void *new_ptr = realloc(old_ptr, new_size);
if (new_ptr == NULL) {
// 处理失败,old_ptr 仍然有效
fprintf(stderr, "realloc failed\n");
} else {
old_ptr = new_ptr; // 更新指针
}
11.2 free 与内存泄漏
有借有还,再借不难。free 就是那个"还"的动作。
但现实是,很多人只借不还。或者还错了地方。或者还了之后又去用。
内存泄漏,说白了就是你 malloc 了一块内存,用完之后忘了 free。程序跑得越久,泄漏的内存越多,最终把堆空间吃光,程序崩溃。
我参与过一个嵌入式项目,设备运行两周后必死机。查了三天,发现是一个循环里每次都要 malloc 一个临时缓冲区,但只在正常路径下 free,异常路径直接跳过了。两周时间,泄漏了上万个缓冲区,堆空间彻底耗尽。
常见的泄漏场景:
- 函数内 malloc,但调用方忘了 free
- 异常分支或错误处理路径遗漏了 free
- 结构体嵌套时,只 free 了外层,内层指针指向的内存没释放
- realloc 失败后直接覆盖原指针,导致原内存无法释放
11.3 Valgrind 检测——让泄漏无处遁形
手动排查内存泄漏,就像大海捞针。好在有 Valgrind 这个神器。
Valgrind 是一个动态分析工具,它能监控程序的内存操作,检测出:
- 未初始化的内存读取
- 越界访问(数组下标超了)
- 内存泄漏(malloc 了没 free)
- 重复释放(double free)
- 使用已释放的内存(use-after-free)
用法很简单:
gcc -g -o my_program my_program.c # 编译时加 -g 保留调试信息
valgrind --leak-check=full ./my_program
Valgrind 会输出类似这样的信息:
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2B0E0: malloc (vg_replace_malloc.c:299)
==12345== by 0x4005E4: main (test.c:10)
它告诉你:第10行 malloc 了40个字节,没释放。直接定位到代码行,省去了你逐行翻代码的功夫。
我个人的工作流是这样的:写完一个模块,先用 Valgrind 跑一遍单元测试。如果报告泄漏,修掉。再跑。直到 Valgrind 报告 "All heap blocks were freed — no leaks are possible"。这时候我才敢说这个模块的内存管理是干净的。
11.4 内存池设计——从源头解决问题
频繁 malloc/free 有两个问题:一是慢,二是碎片化。每次 malloc 都要走系统调用,还要在堆里找合适大小的空闲块。时间长了,堆里到处都是小碎片,明明总空闲空间够,但就是分配不出一块连续的大内存。
内存池的思路很简单:一次性向系统申请一大块内存,然后自己管理这块内存的分配和回收。说白了,就是自己当"二房东"。
一个典型的内存池设计:
#include <stdlib.h>
#include <string.h>
#define POOL_SIZE 1024 * 1024 // 1MB
typedef struct {
char *pool; // 内存池起始地址
size_t offset; // 当前分配偏移量
size_t size; // 池总大小
} MemoryPool;
// 初始化内存池
MemoryPool* pool_create(size_t size) {
MemoryPool *p = (MemoryPool*)malloc(sizeof(MemoryPool));
if (!p) return NULL;
p->pool = (char*)malloc(size);
if (!p->pool) {
free(p);
return NULL;
}
p->size = size;
p->offset = 0;
return p;
}
// 从池中分配内存
void* pool_alloc(MemoryPool *p, size_t size) {
// 对齐到 8 字节边界(很多硬件要求)
size = (size + 7) & ~7;
if (p->offset + size > p->size) {
return NULL; // 池空间不足
}
void *ptr = p->pool + p->offset;
p->offset += size;
return ptr;
}
// 重置内存池(不释放,只是重置偏移量)
void pool_reset(MemoryPool *p) {
p->offset = 0;
}
// 销毁内存池
void pool_destroy(MemoryPool *p) {
if (p) {
free(p->pool);
free(p);
}
}
这个实现很简单,但已经能解决大部分问题了。它只支持顺序分配,不支持单独释放某个块——要释放就整个池一起重置。这种模式特别适合"一次性处理一批请求,处理完统一清理"的场景。
如果你需要更灵活的内存池——比如支持任意顺序的分配和释放——那就得用空闲链表了。每个空闲块头部存一个 next 指针,释放时把块插回链表。这种实现更复杂,但灵活性更高。
11.5 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
从图中可以看到,malloc/calloc/realloc 是入口,free 是出口,Valgrind 是检查工具,而内存池则是一种更高级的管理策略——它把"申请-释放"的粒度从单个对象提升到整个池子,从根源上减少了碎片和泄漏的可能。
嗯,内存管理这块,说白了就是"谁申请谁释放,申请多少释放多少"。规则简单,但执行起来需要纪律。我见过太多人栽在"我以为我释放了"或者"这里应该不会走到"这种侥幸心理上。
养成好习惯:每次写 malloc,立刻写 free。每个模块写完,用 Valgrind 扫一遍。复杂项目,考虑用内存池。做到这三点,内存问题基本就远离你了。