Slab 是什么:内核小对象从哪里来

41 阅读45分钟

Slab 是什么:内核小对象从哪里来

前面几篇已经把用户地址空间VMA页表struct pagezone 和 buddy allocator串起来了。

buddy allocator 解决的是“从物理内存里拿出 1 页、2 页、4 页这样的连续页块”。但内核日常要分配的东西,很多并不是整页大小:

  • 一个 vm_area_struct
  • 一个 task_struct
  • 一个 dentry。
  • 一个 inode。
  • 一个几十字节的临时结构。

如果这些小对象都直接问 buddy 要 4KB 页,会有两个明显问题:

  • 浪费:几十字节对象独占一页,内部碎片太大。
  • 慢:频繁进 buddy、频繁拆合页块,锁竞争和缓存局部性都不好。

所以这一篇回答一个更靠近内核自己的问题:

buddy 分配的是页,内核里大量几十字节、几百字节的小对象到底从哪里来?

答案就是 slab allocator。更准确地说,slab 是一类小对象分配器的统称;现代 Linux 常见实现是 SLUB。旧资料里还会看到 SLAB、SLOB 这些名字。对理解主线机制来说,可以先把重点放在这三层关系上:

   buddy allocator
        │
        │  分配一页或多页
        ▼
   slab page / slab folio
        │
        │  切成固定大小对象
        ▼
   kmem_cache
        │
        │  给内核分配同一种对象
        ▼
   task_struct / vm_area_struct / dentry / kmalloc-*

这里先给出一句总判断:

用户数据页通常来自 buddy;内核小对象通常先从 slab cache 里拿,对应 slab cache 没有空闲对象时,再向 buddy 要新页。

一、为什么 buddy 不适合小对象

buddy allocator 的基本单位是页。假设页大小是 4KB,order=0 是 1 页,order=1 是 2 页,order=2 是 4 页。

这很适合分配用户数据页、页表页、大块连续页:

   alloc_pages(order=0) -> 1 page  -> 4KB
   alloc_pages(order=1) -> 2 pages -> 8KB
   alloc_pages(order=2) -> 4 pages -> 16KB

但如果内核只想要一个 152 字节左右的 vm_area_struct,直接拿一页就很浪费:

   4KB page
   ┌──────────────┬──────────────────────────────────────┐
   │ 152B object  │  剩余空间如果不用来放别的对象,就浪费  │
   └──────────────┴──────────────────────────────────────┘

更好的办法是:一次从 buddy 拿一页或几页,然后把这块页切成很多个等大的对象。

   4KB page
   ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
   │ obj │ obj │ obj │ obj │ obj │ obj │ ... │
   └─────┴─────┴─────┴─────┴─────┴─────┴─────┘

这就是 slab allocator 的基本动机:把页级分配转换成对象级分配

二、slab、SLAB、SLUB 不是同一层概念

很多文章会混用 slab、SLAB、SLUB。读内核资料时要稍微分清:

  • slab allocator:通称,指 Linux 内核的小对象分配体系。
  • SLAB:较早的具体实现,结构更复杂,保留了更多队列和元数据。
  • SLUB:现代 Linux 常见实现,设计目标是减少元数据和锁竞争,快路径更直接。
  • SLOB:旧的小系统实现,面向非常小的内存环境;现代主线资料里已经不应该把它当成常规路径。

对这个系列来说,不需要把 SLAB 和 SLUB 的每个字段都展开。真正重要的是这几个抽象:

   kmem_cache        一种对象的缓存,例如 vm_area_struct cache
   slab              承载一组对象的一页或多页
   object            真正返回给内核调用者的小对象
   per-cpu freelist  每个 CPU 优先使用的本地空闲对象链表
   partial slab      部分对象已分配、部分对象仍空闲的 slab

现代内核里,SLUB 的内部实现会使用 folio、per-node partial list、per-cpu 状态等结构。这里先不被字段名拖住,抓住职责就行:

kmem_cache 管一种对象,slab page 承载一批对象,freelist 串起当前可快速分配的空闲对象。

三、kmem_cache:一种对象一个缓存

内核里有很多“长期大量出现、大小固定”的对象。比如 VMA 对象、进程对象、目录项对象。给这些对象建专门 cache,有几个好处:

  • 对象大小固定,切分简单。
  • 同类对象复用,缓存局部性更好。
  • 可以保留构造/调试/对齐信息。
  • 释放后不一定立刻还给 buddy,可以留在 cache 里下次复用。

所以 kmem_cache 可以先理解成一个“内核对象池”。一个 cache 通常服务一类固定大小的内核对象,或者服务一个固定大小档位的通用 kmalloc 分配。

这一节先把 kmem_cache 自己拆成几块看:先看它作为对象池的静态入口,再看 slab 的几种状态,最后看 per-CPU 和 per-node 两层备用池。

3.1 kmem_cache 的静态入口

下面这张图具体表示:有一个专门管理 struct vm_area_struct 对象的 kmem_cache。它管理的是 VMA 描述符本身,也就是内核里的元数据对象;它不管理这段 VMA 覆盖的用户数据页。

   kmem_cache: vm_area_struct
        │
        ├─ object size: sizeof(struct vm_area_struct)
        ├─ alignment
        ├─ flags / debug / accounting
        ├─ CPU0 freelist -> obj -> obj -> obj
        ├─ CPU1 freelist -> obj -> obj
        └─ partial slabs
              │
              ▼
          slab page
          ┌─────┬─────┬─────┬─────┬─────┐
          │ obj │ obj │ obj │ obj │ obj │
          └─────┴─────┴─────┴─────┴─────┘

按 Linux 6.12 SLUB 的实现拆开看,kmem_cache 自己是这个对象池的总入口。它里面既有对象尺寸、对齐、构造函数、cache 名字等全局属性,也挂着 per-CPU 和 per-node 的状态:

   struct kmem_cache
        ├─ name              -> cache 名字,例如 "vm_area_struct"
        ├─ object_size       -> 调用者真正需要的对象大小
        ├─ size              -> 加上对齐/元数据后的对象槽位大小
        ├─ offset            -> free object 里存 next 指针的位置
        ├─ flags / align / ctor / allocflags ...
        │
        ├─ cpu_slab          -> per-CPU 的 struct kmem_cache_cpu
        │      │
        │      ├─ CPU0: freelist / active slab / partial
        │      ├─ CPU1: freelist / active slab / partial
        │      └─ ...
        │
        └─ node[]            -> 每个 NUMA node 的 struct kmem_cache_node
               │
               ├─ partial slab list
               └─ list_lock / nr_partial

3.2 partial、full 和 empty 只是 slab 状态

这里顺手把 partial slab 说清楚。一个 slab 里有些对象已经分配出去了,有些对象仍然空闲,它就是 partial slab:

   partial slab
   ┌──────┬──────┬──────┬──────┬──────┐
   │ used │ free │ used │ free │ used │
   └──────┴──────┴──────┴──────┴──────┘

它不是一种新的内存区域,而是 slab 的一种状态。SLUB 关心 partial slab,是因为这种 slab 还可以继续提供对象,所以会被挂到备用链表上:

kmem_cache_cpu->partial      -> per-CPU 的 partial slab 备用链表
kmem_cache_node->partial     -> per-node 的 partial slab 备用链表

那全空和全满的 slab 挂在哪里?

full slab
    └─ 所有对象都已分配。普通 SLUB 快路径通常不需要把它挂到可分配链表;
       因为它没有空闲对象,分配时不会优先找它。
       开启 SLUB debug 等配置时,可能维护 full list 方便检查和遍历。

empty slab
    └─ 所有对象都空闲。它可能被保留下来复用,也可能在策略允许时还给 buddy;
       普通路径里通常不会像 partial slab 那样长期挂在“可继续分配”的 partial list 上。

所以不要把 partial/full/empty 想成三条完全对等的常驻链表。对普通 SLUB 分配路径来说,最关键的是 partial list:它保存“还有空闲对象、值得拿来继续分配”的 slab。full slab 没有可分配对象,empty slab 又可能被回收或重新作为当前 slab 使用。

3.3 per-CPU 和 per-node 是两层不同的备用池

这里的 kmem_cache_cpu 不是另一种对象缓存,而是某个 kmem_cache 在某个 CPU 上的快路径状态。它挂在 kmem_cache->cpu_slab 这个 per-CPU 指针下面。以 Linux 6.12 的字段看,可以简化成:

   struct kmem_cache_cpu
        ├─ freelist  -> 当前 CPU 下一个可直接分配的 free object
        ├─ tid       -> 配合 freelist 做无锁 cmpxchg 更新
        ├─ slab      -> 当前 CPU 正在分配的 active slab
        ├─ partial   -> 当前 CPU 缓存的一些 partial slabs
        └─ lock      -> 慢路径修改这些字段时使用

它解决的问题是:如果每个 CPU 都去抢同一把全局锁、同一个全局 freelist,小对象分配会非常慢。于是 SLUB 给每个 cache 准备 per-CPU 状态,让当前 CPU 大多数时候直接从自己的 kmem_cache_cpu->freelist 弹出对象。

那为什么同一个 kmem_cache 里既有 kmem_cache_cpu,又有 kmem_cache_node?因为它们解决的是两个不同层级的问题:

kmem_cache
    └─ 管“这一类对象”的全局规格和入口

kmem_cache_cpu
    └─ 管“某个 CPU 当前怎么最快分配”

kmem_cache_node
    └─ 管“某个 NUMA node 上有哪些可复用 slab”

kmem_cache_cpu 是 CPU 本地快取层,偏向“正在用、马上用”。它里面的 slab 是当前 CPU 正在分配的 active slab,partial 是当前 CPU 旁边缓存的一小串备用 partial slabs。

kmem_cache_node 是 NUMA node 级共享备用池,偏向“这个 node 上可复用”。它的 partial 链表挂着这个 node 上的 partial slabs,给这个 node 上的多个 CPU 在慢路径里共享。

所以它们都引用 slab,但含义不一样:

kmem_cache_cpu->slab
    └─ 当前 CPU 正在分配的 active slab,通常一个

kmem_cache_cpu->partial
    └─ 当前 CPU 本地缓存的备用 partial slabs,可以多个

kmem_cache_node->partial
    └─ 当前 NUMA node 的公共 partial slab 池,给多个 CPU 慢路径共享

什么时候会走到 node 级 partial list?通常是 CPU 本地这几层都不够用了:

1. kmem_cache_cpu->freelist 空了
2. 当前 kmem_cache_cpu->slab 也补不出可用对象
3. kmem_cache_cpu->partial 没有合适的备用 slab
4. 才去 kmem_cache_node->partial 找这个 NUMA node 上的 partial slab
5. 如果 node partial 也没有,再向 buddy allocator 要新页创建新 slab

kmem_cache_cpu->partial 虽然也是备用池,但它不是无限大的,也不一定存在。比如没有开启 CONFIG_SLUB_CPU_PARTIAL、当前 CPU 本地缓存刚好为空、缓存数量达到限制后被 drain 回 node、NUMA node 不匹配、pfmemalloc 属性不匹配,都会让分配路径继续退到 kmem_cache_node->partial

一句话:kmem_cache_cpu->partial 是当前 CPU 的小备用池;kmem_cache_node->partial 是当前 NUMA node 的公共备用池。CPU 本地池空了、不合适、没启用或被限制时,就会走 node 级慢路径。

3.4 专用对象 cache 和通用大小 cache

类似的 cache 有很多,可以粗略分成两类。

第一类是专用对象 cache,一种 cache 对应一种常见内核结构:

vm_area_struct   -> 管理 struct vm_area_struct,描述进程的一段虚拟地址区间
task_struct      -> 管理 struct task_struct,描述一个 Linux task / 线程
dentry           -> 管理目录项缓存对象,连接路径名和 inode
inode_cache      -> 管理 VFS inode 对象,描述文件系统里的文件元数据
files_cache      -> 管理进程打开文件表相关结构
filp             -> 管理 struct file,对应一次打开文件实例
buffer_head      -> 管理块设备缓冲相关元数据
skbuff_head_cache -> 管理网络包 skb 相关对象

第二类是通用大小 cache,服务 kmalloc(size) 这类不绑定具体结构类型的小块分配:

kmalloc-8
kmalloc-16
kmalloc-32
kmalloc-64
kmalloc-128
kmalloc-256
kmalloc-512
kmalloc-1k
kmalloc-2k

通用大小 cache 的静态结构和专用对象 cache 很像,只是它不绑定某个具体结构类型,而是绑定一个大小档位。以 kmalloc-64 为例:

   kmalloc-64 cache
        │
        ├─ object size: 64 bytes
        ├─ alignment / flags / debug / accounting
        ├─ CPU0 freelist -> 64B obj -> 64B obj -> 64B obj
        ├─ CPU1 freelist -> 64B obj -> 64B obj
        └─ partial slabs
              │
              ▼
          slab page
          ┌────┬────┬────┬────┬────┬────┬────┬────┐
          │64B │64B │64B │64B │64B │64B │64B │64B │
          ├────┼────┼────┼────┼────┼────┼────┼────┤
          │64B │64B │64B │64B │64B │64B │64B │64B │
          └────┴────┴────┴────┴────┴────┴────┴────┘

如果页大小是 4096 字节,理想情况下 kmalloc-64 的一个 4KB slab page 可以切出 4096 / 64 = 64 个对象。本文后面的实验里也能看到:

kmalloc-64 objsize=64 objperslab=64 pagesperslab=1

再看一个稍大的例子。实验输出里 kmalloc-512 是:

kmalloc-512 objsize=512 objperslab=32 pagesperslab=4

这表示这个 cache 的一个 slab 由 4 页组成:

4 * 4096 = 16384 bytes
16384 / 512 = 32 objects

也就是:一个 slab 由 4 个物理页组成,里面总共切出 32 个 512 字节的对象槽位。按页展开看,就是每页 8 个对象:

   kmalloc-512 的一个 slab

   page 0          page 1          page 2          page 3
   ┌──────────────┬──────────────┬──────────────┬──────────────┐
   │ 8 个 512B obj │ 8 个 512B obj │ 8 个 512B obj │ 8 个 512B obj │
   └──────────────┴──────────────┴──────────────┴──────────────┘

   总共:4 pages * 8 objects/page = 32 objects

再展开得细一点:

   4-page slab for kmalloc-512

   ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
   │512B  │512B  │512B  │512B  │512B  │512B  │512B  │512B  │  page 0
   ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
   │512B  │512B  │512B  │512B  │512B  │512B  │512B  │512B  │  page 1
   ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
   │512B  │512B  │512B  │512B  │512B  │512B  │512B  │512B  │  page 2
   ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
   │512B  │512B  │512B  │512B  │512B  │512B  │512B  │512B  │  page 3
   └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘

这里仍然要加限定:这是本文这次 x86_64 测试内核上的布局。换内核版本、配置、调试选项或架构,kmalloc-512pagesperslabobjperslab 都可能不同。

所以通用大小 cache 的本质可以概括成:

   kmalloc-N cache
        │
        ├─ 每个对象槽位固定是 N bytes
        ├─ 调用者只要不超过 N bytes,就可以放进去
        └─ 槽位里到底是什么结构,由调用 kmalloc() 的内核代码决定

比如某段内核代码调用 kmalloc(80, GFP_KERNEL),它不一定值得单独建一个专用 cache,SLUB 通常会把它放到一个能容纳 80 字节的通用大小 cache 里。具体落到 kmalloc-96kmalloc-128 还是别的名字,取决于内核配置、架构和 cache merge 策略。

这两类 cache 的共同点是:它们分配出来的都是内核地址空间里的内核对象,不是用户态 malloc(64) 返回给用户程序的那 64 字节。用户态小对象由 libc allocator 在用户虚拟地址空间里切分;内核 slab 管的是内核自己的元数据和临时对象。

当内核代码需要一个 VMA 对象时,它不是手写“从第几页第几个偏移切一块”,而是通过对应 cache 分配:

kmem_cache_alloc(vm_area_struct_cachep, GFP_KERNEL)

当内核需要通用大小的小块内存时,常见入口是:

kmalloc(size, GFP_KERNEL)

kmalloc(64)kmalloc(512) 这类通用分配,背后通常会落到 kmalloc-64kmalloc-512 这类通用 slab cache。也就是说,kmalloc 不是绕开 slab 的另一套东西;小尺寸 kmalloc 本身就依赖 slab/SLUB 提供对象。

   专用对象:
   alloc_vm_area_struct -> kmem_cache_alloc(vm_area_struct cache)

   通用小块:
   kmalloc(80) -> 选择合适大小级别 -> kmalloc-96 / kmalloc-128 等 cache

具体 cache 名称会受内核版本、配置、调试选项和 cache merge 影响,不要把某台机器上的名字当成 ABI。

四、专用对象 cache 是怎么初始化出来的

前面一直在用 vm_area_struct cachedentry cache 这种说法。那这些专用对象 cache 是什么时候、怎么出现的?

先给结论:

专用对象 cache 通常由对应子系统在初始化阶段调用 kmem_cache_create() 建出来;这个调用先建立“这种对象应该怎么被管理”的 cache 描述,真正承载对象的 slab page 往往要等第一次分配或 cache 需要补充对象时,才从 buddy allocator 申请页并切成对象。

也就是说,初始化过程分成两层:

第一层:创建 kmem_cache
    └─ 建立对象规格:名字、对象大小、对齐、flags、ctor、CPU/node 状态

第二层:创建 slab page
    └─ cache 里没有可用对象时,向 buddy 要页,把页初始化成 slab,再切成对象

不要把这两层混在一起。kmem_cache_create() 创建的是“对象池的管理入口”;它不一定马上把所有对象都预先分配出来。

4.1 子系统先声明“我要一种对象池”

以 VMA 为例,内核需要大量 struct vm_area_struct。这类对象大小固定、生命周期频繁、释放后又很可能复用,所以它适合用专用 cache。

初始化时,相关代码会做类似这样的事情:

vm_area_struct cache =
    kmem_cache_create(
        name        = "vm_area_struct",
        object_size = sizeof(struct vm_area_struct),
        align       = 对齐要求,
        flags       = 调试/回收/RCU/accounting 等标志,
        ctor        = 可选构造函数
    )

这里不是在分配某一个 VMA 对象,而是在告诉 SLUB:

以后如果有人要 struct vm_area_struct,
请按这个大小、这个对齐、这些规则来管理它。

同样,dentry、inode、struct filetask_struct 等常见对象,也会在各自子系统初始化时建立自己的专用 kmem_cache。这些 cache 的名字通常也会出现在 /proc/slabinfo 里。

4.2 kmem_cache_create() 先算对象布局

创建专用 cache 时,SLUB 首先要把“调用者眼里的对象大小”转换成“slab 内部真正使用的对象槽位大小”。

这两个大小不一定一样:

object_size
    └─ 调用者真正需要的结构体大小,例如 sizeof(struct vm_area_struct)

size
    └─ slab 里每个 object slot 的实际跨度,
       可能包含对齐、freelist 指针位置、redzone、poison、KASAN 等额外开销

比如一个结构体本身是 152 字节,SLUB 可能因为对齐把槽位扩到 160 字节;如果开启调试选项,还可能更大。这个槽位大小确定以后,SLUB 才能继续计算:

一个 slab 用几页?
一页或多页里能放多少个对象?
free object 里的 next 指针放在哪个 offset?
这个 cache 是否能和别的同规格 cache 合并?

所以 kmem_cache 不是简单记一个 sizeof(type)。它保存的是一整套对象布局和分配策略:

struct kmem_cache
    ├─ name
    ├─ object_size
    ├─ size
    ├─ align
    ├─ offset          -> free object 内 next 指针位置
    ├─ flags
    ├─ ctor
    ├─ cpu_slab        -> per-CPU 快路径状态
    └─ node[]          -> per-node partial slab 池

这一步完成后,cache 已经“存在”了。即使它暂时还没有任何 slab page,它也已经知道以后应该怎样创建 slab、怎样切对象、怎样释放对象。

4.3 再初始化 per-CPU 和 per-node 状态

一个专用 cache 不能只有全局规格。为了让分配快,它还需要给 CPU 和 NUMA node 准备状态。

简化看,创建 cache 时会把这些管理结构准备好:

kmem_cache: vm_area_struct
    │
    ├─ cpu_slab
    │    ├─ CPU0: freelist = NULL, slab = NULL, partial = NULL
    │    ├─ CPU1: freelist = NULL, slab = NULL, partial = NULL
    │    └─ ...
    │
    └─ node[]
         ├─ node0: partial list = empty
         └─ node1: partial list = empty

刚创建完时,这些链表通常是空的:

还没有 active slab
还没有 per-CPU freelist
还没有 per-node partial slab

这很正常。因为此时只是把“仓库规则”和“货架位置”建好了,货还不一定进来。

4.4 第一次分配时才可能向 buddy 要页

当后面真的有人分配 VMA 对象时,才会走到这个 cache:

kmem_cache_alloc(vm_area_struct cache)

如果这是一个刚创建、还没有对象的 cache,那么路径会一路走到慢路径:

1. 当前 CPU freelist 为空
2. 当前 CPU active slab 为空
3. 当前 CPU partial list 为空
4. 当前 node partial list 为空
5. 向 buddy allocator 申请一页或多页

buddy 只返回页,它不知道这些页将来要放 VMA 还是 dentry:

buddy allocator
    └─ 返回 1 页或多页连续物理页

SLUB 拿到这些页以后,才把它们初始化成属于这个 cache 的 slab:

新页 / 新 folio
    │
    ├─ 标记为 slab
    ├─ 记录 slab_cache = vm_area_struct cache
    │    也就是“专门分配 struct vm_area_struct 对象的那个 kmem_cache”
    ├─ 计算 objects 数量
    ├─ 初始化 inuse / frozen 等状态
    └─ 把页内对象槽位串成 freelist

展开成图就是:

kmem_cache_alloc(vm_area_struct cache)
        │
        ▼
cache 里没有可用对象
        │
        ▼
alloc_pages()
        │
        ▼
buddy 返回一组页
        │
        ▼
SLUB 把这组页初始化成 slab
        │
        ▼
按 cache->size 切成 object slots
        │
        ▼
把 free objects 串成 freelist
        │
        ▼
返回其中一个 struct vm_area_struct *

所以从上到下的完整构建关系是:

子系统初始化
    │
    └─ kmem_cache_create("vm_area_struct", sizeof(struct vm_area_struct), ...)
          │
          ├─ 创建 / 初始化 struct kmem_cache
          ├─ 计算对象槽位布局:size、align、offset、objects per slab
          ├─ 初始化 per-CPU 状态:kmem_cache_cpu
          └─ 初始化 per-node 状态:kmem_cache_node

第一次或后续分配
    │
    └─ kmem_cache_alloc(vm_area_struct cache)
          │
          ├─ 先查 per-CPU freelist / partial slab
          ├─ 再查 per-node partial slab
          └─ 都没有时向 buddy 要页
                │
                └─ 初始化 struct slab / slab folio
                      │
                      ├─ 切成多个 vm_area_struct object slot
                      ├─ 串起 free object freelist
                      └─ 返回一个对象给调用者

这里还有一个细节:有些 cache 或子系统可能会在初始化后主动预热、保留一定数量对象,某些引导阶段也有特殊路径。但主线理解可以先按这个模型记:

kmem_cache_create()
    └─ 创建对象池规则和管理入口

kmem_cache_alloc()
    └─ 需要对象时才消费现有 freelist,或者向 buddy 要页创建新 slab

4.5 专用对象 cache 和通用 kmalloc cache 的初始化差别

专用对象 cache 是某个子系统显式创建的:

VMA 子系统      -> vm_area_struct cache
VFS             -> dentry / inode / filp cache
进程管理相关代码 -> task_struct cache

通用 kmalloc-* cache 则是 slab 子系统自己在初始化阶段批量建立的大小档位:

kmalloc-8
kmalloc-16
kmalloc-32
kmalloc-64
kmalloc-128
...

两者创建入口和用途不同,但创建出来以后都落到同一种核心抽象:

都是 struct kmem_cache
都有 object_size / size / offset
都有 per-CPU 和 per-node 状态
缺对象时都向 buddy 要页创建 slab
释放后都把对象挂回 freelist

差别只在上层语义:

专用对象 cache
    └─ “这里放的就是 struct vm_area_struct”

通用 kmalloc cache
    └─ “这里只保证槽位大小是 64B / 128B / 256B,里面是什么由调用者决定”

五、slab page 里面是什么

一个 slab 通常由一页或多页组成。对于小对象,一页里可以放很多个对象;对于大一点的对象,一个 slab 可能需要多页。

更直白地说,slab 是把从 buddy 拿到的一页或多页物理页,按某个 kmem_cache 的对象槽位大小逻辑切分成一个个固定大小的 object slot;其中空闲的 object slot 再通过 freelist 串起来,分配时弹出,释放时压回。

在 Linux 6.12 SLUB 里,struct slab 是一个很关键的描述符。它不是用户能直接看到的对象,而是内核给“一组承载对象的页”挂上的管理状态。实现上它复用了 struct page 的一些字段,因为 slab 本质上仍然来自页分配器。

这一节从两层看 slab page:先看 struct slab 这个描述符如何把对象页接到 cache 上,再看 free object 如何借用自身空间串成 freelist。

5.1 struct slab 描述什么

可以先看精简后的静态结构:

   struct slab
        ├─ slab_cache  -> 这个 slab 属于哪个 kmem_cache
        │
        ├─ slab_list   -> 挂到 node partial/full 链表时使用
        │     或
        ├─ next/slabs  -> 挂到 per-cpu partial 链表时使用
        │
        ├─ freelist    -> 这个 slab 内第一个 free object
        ├─ inuse       -> 当前已经分配出去的对象数
        ├─ objects     -> 这个 slab 总共有多少对象槽位
        └─ frozen      -> 是否暂时脱离普通链表管理

kmem_cachekmem_cache_cpukmem_cache_nodeslab 放在一起看,最直观的方式不是先背字段,而是先看它们在内存管理里的分布:左边是一小批管理结构,右边是一组真正承载对象的 slab pages。

仍然以 kmalloc-512 为例:

下面先放一张完整标注图,用来快速建立全局印象。图里的线很多,只适合先看“谁和谁有关系”;如果想逐条看清某个字段到底指向哪里,建议打开交互版,然后按 cpu freelistslab freelistcpu partialnode partial 这个顺序点一遍:

打开 SLUB 结构关系交互图:点击字段逐条追踪指针关系

SLUB 结构关系完整标注图

5.2 两组最容易混的指针关系

看这张图时,最容易混的是两组关系。

第一组是 kmem_cache_cpu->freelistslab->freelist。它们都是 object freelist,链的是空闲对象,不是 slab:

struct kmem_cache_cpu
    ├─ slab      -> 当前 CPU 正在使用的 active slab
    └─ freelist -> 当前 CPU 下一次可以直接拿的 free object

struct slab
    └─ freelist -> 这个 slab 内部的 free object 链表头

这条链的节点是 free object 本身,next 指针存在 object 内部的 object + cache->offset 位置。它回答的问题是:

下一次分配可以直接返回哪个对象?

kmem_cache_cpu->freelist 是当前 CPU 快路径直接消费的链表头。它非空时,分配可以直接弹出一个对象返回。通常情况下,它指向的就是 kmem_cache_cpu->slab 这个 active slab 里的某个 free object:

kmem_cache_cpu freelist 指向 active slab 内的空闲对象

kmem_cache_cpu
    ├─ slab      -> active slab
    └─ freelist  -> active slab 里的 free object -> free object -> ...

从真实 SLUB 源码看,当一个 slab 被装载成当前 CPU 的 active slab 时,SLUB 会把 slab->freelist 这条链转移到 kmem_cache_cpu->freelist,并把 slab->freelist 清成 NULL。所以 active slab 上常见的是:

kmem_cache_cpu->slab      -> slab
kmem_cache_cpu->freelist  -> obj1 -> obj2 -> obj3 -> NULL
slab->freelist            -> NULL

这不是说这个 slab 没有空闲对象,而是这些空闲对象已经交给当前 CPU 的快路径 freelist 管了。等这个 slab 不再被某个 kmem_cache_cpu->slab 当作 active slab 使用时,如果里面还有 free object,SLUB 会把这些对象重新合回 slab->freelist,让 slab 自己重新描述“我还有哪些空闲对象”。如果这个 slab 已经 full,没有 free object,那么 slab->freelist 仍然是 NULL

slab->freelist 是某个 slab 自己记录的空闲对象链表头。它描述这个 slab 内部还有哪些对象没被分配出去,常见于 partial slab、远端 CPU 释放对象、CPU slab 失活、慢路径迁移等场景。

struct slab freelist 串起本 slab 内部的空闲对象

所以这两个 freelist 不是两个 cache,也不是两种对象。它们指向的都是同一类 free object,只是管理层级不同:

kmem_cache_cpu->freelist
    └─ 当前 CPU 的快路径对象入口

slab->freelist
    └─ 某个 slab 自己的空闲对象入口

kmem_cache_cpu->freelist 空了,慢路径可能从当前 active slab 的 slab->freelist 补充对象;如果当前 slab 也不合适,再去找 partial slab 或向 buddy 要新页。

第二组是 kmem_cache_cpu->slabkmem_cache_cpu->partial。这两个字段链的层级不同:

struct kmem_cache_cpu
    ├─ slab      -> 当前 CPU 正在用的 active slab
    ├─ freelist  -> 当前 CPU 可直接分配的 free object
    └─ partial   -> 当前 CPU 缓存的一串 partial slabs

kmem_cache_cpu->slab 通常指向一个 slab。它是当前 CPU 正在服务分配的 active slab,快路径主要围绕它和 kmem_cache_cpu->freelist 工作。

kmem_cache_cpu->partial 指向一串 partial slabs。它不是当前正在分配的那一个 slab,而是当前 CPU 本地缓存的备用 slab 池。链表节点是 slab,不是 object:

kmem_cache_cpu partial 串起 per-CPU 备用 partial slabs

kmem_cache_cpu->partial -> slab -> slab -> slab

当 active slab 用完或不再适合继续分配时,慢路径可以从 kmem_cache_cpu->partial 摘一个 partial slab 出来,安装到 kmem_cache_cpu->slab,让它变成新的 active slab:

kmem_cache_cpu->partial 上的某个 slab
        │
        ▼
kmem_cache_cpu->slab

再补一个状态视角:

active slab
    └─ 正被某个 CPU 的 kmem_cache_cpu->slab 指着,用于当前 CPU 分配

per-cpu partial slab
    └─ 挂在 kmem_cache_cpu->partial 上,给这个 CPU 备用

per-node partial slab
    └─ 挂在 kmem_cache_node->partial 上,给这个 node 上的 CPU 慢路径使用

full slab
    └─ 所有对象都已分配。普通非调试路径通常不需要维护 full list;
       开启 SLUB debug 等配置时,可能挂到 full list 方便检查。

empty slab
    └─ 所有对象都空闲。可能暂时保留,也可能在策略允许时还给 buddy。

kmem_cache_node partial 用 slab_list 串起 per-node partial slabs

所以同一个 struct slab 同一时刻通常只处在一种外部位置上:

被某个 CPU 当作 active slab
或 挂在某个 CPU 的 partial list
或 挂在某个 node 的 partial list
或 已经 full、不在普通 partial list 上
或 empty 后等待复用/释放

但 slab 内部还可以同时有自己的 object freelist:

struct slab
    ├─ 外部位置:active / per-cpu partial / per-node partial / full / empty
    └─ 内部 freelist:free object -> free object -> NULL

所以 slab 这个描述符回答的是:

这几页属于哪个 cache?
里面一共有多少个对象槽位?
已经用了多少个?
还空闲的对象链表头在哪里?
它现在挂在哪个 partial list 上,还是正被某个 CPU 当作 active slab?

5.3 free object 如何串成 freelist

假设页大小 4KB,对象大小 256 字节,不考虑元数据、对齐和调试填充,直觉上可以放 16 个对象:

   4KB slab page
   ┌────┬────┬────┬────┬────┬────┬────┬────┐
   │obj0│obj1│obj2│obj3│obj4│obj5│obj6│obj7│
   ├────┼────┼────┼────┼────┼────┼────┼────┤
   │obj8│obj9│... │                         │
   └────┴────┴────┴─────────────────────────┘

每个对象只有两种核心状态:

allocated:已经返回给某个内核调用者使用
free:还在 cache 里,等待下次分配

空闲对象会被串成 freelist。简化图如下:

   slab page
   ┌─────┬─────┬─────┬─────┬─────┐
   │ used│ free│ used│ freefree│
   └─────┴──┬──┴─────┴──┬──┴──┬──┘
            │           │     │
            └──────────►└────►┘
                 freelist

这个图还需要再具体一点:在本文实验对应的 Linux 6.12 SLUB 实现里,freelist 不是额外分配一组链表节点,而是把空闲对象自己的存储空间拿来放 next 指针

源码里的关键逻辑是:

static inline void *get_freepointer(struct kmem_cache *s, void *object)
{
    unsigned long ptr_addr;
    freeptr_t p;

    ptr_addr = (unsigned long)object + s->offset;
    p = *(freeptr_t *)(ptr_addr);
    return freelist_ptr_decode(s, p, ptr_addr);
}

static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
    unsigned long freeptr_addr = (unsigned long)object + s->offset;

    *(freeptr_t *)freeptr_addr = freelist_ptr_encode(s, fp, freeptr_addr);
}

也就是说,free object 的 object + s->offset 位置存着“下一个 free object”的地址。没有开启 freelist hardening 时,可以把它理解成普通 next 指针;开启 CONFIG_SLAB_FREELIST_HARDENED 时,这个指针会经过编码,避免 freelist 被轻易篡改。

这里的关键点是:只有 free object 会被当作链表节点使用。对象被分配出去以后,这块内存就归调用者使用,里面不再由 SLUB 维护 next

把一个 free object 内部展开看,大概是这样:

free object A 的起始地址
│
▼
┌──────────────┬────────────────────────┬──────────────────────┐
│ object bytes │ next pointer slot      │ object bytes         │
│              │ *(A + cache->offset)=D │                      │
└──────────────┴────────────────────────┴──────────────────────┘
               ▲
               │
               └─ cache->offset 决定 next 指针放在对象内部哪个位置

cache->offset 是怎么来的?

它不是每次分配对象时动态决定的,而是在创建这个 kmem_cache 时由 SLUB 的布局计算逻辑统一算好。这个 cache 里的所有对象槽位,都用同一个 s->offset 找 freelist 指针。

以 Linux 6.12 的 calculate_sizes() 逻辑简化看,规则大致是:

1. 先把 object_size 按指针大小对齐
   因为 free pointer 只能放在 word-aligned 的位置。

2. 如果 free pointer 不能覆盖对象内容
   例如:SLAB_TYPESAFE_BY_RCU、对象有 ctor、开启 poison、
   某些 redzone/debug 场景
   那就把 free pointer 放到对象有效区域后面:

       s->offset = aligned_object_size

   也就是它不在调用者看到的 object 内容里,而是在这个 slab
   object slot 额外扩出来的元数据位置里。

3. 如果是 SLAB_TYPESAFE_BY_RCU 并且创建 cache 时显式提供了 freeptr_offset
   那就使用调用者指定的位置:

       s->offset = args->freeptr_offset

4. 普通情况则把 free pointer 放在对象中间附近:

       s->offset = ALIGN_DOWN(object_size / 2, sizeof(void *))

   这样做是为了让 next 指针远离对象边界,降低相邻对象小范围
   越界写直接破坏 freelist 指针的概率。

所以 cache->offset 本质上是这个 cache 的对象布局参数。普通 cache 往往把 next 放在 free object 的中间附近;带 RCU、构造函数或调试/poison 约束的 cache,可能把它放到对象有效内容之外。

所以更接近实现的 slab 图是:

   slab page
   ┌────────┬─────────────────┬────────┬─────────────────┬─────────────────┐
   │ used   │ free obj A      │ used   │ free obj D      │ free obj E      │
   │ object │ [next slot]=D   │ object │ [next slot]=E   │ [next slot]=NULL│
   └────────┴────────┬────────┴────────┴────────┬────────┴────────┬────────┘
                     │                          │                 │
                     └─────────────────────────►└────────────────►NULL

注意图里的 [next slot] 不是额外插入的结构体字段。它只是 free object 自己的一小段存储空间,被 SLUB 临时解释成“下一个 free object 的地址”。如果对象重新分配给调用者,这个位置也会变回普通对象内容。

5.4 freelist 的头在哪里

那么 freelist 的“头”在哪里?

在 6.12 SLUB 里有两个常见头指针:

struct kmem_cache_cpu
    ├─ freelist  -> 当前 CPU active slab 中下一个可快速分配的对象
    ├─ tid       -> 和 freelist 一起做无锁更新的事务 id
    └─ slab      -> 当前 CPU 正在分配的 active slab

struct slab
    ├─ freelist  -> 这个 slab 自身的第一个空闲对象
    ├─ inuse     -> 已使用对象数
    └─ objects   -> 这个 slab 里总对象数

快路径分配优先看当前 CPU 的 kmem_cache_cpu->freelist。逻辑可以简化成:

object = c->freelist
next   = get_freepointer(cache, object)
c->freelist = next
return object

也就是从链表头弹出一个对象:

before:
c->freelist -> A -> D -> E -> NULL

allocate one object:
return A

after:
c->freelist -> D -> E -> NULL

释放到当前 CPU active slab 时,逻辑反过来:

old = c->freelist
set_freepointer(cache, object, old)
c->freelist = object

也就是把刚释放的对象压到链表头:

before:
c->freelist -> D -> E -> NULL

free A:
A->next = D

after:
c->freelist -> A -> D -> E -> NULL

slab->freelist 则用于 slab 自身的空闲对象链,尤其在 partial slab、慢路径、远端 CPU 释放对象、CPU slab 失活等场景里使用。比如 6.12 的 slow path 会从 slab->freelist 取对象:

object = slab->freelist
slab->freelist = get_freepointer(cache, object)
slab->inuse++

所以可以把它总结成:

链表节点:free object 本身
next 指针:存在 free object 内部的 object + cache->offset
快路径头:当前 CPU 的 kmem_cache_cpu->freelist
slab 自身头:struct slab->freelist

如果启用了 SLUB debug、KASAN、red zone、poison 等调试能力,对象周围可能会有额外填充和检查信息。这会改变 objsize、每个 slab 能放多少对象,也会影响性能。因此阅读 /proc/slabinfo 时,先记住它反映的是当前内核配置下的现实布局。

六、per-cpu freelist:先走无锁快路径

内核对象分配非常频繁。如果每次分配都去全局链表上加锁,CPU 多了以后锁竞争会很重。

以 Linux 6.12 这类常见 SLUB 实现为例,快路径会尽量从当前 CPU 的 freelist 取对象:

   kmem_cache_alloc(cache)
        │
        ▼
   当前 CPU freelist 有空闲对象?
        │
        ├─ 有
        │   └─ 弹出一个对象,返回给调用者
        │
        └─ 没有
            └─ 进入慢路径

慢路径才会去找 partial slab,甚至向 buddy 要新页。这里的 partial 不是只有一层:通常会先看当前 CPU 本地缓存的 partial slab,再退到当前 NUMA node 共享的 partial list;两边都没有合适 slab,才需要向 buddy 要新页:

   per-cpu freelist 为空
        │
        ▼
   找 per-cpu partial slab
        │
        ├─ 找到
        │    ├─ 把 slab 接到当前 CPU
        │    └─ 从里面拿对象
        │
        └─ 找不到
             │
             ▼
          找 per-node partial slab
             │
             ├─ 找到
             │    ├─ 把 slab 接到当前 CPU
             │    └─ 从里面拿对象
             │
             └─ 找不到
                  │
                  ▼
               向 buddy 分配新页
                  │
                  ▼
               切成一批对象
                  │
                  ▼
               返回其中一个对象,其余挂入 freelist

这解释了为什么 slab 位于 buddy 之上:

   内核要一个小对象
        │
        ▼
   slab cache 有现成空闲对象
        │
        ├─ 有:直接返回对象
        │
        └─ 没有:连 partial slab 也补不到时,向 buddy 要页,再切对象

buddy 并不知道“这是一个 VMA 对象”还是“这是一个 dentry 对象”。buddy 只负责给 slab 提供页;slab 才负责对象大小、对象状态、对象复用。

七、一次 kmem_cache_alloc 的动态流程

把上面的结构串起来,一次专用 cache 分配可以画成:

   kmem_cache_alloc(vm_area_struct cache)
        │
        ▼
   当前 CPU freelist
        │
        ├─ 有对象
        │     │
        │     └─ pop object -> 返回 struct vm_area_struct *
        │
        └─ 没有对象
              │
              ▼
          per-cpu partial slabs
              │
              ├─ 有 partial slab
              │     │
              │     └─ 取出一个空闲对象
              │
              └─ 没有 partial slab
                    │
                    ▼
                per-node partial slabs
                    │
                    ├─ 有 partial slab
                    │     │
                    │     └─ 取出一个空闲对象
                    │
                    └─ 没有 partial slab
                          │
                          ▼
                      alloc_pages()
                          │
                          ▼
                      buddy 从 zone 里分配页
                          │
                          ▼
                      初始化为 slab,切成对象
                          │
                          ▼
                      返回一个对象

释放路径反过来:

   kmem_cache_free(cache, obj)
        │
        ▼
   通过 obj 地址找到它所在的 slab
        │
        ▼
   确认这个 slab 属于哪个 kmem_cache
        │
        ▼
   把 obj 的 next slot 写成旧 freelist 头
        │
        ▼
   freelist 头改成 obj
        │
        ▼
   slab 变成 partial / full / empty 中某种状态
        │
        ├─ cache 仍然保留这页,等待复用
        └─ 压力或策略触发时,空 slab 可能还给 buddy

也就是说,对象“回收”不是把对象里的字段一个个还原成初始值,而是先把这段内存重新标记为 free object,再把它挂回对应 cache 的空闲对象链表。

简化成链表操作就是:

before free:
c->freelist -> D -> E -> NULL

free object A:
*(A + cache->offset) = D
c->freelist = A

after free:
c->freelist -> A -> D -> E -> NULL

这个 A + cache->offset 位置就是前面图里的 [next slot]。对象释放以后,调用者不能再读写 A,所以 SLUB 可以把 A 的内部空间拿来存 freelist 指针。

释放时还有一个隐含步骤:SLUB 要知道 A 属于哪个 slab。因为 slab 本身来自页分配器,内核可以通过对象地址找到承载它的 struct slab,再从 slab->slab_cache 找回所属的 kmem_cache。这样即使走的是通用 kfree(obj),内核也能回到正确的 cache,而不是让调用者手工说明这是 kmalloc-64 还是 kmalloc-512

这就引出一个更底层的问题:给定一个对象地址,内核怎么定位到它所在的 slab? 这个问题和 foliostruct pagestruct slab 的关系有关,单独放到下一节说。

八、对象地址怎么回到 slab:folio 和 struct slab

这里出现的 folio 可以先理解成:内核把一页或多页当成一个整体管理时使用的页级抽象

以前很多内核代码直接传 struct page *,但 struct page 既可能表示一个普通 4KB 页,也可能表示复合页里的某个 tail page,语义容易混。struct folio 的意图是让代码更明确地表达“我操作的是这一组页的头部和整体”,而不是其中任意一个 page。一个 folio 可以只包含一页,也可以包含多页。

8.1 slab folio 是什么

在 SLUB 这里,体现得很直接:slab 是按 folio 分配和描述的。也就是说,一个 slab 可能是一页,也可能是多页;内核把承载这批对象的页组当作一个 folio,然后在这个 folio 的页描述符字段上挂 slab 管理信息。

Linux 6.12 的 mm/slab.h 里能看到这种关系:

struct slab 和 struct page/folio 不是三块互不相干的内存。
对 slab folio 来说,struct slab 是这组页的 slab 视角。

folio_slab(folio) -> 把 slab folio 转成 struct slab
slab_folio(slab)  -> 把 struct slab 转回 folio

源码注释也明确说:slab 是作为 folio 分配的,里面包含实际对象,并使用 folio 第一个 struct page 里的某些字段;这些字段在 SLUB 里通过 struct slab 访问。

再把两个转换拆开看。

8.2 page_folio(page) 怎么知道 page 属于哪个 folio

内核里每个物理页框都有一个 struct page。如果某次分配只是一页,那么这个 page 自己就是 folio 的头。如果某次分配是多页,也就是 compound page / large folio,那么第一个 page 是 head page,后面的 page 是 tail page。tail page 的 compound_head 字段会记录 head page 的地址,并用最低 bit 标记“我是 tail page”。

也就是说,一个多页 folio 只有第一个 page 是 head page,后面的所有 page 都是 tail page:

4-page folio / compound page

┌──────────────┬──────────────┬──────────────┬──────────────┐
│ page[0]      │ page[1]      │ page[2]      │ page[3]      │
│ head page    │ tail page    │ tail page    │ tail page    │
│              │ compound_head│ compound_head│ compound_head│
│              │   = page[0]+1│   = page[0]+1│   = page[0]+1│
└──────────────┴──────┬───────┴──────┬───────┴──────┬───────┘
                      │              │              │
                      └──────────────┴──────────────┘
                                     │
                                     ▼
                                  page[0]

这里的 +1 不是说 head page 的真实地址加 1 字节,而是内核把指针最低 bit 借来做标记:低 bit 为 1 表示“这个字段里存的是 head page 指针,而且当前 page 是 tail page”。取回 head page 时再把这个 bit 去掉。

对于 slab 来说,如果一个 slab 占 4 页,那么这 4 页就是一个 slab folio:

4-page slab folio

┌──────────────┬──────────────┬──────────────┬──────────────┐
│ page[0]      │ page[1]      │ page[2]      │ page[3]      │
│ head page    │ tail page    │ tail page    │ tail page    │
│ slab metadata│ object bytes │ object bytes │ object bytes │
│ / first objs │              │              │              │
└──────────────┴──────────────┴──────────────┴──────────────┘
       ▲
       │
       └─ struct slab / folio 视角从 head page 开始

所以对象地址不管落在 page[0]page[1]page[2] 还是 page[3],先通过 virt_to_page(obj) 找到具体那一页,再通过 page_folio(page) 都能回到同一个 head page,也就是同一个 folio。

Linux 6.12 里 page_folio() 本质上就是取 compound head:

single-page folio:

page
 │
 └─ compound_head(page) == page
        │
        ▼
     folio = (struct folio *)page

multi-page folio:

head page        tail page 1        tail page 2
┌────────┐       ┌──────────┐       ┌──────────┐
│ head   │◄──────│compound  │       │compound  │
│ page   │       │head=head │       │head=head │
└────────┘       └──────────┘       └──────────┘
   │                                      │
   └──────────── page_folio(tail page) ◄──┘
                 returns head as folio

源码简化后就是:

static __always_inline unsigned long _compound_head(const struct page *page)
{
    unsigned long head = READ_ONCE(page->compound_head);

    if (head & 1)
        return head - 1;
    return (unsigned long)page;
}

#define page_folio(p) ((struct folio *)_compound_head(p))

所以 page_folio(page) 不是搜索“哪个 folio 包含这个 page”。包含关系已经写在 tail page 的 compound_head 元数据里;单页 folio 则直接返回自己。

8.3 folio_slab(folio) 怎么知道 folio 对应哪个 slab

先要判断这个 folio 是否被 SLUB 当作 slab 使用。Linux 里有页类型标记,folio_test_slab(folio) 就是在看这个 folio 的 head page 是否带有 slab 类型。不是所有 folio 都能转成 struct slab;只有 slab folio 才能这样转。

一旦确认是 slab folio,folio_slab(folio) 目前就是一次类型转换:

#define folio_slab(folio) ((struct slab *)(folio))

这看起来有点粗暴,但背后有结构布局保证。struct slab 复用了 head page 的页描述符字段,Linux 6.12 的 mm/slab.h 里有一组 static_assert 检查关键字段偏移:

struct page.flags          <-> struct slab.__page_flags
struct page.compound_head  <-> struct slab.slab_cache
struct page._refcount      <-> struct slab.__page_refcount

也就是说,同一块页元数据:

普通页视角:     struct page / struct folio
slab 页视角:    struct slab

当这组页被标记为 slab folio 时,SLUB 就可以用 struct slab 视角读取里面的 slab_cachefreelistinuseobjects 等字段。

这一步不是扫描所有 slab,也不是从 freelist 里反查。对象地址本身落在某个内核虚拟地址范围里,内核可以先把虚拟地址换成对应的 struct page / folio,再从这个 folio 找到 slab 描述符。

Linux 6.12 里这条路径可以简化成:

obj address
    │
    ▼
virt_to_page(obj)
    │
    ▼
page_folio(page)
    │
    ▼
folio_test_slab(folio)
    │
    ├─ 不是 slab folio:不能按 slab object 释放
    │
    └─ 是 slab folio
          │
          ▼
      folio_slab(folio) -> struct slab
          │
          ▼
      slab->slab_cache  -> struct kmem_cache

源码里的封装就是:

static inline struct slab *virt_to_slab(const void *addr)
{
    struct folio *folio = virt_to_folio(addr);

    if (!folio_test_slab(folio))
        return NULL;

    return folio_slab(folio);
}

为什么 folio 能找到 struct slab?因为 slab 本质上是 buddy 分出来的一页或多页,内核本来就为每个物理页维护 struct page 元数据。SLUB 会把这些页标记成 slab,并在页/folio 相关元数据里挂上 slab 描述信息。于是给定对象地址以后,可以先定位“这块地址属于哪组页”,再定位“这组页是不是一个 slab,以及它属于哪个 cache”。

8.4 回到释放路径

如果释放发生在当前 CPU 正在使用的 active slab 上,常见快路径就是把对象压到 kmem_cache_cpu->freelist。如果对象属于别的 CPU 的 active slab,或者当前 slab 状态需要调整,就会走慢路径,更新 slab->freelistslab->inuse,必要时把 slab 放回 partial list。

slab->inuse 从满变成未满时,这个 slab 就重新有空闲对象,可能进入 partial 状态;当 slab->inuse 变成 0 时,整个 slab 都空了。空 slab 可能被 cache 留着复用,也可能在收缩、内存压力或策略允许时释放回 buddy。

所以不要把 kfree() 理解成“立刻把物理页还给系统”。小对象释放后,通常只是回到对应 cache。cache 保留一些空闲对象,是为了下一次分配更快。

九、/proc/slabinfo 看到的是什么

用户态可以通过 /proc/slabinfo 观察 slab cache。它不是每个进程自己的视图,而是当前内核的全局 slab 使用情况。常见行长这样:

vm_area_struct 3066 3276 152 26 1 : tunables 0 0 0 : slabdata 126 126 0

前几个字段最重要:

name active_objs num_objs objsize objperslab pagesperslab

含义可以这样读:

  • name:cache 名称。
  • active_objs:当前正在使用的对象数。
  • num_objs:这个 cache 当前拥有的对象总容量。
  • objsize:单个对象大小。
  • objperslab:每个 slab 里有多少对象。
  • pagesperslab:每个 slab 由多少页组成。

vm_area_struct 为例:

objsize=152 objperslab=26 pagesperslab=1

在这台测试内核上,一个 vm_area_struct cache 对象大小是 152 字节,一个 4KB slab page 可以放 26 个对象。26 * 152 = 3952,剩余空间用于对齐、元数据或不可用碎片。这个数字不是所有内核固定值,换内核版本、配置、架构或调试选项都可能不同。

可以用命令直接看:

head -5 /proc/slabinfo
grep -E '^(vm_area_struct|task_struct|dentry|kmalloc-64|kmalloc-512) ' /proc/slabinfo

也可以用 slabtop 动态观察:

slabtop

但要注意:容器里看到的 /proc/slabinfo 仍然是宿主内核提供的视图,通常不是“这个容器独占的 slab”。因此它适合观察 cache 结构和趋势,不适合当成严格隔离的容器内存账本。

十、实验:创建很多 VMA,观察 vm_area_struct cache

下面这个程序做三件事:

  1. 打印当前机器架构、内核版本和页大小。
  2. 读取 /proc/slabinfo 中几个典型 cache 的字段。
  3. 创建 2000 个 4KB 匿名映射,并交替使用 PROT_NONEPROT_READ,尽量避免相邻 VMA 被内核合并。

10.1 实验代码

完整代码如下,也放在同目录的 06-slabinfo-demo.c

#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/utsname.h>
#include <unistd.h>

struct slab_row {
    char name[96];
    unsigned long active_objs;
    unsigned long num_objs;
    unsigned long objsize;
    unsigned long objperslab;
    unsigned long pagesperslab;
};

static int read_slab_row(const char *cache, struct slab_row *out)
{
    FILE *fp = fopen("/proc/slabinfo", "r");
    char line[512];

    if (!fp) {
        fprintf(stderr, "open /proc/slabinfo failed: %s\n", strerror(errno));
        return -1;
    }

    while (fgets(line, sizeof(line), fp)) {
        struct slab_row row;

        if (line[0] == '#' || strncmp(line, "slabinfo", 8) == 0)
            continue;

        memset(&row, 0, sizeof(row));
        if (sscanf(line, "%95s %lu %lu %lu %lu %lu",
                   row.name, &row.active_objs, &row.num_objs,
                   &row.objsize, &row.objperslab, &row.pagesperslab) != 6)
            continue;

        if (strcmp(row.name, cache) == 0) {
            *out = row;
            fclose(fp);
            return 0;
        }
    }

    fclose(fp);
    return 1;
}

static void print_slab_cache(const char *tag, const char *cache)
{
    struct slab_row row;
    int ret = read_slab_row(cache, &row);

    if (ret < 0)
        exit(1);
    if (ret > 0) {
        printf("[%s] %-24s not found\n", tag, cache);
        return;
    }

    printf("[%s] %-24s active=%lu total=%lu objsize=%lu objperslab=%lu pagesperslab=%lu\n",
           tag, row.name, row.active_objs, row.num_objs, row.objsize,
           row.objperslab, row.pagesperslab);
}

static unsigned long count_self_maps(void)
{
    FILE *fp = fopen("/proc/self/maps", "r");
    char line[512];
    unsigned long count = 0;

    if (!fp) {
        perror("open /proc/self/maps");
        exit(1);
    }

    while (fgets(line, sizeof(line), fp))
        count++;

    fclose(fp);
    return count;
}

int main(int argc, char **argv)
{
    const long page_size = sysconf(_SC_PAGESIZE);
    int mappings = 2000;
    void **addr;
    struct utsname uts;

    if (argc > 1)
        mappings = atoi(argv[1]);
    if (mappings <= 0) {
        fprintf(stderr, "usage: %s [positive_mapping_count]\n", argv[0]);
        return 2;
    }

    if (uname(&uts) != 0) {
        perror("uname");
        return 1;
    }

    addr = calloc((size_t)mappings, sizeof(*addr));
    if (!addr) {
        perror("calloc");
        return 1;
    }

    printf("machine=%s sysname=%s release=%s\n",
           uts.machine, uts.sysname, uts.release);
    printf("page_size=%ld mappings=%d\n", page_size, mappings);

    printf("self VMA count before=%lu\n", count_self_maps());
    print_slab_cache("before", "vm_area_struct");
    print_slab_cache("before", "task_struct");
    print_slab_cache("before", "dentry");
    print_slab_cache("before", "kmalloc-64");
    print_slab_cache("before", "kmalloc-512");

    for (int i = 0; i < mappings; i++) {
        int prot = (i % 2 == 0) ? PROT_NONE : PROT_READ;

        addr[i] = mmap(NULL, (size_t)page_size, prot,
                       MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
        if (addr[i] == MAP_FAILED) {
            fprintf(stderr, "mmap %d failed: %s\n", i, strerror(errno));
            return 1;
        }
    }

    printf("self VMA count after mmap=%lu\n", count_self_maps());
    print_slab_cache("after mmap", "vm_area_struct");
    print_slab_cache("after mmap", "task_struct");
    print_slab_cache("after mmap", "dentry");
    print_slab_cache("after mmap", "kmalloc-64");
    print_slab_cache("after mmap", "kmalloc-512");

    for (int i = 0; i < mappings; i++) {
        if (munmap(addr[i], (size_t)page_size) != 0) {
            fprintf(stderr, "munmap %d failed: %s\n", i, strerror(errno));
            return 1;
        }
    }

    printf("self VMA count after munmap=%lu\n", count_self_maps());
    print_slab_cache("after munmap", "vm_area_struct");
    print_slab_cache("after munmap", "task_struct");
    print_slab_cache("after munmap", "dentry");
    print_slab_cache("after munmap", "kmalloc-64");
    print_slab_cache("after munmap", "kmalloc-512");

    free(addr);
    return 0;
}

10.2 编译运行

在 x86_64 容器里编译运行:

docker run --rm --platform linux/amd64 \
  -v /Users/xyzjiao/article:/work -w /work \
  gcc:13 \
  bash -lc 'gcc -O2 -Wall -Wextra os/memory/06-slabinfo-demo.c -o /tmp/slabinfo-demo && /tmp/slabinfo-demo 2000'

一次真实输出如下:

machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 mappings=2000
self VMA count before=33
[before] vm_area_struct           active=3066 total=3276 objsize=152 objperslab=26 pagesperslab=1
[before] task_struct              active=464 total=476 objsize=4160 objperslab=7 pagesperslab=8
[before] dentry                   active=557786 total=583611 objsize=192 objperslab=21 pagesperslab=1
[before] kmalloc-64               active=5096 total=5440 objsize=64 objperslab=64 pagesperslab=1
[before] kmalloc-512              active=11098 total=13664 objsize=512 objperslab=32 pagesperslab=4
self VMA count after mmap=2033
[after mmap] vm_area_struct           active=4992 total=4992 objsize=152 objperslab=26 pagesperslab=1
[after mmap] task_struct              active=464 total=476 objsize=4160 objperslab=7 pagesperslab=8
[after mmap] dentry                   active=557786 total=583611 objsize=192 objperslab=21 pagesperslab=1
[after mmap] kmalloc-64               active=5096 total=5440 objsize=64 objperslab=64 pagesperslab=1
[after mmap] kmalloc-512              active=11098 total=13664 objsize=512 objperslab=32 pagesperslab=4
self VMA count after munmap=33
[after munmap] vm_area_struct           active=4992 total=4992 objsize=152 objperslab=26 pagesperslab=1
[after munmap] task_struct              active=464 total=476 objsize=4160 objperslab=7 pagesperslab=8
[after munmap] dentry                   active=557785 total=583611 objsize=192 objperslab=21 pagesperslab=1
[after munmap] kmalloc-64               active=5096 total=5440 objsize=64 objperslab=64 pagesperslab=1
[after munmap] kmalloc-512              active=11098 total=13664 objsize=512 objperslab=32 pagesperslab=4

10.3 结果怎么读

这组结果可以读出几件事。

第一,测试确实跑在 x86_64 用户态容器里:

machine=x86_64

第二,创建 2000 个匿名映射后,当前进程自己的 VMA 数从 33 增加到 2033:

self VMA count before=33
self VMA count after mmap=2033

这说明程序确实制造了大量 VMA,而不是被合并成一个大 VMA。

第三,vm_area_struct cache 的 active 对象数明显增加:

[before]     vm_area_struct active=3066 total=3276
[after mmap] vm_area_struct active=4992 total=4992

增加量不是严格等于 2000,这是正常的。原因有几个:

  • /proc/slabinfo 是全局视图,系统上其他任务也可能同时分配或释放对象。
  • 内核可能提前扩容 cache,total 反映的是 cache 当前容量,不是单次实验的精确分配数。
  • SLUB 会保留空闲对象和 slab,不要求 munmap 后立刻把所有对象容量还给 buddy。

第四,munmap 后当前进程的 VMA 数回到 33,但 vm_area_struct cache 的 active/total 没有立刻回到实验前:

self VMA count after munmap=33
[after munmap] vm_area_struct active=4992 total=4992

这正好说明 slab cache 的一个重要特征:对象释放不等于承载它们的 slab page 立刻回到 buddy。cache 可以保留对象容量,后续再创建 VMA 时直接复用。

十一、slab 和前几篇结构怎么接上

现在可以把这个系列前面讲过的结构接起来。

创建 VMA 时,内核需要元数据对象:

   mmap()
      │
      ▼
   创建 / 调整 VMA
      │
      ▼
   分配 vm_area_struct
      │
      ▼
   kmem_cache_alloc(vm_area_struct cache)
      │
      ▼
   slab cache
      │
      └─ 不够时向 buddy 要页

第一次访问匿名映射时,内核需要用户数据页:

   用户写入 mmap 地址
      │
      ▼
   page fault
      │
      ▼
   find_vma:地址合法
      │
      ▼
   alloc_pages()
      │
      ▼
   buddy 从 zone 里分配物理页
      │
      ▼
   填 PTE:虚拟页 -> 物理页

这两条路径不要混在一起:

   VMA 元数据对象:vm_area_struct -> slab -> buddy
   用户数据页面:page fault -> buddy -> PTE
   页表页本身:页表分配 -> buddy

slab 不是给用户进程直接分配 malloc 返回的那块内存。用户态小块 malloc 首先是 libc allocator 的行为;当 libc 需要扩展地址空间时,才通过 brkmmap 找内核。内核在处理这个请求时,可能为 VMA、文件对象、页表等元数据使用 slab;真正承载用户数据的物理页通常仍然来自 buddy。

十二、几个容易踩错的点

第一,slab page 仍然是物理页

slab 不绕过 buddy。它是在 buddy 提供的页上,再切出小对象。

   buddy 管页
   slab 管对象

第二,kmalloc 小对象通常也走 slab cache

kmalloc-64kmalloc-512 这类 cache 就是通用小块分配的常见落点。专用对象用专用 cache,普通小块用 kmalloc cache。

第三,释放对象不代表立刻释放页

kmem_cache_free() 把对象还给 cache。cache 可能继续持有 slab page,等待后续复用。只有在合适时机,空 slab 才可能回到 buddy。

第四,/proc/slabinfo 是全局观察口,不是进程私有账本

你可以用它看到 vm_area_structdentryinodekmalloc-* 等 cache 的规模,但不能直接把某一行数字当成某个进程独占的消耗。

第五,具体对象大小不是 ABI

实验里 vm_area_struct objsize=152,但这只是 6.12.65-linuxkit 这台 x86_64 测试内核上的结果。打开不同配置、换内核版本、换架构、启用调试能力,数值都可能变。

十三、这一篇的核心结论

把 slab 放回整张内存管理地图里,它的位置是:

   用户虚拟地址
        │
        ▼
   VMA:地址是否合法
        │
        ▼
   页表:虚拟页是否已经映射
        │
        ▼
   buddy:分配物理页
        │
        ├─ 用户数据页
        ├─ 页表页
        └─ slab page
              │
              ▼
          内核小对象:vm_area_struct / task_struct / dentry / inode / kmalloc-*

一句话收束:

buddy 是页框分配器,slab 是建立在页框之上的内核对象分配器。前者回答“从哪个 zone 拿几页”,后者回答“这一页里切出来的哪个小对象可以给内核用”。

从初始化视角看,kmem_cache_create() 先创建“这一类对象该怎么被管理”的 cache 入口;kmem_cache_alloc() 在需要对象时再消费 freelist,或者向 buddy 要页创建新的 slab。

下一篇再把 task_structmm_struct、用户栈、内核栈、页表和 VMA 放到一个进程/线程布局里,看这些结构在一个真实 task 身上如何组织起来。

参考资料