深入理解 Linux 內核追蹤機制

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
#include <stdio.h>
void 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 獲取數據了。

#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uprobes.h>
#include <linux/namei.h>
#include <linux/string.h>
#include <linux/uaccess.h>
#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

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 <linux/module.h>
#include <linux/ftrace.h>
#include <linux/tracepoint.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/hashtable.h>
#include <linux/slab.h>
#include <linux/time.h>
#include <linux/percpu.h>
#include <trace/events/sched.h>
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");

通過追蹤工具來注入 Probe

Event Tracing

在前面的代碼示例中,我們需要通過編寫 kernel module 的方式註冊 probe handler,看上去非常簡單,但在實際開發的過程當中,編寫內核模塊是一個很大的挑戰,如果內核模塊的代碼寫的有問題,會直接導致內核 crash,在生產環境上使用內核模塊需要謹慎考慮。

Linux 內核爲此提供了一個不需要編寫內核模塊就能使用 tracepoint 的機制:event tracing。他抽象出了如下概念:

  1. TraceEvent:事件是在程序執行過程中發生的特定事情,例如函數調用、系統調用或硬件中斷。事件被描述爲一個有限的結構,包含有關事件的元數據和數據。每個事件都有一個唯一的標識符和名稱。

  2. Event Provider:事件提供程序是一個模塊或應用程序,用於在事件跟蹤系統中註冊和定義事件。事件提供程序負責確定事件的格式和語義,並將事件發送到跟蹤緩衝區。

  3. Event Consumer:事件消費者是從事件跟蹤緩衝區中讀取事件的進程或應用程序。事件消費者可以將事件輸出到文件、控制檯或通過網絡發送到遠程主機。

  4. Event Tracing Session:事件跟蹤會話是一個包含多個事件提供程序和事件消費者的 ETI 實例。在一個事件跟蹤會話中,可以收集多個事件源的事件數據,並將其聚合到單個跟蹤緩衝區中。

  5. Trace Buffer:跟蹤緩衝區是一個在內核中分配的內存區域,用於存儲事件數據。事件提供程序將事件寫入跟蹤緩衝區,事件消費者從跟蹤緩衝區讀取事件數據。

  6. Trace Event Format (TEF):跟蹤事件格式是一個描述事件數據佈局和語義的模板。它指定事件的名稱、參數和字段,以及每個字段的大小和類型。在 ETI 中,跟蹤事件格式可以由事件提供程序靜態定義或動態生成。

  7. 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:

cat /sys/kernel/debug/tracing/available_events
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 個問題沒有解決:

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 有兩層含義:

gcc 有一個編譯選項:-pg,當使用這個編譯選項編譯代碼時,他會在每一個函數的入口添加對 mcount 函數的調用,mcount 函數由 libc 提供,它的實現會根據具體的機器架構生成相應的代碼。一般情況下 mcount 函數會記錄當前函數的地址、耗時等信息,在程序執行結束後,生成一個. out 文件用於給 gprof 來做性能分析的。我們可以編譯一個 hello.c 文件查看彙編代碼中包含了 mcount 調用。

root@zfane-maxpower:~/traceing# cat hello.c
#include <stdio.h>
void 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 <linux/kernel.h>
#include <linux/module.h>
#include <linux/fprobe.h>
#include <linux/sched/debug.h>
#include <linux/slab.h>
#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 是如何使用 trace event。

$ 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 程序根據其用途劃分爲多種類型,在追蹤方面有如下類型:

那麼 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: "file, __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            755K

BTF-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(<https://clang.llvm.org/docs/LanguageExtensions.html>))和 BPF 加載器,BPF Prog 就可以準確訪問某個 Struct 或者 Union 類型的成員,而不用擔心重定位問題。

在內核 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 的。


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/dlyx-jUJ-CnL5efcWW9PXQ