第 6 章 谁动了我的内存?(上)—— 捕捉内核堆栈里的幽灵
6.1 准备工作与 SLUB 调试基础
6.1.1 引言:看不见的破坏者
有一类 Bug,比逻辑错误更让人头疼,也更难复现。
当你的代码逻辑跑偏了,程序通常会崩溃,或者给出一个错误的结果——至少你知道它错了。但当内存被破坏时,世界往往是静默的。你的驱动可能只是偶尔死机,系统可能在运行了三天后突然 panic,或者某个变量的值莫名其妙变了——就像有一只看不见的手,在你睡觉的时候偷偷修改了你的代码。
这就是内存破坏。它是内核开发者的噩梦。
要捕捉这种幽灵,我们手里需要几种武器。上一章我们聊过了 KASAN 和 UBSAN 这两把重型火炮,它们虽然强力,但有时候太重了——性能开销大,配置复杂。在这一章,我们将把目光收回来,看一看内核自带的、更轻量的探测手段:SLUB 分配器的调试功能,以及专门的内存泄漏检测工具 kmemleak。
本章的任务,就是建立一套「内存调试直觉」:在怀疑内存出问题时,如何快速通过 SLUB 的反馈定位到元凶;在怀疑内存泄漏时,如何让 kmemleak 帮你把那个忘记释放的指针揪出来。
准备好了吗?我们要开始抓幽灵了。
6.1.2 实验环境准备
首先,老规矩,环境必须到位。
这一章的实验环境配置其实和第 1 章「调试软件简介」里讲的是一模一样的。如果你已经跟着之前的章节搭好了调试内核,那现在就可以直接上号;如果你还没搭,或者换了个新机器,赶紧回去看一下第 1 章的清单。
为了方便你对照,所有的示例代码都放在这本书的 GitHub 仓库里了:
https://github.com/PacktPublishing/Linux-Kernel-Debugging
git clone 下来,放到你的虚拟机里,我们马上要用。
6.1.3 为什么我们需要 SLUB 调试?
在深入配置之前,让我们先达成一个共识:我们在调试什么?
内核里的内存破坏千奇百怪,但归根结底就是那么几种老面孔:
- UMR (Uninitialized Memory Read):读了还没初始化的内存。拿到的是垃圾值,这通常是随机的,所以 Bug 也是随机的。
- UAF (Use After Free):内存都释放了,还要去用它。这就像卖掉了房子还留着钥匙,某天晚上回去睡,结果发现房子已经变成别人的了。
- UAR (Use After Return):函数返回了,栈上的内存失效了,还拿着指针去访问。
- Double Free:同一块内存释放了两次。
- OOB (Out Of Bounds):越界访问。数组下标标大了,链表指针指飞了,踩到了隔壁的地盘。
这些问题极其常见。 你还没来得及写出优雅的代码,它们就会先来找你。我们已经知道 KASAN 是对付它们的杀手锏,但有时候,你想要的是一种更轻量级、甚至可以在生产环境偶尔开一下的手段。
这就是 SLUB 分配器自带调试功能存在的意义。
回忆一下内核的内存层级:
在内核的最底层,是页分配器,也就是伙伴系统。它管的是大块的物理页面。但是,内核里大量需要的是小块内存——几十字节、几百字节的结构体。如果你为了一个 64 字节的结构体去跟伙伴系统申请一个 4KB 的页面,那是巨大的浪费,而且会导致严重的内部碎片。
为了解决这个问题,内核在页分配器之上架了一层:Slab 分配器。
你可以把 Slab 想象成一个「零件仓库」。 伙伴系统负责搬运「集装箱」(整页),而 Slab 负责把集装箱拆开,把里面的「螺丝钉」和「垫片」(小对象)整齐地码放在货架上。当内核驱动需要螺丝钉时,Slub 直接从货架上拿一个给它,用完再放回去。这样既快,又省空间。
但在现代 Linux 内核里,这个「仓库」有三个不同的实现版本:
- SLAB:最早期的实现,经典,但老了。
- SLUB (Unqueued Allocator):现在的默认选择,设计更现代,性能更好,也是我们这章的主角。
- SLOB:针对极度内存受限的嵌入式系统,普通桌面和服务器上几乎见不到。
⚠️ 注意:
这一章接下来的所有内容,都是针对 SLUB 实现的。如果你在内核配置里选了 SLAB 或者 SLOB,接下来的参数和路径可能会对不上号。现在的 Linux 发行版(Ubuntu, Fedora 等)默认几乎都是 SLUB(配置项是 CONFIG_SLUB),你可以在 General setup | Choose SLAB allocator 里确认一下。
我们现在的目标,就是给这个「仓库」装上监控摄像头,看看谁拿了螺丝钉没还,谁把货架子踹翻了。
6.1.4 配置内核开启 SLUB 调试
好,动手之前先看清路。我们要调整内核的配置选项。
SLUB 提供了一整套调试支持,主要通过两个配置项来控制。打开你的 make menuconfig(或者直接修改 .config),我们要找的是这两个地方:
1. 开启调试支持总开关
路径:
General setup | Enable SLUB debugging support
配置项:
CONFIG_SLUB_DEBUG=y
这一步是必须的。 打开它,你就拥有了以下能力:
- 在
/sys/kernel/debug/slab(或者某些系统上的/sys/slab)下查看所有 slab 缓存的详细状态。 - 运行时的缓存验证。
- 使用我们在下一节要讲的那些高级调试特性(Red Zone, Poisoning 等)。
一个小知识点:
如果你开启了 Generic KASAN(CONFIG_KASAN),内核会自动选中这个选项。这也是为什么开 KASAN 会让系统变慢的原因之一——它在底层已经做了很多手脚。
2. 默认开启吗?
路径:
Kernel hacking | Memory Debugging | SLUB debugging on by default
配置项:
CONFIG_SLUB_DEBUG_ON=y
这个选项是个双刃剑。
如果设为 y,SLUB 调试会在内核启动时就自动全开。这听起来很爽,像是在系统里装了个全天候监控。但代价是巨大的——性能损耗会非常明显。对于生产环境或者普通的开发调试,这通常太重了。
推荐的策略是:
让 CONFIG_SLUB_DEBUG_ON 保持关闭(默认就是关的),把 CONFIG_SLUB_DEBUG 打开。这样,调试能力是挂载在系统上但未激活的状态。
等你需要抓 Bug 的时候,通过内核启动参数 slub_debug 来按需开启。这才是灵活的玩法。
验证配置
改完配置,重新编译内核(如果你还没编译的话),重启进新内核。
进系统之后,用 grep 确认一下当前的配置状态:
$ grep SLUB_DEBUG /boot/config-5.10.60-dbg02
CONFIG_SLUB_DEBUG=y
# CONFIG_SLUB_DEBUG_ON is not set
这里的 # CONFIG_SLUB_DEBUG_ON is not set 非常关键。
它意味着:我有枪,但我现在还没上膛。
这种「待命但不过度干预」的状态,是我们接下来进行实战的基础。如果不小心把 CONFIG_SLUB_DEBUG_ON 打开了,你会明显感觉到鼠标变卡、文件读写变慢——别说我没警告过你。
6.1.5 下一步:slub_debug 的魔法
既然枪已经准备好了,接下来就是学会怎么扣扳机。
SLUB 的核心在于内核命令行参数 slub_debug。它允许你在不重新编译内核的情况下,精细地控制对哪些缓存开启什么样的调试手段。
关于这个参数的详细官方文档在这里:
https://www.kernel.org/doc/html/latest/vm/slub.html
当然,直接啃文档有点枯燥。在下一节里,我们会把那些晦涩的参数翻译成实战场景,通过几个具体的例子,让你明白 slub_debug 到底是怎么帮我们抓出那个「越界访问」或者「释放后重用」的罪魁祸首的。
先记住这个直觉:SLUB 调试,就是在内存对象的周围埋地雷——越界的脚踩上去,就会炸。下一节,我们就来埋雷。