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 时间。