在嵌入式实时系统中,内存管理是一个绕不开的话题。FreeRTOS 提供了五种不同的堆管理方案(heap_1 到 heap_5),其中 heap_4 可以说是最常用、最成熟的一种。它不仅支持内存释放,还能在释放时自动合并相邻的空闲块,有效减少内存碎片。

今天我们就来深入剖析 heap_4.c 的实现原理,看看它如何在资源受限的嵌入式环境中高效管理内存。

为什么需要 heap_4?

在嵌入式开发中,我们经常需要动态创建任务、队列、信号量等内核对象。如果使用 heap_1(只能分配不能释放),长期运行的应用可能会因为无法回收内存而导致资源耗尽。而 heap_2 虽然支持释放,却不合并相邻空闲块,容易产生碎片。

heap_4 的出现正是为了解决这个问题:在支持释放的同时,通过合并相邻空闲块来最大程度地减少碎片化

核心数据结构:空闲块链表

heap_4 将所有空闲内存块组织成一个单向链表,每个空闲块都有一个头部结构:

typedef struct A_BLOCK_LINK {
    struct A_BLOCK_LINK *pxNextFreeBlock;  // 指向下一个空闲块
    size_t xBlockSize;                     // 当前块的大小(包含头部)
} BlockLink_t;

有意思的是,已分配的内存块同样保留了这个头部,只是它的 pxNextFreeBlock 被设为 NULL,并且 xBlockSize 的最高位会被置 1 作为“已分配”标记。这种设计让内存块的状态判断变得非常高效。

初始化:堆空间的布局

当第一次调用 pvPortMalloc() 时,系统会调用 prvHeapInit() 初始化整个堆空间。初始化后的内存布局大致是这样的:
内存堆结构示意图

  • xStart 是一个虚拟头结点,方便链表操作
  • pxEnd 是尾部标记,位于堆空间的末尾
  • 整个堆初始时只有一个大的空闲块,覆盖了 pxEnd 之前的所有空间

内存分配:首次适应算法

pvPortMalloc() 的实现体现了典型的首次适应策略:

  1. 对齐处理:请求的大小会先加上头部大小,然后按 portBYTE_ALIGNMENT 对齐
  2. 查找空闲块:从链表头开始,找到第一个 xBlockSize >= xWantedSize 的空闲块
  3. 分割策略:如果找到的块比所需大得多(超过 heapMINIMUM_BLOCK_SIZE),就将其分割成两块:
    • 一块正好是所需大小,用于分配
    • 剩余部分作为新的空闲块插回链表
  4. 标记分配:将分配块的 xBlockSize 最高位置 1,pxNextFreeBlock 置为 NULL

这里有一个关键设计:heapMINIMUM_BLOCK_SIZE 防止产生过小的碎片。如果剩余部分太小,还不如不分,避免浪费内存。

内存释放:自动合并相邻块

vPortFree() 的释放过程同样精妙:

  1. 定位头部:通过指针偏移找到内存块前面的 BlockLink_t 结构
  2. 清除标记:去掉 xBlockSize 的分配标志位
  3. 插入空闲链表:调用 prvInsertBlockIntoFreeList() 将块放回

而合并的核心就在 prvInsertBlockIntoFreeList() 函数中。它会按地址顺序插入新块,并尝试与前后相邻的空闲块合并:

假设当前空闲链表有块 A 和块 C,我们释放的块 B 正好在它们之间:
[A] [空闲] -> [C] [空闲]

插入 B 后,先检查能否与 A 合并(A 的结束地址 == B 的起始地址),
再检查能否与 C 合并(B 的结束地址 == C 的起始地址)。
如果都能合并,最终会形成一个大块 [A+B+C]。

这种设计确保了链表中永远不会出现两个相邻的空闲块,最大化了可用连续内存。

并发保护:调度器挂起

heap_4 中一个值得注意的细节是,所有分配和释放操作都在调度器挂起状态下进行:

vTaskSuspendAll();
{
    // 内存操作
}
( void ) xTaskResumeAll();

这样做的好处是:

  • 防止在操作链表时被其他任务打断,保证操作的原子性
  • 相比关中断,挂起调度器允许中断继续响应,只是不会发生任务切换
  • 实现简单且足够高效

当然,这也意味着在中断服务函数中调用内存分配函数需要格外小心。

统计与监控

heap_4 提供了两个非常有用的统计函数:

  • xPortGetFreeHeapSize():返回当前剩余的可用内存字节数
  • xPortGetMinimumEverFreeHeapSize():返回自系统启动以来,剩余内存的历史最小值

通过定期监控这两个值,可以及时发现内存泄漏或堆空间不足的风险。通常建议在开发阶段将历史最小值打印出来,确保在最坏情况下仍有足够内存可用。

配置选项

使用 heap_4 时需要关注几个配置项:

作用
configTOTAL_HEAP_SIZE 定义堆的总大小
configAPPLICATION_ALLOCATED_HEAP 设为 1 时,用户自定义堆数组位置
configUSE_MALLOC_FAILED_HOOK 分配失败时的回调钩子
configSUPPORT_DYNAMIC_ALLOCATION 必须为 1 才能使用动态分配

如果 configAPPLICATION_ALLOCATED_HEAP 为 1,你需要自己在代码中定义:

uint8_t ucHeap[ configTOTAL_HEAP_SIZE ];

这对于将堆放到外部 RAM 或特定内存段非常有用。

与其他 heap 的对比

实现 特点 适用场景
heap_1 只分配不释放,效率最高 永不释放内存的应用
heap_2 支持释放,但不合并碎片 分配/释放模式固定,碎片不严重的场景
heap_3 包装标准库 malloc/free 需要链接器支持,或已有全局内存管理
heap_4 支持释放,合并相邻块 通用推荐,大多数动态内存需求的场景
heap_5 支持多块不连续内存区域 堆空间由多个物理段组成时

实战建议

  1. 预估堆大小:根据应用场景估算内存需求,保留一定余量。利用 xPortGetMinimumEverFreeHeapSize() 验证。

  2. 避免频繁分配:虽然 heap_4 能合并碎片,但频繁分配释放仍会产生开销。对于高频操作,考虑使用内存池。

  3. 中断中的分配:尽量避免在中断中动态分配内存。如果必须,确保中断优先级在 configMAX_SYSCALL_INTERRUPT_PRIORITY 以下。

  4. 调试技巧:配置 configASSERT 可以在内存操作异常时触发断言,帮助快速定位问题。

结语

FreeRTOS 的 heap_4 实现虽然代码量不大(整个文件也就 200 多行),但设计精巧,在嵌入式领域经受住了时间的考验。理解它的工作原理,不仅能帮我们更好地使用 FreeRTOS,也能为在其他系统中实现类似的内存管理提供思路。

如果你正在开发一个需要长期稳定运行的嵌入式应用,heap_4 很可能是你最可靠的选择。当然,如果内存需求非常固定,heap_1 可能更合适;如果需要使用不连续的内存区域,heap_5 才是正确的答案。选对内存管理方案,能让你的系统跑得更稳、更久。


你有没有在实际项目中使用过 heap_4?遇到过什么有趣的问题吗?欢迎留言交流!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐