23. 内存池:从散兵游勇到正规军

说实话,我刚开始写嵌入式程序那会儿,对内存管理这事根本没当回事。需要了就 malloc,用完了就 free,多简单啊。直到有一次,我做一个网络协议栈的项目,设备跑着跑着就卡死了。查了三天,最后发现是频繁的内存分配导致大量碎片,系统再也分配不出一个连续的大块内存了。

嗯,从那以后,我就开始认真研究内存池了。说白了,内存池就是提前申请好一大块内存,然后自己管理它。你想想看,这就像你开公司,与其每次要用人都去人才市场现招,不如直接养一批固定员工,随叫随到。

23.1 内存池到底是个啥?

内存池的核心思想很简单:一次申请,多次使用。我们预先向操作系统要一大块连续内存,然后自己实现分配和释放的逻辑。

为什么要这么做?我总结了几点:

  • 消除碎片:固定大小的内存块,不会产生外部碎片
  • 分配速度快:O(1) 时间复杂度,比 malloc 快一个数量级
  • 确定性高:实时系统里,你不能忍受 malloc 偶尔的耗时抖动
  • 内存泄漏可控:池子用完就回收,不会漏到系统里

核心区别:malloc/free 是通用方案,内存池是专用方案。通用意味着妥协,专用意味着高效。

23.2 简单固定大小内存池的实现

我习惯用空闲链表的方式来实现。每个空闲块的前几个字节存放下一个空闲块的地址。这样,分配和释放都只需要操作链表头,非常快。

来看代码:

#include <stdint.h>
#include <stdlib.h>

// 内存池结构
typedef struct {
    void *pool_start;      // 池起始地址
    size_t block_size;     // 每个块的大小(字节)
    size_t block_count;    // 块的总数
    void *free_list;       // 空闲链表头
} mempool_t;

// 初始化内存池
// 注意:block_size 必须大于等于 sizeof(void*)
int mempool_init(mempool_t *pool, void *buffer, 
                 size_t block_size, size_t block_count) {
    if (!pool || !buffer || block_size < sizeof(void*)) {
        return -1;
    }

    pool->pool_start = buffer;
    pool->block_size = block_size;
    pool->block_count = block_count;

    // 构建空闲链表
    char *ptr = (char*)buffer;
    for (size_t i = 0; i < block_count - 1; i++) {
        // 每个块的前4/8字节存放下一个块的地址
        void **next = (void**)(ptr + i * block_size);
        *next = (void*)(ptr + (i + 1) * block_size);
    }
    // 最后一个块指向 NULL
    void **last = (void**)(ptr + (block_count - 1) * block_size);
    *last = NULL;

    pool->free_list = buffer;
    return 0;
}

// 从池中分配一个块
void *mempool_alloc(mempool_t *pool) {
    if (!pool || !pool->free_list) {
        return NULL;  // 池已耗尽
    }

    void *block = pool->free_list;
    // 空闲链表头指向下一个空闲块
    pool->free_list = *(void**)block;
    return block;
}

// 释放块回池中
void mempool_free(mempool_t *pool, void *block) {
    if (!pool || !block) return;

    // 检查 block 是否属于本池(简单检查)
    char *start = (char*)pool->pool_start;
    char *end = start + pool->block_size * pool->block_count;
    if (block < (void*)start || block >= (void*)end) {
        return;  // 不属于本池,忽略
    }

    // 将块插入空闲链表头部
    *(void**)block = pool->free_list;
    pool->free_list = block;
}

我踩过的坑:曾经我设的 block_size 太小,忘了预留 sizeof(void*) 的空间来存链表指针。结果分配出去后,用户数据把链表指针覆盖了,整个池子乱成一锅粥。记住:block_size 必须 >= sizeof(void*),否则链表指针没地方放。

23.3 内存池的工作流程

为了让你更直观地理解,我画了一张图:

固定大小内存池工作流程 内存池(连续大块内存) 块0 块1 块2 ... 块N-1 空闲链表(free_list) 分配流程(mempool_alloc) 1. 取 free_list 指向的块 2. free_list 指向下一个空闲块 3. 返回取出的块地址 时间复杂度:O(1) 释放流程(mempool_free) 1. 将块的 next 指向 free_list 2. free_list 指向该块 3. 块回到空闲链表头部 时间复杂度:O(1)

你看,整个过程就是链表头插和头删。没有遍历,没有排序,干净利落。

23.4 内存池的优势到底在哪?

我拿一个实际项目来说吧。之前做的一个物联网网关,需要频繁创建和销毁数据包。每个数据包大小固定,大概 256 字节。用 malloc/free 的时候,跑 24 小时就会出现分配失败。换成内存池后,连续跑了三个月没出过问题。

具体优势我列个表:

对比项 malloc/free 内存池
分配速度 几十到几百纳秒,不稳定 几个纳秒,恒定
碎片问题 容易产生外部碎片 无外部碎片
确定性 差,耗时不可预测 好,每次耗时固定
内存利用率 高(按需分配) 低(固定大小,可能有浪费)
适用场景 通用场景 固定大小对象、高频分配

注意:内存池不是银弹。如果你分配的对象大小差异很大,固定大小池子会造成严重的内存浪费。这时候可以考虑分级内存池——不同大小用不同的池子。我有个项目就是这么干的,效果不错。

23.5 使用内存池的注意事项

嗯,这里我要多说几句。用内存池有几个容易翻车的地方:

  • 块大小要算准:别忘了加上链表指针占用的空间。我一般会在 block_size 里预留 8 字节(64位系统)。
  • 对齐问题:如果你的数据需要 4 字节或 8 字节对齐,block_size 必须是对齐大小的整数倍。否则,你分配出去的地址可能不对齐,导致硬件异常。
  • 线程安全:上面的代码没有加锁。多线程环境下,你需要用互斥锁或原子操作保护 free_list。我习惯用关中断的方式,在裸机系统里很管用。
  • 越界检查:释放时最好检查一下地址是否属于本池。不然,有人传了个野指针进来,你的链表就毁了。

我的习惯:在调试阶段,我会在内存池里加一个魔数(magic number)。分配时写入魔数,释放时检查魔数是否被覆盖。这样能快速发现内存越界问题。等产品发布时,再把检查关掉,不影响性能。

说白了,内存池就是拿空间换时间,拿确定性换灵活性。在嵌入式系统里,尤其是实时性要求高的场景,这个交换非常划算。你想想看,一个稳定的系统,比一个偶尔崩溃但内存利用率高的系统,哪个更值钱?

好了,关于内存池的核心内容就这些。记住一句话:高频分配用池,低频分配用堆。这是我在无数个熬夜调 bug 的夜晚总结出来的经验。


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