4. 内存池(Memory Pool):内存池的设计思想、固定大小块分配、内存池在实时系统中的优势

4.1 为什么需要内存池?

做嵌入式实时系统开发的朋友,一定被动态内存分配折磨过。malloc/free 用起来方便,但在 VxWorks 这种硬实时环境里,它就是个定时炸弹。

我个人习惯,在项目初期就会把内存池方案定下来。为什么?因为标准堆分配有几个致命问题:

  • 分配时间不确定:malloc 内部要查找空闲链表,最坏情况下的执行时间可能差好几个数量级
  • 内存碎片化:频繁分配释放后,堆里全是小碎片,明明总内存够用,但就是分配不出一个大块
  • 对实时任务不友好:高优先级任务可能因为低优先级任务正在 malloc 而被阻塞

我在项目中遇到过这样一个场景:一个数据采集系统,每秒要处理 1000 个网络包。一开始用标准 malloc/free,跑了半小时后系统突然报内存分配失败。查了半天,发现堆已经被碎片塞满了。后来换成内存池,问题一次性解决。

核心观点:实时系统中,内存分配的时间确定性比内存利用率更重要。

4.2 内存池的设计思想

内存池说白了就是:提前划分好一块连续内存,然后按固定大小切成若干块。需要内存时,直接从池里拿一块;用完还回去。整个过程没有链表遍历,没有内存合并,时间复杂度是 O(1)。

你想想看,这就像酒店的房间。标准堆分配就像客人来了才临时找空房,可能还要把大套房拆成小房间。内存池呢?提前把房间都装修好,客人来了直接给钥匙,退房后保洁打扫一下就能给下一位。

设计一个内存池,核心要素就三个:

  • 内存块大小:固定还是可变?固定大小最简单,性能最好
  • 块数量:池里最多能容纳多少块?这个要根据业务峰值来定
  • 空闲管理:怎么知道哪些块是空闲的?常用的是空闲链表或位图

4.3 固定大小块分配的实现

固定大小块分配是内存池最经典的形式。我来给你看一个 VxWorks 下的实现思路:

/* 内存池控制块 */
typedef struct {
    void     *pool_start;    /* 池起始地址 */
    uint32_t  block_size;    /* 每个块的大小 */
    uint32_t  block_count;   /* 块总数 */
    void     *free_list;     /* 空闲块链表头 */
    SEM_ID    sem_id;        /* 保护信号量 */
} MEM_POOL;

/* 初始化内存池 */
STATUS memPoolInit(MEM_POOL *pool, void *addr, 
                   uint32_t blk_size, uint32_t blk_cnt)
{
    int i;
    char *p = (char *)addr;
    
    pool->pool_start  = addr;
    pool->block_size  = blk_size;
    pool->block_count = blk_cnt;
    
    /* 将所有块串成空闲链表 */
    pool->free_list = p;
    for (i = 0; i < blk_cnt - 1; i++) {
        *(void **)p = (void *)(p + blk_size);
        p += blk_size;
    }
    *(void **)p = NULL;  /* 最后一个块指向空 */
    
    /* 创建二值信号量保护 */
    pool->sem_id = semBCreate(SEM_Q_FIFO, SEM_FULL);
    
    return (pool->sem_id != NULL) ? OK : ERROR;
}

/* 从内存池分配一个块 */
void *memPoolAlloc(MEM_POOL *pool)
{
    void *block;
    
    semTake(pool->sem_id, WAIT_FOREVER);
    
    block = pool->free_list;
    if (block != NULL) {
        /* 空闲链表头指向下一个空闲块 */
        pool->free_list = *(void **)block;
    }
    
    semGive(pool->sem_id);
    return block;
}

/* 释放块回内存池 */
STATUS memPoolFree(MEM_POOL *pool, void *block)
{
    semTake(pool->sem_id, WAIT_FOREVER);
    
    /* 把块插回空闲链表头部 */
    *(void **)block = pool->free_list;
    pool->free_list = block;
    
    semGive(pool->sem_id);
    return OK;
}

小技巧:空闲链表利用了空闲块本身的前 4 字节(32位系统)或 8 字节(64位系统)来存放下一个空闲块的地址。这样不需要额外内存来管理空闲块,非常高效。

嗯,这里要注意:块大小必须至少能存放一个指针。如果块太小,空闲链表就没法维护了。我一般建议块大小至少 16 字节起步。

4.4 内存池在实时系统中的优势

为什么说内存池是实时系统的标配?我们来对比一下:

对比项 标准堆(malloc/free) 内存池
分配时间 不确定,最坏情况可能几百微秒 固定,通常几十个纳秒
释放时间 不确定,可能触发内存合并 固定,O(1) 操作
碎片问题 严重,长时间运行必碎 无碎片,固定大小块天然抗碎片
内存利用率 高(但碎片会浪费) 中等(内部碎片不可避免)
适合场景 通用计算、非实时 实时任务、高频分配释放

说白了,内存池用空间换时间。它牺牲了一点内存利用率,换来了确定性的分配时间。在实时系统中,时间确定性就是生命线。

我曾经在一个飞控项目中,把任务控制块(TCB)的分配从 malloc 改成内存池。结果任务切换的抖动从原来的 ±50μs 降到了 ±2μs。这就是内存池的威力。

避坑指南:我曾经犯过一个错误——内存池的块大小设置得太小。当时觉得省内存,结果业务数据稍微大一点就放不下了。后来我养成了一个习惯:块大小 = 最大业务数据结构 + 16 字节余量。多出来的空间就当是保险。

4.5 多尺寸内存池策略

实际项目中,往往需要多种大小的内存块。比如网络协议栈,有 64 字节的控制包,也有 1500 字节的数据包。这时候怎么办?

我的做法是:创建多个不同块大小的内存池。比如:

  • Pool A:块大小 64 字节,100 块 —— 用于控制消息
  • Pool B:块大小 256 字节,50 块 —— 用于小数据包
  • Pool C:块大小 2048 字节,20 块 —— 用于大数据包

分配时根据数据大小,选择最合适的池。如果数据超过最大池的块大小,才回退到标准堆分配。这种策略在 VxWorks 的网络协议栈里用得很多。

你想想看,这样做的好处是什么?每个池的分配时间都是确定的,而且不同池之间互不影响。高优先级的控制消息用 Pool A,不会被大数据包的释放操作干扰。

4.6 性能调优要点

最后,我总结几个内存池调优的关键点:

  1. 块大小对齐:建议按 4 字节或 8 字节对齐,这样 CPU 访问效率最高
  2. 池大小估算:根据业务峰值流量 × 1.5 倍余量来定。别抠门,内存池用不完也比不够好
  3. 信号量选择:如果只有一个任务访问内存池,可以用 taskLock 代替信号量,性能更好
  4. 缓存友好:尽量让同一个池的内存块在物理上连续,减少 cache miss
  5. 监控机制:加一个统计接口,记录分配次数、失败次数、峰值使用量。线上问题排查时很有用

一句话总结:内存池是实时系统对抗内存碎片和时间不确定性的利器。它不复杂,但用好了能让你的系统稳定运行几个月不重启。

下一章,我会讲 VxWorks 的缓存管理策略,包括 cache 一致性问题和 DMA 操作的注意事项。到时候见。