在 eBPF 開發中,程式片段定義(section definition)是決定 eBPF 程式附加位置的關鍵元素。它透過 SEC() 巨集指定程式應該掛載的位置,接著才是程式本身的實作。這種設計讓我們能精確控制程式在核心中的執行時機。

以下是一個攔截 execve 系統呼叫的 eBPF 程式範例:

SEC("ksyscall/execve")
int BPF_KPROBE_SYSCALL(hello, const char *pathname)
{
    struct data_t data = {};
    struct user_msg_t *p;
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    bpf_get_current_comm(&data.command,
                        sizeof(data.command));
    bpf_probe_read_user_str(&data.path, sizeof(data.path),
                          pathname);
    p = bpf_map_lookup_elem(&my_config, &data.uid);
    if (p != 0) {
        bpf_probe_read_kernel(&data.message,
                            sizeof(data.message), p->message);
    } else {
        bpf_probe_read_kernel(&data.message,
                            sizeof(data.message), message);
    }
    bpf_perf_event_output(ctx, &output, BPF_F_CURRENT_CPU,
                        &data, sizeof(data));
    return 0;
}

這段程式碼定義了一個 eBPF 程式,它會在每次執行 execve 系統呼叫時被觸發。讓我逐步解析其關鍵部分:

  1. SEC("ksyscall/execve") 指定此程式應附加到 execve 系統呼叫的入口點。

  2. BPF_KPROBE_SYSCALL 是 libbpf 提供的巨集,它簡化了系統呼叫引數的存取。在這個例子中,它讓我們能直接透過 pathname 引數取得即將執行的程式路徑。

  3. 程式收集了多項資訊:

    • 目前處理程式的 PID 與 UID
    • 處理程式名稱 (透過 bpf_get_current_comm)
    • 即將執行的程式路徑 (透過 bpf_probe_read_user_str)
  4. 程式查詢設定對映 (my_config),根據使用者 ID 決定要顯示的訊息。

  5. 最後,所有收集的資料透過 bpf_perf_event_output 送到使用者空間。

libbpf 與 BCC 的差異

在實作 eBPF 程式時,libbpf 與 BCC 框架有一些關鍵差異。以上面的範例來說,若使用 BCC 實作,程式碼會有以下不同:

// BCC 版本
int hello(void *ctx)
{
    struct data_t data = {};
    char message[12] = "Hello World";
    struct user_msg_t *p;
    
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    bpf_get_current_comm(&data.command, sizeof(data.command));
    
    // BCC 版本中無法直接存取 pathname
    
    p = my_config.lookup(&data.uid);
    if (p != 0) {
        // ...使用 p->message
    } else {
        // ...使用 message
    }
    
    output.perf_submit(ctx, &data, sizeof(data));
    return 0;
}

主要差異分析:

  1. 輔助函式存取方式

    • libbpf 版本直接呼叫 BPF 輔助函式,如 bpf_map_lookup_elem()
    • BCC 版本使用高階抽象,如 my_config.lookup()output.perf_submit()
  2. 變數定義位置

    • libbpf 版本可以使用全域變數 (char message[12] = "Hello World")
    • BCC 當前不支援全域變數,必須在函式內部定義
  3. 系統呼叫引數存取

    • libbpf 使用 BPF_KPROBE_SYSCALL 巨集簡化引數存取
    • BCC 需要更多手動操作來存取系統呼叫引數
  4. 上下文變數

    • libbpf 中的 ctx 變數由 BPF_KPROBE_SYSCALL 巨集隱式定義
    • 這可能讓程式碼閱讀起來有些困惑,但提供了很大的便利性

CO-RE 技術與記憶體存取

記憶體存取限制與解決方案

eBPF 程式在存取記憶體時有嚴格限制,無法像一般 C 程式那樣直接透過指標讀取記憶體(如 x = p->y)。這是因為 eBPF 驗證器需要確保程式不會存取無效記憶體,導致系統不穩定。

為解決這個限制,eBPF 提供了 bpf_probe_read_*() 系列輔助函式。而 libbpf 進一步在這些函式基礎上提供了 CO-RE(Compile Once, Run Everywhere)包裝器,讓程式能在不同核心版本間移植。

// CO-RE 包裝器定義
#define bpf_core_read(dst, sz, src) \
    bpf_probe_read_kernel(dst, sz, \
        (const void *)__builtin_preserve_access_index(src))

這個定義揭示了 CO-RE 的關鍵機制:

  1. bpf_core_read() 實際上呼叫 bpf_probe_read_kernel(),但增加了一個關鍵元素
  2. __builtin_preserve_access_index() 是 Clang 編譯器的特殊擴充套件,它告訴編譯器為這個記憶體存取生成 CO-RE 重定位項
  3. 這些重定位項讓 libbpf 能在載入程式到核心時,根據實際核心結構定義調整記憶體存取指令

這種機制解決了 eBPF 程式最大的移植性問題:不同核心版本中結構體佈局可能不同,導致偏移量計算錯誤。

簡化記憶體存取鏈

為了簡化多層指標存取,libbpf 提供了 BPF_CORE_READ() 巨集,讓你能在一行程式碼中完成多次指標解參照:

// 傳統方式
struct b_t *b;
struct c_t *c;
bpf_core_read(&b, 8, &a->b);
bpf_core_read(&c, 8, &b->c);
bpf_core_read(&d, 8, &c->d);

// 使用 BPF_CORE_READ 巨集
d = BPF_CORE_READ(a, b, c, d);

BPF_CORE_READ() 大幅簡化了程式碼,讓開發者能用接近原生 C 語法的方式處理多層指標。這不僅提高了可讀性,還減少了出錯機會。這個巨集內部會展開為多個 bpf_core_read() 呼叫,每個呼叫負責一層指標解參照。

eBPF 程式編譯與最佳化

授權宣告

eBPF 程式必須宣告其授權,這是核心要求的一部分。通常透過以下方式實作:

char LICENSE[] SEC("license") = "Dual BSD/GPL";

這個宣告告訴核心此程式使用 BSD/GPL 雙授權,允許載入到核心中執行。

編譯 eBPF 程式的關鍵選項

要正確編譯支援 CO-RE 的 eBPF 程式,需要注意以下幾個關鍵編譯選項:

1. 除錯資訊處理

clang -g ... # 包含除錯資訊
llvm-strip -g <object file> # 移除不需要的 DWARF 資訊

-g 選項讓編譯器包含 BTF(BPF Type Format)資訊,這對 CO-RE 至關重要。但同時也會加入 DWARF 除錯資訊,這對 eBPF 程式來說是不必要的,可以用 llvm-strip 移除以減小目標檔大小。

2. 最佳化級別

clang -O2 ... # 至少使用 -O2 最佳化

至少 -O2 最佳化級別是必要的,這讓編譯器能生成透過 eBPF 驗證器的位元碼。例如,沒有足夠最佳化時,Clang 可能會生成 callx <register> 指令來呼叫輔助函式,但 eBPF 不支援從暫存器呼叫地址。

3. 目標架構指定

clang -D__TARGET_ARCH_x86 ... # 指定 x86 架構

使用 libbpf 提供的某些巨集時,需要在編譯時指定目標架構。例如 BPF_KPROBEBPF_KPROBE_SYSCALL 巨集依賴於架構特定的 pt_regs 結構,這個結構儲存 CPU 暫存器內容,而暫存器定義因架構而異。

這些編譯選項共同確保了生成的 eBPF 目標檔:

  1. 包含必要的型別資訊(BTF),使 CO-RE 功能正常工作
  2. 生成最佳化的指令序列,能透過嚴格的 eBPF 驗證器檢查
  3. 正確處理架構相關的結構和暫存器存取

不正確的編譯選項可能導致程式無法載入、驗證失敗或在不同核心版本間行為不一致。

深入理解 CO-RE 重定位機制

CO-RE 技術的核心在於其重定位機制,它讓編譯好的 eBPF 程式能適應不同核心版本中的結構體變化。

重定位項的工作原理

當 Clang 編譯包含 __builtin_preserve_access_index() 的程式碼時,會為每個這樣的存取生成重定位項。這些重定位項包含:

  1. 存取路徑訊息(如 task->mm->start_code
  2. 原始編譯環境中的偏移量

當 libbpf 將程式載入到核心時,會:

  1. 分析目標核心的 BTF 資訊
  2. 計算同一存取路徑在目標核心中的實際偏移量
  3. 修改 eBPF 指令,使用正確的偏移量

這個過程完全透明,開發者只需確保程式使用 CO-RE 輔助函式進行記憶體存取即可。

實際案例分析

假設我們有一個存取 task_struct->mm->start_code 的程式:

uint64_t start_code = BPF_CORE_READ(task, mm, start_code);

在編譯時,假設 mmtask_struct 中的偏移量是 0x300,而 start_codemm_struct 中的偏移量是 0x40。

如果在目標核心中,由於新增欄位導致 mm 偏移量變為 0x320,start_code 偏移量變為 0x48,CO-RE 機制會自動調整指令,使用新的偏移量,確保程式正確存取相同的欄位。

這是 CO-RE 技術最強大的部分,它讓我們能編寫一次 eBPF 程式,然後在各種不同核心版本上執行,而不需要為每個核心版本重新編譯。

eBPF 開發工具選擇與比較

在開發 eBPF 程式時,開發者主要有兩種框架選擇:BCC 和 libbpf。這兩種方法各有優缺點,適合不同的使用場景。

BCC 框架特點

BCC 框架提供了更高層次的抽象,簡化了 eBPF 程式的開發:

  1. 優點

    • 簡化的 API(如 map.lookup() 代替 bpf_map_lookup_elem()
    • 自動處理編譯與載入過程
    • 整合 Python 指令碼,便於快速原型開發
  2. 缺點

    • 需要在目標系統安裝編譯工具鏈
    • 每次執行都需要重新編譯
    • 啟動較慢(需要編譯時間)

libbpf 與 CO-RE 方法

libbpf 與 CO-RE 方法提供了更接近核心的體驗:

  1. 優點

    • 預編譯 eBPF 目標檔,無需目標系統安裝編譯器
    • 更快的啟動時間
    • 更好的可移植性(編譯一次,到處執行)
    • 更好的生產環境適用性
  2. 缺點

    • 更低層次的 API,需要直接使用 BPF 輔助函式
    • 開發流程複雜度稍高
    • 需要理解更多底層概念

選擇建議

根據玄貓的經驗,這兩種方法適合不同場景:

  • BCC 適合:快速原型開發、臨時除錯工具、學習 eBPF 概念
  • libbpf/CO-RE 適合:生產環境佈署、需要廣泛分發的工具、效能敏感的應用

對於初學者,可以先從 BCC 開始學習基本概念,然後隨著對 eBPF 理解的加深,逐漸過渡到 libbpf 與 CO-RE 方法。

實用技巧與最佳實踐

在開發 eBPF 程式時,有一些實用技巧可以幫助你避免常見陷阱:

1. 記憶體存取安全

永遠不要嘗試直接存取指標指向的記憶體,而是使用適當的 BPF 輔助函式:

// 錯誤方式 - 會被驗證器拒絕
int value = *ptr;

// 正確方式
int value;
bpf_probe_read_kernel(&value, sizeof(value), ptr);

// 或使用 CO-RE 輔助函式
int value = BPF_CORE_READ(container, field);

2. 錯誤處理

始終檢查 BPF 輔助函式的回傳值,特別是那些可能失敗的函式:

struct data *ptr = bpf_map_lookup_elem(&map, &key);
if (!ptr) {
    // 處理查詢失敗的情況
    return -1;
}
// 現在可以安全使用 ptr

3. 編譯與除錯

保留 BTF 資訊以便於除錯,但移除不必要的 DWARF 資訊以減小檔案大小:

# 編譯時包含所有除錯資訊
clang -g -O2 -target bpf -c prog.c -o prog.o

# 移除 DWARF 但保留 BTF
llvm-strip -g prog.o

4. 全域變數使用

在 libbpf 程式中,全域變數是允許的,但有一些注意事項:

// 只讀全域變數很安全
const char message[] = "Hello World";

// 可變全域變數需要特別注意
// 它們在每個 CPU 核心上都有獨立副本
int counter = 0;  // 每個核心都有自己的 counter

5. 避免複雜控制流

eBPF 驗證器對控制流有嚴格限制,避免過於複雜的迴圈和條件分支:

// 可能被拒絕的複雜迴圈
for (i = 0; i < MAX; i++) {
    if (complex_condition) {
        // 巢狀條件
        if (another_condition) {
            // ...
        }
    }
}

// 更簡單的替代方案
#pragma unroll
for (i = 0; i < 5; i++) {  // 固定次數的迴圈更容易透過驗證
    // 簡化的條件邏輯
}

這些最佳實踐可以幫助你編寫出更可靠、更高效的 eBPF 程式,同時減少與驗證器鬥爭的時間。

eBPF 程式設計提供了強大的系統可觀測性和網路控制能力,但也帶來了一系列獨特的挑戰。本文探討了 eBPF 程式的結構、記憶體存取限制以及 CO-RE 技術如何實作跨核心版本相容。

透過比較 BCC 和 libbpf 方法的差異,我們看到了不同方法適用的場景。BCC 提供了更高層次的抽象和快速原型開發能力,而 libbpf 與 CO-RE 則提供了更好的可移植性和生產環境適用性。

記憶體存取是 eBPF 程式設計中最具挑戰性的部分之一,但 CO-RE 技術透過巧妙的重定位機制解決了這個問題。透過正確使用 bpf_core_read()BPF_CORE_READ() 等輔助函式,我們可以編寫出既安全又可移植的程式。

正確的編譯選項和理解底層機制對於成功開發 eBPF 程式至關重要。透過掌握本文討論的技術和最佳實踐,開發者可以充分利用 eBPF 的強大功能,同時避免常見的陷阱和限制。

隨著 eBPF 生態系統的不斷發展,CO-RE 等技術將繼續降低開發門檻,讓更多開發者能夠利用這一強大技術構建下一代系統工具和應用程式。

BPF CO-RE:核心原理與實作技巧

在開發eBPF程式時,我們經常面臨一個重大挑戰:如何確保在一個系統上編譯的eBPF程式能夠在不同核心版本的系統上正確執行。這正是BPF CO-RE(Compile Once, Run Everywhere,編譯一次,到處執行)技術的核心價值所在。

若不使用CO-RE,開發者就必須為每個目標架構編寫特定的程式碼來存取核心資料結構。正如某些人所戲稱的,這種方法應該被稱為「為每個架構編譯一次,到處執行」,雖然這樣的說法有點拗口,但確實道出了問題的本質。

編譯BPF CO-RE物件的Makefile設定

以下是一個編譯CO-RE物件的Makefile範例(摘自GitHub儲存函式庫的chapter5目錄):

hello-buffer-config.bpf.o: %.o: %.c
	clang \
	-target bpf \
	-D __TARGET_ARCH_$(ARCH) \
	-I/usr/include/$(shell uname -m)-linux-gnu \
	-Wall \
	-O2 -g \
	-c $< -o $@
	llvm-strip -g $@

這個Makefile片段定義瞭如何將C原始碼檔案編譯成eBPF物件檔案。關鍵引數包括:

  • -target bpf:指定目標為BPF虛擬機器
  • -D __TARGET_ARCH_$(ARCH):定義目標架構的巨集
  • -I/usr/include/$(shell uname -m)-linux-gnu:包含特定架構的標頭檔
  • -O2 -g:啟用最佳化並保留除錯資訊
  • llvm-strip -g:移除物件檔中的除錯資訊,減小檔案大小

如果你使用範例程式碼,只需在chapter5目錄中執行make命令,就能建構出eBPF物件檔案hello-buffer-config.bpf.o及其配套的使用者空間執行檔。

物件檔中的BTF資訊

BTF(BPF Type Format)是CO-RE技術的基礎,它在ELF物件檔中以兩個區段的形式存在:.BTF(包含資料和字串資訊)和.BTF.ext(包含函式和行資訊)。我們可以使用readelf工具檢視這些區段:

$ readelf -S hello-buffer-config.bpf.o | grep BTF
[10] .BTF             PROGBITS 0000000000000000 000002c0
[11] .rel.BTF         REL      0000000000000000 00000e50
[12] .BTF.ext         PROGBITS 0000000000000000 00000b18
[13] .rel.BTF.ext     REL      0000000000000000 00000ea0

bpftool工具允許我們檢查物件檔中的BTF資料:

bpftool btf dump file hello-buffer-config.bpf.o

這個命令的輸出與之前從已載入的程式和對映中傾印BTF資訊的輸出相似。

BPF重定位機制

libbpf函式庫能夠讓eBPF程式適應目標核心上的資料結構設定,即使這種設定與編譯程式碼的核心不同。要實作這一點,libbpf需要Clang在編譯過程中生成的BPF CO-RE重定位資訊。

重定位機制的核心在linux/bpf.h標頭檔中定義的struct bpf_core_relo結構:

struct bpf_core_relo {
    __u32 insn_off;
    __u32 type_id;
    __u32 access_str_off;
    enum bpf_core_relo_kind kind;
};

eBPF程式的CO-RE重定位資料由每個需要重定位的指令對應的這些結構組成。例如,如果指令是將暫存器設定為結構中某欄位的值,該指令的bpf_core_relo結構(由insn_off欄位識別)會編碼該結構的BTF型別(type_id欄位),並指示相對於該結構如何存取欄位(access_str_off)。

核心資料結構的重定位資料由Clang自動生成並編碼在ELF物件檔中。這一過程由vmlinux.h檔案開頭附近的以下行觸發:

#pragma clang attribute push (__attribute__((preserve_access_index)), \
apply_to = record)

preserve_access_index屬性告訴Clang為型別定義生成BPF CO-RE重定位。clang attribute push部分表示該屬性應用於所有定義,直到clang attribute pop(出現在檔案末尾)。這意味著Clang為vmlinux.h中定義的所有型別生成重定位資訊。

觀察重定位過程

當載入BPF程式時,我們可以使用bpftool並開啟除錯資訊(-d標誌)來觀察重定位過程:

bpftool -d prog load hello.bpf.o /sys/fs/bpf/hello

這會生成大量輸出,但與重定位相關的部分如下:

libbpf: CO-RE relocating [24] struct user_pt_regs: found target candidate [205] struct user_pt_regs in [vmlinux]
libbpf: prog 'hello': relo #0: <byte_off> [24] struct user_pt_regs.regs[0] (0:0:0 @ offset 0)
libbpf: prog 'hello': relo #0: matching candidate #0 <byte_off> [205] struct user_pt_regs.regs[0] (0:0:0 @ offset 0)
libbpf: prog 'hello': relo #0: patched insn #1 (LDX/ST/STX) off 0 -> 0

從這個例子中,我們可以看到hello程式的BTF資訊中的型別ID 24指的是名為user_pt_regs的結構。libbpf函式庫已將其與核心結構比對,該結構也稱為user_pt_regs,在vmlinux BTF資料集中具有型別ID 205。

在這個例子中,因為我在同一台機器上編譯和載入程式,所以型別定義是相同的,結構開始的偏移量0保持不變,對指令#1的「修補」也保持不變。但在不同核心版本間,這些偏移量可能會變化,這時重定位機制就顯得尤為重要。

使用者空間程式碼與CO-RE整合

在實際應用中,我們通常不希望要求使用者執行bpftool來載入eBPF程式。相反,我們希望將這些功能內建到我們提供的使用者空間程式中。

libbpf函式庫與使用者空間程式

libbpf是一個使用者空間函式庫,如果你用C語言編寫應用程式的使用者空間部分,可以直接使用它。即使不使用CO-RE,你也可以使用這個函式庫。

這個函式庫提供了包裝bpf()系統呼叫及相關功能的函式,用於執行將程式載入核心並將其附加到事件,或從使用者空間存取對映資訊等操作。最常見與最簡單的使用方式是透過自動生成的BPF骨架程式碼。

BPF骨架程式碼

我們可以使用bpftool從現有的ELF格式的eBPF物件自動生成骨架程式碼:

bpftool gen skeleton hello-buffer-config.bpf.o > hello-buffer-config.skel.h

檢視這個骨架標頭檔,你會發現它包含了eBPF程式和對映的結構定義,以及幾個以hello_buffer_config_bpf__開頭的函式(根據物件檔案的名稱)。這些函式管理eBPF程式和對映的生命週期。

你不必使用骨架程式碼——如果你願意,可以直接呼叫libbpf——但自動生成的程式碼通常能節省你的時間。

在生成的骨架檔案末尾,你會看到一個名為hello_buffer_config_bpf__elf_bytes的函式,它回傳ELF物件檔案hello-buffer-config.bpf.o的位元組內容。一旦骨架生成後,我們實際上不再需要那個物件檔案了。你可以透過執行make生成hello-buffer-config執行檔,然後刪除.o檔案來測試這一點;執行檔內已包含eBPF位元組程式碼。

注意:如果你願意,可以使用libbpf函式bpf_object__open_file從ELF檔案而非骨架檔案中的位元組載入eBPF程式和對映。

以下是使用生成的骨架程式碼管理eBPF程式和對映生命週期的使用者空間程式碼概要(為清晰起見,我省略了一些細節和錯誤處理,但你可以在chapter5/hello-buffer-config.c中找到完整的原始程式碼):

... [其他 #includes]
#include "hello-buffer-config.h"
#include "hello-buffer-config.skel.h"
... [一些回呼函式]
int main()
{
    struct hello_buffer_config_bpf *skel;
    struct perf_buffer *pb = NULL;
    int err;

    libbpf_set_print(libbpf_print_fn);
    skel = hello_buffer_config_bpf__open_and_load();
    ...
    err = hello_buffer_config_bpf__attach(skel);

這段程式碼展示瞭如何使用生成的骨架標頭檔來管理eBPF程式的生命週期:

  1. 包含必要的標頭檔,包括自動生成的骨架標頭
  2. main()函式中:
    • 宣告骨架結構和perf緩衝區指標
    • 設定libbpf的列印回呼函式
    • 使用hello_buffer_config_bpf__open_and_load()函式開啟並載入eBPF程式
    • 使用hello_buffer_config_bpf__attach()將程式附加到相應的系統事件

這種方法極大地簡化了eBPF程式的載入和管理過程,讓開發者能夠專注於程式邏輯而非底層細節。

實踐中的CO-RE技術優勢

在我多年開發eBPF應用的經驗中,CO-RE技術帶來了幾個關鍵優勢:

  1. 跨核心版本相容性:同一個eBPF應用能夠在不同核心版本上執行,大幅降低維護成本。

  2. 佈署簡化:無需為每個目標環境編譯特定版本,簡化了CI/CD流程和版本管理。

  3. 開發效率提升:開發者可以在一個環境中開發和測試,然後放心佈署到各種生產環境。

  4. 核心資料結構變更適應:即使核心資料結構在不同版本間發生變化,CO-RE也能自動適應這些變化。

CO-RE技術的進階考量

雖然CO-RE技術強大,但在使用時仍需注意一些細節:

  1. 核心功能支援:較舊的核心可能不支援某些eBPF功能,即使CO-RE能處理資料結構差異。

  2. BTF資訊存在:目標系統需要啟用BTF支援,這在較舊的系統上可能不可用。

  3. 複雜結構變更:對於極端的資料結構重組,CO-RE的自動適應可能會有限制。

  4. 效能考量:重定位過程會引入少量額外開銷,但通常可以忽略不計。

CO-RE技術代表了eBPF生態系統的重大進步,它解決了eBPF程式可移植性的核心挑戰,讓「編譯一次,到處執行」不再是一個遙不可及的夢想。透過理解並善用這一技術,我們可以開發出真正跨核心版本、跨發行版的eBPF應用。

在後續的探索中,我們將深入研究如何利用libbpf和其他框架來構建完整的eBPF應用,以及如何處理更複雜的使用案例和挑戰。無論是系統監控、安全防護還是網路最佳化,CO-RE技術都為我們提供了堅實的基礎。

libbpf 與 eBPF 程式生命週期管理

在進入 eBPF 程式開發的世界後,管理這些程式的生命週期變得至關重要。從載入程式到核心、附加到特定事件,再到處理來自核心的事件資料,每個環節都需要精確控制。本文將探討如何使用 libbpf 來有效管理 eBPF 程式的整個生命週期,特別是效能緩衝區(perf buffer)的處理機制。

效能緩衝區處理的核心機制

當我們需要從核心空間接收事件資料時,效能緩衝區(perf buffer)提供了一種高效的機制。以下是一段關鍵程式碼,展示瞭如何設定和輪詢效能緩衝區:

pb = perf_buffer__new(bpf_map__fd(skel->maps.output), 8,
                      handle_event,
                      lost_event, NULL, NULL);
...
while (true) {
    err = perf_buffer__poll(pb, 100);
    ...
}
perf_buffer__free(pb);
hello_buffer_config_bpf__destroy(skel);
return -err;

這段程式碼展示了 perf buffer 的完整生命週期:

  1. perf_buffer__new() 建立一個新的效能緩衝區,連線到 eBPF 地圖(由 skel->maps.output 參照)
  2. 第一個引數是地圖的檔案描述符,第二個引數 8 是頁面數量
  3. handle_event 是當資料從核心到達時會被呼叫的回呼函式
  4. lost_event 是當緩衝區滿與資料遺失時呼叫的函式
  5. 主迴圈中,perf_buffer__poll(pb, 100) 以 100 毫秒的逾時輪詢緩衝區
  6. 最後釋放資源:perf_buffer__free()hello_buffer_config_bpf__destroy()

這種模式是大多數 eBPF 事件處理程式的基礎架構。

eBPF 程式的載入與管理

eBPF 程式從開發到執行需要經過幾個關鍵階段。讓我們深入瞭解使用 libbpf 管理這些階段的細節。

程式與地圖載入到核心

在 libbpf 應用程式中,載入 eBPF 程式的第一步是:

skel = hello_buffer_config_bpf__open_and_load();

這個函式名稱暗示了兩個階段:開啟(open)和載入(load)。

  • 開啟階段:讀取 ELF 資料並將其區段轉換為代表 eBPF 程式和地圖的結構
  • 載入階段:將這些地圖和程式載入到核心,並執行必要的 CO-RE(Compile Once, Run Everywhere)修正

這兩個階段也可以分開處理:

skel = hello_buffer_config_bpf__open();
if (!skel) {
    // 錯誤處理...
}
skel->data->c = 10;  // 在載入前設定程式
err = hello_buffer_config_bpf__load(skel);

分離開啟和載入階段的主要優勢在於可以在載入前操作 eBPF 資訊。這在需要根據執行時條件設定程式時特別有用。上面的例子展示瞭如何在載入前將全域變數 c 初始化為特定值。

值得注意的是,skel 骨架物件只是 ELF 資料的使用者空間表示。一旦程式載入到核心後,更改骨架物件中的值並不會影響核心端的資料。

存取現有地圖

有時候,我們需要讓 eBPF 程式重用已存在的地圖,而不是建立新地圖。常見的使用場景包括:

  1. 在不同 eBPF 程式之間分享資訊
  2. 連線到特定名稱的地圖(如前一章中的 bpftool 範例)

libbpf 提供了 bpf_map__set_autocreate() 函式來覆寫自動建立行為。若要存取現有地圖,可以使用 bpf_obj_get() 函式:

struct bpf_map_info info = {};
unsigned int len = sizeof(info);
int findme = bpf_obj_get("/sys/fs/bpf/findme");
if (findme <= 0) {
    printf("No FD\n");
} else {
    bpf_obj_get_info_by_fd(findme, &info, &len);
    printf("Name: %s\n", info.name);
}

這段程式碼展示瞭如何透過固定路徑存取現有地圖:

  1. bpf_obj_get() 接收地圖的固定路徑,回傳檔案描述符
  2. 如果找到地圖,使用 bpf_obj_get_info_by_fd() 取得地圖資訊
  3. 最後列印地圖名稱

要測試這個功能,可以使用 bpftool 建立一個地圖:

$ bpftool map create /sys/fs/bpf/findme type array key 4 value 32 entries 4 name findme

執行程式後會輸出:Name: findme

附加到事件

在 hello-buffer-config 範例中,下一步是將程式附加到 execve 系統呼叫函式:

err = hello_buffer_config_bpf__attach(skel);

libbpf 會自動從程式的 SEC() 定義中取得附加點。如果沒有完全定義附加點,libbpf 提供了一系列函式,如 bpf_program__attach_kprobebpf_program__attach_xdp 等,用於附加不同型別的程式。

這種自動附加機制極大地簡化了 eBPF 程式的佈署。骨架程式碼會根據 SEC() 定義自動選擇正確的附加方法,無需手動指定附加型別和目標。這也是為什麼在 eBPF 程式中正確設定 SEC() 巨集如此重要。

事件緩衝區管理的探討

在效能監控和事件追蹤方面,正確管理事件緩衝區至關重要。讓我們深入瞭解如何使用 libbpf 設定和管理效能緩衝區。

設定效能緩衝區

設定效能緩衝區使用 libbpf 中定義的函式,而不是骨架中的函式:

pb = perf_buffer__new(bpf_map__fd(skel->maps.output), 8,
                      handle_event,
                      lost_event, NULL, NULL);

這個函式呼叫設定了一個新的效能緩衝區:

  1. 第一個引數是 “output” 地圖的檔案描述符
  2. 第二個引數 8 指定了緩衝區的頁面數量
  3. handle_event 是新資料到達時的回呼函式
  4. lost_event 是緩衝區滿時的回呼函式
  5. 最後兩個 NULL 引數是額外的上下文和標誌

在實際應用中,頁面數量應根據預期的事件頻率和大小來調整。頁面太少可能導致事件遺失,而頁面太多則會消耗不必要的記憶體。

輪詢效能緩衝區

設定緩衝區後,程式需要不斷輪詢以接收新事件:

while (true) {
    err = perf_buffer__poll(pb, 100);
    ...
}

這個簡單的迴圈是事件處理的核心:

  1. perf_buffer__poll(pb, 100) 以 100 毫秒的逾時輪詢緩衝區
  2. 當資料到達時,之前設定的 handle_event 回呼函式會被呼叫
  3. 如果緩衝區滿,則呼叫 lost_event 回呼函式

逾時值(這裡是 100 毫秒)決定了程式回應其他事件的靈敏度。較小的值會增加 CPU 使用率,但提高回應速度;較大的值則相反。

資源清理

正確的資源清理對於避免記憶體洩漏和其他問題至關重要:

perf_buffer__free(pb);
hello_buffer_config_bpf__destroy(skel);

這兩行程式碼執行關鍵的清理步驟:

  1. perf_buffer__free(pb) 釋放效能緩衝區資源
  2. hello_buffer_config_bpf__destroy(skel) 從核心中解除安裝 eBPF 程式和地圖

這種清理模式確保了所有資源都被正確釋放,無論程式是正常結束還是因錯誤而終止。

實際執行與輸出

如果編譯並執行 hello-buffer-config 程式,會看到類別似以下的輸出:

23664 501 bash Hello World
23665 501 bash Hello World
23667 0 cron Hello World
23668 0 sh Hello World

這個輸出顯示了每次執行 execve 系統呼叫時的程式 ID、使用者 ID、命令名稱,以及來自 eBPF 程式的 “Hello World” 訊息。

libbpf 程式碼範例資源

對於想要進一步探索 libbpf 的開發者,有幾個優秀的資源可供參考:

  1. libbpf-bootstrap 專案:提供了一系列範例程式,幫助你入門
  2. BCC 專案的 libbpf-tools 目錄:包含許多從 BCC 遷移到 libbpf 的工具

這些專案不僅展示了 libbpf 的基本用法,還包含了許多實際的使用案例和最佳實踐。

CO-RE 與 eBPF 可移植性

CO-RE(Compile Once, Run Everywhere)技術極大地提高了 eBPF 程式的可移植性,使其能夠在不同於編譯時的核心版本上執行。這對於希望向使用者和客戶提供生產級工具的工具開發者來說是一個巨大的進步。

CO-RE 透過將型別資訊編碼到編譯後的目標檔案中,並在載入到核心時使用重定位來重寫指令來實作這一目標。這種方法使 eBPF 程式能夠適應不同核心版本中的結構體佈局變化。

在實踐過程中,我發現 CO-RE 技術徹底改變了 eBPF 工具的佈署方式。以前我們可能需要為每個核心版本維護不同的二進位檔案,或者在執行時重新編譯程式。現在,一個二進位檔案可以在多種核心版本上正常工作,大簡化了佈署流程。

進階練習與探索

為了更深入地理解 BTF、CO-RE 和 libbpf,以下是一些值得嘗試的練習:

  1. 使用 bpftool btf dump mapbpftool btf dump prog 檢查與地圖和程式相關聯的 BTF 資訊
  2. 比較同一程式在 ELF 目標檔案形式和載入到核心後的 BTF 資訊輸出
  3. 分析 bpftool -d prog load 的輸出,觀察各個區段的載入、授權檢查和重定位過程
  4. 嘗試針對 BTFHub 中的不同 vmlinux 標頭檔案構建 BPF 程式
  5. 修改 hello-buffer-config.c 程式,使用地圖為不同的使用者 ID 設定不同的訊息
  6. 嘗試更改 SEC() 中的區段名稱,觀察載入程式時的錯誤

這些練習將幫助你更好地理解 eBPF 程式的載入過程、BTF 資訊的使用方式,以及如何利用 libbpf 提供的功能來構建強大的 eBPF 應用程式。

在 eBPF 開發過程中,理解程式的生命週期管理至關重要。從程式載入、附加到事件,再到處理來自核心的資料,每個環節都需要精確控制。使用 libbpf 提供的 API 和自動生成的骨架程式碼,可以大簡化這些管理任務,讓開發者專注於實作核心功能。隨著 CO-RE 技術的成熟,eBPF 程式的可移植性得到了極大提升,為系統監控和效能分析工具開啟了新的可能性。

eBPF 驗證器:確保程式安全的關鍵機制

在 eBPF 技術中,驗證器是一個至關重要的安全機制。當我們嘗試將 eBPF 程式載入核心時,驗證過程會確保該程式不會對系統造成危害。這個驗證步驟是 eBPF 能夠在核心空間安全執行的根本。在這篇文章中,我將探討驗證器的運作原理,並透過例項展示如何理解和解決驗證失敗的問題。

驗證過程包括檢查程式中的每一條可能執行路徑,確保每個指令都是安全的。驗證器還會對位元組碼進行一些更新,為執行做好準備。讓我們從一個基本的工作範例開始,然後逐步修改它,以展示各種驗證失敗的情況。

注意:本文中的範例程式碼可以在 GitHub 上的 learning-ebpf 儲存函式庫的 chapter6 目錄中找到。

需要明白的是,驗證器處理的是 eBPF 位元組碼,而非直接處理原始碼。這些位元組碼取決於編譯器的輸出。由於編譯器最佳化等因素,原始碼的變更可能不會在位元組碼中產生您預期的變化,因此也可能不會在驗證器的判決中得到您期望的結果。例如,驗證器會拒絕無法到達的指令,但編譯器可能在驗證器看到它們之前就將其最佳化掉。

驗證過程的內部機制

驗證器透過分析程式來評估所有可能的執行路徑。它按順序逐步檢查指令,評估而非實際執行它們。在這個過程中,它會在一個稱為 bpf_reg_state 的結構中追蹤每個暫存器的狀態。(這裡提到的暫存器是指第三章中介紹的 eBPF 虛擬機器的暫存器)。這個結構包含一個名為 bpf_reg_type 的欄位,描述該暫存器中儲存的值的型別。可能的型別有很多,包括:

  • NOT_INIT:表示暫存器尚未設定值。
  • SCALAR_VALUE:表示暫存器已設定為不代表指標的值。
  • 多種 PTR_TO_* 型別:表示暫存器持有指向某物的指標。這個"某物"可能是:
    • PTR_TO_CTX:暫存器持有指向傳遞給 BPF 程式的上下文的指標。
    • PTR_TO_PACKET:暫存器指向一個網路封包(在核心中作為 skb->data 儲存)。
    • PTR_TO_MAP_KEYPTR_TO_MAP_VALUE:顧名思義,指向對映鍵或值的指標。

還有其他幾種 PTR_TO_* 型別,完整集合可以在 linux/bpf.h 標頭檔中找到。

bpf_reg_state 結構還追蹤暫存器可能持有的值的範圍。驗證器使用這些資訊來判斷何時嘗試執行無效操作。

每當驗證器遇到分支(需要決定是按順序繼續還是跳到不同指令的地方)時,驗證器會將所有暫存器的當前狀態副本推入堆積積疊,並探索其中一條可能的路徑。它繼續評估指令,直到達程式末尾的回傳指令(或達到處理指令數量的限制,目前為一百萬條指令),此時它會從堆積積疊中彈出一個分支進行下一步評估。如果發現可能導致無效操作的指令,驗證就會失敗。

驗證每一種可能性在計算上可能非常昂貴,因此在實際操作中,有一種稱為狀態剪枝的最佳化方法,可以避免重新評估程式中本質上等效的路徑。在處理程式時,驗證器會記錄程式中某些指令處所有暫存器的狀態。如果後來它以比對狀態的暫存器到達同一指令,則無需繼續驗證該路徑的其餘部分,因為已知它是有效的。

驗證器及其剪枝過程的最佳化工作已經進行了很多。驗證器過去會在每個跳轉指令前後儲存剪枝狀態,但分析顯示這會導致平均每四條指令左右儲存一次狀態,而這些剪枝狀態的絕大多數永遠不會被比對。事實證明,無論是否分支,每10條指令儲存一次剪枝狀態更有效率。

驗證日誌解析

當程式驗證失敗時,驗證器會生成一個日誌,顯示它如何得出程式無效的結論。如果你使用 bpftool prog load,驗證器日誌會輸出到標準錯誤。當你使用 libbpf 編寫程式時,可以使用 libbpf_set_print() 函式設定一個處理程式,來顯示(或進行其他有用的處理)任何錯誤。

注意:如果你真的想深入瞭解驗證器的工作原理,你也可以讓它在成功時生成日誌,而不僅是在失敗時。這涉及將儲存驗證器日誌內容的緩衝區傳遞給載入程式到核心的 libbpf 呼叫,然後將該日誌的內容寫到螢幕上。

驗證器日誌包含驗證器完成的工作摘要,看起來像這樣:

processed 61 insns (limit 1000000) max_states_per_insn 0
total_states 4
peak_states 4 mark_read 3

在這個例子中,驗證器處理了61條指令,包括透過不同路徑到達同一指令而多次處理相同指令的可能性。注意,一百萬的複雜性限制是程式中指令數量的上限;在實際中,如果程式碼中有分支,驗證器將多次處理某些指令。

儲存的狀態總數為4,對於這個簡單的程式來說,這與儲存狀態的峰值數量相比對。如果一些狀態已被剪枝,峰值數可能低於總數。

日誌輸出包括驗證器分析的 BPF 指令,以及相應的 C 原始碼行(如果目標檔案是用 -g 標誌構建的,以包含除錯訊息)和驗證器狀態訊息的摘要。以下是與 hello-verifier.bpf.c 程式前幾行相關的驗證器日誌範例摘錄:

0: (bf) r6 = r1
; data.counter = c;

1: (18) r1 = 0xffff800008178000
3: (61) r2 = *(u32 *)(r1 +0)
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0)
R6_w=ctx(id=0,off=0,imm=0)
R10=fp0
; c++;
4: (bf) r3 = r2
5: (07) r3 += 1
6: (63) *(u32 *)(r1 +0) = r3
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0)
R2_w=inv(id=1,umax_value=4294967295,
var_off=(0x0; 0xffffffff))
R3_w=inv(id=0,umin_value=1,umax_value=4294967296,
var_off=(0x0; 0x1ffffffff)) R6_w=ctx(id=0,off=0,imm=0)
R10=fp0

日誌包括原始碼行,使得更容易理解輸出如何與原始碼關聯。這些原始碼是可用的,因為在編譯過程中使用了 -g 標誌來構建除錯訊息。

剖析驗證器日誌內容

讓我們更深入地解析這個驗證器日誌。每行開始的數字是指令的索引,後面括號中的十六進製程式碼是指令操作碼。例如,(bf) 是移動操作。在這個例子中:

  • 第0條指令將 r1 的值複製到 r6。
  • 第1條指令將一個具體的記憶體地址載入到 r1。
  • 第3條指令從 r1 指向的地址讀取一個32位無符號整數到 r2。

日誌中的 R1_w=map_value(...) 部分顯示了驗證器對暫存器狀態的追蹤。在這個案例中,R1 被識別為指向一個對映值的指標,R6 被識別為指向上下文的指標,R10 是幀指標(frame pointer)。

接下來的幾條指令實作了計數器的增加:

  • 第4條指令將 r2 的值複製到 r3。
  • 第5條指令將 r3 的值增加1。
  • 第6條指令將 r3 的新值寫回 r1 指向的記憶體位置。

驗證器日誌不僅幫助我們瞭解程式的執行流程,還能在驗證失敗時提供寶貴的診斷訊息。當你遇到驗證錯誤時,日誌會指出問題所在,這對於除錯 eBPF 程式非常重要。