3、heap_2实现分析:最佳匹配算法,支持释放,但存在严重碎片问题
好,咱们接着聊 heap_2。说实话,我在刚接触 FreeRTOS 那会儿,heap_2 是我用得最多的一个方案。为什么?因为它支持释放啊!你想想看,heap_1 只能分配不能释放,那在实际项目中基本就是个一次性玩具。但 heap_2 不一样,它引入了最佳匹配算法,让你能动态地申请和归还内存。
不过,用久了你会发现——嗯,这玩意儿也有它的“脾气”。
3.1 最佳匹配算法:到底在匹配什么?
先说说它的核心思想。heap_2 维护了一个空闲块链表,每次你调用 pvPortMalloc() 时,它会遍历这个链表,找到一块“刚刚好”能装下你请求大小的内存块。
什么叫“刚刚好”?就是所有能满足你需求的空闲块中,尺寸最小的那个。这就是“最佳匹配”这个名字的由来。
我举个例子。假设空闲链表里有三个块:大小分别是 100 字节、200 字节、500 字节。你申请 80 字节,那 heap_2 会选 100 字节那个块。为什么?因为 200 和 500 虽然也能用,但浪费太多。选最小的那个,理论上能减少碎片。
分配之后,如果 100 字节的块还剩 20 字节(100 - 80 = 20),它会把这 20 字节作为一个新的空闲块插回链表。这个过程叫“分割”。
// 伪代码示意:最佳匹配的核心逻辑
static BlockLink_t *pxFindSmallestBlock(size_t xWantedSize) {
BlockLink_t *pxBlock = NULL;
BlockLink_t *pxIterator = pxStart->pxNextFreeLink;
size_t xSmallestRemaining = portMAX_DELAY;
while (pxIterator != pxStart) {
if (pxIterator->xBlockSize >= xWantedSize) {
size_t xRemaining = pxIterator->xBlockSize - xWantedSize;
if (xRemaining < xSmallestRemaining) {
xSmallestRemaining = xRemaining;
pxBlock = pxIterator;
}
}
pxIterator = pxIterator->pxNextFreeLink;
}
return pxBlock;
}
你看,这个循环就是挨个比,找到剩余空间最小的那个块。逻辑很直观,对吧?
3.2 释放操作:把内存还回去
heap_2 支持 vPortFree(),这是它和 heap_1 最大的区别。释放时,它会把你归还的内存块重新插入空闲链表。但这里有个细节——它不会做合并。
什么意思?假设你先后分配了 A、B、C 三个相邻的块,然后你释放了 B。B 变成了一块空闲内存。接着你又释放了 A。A 也变成了空闲内存。但 A 和 B 在链表里是两块独立的内存,它们虽然物理上相邻,但 heap_2 不会把它们合并成一块更大的连续内存。
我刚开始用的时候,觉得这没什么大不了的。直到有一次,我在一个需要频繁创建和删除任务的系统里跑测试,跑了大概半天,系统突然报内存分配失败。我查了半天,发现空闲链表里全是零零碎碎的小块,最大的连续块连 100 字节都不到,而我的任务控制块需要 120 字节。这就是典型的碎片问题。
3.3 碎片问题:为什么它这么严重?
咱们来深入分析一下碎片是怎么产生的。其实说白了,就是“分配-释放-再分配”这个循环中,内存被切得越来越碎。
假设你有一块 1000 字节的堆空间。你做了以下操作:
- 分配 100 字节(块 A)
- 分配 200 字节(块 B)
- 分配 150 字节(块 C)
- 释放块 B(200 字节变成空闲)
- 分配 180 字节
这时候会发生什么?heap_2 会去找一个能装下 180 字节的空闲块。空闲链表里有:块 A 后面的 100 字节?不够。块 B 释放的 200 字节?够了!于是它从 200 字节的块里切出 180 字节,剩下 20 字节。
现在堆里的情况是:
| 位置 | 状态 | 大小 |
|---|---|---|
| 0-99 | 已分配(块 A) | 100 |
| 100-119 | 空闲 | 20 |
| 120-299 | 已分配(新块) | 180 |
| 300-449 | 已分配(块 C) | 150 |
| 450-999 | 空闲 | 550 |
看到了吗?中间那个 20 字节的空闲块,基本就废了。下次你申请任何大于 20 字节的内存,它都用不上。这就是碎片。
我曾经在一个网络协议栈的项目里遇到过类似情况。协议栈需要频繁分配和释放不同大小的数据包缓冲区。跑了大概 48 小时后,堆里全是 8 字节、16 字节、32 字节的小碎片,最大的连续块只有 64 字节。而一个标准的数据包需要 256 字节。系统直接瘫痪。
3.4 heap_2 的适用场景
说了这么多问题,那 heap_2 是不是一无是处?当然不是。它有自己的用武之地。
- 场景一: 系统中只有少量动态分配,且分配大小相对固定。比如你只创建几个任务,每个任务栈大小一样,之后就不再动态分配了。
- 场景二: 你明确知道内存使用模式,不会出现频繁的“分配-释放-再分配”循环。
- 场景三: 对实时性要求不是极端苛刻。因为最佳匹配算法需要遍历链表,分配时间不是常数,而是 O(n)。
我个人习惯是:如果项目比较简单,任务数量固定,我就用 heap_2。但如果项目复杂,或者我预感到后期会频繁增删功能,我会直接上 heap_4 或者自己实现一个内存池。
3.5 总结一下
heap_2 是 FreeRTOS 内存管理从“只能分配”到“能分配也能释放”的一次重要进化。它的最佳匹配算法在减少浪费方面做了努力,但因为没有合并机制,碎片问题成了它的阿喀琉斯之踵。
嗯,这里要注意:如果你在项目中遇到了“内存越用越少,但明明释放了很多”的情况,十有八九就是碎片在作祟。这时候别急着怀疑代码有内存泄漏,先看看空闲链表里是不是一堆小碎片。
下一章咱们会讲 heap_4,它解决了碎片问题。说实话,我现在大部分项目都在用 heap_4,因为它更“聪明”。但理解 heap_2 的局限,能帮你更好地理解 heap_4 为什么那么设计。