2. kprobe技术基础

2.1 kprobe机制原理

kprobe(Kernel Probe)是Linux内核提供的一种动态插桩机制,允许开发者在不重新编译内核的情况下,在几乎任何内核指令地址处插入探测点。其核心原理是在目标指令前插入一条断点指令(x86架构下为int3,即0xCC),当CPU执行到该地址时触发trap,进而执行用户注册的探测处理函数。

关键概念:kprobe的插桩粒度是指令级,可以探测内核函数入口、出口、函数内部任意偏移,甚至内联函数和汇编代码片段。这是其与tracepoint(静态插桩)最大的区别。

kprobe机制在内核中由三个核心数据结构支撑:

  • struct kprobe:描述一个探测点的结构体,包含目标地址、pre_handler、post_handler、fault_handler等回调函数指针。
  • struct kprobe_ctlblk:每个CPU的探测控制块,用于保存被替换指令的上下文和单步执行状态。
  • struct kprobe_insn_cache:指令缓存池,用于存放被替换的原始指令和单步执行所需的副本。
kprobe 核心数据结构关系 struct kprobe • addr: kprobe_opcode_t * • pre_handler: kprobe_pre_handler_t • post_handler: kprobe_post_handler_t • fault_handler: kprobe_fault_handler_t • break_handler: kprobe_break_handler_t • opcode: kprobe_opcode_t • flags: int struct kprobe_ctlblk • kprobe_status: int • kprobe_saved_ss: struct pt_regs • kprobe_saved_flags: unsigned long • kprobe_old_opcodes: kprobe_opcode_t • kprobe_ss_saved: int struct kprobe_insn_cache • insn_slots: struct list_head • insn_size: int • nr_insn_slots: int • nr_used_slots: int 每个 kprobe 实例对应一个探测点,通过 kprobe_ctlblk 管理单步执行状态,通过 kprobe_insn_cache 管理指令副本

2.2 kprobe工作流程

当用户通过register_kprobe()注册一个kprobe后,内核会执行以下完整流程:

  1. 地址解析与验证:检查目标地址是否合法(不在模块代码段外、不在NMI处理函数内等)。
  2. 指令备份:将目标地址处的原始指令复制到kprobe_insn_cache中保存。
  3. 断点插入:将目标地址的第一个字节替换为int3(0xCC)指令。
  4. 断点触发:当CPU执行到该地址时,触发do_int3()异常处理函数。
  5. pre_handler执行:在断点处理函数中调用用户注册的pre_handler回调。
  6. 单步执行:将指令指针回退到断点地址,临时恢复原始指令,设置单步标志(TF),执行一条原始指令。
  7. post_handler执行:单步执行完成后,再次触发调试异常,调用post_handler回调。
  8. 恢复断点:重新将int3写回目标地址,恢复正常的指令流执行。
kprobe 工作流程图 ① register_kprobe() ② 地址验证与指令备份 ③ 插入 int3 断点 ④ CPU 执行到断点 → trap ⑤ 执行 pre_handler ⑥ 单步执行原始指令 ⑦ 执行 post_handler ⑧ 恢复 int3 断点
性能说明:每次kprobe触发会产生两次trap(断点trap + 单步trap),因此开销相对较大。在x86架构上,一次kprobe命中大约需要几百纳秒到几微秒,具体取决于回调函数的复杂度。对于高频调用的函数(如sys_write),建议使用tracepoint或fentry/fexit替代。

2.3 kprobe回调函数类型

kprobe支持三种类型的回调函数,分别在不同的执行阶段被调用:

回调类型 函数签名 调用时机 典型用途
pre_handler int (*)(struct kprobe *, struct pt_regs *) 断点触发后、原始指令执行前 捕获函数参数、修改寄存器值
post_handler void (*)(struct kprobe *, struct pt_regs *, unsigned long flags) 原始指令单步执行完成后 捕获函数返回值、清理资源
fault_handler int (*)(struct kprobe *, struct pt_regs *, int trapnr) pre_handler或单步执行中发生异常时 处理内存访问错误、跳过异常指令
注意事项:pre_handler在关闭内核抢占的情况下执行,因此不能调用可能睡眠的函数(如kmalloc(GFP_KERNEL))。post_handler同样在原子上下文中运行。如果需要分配内存,应使用GFP_ATOMIC标志。

2.4 kprobe与jprobe、uprobe的区别

Linux内核提供了多种动态探测机制,kprobe、jprobe和uprobe各有侧重。jprobe在较新内核中已被标记为废弃(deprecated),推荐使用kprobe或fentry替代。下表对比了三者的核心差异:

特性 kprobe jprobe uprobe
探测目标 内核任意指令地址 内核函数入口(仅限) 用户态进程任意指令地址
插桩粒度 指令级 函数级 指令级
回调方式 pre/post/fault handler 模拟函数调用(栈帧复制) pre/post/fault handler
参数访问 通过pt_regs寄存器 直接作为函数参数传递 通过pt_regs寄存器
适用场景 通用内核探测、函数内部插桩 仅函数入口参数捕获(已废弃) 用户态程序行为分析、性能剖析
性能开销 较高(两次trap) 较高(两次trap + 栈复制) 较高(两次trap + 上下文切换)
内核版本 2.6.9+ 2.6.9+(3.x后废弃) 3.5+
kprobe / jprobe / uprobe 探测范围对比 用户空间 (Userspace) uprobe 探测点 uprobe 探测点 uprobe 探测点 内核空间 (Kernel Space) sys_open() kprobe: 入口+内部 sys_read() jprobe: 仅入口(废弃) vfs_write() kprobe: 任意偏移 内核内部指令序列(可被 kprobe 在任意位置插桩) mov eax, [rsp+0x10] | call syscall_handler | test rax, rax | jne error_path kprobe 覆盖内核空间任意指令地址;jprobe 仅限函数入口(已废弃);uprobe 覆盖用户空间任意指令地址

2.5 kprobe的约束与限制

使用kprobe时需要注意以下技术约束:

  • 不可探测点:不能探测NMI(非屏蔽中断)处理函数、kprobe自身实现代码、int3处理函数等关键路径。
  • 原子上下文:回调函数在原子上下文中执行,不能调用可能睡眠的函数(如mutex_lock、copy_from_user)。
  • 递归探测:kprobe框架会检测递归调用,如果pre_handler中再次触发kprobe,会被自动忽略。
  • 指令对齐:某些架构要求探测地址必须指令对齐(如ARM架构的4字节对齐)。
  • 模块卸载:如果kprobe注册在可加载内核模块中,必须在模块卸载前注销所有kprobe。
eBPF与kprobe的结合:在eBPF程序中,通过kprobe类型的BPF程序可以安全地挂载kprobe探测点。eBPF验证器会确保程序不会违反原子上下文限制,并且通过bpf_probe_read_kernel()等辅助函数安全访问内核内存,大大降低了直接编写kprobe模块的风险。

2.6 kprobe的注册与注销API

内核提供的kprobe操作API如下:

#include <linux/kprobes.h>

// 注册一个kprobe探测点
int register_kprobe(struct kprobe *kp);

// 注销一个kprobe探测点
void unregister_kprobe(struct kprobe *kp);

// 批量注册多个kprobe
int register_kprobes(struct kprobe **kps, int num);

// 批量注销多个kprobe
void unregister_kprobes(struct kprobe **kps, int num);

// 禁用/启用kprobe(不注销,临时关闭)
int disable_kprobe(struct kprobe *kp);
int enable_kprobe(struct kprobe *kp);

一个典型的kprobe使用示例:

static struct kprobe kp = {
    .symbol_name = "do_sys_open",
    .pre_handler = handler_pre,
    .post_handler = handler_post,
};

static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
    // 通过 regs->di, regs->si, regs->dx 获取参数
    printk(KERN_INFO "do_sys_open called, filename: %s\n",
           (char *)regs->si);
    return 0;
}

static void handler_post(struct kprobe *p, struct pt_regs *regs,
                         unsigned long flags)
{
    // 通过 regs->ax 获取返回值
    printk(KERN_INFO "do_sys_open returned: %ld\n", regs->ax);
}

int init_module(void)
{
    int ret = register_kprobe(&kp);
    if (ret < 0) {
        printk(KERN_ERR "register_kprobe failed: %d\n", ret);
        return ret;
    }
    return 0;
}

void cleanup_module(void)
{
    unregister_kprobe(&kp);
}
eBPF视角:在eBPF中,上述代码等价于加载一个kprobe类型的BPF程序,通过SEC("kprobe/do_sys_open")指定探测点。eBPF框架自动处理kprobe的注册、注销和上下文管理,开发者只需关注探测逻辑本身。