4.6 设置动态 kprobe(通过 kprobe events)——在任意函数上插眼
上一节我们提到过,现实往往比演示要残酷。当你需要监控的那个函数——也许是你自己写的内核模块里的某个不起眼的内部函数,或者是某个冷门的系统调用——根本没有在 /sys/kernel/tracing/events 下露面,该怎么办?
这就进入真正的「毛坯房」装修模式了。
既然没有现成的目录,我们就得自己造一个目录。这一节,我们要利用内核的 动态事件跟踪框架,也就是所谓的 基于 kprobe 的事件跟踪,在任意函数上强行插一只眼。
凭什么能插眼?
在动手之前,先确认一件事:你能不能在某个函数上插眼,取决于它有没有在「花名册」上登记。
这个花名册有两个地方可以查:
- 内核的全局符号表:也就是
/proc/kallsyms。只要是内核导出的符号,都在这儿。 - ftrace 的可用函数列表:也就是
/sys/kernel/debug/tracing/available_filter_functions。
那如果函数在某个内核模块里呢?没问题。只要模块加载进了内存,模块里的符号就会自动并入内核符号表,你就能在 /proc/kallsyms 里看到它(当然得是 root 权限)。接下来的例子就会展示这一点。
第一步——建立插眼点
先进入 tracing 的控制中心:
# cd /sys/kernel/debug/tracing
如果因为某些原因(比如生产环境的内核配置了 CONFIG_DEBUG_FS_DISALLOW_MOUNT=y),上面的路径不存在,那就走正统路线:
# cd /sys/kernel/tracing
现在,我们在 do_sys_open() 这个函数上建立一个动态 kprobe。语法有点讲究,别写错了:
echo "p:<kprobe-name> <function-to-kprobe> [...]" >> kprobe_events
p:代表你要设置一个 kprobe(如果是返回探针就是r:)。<kprobe-name>是你给这个探针起的代号,随便起,不写默认就是函数名。<function-to-kprobe>是你要抓的那个函数。[...]是可选参数,后面再说,用来抓函数参数是它的杀手锏。
来,我们实战一下,给 do_sys_open() 起个代号叫 my_sys_open:
echo "p:my_sys_open do_sys_open" >> kprobe_events
这就写进去了。这一行命令敲下去,内核并没有去修改磁盘上的文件,而是在内存里动态注册了一个钩子。
现在再去看看 /sys/kernel/[debug]/tracing/events 目录,底下多了一个 kprobes 文件夹,这就是刚才那行命令变出来的。
# ls -lR events/kprobes/
events/kprobes/:
total 0
drwxr-xr-x 2 root root 0 Oct 9 18:58 my_sys_open/
-rw-r--r-- 1 root root 0 Oct 9 18:58 enable
-rw-r--r-- 1 root root 0 Oct 9 18:58 filter
events/kprobes/my_sys_open:
total 0
-rw-r--r-- 1 root root 0 Oct 9 18:59 enable
-rw-r--r-- 1 root root 0 Oct 9 18:58 filter
-r--r--r-- 1 root root 0 Oct 9 18:58 format
[…]
看,结构和上一节的静态 tracepoints 一模一样。这就是 ftrace 框架的优雅之处——不管是静态埋的雷,还是动态埋的眼,接口统一。
第二步——点火
探针虽然建好了,但默认是关着的(enable 文件里是 0)。我们需要把它拉起来:
echo 1 > events/kprobes/my_sys_open/enable
现在,只要有进程调用了 do_sys_open,内核就会把信息吐到 trace buffer 里。你可以直接看:
cat trace
[…]
cat-192796 [001] .... 392192.698410: my_sys_open: (do_sys_open+0x0/0x80) file="/usr/lib/locale/locale-archive"
cat-192796 [001] .... 392192.698650: my_sys_open: (do_sys_open+0x0/0x80) file="trace"
gnome-shell-7441 [005] .... 392192.777608: my_sys_open: (do_sys_open+0x0/0x80) file="/sys/class/net/wlo1/statistics/rx_packets"
[…]
如果你想要那种「实时监控」的感觉,别用 cat,用 cat trace_pipe,数据流会源源不断地刷屏,这在使用动态 kprobe 交互时非常有用。
或者你可以把结果存盘慢慢分析:
cp /sys/kernel/tracing/trace /tmp/mytrc.txt
第三步——拆雷
玩够了,记得收拾战场。分两步:先关开关,再拆炸弹。
echo 0 > events/kprobes/my_sys_open/enable
echo "-:my_sys_open" >> kprobe_events
注意这里有个细节:删除的时候用的是 -:name,这个减号就是告诉内核「把刚才那个删了」。
如果你想一下子删光所有动态探针,可以直接清空文件:
echo > /sys/kernel/tracing/kprobe_events
一旦所有的探针都删了,kprobe_events 这个伪文件自己也会消失。另外,如果你想把 buffer 里的数据也清了:
echo > trace
这一套连招——建、开、看、关、删——就是动态 kprobe 的基本操作。如果你想挖得更深(比如怎么格式化参数),内核文档有一篇 Kprobe-based Event Tracing 写得很详细。或者去看看 kprobe-perf 脚本的源码,那是活生生的教材。
⚠️ 别把系统玩炸了
这里必须严肃一下。就像我们在手动使用 kprobes 时警告过的那样,kprobe-perf 脚本的作者也把警告写在脸上了:
WARNING: This uses dynamic tracing of kernel functions, and could cause kernel panics or freezes, depending on the function traced. Test in a lab environment, and know what you are doing, before use.
怎么「 mitigate」(缓解)这种风险?
- 只追踪你需要的:别贪心,只抓那个具体的函数,别设太宽泛的通配符。
- 缩短时间窗口:看一眼就赶紧关掉。
- 利用 buffer:内核是靠 per-CPU buffer 来存数据的,大小固定在
/sys/kernel/[debug]/tracing/buffer_size_kb。如果你发现数据溢出了,尝试调大这个值。
跨过架构的坑——ARM 实战
在 x86 上跑通上面的命令是理所当然的。但如果你换到 ARM 板子上,还想做点高级操作——比如打印 open 系统调用的文件名参数——事情就变得稍微有点意思了。
我们知道 do_sys_open 的第二个参数是文件名路径(在 x86_64 上,参数按顺序放在 RDI, RSI, RDX... 寄存器里)。所以在 x86 上,你会这么写:
echo "p:my_sys_open do_sys_open file=+0(%si):string" > /sys/kernel/debug/tracing/kprobe_events
但在 ARM 上跑这一行?
bash: echo: write error: Invalid argument
报错了。为什么?
因为 ARM 根本就没有 %si 这个寄存器。
这就回到了我们在前面章节反复强调的 ABI(应用二进制接口) 知识。参数传递是架构相关的。在 ARM-32 上,前四个参数是通过 r0, r1, r2, r3 传递的(回顾一下表 4.1)。所以,命令得改成这样:
echo "p:my_sys_open do_sys_open file=+0(%r1):string" > /sys/kernel/debug/tracing/kprobe_events
这样就对了。我们可以一次性把所有参数都抓出来:
echo 'p:my_sys_open do_sys_open dfd=%r0 file=+0(%r1):string flags=%r2 mode=%r3' > /sys/kernel/debug/tracing/kprobe_events
别忘了 echo 1 启用一下。
如果你装了 perf-tools(或者 perf-tools-unstable),事情可以更简单,直接用封装好的脚本:
rpi # kprobe-perf 'p:my_sys_open do_sys_open dfd=%r0 file=+0(%r1):string flags=%r2 mode=%r3'
Tracing kprobe my_sys_open. Ctrl-C to end.
cat-1866 [000] d... 8803.206194: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="/etc/ld.so.preload" flags=0xa0000 mode=0x0
cat-1866 [000] d... 8803.206548: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="/usr/lib/arm-linux-gnueabihf/libarmmem-v6l.so" flags=0xa0000 mode=0x0
cat-1866 [000] d... 8803.207085: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="/etc/ld.so.cache" flags=0xa0000 mode=0x0
cat-1866 [000] d... 8803.207235: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="/lib/arm-linux-gnueabihf/libc.so.6" flags=0xa0000 mode=0x0
cat-1866 [000] d... 8803.209703: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="/usr/lib/locale/locale-archive" flags=0xa0000 mode=0x0
cat-1866 [000] d... 8803.210395: my_sys_open: (do_sys_open+0x0/0xd8) dfd=0xffffff9c file="trace_pipe" flags=0x20000 mode=0x0
^C
Ending tracing...
看着这些日志,你能清晰地看到每个进程打开了什么文件,传了什么标志位。这种能力,对于调试底层问题来说,简直是透视眼。
练习题:看看中断底半部是谁在调
题目:设置一个 kprobe,每当中断处理程序的 tasklet(底半部)被调度执行时触发,并且显示当时的内核堆栈。
一种解题思路:
在传统的中断处理模式(Top/Bottom halves)里,驱动开发者通常会在硬件中断处理函数(Top half,顶半部)里调用 schedule_tasklet() 内核 API,来请求调度底半部。
我们要找的,就是这个底层的调度函数。
先查一下符号表:
# grep tasklet_schedule /sys/kernel/debug/tracing/available_filter_functions
__tasklet_schedule_common
__tasklet_schedule
好,目标锁定 __tasklet_schedule。我们不仅要在它上面插眼,还要加个 -s 参数,让它顺便打印出内核堆栈,看看是谁在调用它:
# kprobe-perf -s 'p:mytasklets __tasklet_schedule'
Tracing kprobe mytasklets. Ctrl-C to end.
kworker/0:0-1855 [000] d.h. 9909.886809: mytasklets: (__tasklet_schedule+0x0/0x28)
kworker/0:0-1855 [000] d.h. 9909.886829: <stack trace>
=> __tasklet_schedule
=> bcm2835_mmc_irq
=> __handle_irq_event_percpu
=> handle_irq_event_percpu
=> handle_irq_event
=> handle_level_irq
=> generic_handle_irq
=> __handle_domain_irq
=> bcm2835_handle_irq
=> __irq_svc
=> bcm2835_mmc_request
=> __mmc_start_request
=> mmc_start_request
=> mmc_wait_for_req
=> mmc_wait_for_cmd
=> mmc_io_rw_direct_host
=> mmc_io_rw_direct
=> process_sdio_pending_irqs
=> sdio_irq_work
=> process_one_work
=> worker_thread
=> kthread
=> ret_from_fork
[...]
这一堆输出信息量很大。注意看 kworker 那一行后面的 d.h. 标记。回顾图 4.4 的解释:
- d: 中断被禁用了。
- h: 这是一个硬中断上下文。
堆栈要从下往上看。最底层的调用链表明,这是一个 SD/MMC 卡的 I/O 操作触发了中断(bcm2835_mmc_irq),然后在中断处理过程中,调度了一个 tasklet。
这就是动态 kprobe 的威力——你不需要去读源码猜,直接让内核告诉你它是怎么跑的。