Linux 內核調試利器 | kprobe 的使用
軟件調試 是軟件開發(fā)中一個必不可少的過程,通過軟件調試可以排查系統(tǒng)中存在的 BUG。我們在開發(fā)應用層程序時,可以使用 GDB 對程序進行調試。但由于 GDB 只能調試應用層程序,并不能用于調試內核代碼。
那么,如何調試內核代碼呢?與調試應用層程序的 GDB 類似,調試內核代碼也有個名叫 KGDB 的工具,但是使用起來比較繁瑣。所以,本文將會介紹一個使用起來比較簡單的內核調試工具:kprobe。
本篇文章主要介紹 kprobe 的使用,下篇文章將會介紹 kprobe 的實現(xiàn)原理。
kprobe 簡介
回憶一下我們在開發(fā)應用程序時是怎樣調試代碼的?最原始的方法就是,在代碼中使用 printf 這類打印函數(shù)把結果輸出到屏幕或者日志中。當然在內核中有類似的打印函數(shù):printk,但使用 printk 函數(shù)調試內核代碼的話,必須要重新編譯 Linux 內核代碼,代價非常高。
所以,內核開發(fā)者們開發(fā)出一種不需要重新編譯內核代碼的調試工具:kprobe。
kprobe 可以讓用戶在內核幾乎所有的地址空間或函數(shù)(某些函數(shù)是被能被探測的)中插入探測點,用戶可以在這些探測點上通過定義自定義函數(shù)來調試內核代碼。
用戶可以對一個探測點進行執(zhí)行前和執(zhí)行后調試,在介紹 kprobe 的使用方式前,我們先來了解一下 struct kprobe 結構,其定義如下:
struct kprobe {
...
kprobe_opcode_t *addr;
const char *symbol_name;
unsigned int offset;
kprobe_pre_handler_t pre_handler;
kprobe_post_handler_t post_handler;
kprobe_fault_handler_t fault_handler;
...
};
一個 struct kprobe 結構表示一個探測點,下面介紹一下其各個字段的作用:
addr:要探測的指令所在的內存地址(由于需要知道指令的內存地址,所以比較少使用)。symbol_name:要探測的內核函數(shù),symbol_name與addr只能選擇一個進行探測。offset:探測點在內核函數(shù)內的偏移量,用于探測內核函數(shù)內部的指令,如果該值為0表示函數(shù)的入口。pre_handler:在探測點處的指令執(zhí)行前,被調用的調試函數(shù)。post_handler:在探測點處的指令執(zhí)行后,被調用的調試函數(shù)。fault_handler:在執(zhí)行pre_handler、post_handler或單步執(zhí)行被探測指令時出現(xiàn)內存異常,則會調用這個回調函數(shù)。
一個 kprobe 探測點的執(zhí)行過程如下圖所示:

從上面的介紹可知,kprobe 一般用于調試內核函數(shù)。
kprobe 使用
接下來,我們介紹一下怎么使用 kprobe 來調試內核函數(shù)。
使用 kprobe 來進行內核調試的方式有兩種:
第一種是通過編寫內核模塊,向內核注冊探測點。探測函數(shù)可根據(jù)需要自行定制,使用靈活方便; 第二種方式是使用 kprobes on ftrace,這種方式是kprobe和ftrace結合使用,即可以通過kprobe來優(yōu)化ftrace來跟蹤函數(shù)的調用。
由于第一種方式靈活而且功能更為強大,所以本文主要介紹第一種使用方式。
要編寫一個 kprobe 內核模塊,可以按照以下步驟完成:
第一步:根據(jù)需要來編寫探測函數(shù),如 pre_handler和post_handler回調函數(shù)。第二步:定義 struct kprobe結構并且填充其各個字段,如要探測的內核函數(shù)名和各個探測回調函數(shù)。第三步:通過調用 register_kprobe函數(shù)注冊一個探測點。第四步:編寫 Makefile 文件。 第五步:編譯并安裝內核模塊。
接下來就按照上面的步驟來完成一個 kprobe 的內核模塊。
1. 定義回調函數(shù)
第一步就是編寫追蹤的回調函數(shù),一般來說只需要編寫 pre_handler、post_handler 和 fault_handler 這三個回調函數(shù),當然也可以只編寫你想追蹤的其中某一個回調函數(shù)。下面我們將會完成這三個追蹤回調函數(shù)的編寫:
pre_handler 回調函數(shù)
我們首先編寫要追蹤的內核函數(shù)被調用前的回調函數(shù) pre_handler,代碼如下:
static int pre_handler(struct kprobe *p, struct pt_regs *regs)
{
printk(KERN_INFO "pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx\n",
p->addr, regs->ip, regs->flags);
return 0;
}
上面的函數(shù)只是簡單的打印了要追蹤的內核函數(shù)的內存地址、ip 寄存器和 flags 寄存器的值,在函數(shù)的定義中可以發(fā)現(xiàn)有個類型為 pt_regs 結構的參數(shù) ,其主要保存了 CPU 各個寄存器的值,不同 CPU 架構的定義不一樣,例如 x86 CPU 架構的定義如下:
struct pt_regs {
long ebx; // ebx寄存器
long ecx; // ecx寄存器
long edx; // edx寄存器
long esi; // esi寄存器
long edi; // edi寄存器
long ebp; // ebp寄存器
long eax; // eax寄存器
int xds; // ds寄存器
int xes; // es寄存器
int xfs; // fs寄存器
long orig_eax; // ...
long eip; // eip寄存器
int xcs; // cs寄存器
long eflags; // eflags寄存器
long esp; // esp寄存器
int xss; // ss寄存器
};
所以我們可以通過這個結構來獲取 CPU 各個寄存器的值。
post_handler 回調函數(shù)
接著我們來編寫要追蹤的內核函數(shù)被調用后的回調函數(shù) post_handler,其代碼如下:
static void
post_handler(struct kprobe *p, struct pt_regs *regs, unsigned long flags)
{
printk(KERN_INFO "post_handler: p->addr = 0x%p, flags = 0x%lx\n",
p->addr, regs->flags);
}
post_handler 回調函數(shù)也只是簡單的打印了要追蹤的內核函數(shù)的內存地址和 flags 寄存器的值。
fault_handler 回調函數(shù)
最后我們來編寫當發(fā)生內存異常時的回調函數(shù) fault_handler,其代碼如下:
static int fault_handler(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
printk(KERN_INFO "fault_handler: p->addr = 0x%p, trap #%dn",
p->addr, trapnr);
return 0;
}
fault_handler 回調函數(shù)打印了要追蹤的內核函數(shù)的內存地址和發(fā)生異常時的異常編號。
2. 定義 kprobe 結構
接下來我們定義一個 struct kprobe 結構并且填充其各個字段值,代碼如下:
static struct kprobe kp = {
.symbol_name = "do_fork", // 要追蹤的內核函數(shù)為 do_fork
.pre_handler = pre_handler; // pre_handler 回調函數(shù)
.post_handler = post_handler; // post_handler 回調函數(shù)
.fault_handler = fault_handler; // fault_handler 回調函數(shù)
};
由于我們要追蹤 do_fork 內核函數(shù),所以在 kprobe 結構的 symbol_name 設置為 do_fork 字符串,然后設置各個回調函數(shù)即可。
3. 注冊追蹤點
最后通過調用 register_kprobe 函數(shù)來注冊追蹤點,代碼如下:
static int __init kprobe_init(void)
{
int ret;
ret = register_kprobe(&kp); // 調用 register_kprobe 注冊追蹤點
if (ret < 0) {
printk(KERN_INFO "register_kprobe failed, returned %d\n", ret);
return ret;
}
printk(KERN_INFO "planted kprobe at %p\n", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp); // 調用 unregister_kprobe 注銷追蹤點
printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr);
}
module_init(kprobe_init) // 注冊模塊初始化函數(shù)
module_exit(kprobe_exit) // 注冊模塊退出函數(shù)
MODULE_LICENSE("GPL");
4. 編寫 Makefile 文件
Makefile 文件用于編譯內核模塊時使用,一般來說編譯內核模塊的 Makefile 格式相對固定,如下:
obj-m := kprobe_example.o # 編譯后的二進制文件名
CROSS_COMPILE=''
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
rm -f *.ko *.o *.mod.o *.mod.c .*.cmd *.symvers modul*
5. 編譯并安裝內核模塊
最后,我們編譯并且安裝這個內核模塊,命令如下:
$ make
$ sudo insmod kprobe_example.ko
安裝完成后,隨便敲入一個命令(如 ls),然后通過調用 dmesg 命令查看內核模塊輸出的結果,如下所示:
...
planted kprobe at ffffffff81076400
pre_handler: p->addr = 0xffffffff81076400, ip = ffffffff81076401, flags = 0x246
post_handler: p->addr = 0xffffffff81076400, flags = 0x246
pre_handler: p->addr = 0xffffffff81076400, ip = ffffffff81076401, flags = 0x246
post_handler: p->addr = 0xffffffff81076400, flags = 0x246
pre_handler: p->addr = 0xffffffff81076400, ip = ffffffff81076401, flags = 0x246
post_handler: p->addr = 0xffffffff81076400, flags = 0x246
可以看出,我們的調試模塊已經(jīng)正常工作,并且輸出我們需要的信息。
總結
本文主要介紹了 kprobe 的使用方式,kprobe 的功能非常強大,可以幫助我們發(fā)現(xiàn)內核的一些 BUG。當然,本文也只是非常簡單的介紹其使用,但有了這些基礎就可以完成很多復雜的調試。
本文主要為了接下來的 kprobe 原理與實現(xiàn)打好基礎,一下篇文章將會介紹 kprobe 的原理和實現(xiàn),有興趣的同學多多關注。
