内核角落 - Kprobes:内核调试器

作者:R. Krishnakumar

Kprobes 是一种用于注册断点和相应处理程序的机制。在内核中启用 Kprobes 支持后,我们可以调试任何内核地址的任何指令。本文解释了如何编译带有 Kprobes 的内核,以及如何使用实时示例注册和注销 Kprobes。它还涵盖了内核调试的概念,以及 Kprobes 框架的内部操作及其功能。

首先,假设我们尝试调试内核中某个地址位置的特定指令。使用 Kprobes 提供的工具,我们可以执行三个函数,即 pre-handler(前置处理程序)、post-handler(后置处理程序)和 fault handler(故障处理程序)。pre-handler 函数在调试内存位置的指令执行之前执行。post-handler 在被调试的指令执行后执行。如果指令导致故障,则执行 fault handler。

为了进一步解释,让我们看一个例子。假设我们要调试位置 x 的指令。令位置 x 的指令为 i。在 i 执行之前要执行的函数(pre-handler)命名为 pre_x。在 i 执行之后要执行的函数(post-handler)命名为 post_x。故障处理程序本身是 fault_x。

在执行 i 之前,Kprobes 运行 pre_x 函数。在 pre_x 函数中,我们可以执行一些必要的调试操作,例如检查各种寄存器的内容和操作寄存器。在 pre_x 完成执行后,执行 i,然后执行 post_x。当指令 i 导致操作系统故障时,故障处理程序就会发挥作用。如果故障是由于执行 i 引起的,则调用故障处理程序 fault_x。

功能特点

使用 Kprobes 时,调试控制台不是必需的。这是一个重要的设计点,因为它使操作的系统依赖性最小化。因此,它允许在中断时间、上下文切换期间、系统禁用中断时等情况下执行调试。

此外,操作不需要强制序列化系统进程。特别是在 SMP 环境中,不需要处理器间序列化。

Kprobes 的另一个重要特性是,数据可以由 probe handler(探针处理程序)提取并保存在缓冲区中。这对于稍后从崩溃转储中检查数据或在一致的时间转储到控制台的数据非常重要。

如何在内核中启用 Kprobes 支持

在作为树外补丁存在很长时间之后,Kprobes 最终被包含在 vanilla Linux 内核中。本文涵盖了内核版本 2.6.9 中包含的核心 Kprobes 功能。Kprobes 支持许多其他功能,并且这些功能可以从 Kprobes 网站(请参阅在线资源)以补丁的形式获得。

www.kernel.org 下载 vanilla 内核。配置内核时,转到 Kernel Hacking 子菜单。启用 Kernel debugging,然后选择 Kprobes 选项。使用此配置编译内核并启动它。

启用 Kprobes 后,我们可以使用各种内核 API 来注册和注销它。用于注册 Kprobe 的函数是 register_kprobe。此函数接受指向名为 struct kprobe 的结构的指针。该结构的定义是

struct kprobe {
        struct hlist_node hlist;
        kprobe_opcode_t *addr;
        kprobe_pre_handler_t pre_handler;
        kprobe_post_handler_t post_handler;
        kprobe_fault_handler_t fault_handler;
        kprobe_break_handler_t break_handler;
        kprobe_opcode_t opcode;
        kprobe_opcode_t insn[MAX_INSN_SIZE];
};

在结构中,我们可以指定以下内容

  1. 必须设置 Kprobe 的地址 (addr)。

  2. 要执行的 pre-handler (pre_handler)。

  3. 要执行的 post-handler (post_handler)。

  4. 要执行的 fault handler (fault_handler)。

要注销 Kprobe,可以使用 unregister_kprobe,它接受与 register_kprobe 相同的参数。

register_kprobe 和 unregister_kprobe 的原型很简单

int register_kprobe(struct kprobe *p);
void unregister_kprobe(struct kprobe *p);

您可以在 include/linux/kprobes.h 中找到这些定义。

实际操作

让我们看一个使用 Kprobes 进行内核调试的实际例子。我们首先插入我们要调试的函数。执行此操作的代码如下,我添加了行号以供参考

 1 /* Filename: first.c */
 2
 3 #include <linux/module.h>
 4 #include <linux/init.h>
 5
 6 int hello_to_debug(void)
 7 {
 8         printk("\nFrom the function - %s\n",
 9                               __FUNCTION__);
10         return 0;
11 }
12
13 static void exit_to_debug(void)
14 {
15         printk("\nModule exiting \n");
16 }
17
18 static int init_to_debug(void)
19 {
20         printk("\nKeeping the function to debug"
21                "\nat the kernel address %p\n",
22                hello_to_debug);
23         return 0;
24 }
25
26 EXPORT_SYMBOL(hello_to_debug);
27 module_init(init_to_debug);
28 module_exit(exit_to_debug);
29
30 MODULE_AUTHOR ("Krishnakumar. R,
31                <rkrishnakumar@gmail.com>");
32 MODULE_DESCRIPTION ("Kprobes test module");
33 MODULE_LICENSE("GPL");

假设我们需要调试第 6 行中给出的函数 hello_to_debug。首先编译上面的代码并将其作为模块插入。第 26 行的 EXPORT_SYMBOL 指令确保内核代码的其余部分可以看到此函数。

现在,在要调试的位置(函数 hello_to_debug)插入 Kprobe

 1 /* Filename: kprobes.c */
 2
 3 #include <linux/module.h>
 4 #include <linux/init.h>
 5 #include <linux/kprobes.h>
 6
 7 static struct kprobe kpr;
 8 extern int hello_to_debug(void);
 9
10 static void __exit exit_probe(void)
11 {
12        printk("\nModule exiting \n");
13        unregister_kprobe(&kpr);
14 }
15
16 static int before_hook(struct kprobe *kpr,
17                        struct pt_regs *p)
18 {
19        printk("\nBefore hook");
20        printk("\nThis is the Kprobe pre \n"
21               "handler for instruction at \n"
22               "%p\n", kpr->addr);
23        printk("The registers are:\n");
24        printk("eax=%lx, ebx=%lx, ecx=%lx, \n"
25               "edx=%lx\n", p->eax,  p->ebx,
26               p->ecx,  p->edx);
27        printk("eflags=%lx, esp=%lx\n",
28                p->eflags,  p->esp);
29        return 0;
30 }
31
32 static int after_hook(struct kprobe *kpr,
33                       struct pt_regs *p,
34                       unsigned long flags)
35 {
36        printk("\nAfter hook");
37        printk("\nThis is the Kprobe post \n"
38               "handler for instruction at"
39               " %p\n", kpr->addr);
40        printk("The registers are:\n");
41        printk("eax=%lx, ebx=%lx, ecx=%lx, \n"
42               "edx=%lx\n", p->eax,  p->ebx,
43               p->ecx,  p->edx);
44        printk("eflags=%lx, esp=%lx\n",
45                p->eflags,  p->esp);
46        return 0;
47 }
48
49 static int __init init_probe(void)
50 {
51        printk("\nInserting the kprobes \n");
52        /* Registering a kprobe */
53        kpr.pre_handler =
54            (kprobe_pre_handler_t)before_hook;
55        kpr.post_handler =
56            (kprobe_post_handler_t)after_hook;
57        kpr.addr =
58           (kprobe_opcode_t *)(&hello_to_debug);
59        printk("\nAddress where the kprobe is \n"
60               "going to be inserted - %p\n",
61               kpr.addr);
62        register_kprobe(&kpr);
63        return 0;
64 }
65
66 module_init(init_probe);
67 module_exit(exit_probe);
68
69 MODULE_AUTHOR ("Krishnakumar. R,
70                <rkrishnakumar@gmail.com>");
71 MODULE_DESCRIPTION ("Kprobes test module");
72 MODULE_LICENSE("GPL");

第 57 行指定应设置 Kprobe 的地址位置。第 53 行和第 55 行指定 pre-handler 和 post-handler 函数,它们应根据地址位置激活。第 62 行注册 Kprobe。因此,当上面的代码被编译并作为模块插入时,Kprobe 将在 hello_to_debug 函数处注册。当模块卸载时,Kprobe 将被注销,如第 13 行所示。

现在我们必须调用我们要调试的函数。这是通过以下代码完成的

 1 /* Filename: call.c */
 2
 3 #include <linux/module.h>
 4 #include <linux/init.h>
 5
 6 extern int hello_to_debug(void);
 7
 8 static void __exit exit_to_debug(void)
 9 {
10         printk("\nModule exiting \n");
11 }
12
13 static int __init init_to_debug(void)
14 {
15         printk("\nCalling the function \n");
16         hello_to_debug();
17         return 0;
18 }
19
20 module_init(init_to_debug);
21 module_exit(exit_to_debug);
22
23 MODULE_AUTHOR ("Krishnakumar. R,
24                <rkrishnakumar@gmail.com>");
25 MODULE_DESCRIPTION ("Kprobes test module");
26 MODULE_LICENSE("GPL");

这里的第 16 行调用了我们要调试的函数。Kprobes 框架在函数执行之前调用 pre-handler,并在调试下的指令执行后调用 post-handler。然后我们可以打印寄存器内容和 Kprobe 信息。以下是我在编译和插入上述模块后收到的消息记录。

插入第一个模块

[root@kk code]# /sbin/insmod first.ko

Keeping the function to debug
at the kernel address c883a000

插入 Kprobes 放置模块

[root@kk code]# /sbin/insmod kprobes.ko

Inserting the kprobes

Address where the kprobe is
going to be inserted - c883a000

调用调试中的函数

[root@kk code]# /sbin/insmod call.ko

Calling the function

Before hook
This is the Kprobe pre
handler for instruction at
c883a000
The registers are:
eax=17, ebx=c47ba000, ecx=c1264090,
edx=c47ba000
eflags=296, esp=c884000f

After hook
This is the Kprobe post
handler for instruction at c883a000
The registers are:
eax=17, ebx=c47ba000, ecx=c1264090,
edx=c47ba000
eflags=196, esp=c883a09e

From the function - hello_to_debug
断点和调试器

为了更好地理解 Kprobes 的工作原理,我们应该了解断点的一般概念,因为 Kprobes 使用了相同的机制。断点是大多数处理器中的硬件提供的一种机制,我们可以使用它进行调试。现在,我们将考虑 x86 架构。处理器的指令集提供了一条断点指令,该指令会生成一个断点异常。因此,控制权被转移到断点异常处理程序。大多数调试器都使用此功能。

假设调试器利用断点机制进行调试。如果它必须调试特定位置的指令,它会将相应的指令替换为断点指令。然后,断点指令生成异常。调试器包含一个规定,以便在生成此类异常时通知它。然后,调试器采取必要的调试步骤,例如打印出寄存器值并对其进行操作,以及用原始指令替换该指令。在此之后,指令的执行照常进行。

Pre-handler(前置处理程序)

当我们注册一个 pre-handler 时,实际发生的情况是 Kprobes 将内存位置的指令替换为断点指令。原来存在的指令被保存以供稍后参考。

kernel/kprobes.c 中的函数 int register_kprobe(struct kprobe *p) 中的以下行执行此操作

p->opcode = *p->addr;
*p->addr = BREAKPOINT_INSTRUCTION;

因此,每当控制到达特定位置时,就会发生断点异常。默认的断点异常处理程序由 Kprobes 修改。修改后的异常处理程序检查地址是否有关联的 Kprobe 实例。如果存在关联的 Kprobe,则异常处理程序执行 pre-handler。否则,控制权将转移到正常的断点异常处理程序。如果为该特定位置注册了 Kprobe,它会准备处理器调用 post-handler,post-handler 在 pre-handler 执行后接管控制。

负责处理断点的函数如下所示

asmlinkage int do_int3(struct pt_regs *regs,
                       long error_code);

调用 pre-handler 的函数在这里

static inline int kprobe_handler(struct pt_regs *regs);
Post-Handler(后置处理程序)

post-handler 在与我们关联探针的指令执行后执行。为了促进这一点,Kprobes 框架从硬件获得了一些帮助,特别是来自称为 trap generation(陷阱生成)的处理器功能。

如果我们设置了处理器的陷阱标志,它会在每条指令之后生成一个陷阱异常。在 pre-handler 运行后,Kprobes 框架设置陷阱标志。然后,它用原始指令替换断点指令。下面介绍了为 post-handler 准备的函数

static inline void prepare_singlestep(struct kprobe *p,
            struct pt_regs *regs);

在我们正在调试的指令执行后,处理器会生成一个陷阱异常。负责陷阱生成异常处理的函数如下所示

asmlinkage void do_debug(struct pt_regs * regs,
                        long error_code);

为 Kprobes post-handler 执行必要活动的函数是

static inline int post_kprobe_handler(struct pt_regs *regs);

post_kprobe_handler 函数调用我们为特定探针注册的 post-handler。

Fault Handler(故障处理程序)

每当执行调试下的指令时生成故障时,就会执行 fault handler。负责 Kprobes 在故障时活动的函数如下所示

static inline int kprobe_fault_handler(struct pt_regs *regs,
                         int trapnr);

此函数在两种情况下被调用

  1. 每当发生一般保护故障 (do_general_protection) 时,我们知道它是 Kprobes 指令生成的。

  2. 每当发生设备不可用故障生成时,我们知道它是 Kprobes 指令生成的。

在上述任何一种情况下,fault handler 都可以用于发现出了什么问题。

结论

Kprobes 补丁帮助内核开发人员调试内核中的任何地址。Kprobes 主页提供了各种补丁,包括用于设置监视点和调试用户地址位置的补丁。通过正确使用,Kprobes 可以成为任何内核开发人员武器库中的强大武器。

致谢

我感谢 Richard J Moore 和 Andrew Morton 对本文草稿版本的宝贵意见,感谢 Manish P Fadnavis 的支持,以及 Pramode C E、Shine Mohammed Jabbar 和 Chitkala Sethuraman 的反馈。

本文的资源: /article/8136

R. Krishnakumar 热衷于破解 Linux 内核。他为 Hewlett-Packard 工作,并获得了 Govt. Engg. College Thrissur 的技术学士学位。他的主页位于 puggy.symonds.net/~krishnakumar。您可以通过 rkrishnakumar@gmail.com 与他联系。

加载 Disqus 评论