3. kprobe 核心数据结构:struct kprobe、struct kprobe_ctlblk、struct kprobe_insn_slot

kprobe 动态插桩机制的底层实现依赖于三个核心数据结构。它们分别负责:定义探测点属性(struct kprobe)、管理每 CPU 执行上下文(struct kprobe_ctlblk)、以及管理指令槽内存(struct kprobe_insn_slot)。理解这三个结构体是掌握 kprobe 内核实现的关键。

核心要点:struct kprobe 是用户注册的“探针描述符”;struct kprobe_ctlblk 是内核在单步执行时的“安全垫”;struct kprobe_insn_slot 是存放被替换原始指令的“临时避难所”。

3.1 struct kprobe:探针描述符

struct kprobe 是 kprobe 机制中最上层的结构体,用户通过定义和填充该结构体来注册一个动态探测点。它包含了探测点的地址、触发时的回调函数指针、以及用于链表管理的节点。

// 位于 include/linux/kprobes.h
struct kprobe {
    struct hlist_node hlist;          /* 用于挂入全局 kprobe 哈希表 */
    struct list_head list;            /* 用于同一地址上多个 kprobe 的链表 */
    unsigned long addr;               /* 被探测指令的内核虚拟地址 */
    kprobe_pre_handler_t pre_handler; /* 执行被探测指令前调用的回调 */
    kprobe_post_handler_t post_handler; /* 执行被探测指令后调用的回调 */
    kprobe_fault_handler_t fault_handler; /* 处理 probe 期间异常的 handler */
    kprobe_break_handler_t break_handler; /* 处理 kprobe 自身断点的 handler */
    kprobe_opcode_t opcode;           /* 被替换的原始指令的第一个字节 */
    unsigned long flags;              /* 标志位,如 KPROBE_FLAG_GONE */
    ...
};
成员 类型 作用说明
addr unsigned long 被探测指令的虚拟地址,kprobe 的核心定位依据
pre_handler 函数指针 在目标指令执行前触发,常用于记录调用栈、参数捕获
post_handler 函数指针 在目标指令单步执行完成后触发,常用于捕获返回值
fault_handler 函数指针 当 pre/post handler 或单步执行发生缺页等异常时调用
opcode kprobe_opcode_t 保存被替换的原始指令首字节,用于恢复现场
调试提示:在故障函数追踪场景中,pre_handler 通常用于记录函数入参,post_handler 用于捕获返回值。如果 handler 内部发生异常,fault_handler 必须返回 1 以通知内核跳过异常,否则系统会触发 BUG。
struct kprobe 与回调函数关系 struct kprobe pre_handler() post_handler() fault_handler() 执行流程:pre_handler → 单步执行原始指令 → post_handler(异常时触发 fault_handler)

3.2 struct kprobe_ctlblk:每 CPU 执行控制块

struct kprobe_ctlblk 是 kprobe 实现递归安全和单步执行状态管理的关键结构体。每个 CPU 都有一个独立的实例,用于保存当前正在处理的 kprobe 上下文、被中断的寄存器状态以及单步执行标志。

// 位于 arch/x86/include/asm/kprobes.h(架构相关)
struct kprobe_ctlblk {
    struct kprobe *cur_kprobe;        /* 当前 CPU 上正在处理的 kprobe */
    struct kprobe *prev_kprobe;       /* 前一个 kprobe(用于嵌套处理) */
    struct pt_regs jprobe_saved_regs; /* 保存的寄存器上下文 */
    kprobe_opcode_t jprobe_saved_ip;  /* 保存的指令指针 */
    unsigned long jprobe_saved_sp;    /* 保存的栈指针 */
    int kprobe_status;                /* 当前 kprobe 状态标志 */
};
成员 作用
cur_kprobe 指向当前 CPU 正在执行的 kprobe,防止递归重入
prev_kprobe 当发生 kprobe 嵌套时,保存上一个 kprobe 的指针
kprobe_status 标记当前状态:KPROBE_HIT_SS、KPROBE_REENTER 等
jprobe_saved_regs 保存被探测点的完整寄存器现场,用于单步执行后恢复
递归与重入:如果在 pre_handler 内部再次触发断点(例如 handler 访问了另一个被探测的函数),kprobe 通过 kprobe_ctlblk 的 prev_kprobe 和 kprobe_status 实现有限的重入支持。但嵌套深度通常限制为 1,超过则直接 panic。
kprobe_ctlblk 每 CPU 上下文切换流程 CPU 0 cur_kprobe = &kp_A prev_kprobe = NULL status = HIT_SS CPU 1 cur_kprobe = &kp_B prev_kprobe = NULL status = HIT_SS CPU 2 cur_kprobe = &kp_C prev_kprobe = &kp_D status = REENTER 每个 CPU 独立维护 kprobe_ctlblk,互不干扰。 当 kprobe 在 handler 中再次触发断点时,status 标记为 REENTER,prev_kprobe 保存外层 kprobe。

3.3 struct kprobe_insn_slot:指令槽管理

当 kprobe 在目标地址写入断点指令(如 x86 的 int3)后,原始指令必须被保存到一块独立的内存区域,以便在单步执行时能够安全地执行。这个内存区域就是指令槽(instruction slot)。struct kprobe_insn_slot 负责管理这些槽位的分配与回收。

// 位于 kernel/kprobes.c
struct kprobe_insn_slot {
    struct hlist_node hlist;          /* 用于挂入 insn_slot 哈希表 */
    unsigned long addr;               /* 指令槽的虚拟地址 */
    int slot;                         /* 槽位索引 */
    unsigned long flags;              /* 标志位,如是否被使用 */
};
成员 说明
addr 指令槽的起始地址,kprobe 将原始指令复制到此地址
slot 槽位编号,用于快速索引和回收
flags 标记槽位是否已被分配,避免重复使用
性能考量:指令槽的分配采用预分配池机制。内核在初始化时预先分配一批指令槽页面(通常为 1~2 个页面),每个页面被划分为多个固定大小的槽位。当 kprobe 注册时,从池中取出一个空闲槽位;注销时归还。这种设计避免了频繁的页分配开销。
指令槽分配与原始指令保存流程 内核代码段 0xffff...100: mov eax, 1 0xffff...103: add eax, ebx 0xffff...106: int3 (替换) 0xffff...107: ret 复制 指令槽池 (insn_slot) Slot 0: 0xffff...800 (已用) Slot 1: 0xffff...900 (空闲) 单步执行 单步执行区 0xffff...800: mov eax, 1 0xffff...803: add eax, ebx 0xffff...806: (原始指令) 指令槽工作流程 1. kprobe 注册时,从指令槽池中分配一个空闲槽位(如 Slot 1)。 2. 将被断点替换的原始指令(如 mov eax, 1; add eax, ebx)复制到槽位地址。 3. 单步执行时,CPU 跳转到指令槽地址执行原始指令,执行完后跳回原代码的下一条指令。

3.4 三者的协作关系

在 kprobe 的一次完整触发过程中,三个结构体按以下顺序协作:

  1. 注册阶段:用户填充 struct kprobe,内核将其插入全局哈希表,并从 kprobe_insn_slot 池中分配槽位保存原始指令。
  2. 触发阶段:CPU 执行到断点指令,进入 kprobe 异常处理函数。通过 this_cpu_ptr(kprobe_ctlblk) 获取当前 CPU 的控制块,设置 cur_kprobe 和 kprobe_status。
  3. 回调执行:调用 cur_kprobe->pre_handler,然后单步执行指令槽中的原始指令,最后调用 post_handler。
  4. 恢复阶段:清理 kprobe_ctlblk 中的上下文,将 cur_kprobe 置空,CPU 继续执行原代码流。
故障定位应用:当你在追踪一个内核函数时,如果发现 pre_handler 没有被调用,首先检查 struct kprobe.addr 是否指向了正确的函数入口地址(注意函数序言长度)。如果系统在注册后立即崩溃,检查 kprobe_insn_slot 是否耗尽(可通过 /sys/kernel/debug/kprobes/ 查看统计信息)。