Linux 存在眾多 tracing tools,比如 ftrace、perf,他們可用于內核的調試、提高內核的可觀測性。眾多的工具也意味著繁雜的概念,諸如 tracepoint、trace events、kprobe、eBPF 等,甚至讓人搞不清楚他們到底是干什么的。本文嘗試理清這些概念。
注入 Probe 的機制 Probe Handler
如果我們想要追蹤內核的一個函數或者某一行代碼,查看執行的上下文和執行情況,通用的做法是在代碼或函數的執行前后 printk 打印日志,然后通過日志來查看追蹤信息。但是這種方式需要重新編譯內核并重啟,非常麻煩。如果是在生產環境排查問題,這種方式也是無法接受的。
一種比較合理的方式是在內核正常運行時,自定義一個函數,注入到我們想要追蹤的內核函數執行前后,當內核函數執行時觸發我們定義的函數,我們在函數中實現獲取我們想要的上下文信息并保存下來。同時因為增加了內核函數的執行流程,我們定義的函數最好是需要的時候開啟,不需要的時候關閉,避免對內核函數造成影響。
這個自定義的函數就是 probe handler,注入 probe handler 的地方被稱為探測點或者 Hook 點,在探測點前執行的 probe handler 叫 pre handler, 執行后的叫 post handler,注入 probe handler 的方式被稱為“插樁”,內核提供了多種 probe handler 注入機制。接下來我們聊一聊他們是如何實現在內核運行時注入 probe handler。
Kprobes 機制
Kprobes 是一個動態 tracing 機制,能夠動態的注入到內核的任意函數中的任意地方,采集調試信息和性能信息,并且不影響內核的運行。Kprobes 有兩種類型:kprobes、kretprobes。kprobes 用于在內核函數的任意位置注入 probe handler,kretprobes 用于在函數返回位置注入 probe handler。出于安全性考慮,在內核代碼中,并非所有的函數都能“插樁”,kprobe 維護了一個黑名單記錄了不允許插樁的的函數,比如 kprobe 自身,防止遞歸調用。
kprobes 機制如何實現注入 probe handler
內核提供了一個 krpobe 注冊接口,當我們調用接口注冊一個 kprobe 在指定探測點注入 probe handler 時,內核會把探測點對應的指令復制一份,記錄下來,并且把探測點的指令的首字節替換為「斷點」指令,在 x86 平臺上也就是 int3 指令。
cpu 執行斷點指令時,會觸發內核的斷點處理函數「do_int3」,它判斷是否為 kprobe 引起的斷點,如果是 kprobe 機制觸發的斷點,會保存這個程序的狀態,比如寄存器、堆棧等信息,并通過 Linux 的「notifier_call_chain」機制,將 cpu 的使用權交給之前 kprobe 的 probe handler,同時會把內核所保存的寄存器、堆棧信息傳遞給 probe handler。
前面已經提到了,probe handler 分兩種類型,一種是 pre handler、一種是 post handler。pre handler 將首先被調用(如果有的話),pre handler 執行完成后,內核會將 cpu 的 flag 寄存器的值設置為 1,開始單步執行原指令,單步執行是 cpu 的一個 debug 特性,當 cpu 執行完一個指令后便會產生一個 int1 異常,觸發中斷處理函數「do_debug」執行,do_debug 函數會檢查本次中斷是否為 kprobe 引起,如果是的話,執行 post handler,執行完畢后關閉單步,恢復原始執行流。
kretprobe 探針很有意思,Kprobe 會在函數的入口處注冊一個 kprobe,當函數執行時,這個 krpobe 會把函數的返回地址暫存下來,并把它替換為 trampoline 地址。
Kprobe 也會在 trampoline 注冊一個 kprobe,函數執行返回時,cpu 控制權轉移到 trampoline,此時又會觸發 trampoline 上的 kprobe 探針,繼續陷入中斷,并執行 probe handler。
為什么有了 kprobe 還需要 kretprobe?
Kprobe 在可以函數的任意位置插入 probe,理論上他也能實現 kretprobe 的功能,但是實際上會面臨幾個挑戰。
比如當我們在函數的最后一行代碼上注入探針,試圖使用 kprobe 實現 kretprobe 的效果,但是實際上這種方式并不好,函數可能會存在多個返回情況,比如不滿足 if 條件,發生異常等情況,此時代碼完全有可能不會執行最后一行代碼,而是在某個地方就返回了,也就意味著不會觸發探針執行。
kretprobe 的優勢就在于它可以穩定的在函數返回時觸發 probe handler 執行,無論函數是基于什么情況下返回。
另外一方面 kprobe 雖然可以在函數的任意位置插入探針,但是實際情況下都是在函數入口處插入探針,因為函數入口是有一條標準的指令序列 prologue 可以進行斷點替換,而函數內部的其他位置,可能會存在跳轉指令、循環指令等情況,指令序列不太規則,不方便做斷點替換。
Uprobes
Uprobes 也分為 uprobes 和 uretprobes,和 Kprobes 從原理上來說基本上是類似的,通過斷點指令替換原指令實現注入 probe handler 的能力,并且他沒有 Kprobes 的黑名單限制。Uprobes 需要我們提供「探測點的偏移量」,探測點的偏移量是指從程序的起始虛擬內存地址到探測點指令的偏移量。我們可以通過一個簡單的例子來理解:
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 信息,拿到程序的符號表、節表。符號表包含程序中所有的符號,例如全局變量、局部變量、函數、動態鏈接庫符號,以及符號對應的虛擬內存地址。
匯編語言是按照節來編寫程序的,例如.text 節、.data 節。每個節都包含程序中的特定數據或代碼,節表就是程序中各個節的信息表。
通過符號表可以拿到 hello 函數的虛擬內存地址,通過節表拿到.text 節的虛擬內存地址,以及.text 節相較于 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 函數的指令在 hello 二進制文件的偏移量就可以計算出來了。
offset=test 函數的虛擬地址 - .text 段的虛擬地址 + .text 端偏移量 offset= 0000000000001149 - 0000000000001060 + 00001060 offset= 0000000000001149
現在我們可以通過編寫內核模塊向二進制程序注入 probe handler 獲取數據了。
#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 是一個靜態的 tracing 機制,開發者在內核的代碼里的固定位置聲明了一些 Hook 點,通過這些 hook 點實現相應的追蹤代碼插入,一個 Hook 點被稱為一個 tracepoint。
tracepoint 有開啟和關閉兩種狀態,默認處于關閉狀態,對內核產生的影響非常小,只是增加了極少的時間開銷(一個分支條件判斷),極小的空間開銷(一條函數調用語句和幾個數據結構)。
在 x86 環境下,內核代碼編譯后,關閉狀態的 tracepoint 代碼對應的 cpu 指令是:nop 指令,
啟用 tracepoint 時,通過 Linux 內核提供的 static jump patch 靜態跳轉補丁機制,nop 指令會被替換為 jmp 指令,jmp 指令將 cpu 的使用權轉移給 static_call 靜態跳轉函數,這個函數會遍歷 tracepoint probe handler 數組獲取當前 tracepoint 注冊的 probe handler,并進一步跳轉到 probe handler 執行,probe handler 執行完成后,再通過 jmp 指令跳轉回原函數繼續執行。
#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,看上去非常簡單,但在實際開發的過程當中,編寫內核模塊是一個很大的挑戰,如果內核模塊的代碼寫的有問題,會直接導致內核 crash,在生產環境上使用內核模塊需要謹慎考慮。
Linux 內核為此提供了一個不需要編寫內核模塊就能使用 tracepoint 的機制:event tracing。他抽象出了如下概念:
TraceEvent:事件是在程序執行過程中發生的特定事情,例如函數調用、系統調用或硬件中斷。事件被描述為一個有限的結構,包含有關事件的元數據和數據。每個事件都有一個唯一的標識符和名稱。
Event Provider:事件提供程序是一個模塊或應用程序,用于在事件跟蹤系統中注冊和定義事件。事件提供程序負責確定事件的格式和語義,并將事件發送到跟蹤緩沖區。
Event Consumer:事件消費者是從事件跟蹤緩沖區中讀取事件的進程或應用程序。事件消費者可以將事件輸出到文件、控制臺或通過網絡發送到遠程主機。
Event Tracing Session:事件跟蹤會話是一個包含多個事件提供程序和事件消費者的 ETI 實例。在一個事件跟蹤會話中,可以收集多個事件源的事件數據,并將其聚合到單個跟蹤緩沖區中。
Trace Buffer:跟蹤緩沖區是一個在內核中分配的內存區域,用于存儲事件數據。事件提供程序將事件寫入跟蹤緩沖區,事件消費者從跟蹤緩沖區讀取事件數據。
Trace Event Format (TEF):跟蹤事件格式是一個描述事件數據布局和語義的模板。它指定事件的名稱、參數和字段,以及每個字段的大小和類型。在 ETI 中,跟蹤事件格式可以由事件提供程序靜態定義或動態生成。
Trace Event Id (TEID):跟蹤事件 ID 是唯一標識一個跟蹤事件的整數值。每個事件提供程序都有自己的 TEID 命名空間,它們使用不同的整數值來標識它們的事件。在內核代碼中,包含 tracepoint 代碼的函數就可以理解為是一個 event provider,event provider 通過在 tracepoint 上注冊一個 probe handler。當這個函數執行到 tracepoint 時,觸發 probe handler 執行,它會構建一個 TraceEvent。內核代碼中已經有了專門用于構建 trace event 的 probe handler,無需我們自己注入了。
TraceEvent 會包含當前函數的上下文和參數,probe handler 會將 event 保存至在 Trace Buffer 中,接下來對于事件的分析、處理操作可以放在用戶態執行,通過系統調用從 Trace Buffer 中讀取 event,或者直接通過 mmap 直接將 Trace Buffer 映射到用戶態的內存空間讀取 event。
我們現在可以這樣使用 tracepoint:
查看當前內核支持的 event。
cat /sys/kernel/debug/tracing/available_events
啟用 syscalls:sys_enter_connect 這個事件。
echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_connect/enable
查看事件數據。
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,沒有做進一步的分析和處理操作,在后面我們可以借助一些工具消費 trace event。
基于 tracepoint 的 Trace Event 雖然解決了 tracepoint 的 probe handler 注冊需要編寫內核模塊才能使用的問題,但任然有 2 個問題沒有解決:
并非所有的內核函數都有 Tracepoint,即使有某個內核函數有 Tracepoint,如果內核開發者沒有為這個 Tracepoint 實現構建 Event 和保存 Event 到 Trace Buffer 的邏輯,同樣也沒有辦法獲取 Trace 信息。
內核開發者需要編寫代碼將 trace 信息保存到 Trace Buffer,作為內核的用戶,我們只能看到內核開發者想讓我們看到的數據根據前面提到的 trace event 的實現原理,event 就是 probe handler 構建的,那么如果我們在 kprobe 的 probe handler 中實現構建一個 event 并保存的邏輯,不就能實現一個基于 kprobe 的 Trace Event 嗎?Event Trace 已經支持了這樣的騷操作,下面是 Linux 內核給出的示例:
添加基于 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] 是設置參數的類型。在上面的示例中,為什么往這個文件里寫入一些文本,就可以實現 kprobe 的 probe handler 的能力?這主要依賴于 TraceFS 文件系統。
Tracefs 是什么?
TraceFS 是 Linux 內核提供的一個虛擬文件系統,他提供了一組文件和目錄,用戶可以通過讀寫這些文件和目錄來與內核中的跟蹤工具交互。
以 kprobe_event 為例,krpobe_event 在 tracefs 文件系統中注冊了一個回調函數 init_kprobe_trace,在掛載 tracefs 文件系統時執行,他會創建 kprobe_events 文件,并注冊對這個文件的讀寫操作監聽。
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);
當 kprobe_event 文件有寫操作時,便會觸發create_trace_kprobe函數執行,按照特定的語法解析 kprobe_event 文件內容,創建一個 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); }
在內核追蹤技術的發展初期,追蹤相關的文件都放在 debugfs 虛擬文件系統中,debugfs 主要設計目的是為了提供一個通用的內核調試接口,內核的任意子系統都有可能使用 debugfs 做調試,所以很多人出于安全考慮 debugfs 是不啟用的,這就導致無法使用內核的追蹤能力,tracefs 隨之誕生了,他會創建一個/sys/kernel/tracing目錄,但為了保證兼容性,tracefs 仍然掛載在/sys/kernel/debug/tracing 下。如果沒有啟用 debugfs,tracefs 可以掛載在/sys/kernel/tracing。
隨著 Linux 追蹤技術的發展,TraceFS 文件系統也成為了追蹤系統的基礎設施,很多跟蹤工具都使用 TraceFS 作為管理接口,比如 Perf、LTTng 等。
Function Trace
前面提到的 event trace 機制與基于 tracefs 文件系統管理 event 的機制最初就是 Ftrace 的一部分能力,現在已經成為 Linux 內核追蹤系統的通用模塊,很多追蹤工具也都依賴它。那么 Ftrace 是什么呢?
Ftrace 有兩層含義:
為函數注入 probe handler 的函數跟蹤的機制;
基于 trace fs 和 event trace 機制的 trace 框架。我們前面已經了解了 kprobes、tracepoint 兩種注入 probe handler 的機制,而 Ftrace 又帶了一種新的實現方式:編譯時注入。
gcc 有一個編譯選項:-pg,當使用這個編譯選項編譯代碼時,他會在每一個函數的入口添加對 mcount 函數的調用,mcount 函數由 libc 提供,它的實現會根據具體的機器架構生成相應的代碼。一般情況下 mcount 函數會記錄當前函數的地址、耗時等信息,在程序執行結束后,生成一個.out 文件用于給 gprof 來做性能分析的。我們可以編譯一個 hello.c 文件查看匯編代碼中包含了 mcount 調用。
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 調用 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 調用 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:
內核代碼的編譯是不依賴 libc 庫,而 ftrace 提供了一個 mcount 函數,在這個函數中實現 probe handler 的能力,如果所有的內核函數都在函數入口添加 mcount 調用,運行時會對性能造成極大的影響,我們之前介紹的 kprobes、tracepoint 都具備動態開啟和關閉的能力盡可能的減少對內核的影響,Ftrace 也不例外,他具備動態開啟某個函數的 probe handler 的能力,其實現思路有一點特別。
內核編譯時(設置 -pg 的編譯選項),在匯編階段生成.o 的目標文件,再調用 ftrace 在內核代碼包中放置的一個 Perl 腳本 Recordmcount.pl,他會掃描每一個目標文件,查找 mcount 函數調用的地址,并記錄到一個臨時的.s 文件中(一個目標文件對應一個.s 文件),查找完成后,將臨時的.s 文件編譯成.o 目標文件和原來的.o 文件鏈接到一起。
在編譯過程的鏈接階段,vmlinux.lds.h 把所有的 mcount_loc 端的內容放在 vmlinux 的.init.data 端,并聲明了兩個全局符號start_mcount_loc 和 __stop_mcount_loc 來開啟和關閉 mcount 函數調用。
在內核啟動階段,會調用 ftrace_init 函數,在這個函數中,根據記錄的 mcount 函數偏移地址,把所有的 mcount 函數調用對應的指令修改為 NOP 指令。ftrace_init 函數在 start_kernel 中調用,比 kerne__init 還要先執行,此時不會有任何內核代碼執行,修改指令不會有任何影響。
在對某個函數啟用 ftrace probe handler,會將 NOP 指令修改為對 ftrace probe handler 的調用即可,和 kprobe trap 一樣的原理,找到需要被 trace 的函數,函數的 mcount 調用是 NOP 指令,把 NOP 指令的第一個字節改為 int 3,也就是斷點指令,再把 NOP 指令調整為 probe handler 的地址。
在內核 4.19 版本,提升了最低版本的 gcc 限制,最低可允許 gcc 4.6 版本編譯,gcc 4.6 版本支持 -mfentry 編譯參數,使用 fentry 的特殊函數調用作為所有函數的第一條指令,他可以替代 mcount 函數調用,并且性能更好。
Ftrace 這種通過編譯參數注入的 probe handler 非常好用,編譯完成后,相當于各個內核函數都聲明了 tracepoint,在內核運行時可以動態打開和關閉。那我們能否可以只使用 Ftrace 的 probe handler 注入能力呢?也是可以的,他有一個新的名字叫 fprobe,在 2022 年合入內核代碼,他是 ftrace 的包裝器,可以僅使用 ftrace 的函數追蹤的功能。
#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");
除了編寫內核模塊的方式,能否通過 event trace 機制來使用呢?答案是可以的,需要使用最新版的內核才行,fprobe 支持 event trace 是在 23 年 4 月份剛合并到內核里。
Perf
Perf 是一個 Linux 下的性能分析工具的集合,最初由英特爾公司的 Andi Kleen 開發,于 2008 年首次發布。Perf 設計之初是為了解決英特爾處理器性能分析工具集(Intel Performance Tuning Utilities)在 Linux 上的移植問題而開發的,它可以利用英特爾的硬件性能監視器(Hardware Performance Monitoring)來對 CPU 性能進行采樣和分析。隨著時間的推移,Perf 逐漸成為了一個通用的性能分析工具,也支持內核追蹤。
有了前面提到的 Ftrace,為什么 Perf 也要支持內核跟蹤機制呢,主要原因在于 perf 有著特殊的分析方式:采樣分析。采樣的對象是 event,以基于時間的采樣方式為例,他的大致流程是這樣的,每隔一段時間,就在所有 CPU 上產生一個中斷,查看當前是哪個 pid,哪個函數在執行,并將 pid/func 構建成一個 event 做統計,在采樣結束后,我們就能知道 CPU 大部分時間耗在哪個 pid/func 上。
除了上面提到的基于時間的采樣,perf 還支持如下采樣方式:
計數. 統計某個事件的發生次數。
基于事件的采樣. 每當發生的事件數達到特定的閾值時,就會記錄一個樣本。
基于指令的采樣. 處理器跟蹤按給定時間間隔出現的指令,并對這些指令生成的事件采樣。這樣便可以跟蹤各個指令,并查看哪些是對性能至關重要的指令。最開始 perf 是僅支持由硬件產生的 Hardware event,這種方式可以推廣到各種事件,比如 trace event 事件,當這個事件發生的時候上來冒個頭,看看擊中了誰,然后算出分布,我們就知道誰會引發特別多的那個事件了。
接下來我們看一下 perf 是如何使用 trace event。
我們可以通過 perf 命令設置一個 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
現在我們可以通過 report 子命令,分析 trace 信息。
$ sudo perf report -i perf.data
perf 采樣拿到的 event 最終會被放到一個叫做 perf event 的數據結構里面,因為 event 都是在內核態產生的,采樣時需要一個數據結構存儲采集到的 event,并在采樣結束后,將采集到的 event 從內核態發送到用戶態來使用,perf event 就是用來做這個事情的,我們通常說的 perf 是指用戶態的工具,perf event 是內核態的數據結構。perf 工具通過系統調用 perf_event_open 來創建 perf event。
在內核中,perf_event 結構體,存儲該事件的配置和運行狀態。創建 perf event 時還會創建 perf event 對應的 ring buffer 用來存儲 trac event 數據。perf 工具通過 perf_event_open 系統調用拿到 perf event 的 fd 后,就可以通過 mmap 內存映射機制 將內核態的 ringbuffer 映射到用戶態來訪問,最終 perf 將數據寫到 perf.data 中以供后續分析。
Perf 使用 Trace Event
Perf 工具是基于 Perf Event 這個數據結構來實現分析能力的,當使用 Perf 添加 Trace Event 時,內核會將追蹤數據寫到 perf event 對應的 ringbuffer。
還是以上面的 perf 使用案例為例。我們通過 perf probe 子命令添加一個 uprobe event,在 TraceFS 中也可以看到 uprobe_event 的定義,但處于禁用狀態。
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 里發送數據,用 perf 寫就是往 perf event ring buffer 發送數據呢?
在使用 perf record 子命令采集數據時,會通過 perf_event_open 創建 perf event,perf event 在初始化階段掃描所有的 trace event, 檢查是否存在與 perf event 關聯的 uprobe_event,找到對應的 uprobe event 事件后,就可以啟用 urpobe event 了。
uprobe event 啟用時才會觸發 uprobe 注冊操作,但是 perf event 不是通過 TraceFS 的 enable 文件來注冊 uprobe event 的,而是直接調用 uprobe event 注冊接口,uprobe event 注冊接口有兩種注冊類型:TRACE_REG_PERF_REGISTER、TRACE_REG_REGISTER。TRACE_REG_PERF_REGISTER 表示由 perf event 注冊,uprobe event 有一個 flag 屬性 用于存儲注冊類型,TRACE_REG_PERF_REGISTER 對應的 flag 值為 TP_FLAG_PROFILE,其他的則是 TP_FLAG_TRACE。
uprobe event 的 probe handler 固定是 uprobe_dispatcher 函數,uprobe_dispatcher 函數會根據 uprobe event 的 flag 屬性來判斷往哪個 ring buffer 里寫追蹤數據,kprobe 也是同理。tracepoint 和它倆不一樣,用于聲明 tracepoint 的 TRACE_EVENT 宏定義中包含了專門給 perf event 使用的 probe handler,他會直接往 perf event 的 ringbuffer 中寫數據。
為什么要有兩套 Ring buffer?
Event Tracing 框架下,內核中的追蹤數據往 Ring Buffer 中寫入,我們可以通過 Tracefs 文件系統來訪問 Ring Buffer,為什么 perf 工具不直接使用這個 Ring Buffer 來獲取追蹤信息?而是在內核中讓 Trace Event 的追蹤數據直接寫入到 Perf Event 的 ring buffer 中。
其實主要原因就是 Ftrace 實現的 Ring Buffer 無法滿足 Perf 的需要,Perf 需要在 NMI 場景下也能往 Ring Buffer 中寫入數據。
Non-Maskable Interrupt (NMI) 是一種中斷信號,它可以打破處理器的正常執行流程,而且無法被忽略或屏蔽。一般來說,NMI 通常用于緊急情況下的故障處理或者硬件監控等場景。NMI 信號通常是由硬件觸發的,例如內存錯誤、總線錯誤、電源故障等,這些故障可能會導致系統崩潰或者停機。為了避免在故障發生后丟失重要的性能事件數據,Perf 需要將這些數據盡可能快地寫入 ring buffer 中,以確保數據不會丟失,這就要求 Ring Buffer 的實現上不可以有寫競爭,或可能導致死鎖的情況。
很不湊巧的是,Ftrace 的 Ring Buffer 在設計上,使用了自旋鎖來防止并發訪問,自旋鎖會一直占用 CPU 資源直到鎖可用,在 NMI 的場景下,如果 Ftrace 正在持有自旋鎖,NMI 中斷處理程序就無法獲取自旋鎖,可能會導致系統死鎖或者卡死。
另外一點就是 NMI 場景下 RingBuffer 的訪問一定要快,處理器必須盡可能快地響應 NMI 中斷信號,任何慢速的操作都可能會導致系統的穩定性和性能受到影響。Ftrace Ring Buffer 也沒有足夠的快,最終 Perf 的開發人員自行實現了一套新的 無鎖 Ring Buffer。
通過編寫 eBPF 代碼來注入 probe 如何使用 eBPF 追蹤內核?
由于內核態和用戶態的內存空間是隔離的,他們的虛擬內存實現原理不同,想要從內核態向用戶態傳遞數據需要經過地址轉換和數據拷貝,比較耗時。而在分析網絡數據包時,如果所有的網絡數據包都從內核態發到用戶態,帶來的成本也更大,很多時候我們都是只需一部分數據包就可以了,所以最理想的方式是內核態有一個 Packet Filter 機制,能夠過濾我們不需要的數據包,這樣就大大減少了內核需要拷貝的數據。
早期 unix 系統也提供了 packet filter 機制,提供了一個基于內存棧的虛擬機,來對內核態的數據包做過濾計算,比如 CMU/Stanford Packet Filter(CSPF)、NIT(Network Interface Tap) 等,它們的性能不夠好。tcpdump 的作者 Steve McCanne 和 Van Jacobson 在 BSD 操作系統上實現了一個全新架構的 Packet Filter 機制:Berkeley Packet Filter (BPF),拋棄了之前基于內存棧虛擬機的設計,改為基于寄存器的虛擬機,號稱性能比之前的 packet filter 機制快很多。同時可以在內核態接到 device interface 傳過來的包時就進行 filter,不需要的包直接丟棄,不會多出任何無效 copy。憑借優秀的架構設計和性能表現,BPF 被移植到了很多操作系統。
BPF 的作者發表了一篇論文 The BSD Packet Filter: A New Architecture for User-level Packet Capture 來詳細描述了 BPF 的設計理念與實現思路,感興趣的可以看一下。
BSD 系統的 BPF 在被移植到 Linux 上后被稱為 Linux Socket Filter(LSF),但是大家依然稱呼它為 BPF,BPF 在 Linux 內核最初也是提供 Packet filter 的能力,用戶態使用 BPF 字節碼來定義過濾表達式,然后傳遞給內核,由內核虛擬機解釋執行。
隨著時間的推移,Linux 內核開發者為 BPF 添加了更多的能力,比如 Linux 3.0 版本增加 BPF JIT 編譯器,在 2014 年 Alexei Starovoitov 為 BPF 帶來了一次革命性的更新,將 BPF 擴展為一個通用的虛擬機,也就是 eBPF。eBPF 不僅擴展了寄存器的數量,引入了全新的 BPF 映射存儲,還在 4.x 內核中將原本單一的數據包過濾事件逐步擴展到了內核態函數、用戶態函數、跟蹤點、性能事件(perf_events)以及安全控制等。
話說回 Linux 追蹤技術。eBPF 的影響也來到了內核追蹤領域,2015 年 eBPF 支持 kprobe、2016 年開始支持 tracepoint、perf event,現在我們可以通過在 eBPF 虛擬機運行自定義的 probe handler 獲取跟蹤數據,并通過 eBPF Map 共享到用戶態來對跟蹤數據做分析。相比于編寫內核代碼或是 ftrace、perf 靈活性大大增強。
eBPF 的本質是一個在內核態的虛擬機,可以在虛擬機中執行簡單代碼,一個完整的 eBPF 程序通常包含用戶態和內核態兩部分:用戶態程序通過 BPF 系統調用,完成 eBPF 程序的加載、事件掛載以及映射創建和更新,而內核態中的 eBPF 程序可以理解為我們的 probe handler,用來獲取追蹤數據。
eBPF 程序根據其用途劃分為多種類型,在追蹤方面有如下類型:
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 從類型名稱也能看出來對應類型的 eBPF 程序是如何實現追蹤能力的,比如 kprobes 類型的 eBPF 程序,就是通過 kprobes 機制注入 probe handler,probe handler 就是我們在內核態虛擬機中運行的 eBPF 代碼。同時 eBPF 程序類型里面沒有 UPROBE,主要原因是因為 uprobes 和 kprobes 原理相同,KPROBE 類型的 eBPF 程序也可以使用 uprobes。
那么 eBPF 是如何使用 kprobe、tracepoint 等機制將自己作為 probe handler 注入到內核函數中的?
在前面的介紹里,我們如果使用 kprobe 機制探測內核函數,可以使用 register_kprobe 函數、event trace、perf event 方式來注冊 probe handler。**eBPF 采用 perf event 將內核態程序做為 probe handler,** 在 eBPF 用戶態程序中,可以通過 attach_kprobe 函數將內核態 eBPF 程序通過 kprobes 機制附加到某個內核函數中。attach_kprobe 函數會創建一個 perf event,再將 eBPF 內核態程序附加到 perf event。每個 perf event 的 kprobe probe handler 都是 kprobe_dispatch 函數,他會去 perf event 中獲取注冊在當前 perf event 的回調函數列表并依次執行,同時將指向 perf ringbuffer 的指針的傳遞給 eBPF 程序,eBPF 程序可以通過 libbpf 封裝好的 PT_REGS_PARAMx 宏定義來獲取緩沖區中的數據。
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); // 調用 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); // 在這里調用 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 程序,都是復用 perf event 來實現 probe handler 注入,在某個內核版本,eBPF 的負責人 Alex 提出了一個新的方式 Raw Tracepoint,不需要依賴 perf event,eBPF 程序直接作為 probe handler 注冊到 tracepoint 上。
從使用上來說,tracepoint 類型的 eBPF 程序需要定義好 tracepoint 關聯的函數的參數的數據結構,這個可以在 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 定義好數據結構,配合 bpf 輔助函數提取 tracepoint 傳遞過來的數據。
struct sched_process_exec_args{ // 聲明數據結構 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); // 通過輔助函數讀取值 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 程序接受到的數據是由 perf probe 傳遞過來的。tracepoint 關聯的函數的參數會寫到 perf ringbuffer 緩沖區,perf probe 會將指向緩沖區的指針傳遞給 eBPF 程序。tracepoint 關聯的函數參數在緩沖區的布局如下:
+---------+ | 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 傳遞了指向緩沖區的指針,eBPF 也無法直接使用指針訪問內存上的數據,各個內核函數的參數不一樣,在不知道數據的類型、長度,無法保證安全訪問,所以需要借助 bpf 輔助函數讀取數據。
再說回 raw tracepoint 類型的 eBPF 程序,從使用上來說,它的函數參數結構體變成了 struct bpf_raw_tracepoint_args,不在需要我們定義 tracepoint 關聯的結構體了。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 上,可以提供參數的“原始訪問“。直接附加在 tracepoint 的意思是,tracepoint 對應的函數執行時,內核將直接調用 bpf 程序執行,為此內核提供了 tracepoint 注冊 bpf 程序的注冊接口 bpf_raw_tracepoint_open。而參數的原始訪問不好描述,但可以對比 raw_tracepoint 和 tracepoint 參數傳遞方式來理解。
對于 tracepoint 類型 eBPF 程序,是 perf event 在 ringbuffer 中分配一塊內存空間,然后內核會將函數的參數寫到這個內存空間中,perf probe 再把這個內存空間的地址傳遞給 eBPF 程序,而原始訪問則是,直接把函數參數全部轉換為 u64 類型,得到一個數組,并把數組傳遞給 eBPF 程序。更短的調用鏈和跳過參數處理,相比于 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
在內核 4.18 版本,引入了 BTF (BPF Type Format),它用來描述 BPF prog 和 map 相關調試信息的 元數據格式,后面 BTF 又進一步拓展成可描述 function info 和 line info。BTF 為 Struct 和 Union 類型提供了對應成員的 offset 信息,并結合 Clang 的擴展(主要是[__builtin_preserve_access_index(
在內核 5.5 版本專門定義了一個 BPF_PROG_TYPE_TRACING 類型,支持訪問 BTF 信息,率先支持的就是 raw_tracepoint,不再需要輔助函數訪問內存。
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; }內核函數與 BPF 程序的橋梁:BPF Trampoline
BPF_PROG_TYPE_TRACING 類型的 eBPF 程序通過不同的 Attach 類型,可以實現不同的能力,除了支持 raw_tracepoint attach 類型外,還支持 FENTRY/FEXIT。FENTRY、FEXIT 已經是老朋友了,在前面介紹 Ftrace 時就有提到過,這倆是用于函數追蹤的,FENTRY 類似于 kprobe、FEXIT 類似于 kretprobe(除了函數返回值,FEXIT 還可以獲取到函數的參數)。
它們依賴 gcc 的 -pg -mentry 編譯參數在每個函數入口添加 fentry 調用,在不開啟 fentry 時,fentry 調用指令會被替換為 NOP 指令,避免影響性能,開啟時 fentry 指令會被替換為 BPF Trampoline 函數調用指令,在 BPF Trampoline 函數中會調用 eBPF 程序執行。
BPF Trampoline 是一個內核函數和 bpf 程序之間的一個橋梁,它允許內核函數調用 BPF 程序,當我們通過 Fentry 機制 attach 到某個內核函數時,內核會為這個 eBPF 程序生成一個 BPF Trampoline 函數,被追蹤的內核函數的參數會被轉換成 u64 數組,存儲到 Trampoline 函數棧中,指向這個棧的指針又存儲到 eBPF 程序可以訪問的 R1 寄存器中,再根據 BTF 信息,BPF 程序可以直接訪問內存了,同樣也不需要輔助函數來讀取數據。
Fentry、FEXIT 這種基于 Trampoline 方式的 probe handler 注入方式,沒有額外的 kprobe、perf event 數據結構引入,其開銷成本非常小,如果內核支持 FENTRY 機制,函數追蹤場景使用 FENTRY 代替 kprobes 有更好的性能。
eBPF 如何從內核態向用戶態傳遞數據?
BPF Map 是 eBPF 在用戶態和內核態共享數據的方式,在上面的示例中我特意使用了 BPF ringbuffer Map 從內核態向用戶態傳遞數據,它需要內核 5.8 及其以上的版本才可以使用。在此之前,perf event Map 是事實上的標準,通過 perf ring buffer 可以高效的在內核態與用戶態之間傳遞數據。
但在實踐中發現,perf ring buffer 存在兩個缺點:內存浪費和數據亂序。
perf ring buffer 需要在每一個 cpu 上創建,每一個 cpu 都有可能執行 BPF 代碼,產生的數據會存儲到當前 CPU 的 perf ring buffer 上,如果某個時刻執行的 BPF 程序可能會產生大量的數據,perf ring buffer 空間滿了的情況下,就覆蓋掉老數據,造成一部分數據丟失,但是大部分情況下不會產生很多的數據,針對這種情況,要么容忍數據丟失,要么就每個 cpu 創建大容量的 perf ringbuffer,防止突發的數據暴增,但大部分時間空著。
同時每個 cpu 具有獨立的 perf ring buffer,可能會導致連續的追蹤數據分布在不同的 perf ringbuffer 上,比如追蹤進程的生命周期 fork、exec、exit,eBPF 程序在 3 個不同的 cpu 上執行,用戶態是通過輪詢 cpu 上 perf ringbuffer 來接收數據的,可能就會出現 exit 事件比 exec 事件先接收。
perf ringbuffer 這兩個問題并非無解,比如可以在構建一個跨 cpu 的全局計數器,每一次往 perf ringbuffer 寫入數據時帶上序列號。在用戶態聚合所有的 perf ringbuffer 上的數據時,創建一個隊列,并根據序列號按序入隊,這樣就可以保證事件的順序,這種方案總歸是增加了用戶態程序的復雜度和帶來額外的成本。
為此社區內提出了一個新的 ring buffer 設計,BPF ringbuffer,它是一個跨 CPU 共享、MPSC 模型的 ringbuffer,可以直接通過 mmap 機制映射到用戶態訪問 ringbuffer。對于低效率內存使用的問題,由于是跨 cpu 共享的 ring buffer, 所以這個問題就不存在了;對于數據亂序的問題,每個事件被寫入 bpf ringbuffer 時都會被分配一個唯一的 sequence number,并且 sequence number 會遞增。這樣,在讀取 buffer 數據時,可以根據 sequence number 來判斷哪些事件先發生,哪些事件后發生,從而保證讀取的數據是有序的。
應該選擇哪個內核追蹤技術?
Brendan Gregg 博客中有一片文章討論了選擇哪個 trace 追蹤工具(發布于 2015 年),我認為直到現在依然有幫助(Choosing a Linux Tracer (2015)),于我個人而言,排查問題和檢測性能時,我會優先考慮 perf 系列的工具,它可以幫助我獲取追蹤數據,并快速的得到一個分析結果。如果構建一個常駐的內核追蹤程序,eBPF 是我的好幫手,它具備可編程性,可以讓我在多個節點上按照期望的方式拿到追蹤數據并匯總計算。
總 結
(kprobes、uprobes)、tracepoint、fprobe(fentry/fexit) 是注入 probe handler 調用的機制。kprobes、uprobes 通過動態指令替換實現在指令執行時調用 probe handler。
tracepoint 是代碼里靜態聲明了 probe handler 的調用,提供 probe handler 的注冊接口,內核開發者定義發給 probe handler 的追蹤數據,執行 tracepoint 時將追蹤數據傳遞給 probe handler,可以動態開啟和關閉,tracepoint 由內核開發者維護,穩定性很好。
fprobe(fentry/fexit) 是通過在內核編譯期間對函數添加第三方調用,可以動態開啟和關閉,達到了類似于 tracepoint 的效果,除了 frpobe ,eBPF 同樣也可以實現 fentry/fexit 的機制,他們都是通過 Trampoline 來跳轉到 probe handler 執行。
probe handler 在內核態執行,抓取到的追蹤數據往往需要傳遞到用戶態做分析使用,perf_event、trace_event_ring_buffer、eBPF Map 是從內核態向用戶態傳遞數據的方式。
perf_event 存儲的追蹤數據可以通過 MMAP 映射到用戶態來訪問。trace_event_ring_buffer 是通過虛擬文件系統 TraceFS 的方式暴露追蹤數據。eBPF Map 有多種實現方式,有基于 perf event 的、有基于系統調用的,有基于 BPF ringbuffer 的。
審核編輯:劉清
-
寄存器
+關注
關注
31文章
5397瀏覽量
122694 -
中斷處理
+關注
關注
0文章
94瀏覽量
11164 -
LINUX內核
+關注
關注
1文章
317瀏覽量
22081 -
gcc編譯器
+關注
關注
0文章
78瀏覽量
3575
原文標題:萬字長文解讀 Linux 內核追蹤機制
文章出處:【微信號:良許Linux,微信公眾號:良許Linux】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
富士康在美拿到285億補貼 美媒寫2萬字長文討伐
李書福萬字長文確認“藍色吉利行動”失敗,將組建全新純電車公司
人工智能300年!LSTM之父萬字長文:詳解現代AI和深度學習發展史
萬字長文聊聊“車規級”芯片
萬字長文聊聊“車規級”芯片
人工智能300年!LSTM之父萬字長文:詳解現代AI和深度學習發展史

評論