Linux內(nèi)核調(diào)試?yán)鳎黭probe 原理與實(shí)現(xiàn)
在《Linux 內(nèi)核調(diào)試?yán)?| kprobe 的使用》一文中,我們介紹過怎么使用 kprobe 來追蹤內(nèi)核函數(shù),而本文將會(huì)介紹 kprobe 的原理和實(shí)現(xiàn)。
kprobe 原理
kprobe 可以用來跟蹤內(nèi)核函數(shù)中某一條指令在運(yùn)行前和運(yùn)行后的情況。
我們只需在 kprobe 模塊中定義好指令執(zhí)行前的回調(diào)函數(shù) pre_handler() 和執(zhí)行后的回調(diào)函數(shù) post_handler(),那么內(nèi)核將會(huì)在被跟蹤的指令執(zhí)行前調(diào)用 pre_handler() 函數(shù),并且在指令執(zhí)行后調(diào)用 post_handler() 函數(shù)。如下圖所示:

(圖1)
那么,內(nèi)核是怎樣做到在被跟蹤指令執(zhí)行前調(diào)用 pre_handler() 函數(shù)和指令執(zhí)行后調(diào)用 post_handler() 函數(shù)的呢?
如果你讀過我們之前寫的一篇文章《斷點(diǎn)的原理》,那么就比較容易理解 kprobe 的原理了,因?yàn)?kprobe 使用了類似于斷點(diǎn)的機(jī)制來實(shí)現(xiàn)的。
如果不了解斷點(diǎn)的原理,那么請先看看這篇文章《斷點(diǎn)的原理》。
當(dāng)使用 kprobe 來跟蹤內(nèi)核函數(shù)的某條指令時(shí),kprobe 首先會(huì)把要追蹤的指令保存起來,然后把要追蹤的指令替換成 int3 指令。如下圖所示:

被追蹤的指令替換成 int3 指令后,當(dāng)內(nèi)核執(zhí)行到這條指令時(shí),將會(huì)觸發(fā) do_int3() 異常處理例程。
do_int3() 異常處理例程的執(zhí)行過程如下:
首先調(diào)用 kprobe 模塊的 pre_handler()回調(diào)函數(shù)。然后將 CPU 設(shè)置為單步調(diào)試模式。 接著從異常處理例程中返回,并且執(zhí)行原來的指令。
我們通過下圖來展示 do_int3() 函數(shù)的執(zhí)行過程:

(圖3)
由于設(shè)置了單步調(diào)試模式,當(dāng)執(zhí)行完原來的指令后,將會(huì)觸發(fā) debug異常(這是 Intel x86 CPU 的一個(gè)特性)。
當(dāng) CPU 觸發(fā) debug異常 后,內(nèi)核將會(huì)執(zhí)行 debug 異常處理例程 do_debug(),而 do_debug() 異常處理例程將會(huì)調(diào)用 kprobe 模塊的 post_handler() 回調(diào)函數(shù)。
下圖展示了 kprobe 的執(zhí)行流程:

(圖4)
kprobe 實(shí)現(xiàn)
了解了 kprobe 的原理后,現(xiàn)在我們開始分析 kprobe 的代碼實(shí)現(xiàn)。
由于 kprobe 的細(xì)節(jié)很多,本文只會(huì)對(duì) kprobe 整個(gè)大體實(shí)現(xiàn)方式進(jìn)行分析,有些細(xì)節(jié)需要讀者自行閱讀源碼了解。
1. kprobe 初始化
一個(gè)功能的實(shí)現(xiàn),一般都需要先初始化其所使用的資源和環(huán)境,kprobe 功能也不例外。
下面我們來看看 kprobe 的初始化過程,kprobe 的初始化由 init_kprobes() 函數(shù)實(shí)現(xiàn):
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) 初始化用于存儲(chǔ) kprobe 模塊的哈希表
for (i = 0; i < KPROBE_TABLE_SIZE; i++) {
INIT_HLIST_HEAD(&kprobe_table[i]);
...
}
// 2) 初始化 kprobe 的黑名單函數(shù)列表(不能被 kprobe 跟蹤的函數(shù)列表)
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架構(gòu)相關(guān)的環(huán)境(x86架構(gòu)的實(shí)現(xiàn)為空)
err = arch_init_kprobes();
// 4) 注冊die通知鏈(這個(gè)比較重要)
if (!err)
err = register_die_notifier(&kprobe_exceptions_nb);
// 5) 注冊模塊通知鏈
if (!err)
err = register_module_notifier(&kprobe_module_nb);
...
return err;
}
上面代碼精簡了一些與
kprobe功能無關(guān)的代碼(如kretprobe的功能代碼)。
init_kprobes() 函數(shù)主要完成 5 件事情:
初始化用于存儲(chǔ) kprobe 模塊的哈希表。 初始化 kprobe 的黑名單函數(shù)列表(不能被 kprobe 跟蹤的函數(shù)列表)。 初始化CPU架構(gòu)相關(guān)的環(huán)境(x86 CPU架構(gòu)的實(shí)現(xiàn)為空)。 注冊die通知鏈(重要)。 注冊模塊通知鏈。
kprobe模塊哈希表
我們在《Linux 內(nèi)核調(diào)試?yán)?| kprobe 的使用》一文中介紹過,一個(gè) kprobe 模塊是由一個(gè) struct kprobe 結(jié)構(gòu)來描述的。我們再來重溫一下這個(gè)結(jié)構(gòu):
struct kprobe {
// 用于保存到 kprobe 模塊哈希表
struct hlist_node hlist;
...
kprobe_opcode_t *addr;
const char *symbol_name;
unsigned int offset;
// 回調(diào)函數(shù)
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 結(jié)構(gòu)的 hlist 字段用于把當(dāng)前結(jié)構(gòu)存放到 kprobe 模塊 哈希表中,如下圖所示:

(圖5)
內(nèi)核把跟蹤的指令地址作為鍵,然后將 kprobe 結(jié)構(gòu)保存到哈希表中,這樣就能通過指令的地址快速查找到對(duì)應(yīng)的 kprobe 結(jié)構(gòu)。
注冊 die 通知鏈
通知鏈 機(jī)制是內(nèi)核用于做一些事件回調(diào)操作的功能,比如說:當(dāng)關(guān)機(jī)時(shí),需要把內(nèi)存中的數(shù)據(jù)寫入到磁盤,就可以通過 通知鏈 來實(shí)現(xiàn)。
kprobe 在初始化階段,會(huì)把 kprobe_exceptions_notify() 回調(diào)函數(shù)注冊到 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() 通過調(diào)用 register_die_notifier() 函數(shù)將 kprobe_exceptions_notify() 回調(diào)函數(shù)注冊到 die 通知鏈中。
當(dāng) CPU 觸發(fā)斷點(diǎn)異常時(shí)(執(zhí)行 int3 指令),內(nèi)核將會(huì)執(zhí)行 do_int3() 異常處理例程,而 do_int3() 例程將會(huì)調(diào)用 die 通知鏈中的回調(diào)函數(shù)。此時(shí),kprobe_exceptions_notify() 回調(diào)函數(shù)將會(huì)被執(zhí)行。
關(guān)于 kprobe_exceptions_notify() 回調(diào)函數(shù)的執(zhí)行流程下面將會(huì)介紹。
2. 注冊 kprobe 實(shí)例
在《Linux 內(nèi)核調(diào)試?yán)?| kprobe 的使用》一文中介紹過,編寫好的 kprobe 模塊需要通過調(diào)用 register_kprobe() 函數(shù)來注冊到內(nèi)核。
我們來看看 register_kprobe() 函數(shù)的實(shí)現(xiàn):
int __kprobes register_kprobe(struct kprobe *p)
{
...
// 1) 獲取要跟蹤的指令的內(nèi)存地址
addr = kprobe_addr(p);
...
p->addr = addr;
...
// 2) 檢測跟蹤點(diǎn)是否合法
ret = check_kprobe_address_safe(p, &probed_mod);
...
// 3) 保存被跟蹤指令的值
ret = prepare_kprobe(p);
...
// 4) 將 kprobe 結(jié)構(gòu)添加到 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;
}
經(jīng)過精簡后,上面代碼只留下了主要流程。
從上面代碼可以看出,register_kprobe() 函數(shù)主要完成 5 件事情:
獲取要跟蹤的內(nèi)核函數(shù)中的指令內(nèi)存地址(跟蹤點(diǎn))。 檢測跟蹤點(diǎn)地址是否合法。 保存被跟蹤指令的值。 將當(dāng)前注冊的 kprobe 結(jié)構(gòu)添加到 kprobe 模塊哈希表中。 將要跟蹤的指令替換成 int3 指令。
下面說說這 5 件事情分別要完成什么功能:
獲取跟蹤指令的內(nèi)存地址
一般來說,我們要跟蹤一個(gè)內(nèi)核函數(shù)的某條指令,都是通過內(nèi)核函數(shù)名去指定的(當(dāng)然也可以直接指定指令的內(nèi)存地址,但這個(gè)方法比較麻煩)。
所以,內(nèi)核首先需要通過函數(shù)名,來獲取其第一條指令對(duì)應(yīng)的內(nèi)存地址。而內(nèi)核是通過調(diào)用 kprobe_addr() 函數(shù)來獲取跟蹤函數(shù)的內(nèi)存地址。
而 kprobe_addr() 最終會(huì)調(diào)用 kallsyms_lookup_name() 來獲取跟蹤函數(shù)的內(nèi)存地址。kallsyms_lookup_name() 函數(shù)的實(shí)現(xiàn),本文不再展開細(xì)說,有興趣可以自行閱讀代碼或者查閱其他文獻(xiàn)。
檢測跟蹤點(diǎn)地址是否合法
這個(gè)過程主要對(duì)跟蹤指令的內(nèi)存地址進(jìn)行合法檢測,主要檢查幾個(gè)點(diǎn):
跟蹤點(diǎn)是否已經(jīng)被 ftrace 跟蹤,如果是就返回錯(cuò)誤(kprobe 與 ftrace 不能同時(shí)跟蹤同一個(gè)地址)。 跟蹤點(diǎn)是否在內(nèi)核代碼段,因?yàn)?kprobe 只能跟蹤內(nèi)核函數(shù),所以跟蹤點(diǎn)必須在內(nèi)核代碼段中。 跟蹤點(diǎn)是否在 kprobe 的黑名單中,如果是就返回錯(cuò)誤。 跟蹤點(diǎn)是否在內(nèi)核模塊代碼段中,kprobe 也可以跟蹤內(nèi)核模塊的函數(shù)。
保存被跟蹤指令的值
內(nèi)核通過調(diào)用 prepare_kprobe() 函數(shù)來保存被跟蹤的指令,而 prepare_kprobe() 最終會(huì)調(diào)用 CPU 架構(gòu)相關(guān)的 arch_prepare_kprobe() 函數(shù)來完成任務(wù)。
我們來看看 arch_prepare_kprobe() 函數(shù)的實(shí)現(xiàn):
int __kprobes arch_prepare_kprobe(struct kprobe *p)
{
...
// 1) 申請內(nèi)存空間,用于存放原指令的數(shù)據(jù)
p->ainsn.insn = get_insn_slot();
...
// 2) 保存原來指令的值
return arch_copy_kprobe(p);
}
最終結(jié)果如 圖2 所示。
將當(dāng) kprobe 結(jié)構(gòu)添加到哈希表中
將當(dāng)前 kprobe 結(jié)構(gòu)添加到 kprobe 模塊哈希表中,主要為了能夠通過跟蹤點(diǎn)的內(nèi)存地址快速查找到對(duì)應(yīng)的 kprobe 結(jié)構(gòu),如 圖5 所示。
將跟蹤點(diǎn)替換成 int3 指令
將跟蹤點(diǎn)替換成 int3 指令的目的是,當(dāng) CPU 執(zhí)行到跟蹤點(diǎn)時(shí),將會(huì)觸發(fā)產(chǎn)生斷點(diǎn)中斷,這時(shí)內(nèi)核將會(huì)調(diào)用 do_int3() 處理異常,如 圖2 所示。
將跟蹤點(diǎn)替換成 int3 指令是由 arm_kprobe() 函數(shù)完成,其調(diào)用鏈如下:
arm_kprobe()
└→ __arm_kprobe()
└→ arch_arm_kprobe()
從上面的調(diào)用可以看到,arm_kprobe() 最終會(huì)調(diào)用 arch_arm_kprobe() 函數(shù)來完成替換工作,我們來看看 arch_arm_kprobe() 函數(shù)的實(shí)現(xiàn):
#define BREAKPOINT_INSTRUCTION 0xcc
void __kprobes arch_arm_kprobe(struct kprobe *p)
{
text_poke(p->addr, ((unsigned char []){BREAKPOINT_INSTRUCTION}), 1);
}
從上面可以看出,arch_arm_kprobe() 函數(shù)把跟蹤點(diǎn)地址處的數(shù)據(jù)替換成 0xcc(也就是 int3 指令)。
3. kprobe 回調(diào)
前面說過,當(dāng) CPU 執(zhí)行到 int3 指令時(shí),將會(huì)觸發(fā)斷點(diǎn)異常。此時(shí),內(nèi)核將會(huì)調(diào)用 do_int3() 函數(shù)來處理異常。
do_int3() 函數(shù)對(duì) kprobe 處理的調(diào)用鏈如下:
do_int3()
└→ notify_die()
└→ atomic_notifier_call_chain()
└→ __atomic_notifier_call_chain()
└→ notifier_call_chain()
└→ kprobe_exceptions_notify()
從上面的調(diào)用鏈可以看出,do_int3() 最終會(huì)調(diào)用 kprobe_exceptions_notify() 函數(shù)來處理 kprobe 的流程。
我們來看看 kprobe_exceptions_notify() 函數(shù)的實(shí)現(xiàn):
int __kprobes
kprobe_exceptions_notify(struct notifier_block *self, unsigned long val, void *data)
{
struct die_args *args = data;
int ret = NOTIFY_DONE;
// 1) 如果是用戶態(tài)觸發(fā),直接返回,因?yàn)橛脩魬B(tài)不能使用 kprobe
if (args->regs && user_mode_vm(args->regs))
return ret;
switch (val) {
// 2) 如果異常是由 int3 指令觸發(fā)的,則調(diào)用 kprobe_handler() 處理異常
case DIE_INT3:
if (kprobe_handler(args->regs))
ret = NOTIFY_STOP;
break;
...
default:
break;
}
return ret;
}
從上面代碼可以看出,當(dāng)異常是由 int3 指令觸發(fā)的,將會(huì)調(diào)用 kprobe_handler() 函數(shù)處理異常。
我們來分析下 kprobe_handler() 函數(shù)的實(shí)現(xiàn):
static int __kprobes
kprobe_handler(struct pt_regs *regs)
{
...
// 1) 獲取觸發(fā)異常的指令內(nèi)存地址
addr = (kprobe_opcode_t *)(regs->ip - sizeof(kprobe_opcode_t));
...
// 2) 通過內(nèi)存地址獲取 kprobe 結(jié)構(gòu)(在注冊階段將其添加到哈希表中)
p = get_kprobe(addr);
if (p) {
...
// 3) 如果 kprobe 模塊定義了 pre_handler() 回調(diào),那么調(diào)用 pre_handler() 回調(diào)函數(shù)
if (!p->pre_handler || !p->pre_handler(p, regs))
// 4) 設(shè)置單步調(diào)試模式
setup_singlestep(p, regs, kcb, 0);
return 1;
...
}
...
return 0;
}
kprobe_handler() 函數(shù)會(huì)處理幾種情況,本文我們主要按照最常見的情況分析,就是上面代碼的流程。
從上面代碼可以看到,kprobe_handler() 函數(shù)主要完成 4 件事情:
獲取觸發(fā)異常的指令內(nèi)存地址(也就是 int3 指令的內(nèi)存地址)。 通過內(nèi)存地址獲取 kprobe 結(jié)構(gòu)(在注冊階段將其添加到哈希表中)。 如果 kprobe 模塊定義了 pre_handler()回調(diào),那么調(diào)用pre_handler()回調(diào)函數(shù)。設(shè)置單步調(diào)試模式。
從上面的分析可以知道,在 do_int3() 異常處理例程中調(diào)用了 kprobe 模塊的 pre_handler() 回調(diào)函數(shù),但 post_handler() 回調(diào)函數(shù)在什么地方調(diào)用呢?
我們知道,kprobe 模塊的 post_handler() 回調(diào)函數(shù)是在被跟蹤指令執(zhí)行完后被調(diào)用的。所以,在 do_int3() 異常處理例程中調(diào)用是不合適的。
為了解決這個(gè)問題,Linux 內(nèi)核使用單步調(diào)試模式來處理這種情況。設(shè)置單步調(diào)試模式由 setup_singlestep() 函數(shù)完成,我們來分析其實(shí)現(xiàn):
static void __kprobes
setup_singlestep(struct kprobe *p, struct pt_regs *regs,
struct kprobe_ctlblk *kcb, int reenter)
{
...
// 1) 將 flags 寄存器的 TF 標(biāo)志位設(shè)置為1,進(jìn)入單步調(diào)試模式
regs->flags |= X86_EFLAGS_TF;
regs->flags &= ~X86_EFLAGS_IF;
// 2) 設(shè)置異常返回后執(zhí)行的下一條指令的地址
if (p->opcode == BREAKPOINT_INSTRUCTION)
regs->ip = (unsigned long)p->addr;
else
regs->ip = (unsigned long)p->ainsn.insn;
}
setup_singlestep() 函數(shù)主要完成兩件事情:
將 flags 寄存器的 TF 標(biāo)志位設(shè)置為1,進(jìn)入單步調(diào)試模式(可以參考 Intel 的手冊)。 設(shè)置異常處理例程( do_int3()函數(shù))返回后,執(zhí)行下一條指令的地址(執(zhí)行原來的指令)。
設(shè)置完單步調(diào)試模式后,內(nèi)核就從 do_int3() 異常處理例程中返回,接著執(zhí)行原來的指令。
4. 單步調(diào)試
由于設(shè)置了單步調(diào)試模式后,CPU 每執(zhí)行一條指令,都會(huì)觸發(fā)一次 debug 異常。這時(shí),內(nèi)核將會(huì)調(diào)用 do_debug() 異常處理例程來處理 debug 異常。
然而,在 do_debug() 異常處理例程中,會(huì)通過調(diào)用 kprobe_exceptions_notify() 函數(shù)來執(zhí)行 kprobe 模塊的 post_handler() 回調(diào)函數(shù)。我們來看看其調(diào)用鏈:
do_debug()
└→ notify_die()
└→ atomic_notifier_call_chain()
└→ __atomic_notifier_call_chain()
└→ notifier_call_chain()
└→ kprobe_exceptions_notify()
└→ post_kprobe_handler()
└→ post_handler()
從上面的調(diào)用鏈可以看出,do_deubg() 也是通過調(diào)用 kprobe_exceptions_notify() 函數(shù)來處理 kprobe 機(jī)制的流程。
下面我們來分析 kprobe_exceptions_notify() 函數(shù)對(duì) 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) 如果是用戶態(tài)觸發(fā)的異常,那么直接返回
if (args->regs && user_mode_vm(args->regs))
return ret;
switch (val) {
...
// 2) 如果是 debug 異常觸發(fā)的,那么就調(diào)用 post_kprobe_handler() 進(jìn)行處理
case DIE_DEBUG:
if (post_kprobe_handler(args->regs)) {
...
}
break;
...
default:
break;
}
return ret;
}
從上面代碼可知,如果當(dāng)前發(fā)生的異常是 debug 異常,那么將會(huì)調(diào)用 post_kprobe_handler() 函數(shù)進(jìn)行處理。
我們來看看 post_kprobe_handler() 函數(shù)的實(shí)現(xiàn):
static int __kprobes post_kprobe_handler(struct pt_regs *regs)
{
...
// 如果 kprobe 模塊實(shí)現(xiàn)了 post_handler() 回調(diào)函數(shù),那么就執(zhí)行 post_handler() 回調(diào)函數(shù)
if ((kcb->kprobe_status != KPROBE_REENTER) && cur->post_handler) {
...
cur->post_handler(cur, regs, 0);
}
...
return 1;
}
如果 kprobe 模塊實(shí)現(xiàn)了 post_handler() 回調(diào)函數(shù),那么 post_kprobe_handler() 將會(huì)執(zhí)行它。
總結(jié)
本文主要介紹了 kprobe 的原理與實(shí)現(xiàn),正如本文開始時(shí)所說,kprobe 機(jī)制的細(xì)節(jié)很多,所以本文不可能對(duì)所有細(xì)節(jié)進(jìn)行分析。
如果大家對(duì) kprobe 的所有實(shí)現(xiàn)細(xì)節(jié)有興趣,可以自行閱讀源碼。
