Linux内核调试利器|kprobe 原理与实现
在《Linux 内核调试利器 | kprobe 的使用》一文中,我们介绍过怎么使用 kprobe 来追踪内核函数,而本文将会介绍 kprobe 的原理和实现。
kprobe 原理
kprobe 可以用来跟踪内核函数中某一条指令在运行前和运行后的情况。
我们只需在 kprobe 模块中定义好指令执行前的回调函数 pre_handler() 和执行后的回调函数 post_handler(),那么内核将会在被跟踪的指令执行前调用 pre_handler() 函数,并且在指令执行后调用 post_handler() 函数。如下图所示:

(图1)
那么,内核是怎样做到在被跟踪指令执行前调用 pre_handler() 函数和指令执行后调用 post_handler() 函数的呢?
如果你读过我们之前写的一篇文章《断点的原理》,那么就比较容易理解 kprobe 的原理了,因为 kprobe 使用了类似于断点的机制来实现的。
如果不了解断点的原理,那么请先看看这篇文章《断点的原理》。
当使用 kprobe 来跟踪内核函数的某条指令时,kprobe 首先会把要追踪的指令保存起来,然后把要追踪的指令替换成 int3 指令。如下图所示:

被追踪的指令替换成 int3 指令后,当内核执行到这条指令时,将会触发 do_int3() 异常处理例程。
do_int3() 异常处理例程的执行过程如下:
首先调用 kprobe 模块的 pre_handler()回调函数。然后将 CPU 设置为单步调试模式。 接着从异常处理例程中返回,并且执行原来的指令。 
我们通过下图来展示 do_int3() 函数的执行过程:

(图3)
由于设置了单步调试模式,当执行完原来的指令后,将会触发 debug异常(这是 Intel x86 CPU 的一个特性)。
当 CPU 触发 debug异常 后,内核将会执行 debug 异常处理例程 do_debug(),而 do_debug() 异常处理例程将会调用 kprobe 模块的 post_handler() 回调函数。
下图展示了 kprobe 的执行流程:

(图4)
kprobe 实现
了解了 kprobe 的原理后,现在我们开始分析 kprobe 的代码实现。
由于 kprobe 的细节很多,本文只会对 kprobe 整个大体实现方式进行分析,有些细节需要读者自行阅读源码了解。
1. kprobe 初始化
一个功能的实现,一般都需要先初始化其所使用的资源和环境,kprobe 功能也不例外。
下面我们来看看 kprobe 的初始化过程,kprobe 的初始化由 init_kprobes() 函数实现:
static int __init init_kprobes(void)
{
    int i, err = 0;
    unsigned long offset = 0, size = 0;
    char *modname, namebuf[128];
    const char *symbol_name;
    void *addr;
    struct kprobe_blackpoint *kb;
    // 1) 初始化用于存储 kprobe 模块的哈希表
    for (i = 0; i < KPROBE_TABLE_SIZE; i++) {
        INIT_HLIST_HEAD(&kprobe_table[i]);
        ...
    }
    // 2) 初始化 kprobe 的黑名单函数列表(不能被 kprobe 跟踪的函数列表)
    for (kb = kprobe_blacklist; kb->name != NULL; kb++) {
        kprobe_lookup_name(kb->name, addr);
        if (!addr)
            continue;
        kb->start_addr = (unsigned long)addr;
        symbol_name = kallsyms_lookup(kb->start_addr, &size, &offset, &modname,
                                      namebuf);
        if (!symbol_name)
            kb->range = 0;
        else
            kb->range = size;
    }
    ...
    kprobes_all_disarmed = false;
    // 3) 初始化CPU架构相关的环境(x86架构的实现为空)
    err = arch_init_kprobes();
    // 4) 注册die通知链(这个比较重要)
    if (!err)
        err = register_die_notifier(&kprobe_exceptions_nb);
    // 5) 注册模块通知链
    if (!err)
        err = register_module_notifier(&kprobe_module_nb);
    ...
    return err;
}
上面代码精简了一些与
kprobe功能无关的代码(如kretprobe的功能代码)。
init_kprobes() 函数主要完成 5 件事情:
初始化用于存储 kprobe 模块的哈希表。 初始化 kprobe 的黑名单函数列表(不能被 kprobe 跟踪的函数列表)。 初始化CPU架构相关的环境(x86 CPU架构的实现为空)。 注册die通知链(重要)。 注册模块通知链。 
kprobe模块哈希表
我们在《Linux 内核调试利器 | kprobe 的使用》一文中介绍过,一个 kprobe 模块是由一个 struct kprobe 结构来描述的。我们再来重温一下这个结构:
struct kprobe {
    // 用于保存到 kprobe 模块哈希表
    struct hlist_node hlist;
    ...
    kprobe_opcode_t *addr;
    const char *symbol_name;
    unsigned int offset;
    // 回调函数
    kprobe_pre_handler_t pre_handler;
    kprobe_post_handler_t post_handler;
    ...
    kprobe_opcode_t opcode;
    struct arch_specific_insn ainsn;
    u32 flags;
};
struct kprobe 结构的 hlist 字段用于把当前结构存放到 kprobe 模块 哈希表中,如下图所示:

(图5)
内核把跟踪的指令地址作为键,然后将 kprobe 结构保存到哈希表中,这样就能通过指令的地址快速查找到对应的 kprobe 结构。
注册 die 通知链
通知链 机制是内核用于做一些事件回调操作的功能,比如说:当关机时,需要把内存中的数据写入到磁盘,就可以通过 通知链 来实现。
kprobe 在初始化阶段,会把 kprobe_exceptions_notify() 回调函数注册到 die 通知链中。代码如下:
static struct notifier_block kprobe_exceptions_nb = {
    .notifier_call = kprobe_exceptions_notify,
    ...
};
static int __init init_kprobes(void)
{
    ...
    if (!err)
        err = register_die_notifier(&kprobe_exceptions_nb);
    ...
}
init_kprobes() 通过调用 register_die_notifier() 函数将 kprobe_exceptions_notify() 回调函数注册到 die 通知链中。
当 CPU 触发断点异常时(执行 int3 指令),内核将会执行 do_int3() 异常处理例程,而 do_int3() 例程将会调用 die 通知链中的回调函数。此时,kprobe_exceptions_notify() 回调函数将会被执行。
关于 kprobe_exceptions_notify() 回调函数的执行流程下面将会介绍。
2. 注册 kprobe 实例
在《Linux 内核调试利器 | kprobe 的使用》一文中介绍过,编写好的 kprobe 模块需要通过调用 register_kprobe() 函数来注册到内核。
我们来看看 register_kprobe() 函数的实现:
int __kprobes register_kprobe(struct kprobe *p)
{
    ...
    // 1) 获取要跟踪的指令的内存地址
    addr = kprobe_addr(p);       
    ...
    p->addr = addr;
    ...
    // 2) 检测跟踪点是否合法
    ret = check_kprobe_address_safe(p, &probed_mod); 
    ...
    // 3) 保存被跟踪指令的值
    ret = prepare_kprobe(p);
    ...
    // 4) 将 kprobe 结构添加到 kprobe 模块哈希表中
    hlist_add_head_rcu(&p->hlist,
                       &kprobe_table[hash_ptr(p->addr, KPROBE_HASH_BITS)]);
    // 5) 将要跟踪的指令替换成 int3 指令
    if (!kprobes_all_disarmed && !kprobe_disabled(p))
        arm_kprobe(p);
    ...
    return ret;
}
经过精简后,上面代码只留下了主要流程。
从上面代码可以看出,register_kprobe() 函数主要完成 5 件事情:
获取要跟踪的内核函数中的指令内存地址(跟踪点)。 检测跟踪点地址是否合法。 保存被跟踪指令的值。 将当前注册的 kprobe 结构添加到 kprobe 模块哈希表中。 将要跟踪的指令替换成 int3 指令。 
下面说说这 5 件事情分别要完成什么功能:
获取跟踪指令的内存地址
一般来说,我们要跟踪一个内核函数的某条指令,都是通过内核函数名去指定的(当然也可以直接指定指令的内存地址,但这个方法比较麻烦)。
所以,内核首先需要通过函数名,来获取其第一条指令对应的内存地址。而内核是通过调用 kprobe_addr() 函数来获取跟踪函数的内存地址。
而 kprobe_addr() 最终会调用 kallsyms_lookup_name() 来获取跟踪函数的内存地址。kallsyms_lookup_name() 函数的实现,本文不再展开细说,有兴趣可以自行阅读代码或者查阅其他文献。
检测跟踪点地址是否合法
这个过程主要对跟踪指令的内存地址进行合法检测,主要检查几个点:
跟踪点是否已经被 ftrace 跟踪,如果是就返回错误(kprobe 与 ftrace 不能同时跟踪同一个地址)。 跟踪点是否在内核代码段,因为 kprobe 只能跟踪内核函数,所以跟踪点必须在内核代码段中。 跟踪点是否在 kprobe 的黑名单中,如果是就返回错误。 跟踪点是否在内核模块代码段中,kprobe 也可以跟踪内核模块的函数。 
保存被跟踪指令的值
内核通过调用 prepare_kprobe() 函数来保存被跟踪的指令,而 prepare_kprobe() 最终会调用 CPU 架构相关的 arch_prepare_kprobe() 函数来完成任务。
我们来看看 arch_prepare_kprobe() 函数的实现:
int __kprobes arch_prepare_kprobe(struct kprobe *p)
{
    ...
    // 1) 申请内存空间,用于存放原指令的数据
    p->ainsn.insn = get_insn_slot();
    ...
    // 2) 保存原来指令的值
    return arch_copy_kprobe(p);
}
最终结果如 图2 所示。
将当 kprobe 结构添加到哈希表中
将当前 kprobe 结构添加到 kprobe 模块哈希表中,主要为了能够通过跟踪点的内存地址快速查找到对应的 kprobe 结构,如 图5 所示。
将跟踪点替换成 int3 指令
将跟踪点替换成 int3 指令的目的是,当 CPU 执行到跟踪点时,将会触发产生断点中断,这时内核将会调用 do_int3() 处理异常,如 图2 所示。
将跟踪点替换成 int3 指令是由 arm_kprobe() 函数完成,其调用链如下:
arm_kprobe()
└→ __arm_kprobe()
   └→ arch_arm_kprobe()
从上面的调用可以看到,arm_kprobe() 最终会调用 arch_arm_kprobe() 函数来完成替换工作,我们来看看 arch_arm_kprobe() 函数的实现:
#define BREAKPOINT_INSTRUCTION  0xcc
void __kprobes arch_arm_kprobe(struct kprobe *p)
{
    text_poke(p->addr, ((unsigned char []){BREAKPOINT_INSTRUCTION}), 1);
}
从上面可以看出,arch_arm_kprobe() 函数把跟踪点地址处的数据替换成 0xcc(也就是 int3 指令)。
3. kprobe 回调
前面说过,当 CPU 执行到 int3 指令时,将会触发断点异常。此时,内核将会调用 do_int3() 函数来处理异常。
do_int3() 函数对 kprobe 处理的调用链如下:
do_int3()
└→ notify_die()
   └→ atomic_notifier_call_chain()
      └→ __atomic_notifier_call_chain()
         └→ notifier_call_chain()
            └→ kprobe_exceptions_notify()
从上面的调用链可以看出,do_int3() 最终会调用 kprobe_exceptions_notify() 函数来处理 kprobe 的流程。
我们来看看 kprobe_exceptions_notify() 函数的实现:
int __kprobes 
kprobe_exceptions_notify(struct notifier_block *self, unsigned long val, void *data)
{
    struct die_args *args = data;
    int ret = NOTIFY_DONE;
    // 1) 如果是用户态触发,直接返回,因为用户态不能使用 kprobe
    if (args->regs && user_mode_vm(args->regs))
        return ret;
    switch (val) {
    // 2) 如果异常是由 int3 指令触发的,则调用 kprobe_handler() 处理异常
    case DIE_INT3:
        if (kprobe_handler(args->regs)) 
            ret = NOTIFY_STOP;
        break;
    ...
    default:
        break;
    }
    return ret;
}
从上面代码可以看出,当异常是由 int3 指令触发的,将会调用 kprobe_handler() 函数处理异常。
我们来分析下 kprobe_handler() 函数的实现:
static int __kprobes 
kprobe_handler(struct pt_regs *regs)
{
    ...
    // 1) 获取触发异常的指令内存地址
    addr = (kprobe_opcode_t *)(regs->ip - sizeof(kprobe_opcode_t));
    ...
    // 2) 通过内存地址获取 kprobe 结构(在注册阶段将其添加到哈希表中)
    p = get_kprobe(addr);
    if (p) {
            ...
            // 3) 如果 kprobe 模块定义了 pre_handler() 回调,那么调用 pre_handler() 回调函数
            if (!p->pre_handler || !p->pre_handler(p, regs))
                // 4) 设置单步调试模式
                setup_singlestep(p, regs, kcb, 0);
            return 1;
            ...
    }
    ...
    return 0;
}
kprobe_handler() 函数会处理几种情况,本文我们主要按照最常见的情况分析,就是上面代码的流程。
从上面代码可以看到,kprobe_handler() 函数主要完成 4 件事情:
获取触发异常的指令内存地址(也就是 int3 指令的内存地址)。 通过内存地址获取 kprobe 结构(在注册阶段将其添加到哈希表中)。 如果 kprobe 模块定义了 pre_handler()回调,那么调用pre_handler()回调函数。设置单步调试模式。 
从上面的分析可以知道,在 do_int3() 异常处理例程中调用了 kprobe 模块的 pre_handler() 回调函数,但 post_handler() 回调函数在什么地方调用呢?
我们知道,kprobe 模块的 post_handler() 回调函数是在被跟踪指令执行完后被调用的。所以,在 do_int3() 异常处理例程中调用是不合适的。
为了解决这个问题,Linux 内核使用单步调试模式来处理这种情况。设置单步调试模式由 setup_singlestep() 函数完成,我们来分析其实现:
static void __kprobes 
setup_singlestep(struct kprobe *p, struct pt_regs *regs,
                 struct kprobe_ctlblk *kcb, int reenter)
{
    ...
    // 1) 将 flags 寄存器的 TF 标志位设置为1,进入单步调试模式
    regs->flags |= X86_EFLAGS_TF;
    regs->flags &= ~X86_EFLAGS_IF;
    // 2) 设置异常返回后执行的下一条指令的地址
    if (p->opcode == BREAKPOINT_INSTRUCTION)
        regs->ip = (unsigned long)p->addr;
    else
        regs->ip = (unsigned long)p->ainsn.insn;
}
setup_singlestep() 函数主要完成两件事情:
将 flags 寄存器的 TF 标志位设置为1,进入单步调试模式(可以参考 Intel 的手册)。 设置异常处理例程( do_int3()函数)返回后,执行下一条指令的地址(执行原来的指令)。
设置完单步调试模式后,内核就从 do_int3() 异常处理例程中返回,接着执行原来的指令。
4. 单步调试
由于设置了单步调试模式后,CPU 每执行一条指令,都会触发一次 debug 异常。这时,内核将会调用 do_debug() 异常处理例程来处理 debug 异常。
然而,在 do_debug() 异常处理例程中,会通过调用 kprobe_exceptions_notify() 函数来执行 kprobe 模块的 post_handler() 回调函数。我们来看看其调用链:
do_debug()
└→ notify_die()
   └→ atomic_notifier_call_chain()
      └→ __atomic_notifier_call_chain()
         └→ notifier_call_chain()
            └→ kprobe_exceptions_notify()
               └→ post_kprobe_handler()
                  └→ post_handler()
从上面的调用链可以看出,do_deubg() 也是通过调用 kprobe_exceptions_notify() 函数来处理 kprobe 机制的流程。
下面我们来分析 kprobe_exceptions_notify() 函数对 debug 异常的处理过程,代码如下:
int __kprobes 
kprobe_exceptions_notify(struct notifier_block *self, unsigned long val, 
                         void *data)
{
    struct die_args *args = data;
    int ret = NOTIFY_DONE;
    // 1) 如果是用户态触发的异常,那么直接返回
    if (args->regs && user_mode_vm(args->regs))
        return ret;
    switch (val) {
    ...
    // 2) 如果是 debug 异常触发的,那么就调用 post_kprobe_handler() 进行处理
    case DIE_DEBUG:
        if (post_kprobe_handler(args->regs)) {
            ...
        }
        break;
    ...
    default:
        break;
    }
    return ret;
}
从上面代码可知,如果当前发生的异常是 debug 异常,那么将会调用 post_kprobe_handler() 函数进行处理。
我们来看看 post_kprobe_handler() 函数的实现:
static int __kprobes post_kprobe_handler(struct pt_regs *regs)
{
    ...
    // 如果 kprobe 模块实现了 post_handler() 回调函数,那么就执行 post_handler() 回调函数
    if ((kcb->kprobe_status != KPROBE_REENTER) && cur->post_handler) {
        ...
        cur->post_handler(cur, regs, 0);
    }
    ...
    return 1;
}
如果 kprobe 模块实现了 post_handler() 回调函数,那么 post_kprobe_handler() 将会执行它。
总结
本文主要介绍了 kprobe 的原理与实现,正如本文开始时所说,kprobe 机制的细节很多,所以本文不可能对所有细节进行分析。
如果大家对 kprobe 的所有实现细节有兴趣,可以自行阅读源码。
