14、内存池技术:为什么需要内存池、固定大小内存池实现、性能对比

说到内存管理,有个话题绕不开——内存池

我刚开始做嵌入式那会儿,觉得malloc/free挺好用的。直到有一次,一个数据采集项目跑着跑着就卡死了。查了三天,发现是频繁分配释放导致的内存碎片。嗯,从那以后,我开始认真研究内存池。

为什么需要内存池?

说白了,标准库的malloc不是为实时系统设计的。它有几个硬伤:

  • 分配时间不确定:malloc内部要查找空闲链表,最坏情况下的耗时你根本没法预估
  • 内存碎片:频繁分配释放,堆空间会变得像蜂窝煤一样,明明总空闲内存够,却分配不出一个大块
  • 额外开销:每个malloc分配块都有管理头,小对象分配时,头开销占比很高

我在项目中遇到过这样一个场景:一个网络协议栈,每秒要处理上千个小包。每个包都要分配几十字节的缓冲区。用malloc跑了半小时,内存碎片率就飙到30%以上。后来换成内存池,问题直接解决。

核心思想:内存池就是预先申请一大块内存,然后自己管理。分配和释放都在池子里完成,不经过操作系统。

固定大小内存池实现

固定大小内存池,也叫slab分配器。它只分配一种大小的内存块。你想想看,如果所有对象大小都一样,管理起来就简单多了。

我习惯用空闲链表的方式来实现。每个空闲块里存一个指向下一个空闲块的指针。分配时取头节点,释放时插回头部。时间复杂度O(1)。

来看代码:

// 固定大小内存池结构
typedef struct {
    void *pool_start;       // 池起始地址
    void *pool_end;         // 池结束地址
    void *free_list;        // 空闲链表头
    size_t block_size;      // 每个块的大小
    size_t total_blocks;    // 总块数
    size_t free_blocks;     // 空闲块数
} fixed_pool_t;

// 初始化内存池
int fixed_pool_init(fixed_pool_t *pool, void *buf, 
                    size_t buf_size, size_t block_size) {
    // 块大小至少能存一个指针
    if (block_size < sizeof(void*)) {
        block_size = sizeof(void*);
    }
    
    // 对齐到4字节
    block_size = (block_size + 3) & ~3;
    
    pool->block_size = block_size;
    pool->total_blocks = buf_size / block_size;
    pool->free_blocks = pool->total_blocks;
    pool->pool_start = buf;
    pool->pool_end = (char*)buf + buf_size;
    
    // 构建空闲链表
    pool->free_list = buf;
    char *current = (char*)buf;
    for (size_t i = 0; i < pool->total_blocks - 1; i++) {
        void **next = (void**)current;
        *next = current + block_size;
        current += block_size;
    }
    // 最后一个块指向NULL
    void **last = (void**)current;
    *last = NULL;
    
    return 0;
}

// 从内存池分配
void *fixed_pool_alloc(fixed_pool_t *pool) {
    if (pool->free_list == NULL) {
        return NULL;  // 池已满
    }
    
    void *block = pool->free_list;
    pool->free_list = *(void**)block;
    pool->free_blocks--;
    
    return block;
}

// 释放回内存池
void fixed_pool_free(fixed_pool_t *pool, void *block) {
    if (block == NULL) return;
    
    // 检查是否属于本池
    if (block < pool->pool_start || block >= pool->pool_end) {
        return;  // 非法地址
    }
    
    *(void**)block = pool->free_list;
    pool->free_list = block;
    pool->free_blocks++;
}

个人经验:初始化时把空闲链表建好,运行时就只有指针操作。没有malloc调用,没有系统调用,速度极快。

性能对比

光说快不行,咱们拿数据说话。我在STM32F407上做过一个对比测试:分配和释放10000次,每次32字节。

分配方式 总耗时(us) 平均每次(us) 最大耗时(us) 碎片率
malloc/free 2850 0.285 12.4 18%
固定大小内存池 320 0.032 0.04 0%

看到了吗?内存池快了将近9倍。而且最关键的——最大耗时。malloc最慢的一次花了12.4微秒,内存池只有0.04微秒。这个差距在实时系统中就是生与死的区别。

为什么会这样?因为内存池的分配和释放都是常数时间。没有链表遍历,没有内存合并,没有系统调用。就是简单的指针操作。

注意:内存池不是银弹。它适合固定大小、频繁分配释放的场景。如果对象大小差异很大,或者分配模式很随机,固定大小池反而浪费内存。

内存池的核心逻辑

我画了一张图,帮你理解内存池的工作流程:

固定大小内存池核心逻辑 初始化阶段 预先申请一大块内存 → 按固定大小切块 → 构建空闲链表 运行阶段 分配 取空闲链表头 → 返回该块 → 头指针后移 释放 该块指向当前头 → 头指针指向该块 结果 分配/释放均为 O(1) 时间复杂度 · 无内存碎片 · 实时性有保障 整个生命周期中,没有 malloc/free 调用,没有系统调用

避坑指南

我曾经踩过一个坑,分享给你:

  • 块大小要对齐:ARM Cortex-M系列要求4字节对齐,否则访问会异常。我习惯用 (size + 3) & ~3 做对齐
  • 检查释放地址:释放时一定要验证地址是否属于本池。我曾经因为野指针释放,把池子搞乱了,排查了两天
  • 考虑多线程:如果多个任务共享一个池,要加锁保护。我一般用关中断的方式,开销最小
  • 池大小要算好:总内存 = 块大小 × 块数 + 管理结构。别算少了,运行中池满是很麻烦的事

我的习惯:在调试阶段,我会在池结构里加一个magic number。每次分配释放时校验一下,能快速发现内存越界问题。

内存池技术,说白了就是用空间换时间。你预先多花点内存,换来的是确定性的分配时间和零碎片。在实时系统、网络协议栈、音视频处理这些场景下,它几乎是标配。

嗯,这一章就到这里。记住一句话:频繁分配小对象,就用内存池


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