2、heap_1源码深度解析:最简单的分配器,不支持释放,适用于任务创建等一次性分配场景

各位同学,今天我们来聊聊 FreeRTOS 里最“单纯”的内存分配器——heap_1

说实话,我第一次接触 FreeRTOS 时,看到有 5 种 heap 方案,心里是有点懵的。但后来我明白了,简单有简单的好处。heap_1 就是那种“一招鲜,吃遍天”的典型代表。

2.1 heap_1 的核心思想:一根筋分配到底

heap_1 的设计哲学,说白了就是四个字:只分不收

它把一大块静态数组当作内存池,每次调用 pvPortMalloc() 就从池子里切一块出去。切完就完了,不支持释放,也不支持碎片整理。

你可能会问:“不支持释放,那有什么用?”

嗯,这个问题问得好。我当年也有同样的疑惑。直到我在一个工业控制项目里,需要创建 10 个任务、3 个队列、2 个信号量,这些资源一旦创建好,整个生命周期都不会销毁。这时候 heap_1 简直就是天选之子。

适用场景总结:
  • 任务创建(xTaskCreate)—— 一次性分配任务栈和TCB
  • 队列创建(xQueueCreate)—— 队列控制块一次性分配
  • 信号量/互斥量创建 —— 内核对象一次性分配
  • 任何“分配后永不释放”的场景

2.2 源码解剖:不到 100 行的精华

heap_1 的实现代码非常短,我数过,核心逻辑不到 100 行。咱们直接看关键部分。

2.2.1 内存池的定义

/* 定义一个大数组作为堆内存 */
static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ];

这里 configTOTAL_HEAP_SIZE 是在 FreeRTOSConfig.h 里定义的宏。我习惯把它设成 任务栈总大小的 1.5 倍,留点余量。

我的经验: 如果任务栈总共需要 4KB,我会把堆设成 6KB。多出来的 2KB 给队列和信号量用。别抠门,嵌入式开发最怕“内存不够”这种运行时错误。

2.2.2 分配器的核心:pvPortMalloc

void *pvPortMalloc( size_t xWantedSize )
{
    void *pvReturn = NULL;
    static uint8_t *pucAlignedHeap = NULL;

    /* 对齐处理:确保返回地址按 8 字节对齐 */
    #if ( portBYTE_ALIGNMENT != 1 )
        if( xWantedSize & portBYTE_ALIGNMENT_MASK )
        {
            xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
        }
    #endif

    vTaskSuspendAll();
    {
        if( pucAlignedHeap == NULL )
        {
            /* 第一次调用时,对齐堆起始地址 */
            pucAlignedHeap = ( uint8_t * ) ( ( ( portPOINTER_SIZE_TYPE ) &ucHeap[ portBYTE_ALIGNMENT ] ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
        }

        /* 检查剩余空间是否足够 */
        if( ( xWantedSize > 0 ) && ( ( xNextFreeByte + xWantedSize ) < configTOTAL_HEAP_SIZE ) )
        {
            pvReturn = pucAlignedHeap + xNextFreeByte;
            xNextFreeByte += xWantedSize;
        }
    }
    xTaskResumeAll();

    return pvReturn;
}

这段代码的逻辑非常直白:

  1. 对齐处理:把请求的大小向上对齐到 8 字节(或 4 字节,取决于架构)。
  2. 挂起调度器:防止分配过程中被任务切换打断。
  3. 检查剩余空间:如果够,就从当前空闲位置切一块出去。
  4. 更新空闲指针xNextFreeByte 往前移动。
  5. 恢复调度器:完事。
注意: 这里没有锁!它靠的是 vTaskSuspendAll() 来保证原子性。如果你在中断里调用 pvPortMalloc,会出大问题。我曾经在一个项目中,不小心在定时器中断里调用了任务创建函数,结果 heap 指针乱飞,系统直接挂掉。排查了整整两天才找到原因。

2.2.3 释放函数:vPortFree

void vPortFree( void *pv )
{
    /* 啥也不做 */
    ( void ) pv;
}

你没看错,vPortFree 就是一个空函数。它存在的唯一意义,就是让编译器别报“未定义函数”的警告。

所以,如果你在代码里调用了 vPortFree,它不会报错,但也不会释放内存。嗯,这其实是个陷阱。我建议你在项目初期就明确告诉团队:用了 heap_1,就别想着释放

2.3 性能分析:快,是真的快

指标 heap_1 备注
分配时间复杂度 O(1) 固定几条指令,极快
释放时间复杂度 不支持
内存碎片 线性分配,永不产生碎片
内存利用率 仅浪费对齐填充的字节
确定性 完全确定 每次分配时间固定

你看这个表格,heap_1 在确定性速度上都是满分。对于实时系统来说,这太重要了。

2.4 避坑指南:我曾经踩过的坑

  • 坑一:堆大小设得太小
    我曾经在一个项目中,把 configTOTAL_HEAP_SIZE 设成 2KB,结果任务创建到第 5 个就失败了。后来一算,每个任务栈至少 512 字节,加上 TCB 开销,2KB 根本不够。建议先估算所有任务栈总和,再乘以 1.5。
  • 坑二:在中断里分配内存
    前面说了,heap_1 依赖 vTaskSuspendAll() 来保护临界区。中断里不能挂起调度器,所以千万别在中断里调用 pvPortMalloc。我那次血的教训就是:定时器中断里创建了一个任务,结果系统直接 hard fault。
  • 坑三:误以为可以释放
    有些新手看到 vPortFree 存在,就以为可以释放。结果内存越用越少,最后系统崩溃。我的建议是:在代码注释里明确写上“此项目使用 heap_1,禁止调用 vPortFree”

2.5 知识体系:一张图看懂 heap_1

下面我用一张 SVG 图,把 heap_1 的整个工作流程画出来。你看完就明白了。

heap_1 内存分配流程 初始状态:ucHeap[configTOTAL_HEAP_SIZE] 全部空闲 xNextFreeByte = 0 pvPortMalloc(100) 已分配 100 字节 剩余空闲空间 xNextFreeByte = 100 pvPortMalloc(200) 已分配 100 字节 已分配 200 字节 剩余空闲空间 xNextFreeByte = 300 vPortFree() → 空函数,啥也不做 内存只能增长,不能回收!

这张图很直观:分配就是指针往前挪,释放就是啥也不干。所以 heap_1 的内存使用量只会单调递增,永远不会减少。

2.6 什么时候选 heap_1?

我个人的判断标准很简单:

  • 选 heap_1:如果你的系统里,所有动态内存都在初始化阶段分配完,之后再也不动。比如:创建固定数量的任务、队列、信号量。
  • 不选 heap_1:如果运行过程中需要释放内存(比如动态加载/卸载模块),或者不确定内存使用模式,那就选 heap_4 或 heap_5。
小技巧: 在项目初期,我通常会先用 heap_1 做原型验证。因为它简单、可靠、不出错。等确定内存分配模式后,再切换到更复杂的 heap 方案。这样能减少早期调试的复杂度。

好了,heap_1 就讲到这里。它虽然简单,但用对了地方,就是一把利器。记住:简单不等于弱,合适才是王道


公众号:蓝海资料掘金营,微信deep3321