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
指令。如下图所示:
被追踪的指令替换成 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 的所有实现细节有兴趣,可以自行阅读源码。