使用 JProbe 調試 Linux 內核

對於我這種普通程序員來說,Linux 內核是神秘而高貴的,輕易我們不敢去說內核相關的事情。不過,有時候逼不得已,也得硬著頭皮對內核進行一些調試。(比如發現一些異常現象,懷疑是某個系統調用的異常行為在作祟時)為此,學習一些內核調試技術也是有必要的。

限於個人水平,此篇以操作指南為主,不涉及過多的理論知識——其實是我不懂。

KProbes 介紹

JProbe 是 KProbes 的一部分。因此,介紹 JProbe 大致應當從 KProbes 開始。

遊戲的名目

The Name of the Game--- Knuth, The TeXbook

KProbes 的名字由字母 K 和 Probes 組合而成。此處,字母 K 表示是「Kernel」的縮寫,表示 Linux 內核;英文單詞 probe 則是「探測」的意思。因此 KProbes 從名字來說,即是內核探測工具的意思。

KProbes 的背景

在內核或者內核模塊的調試過程中,瞭解一些函數是否被調用、何時被調用、調用後的執行情況如何、傳入參數和返回值分別是什麼是很自然的想法。為此,最簡單的方法是修改這些函數的源碼,在適當的位置打印相關日誌。不過,這種方案雖然聽起來簡單,實際操作時候卻不簡單:需要重新編譯內核。這算是很高的代價了。

KProbes 技術大體上就是為了解決這一需求而設計的。KProbes 允許用戶

  • 自行定義回調函數;
  • 動態地插入或者移除探測點;
  • 當內核執行到相關探測點時,KProbes 會調用用戶註冊的回調函數,待回調函數執行完畢後再繼續正常的執行流程。

顯而易見,利用 KProbes 的回調函數收集和打印相關信息比上述「簡單的方法」代價要小得多了。

KProbes 的組成

KProbes 提供了三種探測手段:

  • KProbe
  • JProbe
  • KRetProbe

這裡,KProbe 最基本也最強大,是後續兩種探測手段的基礎。KProbe 允許在任意位置放置探測點,例如可以在函數內部某條指令處放置探測點;並且提供了探測點調用前、調用後、訪存出錯三種情況的回調方式。

  • 調用前回調:pre_handler
  • 調用後回調:post_handler
  • 訪存出錯回調:fault_handler

JProbe 是本文的重點,它和 KRetProbe 都是在 KProbe 的基礎上實現的。JProbe 的探測點在函數入口處,可用於收集函數的參數;KRetProbe 則顧名思義,其探測點在函數出口處,可用於收集函數的返回值。

硬件依賴

從前面的描述不難看出,KProbes 這類技術一方面需要在某些時候讓內核執行流程陷入到用戶註冊的回調函數中,另一方面需要單步執行被探測點的指令。因此,KProbes 對硬件平臺是有依賴的。前者依賴 CPU 的異常處理,而後者依賴單步調試技術。

在目前主流的 i386, x86_64, arm 等平臺上,KProbes 已經能較好地工作。在其它平臺上,KProbes 則可能只實現了部分功能。具體則需要查看內核相關文檔:Documentation/kprobes.txt。

KProbes 的一些限制

  • KProbes 允許在同一個位置註冊多個 KProbe 探測點,但是不能註冊多個 JProbe 探測點。
  • JProbe 不能以 JProbe 的回調函數或者 KProbe post_handler 作為探測點。
  • KProbes 可以於包括中斷處理函數在內的幾乎所有函數中註冊探測點,但是不能在 KProbes 自身的相關函數中註冊探測點(定義在 kernel/kprobes.c 以及 arch/*/kernel/kprobes.c 中的函數),以及不能在 do_page_fault 和 notifier_call_chain 中註冊探測點。
  • KProbes 的探測依賴函數調用,因此在內聯函數或者可能被內聯的函數中註冊探測點可能失效。
  • KProbes 的各種回調函數會關閉內核搶佔,甚至依平臺不同關閉終端,因此在回調函數中不應調用會放棄當前 CPU 時間片的函數(例如互斥量相關函數)。

JProbe 使用方法

回調函數

首先我們要明確,我們希望利用 JProbe 做什麼,也就是 JProbe 的回調函數應該如何實現。

我們假設有這樣一個任務:關注某一個進程在調用 Linux 虛擬文件系統的 write 操作時,打印其進程 ID (PID),並打印參數中的偏移量。假設這個進程的名字是 "liam_test"。考慮到我們要在 vfs_write 函數的入口處做探測,我們需要實現的回調函數其實是 vfs_write 的一個代理,因此它的參數應當與 vfs_write 完全一致。因此有如下實現。


ssize_t jvfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) {
if (!strcmp("liam_test", current->comm) /* we're in the program `liam_test` */ ) {
printk(KERN_INFO "pid: %u, pos: %lld.\\n", current->pid, *pos);
}
jprobe_return();
return 0;
}

複製

注意這裡涉及了 jprobe_return() 這個 JProbe API。在回調函數執行完畢以後,必須調用該函數,如此執行流才會回到正常的執行路徑中去。

JProbe 結構體

實現好了回調函數之後,我們來看如何用 JProbe 結構體,將回調函數和被探測的函數關聯起來。


/*
* Special probe type that uses setjmp-longjmp type tricks to resume
* execution at a specified entry with a matching prototype corresponding
* to the probed function - a trick to enable arguments to become
* accessible seamlessly by probe handling logic.
* Note:
* Because of the way compilers allocate stack space for local variables
* etc upfront, regardless of sub-scopes within a function, this mirroring
* principle currently works only for probes placed on function entry points.
*/
struct jprobe {
struct kprobe kp;
void *entry; /* probe handling code to jump to */
};

複製

結構體本身非常簡單,內裡只有一個 struct kprobe 和一個 void* 指針。前者說明 JProbe 是基於 KProbe 實現的,後者保存回調函數的入口。為此我們還需要查看 struct kprobe 的實現,具體每個成員的含義以註釋的形式給出。


struct kprobe {
/* Hash 表,索引值為被探測點的地址 */
struct hlist_node hlist;
/* 同一探測點上多個 kprobe 結構的鏈表 */
struct list_head list;
/* 記錄當前 probe 被暫時放棄的計數器 */
unsigned long nmissed;
/* 被探測點的地址 */
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;
/* 在上述三個回調函數執行過程中,若觸發斷點指令,則調用該回調函數 */

kprobe_break_handler_t break_handler;
/* 斷點處的原始指令 */
kprobe_opcode_t opcode;
/* 上述原始指令的拷貝,被用於單步執行 */
struct arch_specific_insn ainsn;
/* 該 probe 的狀態標記 */
u32 flags;
};

複製

因此,對於一個典型的 JProbe 任務(探測 vfs_write 函數的傳入參數),我們通常會設置這樣的結構體。


static struct jprobe write_stub = {
.kp = {
.symbol_name = "vfs_write",
},
.entry = jvfs_write,
};

複製

這樣的結構體表示我們希望在 vfs_write 這個符號(對應內核的 vfs_write() 函數)的入口處進行探測,探測時的回調函數是 jvfs_write。注意,當函數名被用作值時,它等價於一個指針。這樣,我們就通過 write_stub 這個 struct jprobe 將回調函數和被探測函數關聯起來了。

註冊與卸載

接下來的工作,就是要向系統內核註冊我們實現的 JProbe 了。為此,我們需要實現兩個函數 jprobe_init 和 jprobe_exit。


static int __init jprobe_init(void) {
int ret;
ret = register_jprobe(&write_stub);
if (ret < 0) {
printk(KERN_INFO "register_jprobe failed, returned %d\\n", ret);
return -1;
}
printk(KERN_INFO "Planted jprobe at %p, handler addr %p\\n",
write_stub.kp.addr, write_stub.entry);
return 0;
}
static void __exit jprobe_exit(void) {
unregister_jprobe(&write_stub);
printk(KERN_INFO "jprobe at %p unregistered\\n", write_stub.kp.addr);
}

複製

此處 jprobe_init 和 jprobe_exit 兩個函數的名字可以自由更改,重點是其中調用的 register_jprobe 和 unregister_jprobe 兩個 JProbe API。JProbe 中,註冊與卸載相關的 API 有如下一些。


/* 向內核註冊 JProbe 探測點 */
int register_jprobe(struct jprobe *jp)
/* 卸載 JProbe 探測點 */
void unregister_jprobe(struct jprobe *jp)
/* 向內核註冊多個 JProbe 探測點 */
int register_jprobes(struct jprobe **jps, int num)
/* 卸載多個 JProbe 探測點 */
void unregister_jprobes(struct jprobe **jps, int num)
/* 暫停指定探測點 */

int disable_jprobe(struct jprobe *jp)
/* 恢復指定探測點 */
int enable_jprobe(struct jprobe *jp)

複製

實現為內核模塊

為了將我們的代碼插入內核,我們需要將 JProbe 探測點實現為內核模塊。為此我們需要調用一些內核宏。


module_init(jprobe_init)
module_exit(jprobe_exit)
MODULE_AUTHOR("Liam Huang");
MODULE_DESCRIPTION("Print information of \\"vfs_write\\", when current process command name is \\"liam_test\\"");
MODULE_LICENSE("GPL");

複製

編譯內核模塊

完整的 write_stub.c 文件應當如下。


#include <linux>
#include <linux>
#include <linux>
#include <linux>
#include <linux>
ssize_t jvfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) {
if (!strcmp("liam_test", current->comm) /* we're in the program `liam_test` */ ) {
printk(KERN_INFO "pid: %u, pos: %lld.\\n", current->pid, *pos);
}

jprobe_return();
return 0;
}
static struct jprobe write_stub = {
.kp = {
.symbol_name = "vfs_write",
},
.entry = jvfs_write,
};
static int __init jprobe_init(void) {
int ret;
ret = register_jprobe(&write_stub);
if (ret < 0) {
printk(KERN_INFO "register_jprobe failed, returned %d\\n", ret);
return -1;
}
printk(KERN_INFO "Planted jprobe at %p, handler addr %p\\n",
write_stub.kp.addr, write_stub.entry);
return 0;
}
static void __exit jprobe_exit(void) {
unregister_jprobe(&write_stub);
printk(KERN_INFO "jprobe at %p unregistered\\n", write_stub.kp.addr);
}
module_init(jprobe_init)
module_exit(jprobe_exit)
MODULE_AUTHOR("Liam Huang");
MODULE_DESCRIPTION("Print information of \\"vfs_write\\", when current process command name is \\"liam_test\\"");
MODULE_LICENSE("GPL");
/<linux>/<linux>/<linux>/<linux>/<linux>

複製

我們編寫如下 Makefile,以便調用 make 來將源碼編譯為內核模塊。


obj-m +=write_stub.o
KDIR= /lib/modules/$(shell uname -r)/build
all:
$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules
clean:
rm -rf *.o *.ko *.mod.* .c* .t*

複製

此時調用 make 即可編譯得到內核模塊 write_stub.ko。


$ make
make -C /lib/modules/2.6.32-2.0.0.8-6/build SUBDIRS=/home/Yuuki/test/c modules
'make[1]: Entering directory `/usr/src/kernels/2.6.32-220.7.1.el6.2.0.0.8.x86_64'
Building modules, stage 2.
MODPOST 1 modules
make[1]: Leaving directory `/usr/src/kernels/2.6.32-220.7.1.el6.2.0.0.8.x86_64'
$ ls
delay_stub.c delay_stub.ko delay_stub.ko.unsigned delay_stub.mod.c delay_stub.mod.o delay_stub.o Makefile

複製

熱插拔內核模塊

Linux 提供了 insmod 和 rmmod 兩個命令來熱插拔內核模塊。因此,在 insmod write_stub.ko 之後,名為 "liam_test" 的程序調用 vfs_write 就會在內核信息中打印 PID 和相關參數了;而在 rmmod write_stub.ko 之後,則可以將該模塊從內核中卸載。


$ lsmod
Module Size Used by
tcp_diag 1041 0
inet_diag 8703 1 tcp_diag
fuse 66726 2
# ...
$ sudo insmod write_stub.ko
$ lsmod
Module Size Used by
delay_stub 1346 0
tcp_diag 1041 0
inet_diag 8703 1 tcp_diag
fuse 66726 2
# ...

複製

需要注意的是,這種做法需要內核支持。具體來說,內核必須打開如下編譯選項

  • CONFIG_KPROBES: 以便支持 KProbes;
  • CONFIG_MODULES:以便支持模塊動態加載;
  • CONFIG_MODULE_UNLOAD:以便支持模塊動態卸載。

你可以在 /boot/config-XXX 中找到內核編譯選項的記錄,以檢查你的內核是否打開了上述選項。

零聲學院專門整理了Linux後臺服務開發大綱,有興趣的同學可以關注私信我(關鍵詞“Linux後臺開發”)!更多免費學習資料等你來取。


分享到:


相關文章: