跳到主要内容

第 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 还有一个非常实用的用途:性能测量。

你可以算出内核里的某个函数到底跑了多久。逻辑很直观,甚至不需要我解释:

  1. 进门时:在 pre_handler 里打个戳,记作 tm_start。用 ktime_get_real_ns() 就行。
  2. 出门时:在 post_handler 刚开始执行的时候,再打个戳,记作 tm_end
  3. 算差值(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_DELTAPRINT_CTX 这两个宏定义在我们的 convenient.h 头文件里。它们不是内核标准 API,是我们为了方便调试自己写的工具。
  • PRINT_CTX 宏内部实际上调用的是 pr_debug()。这意味着,如果你没有开启 DEBUG 宏,或者没有配置内核的动态调试功能,你在 dmesg 里什么都看不见。这不是没干活,是日志被系统吃了。
  • 那个 spin_lock 是干什么的?这是为了并发控制。因为 pre_handlerpost_handler 可能在多核上同时运行(如果 do_sys_open 被频繁调用),而 tm_starttm_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:在哪个核上跑的。
  • Deltado_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),编译器在把它变成汇编指令时,必须解决几个问题:

  1. filename 这个字符串的地址,放在哪个寄存器里传给函数?
  2. O_RDWR 这个整数,放哪里?
  3. 函数返回值,我该去哪个寄存器里拿?

这些规则,不是 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 试图打开的那个文件的完整路径。