FreeRTOS 内核在创建任务、队列、信号量等对象时需要动态分配内存。但标准 C 库的 malloc()/free() 在嵌入式系统上有诸多问题(不可重入、实现不一定确定、可能产生碎片等),因此 FreeRTOS 将内存管理作为可移植层的一部分,提供了 5 种内存分配方案,位于 portable/MemMang/ 目录下。

你可以选择其中一种(也可以自己实现),在项目中编译对应的 heap_x.c 文件。

五种方案总览

方案分配释放合并相邻空闲块适用场景
heap_1❌ 不支持N/A只创建不删除的系统(最简单安全)
heap_2❌ 不合并⚠️ 已被 heap_4 取代
heap_3取决于编译器使用编译器自带的 malloc/free
heap_4✅ 合并最常用,反复创建删除的系统
heap_5✅ 合并(支持多内存区)内存不连续的系统

heap_1.c:只分配不释放

最简单的实现。预先定义一个大数组 ucHeap[configTOTAL_HEAP_SIZE] 作为堆空间,每次分配就从数组中切一块,指针往后挪。不支持 vPortFree()

分配过程:

初始:|----------------------------------------------------------| ucHeap
       ↑
       pucAlignedHeap

第一次分配(TCB):|███████|---------------------------------------------|
                  ↑      ↑
                 start   pucAlignedHeap(后移)

第二次分配(栈):|███████|████████|--------------------------------------|
                         ↑       ↑
                       start    pucAlignedHeap(继续后移)

适用场景

  • 系统启动时创建所有任务和内核对象,之后永远不删除
  • 对安全性要求极高的系统(不存在碎片问题,行为完全确定)
  • 大多数嵌入式产品实际上都属于这种模式

优点:代码最简单,执行时间确定,永远不会产生碎片。 缺点:不能释放内存。

heap_2.c:最佳适应(已废弃)

引入了空闲链表来管理空闲内存块,支持 vPortFree()。使用最佳适应算法(Best Fit):分配时找能满足要求的最小空闲块。

分配示例:

空闲链表中有三块:5字节、25字节、100字节
请求分配 20 字节 → 选择 25 字节块(最接近且够大)
→ 分割为 20 字节(分配出去)+ 5 字节(回收到空闲链表)

致命缺点不会合并相邻空闲块。释放后如果相邻有空闲块,它们不会合并成大块,导致内存碎片化。比如分配 20 字节后释放,留下 5 字节小块,多次分配释放后会产生大量无法利用的小碎片。

已被 heap_4 取代

heap_2 保留仅为了向后兼容。新代码使用 heap_4。

heap_3.c:包装标准 malloc

对编译器标准库的 malloc()free() 做了一层线程安全包装——通过临时挂起调度器保证 malloc/free 不会被并发调用。

void *pvPortMalloc(size_t xWantedSize) {
    void *pvReturn;
    vTaskSuspendAll();        // 挂起调度器
    pvReturn = malloc(xWantedSize);  // 调用标准库 malloc
    xTaskResumeAll();         // 恢复调度器
    return pvReturn;
}

特点

  • 不使用 configTOTAL_HEAP_SIZE,堆大小由链接器脚本决定
  • 不定义 ucHeap 数组
  • 线程安全,但 malloc()/free() 的执行时间通常不确定
  • 取决于编译器提供的 malloc 实现是否适合嵌入式(有些会产生碎片)

适用场景:你信任编译器的 malloc 实现,或者需要和使用 malloc 的其他代码库共存。

heap_4.c:首次适应+合并(最常用)

和 heap_1/heap_2 一样使用静态数组 ucHeap[configTOTAL_HEAP_SIZE],但采用首次适应算法(First Fit),并且会合并相邻空闲块(coalescence),大幅减少碎片。

分配过程:

空闲块:5字节、200字节、100字节
请求 20 字节 → 从第一块开始扫描 → 第一个够大的是 200 字节块
→ 分割为 20 字节(分配)+ 180 字节(回收到空闲链表)

关键特性——释放时合并相邻块

释放一个块时,检查它的"左邻"和"右舍"是否也是空闲块
如果是,就把它们合并成一个更大的连续块

例如释放 20 字节块后:
  左边 5 字节空闲,右边 180 字节空闲
  → 合并成 5+20+180 = 205 字节的大块

这有效解决了 heap_2 的碎片化问题。只要总剩余空间足够,总能分配到较大的连续块。

适用场景

  • 需要反复创建/删除任务、队列、信号量等内核对象的系统
  • 绝大多数有动态内存需求的应用
  • 最推荐使用的方案

内存碎片问题

虽然 heap_4 会合并相邻块,但在某些分配/释放模式下仍可能产生碎片。例如:

分配 [A][B][C][D] → 释放 B 和 D(不相邻)→ 空闲块 [A][ ][C][ ]
B 和 D 之间隔着 C,无法合并。如果需要分配大于 B 或 D 的块,即使总剩余空间够大也分不出来。

减少碎片的方法:

  1. 尽量以**后进先出(LIFO)**的顺序分配/释放(后分配的先释放)
  2. 避免频繁分配/释放不同大小的块
  3. 一次性分配够大的 configTOTAL_HEAP_SIZE
  4. 对于任务栈,考虑静态分配(xTaskCreateStatic

heap_5.c:支持非连续内存

和 heap_4 使用相同的分配/释放/合并算法,区别在于 heap_5 可以将多个不连续的内存区域合并成一个堆使用。

// 需要在调用任何内核分配函数之前,先初始化 heap_5:
HeapRegion_t xHeapRegions[] = {
    { (uint8_t *)0x20000000, 0x10000 },  // 内部 SRAM:64KB,起始地址 0x20000000
    { (uint8_t *)0x60000000, 0x8000  },  // 外部 SRAM:32KB,起始地址 0x60000000
    { NULL, 0 }                           // 数组终止标记
};
vPortDefineHeapRegions(xHeapRegions);

适用场景

  • 系统有多个 RAM 区域(如内部 SRAM + 外部 SDRAM/SRAM)
  • 内存映射中非连续的系统
  • 希望把大缓冲区放在外部 RAM 而内核对象放在内部 RAM

使用 heap_5 必须先调用 vPortDefineHeapRegions()

在初始化堆区域之前,不能调用任何会分配内存的 API(包括 xTaskCreate())。

堆大小配置:configTOTAL_HEAP_SIZE

对于 heap_1、heap_2、heap_4、heap_5,堆大小由 FreeRTOSConfig.h 中的 configTOTAL_HEAP_SIZE 决定(单位:字节):

#define configTOTAL_HEAP_SIZE  ((size_t)(20 * 1024))  // 20KB 堆

如何确定堆大小?

FreeRTOS 提供了查询剩余堆空间的函数:

// 返回当前空闲堆内存大小(字节)
size_t xPortGetFreeHeapSize(void);

// 返回历史上最小的剩余堆空间(高水位标记)
size_t xPortGetMinimumEverFreeHeapSize(void);  // heap_4 和 heap_5 支持

调优方法:

  1. 先设一个偏大的值(比如 10KB)
  2. 系统正常运行所有功能一段时间
  3. 调用 xPortGetMinimumEverFreeHeapSize() 查看最低剩多少
  4. 最终设置 = 最低剩余 + 20%~30% 安全余量 + 实际使用量

内核对象内存占用参考

对象大致内存占用(heap_4,Cortex-M)
任务(含 TCB)~80 字节 + 栈大小(由 usStackDepth 决定)
队列~80 字节 + 队列长度 × 项目大小
二值/计数信号量~80 字节(队列结构复用)
互斥锁~80 字节 + 少量额外字段
事件组~36 字节
软件定时器~60 字节/个
定时器命令队列~80 字节 + configTIMER_QUEUE_LENGTH × 命令大小

内存分配失败钩子

// FreeRTOSConfig.h
#define configUSE_MALLOC_FAILED_HOOK  1

// 应用层必须提供这个函数
void vApplicationMallocFailedHook(void)
{
    // pvPortMalloc() 返回 NULL 时被调用
    // 可以在这里:记录错误、LED闪烁报警、系统重启等
    taskDISABLE_INTERRUPTS();
    for(;;);  // 死循环,方便调试
}

选择建议

你的情况选择
启动后创建所有对象,之后不删除heap_1(最安全确定)
需要动态创建/删除对象heap_4(首选)
有外部 RAM 或内存不连续heap_5
必须用编译器的 malloc(和第三方库兼容)heap_3
新代码不要用 heap_2

下一步:10-FreeRTOSConfig配置

相关:02-任务管理与调度10-FreeRTOSConfig配置返回目录