第 4 章 透视黑盒:Kprobes 与内核 instrumentation
4.2 传统艺能:静态 Kprobes 的硬核玩法
我们要从最原始的方式开始——静态 Kprobes。
这种「传统」的探测方式,指的是你作为开发者,必须老老实实地坐在键盘前,用 C 语言写一个内核模块,把要探测的内核函数名字硬编码进源代码里,然后编译,然后 insmod。任何修改——哪怕只是想换一个探测点——都意味着一轮新的 make 和模块重载。
这听起来很麻烦,甚至有点过时。在这个连咖啡机都能连 WiFi 的年代,为什么我们还要学这个?
因为它是地基。动态探测和 eBPF 的花哨操作,底层依然是这套机制。理解了静态 Kprobes 是怎么把「钩子」敲进内核流程里的,后面看那些自动化工具时,你看到的就不是魔法,而是逻辑。
Demo 1 硬编码的拦截
我们的第一个目标是 do_sys_open()。这是内核中处理文件打开的核心例程。只要用户空间调用了 open(),最终都会落到这里。
我们现在的任务很简单:在这个函数的门口和出口各设一个岗哨。
注册探针
所有的动作都发生在内核模块的 init 函数里。我们需要初始化一个 struct kprobe 结构体,然后把它注册到内核里。
代码位于 ch4/kprobes/1_kprobe/1_kprobe.c:
#include <linux/kprobes.h>
#include "<...>/convenient.h"
static struct kprobe kpb;
/* 注册 kprobe 处理函数 */
kpb.pre_handler = handler_pre;
kpb.post_handler = handler_post;
kpb.fault_handler = handler_fault;
kpb.symbol_name = "do_sys_open";
if (register_kprobe(&kpb)) {
pr_alert("register_kprobe on do_sys_open() failed!\n");
return -EINVAL;
}
pr_info("registering kernel probe @ 'do_sys_open()'\n");
这里有一个常见的误解:很多人以为 kprobe 只能拦截「系统调用」。错。它能拦截几乎任何内核函数或模块导出的符号。这里的 do_sys_open 只是一个例子,你可以试着把 symbol_name 换成 do_fork 或者其他乱七八糟的内核函数,只要它在黑名单之外,这套机制都有效。
计算执行时间
除了简单地打印日志,kprobe 还有一个非常实用的用途:性能测量。
你可以算出内核里的某个函数到底跑了多久。逻辑很直观,甚至不需要我解释:
- 进门时:在
pre_handler里打个戳,记作tm_start。用ktime_get_real_ns()就行。 - 出门时:在
post_handler刚开始执行的时候,再打个戳,记作tm_end。 - 算差值:
(tm_end - tm_start)就是这个函数消耗的时间。
来看看具体的 handler 实现:
/* Pre-handler: 函数执行前调用 */
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
PRINT_CTX(); // 使用 pr_debug() 打印上下文
spin_lock(&lock);
tm_start = ktime_get_real_ns();
spin_unlock(&lock);
return 0;
}
/* Post-handler: 函数执行后调用 */
static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags)
{
spin_lock(&lock);
tm_end = ktime_get_real_ns();
PRINT_CTX(); // 使用 pr_debug() 打印上下文
SHOW_DELTA(tm_end, tm_start); // 计算并打印时间差
spin_unlock(&lock);
}
这里有几个技术细节值得注意:
SHOW_DELTA和PRINT_CTX这两个宏定义在我们的convenient.h头文件里。它们不是内核标准 API,是我们为了方便调试自己写的工具。PRINT_CTX宏内部实际上调用的是pr_debug()。这意味着,如果你没有开启DEBUG宏,或者没有配置内核的动态调试功能,你在dmesg里什么都看不见。这不是没干活,是日志被系统吃了。- 那个
spin_lock是干什么的?这是为了并发控制。因为pre_handler和post_handler可能在多核上同时运行(如果do_sys_open被频繁调用),而tm_start和tm_end是全局变量。不加锁的话,你会得到非常奇怪的测试结果——或者是直接 Kernel Panic。
故障处理:防御性编程
既然我们在内核里乱动,就得做好出事的准备。
Kprobe 机制提供了一个 fault_handler。当我们的 handler 执行过程中触发了缺页中断或者其他异常时,这个函数会被调用。在这里,我们通常做不了太复杂的恢复操作——那是内核核心代码的工作——我们主要负责记录现场,然后把这个「烫手山芋」扔回给内核。
static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
pr_info("fault_handler: p->addr = 0x%p, trap #%d\n",
p->addr, trapnr);
/* 返回 0,表示我们不处理这个故障,让内核默认机制接管 */
return 0;
}
NOKPROBE_SYMBOL(handler_fault);
这里有一个极其重要的宏:NOKPROBE_SYMBOL()。
请务必记住它。 它告诉内核:「这个函数绝对不能被 kprobe 探测」。
为什么?想象一下,如果 handler_fault 自己触发了一个 kprobe,而这个 kprobe 的 handler 又挂了,再次触发了 handler_fault……这就变成了无限递归,系统会瞬间死锁。所以,作为 kprobe 内部使用的辅助函数(尤其是 handler 们),必须用这个宏保护起来。
上板实测
光看代码没感觉,我们来跑一下。
这一节配套的 run 脚本已经把所有步骤都封装好了:清空日志、编译、插入模块、等待 5 秒(这期间系统会疯狂调用 do_sys_open),然后卸载模块并打印日志。
$ cd <lkd-src-tree>/ch4/kprobes/1_kprobe
$ ./run
[... 编译输出 ...]
[... 插入模块 ...]
(等待 5 秒)
[... 卸载模块 ...]
来看看 dmesg 的输出。你应该会看到类似下面这样的信息:
[ 123.456789] 1_kprobe:handler_pre: [...] <...> do_sys_open-1234
[ 123.456790] 1_kprobe:handler_post: [...] <...> do_sys_open-1234
[ 123.456790] 1_kprobe:handler_post: delta: 3501 ns
这行日志包含了大量的信息。
解读 PRINT_CTX() 的输出
PRINT_CTX() 是我模仿 ftrace 的延迟追踪格式写的一个宏。它能告诉你当前运行进程是谁、PID 是多少、是在哪个 CPU 上跑的、是在中断上下文还是进程上下文里。
仔细研究一下这个输出格式,这在深度调试时非常有用:
[<时间戳>] <模块名>:<函数名>: <...> <进程名>-<PID> [<CPU>] <中断标志> <抢占标志>
- 进程名 (comm):哪个进程触发了这个调用?
- PID:进程 ID。
- CPU:在哪个核上跑的。
- Delta:
do_sys_open这次执行花了多少纳秒。你会发现它非常快,通常只有几微秒。
⚠️ 踩坑预警:中断上下文的陷阱
问一个问题:如果在**硬中断(HardIRQ)**上下文里调用了 do_sys_open(虽然极少见,但在某些驱动操作里可能发生),PRINT_CTX() 会显示什么?
答案可能会让你意外:它会显示被中断打断的那个进程的名字和 PID,而不是中断本身。因为中断本身没有「进程上下文」,它只是「借」了当前进程的栈在运行。如果你看到日志里进程名是 sshd,但逻辑上 sshd 不可能做这个操作,那多半是因为中断发生时,sshd 正好倒霉地在 CPU 上运行。
这个细节在排查诡异 Bug 时往往是破案的关键。
Kprobe 黑名单——有些地方你不能碰
内核里有些函数是「禁区」。你不能在那儿插桩,主要原因很简单:kprobe 自己的实现就需要调用这些函数。如果你在这些函数里插探针,很容易造成递归崩溃,把整个内核搞挂。
你可以直接查看黑名单:
sudo cat /sys/kernel/debug/kprobes/blacklist
如果 register_kprobe() 失败了,除了检查函数名拼错没,第一件事就是去这个黑名单里看看。内核文档对 kprobe 的限制有详细说明,强烈建议在动手之前读一遍。
Demo 2 灵活一点:通过模块参数指定函数
Demo 1 有个明显的弱点:每次想换一个函数探测,都要改代码里的 symbol_name,然后重新编译。这太累了。
我们来改进一下,引入模块参数。
这样,我们可以在 insmod 的时候,把要探测的函数名作为参数传进去。
参数化改造
代码位于 ch4/kprobes/2_kprobe/2_kprobe.c:
#define MAX_FUNCNAME_LEN 64
static char kprobe_func[MAX_FUNCNAME_LEN];
module_param_string(kprobe_func, kprobe_func, sizeof(kprobe_func), 0);
MODULE_PARM_DESC(kprobe_func, "Function name to attach a kprobe to");
static int verbose;
module_param(verbose, int, 0644);
MODULE_PARM_DESC(verbose, "Set to 1 to get verbose printks (defaults to 0).");
注册时,不再用硬编码的字符串,而是用变量:
kpb.symbol_name = kprobe_func;
使用方式
现在插入模块时,我们可以自由指定目标了:
# 探测 do_sys_open
sudo insmod 2_kprobe.ko kprobe_func=do_sys_open verbose=1
# 也可以探测 do_fork
sudo rmmod 2_kprobe
sudo insmod 2_kprobe.ko kprobe_func=do_fork verbose=1
这就舒服多了。这把我们从「修改源码 -> 编译」的死循环里解放了出来。
抑制日志洪峰
但这里有一个新问题:如果你探测的是像 do_sys_open 这种高频调用的函数,你的 dmesg 会瞬间被刷屏,有用的信息会被淹没。
为了解决这个问题,我们在 Demo 2 里引入了一个过滤宏 SKIP_IF_NOT_VI。
它的逻辑很简单:只当当前进程是 vi 时,才打印日志。
#ifdef SKIP_IF_NOT_VI
/* 为了演示目的,我们只记录进程上下文是 'vi' 的信息 */
if (strncmp(current->comm, "vi", 2))
return 0;
#endif
你可以试着把这个宏改成你关心的进程名(比如 firefox),或者把它改造为模块参数,这样你就能精确过滤噪音,只看你需要的那部分系统行为。
理解基础:什么是 ABI?
现在我们已经能「看见」函数被调用了。但这还不够。
如果你是一个真正的调试高手,你会想知道:这个函数被调用时,传进去的参数是什么?
比如,当 do_sys_open 被调用时,那个文件名到底在哪? 是在寄存器里?还是在栈上?
要回答这个问题,我们必须涉足一个被称为 ABI (Application Binary Interface) 的领域。这是编译器和处理器之间的契约。
编译器是怎么干活的?
当你写下一行 C 代码 open(filename, O_RDWR),编译器在把它变成汇编指令时,必须解决几个问题:
filename这个字符串的地址,放在哪个寄存器里传给函数?O_RDWR这个整数,放哪里?- 函数返回值,我该去哪个寄存器里拿?
这些规则,不是 C 语言规定的,而是处理器架构规定的。每种架构——x86, ARM64, MIPS——都有自己的一套「家规」,这就是 ABI。
关键差异:x86 vs ARM
如果不了解 ABI,你就不知道怎么从 struct pt_regs 里抠出参数来。这里有一张速查表,值得你保存下来:
| 架构 | 参数传递规则 | 返回值存放 |
|---|---|---|
| x86-32 (ia32) | 几乎全靠栈传递。参数按从右到左的顺序压栈。 | EAX |
| x86-64 | 前 6 个参数依次放在 RDI, RSI, RDX, RCX, R8, R9 这 6 个寄存器里。第 7 个及以后参数压栈。 | RAX |
| ARM-32 | 前 4 个参数放在 R0, R1, R2, R3。其余压栈。 | R0 |
| ARM-64 | 前 8 个参数放在 X0 ~ X7。其余压栈。 | X0 |
这张表解释了为什么内核代码里经常有一大堆 ifdef —— 为了在不同架构上拿到函数参数,你必须去查对应架构的寄存器。
⚠️ 注意:这张表是针对整数和指针类型的。浮点数参数通常有另一套规则(比如 x86-64 用 xmm 寄存器传浮点数)。而且,编译器优化可能会改变这些细节,但在内核的核心流程中,这些规则通常是稳定的。
有了这些知识,我们就可以进入下一阶段了:不只是「看见」函数调用,而是偷窥它的参数。我们将利用这个知识,在下一个演示中抓出 do_sys_open 试图打开的那个文件的完整路径。