4. 堆(Heap)管理算法详解:静态分配 vs 动态分配,实现一个简单的固定大小块分配器,分析碎片化问题

说到内存管理,很多嵌入式工程师第一反应就是「别用 malloc」。这个观点我理解,但不完全认同。我做过不少物联网终端和工业控制器项目,堆管理用得好,系统灵活度能上一个台阶;用得不好,三天两头死机。关键不在于用不用,而在于怎么用。

这一章,我们就把堆管理这件事彻底讲透。从静态分配和动态分配的本质区别,到亲手实现一个固定大小块分配器,再到碎片化问题的根源分析。嗯,内容不少,但都是硬货。

4.1 静态分配 vs 动态分配:到底差在哪?

先看一个最直观的对比。静态分配,说白了就是编译时就定死了。你写 int buffer[1024],那 4KB 空间在链接阶段就锁死了。动态分配呢?运行时才决定要多大,用完了还能还回去。

对比维度 静态分配 动态分配
分配时机 编译/链接时 运行时
内存位置 全局区/栈 堆区
大小灵活性 固定,不可变 可变,按需分配
释放方式 自动(生命周期结束) 手动(free)
碎片化风险 无 有(尤其频繁分配释放时)
确定性 高(执行时间可预测) 低(分配时间不确定)
适用场景 大小已知、生命周期明确 大小未知、需要灵活复用

我个人习惯是:能静态分配就静态分配。但有些场景你绕不开动态分配。比如通信协议栈里,报文长度是运行时才知道的;再比如多任务系统里,任务栈大小需要根据负载动态调整。这时候,你就得跟堆管理打交道了。

4.2 固定大小块分配器:从零开始实现

标准库的 malloc/free 为什么在嵌入式里不好用?原因很多:实现复杂、执行时间不确定、容易产生外部碎片。我早期一个项目就吃过这个亏——一个跑 Modbus 协议的网关,跑了两周后突然分配失败,查了半天是碎片把堆搞碎了。

从那以后,我倾向于用固定大小块分配器。思路很简单:把堆区预先切成若干个大小相同的块,分配时拿一块,释放时还回去。没有合并、没有分裂,时间复杂度 O(1)。

来看一个具体实现。我用的是空闲链表法:

/* 固定大小块分配器 */
#define BLOCK_SIZE   64    /* 每个块 64 字节 */
#define BLOCK_COUNT  32    /* 总共 32 个块 */
#define HEAP_SIZE    (BLOCK_SIZE * BLOCK_COUNT)

static uint8_t heap[HEAP_SIZE];
static uint8_t free_list[BLOCK_COUNT];
static uint8_t next_free;

void block_alloc_init(void)
{
    int i;
    for (i = 0; i < BLOCK_COUNT - 1; i++) {
        free_list[i] = i + 1;
    }
    free_list[BLOCK_COUNT - 1] = 0xFF;  /* 链表结束标记 */
    next_free = 0;
}

void *block_alloc(void)
{
    void *ptr = NULL;
    if (next_free != 0xFF) {
        ptr = &heap[next_free * BLOCK_SIZE];
        next_free = free_list[next_free];
    }
    return ptr;
}

void block_free(void *ptr)
{
    uint8_t index;
    if (ptr == NULL) return;
    index = ((uint8_t *)ptr - heap) / BLOCK_SIZE;
    free_list[index] = next_free;
    next_free = index;
}

这段代码的核心逻辑就三件事:

  • 初始化:把所有块串成一条空闲链表
  • 分配:从链表头部取一个块,返回地址
  • 释放:把块放回链表头部

你想想看,这个分配器没有遍历、没有排序、没有合并。每次分配和释放都是常数时间。对于实时系统来说,这个特性太重要了。

我的经验:块大小怎么选?我一般取系统中最大常见数据结构的尺寸,再向上取整到 2 的幂。比如最大报文是 50 字节,我就取 64。这样既不会浪费太多,又能覆盖绝大多数场景。

4.3 碎片化问题:为什么固定大小块也有坑?

你可能会问:固定大小块没有外部碎片,是不是就完美了?不是的。它会产生内部碎片。

内部碎片是什么?就是分配给你的块你没用完的那部分。比如块大小是 64 字节,你只存了 30 字节的数据,那剩下的 34 字节就浪费了。所有块加起来,浪费的总量就是内部碎片。

外部碎片呢?标准 malloc 那种才会产生。频繁分配释放不同大小的块,堆里会出现很多小空隙,每个空隙都不够用,但总和却很大。这就是外部碎片。

我画了一张图,把这两种碎片的关系说清楚:

堆碎片化类型对比 外部碎片(标准 malloc) A(32) 空 B(48) 空 C(24) 空 空隙总和很大,但每个都不够用 内部碎片(固定大小块) 块0(30/64) 块1(48/64) 块2(24/64) 空 每个块内都有未使用的空间 碎片化对比总结 • 外部碎片:空隙在已分配块之间,需要合并才能利用 • 内部碎片:块内未使用的空间,无法回收利用 • 固定大小块消除了外部碎片,但内部碎片是代价 • 选择块大小时需要在「浪费」和「灵活性」之间权衡

核心结论:固定大小块分配器用「内部碎片」换「确定性」和「无外部碎片」。在实时嵌入式系统中,这个交换通常是值得的。

4.4 如何评估你的堆分配器?

选型的时候,我一般看四个指标:

  1. 分配/释放时间复杂度:是不是 O(1)?最坏情况会不会退化?
  2. 碎片化上限:最坏情况下会浪费多少内存?能不能算出来?
  3. 内存利用率:有效载荷占总堆大小的比例。
  4. 确定性:每次分配的时间是否可预测?

标准 malloc 在指标 1 和 4 上表现很差。固定大小块分配器在 1、2、4 上都很优秀,但指标 3 可能不高。如果你对内存利用率要求极高,可以考虑伙伴分配器(Buddy Allocator)或 slab 分配器,但实现复杂度会上升。

避坑提醒:我曾经在一个项目里把块大小设成 128 字节,结果大部分报文只有 20-30 字节。内部碎片率高达 70% 以上。后来改成 32 字节块 + 链式缓冲才解决问题。记住:块大小一定要基于实际数据分布来定,不要拍脑袋。

4.5 什么时候该用动态分配?

说了这么多动态分配的坑,那到底什么时候该用?我总结三个场景:

  • 协议栈接收缓冲:报文长度不确定,用固定大小块池按需分配
  • 任务/线程栈:RTOS 中任务栈大小需要动态创建时
  • 配置数据存储:系统启动时从外部加载配置,大小不固定

但记住一条铁律:不要在中断里做动态分配。中断上下文要求极短执行时间,而任何分配器都可能触发内存整理或等待锁。我见过不止一个工程师在中断里调 malloc,然后系统随机死机——查都查不出来。

好了,这一章的内容就到这里。固定大小块分配器虽然简单,但它是理解更复杂分配器的基础。你把这个吃透了,后面讲伙伴分配器和 slab 分配器时,上手会快很多。