10、动态内存分配:malloc、calloc、realloc、free 的常见错误
动态内存分配,说白了就是程序运行时,跟操作系统「要一块地」来用。用完了,还得还回去。这个机制在嵌入式开发里尤其关键——你想想看,单片机的RAM就那么几K到几百K,稍不留神就踩雷了。
我刚开始做嵌入式那会儿,就吃过不少动态内存的亏。有一次调试一个数据采集模块,程序跑着跑着就死机了。查了两天,最后发现是 free 之后忘了把指针置 NULL,导致野指针访问。嗯,从那以后,我对动态内存的每个细节都格外小心。
这一章,咱们就把 malloc、calloc、realloc、free 这四个函数常见的坑,一个一个掰开揉碎了讲清楚。
10.1 malloc:最基础的分配,也是最容易出错的
malloc 负责从堆上申请一块连续内存。原型很简单:
void *malloc(size_t size);
但坑就藏在细节里。
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,整个系统直接复位。这在工业现场是不可接受的。
很多人写 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 会分配一个很小的内存,但你以为拿到了大块内存,后续写入就会越界。
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;
10.4 free:释放不是结束,是新的开始
free 把内存还给堆管理器。但释放之后,指针的值并没有变,它仍然指向那块内存(虽然已经无效了)。这就是野指针的来源。
对同一个指针调用两次 free,会导致堆管理结构损坏,程序崩溃。
int *p = (int *)malloc(sizeof(int));
free(p);
// ... 中间没有重新赋值 ...
free(p); // 未定义行为!大概率崩溃
解决办法很简单:free 之后立即把指针置为 NULL。
free(p);
p = NULL;
这样第二次 free(NULL) 是安全的——标准规定 free(NULL) 什么都不做。
只能 free 由 malloc/calloc/realloc 返回的指针。释放栈变量或全局变量是未定义行为。
int a;
int *p = &a;
free(p); // 错误!a 在栈上,不是堆内存
free 必须传入 malloc 返回的原始指针。不能释放指针的偏移版本。
int *p = (int *)malloc(10 * sizeof(int));
p++; // p 指向了第二个元素
free(p); // 错误!不是原始分配地址
10.5 嵌入式环境下的特殊注意事项
在嵌入式系统里,动态内存分配比 PC 上更危险。原因有三:
- 堆空间小: 很多 MCU 的 RAM 只有几十 KB,堆可能只有几 KB。一次分配失败就可能让系统瘫痪。
- 内存碎片: 频繁分配释放会导致堆碎片化,即使总空闲空间够,也分配不出连续的大块内存。
- 实时性要求: malloc 的实现通常不是确定性的,分配时间可能随碎片程度变化。这在实时系统中是致命的。
我曾经在一个 RTOS 项目里,因为动态分配导致任务调度抖动,最后把所有动态分配改成了静态数组 + 内存池,问题才解决。
- 尽量在初始化阶段一次性分配所有需要的内存,运行期间不再分配。
- 如果必须动态分配,使用固定大小的内存池(如 FreeRTOS 的 pvPortMalloc)。
- 每次分配后检查返回值,每次释放后置 NULL。
- 记录分配和释放的配对,避免泄漏。
10.6 总结:动态内存的黄金法则
| 操作 | 必须做的事 | 绝对不能做的事 |
|---|---|---|
| malloc/calloc | 检查返回值是否为 NULL | 不检查就直接使用 |
| realloc | 用临时变量接收返回值 | 直接赋值给原指针 |
| free | 释放后置 NULL | 重复释放、释放非堆内存 |
| 所有操作 | 确保分配和释放一一对应 | 内存泄漏、野指针 |
动态内存分配就像借书——借了要还,还了要记得把借书卡注销。做到这几点,你的程序就能在内存管理上少踩很多坑。
公众号:蓝海资料掘金营,微信deep3321