瞭解了 BTF 的基本概念後,讓我們看如何在實際專案中應用 CO-RE 技術。CO-RE 的主要優勢在於它允許我們編寫一次 BPF 程式,然後在不同的核心版本上執行,而無需重新編譯。

建立 CO-RE 相容的 BPF 程式

要建立 CO-RE 相容的 BPF 程式,我們需要遵循以下步驟:

  1. 使用 libbpf 或其他支援 CO-RE 的函式庫
  2. 確保核心設定了 BTF 支援
  3. 使用 BPF 骨架來簡化使用者空間程式碼

以下是一個簡單的 CO-RE 相容程式範例:

// BPF 程式部分
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, u32);
    __type(value, u64);
} my_map SEC(".maps");

SEC("kprobe/do_sys_open")
int bpf_prog(struct pt_regs *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 count = 1;
    u64 *value;
    
    value = bpf_map_lookup_elem(&my_map, &pid);
    if (value) {
        count += *value;
    }
    bpf_map_update_elem(&my_map, &pid, &count, BPF_ANY);
    
    return 0;
}

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

這個簡單的 BPF 程式追蹤系統中的檔案開啟操作。它使用 kprobe 附加到 do_sys_open 函式,每次有程式開啟檔案時都會執行。程式會記錄每個程式(按 PID 識別)開啟的檔案數量,並將這些訊息儲存在一個雜湊對映中。

注意程式中的 SEC 巨集,它用於定義 ELF 節,這些節在載入程式時被 libbpf 識別和處理。SEC(".maps") 定義了對映節,而 SEC("kprobe/do_sys_open") 定義了程式將附加到的探針型別和位置。

使用 BPF 骨架簡化使用者空間程式

一旦我們編譯了 BPF 程式,就可以使用 bpftool gen skeleton 生成骨架,然後在使用者空間程式中使用它:

// 使用者空間程式部分
#include <stdio.h>
#include <unistd.h>
#include "my_bpf_program.skel.h"

int main()
{
    struct my_bpf_program *skel;
    int err;
    
    // 設定 libbpf 錯誤和除錯訊息回呼
    libbpf_set_print(libbpf_print_fn);
    
    // 開啟 BPF 應用
    skel = my_bpf_program__open();
    if (!skel) {
        fprintf(stderr, "Failed to open BPF skeleton\n");
        return 1;
    }
    
    // 載入並驗證 BPF 程式
    err = my_bpf_program__load(skel);
    if (err) {
        fprintf(stderr, "Failed to load and verify BPF skeleton\n");
        goto cleanup;
    }
    
    // 附加 BPF 程式
    err = my_bpf_program__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to attach BPF skeleton\n");
        goto cleanup;
    }
    
    printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
           "to see output of the BPF programs.\n");
    
    // 無限迴圈,直到按 Ctrl+C
    for (;;) {
        // 讀取對映資料並處理
        sleep(1);
    }
    
cleanup:
    my_bpf_program__destroy(skel);
    return -err;
}

這個使用者空間程式使用生成的骨架來管理 BPF 程式的生命週期。它首先開啟骨架,然後載入並驗證 BPF 程式,最後將程式附加到指定的事件。骨架提供了型別安全的 API,使得與 BPF 程式和對映的互動變得簡單。

注意使用者空間程式如何使用骨架提供的函式(如 my_bpf_program__open()my_bpf_program__load() 等)來管理 BPF 程式,而不是直接使用底層的 libbpf 函式。這大簡化了程式碼,並減少了錯誤的可能性。

CO-RE 重定位過程

CO-RE 的核心是重定位過程,它允許 BPF 程式適應不同核心版本中的結構佈局差異。當 BPF 程式被載入核心時,libbpf 會使用 BTF 訊息來確定程式編譯環境和執行環境之間的差異,並相應地調整程式。

這個過程包括:

  1. 從目標核心取得 BTF 訊息
  2. 比較程式編譯時的結構佈局與目標核心中的佈局
  3. 調整程式中的偏移量以適應目標核心

這種調整是透明的,開發者不需要手動處理這些差異,這正是 CO-RE 的美妙之處。

CO-RE 的實際案例研究

讓我們透過一個實際案例來更好地理解 CO-RE 的價值。假設我們想要開發一個監控網路通訊端活動的 BPF 程式,但我們知道不同核心版本中通訊端結構可能有差異。

不使用 CO-RE 的問題

在傳統方法中,我們可能需要:

  1. 為每個目標核心版本維護不同的程式碼分支
  2. 在執行時檢測核心版本並選擇正確的程式碼路徑
  3. 使用複雜的巨集和條件編譯來處理差異

這種方法不僅複雜與容易出錯,還會導致程式碼維護困難。

使用 CO-RE 的解決方案

使用 CO-RE,我們可以編寫一個單一版本的程式,它可以在所有支援的核心版本上執行:

// BPF 程式部分
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/socket.h>
#include <linux/net.h>

// 使用 CO-RE 重定位來存取通訊端結構
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(int));
    __uint(value_size, sizeof(int));
} events SEC(".maps");

SEC("kprobe/sock_sendmsg")
int BPF_KPROBE(trace_sendmsg, struct socket *sock, struct msghdr *msg)
{
    struct event_t {
        u32 pid;
        u16 family;
        u16 type;
        u64 ts;
    } event;
    
    // 取得程式 ID
    event.pid = bpf_get_current_pid_tgid() >> 32;
    event.ts = bpf_ktime_get_ns();
    
    // 存取通訊端結構 - CO-RE 會自動處理不同核心版本的偏移量
    event.family = BPF_CORE_READ(sock, sk, __sk_common.skc_family);
    event.type = BPF_CORE_READ(sock, type);
    
    // 將事件傳送到效能緩衝區
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
    
    return 0;
}

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

這個程式使用 BPF_CORE_READ 巨集來存取通訊端結構中的欄位。這個巨集是 CO-RE 的關鍵部分,它告訴 libbpf 這些欄位存取需要在載入時進行重定位。

當程式在不同的核心版本上執行時,CO-RE 會自動調整對 sock->sk->__sk_common.skc_familysock->type 的存取,以比對目標核心中的實際結構佈局。這使得程式能夠在不同的核心版本上正確執行,而無需任何修改或重新編譯。

注意 BPF_KPROBE 巨集的使用,它簡化了 kprobe 處理函式的定義,並自動處理引數存取。這是 libbpf 提供的另一個便利功能,使得 BPF 程式更容易編寫和理解。

CO-RE 與 BCC 的比較

BCC (BPF Compiler Collection) 是另一個流行的 BPF 開發框架,它採用不同的方法來處理核心版本差異:

  1. BCC 在執行時編譯 BPF 程式,使用目標系統的核心頭檔案
  2. CO-RE 在編譯時生成可重定位的 BPF 程式,在載入時進行調整

CO-RE 的優勢包括:

  • 更快的啟動時間 - 無需在執行時編譯
  • 更小的佈署包 - 無需包含核心頭檔案
  • 更好的可移植性 - 一個二進位檔案可以在多個核心版本上執行
  • 更好的除錯體驗 - 編譯錯誤在開發時就能發現,而不是在佈署時

這些優勢使得 CO-RE 成為開發生產級 BPF 應用的首選方法。

構建高階 CO-RE 應用的最佳實踐

在開發生產級 CO-RE 應用時,以下是一些最佳實踐:

1. 使用 libbpf-bootstrap 作為起點

libbpf-bootstrap 提供了一個完整的專案範本,包括 Makefile、骨架生成和基本的程式結構。它是開始新專案的理想起點。

2. 謹慎使用 BTF 重定位

雖然 CO-RE 可以處理大多數結構差異,但有些情況需要特別注意:

  • 當欄位被完全移除或重新命名時
  • 當結構的語義發生變化時
  • 當存取巢狀指標結構時

在這些情況下,可能需要使用條件編譯或在執行時檢測功能可用性。

3. 處理向後相容性

為了確保程式在舊核心上也能執行,可以使用 libbpf 的功能檢測 API:

// 使用者空間程式部分
if (!bpf_probe_helper(skel->progs.my_prog, BPF_FUNC_perf_event_output)) {
    fprintf(stderr, "Kernel doesn't support perf_event_output helper\n");
    return 1;
}

4. 最佳化效能

CO-RE 應用的效能與傳統 BPF 應用相同,但有些技巧可以進一步最佳化:

  • 使用適當的對映型別和大小
  • 最小化 BPF 程式的複雜性
  • 使用批處理來減少使用者空間和核心之間的互動

5. 全面的測試策略

為 CO-RE 應用開發全面的測試策略至關重要:

  • 在多個核心版本上測試
  • 測試邊界情況和錯誤處理
  • 使用 BPF 測試框架自動化測試

BPF CO-RE 技術為開發跨核心版本的 eBPF 應用開闢了新的可能性。透過 BTF 型別訊息和人工智慧重定位,我們可以編寫一次 BPF 程式,然後在各種不同的環境中執行它。

BPF 骨架進一步簡化了開發過程,提供了型別安全的 API 來管理 BPF 程式的生命週期。結合 libbpf 和其他現代工具,CO-RE 使得開發高效、可靠和可移植的 BPF 應用變得比以往往任何時候都更容易。

隨著 Linux 核心不斷發展,CO-RE 將繼續發揮關鍵作用,使得 BPF 技術能夠在保持向後相容性的同時不斷創新。對於系統工程師和效能分析師來說,掌握 CO-RE 技術將是一項寶貴的技能,使他們能夠構建下一代的觀測性、安全性和網路工具。

理解 BTF 型別系統與其在 eBPF 中的應用

在處理 eBPF(extended Berkeley Packet Filter)程式時,需要面對一個關鍵挑戰:如何讓 eBPF 程式正確理解並操作核心資料結構。這就是 BTF(BPF Type Format)型別系統發揮作用的地方。BTF 為 eBPF 程式提供了完整的型別資訊,讓開發者能夠更容易地編寫、除錯和維護 eBPF 程式。

BTF 型別資訊的解析與應用

在 eBPF 程式中,BTF 型別資訊提供了資料結構的詳細佈局。讓我們透過一個實際例子來理解這些資訊:

[5] INT 'char' size=1 bits_offset=0 nr_bits=8 encoding=NONE
[6] ARRAY '(anon)' type_id=5 index_type_id=7 nr_elems=12
[7] INT '__ARRAY_SIZE_TYPE__' size=4 bits_offset=0 nr_bits=32 encoding=NONE

這段 BTF 資訊描述了一個陣列型別。讓我們逐行解析:

  • 第一行定義了一個名為 char 的基本型別(type_id=5),大小為 1 byte,這與 C 語言中的 char 型別完全相符。
  • 第二行定義了一個匿名陣列(type_id=6),其元素型別為前面定義的 char(type_id=5),索引型別為 type_id=7,與包含 12 個元素。
  • 第三行定義了索引型別(type_id=7),這是一個 4 byte 的整數,用於陣列索引。

透過這些定義,我們可以完整理解記憶體中 user_msg_t 結構的佈局,這個結構佔用 12 bytes 的記憶體空間。

結構體中的欄位佈局

接下來,我們來看一個更複雜的結構體定義:

[8] STRUCT '____btf_map_config' size=16 vlen=2
'key' type_id=1 bits_offset=0
'value' type_id=4 bits_offset=32

這是一個名為 ____btf_map_config 的結構體定義,用於 eBPF map 中的鍵值對。結構體總大小為 16 bytes,包含 2 個欄位:

  • key:型別為 type_id=1(u32 型別),位於結構體起始位置(bits_offset=0)
  • value:型別為 type_id=4(user_msg_t 結構體),從結構體起始位置偏移 32 bits(4 bytes)開始

這個結構體佈局完全符合邏輯,因為第一個 32 位元用於存放 key 欄位,之後才是 value 欄位。

C 結構體中的記憶體對齊

在 C 語言中,結構體欄位會自動進行記憶體對齊,這意味著一個欄位不一定緊接著前一個欄位。例如:

struct something {
    char letter;
    u64 number;
}

在這個結構中,letter 欄位後會有 7 bytes 的未使用空間,然後才是 number 欄位,這樣 64 位元的 number 可以對齊到 8 的倍數的記憶體位置。

在某些情況下可以啟用編譯器封裝選項來避免這種未使用的空間,但通常會導致效能下降。我在實際開發中發現,手動設計結構體以高效利用空間是更常見的做法。

帶有 BTF 資訊的 Maps

瞭解了 BTF 如何描述資料型別後,讓我們看這些 BTF 資料如何在建立 map 時傳遞給核心。

在第四章中,我們瞭解到 maps 是使用 bpf(BPF_MAP_CREATE) 系統呼叫建立的。這個呼叫接受一個 bpf_attr 結構作為引數,該結構在核心中定義如下(省略了一些細節):

struct { /* anonymous struct used by BPF_MAP_CREATE command */
    __u32 map_type;      /* one of enum bpf_map_type */
    __u32 key_size;      /* size of key in bytes */
    __u32 value_size;    /* size of value in bytes */
    __u32 max_entries;   /* max number of entries in a map */
    ...
    char map_name[BPF_OBJ_NAME_LEN];
    ...
    __u32 btf_fd;            /* fd pointing to a BTF type data */
    __u32 btf_key_type_id;   /* BTF type_id of the key */
    __u32 btf_value_type_id; /* BTF type_id of the value */
    ...
};

在 BTF 引入之前,bpf_attr 結構中沒有 btf_* 欄位,核心對鍵值結構沒有任何瞭解。key_sizevalue_size 欄位定義了它們需要多少記憶體,但它們只被視為一堆積積位元組。

透過額外傳入定義鍵和值型別的 BTF 資訊,核心可以檢視它們,而像 bpftool 這樣的工具可以檢索型別資訊進行美化輸出。

有趣的是,為鍵和值傳入的是分開的 BTF 型別 ID。我們剛才看到的 ____btf_map_config 結構體並不是由核心用於 map 定義的,它只是由 BCC 在使用者空間端使用。

函式和函式原型的 BTF 資料

BTF 資料不僅包含資料型別的資訊,還包含函式和函式原型的資訊。以下是描述 hello 函式的 BTF 資料:

[31] FUNC_PROTO '(anon)' ret_type_id=23 vlen=1
'ctx' type_id=10
[32] FUNC 'hello' type_id=31 linkage=static
  • 型別 32 定義了名為 hello 的函式,其型別定義在前一行(type_id=31)。
  • 這是一個函式原型,回傳型別為 type_id=23,接受單個引數(vlen=1)名為 ctx,型別為 type_id=10。

對應的型別定義如下:

[10] PTR '(anon)' type_id=0
[23] INT 'int' size=4 bits_offset=0 nr_bits=32 encoding=SIGNED
  • 型別 10 是一個匿名指標,預設型別為 0(void 指標)。
  • 回傳值型別 23 是一個 4 byte 的有符號整數。

這完全對應了原始碼中的函式定義:

int hello(void *ctx)

檢查 Maps 和 Programs 的 BTF 資料

如果想檢查特定 map 關聯的 BTF 型別,bpftool 提供了簡單的方法。例如,對於 config map:

$ bpftool btf dump map name config
[1] TYPEDEF 'u32' type_id=2
[4] STRUCT 'user_msg_t' size=12 vlen=1
'message' type_id=6 bits_offset=0

同樣,可以使用 bpftool btf dump prog <prog identity> 檢查特定程式的 BTF 資訊。

產生核心標頭檔

在支援 BTF 的核心上執行 bpftool btf list,會看到許多預先存在的 BTF 資料塊:

$ bpftool btf list
1: name [vmlinux] size 5842973B
2: name [aes_ce_cipher] size 407B
3: name [cryptd] size 3372B
...

列表中的第一項,ID 為 1,名為 vmlinux,包含了這台機器上執行的核心使用的所有資料型別、結構和函式定義的 BTF 資訊。

eBPF 程式需要它將參照的任何核心資料結構和型別的定義。在 CO-RE(Compile Once, Run Everywhere)出現之前,通常需要找出 Linux 核心原始碼中包含感興趣結構定義的特定標頭檔,但現在有了更簡單的方法,因為支援 BTF 的工具可以從核心中包含的 BTF 資訊產生適當的標頭檔。

這個標頭檔通常稱為 vmlinux.h,可以使用 bpftool 產生:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

這個檔案定義了核心的所有資料型別,因此在 eBPF 程式原始碼中包含這個產生的 vmlinux.h 檔案,就能提供所需的任何 Linux 資料結構定義。當編譯原始碼為 eBPF 物件檔時,該物件將包含與此標頭檔中使用的定義相比對的 BTF 資訊。

稍後,在目標機器上執行程式時,載入它到核心的使用者空間程式將進行調整,以適應此建置時 BTF 資訊與目標機器上執行的核心的 BTF 資訊之間的差異。

/sys/kernel/btf/vmlinux 檔案形式的 BTF 資訊自 Linux 核心版本 5.4 起被包含在核心中,但 libbpf 可以使用的原始 BTF 資料也可以為較舊的核心產生。換句話說,如果想在沒有 BTF 資訊的目標機器上執行支援 CO-RE 的 eBPF 程式,可能可以自行提供該目標的 BTF 資料。

BTF 與 CO-RE:實作跨核心版本的相容性

BTF 資訊的最大價值之一是它支援 CO-RE(Compile Once, Run Everywhere)技術。CO-RE 允許我們編譯一次 eBPF 程式,然後在不同的 Linux 核心版本上執行,即使這些核心的資料結構有所不同。

在實際應用中,我發現 BTF 和 CO-RE 極大地簡化了 eBPF 程式的佈署流程。以前,我們需要為不同的核心版本編譯不同版本的 eBPF 程式,或者在執行時動態編譯,這增加了佈署的複雜性和風險。現在,使用 BTF 和 CO-RE,我可以編寫一個 eBPF 程式,並在各種 Linux 發行版和核心版本上佈署它。

實際案例:跨核心版本效能監控

例如,我曾經需要為一個混合雲環境開發一個統一的效能監控解決方案,其中包含從 Ubuntu 18.04(核心 4.15)到最新的 Fedora(核心 5.14)的各種 Linux 發行版。使用 BTF 和 CO-RE,我能夠開發一個單一的 eBPF 程式來收集關鍵效能指標,而不必擔心不同核心版本之間的資料結構差異。

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
    __uint(max_entries, 1024);
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_read")
int trace_read_enter(struct trace_event_raw_sys_enter *ctx)
{
    u64 id = bpf_get_current_pid_tgid();
    u32 pid = id >> 32;
    
    struct event_data data = {
        .pid = pid,
        .fd = (int)ctx->args[0],
        .timestamp = bpf_ktime_get_ns(),
    };
    
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
    return 0;
}

這個程式追蹤 read 系統呼叫的進入點。它使用 vmlinux.h 提供的核心型別定義,這使它能夠正確存取 trace_event_raw_sys_enter 結構,無論目標核心版本如何。程式收集程式 ID、檔案描述符和時間戳,並將其傳送到效能事件陣列。

由於 BTF 和 CO-RE 的支援,這個程式可以在不同的核心版本上執行,而不需要修改或重新編譯。libbpf 在執行時會處理資料結構的差異,確保程式正確存取所需的欄位。

BTF 的未來發展與實際應用

BTF 和 CO-RE 技術正在迅速發展,為 eBPF 程式設計帶來更多可能性。我預見未來會有更多工具和框架利用 BTF 資訊來簡化 eBPF 開發流程,特別是在自動化除錯和最佳化方面。

在我的實踐中,我發現 BTF 不僅對於 eBPF 程式有用,還可以作為核心內部結構的檔案工具。當我需要理解特定核心功能的實作時,我經常使用 BTF 資訊來探索相關的資料結構,這比閱讀原始碼更加高效。

BTF 和 CO-RE 的結合使 eBPF 成為跨發行版、跨版本的 Linux 可觀測性和效能最佳化解決方案的理想選擇。透過理解和利用 BTF,我們可以編寫更強大、更可移植的 eBPF 程式,從而更好地理解和最佳化 Linux 系統的行為。

在 eBPF 程式設計中,掌握 BTF 型別系統是邁向高階應用的關鍵一步。它不僅提供了對核心內部結構的深入瞭解,還使我們能夠開發出在各種 Linux 環境中可靠執行的工具。隨著 eBPF 生態系統的不斷發展,BTF 將繼續發揮核心作用,使 eBPF 技術在系統可觀測性、安全性和效能最佳化方面的應用更加廣泛和強大。

CO-RE:實作 eBPF 程式的跨核心可攜性

在前面的探討中,玄貓介紹了 BTF(BPF Type Format)的基本概念。BTF 提供了核心結構的型別資訊,這是實作 CO-RE(Compile Once, Run Everywhere)的根本。如果你對 BTF 內部運作機制感興趣,BTFHub 儲存函式庫中提供了更深入的資料可供參考。

接下來,讓我們探討如何運用 BTF 以及其他技術來撰寫可跨不同核心版本執行的 eBPF 程式。

CO-RE eBPF 程式設計

眾所周知,eBPF 程式在核心空間執行。在這個部分,玄貓將專注於核心端的程式碼設計,之後再討論如何從使用者空間與這些在核心中執行的程式互動。

如前所述,eBPF 程式需要被編譯成 eBPF 位元組碼。截至撰文時間,支援這種編譯的工具主要有用於 C 語言的 Clang 或 gcc 編譯器,以及 Rust 編譯器。雖然在第 10 章會討論使用 Rust 的選項,但在本章中,玄貓將假設你使用 C 語言和 Clang 編譯器,並搭配 libbpf 函式庫。

hello-buffer-config 範例應用

在本章剩餘部分,玄貓將以一個名為 hello-buffer-config 的範例應用程式為例。這個範例與前一章使用 BCC 框架的 hello-buffer-config.py 非常相似,但這個版本是使用 C 語言編寫,並採用 libbpf 和 CO-RE 技術。

如果你有根據 BCC 的 eBPF 程式碼想要遷移到 libbpf,Andrii Nakryiko 在其網站上提供了一份出色與全面的。BCC 提供了一些便捷的捷徑,這些在 libbpf 中的處理方式有所不同;相反地,libbpf 也提供了自己的巨集和函式庫函式,以簡化 eBPF 程式設計師的工作。在探討這個範例時,玄貓會指出 BCC 和 libbpf 方法之間的一些差異。

你可以在 github.com/lizrice/learning-ebpf 儲存函式庫的 chapter5 目錄中找到本文的 C 語言 eBPF 程式範例。

首先讓我們看 hello-buffer-config.bpf.c,它實作了在核心中執行的 eBPF 程式。後面玄貓將展示使用者空間程式碼 hello-buffer-config.c,這部分負責載入程式並顯示輸出,類別似於第 4 章中 BCC 實作的 Python 程式碼。

標頭檔案設計

與任何 C 程式一樣,eBPF 程式需要包含一些標頭檔案。以下是 hello-buffer-config.bpf.c 開頭幾行指定的標頭檔案:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "hello-buffer-config.h"

這五個檔案包括 vmlinux.h 檔案、來自 libbpf 的幾個標頭以及一個應用程式專屬的標頭檔案。這是 libbpf 程式典型的標頭檔案模式,讓我們來瞭解原因。

核心標頭資訊

如果你正在編寫參照任何核心資料結構或型別的 eBPF 程式,最簡單的選擇是包含前面描述的 vmlinux.h 檔案。或者,也可以從 Linux 原始碼中包含個別的標頭檔案,或者如果你真的願意費心的話,在自己的程式碼中手動定義這些型別。如果你打算使用 libbpf 的任何 BPF 輔助函式,則需要包含 vmlinux.hlinux/types.h 來取得 BPF 輔助函式原始碼參照的型別定義,如 u32、u64 等。

vmlinux.h 檔案是從核心原始碼標頭衍生的,但它不包括其中的 #define 值。例如,如果你的 eBPF 程式解析乙太網路封包,你可能需要告訴你封包含什麼協定的常數定義(如 0x0800 表示它是 IP 封包,或 0x0806 表示 ARP 封包)。如果你不包含定義這些值的 if_ether.h 檔案,則需要在自己的程式碼中複製一系列常數值。在 hello-buffer-config 中不需要這些值定義,但在第 8 章中會看到一個相關的例子。

來自 libbpf 的標頭

要在 eBPF 程式碼中使用任何 BPF 輔助函式,需要包含提供其定義的 libbpf 標頭檔案。

關於 libbpf 有一點可能稍微令人困惑,它不僅是一個使用者空間函式庫。你會發現自己在使用者空間和 eBPF C 程式碼中都包含來自 libbpf 的標頭檔案。

截至撰寫時,常見的做法是將 libbpf 作為子模組包含在 eBPF 專案中,並從原始碼建置/安裝—這就是玄貓在本文範例儲存函式庫中所做的。如果你將其包含為子模組,只需要從 libbpf/src 目錄執行 make install。玄貓認為不久後,在常見的 Linux 發行版上以套件形式廣泛使用 libbpf 將會更普遍,尤其是因為 libbpf 現已發布 1.0 版本。

應用程式專屬標頭

有一個應用程式專屬的標頭檔案定義應用程式使用者空間和 eBPF 部分共用的結構是很常見的做法。在範例中,hello-buffer-config.h 標頭檔案定義了 data_t 結構,用於從 eBPF 程式傳遞事件資料到使用者空間。它與 BCC 版本中的結構幾乎相同:

struct data_t {
    int pid;
    int uid;
    char command[16];
    char message[12];
    char path[16];
};

與之前版本的唯一區別是增加了一個名為 path 的欄位。

將此結構定義放入單獨標頭檔案的原因是,玄貓也會在 hello-buffer-config.c 的使用者空間程式碼中參照它。在 BCC 版本中,核心和使用者空間程式碼都定義在單一檔案中,BCC 在幕後做了一些工作使該結構可用於 Python 使用者空間程式碼。

定義對映(Maps)

在包含標頭檔案後,hello-buffer-config.bpf.c 原始碼的下一部分定義了用於對映的結構:

struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} output SEC(".maps");

struct user_msg_t {
    char message[12];
};

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, struct user_msg_t);
} my_config SEC(".maps");

這比等效的 BCC 範例需要更多的程式碼行!在 BCC 中,名為 config 的對映是使用以下巨集建立的:

BPF_HASH(config, u64, struct user_msg_t);

當不使用 BCC 時,這個巨集不可用,所以在 C 中必須詳細寫出。可以看到玄貓使用了 __uint__type。這些定義在 bpf/bpf_helpers_def.h 中,連同 __array,如下所示:

#define __uint(name, val) int (*name)[val]
#define __type(name, val) typeof(val) *name
#define __array(name, val) typeof(val) *name[]

這些巨集通常按慣例在根據 libbpf 的程式中使用,玄貓認為它們使對映定義更容易閱讀。

名稱 “config” 與 vmlinux.h 中的定義衝突,所以玄貓在這個範例中將對映重新命名為 “my_config”。

eBPF 程式段落

使用 libbpf 要求每個 eBPF 程式都用 SEC() 巨集標記,定義程式型別,如:

SEC("kprobe")

這在編譯的 ELF 物件中產生一個名為 kprobe 的段落,因此 libbpf 知道將其載入為 BPF_PROG_TYPE_KPROBE。玄貓會在第 7 章中進一步討論不同的程式型別。

根據程式型別,還可以使用段落名稱指定程式將附加到的事件。libbpf 函式庫將使用此資訊自動設定附加,而不是讓你在使用者空間程式碼中明確設定。例如,要自動附加到 ARM 機器上 execve 系統呼叫的 kprobe,可以這樣指定段落:

SEC("kprobe/__arm64_sys_execve")

這需要你知道該架構上系統呼叫的函式名稱(或者找出來,也許可以檢視目標機器上的 /proc/kallsyms 檔案,它列出了所有核心符號,包括其函式名稱)。但 libbpf 可以使用 k(ret)syscall 段落名稱讓生活更輕鬆,它告訴載入器自動附加到架構特定函式的 kprobe:

SEC("ksyscall/execve")

有效的段落名稱和格式列在 libbpf 檔案中。過去,段落名稱的要求更寬鬆,所以你可能會遇到使用不同名稱格式的舊 eBPF 程式碼。

在這段程式碼中,我們看到了如何使用 SEC 巨集來指定 eBPF 程式的型別和附加點。這個巨集的作用是在編譯出的 ELF 檔案中建立特定的段落,讓 libbpf 能夠識別程式型別並正確處理。

特別值得注意的是 libbpf 提供的便捷功能,允許透過特定格式的段落名稱(如 ksyscall/execve)自動處理架構特定的系統呼叫附加,這大簡化了跨架構的 eBPF 程式開發。這是 CO-RE 理念的一部分,讓開發者不必關心底層架構的具體差異。

撰寫 eBPF 程式

在定義了必要的標頭檔案和對映後,接下來讓我們看實際的 eBPF 程式如何編寫。以下是 hello-buffer-config.bpf.c 中的一個範例函式:

SEC("ksyscall/openat")
int BPF_PROG(hello, int dfd, const char *filename, int flags)
{
    struct data_t data = {};
    struct user_msg_t *p;
    u32 uid;

    // 僅跟蹤特定 UID 的事件
    uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    data.uid = uid;
    data.pid = bpf_get_current_pid_tgid() >> 32;

    // 從設定對映中查詢訊息
    p = bpf_map_lookup_elem(&my_config, &uid);
    if (p != 0) {
        bpf_probe_read_user_str(&data.message, sizeof(data.message), p->message);
    } else {
        bpf_probe_read_user_str(&data.message, sizeof(data.message), "default");
    }

    // 讀取命令名稱和檔案路徑
    bpf_get_current_comm(&data.command, sizeof(data.command));
    bpf_probe_read_user_str(&data.path, sizeof(data.path), filename);

    // 傳送事件到效能緩衝區
    bpf_perf_event_output(ctx, &output, BPF_F_CURRENT_CPU, &data, sizeof(data));

    return 0;
}

這個 eBPF 程式附加到 openat 系統呼叫,每當程式嘗試開啟檔案時都會執行。讓我們分析其工作原理:

  1. 首先,程式使用 SEC("ksyscall/openat") 指定它應該附加到 openat 系統呼叫。
  2. 函式宣告使用 BPF_PROG 巨集,這是 libbpf 提供的便捷方式,自動處理上下文引數。
  3. 程式取得當前程式的 UID 和 PID,這些是我們想要記錄的資訊。
  4. 接著嘗試從 my_config 對映中查詢與當前 UID 相關聯的訊息。如果找到,則使用該訊息;否則使用 “default”。
  5. 然後讀取當前程式的命令名稱(使用 bpf_get_current_comm)和被開啟的檔案路徑。
  6. 最後,使用 bpf_perf_event_output 將收集的資料傳送到效能緩衝區,這樣使用者空間程式就能讀取這些資訊。

這展示了 eBPF 程式如何攔截系統呼叫、收集資訊並將其傳遞到使用者空間。注意使用 bpf_probe_read_user_str 函式安全地從使用者空間讀取字串資料,這是 eBPF 程式處理使用者空間資料的標準方式。

使用 libbpf 的優勢

相比於 BCC,使用 libbpf 和 CO-RE 方法有幾個顯著優勢:

  1. 更好的效能 - libbpf 程式不需要在執行時編譯,因此啟動更快與資源使用更少。

  2. 更少的依賴 - BCC 需要安裝完整的 LLVM 和 Clang 工具鏈,而 libbpf 程式只需要預編譯的 eBPF 物件檔案。

  3. 可攜性 - 透過 CO-RE,eBPF 程式可以在不同的核心版本上執行,無需重新編譯。

  4. 更好的佈署體驗 - 預編譯的 eBPF 程式更容易封裝和佈署。

  5. 直接的核心 API 存取 - libbpf 提供更直接的 eBPF 核心 API 存取,允許更精細的控制。

然而,這些優勢是以更複雜的程式碼為代價的。正如我們所見,libbpf 需要更多的樣板程式碼來設定對映和程式。

使用者空間整合

雖然本文主要關注核心空間的 eBPF 程式,但值得簡要討論使用者空間整合。在 libbpf 中,使用者空間程式負責:

  1. 載入 eBPF 物件檔案
  2. 設定對映初始值
  3. 附加 eBPF 程式到適當的事件
  4. 從對映或效能緩衝區讀取資料

以下是 hello-buffer-config.c 中使用者空間程式碼的簡化版本:

int main(int argc, char **argv)
{
    struct hello_buffer_config_bpf *obj;
    int err;

    // 設定 libbpf 錯誤和除錯回呼
    libbpf_set_print(libbpf_print_fn);

    // 開啟和載入 eBPF 物件
    obj = hello_buffer_config_bpf__open_and_load();
    if (!obj) {
        fprintf(stderr, "Failed to open and load BPF object\n");
        return 1;
    }

    // 設定效能緩衝區回呼
    struct perf_buffer *pb = NULL;
    struct perf_buffer_opts pb_opts = {};
    pb_opts.sample_cb = handle_event;
    pb = perf_buffer__new(bpf_map__fd(obj->maps.output), 8, &pb_opts);

    // 附加 eBPF 程式
    err = hello_buffer_config_bpf__attach(obj);
    if (err) {
        fprintf(stderr, "Failed to attach BPF program\n");
        goto cleanup;
    }

    // 主事件迴圈
    while (1) {
        perf_buffer__poll(pb, 100);
    }

cleanup:
    hello_buffer_config_bpf__destroy(obj);
    return err != 0;
}

這段使用者空間程式碼負責載入和管理 eBPF 程式。它執行以下關鍵步驟:

  1. 設定 libbpf 的錯誤和除錯處理函式。
  2. 開啟並載入 eBPF 物件檔案,這包含了我們的 eBPF 程式和對映定義。
  3. 設定效能緩衝區回呼函式,用於處理從 eBPF 程式傳送的事件。
  4. 附加 eBPF 程式到適當的事件(在這裡是 openat 系統呼叫)。
  5. 進入主事件迴圈,持續從效能緩衝區讀取事件。
  6. 最後在清理時釋放資源。

libbpf 自動生成了許多輔助函式,如 hello_buffer_config_bpf__open_and_load()hello_buffer_config_bpf__attach(),這些函式簡化了 eBPF 程式的載入和附加過程。

CO-RE 的未來發展

CO-RE 技術代表了 eBPF 生態系統的重要進步,使 eBPF 程式能夠在不同的核心版本上執行,而無需重新編譯或修改。隨著 libbpf 的成熟和更廣泛的採用,玄貓預見未來幾年 CO-RE 將成為 eBPF 程式開發的標準方法。

特別是,以下幾個領域值得關注:

  1. 工具鏈改進 - 更好的編譯器整合和除錯工具將簡化 CO-RE eBPF 程式的開發。

  2. 語言支援擴充套件 - 除了 C 和 Rust 外,可能會有更多語言獲得 CO-RE eBPF 支援。

  3. 更高階的抽象 - 建立在 libbpf 之上的框架將提供更高階的抽象,進一步簡化 eBPF 程式開發。

  4. 跨發行版標準化 - 隨著 libbpf 成為標準套件,跨發行版的 eBPF 程式佈署將變得更加無縫。

eBPF 和 CO-RE 技術的結合為 Linux 系統可觀測性、網路和安全領域開啟了新的可能性,讓開發者能夠建立強大與可攜的核心擴充套件。

結語

CO-RE 技術為 eBPF 程式開發帶來了革命性的變化,使開發者能夠編寫一次程式碼,然後在各種核心版本上執行。透過 BTF 和 libbpf 的結合,CO-RE 解決了 eBPF 程式可攜性的長期挑戰。

在本文中,玄貓探討了 CO-RE eBPF 程式的基本結構,包括標頭檔案的組織、對映定義、程式段落和使用者空間整合。雖然相較於 BCC 框架,使用 libbpf 和 CO-RE 需要更多的樣板程式碼,但它提供了更好的效能、更少的依賴和更好的可攜性。

隨著 eBPF 生態系統的不斷發展,CO-RE 將繼續發揮關鍵作用,讓開發者能夠建立強大、高效與可攜的核心擴充套件,無需擔心核心版本的差異。無論是系統可觀測性、網路還是安全領域,CO-RE eBPF 程式都將成為關鍵工具。