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:「这个函数八百年也跑不到一次,把它扔到角落里去吧,别挡在热代码的旁边」。这是一种优化策略,叫「冷热分离」。你看上面的输出,我们的 read 和 write 既有正常版本,也有 .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。
这看起来非常合理。在一个进程的上下文里,0、1、2 这三个文件描述符通常是 stdin、stdout 和 stderr 的既定席位。新打开的文件,顺理成章地拿到了 3。
再来看看这行输出的括号部分:
(sys_openat+0x1c/0x20 <- do_sys_open)
这句话包含了丰富的调用栈信息:
- 我们的探针点
do_sys_open正在返回。 - 返回到
sys_openat函数。 +0x1c/0x20这种<func>+off/len的格式揭示了指令级的细节:- off (0x1c):调用返回点相对于
sys_openat函数起始地址的偏移量。 - len (0x20):内核估算的这个函数的总长度。
- off (0x1c):调用返回点相对于
剩下的部分就是标准的 ftrace 格式,你应该已经很眼熟了。
潜力无限:深入内核结构
如果我说这就到头了,那肯定是在骗你。
这套基于函数的动态 kprobes 框架,其实是一个通往内核内部的任意门。只要你足够了解内核数据结构的内存布局(通过偏移量),你甚至可以在探针里直接打印出某个结构体内部的字段值。
Steven Rostedt(ftrace 的主要作者)曾经在他的 slides 里演示过如何像剥洋葱一样,通过动态探针一层层剥出函数参数和结构体成员的值。那才是真正的黑魔法。
小结
这一节我们把目光移到了内核模块上,验证了动态 kprobe 事件跟踪在非自己编写的内核代码上的威力。
主要收获了两点:
- 实战确认:任何导出到全局符号表的模块函数,都可以成为动态 kprobe 的猎物。
- kretprobe 的威力:不只是看入口,还能抓出口——拿到函数的返回值,这对调试逻辑错误至关重要。
在下一章结束之前,我们还有最后一个非常实用的场景要讲:进程追踪。这不仅仅是调试,更是一种像审计日志一样的能力——让我们知道系统里到底谁在干什么。