4. kprobe回调函数编写

kprobe的核心价值在于其回调函数——pre_handler和post_handler。这两个函数是开发者与内核执行流交互的接口,通过它们可以捕获函数入口和出口处的关键状态。本章将深入剖析这两个回调函数的参数结构,重点讲解如何通过struct kprobe和struct pt_regs读取函数参数与返回值。

4.1 回调函数原型与注册机制

每个kprobe实例在注册时,需要填充struct kprobe结构体中的回调函数指针。内核在探测点命中时,会根据当前执行位置自动调用对应的处理函数。

回调类型 函数原型 触发时机 典型用途
pre_handler int (*pre_handler)(struct kprobe *p, struct pt_regs *regs) 在被探测指令执行前 读取函数参数、修改寄存器、决定是否跳过原指令
post_handler void (*post_handler)(struct kprobe *p, struct pt_regs *regs, unsigned long flags) 在被探测指令执行后 读取函数返回值、捕获副作用、记录性能数据
fault_handler int (*fault_handler)(struct kprobe *p, struct pt_regs *regs, int trapnr) 回调执行期间发生异常时 处理页错误等异常,防止系统崩溃
关键区别: pre_handler返回非零值可以阻止原始指令执行,而post_handler无返回值且仅在pre_handler允许执行原指令后才会被调用。

4.2 struct kprobe:探测点的元数据容器

struct kprobe是kprobe机制的核心数据结构,它既承载了探测点的配置信息,也包含了运行时状态。在回调函数中,通过该结构体可以获取探测点的地址、符号名称、以及用户自定义数据。

struct kprobe {
    struct hlist_node hlist;      /* 哈希链表节点,用于内核内部管理 */
    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; /* 异常处理回调 */
    unsigned long symbol_addr;    /* 符号地址(如果通过符号注册) */
    const char *symbol_name;      /* 符号名称 */
    unsigned int offset;          /* 符号内的偏移量 */
    unsigned long flags;          /* 标志位,如KPROBE_FLAG_DISABLED */
    void *user_data;              /* 用户自定义数据指针 */
};
实战技巧: 利用p->user_data字段可以在多个回调之间传递上下文信息。例如,在pre_handler中记录时间戳,在post_handler中计算执行耗时。

4.3 struct pt_regs:寄存器窗口的钥匙

struct pt_regs是内核在中断或异常发生时保存的寄存器快照。在kprobe回调中,它反映了被探测指令执行前(pre_handler)或执行后(post_handler)的CPU寄存器状态。通过解析该结构体,可以读取函数参数、返回值以及栈指针等信息。

不同架构下struct pt_regs的成员不同,但核心访问模式一致。以x86_64架构为例:

寄存器成员 ABI用途 在kprobe回调中的含义
regs->di 第一个整数参数(rdi) 函数第1个参数
regs->si 第二个整数参数(rsi) 函数第2个参数
regs->dx 第三个整数参数(rdx) 函数第3个参数
regs->cx 第四个整数参数(rcx) 函数第4个参数
regs->r8 第五个整数参数(r8) 函数第5个参数
regs->r9 第六个整数参数(r9) 函数第6个参数
regs->ax 函数返回值(rax) 在post_handler中读取返回值
regs->sp 栈指针(rsp) 访问栈上额外参数或局部变量
regs->ip 指令指针(rip) 当前执行位置,可用于计算偏移
架构差异注意: ARM64使用regs->regs[0]到regs->regs[7]传递参数,返回值在regs->regs[0]。编写跨架构代码时,应使用#ifdef条件编译或封装宏。

4.4 读取函数参数:pre_handler实战

在pre_handler中,寄存器保存的是调用者传入的参数值。以下示例演示如何读取sys_open系统调用的参数:

static int sys_open_pre_handler(struct kprobe *p, struct pt_regs *regs)
{
    const char *filename;
    int flags;
    umode_t mode;

    /* x86_64: 参数1(dfd)在rdi, 参数2(filename)在rsi, 参数3(flags)在rdx, 参数4(mode)在rcx */
    /* 注意:sys_open的签名是 long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode) */

    /* 读取第二个参数:文件名指针(用户空间地址) */
    filename = (const char *)regs->si;

    /* 读取第三个参数:flags */
    flags = (int)regs->dx;

    /* 读取第四个参数:mode */
    mode = (umode_t)regs->cx;

    /* 安全地拷贝用户空间字符串(需要__copy_from_user或strncpy_from_user) */
    char buf[256];
    long ret = strncpy_from_user(buf, filename, sizeof(buf) - 1);
    if (ret > 0) {
        buf[ret] = '\0';
        printk(KERN_INFO "kprobe: do_sys_open called, filename=%s, flags=0x%x, mode=0x%x\n",
               buf, flags, mode);
    }

    return 0;  /* 允许原指令继续执行 */
}
安全准则: 从pt_regs读取的指针可能是用户空间地址,必须使用strncpy_from_user、copy_from_user等函数安全访问,直接解引用会导致内核崩溃。

4.5 读取返回值:post_handler实战

在post_handler中,regs->ax(x86_64)保存了函数的返回值。以下示例演示如何捕获kmalloc的返回值:

static void kmalloc_post_handler(struct kprobe *p, struct pt_regs *regs,
                                  unsigned long flags)
{
    unsigned long retval;

    /* 读取返回值(rax寄存器) */
    retval = regs->ax;

    /* 读取第一个参数(size_t size) */
    size_t size = (size_t)regs->di;

    printk(KERN_INFO "kprobe: kmalloc(size=%zu) returned 0x%lx\n", size, retval);

    if (retval == 0) {
        printk(KERN_WARNING "kprobe: kmalloc allocation FAILED for size=%zu\n", size);
    }
}
返回值类型注意: 对于返回指针的函数,regs->ax直接包含指针值;对于返回long或int的函数,低32位有效。对于返回void的函数,regs->ax的值无意义。

4.6 参数与返回值读取流程总览

下图展示了从kprobe注册到回调中读取参数/返回值的完整数据流:

kprobe回调参数与返回值读取流程 kprobe回调参数与返回值读取流程 注册kprobe(填充struct kprobe) CPU命中探测点(触发int3) pre_handler 被调用 post_handler 被调用 pre_handler 内部操作 • 通过 regs->di/si/dx/cx 读取参数 • 可修改寄存器改变执行行为 post_handler 内部操作 • 通过 regs->ax 读取返回值 • 可读取原始参数进行对比 struct pt_regs 寄存器快照 pre_handler:反映调用者传入的参数状态(指令执行前) post_handler:反映函数执行完毕后的状态(返回值已写入rax)

4.7 完整示例:跟踪do_sys_open并记录参数与返回值

以下是一个完整的kprobe模块,同时注册pre_handler和post_handler,完整捕获do_sys_open的输入参数和返回的文件描述符:

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

static struct kprobe kp;

/* 保存每个探测点的调用上下文 */
struct open_context {
    int dfd;
    char filename[256];
    int flags;
    umode_t mode;
    ktime_t start_time;
};

static int open_pre_handler(struct kprobe *p, struct pt_regs *regs)
{
    struct open_context *ctx;
    const char __user *filename;
    long ret;

    /* 分配上下文(实际项目中建议使用percpu或预分配) */
    ctx = kzalloc(sizeof(*ctx), GFP_ATOMIC);
    if (!ctx)
        return 0;

    /* 读取参数 */
    ctx->dfd = (int)regs->di;
    filename = (const char __user *)regs->si;
    ctx->flags = (int)regs->dx;
    ctx->mode = (umode_t)regs->cx;

    /* 安全拷贝文件名 */
    ret = strncpy_from_user(ctx->filename, filename, sizeof(ctx->filename) - 1);
    if (ret > 0)
        ctx->filename[ret] = '\0';
    else
        ctx->filename[0] = '\0';

    /* 记录开始时间 */
    ctx->start_time = ktime_get();

    /* 将上下文存入kprobe的user_data */
    p->user_data = ctx;

    printk(KERN_INFO "open_pre: dfd=%d, filename=%s, flags=0x%x, mode=0x%x\n",
           ctx->dfd, ctx->filename, ctx->flags, ctx->mode);

    return 0;
}

static void open_post_handler(struct kprobe *p, struct pt_regs *regs,
                               unsigned long flags)
{
    struct open_context *ctx = p->user_data;
    long fd = regs->ax;  /* 返回值 */
    ktime_t end_time;
    s64 delta_ns;

    if (!ctx)
        return;

    end_time = ktime_get();
    delta_ns = ktime_to_ns(ktime_sub(end_time, ctx->start_time));

    printk(KERN_INFO "open_post: filename=%s, returned fd=%ld, duration=%lld ns\n",
           ctx->filename, fd, delta_ns);

    /* 清理上下文 */
    kfree(ctx);
    p->user_data = NULL;
}

static int __init kprobe_demo_init(void)
{
    int ret;

    kp.symbol_name = "do_sys_open";
    kp.pre_handler = open_pre_handler;
    kp.post_handler = open_post_handler;

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

    printk(KERN_INFO "kprobe registered at %p (symbol: %s)\n",
           kp.addr, kp.symbol_name);
    return 0;
}

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

module_init(kprobe_demo_init);
module_exit(kprobe_demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Debug Expert");

4.8 常见陷阱与最佳实践

  • 原子上下文限制: kprobe回调在原子上下文中执行,不能调用可能睡眠的函数(如kmalloc(GFP_KERNEL)、mutex_lock)。应使用GFP_ATOMIC分配内存。
  • 用户空间指针安全: 从pt_regs读取的指针可能是用户空间地址,必须使用copy_from_user系列函数,不能直接解引用。
  • 寄存器值有效性: 在pre_handler中,regs->ax可能包含调用者的临时值,不代表返回值。只有在post_handler中regs->ax才包含函数返回值。
  • 栈上参数访问: 当参数超过6个(x86_64)时,多余参数通过栈传递。需要通过regs->sp加上偏移量访问栈上的参数。
  • 性能开销: 每个kprobe命中都会触发两次陷阱(int3和单步),回调中的代码应尽量精简,避免长时间操作。
调试建议: 在开发阶段,可以在回调中使用printk输出关键信息。生产环境中建议使用trace_printk或通过perf ring buffer传递数据,减少对系统性能的影响。

4.9 小结

本章深入剖析了kprobe回调函数的参数结构:struct kprobe提供了探测点的元数据和用户上下文,struct pt_regs提供了寄存器级别的执行状态快照。通过pre_handler可以读取函数参数,通过post_handler可以捕获返回值。掌握这些基础操作后,开发者可以构建强大的动态跟踪工具,在不修改内核代码的情况下深入洞察内核函数的执行细节。