跳到主要内容

4.7 在内核模块上使用动态 kprobe 事件跟踪

上一节我们还在看内核的堆栈回溯,现在换个场景。

目前为止,我们遇到的场景大多是「内核已经挂了,赶紧去救火」或者「为了加个打印把模块重编了一万遍」。但在现实的生产环境里——尤其是你的用户正在用那个关键服务赚钱的时候——你往往既不能重启机器,也不能随便卸载模块。

这时候你需要的是一种能在活体上做手术的能力:动态插入探针

这一节,我们要在 x86_64 的 Ubuntu 虚拟机(模拟生产环境)上,对一个正在运行的内核模块进行外科手术式的观察。不用改代码,不用重编,甚至不用停机。


准备目标:把小白鼠抓回来

首先,我们需要一个实验对象。把我们在第 3 章写好的 miscdrv_rdwr 驱动模块加载进来。

$ cd <lkd-src-tree>/ch3/miscdrv_rdwr
$ ../../lkm
Usage: lkm name-of-kernel-module-file (without the .c)
$ ../../lkm miscdrv_rdwr
Version info:
Distro: Ubuntu 20.04.3 LTS
Kernel: 5.10.60-prod01
[...]
$ sudo dmesg
------------------------------
[ 1987.178246] miscdrv_rdwr:miscdrv_rdwr_init(): LLKD misc driver (major # 10) registered, minor# = 58, dev node is /dev/llkd_miscdrv_rdwr
$

模块已经就位。接下来,让我们确认一下它在我们「视野」里。

在动态探针的世界里,第一个前提是:目标函数必须在内核的全局符号表里。如果你是私有的静态函数,探针是找不到你的(除非你用源码级别的 fgraph)。像我们这种通过 module_init 注册的标准函数,通常都会乖乖地把自己登记在 /proc/kallsyms 里。

让我们搜一下:

$ sudo grep miscdrv /proc/kallsyms
ffffffffc0562000 t write_miscdrv_rdwr [miscdrv_rdwr]
ffffffffc0562982 t write_miscdrv_rdwr.cold [miscdrv_rdwr]
ffffffffc0562290 t open_miscdrv_rdwr [miscdrv_rdwr]
ffffffffc0562480 t close_miscdrv_rdwr [miscdrv_rdwr]
ffffffffc0562650 t read_miscdrv_rdwr [miscdrv_rdwr]
ffffffffc05629b5 t read_miscdrv_rdwr.cold [miscdrv_rdwr]
[...]

都在。

顺便说一句,你可能注意到了有些函数屁股后面挂了个 .cold。这是什么鬼东西?

这是 GCC 搞的鬼。.cold 是一个编译器属性,它告诉 CPU:「这个函数八百年也跑不到一次,把它扔到角落里去吧,别挡在热代码的旁边」。这是一种优化策略,叫「冷热分离」。你看上面的输出,我们的 readwrite 既有正常版本,也有 .cold 版本——编译器觉得某些错误处理路径是「冷」的,就给它们单独开了小灶。

这给我们一个启示:你要探测的时候,最好确认你要hook的是哪一个版本,否则可能盯着冷路径看了半天,啥也没抓着。


挂上钩子:动态 kprobe 入场

现在,开始动手术。

打开一个终端,进入 tracefs 的控制中心:

cd /sys/kernel/tracing

我们要在 write_miscdrv_rdwr 这个函数上挂一个探针。这时候用到了 kprobe_events 这个控制文件。你可以把它理解成一张「手术单」,我们在上面写清楚要切哪里、怎么切。

把这条命令写进去:

echo "p:mymiscdrv_wr write_miscdrv_rdwr" >> kprobe_events
  • p::表示这是一个 kprobe(探入点)。
  • mymiscdrv_wr:这是我们自己给这个探针起的名字,起个好名字,后面找起来方便。
  • write_miscdrv_rdwr:目标函数名。

确认一下是否写入成功:

# cat kprobe_events
p:kprobes/mymiscdrv_wr write_miscdrv_rdwr
#

系统已经回复了:OK,探针已注册,但还没开保险。

现在打开保险:

echo 1 > events/kprobes/mymiscdrv_wr/enable

真正的测试:见证数据流动的时刻

准备工作做完了,现在是见证奇迹的时刻。我们需要两个窗口来配合演出。

窗口 A(观察者)

保持在 /sys/kernel/tracing 目录下,执行:

cat trace_pipe

这个命令会像在那儿守株待兔,一旦有探针触发,数据就会立刻流出来。

窗口 B(触发者)

在另一个终端,运行我们的用户态测试程序,往设备里写点东西。这一步一定会调用驱动的 write 函数,也就是我们刚才埋伏点的地方。

$ ./rdwr_test_secret w /dev/llkd_miscdrv_rdwr "dyn kprobes event tracing is awesome"

当你敲下回车的那一瞬间,窗口 A 应该会瞬间刷出一段日志。

让我们来「审片」(虽然这里没有配图,但我们可以脑补一下那个屏幕):

你会看到日志里冒出了 mymiscdrv_wr 相关的记录。这里展示了 trace 输出的典型格式:时间戳、CPU 编号、进程信息,以及最重要的——函数被命中了

这证明了什么?证明了在没有修改任何驱动代码、没有重新编译内核、没有重启机器的情况下,我们精准地捕获了一次内核模块内部的函数调用。

这就是动态 kprobe 的美学:非侵入式


收拾战场:拆除炸弹

实验做完了,别把探针留在那,否则会无谓地消耗性能,填满 trace buffer。记得清理现场。

# echo 0 > events/kprobes/mymiscdrv_wr/enable
# echo "-:mymiscdrv_wr" >> kprobe_events
# cat kprobe_events
#

- 前缀表示删除。最后清空 trace buffer,给下一次实验腾地方:

# echo > trace

更优雅的方式:kprobe-perf 脚本

说实话,刚才那一通 echo 操作虽然能让你明白底层原理,但用起来真的很像是在用手摇钻钻孔。

在实际工程里,我们会用封装好的工具。还记得我们前面提到过的 kprobe-perf 脚本吗?它本质上就是把上面那一长串命令自动化了,而且格式化输出做得更漂亮。它几乎就是一条命令的事,非常适合放在你的 debug 工具箱里。

但知道底层原理很重要——当脚本跑不通的时候,你得知道是 kprobe_events 没写对,还是权限出了问题。


进阶:别忘了 Uprobes

既然我们能对内核函数下钩子,那用户态的程序呢?

答案是肯定的。这套机制在用户空间的对应物叫 Uprobes。它的用法和 kprobes 几乎一模一样,也是通过 tracefs 接口(uprobe_events)来配置。

这意味着你可以用同样的思路去调试一个正在运行的 Nginx 或者 MySQL,而不用重启它们。关于 Uprobes 的详细用法,可以参考内核文档里的 Documentation/trace/uprobetracer.txt


捕捉返回值:kretprobe 实战

很多时候,光知道「函数被调用了」还不够。我们还想知道:「它返回了什么?」

比如内核里的 do_sys_open,我想知道它成功打开文件后返回的那个文件描述符是多少。这时候就要用到 kretprobe(返回探针)。

kprobe-perf 脚本能非常简单地做到这一点。看下面这个例子:

rpi # kprobe-perf 'r:do_sys_open ret=$retval'
Tracing kprobe do_sys_open. Ctrl-C to end.
kprobe-perf-2287 [000] d... 13013.021003: do_sys_open: (sys_openat+0x1c/0x20 <- do_sys_open) ret=0x3
<...>-2289 [000] d... 13013.027167: do_sys_open: (sys_openat+0x1c/0x20 <- do_sys_open) ret=0x3
<...>-2289 [000] d... 13013.027504: do_sys_open: (sys_openat+0x1c/0x20 <- do_sys_open) ret=0x3
^C
Ending tracing...
rpi #

这里的核心是 ret=$retval。它告诉 kretprobe:把函数的返回值抓下来,存到变量 ret 里,然后打印出来。

看输出中的 ret=0x3。 这看起来非常合理。在一个进程的上下文里,012 这三个文件描述符通常是 stdin、stdout 和 stderr 的既定席位。新打开的文件,顺理成章地拿到了 3

再来看看这行输出的括号部分:

(sys_openat+0x1c/0x20 <- do_sys_open)

这句话包含了丰富的调用栈信息:

  1. 我们的探针点 do_sys_open 正在返回。
  2. 返回到 sys_openat 函数。
  3. +0x1c/0x20 这种 <func>+off/len 的格式揭示了指令级的细节:
    • off (0x1c):调用返回点相对于 sys_openat 函数起始地址的偏移量。
    • len (0x20):内核估算的这个函数的总长度。

剩下的部分就是标准的 ftrace 格式,你应该已经很眼熟了。


潜力无限:深入内核结构

如果我说这就到头了,那肯定是在骗你。

这套基于函数的动态 kprobes 框架,其实是一个通往内核内部的任意门。只要你足够了解内核数据结构的内存布局(通过偏移量),你甚至可以在探针里直接打印出某个结构体内部的字段值。

Steven Rostedt(ftrace 的主要作者)曾经在他的 slides 里演示过如何像剥洋葱一样,通过动态探针一层层剥出函数参数和结构体成员的值。那才是真正的黑魔法。


小结

这一节我们把目光移到了内核模块上,验证了动态 kprobe 事件跟踪在非自己编写的内核代码上的威力。

主要收获了两点:

  1. 实战确认:任何导出到全局符号表的模块函数,都可以成为动态 kprobe 的猎物。
  2. kretprobe 的威力:不只是看入口,还能抓出口——拿到函数的返回值,这对调试逻辑错误至关重要。

在下一章结束之前,我们还有最后一个非常实用的场景要讲:进程追踪。这不仅仅是调试,更是一种像审计日志一样的能力——让我们知道系统里到底谁在干什么。