FreeRTOS 堆内存管理源码剖析:释放一块内存,空闲块数量反而变少了?

组件有一个共同点:创建时都要申请内存,而且走的是同一个函数 pvPortMalloc。创建任务要它(任务栈 + 任务控制块)、创建队列要它、创建事件组要它、创建流缓冲还是要它------《FreeRTOS任务创建与删除机制源码剖析》里你看着任务栈从堆里割出来,《FreeRTOS 流缓冲与消息缓冲源码剖析:主体不用等待链表,也不用关中断的"队列"》里你看着它"一次 malloc 连结构带缓冲区"。但这个函数自己长什么样,系列一直没拆------它是整个内核的地基,本篇就来拆它。

先看一个反直觉的现象。实验里往堆中间释放一块 112 字节的内存,释放前 空闲块有 2 个,释放后反而只剩 1 个------你多还了一块内存,空闲块的个数却变少了。这不是 bug:被释放的块压根没有作为新块挂进链表,它直接并进了前一个空闲块、又吞掉了后一个空闲块。这一"并"一"吞",就是 heap_4 管理内存的全部核心,也是它敢说"碎片攒不起来"的底气。

这篇文章沿着一条主线走:堆怎么创建、创建时注意什么、创建了什么、怎么分配给下次用、怎么释放、释放时怎么合并、日常怎么管理。先把实验跑起来看见"块数变少",再进源码看它每一步怎么做的。

平台:STM32F103RET6(Cortex-M3,标准库)

内核:FreeRTOS V11.1.0(本工程实际源码,代码块首行注释标注来源)

本工程 configTOTAL_HEAP_SIZE = 4096,configSUPPORT_DYNAMIC_ALLOCATION = 1,configAPPLICATION_ALLOCATED_HEAP = 0,configHEAP_CLEAR_MEMORY_ON_FREE = 1,configENABLE_HEAP_PROTECTOR = 0,configUSE_MALLOC_FAILED_HOOK = 0------本文按此配置剖析

一、概述

1.1 堆是什么

把所有花哨概念剥掉,FreeRTOS 的堆就是一个静态数组:

cpp 复制代码
/* heap_4.c */
PRIVILEGED_DATA static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ];

本工程 configTOTAL_HEAP_SIZE = 4096,链接器给这个数组在 SRAM 里划 4KB,堆的全部物理内存就这些。pvPortMalloc 干的事是从这 4KB 里割一块给你,vPortFree 把用完的块还回来。没有页表、没有虚拟地址、没有垃圾回收------和你自己写一个"4KB 数组的管理器"面对的问题一模一样:记录哪些字节闲着、给申请者划一块、收回来接着用、别让空闲的字节碎成渣。

官方在 FreeRTOS/portable/MemMang/ 下给了五份实现,一个工程只能编入一份 (pvPortMalloc 符号会冲突)。各自适配的场景一句话说清:

实现 适配场景 释放 相邻合并
heap_1 对象创建后永不删除------只管分配不管还,换来最简单可靠 不支持 -
heap_2 需要 malloc/free 且内存紧张到不在乎碎片------现在已有更好的选择 支持 不做
heap_3 想复用标准库的 malloc/free(调试器、分析工具认识它) 支持 看标准库
heap_4 绝大多数场景的默认选择------本工程用的就是它 支持 做
heap_5 同 heap_4,但内存分成好几段(比如主 RAM + 外挂 SRAM)也能拼成一个堆 支持 做

heap_5 的核心逻辑和 heap_4 逐行相同,只是初始化时把多段内存串成一条链。本文拆 heap_4,拆完你直接能看懂 heap_5。

1.2 内存块长什么样

堆不打散成"字节"来管,而是切成块(block)。每个块(空闲的、已分配的都一样)最前面 8 字节是一个管理头:​​​​​​​

cpp 复制代码
/* heap_4.c */
typedef struct A_BLOCK_LINK
{
    struct A_BLOCK_LINK * pxNextFreeBlock; /* 空闲时:指向下一个空闲块 */
    size_t xBlockSize;                     /* 本块大小(含这8字节头), 最高位=已分配标志 */
} BlockLink_t;

由这块头推出本篇所有数字的计算规则:

  • 实占 = 8 字节头 + 你要的 N ,再向上对齐到 8 的倍数:malloc(100) 实占 112、malloc(20) 实占 32、malloc(150) 实占 160;

  • 块大小永远落在 8 的倍数上;

  • xBlockSize

    的最高位 被征用当标志位:置 1 表示已分配,清 0 表示空闲(heapBLOCK_ALLOCATED_BITMASK),所以块大小上限是 2GB 而不是 4GB------4KB 的堆用不到万分之一;

  • xPortGetFreeHeapSize()

    返回所有空闲块的 xBlockSize 之和。注意口径:它说"总共闲多少",不说"最大一整块多大"------这是两个量,第三节你会看到它们怎么分道扬镳。

1.3 API 家族

API 作用 关键点
pvPortMalloc(字节数) 分配 失败返回 NULL,无阻塞语义,没有 FromISR 版
vPortFree(指针) 释放 传 NULL 安全;双重释放/野指针触发断言
pvPortCalloc(个数, 单个大小) 分配+清零 V11 新增
xPortGetFreeHeapSize() 当前空闲总字节 只报总数,不报碎片
xPortGetMinimumEverFreeHeapSize() 历史最低空闲水位 评估堆够不够,看这个数
vPortGetHeapStats(&统计) 空闲块数/最大整块/分配计数 快照式体检,字段见 4.6

HeapStats_t 定义在 portable.h(经 FreeRTOS.h 间接包含,不用额外加头文件),vPortGetHeapStats 和 xPortGetMinimumEverFreeHeapSize 只有 heap_4 / heap_5 提供------换别的实现前对着这张表核一遍。

二、使用场景

什么时候轮得到堆管理说话?两种情况。

第一种:内核对象的动态创建。 xTaskCreate、xQueueCreate、xSemaphoreCreateMutex、xStreamBufferCreate......内部全是一条路:pvPortMalloc。你在应用代码里从没写过 pvPortMalloc,但你每创建一个对象,堆就瘦一圈------哪怕一个任务都不创建,vTaskStartScheduler 一跑,空闲任务、定时器守护任务的栈和 TCB 也已经从堆里割走两回了。

**第二种:你自己有大块变长的临时数据。**比如一帧不确定长度的协议报文、一张解码中的图片行缓冲。这些内存的生死由业务控制,用堆比用静态数组灵活------代价是你要管好它的生老病死:谁申请谁释放、绝不重复释放、绝不越界。

什么时候不该 用堆?对象个数编译期定死、且一个都不会删------开 configSUPPORT_STATIC_ALLOCATION 用静态版本(xTaskCreateStatic 等),内存放 .bss/.data 段,省下 8 字节管理头和链表维护,还避开第 5 节坑清单里的所有坑。取舍标准就一条:生命周期编译期定死 → 静态;运行期决定生灭 → 动态。

三、测试场景用例(先跑起来)

完整代码

实验思路:把堆切成"大-小-大-小-大"五块、再吃掉尾部整块,然后按能制造碎片的顺序释放,全程盯三个数:空闲总字节、最大整块、空闲块数。整个实验在 main() 里、调度器启动前跑------此时堆还没初始化(第一次 malloc 才建堆,见 4.2),基线最干净;启动流程篇拆过,这个阶段调度器没意见可提,用内核 API 完全安全。

三个数全来自官方 API:xPortGetFreeHeapSize 给空闲总数,vPortGetHeapStats 给最大整块和空闲块数:​​​​​​​

cpp 复制代码
/* ==================================================================
 * FreeRTOS 实验:看 heap_4 释放时怎么合并
 * (配套公众号 heap_4 内存管理篇)
 * 在 main() 里、调度器启动前跑:此时堆是完整一块,基线最干净
 * ================================================================== */

/* 打印一行快照:空闲总字节 + 最大整块 + 空闲块数量 */
static void vHeapReport( const char * pcTag )
{
    HeapStats_t xStats;

    vPortGetHeapStats( &xStats );
    printf( "%s:空闲 %u 字节,最大整块 %u 字节,空闲块 %u 个\r\n",
            pcTag,
            ( unsigned int ) xPortGetFreeHeapSize(),
            ( unsigned int ) xStats.xSizeOfLargestFreeBlockInBytes,
            ( unsigned int ) xStats.xNumberOfFreeBlocks );
}

void heap_demo( void )
{
    void * pvA, * pvB, * pvC, * pvD, * pvE, * pvF;

    printf( "\r\n=== heap_4 内存管理实验 ===\r\n" );

    /* 第 1 步:分配 5 块,实际块大小 112/32/112/32/112(含 8 字节块头) */
    pvA = pvPortMalloc( 100 );
    pvB = pvPortMalloc( 20 );
    pvC = pvPortMalloc( 100 );
    pvD = pvPortMalloc( 20 );
    pvE = pvPortMalloc( 100 );
    vHeapReport( "分配A-E后" );

    /* 第 2 步:一口吃掉尾部整块(申请"空闲-8"恰好用完最后一块) */
    pvF = pvPortMalloc( xPortGetFreeHeapSize() - 8 );
    vHeapReport( "分配F吃掉尾部后" );

    /* 第 3 步:放 B、D------堆里出现两个互不相邻的空洞 */
    vPortFree( pvB );
    vPortFree( pvD );
    vHeapReport( "释放B、D后" );

    /* 第 4 步:趁没合并,先试一次 150------空闲才 64,粗筛都过不了 */
    pvB = pvPortMalloc( 150 );
    if( pvB != NULL )
    {
        printf( "申请150字节:成功\r\n" );
    }
    else
    {
        printf( "申请150字节:失败(空闲64字节 < 需要160字节)\r\n" );
    }

    /* 第 5 步:放 C------夹在两个空洞中间的大块,关键一步 */
    vPortFree( pvC );
    vHeapReport( "释放C后(关键)" );

    /* 第 6 步:合并成果验证:再申请 150(需要一块 160 的整块) */
    pvB = pvPortMalloc( 150 );
    if( pvB != NULL )
    {
        printf( "再申请150字节:成功(从合并出的176整块里拿到)\r\n" );
    }
    else
    {
        printf( "再申请150字节:失败\r\n" );
    }

    /* 第 7 步:全部释放,看能不能回到初始状态 */
    vPortFree( pvA );
    vPortFree( pvB );
    vPortFree( pvE );
    vPortFree( pvF );
    vHeapReport( "全部释放后" );
}

main() 里把原来的实验入口换成它(本实验不需要任务,也不需要调度器):​​​​​​​

cpp 复制代码
int main( void )
{
    NVIC_PriorityGroupConfig( NVIC_PriorityGroup_4 );
    USART1_Init( 115200 );

    heap_demo();    /* 跑完停在死循环 */

    for( ; ; );
}

实验输出

快照 空闲字节 最大整块 空闲块数
分配A-E后 3680 3680 1
分配F吃掉尾部后 0 0 0
释放B、D后 64 32 2
申请150字节(第一次) 失败 (空闲 64 < 需要 160) <br /> <br />
释放C后(关键) 176 176 1
申请150字节(第二次) 成功(从合并出的 176 整块里拿到) <br /> <br />
全部释放后 4080 4080 1

七个快照连起来看一遍(紫 = 空闲、灰 = 已分配):

三个现象进源码前先记下

  1. 释放让堆变碎

    :放掉 B、D 两个 32 字节的小块,空闲总数涨了 64,但最大整块从 3680 跌到 32、空闲块从 1 变 2------空闲字节是回来了,可整块能力碎没了。这时候谁要 150 字节,分配必然失败------第 4 步真的试了一次:返回 NULL;

  2. 释放让堆变整

    :放掉 C 这一块,空闲字节只涨 112(64→176),但空闲块从 2 变 1、最大整块从 32 跳到 176。释放了一块内存,块数反而变少------说明 C 根本没有作为新块挂进链表,它并进了左邻 B 的地盘,顺手又吞了右舍 D。三个孤岛(32+112+32)就此合成了一个 176 的整块;

  3. 满血复原

    :同一个 150 的申请,合并前(第 4 步)失败、合并后(第 6 步)成功------差别不在释放了多少内存,在有没有一整块装得下 160 的申请。全部释放后,空闲 4080、最大整块 4080、空闲块 1------和开机第一刻一模一样。每次释放都在合并,堆就永远回得到起点。

下面进源码,看这三个现象各自对应哪几行代码。

四、源码剖析

4.1 堆的全部状态

heap_4 管着 4KB 内存,但它的全部管理状态小得可怜------一条空闲块链表加四个计数器:​​​​​​​

cpp 复制代码
/* heap_4.c */
PRIVILEGED_DATA static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ]; /* 堆本体:4KB 数组 */

PRIVILEGED_DATA static BlockLink_t xStart;          /* 链表头哨兵(本体在堆外) */
PRIVILEGED_DATA static BlockLink_t * pxEnd = NULL; /* 链表尾哨兵(本体藏在堆尾) */

PRIVILEGED_DATA static size_t xFreeBytesRemaining = 0;            /* 空闲字节总数 */
PRIVILEGED_DATA static size_t xMinimumEverFreeBytesRemaining = 0; /* 历史最低空闲水位 */
PRIVILEGED_DATA static size_t xNumberOfSuccessfulAllocations = 0; /* 累计分配次数 */
PRIVILEGED_DATA static size_t xNumberOfSuccessfulFrees = 0;       /* 累计释放次数 */

空闲链表有一条铁律:按地址从低到高排序。这不是为了好看------物理上相邻的两个空闲块,在链表里也必然前后脚,"找邻居"才能在链表上就近完成。4.5 节你会看到,合并的全部前提就是这条排序规则。

4.2 创建:prvHeapInit,堆的第一次 malloc 才出生

堆不是开机就初始化的。ucHeap 在开机时就是一个没人碰过的普通数组(内容全 0,躺在 .bss 段),直到第一次 pvPortMalloc ,prvHeapInit 才被调用(判断依据:pxEnd == NULL)。​​​​​​​

cpp 复制代码
/* heap_4.c prvHeapInit() (节选) */
static void prvHeapInit( void )
{
    BlockLink_t * pxFirstFreeBlock;
    portPOINTER_SIZE_TYPE uxStartAddress, uxEndAddress;
    size_t xTotalHeapSize = configTOTAL_HEAP_SIZE;

    /* 1. 数组起点向上对齐到 8 */
    uxStartAddress = ( portPOINTER_SIZE_TYPE ) ucHeap;
    if( ( uxStartAddress & portBYTE_ALIGNMENT_MASK ) != 0 )
    {
        uxStartAddress += ( portBYTE_ALIGNMENT - 1 );
        uxStartAddress &= ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK );
        xTotalHeapSize -= ( size_t ) ( uxStartAddress - ( portPOINTER_SIZE_TYPE ) ucHeap );
    }

    xStart.pxNextFreeBlock = ( void * ) uxStartAddress;
    xStart.xBlockSize = ( size_t ) 0;

    /* 2. 堆尾扣 8 字节,pxEnd 哨兵的本体放这里 */
    uxEndAddress = uxStartAddress + ( portPOINTER_SIZE_TYPE ) xTotalHeapSize;
    uxEndAddress -= ( portPOINTER_SIZE_TYPE ) xHeapStructSize;
    uxEndAddress &= ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK );
    pxEnd = ( BlockLink_t * ) uxEndAddress;
    pxEnd->xBlockSize = 0;
    pxEnd->pxNextFreeBlock = NULL;

    /* 3. 全堆就一个空闲块:从起点到哨兵前,挂上链表 */
    pxFirstFreeBlock = ( BlockLink_t * ) uxStartAddress;
    pxFirstFreeBlock->xBlockSize = ( size_t ) ( uxEndAddress - uxStartAddress );
    pxFirstFreeBlock->pxNextFreeBlock = pxEnd;

    xMinimumEverFreeBytesRemaining = pxFirstFreeBlock->xBlockSize;
    xFreeBytesRemaining = pxFirstFreeBlock->xBlockSize;   /* 真机实测 = 4080,来历见下文三条 */
}

初始化一共三件事,对应的"创建时需要注意什么"也有三个注意点:

  • 起点对齐 :数组地址如果不是 8 的倍数,起点上移、总量缩水。别默认静态数组天然对齐------本工程的 ucHeap 被链接器摆在 0x20000A2C(map 文件可查),尾数 0xC 差 4 个到 8 的倍数,起点上移到 0x20000A30,头部 4 字节用不上,总量先缩成 4092;- 4080 = 4096 − 16 :堆尾要扣 8 字节给 pxEnd 哨兵放本体------哨兵跟着堆走,出不了"指针还在、堆没了"的幺蛾子。而且哨兵的地址还要向下对齐:起点 + 4092 − 8 = 0x20001A24,尾数又差 4,再舍 4 字节,哨兵最终落在 0x20001A20,它身后到数组尽头的 4 字节也跟着废了。头 4 + 哨兵 8 + 尾 4,固定开销 16 字节------所以 configTOTAL_HEAP_SIZE 不是全部可用,真机开机第一刻的空闲就是 4080 (实验输出表里的基数);- 懒初始化 :第一次 malloc 才建堆。如果你把 configAPPLICATION_ALLOCATED_HEAP 设成 1,ucHeap 数组改由你自己提供,链接器不再分配------本工程是 0,用内核自带的。

4.3 怎么给下次用:pvPortMalloc 的五个步骤

分配是"割":从链表上找一块够大的,割下你要的部分,剩余的挂回链表给下次用。​​​​​​​

cpp 复制代码
/* heap_4.c pvPortMalloc() (节选) */
void * pvPortMalloc( size_t xWantedSize )
{
    BlockLink_t * pxBlock, * pxPreviousBlock, * pxNewBlockLink;
    void * pvReturn = NULL;
    size_t xAdditionalRequiredSize;

    /* 步骤1:加 8 字节头,再向上对齐到 8 */
    if( xWantedSize > 0 )
    {
        /* 源码此处还有加法溢出检查:溢出直接置 0(返回 NULL),节选略 */
        xWantedSize += xHeapStructSize;
        if( ( xWantedSize & portBYTE_ALIGNMENT_MASK ) != 0x00 )
        {
            xAdditionalRequiredSize =
                portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK );
            xWantedSize += xAdditionalRequiredSize;
        }
    }

    vTaskSuspendAll();          /* 堆操作的"锁":挂起调度器,不关中断 */
    {
        if( pxEnd == NULL )
        {
            prvHeapInit();      /* 首次调用,建堆 */
        }

        /* 步骤2:粗筛------空闲总数够不够(注意:只看总数!) */
        if( ( xWantedSize > 0 ) && ( xWantedSize <= xFreeBytesRemaining ) )
        {
            /* 步骤3:从低地址往高地址找,第一个够大的块 */
            pxPreviousBlock = &xStart;
            pxBlock = xStart.pxNextFreeBlock;
            while( ( pxBlock->xBlockSize < xWantedSize )
                   && ( pxBlock->pxNextFreeBlock != NULL ) )
            {
                pxPreviousBlock = pxBlock;
                pxBlock = pxBlock->pxNextFreeBlock;
            }

            if( pxBlock != pxEnd )   /* 找到了 */
            {
                /* 返回的指针跳过 8 字节块头 */
                pvReturn = ( void * ) ( ( ( uint8_t * ) pxPreviousBlock->pxNextFreeBlock )
                                        + xHeapStructSize );

                /* 步骤4:从链表摘下这个块 */
                pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock;

                /* 步骤5:剩余 > 16 字节才分裂;≤ 16 整块白送 */
                if( ( pxBlock->xBlockSize - xWantedSize ) > heapMINIMUM_BLOCK_SIZE )
                {
                    pxNewBlockLink = ( void * ) ( ( ( uint8_t * ) pxBlock ) + xWantedSize );
                    pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize;
                    pxBlock->xBlockSize = xWantedSize;
                    pxNewBlockLink->pxNextFreeBlock = pxPreviousBlock->pxNextFreeBlock;
                    pxPreviousBlock->pxNextFreeBlock = pxNewBlockLink;
                }

                xFreeBytesRemaining -= pxBlock->xBlockSize;
                if( xFreeBytesRemaining < xMinimumEverFreeBytesRemaining )
                {
                    xMinimumEverFreeBytesRemaining = xFreeBytesRemaining;
                }

                heapALLOCATE_BLOCK( pxBlock );      /* xBlockSize 最高位置 1 */
                pxBlock->pxNextFreeBlock = NULL;   /* 已分配块不在任何链表上 */
            }
        }
    }
    ( void ) xTaskResumeAll();

    return pvReturn;
}

五个步骤对着实验数字看:

  • 步骤 1 解释了 112/32/160 :实验注释里每个"实际块大小"都是这一步算出来的;- 步骤 2 和步骤 3 是两道筛子 :粗筛看总数,细查找块(源码在粗筛之前还验一道申请大小没顶到最高位,4KB 的堆够不到,略)。实验第 4 步的申请 150 是被粗筛 拦下的------150 加头对齐要 160,空闲总数才 64,第一道筛子就过不去;碎片还有更隐蔽的死法:还是这个处境(空闲 64、最大整块 32),申请 48 的话粗筛通过(56 ≤ 64)、细找失败(没有任何一块空闲 ≥ 56),照样返回 NULL。总数够、块不够,两层筛子之间漏下去的就是碎片;- 步骤 5 解释了"分裂"和"F 的技巧" :割完还剩多于 16 字节就切一刀,剩余部分作为新空闲块当场挂回链表 ------这就是"给下次用"的全部实现。实验第 2 步的 malloc(空闲−8) 让对齐后请求恰好等于尾块大小,剩余 0 ≤ 16 不分裂、整块交付,堆的空闲清零,为后续实验铺出干净舞台;
  • 分配出去的块 pxNextFreeBlock 写成 NULL------这不是顺手清洁,4.4 节 vPortFree 靠"这个字段必须还是 NULL"抓双重释放。

整个过程用 vTaskSuspendAll / xTaskResumeAll 包住,挂起调度器、不关中断------任务之间不会互相踩,中断照常响应(这也是堆 API 进不了 ISR 的原因之一,见坑清单第 8 条)。

4.4 怎么释放:vPortFree,先验明再回收​​​​​​​

cpp 复制代码
/* heap_4.c vPortFree() (节选) */
void vPortFree( void * pv )
{
    uint8_t * puc = ( uint8_t * ) pv;
    BlockLink_t * pxLink;

    if( pv != NULL )                         /* 传 NULL 直接返回,安全 */
    {
        puc -= xHeapStructSize;              /* 从数据指针倒退 8 字节,摸到块头 */
        pxLink = ( void * ) puc;

        heapVALIDATE_BLOCK_POINTER( pxLink );      /* 块头地址必须在堆内 */
        configASSERT( heapBLOCK_IS_ALLOCATED( pxLink ) != 0 ); /* 必须是"已分配"状态 */
        configASSERT( pxLink->pxNextFreeBlock == NULL );       /* 必须没被释放过 */

        if( heapBLOCK_IS_ALLOCATED( pxLink ) != 0 )
        {
            if( pxLink->pxNextFreeBlock == NULL )
            {
                heapFREE_BLOCK( pxLink );    /* xBlockSize 最高位清 0 */

                /* 本工程 configHEAP_CLEAR_MEMORY_ON_FREE = 1:释放即清零 */
                #if ( configHEAP_CLEAR_MEMORY_ON_FREE == 1 )
                {
                    ( void ) memset( puc + xHeapStructSize, 0,
                                     pxLink->xBlockSize - xHeapStructSize );
                }
                #endif

                vTaskSuspendAll();
                {
                    xFreeBytesRemaining += pxLink->xBlockSize;
                    prvInsertBlockIntoFreeList( pxLink );  /* 核心:进 4.5 */
                }
                ( void ) xTaskResumeAll();
            }
        }
    }
}

释放前的三道断言是三重校验:块头在堆内、最高位是"已分配"、pxNextFreeBlock 还是分配时写下的 NULL。双重释放 为什么抓得住?第一次 free 把块插回空闲链表,pxNextFreeBlock 不再是 NULL,第二次 free 触发第三道断言当场卡死(前提是开了断言------本工程是开的)。写越界把块头打烂,靠的是第二、三道断言拦下(前提是块头被改得不合法);要是字段恰好被改成合法值,三道全过,堆结构静默损坏、死在八竿子打不着的地方------坑清单第 5 条说的"更糟"就是它。

本工程还有个值得知道的配置:configHEAP_CLEAR_MEMORY_ON_FREE = 1,每次释放把数据区 memset 清零。好处是野指针读回来的全是 0 而不是旧数据,坏处是 free 变慢(块越大越慢)。调试上的注意点留到坑清单第 7 条说。

验完正身,块被交给本篇的主角。

4.5 怎么合并:prvInsertBlockIntoFreeList,先看前邻、再看后邻

释放的块要插回空闲链表。直接插行不行?行------但堆会越来越碎(1.1 的表里 heap_2 就是这么干的,也是它退出主流的原因)。heap_4 在插回之前多做两件事:查前邻、查后邻,物理相邻就当场粘成一个块:​​​​​​​

cpp 复制代码
/* heap_4.c prvInsertBlockIntoFreeList() */
static void prvInsertBlockIntoFreeList( BlockLink_t * pxBlockToInsert )
{
    BlockLink_t * pxIterator;
    uint8_t * puc;

    /* 1. 按地址找插入位置:走到"地址刚好比我小"的那个空闲块后面 */
    for( pxIterator = &xStart;
         pxIterator->pxNextFreeBlock < pxBlockToInsert;
         pxIterator = pxIterator->pxNextFreeBlock )
    {
        /* 空循环,纯找位置 */
    }

    /* 2. 前合并:我紧跟在 pxIterator 后面吗? */
    puc = ( uint8_t * ) pxIterator;
    if( ( puc + pxIterator->xBlockSize ) == ( uint8_t * ) pxBlockToInsert )
    {
        pxIterator->xBlockSize += pxBlockToInsert->xBlockSize;
        pxBlockToInsert = pxIterator;       /* 我并入前块,前块代表我们俩 */
    }

    /* 3. 后合并:pxIterator 的下一个空闲块紧跟在我后面吗? */
    puc = ( uint8_t * ) pxBlockToInsert;
    if( ( puc + pxBlockToInsert->xBlockSize ) ==
         ( uint8_t * ) pxIterator->pxNextFreeBlock )
    {
        if( pxIterator->pxNextFreeBlock != pxEnd )
        {
            /* 我和后块合成一块,接管它的链表位置 */
            pxBlockToInsert->xBlockSize += pxIterator->pxNextFreeBlock->xBlockSize;
            pxBlockToInsert->pxNextFreeBlock =
                pxIterator->pxNextFreeBlock->pxNextFreeBlock;
        }
        else
        {
            pxBlockToInsert->pxNextFreeBlock = pxEnd;
        }
    }
    else
    {
        pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock;
    }

    /* 4. 前面没合并过,才把我挂进链表(合并过的话,位置已经占好了) */
    if( pxIterator != pxBlockToInsert )
    {
        pxIterator->pxNextFreeBlock = pxBlockToInsert;
    }
}

"相邻"的判定朴素到不像话:块 A 从地址 p 开始、大小 s,那 p + s 就是 A 的物理尽头------那里如果站着另一个空闲块,它俩就是邻居。两次判定、两次加法,就是 heap_4 合并的全部代码。

把实验第 5 步放进来推演。释放 C 之前,堆的格局(紫 = 空闲):

vPortFree(pvC) 进来,pxBlockToInsert 指向 C:

  1. 找位置

    :从 xStart 沿链表走,走到 B------B 的下一个是 D,地址比 C 大,所以 C 该插在 B 和 D 之间;

  2. 查前邻

    :B地址 + 32 == C地址?成立------C 紧贴 B 之后。B.xBlockSize = 32 + 112 = 144,C 并入 B,不再单独存在;

  3. 查后邻

    :(B+C) 的尽头 == D地址?B 地址 + 144 正好落在 D 上------成立。再吞 D:xBlockSize = 144 + 32 = 176,链表位置接管 D 的下家;

  4. 前合并发生过,跳过第 4 步的挂链------B 的位置就是合体的位置。

结果:链表里一个 176 的整块取代了 B、D 两个内存碎片。释放 C 这一下,空闲块数量从 2 变 1------这就是实验表里那记反直觉的数字,现在你知道它是怎么来的了。

用一张图把"两眼"记牢:

顺带把实验第 4 步和第 6 步的对照也解释掉:

  • 第 4 步申请 150 失败 :150 加头对齐要 160,空闲总数才 64------粗筛第一关就不过,返回 NULL。就算放行也白搭:最大整块 32,凑不出 160 的整块;- 第 6 步申请 150 成功 :150 对齐后 160,落在合并出的 176 整块里。剩余 176−160=16,不大于 16,不分裂------这 16 字节并进块头的大小里(xBlockSize 记 176,释放时整块归还),用户拿到的还是自己申请的 150,多出的 16 字节谁也不用。同一个申请,中间只差一步释放 C------没有 4.5 的合并,它永远失败 (三个孤岛最大才 112);- 全部释放后满血 :放 A,A 自己单独成一块(1 块);放 pvB(那个 176),和 A 相邻,并成 288;放 E,并成 400;放 F,并成 4080。链表回到只有一个大块的状态,和开机第一刻一样。每次释放都顺手合并,堆就永远回得到起点------这就是 heap_4 的碎片治理。

思考一下:把第 5 步换成释放 E,三个数会怎么变?

先自己推一遍再往下看。提示:第 4 步结束时 E 的左邻是 D(空闲),右邻是 F(还在用)。

答案:只能单向合并 。E 并入前邻 D,32 + 112 = 144;右邻 F 在用,合不动。E 并没有成为新块,它并进了 D 的块里。格局变成:

释放 C(实验) 释放 E(推演)
空闲字节 176 176
最大整块 176 144
空闲块数 1 2 (B、D+E 还是两块)

两个值得记的结论:

  1. 块数 2→2,一个没少

    。释放 C 是双向合并(前后都空着),块数减一;释放 E 只有半边空,并进 D------既不添新块,也不减旧块;

  2. 同样腾出 112 字节,位置决定收益

    。三个数看着差别不大?再走一步就见分晓:这时申请 150(需要 160 的整块),144 < 160,照样失败------空闲总数和实验里一模一样是 176,malloc 却拿不出。碎片最气人的地方就在这:空闲总数明明够,最大整块却不够。

补一刀:如果先释放 E、再释放 C 呢?C 的前邻 B(32)、后邻 D+E(144)这时都是空的,一次双向合并串成 32+112+144 = 288 的巨块,空闲块 2→1。释放顺序不影响最终合出多大,但影响每一步中间有没有整块可用------这也是为什么工程上建议分配和释放尽量成对:谁申请谁释放,后申请的先还------块归还的顺序越贴近分配的逆序(像栈一样),中间状态就越不容易碎。

4.6 怎么管理:三个水位 + 一次体检

堆跑起来了,怎么知道它健不健康?heap_4 的管理数据刚好凑齐一套体检指标:​​​​​​​

cpp 复制代码
/* 三个水位 */
size_t xNow = xPortGetFreeHeapSize();               /* 现在剩多少 */
size_t xLow = xPortGetMinimumEverFreeHeapSize();    /* 历史最惨剩多少 */

/* 一次体检:HeapStats_t(portable.h) */
HeapStats_t xStats;
vPortGetHeapStats( &xStats );
字段 告诉你什么 怎么用
xAvailableHeapSpaceInBytes 当前空闲总数 同 xPortGetFreeHeapSize
xSizeOfLargestFreeBlockInBytes 最大整块 和空闲总数一比,差得越远堆越碎
xSizeOfSmallestFreeBlockInBytes 最小整块 碎到什么程度了
xNumberOfFreeBlocks 空闲块数量 越大越碎(实验里那个 2→1 说的就是它)
xMinimumEverFreeBytesRemaining 历史最低空闲 同 xPortGetMinimumEverFreeHeapSize
xNumberOfSuccessfulAllocations 累计分配次数 和释放次数长期对比
xNumberOfSuccessfulFrees 累计释放次数 两者只增不减的差值持续变大 = 泄漏嫌疑

判读方法就三句话:

  • xLow 贴近 0 或一直下降 :堆在往耗尽走。configUSE_MALLOC_FAILED_HOOK = 0 时(本工程),malloc 失败是静默的,没人报警,只有这个水位在提醒你;- 空闲总数不小、最大整块很小 :碎片在长。病根通常是"频繁小对象创建删除",药方是改对象池或改静态创建;- 分配次数和释放次数的差长期单调变大:有内存只借不还,回头查谁 malloc 没 free。

长期跑的工程可以加一个低优先级监控任务,每隔几分钟打印 xNow / xLow / 最大整块 / 空闲块数 四个数------四个数字的趋势比任何单次读数都有说服力。

五、踩坑清单

  1. "堆耗尽"和"栈溢出"会互相冒充

    :都是死机、HardFault 居多。任务栈本身就在堆里(创建任务篇拆过),configTOTAL_HEAP_SIZE 太小,最先遭殃的是空闲任务、定时器守护任务的创建------失败发生在 vTaskStartScheduler 内部,现象是"调度器起不来/卡死",不是你检查得到的返回值。先看 xPortGetMinimumEverFreeHeapSize 再查栈溢出钩子,别把堆耗尽当栈溢出修;

  2. configUSE_MALLOC_FAILED_HOOK = 0 时 malloc 失败是静默的

    :本工程就是这个配置,pvPortMalloc 返回 NULL 没有任何报错。动态创建的返回值必须判:xTaskCreate 的返回值、xQueueCreate 的句柄判空,一个都不能省。想加保险就开钩子,在 vApplicationMallocFailedHook 里点灯打印;

  3. configTOTAL_HEAP_SIZE 要把固定开销算进去

    :本工程固定 16 字节开销(头尾对齐共舍 8 + pxEnd 哨兵本体 8)不属于你;任务栈、TCB、队列存储区全从这一个数里出。调大它要真机验证------把 xPortGetMinimumEverFreeHeapSize 跑到历史最低,留出余量再定值,拍脑袋定大小是坑清单第 1 条的主要来源;

  4. malloc(0) 返回 NULL

    :不是错误,但很容易当成"出错了"。申请长度来自变长数据(报文长度、字符串长度)时,长度为 0 的路径要么提前绕过,要么容忍 NULL;

  5. 越界写数据区 = 打烂块头

    :pvPortMalloc 给你的指针前面 8 字节是 pxBlockSize 和链表指针。数组越界往前写几字节,free 时要么触发断言卡死,要么更糟------链表指针改了,堆结构静默损坏,死在八竿子打不着的地方。configENABLE_HEAP_PROTECTOR = 1 可以给块头里的链表指针再加一层保险:存储时和一个随机金丝雀值异或,越界把指针改花了就过不了校验断言,怀疑内存被踩时建议打开排查;

  6. 双重释放靠断言抓,前提是断言开着

    :vPortFree 三道防伪专抓 double free,开了 configASSERT 当场停;没开的话链表被插成环,下次 malloc 死循环。释放路径必须"谁申请谁释放",同一次释放只执行一次;

  7. 释放即清零是本工程行为,不是 FreeRTOS 行为

    :configHEAP_CLEAR_MEMORY_ON_FREE = 1,free 顺手 memset。调试时别再依赖"free 完数据还在"------读回来全是 0;写敏感数据的项目反而可以靠它防数据残留;

  8. ISR 里没有堆 API 可用

    :malloc/free 没有 FromISR 版本,也不可能有------它们的锁是挂起调度器,而 ISR 根本不受调度器约束。中断里要动态内存?中断篇的老答案:用 xTimerPendFunctionCallFromISR 把活转交守护任务,让任务上下文去 malloc。

六、总结

把这一篇放回系列的坐标系里:

  • 结构上 ,堆是一个 4KB 静态数组 + 一条按地址排序的空闲块链表 + 四个计数器------prvHeapInit 只做三件事:对齐、把哨兵藏进堆尾、挂上唯一的一整块 4080;- 分配上 ,"给下次用"靠分裂:加头对齐、粗筛总数、细找整块、割走后剩余当场挂回链表------总数够、整块不够时返回 NULL,两层筛子之间漏下去的就是碎片;- 释放上 ,heap_4 比普通实现多做两件事:查前邻、查后邻。判定只有一行"本块地址 + 本块大小 == 那个空闲块的地址",相邻就两次加法粘成一个块------释放让块数变少,不是 bug,是它工作的痕迹;- 管理上 ,判堆健康看三个数:历史最低水位防耗尽、最大整块对比空闲总数防碎片、分配释放计数防泄漏。本工程 configUSE_MALLOC_FAILED_HOOK = 0,没人替你报警,水位要自己盯。

到这里,拆过的组件源码都收尾了。回看这些文章,其实一直在拆两样东西:等待的艺术 (链表、位图、句柄槽位,任务总要睡觉,事件总要唤醒)和内存的来源(TCB、栈、队列存储区、环形缓冲,全从一个 4KB 数组里长出来)。最后这篇拆的就是这个 4KB 数组本身------所有组件的地基。heap_4 没有任何魔法,它只是在你每次还内存的时候,顺手看了看这笔内存两边的邻居是不是也闲着。

相关推荐
新晨单片机设计2 小时前
S017C-基于STM32汽车防酒驾系统(心率、血氧、短信)【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·防酒驾报警系统
fly_sunnn3 小时前
STM32-GPIO输出以及按键控制LED&光敏传感器控制蜂鸣器
stm32·单片机·嵌入式硬件
qq_401700415 小时前
FreeRtos:SysTick的功能分析
单片机·嵌入式硬件
zbyyd6 小时前
i.MX6ULL 裸机开发|GT9147 触控 + SPI 与 ADXL345 加速度传感器驱动
单片机·嵌入式硬件
fly_sunnn6 小时前
STM32-LED闪烁·LED流水灯·蜂鸣器
stm32·单片机·嵌入式硬件
jianqiang.xue6 小时前
【CStackGUI 实战】画板 drawpad:鼠标拖拽作图、撤销 / 重做、导出 PNG
单片机·嵌入式·cstackgui·c语言gui·可视化拖拽
新晨单片机设计7 小时前
S022A-基于STM32单片机音乐盒(数码管显示)【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·音乐盒
深圳老胡7 小时前
STM32 MCU 国产替代型号简介
笔记·stm32·单片机·代码规范
搁浅小泽9 小时前
通用二极管选型设计规范
单片机·嵌入式硬件·设计规范