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 后,内核会执行以下关键步骤:
- 符号解析: 如果指定了
symbol_name,内核通过kprobe_lookup_name在 kallsyms 中查找对应地址。 - 地址校验: 检查目标地址是否在可探测区域(非 __init 段、非 ftrace 保留区域)。
- 指令备份: 将目标地址处的原始指令复制到
kp->ainsn.insn中,用于后续单步执行和恢复。 - 断点插入: 将目标指令的第一个字节替换为
int3(x86 架构)或等效断点指令。 - 链表挂载: 将当前 kprobe 实例插入全局 kprobe 哈希表,供后续命中查找。
注意: 注册操作会修改内核代码段,因此需要持有
text_mutex 锁。在 SMP 系统中,内核会确保所有 CPU 核的指令缓存(icache)同步,避免执行旧指令。
4.4 unregister_kprobe 函数原型与参数
注销操作是注册的逆过程,原型同样简洁:
void unregister_kprobe(struct kprobe *kp);
| 参数 | 类型 | 说明 |
|---|---|---|
| kp | struct kprobe * |
指向之前成功注册的 kprobe 结构体指针 |
无返回值设计:
unregister_kprobe 为 void 类型,不会返回错误。如果传入未注册或已注销的 kprobe,内核会通过 WARN_ON 打印告警,但不会导致系统崩溃。建议在模块退出路径中无条件调用,无需检查返回值。
4.5 注销流程与内核内部行为
注销操作执行以下关键步骤:
- 断点移除: 将目标地址的
int3指令恢复为原始指令。 - 同步等待: 通过
synchronize_rcu或synchronize_sched确保所有正在执行的 kprobe 处理函数已退出。 - 链表移除: 从全局 kprobe 哈希表中删除当前实例。
- 资源释放: 释放内部分配的指令缓冲区(ainsn)等资源。
竞态风险: 注销操作必须在确保没有其他内核路径正在使用该 kprobe 的情况下进行。典型做法是在模块退出函数
module_exit 中调用,此时模块引用计数已降为 0,不会再有新的探测触发。
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 仍持有引用,后续命中时引发 双重断点异常 或 空指针解引用。