4. 动态内存分配:malloc/free、new/delete,堆管理机制
动态内存分配,说白了就是程序在运行的时候,跟操作系统要一块内存来用。用完了再还回去。这事儿在嵌入式系统里特别敏感,因为资源就那么点,搞不好就崩了。
我刚开始做嵌入式那会儿,总觉得动态分配跟PC上写程序差不多。后来在一个实时控制项目里,系统跑着跑着就卡死了。查了三天,才发现是堆碎片把内存吃光了。嗯,从那以后我再也不敢小看堆管理了。
4.1 malloc/free 与 new/delete 的本质区别
很多人觉得 malloc 和 new 差不多,都是分配内存嘛。其实差别大了去了。
| 特性 | malloc/free | new/delete |
|---|---|---|
| 语言 | C 标准库函数 | C++ 运算符 |
| 返回值 | void*,需强制类型转换 | 自动类型安全 |
| 初始化 | 不初始化,内存内容随机 | 调用构造函数初始化 |
| 大小计算 | 手动 sizeof | 编译器自动计算 |
| 失败处理 | 返回 NULL | 抛出 std::bad_alloc 异常 |
| 重载 | 不可重载 | 可重载,用于自定义内存管理 |
我个人习惯在纯 C 项目里用 malloc,在 C++ 项目里尽量用 new。为什么?因为 new 能自动调用构造函数,省得你忘了初始化。我曾经在一个通信协议栈里,用 malloc 分配了一个结构体,结果忘了把里面的指针置 NULL,后来野指针问题查得我头都大了。
核心要点:new 不仅仅是分配内存,它还负责构造对象。malloc 只管给一块原始内存,不负责初始化。
4.2 堆管理机制:你看到的不是全部
堆管理器是怎么工作的?你调用 malloc(100),它真的只给你 100 字节吗?
当然不是。你想想看,堆管理器得记录哪些块是空闲的,哪些块被占用了。这些信息存哪儿?就存在你分配的内存块前后。
典型的堆块结构长这样:
// 堆块内部结构(简化版)
struct heap_block {
size_t size; // 块大小(包含头部)
int free; // 空闲标志
void *prev; // 前一个块指针
void *next; // 后一个块指针
// ... 可能还有校验和、对齐填充等
char data[1]; // 实际可用数据从这里开始
};
你调用 malloc(100),实际消耗的内存可能是 100 + 16(头部开销)+ 对齐填充。在 32 位系统上,一个堆块头部通常占 8 到 16 字节。你分配得越频繁、块越小,浪费的比例就越高。
注意:在嵌入式系统里,频繁分配小块内存(比如几十字节)是非常危险的。头部开销占比太高,而且容易产生碎片。我建议你一次分配一个大块,然后自己管理子分配。
4.3 堆碎片:嵌入式系统的头号杀手
堆碎片有两种:内部碎片和外部碎片。
- 内部碎片:你申请 30 字节,堆管理器给了你一个 32 字节的块。那 2 字节就是内部碎片。
- 外部碎片:你释放了一些块,空闲内存总量够用,但都是小碎片,拼不成一个大块。这就是外部碎片。
外部碎片最要命。我记得在一个传感器数据采集项目里,系统运行 72 小时后,malloc 开始频繁返回 NULL。明明还有 20KB 空闲内存,但最大的连续块只有 200 字节。这就是典型的堆碎片问题。
为什么会这样?因为分配和释放的顺序是随机的。你分配 A、B、C,然后释放 B,中间就出现了一个空洞。再分配 D,如果 D 比 B 大,就放不进去,只能往后找。久而久之,堆就变成了一盘散沙。
4.4 嵌入式环境下的堆管理策略
既然标准 malloc/free 在嵌入式里这么不靠谱,那怎么办?我总结了几个实战策略。
4.4.1 固定大小块分配(伙伴算法)
把堆分成固定大小的块,比如 16、32、64、128 字节。你申请内存时,给一个刚好能装下你的最小块。释放时直接归还到对应链表。
这样做的好处是:
- 没有外部碎片,因为所有块大小一样
- 分配和释放都是 O(1) 复杂度,实时性好
- 头部开销可以做到很小(甚至不需要头部)
缺点也很明显:内部碎片可能比较多。你申请 17 字节,给你 32 字节的块,浪费了 15 字节。
4.4.2 内存池(Memory Pool)
这是我最喜欢的方式。在系统初始化时,一次性从堆里申请一大块内存,然后自己管理。
// 简单的内存池实现
#define POOL_SIZE 1024
#define BLOCK_SIZE 64
static char pool[POOL_SIZE];
static char pool_used[POOL_SIZE / BLOCK_SIZE]; // 位图标记
void* pool_alloc(void) {
for (int i = 0; i < POOL_SIZE / BLOCK_SIZE; i++) {
if (!pool_used[i]) {
pool_used[i] = 1;
return &pool[i * BLOCK_SIZE];
}
}
return NULL; // 池满了
}
void pool_free(void *ptr) {
int index = ((char*)ptr - pool) / BLOCK_SIZE;
pool_used[index] = 0;
}
这个实现虽然简单,但在很多场景下够用了。我曾在无人机飞控项目里用这种内存池管理传感器数据缓冲区,运行几个月都没出过问题。
小技巧:如果你的系统里只有几种固定大小的对象(比如任务控制块、消息队列节点),为每种对象单独建一个内存池。这样既避免了碎片,又提高了分配速度。
4.4.3 栈式分配(alloca)
有些场景下,你其实不需要堆分配。用 alloca 可以在栈上分配内存,函数返回时自动释放。速度极快,而且不会产生碎片。
void process_data(int size) {
// 在栈上分配,自动释放
char *buffer = (char*)alloca(size);
// 使用 buffer ...
// 不需要 free
}
但要注意:栈空间通常很小(几 KB 到几十 KB),别分配太大的块。而且 alloca 不是标准 C 函数,移植性差一些。
4.5 实时系统中的内存分配禁忌
在实时系统里,有些操作是绝对不能做的。我列几个典型的:
- 不要在中断服务程序里调用 malloc:malloc 可能触发系统调用,导致上下文切换,破坏实时性。
- 不要在时间关键路径上分配内存:malloc 的执行时间不确定,可能触发垃圾回收或堆合并。
- 不要依赖 malloc 的成功率:在嵌入式系统里,malloc 失败是常态。你必须处理 NULL 返回值。
- 不要频繁分配释放:每次分配释放都会改变堆的状态,增加碎片风险。
血的教训:我曾经在一个汽车电子项目里,有人在 CAN 报文接收中断里调用了 new。结果系统在高速报文时频繁死锁。查了两个月才发现是堆分配导致的中断嵌套问题。从那以后,我定了个规矩:中断里只允许用静态分配或内存池。
4.6 如何选择合适的内存分配策略
没有银弹。不同的场景需要不同的策略。我一般这样选:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 系统初始化时分配,永不释放 | malloc 一次 | 没有碎片问题,简单直接 |
| 固定大小的对象频繁分配释放 | 内存池 | 无碎片,速度快,实时性好 |
| 大小变化较大的临时缓冲区 | 栈分配(alloca) | 自动释放,无碎片 |
| 复杂的数据结构(树、图) | 自定义堆管理器 | 需要精细控制内存布局 |
| 对实时性要求极高的系统 | 完全静态分配 | 零不确定性,最安全 |
我个人建议,在嵌入式系统里,能用静态分配就别用动态分配。如果非用不可,优先考虑内存池。malloc 和 new 只适合在初始化阶段使用,或者在不关心实时性的后台任务里用。
嗯,关于动态内存分配,今天就聊到这儿。记住一句话:在嵌入式系统里,内存不是用来浪费的,时间不是用来等待的。你设计的每一个分配策略,都要对得起那几 KB 的 RAM 和那几微秒的 CPU 时间。