Linux内核调试利器|kprobe 原理与实现

共 17748字,需浏览 36分钟

 ·

2022-06-17 15:35

在《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 指令。如下图所示:


(图2)

被追踪的指令替换成 int3 指令后,当内核执行到这条指令时,将会触发 do_int3() 异常处理例程。

do_int3() 异常处理例程的执行过程如下:

  1. 首先调用 kprobe 模块的 pre_handler() 回调函数。
  2. 然后将 CPU 设置为单步调试模式。
  3. 接着从异常处理例程中返回,并且执行原来的指令。

我们通过下图来展示 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 件事情:

  1. 初始化用于存储 kprobe 模块的哈希表。
  2. 初始化 kprobe 的黑名单函数列表(不能被 kprobe 跟踪的函数列表)。
  3. 初始化CPU架构相关的环境(x86 CPU架构的实现为空)。
  4. 注册die通知链(重要)。
  5. 注册模块通知链。

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 件事情:

  1. 获取要跟踪的内核函数中的指令内存地址(跟踪点)。
  2. 检测跟踪点地址是否合法。
  3. 保存被跟踪指令的值。
  4. 将当前注册的 kprobe 结构添加到 kprobe 模块哈希表中。
  5. 将要跟踪的指令替换成 int3 指令。

下面说说这 5 件事情分别要完成什么功能:

获取跟踪指令的内存地址

一般来说,我们要跟踪一个内核函数的某条指令,都是通过内核函数名去指定的(当然也可以直接指定指令的内存地址,但这个方法比较麻烦)。

所以,内核首先需要通过函数名,来获取其第一条指令对应的内存地址。而内核是通过调用 kprobe_addr() 函数来获取跟踪函数的内存地址。

而 kprobe_addr() 最终会调用 kallsyms_lookup_name() 来获取跟踪函数的内存地址。kallsyms_lookup_name() 函数的实现,本文不再展开细说,有兴趣可以自行阅读代码或者查阅其他文献。

检测跟踪点地址是否合法

这个过程主要对跟踪指令的内存地址进行合法检测,主要检查几个点:

  1. 跟踪点是否已经被 ftrace 跟踪,如果是就返回错误(kprobe 与 ftrace 不能同时跟踪同一个地址)。
  2. 跟踪点是否在内核代码段,因为 kprobe 只能跟踪内核函数,所以跟踪点必须在内核代码段中。
  3. 跟踪点是否在 kprobe 的黑名单中,如果是就返回错误。
  4. 跟踪点是否在内核模块代码段中,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 件事情:

  1. 获取触发异常的指令内存地址(也就是 int3 指令的内存地址)。
  2. 通过内存地址获取 kprobe 结构(在注册阶段将其添加到哈希表中)。
  3. 如果 kprobe 模块定义了 pre_handler() 回调,那么调用 pre_handler() 回调函数。
  4. 设置单步调试模式。

从上面的分析可以知道,在 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() 函数主要完成两件事情:

  1. 将 flags 寄存器的 TF 标志位设置为1,进入单步调试模式(可以参考 Intel 的手册)。
  2. 设置异常处理例程(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 的所有实现细节有兴趣,可以自行阅读源码。

浏览 139
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报