FreeRTOS学习(九)- 内存管理

源码路径:.\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 宏切换:

  • 默认 0static uint8_t ucHeap[configTOTAL_HEAP_SIZE] 放在 .bss 段
  • 设为 1:你必须在工程里自己 uint8_t ucHeap[...] 定义——可通过链接脚本把它放在特殊内存区域(例如 DTCM、外部 SDRAM、CCM RAM 等)
  • heap_5 独立机制:用 HeapRegion_t 数组同时支持多个区域

三、heap_1:只分配不释放(最简单、最安全)

heap_1.c

工作原理

整个堆就是一块大数组。有一个全局游标 xNextFreeByte,每次 malloc 就:

pvReturn = pucAlignedHeap + xNextFreeByte;   // 返回当前位置
xNextFreeByte += xWantedSize;                // 游标前进(永不后退!)

vPortFree(pv) 直接 configASSERT(pv == NULL)——也就是不允许释放

适用场景

  • 启动时一次性把所有任务/队列/信号量都创建好,运行期再也不做动态分配
  • 超高可靠性航空航天、医疗设备:确定性、零碎片、无泄漏可能
  • 对代码量极敏感的极小 MCU(~180 行实现)

内存形态

永远是:已用区 [0, xNextFreeByte) + 空闲区 [xNextFreeByte, heap_end),整块无碎片。


四、heap_2:能 free 但不合并相邻块(有碎片风险)

heap_2.c

与 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

heap_3.c

代码极其短小(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:释放 + 合并的核心

heap_4.c#L504-L569

分 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 指针在写入内存前,与一个随机"金丝雀值"XORheapPROTECT_BLOCK_POINTER),读出来再 XOR 还原。

  • 好处:如果用户任务堆缓冲区溢出覆盖了下一个块的 BlockLink_t(pxNextFreeBlock),还原后得到的是乱码指针,heapVALIDATE_BLOCK_POINTER 宏立刻 configASSERT 抓现行。
  • 代价:需要用户提供 vApplicationGetRandomHeapCanary() 函数生成随机数(真随机最好,无硬件 RNG 时至少用一个难猜的常数值)

七、heap_5:heap_4 的"多段内存合体版"

heap_5.c

什么时候需要它?

很多 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_tStackType_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 xTaskCreateTCB_t + 任务栈(连续 1 块 malloc)sizeof(TCB_t) + uxStackDepth*4vTaskDelete(栈+TCB 都 free;但 Idle 任务回收前任务自杀需等 Idle 处理)
queue.c xQueueGenericCreateQueue_t + 存储区(连续 1 块)sizeof(Queue_t) + uxLength*uxItemSizevQueueDelete(信号量/互斥锁也是这个路径)
timers.c xTimerCreateTimer_tsizeof(Timer_t)(不含存储区,uxItemSize=0)xTimerDelete
event_groups.cEventGroup_t结构体本体vEventGroupDelete
stream_buffer.cStreamBuffer_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,构成一套"可裁剪、可观察、强确定性"的嵌入式内存管理方案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值