夜雨聆风学习资料网

ARTICLE · 1093735

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

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 的堆就是一个静态数组:

/* 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 字节是一个管理头:

/* 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 给最大整块和空闲块数:

/* ================================================================== * FreeRTOS 实验:看 heap_4 释放时怎么合并 * (配套公众号 heap_4 内存管理篇) * 在 main() 里、调度器启动前跑:此时堆是完整一块,基线最干净 * ================================================================== *//* 打印一行快照:空闲总字节 + 最大整块 + 空闲块数量 */staticvoidvHeapReport( constchar * 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 );}voidheap_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() 里把原来的实验入口换成它(本实验不需要任务,也不需要调度器):

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后(关键)1761761
申请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 内存,但它的全部管理状态小得可怜——一条空闲块链表加四个计数器:

/* 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)。

/* heap_4.c prvHeapInit() (节选) */staticvoidprvHeapInit( 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 的五个步骤

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

/* 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,先验明再回收

/* heap_4.c vPortFree() (节选) */voidvPortFree( 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 在插回之前多做两件事:查前邻、查后邻,物理相邻就当场粘成一个块:

/* heap_4.c prvInsertBlockIntoFreeList() */staticvoidprvInsertBlockIntoFreeList( 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
空闲块数
12
(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 的管理数据刚好凑齐一套体检指标:

/* 三个水位 */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 没有任何魔法,它只是在你每次还内存的时候,顺手看了看这笔内存两边的邻居是不是也闲着。

— 写在最后 —

如果这篇文章对你有帮助,欢迎点赞、在看、转发三连支持,这是我持续更新的最大动力。

有问题欢迎在评论区指出,我都会认真看。

点个关注不迷路,更多硬核技术剖析持续更新中,下期再见。

相关学习资料