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 核心知识体系 calloc(nmemb, size) realloc(ptr, size) 分配 nmemb*size 字节,自动清零 ⚠ 乘法溢出风险:nmemb * size > SIZE_MAX 性能优势:可能直接获取清零物理页 原地扩容 vs 搬迁扩容 陷阱1:直接赋值导致内存泄漏 陷阱2:原地扩容假象,旧指针失效 陷阱3:偏移指针传给 realloc 扩容策略:指数增长(翻倍)

嗯,以上就是 calloc 和 realloc 的核心内容。这两个函数用好了,动态内存管理就掌握了七八成。剩下的就是多写多练,在实战中积累手感。


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