Linux内核分析之内存管理-03
21.1 SLAB/SLUB/SLOB 三代分配器
内核对象分配器的第一版 SLAB 出现于 1996 年,其设计直接借鉴 Solaris 的 Jeff Bonwick 论文;随后 SLOB 与 SLUB 相继加入,三种实现长期以编译期互斥的方式并存,由同一套 kmem_cache_* API 统一供给上层。演进到 7.0,主干只剩 SLUB——本树 mm/Kconfig:168 仅有 config SLUB 一项即为直接证据。本节回顾三代设计,理解它们对同一问题(对象零售的缓存友好与记账成本)的不同答案,也解释了 21.2 节 SLUB 结构为何长成现在的样子。
21.1.1 为什么页分配器之上还需要一层
伙伴系统有三个无法直接服务对象分配的短板:
问题 SLUB 的解法
──────────────────────────────────────────────────────────────
1. 粒度过粗: 对象 128B, slab = 1..N 页的"容器",
最小供货 4KB → 平均浪费 内切 N 个等大对象,
94% 以上 (若整页单对象) 零售粒度 = 对象大小
2. 初始化重复: 释放/再分配 ctor 构造函数 + SLAB_TYPESAFE_BY_RCU
间对象状态可复用 (不清零、不重构造, 只重声明)
3. 局部性差: 相邻分配落点 分配点集中在 per-CPU 捆上,
随机散布于物理内存 同 CPU 热对象物理相邻
量化第一点:一个 128 字节的对象若独占一页,浪费 (4096-128)/4096 = 96.9%;切成 32 个/页后零浪费(除元数据)。而内核每天要创建销毁数百万个这样的对象(进程的 mm_struct、VFS 的 dentry、网络的 skb 描述符……)。
一次 dentry 创建销毁的两种命运:
无对象缓存: 有 SLUB:
alloc_pages(1) ~1μs+ kmem_cache_alloc(dentry_cache)
填充初始化 4096B └─ main 捆弹出 ~20ns
用 128B (对象在 CPU 缓存 L1 里, 热!)
释放 → 整页回 buddy kmem_cache_free → 入捆 ~20ns
(冷页, TLB miss, 后续 ( freed 状态保留初始化,
再分配是冷的) d_revalidate 等字段天然复用)
另一个动机是同类型对象的规模可预测:内核知道自己会有多少 dentry、多少 skbuff_head,专用缓存按类型统计与调参(/proc/slabinfo、/sys/kernel/slab/<cache>/)成为观测内核内存构成的基本手段——18 章 totalram 之外的"slab 记账"视角。
21.1.2 SLAB:多链表的精细管理(已移除)
原始 SLAB 设计把每个缓存分为三种颜色带、三级链表(full/partial/free),每 CPU 一个数组缓存(array cache),对象内嵌 freelist。它的优点是元数据精细、锁竞争被 per-CPU 数组隔离;缺点也随之而来:
- 元数据外置:链表头、数组缓存在对象之外,结构复杂;
- 多队列维护成本:full/partial/free 三链的状态转换在高并发下锁开销显著;
- NUMA 时代退化:精细的着色(cache coloring)在物理索引缓存上失效。
SLAB 的对象布局与三链 (回忆图):
kmem_cache
├─ arrays[cpu]: 每 CPU 数组缓存 (热对象)
└─ slabs 链: slabs_full / slabs_partial / slabs_free
slab: [slab_head][obj0][obj1]...[objN]
(着色偏移使 obj0 落在不同 cache line)
SLAB 于 6.8(2024 年初)被移除主干——mm/slab.c 在本树已不存在。移除的理由在当时的合并说明中归纳为"SLUB 已覆盖其全部调试能力且更快、更省元数据,维护两套实现的双倍成本不再值得"。
21.1.3 SLOB:极简首选适应(已移除)
SLOB(Simple List Of Blocks)面向 2MB 以下内存的嵌入式场景:整个分配器就是若干 kmalloc 档的首选适应空闲链,对象与块共用地址空间。它的内存占用最小(几乎没有元数据),但碎片随时间恶化、无调试设施、无 NUMA 感知。SLOB 于 6.4(2023 年)移除——彼时内核最小配置的内存目标已被 SLUB_TINY 覆盖。
SLOB 的碎片恶化示意:
初始: [128B][128B][128B][128B][128B][128B] (一个 768B 块)
分配 A(256B): 首选适应切前两格
分配 B(128B): 切第三格
释放 A: 两格空洞 — 小于任何后续请求 → 永久浪费
时间演化: 碎片率单调上升, 无凝聚机制 (buddy 的
伙伴合并思想完全缺席)
21.1.4 SLUB:极简元数据 + per-CPU 热路径
SLUB(SLab UBiquitous)的原始设计哲学是把元数据压到极限:
SLUB 的两个原始招式 (6.x 时代形态):
1. 对象即链表节点: freelist 指针直接藏在空闲对象体内
(21.5 节将看到 7.0 甚至把它藏进对象"中点"防溢出)
2. slab 即 page: struct slab 复用 struct page 的位布局
(本树 mm/slab.h:74-99 + SLAB_MATCH 断言 :100-112)
→ 无需独立元数据分配, 18.4 节的 page 数组直接可用
每 CPU 缓存演进:
6.x 及以前: cpu_slab->freelist/tid 单链指针
分配 = 弹出 freelist 头 (一条 CAS)
7.0 (本树): cpu_sheaves 成捆对象数组
分配 = main->objects[--size] (数组栈顶弹出, 连 CAS 都省)
(slub.c:420-425 struct slub_percpu_sheaves)
sheaves(捆) 是 7.0 本树的关键新形态:per-CPU 缓存从"一条 freelist 指针"升级为"两捆对象数组 + 一个 RCU 批量释放捆"(main/spare/rcu_free,21.4 节展开)。批量化的收益有三:单次慢路径补货一次搬一捆对象(摊薄锁与 partial 链操作)、kfree_rcu() 按捆整批延迟(rcu_free 捆,slub.c:5790 rcu_free_sheaf)、热路径的数组栈顶操作对缓存行更友好。
三代 per-CPU 快路径的成本对比 (分配一次):
SLAB: 数组缓存弹出 + 引用计数 + 可能的 batch 补货
SLOB: 空闲链首选适应搜索 (O(碎片数))
SLUB 旧: freelist 指针 CAS 弹出 (一条原子指令)
SLUB 7.0: objects[--size] (数组下标减一, 无原子!)
(local_trylock 失败才走慢路径 —
21.3.2 节 alloc_from_pcs)
21.1.5 三代对比表
| 维度 | SLAB(已移除) | SLOB(已移除) | SLUB(7.0) |
|---|---|---|---|
| 设计蓝本 | Solaris Bonwick | 嵌入式极简 | 极简元数据 + per-CPU |
| 元数据 | 外置多链表+数组缓存 | 几乎为零 | slab 复用 page + sheaves |
| per-CPU | 数组缓存 | 无 | main/spare/rcu 三捆 |
| 碎片控制 | 着色+三链 | 差 | partial 链 + barn 捆池 |
| 调试设施 | 最丰富 | 无 | red zone/poison/trace/kfence |
| 定位 | 服务器经典 | <2MB 嵌入式 | 全场景默认 |
21.1.6 SLUB_TINY:现代的"最小配置"
SLOB 移除后,嵌入式极简需求由 CONFIG_SLUB_TINY 承接。本树的代码大量使用它做裁剪判断:
// mm/slab.h:274-277(要点)
static inline bool cache_has_sheaves(struct kmem_cache *s)
{
/* Test CONFIG_SLUB_TINY for code elimination purposes */
return !IS_ENABLED(CONFIG_SLUB_TINY) && s->sheaf_capacity;
}
SLUB_TINY 下:禁用 sheaves(所有分配走慢路径)、合并大部分调试批处理、牺牲性能换最小内存占用——这正是 SLOB 用户的迁移通道。21.2 节的 bootstrap_sheaf 与 cache_has_sheaves() 分支是阅读代码时必须跟踪的编译期岔路。
小结
三代分配器回答同一个问题而取舍各异:SLAB 以精细元数据换管理能力、SLOB 以零元数据换内存下限、SLUB 以"对象即链表、slab 即 page"的极简主义胜出并在 7.0 演进出成捆批发的 sheaves 结构。理解这段历史的意义在于:SLUB 代码中每处看似多余的抽象(sheaf 容量、barn 池、TINY 分支)都是某代设计教训的直接沉淀。下一节进入 7.0 SLUB 的数据结构本体。
21.2 SLUB 数据结构 —— kmem_cache 与 slab
SLUB 的数据结构分三层:kmem_cache 是"对象仓库"的管理者(每类对象一个),struct slab 是"一整页(或几页)的容器"(复用 struct page 位布局),slab_sheaf 是 per-CPU 的"一捆对象"(7.0 新形态)。本节逐层拆解,并解释对象布局 calculate_sizes() 如何把 freelist 指针、调试区与对齐编排进 s->size。
21.2.1 kmem_cache:仓库管理者
// mm/slab.h:197-265(节选, 保留关键注释)
struct kmem_cache {
struct slub_percpu_sheaves __percpu *cpu_sheaves; /* :199 每 CPU 捆 */
/* Used for retrieving partial slabs, etc. */
slab_flags_t flags;
unsigned long min_partial; /* 节点至少保留的 partial slab 数 */
unsigned int size; /* Object size including metadata */
unsigned int object_size; /* Object size without metadata */
struct reciprocal_size; /* reciprocal_value: 除法换倒数乘法 */
unsigned int offset; /* Free pointer offset */
unsigned int sheaf_capacity; /* 每捆对象数 */
struct kmem_cache_order_objects oo; /* 理想: 一 slab 的阶×对象数 */
...
void (*ctor)(void *object); /* 对象构造函数 */
unsigned int inuse; /* Offset to metadata */
unsigned int align; /* Alignment */
unsigned int red_left_pad; /* Left redzone padding size */
const char *name; /* Name (only for display!) */
struct list_head list; /* 挂入 slab_caches 全链 */
...
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random; /* freelist 混淆密钥 (21.5 节) */
#endif
#ifdef CONFIG_SLAB_FREELIST_RANDOM
unsigned int *random_seq; /* 对象序随机排列 (21.5 节) */
#endif
...
struct kmem_cache_node *node[MAX_NUMNODES]; /* :265 节点级 partial */
};
三个字段值得停顿:size 与 object_size 的差是元数据(freelist 槽、调试区、对齐填充)——用户申请 100 字节,实际 stride 可能是 112 或更多;oo(order-objects) 打包"slab 阶与每 slab 对象数"为一个字(kmem_cache_order_objects,slab.h:188-192 的"Word size structure"注释),使"进货一页组货 N 个对象"成为一个原子量;cpu_sheaves 是 per-CPU 指针(18.2 节 percpu 机制的直接消费者),快路径完全无锁。
min_partial 是碎片治理的核心参数:节点 partial 链至少保留这么多半空 slab,避免"刚释放的对象所在 slab 立刻被还页→马上又要新页"的抖动(21.4 节)。
21.2.2 struct slab:一页容器的元数据
// mm/slab.h:74-99(节选)
/* Reuses the bits in struct page */
struct slab {
memdesc_flags_t flags;
struct kmem_cache *slab_cache;
union {
struct {
struct list_head slab_list; /* partial 链节点 */
/* Double-word boundary */
struct freelist_counters; /* 并发 freelist 计数 */
};
struct rcu_head rcu_head;
};
unsigned int __page_type;
atomic_t __page_refcount;
#ifdef CONFIG_SLAB_OBJ_EXT
unsigned long obj_exts; /* 对象级扩展(memcg/kasan) */
#endif
};
#define SLAB_MATCH(pg, sl) \
static_assert(offsetof(struct page, pg) == offsetof(struct slab, sl))
SLAB_MATCH(flags, flags);
SLAB_MATCH(compound_head, slab_cache); /* Ensure bit 0 is clear */
SLAB_MATCH(_refcount, __page_refcount);
...
static_assert(sizeof(struct slab) <= sizeof(struct page)); /* :112 */
struct slab 不是独立分配的元数据——它就是 slab 首页的 struct page 本身,靠 SLAB_MATCH 静态断言保证两者字段布局逐一对齐。这是 18.4.1 节"一页多义"设计在 slab 语境的再现:page 的 mapping/lru 槽位在 slab 语义下是 slab_cache/slab_list。page_slab()(slab.h:132-145)经 page->page_type >> 24 == PGTY_slab 判别一页是否属于 slab——18.4.2 节"page_type 与 _mapcount 互斥"的判别位。
slab 本体是 folio:slab_folio()(slab.h:150-156)与 alloc_frozen_pages() 进货(slub.c:3290-3298 的 __SetPageSlab(page))表明一个 slab 就是一个复合 folio——高阶 slab(单页放不下对象时自动升阶)由 18.4 节的 compound 机制自然承载。
21.2.3 slab_sheaf 与三级缓存
// mm/slub.c:396-425
struct node_barn { /* :396 节点级捆池 */
spinlock_t lock;
struct list_head sheaves_full;
struct list_head sheaves_empty;
unsigned int nr_full;
unsigned int nr_empty;
};
struct slab_sheaf { /* :407 */
union {
struct rcu_head rcu_head;
struct list_head barn_list;
/* only used for prefilled sheafs */
struct { unsigned int capacity; bool pfmemalloc; };
};
struct kmem_cache *cache;
unsigned int size; /* 当前已存对象数 */
int node; /* only used for rcu_sheaf */
void *objects[]; /* 柔性数组: 对象指针栈 */
};
struct slub_percpu_sheaves { /* :420 */
local_trylock_t lock;
struct slab_sheaf *main; /* never NULL when unlocked */
struct slab_sheaf *spare; /* empty or full, may be NULL */
struct slab_sheaf *rcu_free; /* for batching kfree_rcu() */
};
7.0 SLUB 三级对象缓存:
[per-CPU] pcs->main 满/部分满的捆 ←分配/释放的主战场(无锁 trylock)
pcs->spare 空捆或满捆 ← main 空了换 spare, 满了换 spare
pcs->rcu_free 批量 kfree_rcu 缓冲
│ main 空 & spare 空/满时
v
[节点] node->barn 空捆池+满捆池 ← per-CPU 捆的补给站(自旋锁)
│ barn 空时
v
[节点] node->partial 半空 slab 链 ← 从 slab 里现切对象
│ partial 空时
v
[伙伴系统] new_slab() → alloc_frozen_pages() ← 批发整页
三级各有明确分工:main/spare 是零竞争的数组栈操作(local_trylock 失败说明本 CPU 被中断上下文占着,直接走慢路径而不是自旋等待——21.3 节);barn 以捆为粒度缓冲 per-CPU 与 slab 之间的流量,空捆承接逐出、满捆供给补给;partial 以 slab 为粒度承接 barn 的上游。cache_has_sheaves()(slab.h:273-277)区分"真捆"与 bootstrap/调试/TINY 场景的退化直连。
21.2.4 calculate_sizes():对象布局的总设计
s->size(对象 stride)如何长出来的(mm/slub.c:7665-7800):
// mm/slub.c:7665(节选)
static int calculate_sizes(struct kmem_cache_args *args, struct kmem_cache *s)
{
unsigned int size = s->object_size;
...
size = ALIGN(size, sizeof(void *)); /* 字对齐 */
...
s->inuse = size; /* :7705 对象+redzone 的边界 */
if (((flags & SLAB_TYPESAFE_BY_RCU) && !args->use_freeptr_offset) ||
(flags & SLAB_POISON) || (s->ctor && ...) || ...) {
s->offset = size; /* freelist 槽放对象尾后 */
size += sizeof(void *);
} else if (...) {
s->offset = args->freeptr_offset; /* 调用者指定 */
} else {
/*
* Store freelist pointer near middle of object to keep
* it away from the edges of the object to avoid small
* sized over/underflows from neighboring allocations.
*/
s->offset = ALIGN_DOWN(s->object_size / 2, sizeof(void *)); /* :7735 */
}
...
if (flags & SLAB_STORE_USER) { /* :7744 泄漏跟踪 */
size += 2 * sizeof(struct track);
...
}
...
if (flags & SLAB_RED_ZONE) { /* :7765 左 redzone 填充 */
...
s->red_left_pad = sizeof(void *);
s->red_left_pad = ALIGN(s->red_left_pad, s->align);
size += s->red_left_pad;
}
/* 此后: 按 align 对齐 size, 用 calculate_order() 选 oo 阶 */
}
典型对象布局 (无调试, 100B 请求):
offset 0 50 100 112
┌────────────────────┬──┬──────┬─────┐
│ 对象可用空间 │FP│ pad │ │ FP = freelist 槽
└────────────────────┴──┴──────┴─────┘ 藏在对象中点(:7735)
<─── object_size ──> <── s->size = 112 ──>
注: freeptr_outside_object()(slub.c:552) 的判别即 offset >= inuse
带调试布局 (STORE_USER+RED_ZONE):
┌─────┬──────────────┬─────┬────────┬────────┬──────┐
│L-red│ 对象 │R-red│ track A│ track F│ kmreq │
└─────┴──────────────┴─────┴────────┴────────┴──────┘
red_left_pad (:7765) alloc/free 轨迹 原始请求大小
freelist 槽中点埋藏(:7727-7740 的注释原文"avoid small sized over/underflows from neighboring allocations")是 7.0 的安全姿态:旧版把它放在对象第一个字,堆溢出恰好写坏链表;藏进中点后,小尺寸越界(最常见的溢出形态)摸不到指针——21.5 节展开这条攻防线。
小结
SLUB 的三层结构各有清晰边界:kmem_cache 管理仓库参数与三级缓存入口(cpu_sheaves/node[]),struct slab 复用 page 位布局零成本承载容器元数据,slab_sheaf 以对象数组捆为粒度承接 per-CPU 流量并用 node barn 池缓冲。calculate_sizes() 把 freelist 槽(默认藏对象中点)、track 区、red zone、对齐编排成 s->size 的最终 stride。下一节沿这三级缓存走通分配与释放的完整路径。
21.3 对象分配与释放流程
本节走通 SLUB 的完整分配/释放链路:kmem_cache_alloc_noprof() 的统一入口如何先尝试 per-CPU 捆快路径(alloc_from_pcs())、失败后落入慢路径(___slab_alloc())的 partial 链与新 slab 进货;释放则对称地先入捆(free_to_pcs())、跨 CPU 时走 __slab_free() 的 CAS 链。通用接口 kmalloc() 在此之上按大小选档转发。
21.3.1 分配总调度:slab_alloc_node
// mm/slub.c:4837-4869(节选)
static __fastpath_inline void *slab_alloc_node(struct kmem_cache *s, struct list_lru *lru,
gfp_t gfpflags, int node, unsigned long addr, size_t orig_size)
{
void *object;
bool init = false;
s = slab_pre_alloc_hook(s, gfpflags); /* memcg/kasan 前钩 */
if (unlikely(!s))
return NULL;
object = kfence_alloc(s, orig_size, gfpflags); /* :4847 kfence 拦截池 */
if (unlikely(object))
goto out;
object = alloc_from_pcs(s, gfpflags, node); /* :4850 快路径 */
if (!object)
object = __slab_alloc_node(s, gfpflags, node, addr, orig_size); /* :4852 */
maybe_wipe_obj_freeptr(s, object); /* init_on_alloc 配合 */
init = slab_want_init_on_alloc(gfpflags, s);
out:
slab_post_alloc_hook(s, lru, gfpflags, 1, &object, init, orig_size);
return object;
}
slab_pre_alloc_hook/slab_post_alloc_hook 是 memcg 记账(12 章)与 KASAN/调试的统一挂点;kfence_alloc() 以采样概率把分配改道 KFENCE 越界检测池(21.5 节)。三个对外入口共享此调度:
// mm/slub.c:4871-4880
void *kmem_cache_alloc_noprof(struct kmem_cache *s, gfp_t gfpflags)
{
void *ret = slab_alloc_node(s, NULL, gfpflags, NUMA_NO_NODE, _RET_IP_,
s->object_size);
trace_kmem_cache_alloc(_RET_IP_, ret, s, gfpflags, NUMA_NO_NODE);
return ret;
}
EXPORT_SYMBOL(kmem_cache_alloc_noprof);
kmem_cache_alloc_node_noprof()(slub.c:4916-4921)仅多传 node 指定 NUMA 归属;__kmalloc_noprof()(slub.c:5270)先查 size <= KMALLOC_MAX_CACHE_SIZE(slab_common.c:785)决定走通用档缓存还是 kmalloc_large 直接向伙伴系统取页。
21.3.2 快路径:alloc_from_pcs
// mm/slub.c:4668-4753(节选)
static __fastpath_inline
void *alloc_from_pcs(struct kmem_cache *s, gfp_t gfp, int node)
{
...
node_requested = IS_ENABLED(CONFIG_NUMA) && node != NUMA_NO_NODE;
if (unlikely(node_requested && node != numa_mem_id())) {
stat(s, ALLOC_NODE_MISMATCH);
return NULL; /* 跨节点请求不碰本地捆 */
}
if (!local_trylock(&s->cpu_sheaves->lock))
return NULL; /* 捆被占(中断中): 立即让路 */
pcs = this_cpu_ptr(s->cpu_sheaves);
if (unlikely(pcs->main->size == 0)) { /* main 捆空 */
pcs = __pcs_replace_empty_main(s, pcs, gfp);
if (unlikely(!pcs))
return NULL;
}
object = pcs->main->objects[pcs->main->size - 1]; /* 栈顶对象 */
if (unlikely(node_requested)) {
/* CPU 迁移窗口: 对象可能来自别的节点, 校验 */
if (page_to_nid(virt_to_page(object)) != node) {
local_unlock(&s->cpu_sheaves->lock);
stat(s, ALLOC_NODE_MISMATCH);
return NULL;
}
}
pcs->main->size--;
local_unlock(&s->cpu_sheaves->lock);
stat(s, ALLOC_FASTPATH);
return object;
}
快路径的全部操作是数组栈顶弹出一个指针(objects[--size])——没有 CAS、没有链表遍历,local_trylock 是纯本地的抢占保护(失败即放弃而非自旋,让中断上下文的短暂占用不阻塞任何线程)。__pcs_replace_empty_main() 处理补货:先试 spare 捆(21.4 节),再从 node barn 取满捆,最后从 partial slab 现切一捆——每次补货搬一捆对象,慢路径成本被 1/N 摊薄。
21.3.3 慢路径:___slab_alloc 的三级回退
// mm/slub.c:4374-4451(节选, 保留三级策略注释)
static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
unsigned long addr, unsigned int orig_size)
{
...
new_objects:
pc.flags = gfpflags;
/*
* 指定了首选节点且无 __GFP_THISNODE 时:
* 1) 先只对目标节点找 partial (给 pc.flags 加 __GFP_THISNODE)
* 2) 失败则以 NOWAIT|THISNODE 试图在目标节点开新 slab
* 3) 再失败: 恢复原始 gfp, 允许扫其他节点 partial / 他节点进货
*/
if (unlikely(node != NUMA_NO_NODE && !(gfpflags & __GFP_THISNODE)
&& try_thisnode)) {
...
pc.flags = GFP_NOWAIT | __GFP_THISNODE;
}
pc.orig_size = orig_size;
object = get_from_partial(s, node, &pc); /* 局部链取对象 :4416 */
if (object)
goto success;
slab = new_slab(s, pc.flags, node); /* 批发新页 :4421 */
if (unlikely(!slab)) {
if (node != NUMA_NO_NODE && !(gfpflags & __GFP_THISNODE)
&& try_thisnode) {
try_thisnode = false;
goto new_objects; /* 放宽节点限制重跑 */
}
slab_out_of_memory(s, gfpflags, node);
return NULL;
}
...
alloc_from_new_slab(s, slab, &object, 1, allow_spin);
...
success:
if (kmem_cache_debug_flags(s, SLAB_STORE_USER))
set_track(s, object, TRACK_ALLOC, addr, gfpflags);
return object;
}
三级策略的骨架是"本地性逐级放宽":目标节点 partial → 目标节点新 slab → 任意节点 partial/新 slab。get_from_partial() 内部经 alloc_single_from_partial()(slub.c:3649)从 partial slab 的 freelist 弹对象并把 slab 在链上重排;new_slab()(slub.c:3285-3300)调用 alloc_frozen_pages()(18.3 节接口,__SetPageSlab(page) 置页类型,:3296)整页进货,随后 alloc_single_from_new_slab()(slub.c:3687)或批量把首批对象喂进 per-CPU 捆。
分配全链路汇总 (无调试、命中快路径为 1 步):
kmem_cache_alloc(s)
└─ slab_alloc_node() slub.c:4837
├─ kfence_alloc 采样改道 :4847
├─ alloc_from_pcs :4668
│ ├─ trylock 失败 → 让路慢路径
│ ├─ main 空 → __pcs_replace_empty_main (spare/barn/切 slab)
│ └─ objects[--size] ← 命中: 全程 ~20 条指令
└─ __slab_alloc_node → ___slab_alloc :4453; :4374
├─ get_from_partial (本节点 → 他节点)
├─ new_slab → alloc_frozen_pages (伙伴系统, 18.3)
└─ slab_out_of_memory 报警
21.3.4 释放:入捆与跨 CPU 链
// mm/slub.c:5768-5788(节选)
bool free_to_pcs(struct kmem_cache *s, void *object, bool allow_spin)
{
struct slub_percpu_sheaves;
if (!local_trylock(&s->cpu_sheaves->lock))
return false; /* 同快路径让路策略 */
pcs = this_cpu_ptr(s->cpu_sheaves);
if (unlikely(pcs->main->size == s->sheaf_capacity)) {
pcs = __pcs_replace_full_main(s, pcs, allow_spin);
if (unlikely(!pcs))
return false; /* 满捆无处安放: 走慢路径 */
}
pcs->main->objects[pcs->main->size++] = object; /* 栈顶压入 */
local_unlock(&s->cpu_sheaves->lock);
stat(s, FREE_FASTPATH);
return true;
}
快路径释放是快路径分配的镜像:满捆时 __pcs_replace_full_main() 把满捆送 barn、换空捆回来。当对象属于非当前 CPU 或非当前捆的 slab 时,kmem_cache_free() 转入 __slab_free()(slub.c:5474-5528):
// mm/slub.c:5474(要点)
static void __slab_free(struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt,
unsigned long addr)
{
...
do {
/* CAS: 把 [head..tail] 插回 slab 的 freelist 头 */
} while (!slab_update_freelist(s, slab, &old, &new, "__slab_free")); /* :5528 */
/* slab 从 full → partial 的转换点:
这里检查 was_full, 把 slab 挂上 node->partial 链,
并按 min_partial 决定是否留仓 (21.4 节) */
}
slab_update_freelist() 是一条 cmpxchg 双字(freelist 指针 + 计数,防 ABA——19.3 节 ABx 计数的 slab 版),把 [head..tail] 段插回 slab。释放路径的批量化同样贯穿:kfree_rcu() 的对象先攒进 pcs->rcu_free 捆,满捆后整捆经 RCU 回调 rcu_free_sheaf()(slub.c:5790-5850)一次性解钩、还仓——延迟释放的批处理粒度从对象升到捆。
21.3.5 kmalloc:大小分档的转发层
// mm/slab_common.c:785
if (size && size <= KMALLOC_MAX_CACHE_SIZE) {
/* 走通用档 kmem_cache */
通用分配 __kmalloc_noprof()(slub.c:5270)把请求大小映射到 kmalloc_caches[type][idx] 档位(8/16/32…4MB,2 的幂,KMALLOC_MIN_SIZE 起步按编译配置可跳过低档,slab_common.c:901-930 的 BUILD_BUG_ON 与循环);超过 KMALLOC_MAX_CACHE_SIZE 转为 kmalloc_large——直接向伙伴系统按页申请并在 page 上记大页类型(18.4 节 page_type)。缓存类型枚举(include/linux/slab.h:629-642)区分 KMALLOC_NORMAL/CGROUP/RECLAIM/DMA 四类:memcg 记账对象落到 CGROUP 副本、可回收对象落 RECLAIM 副本,让 12 章 cgroup 的 slab 记账无需逐对象判断。
小结
SLUB 的分配链是"入口挂钩 → kfence 采样 → per-CPU 捆弹出 → 三级回退(本节点 partial → 本节点新页 → 放宽节点重试)",快路径把成本压到数组栈顶一弹,慢路径的每次进货都按捆或按页批量化。释放链镜像对称:入捆、满捆换仓、跨 CPU 时以 cmpxchg 双字插回 slab freelist 并维护 full/partial 转换。kmalloc 层按 2 的幂分档转发,四类缓存副本承接记账差异。下一节聚焦这套机制如何治理碎片——partial 链、min_partial 与 barn 捆池的协作。
21.4 per-CPU sheaves 与碎片管理
SLUB 的碎片问题有两个维度:slab 内碎片(对象 stride 与请求的差,21.2.4 节 calculate_sizes 的课题)与 slab 间碎片(大量"半空 slab"驻留内存却不供货)。本节分析 7.0 的治理方案:per-CPU sheaves 把热路径流量与 slab 状态解耦,node barn 以捆为粒度缓冲,partial 链以 slab 为粒度保底,min_partial 水位控制何时还页给伙伴系统。
21.4.1 捆的流转:main/spare 与 barn
一次典型的"消耗-补货"循环 (capacity = 32):
main 捆消耗 __pcs_replace_empty_main() 的补给顺序
┌─────────┐
│#### ▓ │ main 空 ──┬─ 1. spare 满? 交换 main<->spare (零成本)
└─────────┘ ├─ 2. barn 满捆池有货? 取一捆 (自旋锁+链摘)
├─ 3. 从 partial slab 现切一捆 (slab 锁)
└─ 4. 开新 slab 批发后灌满一捆
释放侧对称:
main 满 ──┬─ 1. spare 空? 满捆挪去 spare
├─ 2. barn 空捆池有空位? 满捆存入 (换出空捆)
└─ 3. barn 满? 整捆对象退回各 slab (__sheaf_... / __slab_free)
main/spare/barn 的库存视角:
per-CPU: main[██──] spare[████] ← 零成本互转
↕ (捆为粒度的流量)
node barn: full 池 [捆][捆] empty 池 [捆][捆]
↕ (满捆吸收/供给)
slabs: partial [s1 半空][s2 半空] ← min_partial 保底
alloc_from_pcs()(slub.c:4700-4710)里 main 空时调用的 __pcs_replace_empty_main() 与 free_to_pcs()(slub.c:5774-5779)里 main 满时调用的 __pcs_replace_full_main() 是两个补给/退换枢纽;slub.c:4660-4666(pcs->spare = full 与 barn_put_full_sheaf(barn, full))展示满捆优先回流 spare、barn 收尾的次序。barn 的双向缓冲把"per-CPU 捆的临时盈缺"与"slab 的实际状态"隔开:CPU 间流量在捆粒度就消化掉大半,真正穿透到 slab 锁的只剩 barn 池空转的场景。
21.4.2 kmem_cache_node:partial 链与保底水位
// mm/slub.c:428-438
struct kmem_cache_node {
spinlock_t list_lock;
unsigned long nr_partial;
struct list_head partial; /* 半空 slab 链 */
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs;
atomic_long_t total_objects;
struct list_head full;
#endif
struct node_barn *barn; /* :437 捆池 */
};
partial 链的"半空 slab"承担对象回访点:释放对象把 slab 从 full 转入 partial(__slab_free() 的转换点,slub.c:5528 后),分配从中取货。何时把半空 slab 还给伙伴系统是碎片治理的裁判点:
s->min_partial:节点至少保留的半空 slab 数——低于它时释放侧绝不还页(避免"还页→立即又要页"抖动);- 高于水位且 slab 空闲比例足够时,
put_cpu_partial/退页路径把 slab 整页还伙伴系统(18.3 节__free_pages侧对应__SetPageSlab的清除); SLAB_TINY或调试模式直连场景(cache_has_sheaves()为假)下,partial 链直接面对每对象操作。
半空 slab 的生命周期状态机:
新 slab 进货 最后一个对象被取走
┌──────────┐ ──────> ┌──────────┐ ──────> ┌──────┐
│ EMPTY │ │ PARTIAL │ │ FULL │
│ (刚批发) │ <────── │ (在链上) │ <────── │(离链) │
└──────────┘ 放回对象 └──────────┘ 放回对象 └──────┘
│ 首个对象 ^ 释放首个对象 │
│ 无人认领(高水位退页) │ │
v │ │
还给伙伴系统 ────────────────────┘ min_partial 保底 ←┘
21.4.3 批量接口:bulk 与 RCU 捆
对象级流量还有两条批量化通道:
[1] bulk 分配/释放。 alloc_from_pcs_bulk()(slub.c:4756 起)一次从 main 捆连续弹出 N 个对象,捆不足时 ___slab_alloc 的批量变体直接从新 slab 整段划拨;kmem_cache_free_bulk() 走 slub.c:2849-2883 的注释路径——"在 cpu_sheaves->lock 外分批处理"(__kmem_cache_free_bulk 在锁内成段入捆),把 memcg/kasan 钩子的开销同样摊薄。
[2] kfree_rcu 的整捆延迟。 pcs->rcu_free 捆攒满后整捆交 RCU:
// mm/slub.c:5790(节选)
static void rcu_free_sheaf(struct rcu_head *head)
{
sheaf = container_of(head, struct slab_sheaf, rcu_head);
s = sheaf->cache;
...
if (__rcu_free_sheaf_prepare(s, sheaf)) /* 解钩(含调试过滤) */
goto flush;
n = get_node(s, sheaf->node);
...
/* 满捆直接存回 barn 满捆池: 对象一次都不落 slab */
}
优雅之处在于满捆对象可能一次都不回到 slab:宽限期结束后整捆存入 barn 满捆池,等待下一个 CPU 的 main 补货直接取走——slab 的 freelist 完全未被触碰。读侧(RCU 宽限期)与写侧(补货)天然解耦,这是"捆粒度批发"对延迟释放的红利。
21.4.4 碎片的观测与调参
观测入口:
/proc/slabinfo 每缓存: 活跃对象/总数, slab 数, 阶
/sys/kernel/slab/<c>/ order, objects, partial, cpu_sheaves...
alias 子目录 合并别名缓存共享同一仓库 (21.1 节)
调参入口:
slub_debug=KPZ,... 启动参数开调试区(红区/毒化/trace)
min_partial (sysfs) 节点保底半空 slab 数
slab_merge/nomerge 控制同规格缓存合并 (slab_common.c:60)
sheaf_capacity 捆容量 (auto: 按对象大小与缓存行折算)
碎片恶化的典型信号:
slabinfo 里 "对象总数高而 active 占比低"
→ 大量半空 slab 驻留。治理顺序:
[1] 查泄漏 (STORE_USER 定位分配点, 21.5.3)
[2] 评估 min_partial 水位
[3] cgroup 场景查 memory.kmem 绑死
(slab 无法跨 cgroup 复用 = 伪碎片, 12 章)
21.4.5 三级流量的量化直觉
一次 1 万次 alloc/free 脉冲的分流估算 (capacity=32, 4 CPU):
10000 次分配
├─ ~9700 次: main 捆命中 (数组弹出, ~20ns) ← 97%
├─ ~250 次: 触发补给 (每次搬 32 个) —
│ 大半由 spare/barn 承接 (捆粒度)
└─ ~50 次: 穿透到 partial/新 slab (批发生意)
对比无捆设计: ~10000 次都碰 freelist CAS,
争用同一缓存行 — 多核扩展性差一个数量级
慢路径成本 ÷ 捆容量 = 摊薄后的单次成本
这就是"捆"存在的全部数学
小结
7.0 的碎片治理是三级流量的分层:per-CPU main/spare 捆消化零成本盈缺,node barn 以捆粒度缓冲跨 CPU 流量(满捆 RCU 回流甚至完全不触碰 slab),partial 链以 slab 粒度保底并受 min_partial 水位仲裁。bulk 接口与 rcu_free 捆把批处理粒度从对象提到捆,使"慢路径成本 ÷ 捆容量"的摊薄在所有通道成立。下一节盘点这套机制之上的安全加固——攻击者眼中 SLUB 是堆风水的主战场,防御方在对象布局与 freelist 指针上层层设防。
21.5 SLUB 安全加固 —— freelist 随机化与校验
SLUB 是内核堆攻击的主战场:一个 UAF(Use-After-Free)或堆溢出如果能把 freelist 指针改写成受控值,就能在下次分配时获得"任意地址对象"。SLUB 的加固体系从四个层次设防:指针混淆(XOR 编码让溢出写不出合法指针)、布局随机化(freelist 顺序与槽位位置不可预测)、损坏检测(red zone/poison/track)、以及替代隔离(kfence 采样、init-on-alloc 消息、usercopy 边界)。本节逐层取证,所有行号针对 7.0.10 本树。
21.5.1 freelist 指针混淆:SLAB_FREELIST_HARDENED
// mm/slub.c:494-524(节选)
/*
* Returns freelist pointer (ptr). With hardening, this is obfuscated
* with an XOR of the address where the pointer is held and a per-cache
* random number.
*/
static inline freeptr_t freelist_ptr_encode(const struct kmem_cache *s,
void *ptr, unsigned long ptr_addr)
{
unsigned long encoded;
#ifdef CONFIG_SLAB_FREELIST_HARDENED
encoded = (unsigned long)ptr ^ s->random ^ swab(ptr_addr);
#else
encoded = (unsigned long)ptr;
#endif
return (freeptr_t){.v = encoded};
}
三个异或项各堵一条路:
| 成分 | 堵住的攻击 |
|---|---|
s->random(per-cache 密钥,slab.h:229-231) |
攻击者知道地址也编码不出合法指针 |
ptr_addr(指针自身存放地址) |
同一对象被搬到不同槽位时编码不同——把对象 A 的编码值抄给对象 B 无效 |
swab() 字节翻转 |
打断"XOR 线性"代数结构,多点采样推断密钥的难度陡增 |
配套的 set_freepointer()(slub.c:537-548)带一个零成本的双 free 现行捕获:
// mm/slub.c:540-542
#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif
"把对象 X 指向自己"是 double free 最常见的第一步——链表成环直接被 BUG_ON 拍死在案发现场。
21.2.4 节的槽位中点策略在此叠加第二层:s->offset = ALIGN_DOWN(s->object_size / 2, ...)(slub.c:7735)让指针藏在对象中部,最常见的"溢出/下溢一个字节到几个字"够不着它。freeptr_outside_object()(slub.c:552-556)维护"offset >= inuse 即指针在对象外"的判别约定供调试代码使用。
21.5.2 分配顺序随机化:SLAB_FREELIST_RANDOM
新 slab 的 freelist 默认按地址顺序串链——攻击者以同样规律请求对象即可推知邻居。随机化把对象在 slab 内的释放顺序打乱:
// mm/slub.c:3307-3341(节选)
static int init_cache_random_seq(struct kmem_cache *s)
{
unsigned int count = oo_objects(s->oo);
int err;
/* Bailout if already initialised */
if (s->random_seq)
return 0;
err = cache_random_seq_create(s, count, GFP_KERNEL); /* 生成随机排列 */
if (err) {
pr_err("SLUB: Unable to initialize free list for %s\n", s->name);
return err;
}
/* Transform to an offset on the set of pages */
if (s->random_seq) {
unsigned int i;
for (i = 0; i < count; i++)
s->random_seq[i] *= s->size; /* 序号×stride=偏移 */
}
return 0;
}
每个缓存创建时生成一份 random_seq[] 随机排列(slab.h:662-673 声明,cache_random_seq_create 实现于 slab_common),新 slab 建 freelist 时按此排列串链。shuffle_freelist()(slub.c:3371)在 allocate_slab 路径调用。防御本质:堆风水需要"可控的分配/释放序列 → 可预测的物理相邻关系",随机排列让"第 N 次分配会得到哪个槽"不再确定。
21.5.3 损坏检测:red zone、poison 与 track
调试标志族(slub.c:272-288):
SLAB_RED_ZONE 对象前后埋哨兵, free/alloc 时校验 (布局见 21.2.4 图)
SLAB_POISON 释放时填充 0x6b, 分配时校验 0x6b (UAF 写探针)
SLAB_STORE_USER 对象尾附 track: 分配/释放的调用栈 (泄漏/溢出定位)
SLAB_CONSISTENCY_CHECKS 链表与计数全量校验
slub_debug=FPZU 开启组合; slub_debug=<cachename> 定点开
red_left_pad(slub.c:957-966、7765-7780)的实现细节:左红区不是"对象前的一个字"而是独立填充区,kmem_cache_debug 路径返回对象地址时跳过它(slub.c:237-238 p += s->red_left_pad)——保证越界写"从左边打进来"也先撞红区。alloc_debug_processing/free_debug_processing(slub.c:1999-2002)在进出对象时全量走一遍校验清单。
这些设施的运行成本使其默认关闭、按 slub_debug 参数或 CONFIG_SLUB_DEBUG_ON 开启——生产环境的第一线是下一层的 kfence 与 init-on-free。
21.5.4 采样检测与初始化消毒
KFENCE(采样式检测池):slab_alloc_node() 的第 4847 行 kfence_alloc() 以低概率(默认每 100ms 一个样本)把分配改道独立守护池——对象四周是守护页,越界立即缺页报点。采样率低到生产可开,覆盖面靠时间积累。
init_on_alloc / init_on_free(sysctl 与编译开关):slab_alloc_node() 尾部 slab_want_init_on_alloc()(slub.c:4855)与 maybe_wipe_obj_freeptr()(:4854)组合,在分配时清零对象(或仅清 freeptr 槽)。防的是跨 privilege 边界的信息残留:内核对象里残留的密钥/指针被用户态触发的分配继承。init_on_free 反向在释放时清零,代价更高、防护更彻底。
HARDENED_USERCOPY:check_heap_object() 用 kmem_cache 的 useroffset/usersize(slab.h:252-256)校验 copy_to/from_user() 的范围确实落在"该对象声明可拷贝的区间"内——把"内核对象当缓冲区整块拷给用户"的越界读堵在拷贝层。
21.5.5 加固矩阵总览
| 层次 | 机制 | 配置 | 防的攻击 | 运行成本 |
|---|---|---|---|---|
| 指针编码 | XOR 密钥+地址+swab | FREELIST_HARDENED(默认 y) | 溢出伪造 freelist | 每次指针读写 2 次异或 |
| 槽位布局 | 指针藏对象中点 | 无开关(calculate_sizes) | 小尺寸溢出摸指针 | 零 |
| 顺序随机 | random_seq 排列 | FREELIST_RANDOM(默认 y) | 堆风水可预测性 | 建 slab 时一次排列 |
| 双 free 捕获 | set_freepointer BUG_ON | FREELIST_HARDENED | double free 成环 | 一次比较 |
| 哨兵/毒化 | red zone/poison | slub_debug(默认关) | 越界/UAF 写探针 | 高,调试用 |
| 采样检测 | KFENCE 守护池 | CONFIG_KFENCE(生产可开) | 越界/UAF 现行 | 每百毫秒一个样本 |
| 消毒 | init_on_alloc/free | sysctl/编译 | 信息残留 | 分配/释放清零 |
| 边界校验 | HARDENED_USERCOPY | 默认 y | 对象级越界用户拷贝 | 拷贝前检查 |
小结
SLUB 加固的哲学是"让默认配置挡住廉价攻击,把昂贵检测留给可选项":freelist 指针的 XOR 混淆(密钥×地址×swab 三重异或)与中点埋藏使溢出伪造指针失去可行性,random_seq 打乱堆风水时序,set_freepointer 的自指 BUG_ON 让 double free 现行;red zone/poison/track 作为 slub_debug 的重武器按需开启,KFENCE 以采样成本换取生产环境的越界现行捕获,init-on-alloc/free 消灭跨边界信息残留。至此 SLUB 的结构与攻防全部展开;下一章回到用户进程视角——虚拟内存区域 VMA 与进程地址空间。