Slab 与 kmalloc:对象复用、空间保留和生命周期

本页目录

对象已经 kfree,系统空闲内存却没有立即增加。这一现象需要两本账解释:⁠调用者还持有多少对象,分配器还保留多少承载页⁠。

从一个 200 字节对象开始,先算槽内开销与备用容量,再追踪分配、跨 CPU 释放、失败回滚和地址复用。

要解释的现象阅读位置
200 字节对象为什么占用更多空间?第 1 节:对象、槽与整页
kmalloc、cache 和 kvmalloc 如何选择?第 2 节:申请与释放契约
kfree 之后页面何时归还?第 3~4 节:缓存与所有权
泄漏和 UAF 的证据有什么不同?第 5~6 节:检测与只读统计

1. 用 200 字节对象算清三种空间

假设业务需要 200 字节,所用 cache 的实际对象步长为 256 字节,承载页为 4096 字节,并忽略页外管理元数据。一个 slab 页可以放 16 个槽;若 16 个槽全部占用,业务有效内容为 3200 字节,槽内对齐等开销合计 896 字节。

若只有 10 个对象存活,同一页的 4096 字节应这样记账:

去向计算字节数
业务内容10 × 2002000
已用槽内部开销10 × (256 − 200)560
可复用空槽6 × 2561536
合计一张承载页4096

除了业务内容之外的 2096 字节,有明确的用途,不能全部算成泄漏。

少量长寿命对象,为什么能留住很多页

如果两个 slab 各只有一个对象存活,总共只有 400 字节业务内容,却可能仍需保留两页。分配器不能把任何对象随意搬到一起,因为调用者保存的是指针,任意搬迁会使这些指针失效。某些子系统可以通过自己的回收协议释放对象,但这与页整理迁移用户页的能力不同。

对象已经释放,承载它们的 slab 却可能仍需保留 两个 slab 各剩一个对象 slab A slab B 假设对象集中到一页 slab A slab B 每格示意一个槽;普通 slab 对象不能任意搬移。下行只说明占用分布对可释放页数的影响。

这也解释了为什么增大 slab order 有双面效果:一次获取更多对象槽可减少向共享资源补充的频率,却更依赖高阶连续页;一个长寿命对象又可能使更大的承载块不能归还。调试 redzone、分配追踪和对齐会改变对象步长,因此启用检查后空间占用与分配成功率可能变化。见 slab 管理与调试。

保持对象数量相同,切换集中与分散的布局。下面按同一套槽尺寸把内容、槽内开销、空槽和承载页分别记账;点击槽位也能构造自己的寿命分布。

本模型每页 n=16 槽,P=4096 B、q=200 B、s=256 B;S 是仍有存活对象的页数,N 是存活对象总数。

正在呈现知识画面
同样四个对象,为什么能占住四倍的页?

有效内容、已用槽的内部开销和整页保留是不同数量。分散的少量活对象能让多张 slab 无法归还,释放一个槽也不等于释放一页。

2. kmalloc 与 kmem_cache 分别替调用者决定什么

kmalloc 按请求大小与标志选择通用分配路径,小对象通常进入合适的大小类;较大申请可直接依赖高阶页,而不是所有大小都必然存在一个同名 cache。大小类和各类用途分组依架构、配置及版本而变,不能列一组 cache 名字就认为所有机器都如此。

专用 kmem_cache 则表达固定对象布局、对齐、标志与可选构造需求。相近的 cache 在满足条件时可以合并,但条件不只是大小接近,还涉及 flags、对齐、构造函数及调试要求;sysfs 中不同名字可能指向同一底层 cache。定义专用 cache 的理由应来自对象用途和管理需求,不是仅为获得一个好看的统计名称。

复用对象时,初始化责任仍在调用者

构造函数通常用于 slab 中新对象的初始建立,不是在每次分配到调用者时重新运行。上一个使用者写过的字段,不能指望下一次 alloc 会被 ctor 自动恢复。kmalloc/kmem_cache_alloc 也不保证零初始化;需要清零时使用相应接口或明确初始化字段,再把对象发布给其他线程。

选择接口时,连续性和释放接口是一组契约

申请方式适合的需求释放与边界
kmalloc/kzalloc常规内核缓冲,物理连续的分配结果kfree;不能越过请求的有效范围访问
kmalloc_array/kcalloc元素数乘元素大小的数组检查乘法溢出的分配接口,仍需处理 NULL
kmem_cache_alloc已创建 cache 的固定对象对应 kmem_cache_free,不是混用其他 cache
kvmalloc/kvzalloc可以接受虚拟连续、物理不连续的较大缓冲kvfree;使用场景与 GFP 需符合回退路径要求
vmalloc虚拟连续映射多个后备页vfree;不可当作单段 DMA 物理缓冲

实际分配大小可能大于请求大小,但尾部空间不是业务可随意使用的隐含许可;若需要按大小类取整,应使用相应接口并以请求/分配契约为准。DMA 设备能否访问缓冲还要经过 DMA API,不能把“kmalloc 连续”推导成任意设备都能直接使用其 CPU 地址。见 内存分配指南。

3. 走完一次申请、跨 CPU 释放和页归还

CPU 0 申请对象时,如果本地已有合适的空槽,就可快速取得它。没有本地对象时,需要从共享储备或 partial slab 补充;这些也不足时才向页分配器申请承载页并建立更多槽。小对象申请同样可能进入页分配慢路径,因此“只申请 200 字节”不等于不可能睡眠或失败。

对象交给 CPU 1 完成处理并释放时,它可能不属于 CPU 1 当前正在使用的本地 slab/缓存。分配器需要维护所属 slab、空闲数和缓存归属等信息,必要时使用原子更新或锁;不能假设所有 free 都只是往当前 CPU 的一个链表头写两次指针。

从对象请求到物理页:快路径靠缓存,慢路径补资源 对象申请 / 释放 kmalloc 或专用 cache CPU 侧对象缓存 优先本地复用与批量处理 共享资源与 partial slab 必要时协调其他对象储备 页分配器 新增 / 归还承载页 示意缓存层次,不固定某版本的 freelist/sheaf 结构;调试和 NUMA 约束可改变快路径。

释放一个对象只是把其使用权交还分配器。等某些对象批次排空、slab 满足可归还条件,承载页才可能回到页分配器。保留一定空 slab 或本地对象容量能加速下一轮申请,却使低负载后的内存下降不是瞬间完成。判断异常要结合稳定负载下是否持续增长、cache 的设计保留策略及引用链,而不是只比较一次 kfree 前后的 MemFree。

源码阅读:缓存组织随版本变化

旧版 SLUB 常用 per-CPU active slab/freelist 解释快路径;本次核对的 Linux 7.2 mm/slub.c 已使用 CPU 侧 sheaves 对象数组、NUMA node 的 barn 进行批量缓存与交换。

它们都在减少共享资源操作频率,但同步、RT 和 debug 路径需按目标版本阅读。学习 kmalloc 等接口契约时,应把这类内部组织变化单独看待。

4. 分配失败时,半成品必须有明确所有者

考虑一个初始化过程,需要创建 cache,再申请一个指针数组,再分配 32 个对象。第 19 个对象失败时,前 18 个已成功的对象仍然属于初始化者;只释放数组会遗失它们的地址,只销毁 cache 又不能替代释放存活对象。

第 19 次对象申请失败时,清理责任按成功记录反向展开:

撤销顺序要释放什么为什么必须在这一步处理
1前 18 个已成功对象它们仍属于初始化者,不能遗失地址
2指针数组对象全部释放后,索引才不再需要
3cache确认没有存活对象再销毁承载机制

复杂驱动还需先停止入口、后台任务和异步引用。分配器知道槽是否空闲,却不知道外部回调是否还会访问它。

实验代码:先看回滚顺序,再看各错误标签

下面是完整教学模块的初始化路径。它分配少量对象、验证字段,然后在返回前清理;代码用于展示逐级回滚,编译检查不要求加载模块。

#include <linux/errno.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>

struct kb_item {
    unsigned int id;
    unsigned char payload[196];
};

static int __init kb_alloc_init(void)
{
    struct kmem_cache *cache;
    struct kb_item **items;
    unsigned int made = 0;
    int result = -ENOMEM;

    cache = kmem_cache_create("kb_alloc_example", sizeof(struct kb_item),
                              0, 0, NULL);
    if (!cache)
        return -ENOMEM;
    items = kcalloc(32, sizeof(*items), GFP_KERNEL);
    if (!items)
        goto destroy_cache;
    while (made < 32) {
        struct kb_item *item = kmem_cache_zalloc(cache, GFP_KERNEL);
        if (!item)
            goto free_items;
        item->id = made;
        items[made++] = item;
    }
    result = 0;
    for (unsigned int i = 0; i < made; ++i) {
        if (items[i]->id != i || items[i]->payload[0] != 0) {
            result = -EINVAL;
            break;
        }
    }
free_items:
    while (made)
        kmem_cache_free(cache, items[--made]);
    kfree(items);
destroy_cache:
    kmem_cache_destroy(cache);
    return result;
}

static void __exit kb_alloc_exit(void) { }
module_init(kb_alloc_init);
module_exit(kb_alloc_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Slab allocation and rollback teaching example");

Kbuild 可使用 obj-m += kb_alloc_example.o。真实故障分支是否按预期执行,需要另外的受控失败注入;仅编译成功不能当作已跑过第 19 次失败路径。不要在生产对象仍被引用时通过销毁 cache 来“修复泄漏”。

5. 释放后复用解释了 UAF,也解释了检测工具的不同证据

设对象 A 被释放,原地址随后分配给对象 B。A 的旧指针仍指向相同地址,所以一次错误写入可能破坏 B 而不是立即崩溃。把释放内存填成 poison 有助于之后识别异常,却不能保证每次错误访问都在发生当刻被截获;分配/释放追踪也可能在之后的检查点才发现破坏。

检测工具:非法访问与不可达分配是两类证据

KASAN 的不同模式利用影子内存或标签等机制检查非法访问,代价、覆盖范围和粒度不同。Generic KASAN 的 1:8 shadow 比例不是整个系统唯一开销,还可能有 quarantine、对齐和其他元数据。基于标签的检测也不能保证识别所有具有相同标签或落在检查粒度内的错误。见 KASAN。

kmemleak 主要通过扫描可疑对象是否仍可由已知根到达来报告潜在泄漏。它不是追踪所有 C 指针语义的证明器:隐藏或编码指针可能带来误报,偶然像指针的值又可能使泄漏看似可达。因此它回答“哪些分配值得检查”,而 KASAN 更直接针对某次非法访问。见 kmemleak。

普通 slab 对象的回收责任属于使用它的子系统。带有可回收缓存语义的子系统可以注册 shrinker,在压力下释放不再需要的对象;仅把 cache 标为某种可回收用途,不会让内核自动理解并销毁仍在使用的业务对象。

6. 只读比较对象大小、槽步长与页阶数

from pathlib import Path
import os

root = Path("/sys/kernel/slab/dentry")
if not root.exists():
    print("当前未暴露 dentry slab sysfs")
else:
    print("实际 cache 路径:", root.resolve())
    values = {}
    for name in ("object_size", "slab_size", "order", "objects", "slabs"):
        try:
            values[name] = (root / name).read_text().strip()
        except OSError as error:
            values[name] = type(error).__name__
        print(name, values[name])
    if values["order"].isdigit():
        print("按当前 order 计算的 slab 字节数:",
              os.sysconf("SC_PAGE_SIZE") * (1 << int(values["order"])))

读取结果时先确认单位:

字段应怎样理解
object_size对象内容大小相关字段
slab_size对象槽大小相关字段,⁠不是整块 slab 字节数
order需结合基础页大小计算整块承载空间
objects、slabs可能附带节点分布信息,不能整行直接转整数

已有 slab 的实际阶数、调试模式和回退可能需要更详细统计。当前配置值不能反推全部历史分配。

结合 /proc/slabinfo 时分别看 active objects、容量和承载页。统计也受本地缓存与实现记账方式影响,不能无条件将 active 数视为业务仍持有的精确对象数。接下来在 页面回收与交换 中,把 slab shrinker 与普通文件页、匿名页的回收放进同一个压力模型。