3. 堆内存管理

堆内存,说白了就是程序运行时动态申请的那块地儿。跟栈上自动分配回收不同,堆上的内存你得自己管——申请、使用、释放,少一步都不行。C标准库给了我们四个函数来处理这件事:malloc、calloc、realloc 和 free。今天咱们就把它们掰开揉碎了讲清楚。

3.1 malloc:最基础的堆内存申请

malloc 的全称是 memory allocation。它的原型很简单:

void *malloc(size_t size);

传入你要的字节数,返回一个指向这块内存的指针。如果失败,返回 NULL。

我个人习惯,每次 malloc 之后都会检查返回值。你想想看,嵌入式设备上内存本来就紧张,申请失败是常有的事。不检查就直接用,解引用空指针,程序直接就崩了。

核心要点:malloc 分配的内存是未初始化的。里面的数据是随机值,可能是上次使用留下的垃圾数据。

#include <stdlib.h>

int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
    // 处理内存分配失败
    return -1;
}
// 使用 p 指向的内存
// ...
free(p);

这里有个细节:sizeof(int) 在不同平台上可能是 2 字节或 4 字节。我建议永远用 sizeof 来计算大小,别写死数字。我在项目中遇到过有人写 malloc(40),结果换了个平台就出问题了。

3.2 calloc:带初始化的内存申请

calloc 跟 malloc 很像,但有两个区别:

  1. 它接受两个参数:元素个数和每个元素的大小
  2. 它会自动把分配的内存清零
void *calloc(size_t nmemb, size_t size);

举个例子:

int *arr = (int *)calloc(10, sizeof(int));
// 此时 arr[0] 到 arr[9] 全部是 0

为什么会有 calloc?说白了就是省事。如果你需要一块初始化为零的内存,用 calloc 一步到位。用 malloc 的话你还得自己写个 memset。

小技巧:calloc 内部会做乘法 nmemb * size,如果乘积溢出,它会返回 NULL。而 malloc 不会帮你检查这个。所以分配数组时,用 calloc 更安全。

不过要注意,calloc 的清零操作是有开销的。如果你不需要零初始化,用 malloc 更快。我曾经在一个音视频处理项目里,用 calloc 分配了 100MB 的缓冲区,结果初始化花了十几毫秒,后来改成 malloc 就快多了。

3.3 realloc:动态调整内存大小

有时候你申请的内存不够用了,或者太多了想缩一缩。这时候 realloc 就派上用场了。

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

它的行为有点复杂,我总结一下:

  • 如果 ptr 是 NULL,等价于 malloc(new_size)
  • 如果 new_size 是 0,等价于 free(ptr)
  • 如果新大小比原来大,新增加的部分是未初始化的
  • 如果新大小比原来小,多余的部分会被丢弃

嗯,这里有个坑。realloc 可能会移动内存块,返回一个新的地址。原来的指针就失效了。所以一定要用返回值:

int *p = (int *)malloc(10 * sizeof(int));
// ... 使用 p ...

int *new_p = (int *)realloc(p, 20 * sizeof(int));
if (new_p == NULL) {
    // realloc 失败,原来的 p 仍然有效
    // 但要注意处理
} else {
    p = new_p;  // 更新指针
}

警告:千万不要写成 p = realloc(p, new_size)!如果 realloc 失败返回 NULL,原来的 p 就丢了,造成内存泄漏。

我在项目中遇到过这种情况:一个动态数组不断增长,每次 realloc 都复制数据,性能很差。后来我改成每次翻倍增长,减少了 realloc 调用次数。说白了就是空间换时间。

3.4 free:释放内存,别忘了我

申请了就要释放,这是铁律。free 的原型:

void free(void *ptr);

释放后,指针变成野指针。我建议立即把指针置为 NULL:

free(p);
p = NULL;

为什么要这么做?因为 free 之后 p 指向的地址已经无效了,但 p 本身的值还在。如果不置 NULL,后面不小心又用了一次 free(p),那就是 double free,程序会崩溃。

我曾经在一个通信协议栈项目里,排查了一整天的 double free 问题。最后发现是某个回调函数里释放了指针,但主循环里又释放了一次。从那以后,我养成了 free 后立即置 NULL 的习惯。

3.5 常见错误与避坑指南

堆内存管理最容易出问题的几个地方:

  • 内存泄漏:申请了没释放。程序跑久了内存越占越多,最后 OOM。
  • 野指针:释放后继续使用。这是未定义行为,可能当场崩溃,也可能过一会儿才崩。
  • 越界访问:写入了超出申请范围的数据。会破坏堆管理结构,导致 free 时崩溃。
  • 重复释放:对同一个指针 free 两次。

避坑指南:我曾经在一个嵌入式项目里,malloc 和 free 配对使用,但有个分支路径忘了 free,导致设备运行 72 小时后内存耗尽重启。排查时用了一个简单的办法:在 malloc 和 free 处加日志,统计申请和释放的次数。一对比就发现问题了。

3.6 知识体系图

下面这张图展示了堆内存管理的核心逻辑:

堆内存管理核心流程 申请内存 malloc(size) 未初始化 calloc(n, size) 已清零 realloc(ptr, size) 调整大小 使用内存 free(ptr) 选择分配方式 记得检查 NULL 释放后置 NULL

3.7 总结

堆内存管理说白了就三件事:申请、使用、释放。每个环节都有坑,但记住几条原则就能避开大部分问题:

  • malloc 后检查 NULL
  • calloc 适合需要清零的场景
  • realloc 用返回值更新指针
  • free 后立即置 NULL
  • 申请和释放要成对出现

嗯,这些经验都是我用实际加班换来的。你想想看,一个内存泄漏查三天,还不如一开始就养成好习惯。

我的建议:在嵌入式开发中,尽量少用动态内存分配。如果非用不可,一定要有配套的释放机制。我见过太多因为堆管理不当导致的死机问题了。

可可精品资料库