跳到主要内容

第 4 章 透过针眼看大象——内核探针与动态追踪

我们面对着一团高速运转的乱麻。

当你试图理解一个复杂的系统——比如 Linux 内核——时,最直观的工具是「打印」。你在代码里塞满 printf,重新编译,运行,观察输出。这在用户空间开发中是家常便饭。但在内核里,这不仅是麻烦,简直是灾难。

为什么?因为内核没有它自己的运行环境。一旦内核代码停下来打印日志,整个系统——包括那个负责接收日志的打印系统——可能都暂停了。更糟糕的是,你想要观察的 bug 可能发生在并发竞争的微妙瞬间,或者发生在中断处理的深水区。如果你为了插入一行日志而重新编译并重启了内核,那个 bug 可能就像受惊的鱼一样游走了。

我们需要一种能力:在不打断系统运行、不重新编译内核的前提下,在任意内核函数的入口或出口插入「钩子」。当系统流经那个点时,钩子触发,记录下我们想知道的寄存器状态、参数值、返回值,然后像什么都没发生过一样让系统继续运行。

这就是本章的核心任务:构建这种「上帝视角」的观测能力。

但说实话,这条路并不平坦。老派的内核调试要求你编写 C 语言内核模块,手动注册名为 kprobe 的探针接口——这就是所谓的「静态探针」。它管用,但写起来极其繁琐,改个打印格式都要重新编译加载。而现代 Linux 内核提供了一种更优雅的方式:利用 ftracetracepoints 框架,直接通过用户空间的命令行工具或者脚本,动态地在内核里安插探针。这被称为「动态探针」,甚至 eBPF 这样革命性的技术也是建立在这些底层机制之上的。

在这一章,我们将从最原始的「静态探针」开始,亲手写代码、填结构体、处理寄存器;然后再逐步解放双手,体验动态追踪和 eBPF 带来的便利。只有先痛苦过,你才会真正理解现代工具为什么设计成那样。

现在,让我们先从那个最基础、最通用的机制开始:kprobes


4.1 Kprobes 的基本原理:在函数上动刀

想象你是一个拥有特权的系统内部观察者。你想要监控内核空间里发生的每一次「文件打开」操作,不论它是哪个进程发起的,也不论它调用的是 open() 还是 openat()

如果是传统的调试方法,你会去找到 do_sys_open 这个内核函数的源码,在第一行加个 printk,然后重新编译内核。但这太笨重了。Kprobes 提供了一种更轻量级的介入方式:它允许你动态地在内核函数的特定地址上「打入」一个断点。

你可以把它理解为内核调试界的瑞士军刀

但这把刀有一个特殊的刀刃——它不仅能切入函数,还能在函数返回时截获数据。为了用好它,我们首先得搞清楚它到底长什么样。

探针的三种状态

Kprobes 不仅仅是一个简单的「打断点」机制。为了让调试更灵活,它在执行流中为你提供了三个切入点。

假设我们要探测大名鼎鼎的内核函数 do_sys_open()(这是用户空间调用 open(2) 系统调用时最终落入的内核函数,详情请参见「系统调用在内核中的着陆点」一节)。通过 kprobes 基础设施,你可以挂接三种不同类型的处理程序:

  1. Pre-handler(前置处理程序): 在 do_sys_open() 函数的第一条指令执行之前触发。 这是最常用的钩子,通常用来打印函数参数(通过寄存器)、检查调用栈,或者在这一步就决定是否跳过该函数的执行。

  2. Post-handler(后置处理程序): 在 do_sys_open() 函数的所有指令执行完毕,准备返回之后立即触发。 它适合用来检查函数执行后的副作用,或者验证状态是否被修改。

  3. Fault-handler(故障处理程序): 这是一个保底机制。如果在执行 pre-handler 或 post-handler 的过程中,CPU 发生了异常(比如缺页错误,Page Fault),或者 kprobes 自身在单步执行指令时出了问题,这个处理程序就会被调用。 很多时候,仅仅是因为你的 handler 代码访问了一个非法的内存地址。没有 fault-handler 的话,内核可能会直接 panic;有了它,你至少有机会优雅地报错退出。

这三个处理程序都是可选的。你可以只设 pre-handler,也可以三个全设,全看你的需求。

Kprobe 还是 Kretprobe?

除了上述的「常规探针」,Linux 内核还提供了一种特殊的探针,专门用来解决一个痛点:我想知道这个函数的返回值是多少?

这就是 Kretprobe(Return Probe)

为什么需要它?因为当函数执行完毕时,CPU 的指令指针已经回到了调用者那里。此时你再去检查常规的 post-handler,虽然能触发,但想拿到函数的返回值往往得去深挖堆栈或者寄存器,非常麻烦(而且与 CPU 架构强相关)。

Kretprobe 做了一件很聪明的事:它在函数入口处通过修改堆栈帧等方式,偷偷记录了返回地址,并在函数真正返回前拦截了控制流。这样,它就能轻而易举地把返回值塞给你。

这两种探针的注册 API 也是分开的:

  • Regular Kprobe:使用 register_kprobe[s]() / unregister_kprobe[s]()
  • Kretprobe:使用 register_kretprobe[s]() / unregister_kretprobe[s]()

我们先从最基础的 kprobe 开始,把 kretprobe 留到后面稍微进阶的部分再讲。

手术刀的说明书:struct kprobe

要在代码里真的用起来,你需要准备一个核心的数据结构:struct kprobe。把它想象成一张「手术清单」,内核拿着这张单子才知道要把刀子插在哪里,以及插进去之后干什么。

注册这个结构体的 API 长这样:

#include <linux/kprobes.h>
int register_kprobe(struct kprobe *p);

为了不让你迷失在结构的海洋里,我们只关注这几个最关键的字段(其他的交给默认值就行):

  • const char *symbol_name: 这就是你要「开刀」的那个内核函数的名字,比如 "do_sys_open"。 底层机制上,kprobes 框架会调用 kallsyms_lookup() 这类 API,把这个符号字符串解析成内核虚拟地址(KVA),并填入结构体内部的 addr 成员中。 注意:不是所有的函数都能被探测。有一些函数被列入了黑名单(比如 kprobes 自己的内部函数),探测它们会导致内核崩溃或死锁。我们会在后面的「Kprobes 的限制」一节里细说。

  • kprobe_pre_handler_t pre_handler: 这是一个函数指针,指向你的 pre-handler 代码。它会在目标指令执行前被调用。

  • kprobe_post_handler_t post_handler: 同理,这是 post-handler 的函数指针。

  • kprobe_fault_handler_t fault_handler: 如果你的 pre 或 post handler 引发了异常,内核就会跳到这个函数里来。 关键点:这个函数的返回值很有讲究。返回 0 意味着「我没法处理,交给内核的默认异常处理机制吧」(这是通常情况);返回 1 意味着「搞定了,错误已经修复,继续执行」(这很少见,需要你真的知道自己在做什么,比如手动修复了页表)。

这里有个进阶玩法

kprobe 不仅能挂在函数开头,还能挂在函数内部的任意偏移处。你只需要设置 struct kprobeoffset 成员即可。 这在调试复杂的汇编代码块或者只关心函数后半段逻辑时非常有用。但在 CISC 架构(比如 x86)上,如果你偏移量设置不当,直接跳到了一条指令的中间,CPU 会直接报错。这就像在心脏跳动的一瞬间给它扎了一针——后果自负。

用完了别忘了一件事:清理

当你的模块卸载时,必须调用反注册函数把探针拿掉:

void unregister_kprobe(struct kprobe *p);

如果你忘了这一步,后果很严重。下次任何代码流经那个地址时,内核会试图触发一个已经不存在的探针回调。结果?内核 Bug,甚至直接死机。这是一个典型的资源泄漏场景,只不过泄漏的不是内存,而是「控制流劫持点」。

它是怎么做到的?(黑盒之外的一瞥)

你可能会好奇,把断点打进正在运行的内核里,到底是怎么实现的?是停掉所有 CPU 吗?还是某种黑魔法?

实话告诉你,这背后的实现细节(比如如何临时替换指令、如何处理单步执行、如何保证在多核环境下的正确性)极其复杂,涉及到架构相关的汇编技巧和内核底层的异常处理机制。

如果你真的对那些血淋淋的细节感兴趣,强烈建议去读内核官方文档。它解释了 kprobes 如何利用 CPU 的调试寄存器(如 x86 的 DR0-DR7)和断点指令(如 INT3)来实现这一切。

在这本书里,我们暂时把这些黑盒子关上,专注于如何使用这把强大的刀。

为什么要这么麻烦?(从静态到动态)

这一节我们要介绍的这种方法——编写内核模块,填充 struct kprobe,注册 handler ——被称为 Static Kprobes(静态探针)

之所以叫「静态」,是因为每次你想探测一个新的函数,或者想改变一下日志的输出格式,你就得修改 C 代码,重新编译模块,卸载旧模块,加载新模块。

这听起来有点过时,对吧?在这种时代,还要为了看一眼日志而重新编译?现代 Linux 内核确实有更高级的玩法。

这就是 Dynamic Probing(动态探针),或者叫 Kprobe-based Event Tracing。它深度结合了 ftracetracepoints 框架。你可以不需要写一行 C 代码,只需要通过 debugfs 写入一行字符串配置,或者使用 perf 工具、eBPF 脚本,就能在内核里动态地插桩。没有编译,没有重启,极其丝滑。

但在我们飞向那个现代化的世界之前,先让我们把「地基」打牢。接下来的几个演示(Demo),我们将从最原始的手写静态 kprobe 开始,一步步逼近那个文件打开的真相:

  • Demo 1:最简单的「硬编码」探针——写死探测 open 系统调用。
  • Demo 2:稍微聪明点——通过模块参数指定要探测的函数名,不用改代码重新编译了。
  • Demo 3:真正的干货——不仅仅是「看见」函数被调用,还要抓出文件名

准备好了吗?我们要开始写代码了。