组件有一个共同点:创建时都要申请内存,而且走的是同一个函数 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 |
七个快照连起来看一遍(紫 = 空闲、灰 = 已分配):

三个现象进源码前先记下
-
释放让堆变碎
:放掉 B、D 两个 32 字节的小块,空闲总数涨了 64,但最大整块从 3680 跌到 32、空闲块从 1 变 2------空闲字节是回来了,可整块能力碎没了。这时候谁要 150 字节,分配必然失败------第 4 步真的试了一次:返回 NULL;
-
释放让堆变整
:放掉 C 这一块,空闲字节只涨 112(64→176),但空闲块从 2 变 1、最大整块从 32 跳到 176。释放了一块内存,块数反而变少------说明 C 根本没有作为新块挂进链表,它并进了左邻 B 的地盘,顺手又吞了右舍 D。三个孤岛(32+112+32)就此合成了一个 176 的整块;
-
满血复原
:同一个 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:
-
找位置
:从 xStart 沿链表走,走到 B------B 的下一个是 D,地址比 C 大,所以 C 该插在 B 和 D 之间;
-
查前邻
:
B地址 + 32 == C地址?成立------C 紧贴 B 之后。B.xBlockSize = 32 + 112 = 144,C 并入 B,不再单独存在; -
查后邻
:
(B+C) 的尽头 == D地址?B 地址 + 144 正好落在 D 上------成立。再吞 D:xBlockSize = 144 + 32 = 176,链表位置接管 D 的下家; -
前合并发生过,跳过第 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 还是两块) |
两个值得记的结论:
-
块数 2→2,一个没少
。释放 C 是双向合并(前后都空着),块数减一;释放 E 只有半边空,并进 D------既不添新块,也不减旧块;
-
同样腾出 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 / 最大整块 / 空闲块数 四个数------四个数字的趋势比任何单次读数都有说服力。
五、踩坑清单
-
"堆耗尽"和"栈溢出"会互相冒充
:都是死机、HardFault 居多。任务栈本身就在堆里(创建任务篇拆过),
configTOTAL_HEAP_SIZE太小,最先遭殃的是空闲任务、定时器守护任务的创建------失败发生在vTaskStartScheduler内部,现象是"调度器起不来/卡死",不是你检查得到的返回值。先看xPortGetMinimumEverFreeHeapSize再查栈溢出钩子,别把堆耗尽当栈溢出修; -
configUSE_MALLOC_FAILED_HOOK = 0时 malloc 失败是静默的:本工程就是这个配置,
pvPortMalloc返回 NULL 没有任何报错。动态创建的返回值必须判:xTaskCreate的返回值、xQueueCreate的句柄判空,一个都不能省。想加保险就开钩子,在vApplicationMallocFailedHook里点灯打印; -
configTOTAL_HEAP_SIZE要把固定开销算进去:本工程固定 16 字节开销(头尾对齐共舍 8 + pxEnd 哨兵本体 8)不属于你;任务栈、TCB、队列存储区全从这一个数里出。调大它要真机验证------把
xPortGetMinimumEverFreeHeapSize跑到历史最低,留出余量再定值,拍脑袋定大小是坑清单第 1 条的主要来源; -
malloc(0) 返回 NULL
:不是错误,但很容易当成"出错了"。申请长度来自变长数据(报文长度、字符串长度)时,长度为 0 的路径要么提前绕过,要么容忍 NULL;
-
越界写数据区 = 打烂块头
:
pvPortMalloc给你的指针前面 8 字节是pxBlockSize和链表指针。数组越界往前写几字节,free 时要么触发断言卡死,要么更糟------链表指针改了,堆结构静默损坏,死在八竿子打不着的地方。configENABLE_HEAP_PROTECTOR = 1可以给块头里的链表指针再加一层保险:存储时和一个随机金丝雀值异或,越界把指针改花了就过不了校验断言,怀疑内存被踩时建议打开排查; -
双重释放靠断言抓,前提是断言开着
:
vPortFree三道防伪专抓 double free,开了configASSERT当场停;没开的话链表被插成环,下次 malloc 死循环。释放路径必须"谁申请谁释放",同一次释放只执行一次; -
释放即清零是本工程行为,不是 FreeRTOS 行为
:
configHEAP_CLEAR_MEMORY_ON_FREE = 1,free 顺手 memset。调试时别再依赖"free 完数据还在"------读回来全是 0;写敏感数据的项目反而可以靠它防数据残留; -
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 没有任何魔法,它只是在你每次还内存的时候,顺手看了看这笔内存两边的邻居是不是也闲着。