跳到主要内容

使用 kmemleak

回想一下我们在上一节结尾看到的场景:成百上千个 vm_area_struct 对象被分配出来,堆在内存里,没人去管。当时我们说,这是一种合理的系统行为——直到它不再合理为止。

问题是,你怎么知道什么时候是「合理」的,什么时候是「泄漏」的开始?

当你在写一个内核模块,或者调试某个驱动时,内存这种东西就像是指缝里的沙子——你以为你握紧了,其实它一直在漏。我们需要一把能够照出这些「孤儿」内存的探照灯。

这就是 kmemleak 存在的意义。

它的名字听起来像是个黑客工具,其实它的工作原理非常朴素,甚至有点「笨」:它像一个不知疲倦的清洁工,定期扫描整个内核内存,寻找那些已经被分配出来,但没有任何指针指向它们的内存块。如果发现了一块内存既没被释放,也没有任何指针指着它,kmemleak 就会判定:这是一个泄漏。

这一节,我们要做的就是这个——把 kmemleak 打开,让它跑起来,然后把我们的那些烂代码扔进去,看看它是怎么把这些尴尬的秘密揭露出来的。


6.1 基础流程——五步法

使用 kmemleak 的基本流程其实非常机械,甚至有点枯燥。我们可以把它浓缩成一个标准的五步 checklist。但在你跟着做之前,有一个重要的前提需要确认。

第 0 步:确认环境(至关重要)

在开始之前,你得确保两件事:

  1. debugfs 挂没挂? 通常它都在 /sys/kernel/debug 下。如果这目录不存在,你得先动手把它挂上。
  2. kmemleak 到底启动了没? 这是个大坑。你以为内核配置里选了它就能用?未必。它可能处于「已配置但已禁用」的状态。如果 echo scan 命令报错,十有八九就是这个问题。

假设上面这两点都搞定了,接下来的五步就是标准的操作流:

第 1 步:让它脏起来

运行你那个可能漏洞百出的代码,或者把测试用例跑起来。这一步的目的是「制造现场」——让内存泄漏发生。

第 2 步:扫描(这也是最核心的一步)

现在,手动触发一次内存扫描。以 root 身份执行:

echo scan > /sys/kernel/debug/kmemleak

这行命令会唤醒一个名为 kmemleak 的内核线程。它不是真的用眼睛看,而是遍历内存,检查指针的引用关系。

这个过程可能会有点慢。在我的虚拟机上,这通常需要几秒钟。如果你的机器内存很大,或者负载很高,你可能得稍微等一等。

如果它找到了怀疑对象,内核日志里会弹出一行这样的消息:

kmemleak: 1 new suspected memory leaks (see /sys/kernel/debug/kmemleak)

第 3 步:查看报告

就像日志里提示的那样,去读取那个伪文件:

cat /sys/kernel/debug/kmemleak

第 4 步:打扫战场(可选)

如果你想进行下一轮测试,不想被上一轮的报告干扰,清空它:

echo clear > /sys/kernel/debug/kmemleak

6.2 当你无法写入 kmemleak 时——排错实战

听上去很简单,对吧?

但在现实中,你在第 2 步就会撞墙。当你满怀信心地敲下 echo scan 时,终端可能冷冷地回你一句:

# echo scan > /sys/kernel/debug/kmemleak
bash: echo: write error: Operation not permitted

这时候你的第一反应可能是:「我不是用 root 跑的吗?」

别急,这不是权限问题。去内核日志里找找线索(用 dmesgjournalctl -k),你很可能会看到这行刺眼的红色字:

kmemleak: Kernel memory leak detector disabled

这就很奇怪了——明明配置里开启了 CONFIG_DEBUG_KMEMLEAK,为什么它还是说自己被禁用了?

这里有一个非常有意思的调试技巧,我们在前面章节提到过。我们可以用 debuginitcall_debug 内核参数来重新启动,看看启动过程中到底发生了什么。

给内核启动参数加上这两个选项:

debug initcall_debug

重启进系统后,查看一下启动日志,专门搜索 kmemleak 相关的消息:

[ ... ] kmemleak: kmemleak_late_init() failed (-12)

看到了吗?返回值是 -12

玩转内核错误码的直觉告诉你:-12 对应的是 ENOMEM(内存不足)。

停一下。这很反直觉。

kmemleak 是一个内存泄漏检测工具,它自己竟然因为内存不足而初始化失败了?这就像是一个修水管的人因为水管没水而渴死了一样。

其实,kmemleak 在启动早期需要一个小的内存池(mem pool)来记录日志,这个池子的大小是由内核配置项 CONFIG_DEBUG_KMEMLEAK_MEM_POOL_SIZE 决定的,默认只有 16000 字节。在某些情况下,这点小钱确实不够花。

于是,你像模像样地拿出 scripts/config 工具,把这个值翻了一倍:

$ scripts/config --set-val CONFIG_DEBUG_KMEMLEAK_MEM_POOL_SIZE 32000

重新编译内核,重启。

结果……

$ dmesg | grep "kmemleak"
kmemleak: Kernel memory leak detector disabled

还是这个鬼样子。

这时候如果你把头埋进日志里研究半天内存池的大小,你就掉进了陷阱(所谓的 red herring)。问题根本不在这里。

问题的真正原因非常简单,简单到你甚至会觉得有点「蠢」。

你还需要再做一个动作:在内核启动命令行里明确地告诉它「请开启」。

默认情况下,即使你编译了 kmemleak,它也是默认关闭的(因为有性能开销)。你必须在启动参数里加上:

kmemleak=on

这才是那把真正的钥匙。

加上这个参数重启,你再看看日志:

$ dmesg | grep "kmemleak"
[ 6.743927] kmemleak: Kernel memory leak detector initialized (mem pool available: 14090)
[ 6.743956] kmemleak: Automatic memory scanning thread started

这就对了。那个内存池的大小警告甚至都不见了(因为现在它成功初始化了)。

⚠️ 踩坑预警 这个坑简直经典。 要点:配置 CONFIG_DEBUG_KMEMLEAK 只是造好了车,想开走还得插钥匙——那就是 kmemleak=on副作用:忘了这个参数,你会对着 Operation not permitted 抓狂半天,最后发现只是缺个启动参数。

顺便一提,那个被唤醒的 kmemleak 内核线程,它的优先级是被故意调低的(nice 值为 10),这意味着它只在系统比较闲的时候才会出来干活,不会抢着你正常业务的资源。


6.3 实战演练——捕捉泄漏

好了,工具修好了。现在是时候找几个真正的「犯人」来练练手了。

我们准备了三个测试用例。别眨眼,我们要开始抓虫了。

测试用例 3.1:教科书式的泄漏

先看第一段代码。这段代码烂得很直接:

// ch5/kmembugs_test/kmembugs_test.c
void leak_simple1(void)
{
volatile char *p = NULL;
pr_info("testcase 3.1: simple memory leak testcase 1\n");
p = kzalloc(1520, GFP_KERNEL);
if (unlikely(!p))
return;
pr_info("kzalloc(1520) = 0x%px\n", p);
if (0) // test: ensure it isn't freed
kfree((char *)p);
#ifndef CONFIG_MODULES
pr_info("kmem_cache_alloc(task_struct) = 0x%px\n",
kmem_cache_alloc(task_struct, GFP_KERNEL));
#endif
pr_info("vmalloc(5*1024) = 0x%px\n", vmalloc(5*1024));
}

这段代码里有三处泄漏:

  1. kzalloc 分配了 1520 字节。
  2. kmem_cache_alloc 分配了一个 task_struct(如果 CONFIG_MODULES 没定义的话)。
  3. vmalloc 分配了 5KB。

最讽刺的是那个 if (0),这就像是明目张胆地写着:「我知道该释放,但我就是不想。」

把它跑起来:

cd <booksrc>/ch5/kmembugs_test
sudo ./run_tests
...
(Type in the testcase number to run): 3.1
Running testcase "3.1" via test module now...
[ ... ] kzalloc(1520) = 0xffff888003f17000
[ ... ] vmalloc(5*1024) = 0xffffc9000005c000

代码跑完了,内存也漏了。现在,让 kmemleak 出场:

sudo sh -c "echo scan > /sys/kernel/debug/kmemleak"

等它跑完(那几秒钟的停顿感有点微妙),看看报告:

sudo cat /sys/kernel/debug/kmemleak

你会看到一堆输出。别慌,我们只看第一个泄漏报告:

unreferenced object 0xffff8880127f8000 (size 2048):
comm "run_tests", pid 5498, jiffies 4296684850 (age 84.737s)
hex dump (first 32 bytes):
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...
backtrace:
[<00000000c0b84cb6>] slab_post_alloc_hook+0x78/0x5b0
[<00000000f76c1d8d>] kmem_cache_alloc_trace+0x16b/0x370
[<000000009f614545>] leak_simple1+0xc0/0x19b [test_kmembugs]
[<00000000747f9f09>] dbgfs_run_testcase+0x1e6/0x51a [test_kmembugs]
[...]

我们来像侦探一样解读这段日志:

  1. 地址和大小0xffff8880127f8000,这块内存大小是 2048 字节。 等等,我们代码里明明申请的是 1520 字节,为什么是 2048? 这就是 slab 分配器的「取整」逻辑。内核会找一个最接近且大于请求大小的缓存,这里是 kmalloc-2k,所以给的是 2KB。

  2. 上下文comm "run_tests", pid 5498。 这是告诉你是谁干的。age 字段告诉我们这个泄漏已经存在了 84 秒多。

  3. 内存内容:hex dump 部分全是 0x00。 这是因为我们用的是 kzalloc(zeroed alloc),内容被清零了。如果我们用的是普通的 kmalloc,这里可能会残留一些之前的数据,那才是真正令人兴奋(或惊恐)的时刻。

  4. 调用栈(Backtrace)——这是最硬核的证据。 从下往上看:

    • __x64_sys_write:这是用户空间发起了系统调用(我们的 echo 命令)。
    • dbgfs_run_testcase:进入了我们的 debugfs 处理函数。
    • leak_simple1:进入了那个故意写坏的测试函数。
    • kmem_cache_alloc_trace:最终执行了 slab 分配。

铁证如山。kmemleak 不止抓住了它,还把它的祖宗十八代都抖出来了。

至于第二个泄漏(vmalloc 那个),报告里也有,逻辑完全一样,栈里会显示 vmalloc 的调用路径。

测试用例 3.2:甩锅给调用者

有时候泄漏不是那么明显。比如这种写法:

// caller
else if (!strncmp(udata, "3.2", 4)) {
res2 = (char *)leak_simple2();
pr_info(" res2 = \"%s\"\n", res2 == NULL ? "<whoops, it's NULL>" : (char *)res2);
if (0) /* test: ensure it isn't freed by us, the caller */
kfree((char *)res2);
}

被调用的 leak_simple2 乖乖地分配了内存并返回指针:

char *leak_simple2(void)
{
char *p = kmalloc(8, GFP_KERNEL);
if (!p) return NULL;
strcpy(p, "leaky!!");
return p;
}

这在 C 语言里是个经典的灰色地带:函数文档里写着「调用者负责释放」,但如果调用者忘了?编译器不会提醒你,运行时也不会当场崩溃。

kmemleak 怎么看这事?

跑一下用例 3.2,扫描,看报告:

unreferenced object 0xffff8880074b5d20 (size 8):
comm "run_tests", pid 5779, jiffies 4298012622 (age 181.044s)
hex dump (first 8 bytes):
6c 65 61 6b 79 21 21 00 leaky!!.
backtrace:
[<00000000c0b84cb6>] slab_post_alloc_hook+0x78/0x5b0
[<00000000f76c1d8d>] kmem_cache_alloc_trace+0x16b/0x370
[<000000009f614545>] leak_simple2+0xc0/0x19b [test_kmembugs]
[<00000000747f9f09>] dbgfs_run_testcase+0x1e6/0x51a [test_kmembugs]

看到了吗? Hex dump 里甚至把那个字符串 leaky!! 给打出来了。 kmemleak 才不管你是「设计上由调用者释放」还是「忘了释放」,只要没人指着它,就是孤儿,就是泄漏。

这也是为什么内核社区后来推崇 devm_kalloc 这种资源管理机制的原因——把责任从健忘的人身上转移到系统身上。

测试用例 3.3:中断上下文中的幽灵

最后一个场景更刁钻。

前面的例子都是在进程上下文里跑的。如果泄漏发生在中断上下文呢?毕竟中断处理函数是不能睡眠、不能乱来的。

为了模拟这个场景,我们用了一个叫 irq_work 的机制,强制让代码在 hardirq 上下文里跑。

void leak_simple3(void)
{
pr_info("testcase 3.3: simple memory leak testcase 3\n");
irq_work_queue(&irqwork);
}

/* This function runs in (hardirq) interrupt context */
void irq_work_leaky(struct irq_work *irqwk)
{
// ...
pr_debug("kzalloc(129) = 0x%px\n",
kzalloc(129, GFP_ATOMIC)); // Must use GFP_ATOMIC here!
}

跑起来,扫描,看结果:

unreferenced object 0xffff88800b614800 (size 256):
comm "hardirq", pid 0, jiffies 4298048922 (age 12.020s)
hex dump (first 32 bytes):
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...
backtrace:
[...]
[<00000000d54114cc>] irq_work_leaky+0x2a/0x40 [test_kmembugs]
[<00000000c4d72c5e>] irq_work_run_list+0x54/0xf0
[<00000000c4d73044>] irq_work_tick+0x64/0x80

注意这一行:comm "hardirq",以及 pid 0

这就是 kmemleak 聪明的地方。它不仅发现了泄漏,还准确地指出了泄漏发生的上下文。这对你调试中断处理函数里的内存泄漏简直是救命稻草。


6.4 内核自带的测试模块

其实你不用自己写这些烂代码。内核源码里早就给你准备好了。

如果你配置内核时开启了 CONFIG_DEBUG_KMEMLEAK_TEST,编译系统会生成一个名为 kmemleak-test.ko 的模块(通常在 samples/kmemleak/ 下)。

直接把它插进去:

sudo modprobe kmemleak-test

然后跑一遍 scan。你会被吓一跳的——它一口气报告了 13 个内存泄漏。这是一个很好的验证环境,你可以看看 kmemleak 是怎么一口气处理这么多不同类型的泄漏的。


6.5 控制台上的指挥棒

最后,总结一下那个神奇的 /sys/kernel/debug/kmemleak 文件。我们不仅能读它,还能往里写东西来控制它:

写入内容效果
scan立即触发一次内存扫描。
clear清空当前的泄漏报告列表。
dump=...这是一个高级玩法。你可以指定一个地址,让 kmemleak 把这个地址周围的引用关系打印出来。这就像是在问:「这块内存到底被谁指着?」
off彻底关掉 kmemleak 扫描线程。
on重新打开扫描线程。

一个推荐的调试工作流:

# 1. 清理现场
echo clear > /sys/kernel/debug/kmemleak

# 2. 跑你的测试模块或操作
...

# 3. 稍微等一下(让内存分配稳定下来)

# 4. 触发扫描
echo scan > /sys/kernel/debug/kmemleak

# 5. 检查日志有没有 "new suspected memory leaks"
dmesg | tail

# 6. 如果有,看详细报告
cat /sys/kernel/debug/kmemleak

这套流程虽然简单,但它能帮你省去那些盯着 slabinfo 瞎猜的夜晚。

kmemleak 并不是完美的(它有误报,也有性能开销),但在那漆黑的内核内存空间里,它绝对是你手里最亮的一盏灯。