内存管理:Linux内核驱动slab分配器与内存池优化 - 第1节

1.1 Linux 内核内存管理架构总览

Linux 内核的内存管理子系统是驱动开发中最核心、最复杂的模块之一。它向上为进程、文件系统、网络协议栈等提供虚拟地址空间,向下管理物理内存的分配与回收。理解其整体架构,是掌握 slab 分配器与内存池优化的前提。

内核内存管理采用分层设计,从最底层的物理页管理到最上层的进程虚拟地址空间,每一层都解决特定的问题:

  • 物理页管理(伙伴系统):以 4KB 页为基本单位,管理物理内存的分配与释放,解决外部碎片问题。
  • slab 分配器:在伙伴系统之上,为内核对象(如 task_struct、inode)提供高速、低碎片的分配与缓存。
  • 内存池(mempool):在 slab 分配器之上,为原子上下文或紧急场景提供预留内存保障。
  • 虚拟内存管理:管理进程的页表、VMA、缺页异常等。
驱动开发者视角: 驱动代码中 90% 的内存分配请求(如 DMA 缓冲区、设备私有结构体)最终都会落到 slab 分配器或伙伴系统上。理解这两层的关系,能帮助你写出更高效、更稳定的驱动。

1.1.1 内存管理分层架构图

下图展示了 Linux 内核内存管理的核心分层关系,以及数据流在层间的传递路径:

Linux 内核内存管理分层架构 进程虚拟地址空间 (VMA / 页表) 内核虚拟地址空间 (直接映射 / vmalloc) slab 分配器 (kmem_cache) / 内存池 (mempool) 对象缓存、去碎片、紧急预留 伙伴系统 (Buddy System) 物理内存 (页帧 / 内存节点) 驱动常用接口 kmalloc / kzalloc 驱动常用接口 vmalloc / ioremap 驱动常用接口 kmem_cache_alloc

1.2 伙伴系统与 slab 分配器的关系

伙伴系统(Buddy System)是 Linux 内核最底层的物理页分配器,它以 2 的幂次阶(order) 为单位管理连续物理页。slab 分配器则建立在伙伴系统之上,专门用于分配小尺寸、频繁创建/销毁的内核对象。

两者的核心关系可以概括为:

  • 伙伴系统提供“原材料”:slab 分配器通过 __get_free_pages() 从伙伴系统申请整页(通常为 1 页),然后将其切分为多个等大小的对象缓存。
  • slab 分配器解决“碎片与效率”:伙伴系统只能分配 2^n 页的连续内存,对于几十字节的对象(如 task_struct 约 2KB)会造成严重内部碎片;slab 分配器通过对象复用和着色(coloring)技术,大幅降低碎片并提升分配速度。
  • 内存池提供“安全网”:在 slab 分配器之上,内存池(mempool)为原子上下文(如中断处理)预留少量对象,确保在内存紧张时仍能分配成功。

1.2.1 伙伴系统 vs slab 分配器对比

对比维度 伙伴系统 slab 分配器
分配粒度 物理页(4KB ~ 4MB) 任意大小(8B ~ 4MB)
主要问题 外部碎片(页级) 内部碎片(对象级)
分配速度 较慢(可能触发页回收) 极快(常从空闲链表直接返回)
适用场景 大块连续内存、DMA 缓冲区 内核对象、小数据结构
核心 API alloc_pages() / __get_free_pages() kmem_cache_alloc() / kmalloc()
碎片控制 伙伴合并算法 对象复用 + 着色

1.2.2 从伙伴系统到 slab 的分配流程

下图展示了一个典型的 slab 缓存(如 kmalloc-64)如何从伙伴系统获取物理页,并切分为多个 64 字节对象的过程:

slab 分配器从伙伴系统获取页并切分对象 伙伴系统 alloc_pages(order=0) 物理页 4KB slab 缓存 (kmalloc-64) 对象 0 | 对象 1 | ... | 对象 63 每个对象 64 字节,共 64 个对象 关键点:slab 分配器将 4KB 物理页切分为 64 个 64 字节对象 伙伴系统只关心页的分配与回收,不关心页内部如何划分 slab 分配器负责对象级的管理,包括空闲链表、着色、NUMA 优化 驱动调用流程: kmalloc(64, GFP_KERNEL) → slab 分配器 → 从空闲链表取对象 → 若空闲链表为空 → 从伙伴系统申请新页

1.3 为什么需要 slab 分配器

在 Linux 内核早期版本(2.0 以前),内核直接使用伙伴系统分配所有内存。随着内核功能增长,这种方式的缺陷越来越明显:

  • 内部碎片严重:一个 300 字节的 task_struct 需要分配一个 4KB 页,浪费 92% 的空间。
  • 分配速度慢:每次分配都需要遍历伙伴系统的空闲链表,可能触发页合并或回收。
  • 缺乏对象缓存:频繁创建/销毁的对象(如 socket、inode)每次都要重新初始化,开销大。

slab 分配器的引入解决了上述问题,其核心优势包括:

  1. 对象复用:释放的对象不会真正返回伙伴系统,而是留在 slab 缓存中,下次分配时直接复用,避免重复初始化。
  2. 着色(Coloring):通过调整对象在页内的偏移,使不同 slab 页中的对象落在不同的 cache line 上,减少 CPU 缓存行颠簸。
  3. NUMA 感知:slab 分配器支持为每个内存节点维护独立的空闲链表,提升多核系统的内存访问局部性。
  4. 统计与调试:通过 /proc/slabinfo 可以查看每个 slab 缓存的使用情况,便于驱动开发者定位内存泄漏。

1.3.1 使用 slab 分配器的典型场景

内核对象 对象大小 使用频率 slab 缓存名称
task_struct ~2KB 极高(进程创建/销毁) task_struct
inode ~600B 高(文件操作) inode_cache
dentry ~200B 极高(路径查找) dentry_cache
socket ~1KB 高(网络通信) sock_inode_cache
驱动私有结构体 自定义 取决于驱动 驱动自定义
驱动开发建议: 如果你的驱动需要频繁分配/释放同一大小的结构体(如网络驱动的 sk_buff、块设备请求结构体),强烈建议使用 kmem_cache_create() 创建专用 slab 缓存,而不是使用通用的 kmalloc()。这能显著提升性能并减少碎片。

1.3.2 slab 分配器 vs 通用 kmalloc

通用 kmalloc() 底层也是基于 slab 分配器实现的,但它使用预定义的通用缓存(如 kmalloc-8、kmalloc-16、...、kmalloc-4M)。专用 slab 缓存相比通用 kmalloc 有以下优势:

  • 更小的内部碎片:专用缓存的对象大小精确匹配结构体大小,而通用缓存只能向上取整到 2 的幂次。
  • 更好的缓存局部性:同一类型的对象集中在同一组页中,CPU 缓存命中率更高。
  • 更易调试:通过 /proc/slabinfo 可以单独监控该类型对象的分配/释放情况。

以下代码展示了驱动如何创建和使用专用 slab 缓存:

/* 定义驱动私有结构体 */
struct my_device {
    unsigned int id;
    char name[32];
    void __iomem *regs;
    /* ... 其他字段 */
};

/* 创建 slab 缓存(通常在模块初始化时) */
static struct kmem_cache *my_device_cache;

static int __init my_driver_init(void)
{
    my_device_cache = kmem_cache_create(
        "my_device",           /* 缓存名称,显示在 /proc/slabinfo */
        sizeof(struct my_device), /* 对象大小 */
        0,                     /* 对齐方式,0 表示默认 */
        SLAB_HWCACHE_ALIGN,    /* 标志:按硬件缓存行对齐 */
        NULL                   /* 构造函数,通常为 NULL */
    );
    if (!my_device_cache)
        return -ENOMEM;
    return 0;
}

/* 分配对象 */
struct my_device *dev = kmem_cache_alloc(my_device_cache, GFP_KERNEL);

/* 释放对象 */
kmem_cache_free(my_device_cache, dev);

/* 销毁缓存(通常在模块退出时) */
static void __exit my_driver_exit(void)
{
    kmem_cache_destroy(my_device_cache);
}
注意: 使用 kmem_cache_create() 创建缓存时,如果指定了 SLAB_HWCACHE_ALIGN 标志,slab 分配器会尝试将对象对齐到 CPU 缓存行大小(通常为 64 或 128 字节),这能有效避免多核环境下的伪共享(False Sharing)问题。

1.3.3 slab 分配器的三种变体

Linux 内核目前支持三种 slab 分配器实现,驱动开发者需要了解它们的差异:

分配器 引入版本 核心特点 适用场景
SLAB 2.2 经典实现,每个 CPU 维护空闲对象链表,支持着色 通用场景,调试友好
SLUB 2.6.22 简化设计,移除 per-CPU 队列,使用页级空闲链表,减少锁开销 默认分配器(大多数发行版),高并发场景
SLOB 2.6.16 极度简化,适合嵌入式系统,内存占用最小 内存受限的嵌入式设备

当前主流内核(5.x / 6.x)默认使用 SLUB 分配器。SLUB 在保持 SLAB 大部分优点的同时,大幅简化了代码复杂度,并提升了多核扩展性。驱动开发者无需关心底层使用哪种分配器,因为 kmem_cache_alloc() / kmem_cache_free() 接口在所有变体上保持一致。

1.3.4 内存池(mempool)的引入

即使有了 slab 分配器,在极端内存压力下(如 GFP_ATOMIC 分配失败),驱动仍然可能面临分配失败的风险。内存池(mempool)在 slab 分配器之上提供了一层紧急预留机制:

  • 在初始化时预先分配一定数量的对象,保存在池中。
  • 正常分配时,优先从 slab 分配器获取;如果失败,则从预留池中取出。
  • 释放时,如果预留池未满,则放回池中;否则正常释放给 slab 分配器。

内存池适用于以下场景:

  • 中断处理程序或自旋锁上下文(只能使用 GFP_ATOMIC)。
  • 块设备层的 I/O 请求,必须保证在内存紧张时仍能分配 bio 结构体。
  • 网络设备驱动,确保在接收数据包时能分配 sk_buff。
最佳实践: 不要滥用内存池。内存池中的对象在正常场景下不会被使用,它们占用内存但处于空闲状态。只有在确定分配失败会导致系统崩溃或数据丢失的关键路径上,才应该使用内存池。

1.3.5 本章小结

通过本章的学习,你应该掌握了以下核心概念:

  • Linux 内核内存管理采用分层架构,伙伴系统管理物理页,slab 分配器管理内核对象,内存池提供紧急保障。
  • 伙伴系统以 2^n 页为单位分配,slab 分配器在此基础上切分为等大小对象,解决内部碎片和分配效率问题。
  • 驱动开发者应优先使用 kmem_cache_create() 创建专用 slab 缓存,而不是通用 kmalloc(),以获得更好的性能和可调试性。
  • 在原子上下文或关键路径上,考虑使用内存池(mempool)作为最后的保障。