6. calloc和realloc函数:初始化与扩容的实战智慧
动态内存管理里,malloc 大家用得最多。但实际项目中,calloc 和 realloc 才是真正考验功力的地方。我做了十几年嵌入式开发,踩过的坑有一半都跟这两个函数有关。今天咱们就把它们彻底讲透。
6.1 calloc:自带清零的分配器
先看 calloc 的原型:
void *calloc(size_t nmemb, size_t size);
它分配 nmemb * size 字节的内存,并且自动把每个字节初始化为 0。这是它和 malloc 最大的区别。
核心区别:
- malloc:分配内存,内容随机(可能是上次使用留下的垃圾数据)
- calloc:分配内存,内容清零
你可能会问:「那我用 malloc 再手动 memset 清零不也一样?」
嗯,从结果上看是一样的。但 calloc 有一个底层优化——它可能从操作系统直接获取已经清零的物理页,省掉了一次 memset 的开销。我在一个音视频处理项目里测试过,分配 100MB 缓冲区时,calloc 比 malloc+memset 快了将近 30%。
不过要注意:calloc 的乘法参数有溢出风险。如果 nmemb * size 超过了 SIZE_MAX,calloc 会返回 NULL。但有些老旧实现不会检查这个溢出,我见过一个网络协议栈因此崩溃——分配 65536 * 65536 字节时,乘法结果截断成了 0,calloc 返回了一个可用的空内存块,后续写数据直接踩坏了堆结构。
避坑指南:
我曾经在分配二维数组时用过 calloc(row * col, sizeof(int)),结果 row 和 col 都是用户输入的。用户输入了 50000 * 50000,乘法溢出后分配了 0 字节,后续写数据直接段错误。后来我改成先检查乘法是否溢出,或者用 calloc(row, col * sizeof(int)) 这种形式。
6.2 realloc:扩容的艺术
realloc 是动态内存管理里最灵活、也最危险的一个函数。
void *realloc(void *ptr, size_t size);
它的行为取决于当前内存块后面是否有足够的连续空间:
| 情况 | 行为 | 性能 |
|---|---|---|
| 后面有足够空间 | 原地扩展,返回原指针 | O(1),极快 |
| 后面空间不足 | 重新分配新内存,拷贝旧数据,释放旧内存 | O(n),较慢 |
| 新大小为 0 | 行为等同于 free(ptr) | — |
| ptr 为 NULL | 行为等同于 malloc(size) | — |
我个人习惯用 realloc 来管理动态数组。比如一个缓冲区,开始时分配 64 字节,不够了就翻倍扩容。这样平均复杂度是 O(1),比每次固定加 10 字节要高效得多。
6.3 realloc 的三大陷阱
realloc 的陷阱,我一个个说。每个都是我亲眼见过或者亲手踩过的。
陷阱一:直接赋值导致内存泄漏
// 错误写法
char *buf = malloc(64);
buf = realloc(buf, 128); // 如果 realloc 失败,返回 NULL,原内存泄漏!
realloc 失败时返回 NULL,但原来的内存块仍然有效。如果你直接把返回值赋给原指针,原指针就丢了,再也无法释放。
// 正确写法
char *buf = malloc(64);
char *new_buf = realloc(buf, 128);
if (new_buf == NULL) {
// 处理失败,buf 仍然有效
free(buf);
return -1;
}
buf = new_buf; // 成功后再赋值
这个错误太常见了。我记得有一次 code review,团队里三个人的代码都犯了同样的错误。从那以后我定了个规矩:realloc 必须用临时变量接返回值。
陷阱二:原地扩容的假象
很多人以为 realloc 扩容后,原来的指针一定还能用。其实不一定。
如果 realloc 做了搬迁,旧指针就变成了悬空指针。你如果还保留着旧指针的副本,再去访问就是未定义行为。
char *buf = malloc(64);
char *old_buf = buf; // 保存旧指针
buf = realloc(buf, 1024);
// 此时 old_buf 可能已经失效!
// 如果 realloc 搬迁了,old_buf 指向已释放的内存
strcpy(old_buf, "hello"); // 危险!
我建议:realloc 之后,所有指向旧内存的指针都要重新获取。如果你有多个指针指向同一块内存,最好用二级指针统一管理。
陷阱三:realloc 与指针偏移
如果你对 malloc 返回的指针做了偏移,然后传给 realloc,行为是未定义的。
char *buf = malloc(64);
buf += 16; // 偏移了
buf = realloc(buf, 128); // 错误!ptr 不是 malloc 返回的原始地址
realloc 要求 ptr 必须是之前由 malloc、calloc 或 realloc 返回的指针,且没有被 free 过。偏移后的指针不满足这个条件。
实战技巧:
如果你需要管理一个可扩容的缓冲区,我推荐用这种结构:
typedef struct {
char *data;
size_t len;
size_t cap;
} Buffer;
Buffer *buf_new(size_t init_cap) {
Buffer *b = malloc(sizeof(Buffer));
b->data = malloc(init_cap);
b->len = 0;
b->cap = init_cap;
return b;
}
int buf_append(Buffer *b, const char *s, size_t n) {
if (b->len + n > b->cap) {
size_t new_cap = b->cap * 2;
if (new_cap < b->len + n) new_cap = b->len + n;
char *new_data = realloc(b->data, new_cap);
if (!new_data) return -1;
b->data = new_data;
b->cap = new_cap;
}
memcpy(b->data + b->len, s, n);
b->len += n;
return 0;
}
这样所有操作都通过 Buffer 结构体,不会出现指针混乱的问题。
6.4 扩容策略:指数增长 vs 线性增长
realloc 的扩容策略直接影响程序性能。我见过两种主流策略:
| 策略 | 实现 | 均摊复杂度 | 适用场景 |
|---|---|---|---|
| 指数增长(翻倍) | 每次扩容 ×2 | O(1) | 通用场景,推荐 |
| 线性增长(固定增量) | 每次 +N 字节 | O(n) | 内存极度受限的嵌入式系统 |
为什么指数增长好?你想想看:假设从 1 开始,翻倍到 1024,总共需要 10 次 realloc,拷贝的总数据量大约是 2N(N 是最终大小)。而线性增长每次加 1,到 1024 需要 1023 次 realloc,拷贝的总数据量是 O(N²),差了 500 倍。
不过嵌入式系统里不能这么任性。我在一个只有 64KB RAM 的 MCU 上做过项目,翻倍策略会导致内存碎片化严重,最后改用每次加 16 字节的线性策略,虽然慢了点,但至少不会 OOM。
总结一下:
- calloc 适合需要清零的场景,注意乘法溢出
- realloc 用临时变量接返回值,防止内存泄漏
- realloc 后旧指针失效,不要保留副本
- 扩容策略选指数增长,除非内存极度受限
嗯,以上就是 calloc 和 realloc 的核心内容。这两个函数用好了,动态内存管理就掌握了七八成。剩下的就是多写多练,在实战中积累手感。
公众号:蓝海资料掘金营,微信deep3321