前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Linux内核调试利器|kprobe 原理与实现

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

作者头像
用户7686797
发布2022-08-24 18:33:37
3.1K0
发布2022-08-24 18:33:37
举报
文章被收录于专栏:Linux内核那些事

在《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() 函数实现:

代码语言:javascript
复制
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 结构来描述的。我们再来重温一下这个结构:

代码语言:javascript
复制
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 通知链中。代码如下:

代码语言:javascript
复制
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() 函数的实现:

代码语言:javascript
复制
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() 函数的实现:

代码语言:javascript
复制
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() 函数完成,其调用链如下:

代码语言:javascript
复制
arm_kprobe()
└→ __arm_kprobe()
   └→ arch_arm_kprobe()

从上面的调用可以看到,arm_kprobe() 最终会调用 arch_arm_kprobe() 函数来完成替换工作,我们来看看 arch_arm_kprobe() 函数的实现:

代码语言:javascript
复制
#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 处理的调用链如下:

代码语言:javascript
复制
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() 函数的实现:

代码语言:javascript
复制
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() 函数的实现:

代码语言:javascript
复制
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() 函数完成,我们来分析其实现:

代码语言:javascript
复制
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() 回调函数。我们来看看其调用链:

代码语言:javascript
复制
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 异常的处理过程,代码如下:

代码语言:javascript
复制
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() 函数的实现:

代码语言:javascript
复制
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 的所有实现细节有兴趣,可以自行阅读源码。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Linux内核那些事 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • kprobe 原理
  • kprobe 实现
    • 1. kprobe 初始化
      • kprobe模块哈希表
      • 注册 die 通知链
    • 2. 注册 kprobe 实例
      • 获取跟踪指令的内存地址
      • 检测跟踪点地址是否合法
      • 保存被跟踪指令的值
      • 将当 kprobe 结构添加到哈希表中
      • 将跟踪点替换成 int3 指令
    • 3. kprobe 回调
      • 4. 单步调试
      • 总结
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档