15、指针与动态内存分配:malloc/calloc/realloc/free、动态数组的实现、内存泄漏的避免
动态内存分配,说白了就是让程序在运行的时候,跟操作系统“要”一块内存来用。这跟咱们平时定义变量不一样——变量的大小是编译时就定死的,而动态分配的内存,大小可以在运行时才决定。
我刚开始学C语言那会儿,总觉得静态数组够用了。直到有一次做嵌入式项目,需要处理不同长度的数据包,才真正体会到动态分配的威力。嗯,咱们今天就把这块彻底讲透。
15.1 为什么需要动态内存分配?
你想想看,写程序的时候,很多时候你根本不知道用户会输入多少数据。比如一个文本编辑器,用户可能打开一个1KB的文件,也可能打开一个1GB的文件。如果用静态数组,你只能按最大可能去申请——这太浪费了。
动态内存分配解决了三个核心问题:
- 大小不确定:运行时才知道需要多少内存
- 生命周期灵活:可以手动控制内存的创建和销毁
- 内存复用:用完就释放,给别的模块用
在嵌入式系统里,内存是稀缺资源。我见过不少同事因为滥用动态分配,导致系统跑着跑着就崩了。所以,掌握好这块,是每个C程序员的必修课。
15.2 四大内存管理函数
C标准库提供了四个核心函数,都在 <stdlib.h> 里。咱们一个一个说。
15.2.1 malloc —— 最常用的分配方式
malloc 的全称是 memory allocation。它的原型很简单:
void* malloc(size_t size);
它分配 size 个字节的连续内存,返回一个指向这块内存的指针。注意,它不会初始化这块内存——里面的数据是随机的。
我个人习惯,拿到 malloc 返回的指针后,第一时间用 memset 清零,或者直接用 calloc。为什么?因为我在项目中遇到过,读取未初始化的内存,导致程序行为随机——这种bug最难查。
核心用法示例:
int *p = (int*)malloc(10 * sizeof(int));
if (p == NULL) {
// 处理内存分配失败
fprintf(stderr, "内存分配失败\n");
exit(1);
}
// 使用内存...
free(p); // 别忘了释放
15.2.2 calloc —— 带清零的分配
calloc 和 malloc 的区别在于两点:
- 它会自动把分配的内存全部清零
- 它接受两个参数:元素个数和每个元素的大小
void* calloc(size_t num, size_t size);
比如分配10个int:
int *p = (int*)calloc(10, sizeof(int));
// 此时 p[0] 到 p[9] 全部是 0
我建议:如果你需要初始化为0的场景,直接用 calloc。它比 malloc + memset 更安全,而且有些实现上 calloc 还能利用操作系统的零页优化,性能反而更好。
15.2.3 realloc —— 调整已分配内存的大小
这个函数很实用。比如你一开始分配了100个字节,后来发现不够用,想扩展到200个字节。这时候就用 realloc。
void* realloc(void* ptr, size_t new_size);
它的行为有点复杂,我总结一下:
- 如果
ptr是 NULL,等价于 malloc - 如果
new_size是 0,等价于 free - 如果扩展内存,原有数据保持不变,新增部分不初始化
- 如果缩小内存,尾部数据被丢弃
- 如果无法在原地扩展,会分配新内存,拷贝旧数据,然后释放旧内存
重要陷阱:千万不要这样写:
p = (int*)realloc(p, new_size * sizeof(int)); // 危险!
如果 realloc 失败,返回 NULL,原来的 p 就丢了——内存泄漏!
正确做法:
int *new_p = (int*)realloc(p, new_size * sizeof(int));
if (new_p == NULL) {
// 处理失败,但 p 仍然有效
free(p);
exit(1);
}
p = new_p;
15.2.4 free —— 释放内存
有借有还,再借不难。动态分配的内存,用完之后必须用 free 归还给系统。
void free(void* ptr);
关于 free,有几点必须记住:
- 只能 free 动态分配的内存(malloc/calloc/realloc 返回的)
- 不能 free 同一块内存两次(double free 是未定义行为)
- free 之后,指针变成野指针,建议立即置为 NULL
我的习惯:
free(p);
p = NULL; // 避免野指针
这样即使不小心再次 free,free(NULL) 是安全的。
15.3 动态数组的实现
动态数组,说白了就是可以在运行时改变大小的数组。咱们用 malloc + realloc 就能实现一个简单的版本。
我曾经在一个数据采集项目中,需要实时存储传感器数据。数据量不确定,用静态数组要么浪费要么不够。动态数组完美解决了这个问题。
15.3.1 基本结构
typedef struct {
int *data; // 数据指针
size_t size; // 当前元素个数
size_t capacity; // 当前容量
} DynamicArray;
15.3.2 初始化与销毁
// 初始化
void da_init(DynamicArray *da, size_t initial_capacity) {
da->data = (int*)malloc(initial_capacity * sizeof(int));
da->size = 0;
da->capacity = initial_capacity;
}
// 销毁
void da_destroy(DynamicArray *da) {
free(da->data);
da->data = NULL;
da->size = 0;
da->capacity = 0;
}
15.3.3 添加元素(自动扩容)
void da_append(DynamicArray *da, int value) {
if (da->size >= da->capacity) {
// 容量翻倍,避免频繁 realloc
size_t new_capacity = da->capacity * 2;
int *new_data = (int*)realloc(da->data, new_capacity * sizeof(int));
if (new_data == NULL) {
fprintf(stderr, "扩容失败\n");
exit(1);
}
da->data = new_data;
da->capacity = new_capacity;
}
da->data[da->size++] = value;
}
扩容策略:每次容量不够时,翻倍扩容。这样平均每次插入的时间复杂度是 O(1)。如果每次只加1个元素,那 realloc 会被频繁调用,性能极差。
15.4 内存泄漏的避免
内存泄漏,就是申请了内存但没释放,导致系统可用内存越来越少。在嵌入式系统里,跑个几天几夜,内存泄漏会导致系统崩溃——我吃过这个亏。
我曾经维护过一个老项目,设备运行72小时后必死。查了三天,发现是一个循环里每次 malloc 但只在特定条件下 free。条件不满足时,内存就漏了。修复后,设备稳定运行了半年。
15.4.1 常见的内存泄漏场景
| 场景 | 说明 | 解决方案 |
|---|---|---|
| 忘记 free | malloc 后没有对应的 free | 写代码时成对出现,立即补上 free |
| 丢失指针 | 指针被重新赋值,原内存无法访问 | 使用临时变量保存原指针 |
| 异常路径未释放 | 函数提前 return 或抛出异常 | 使用 goto cleanup 模式 |
| 容器未释放元素 | 释放了容器本身,但没释放里面的元素 | 遍历释放每个元素 |
15.4.2 最佳实践
我总结了几条铁律,照着做基本不会漏:
- 谁分配,谁释放:模块A malloc 的内存,由模块A free,不要跨模块
- 成对出现:写 malloc 的同时,立刻写对应的 free 注释
- 初始化指针为 NULL:避免野指针
- free 后置 NULL:防止 double free
- 使用工具检测:Valgrind、AddressSanitizer 都是好帮手
嵌入式环境特别提醒:
在资源受限的MCU上,我建议尽量少用动态分配。如果非用不可,考虑使用静态内存池——预先分配一大块内存,然后自己管理。这样既避免了碎片,又不会因为 malloc 失败而崩溃。
15.5 知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。
15.6 总结
动态内存分配是C语言的精髓之一,也是最容易出bug的地方。说白了,就是四个函数加一个原则:malloc/calloc/realloc/free,加上“有借有还”。
我最后再啰嗦一句:在嵌入式系统里,能不动态分配就别动态分配。如果非用不可,一定要做好错误处理,并且用工具反复检查。我见过太多因为内存泄漏导致的产品事故了——别让自己成为下一个。
嗯,这一章就到这儿。代码写多了,自然就熟练了。