ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

FreeRTOS 堆内存管理源码剖析:释放一块内存,空闲块数量反而变少了? 组件有一个共同点创建时都要申请内存而且走的是同一个函数pvPortMalloc。创建任务要它任务栈 任务控制块、创建队列要它、创建事件组要它、创建流缓冲还是要它——《FreeRTOS任务创建与删除机制源码剖析》里你看着任务栈从堆里割出来《FreeRTOS 流缓冲与消息缓冲源码剖析主体不用等待链表也不用关中断的队列》里你看着它一次 malloc 连结构带缓冲区。但这个函数自己长什么样系列一直没拆——它是整个内核的地基本篇就来拆它。先看一个反直觉的现象。实验里往堆中间释放一块 112 字节的内存释放前空闲块有 2 个释放后反而只剩 1 个——你多还了一块内存空闲块的个数却变少了。这不是 bug被释放的块压根没有作为新块挂进链表它直接并进了前一个空闲块、又吞掉了后一个空闲块。这一并一吞就是 heap_4 管理内存的全部核心也是它敢说碎片攒不起来的底气。这篇文章沿着一条主线走堆怎么创建、创建时注意什么、创建了什么、怎么分配给下次用、怎么释放、释放时怎么合并、日常怎么管理。先把实验跑起来看见块数变少再进源码看它每一步怎么做的。平台STM32F103RET6Cortex-M3标准库内核FreeRTOS V11.1.0本工程实际源码代码块首行注释标注来源本工程configTOTAL_HEAP_SIZE 4096configSUPPORT_DYNAMIC_ALLOCATION 1configAPPLICATION_ALLOCATED_HEAP 0configHEAP_CLEAR_MEMORY_ON_FREE 1configENABLE_HEAP_PROTECTOR 0configUSE_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.6HeapStats_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 完全安全。三个数全来自官方 APIxPortGetFreeHeapSize给空闲总数vPortGetHeapStats给最大整块和空闲块数​​​​​​​/* * 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()里把原来的实验入口换成它本实验不需要任务也不需要调度器​​​​​​​int main( void ) { NVIC_PriorityGroupConfig( NVIC_PriorityGroup_4 ); USART1_Init( 115200 ); heap_demo(); /* 跑完停在死循环 */ for( ; ; ); }实验输出快照空闲字节最大整块空闲块数分配A-E后368036801分配F吃掉尾部后000释放B、D后64322申请150字节(第一次)失败空闲 64 需要 160br /br /释放C后(关键)1761761申请150字节(第二次)成功从合并出的 176 整块里拿到br /br /全部释放后408040801七个快照连起来看一遍紫 空闲、灰 已分配三个现象进源码前先记下释放让堆变碎放掉 B、D 两个 32 字节的小块空闲总数涨了 64但最大整块从 3680 跌到 32、空闲块从 1 变 2——空闲字节是回来了可整块能力碎没了。这时候谁要 150 字节分配必然失败——第 4 步真的试了一次返回 NULL释放让堆变整放掉 C 这一块空闲字节只涨 11264→176但空闲块从 2 变 1、最大整块从 32 跳到 176。释放了一块内存块数反而变少——说明 C 根本没有作为新块挂进链表它并进了左邻 B 的地盘顺手又吞了右舍 D。三个孤岛3211232就此合成了一个 176 的整块满血复原同一个 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 段直到第一次pvPortMallocprvHeapInit才被调用判断依据pxEnd NULL。​​​​​​​/* 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被链接器摆在0x20000A2Cmap 文件可查尾数 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设成 1ucHeap数组改由你自己提供链接器不再分配——本工程是 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() (节选) */ 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 在插回之前多做两件事查前邻、查后邻物理相邻就当场粘成一个块​​​​​​​/* 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 144C 并入 B不再单独存在查后邻(BC) 的尽头 D地址B 地址 144 正好落在 D 上——成立。再吞 DxBlockSize 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−16016不大于 16不分裂——这 16 字节并进块头的大小里xBlockSize记 176释放时整块归还用户拿到的还是自己申请的 150多出的 16 字节谁也不用。同一个申请中间只差一步释放 C——没有 4.5 的合并它永远失败三个孤岛最大才 112-全部释放后满血放 AA 自己单独成一块1 块放 pvB那个 176和 A 相邻并成 288放 E并成 400放 F并成 4080。链表回到只有一个大块的状态和开机第一刻一样。每次释放都顺手合并堆就永远回得到起点——这就是 heap_4 的碎片治理。思考一下把第 5 步换成释放 E三个数会怎么变先自己推一遍再往下看。提示第 4 步结束时 E 的左邻是 D空闲右邻是 F还在用。答案只能单向合并。E 并入前邻 D32 112 144右邻 F 在用合不动。E 并没有成为新块它并进了 D 的块里。格局变成释放 C实验释放 E推演空闲字节176176最大整块176144空闲块数12B、DE 还是两块两个值得记的结论块数 2→2一个没少。释放 C 是双向合并前后都空着块数减一释放 E 只有半边空并进 D——既不添新块也不减旧块同样腾出 112 字节位置决定收益。三个数看着差别不大再走一步就见分晓这时申请 150需要 160 的整块144 160照样失败——空闲总数和实验里一模一样是 176malloc 却拿不出。碎片最气人的地方就在这空闲总数明明够最大整块却不够。补一刀如果先释放 E、再释放 C 呢C 的前邻 B32、后邻 DE144这时都是空的一次双向合并串成32112144 288的巨块空闲块 2→1。释放顺序不影响最终合出多大但影响每一步中间有没有整块可用——这也是为什么工程上建议分配和释放尽量成对谁申请谁释放后申请的先还——块归还的顺序越贴近分配的逆序像栈一样中间状态就越不容易碎。4.6 怎么管理三个水位 一次体检堆跑起来了怎么知道它健不健康heap_4 的管理数据刚好凑齐一套体检指标​​​​​​​/* 三个水位 */ size_t xNow xPortGetFreeHeapSize(); /* 现在剩多少 */ size_t xLow xPortGetMinimumEverFreeHeapSize(); /* 历史最惨剩多少 */ /* 一次体检HeapStats_tportable.h */ HeapStats_t xStats; vPortGetHeapStats( xStats );字段告诉你什么怎么用xAvailableHeapSpaceInBytes当前空闲总数同 xPortGetFreeHeapSizexSizeOfLargestFreeBlockInBytes最大整块和空闲总数一比差得越远堆越碎xSizeOfSmallestFreeBlockInBytes最小整块碎到什么程度了xNumberOfFreeBlocks空闲块数量越大越碎实验里那个 2→1 说的就是它xMinimumEverFreeBytesRemaining历史最低空闲同 xPortGetMinimumEverFreeHeapSizexNumberOfSuccessfulAllocations累计分配次数和释放次数长期对比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 1free 顺手 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 没有任何魔法它只是在你每次还内存的时候顺手看了看这笔内存两边的邻居是不是也闲着。
返回列表