從這一章開始,我們先放下 BCC 框架,來看僅透過 C 語言如何實現一個 eBPF。如此一來,你會更加理解 BCC 所做的底層工作。
在這一章中,我們會討論一個 eBPF 程式被執行的完整流程,如下圖所示。
一個 eBPF 程式實際上是一組 eBPF 位元組碼
指令。因此你可以直接使用這種特定的位元組碼來編寫 eBPF 程式,就像寫彙編程式碼一樣。但實際上,我們都知道,彙編程式往往太抽象了。因此現在絕大部分的 eBPF 都是透過 C 語言這樣的高階語言來編寫的,最後經過編譯,生成可供執行的位元組碼。
從概念上講,eBPF 位元組碼將在核心的 eBPF 虛擬機器
中執行。
3.1 eBPF 虛擬機器
和其他虛擬機器一樣,eBPF 虛擬機器的主要作用就是將 eBPF 位元組碼
轉換成可以在本機 CPU 上執行的 機器碼
。
在原始的 eBPF 實現中,位元組碼是在核心中解釋執行的。這種方式有效能上的弊端,即,每次執行,都需要將 eBPF 從原始碼編譯解釋為機器碼,然後再執行。此外,這種傳統的方式也可能存在 Spectre 相關的漏洞。
Spectre 漏洞是一類側通道攻擊,可以利用程式碼執行路徑的依賴性來竊取敏感資訊。
JIT
(just-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()
是一個宏定義,它定義了一個名為 xdp
的 section
。我們將在第 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 章)。
到這裡為止,我們就可以看到 BCC
和 libbpf
的區別了。以列印字串為例,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 ),你可以在這個文件中查詢指令操作碼對應的操作虛擬碼。
可以看到,0xb7
對應的虛擬碼是 dst = (s64) (s8) imm
,即,將目標地址設定為一個立即數。
再來看,第 2 個位元組是 0x02
,代表源地址和目標地址,即源地址為空,目標地址為暫存器 R2
。
再來看,接下來 2 個位元組(一共16 bit)為 0,代表偏移量 off
為空。
再來看,接下來 4 個位元組(一共 32 bit),為 0x10
(小端法實際上為 0x00000010
),是立即數的十六進位制表示,對應的十進位制數為 16。
這條指令的實際含義就是通知核心,將暫存器 R2
的地址上存入一個立即數 16
。
譯者注:如果你結合前文給出的 bpf_insn
結構體來看,你就會發現,是可以一一對應的。
再舉一個例子。偏移量為 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_jited
:JIT
即時編譯產出 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 位元組碼,後面會有機會解釋。
我們用 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
事件上了。後面的 tc
和 flow_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 程式載入核心?又是如何繫結到某個事件上的?且聽下回分解。