【Learning eBPF-3】一個 eBPF 程式的深入剖析

_hong發表於2024-04-08

從這一章開始,我們先放下 BCC 框架,來看僅透過 C 語言如何實現一個 eBPF。如此一來,你會更加理解 BCC 所做的底層工作。

在這一章中,我們會討論一個 eBPF 程式被執行的完整流程,如下圖所示。

【Learning eBPF-3】一個 eBPF 程式的深入剖析

一個 eBPF 程式實際上是一組 eBPF 位元組碼指令。因此你可以直接使用這種特定的位元組碼來編寫 eBPF 程式,就像寫彙編程式碼一樣。但實際上,我們都知道,彙編程式往往太抽象了。因此現在絕大部分的 eBPF 都是透過 C 語言這樣的高階語言來編寫的,最後經過編譯,生成可供執行的位元組碼。

從概念上講,eBPF 位元組碼將在核心的 eBPF 虛擬機器中執行。

3.1 eBPF 虛擬機器

和其他虛擬機器一樣,eBPF 虛擬機器的主要作用就是將 eBPF 位元組碼 轉換成可以在本機 CPU 上執行的 機器碼

在原始的 eBPF 實現中,位元組碼是在核心中解釋執行的。這種方式有效能上的弊端,即,每次執行,都需要將 eBPF 從原始碼編譯解釋為機器碼,然後再執行。此外,這種傳統的方式也可能存在 Spectre 相關的漏洞。

Spectre 漏洞是一類側通道攻擊,可以利用程式碼執行路徑的依賴性來竊取敏感資訊。

JITjust-in-time,及時編譯)的出現,很好的解決了這兩個問題。JIT 可以將 eBPF 位元組碼即時編譯成本機機器指令,直接在硬體上執行。由於編譯只需要進行一次,之後的執行過程中可以直接執行本機機器指令,從而獲得更高的效能。這種方式,因此能夠降低潛在的 Spectre 漏洞風險。

eBPF 位元組碼實際上是由一組指令組成,它們運作於虛擬 eBPF 暫存器上。實際上,eBPF 指令集和暫存器能夠適配目前主流的 CPU 架構,因此編譯和解釋這些位元組碼其實沒有那麼複雜。

3.1.1 eBPF 暫存器

eBPF 虛擬機器定義了 10 個通用暫存器(R0 ~ R9),和一個始終指向棧頂的暫存器 R10(只讀)。這些暫存器用於在 eBPF 執行時追蹤記錄執行時狀態。

這些 eBPF 暫存器定義於核心原始碼 include/uapi/linux/bpf.h 標頭檔案中,是一個列舉型別。如下所示:

/* Register numbers */
enum {
	BPF_REG_0 = 0,
	BPF_REG_1,
	BPF_REG_2,
	BPF_REG_3,
	BPF_REG_4,
	BPF_REG_5,
	BPF_REG_6,
	BPF_REG_7,
	BPF_REG_8,
	BPF_REG_9,
	BPF_REG_10,
	__MAX_BPF_REG,
};

簡單列舉幾個暫存器的作用:

  • eBPF 程式被執行之前,其上下文資訊引數被載入 R1
  • 函式的返回值儲存於 R0
  • eBPF 程式呼叫其他函式之前,會將函式引數存入 R1 ~ R5

3.1.2 eBPF 指令集

include/uapi/linux/bpf.h 標頭檔案中也給出了 eBPF 指令的結構定義,如下:

struct bpf_insn {
	__u8	code;		// 1	位元組		/* opcode */						// A
	__u8	dst_reg:4;	// 0.5	位元組		/* dest register */					// B
	__u8	src_reg:4;	// 0.5	位元組		/* source register */
	__s16	off;		// 2	位元組		/* signed offset */					// C
	__s32	imm;		// 4	位元組		/* signed immediate constant */
};

程式碼解釋:

【A】每個指令都包含一個操作碼,代表當前指令是什麼操作。例如,加法操作 ADD、跳轉操作 JUMP 等等。

Iovisor 專案 "Unofficial eBPF spec" 中給出了一個有效的指令列表(https://github.com/iovisor/bpf-docs/blob/master/eBPF.md)。

【B】有些操作可能涉及兩個暫存器。

【C】有些操作可能需要 offset(偏移量)和 imm(立即數)。

bpf_insn 結構體一共 64 位(8位元組)。當一段 eBPF 程式被載入核心時,其位元組碼就會由一系列的 bpf_insn來表示。而 eBPF 驗證器就是檢查這段資訊,以確保安全性的。(見第 6 章)

解釋:code:8 bit;dts_reg:4 bit;src_reg:4 bit;off:16 bit;imm:32 bit

實際上,bpf_insn 結構體在某些情況(寬指令)下,可能會額外擴充套件 8 位元組,這樣一來單條指令可能會達到 16 位元組。(注意:伏筆)

操作碼可以分為以下幾類:

  • 載入一個值到暫存器中(可以是立即數imm,也可以是另一個暫存器中的值)。
  • 將一個暫存器中的值存入記憶體。
  • 執行算術運算(加、減、乘等等)。
  • 在某些條件下,跳轉到另一個指令執行。

接下來,我們來看一個簡單的例子(使用 libbpf 庫),詳細追蹤一下它從原始碼到位元組碼再到機器碼的全過程。

3.2 另一個 eBPF 的 Hello World

上一章我們給出的 eBPF 程式是透過核心探針 kprobe 繫結事件進行觸發的。這次我們換一種方式,以網路包的到達作為 eBPF 程式的觸發條件。

在目前 eBPF 的應用領域中,網路資料包的處理非常熱門。網路介面中的 eBPF 程式是很牛的,它可以檢查甚至修改網路包中的內容,並且可以控制核心的後續行為(接收、丟棄或重定向)。有關網路方面的應用,詳見第 8 章。書中在這裡給出了一個網路包處理的 eBPF 例子,是因為作者認為,因網路包到達而觸發的 eBPF 程式對於理解整個過程很有幫助。

但接下來給出的例子不會新增太多的邏輯,僅僅是在網路包到達時列印 “Hello World”。

下面的程式名為 hello.bpf.c注意:在 libbpf 框架中,eBPF 程式字尾為 .bof.c這一點和前文有所差別。

#include <linux/bpf.h>							// A
#include <bpf/bpf_helpers.h>

int counter = 0;								// B

SEC("xdp")										// C
int hello(void *ctx) {							// D
    bpf_printk("Hello World %d\n", counter);
    counter++;
    return XDP_PASS;
}

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

程式碼解釋:

【A】#include <linux/bpf.h>,eBPF 程式需要包含這個標頭檔案。

【B】eBPF 程式是可以使用全域性變數的!這個變數 counter 會在每次執行時自增。

【C】SEC("xdp")SEC() 是一個宏定義,它定義了一個名為 xdpsection。我們將在第 5 章繼續詳細討論有關 section 的內容。不過現在,可以簡單把它理解為,定義了當前函式是一個 xdp(eXpress Data Path)型別的 eBPF 程式。

【D】這一部分程式碼定義了一個函式,名為 hello()。這就是真正的 eBPF 程式了。函式內部呼叫了一個名為 bpf_printk() 的函式,用來寫入一個字串;同時將全域性計數器 counter 自增。在函式的最後,返回值為 XDP_PASS 。這裡實際上是 eBPF 程式對核心下達的用於處理當前網路包指令,這裡是透過這個網路包,不作操作。

【E】最後這句程式碼,也是一個 SEC() 宏定義,規定了當前 eBPF 程式的許可證。這是因為,很多核心函式(包括 eBPF 輔助函式)都標識了 GPL 相容許可證,eBPF 程式只有也新增這些標識才能使用它們。當然,eBPF 驗證器也會驗證 eBPF 許可證資訊(詳見第 6 章)。

到這裡為止,我們就可以看到 BCClibbpf 的區別了。以列印字串為例,BCC 框架中是 bpf_trace_printk(),libbpf 框架中是 bpf_printk()。實際上這倆都是核心函式bpf_trace_printk()的封裝。

在編寫完 eBPF 原始碼之後,下一步就是將其編譯為核心能夠理解的目標檔案了。

3.3 編譯出目標檔案

這一節中,我們的主要目標就是,將前文給出的 eBPF 原始碼編譯成 eBPF 位元組碼,以便能夠被 eBPF 虛擬機器所理解。

LLVM + Clang 是很合適的編譯器。你只需要指定 -target bpf 引數即可完成編譯。

hello.bpf.o: %.o: %.c
	clang \
		-target bpf \
		-I/usr/include/ \
		-g \
		-O2 -c $< -o $@

注意,譯者這裡給出的 Makefile 檔案與書中給出的並不相同。變化之處是標頭檔案路徑,該路徑是被引用的 libbpf 開發包的地址(bpf/bpf_helpers.h 在這)。

你可以預先檢視這個目錄是否存在 libbpf 相關的標頭檔案,如果不存在,那麼你需要先安裝 libbpf 開發包。否則編譯時會提示:"hello.bpf.c:2:10: fatal error: 'bpf/bpf_helpers.h' file not found"。

可以直接用包管理器安裝 libbpf 開發包,以 yum/dnf 為例。

yum install -y libbpf-devel.x86_64

透過這種規則編譯後,將會生成一個名為 hello.bpf.o 的目標檔案。-g 引數是可選的,可以在目標檔案中生成一些 debug 資訊(在位元組碼的側邊欄顯示原始碼),閱讀這些資訊對於理解 eBPF 是很有幫助的。

3.4 看看編譯出來的是啥

首先,使用 file 工具看看這個.o檔案是個啥。

$ file hello.bpf.o
hello.bpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), with debug_info, not stripped

對輸出的解釋:

  • ELF:這個檔案型別是 ELF (Executable and Linkable Format),即可執行或可連結型別的檔案。
  • 64-bit LSB relocatable:表明這是一個 64 位的 LSB(小端法?不確定) 架構。
  • eBPF:這個檔案包含 eBPF 程式碼。
  • version 1 (SYSV):版本號。
  • with debug_info:說明這個目標檔案帶有 debug 資訊。

可以使用 llvm-objdump工具來檢視這個 eBPF 目標檔案。

$ llvm-objdump -S hello.bpf.o

可以看到如下的內容(注意這裡的內容和書上不同,這裡是譯者機器上給出的位元組碼):

hello.bpf.o:    file format elf64-bpf							; A

Disassembly of section xdp:										; B

0000000000000000 <hello>:										; C
; int hello(void *ctx) {
       0:       18 01 00 00 72 6c 64 20 00 00 00 00 25 64 0a 00 r1 = 2924860387126386 ll
;     bpf_printk("Hello World %d\n", counter);					; D
       2:       7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
       3:       18 01 00 00 48 65 6c 6c 00 00 00 00 6f 20 57 6f r1 = 8022916924116329800 ll
       5:       7b 1a f0 ff 00 00 00 00 *(u64 *)(r10 - 16) = r1
       6:       18 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r6 = 0 ll
       8:       61 63 00 00 00 00 00 00 r3 = *(u32 *)(r6 + 0)
       9:       bf a1 00 00 00 00 00 00 r1 = r10
      10:       07 01 00 00 f0 ff ff ff r1 += -16
;     bpf_printk("Hello World %d\n", counter);
      11:       b7 02 00 00 10 00 00 00 r2 = 16
      12:       85 00 00 00 06 00 00 00 call 6
;     counter++;												; E
      13:       61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
      14:       07 01 00 00 01 00 00 00 r1 += 1
      15:       63 16 00 00 00 00 00 00 *(u32 *)(r6 + 0) = r1
;     return XDP_PASS;											; F
      16:       b7 00 00 00 02 00 00 00 r0 = 2
      17:       95 00 00 00 00 00 00 00 exit

程式碼解釋:

【A】第一行說明 hello.bpf.o 檔案是一個 64-bit 的 eBPF 程式碼的 ELF 檔案。

【B】接下來是對 xdp section 的宣告。這就是我們之前在 SEC() 中定義的內容。

【C】這部分是 hello() 函式。

【D】接下來兩個部分,是 bpf_printk() 的位元組碼。

【E】下面三行,是 counter 自增的位元組碼。

【F】最後兩行是 eBPF 程式的返回值 XDP_PASS

除非你有特殊的需求,不然的話,上述位元組碼建議就圖一樂看看,不用深究其和原始碼的對應關係。人工去重複編譯器的工作沒有意義。但是為了學習,我們還是簡單來分析一下幾點內容。

hello() 函式為例,hello() 函式內是一行行的 eBPF 指令(前文說的 bpf_insn 結構)。

對於每一行的位元組碼指令,最左一列代表這行指令相比 hello() 函式在記憶體中位置的偏移量,中間一大坨是當前指令的位元組碼形式,右邊一坨是人類可讀的指令解釋(彙編形式)。

不難發現,最左側的偏移量從上往下是遞增的。遞增的大小可能是 1,可能是 2。這是因為 eBPF 指令的大小可能為 8 (通常情況)或 16 位元組(前文 [3.1.2 eBPF 指令集](#3.1.2-eBPF 指令集) 中提到過)。而在 64-bit 的平臺上,一個記憶體單元佔據 8 位元組,因此,每條指令可能會佔據 1~2 個記憶體單元。以偏移量為 0 的這條指令為例:這一行位元組碼指令剛好是一條寬指令(中間一坨佔據 16 個位元組),因此下一行指令的偏移量便為 2 了。

中間一坨是真正的位元組碼內容。其第一個位元組為指令操作碼,用於告知核心當前是什麼操作。

例如,偏移量為 11 的這條指令,如下:

11:       b7 02 00 00 10 00 00 00 r2 = 16

指令操作碼為 0xb7, 那麼,這個操作碼應該如何翻譯呢?eBPF 指令基金會給出了一個標準文件(https://datatracker.ietf.org/doc/html/draft-ietf-bpf-isa ),你可以在這個文件中查詢指令操作碼對應的操作虛擬碼。

【Learning eBPF-3】一個 eBPF 程式的深入剖析

可以看到,0xb7 對應的虛擬碼是 dst = (s64) (s8) imm,即,將目標地址設定為一個立即數。

再來看,第 2 個位元組是 0x02 ,代表源地址和目標地址,即源地址為空,目標地址為暫存器 R2

再來看,接下來 2 個位元組(一共16 bit)為 0,代表偏移量 off 為空。

再來看,接下來 4 個位元組(一共 32 bit),為 0x10(小端法實際上為 0x00000010),是立即數的十六進位制表示,對應的十進位制數為 16。

這條指令的實際含義就是通知核心,將暫存器 R2 的地址上存入一個立即數 16

譯者注:如果你結合前文給出的 bpf_insn 結構體來看,你就會發現,是可以一一對應的。

【Learning eBPF-3】一個 eBPF 程式的深入剖析

再舉一個例子。偏移量為 16 的指令也是一個寫入立即數的操作,和上面類似:

 16:       b7 00 00 00 02 00 00 00 r0 = 2

這裡不再詳細介紹了,感興趣可以自己分析一下。這條指令的含義是,將暫存器 R0 的地址中存入立即數 2

我們前文介紹過([3.1.1 eBPF 暫存器](#3.1.1-eBPF 暫存器)),暫存器 R0 用來儲存函式的返回值。這裡的立即數 2 其實是 XDP_PASS 的宏定義值。

好了,到目前為止,我們已經獲得了位元組碼格式的目標檔案,接下來的目的就是把它載入到核心中了!

3.5 位元組碼載入核心

在這一章裡,我們使用一個工具來完成 eBPF 載入核心的操作。這個工具是 bpftool,一個服務於 eBPF 程式的很常用的工具。

現在很多發行版作業系統都會預設整合安裝這個工具了,如果沒有,可以嘗試使用對應的軟體包管理器安裝它。

使用下面的命令,可以將 eBPF 位元組碼檔案載入核心(注意 root 許可權)。

$ bpftool prog load hello.bpf.o /sys/fs/bpf/hello

這條命令是將我們編譯好的 hello.bpf.o 檔案載入核心,並 PIN/sys/fs/bpf/hello 這個位置上。

譯者注:在低版本的 bpftool 上,這條命令可能會執行失敗,報錯如下:

libbpf: Error loading ELF section .BTF: -22. Ignored and continue.
libbpf: Program 'xdp' contains non-map related relo data pointing to section 5
Error: failed to open object file

這個錯誤的原因是核心版本太低,對應的 eBPF 不支援全域性的靜態變數。如果遇到這個問題,請適當升級你的核心版本。

詳情請參考:https://stackoverflow.com/questions/48653061/ebpf-global-variables-and-structs

成功載入後,你可以檢視 /sys/fs/bpf 目錄中的輸出列印。

$ ls /sys/fs/bpf
hello

至此,hello.bpf.o 檔案就被成功載入核心了。那麼接下來,我們繼續利用 bpftool 這個強大工具,來看一看這個 eBPF 程式在核心中到底是個什麼樣子。

3.6 載入後的 eBPF 全貌

首先,若你想檢視當前核心中載入的所有 eBPF 程式,可以使用下面的命令。這個指令會輸出一個列表。

$ bpftool prog list
5: xdp  name hello  tag ec5542c3187de469  gpl
        loaded_at 2024-01-23T08:33:12+0800  uid 0
        xlated 144B  jited 95B  memlock 4096B  map_ids 3
        btf_id 5

譯者給出的例子均是在我的系統上執行的結果,與書上不同,請讀者悉知。後文不再贅述。

每段 eBPF 程式在核心中都有一個唯一標識(ID),當前為 5。你可以根據 eBPF 的 ID,繼續使用 bpftool 來檢視 eBPF 的詳細資訊。

$ bpftool prog show id 5 --pretty
{
    "id": 5,
    "type": "xdp",
    "name": "hello",
    "tag": "ec5542c3187de469",
    "gpl_compatible": true,
    "loaded_at": 1705969992,
    "uid": 0,
    "bytes_xlated": 144,
    "jited": true,
    "bytes_jited": 95,
    "bytes_memlock": 4096,
    "map_ids": [3
    ],
    "btf_id": 5
}

這些欄位的含義都很直觀:

  • id:當前 eBPF 程式 ID 為 5。
  • type:這是一個 xdp 型別的 eBPF 程式,可以繫結到 xdp 事件的網路介面上。eBPF 還有其他型別,後面再說(第 7 章)。
  • name:當前程式名稱為 “hello”,其實就是 hello() 函式名。
  • tag:這個欄位也是 eBPF 程式的另一個標識,後面詳細說([3.6.1 BPF tag](#3.6.1-BPF tag))。
  • gpl_compatible:基於 GPL 相容許可證
  • loaded_at:時間戳。為當前 eBPF 載入的時間。
  • uid:使用者 ID。0 為 root 使用者。
  • bytes_xlated:編譯後的 eBPF 位元組碼共有 144 個位元組。後面詳細說([3.6.2 BPF xlated 編譯產物](#3.6.2-BPF xlated 編譯產物))。
  • jited:這段 eBPF 已經被 JIT 即時編譯了。
  • bytes_jitedJIT 即時編譯產出 95 位元組的機器碼。後面說([3.6.3 BPF jited 編譯產物](#3.6.3-BPF jited 編譯產物))。
  • bytes_memlock:當前 eBPF 預留了 4096 個位元組的記憶體,這些記憶體頁不會被換走。
  • map_ids:這段程式使用了 ID 為 3 的 BPF_MAP(全域性變數實際上就是 BPF_MAP)。
  • btf_id:當前程式包含一個 BTF 程式塊(只有使用了 -g 引數編譯後,這條資訊才會顯示在 .o 檔案中)。有關 BTF,我們將在第 5 章詳細展開討論。

3.6.1 BPF tag

BPF tag 欄位是一個基於程式所有指令的 SHA 雜湊值(Secure Hashing Algorithm)。BPF tag 同樣可以用來標識 eBPF 程式。與 BPF ID 不同之處在於,每次載入或解除安裝 eBPF 程式時,ID 可能會不同,但是 tag 始終保持不變。

bpftool 工具支援透過 ID/name/tag/pinned 四種方式來檢視 eBPF 詳情。下面四條命令得出的結果相同:

$ bpftool prog show id 5
$ bpftool prog show name hello
$ bpftool prog show tag ec5542c3187de469
$ bpftool prog show pinned /sys/fs/bpf/hello

值得注意的是,eBPF 程式的 name、tag 可能會相同,但其 ID、pinned 都是唯一的。

3.6.2 BPF xlated 編譯產物

不要把這一節和下一節的兩個編譯階段搞混淆了。書上在這裡給出了一個讓我感覺很迷惑的標題 “The translated Bytecode”,直譯為:翻譯後的位元組碼。但實際上,這一階段是 eBPF 位元組碼(.o 目標檔案)經歷 BPF 驗證器 之後的微調版 BPF 位元組碼。在這裡,譯者姑且稱它為 “BPF xlated 編譯產物”。

為什麼是微調版 BPF 位元組碼,後面會有機會解釋。

【Learning eBPF-3】一個 eBPF 程式的深入剖析

我們用 bpftool 工具來看一看這一階段的位元組碼長什麼樣。

$ bpftool prog dump xlated name hello
int hello(void * ctx):
; int hello(void *ctx) {                                                        // D
   0: (18) r1 = 0xa642520646c72
; bpf_printk("Hello World %d\n", counter);
   2: (7b) *(u64 *)(r10 -8) = r1
   3: (18) r1 = 0x6f57206f6c6c6548
   5: (7b) *(u64 *)(r10 -16) = r1
   6: (18) r6 = map[id:3][0]+0
   8: (61) r3 = *(u32 *)(r6 +0)
   9: (bf) r1 = r10
;
  10: (07) r1 += -16
; bpf_printk("Hello World %d\n", counter);
  11: (b7) r2 = 16
  12: (85) call bpf_trace_printk#-57216
; counter++;
  13: (61) r1 = *(u32 *)(r6 +0)
  14: (07) r1 += 1
  15: (63) *(u32 *)(r6 +0) = r1
; return XDP_PASS;
  16: (b7) r0 = 2
  17: (95) exit

乍一看上去,和前文我們使用 llvm-objdump 工具得出的位元組碼(3.4 看看編譯出來的是啥)很相似。指令長得很像,偏移地址完全相同。

3.6.3 BPF jited 編譯產物

這一階段發生在上一節的編譯產物之後,是 JIT 編譯的產物。JIT 之後,eBPF 位元組碼(此時應該稱其為機器碼了)就具有了執行在本機 CPU 上的能力,雖然已經很底層了,但它仍然與一般的機器碼不同。bytes_jited 欄位告知了我們這一部分機器碼的長度。

其實有兩種方式執行 eBPF 程式。我們現在討論的,是使用 JIT 編譯器生成機器碼然後執行。另一種方式是,直接解釋執行 eBPF 位元組碼。

顯然 JIT 方式效能更強。

bpftool 工具能夠將 JIT 機器碼 輸出為組合語言。

$ bpftool prog dump jited name hello

輸出如下:

int hello(void * ctx):
bpf_prog_ec5542c3187de469_hello:
; int hello(void *ctx) {                                                        // D
   0:   nopl   0x0(%rax,%rax,1)
   5:   xchg   %ax,%ax
   7:   push   %rbp
   8:   mov    %rsp,%rbp
   b:   sub    $0x10,%rsp
  12:   push   %rbx
  13:   movabs $0xa642520646c72,%rdi
; bpf_printk("Hello World %d\n", counter);
  1d:   mov    %rdi,-0x8(%rbp)
  21:   movabs $0x6f57206f6c6c6548,%rdi
  2b:   mov    %rdi,-0x10(%rbp)
  2f:   movabs $0xffffba56c0362000,%rbx
  39:   mov    0x0(%rbx),%edx
  3c:   mov    %rbp,%rdi
;
  3f:   add    $0xfffffffffffffff0,%rdi
; bpf_printk("Hello World %d\n", counter);
  43:   mov    $0x10,%esi
  48:   callq  0xffffffffed7f1930
; counter++;
  4d:   mov    0x0(%rbx),%edi
  50:   add    $0x1,%rdi
  54:   mov    %edi,0x0(%rbx)
; return XDP_PASS;
  57:   mov    $0x2,%eax
  5c:   pop    %rbx
  5d:   leaveq
  5e:   retq

有些版本的 bpftool 不支援輸出 JIT 產物。可以參考:https://github.com/libbpf/bpftool

到目前為止,eBPF 程式已經被載入核心,但並沒有和任何事件關聯繫結,現在什麼都觸發不了它。接下來,我們給它裝上開關。

3.7 繫結一個事件

eBPF 程式只能繫結到和他型別匹配的事件上去。(詳見第 7 章)當前的例子是一個 xdp 程式,因此需要繫結到網路介面的 XDP 事件上去。

使用下面的命令,如果繫結成功,什麼也不會輸出。

$ bpftool net attach xdp id 5 dev enp0s8

在這個命令中,我們透過 ID 來繫結對應的 eBPF 程式。當然使用 name 或 tag 來指定 eBPF 程式也是 OK 的。

注意,我們指定了 enp0s8 這個網路卡(譯者使用的是虛擬機器,但是不影響)。

現在,我們可以使用以下命令檢視 eBPF 的所有網路事件繫結列表:

$ bpftool net list
xdp:
enp0s8(3) generic id 5

tc:

flow_dissector:

能夠看到,ID 為 5 的 eBPF 程式已經被繫結到 enp0s8 網路卡的 XDP 事件上了。後面的 tcflow_dissector我們第 7 章再詳細討論。

除此之外,你還可以使用 ip link 命令檢視網路介面資訊,本機輸出如下:

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    ···
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    ···
    prog/xdp id 5 tag ec5542c3187de469 jited
···

你可以看到 enp0s8 網路卡介面上繫結的 eBPF 程式資訊,包括:ID、tag 資訊以及被 JIT 編譯過。

lo 是本機迴環網路介面,用於同一臺計算機的內部通訊(不需要經過物理網路)。lo 的 IP 地址通常是固定的,為 127.0.0.1

ip link 命令也可以被用於繫結和解綁 xdp 程式,第 7 章再說。

那麼,此時此刻,我們的 hello() eBPF 程式就可以發揮它的作用了。當每有網路包到達 enp0s8 時,都會向 /sys/kernel/debug/tracing/trace_pipe 中輸出一次 Hello World

你可以使用 cat 檢視輸出:

$ cat /sys/kernel/debug/tracing/trace_pipe
 <idle>-0       [003] d.s. 56972.929829: bpf_trace_printk: Hello World 170
 <idle>-0       [003] dNs. 56973.582190: bpf_trace_printk: Hello World 171
	sshd-64304   [003] d.s1 56973.592084: bpf_trace_printk: Hello World 172
	sshd-64304   [003] d.s1 56973.596605: bpf_trace_printk: Hello World 173
 <idle>-0       [003] d.s. 56974.426690: bpf_trace_printk: Hello World 174

你也可以使用 bpftool prog tracelog 檢視相同的內容。

$ bpftool prog tracelog

現在,我們將上述輸出結果來和第 2 章的 eBPF 程式對比一下。

首先,系統呼叫事件和 xdp 事件是兩個完全不同的核心事件。

在系統呼叫事件中,程序透過執行系統呼叫,從使用者態陷入核心態,並以此來觸發 eBPF 程式的執行。此時 eBPF 函式所處的上下文是程序相關的資訊。

xdp 事件中,一旦有網路包到達指定網路卡,eBPF 程式就發生了。此時核心對於接收到的網路包是啥一無所知。更有甚者,核心對於網路包的去留也不能獨斷。

在上述的輸出中,每一行的 Hello World 之後跟隨著一個不斷遞增的數字,這就是我們定義的 counter 計數器。這個 counter 是一個全域性變數,並且我們前文提到過,它實際上是由 BPF_MAP 實現的([3.6 載入後的 eBPF 全貌](#3.6-載入後的 eBPF 全貌))。

接下來,我們來瞧一瞧 eBPF 程式中的全域性變數。

3.8 全域性變數

為啥 BPF_MAP 可以用作全域性變數?

這很好理解。我們前面的章節說過,BPF_MAP 這種結構是靜態的,存放在一段特定的記憶體中。它不僅允許從使用者空間訪問,還允許一段 eBPF 程式在多次執行中訪問,甚至允許多個不同的 eBPF 程式來訪問。

BPF_MAP 的這種特性,用來當做全域性變數再好不過了。

2019 年 2 月,全域性變數才被正式地引入 eBPF。

見:https://lore.kernel.org/bpf/20190228231829.11993-7-daniel@iogearbox.net/t/#u

同樣的,你可以使用 bpftool 來檢視核心空間的 BPF_MAP

$ bpftool map list
3: array  name hello.bss  flags 0x400
        key 4B  value 4B  max_entries 1  memlock 8192B
        btf_id 5

和前文我們得出的 eBPF 程式資訊一樣,hello() 程式被 ID 為 3 的 map 所關聯。

bss(block started by symbol)實際上是一個目標檔案內的其中一個 section,其通常用於存放全域性變數。我們繼續使用 bpftool 來檢視它的內容。

$ bpftool map dump name hello.bss
[{
        "value": {
            ".bss": [{
                    "counter": 780
                }
            ]
        }
    }
]

上面的結果,你也可以使用 bpftool map dump id 3 命令得到。

注意,我們檢視的 BPF_MAP 被應用為全域性變數,是會實時變化的。上述給出的內容實際上是某一時刻下的內容。

書中提到,如果在編譯時指定了 -g,並且當前 BTF 資訊可用,bpftool map dump name hello.bss 就會給出一個很漂亮的輸出:

![image-20240124101136607](D:\lianyihong\DeskTop\學習資料\eBPF\Learning eBPF.assets\image-20240124101136607.png)

有關 BTF,我們將在第 5 章深入探討。

書中的例子,在編譯後,還能夠看到一個名為 hello.rodata 的 map,這是一段只讀的資訊。這裡不再贅述,有興趣可以檢視原書。

到目前為止,我們已經完整的檢視了整個 eBPF 在核心中的樣貌了。是時候把它清理掉了。

清理需要分兩步:

  • 和事件解綁。
  • 從核心解除安裝。

3.9 清理-1:和事件解綁

解綁事件的操作與繫結操作正好相反。

$ bpftool net detach xdp dev enp0s8

這個命令如果執行成功了,啥也不會輸出,我們可以使用 bpftool net list 看一下。

$ bpftool net list
xdp:

tc:

flow_dissector:

解綁事件成功。

3.10 清理-2:從核心解除安裝

解綁事件並不會影響 eBPF 程式在核心中的載入狀態。用 bpftool 工具看一下:

$ bpftool prog show id 5
5: xdp  name hello  tag ec5542c3187de469  gpl
        loaded_at 2024-01-23T08:33:12+0800  uid 0
        xlated 144B  jited 95B  memlock 4096B  map_ids 3
        btf_id 5

還在核心空間。

但是,bpftool 到書成為止,還沒有提供直接解除安裝 eBPF 程式的命令。但是我們可以這樣做:

$ rm -f /sys/fs/bpf/hello

再次檢視名稱為 hello() 的 eBPF 程式:

$ bpftool prog show name hello

恭喜你,這個 eBPF 程式已經成功從核心態解除安裝了。

3.11 BPF 和 BPF 呼叫

eBPF 是支援函式呼叫的。注意啊,這裡說的不是前文提到的尾呼叫(Tail Call),而是正兒八百的函式呼叫。即,將一部分邏輯抽象成自定義函式,然後在 eBPF 程式中呼叫它。

舉個例子,我們魔改一下第二章的尾呼叫程式,讓它來繫結系統系統呼叫 sys_enter 的追蹤點。我們來看一看 eBPF 是如何抽象和呼叫函式的。

程式碼位置:chapter3/hello-func.bpf.c

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args *ctx) {
    return ctx->args[1];
}

SEC("raw_tp/")
int hello(struct bpf_raw_tracepoint_args *ctx) {
    int opcode = get_opcode(ctx);
    bpf_printk("Syscall: %d", opcode);
    return 0;
}

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

我們將獲取 opcode 動作抽象成函式,並宣告其為 static 靜態的。使用方式和幾乎和正常 C 函式一樣。

不過,這裡我們使用了 __attribute((noinline)) 來規定編譯器不要將我們的函式編譯成行內函數的形式(正常來講,編譯器會對 eBPF 函式做內聯最佳化)。

在對應目錄下使用 make 進行編譯(Makefile 檔案參考前文),並使用 bpftool 將其載入核心。

$ bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello
$ bpftool prog list name hello
4: raw_tracepoint  name hello  tag c86c2cef74f2057a  gpl
        loaded_at 2024-01-25T09:49:22+0800  uid 0
        xlated 120B  jited 86B  memlock 4096B
        btf_id 5

繼續,檢視位元組碼:

$ bpftool prog dump xlated name hello

位元組碼如下:

int hello(struct bpf_raw_tracepoint_args * ctx):
; int opcode = get_opcode(ctx);							; A
   0: (85) call pc+12#bpf_prog_cbacc90865b1b9a5_F
   1: (b7) r1 = 6563104
; bpf_printk("Syscall: %d", opcode);
   2: (63) *(u32 *)(r10 -8) = r1
   3: (18) r1 = 0x3a6c6c6163737953
   5: (7b) *(u64 *)(r10 -16) = r1
   6: (bf) r1 = r10
;
   7: (07) r1 += -16
; bpf_printk("Syscall: %d", opcode);
   8: (b7) r2 = 12
   9: (bf) r3 = r0
  10: (85) call bpf_trace_printk#-57216
; return 0;
  11: (b7) r0 = 0
  12: (95) exit
int get_opcode(struct bpf_raw_tracepoint_args * ctx):	; B
; return ctx->args[1];
  13: (79) r0 = *(u64 *)(r1 +8)
; return ctx->args[1];
  14: (95) exit

程式碼解釋:

【A】在這一行,我們可以看到 eBPF 程式呼叫了 get_opcode() 函式,第 0 條指令的操作碼為 0x85,代表函式呼叫。這條指令中的 call pc+12,代表下一條即將被執行的指令為當前 pc(程式計數器)向前移動 12 次的位置,也就是指令 13。

【B】這一部分是 get_opcode() 函式的位元組碼,起始位置就在指令 13。

函式呼叫指令會將當前狀態儲存在 eBPF 虛擬機器執行棧上,和一般的函式呼叫無二,當被呼叫者退出時,呼叫者將接續之前的狀態執行。

注意,前文在介紹尾呼叫時提到過:eBPF 執行棧僅有 512 位元組大小,因此設計多層函式呼叫的巢狀是非常不明智的選擇。

3.12 小結

本章深入剖析了一個基於 C 語言的 eBPF 程式從被編碼、編譯,到載入核心、繫結事件,再到執行、解除安裝的全過程。在這期間,我們使用了 bpftool 這個利器,作為掌控 eBPF 程式的強大法寶。

此外,我們瞭解了不同的 eBPF 事件種類(kprobe、tracepoint、xdp),以及他們的觸發時機和簡單區別。

我們也學習瞭如何使用 BPF_MAP 結構來實現全域性變數,以及如何在 eBPF 程式中抽象和定義函式,在某種程度上便捷了我們的 eBPF 程式設計。

那麼在下一章中,我們將繼續深入 bpf() 系統呼叫的機理。在使用 bpftool 的時候究竟發生了什麼?系統如何將我們的 eBPF 程式載入核心?又是如何繫結到某個事件上的?且聽下回分解。

相關文章