源码路径:.\FreeRTOS\Source\portable\MemMang\heap_1.c ~ heap_5.c
一、生活化类比:5 种"内存管家"的性格
想象一家小餐馆的后厨工作台(RAM),厨师(任务/队列/信号量)需要占用台面空间。FreeRTOS 提供了 5 位不同风格的帮厨(heap_1 ~ heap_5)来"分配台面位置":
| 帮厨 | 性格 | 优点 | 缺点 |
|---|---|---|---|
| heap_1 只拿不还 | 一次性分配,永远不收回(像一次性桌布) | 最简单、最稳定、零碎片 | 内存只会越用越少 |
| heap_2 随便收,但不拼桌 | 能回收但相邻空闲块不合并(像拼乐高,拆开就不拼回去) | 能 free,简单 | 容易产生"小洞"(碎片),大分配可能失败 |
| heap_3 外包给 C 库 | 直接调用标准 C 的 malloc/free,多线程加锁包一层 | 由编译器/标准库决定,可能自带碎片合并 | 不确定、不可控、难分析最坏情况 |
| heap_4 细心拼桌 ★推荐 | 回收后会与前后相邻的空闲块自动合并 | 碎片最少,大多数场景首选 | 代码略多(500 行 vs 100 行) |
| heap_5 跨越多个工作台 | 在 heap_4 基础上,支持多块不连续内存区域拼成一个堆 | 解决 MCU 多片 SRAM/外部 SDRAM 拼接问题 | 需要手动定义区域 |
95% 的项目选用 heap_4(碎片合并 + 统计功能 + 金丝雀防护)。
二、先搞清楚几个基本概念
2.1 对齐(Alignment)
ARM 架构要求 4 字节或 8 字节对齐访问。FreeRTOS 所有分配都按 portBYTE_ALIGNMENT(一般是 8 字节)向上取整对齐,保证返回指针总是对齐到所需边界。
- 对齐掩码:
portBYTE_ALIGNMENT_MASK = 7(对 8 字节对齐来说) - 若你请求 9 字节 → 实际分配 +xHeapStructSize 后 → 向上对齐到 16 字节块
2.2 BlockLink_t(块链接头)
所有 heap_2/4/5 都用同一个小结构嵌在每块内存的头部:
typedef struct A_BLOCK_LINK {
struct A_BLOCK_LINK *pxNextFreeBlock; // 下一空闲块的指针
size_t xBlockSize; // 本块总字节数(含 BlockLink_t!)
} BlockLink_t;
这就是 FreeRTOS 堆的元数据核心。当你 pvPortMalloc(N) 时,实际占用字节数是:
实际 = xHeapStructSize(BlockLink_t 对齐后) + N(向上取整对齐后)
返回给你的指针是 BlockLink_t + 1 的地址(即数据区起点)。释放时 vPortFree(pv) 会从 pv 地址减去 xHeapStructSize 回到块头,拿回来元数据。
2.3 MSB 标记:一个字段两个用途
BlockLink_t.xBlockSize 的**最高位(MSB)**被借用来标记"本块是否已分配":
#define heapBLOCK_ALLOCATED_BITMASK (1UL << 31) // 32位系统
#define heapBLOCK_IS_ALLOCATED(px) ( (px->xBlockSize & heapBLOCK_ALLOCATED_BITMASK) != 0 )
#define heapALLOCATE_BLOCK(px) ( px->xBlockSize |= heapBLOCK_ALLOCATED_BITMASK )
#define heapFREE_BLOCK(px) ( px->xBlockSize &= ~heapBLOCK_ALLOCATED_BITMASK )
这是一个极其节省空间的技巧——不用额外字节存状态,只用一个 size_t 的最高位。当然副作用是:xBlockSize 最大只能到 2^31-1(对嵌入式完全不是问题)。
2.4 堆的三种放置方式
通过 configAPPLICATION_ALLOCATED_HEAP 宏切换:
- 默认
0:static uint8_t ucHeap[configTOTAL_HEAP_SIZE]放在.bss段 - 设为
1:你必须在工程里自己uint8_t ucHeap[...]定义——可通过链接脚本把它放在特殊内存区域(例如 DTCM、外部 SDRAM、CCM RAM 等) - heap_5 独立机制:用
HeapRegion_t数组同时支持多个区域
三、heap_1:只分配不释放(最简单、最安全)
工作原理
整个堆就是一块大数组。有一个全局游标 xNextFreeByte,每次 malloc 就:
pvReturn = pucAlignedHeap + xNextFreeByte; // 返回当前位置
xNextFreeByte += xWantedSize; // 游标前进(永不后退!)
vPortFree(pv) 直接 configASSERT(pv == NULL)——也就是不允许释放。
适用场景
- 启动时一次性把所有任务/队列/信号量都创建好,运行期再也不做动态分配
- 超高可靠性航空航天、医疗设备:确定性、零碎片、无泄漏可能
- 对代码量极敏感的极小 MCU(~180 行实现)
内存形态
永远是:已用区 [0, xNextFreeByte) + 空闲区 [xNextFreeByte, heap_end),整块无碎片。
四、heap_2:能 free 但不合并相邻块(有碎片风险)
与 heap_4 的唯一关键差别
// heap_2 的空闲链表按【块大小】升序排列
for (pxIterator = &xStart;
pxIterator->pxNextFreeBlock->xBlockSize < xBlockSize;
pxIterator = pxIterator->pxNextFreeBlock)
;
空闲块按大小排序,而不是按地址排序!这意味着 free 时无法知道"我前后邻居是谁"——自然无法做相邻合并。
malloc 算法:首次命中(First Fit 变体:Best Fit)
遍历空闲链表(按大小从小到大),第一个够大就用。因为链表是按尺寸升序的,所以第一个够大的就是"最小的能满足的"(近似 best-fit)。
- 优点:小分配不浪费大块
- 缺点:长期 malloc/free 循环后,小块间隙遍地(碎片)
适用场景
- 运行期创建/销毁任务或队列,但每次都创建大小相同的对象(碎片不累积)
- 内存充足、碎片影响可预期的项目(一般不推荐,直接上 heap_4)
五、heap_3:直接包装标准 C 库 malloc/free
代码极其短小(100 行):
void *pvPortMalloc(size_t xWantedSize) {
void *pv;
vTaskSuspendAll(); // 挂调度器(保证多线程安全)
pv = malloc(xWantedSize); // 直接调编译器/标准库的 malloc
xTaskResumeAll();
return pv;
}
void vPortFree(void *pv) {
vTaskSuspendAll();
free(pv); // 直接调标准库 free
xTaskResumeAll();
}
关键特点
configTOTAL_HEAP_SIZE不生效,堆由链接脚本/启动文件决定(Windows GCC 的_heap_end、ARMCC 的__heap_size等)- 线程安全通过 FreeRTOS 的
vTaskSuspendAll保证——若标准库 malloc 本身有内部锁,会产生双重锁 - 碎片合并情况取决于工具链(通常 newlib/newlib-nano 有相当不错的合并),但无法从 FreeRTOS 分析/统计(
xPortGetFreeHeapSize在 heap_3 中通常返回 0 或不实现)
适用场景
- 移植到新架构时最快 bring up(不用自己写堆)
- 想与第三方 C 库代码共用同一个堆,避免 FreeRTOS 堆和 C 库堆各占一块内存的"双堆"浪费
六、heap_4:带碎片合并(推荐默认)★
heap_4.c(~650 行,含 heap protector、统计 API)
6.1 空闲链表按【内存地址】升序
这是与 heap_2 的根本区别。xStart→块1→块2→…→pxEnd,按物理地址从小到大。好处:释放一块时,可以判断它的前后邻居在物理地址上是否连续——连续就合并!
6.2 prvInsertBlockIntoFreeList:释放 + 合并的核心
分 3 步判断合并:
低地址 ───────────────────────────────► 高地址
已有空闲块A 新释放块B 已有空闲块C
[ A ] [ B ] [ C ]
pxIterator pxBlockToInsert pxNext
① 判断前合并(B 是否正好紧跟在 A 后面):
if( (uint8_t*)A + A->xBlockSize == (uint8_t*)B ) {
A->xBlockSize += B->xBlockSize; // A 吞并 B
B = A; // ★ 让后续"后合并"判断用新块 A
}
② 判断后合并(新块 B 后面是否正好接 C):
if( (uint8_t*)B + B->xBlockSize == (uint8_t*)C && C != pxEnd ) {
B->xBlockSize += C->xBlockSize; // B 吞并 C
B->pxNextFreeBlock = C->pxNextFreeBlock; // 跳过 C
}
③ 把 B 挂进链表(若没被吞并就插中间;被吞并了则新 A/B 的 next 指针在 ② 里已更新)。
4 种情况结果:
| 前连续? | 后连续? | 结果 |
|---|---|---|
| 否 | 否 | 插入一个独立块 |
| 是 | 否 | 变成 [A+B] |
| 否 | 是 | 变成 [B+C] |
| 是 | 是 | 变成 [A+B+C](★ 三洞合并为一整块) |
这就是 heap_4 抗碎片的秘诀。
6.3 malloc 分配算法
heap_4.c#L173-L351:First Fit(从头找第一个够大的块)
pxPreviousBlock = &xStart;
pxBlock = xStart.pxNextFreeBlock;
while (pxBlock->xBlockSize < xWantedSize && pxBlock != pxEnd) {
下一个;
}
if (pxBlock != pxEnd) {
找到了:
pvReturn = (uint8_t*)pxBlock + xHeapStructSize; // 返回数据区
从空闲链表中摘出 pxBlock
if (剩下空间 >= heapMINIMUM_BLOCK_SIZE) {
拆分:尾部建 pxNewBlockLink → 放回空闲链表
pxBlock->xBlockSize = xWantedSize;
}
标记 ALLOCATED;pxNextFreeBlock = NULL
}
拆块图示(从 1000 字节的块申请 100 字节):
前:[ BlockLink_t(1000) | 992 B 空闲数据区 ]
↑ pxBlock
后:[ BlockLink_t(104+对齐)←被标记 ALLOCATED,返回给用户
BlockLink_t(1000-104)←新建空闲块,插回空闲链表 ]
6.4 heap_4 的统计与观测
heap_4 比其他方案多了以下可观测能力(必须用 heap_4/5 才有效):
| API | 返回什么 |
|---|---|
xPortGetFreeHeapSize() | 当前总空闲字节(不含碎片) |
xPortGetMinimumEverFreeHeapSize() | 历史最低空闲值(watermark,用来估算"你把堆开多大合适"——这是最实用的调优函数!) |
vPortGetHeapStats(&HeapStats_t) | 一次性返回:最大/最小空闲块、空闲块数量、总空闲字节、历史最低、成功分配/释放次数 |
pvPortCalloc(xNum, xSize) | malloc + memset 0(避免乘溢出) |
6.5 金丝雀防护 Heap Protector
heap_4/5 独有(V10+),通过 configENABLE_HEAP_PROTECTOR=1 开启。
原理:空闲块的 pxNextFreeBlock 指针在写入内存前,与一个随机"金丝雀值"XOR(heapPROTECT_BLOCK_POINTER),读出来再 XOR 还原。
- 好处:如果用户任务堆缓冲区溢出覆盖了下一个块的 BlockLink_t(pxNextFreeBlock),还原后得到的是乱码指针,
heapVALIDATE_BLOCK_POINTER宏立刻 configASSERT 抓现行。 - 代价:需要用户提供
vApplicationGetRandomHeapCanary()函数生成随机数(真随机最好,无硬件 RNG 时至少用一个难猜的常数值)
七、heap_5:heap_4 的"多段内存合体版"
什么时候需要它?
很多 MCU 有多片不连续 RAM:
- STM32 F4/F7/H7:内部 SRAM1 (112K) + SRAM2 (16K) + CCM RAM (64K,不连 DMA) + 外部 SDRAM
- 多核 AMP:每个核有自己的 TCM
- NXP i.MX RT:FlexRAM 分区后的几块 这些地址之间隔了大段空洞,heap_4 的 ucHeap 单数组放不下。
用法(关键!先初始化再建任何内核对象)
任何 xTaskCreate/xQueueCreate 都会内部调 pvPortMalloc,所以必须先:
// 1. 定义区域数组(必须按【起始地址从低到高】排列!最后一项 {NULL, 0} 终止)
HeapRegion_t xHeapRegions[] = {
{ (uint8_t*)0x20000000UL, 0x30000 }, // SRAM1 192K
{ (uint8_t*)0x20020000UL, 0x10000 }, // SRAM2 64K
{ (uint8_t*)0xC0000000UL, 0x800000 }, // 外扩 SDRAM 8MB
{ NULL, 0 } // 终止标志
};
// 2. 在 main() 最开头,创建任何任务/队列之前!
vPortDefineHeapRegions(xHeapRegions);
vPortDefineHeapRegions 的实现:遍历每个区域,分别对齐、在末尾塞一个哨兵 BlockLink_t,然后把它们当作若干空闲块依次插入到同一条空闲链表中。之后分配/释放的逻辑与 heap_4 完全相同(同一套 prvInsertBlockIntoFreeList 合并机制)——malloc 时如果在区域 1 没够大的块,遍历链表自然会跳到区域 2 继续找。
八、静态分配 vs 动态分配(configSUPPORT_STATIC_ALLOCATION)
FreeRTOS 从 V9 开始支持完全不依赖堆的"全静态模式":
| 创建方式 | 内存来源 | 失败返回 | 何时用 |
|---|---|---|---|
xTaskCreate | 自动 pvPortMalloc TCB+栈 | NULL(堆不够) | 一般快速开发、对确定性要求不高 |
xTaskCreateStatic | 你提供 StackType_t[] + StaticTask_t | StackType_t* + StaticTask_t* 必须非空 | 航空航天/医疗/工业安全,零堆、零不确定性 |
同样对应 xQueueCreateStatic / xSemaphoreCreateMutexStatic / xTimerCreateStatic / xEventGroupCreateStatic / xStreamBufferCreateStatic。
纯静态模式需要你实现两个回调(configSUPPORT_STATIC_ALLOCATION=1 && configSUPPORT_DYNAMIC_ALLOCATION=0):
void vApplicationGetIdleTaskMemory(StaticTask_t** ppx, StackType_t** ppx, uint32_t* sz);
void vApplicationGetTimerTaskMemory(StaticTask_t** ppx, StackType_t** ppx, uint32_t* sz);
——Idle 任务和 Timer 守护任务也必须给静态栈和 TCB 结构体。
九、内存与 FreeRTOS 内核各模块的耦合关系
| 模块 | 分配什么 | 大小 | 何时释放 |
|---|---|---|---|
tasks.c xTaskCreate | TCB_t + 任务栈(连续 1 块 malloc) | sizeof(TCB_t) + uxStackDepth*4 | vTaskDelete(栈+TCB 都 free;但 Idle 任务回收前任务自杀需等 Idle 处理) |
queue.c xQueueGenericCreate | Queue_t + 存储区(连续 1 块) | sizeof(Queue_t) + uxLength*uxItemSize | vQueueDelete(信号量/互斥锁也是这个路径) |
timers.c xTimerCreate | Timer_t | sizeof(Timer_t)(不含存储区,uxItemSize=0) | xTimerDelete |
| event_groups.c | EventGroup_t | 结构体本体 | vEventGroupDelete |
| stream_buffer.c | StreamBuffer_t + 环形存储 | 结构体 + xTriggerLevel 字节 | vStreamBufferDelete |
重要:xTimerCreate/Start/Stop 等操作还会向定时器命令队列 xTimerQueue 发消息——不分配内存(队列是事先预分配的)。但如果命令队列满且阻塞时间非零,会导致调用任务阻塞等待——这也是为什么 configTIMER_QUEUE_LENGTH 要设够。
十、常见坑点与最佳实践
10.1 怎么选 heap?
- 不做运行期 free → heap_1
- 一般 MCU 项目(task/queue 动态创建+销毁) → heap_4(默认首选)
- 多片不连续 RAM → heap_5
- 要和第三方 C 代码共享 malloc 堆 → heap_3
- 对 heap_2 一般不推荐,除非你确认自己的内存分配"大小固定、不会产生碎片积累"
10.2 堆应该开多大?
最小估算:
所有任务栈大小之和
+ sizeof(TCB_t) × 任务数
+ sizeof(Queue_t) × 队列数 + 各队列存储
+ sizeof(Timer_t) × 定时器数
+ sizeof(EventGroup_t) × 事件组数
+ TCB/队列对齐损耗(保守加 5~10%)
+ 512~2048 字节"安全垫"
然后跑起来用 xPortGetMinimumEverFreeHeapSize() 观察"历史最低值"。若最低值接近 0,说明堆太小,加 configTOTAL_HEAP_SIZE。最低值若稳定在几千字节,就说明开得合理。
10.3 Malloc Failed Hook
configUSE_MALLOC_FAILED_HOOK=1 → 定义 vApplicationMallocFailedHook()。发生分配失败时千万不要返回,要做:
- 打印
xPortGetFreeHeapSize()信息(若可用) - 记录故障码到后备 RAM / Flash
- 安全停机(关输出、进容错态),不要让系统"带病跑"(后续必然 HardFault 或行为错乱)
10.4 典型的内存泄漏场景
根据经验(871278、376297、817794):
- "生产者 malloc、消费者忘了 free":最常见。特别是队列里传指针(
xQueueSend(q, &pMsgPtr, 0))——消费者收到后如果没释放,泄漏巨大 - 任务自杀后没人回收:任务自己调
vTaskDelete(NULL),TCB+栈内存要等 Idle 任务 回收。如果 Idle 任务一直被更高优先级任务抢占(死循环高优先级任务不阻塞)→ 泄漏 - 一次性泄漏:忘记
xTimerStart后再次xTimerCreate同一个定时器句柄而不先 Delete
10.5 栈 vs 堆混淆
- 每个任务有私有栈(由
xTaskCreate的usStackDepth指定,分配在堆上) - 每个任务函数里的局部变量、函数调用返回地址都在该任务的私有栈上,不占堆
- 栈溢出检测:
configCHECK_FOR_STACK_OVERFLOW=1 or 2分别用"高水位标记法"和"栈顶魔法数法" - 堆溢出:表现为 BlockLink_t 的 next 指针或 size 被覆写 → 开 configUSE_LIST_DATA_INTEGRITY_CHECK_BYTES + configENABLE_HEAP_PROTECTOR 尽早抓现行
10.6 从 ISR 中能否调用 pvPortMalloc?
原则上禁止。虽然 heap_4 在 malloc/free 内部用了 vTaskSuspendAll() 保证任务级线程安全,但 ISR 里调用会:
- 破坏"调度器挂起中禁用 API"的契约
- malloc 执行时间不确定(找空闲块是 O(N) 链表遍历),ISR 中不可接受 正确做法:ISR 从 RingBuffer/队列传数据给任务,由任务去 malloc 处理。
十一、一张总图:heap_4 的分配与释放全链路
ucHeap[configTOTAL_HEAP_SIZE] (对齐后)
│
├─ prvHeapInit() 第一次 malloc 懒初始化
│ ├─ xStart.pxNextFreeBlock → 首空闲块(整个可用区)
│ └─ 尾部塞 pxEnd (哨兵,BlockSize=0, next=NULL)
│
├─ pvPortMalloc(100)
│ ├─ 对齐 xWantedSize:100 + xHeapStructSize → 112
│ ├─ 遍历 xStart→...→pxEnd → 找到第一个 ≥112 的块
│ ├─ 够大?拆:前 112 字节标记 ALLOCATED,MSB=1;剩下的建为新空闲块插链表
│ └─ 返回 (BlockLink_t*) + xHeapStructSize → 用户数据区指针
│
└─ vPortFree(pv)
├─ pv - xHeapStructSize → 回到 BlockLink_t 头
├─ 校验 MSB==1(必须是已分配块)、pxNextFreeBlock==NULL
├─ 清 MSB=0(FREE),可选 memset 0 擦数据
├─ prvInsertBlockIntoFreeList:按物理地址插 → 尝试与前块合并 → 尝试与后块合并
└─ xFreeBytesRemaining += 块大小;xNumberOfSuccessfulFrees++
十二、一句话总结
FreeRTOS 内存管理是"同一 API,5 种策略":从最简单的只拿不还 (heap_1),到基于最佳 fit 的可释放(heap_2),到直接包标准库(heap_3),到生产首选的地址排序+相邻自动合并(heap_4,推荐),到多段内存合体(heap_5)。它们共享 pvPortMalloc/vPortFree 接口。再结合静态分配和各种观测 hook,构成一套"可裁剪、可观察、强确定性"的嵌入式内存管理方案。
- 内存管理&spm=1001.2101.3001.5002&articleId=163865810&d=1&t=3&u=d45b7764f7ab48c1bbdd055184a82186)
2万+

被折叠的 条评论
为什么被折叠?



