45、内存池设计:固定大小内存池,伙伴算法,slab分配器思想
内存管理这个话题,说实话,是嵌入式开发里绕不开的坎儿。我早年做一个小型路由器项目时,系统跑着跑着就莫名其妙地重启了。查了三天,最后发现是频繁的 malloc/free 把堆内存切得支离破碎——碎片化导致的分配失败。从那以后,我对内存池设计就格外上心。
今天咱们聊聊三种经典的内存池方案:固定大小内存池、伙伴算法、slab 分配器。它们各有各的脾气,也各有各的用武之地。
为什么需要内存池?
标准库的 malloc 虽然方便,但在嵌入式场景下有几个硬伤:
- 分配时间不确定——可能因为查找空闲块而耗时波动
- 内存碎片——频繁分配释放后,空闲块变得又小又散
- 缺乏实时性保障——对于 RTOS 环境,这可能是致命的
内存池的思路很简单:提前申请一大块连续内存,然后由我们自己来管理分配和释放。说白了,就是把动态分配变成半静态分配。
固定大小内存池
这是最朴素、也最可靠的一种方式。我习惯叫它「等分蛋糕法」。
核心思想:把一块大内存切成若干个大小相同的块,每个块用链表串起来。分配时取一个块,释放时还回去。
适用场景:系统中频繁分配和释放固定大小的对象,比如网络数据包、任务控制块、消息队列节点。
// 固定大小内存池的简化实现
typedef struct {
void *next; // 指向下一个空闲块
} BlockHeader;
typedef struct {
void *free_list; // 空闲块链表头
size_t block_size; // 每个块的大小
size_t block_count; // 块的总数
void *pool_start; // 内存池起始地址
} FixedPool;
void pool_init(FixedPool *pool, void *memory,
size_t block_size, size_t block_count) {
pool->free_list = memory;
pool->block_size = block_size;
pool->block_count = block_count;
pool->pool_start = memory;
// 将内存块串成链表
char *ptr = (char *)memory;
for (size_t i = 0; i < block_count - 1; i++) {
BlockHeader *header = (BlockHeader *)ptr;
header->next = ptr + block_size;
ptr += block_size;
}
// 最后一个块指向 NULL
((BlockHeader *)ptr)->next = NULL;
}
void *pool_alloc(FixedPool *pool) {
if (pool->free_list == NULL) return NULL;
BlockHeader *block = (BlockHeader *)pool->free_list;
pool->free_list = block->next;
return (void *)block;
}
void pool_free(FixedPool *pool, void *ptr) {
BlockHeader *block = (BlockHeader *)ptr;
block->next = pool->free_list;
pool->free_list = block;
}
你看,代码就这么几行。分配和释放都是 O(1) 的复杂度,没有任何碎片问题。但缺点也很明显:只能分配固定大小的块。如果你要分配一个比块大的对象,那就没辙了。
我的经验:在项目中,我通常会给每种常用对象类型单独建一个固定大小内存池。比如网络协议栈里,TCP 控制块用一个池,UDP 控制块用另一个池。这样既避免了碎片,又提高了缓存命中率。
伙伴算法
伙伴算法解决了「只能分配固定大小」的问题。它支持 2 的幂次大小的分配,而且合并相邻空闲块来减少碎片。
核心思想:把内存按 2 的幂次分成不同大小的块。每个大小的块用链表管理。分配时,如果找不到合适大小的块,就分裂一个更大的块。释放时,如果相邻的块也是空闲的,就合并成一个更大的块。
嗯,这里要注意:「相邻」是有条件的——两个块必须是由同一个大块分裂出来的,它们才是「伙伴」。
// 伙伴算法的核心数据结构
#define MAX_ORDER 10 // 最大阶数,2^10 = 1024 字节
typedef struct {
void *free_list[MAX_ORDER + 1]; // 每个阶的空闲链表
void *base; // 内存基地址
size_t total_size; // 总大小
} BuddySystem;
// 分配:从指定阶开始查找,必要时分裂
void *buddy_alloc(BuddySystem *buddy, size_t size) {
int order = get_order(size); // 计算需要的阶数
for (int i = order; i <= MAX_ORDER; i++) {
if (buddy->free_list[i] != NULL) {
// 找到空闲块,从链表中取出
void *block = buddy->free_list[i];
buddy->free_list[i] = get_next(block);
// 如果阶数比需要的更大,分裂
while (i > order) {
i--;
void *buddy_block = (char *)block + (1 << i);
// 将伙伴块加入低一阶的空闲链表
set_next(buddy_block, buddy->free_list[i]);
buddy->free_list[i] = buddy_block;
}
return block;
}
}
return NULL; // 没有足够的内存
}
伙伴算法的好处是:外部碎片很少,而且分配和释放的速度也很快(O(log N))。但内部碎片是有的——比如你申请 100 字节,它给你 128 字节的块,那 28 字节就浪费了。
我曾经踩过的坑:在一个实时音频处理系统里,我用伙伴算法管理音频缓冲区。结果发现频繁的 3 字节、5 字节分配导致内部碎片率高达 40%。后来我加了一层小对象缓存,把小于 64 字节的分配用固定大小池处理,问题才解决。
Slab 分配器思想
Slab 分配器是 Linux 内核里用的方案。它结合了固定大小池和伙伴算法的优点。
核心思想:
- 每种对象类型(比如 task_struct、inode)都有自己的 slab 缓存
- 每个 slab 缓存由若干个 slab 组成,每个 slab 是一块连续内存
- 每个 slab 被分成若干个对象大小的槽位
- 分配时从 slab 中取一个空闲槽位,释放时还回去
说白了,就是为每种对象类型定制一个专属的内存池。
// Slab 分配器的简化结构
typedef struct slab {
void *free_list; // 空闲对象链表
struct slab *next; // 指向下一个 slab
struct slab *prev; // 指向上一个 slab
int free_count; // 空闲对象数量
int total_count; // 对象总数
} Slab;
typedef struct {
size_t object_size; // 对象大小
int objects_per_slab; // 每个 slab 的对象数
Slab *slabs_full; // 全满的 slab 链表
Slab *slabs_partial; // 部分空闲的 slab 链表
Slab *slabs_free; // 全空的 slab 链表
} SlabCache;
Slab 分配器还有一个很妙的设计:对象着色(coloring)。每个 slab 的起始地址故意偏移几个字节,让不同 slab 中的对象落在不同的缓存行上。这样可以减少 CPU 缓存行冲突,提升性能。
我个人的习惯:在嵌入式 Linux 或 RTOS 项目中,如果内核已经提供了 slab 接口,我直接用它。如果是裸机项目,我会自己实现一个简化版——只保留「每个对象类型一个池」的核心思想,去掉着色等高级特性。够用就好,别过度设计。
三种方案对比
| 特性 | 固定大小池 | 伙伴算法 | Slab 分配器 |
|---|---|---|---|
| 分配复杂度 | O(1) | O(log N) | O(1) |
| 释放复杂度 | O(1) | O(log N) | O(1) |
| 内部碎片 | 无(大小精确) | 有(2 的幂次对齐) | 无(大小精确) |
| 外部碎片 | 无 | 很少 | 无 |
| 适用场景 | 单一固定大小对象 | 多种大小,2 的幂次 | 多种对象类型,每种固定大小 |
| 实现复杂度 | 低 | 中 | 高 |
核心逻辑图
下面这张图展示了三种方案的核心逻辑对比。你想想看,它们其实是在「灵活性」和「效率」之间做了不同的取舍。
如何选择?
说实话,没有银弹。我一般这样选:
- 如果系统中只有一两种固定大小的对象频繁分配——用固定大小池,简单可靠。
- 如果分配大小范围广,且都是 2 的幂次——用伙伴算法,比如文件系统缓存。
- 如果对象类型多,每种大小固定——用 slab 思想,每种类型一个池。
- 如果资源极度紧张(比如只有几 KB RAM)——固定大小池是唯一选择,伙伴算法的管理开销太大。
注意:无论用哪种方案,都要考虑内存对齐。ARM Cortex-M 系列要求 4 字节对齐,有些 DSP 要求 8 字节对齐。不对齐的话,轻则性能下降,重则硬件异常。
好了,关于内存池设计就聊到这儿。这三种方案你可以在自己的项目里试试看,从最简单的固定大小池开始,慢慢体会它们各自的优缺点。实践出真知嘛。
公众号:蓝海资料掘金营,微信deep3321