4. kprobe 注册与注销:register_kprobe / unregister_kprobe API 详解与参数说明

kprobe 动态跟踪的核心操作围绕 注册(register_kprobe) 与 注销(unregister_kprobe) 两个 API 展开。理解这两个函数的参数含义、返回值处理以及生命周期管理,是编写稳定 kprobe 模块的基础。本章从函数原型、参数结构、注册流程、注销时机四个维度进行深度解析。

4.1 register_kprobe 函数原型与参数

register_kprobe 是 kprobe 机制中最重要的注册接口,其原型定义在 <linux/kprobes.h> 中:

int register_kprobe(struct kprobe *kp);
参数 / 返回值 类型 说明
kp struct kprobe * 指向已初始化的 kprobe 结构体指针,包含探测地址、处理函数等核心信息
返回值 int 0 表示成功;负数表示错误码(如 -EINVAL、-ENOMEM、-EEXIST)
关键约束: 传入的 kp 结构体必须在调用前完成零初始化或手动赋值,且 kp->symbol_name 与 kp->addr 至少指定一个。内核 5.x 之后推荐优先使用符号名方式,避免地址硬编码。

4.2 struct kprobe 核心字段详解

struct kprobe 是 kprobe 机制的数据载体,其关键字段决定了探测行为。以下表格列出最常用的字段及其赋值规则:

字段名 类型 赋值方式 说明
symbol_name const char * 直接赋值字符串 被探测函数的符号名,如 "do_sys_open"
offset unsigned long 整数赋值 符号入口后的偏移字节,通常为 0
addr kprobe_opcode_t * 通过 kallsyms_lookup_name 获取 直接指定探测地址,与 symbol_name 二选一
pre_handler kprobe_pre_handler_t 函数指针赋值 指令执行前触发的回调函数
post_handler kprobe_post_handler_t 函数指针赋值 指令执行后触发的回调函数
fault_handler kprobe_fault_handler_t 函数指针赋值 处理回调中发生异常时的恢复逻辑
典型初始化模式:
static struct kprobe kp = {
    .symbol_name = "do_sys_open",
    .pre_handler = handler_pre,
    .post_handler = handler_post,
};

4.3 注册流程与内核内部行为

调用 register_kprobe 后,内核会执行以下关键步骤:

  1. 符号解析: 如果指定了 symbol_name,内核通过 kprobe_lookup_name 在 kallsyms 中查找对应地址。
  2. 地址校验: 检查目标地址是否在可探测区域(非 __init 段、非 ftrace 保留区域)。
  3. 指令备份: 将目标地址处的原始指令复制到 kp->ainsn.insn 中,用于后续单步执行和恢复。
  4. 断点插入: 将目标指令的第一个字节替换为 int3(x86 架构)或等效断点指令。
  5. 链表挂载: 将当前 kprobe 实例插入全局 kprobe 哈希表,供后续命中查找。
注意: 注册操作会修改内核代码段,因此需要持有 text_mutex 锁。在 SMP 系统中,内核会确保所有 CPU 核的指令缓存(icache)同步,避免执行旧指令。
register_kprobe 内部注册流程 register_kprobe 内部注册流程 用户调用 register_kprobe 符号解析 (kprobe_lookup_name) 地址校验与权限检查 指令备份 (ainsn.insn) 断点插入 (int3) + 哈希表挂载 icache 同步完成

4.4 unregister_kprobe 函数原型与参数

注销操作是注册的逆过程,原型同样简洁:

void unregister_kprobe(struct kprobe *kp);
参数 类型 说明
kp struct kprobe * 指向之前成功注册的 kprobe 结构体指针
无返回值设计: unregister_kprobe 为 void 类型,不会返回错误。如果传入未注册或已注销的 kprobe,内核会通过 WARN_ON 打印告警,但不会导致系统崩溃。建议在模块退出路径中无条件调用,无需检查返回值。

4.5 注销流程与内核内部行为

注销操作执行以下关键步骤:

  1. 断点移除: 将目标地址的 int3 指令恢复为原始指令。
  2. 同步等待: 通过 synchronize_rcu 或 synchronize_sched 确保所有正在执行的 kprobe 处理函数已退出。
  3. 链表移除: 从全局 kprobe 哈希表中删除当前实例。
  4. 资源释放: 释放内部分配的指令缓冲区(ainsn)等资源。
竞态风险: 注销操作必须在确保没有其他内核路径正在使用该 kprobe 的情况下进行。典型做法是在模块退出函数 module_exit 中调用,此时模块引用计数已降为 0,不会再有新的探测触发。
unregister_kprobe 内部注销流程 unregister_kprobe 内部注销流程 用户调用 unregister_kprobe 断点移除 (恢复原始指令) 同步等待 (synchronize_rcu) 哈希表移除 + ainsn 资源释放 完成注销

4.6 注册与注销的常见错误码与排查

错误码 宏定义 含义 排查方向
-1 -EPERM 操作不允许 目标地址在受保护区域(如 __init 段)
-2 -ENOENT 符号未找到 检查 symbol_name 拼写,确认内核是否导出该符号
-12 -ENOMEM 内存不足 检查系统内存水位,或 ainsn 预留池是否耗尽
-17 -EEXIST 重复注册 同一地址已存在 kprobe,需先注销或使用 register_kprobe 的叠加模式
调试技巧: 在注册失败时,通过 printk 打印 kp->symbol_name 和 kp->addr,结合 /proc/kallsyms 对比确认地址有效性。使用 kprobes 内核调试参数 kprobe_debug=1 可输出更详细的注册日志。

4.7 典型使用模式:模块加载与卸载

以下代码片段展示了 kprobe 在内核模块中的标准生命周期管理:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>

static struct kprobe kp;

static int handler_pre(struct kprobe *p, struct pt_regs *regs) {
    printk(KERN_INFO "pre_handler: ip=0x%lx\n", regs->ip);
    return 0;
}

static int __init kprobe_init(void) {
    int ret;
    kp.symbol_name = "do_sys_open";
    kp.pre_handler = handler_pre;

    ret = register_kprobe(&kp);
    if (ret < 0) {
        printk(KERN_ERR "register_kprobe failed: %d\n", ret);
        return ret;
    }
    printk(KERN_INFO "kprobe registered at %p\n", kp.addr);
    return 0;
}

static void __exit kprobe_exit(void) {
    unregister_kprobe(&kp);
    printk(KERN_INFO "kprobe unregistered\n");
}

module_init(kprobe_init);
module_exit(kprobe_exit);
MODULE_LICENSE("GPL");
最佳实践: 在 kprobe_init 中注册失败时,不要调用 unregister_kprobe;在 kprobe_exit 中无条件调用 unregister_kprobe 是安全的,即使注册从未成功,内核会通过内部状态判断并跳过实际操作。

4.8 多 kprobe 实例的注册顺序与依赖

当需要同时探测多个函数时,注册顺序会影响探测的优先级。内核使用 聚合 kprobe(aggregate kprobe) 机制管理同一地址上的多个 kprobe:

  • 先注册优先: 第一个注册的 kprobe 成为主 kprobe,负责断点管理。
  • 后注册追加: 后续注册的同地址 kprobe 被加入聚合链表,共享同一个断点。
  • 注销顺序: 后注册的 kprobe 应先注销,最后注销主 kprobe。
聚合 kprobe 结构示意 聚合 kprobe 结构示意 主 kprobe (断点持有者) 聚合 kprobe 链表 kprobe A kprobe B kprobe C
内核崩溃场景关联: 在调试内核崩溃时,如果需要在同一个函数入口注册多个 kprobe 来捕获不同阶段的寄存器状态,务必注意聚合 kprobe 的注册顺序。错误地先注销主 kprobe 会导致断点被移除,但子 kprobe 仍持有引用,后续命中时引发 双重断点异常 或 空指针解引用。