MCU 内存管理算法:从 FreeRTOS heap_4 讲起
在 MCU 开发中,动态内存管理一直是个争议点。有人从安全的角度去讲:绝大多数 MCU 都没有 MMU(内存管理单元),而且 RAM 本身就小,使用动态内存不仅给长时间运行带来了内存耗尽的挑战,也容易在开发时引入内存泄漏的问题。但也有人觉得,动态内存是一种进阶的用法,满足物尽其用、不浪费每一分资源的要求,况且很多久经验证的 RTOS 也都用了动态内存。一看,仍然是各有各的道理,那么为什么有些使用了 RTOS 的系统在长时间运行中也并不会出现内存耗尽的情况呢?
以 FreeRTOS 为例,它提供了五种内存管理方案(heap_1 到 heap_5),通过选择不同的算法来应对不同的应用场景。它们的核心区别如下:
| 方案 | 是否可动态释放 | 碎片处理 | 线程安全 | 主要特点与适用场景 |
|---|---|---|---|---|
| heap_1 | ❌ 不可释放 | 无碎片 | 安全 | 只能申请,不能释放。极简单,适用于任务数量固定的场景。 |
| heap_2 | ✅ 可释放 | 不合并相邻块 | 安全 | 简单快速,但长期运行会产生严重内存碎片。不推荐用于反复申请释放的场景。 |
| heap_3 | ✅ 可释放 | 取决于编译器 | 需自行加锁 | 直接封装标准库的 malloc/free。不做碎片整理,线程安全需自己保证。 |
| heap_4 | ✅ 可释放 | 相邻块合并 | 安全 | 最常用的方案。通过合并相邻空闲块来避免碎片,适合长时间运行。 |
| heap_5 | ✅ 可释放 | 相邻块合并 | 安全 | 与 heap_4 类似,但允许跨多个不连续的物理内存区域分配。 |
简单总结:heap_1 和 heap_4 是实际工程中最受青睐的两个方案。前者靠“完全不释放”来彻底规避碎片和耗尽风险;后者靠“释放时把相邻空闲块拼回去”来长期维持可用空间。
如果把堆内存想成一整排储物柜,那么内存管理算法本质上就在解决两件事:
来了一个新包裹,应该塞进哪个空柜子?
取走旧包裹之后,空出来的柜子要不要和旁边空柜子拼成更大的柜子?
不同算法的差别,主要就体现在这两件事上。
先把 FreeRTOS 的五种方案讲透
heap_1:只分配,不回收
heap_1 最容易理解:内存一旦分出去,就永远不收回来。它像是在白纸上一直往后写字,写过的地方就不再回头改。
这样做的好处非常直接:
不需要考虑释放后的碎片问题。
实现极简单,出错点最少。
行为很稳定,特别适合启动阶段就把任务、队列、信号量一次性创建完的系统。
它的代价也很明显:只要系统里有“反复创建又销毁”的需求,heap_1 就不适合,因为它根本没有“还回去”的能力。
举例:
假设系统只有 8 KB 堆空间,开机后依次创建:
任务 A,用掉 2 KB
任务 B,用掉 2 KB
消息队列,用掉 1 KB
定时器对象,用掉 512 B
这时总共用了 5.5 KB,还剩 2.5 KB。只要后面不再动态申请,系统就可以一直稳定运行。因为根本没有释放动作,所以也就不会有碎片。
如果画成一排内存格子,大致像这样:
初始 8 KB:
[ 全部空闲 8 KB ]
依次分配后:
[任务A 2 KB][任务B 2 KB][队列 1 KB][定时器 0.5 KB][剩余空闲 2.5 KB]
初始 8 KB:
[ 全部空闲 8 KB ]
依次分配后:
[任务A 2 KB][任务B 2 KB][队列 1 KB][定时器 0.5 KB][剩余空闲 2.5 KB]
heap_2:可以释放,但不把碎片重新拼起来
heap_2 比 heap_1 多了一步:允许释放内存。但它的问题在于,释放之后,空闲块只是回到空闲链表里,不会主动和左右相邻的空闲块合并。
这就很像一排储物柜,中间有些柜子空了,但管理员只是在门上贴了“空闲”标签,却不把连在一起的小柜子拆掉重新拼成大柜子。时间一长,空柜子会越来越零碎。
举例:
假设有一段 4 KB 堆空间,先后分配:
A 用 1 KB
B 用 1 KB
C 用 1 KB
此时还剩最后 1 KB 空闲。
如果后来释放了 B,那么堆上的空闲情况就变成:
中间有 1 KB 空闲
末尾有 1 KB 空闲
虽然总空闲量加起来有 2 KB,但它们不挨着。此时如果再来一个 1.5 KB 的申请,请求仍然可能失败,因为找不到一块连续的 1.5 KB 空间。
这就是 heap_2 的典型问题:明明还有空闲,但大块申请过不去。
可以把它画成这样:
最开始分配后:
[A 1 KB][B 1 KB][C 1 KB][空闲 1 KB]
释放 B 之后:
[A 1 KB][空闲 1 KB][C 1 KB][空闲 1 KB]
此时总空闲 = 2 KB
但没有任何一块连续空闲 >= 1.5 KB
所以申请 1.5 KB 仍可能失败
最开始分配后:
[A 1 KB][B 1 KB][C 1 KB][空闲 1 KB]
释放 B 之后:
[A 1 KB][空闲 1 KB][C 1 KB][空闲 1 KB]
此时总空闲 = 2 KB
但没有任何一块连续空闲 >= 1.5 KB
所以申请 1.5 KB 仍可能失败
heap_3:直接把问题交给标准库
heap_3 的思路是:FreeRTOS 自己不设计分配器,而是直接调用编译器或 C 运行库提供的 malloc/free。
你可以把它理解成:RTOS 不自己管仓库,而是把仓库钥匙交给外部管理员。至于管理员怎么摆货、怎么清理、效率高不高,都取决于这个外部管理员本身。
它的优点是省事,因为很多工具链已经自带 malloc/free;缺点是可控性差,而且线程安全通常要开发者自己兜底。
举例:
假设你在单线程裸机程序里用 malloc/free,一般问题不大。但搬到 RTOS 后,任务 A 和任务 B 可能同时申请内存。如果底层标准库本身没有加锁,就可能出现两个任务同时改一份堆管理数据,最后把堆结构搞乱。
所以 heap_3 不是不能用,而是它把关键风险转移到了“标准库质量”和“你有没有正确加锁”上。
heap_4:第一个够用就拿,释放时再把相邻空闲块拼起来
heap_4 是 FreeRTOS 里最常用的一种方案。它的核心思路非常务实:
分配时,不追求“最完美”,而是找到第一个装得下的空闲块就用。
释放时,不让碎片散着不管,而是立即看看左右邻居能不能拼回去。
这套办法的优点是:实现不复杂,运行速度也不错,而且能有效压制碎片累积。所以很多需要长期运行的 MCU 项目,都会优先选 heap_4。
举例:
假设当前空闲块依次是 80 B、200 B、60 B。现在要申请 70 B:
heap_4 不会去全局比较哪块最合适。
它从前往后看,发现第一个 80 B 已经够了,就直接从这块里分 70 B。
剩下 10 B 留在原地,继续作为空闲块存在。
这种做法不一定“最省”,但非常直接,整体效果通常已经足够好。
heap_5:heap_4 的“多仓库版本”
heap_5 可以看作是 heap_4 的增强版。它的分配和释放规则,和 heap_4 基本一样;最大的不同在于:heap_5 允许你把几段互不连续的物理内存,拼成一个统一可分配的堆。
这在 MCU 上很常见,因为很多芯片的 RAM 并不是一整块连续空间,而是分成片上 SRAM、CCM、外扩 RAM 等不同区域。
举例:
假设一颗芯片上有两段可用内存:
内部 SRAM 64 KB
外部 SRAM 256 KB
这两段地址并不连着。对于 heap_4 来说,它通常更适合管理一整段连续区域;而 heap_5 则可以把这两段都登记进来,统一对外提供“分配内存”的能力。这样应用层不需要分别管理两套堆。
如果用图来表示,就是:
区域 1:内部 SRAM
[ 64 KB ]
区域 2:外部 SRAM
[ 256 KB ]
heap_5 会把这两段都纳入管理,
应用层看到的是“一个可分配内存池”,
底层再决定这次该从哪一段里拿空间。
区域 1:内部 SRAM
[ 64 KB ]
区域 2:外部 SRAM
[ 256 KB ]
heap_5 会把这两段都纳入管理,
应用层看到的是“一个可分配内存池”,
底层再决定这次该从哪一段里拿空间。
heap_4 详解:First-fit + 释放合并
heap_4 采用的是 首次适应(First Fit)。一句人话概括就是:
先找到第一个装得下的空位,先用上;等东西拿走了,再把挨在一起的空位重新拼大。
什么是 First-fit?
First-fit 会维护一条按地址从低到高排序的空闲块链表。每当有新的内存申请,它就从头开始看:
第一个空闲块太小,跳过
第二个空闲块还太小,继续跳过
直到遇到第一个足够大的空闲块,就直接使用
如果这个块比需求大很多,它会把这个块切成两部分:
一部分真正分给申请者
另一部分继续留在空闲链表里
如果一直走到链表末尾都没找到合适的块,那就说明当前没有足够大的连续空闲空间。
它的特点不是“最省”,而是“够简单、够快”。对于 MCU 这种资源有限、代码要稳的场景,这个取舍非常实际。
First-fit 的一步一步例子
假设当前空闲链表里有三块空间,按地址顺序排列:
80 B
200 B
60 B
现在来了一个 70 B 的申请。
First-fit 的动作是:
先看 80 B,发现已经够用。
不再继续看后面的 200 B 和 60 B。
从 80 B 里切出 70 B 分配出去。
剩下 10 B 继续留在链表里。
处理完之后,空闲块就可能变成:
10 B
200 B
60 B
接着如果又来了一个 50 B 的申请:
先看 10 B,不够。
再看 200 B,够了。
从 200 B 里切出 50 B。
剩余 150 B 留在原位。
可以看到,First-fit 不会为了“更节省”去挑后面那块 60 B,而是按顺序找到第一个能装下的就处理。这就是它简单高效的原因。
把这个过程画出来会更直观:
初始空闲块:
[空闲 80 B][空闲 200 B][空闲 60 B]
申请 70 B 之后:
[已分配 70 B][空闲 10 B][空闲 200 B][空闲 60 B]
再申请 50 B 之后:
[已分配 70 B][空闲 10 B][已分配 50 B][空闲 150 B][空闲 60 B]
初始空闲块:
[空闲 80 B][空闲 200 B][空闲 60 B]
申请 70 B 之后:
[已分配 70 B][空闲 10 B][空闲 200 B][空闲 60 B]
再申请 50 B 之后:
[已分配 70 B][空闲 10 B][已分配 50 B][空闲 150 B][空闲 60 B]
释放后为什么要合并?
只靠 First-fit 还不够。因为你反复申请和释放之后,空闲空间很容易被切成很多小块。
这里有两个容易混淆但必须搞清楚的概念:
外部碎片:空闲内存总量明明不少,但被切成很多零散小块,拼不出一块够大的连续空间。
内部碎片:分配出去的块比实际需求大,块内部剩下一点用不上的边角料。
heap_4 主要解决的是前一种,也就是“空闲空间零零碎碎”的问题。
它的做法很直接:释放某块内存时,马上检查它前后两边的空闲块是不是物理上挨着。只要挨着,就合并。
因为空闲链表本身就是按地址排序的,所以查前驱和后继都比较方便。这样一来,原本分散的小块,只要重新连在一起,就能被恢复成更大的连续空间。
释放合并的例子
假设堆里的布局是这样的:
0 到 99:A 正在使用
100 到 199:空闲
200 到 299:B 正在使用
300 到 399:空闲
现在如果 B 被释放,那么 200 到 299 这一段也空出来了。此时 heap_4 会检查:
左边的 100 到 199 是不是空闲?是。
右边的 300 到 399 是不是空闲?也是。
于是三段会被直接合并成一整块:
100 到 399:总共 300 B 的连续空闲空间
这个合并动作非常关键。因为如果不合并,你手里只是三块分散的 100 B;如果合并了,你就得到一块完整的 300 B。后面再来一个 220 B 的申请,就能成功;不合并的话,这个申请就只能失败。
正是“分配时图省事,释放时认真收拾现场”这套组合,让 heap_4 在实现复杂度和长期稳定性之间取得了很好的平衡。
对应的内存布局如下:
释放 B 之前:
[A 使用 100 B][空闲 100 B][B 使用 100 B][空闲 100 B]
释放 B 之后,heap_4 立刻合并:
[A 使用 100 B][ 连续空闲 300 B ]
如果不合并,就只能看到:
[A 使用 100 B][空闲 100 B][空闲 100 B][空闲 100 B]
释放 B 之前:
[A 使用 100 B][空闲 100 B][B 使用 100 B][空闲 100 B]
释放 B 之后,heap_4 立刻合并:
[A 使用 100 B][ 连续空闲 300 B ]
如果不合并,就只能看到:
[A 使用 100 B][空闲 100 B][空闲 100 B][空闲 100 B]
从 First-fit 延伸到其他常见算法
理解了 heap_4 之后,再看其他算法就容易多了。它们本质上还是在回答同样的问题:新请求该落在哪块空闲空间里?释放后要怎么恢复出更大的可用空间?
Next-fit(下次适应)
Next-fit 可以看作 First-fit 的“接着上次位置继续找”。
First-fit 每次都从链表头开始找;Next-fit 则会记住“上次找到哪里了”,下一次从那个位置继续往后找,走到末尾再绕回开头。
你可以把它想成一个绕操场找空位的人:
First-fit 每次都从起跑线出发
Next-fit 每次都从上次停下来的地方继续跑
举例:
假设空闲块依次是:12 B、40 B、18 B、64 B。
上一次分配刚好停在 40 B 那块附近。现在来了一个 16 B 的申请:
Next-fit 不会回头先看前面的 12 B 和 40 B
它会先看后面的 18 B
发现够用,就直接从 18 B 里切出 16 B
也就是说,哪怕前面还有更大的 40 B,它也不会优先回头使用。
这样做的好处是,分配压力不会总是集中在低地址区域;坏处是行为不如 First-fit 直观,结果也更依赖“上次停在哪里”,所以可预测性差一些。
画出来就是:
当前空闲块:
[12 B][40 B][18 B][64 B]
^
上次停在这里
现在申请 16 B,Next-fit 的搜索顺序是:
18 B -> 成功
分配后:
[12 B][40 B][已分配 16 B][空闲 2 B][64 B]
当前空闲块:
[12 B][40 B][18 B][64 B]
^
上次停在这里
现在申请 16 B,Next-fit 的搜索顺序是:
18 B -> 成功
分配后:
[12 B][40 B][已分配 16 B][空闲 2 B][64 B]
Best-fit(最佳适应)
Best-fit 的想法很朴素:既然要分配,那就尽量找一块“刚刚好”的空闲块,别浪费大块。
所以它通常会把所有候选空闲块都看一遍,然后挑出那个“最接近需求大小”的块来分配。
听起来很聪明,但它有个常见副作用:特别容易留下很多很小、很尴尬的碎片。
举例:
假设当前有三块空闲空间:
100 B
24 B
40 B
现在要申请 20 B。
Best-fit 会怎么选?
它不会用 100 B,因为太大了
它也不会优先用 40 B,因为 24 B 更接近 20 B
所以它会选择 24 B 那一块
这样分完以后,会剩下一个 4 B 的小碎片。
一次看起来还好,但如果后面反复发生:
24 B 分给 20 B,剩 4 B
40 B 分给 33 B,剩 7 B
18 B 分给 16 B,剩 2 B
堆里就会慢慢积累很多 2 B、4 B、7 B 这种小零头。它们加起来也许不少,但单独拿出来几乎没法用。于是系统就会出现“还有空闲,总也申请不到大块”的情况。
所以 Best-fit 看似节省,实际上很容易把空闲空间“切成渣”。
用图看会很清楚:
初始空闲块:
[100 B][24 B][40 B]
申请 20 B,Best-fit 选择最接近的 24 B:
[100 B][已分配 20 B][空闲 4 B][40 B]
继续多次发生后,可能变成:
[100 B][4 B][7 B][2 B][ ... ]
这些小碎片加起来不少,
但单块都太小,很难再派上大用场。
初始空闲块:
[100 B][24 B][40 B]
申请 20 B,Best-fit 选择最接近的 24 B:
[100 B][已分配 20 B][空闲 4 B][40 B]
继续多次发生后,可能变成:
[100 B][4 B][7 B][2 B][ ... ]
这些小碎片加起来不少,
但单块都太小,很难再派上大用场。
伙伴系统(Buddy System)
伙伴系统的思路很有特点:内存块大小尽量保持为 2 的幂,比如 16、32、64、128、256 这样。
如果你要一块内存,系统会把请求向上凑到最近的 2 的幂大小;如果没有现成的小块,就把更大的块不断对半拆开。
“伙伴”这个名字的意思是:同一次拆分出来的两半,互为伙伴。 如果一块释放后发现它的伙伴也空着,那么这两块就可以重新合并成更大的块。
举例:
假设系统一开始有一整块 1024 B 空闲空间。现在来了一个 100 B 的申请。
伙伴系统会这么做:
100 B 不能直接分到 100 B,因为伙伴系统偏爱 2 的幂。
它会把需求向上取整到 128 B。
如果当前只有 1024 B 大块,就把它拆成两个 512 B。
再把其中一个 512 B 拆成两个 256 B。
再把其中一个 256 B 拆成两个 128 B。
最后把其中一个 128 B 分出去。
这样做的好处是,分裂和合并都很规整,速度很快;缺点是会有明显的浪费。比如你只要 100 B,最后却拿到 128 B,其中 28 B 其实是白白占着的。这部分浪费就是内部碎片。
再看释放:
如果这个 128 B 被释放时,它的那个“同胞 128 B”也正好空着
那么两者就能合并回 256 B
如果与之配对的 256 B 也空着,还能继续向上合并回 512 B
所以伙伴系统恢复大块空间的能力很强,只是它用“按 2 的幂对齐”换来了额外浪费。
这个拆分过程可以画成:
初始:
[ 1024 B 空闲 ]
拆成两半:
[512 B][512 B]
左半继续拆:
[256 B][256 B][512 B]
再拆一次:
[128 B][128 B][256 B][512 B]
最后分配一个 128 B 给“申请 100 B”的请求:
[已分配 128 B][空闲 128 B][空闲 256 B][空闲 512 B]
虽然只要 100 B,但实际拿到 128 B,
多出来的 28 B 就是内部碎片。
初始:
[ 1024 B 空闲 ]
拆成两半:
[512 B][512 B]
左半继续拆:
[256 B][256 B][512 B]
再拆一次:
[128 B][128 B][256 B][512 B]
最后分配一个 128 B 给“申请 100 B”的请求:
[已分配 128 B][空闲 128 B][空闲 256 B][空闲 512 B]
虽然只要 100 B,但实际拿到 128 B,
多出来的 28 B 就是内部碎片。
TLSF(Two-Level Segregated Fit)
TLSF 可以理解成一种“分桶特别细、找块特别快”的分配器。它主要服务于实时系统,因为它希望每次分配和释放都能在基本固定的时间内完成,而不是堆越乱、链表越长,查找时间就越飘。
如果不用术语,可以把 TLSF 想成一个很会整理仓库的管理员:
先按“大致大小范围”分一层区
再在每个大范围里按“更细的大小”继续分小格
同时还在门口挂了一块指示牌,告诉你哪些格子目前有货
这样一来,当你要申请一块内存时,就不用一格一格慢慢翻,而是可以很快跳到“最可能合适”的那几格去找。
举例:
假设仓库里当前有这些空闲块:
36 B
40 B
96 B
160 B
现在要申请 90 B。
TLSF 不会像 First-fit 那样从头一个个看,也不会像 Best-fit 那样把所有块都比一遍。它会先判断:
90 B 大致属于“64 到 127 B”这个范围
然后再去这个范围内更细的小分类里找最接近的空闲块
如果这个小分类里正好有 96 B,它就很快定位过去并完成分配。
接着如果要申请 34 B,它又会直接跳到“32 到 63 B”这一类附近,而不是重新扫描所有空闲块。
这就是 TLSF 的价值:查找速度稳定,不容易因为堆里空闲块变多而明显变慢。
可以粗略画成“按大小分层分桶”的样子:
第 1 层:按大范围分组
32~63 B -> [36 B][40 B]
64~127 B -> [96 B]
128~255 B -> [160 B]
申请 90 B 时:
直接跳到 64~127 B 这一组
-> 很快找到 96 B
申请 34 B 时:
直接跳到 32~63 B 这一组
-> 在 [36 B][40 B] 里找合适块
第 1 层:按大范围分组
32~63 B -> [36 B][40 B]
64~127 B -> [96 B]
128~255 B -> [160 B]
申请 90 B 时:
直接跳到 64~127 B 这一组
-> 很快找到 96 B
申请 34 B 时:
直接跳到 32~63 B 这一组
-> 在 [36 B][40 B] 里找合适块
当然,它的代价也不小:实现比 First-fit、Next-fit 复杂得多,代码和数据结构都更重,更适合对实时性要求很高的系统,而不是所有 MCU 项目都必须上。
小结
从 First-fit 到 TLSF,本质上都是在几个目标之间做取舍:
分配和释放要不要足够快
空闲空间要不要尽量少碎片
内存利用率要不要尽量高
实现本身要不要足够简单、好维护
如果用最通俗的话来收尾,可以这样理解:
heap_1:最保守,像记账只记支出不记退款,胜在稳。
heap_2:允许退款,但不整理零钱,时间长了容易乱。
heap_3:把仓库管理外包出去,省事,但风险也一起外包了。
heap_4:不追求最精打细算,但会主动收拾碎片,所以综合表现最好。
heap_5:在 heap_4 的基础上,进一步解决“内存分散在好几块地方”的问题。
伙伴系统:拆分和合并都很规整,速度快,但容易有内部浪费。
TLSF:查找特别稳定,适合实时系统,但实现更复杂。
所以 FreeRTOS 的 heap_4 之所以常用,不是因为它在每个指标上都最强,而是因为它在“够简单、够稳、够能打”这三个点上,取得了非常均衡的结果。理解这些算法的取舍,才能在实际工程里选到真正合适的方案。