跳到主要内容

4.4 kretprobes 入门

我们在上一节见识了 kprobes 的威力——就像在内核函数里强行插入了一个断点。但这只能让你看到函数「进去」时的样子,如果我想看它「出来」时带回了什么结果呢?

这就是 kretprobe(返回探针) 登场的时候。

你可以把它理解为 kprobe 的孪生兄弟。kprobe 监管「入口」,kretprobe 监管「出口」。


动机:为什么我们需要关心返回值?

在调试场景下,能够动态地抓取一个函数的返回值,往往是破案的关键。

想象一下,你怀疑某个内核路径上的分配函数失败了,但日志里空空如也。如果能在那个函数返回的瞬间拦截它,看一眼它手里到底拿着什么东西(是一个有效的指针,还是一个刺眼的负数 errno),你就省了一整晚的盲目猜测。

Pro Dev Tip

别想当然:只要函数有返回值,就必须检查失败情况。

哪怕是 malloc() 或者内核里的 kmalloc(),总有那么一天它会失败。如果你没捕获那个可能的失败返回值,出问题时你就只能对着空气抓狂。记住,鲁棒的代码不是假设事情会顺利,而是假设事情一定会出岔子。


核心 API:注册与注销

kretprobe 的 API 非常直观,和 kprobe 像是一个模子刻出来的:

#include <linux/kprobes.h>
int register_kretprobe(struct kretprobe *rp);
void unregister_kretprobe(struct kretprobe *rp);

遵循内核的一贯风格,成功返回 0,失败返回负的 errno 值。

Tech Tip:errno 是怎么来的?

你肯定熟悉 errno。在用户空间,它是每个进程未初始化数据段里的一个整数(现代实现为了线程安全,用了 Thread Local Storage,也就是编译器层面的 __thread 关键字)。

当系统调用失败(通常返回 -1)时,glibc 的胶水代码会把内核返回的负 errno 值乘以 -1 变成正数,填进去。

怎么查错? 别背。去头文件里找:

  • /usr/include/asm-generic/errno-base.h (1 到 34,常见错误)
  • /usr/include/asm-generic/errno.h (35 到 133,扩展错误)

比如你在日志里看到某函数返回了 -101,查一下就知道是 ENETUNREACH(网络不可达)。这比猜谜强多了。


数据结构:深入 kretprobe

struct kretprobe 内部其实包含了一个 struct kprobe。这很合理——你要拦截返回,总得先知道去哪拦吧?

结构体的关键成员如下:

  1. 设置目标: 通过内部的 kp 成员来设置。

    • rp->kp.addr:直接指定地址。
    • rp->kp.symbol_name:指定函数名(这是最常用的方式)。
  2. 设置处理函数

    • rp->handler:这就是你的「返回处理函数」。当被探测函数执行完毕,准备返回时,这个函数会被调用。

它的签名长这样:

int kretprobe_handler(struct kretprobe_instance *ri, struct pt_regs *regs);

这里的参数 struct pt_regs *regs 是老熟人了——它保存了 CPU 寄存器的状态。而参数 struct kretprobe_instance *ri 则包含了本次探测实例的上下文:

  • ri->ret_addr:返回地址。
  • ri->task:指向当前进程的 task_struct
  • ri->data:用于存储私有的、per-instance 的数据。

关键一步:拿到返回值

好了,我们的目标是「返回值」。

还记得我们在讨论 ABI 时提到的约定吗?函数的返回值通常放在一个特定的寄存器里。

  • x86 上是 ax
  • ARM (32位) 上是 r0
  • ARM64 上是 regs[0]

虽然你可以手动查表去抠寄存器,但那是自讨苦吃。内核提供了一个完美的硬件无关抽象宏:

regs_return_value(regs);

这个宏会根据当前架构,自动去 pt_regs 里把正确的寄存器值捞出来。

它是怎么实现的? 打开源码看一眼,非常暴力且优雅:

  • ARM (AArch32): return regs->ARM_r0;
  • ARM64 (AArch64): return regs->regs[0];
  • x86: return regs->ax;

你只需要调用它,剩下的交给架构层。


实战:解剖内核示例代码

内核源码里自带了 kretprobe 的示例(samples/kprobes/kretprobe_example.c)。我们拆解一下它的关键部分,这比从头写一个更直观。

1. 模块参数:想查谁?

为了灵活,示例代码允许你传入函数名:

static char func_name[NAME_MAX] = "kernel_clone";
module_param_string(func, func_name, NAME_MAX, S_IRUGO);
MODULE_PARM_DESC(func, "Function to kretprobe; this module will report the function's execution time");

默认探测的是 kernel_clone(也就是创建进程/线程的核心路径),但你加载模块时可以改成任何一个你想监控的函数。

2. 定义 kretprobe 结构体

这是核心配置:

static struct kretprobe my_kretprobe = {
.handler = ret_handler, // 返回时的处理函数
.entry_handler = entry_handler, // 入口时的处理函数
.data_size = sizeof(struct my_data), // 需要多少私有空间
/* Probe up to 20 instances concurrently. */
.maxactive = 20, // 同时监控多少个实例
};

这里有几个值得细嚼慢咽的字段:

.entry_handler (入口处理器) 这有点像 kprobe 的 pre-handler。它在目标函数刚被调用、还没开始执行时触发。

  • 如果它返回 0,表示「继续探测」,那么等函数执行完会调用 .handler
  • 如果它返回非 0,表示「这次不管了」,kretprobe 会直接忽略这个实例。

.maxactive (并发数) 这是一个很容易被踩坑的参数。 它的意思是:你允许有多少个该函数的实例同时被探测?

  • 默认值是 NR_CPUS(CPU 核心数)。
  • 如果你的目标函数执行很慢,或者是递归调用的,可能同时会有很多个实例在跑。
  • 如果 maxactive 设小了,多出来的实例就会被漏掉,记在 nmissed 字段里。如果你发现 nmissed 一直在涨,就把这个值调大。

3. 植入探针

在模块初始化时,把名字填进去,注册:

my_kretprobe.kp.symbol_name = func_name;
ret = register_kretprobe(&my_kretprobe);
if (ret < 0) {
pr_info("register_kretprobe failed, returned %d\n", ret);
return ret;
}

4. 真正的收获:返回值与耗时

这是 .handler 的实现,也就是我们抓取数据的地方:

static int ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs)
{
unsigned long retval = regs_return_value(regs);
struct my_data *data = (struct my_data *)ri->data;

// ... 省略时间计算代码 ...
// delta = ktime_to_ns(ktime_sub(now, data->entry_stamp));

pr_info("%s returned %lu and took %lld ns to execute\n",
func_name, retval, (long long)delta);

return 0;
}

注意看第一行:regs_return_value(regs)。 这就是我们费这么大劲要找的东西——那个被藏在寄存器里的返回值。

5. 扫尾工作

别忘了解注册。顺便看看有没有漏掉的实例:

unregister_kretprobe(&my_kretprobe);
pr_info("kretprobe at %p unregistered\n", my_kretprobe.kp.addr);

/* nmissed > 0 suggests that maxactive was set too low. */
pr_info("Missed probing %d instances of %s\n",
my_kretprobe.nmissed, my_kretprobe.kp.symbol_name);

如果你的 nmissed 不为 0,回去把 .maxactive 调大一点,重来一次。


杂项:多探针与开关

批量注册 如果你有一堆函数要探,一个一个 register 太烦了。内核提供了批量 API:

int register_kretprobes(struct kretprobe **rps, int num);
int unregister_kretprobes(struct kretprobe **rps, int num);

这其实就是个循环调用的封装,但能让代码整洁不少。

临时开关 有时候你只想在高负载时开启探测,低负载时关掉以减少性能损耗。别卸载模块,用这两个 API:

int disable_kretprobe(struct kretprobe *rp);
int enable_kretprobe(struct kretprobe *rp);

这比粗暴的 rmmod 要优雅得多。


内部原理一窥

如果你对 kretprobe 是怎么挂到函数返回路径上的感兴趣——这其实涉及到了修改函数栈上的返回地址,或者利用架构特定的陷阱机制——可以去啃一下内核文档: Documentation/kprobes.txt

简单说,它是在函数入口处做了手脚,保存了原始返回地址,并替换成了一个陷阱地址。当函数返回时,CPU 跳转到这个陷阱,触发你的 handler,然后再跳回真正的返回地址。

这也就是为什么 kretprobe 的开销比 kprobe 稍大的原因——它要动栈。


代价与限制:没有银弹

Frederick Brooks 说过:「没有银弹」。kretprobe 再强,也不是万能的。

1. 哪些函数不能探?

内核开发者为了保护自己(也为了防止你把系统搞崩),禁止探测某些关键函数:

  • 标记了 __kprobesnokprobe_inline 的函数。
  • 显式使用了 NOKPROBE_SYMBOL() 宏的函数。
  • 黑名单里的函数。

黑名单在哪? 在 /sys/kernel/debug/kprobes/blacklist。这里面列出的函数通常跟 kprobe 的实现本身紧密相关,探测它们会导致递归死锁。

顺便提一下:我们上一节写的那个 kp_load.sh 辅助脚本,其实很聪明——它加载前会先查一下这个黑名单,如果发现你要探测的函数在里面,它会拒绝执行。

2. 生产环境的稳定性

在产品环境上用 k[ret]probe 是要冒风险的。

  • 性能开销:每次进出都要 trap,虽然单次很快,但在高频函数上(比如网络收包路径、调度器路径),这个开销会指数级放大。
  • ABI 的脆弱性: 内核内部的 API 是不稳定的。你今天探测 x() 函数,拿它的第 3 个参数和返回值;明天内核一升级,x() 函数可能改名了,或者参数变了,或者返回值意义变了。 这意味着你的内核模块必须跟着内核版本维护,这是一笔长期债务。

所以,把它当作手术刀吧——关键时刻救命用的,别拿它当切菜刀天天使。

好了,既然静态探测有这么多麻烦(还要写代码、编译、加载),有没有一种「我想看啥就看啥,不用写 C 代码」的懒人办法?

当然有。下一节,我们看看真正的动态跟踪魔法。