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 的逻辑,说白了就是三步:

  1. 遍历空闲链表,找到第一个大小 >= 请求大小的块
  2. 如果这个块比请求的大很多(超过一个块头大小+最小对齐),就分裂
  3. 返回用户可用的内存地址

我贴一段核心代码,你感受一下:

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 碎片化问题到底有多严重?

咱们画个图,你就明白了:

初始堆: 空闲块 (600字节) 分配 A(100B): A 空闲 (500B) 分配 B(200B): A B 空闲 (300B) 释放 A,分配 C(80B): 空闲 B C 空闲 (220B) 碎片1 (100B) 碎片2 (80B)

看到了吗?释放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就聊这么多。它的源码其实不长,总共也就两三百行,但里面的设计思路值得琢磨。下次你写自己的内存管理器时,可以参考它的链表管理方式,但一定要加上合并逻辑——除非你确定你的分配模式永远不会产生碎片。