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 的幂次 多种对象类型,每种固定大小
实现复杂度 低 中 高

核心逻辑图

下面这张图展示了三种方案的核心逻辑对比。你想想看,它们其实是在「灵活性」和「效率」之间做了不同的取舍。

三种内存池方案核心逻辑对比 固定大小内存池 大块内存 → 等分 → 链表串起 分配:取链表头 O(1) 释放:放回链表头 O(1) 碎片:无内部碎片,无外部碎片 伙伴算法 内存按 2ⁿ 分阶管理 分配:查找 → 分裂 O(logN) 释放:查找 → 合并 O(logN) 碎片:少量内部碎片 Slab 分配器 每种对象类型一个缓存 每个缓存含多个 slab 分配:取空闲槽位 O(1) 释放:还回槽位 O(1) 核心取舍 固定大小池 → 效率最高,灵活性最低 伙伴算法 → 灵活性中等,效率中等 Slab 分配器 → 灵活性最高,实现最复杂 实际项目中,常将多种方案组合使用 选择依据:对象大小分布、实时性要求、实现成本

如何选择?

说实话,没有银弹。我一般这样选:

  1. 如果系统中只有一两种固定大小的对象频繁分配——用固定大小池,简单可靠。
  2. 如果分配大小范围广,且都是 2 的幂次——用伙伴算法,比如文件系统缓存。
  3. 如果对象类型多,每种大小固定——用 slab 思想,每种类型一个池。
  4. 如果资源极度紧张(比如只有几 KB RAM)——固定大小池是唯一选择,伙伴算法的管理开销太大。

注意:无论用哪种方案,都要考虑内存对齐。ARM Cortex-M 系列要求 4 字节对齐,有些 DSP 要求 8 字节对齐。不对齐的话,轻则性能下降,重则硬件异常。

好了,关于内存池设计就聊到这儿。这三种方案你可以在自己的项目里试试看,从最简单的固定大小池开始,慢慢体会它们各自的优缺点。实践出真知嘛。


公众号:蓝海资料掘金营,微信deep3321