3. heap_2源码深度解析:支持释放,但存在严重碎片化问题,适用于频繁创建删除的场景。
好,咱们接着聊heap_2。说实话,这个内存管理方案在FreeRTOS里算是个「过渡角色」。它比heap_1灵活,能释放内存了,但又不像heap_4那样能合并碎片。我当年第一次用heap_2时,觉得它挺完美的——能分配能释放,多方便啊。结果跑了一个星期的产品,系统突然就分配不出内存了。嗯,这就是碎片化惹的祸。
3.1 heap_2的核心思想:链表管理 + 最佳匹配
heap_2的实现思路其实很直白。它维护了一个空闲块链表,每次分配时,从链表中找到大小最合适的那个块。你想想看,这就像你去买鞋,店员不是给你拿最大的,也不是拿最小的,而是拿跟你脚码最接近的那双。
它的核心数据结构就两个:
/* 空闲块链表头指针 */
static BlockLink_t xStart;
/* 链表结尾哨兵 */
static BlockLink_t xEnd;
/* 每个内存块的结构 */
typedef struct A_BLOCK_LINK {
struct A_BLOCK_LINK *pxNextFreeBlock; /* 指向下一个空闲块 */
size_t xBlockSize; /* 块大小(包含本结构体) */
} BlockLink_t;
这里有个细节要注意:xBlockSize 存的是整个块的大小,包括这个结构体本身占用的字节。我刚开始看源码时,老觉得这个大小算得不对,后来才反应过来——它把管理开销也算进去了。
3.2 分配过程:最佳匹配算法
分配函数 pvPortMalloc 的逻辑,说白了就是三步:
- 遍历空闲链表,找到第一个大小 >= 请求大小的块
- 如果这个块比请求的大很多(超过一个块头大小+最小对齐),就分裂
- 返回用户可用的内存地址
我贴一段核心代码,你感受一下:
void *pvPortMalloc( size_t xWantedSize ) {
BlockLink_t *pxBlock, *pxPreviousBlock, *pxNewBlockLink;
static uint8_t *pucAlignedHeap = NULL;
void *pvReturn = NULL;
/* 对齐处理 */
if( xWantedSize & portBYTE_ALIGNMENT_MASK ) {
xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
}
if( xWantedSize > 0 ) {
xWantedSize += heapSTRUCT_SIZE; /* 加上管理结构体的大小 */
/* 遍历链表,找最佳匹配 */
pxPreviousBlock = &xStart;
pxBlock = xStart.pxNextFreeBlock;
while( ( pxBlock->xBlockSize < xWantedSize ) && ( pxBlock->pxNextFreeBlock != NULL ) ) {
pxPreviousBlock = pxBlock;
pxBlock = pxBlock->pxNextFreeBlock;
}
if( pxBlock != &xEnd ) {
/* 如果剩余空间够大,就分裂 */
if( ( pxBlock->xBlockSize - xWantedSize ) > heapMINIMUM_BLOCK_SIZE ) {
pxNewBlockLink = ( void * )( ( ( uint8_t * ) pxBlock ) + xWantedSize );
pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize;
pxBlock->xBlockSize = xWantedSize;
pxNewBlockLink->pxNextFreeBlock = pxBlock->pxNextFreeBlock;
pxPreviousBlock->pxNextFreeBlock = pxNewBlockLink;
} else {
pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock;
}
pvReturn = ( void * )( ( ( uint8_t * ) pxBlock ) + heapSTRUCT_SIZE );
}
}
return pvReturn;
}
你看,它找的是第一个满足大小的块,而不是最小的。严格来说这不算「最佳匹配」,而是「首次匹配」。但FreeRTOS官方文档里管它叫最佳匹配,咱们就跟着叫吧。实际效果上,因为链表是按地址排序的,所以它找到的确实是第一个能用的块。
3.3 释放过程:简单粗暴
释放函数 vPortFree 就更简单了:
void vPortFree( void *pv ) {
uint8_t *puc = ( uint8_t * ) pv;
BlockLink_t *pxLink;
if( pv != NULL ) {
/* 指针往前偏移,拿到块头 */
pxLink = ( void * ) ( puc - heapSTRUCT_SIZE );
/* 直接把块插回空闲链表 */
pxLink->pxNextFreeBlock = xStart.pxNextFreeBlock;
xStart.pxNextFreeBlock = pxLink;
}
}
看到了吗?它只是把释放的块插回链表头部,完全没有做相邻块的合并。这就是碎片化的根源。我曾经在一个项目中,频繁创建和删除任务,每次任务栈大小不一样,跑了三天后,堆里全是小碎片,最大的连续空闲块连一个任务栈都分配不出来。
3.4 碎片化问题到底有多严重?
咱们画个图,你就明白了:
看到了吗?释放A之后,那块100字节的空闲块夹在中间。如果这时候想分配一个150字节的块,虽然总空闲空间有100+220=320字节,但最大的连续块只有220字节,不够用。这就是碎片化。
3.5 什么时候该用heap_2?
我个人觉得,heap_2最适合的场景是:
- 频繁创建和删除,但每次分配的大小都一样。比如固定大小的任务栈、固定大小的消息缓冲区。这种情况下,释放的块正好能被下次分配复用,不会产生碎片。
- 系统运行时间短。如果产品每次运行不超过几个小时,碎片化还没积累到致命程度,用heap_2完全没问题。
- 对实时性要求高。heap_2的分配和释放都是O(1)或O(n)的,没有复杂的合并操作,执行时间可预测。
核心结论: heap_2是「能释放但不能合并」的方案。它比heap_1灵活,但比heap_4脆弱。如果你确定分配模式是「等大小分配释放」,那它很高效。否则,趁早换heap_4。
3.6 避坑指南
我曾经踩过的坑: 在一个物联网网关项目中,我用heap_2管理任务栈。刚开始测试没问题,但部署到现场后,设备每隔几天就会死机。排查了整整一周,最后用 uxHeapGetFreeSize() 和 xPortGetFreeHeapSize() 打印堆状态,发现最大空闲块从最初的8KB降到了不足1KB。这就是碎片化的典型症状。
我的建议: 如果你不得不用heap_2,可以在代码里加一个监控任务,定期检查 xPortGetFreeHeapSize() 的返回值。如果最大空闲块低于某个阈值,就触发告警或重启。虽然粗暴,但能救命。
3.7 heap_2 vs heap_4 对比
| 特性 | heap_2 | heap_4 |
|---|---|---|
| 是否支持释放 | ✅ 支持 | ✅ 支持 |
| 是否合并相邻空闲块 | ❌ 不合并 | ✅ 合并 |
| 碎片化风险 | 高 | 低 |
| 分配算法 | 首次匹配 | 首次匹配 + 合并 |
| 执行时间确定性 | 较好 | 稍差(合并需要遍历) |
| 适用场景 | 等大小分配/释放 | 通用场景 |
说白了,heap_2就是heap_4的「简化版」。它砍掉了合并逻辑,换来了更简单的代码和更确定的执行时间。但代价就是——你得自己保证不会产生碎片。
嗯,关于heap_2就聊这么多。它的源码其实不长,总共也就两三百行,但里面的设计思路值得琢磨。下次你写自己的内存管理器时,可以参考它的链表管理方式,但一定要加上合并逻辑——除非你确定你的分配模式永远不会产生碎片。