在《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() 函数实现:
staticint
 __init 
init_kprobes(void)
{

int
 i, err = 
0
;

unsignedlong
 offset = 
0
, size = 
0
;

char
 *modname, namebuf[
128
];

constchar
 *symbol_name;

void
 *addr;

structkprobe_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 = (
unsignedlong
)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 结构来描述的。我们再来重温一下这个结构:
structkprobe {
// 用于保存到 kprobe 模块哈希表
structhlist_nodehlist;
    ...

kprobe_opcode_t
 *addr;

constchar
 *symbol_name;

unsignedint
 offset;

// 回调函数
kprobe_pre_handler_t
 pre_handler;

kprobe_post_handler_t
 post_handler;

    ...


kprobe_opcode_t
 opcode;

structarch_specific_insnainsn;
    u32 flags;

};

struct kprobe 结构的 hlist 字段用于把当前结构存放到 kprobe 模块 哈希表中,如下图所示:
(图5)
内核把跟踪的指令地址作为键,然后将 kprobe 结构保存到哈希表中,这样就能通过指令的地址快速查找到对应的 kprobe 结构。

注册 die 通知链

通知链 机制是内核用于做一些事件回调操作的功能,比如说:当关机时,需要把内存中的数据写入到磁盘,就可以通过 通知链 来实现。
kprobe 在初始化阶段,会把 kprobe_exceptions_notify() 回调函数注册到 die 通知链中。代码如下:
staticstructnotifier_blockkprobe_exceptions_nb = {
    .notifier_call = kprobe_exceptions_notify,

    ...

};


staticint
 __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, ((
unsignedchar
 []){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, unsignedlong val, void *data)
{

structdie_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() 函数的实现:
staticint
 __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
);

return1
;

            ...

    }

    ...

return0
;

}

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() 函数完成,我们来分析其实现:
staticvoid
 __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 = (
unsignedlong
)p->addr;

else
        regs->ip = (
unsignedlong
)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, 
unsignedlong
 val, 

void
 *data)

{

structdie_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() 函数的实现:
staticint
 __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
);

    }

    ...

return1
;

}

如果 kprobe 模块实现了 post_handler() 回调函数,那么 post_kprobe_handler() 将会执行它。

总结

本文主要介绍了 kprobe 的原理与实现,正如本文开始时所说,kprobe 机制的细节很多,所以本文不可能对所有细节进行分析。
如果大家对 kprobe 的所有实现细节有兴趣,可以自行阅读源码。
继续阅读
阅读原文