4. kprobe 参数配置:struct kprobe 成员详解

kprobe 的核心数据结构是 struct kprobe,它定义了探针的插入位置、回调函数以及行为控制。正确配置这些成员是驱动性能调优的第一步。本章将逐一解析关键成员,并结合实战场景说明配置要点。

4.1 struct kprobe 核心成员一览

下表列出了 struct kprobe 中最常用的成员及其作用:

成员名称 类型 说明 配置要求
symbol_name const char * 被探测的内核函数名称 必须指定,或与 offset 配合使用
offset unsigned long 函数内的偏移字节数 可选,默认为 0(函数入口)
pre_handler kprobe_pre_handler_t 指令执行前调用的回调 至少实现一个处理函数
post_handler kprobe_post_handler_t 指令执行后调用的回调 可选,用于采集返回值
fault_handler kprobe_fault_handler_t 处理回调中发生的异常 强烈建议实现,防止系统崩溃
addr kprobe_opcode_t * 直接指定探测地址 与 symbol_name 二选一
实战建议:在性能调优场景中,优先使用 symbol_name + offset 组合,避免直接操作 addr,以保持内核版本兼容性。

4.2 symbol_name 与 offset:精准定位探测点

symbol_name 指定要探测的内核函数名称,offset 则允许你在函数内部偏移指定字节数后插入探针。两者配合可以实现从函数入口到内部任意指令位置的精确探测。

关键规则:
  • 如果只设置 symbol_name,探针默认插入函数第一条指令(偏移 0)。
  • 设置 offset 时,必须确保偏移位置是一条指令的起始地址,否则注册会失败。
  • 对于内联函数或静态函数,symbol_name 可能无法直接解析,此时需使用 addr 或通过 /proc/kallsyms 确认符号是否存在。

以下是一个典型的配置示例:

static struct kprobe kp = {
    .symbol_name = "tcp_v4_connect",
    .offset = 0x10,          // 在函数入口后第16字节处插入探针
    .pre_handler = my_pre_handler,
    .post_handler = my_post_handler,
    .fault_handler = my_fault_handler,
};
注意:偏移值 0x10 是十六进制,表示 16 字节。不同内核版本、不同编译选项下,同一函数的指令布局可能不同,建议先通过反汇编确认偏移位置的指令类型。

4.3 pre_handler 与 post_handler:采集调用频次与参数

pre_handler 在探测指令执行前被调用,post_handler 在指令执行后被调用。两者是性能数据采集的核心入口。

4.3.1 pre_handler:捕获输入参数

pre_handler 的原型为:

int (*pre_handler)(struct kprobe *p, struct pt_regs *regs);

通过 regs 可以读取函数参数。在 x86_64 架构下,前 6 个参数分别位于 rdi、rsi、rdx、rcx、r8、r9 寄存器中。

性能调优场景:统计 tcp_v4_connect 的调用频次,并记录目标 IP 地址(第二个参数)。
static int my_pre_handler(struct kprobe *p, struct pt_regs *regs)
{
    struct sockaddr *uaddr = (struct sockaddr *)regs->si;
    // 记录调用计数
    atomic64_inc(&call_count);
    // 记录目标 IP(仅示例,实际需做地址校验)
    printk("connect to %pISc\n", uaddr);
    return 0;
}

4.3.2 post_handler:捕获返回值

post_handler 的原型为:

void (*post_handler)(struct kprobe *p, struct pt_regs *regs, unsigned long flags);

返回值保存在 regs->ax(x86_64)中。通过 post_handler 可以统计函数执行成功/失败的比例。

static void my_post_handler(struct kprobe *p, struct pt_regs *regs,
                            unsigned long flags)
{
    long ret = regs->ax;
    if (ret < 0)
        atomic64_inc(&fail_count);
    else
        atomic64_inc(&success_count);
}

4.4 fault_handler:保障系统稳定性

回调函数中可能访问无效内存或触发异常,fault_handler 用于捕获这些异常并决定是否继续执行。其原型为:

int (*fault_handler)(struct kprobe *p, struct pt_regs *regs, int trapnr);
  • 返回 0:表示异常已处理,kprobe 继续执行原始指令。
  • 返回非 0:表示异常未处理,kprobe 将异常传播给原始处理流程。
必须实现 fault_handler 的场景:
  • 在 pre_handler 中访问用户态指针(如 copy_from_user 的替代场景)。
  • 在 pre_handler 中执行复杂计算或内存分配。
  • 探测点位于频繁调用的热路径上,异常可能导致系统不稳定。

一个安全的 fault_handler 实现示例:

static int my_fault_handler(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
    // 记录异常信息
    printk("kprobe fault at IP: %lx, trap: %d\n", regs->ip, trapnr);
    // 返回 0 表示异常已处理,继续执行
    return 0;
}

4.5 成员配置关系与注册流程

下图展示了 struct kprobe 各成员在注册和回调过程中的协作关系:

struct kprobe 成员配置与回调流程 symbol_name + offset addr(直接地址) pre_handler register_kprobe() 注册探针 探测指令被执行 → 触发 kprobe pre_handler 执行 读取参数、记录计数 原始指令执行 单步执行被替换指令 post_handler 执行 读取返回值、统计结果 fault_handler 执行 处理异常,返回 0/非 0 异常 继续执行原始指令流

4.6 成员配置实战对比

下表总结了不同配置组合的适用场景:

配置组合 适用场景 优点 缺点
symbol_name 仅入口 统计函数调用频次、捕获所有参数 配置简单,兼容性好 无法探测函数内部逻辑
symbol_name + offset 探测函数内部特定指令位置 灵活定位,可绕过函数头 需反汇编确认偏移,版本敏感
addr 直接指定 探测非符号地址(如模块内) 可探测任意指令地址 地址随版本变化,维护成本高
仅实现 pre_handler 只关心输入参数和调用频次 开销最小,适合高频探测 无法获取返回值
实现 pre + post 需要完整记录调用输入输出 数据完整,可分析执行结果 额外开销,需注意性能影响
实现 fault_handler 探测点可能触发异常 保障系统稳定性 增加代码复杂度

4.7 性能调优中的配置策略

在性能调优场景下,配置 struct kprobe 应遵循以下原则:

  1. 最小化回调逻辑:pre_handler 和 post_handler 中只做必要的计数和参数记录,避免复杂计算或内存分配。
  2. 优先使用 atomic 操作:统计调用频次时使用 atomic64_inc 而非锁保护的自增。
  3. 按需实现 fault_handler:如果回调中访问用户态指针或执行可能失败的操作,务必实现 fault_handler。
  4. 合理选择 offset:在函数内部偏移探测时,优先选择不会频繁跳过的指令位置,避免漏统计。
实战技巧:在调试阶段,可以在 pre_handler 中打印 regs->ip 确认探测点实际地址,与预期偏移对比验证。