6.2 精准打击:使用 slub_debug 参数埋雷
上一节我们讲,SLUB 调试机制这把枪已经架好了(CONFIG_SLUB_DEBUG),但默认情况下保险没开。
现在,是时候教你怎么打开保险,并往这片内存雷区里扔几颗手雷了。
我们要用到的核心工具,是内核命令行参数 slub_debug。它允许你在不重新编译内核、不动一行代码的情况下,精细地控制调试逻辑的开启与关闭。这就像你在飞机上瞄准了一片区域(slab 缓存),然后决定是扔传单(仅日志)还是扔炸弹(强制崩溃)。
但在扔炸弹之前,我们需要先看一眼炸弹的引信。
6.2.1 拆解 slub_debug:你的军火清单
slub_debug 参数的语法非常直观,但选项很多。你可以把它想象成是一排拨动开关,每个开关控制一种检测机制。
先来看看官方给定的这套「军火清单」:
[插入原文 Table 6.1 的内容翻译或保留]
- F (Sanity checks): 基本健全性检查。在分配和释放时检查元数据是否损坏。
- Z (Red zoning): 红区。在对象前后填充特定区域,抓越界。
- P (Poisoning): 投毒。填充特定魔数,抓未初始化内存访问(UMR)或释放后重用(UAF)。
- U (User tracking): 用户跟踪。记录是谁分配/释放了这个对象(保存栈信息)。
- T (Trace): 跟踪分配/释放的轨迹。
- A (Failure tracking): 失败统计,记录哪些地方分配失败了。
这些开关不仅可以单独使用,还可以组合使用。除此之外,还有一个非常有用的 sysfs 接口文档(虽然看起来有点年头了):Documentation/ABI/testing/sysfs-kernel-slab。
但在深入实战之前,我们得先理解一个最核心的概念——Poisoning(投毒)。因为这是大多数诡异内存错误的「照妖镜」。
6.2.2 原理深挖:那些奇怪的十六进制数
SLUB 调试机制怎么知道你动过哪块内存?靠的是「染色」。
内核在 include/linux/poison.h 里定义了几种特殊的颜料。我们可以把内存看作一块画布,内核在这些画布的特定位置涂上特定的颜色。如果你修改了这些颜色,内核就知道是你干的。
来看看这些颜色(魔数)的配方:
// include/linux/poison.h
#define POISON_INUSE 0x5a /* for use-uninitialised poisoning (ASCII 'Z') */
#define POISON_FREE 0x6b /* for use-after-free poisoning (ASCII 'k') */
#define POISON_END 0xa5 /* end-byte of poisoning */
这三个宏定义就是整个投毒机制的基石。让我们来逐一拆解:
-
POISON_FREE (0x6b):这是最常用的「毒药」。 当你开启
slub_debug=P或者创建缓存时带上SLAB_POISON标志,内核会在对象被释放后,或者在刚分配出来还没写入数据前,把内存填充为0x6b(对应 ASCII 字符k)。- 含义:这块内存现在是「死的」。如果你读取它,应该看到满屏的
0x6b;如果你看到了别的值,说明要么你还没初始化就读了(UMR),要么你释放后又写入了(UAF)。
- 含义:这块内存现在是「死的」。如果你读取它,应该看到满屏的
-
POISON_INUSE (0x5a): 这个值主要用于填充红区内部,或者某些特殊的状态标记。如果你在红区里看到了
0x5a,说明这里是安全的边界;如果它变了,说明你把边界炸穿了。 -
POISON_END (0xa5): 这是个哨兵。每个 slab 对象的最后一个合法字节会被设置为这个值。它就像地雷旁边的警示牌,一旦被踩烂(覆盖),内核立刻报警。
6.2.3 未初始化内存读取(UMR):它在沉默中爆发
光看定义太空泛了。让我们拿第 5 章写过的测试代码 ch5/kmembugs_test.c 做个实验。
里面的 umr_slub() 函数非常简单(也非常愚蠢):它申请了 32 字节内存,然后什么都不填,直接读。这是典型的「未初始化内存读取」(Uninitialized Memory Read, UMR)。
/* 代码逻辑示意 */
q = kmalloc(32, GFP_KERNEL);
/* 故意不写 memset 或赋值 */
printk("q[3] is 0x%x\n", q[3]); /* 直接读垃圾值 */
场景一:没有调试保护(裸奔状态)
如果我们在没有任何 slub_debug 参数的情况下运行这个测试,内核日志会是什么样?
大概是这样:
[ 6845.100813] testcase to run: 10
[ 6845.101126] test_kmembugs:umr_slub(): testcase 10: simple UMR on slab memory
[ 6845.101771] test_kmembugs:umr_slub(): q[3] is 0x0
[ 6845.102203] q: 00000000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
[ 6845.102946] q: 00000010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
看到那一排整齐的 00 了吗?
这非常危险。因为它看起来很「干净」,就像是这块内存被初始化成了 0。但这纯属巧合——内核分配给我们的恰好是一块被清零过的页面(比如由页面分配器回收的干净内存)。如果你依赖这个「巧合」,你的代码在其他环境、其他时刻一定会崩溃,而且这种 Bug 是不可复现的。
现在,让我们换个姿势,开启调试模式再跑一次。
场景二:开启 Poison(投毒模式)
虽然我们不在这里展示新的输出(留个悬念),但我可以告诉你后果:那 32 字节里不再是 00,而是全部变成了 6b。
如果你去读 q[3],你不会得到 0x0,你会得到 0x6b6b6b6b。
这就产生了一个巨大的认知反差:
- 之前:你以为读到的是 0,你开心地继续用了,结果三天后因为某个条件触发,程序在几千行之外崩了。
- 现在:你读到的是满屏的
k(0x6b),你立刻就知道:「哦,我忘了初始化这块内存。」
这就是 SLUB 调试的威力——它把不确定的垃圾值,变成了确定的错误标志。
6.2.4 手把手配置:slub_debug 的实战语法
理解了原理,现在我们把开关拨上去。
只要你的内核配置了 CONFIG_SLUB_DEBUG=y(我们在上一章已经确认过了),你就可以在启动时传递 slub_debug 参数。
语法格式如下:
slub_debug=<Flags>,<SlabList>
- Flags: 就是我们刚才提到的
F,Z,P,U等字母的任意组合。比如FZPU。不要留空格,直接连在一起。 - SlabList (可选): 指定要调试哪个具体的缓存名。如果不填,默认作用于所有 slab 缓存。
几个典型的用法:
-
全开(穷追猛打型):
slub_debug=FZPU这会给所有缓存开启全套检查。⚠️ 警告:这会让系统变慢非常非常多,内存开销也会剧增,除非是在专门做故障复现,否则别在生产力环境这么干。
-
精准打击(针对某个缓存):
slub_debug=,kmalloc-256注意逗号前面是空的,这意味着「使用默认标志(通常可能没有)」,但只针对
kmalloc-256这个缓存。如果你想针对kmalloc-256开启 P 和 Z:slub_debug=PZ,kmalloc-256 -
全部关闭(虽然默认就是关的):
slub_debug=-
实战演示:开启全套监控
假设我们需要复现一个极其刁钻的 Bug,决定对所有缓存开启红区、投毒、健全性检查和用户跟踪。我们需要修改 GRUB 配置,在内核启动参数后面加上:
slub_debug=FZPU
保存,重启,进入系统。
验证一下是不是真的生效了。看内核启动参数:
$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.10.60-dbg02-gcc root=UUID=<...> ro quiet splash 3 slub_debug=FZPU
很好,参数已经传进去了。但这只是启动时的命令,内核真的听进去了吗?
我们需要去看 slab 缓存在 sysfs 下的实际状态。拿 kmalloc-32(专门分配 32 字节小对象的缓存)来开刀。
$ export SLAB=/sys/kernel/slab/kmalloc-32
$ sudo cat ${SLAB}/sanity_checks ${SLAB}/red_zone ${SLAB}/poison ${SLAB}/store_user
1
1
1
1
$
看到那四个 1 了吗?
这意味着:
sanity_checks(F): ONred_zone(Z): ONpoison(P): ONstore_user(U): ON
现在的内存分配器就像一个武装到牙齿的安检员,每一个字节的进出都要被它摸一遍。
系统已经准备好了。接下来,我们把那些有问题的测试代码扔进去,看看这套机制是怎么报错的。