2. 内存池设计原理

在嵌入式 Linux 系统中,尤其是涉及 DMA 传输的场景,内存分配与释放的频率和效率直接影响系统吞吐量。标准 malloc/free 或内核 kmalloc/kfree 在面对大量小对象、实时性要求高的数据路径时,会引入不可预测的延迟和内存碎片。内存池(Memory Pool)正是为解决这些问题而生的预分配内存管理机制。

2.1 内存池概念与核心思想

内存池是一种在系统初始化阶段或运行前,从连续物理内存或大块虚拟地址空间中预先划分出一块区域,由池管理器统一管理的内存分配策略。其核心思想是“一次分配,多次复用”,避免频繁向操作系统申请和释放内存。

内存池 vs 通用分配器:
  • 通用分配器(malloc/kmalloc): 每次分配大小可变,分配器需维护复杂的数据结构(如空闲链表、红黑树),分配延迟不确定,且容易产生外部碎片。
  • 内存池: 预先划分固定或可变大小的块,分配时直接从池中取出一个空闲块,释放时归还到池中。操作时间复杂度通常为 O(1),无外部碎片。
内存池概念结构图 内存池概念结构图 系统内存(物理连续 / 大块虚拟地址) 内存池管理器(Pool Manager) 空闲块链表(Free List) 已分配块(Allocated)

2.2 内存碎片问题深度分析

内存碎片是嵌入式实时系统的隐形杀手。在长时间运行的 DMA 数据通路中,碎片会导致分配失败,即使系统总空闲内存足够。碎片分为两类:

碎片类型 产生原因 对 DMA 的影响 内存池解决方案
外部碎片 频繁分配和释放不同大小的内存块,导致空闲内存被分割成许多不连续的小区域。 无法分配满足 DMA 要求的连续物理缓冲区,导致 DMA 传输失败或需要 SG(Scatter-Gather)模式。 固定大小内存池消除外部碎片;可变大小内存池通过伙伴算法或 slab 分配器缓解。
内部碎片 分配的内存块大于实际请求的大小,多余部分被浪费。 浪费宝贵的 DMA 缓冲区空间,降低内存利用率。 固定大小池通过选择合适粒度控制内部碎片;可变大小池按需分配,内部碎片更少。
DMA 场景特别警示: 外部碎片不仅导致分配失败,还可能使原本可以合并的 SG 描述符数量激增,增加 DMA 引擎的调度开销和描述符 FIFO 溢出风险。在无 MMU 的 MCU 或禁用 SWIOTLB 的系统中,连续物理内存的碎片问题尤为致命。
内存碎片示意图 内存碎片示意图 理想状态(无碎片): 连续空闲大块内存 碎片状态(外部碎片): 已分配 空闲 已分配 空闲 已分配 空闲 已分配 碎片状态:空闲内存总量足够(180字节),但最大连续空闲块仅80字节,无法满足100字节的DMA请求。

2.3 固定大小内存池(Fixed-Size Pool)

固定大小内存池是最简单、最高效的池化方案。它在初始化时分配一个大的连续内存区域,并将其划分为多个大小相等的块(chunk)。

2.3.1 工作原理

  1. 初始化: 计算块数量 = 总大小 / 块大小,将所有块地址串成空闲链表。
  2. 分配(alloc): 从空闲链表头部取出一个块,返回其地址。时间复杂度 O(1)。
  3. 释放(free): 将块重新插入空闲链表头部。时间复杂度 O(1)。

2.3.2 典型实现代码片段

/* 固定大小内存池结构体 */
struct fixed_pool {
    void        *base;          /* 池基地址 */
    size_t      block_size;     /* 块大小(字节) */
    size_t      total_blocks;   /* 总块数 */
    void        *free_list;     /* 空闲链表头指针 */
    spinlock_t  lock;           /* 自旋锁(SMP安全) */
};

/* 分配一个块 */
void *fixed_pool_alloc(struct fixed_pool *pool)
{
    void *block;
    unsigned long flags;

    spin_lock_irqsave(&pool->lock, flags);
    block = pool->free_list;
    if (block)
        pool->free_list = *(void **)block;  /* 链表头指向下一个空闲块 */
    spin_unlock_irqrestore(&pool->lock, flags);

    return block;  /* 返回 NULL 表示池空 */
}

/* 释放一个块 */
void fixed_pool_free(struct fixed_pool *pool, void *block)
{
    unsigned long flags;

    spin_lock_irqsave(&pool->lock, flags);
    *(void **)block = pool->free_list;  /* 当前块指向原链表头 */
    pool->free_list = block;            /* 链表头指向当前块 */
    spin_unlock_irqrestore(&pool->lock, flags);
}
DMA 适配建议: 固定大小池的块大小应设置为 DMA 描述符大小、单个音频帧大小、网络 MTU 等典型数据单元。对于需要 cacheline 对齐的 DMA 缓冲区,块大小应向上对齐到 L1_CACHE_BYTES(通常 32 或 64 字节),避免 cache 一致性操作跨越块边界。

2.4 可变大小内存池(Variable-Size Pool)

可变大小内存池允许分配不同大小的内存块,灵活性更高,但管理复杂度也相应增加。常见实现包括伙伴系统(Buddy System)和 slab 分配器。

2.4.1 伙伴系统原理

伙伴系统将内存按 2 的幂次划分成不同大小的块。分配时,将请求大小向上取整到 2 的幂次,从对应空闲链表中分配。若该链表为空,则从更大块中分裂出两个伙伴块。

伙伴系统分裂与合并流程图 伙伴系统分裂与合并流程图 初始块(64KB) 伙伴 A(32KB) 伙伴 B(32KB) A1(16KB) A2(16KB) B1(16KB) B2(16KB) 合并条件:当伙伴 A1 和 A2 都空闲时,合并回 32KB 的伙伴 A;若伙伴 A 和 B 都空闲,继续合并回 64KB。

2.4.2 Slab 分配器

Linux 内核的 slab 分配器是可变大小内存池的经典实现。它维护多个“缓存”(cache),每个缓存管理一组固定大小的对象。分配时从对应缓存中获取对象,释放时归还。Slab 分配器还支持着色(coloring)以优化 cache 利用率。

2.5 固定大小 vs 可变大小内存池对比

对比维度 固定大小内存池 可变大小内存池
分配/释放时间复杂度 O(1),极快且确定 O(1) ~ O(log N),取决于实现(伙伴系统为 O(log N))
外部碎片 完全消除 显著减少,但伙伴系统仍可能产生少量外部碎片
内部碎片 平均 50%(请求大小与块大小不匹配时) 平均 25%(向上取整到 2 的幂次)
内存利用率 低(块大小固定,小请求浪费空间) 高(按需分配)
实现复杂度 极低,几十行代码即可实现 较高,需要维护多级空闲链表或缓存管理
适用场景 DMA 描述符、音频帧、网络数据包、固定大小控制块 通用内存分配、大小不固定的 DMA 缓冲区、文件系统缓存
实时性 高,无动态分裂/合并开销 中等,分裂/合并操作可能引入不确定延迟
工程选择原则:
  • 如果系统中有明确的“热点对象”(如 DMA 描述符、网络 skb),且对象大小固定,优先使用固定大小内存池。
  • 如果内存需求多样且不可预测,或需要兼容不同硬件 IP 的缓冲区大小,选择可变大小内存池(如内核的 kmem_cache)。
  • 在 DMA 驱动中,通常采用“混合策略”:描述符使用固定大小池,数据缓冲区使用可变大小池或直接使用 dma_alloc_coherent。

2.6 内存池在 DMA 驱动中的典型架构

以下是一个典型的 DMA 驱动内存池架构图,展示了固定大小池与可变大小池的协同工作方式:

DMA 驱动内存池架构图 DMA 驱动内存池架构图 DMA 引擎(DMA Engine) DMA 驱动(DMA Driver) 固定大小内存池 DMA 描述符(Descriptor) 可变大小内存池 DMA 数据缓冲区(Buffer) DMA 硬件(DMA Controller + Memory)
实践要点: 在编写 DMA 驱动时,建议将描述符池的块大小设置为 sizeof(struct dma_desc) 并 cacheline 对齐。数据缓冲区池则根据典型传输大小配置多个 slab 缓存(如 64B、256B、1KB、4KB),以平衡灵活性与碎片控制。