Slab 是什么:内核小对象从哪里来
前面几篇已经把用户地址空间、VMA、页表、struct page、zone 和 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-512 的 pagesperslab 和 objperslab 都可能不同。
所以通用大小 cache 的本质可以概括成:
kmalloc-N cache
│
├─ 每个对象槽位固定是 N bytes
├─ 调用者只要不超过 N bytes,就可以放进去
└─ 槽位里到底是什么结构,由调用 kmalloc() 的内核代码决定
比如某段内核代码调用 kmalloc(80, GFP_KERNEL),它不一定值得单独建一个专用 cache,SLUB 通常会把它放到一个能容纳 80 字节的通用大小 cache 里。具体落到 kmalloc-96、kmalloc-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-64、kmalloc-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 cache、dentry 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 file、task_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_cache、kmem_cache_cpu、kmem_cache_node 和 slab 放在一起看,最直观的方式不是先背字段,而是先看它们在内存管理里的分布:左边是一小批管理结构,右边是一组真正承载对象的 slab pages。
仍然以 kmalloc-512 为例:
下面先放一张完整标注图,用来快速建立全局印象。图里的线很多,只适合先看“谁和谁有关系”;如果想逐条看清某个字段到底指向哪里,建议打开交互版,然后按 cpu freelist、slab freelist、cpu partial、node partial 这个顺序点一遍:
5.2 两组最容易混的指针关系
看这张图时,最容易混的是两组关系。
第一组是 kmem_cache_cpu->freelist 和 slab->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
├─ 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 失活、慢路径迁移等场景。
所以这两个 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->slab 和 kmem_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 -> 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。
所以同一个 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│ free│ free│
└─────┴──┬──┴─────┴──┬──┴──┬──┘
│ │ │
└──────────►└────►┘
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? 这个问题和 folio、struct page、struct 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_cache、freelist、inuse、objects 等字段。
这一步不是扫描所有 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->freelist、slab->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
下面这个程序做三件事:
- 打印当前机器架构、内核版本和页大小。
- 读取
/proc/slabinfo中几个典型 cache 的字段。 - 创建 2000 个 4KB 匿名映射,并交替使用
PROT_NONE和PROT_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 需要扩展地址空间时,才通过 brk 或 mmap 找内核。内核在处理这个请求时,可能为 VMA、文件对象、页表等元数据使用 slab;真正承载用户数据的物理页通常仍然来自 buddy。
十二、几个容易踩错的点
第一,slab page 仍然是物理页。
slab 不绕过 buddy。它是在 buddy 提供的页上,再切出小对象。
buddy 管页
slab 管对象
第二,kmalloc 小对象通常也走 slab cache。
kmalloc-64、kmalloc-512 这类 cache 就是通用小块分配的常见落点。专用对象用专用 cache,普通小块用 kmalloc cache。
第三,释放对象不代表立刻释放页。
kmem_cache_free() 把对象还给 cache。cache 可能继续持有 slab page,等待后续复用。只有在合适时机,空 slab 才可能回到 buddy。
第四,/proc/slabinfo 是全局观察口,不是进程私有账本。
你可以用它看到 vm_area_struct、dentry、inode、kmalloc-* 等 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_struct、mm_struct、用户栈、内核栈、页表和 VMA 放到一个进程/线程布局里,看这些结构在一个真实 task 身上如何组织起来。
参考资料
- Linux kernel documentation: Short users guide for the slab allocator
- Linux kernel documentation: The /proc Filesystem
- Linux kernel documentation: Memory Management APIs