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