本节摘要:碎片是「总余量够、连续块不够」的慢性病:单次分配都正常,累积数周后系统突然无块可用。本节讲清内外碎片的成因与演化过程,给出一套让碎片在结构上无法成形的工程组合拳,以及事后监控的接口用法。
设备在实验室连续跑了几个星期一切正常,发到现场后开始隔三差五报「缓冲区申请失败」,可日志里的堆余量明明还有几 KB。这类工单十有八九指向碎片——总余量被切割得七零八落,最大连续块已经小于任何一个常规请求。碎片的特点是与时间做交易:每次分配都在小账上占便宜(灵活、按需),账单在长周期上慢慢寄到。本节的任务是让你在签订这笔交易之前看清条款,并给出免单的方法。
外部碎片发生在块与块之间:内存被切成许多小块,总量可观,但每一块都小于请求尺寸。它是「分配与释放次序错位」的产物,治法是让分配尺寸规整化。内部碎片发生在块之内:为了规整,块按固定尺寸发放,请求一百字节的对象拿到一百二十八字节的块,多出的空间在块内浪费。它是「规整化」的代价,治法是按尺寸分档,让每档贴近其服务对象的典型大小。两类碎片互为跷跷板:越想压外碎片,块越规整,内碎片越多;越想省内碎片,块越按需,外碎片越旺。工程答案不是消灭两者,而是把两者的总和压到可控。
用一个具体序列看清外部碎片的成病过程。设堆总容量十格,按时间发生如下操作:先分配三块各两格的缓冲,堆剩下四格连续;其中一块被释放,堆里出现一个两格的空洞;再分配一个三格的对象——两格空洞装不下,只好切走末尾那格,空洞依旧;随后释放的块越来越多、尺寸不一,空洞散落各处;最终哪怕空闲总量有五格,来了一个四格请求也无处安放。注意全程没有任何一次分配「失败得没有道理」——每一步都合法,病是次序与尺寸的错位攒出来的。这就是为什么碎片问题在几小时的测试里从不现身:它需要长周期、多轮次的分配释放循环才能显形。

结构性的防线按优先级排列。第一拳,消灭变长:把同一类需求归并成固定尺寸,用 5.1 节的静态池服务——池在结构上不可能产生外部碎片,块只有「用着」与「空闲」两态。第二拳,尺寸分档:确需多种尺寸时,按对象家族设两三档(比如小档六十四字节、中档二百五十六字节、大档一千零二十四字节),档内规整、档间隔离,每一档各自成池,比万能堆的碎片行为可控得多。第三拳,启动期分配:把变长对象集中在初始化阶段创建、终身持有,运行期的分配路径上就什么都不剩——堆一型管理器(只分配不回收)在这类设计里反而是最优解,因为它对「只借不还」免疫。第四拳,单一所有者:每块内存有明确的创建者与归还者,杜绝「半路转手」导致的释放时机紊乱,这条与 4.2 节指针传递的快用快还纪律是同一件事在内存侧的投影。
四拳打完,运行期堆分配的次数趋近于零,外部碎片自然无处生长。剩下的内部碎片由分档精度决定,属于用少量浪费换确定性的一笔划算交易。
防线之外要有仪表。多数内核提供三个关键读数:当前空闲量、历史最低空闲量、堆内最大连续块。历史最低值尤其重要——它记录的是系统在最长的一次压力里差多少见底,比当前值诚实得多:
/* 运行期堆仪表:建议挂进低优先级的监控任务周期上报 */ size_t now = xPortGetFreeHeapSize(); /* 当前空闲 */ size_t low = xPortGetMinimumEverFreeHeapSize(); /* 历史最低:预算真相 */ /* 部分内核还提供最大连续块查询:外碎片的直接指标 */
工程判读规则:历史最低空闲低于总量的两成,说明预算太紧,回去扩池;空闲充足但最大连续块持续下滑,说明有碎片在暗中生长,按「谁在运行期反复分配」排查——监控任务、日志格式化、协议解析是最常见的三个嫌疑人。
问:没有 MMU 就不能整理碎片了吗? 对。碎片整理需要移动已分配块并更新全部指针,这在无地址重映射的芯片上等于重新设计整个内存系统。所以嵌入式治碎片的正确思路从来不是「事后整理」而是「结构上不让它产生」——本节组合拳的全部意义。
问:分档之间能转移吗? 不建议。档间转移(大块切小块、小块合并成大块)就是通用堆干的事,做了它就重新背上碎片的账。让每档独立成池、各管各的,才是分档的意义。
问:只借不还的对象怎么监控? 它们不产生碎片,但占用是单调上升的。给这类对象计数并与设计上限对照,越过八成即告警——监控的是「耗尽」而不是「碎片」。
问:历史最低余量怎么解读才科学? 结合负载场景看:最低值出现在哪个用例之后?如果那正是边界压力用例,说明预算贴边但合理;如果出现在普通业务流里,说明有隐藏的分配大户需要排查。
问:首次适配与最佳适配怎么选? 实测普遍显示首次适配(取第一块放得下的)碎片行为更稳:最佳适配留下的小尾巴更难再用。但这是通用堆层面的话题——运行期本不该有变长分配,这个选择多数时候影响的是启动期那几次分配的整洁度。
把本节方法固化成设计文档里的一节,模板如下:内存对象家族表(家族、来源、尺寸、深度、峰值并发、耗尽策略);堆使用声明(允许分配的阶段、允许的调用方、生命周期规则);监控方案(三个仪表读数的采样点与上报频率);回归判据(浸泡测试后历史最低余量不低于总量两成、最大连续块无下降趋势)。若团队沿用现成的内存组件,把「来源」列换成组件名并在评审时对照其实现——别人的实现也是实现,同样接受质询。这份模板配合第九章的设计七步法,让内存设计从口头约定变成可评审、可回归的工程件。碎片问题的终极答案不是某个精巧算法,而是一纸人人遵守的供给纪律。