Linux 存在眾多 tracing tools,比如 ftrace、perf,他們可用于內(nèi)核的調(diào)試、提高內(nèi)核的可觀測性。眾多的工具也意味著繁雜的概念,諸如 tracepoint、trace events、kprobe、eBPF 等,甚至讓人搞不清楚他們到底是干什么的。本文嘗試?yán)砬暹@些概念。
注入 Probe 的機(jī)制 Probe Handler
如果我們想要追蹤內(nèi)核的一個函數(shù)或者某一行代碼,查看執(zhí)行的上下文和執(zhí)行情況,通用的做法是在代碼或函數(shù)的執(zhí)行前后 printk 打印日志,然后通過日志來查看追蹤信息。但是這種方式需要重新編譯內(nèi)核并重啟,非常麻煩。如果是在生產(chǎn)環(huán)境排查問題,這種方式也是無法接受的。
一種比較合理的方式是在內(nèi)核正常運行時,自定義一個函數(shù),注入到我們想要追蹤的內(nèi)核函數(shù)執(zhí)行前后,當(dāng)內(nèi)核函數(shù)執(zhí)行時觸發(fā)我們定義的函數(shù),我們在函數(shù)中實現(xiàn)獲取我們想要的上下文信息并保存下來。同時因為增加了內(nèi)核函數(shù)的執(zhí)行流程,我們定義的函數(shù)最好是需要的時候開啟,不需要的時候關(guān)閉,避免對內(nèi)核函數(shù)造成影響。
這個自定義的函數(shù)就是 probe handler,注入 probe handler 的地方被稱為探測點或者 Hook 點,在探測點前執(zhí)行的 probe handler 叫 pre handler, 執(zhí)行后的叫 post handler,注入 probe handler 的方式被稱為“插樁”,內(nèi)核提供了多種 probe handler 注入機(jī)制。接下來我們聊一聊他們是如何實現(xiàn)在內(nèi)核運行時注入 probe handler。
Kprobes 機(jī)制
Kprobes 是一個動態(tài) tracing 機(jī)制,能夠動態(tài)的注入到內(nèi)核的任意函數(shù)中的任意地方,采集調(diào)試信息和性能信息,并且不影響內(nèi)核的運行。Kprobes 有兩種類型:kprobes、kretprobes。kprobes 用于在內(nèi)核函數(shù)的任意位置注入 probe handler,kretprobes 用于在函數(shù)返回位置注入 probe handler。出于安全性考慮,在內(nèi)核代碼中,并非所有的函數(shù)都能“插樁”,kprobe 維護(hù)了一個黑名單記錄了不允許插樁的的函數(shù),比如 kprobe 自身,防止遞歸調(diào)用。
kprobes 機(jī)制如何實現(xiàn)注入 probe handler
內(nèi)核提供了一個 krpobe 注冊接口,當(dāng)我們調(diào)用接口注冊一個 kprobe 在指定探測點注入 probe handler 時,內(nèi)核會把探測點對應(yīng)的指令復(fù)制一份,記錄下來,并且把探測點的指令的首字節(jié)替換為「斷點」指令,在 x86 平臺上也就是 int3 指令。
cpu 執(zhí)行斷點指令時,會觸發(fā)內(nèi)核的斷點處理函數(shù)「do_int3」,它判斷是否為 kprobe 引起的斷點,如果是 kprobe 機(jī)制觸發(fā)的斷點,會保存這個程序的狀態(tài),比如寄存器、堆棧等信息,并通過 Linux 的「notifier_call_chain」機(jī)制,將 cpu 的使用權(quán)交給之前 kprobe 的 probe handler,同時會把內(nèi)核所保存的寄存器、堆棧信息傳遞給 probe handler。
前面已經(jīng)提到了,probe handler 分兩種類型,一種是 pre handler、一種是 post handler。pre handler 將首先被調(diào)用(如果有的話),pre handler 執(zhí)行完成后,內(nèi)核會將 cpu 的 flag 寄存器的值設(shè)置為 1,開始單步執(zhí)行原指令,單步執(zhí)行是 cpu 的一個 debug 特性,當(dāng) cpu 執(zhí)行完一個指令后便會產(chǎn)生一個 int1 異常,觸發(fā)中斷處理函數(shù)「do_debug」執(zhí)行,do_debug 函數(shù)會檢查本次中斷是否為 kprobe 引起,如果是的話,執(zhí)行 post handler,執(zhí)行完畢后關(guān)閉單步,恢復(fù)原始執(zhí)行流。
kretprobe 探針很有意思,Kprobe 會在函數(shù)的入口處注冊一個 kprobe,當(dāng)函數(shù)執(zhí)行時,這個 krpobe 會把函數(shù)的返回地址暫存下來,并把它替換為 trampoline 地址。
Kprobe 也會在 trampoline 注冊一個 kprobe,函數(shù)執(zhí)行返回時,cpu 控制權(quán)轉(zhuǎn)移到 trampoline,此時又會觸發(fā) trampoline 上的 kprobe 探針,繼續(xù)陷入中斷,并執(zhí)行 probe handler。
為什么有了 kprobe 還需要 kretprobe?
Kprobe 在可以函數(shù)的任意位置插入 probe,理論上他也能實現(xiàn) kretprobe 的功能,但是實際上會面臨幾個挑戰(zhàn)。
比如當(dāng)我們在函數(shù)的最后一行代碼上注入探針,試圖使用 kprobe 實現(xiàn) kretprobe 的效果,但是實際上這種方式并不好,函數(shù)可能會存在多個返回情況,比如不滿足 if 條件,發(fā)生異常等情況,此時代碼完全有可能不會執(zhí)行最后一行代碼,而是在某個地方就返回了,也就意味著不會觸發(fā)探針執(zhí)行。
kretprobe 的優(yōu)勢就在于它可以穩(wěn)定的在函數(shù)返回時觸發(fā) probe handler 執(zhí)行,無論函數(shù)是基于什么情況下返回。
另外一方面 kprobe 雖然可以在函數(shù)的任意位置插入探針,但是實際情況下都是在函數(shù)入口處插入探針,因為函數(shù)入口是有一條標(biāo)準(zhǔn)的指令序列 prologue 可以進(jìn)行斷點替換,而函數(shù)內(nèi)部的其他位置,可能會存在跳轉(zhuǎn)指令、循環(huán)指令等情況,指令序列不太規(guī)則,不方便做斷點替換。
Uprobes
Uprobes 也分為 uprobes 和 uretprobes,和 Kprobes 從原理上來說基本上是類似的,通過斷點指令替換原指令實現(xiàn)注入 probe handler 的能力,并且他沒有 Kprobes 的黑名單限制。Uprobes 需要我們提供「探測點的偏移量」,探測點的偏移量是指從程序的起始虛擬內(nèi)存地址到探測點指令的偏移量。我們可以通過一個簡單的例子來理解:
root@zfane-maxpower:~/traceing# cat hello.c #includevoid test(){ printf("hello world"); } int main() { test(); return 0; } root@zfane-maxpower:~/traceing# gcc hello.c -o hello
通過 readelf 讀取程序的 ELF 信息,拿到程序的符號表、節(jié)表。符號表包含程序中所有的符號,例如全局變量、局部變量、函數(shù)、動態(tài)鏈接庫符號,以及符號對應(yīng)的虛擬內(nèi)存地址。
匯編語言是按照節(jié)來編寫程序的,例如.text 節(jié)、.data 節(jié)。每個節(jié)都包含程序中的特定數(shù)據(jù)或代碼,節(jié)表就是程序中各個節(jié)的信息表。
通過符號表可以拿到 hello 函數(shù)的虛擬內(nèi)存地址,通過節(jié)表拿到.text 節(jié)的虛擬內(nèi)存地址,以及.text 節(jié)相較于 ELF 起始地址的偏移量。
root@zfane-maxpower:~/traceing# readelf -s hello|grep test 36: 0000000000001149 31 FUNC GLOBAL DEFAULT 16 test root@zfane-maxpower:~/traceing# readelf -S hello|grep .text [16] .text PROGBITS 0000000000001060 00001060
那么 test 函數(shù)的指令在 hello 二進(jìn)制文件的偏移量就可以計算出來了。
offset=test 函數(shù)的虛擬地址 - .text 段的虛擬地址 + .text 端偏移量 offset= 0000000000001149 - 0000000000001060 + 00001060 offset= 0000000000001149
現(xiàn)在我們可以通過編寫內(nèi)核模塊向二進(jìn)制程序注入 probe handler 獲取數(shù)據(jù)了。
#includeTracepoint#include #include #include #include #include #include #include #define DEBUGGEE_FILE "/home/zfane/hello/hello" #define DEBUGGEE_FILE_OFFSET (0x1149) static struct inode *debuggee_inode; static int uprobe_sample_handler(struct uprobe_consumer *con, struct pt_regs *regs) { printk("handler is executed, arg0: %s\n",regs->di); return 0; } static int uprobe_sample_ret_handler(struct uprobe_consumer *con, unsigned long func, struct pt_regs *regs) { printk("ret_handler is executed\n"); return 0; } static struct uprobe_consumer uc = { .handler = uprobe_sample_handler, .ret_handler = uprobe_sample_ret_handler }; static int __init init_uprobe_sample(void) { int ret; struct path path; ret = kern_path(DEBUGGEE_FILE, LOOKUP_FOLLOW, &path); if (ret) { return -1; } debuggee_inode = igrab(path.dentry->d_inode); path_put(&path); ret = uprobe_register(debuggee_inode, DEBUGGEE_FILE_OFFSET, &uc); if (ret < 0) { return -1; } printk(KERN_INFO "insmod uprobe_sample\n"); return 0; } static void __exit exit_uprobe_sample(void) { uprobe_unregister(debuggee_inode, DEBUGGEE_FILE_OFFSET, &uc); printk(KERN_INFO "rmmod uprobe_sample\n"); } module_init(init_uprobe_sample); module_exit(exit_uprobe_sample); MODULE_LICENSE("GPL");
Tracepoint 是一個靜態(tài)的 tracing 機(jī)制,開發(fā)者在內(nèi)核的代碼里的固定位置聲明了一些 Hook 點,通過這些 hook 點實現(xiàn)相應(yīng)的追蹤代碼插入,一個 Hook 點被稱為一個 tracepoint。
tracepoint 有開啟和關(guān)閉兩種狀態(tài),默認(rèn)處于關(guān)閉狀態(tài),對內(nèi)核產(chǎn)生的影響非常小,只是增加了極少的時間開銷(一個分支條件判斷),極小的空間開銷(一條函數(shù)調(diào)用語句和幾個數(shù)據(jù)結(jié)構(gòu))。
在 x86 環(huán)境下,內(nèi)核代碼編譯后,關(guān)閉狀態(tài)的 tracepoint 代碼對應(yīng)的 cpu 指令是:nop 指令,
啟用 tracepoint 時,通過 Linux 內(nèi)核提供的 static jump patch 靜態(tài)跳轉(zhuǎn)補(bǔ)丁機(jī)制,nop 指令會被替換為 jmp 指令,jmp 指令將 cpu 的使用權(quán)轉(zhuǎn)移給 static_call 靜態(tài)跳轉(zhuǎn)函數(shù),這個函數(shù)會遍歷 tracepoint probe handler 數(shù)組獲取當(dāng)前 tracepoint 注冊的 probe handler,并進(jìn)一步跳轉(zhuǎn)到 probe handler 執(zhí)行,probe handler 執(zhí)行完成后,再通過 jmp 指令跳轉(zhuǎn)回原函數(shù)繼續(xù)執(zhí)行。
#include通過追蹤工具來注入 Probe Event Tracing#include #include #include #include #include #include #include #include #include static void probe_sched_switch(void *ignore, bool preempt, struct task_struct *prev, struct task_struct *next, unsigned int prev_state) { pr_info("probe_sched_switch: pid [%d] -> [%d] \n",prev->tgid, next->tgid); } struct tracepoints_table { const char *name; void *fct; struct tracepoint *value; char init; }; struct tracepoints_table interests[] = {{.name = "sched_switch", .fct = probe_sched_switch}}; #define FOR_EACH_INTEREST(i) \ for (i = 0; i < sizeof(interests) / sizeof(struct tracepoints_table); i++) static void lookup_tracepoints(struct tracepoint *tp, void *ignore) { int i; FOR_EACH_INTEREST(i) { if (strcmp(interests[i].name, tp->name) == 0) interests[i].value = tp; } } static void cleanup(void) { int i; // Cleanup the tracepoints FOR_EACH_INTEREST(i) { if (interests[i].init) { tracepoint_probe_unregister(interests[i].value, interests[i].fct,NULL); } } } static void __exit tracepoint_exit(void) { cleanup(); } static int __init tracepoint_init(void) { int i; // Install the tracepoints for_each_kernel_tracepoint(lookup_tracepoints, NULL); FOR_EACH_INTEREST(i) { if (interests[i].value == NULL) { printk("Error, %s not found\n", interests[i].name); cleanup(); return 1; } tracepoint_probe_register(interests[i].value, interests[i].fct, NULL); interests[i].init = 1; } return 0; } module_init(tracepoint_init) module_exit(tracepoint_exit) MODULE_LICENSE("GPL");
在前面的代碼示例中,我們需要通過編寫 kernel module 的方式注冊 probe handler,看上去非常簡單,但在實際開發(fā)的過程當(dāng)中,編寫內(nèi)核模塊是一個很大的挑戰(zhàn),如果內(nèi)核模塊的代碼寫的有問題,會直接導(dǎo)致內(nèi)核 crash,在生產(chǎn)環(huán)境上使用內(nèi)核模塊需要謹(jǐn)慎考慮。
Linux 內(nèi)核為此提供了一個不需要編寫內(nèi)核模塊就能使用 tracepoint 的機(jī)制:event tracing。他抽象出了如下概念:
TraceEvent:事件是在程序執(zhí)行過程中發(fā)生的特定事情,例如函數(shù)調(diào)用、系統(tǒng)調(diào)用或硬件中斷。事件被描述為一個有限的結(jié)構(gòu),包含有關(guān)事件的元數(shù)據(jù)和數(shù)據(jù)。每個事件都有一個唯一的標(biāo)識符和名稱。
Event Provider:事件提供程序是一個模塊或應(yīng)用程序,用于在事件跟蹤系統(tǒng)中注冊和定義事件。事件提供程序負(fù)責(zé)確定事件的格式和語義,并將事件發(fā)送到跟蹤緩沖區(qū)。
Event Consumer:事件消費者是從事件跟蹤緩沖區(qū)中讀取事件的進(jìn)程或應(yīng)用程序。事件消費者可以將事件輸出到文件、控制臺或通過網(wǎng)絡(luò)發(fā)送到遠(yuǎn)程主機(jī)。
Event Tracing Session:事件跟蹤會話是一個包含多個事件提供程序和事件消費者的 ETI 實例。在一個事件跟蹤會話中,可以收集多個事件源的事件數(shù)據(jù),并將其聚合到單個跟蹤緩沖區(qū)中。
Trace Buffer:跟蹤緩沖區(qū)是一個在內(nèi)核中分配的內(nèi)存區(qū)域,用于存儲事件數(shù)據(jù)。事件提供程序?qū)⑹录懭敫櫨彌_區(qū),事件消費者從跟蹤緩沖區(qū)讀取事件數(shù)據(jù)。
Trace Event Format (TEF):跟蹤事件格式是一個描述事件數(shù)據(jù)布局和語義的模板。它指定事件的名稱、參數(shù)和字段,以及每個字段的大小和類型。在 ETI 中,跟蹤事件格式可以由事件提供程序靜態(tài)定義或動態(tài)生成。
Trace Event Id (TEID):跟蹤事件 ID 是唯一標(biāo)識一個跟蹤事件的整數(shù)值。每個事件提供程序都有自己的 TEID 命名空間,它們使用不同的整數(shù)值來標(biāo)識它們的事件。在內(nèi)核代碼中,包含 tracepoint 代碼的函數(shù)就可以理解為是一個 event provider,event provider 通過在 tracepoint 上注冊一個 probe handler。當(dāng)這個函數(shù)執(zhí)行到 tracepoint 時,觸發(fā) probe handler 執(zhí)行,它會構(gòu)建一個 TraceEvent。內(nèi)核代碼中已經(jīng)有了專門用于構(gòu)建 trace event 的 probe handler,無需我們自己注入了。
TraceEvent 會包含當(dāng)前函數(shù)的上下文和參數(shù),probe handler 會將 event 保存至在 Trace Buffer 中,接下來對于事件的分析、處理操作可以放在用戶態(tài)執(zhí)行,通過系統(tǒng)調(diào)用從 Trace Buffer 中讀取 event,或者直接通過 mmap 直接將 Trace Buffer 映射到用戶態(tài)的內(nèi)存空間讀取 event。
我們現(xiàn)在可以這樣使用 tracepoint:
查看當(dāng)前內(nèi)核支持的 event。
cat /sys/kernel/debug/tracing/available_events
啟用 syscalls:sys_enter_connect 這個事件。
echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_connect/enable
查看事件數(shù)據(jù)。
root@zfane-powerpc:~# cat /sys/kernel/debug/tracing/trace # tracer: nop # # entries-in-buffer/entries-written: 195/195 #P:16 # # _-----=> irqs-off/BH-disabled # / _----=> need-resched # | / _---=> hardirq/softirq # || / _--=> preempt-depth # ||| / _-=> migrate-disable # |||| / delay # TASK-PID CPU# ||||| TIMESTAMP FUNCTION # | | | ||||| | | sd-resolve-809 [001] ..... 1401.623886: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10) sd-resolve-809 [001] ..... 1411.634396: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10) systemd-resolve-793 [001] ..... 1411.634827: sys_connect(fd: 14, uservaddr: 7ffe2e97d050, addrlen: 10) systemd-resolve-793 [001] ..... 1411.634967: sys_connect(fd: 13, uservaddr: 7ffe2e97d000, addrlen: 10) sd-resolve-809 [001] ..... 1421.645348: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10) rsyslogd-848 [002] ..... 1426.678287: sys_connect(fd: 6, uservaddr: 7f3be1fb3bc0, addrlen: 6e) sd-resolve-809 [001] ..... 1431.655820: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10) systemd-resolve-793 [001] ..... 1436.661514: sys_connect(fd: 13, uservaddr: 7ffe2e97d050, addrlen: 10) systemd-resolve-793 [001] ..... 1436.661679: sys_connect(fd: 14, uservaddr: 7ffe2e97d000, addrlen: 10) rsyslogd-848 [009] ..... 1436.677930: sys_connect(fd: 6, uservaddr: 7f3be1fb3bc0, addrlen: 6e) rsyslogd-848 [009] ..... 1436.686721: sys_connect(fd: 6, uservaddr: 7f3be1fb3bc0, addrlen: 6e) sd-resolve-809 [001] ..... 1441.666368: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10) systemd-resolve-793 [001] ..... 1451.675741: sys_connect(fd: 13, uservaddr: 7ffe2e97d050, addrlen: 10) sd-resolve-809 [000] ..... 1451.675874: sys_connect(fd: c, uservaddr: 7f618b836d8c, addrlen: 10)
在這個示例中,我們只是查看了 sys_enter_connect 這個 trace event,沒有做進(jìn)一步的分析和處理操作,在后面我們可以借助一些工具消費 trace event。
基于 tracepoint 的 Trace Event 雖然解決了 tracepoint 的 probe handler 注冊需要編寫內(nèi)核模塊才能使用的問題,但任然有 2 個問題沒有解決:
并非所有的內(nèi)核函數(shù)都有 Tracepoint,即使有某個內(nèi)核函數(shù)有 Tracepoint,如果內(nèi)核開發(fā)者沒有為這個 Tracepoint 實現(xiàn)構(gòu)建 Event 和保存 Event 到 Trace Buffer 的邏輯,同樣也沒有辦法獲取 Trace 信息。
內(nèi)核開發(fā)者需要編寫代碼將 trace 信息保存到 Trace Buffer,作為內(nèi)核的用戶,我們只能看到內(nèi)核開發(fā)者想讓我們看到的數(shù)據(jù)根據(jù)前面提到的 trace event 的實現(xiàn)原理,event 就是 probe handler 構(gòu)建的,那么如果我們在 kprobe 的 probe handler 中實現(xiàn)構(gòu)建一個 event 并保存的邏輯,不就能實現(xiàn)一個基于 kprobe 的 Trace Event 嗎?Event Trace 已經(jīng)支持了這樣的騷操作,下面是 Linux 內(nèi)核給出的示例:
添加基于 kprobe、kretprobe 的 event。
echo 'p:myprobe do_sys_open dfd=%ax filename=%dx flags=%cx mode=+4($stack)' > /sys/kernel/tracing/kprobe_events
他的語法格式按照如下約定:
p[:[GRP/]EVENT] [MOD:]SYM[+offs]|MEMADDR [FETCHARGS] : Set a probe r[MAXACTIVE][:[GRP/]EVENT] [MOD:]SYM[+0] [FETCHARGS] : Set a return probe p:[GRP/]EVENT] [MOD:]SYM[+0]%return [FETCHARGS] : Set a return probe -:[GRP/]EVENT : Clear a probe
[GRP/][EVENT] 定義一個 event,[MOD:]SYM[+offs]|MEMADDR, 定義一個 kprobe。[FETCHARGS] 是設(shè)置參數(shù)的類型。在上面的示例中,為什么往這個文件里寫入一些文本,就可以實現(xiàn) kprobe 的 probe handler 的能力?這主要依賴于 TraceFS 文件系統(tǒng)。
Tracefs 是什么?
TraceFS 是 Linux 內(nèi)核提供的一個虛擬文件系統(tǒng),他提供了一組文件和目錄,用戶可以通過讀寫這些文件和目錄來與內(nèi)核中的跟蹤工具交互。
以 kprobe_event 為例,krpobe_event 在 tracefs 文件系統(tǒng)中注冊了一個回調(diào)函數(shù) init_kprobe_trace,在掛載 tracefs 文件系統(tǒng)時執(zhí)行,他會創(chuàng)建 kprobe_events 文件,并注冊對這個文件的讀寫操作監(jiān)聽。
static const struct file_operations kprobe_events_ops = { .owner = THIS_MODULE, .open = probes_open, .read = seq_read, .llseek = seq_lseek, .release = seq_release, .write = probes_write, }; /* Make a tracefs interface for controlling probe points */ static __init int init_kprobe_trace(void) { struct dentry *d_tracer; struct dentry *entry; if (register_module_notifier(&trace_kprobe_module_nb)) return -EINVAL; d_tracer = tracing_init_dentry(); if (IS_ERR(d_tracer)) return 0; entry = tracefs_create_file("kprobe_events", 0644, d_tracer, NULL, &kprobe_events_ops); /* Event list interface */ if (!entry) pr_warning("Could not create tracefs " "'kprobe_events' entry\n"); /* Profile interface */ entry = tracefs_create_file("kprobe_profile", 0444, d_tracer, NULL, &kprobe_profile_ops); if (!entry) pr_warning("Could not create tracefs " "'kprobe_profile' entry\n"); return 0; } fs_initcall(init_kprobe_trace);
當(dāng) kprobe_event 文件有寫操作時,便會觸發(fā)create_trace_kprobe函數(shù)執(zhí)行,按照特定的語法解析 kprobe_event 文件內(nèi)容,創(chuàng)建一個 kprobe。
static ssize_t probes_write(struct file *file, const char __user *buffer, size_t count, loff_t *ppos) { return traceprobe_probes_write(file, buffer, count, ppos, create_trace_kprobe); }
在內(nèi)核追蹤技術(shù)的發(fā)展初期,追蹤相關(guān)的文件都放在 debugfs 虛擬文件系統(tǒng)中,debugfs 主要設(shè)計目的是為了提供一個通用的內(nèi)核調(diào)試接口,內(nèi)核的任意子系統(tǒng)都有可能使用 debugfs 做調(diào)試,所以很多人出于安全考慮 debugfs 是不啟用的,這就導(dǎo)致無法使用內(nèi)核的追蹤能力,tracefs 隨之誕生了,他會創(chuàng)建一個/sys/kernel/tracing目錄,但為了保證兼容性,tracefs 仍然掛載在/sys/kernel/debug/tracing 下。如果沒有啟用 debugfs,tracefs 可以掛載在/sys/kernel/tracing。
隨著 Linux 追蹤技術(shù)的發(fā)展,TraceFS 文件系統(tǒng)也成為了追蹤系統(tǒng)的基礎(chǔ)設(shè)施,很多跟蹤工具都使用 TraceFS 作為管理接口,比如 Perf、LTTng 等。
Function Trace
前面提到的 event trace 機(jī)制與基于 tracefs 文件系統(tǒng)管理 event 的機(jī)制最初就是 Ftrace 的一部分能力,現(xiàn)在已經(jīng)成為 Linux 內(nèi)核追蹤系統(tǒng)的通用模塊,很多追蹤工具也都依賴它。那么 Ftrace 是什么呢?
Ftrace 有兩層含義:
為函數(shù)注入 probe handler 的函數(shù)跟蹤的機(jī)制;
基于 trace fs 和 event trace 機(jī)制的 trace 框架。我們前面已經(jīng)了解了 kprobes、tracepoint 兩種注入 probe handler 的機(jī)制,而 Ftrace 又帶了一種新的實現(xiàn)方式:編譯時注入。
gcc 有一個編譯選項:-pg,當(dāng)使用這個編譯選項編譯代碼時,他會在每一個函數(shù)的入口添加對 mcount 函數(shù)的調(diào)用,mcount 函數(shù)由 libc 提供,它的實現(xiàn)會根據(jù)具體的機(jī)器架構(gòu)生成相應(yīng)的代碼。一般情況下 mcount 函數(shù)會記錄當(dāng)前函數(shù)的地址、耗時等信息,在程序執(zhí)行結(jié)束后,生成一個.out 文件用于給 gprof 來做性能分析的。我們可以編譯一個 hello.c 文件查看匯編代碼中包含了 mcount 調(diào)用。
root@zfane-maxpower:~/traceing# cat hello.c #includevoid test(){ printf("hello world"); } int main() { test(); return 0; } root@zfane-maxpower:~/traceing# gcc -pg -S hello.c root@zfane-maxpower:~/traceing# cat hello.s .file "hello.c" .text .section .rodata .LC0: .string "hello world" .text .globl test .type test, @function test: .LFB0: .cfi_startproc endbr64 pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 1: call *mcount@GOTPCREL(%rip) // 在這個地方添加了 mcount 調(diào)用 leaq .LC0(%rip), %rax movq %rax, %rdi movl $0, %eax call printf@PLT nop popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size test, .-test .globl main .type main, @function main: .LFB1: .cfi_startproc endbr64 pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 1: call *mcount@GOTPCREL(%rip) // 在這個地方添加了 mcount 調(diào)用 movl $0, %eax call test movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE1: .size main, .-main .ident "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0" .section .note.GNU-stack,"",@progbits .section .note.gnu.property,"a" .align 8 .long 1f - 0f .long 4f - 1f .long 5 0: .string "GNU" 1: .align 8 .long 0xc0000002 .long 3f - 2f 2: .long 0x3 3: .align 8 4:
內(nèi)核代碼的編譯是不依賴 libc 庫,而 ftrace 提供了一個 mcount 函數(shù),在這個函數(shù)中實現(xiàn) probe handler 的能力,如果所有的內(nèi)核函數(shù)都在函數(shù)入口添加 mcount 調(diào)用,運行時會對性能造成極大的影響,我們之前介紹的 kprobes、tracepoint 都具備動態(tài)開啟和關(guān)閉的能力盡可能的減少對內(nèi)核的影響,F(xiàn)trace 也不例外,他具備動態(tài)開啟某個函數(shù)的 probe handler 的能力,其實現(xiàn)思路有一點特別。
內(nèi)核編譯時(設(shè)置 -pg 的編譯選項),在匯編階段生成.o 的目標(biāo)文件,再調(diào)用 ftrace 在內(nèi)核代碼包中放置的一個 Perl 腳本 Recordmcount.pl,他會掃描每一個目標(biāo)文件,查找 mcount 函數(shù)調(diào)用的地址,并記錄到一個臨時的.s 文件中(一個目標(biāo)文件對應(yīng)一個.s 文件),查找完成后,將臨時的.s 文件編譯成.o 目標(biāo)文件和原來的.o 文件鏈接到一起。
在編譯過程的鏈接階段,vmlinux.lds.h 把所有的 mcount_loc 端的內(nèi)容放在 vmlinux 的.init.data 端,并聲明了兩個全局符號start_mcount_loc 和 __stop_mcount_loc 來開啟和關(guān)閉 mcount 函數(shù)調(diào)用。
在內(nèi)核啟動階段,會調(diào)用 ftrace_init 函數(shù),在這個函數(shù)中,根據(jù)記錄的 mcount 函數(shù)偏移地址,把所有的 mcount 函數(shù)調(diào)用對應(yīng)的指令修改為 NOP 指令。ftrace_init 函數(shù)在 start_kernel 中調(diào)用,比 kerne__init 還要先執(zhí)行,此時不會有任何內(nèi)核代碼執(zhí)行,修改指令不會有任何影響。
在對某個函數(shù)啟用 ftrace probe handler,會將 NOP 指令修改為對 ftrace probe handler 的調(diào)用即可,和 kprobe trap 一樣的原理,找到需要被 trace 的函數(shù),函數(shù)的 mcount 調(diào)用是 NOP 指令,把 NOP 指令的第一個字節(jié)改為 int 3,也就是斷點指令,再把 NOP 指令調(diào)整為 probe handler 的地址。
在內(nèi)核 4.19 版本,提升了最低版本的 gcc 限制,最低可允許 gcc 4.6 版本編譯,gcc 4.6 版本支持 -mfentry 編譯參數(shù),使用 fentry 的特殊函數(shù)調(diào)用作為所有函數(shù)的第一條指令,他可以替代 mcount 函數(shù)調(diào)用,并且性能更好。
Ftrace 這種通過編譯參數(shù)注入的 probe handler 非常好用,編譯完成后,相當(dāng)于各個內(nèi)核函數(shù)都聲明了 tracepoint,在內(nèi)核運行時可以動態(tài)打開和關(guān)閉。那我們能否可以只使用 Ftrace 的 probe handler 注入能力呢?也是可以的,他有一個新的名字叫 fprobe,在 2022 年合入內(nèi)核代碼,他是 ftrace 的包裝器,可以僅使用 ftrace 的函數(shù)追蹤的功能。
#define pr_fmt(fmt) "%s: " fmt, __func__ #include#include #include #include #include #define BACKTRACE_DEPTH 16 #define MAX_SYMBOL_LEN 4096 static struct fprobe sample_probe; static unsigned long nhit; static char symbol[MAX_SYMBOL_LEN] = "kernel_clone"; module_param_string(symbol, symbol, sizeof(symbol), 0644); MODULE_PARM_DESC(symbol, "Probed symbol(s), given by comma separated symbols or a wildcard pattern."); static char nosymbol[MAX_SYMBOL_LEN] = ""; module_param_string(nosymbol, nosymbol, sizeof(nosymbol), 0644); MODULE_PARM_DESC(nosymbol, "Not-probed symbols, given by a wildcard pattern."); static bool stackdump = true; module_param(stackdump, bool, 0644); MODULE_PARM_DESC(stackdump, "Enable stackdump."); static bool use_trace = false; module_param(use_trace, bool, 0644); MODULE_PARM_DESC(use_trace, "Use trace_printk instead of printk. This is only for debugging."); static void show_backtrace(void) { unsigned long stacks[BACKTRACE_DEPTH]; unsigned int len; len = stack_trace_save(stacks, BACKTRACE_DEPTH, 2); stack_trace_print(stacks, len, 24); } static void sample_entry_handler(struct fprobe *fp, unsigned long ip, struct pt_regs *regs) { if (use_trace) /* * This is just an example, no kernel code should call * trace_printk() except when actively debugging. */ trace_printk("Enter <%pS> ip = 0x%p\n", (void *)ip, (void *)ip); else pr_info("Enter <%pS> ip = 0x%p\n", (void *)ip, (void *)ip); nhit++; if (stackdump) show_backtrace(); } static void sample_exit_handler(struct fprobe *fp, unsigned long ip, struct pt_regs *regs) { unsigned long rip = instruction_pointer(regs); if (use_trace) /* * This is just an example, no kernel code should call * trace_printk() except when actively debugging. */ trace_printk("Return from <%pS> ip = 0x%p to rip = 0x%p (%pS)\n", (void *)ip, (void *)ip, (void *)rip, (void *)rip); else pr_info("Return from <%pS> ip = 0x%p to rip = 0x%p (%pS)\n", (void *)ip, (void *)ip, (void *)rip, (void *)rip); nhit++; if (stackdump) show_backtrace(); } static int __init fprobe_init(void) { char *p, *symbuf = NULL; const char **syms; int ret, count, i; sample_probe.entry_handler = sample_entry_handler; sample_probe.exit_handler = sample_exit_handler; if (strchr(symbol, '*')) { /* filter based fprobe */ ret = register_fprobe(&sample_probe, symbol, nosymbol[0] == '\0' ? NULL : nosymbol); goto out; } else if (!strchr(symbol, ',')) { symbuf = symbol; ret = register_fprobe_syms(&sample_probe, (const char **)&symbuf, 1); goto out; } /* Comma separated symbols */ symbuf = kstrdup(symbol, GFP_KERNEL); if (!symbuf) return -ENOMEM; p = symbuf; count = 1; while ((p = strchr(++p, ',')) != NULL) count++; pr_info("%d symbols found\n", count); syms = kcalloc(count, sizeof(char *), GFP_KERNEL); if (!syms) { kfree(symbuf); return -ENOMEM; } p = symbuf; for (i = 0; i < count; i++) syms[i] = strsep(&p, ","); ret = register_fprobe_syms(&sample_probe, syms, count); kfree(syms); kfree(symbuf); out: if (ret < 0) pr_err("register_fprobe failed, returned %d\n", ret); else pr_info("Planted fprobe at %s\n", symbol); return ret; } static void __exit fprobe_exit(void) { unregister_fprobe(&sample_probe); pr_info("fprobe at %s unregistered. %ld times hit, %ld times missed\n", symbol, nhit, sample_probe.nmissed); } module_init(fprobe_init) module_exit(fprobe_exit) MODULE_LICENSE("GPL");
除了編寫內(nèi)核模塊的方式,能否通過 event trace 機(jī)制來使用呢?答案是可以的,需要使用最新版的內(nèi)核才行,fprobe 支持 event trace 是在 23 年 4 月份剛合并到內(nèi)核里。
Perf
Perf 是一個 Linux 下的性能分析工具的集合,最初由英特爾公司的 Andi Kleen 開發(fā),于 2008 年首次發(fā)布。Perf 設(shè)計之初是為了解決英特爾處理器性能分析工具集(Intel Performance Tuning Utilities)在 Linux 上的移植問題而開發(fā)的,它可以利用英特爾的硬件性能監(jiān)視器(Hardware Performance Monitoring)來對 CPU 性能進(jìn)行采樣和分析。隨著時間的推移,Perf 逐漸成為了一個通用的性能分析工具,也支持內(nèi)核追蹤。
有了前面提到的 Ftrace,為什么 Perf 也要支持內(nèi)核跟蹤機(jī)制呢,主要原因在于 perf 有著特殊的分析方式:采樣分析。采樣的對象是 event,以基于時間的采樣方式為例,他的大致流程是這樣的,每隔一段時間,就在所有 CPU 上產(chǎn)生一個中斷,查看當(dāng)前是哪個 pid,哪個函數(shù)在執(zhí)行,并將 pid/func 構(gòu)建成一個 event 做統(tǒng)計,在采樣結(jié)束后,我們就能知道 CPU 大部分時間耗在哪個 pid/func 上。
除了上面提到的基于時間的采樣,perf 還支持如下采樣方式:
計數(shù). 統(tǒng)計某個事件的發(fā)生次數(shù)。
基于事件的采樣. 每當(dāng)發(fā)生的事件數(shù)達(dá)到特定的閾值時,就會記錄一個樣本。
基于指令的采樣. 處理器跟蹤按給定時間間隔出現(xiàn)的指令,并對這些指令生成的事件采樣。這樣便可以跟蹤各個指令,并查看哪些是對性能至關(guān)重要的指令。最開始 perf 是僅支持由硬件產(chǎn)生的 Hardware event,這種方式可以推廣到各種事件,比如 trace event 事件,當(dāng)這個事件發(fā)生的時候上來冒個頭,看看擊中了誰,然后算出分布,我們就知道誰會引發(fā)特別多的那個事件了。
接下來我們看一下 perf 是如何使用 trace event。
我們可以通過 perf 命令設(shè)置一個 probe。
$ sudo perf probe -x /usr/lib/debug/boot/vmlinux-$(uname -r) -k do_sys_open
接下來通過 record 子命令 啟用 Trace Event,并將 trace 信息保存到 perf.data。
$ sudo perf record -e probe:do_sys_open -aR sleep 1
現(xiàn)在我們可以通過 report 子命令,分析 trace 信息。
$ sudo perf report -i perf.data
perf 采樣拿到的 event 最終會被放到一個叫做 perf event 的數(shù)據(jù)結(jié)構(gòu)里面,因為 event 都是在內(nèi)核態(tài)產(chǎn)生的,采樣時需要一個數(shù)據(jù)結(jié)構(gòu)存儲采集到的 event,并在采樣結(jié)束后,將采集到的 event 從內(nèi)核態(tài)發(fā)送到用戶態(tài)來使用,perf event 就是用來做這個事情的,我們通常說的 perf 是指用戶態(tài)的工具,perf event 是內(nèi)核態(tài)的數(shù)據(jù)結(jié)構(gòu)。perf 工具通過系統(tǒng)調(diào)用 perf_event_open 來創(chuàng)建 perf event。
在內(nèi)核中,perf_event 結(jié)構(gòu)體,存儲該事件的配置和運行狀態(tài)。創(chuàng)建 perf event 時還會創(chuàng)建 perf event 對應(yīng)的 ring buffer 用來存儲 trac event 數(shù)據(jù)。perf 工具通過 perf_event_open 系統(tǒng)調(diào)用拿到 perf event 的 fd 后,就可以通過 mmap 內(nèi)存映射機(jī)制 將內(nèi)核態(tài)的 ringbuffer 映射到用戶態(tài)來訪問,最終 perf 將數(shù)據(jù)寫到 perf.data 中以供后續(xù)分析。
Perf 使用 Trace Event
Perf 工具是基于 Perf Event 這個數(shù)據(jù)結(jié)構(gòu)來實現(xiàn)分析能力的,當(dāng)使用 Perf 添加 Trace Event 時,內(nèi)核會將追蹤數(shù)據(jù)寫到 perf event 對應(yīng)的 ringbuffer。
還是以上面的 perf 使用案例為例。我們通過 perf probe 子命令添加一個 uprobe event,在 TraceFS 中也可以看到 uprobe_event 的定義,但處于禁用狀態(tài)。
root@zfane-maxpower:~/traceing# perf probe -x /root/traceing/hello show_test=test Added new event: probe_hello:show_test (on test in /root/traceing/hello) You can now use it in all perf tools, such as: perf record -e probe_hello:show_test -aR sleep 1 root@zfane-maxpower:~/traceing# cat /sys/kernel/tracing/uprobe_events p:probe_hello/show_test /root/traceing/hello:0x0000000000001169 root@zfane-maxpower:~/traceing# cat /sys/kernel/tracing/events/probe_hello/enable 0 root@zfane-maxpower:~/traceing#
同樣是往 uprobe_events 文件中寫 trace event definition,為什么手動寫就是往 Trace Buffer 里發(fā)送數(shù)據(jù),用 perf 寫就是往 perf event ring buffer 發(fā)送數(shù)據(jù)呢?
在使用 perf record 子命令采集數(shù)據(jù)時,會通過 perf_event_open 創(chuàng)建 perf event,perf event 在初始化階段掃描所有的 trace event, 檢查是否存在與 perf event 關(guān)聯(lián)的 uprobe_event,找到對應(yīng)的 uprobe event 事件后,就可以啟用 urpobe event 了。
uprobe event 啟用時才會觸發(fā) uprobe 注冊操作,但是 perf event 不是通過 TraceFS 的 enable 文件來注冊 uprobe event 的,而是直接調(diào)用 uprobe event 注冊接口,uprobe event 注冊接口有兩種注冊類型:TRACE_REG_PERF_REGISTER、TRACE_REG_REGISTER。TRACE_REG_PERF_REGISTER 表示由 perf event 注冊,uprobe event 有一個 flag 屬性 用于存儲注冊類型,TRACE_REG_PERF_REGISTER 對應(yīng)的 flag 值為 TP_FLAG_PROFILE,其他的則是 TP_FLAG_TRACE。
uprobe event 的 probe handler 固定是 uprobe_dispatcher 函數(shù),uprobe_dispatcher 函數(shù)會根據(jù) uprobe event 的 flag 屬性來判斷往哪個 ring buffer 里寫追蹤數(shù)據(jù),kprobe 也是同理。tracepoint 和它倆不一樣,用于聲明 tracepoint 的 TRACE_EVENT 宏定義中包含了專門給 perf event 使用的 probe handler,他會直接往 perf event 的 ringbuffer 中寫數(shù)據(jù)。
為什么要有兩套 Ring buffer?
Event Tracing 框架下,內(nèi)核中的追蹤數(shù)據(jù)往 Ring Buffer 中寫入,我們可以通過 Tracefs 文件系統(tǒng)來訪問 Ring Buffer,為什么 perf 工具不直接使用這個 Ring Buffer 來獲取追蹤信息?而是在內(nèi)核中讓 Trace Event 的追蹤數(shù)據(jù)直接寫入到 Perf Event 的 ring buffer 中。
其實主要原因就是 Ftrace 實現(xiàn)的 Ring Buffer 無法滿足 Perf 的需要,Perf 需要在 NMI 場景下也能往 Ring Buffer 中寫入數(shù)據(jù)。
Non-Maskable Interrupt (NMI) 是一種中斷信號,它可以打破處理器的正常執(zhí)行流程,而且無法被忽略或屏蔽。一般來說,NMI 通常用于緊急情況下的故障處理或者硬件監(jiān)控等場景。NMI 信號通常是由硬件觸發(fā)的,例如內(nèi)存錯誤、總線錯誤、電源故障等,這些故障可能會導(dǎo)致系統(tǒng)崩潰或者停機(jī)。為了避免在故障發(fā)生后丟失重要的性能事件數(shù)據(jù),Perf 需要將這些數(shù)據(jù)盡可能快地寫入 ring buffer 中,以確保數(shù)據(jù)不會丟失,這就要求 Ring Buffer 的實現(xiàn)上不可以有寫競爭,或可能導(dǎo)致死鎖的情況。
很不湊巧的是,F(xiàn)trace 的 Ring Buffer 在設(shè)計上,使用了自旋鎖來防止并發(fā)訪問,自旋鎖會一直占用 CPU 資源直到鎖可用,在 NMI 的場景下,如果 Ftrace 正在持有自旋鎖,NMI 中斷處理程序就無法獲取自旋鎖,可能會導(dǎo)致系統(tǒng)死鎖或者卡死。
另外一點就是 NMI 場景下 RingBuffer 的訪問一定要快,處理器必須盡可能快地響應(yīng) NMI 中斷信號,任何慢速的操作都可能會導(dǎo)致系統(tǒng)的穩(wěn)定性和性能受到影響。Ftrace Ring Buffer 也沒有足夠的快,最終 Perf 的開發(fā)人員自行實現(xiàn)了一套新的 無鎖 Ring Buffer。
通過編寫 eBPF 代碼來注入 probe 如何使用 eBPF 追蹤內(nèi)核?
由于內(nèi)核態(tài)和用戶態(tài)的內(nèi)存空間是隔離的,他們的虛擬內(nèi)存實現(xiàn)原理不同,想要從內(nèi)核態(tài)向用戶態(tài)傳遞數(shù)據(jù)需要經(jīng)過地址轉(zhuǎn)換和數(shù)據(jù)拷貝,比較耗時。而在分析網(wǎng)絡(luò)數(shù)據(jù)包時,如果所有的網(wǎng)絡(luò)數(shù)據(jù)包都從內(nèi)核態(tài)發(fā)到用戶態(tài),帶來的成本也更大,很多時候我們都是只需一部分?jǐn)?shù)據(jù)包就可以了,所以最理想的方式是內(nèi)核態(tài)有一個 Packet Filter 機(jī)制,能夠過濾我們不需要的數(shù)據(jù)包,這樣就大大減少了內(nèi)核需要拷貝的數(shù)據(jù)。
早期 unix 系統(tǒng)也提供了 packet filter 機(jī)制,提供了一個基于內(nèi)存棧的虛擬機(jī),來對內(nèi)核態(tài)的數(shù)據(jù)包做過濾計算,比如 CMU/Stanford Packet Filter(CSPF)、NIT(Network Interface Tap) 等,它們的性能不夠好。tcpdump 的作者 Steve McCanne 和 Van Jacobson 在 BSD 操作系統(tǒng)上實現(xiàn)了一個全新架構(gòu)的 Packet Filter 機(jī)制:Berkeley Packet Filter (BPF),拋棄了之前基于內(nèi)存棧虛擬機(jī)的設(shè)計,改為基于寄存器的虛擬機(jī),號稱性能比之前的 packet filter 機(jī)制快很多。同時可以在內(nèi)核態(tài)接到 device interface 傳過來的包時就進(jìn)行 filter,不需要的包直接丟棄,不會多出任何無效 copy。憑借優(yōu)秀的架構(gòu)設(shè)計和性能表現(xiàn),BPF 被移植到了很多操作系統(tǒng)。
BPF 的作者發(fā)表了一篇論文 The BSD Packet Filter: A New Architecture for User-level Packet Capture 來詳細(xì)描述了 BPF 的設(shè)計理念與實現(xiàn)思路,感興趣的可以看一下。
BSD 系統(tǒng)的 BPF 在被移植到 Linux 上后被稱為 Linux Socket Filter(LSF),但是大家依然稱呼它為 BPF,BPF 在 Linux 內(nèi)核最初也是提供 Packet filter 的能力,用戶態(tài)使用 BPF 字節(jié)碼來定義過濾表達(dá)式,然后傳遞給內(nèi)核,由內(nèi)核虛擬機(jī)解釋執(zhí)行。
隨著時間的推移,Linux 內(nèi)核開發(fā)者為 BPF 添加了更多的能力,比如 Linux 3.0 版本增加 BPF JIT 編譯器,在 2014 年 Alexei Starovoitov 為 BPF 帶來了一次革命性的更新,將 BPF 擴(kuò)展為一個通用的虛擬機(jī),也就是 eBPF。eBPF 不僅擴(kuò)展了寄存器的數(shù)量,引入了全新的 BPF 映射存儲,還在 4.x 內(nèi)核中將原本單一的數(shù)據(jù)包過濾事件逐步擴(kuò)展到了內(nèi)核態(tài)函數(shù)、用戶態(tài)函數(shù)、跟蹤點、性能事件(perf_events)以及安全控制等。
話說回 Linux 追蹤技術(shù)。eBPF 的影響也來到了內(nèi)核追蹤領(lǐng)域,2015 年 eBPF 支持 kprobe、2016 年開始支持 tracepoint、perf event,現(xiàn)在我們可以通過在 eBPF 虛擬機(jī)運行自定義的 probe handler 獲取跟蹤數(shù)據(jù),并通過 eBPF Map 共享到用戶態(tài)來對跟蹤數(shù)據(jù)做分析。相比于編寫內(nèi)核代碼或是 ftrace、perf 靈活性大大增強(qiáng)。
eBPF 的本質(zhì)是一個在內(nèi)核態(tài)的虛擬機(jī),可以在虛擬機(jī)中執(zhí)行簡單代碼,一個完整的 eBPF 程序通常包含用戶態(tài)和內(nèi)核態(tài)兩部分:用戶態(tài)程序通過 BPF 系統(tǒng)調(diào)用,完成 eBPF 程序的加載、事件掛載以及映射創(chuàng)建和更新,而內(nèi)核態(tài)中的 eBPF 程序可以理解為我們的 probe handler,用來獲取追蹤數(shù)據(jù)。
eBPF 程序根據(jù)其用途劃分為多種類型,在追蹤方面有如下類型:
BPF_PROG_TYPE_KPROBE
BPF_PROG_TYPE_TRACEPOINT
BPF_PROG_TYPE_PERF_EVENT
BPF_PROG_TYPE_RAW_TRACEPOINT
BPF_PROG_TYPE_RAW_TRACEPOINT_WRITEABLE
BPF_PROG_TYPE_TRACING 從類型名稱也能看出來對應(yīng)類型的 eBPF 程序是如何實現(xiàn)追蹤能力的,比如 kprobes 類型的 eBPF 程序,就是通過 kprobes 機(jī)制注入 probe handler,probe handler 就是我們在內(nèi)核態(tài)虛擬機(jī)中運行的 eBPF 代碼。同時 eBPF 程序類型里面沒有 UPROBE,主要原因是因為 uprobes 和 kprobes 原理相同,KPROBE 類型的 eBPF 程序也可以使用 uprobes。
那么 eBPF 是如何使用 kprobe、tracepoint 等機(jī)制將自己作為 probe handler 注入到內(nèi)核函數(shù)中的?
在前面的介紹里,我們?nèi)绻褂?kprobe 機(jī)制探測內(nèi)核函數(shù),可以使用 register_kprobe 函數(shù)、event trace、perf event 方式來注冊 probe handler。**eBPF 采用 perf event 將內(nèi)核態(tài)程序做為 probe handler,** 在 eBPF 用戶態(tài)程序中,可以通過 attach_kprobe 函數(shù)將內(nèi)核態(tài) eBPF 程序通過 kprobes 機(jī)制附加到某個內(nèi)核函數(shù)中。attach_kprobe 函數(shù)會創(chuàng)建一個 perf event,再將 eBPF 內(nèi)核態(tài)程序附加到 perf event。每個 perf event 的 kprobe probe handler 都是 kprobe_dispatch 函數(shù),他會去 perf event 中獲取注冊在當(dāng)前 perf event 的回調(diào)函數(shù)列表并依次執(zhí)行,同時將指向 perf ringbuffer 的指針的傳遞給 eBPF 程序,eBPF 程序可以通過 libbpf 封裝好的 PT_REGS_PARAMx 宏定義來獲取緩沖區(qū)中的數(shù)據(jù)。
static int kprobe_dispatcher(struct kprobe *kp, struct pt_regs *regs) { struct trace_kprobe *tk = container_of(kp, struct trace_kprobe, rp.kp); int ret = 0; raw_cpu_inc(*tk->nhit); if (trace_probe_test_flag(&tk->tp, TP_FLAG_TRACE)) kprobe_trace_func(tk, regs); #ifdef CONFIG_PERF_EVENTS if (trace_probe_test_flag(&tk->tp, TP_FLAG_PROFILE)) ret = kprobe_perf_func(tk, regs); // 調(diào)用 perf event 的 probe handler #endif return ret; } /* Kprobe profile handler */ static int kprobe_perf_func(struct trace_kprobe *tk, struct pt_regs *regs) { struct trace_event_call *call = trace_probe_event_call(&tk->tp); struct kprobe_trace_entry_head *entry; struct hlist_head *head; int size, __size, dsize; int rctx; if (bpf_prog_array_valid(call)) { unsigned long orig_ip = instruction_pointer(regs); int ret; ret = trace_call_bpf(call, regs); // 在這里調(diào)用 bpf 程序 /* * We need to check and see if we modified the pc of the * pt_regs, and if so return 1 so that we don't do the * single stepping. */ if (orig_ip != instruction_pointer(regs)) return 1; if (!ret) return 0; } head = this_cpu_ptr(call->perf_events); if (hlist_empty(head)) return 0; dsize = __get_data_size(&tk->tp, regs); __size = sizeof(*entry) + tk->tp.size + dsize; size = ALIGN(__size + sizeof(u32), sizeof(u64)); size -= sizeof(u32); entry = perf_trace_buf_alloc(size, NULL, &rctx); if (!entry) return 0; entry->ip = (unsigned long)tk->rp.kp.addr; memset(&entry[1], 0, dsize); store_trace_args(&entry[1], &tk->tp, regs, sizeof(*entry), dsize); perf_trace_buf_submit(entry, size, rctx, call->event.type, 1, regs, head, NULL); return 0; }
不論是 kprobes、tracepoint 類型的 eBPF 程序,都是復(fù)用 perf event 來實現(xiàn) probe handler 注入,在某個內(nèi)核版本,eBPF 的負(fù)責(zé)人 Alex 提出了一個新的方式 Raw Tracepoint,不需要依賴 perf event,eBPF 程序直接作為 probe handler 注冊到 tracepoint 上。
從使用上來說,tracepoint 類型的 eBPF 程序需要定義好 tracepoint 關(guān)聯(lián)的函數(shù)的參數(shù)的數(shù)據(jù)結(jié)構(gòu),這個可以在 TraceFS 中查看,比如 sched_process_exec 這個 tracepoint。
root@zfane-maxpower:~# cat /sys/kernel/tracing/events/sched/sched_process_exec/format name: sched_process_exec ID: 311 format: field:unsigned short common_type; offset:0; size:2; signed:0; field:unsigned char common_flags; offset:2; size:1; signed:0; field:unsigned char common_preempt_count; offset:3; size:1; signed:0; field:int common_pid; offset:4; size:4; signed:1; field:__data_loc char[] filename; offset:8; size:4; signed:1; field:pid_t pid; offset:12; size:4; signed:1; field:pid_t old_pid; offset:16; size:4; signed:1; print fmt: "filename=%s pid=%d old_pid=%d", __get_str(filename), REC->pid, REC->old_pid
tracepoint 定義好數(shù)據(jù)結(jié)構(gòu),配合 bpf 輔助函數(shù)提取 tracepoint 傳遞過來的數(shù)據(jù)。
struct sched_process_exec_args{ // 聲明數(shù)據(jù)結(jié)構(gòu) unsigned short common_type; unsigned char common_flags; unsigned char common_preempt_count; int common_pid; int __data_loc; pid_t pid; pid_t old_pid; }; SEC("tracepoint/sched/sched_process_exec") int tracepoint_demo(struct sched_process_exec_args *ctx) { struct event *e; e = bpf_ringbuf_reserve(&events, sizeof(*e), 0); if (!e) { return 0; } unsigned short filename_offset=ctx->__data_loc & 0xFFFF; char *filename=(char *)ctx +filename_offset; bpf_core_read(&e->filename,sizeof(e->filename),filename); // 通過輔助函數(shù)讀取值 e->pid=bpf_get_current_pid_tgid() >>32; bpf_get_current_comm(&e->command,sizeof(e->command)); bpf_ringbuf_submit(e, 0); return 0; } char _license[] SEC("license") = "GPL";
eBPF 程序接受到的數(shù)據(jù)是由 perf probe 傳遞過來的。tracepoint 關(guān)聯(lián)的函數(shù)的參數(shù)會寫到 perf ringbuffer 緩沖區(qū),perf probe 會將指向緩沖區(qū)的指針傳遞給 eBPF 程序。tracepoint 關(guān)聯(lián)的函數(shù)參數(shù)在緩沖區(qū)的布局如下:
+---------+ | 8 bytes | hidden 'struct pt_regs *' (inaccessible to bpf program) +---------+ | N bytes | static tracepoint fields defined in tracepoint/format (bpf readonly) +---------+ | dynamic | __dynamic_array bytes of tracepoint (inaccessible to bpf yet) +---------+
perf probe 傳遞了指向緩沖區(qū)的指針,eBPF 也無法直接使用指針訪問內(nèi)存上的數(shù)據(jù),各個內(nèi)核函數(shù)的參數(shù)不一樣,在不知道數(shù)據(jù)的類型、長度,無法保證安全訪問,所以需要借助 bpf 輔助函數(shù)讀取數(shù)據(jù)。
再說回 raw tracepoint 類型的 eBPF 程序,從使用上來說,它的函數(shù)參數(shù)結(jié)構(gòu)體變成了 struct bpf_raw_tracepoint_args,不在需要我們定義 tracepoint 關(guān)聯(lián)的結(jié)構(gòu)體了。SEC 聲明也改成 raw_traceoint,其他的在使用上和 tracepoint 類型的 eBPF 程序保持一致。
// include/trace/events/sched.h SEC("raw_tracepoint/sched_process_exec") int raw_tracepoint_demo(struct bpf_raw_tracepoint_args *ctx) { struct event *e; e=bpf_ringbuf_reserve(&events,sizeof(*e),0); if (!e) { return 0; } bpf_core_read(&e->filename,sizeof(e->filename),ctx->args[0]); e->pid=bpf_get_current_pid_tgid() >>32; bpf_get_current_comm(&e->command,sizeof(e->command)); bpf_ringbuf_submit(e, 0); return 0; }
raw_tracepoint 類型的 eBPF 程序相比于普通的 tracepoint 類型的 eBPF 程序核心的改變是,直接附加在 tracepoint 上,可以提供參數(shù)的“原始訪問“。直接附加在 tracepoint 的意思是,tracepoint 對應(yīng)的函數(shù)執(zhí)行時,內(nèi)核將直接調(diào)用 bpf 程序執(zhí)行,為此內(nèi)核提供了 tracepoint 注冊 bpf 程序的注冊接口 bpf_raw_tracepoint_open。而參數(shù)的原始訪問不好描述,但可以對比 raw_tracepoint 和 tracepoint 參數(shù)傳遞方式來理解。
對于 tracepoint 類型 eBPF 程序,是 perf event 在 ringbuffer 中分配一塊內(nèi)存空間,然后內(nèi)核會將函數(shù)的參數(shù)寫到這個內(nèi)存空間中,perf probe 再把這個內(nèi)存空間的地址傳遞給 eBPF 程序,而原始訪問則是,直接把函數(shù)參數(shù)全部轉(zhuǎn)換為 u64 類型,得到一個數(shù)組,并把數(shù)組傳遞給 eBPF 程序。更短的調(diào)用鏈和跳過參數(shù)處理,相比于 tracepoint ,raw tracepoint 有更好的性能。
samples/bpf/test_overhead performance on 1 cpu: tracepoint base kprobe+bpf tracepoint+bpf raw_tracepoint+bpf task_rename 1.1M 769K 947K 1.0M urandom_read 789K 697K 750K 755KBTF-enabled raw_tracepoint
在內(nèi)核 4.18 版本,引入了 BTF (BPF Type Format),它用來描述 BPF prog 和 map 相關(guān)調(diào)試信息的 元數(shù)據(jù)格式,后面 BTF 又進(jìn)一步拓展成可描述 function info 和 line info。BTF 為 Struct 和 Union 類型提供了對應(yīng)成員的 offset 信息,并結(jié)合 Clang 的擴(kuò)展(主要是[__builtin_preserve_access_index(
在內(nèi)核 5.5 版本專門定義了一個 BPF_PROG_TYPE_TRACING 類型,支持訪問 BTF 信息,率先支持的就是 raw_tracepoint,不再需要輔助函數(shù)訪問內(nèi)存。
SEC("tp_btf/sched_process_exec") int BPF_PROG(sched_process_exec,struct task_struct *p, pid_t old_pid, struct linux_binprm *bprm) { struct event *e; e =bpf_ringbuf_reserve(&events,sizeof (*e),0); if (!e){ return 0; } bpf_printk("filename : %s",bprm->filename); // 直接訪問 bpf_core_read(&e->filename,sizeof(e->filename),bprm->filename); e->pid=bpf_get_current_pid_tgid() >>32; bpf_get_current_comm(&e->command,sizeof(e->command)); bpf_ringbuf_submit(e, 0); return 0; }內(nèi)核函數(shù)與 BPF 程序的橋梁:BPF Trampoline
BPF_PROG_TYPE_TRACING 類型的 eBPF 程序通過不同的 Attach 類型,可以實現(xiàn)不同的能力,除了支持 raw_tracepoint attach 類型外,還支持 FENTRY/FEXIT。FENTRY、FEXIT 已經(jīng)是老朋友了,在前面介紹 Ftrace 時就有提到過,這倆是用于函數(shù)追蹤的,F(xiàn)ENTRY 類似于 kprobe、FEXIT 類似于 kretprobe(除了函數(shù)返回值,F(xiàn)EXIT 還可以獲取到函數(shù)的參數(shù))。
它們依賴 gcc 的 -pg -mentry 編譯參數(shù)在每個函數(shù)入口添加 fentry 調(diào)用,在不開啟 fentry 時,fentry 調(diào)用指令會被替換為 NOP 指令,避免影響性能,開啟時 fentry 指令會被替換為 BPF Trampoline 函數(shù)調(diào)用指令,在 BPF Trampoline 函數(shù)中會調(diào)用 eBPF 程序執(zhí)行。
BPF Trampoline 是一個內(nèi)核函數(shù)和 bpf 程序之間的一個橋梁,它允許內(nèi)核函數(shù)調(diào)用 BPF 程序,當(dāng)我們通過 Fentry 機(jī)制 attach 到某個內(nèi)核函數(shù)時,內(nèi)核會為這個 eBPF 程序生成一個 BPF Trampoline 函數(shù),被追蹤的內(nèi)核函數(shù)的參數(shù)會被轉(zhuǎn)換成 u64 數(shù)組,存儲到 Trampoline 函數(shù)棧中,指向這個棧的指針又存儲到 eBPF 程序可以訪問的 R1 寄存器中,再根據(jù) BTF 信息,BPF 程序可以直接訪問內(nèi)存了,同樣也不需要輔助函數(shù)來讀取數(shù)據(jù)。
Fentry、FEXIT 這種基于 Trampoline 方式的 probe handler 注入方式,沒有額外的 kprobe、perf event 數(shù)據(jù)結(jié)構(gòu)引入,其開銷成本非常小,如果內(nèi)核支持 FENTRY 機(jī)制,函數(shù)追蹤場景使用 FENTRY 代替 kprobes 有更好的性能。
eBPF 如何從內(nèi)核態(tài)向用戶態(tài)傳遞數(shù)據(jù)?
BPF Map 是 eBPF 在用戶態(tài)和內(nèi)核態(tài)共享數(shù)據(jù)的方式,在上面的示例中我特意使用了 BPF ringbuffer Map 從內(nèi)核態(tài)向用戶態(tài)傳遞數(shù)據(jù),它需要內(nèi)核 5.8 及其以上的版本才可以使用。在此之前,perf event Map 是事實上的標(biāo)準(zhǔn),通過 perf ring buffer 可以高效的在內(nèi)核態(tài)與用戶態(tài)之間傳遞數(shù)據(jù)。
但在實踐中發(fā)現(xiàn),perf ring buffer 存在兩個缺點:內(nèi)存浪費和數(shù)據(jù)亂序。
perf ring buffer 需要在每一個 cpu 上創(chuàng)建,每一個 cpu 都有可能執(zhí)行 BPF 代碼,產(chǎn)生的數(shù)據(jù)會存儲到當(dāng)前 CPU 的 perf ring buffer 上,如果某個時刻執(zhí)行的 BPF 程序可能會產(chǎn)生大量的數(shù)據(jù),perf ring buffer 空間滿了的情況下,就覆蓋掉老數(shù)據(jù),造成一部分?jǐn)?shù)據(jù)丟失,但是大部分情況下不會產(chǎn)生很多的數(shù)據(jù),針對這種情況,要么容忍數(shù)據(jù)丟失,要么就每個 cpu 創(chuàng)建大容量的 perf ringbuffer,防止突發(fā)的數(shù)據(jù)暴增,但大部分時間空著。
同時每個 cpu 具有獨立的 perf ring buffer,可能會導(dǎo)致連續(xù)的追蹤數(shù)據(jù)分布在不同的 perf ringbuffer 上,比如追蹤進(jìn)程的生命周期 fork、exec、exit,eBPF 程序在 3 個不同的 cpu 上執(zhí)行,用戶態(tài)是通過輪詢 cpu 上 perf ringbuffer 來接收數(shù)據(jù)的,可能就會出現(xiàn) exit 事件比 exec 事件先接收。
perf ringbuffer 這兩個問題并非無解,比如可以在構(gòu)建一個跨 cpu 的全局計數(shù)器,每一次往 perf ringbuffer 寫入數(shù)據(jù)時帶上序列號。在用戶態(tài)聚合所有的 perf ringbuffer 上的數(shù)據(jù)時,創(chuàng)建一個隊列,并根據(jù)序列號按序入隊,這樣就可以保證事件的順序,這種方案總歸是增加了用戶態(tài)程序的復(fù)雜度和帶來額外的成本。
為此社區(qū)內(nèi)提出了一個新的 ring buffer 設(shè)計,BPF ringbuffer,它是一個跨 CPU 共享、MPSC 模型的 ringbuffer,可以直接通過 mmap 機(jī)制映射到用戶態(tài)訪問 ringbuffer。對于低效率內(nèi)存使用的問題,由于是跨 cpu 共享的 ring buffer, 所以這個問題就不存在了;對于數(shù)據(jù)亂序的問題,每個事件被寫入 bpf ringbuffer 時都會被分配一個唯一的 sequence number,并且 sequence number 會遞增。這樣,在讀取 buffer 數(shù)據(jù)時,可以根據(jù) sequence number 來判斷哪些事件先發(fā)生,哪些事件后發(fā)生,從而保證讀取的數(shù)據(jù)是有序的。
應(yīng)該選擇哪個內(nèi)核追蹤技術(shù)?
Brendan Gregg 博客中有一片文章討論了選擇哪個 trace 追蹤工具(發(fā)布于 2015 年),我認(rèn)為直到現(xiàn)在依然有幫助(Choosing a Linux Tracer (2015)),于我個人而言,排查問題和檢測性能時,我會優(yōu)先考慮 perf 系列的工具,它可以幫助我獲取追蹤數(shù)據(jù),并快速的得到一個分析結(jié)果。如果構(gòu)建一個常駐的內(nèi)核追蹤程序,eBPF 是我的好幫手,它具備可編程性,可以讓我在多個節(jié)點上按照期望的方式拿到追蹤數(shù)據(jù)并匯總計算。
總 結(jié)
(kprobes、uprobes)、tracepoint、fprobe(fentry/fexit) 是注入 probe handler 調(diào)用的機(jī)制。kprobes、uprobes 通過動態(tài)指令替換實現(xiàn)在指令執(zhí)行時調(diào)用 probe handler。
tracepoint 是代碼里靜態(tài)聲明了 probe handler 的調(diào)用,提供 probe handler 的注冊接口,內(nèi)核開發(fā)者定義發(fā)給 probe handler 的追蹤數(shù)據(jù),執(zhí)行 tracepoint 時將追蹤數(shù)據(jù)傳遞給 probe handler,可以動態(tài)開啟和關(guān)閉,tracepoint 由內(nèi)核開發(fā)者維護(hù),穩(wěn)定性很好。
fprobe(fentry/fexit) 是通過在內(nèi)核編譯期間對函數(shù)添加第三方調(diào)用,可以動態(tài)開啟和關(guān)閉,達(dá)到了類似于 tracepoint 的效果,除了 frpobe ,eBPF 同樣也可以實現(xiàn) fentry/fexit 的機(jī)制,他們都是通過 Trampoline 來跳轉(zhuǎn)到 probe handler 執(zhí)行。
probe handler 在內(nèi)核態(tài)執(zhí)行,抓取到的追蹤數(shù)據(jù)往往需要傳遞到用戶態(tài)做分析使用,perf_event、trace_event_ring_buffer、eBPF Map 是從內(nèi)核態(tài)向用戶態(tài)傳遞數(shù)據(jù)的方式。
perf_event 存儲的追蹤數(shù)據(jù)可以通過 MMAP 映射到用戶態(tài)來訪問。trace_event_ring_buffer 是通過虛擬文件系統(tǒng) TraceFS 的方式暴露追蹤數(shù)據(jù)。eBPF Map 有多種實現(xiàn)方式,有基于 perf event 的、有基于系統(tǒng)調(diào)用的,有基于 BPF ringbuffer 的。
審核編輯:劉清
-
寄存器
+關(guān)注
關(guān)注
31文章
5343瀏覽量
120377 -
中斷處理
+關(guān)注
關(guān)注
0文章
94瀏覽量
10976 -
LINUX內(nèi)核
+關(guān)注
關(guān)注
1文章
316瀏覽量
21651 -
gcc編譯器
+關(guān)注
關(guān)注
0文章
78瀏覽量
3385
原文標(biāo)題:萬字長文解讀 Linux 內(nèi)核追蹤機(jī)制
文章出處:【微信號:良許Linux,微信公眾號:良許Linux】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論