10、动态内存分配:malloc、calloc、realloc、free 的常见错误

动态内存分配,说白了就是程序运行时,跟操作系统「要一块地」来用。用完了,还得还回去。这个机制在嵌入式开发里尤其关键——你想想看,单片机的RAM就那么几K到几百K,稍不留神就踩雷了。

我刚开始做嵌入式那会儿,就吃过不少动态内存的亏。有一次调试一个数据采集模块,程序跑着跑着就死机了。查了两天,最后发现是 free 之后忘了把指针置 NULL,导致野指针访问。嗯,从那以后,我对动态内存的每个细节都格外小心。

这一章,咱们就把 malloc、calloc、realloc、free 这四个函数常见的坑,一个一个掰开揉碎了讲清楚。

动态内存分配常见错误分类 malloc calloc realloc free 未检查返回值 malloc返回NULL直接使用 内存泄漏 分配后忘记free 野指针/重复释放 free后未置NULL 后果:程序崩溃 / 内存碎片 / 安全漏洞 嵌入式系统中可能导致系统复位、数据丢失

10.1 malloc:最基础的分配,也是最容易出错的

malloc 负责从堆上申请一块连续内存。原型很简单:

void *malloc(size_t size);

但坑就藏在细节里。

常见错误1:忘记检查返回值

malloc 可能返回 NULL。尤其是在嵌入式环境,堆空间本来就小。我见过太多人直接这样写:

int *p = (int *)malloc(100 * sizeof(int));
*p = 42;  // 如果 p 是 NULL,这里直接崩溃

正确的做法是:

int *p = (int *)malloc(100 * sizeof(int));
if (p == NULL) {
    // 错误处理,比如返回错误码或进入安全模式
    return -1;
}
*p = 42;

我个人习惯在 malloc 之后立即检查 NULL,绝不拖延。你想想看,一个 NULL 指针的赋值操作,在 Cortex-M 内核上会触发 HardFault,整个系统直接复位。这在工业现场是不可接受的。

常见错误2:分配大小计算错误

很多人写 malloc 时,size 参数算错了。比如:

int *arr = (int *)malloc(100);  // 应该是 100 * sizeof(int)

这只会分配 100 字节,而不是 100 个 int。如果你后面写 arr[50] = 1,已经越界了。越界写不会立刻报错,但可能破坏堆管理结构,导致后续 free 时崩溃。

我的建议: 永远写成 malloc(n * sizeof(类型)),不要偷懒。如果类型变了,sizeof 会自动适配,减少维护成本。

10.2 calloc:初始化了,但别大意

calloc 比 malloc 多做了两件事:一是把分配的内存清零,二是参数分成了两个(数量 + 每个大小)。

void *calloc(size_t nmemb, size_t size);

它的好处是:分配出来的内存直接可用,不用手动 memset。但有个隐藏问题——乘法溢出

我曾经在项目中遇到过:

size_t count = 1000000;
size_t elem_size = 4096;
void *p = calloc(count, elem_size);  // count * elem_size 可能溢出

在 32 位系统上,size_t 是 4 字节,最大值约 4GB。但 1000000 * 4096 = 4,096,000,000,超过了 4GB,溢出后变成一个很小的值。calloc 会分配一个很小的内存,但你以为拿到了大块内存,后续写入就会越界。

避坑指南: 如果你要分配的内存块很大,建议手动检查乘法是否溢出,或者直接用 malloc + memset 自己控制。

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

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

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

它的行为分三种情况:

  • 如果当前内存块后面有足够的连续空间,直接扩展,返回原指针。
  • 如果后面空间不够,会重新找一块更大的内存,把旧数据拷贝过去,然后释放旧内存。
  • 如果分配失败,返回 NULL,但原来的内存块仍然有效。

这里有个经典错误:

int *p = (int *)malloc(10 * sizeof(int));
// ... 使用 p ...
p = (int *)realloc(p, 20 * sizeof(int));  // 危险!

如果 realloc 失败,返回 NULL,那么原来的 p 就丢了——既无法访问旧数据,也无法 free 它,造成内存泄漏。

正确的写法是:

int *new_p = (int *)realloc(p, 20 * sizeof(int));
if (new_p == NULL) {
    // 错误处理,p 仍然有效,可以继续使用或释放
    free(p);
    return -1;
}
p = new_p;
我个人习惯: 永远用一个临时指针接收 realloc 的返回值。确认成功后再赋值给原指针。这个习惯帮我避免了好几次线上事故。

10.4 free:释放不是结束,是新的开始

free 把内存还给堆管理器。但释放之后,指针的值并没有变,它仍然指向那块内存(虽然已经无效了)。这就是野指针的来源。

常见错误1:重复释放(double free)

对同一个指针调用两次 free,会导致堆管理结构损坏,程序崩溃。

int *p = (int *)malloc(sizeof(int));
free(p);
// ... 中间没有重新赋值 ...
free(p);  // 未定义行为!大概率崩溃

解决办法很简单:free 之后立即把指针置为 NULL。

free(p);
p = NULL;

这样第二次 free(NULL) 是安全的——标准规定 free(NULL) 什么都不做。

常见错误2:释放栈上的地址

只能 free 由 malloc/calloc/realloc 返回的指针。释放栈变量或全局变量是未定义行为。

int a;
int *p = &a;
free(p);  // 错误!a 在栈上,不是堆内存
常见错误3:释放部分内存

free 必须传入 malloc 返回的原始指针。不能释放指针的偏移版本。

int *p = (int *)malloc(10 * sizeof(int));
p++;  // p 指向了第二个元素
free(p);  // 错误!不是原始分配地址

10.5 嵌入式环境下的特殊注意事项

在嵌入式系统里,动态内存分配比 PC 上更危险。原因有三:

  • 堆空间小: 很多 MCU 的 RAM 只有几十 KB,堆可能只有几 KB。一次分配失败就可能让系统瘫痪。
  • 内存碎片: 频繁分配释放会导致堆碎片化,即使总空闲空间够,也分配不出连续的大块内存。
  • 实时性要求: malloc 的实现通常不是确定性的,分配时间可能随碎片程度变化。这在实时系统中是致命的。

我曾经在一个 RTOS 项目里,因为动态分配导致任务调度抖动,最后把所有动态分配改成了静态数组 + 内存池,问题才解决。

嵌入式避坑建议:
  1. 尽量在初始化阶段一次性分配所有需要的内存,运行期间不再分配。
  2. 如果必须动态分配,使用固定大小的内存池(如 FreeRTOS 的 pvPortMalloc)。
  3. 每次分配后检查返回值,每次释放后置 NULL。
  4. 记录分配和释放的配对,避免泄漏。

10.6 总结:动态内存的黄金法则

操作 必须做的事 绝对不能做的事
malloc/calloc 检查返回值是否为 NULL 不检查就直接使用
realloc 用临时变量接收返回值 直接赋值给原指针
free 释放后置 NULL 重复释放、释放非堆内存
所有操作 确保分配和释放一一对应 内存泄漏、野指针

动态内存分配就像借书——借了要还,还了要记得把借书卡注销。做到这几点,你的程序就能在内存管理上少踩很多坑。

最后说一句: 如果你在写嵌入式代码,能用静态分配就别用动态分配。静态分配虽然不够灵活,但它确定、可靠、不会碎片化。在资源受限的系统里,确定性比灵活性更重要。

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