eBPF 是 Linux 核心中一項強大的技術,而對映(maps)則是 eBPF 程式與使用者空間通訊的關鍵機制。在實際開發中,我發現對映的選擇與使用方式對效能和功能有決定性影響。本文將探討不同對映型別,並透過例項展示它們的應用場景。
對映型別與平行考量
某些對映型別具有每 CPU(per-CPU)變體,這意味著核心為每個 CPU 核心的對映版本使用不同的記憶體區塊。這引發了一個問題:對於非每 CPU 的對映,當多個 CPU 核心同時存取相同對映時,會有什麼平行性問題?
在 Linux 核心 5.1 版本中,部分對映型別加入了自旋鎖(spin lock)支援,這大幅改善了平行存取的安全性。這個機制確保在多核心環境下,對映操作能夠保持資料一致性,避免競爭條件(race condition)發生。
雜湊表對映實戰應用
接下來,我將透過一個雜湊表對映的基本操作範例,展示 BCC 提供的便捷抽象,讓對映使用變得極為簡單。這個 eBPF 程式將附加到 execve
系統呼叫的 kprobe 入口點,收集不同使用者執行程式的次數統計。
以下是 eBPF 程式的 C 程式碼:
BPF_HASH(counter_table);
int hello(void *ctx) {
u64 uid;
u64 counter = 0;
u64 *p;
uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
p = counter_table.lookup(&uid);
if (p != 0) {
counter = *p;
}
counter++;
counter_table.update(&uid, &counter);
return 0;
}
這段程式碼實作了一個簡單但實用的使用者活動監控功能:
BPF_HASH(counter_table)
是 BCC 定義雜湊表對映的巨集。bpf_get_current_uid_gid()
是一個輔助函式,用於取得觸發 kprobe 事件的處理程式的使用者 ID。它回傳一個 64 位元的值,低 32 位元存放使用者 ID,高 32 位元存放群組 ID(這裡使用遮罩& 0xFFFFFFFF
只保留使用者 ID)。counter_table.lookup(&uid)
在雜湊表中尋找與使用者 ID 比對的專案,並回傳指向對映中對應值的指標。- 如果找到該使用者 ID 的專案,則設定計數器變數為雜湊表中的當前值;如果沒有找到,指標為 0,計數器值保持為 0。
- 計數器值加 1,然後使用
counter_table.update(&uid, &counter)
更新雜湊表中該使用者 ID 的計數器值。
值得注意的是,這裡使用的語法 counter_table.lookup()
和 counter_table.update()
並非標準 C 語法。C 語言不支援像這樣在結構上定義方法。這是 BCC 的 C 語言變體,BCC 會在將程式碼傳送到編譯器之前重寫這些程式碼,將這些便捷的捷徑和巨集轉換為「正確的」C 程式碼。
與前面的「Hello World」範例一樣,C 程式碼被定義為名為 program
的字串。程式被編譯、載入核心,並以完全相同的方式附加到 execve
kprobe:
b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")
在 Python 端,我們需要一些額外的程式碼來讀取雜湊表中的資訊:
while True:
sleep(2)
s = ""
for k,v in b["counter_table"].items():
s += f"ID {k.value}: {v.value}\t"
print(s)
這段 Python 程式碼:
- 無限迴圈每兩秒檢查一次輸出
- BCC 自動建立一個 Python 物件來表示雜湊表
- 程式碼遍歷所有值並將它們列印到螢幕上
當執行這個範例時,開啟兩個終端視窗,一個執行 eBPF 程式,另一個執行一些命令來產生輸出。以下是一個執行結果的範例:
# 終端 1 # 終端 2
$ ./hello-map.py
[空行,直到執行命令]
ID 501: 1 ls
ID 501: 1
ID 501: 2 ls
ID 501: 3 ID 0: 1 sudo ls
ID 501: 4 ID 0: 1 ls
ID 501: 4 ID 0: 1
ID 501: 5 ID 0: 2 sudo ls
在這個輸出中,雜湊表最終包含兩個專案:
- key=501, value=5
- key=0, value=2
在第二個終端中,使用者 ID 為 501。執行 ls
命令會增加 execve
計數器。執行 sudo ls
會導致兩次 execve
呼叫:一次是以使用者 ID 501 執行 sudo
,另一次是以 root 使用者 ID 0 執行 ls
。
效能緩衝區與環形緩衝區對映
雜湊表在資料自然為鍵值對時非常方便,但使用者空間程式碼必須定期輪詢表格。Linux 核心已經支援 perf 子系統從核心向使用者空間傳送資料,而 eBPF 則包含對使用 perf 緩衝區及其後繼者 BPF 環形緩衝區的支援。
接下來我將介紹一個更複雜的「Hello World」版本,它使用 BCC 的 BPF_PERF_OUTPUT
功能,讓你能將自定義結構的資料寫入 perf 環形緩衝區對映。
注意:如果你使用的是 5.8 或以上版本的核心,一般建議使用較新的「BPF 環形緩衝區」而非 BPF perf 緩衝區。Andrii Nakryiko 在他的 BPF 環形緩衝區部落格文章中討論了兩者的差異。
環形緩衝區原理
環形緩衝區並非 eBPF 獨有,但為了確保理解,我簡要解釋一下。你可以將環形緩衝區視為邏輯上組織成一個環的記憶體區塊,具有獨立的「寫入」和「讀取」指標。
任意長度的資料會被寫入到寫指標所在的位置,包括該資料的長度資訊放在標頭中。寫指標移動到該資料的末尾,準備下一次寫入操作。
同樣,對於讀取操作,資料從讀指標所在的位置讀取,使用標頭來確定要讀取的資料量。讀指標朝著與寫指標相同的方向移動,指向下一個可用的資料片段。
如果讀指標趕上寫指標,這僅意味著沒有資料可讀。如果寫入操作會使寫指標超過讀指標,則資料不會被寫入,而是增加丟棄計數器。讀取操作包括丟棄計數器,以指示自上次成功讀取以來是否有資料丟失。
在大多數應用中,讀取和寫入操作的時間隔會有一些變化,因此緩衝區大小需要調整以考慮這一點。
使用效能緩衝區的「Hello World」範例
以下是一個使用 BCC 的 BPF_PERF_OUTPUT
功能的範例,每次使用 execve()
系統呼叫時,它會將「Hello World」字串寫入螢幕。它還會查詢程式 ID 和執行 execve()
呼叫的命令名稱。
以下是將載入到核心的 eBPF 程式:
BPF_PERF_OUTPUT(output);
struct data_t {
int pid;
int uid;
char command[16];
char message[12];
};
int hello(void *ctx) {
struct data_t data = {};
char message[12] = "Hello World";
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_kernel(&data.message, sizeof(data.message), message);
output.perf_submit(ctx, &data, sizeof(data));
return 0;
}
BPF_PERF_OUTPUT
是 BCC 定義的巨集,用於建立一個對映,用於將訊息從核心傳遞到使用者空間。- 定義了一個名為
data_t
的結構,包含 pid、uid、命令名稱和訊息。 bpf_get_current_pid_tgid()
回傳一個 64 位元值,高 32 位元是處理程式 ID(PID),低 32 位元是執行緒組 ID(TGID)。透過右移 32 位元,我們取得 PID。bpf_get_current_comm()
是一個輔助函式,用於取得當前處理程式的名稱(最多 16 個字元)。bpf_probe_read_kernel()
從核心記憶體中安全地讀取資料,這裡用於複製「Hello World」字串到我們的資料結構中。- 最後,使用
output.perf_submit()
將資料傳送到效能緩衝區,從而使其可從使用者空間存取。
這個範例展示了 eBPF 程式如何從核心收集資料並透過效能緩衝區對映將其傳輸到使用者空間,這是許多 eBPF 應用程式的常見模式。
對映型別選擇策略
在實際開發 eBPF 應用程式時,選擇合適的對映型別對效能和功能至關重要。以下是我在選擇對映型別時考慮的關鍵因素:
資料結構比對度:如果資料自然形成鍵值對,雜湊表是理想選擇;如果鍵是連續整數,陣列可能更有效率。
通訊方向:對於核心到使用者空間的事件通知,效能緩衝區或環形緩衝區更適合;對於使用者空間查詢核心資料,雜湊表或陣列更合適。
平行存取需求:如果多個 CPU 核心會同時存取對映,考慮使用每 CPU 變體或具有自旋鎖支援的對映。
記憶體使用:陣列預先分配固定大小的記憶體,適合已知大小的資料集;雜湊表動態分配,適合大小未知或變化的資料集。
核心版本相容性:較新的對映型別(如 BPF 環形緩衝區)可能需要較新的核心版本,需要考慮佈署環境的相容性。
對映操作的效能考量
在處理高流量系統時,對映操作的效能變得尤為重要。從我的經驗來看,以下幾點可以顯著提升 eBPF 程式的效能:
減少對映查詢次數:每次對映操作都有開銷,因此應盡量減少不必要的查詢。
適當的批次處理:對於頻繁發生的事件,考慮批次更新對映而非每次事件都更新。
考慮記憶體區域性:頻繁存取的資料應盡可能保持在相同的對映中,以提高快取命中率。
選擇合適的對映大小:對於陣列和雜湊表,選擇合適的大小既能避免記憶體浪費,又能確保足夠的容量。
避免複雜的對映操作:在 eBPF 程式中,複雜的對映操作可能導致驗證器拒絕程式,應盡量保持操作簡單。
結語
eBPF 對映提供了核心與使用者空間之間的強大通訊機制,理解不同對映型別的特性和適用場景對於開發高效的 eBPF 應用至關重要。透過本文介紹的雜湊表和效能緩衝區對映範例,你可以開始探索 eBPF 的強大功能,並將其應用到實際問題中。
隨著 Linux 核心的發展,eBPF 對映型別和功能也在不斷演進,保持對新特性的關注將幫助你充分利用這一強大技術。在系統監控、安全分析和網路加速等領域,eBPF 對映已經成為不可或缺的工具。
eBPF 的資料傳遞機制:從追蹤到效能監控
在建構 eBPF 監控工具時,資料傳遞是一個核心問題。上次我們討論了基礎的 eBPF 程式,今天讓我們探討更進階的資料傳遞機制,特別是透過環形緩衝區(Ring Buffer)實作高效能的資料交換。
環形緩衝區與結構化資料傳輸
當 eBPF 程式需要向使用者空間傳遞複雜資料時,環形緩衝區提供了比追蹤管道(trace pipe)更靈活的解決方案。以下是一個典型的資料結構定義:
struct data_t {
u32 pid;
u32 uid;
char command[16];
char message[12];
};
BPF_PERF_OUTPUT(output);
int hello(void *ctx) {
struct data_t data = {};
char message[12] = "Hello World";
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_kernel(&data.message, sizeof(data.message), message);
output.perf_submit(ctx, &data, sizeof(data));
return 0;
}
這段程式碼定義了一個資料傳輸機制,每次 hello()
函式執行時,都會傳送一個包含多個欄位的結構體資料:
data_t
結構定義了我們要傳遞的資料格式,包含處理程式 ID、使用者 ID、命令名稱和訊息文字BPF_PERF_OUTPUT(output)
建立了一個名為 “output” 的效能緩衝區對映data.pid
透過bpf_get_current_pid_tgid()
取得當前處理程式 ID(從 64 位元值的高 32 位取得)data.uid
使用bpf_get_current_uid_gid()
取得使用者 IDbpf_get_current_comm()
取得觸發此 eBPF 程式的處理程式名稱,注意這裡需要傳入欄位的記憶體位址bpf_probe_read_kernel()
將 “Hello World” 訊息複製到結構體中- 最後,
output.perf_submit()
將填充好的資料結構送入效能緩衝區對映
這種方法的優勢在於能傳輸結構化資料,而不只是簡單的字串。
Python 端的資料接收與處理
與 C 程式搭配的 Python 程式碼負責接收和處理來自核心的資料:
b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")
def print_event(cpu, data, size):
data = b["output"].event(data)
print(f"{data.pid} {data.uid} {data.command.decode()} " + \
f"{data.message.decode()}")
b["output"].open_perf_buffer(print_event)
while True:
b.perf_buffer_poll()
Python 端的程式碼設定了資料接收機制:
- 編譯並載入 eBPF 程式,將其附加到
execve
系統呼叫 - 定義
print_event
回呼函式,當有資料從核心傳來時被觸發 b["output"].event(data)
將原始資料轉換為結構化物件,可以直接存取其中的欄位open_perf_buffer()
開啟效能環形緩衝區,指定回呼函式- 無限迴圈中的
perf_buffer_poll()
持續檢查緩衝區是否有新資料
執行此程式時,每當系統上有新的 execve
系統呼叫發生,就會在終端輸出相應的處理程式 ID、使用者 ID、命令名稱和 “Hello World” 訊息。
環形緩衝區的優勢
與直接使用追蹤管道相比,環形緩衝區機制有幾個重要優勢:
- 專用資料通道:每個 eBPF 程式可以擁有自己的緩衝區,避免與其他程式混淆
- 結構化資料:可以傳輸複雜的資料結構,而不僅是格式化字串
- 效能優勢:資料直接從核心傳遞到使用者空間,無需經過中間層處理
- 上下文資訊:可以輕鬆捕捉事件的相關上下文資訊
這種機制使 eBPF 非常適合建構高效能的監控工具,因為它能在事件發生時收集豐富的上下文資訊,而無需在核心和使用者空間之間進行同步上下文切換。
eBPF 中的函式呼叫機制
在開發複雜的 eBPF 程式時,我們自然希望能將程式碼模組化,避免重複。然而,eBPF 的函式呼叫機制與一般程式設計有些不同。
內嵌函式
早期的 eBPF 程式不允許呼叫除了輔助函式以外的其他函式。為瞭解決這個限制,開發者通常使用 “always inline” 指令強制編譯器內嵌函式:
static __always_inline void my_function(void *ctx, int val) {
// 函式實作
}
當函式被標記為 __always_inline
時,編譯器不會生成跳轉指令,而是直接將函式的指令複製到呼叫處。這與一般的函式呼叫有根本差異:
- 一般函式呼叫:編譯器生成跳轉指令,執行時跳到函式的程式碼位置,執行完後再跳回
- 內嵌函式:編譯器直接將函式的程式碼複製到呼叫處,不產生跳轉指令
如果一個內嵌函式在多處被呼叫,最終編譯後的程式碼中會有多份該函式的指令副本。這會增加程式碼大小,但避免了跳轉開銷。
有一點值得注意的是,有時編譯器會出於最佳化目的自行決定內嵌某些函式,這也是為什麼有時候無法附加 kprobe 到某些核心函式的原因之一。
BPF 到 BPF 函式呼叫
從 Linux 核心 4.16 和 LLVM 6.0 開始,eBPF 程式可以使用正常的函式呼叫,不再需要強制內嵌。這個功能被稱為 “BPF to BPF function calls” 或 “BPF subprograms”。
不過,目前 BCC 框架尚未完全支援這個功能。如果使用 BCC,仍然需要將函式標記為內嵌。在後續的文章中,我會使用其他框架(如 libbpf)展示如何使用此功能。
尾呼叫:另一種程式結構化方法
除了內嵌函式和常規函式呼叫外,eBPF 還支援一種特殊的呼叫方式:尾呼叫(Tail Calls)。
根據 ebpf.io 的定義,“尾呼叫可以呼叫並執行另一個 eBPF 程式並替換執行上下文,類別似於一般程式中 execve() 系統呼叫的操作方式。” 重要的是,尾呼叫完成後不會回傳到呼叫者。
long bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index);
尾呼叫使用 bpf_tail_call()
輔助函式實作,該函式有三個引數:
ctx
:傳遞給被呼叫程式的上下文prog_array_map
:型別為BPF_MAP_TYPE_PROG_ARRAY
的 eBPF 對映,其中包含指向 eBPF 程式的檔案描述符index
:指定要呼叫的 eBPF 程式在對映中的索引
這個輔助函式有一個獨特的特性:如果成功,它永遠不會回傳。當前執行的 eBPF 程式會在堆積積疊上被呼叫的程式替換。如果呼叫失敗(例如指定的程式不存在),則原程式會繼續執行。
尾呼叫不僅是 eBPF 獨有的概念,它在函式程式設計中很常見。其主要目的是避免在遞迴呼叫中不斷增加堆積積疊框架,最終可能導致堆積積疊溢位。在 eBPF 中,由於堆積積疊限制為 512 位元組,尾呼叫特別有用,可以在不增加堆積積疊使用量的情況下呼叫一系列函式。
實際應用:構建模組化的 eBPF 監控解決方案
當我開發複雜的 eBPF 監控解決方案時,通常會結合上述技術來構建模組化、高效能的系統。以下是一個實際的設計模式:
- 使用環形緩衝區傳輸豐富的結構化資料
- 內嵌函式處理常見的資料處理邏輯
- 尾呼叫實作複雜的處理管道,特別是需要根據不同條件執行不同分析邏輯時
例如,在一個網路監控解決方案中,我可能會使用尾呼叫根據封包型別選擇不同的分析程式,每個分析程式再使用內嵌函式處理共同的資料提取邏輯,最後透過環形緩衝區將結構化的分析結果傳送到使用者空間。
這種組合使用的方法能夠在 eBPF 的限制條件下實作複雜的邏輯,同時保持高效能。
eBPF 的強大之處不僅在於它能夠執行在核心中,還在於它提供了多種機制來實作複雜的程式邏輯和高效能的資料傳輸:
環形緩衝區提供了從核心到使用者空間傳輸結構化資料的高效機制,使監控工具能夠捕捉豐富的上下文資訊。內嵌函式、BPF 到 BPF 函式呼叫和尾呼叫則提供了不同的程式結構化方法,讓開發者能夠根據需求選擇合適的方式組織程式碼。
透過這些機制,eBPF 為系統觀測性、網路監控和安全防護提供了前所未有的能力,讓我們能夠以最小的效能開銷取得最豐富的系統資訊。在下一篇文章中,我將探討如何使用 eBPF 對映儲存狀態和在多個 eBPF 程式間分享資料,進一步擴充套件 eBPF 的應用範圍。
eBPF 尾呼叫:在核心中實作動態程式流程控制
在eBPF程式設計中,尾呼叫(Tail Calls)是一種強大的機制,讓開發者可以在核心中實作複雜的程式流程控制。本文將深入解析尾呼叫的工作原理、應用場景以及如何在實際開發中運用這種技術來追蹤系統呼叫。
尾呼叫的核心概念
尾呼叫允許一個eBPF程式在結束前跳轉到另一個eBPF程式,而不回傳原程式。這種機制特別適合實作複雜的條件式處理邏輯,同時繞過eBPF程式指令數的限制。
使用者空間的程式碼需要負責將所有eBPF程式載入核心,並設定程式陣列對映(program array map)。這種對映是尾呼叫實作的關鍵,它允許一個eBPF程式根據特定條件動態選擇並跳轉到不同的處理程式。
使用BCC框架實作尾呼叫
以下是使用Python和BCC框架實作尾呼叫的範例。這個範例追蹤系統呼叫,並根據不同的系統呼叫程式碼執行不同的處理邏輯:
BPF_PROG_ARRAY(syscall, 300);
int hello(struct bpf_raw_tracepoint_args *ctx) {
int opcode = ctx->args[1];
syscall.call(ctx, opcode);
bpf_trace_printk("Another syscall: %d", opcode);
return 0;
}
int hello_execve(void *ctx) {
bpf_trace_printk("Executing a program");
return 0;
}
int hello_timer(struct bpf_raw_tracepoint_args *ctx) {
if (ctx->args[1] == 222) {
bpf_trace_printk("Creating a timer");
} else if (ctx->args[1] == 226) {
bpf_trace_printk("Deleting a timer");
} else {
bpf_trace_printk("Some other timer operation");
}
return 0;
}
int ignore_opcode(void *ctx) {
return 0;
}
這段程式碼展示了尾呼叫的核心實作:
BPF_PROG_ARRAY(syscall, 300)
- 定義了一個能存放300個專案的程式陣列對映,這個對映將用於存放不同系統呼叫對應的處理程式。hello()
函式是主要的eBPF程式,它會被附加到系統呼叫的進入點上。當系統呼叫發生時,它從上下文中提取操作碼(opcode),然後嘗試執行對應的尾呼叫。syscall.call(ctx, opcode)
- 這行程式碼在BCC中會被重寫為bpf_tail_call(ctx, syscall, opcode)
,它根據系統呼叫的操作碼執行相應的尾呼叫程式。若找不到對應的尾呼叫程式(即對映中沒有該操作碼的專案),則執行後續的預設邏輯,輸出一般性的追蹤訊息。
hello_execve()
、hello_timer()
和ignore_opcode()
是三個不同的尾呼叫程式,分別處理不同型別的系統呼叫。
使用者空間程式碼的設定
尾呼叫機制需要使用者空間程式碼來設定和管理對映關係。以下是相應的Python程式碼:
b = BPF(text=program)
b.attach_raw_tracepoint(tp="sys_enter", fn_name="hello")
ignore_fn = b.load_func("ignore_opcode", BPF.RAW_TRACEPOINT)
exec_fn = b.load_func("hello_exec", BPF.RAW_TRACEPOINT)
timer_fn = b.load_func("hello_timer", BPF.RAW_TRACEPOINT)
prog_array = b.get_table("syscall")
prog_array[ct.c_int(59)] = ct.c_int(exec_fn.fd)
prog_array[ct.c_int(222)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(226)] = ct.c_int(timer_fn.fd)
# 忽略一些頻繁出現的系統呼叫
prog_array[ct.c_int(21)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(22)] = ct.c_int(ignore_fn.fd)
b.trace_print()
這段使用者空間程式碼展示瞭如何設定和管理尾呼叫:
首先,將主eBPF程式附加到
sys_enter
原始追蹤點,這個追蹤點會在任何系統呼叫發生時被觸發。使用
load_func()
載入三個尾呼叫程式,取得它們的檔案描述符。注意,尾呼叫程式必須與其父程式有相同的程式型別。取得程式陣列對映
syscall
,並設定對映關係:- 操作碼59(execve)對映到
hello_exec
程式 - 操作碼222(建立計時器)和226(刪除計時器)對映到
hello_timer
程式 - 一些頻繁執行的系統呼叫對映到
ignore_opcode
程式,以減少輸出雜訊
- 操作碼59(execve)對映到
最後,使用
trace_print()
輸出追蹤結果,直到使用者終止程式。
執行結果分析
執行這個程式會產生所有系統呼叫的追蹤輸出,除非該系統呼叫的操作碼在對映中指向 ignore_opcode()
程式。以下是在另一個終端執行 ls
命令時的部分輸出:
./hello-tail.py
b' hello-tail.py-2767 ... Another syscall: 62'
b' hello-tail.py-2767 ... Another syscall: 62'
...
b' bash-2626 ... Executing a program'
b' bash-2626 ... Another syscall: 220'
...
b' <...>-2774 ... Creating a timer'
b' <...>-2774 ... Another syscall: 48'
b' <...>-2774 ... Deleting a timer'
...
b' ls-2774 ... Another syscall: 61'
b' ls-2774 ... Another syscall: 61'
...
從輸出可以看出:
- 當執行程式(操作碼59)時,會輸出 “Executing a program”
- 當建立計時器(操作碼222)時,會輸出 “Creating a timer”
- 當刪除計時器(操作碼226)時,會輸出 “Deleting a timer”
- 對於其他沒有特定處理的系統呼叫,輸出預設的 “Another syscall: [操作碼]”
尾呼叫的效能與限制
關於尾呼叫的效能,值得一提的是Paul Chaignon的部落格文章,其中分析了不同核心版本上eBPF尾呼叫的成本。尾呼叫自核心4.2版本開始支援,但長期以來與BPF到BPF的函式呼叫不相容。這一限制在核心5.10中被解除。
目前,eBPF允許連結最多33個尾呼叫,再加上每個eBPF程式100萬條指令的複雜度限制,這使得開發者能夠編寫非常複雜的程式,完全在核心中執行。
尾呼叫的進階應用
尾呼叫不僅可以用於系統呼叫追蹤,還可以應用於更多複雜的場景。以下是一些進階應用:
動態程式選擇
尾呼叫的一個主要優勢是能夠根據執行時條件動態選擇執行路徑。這在處理複雜協定或有多種可能狀態的系統時特別有用。例如,在網路封包處理中,可以根據協定型別、封包頭部特徵等條件跳轉到不同的處理程式。
擴充套件程式複雜度
由於eBPF程式的指令數量有限制,尾呼叫提供了一種變通方法,允許開發者將複雜邏輯分割成多個較小的程式。這些程式可以透過尾呼叫連結在一起,實作超出單一程式複雜度限制的功能。
程式熱更新
尾呼叫還支援一種形式的程式熱更新。由於程式陣列對映可以在執行時修改,開發者可以在不中斷服務的情況下,更新特定操作碼對應的處理程式。這對於長時間執行的系統監控或安全工具特別有價值。
實作考量與最佳實踐
在實際應用尾呼叫時,有幾點值得注意:
程式型別一致性
所有參與尾呼叫的程式必須具有相同的程式型別。在上面的例子中,所有程式都是 BPF.RAW_TRACEPOINT
型別。這是因為尾呼叫本質上是在相同執行上下文中的跳轉。
上下文引數處理
尾呼叫程式接收與主程式相同的上下文引數。這意味著尾呼叫程式必須正確理解和處理這些引數。在我們的例子中,hello_timer()
需要知道 ctx->args[1]
包含系統呼叫操作碼。
對映管理策略
程式陣列對映不需要為每個可能的鍵值都設定專案。未設定的鍵值只會導致尾呼叫失敗,程式會繼續執行尾呼叫之後的程式碼。這提供了一種簡單的預設行為機制。
避免過度使用
雖然尾呼叫很強大,但不應該過度使用。每次尾呼叫都有一定的效能開銷,特別是在較舊的核心版本上。在設計時,應該權衡使用尾呼叫帶來的靈活性與潛在的效能影響。
實驗練習
如果你想進一步探索eBPF和尾呼叫,這裡有一些值得嘗試的練習:
修改 hello-buffer.py eBPF 程式,為奇數和偶數程式ID輸出不同的追蹤訊息。
擴充套件 hello-map.py,使eBPF程式能夠追蹤多種系統呼叫。例如,追蹤 openat() 和 write() 系統呼叫,分別處理檔案開啟和寫入操作。
將 hello-tail.py 修改為追蹤每個使用者ID發起的系統呼叫總數。這需要將程式附加到 sys_enter 原始追蹤點,並使用對映來統計不同使用者的系統呼叫次數。
這些練習將幫助你更好地理解eBPF程式的工作原理,以及如何利用對映和尾呼叫實作更複雜的功能。
結語
eBPF尾呼叫機制為開發者提供了強大的工具,使我們能夠在核心中實作複雜的條件處理邏輯。透過將多個eBPF程式組織在一起,我們可以構建出功能豐富與高效的系統監控、網路處理和安全工具。
隨著核心5.10版本解除了尾呼叫與BPF到BPF函式呼叫的不相容性,eBPF程式設計變得更加靈活和強大。這為未來開發更複雜的核心內應用開啟了新的可能性。
在接下來的實作中,我們將探討eBPF程式的構建、載入和附加機制,以及如何不依賴BCC框架直接實作這些功能。這將幫助你更全面地理解eBPF技術的內部工作原理。
eBPF 程式的解剖學:從原始碼到執行的完整旅程
在前一篇文章中,我們透過 BCC 框架實作了一個簡單的 eBPF “Hello World” 程式。BCC 框架雖然簡化了開發流程,但也隱藏了許多底層細節。這次,我想帶大家深入瞭解 eBPF 程式的內部結構,從原始碼到在核心中執行的完整流程。
eBPF 程式的生命週期
eBPF 程式從撰寫到執行,會經過一系列轉換階段。這個過程可以概括為:
- 開發者使用高階語言(通常是 C 或 Rust)撰寫程式
- 高階語言編譯為 eBPF 位元組碼
- 位元組碼經過核心驗證器檢查安全性
- 位元組碼透過 JIT(Just-In-Time)編譯或解釋執行成機器碼
- 程式在核心中執行
這個流程確保了 eBPF 程式既有高效能,又能維持核心的安全性和穩定性。
eBPF 虛擬機器:核心中的程式執行環境
eBPF 虛擬機器是 Linux 核心中的一個軟體實作,負責執行 eBPF 位元組碼。這個虛擬機器提供了一個安全的沙盒環境,讓使用者空間的程式能夠在核心空間執行特定的功能,而不會危及整個系統的穩定性。
在早期的 eBPF 實作中,位元組碼指令是透過直譯器(interpreter)執行的。這意味著每次執行 eBPF 程式時,核心都需要即時將指令轉換為機器碼。然而,為了提高效能並避免 Spectre 相關的安全漏洞,現代 Linux 核心大多使用 JIT 編譯技術。
JIT 編譯只需在程式載入核心時進行一次轉換,將 eBPF 位元組碼編譯成原生機器指令。這大幅提高了執行效率,因為後續每次觸發程式時,都可以直接執行已編譯好的機器碼。
eBPF 暫存器模型
eBPF 虛擬機器設計了一個簡潔而高效的暫存器模型,包含 11 個暫存器:
- 10 個通用暫存器(R0-R9):用於儲存和操作資料
- 1 個唯讀堆積積疊指標暫存器(R10):用於存取堆積積疊空間
這些暫存器是 eBPF 虛擬機器的軟體實作,而非實體 CPU 暫存器。當 eBPF 程式執行時,這些虛擬暫存器會被對映到實體 CPU 暫存器上。
暫存器使用有特定的慣例:
- R0:用於儲存函式的回傳值
- R1-R5:用於傳遞函式引數(最多支援 5 個引數)
- R1:在程式開始執行前,會被載入程式的上下文(context)引數
- R10:堆積積疊指標,唯讀,不能被修改
當我在開發複雜的 eBPF 程式時,瞭解這些暫存器的用途和限制對於最佳化程式流程非常重要。例如,如果需要傳遞超過 5 個引數,就必須使用堆積積疊空間來儲存額外的引數。
eBPF 指令集結構
eBPF 位元組碼由一系列指令組成,每條指令由一個 bpf_insn
結構表示。這個結構在 Linux 核心的 include/uapi/linux/bpf.h
標頭檔中定義:
struct bpf_insn {
__u8 code; /* 操作碼 */
__u8 dst_reg:4; /* 目標暫存器 */
__u8 src_reg:4; /* 來源暫存器 */
__s16 off; /* 有符號偏移量 */
__s32 imm; /* 有符號立即數常數 */
};
這個結構定義了 eBPF 指令的格式,每個欄位都有特定用途:
code
:操作碼,定義指令要執行的操作(如加法、載入、儲存等)dst_reg
和src_reg
:目標和來源暫存器,指定操作涉及的暫存器off
:偏移量,用於記憶體存取或跳轉指令imm
:立即數,可直接用於運算的常數值
每條指令佔用 8 位元組(64 位元),但有些操作(如載入 64 位元常數)需要更多空間,這時會使用「寬指令編碼」(wide instruction encoding),擴充套件到 16 位元組。
eBPF 指令集主要包含以下幾類別操作:
- 載入指令:將值載入暫存器(從記憶體、其他暫存器或立即數)
- 儲存指令:將暫存器中的值儲存到記憶體
- 算術指令:執行加減乘除等運算
- 跳轉指令:根據條件跳轉到不同的指令
- 函式呼叫指令:呼叫 BPF 輔助函式或其他 BPF 程式
從 C 語言到 eBPF 位元組碼
當我們用 C 語言撰寫 eBPF 程式時,編譯器(通常是 LLVM)會將 C 程式碼轉換為 eBPF 位元組碼。這個過程與傳統 C 編譯相似,但目標平台是 eBPF 虛擬機器而非實體 CPU。
以下是一個簡化的 Hello World eBPF 程式的 C 語言版本:
#include <linux/bpf.h>
#include <linux/version.h>
#include <bpf/bpf_helpers.h>
SEC("tracepoint/syscalls/sys_enter_execve")
int hello(void *ctx) {
char msg[] = "Hello, eBPF!";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
這個程式將在系統呼叫 execve
執行時被觸發,並輸出 “Hello, eBPF!” 訊息。關鍵元素包括:
SEC
巨集:定義程式應該附加到的掛載點(這裡是execve
系統呼叫的追蹤點)bpf_trace_printk
:一個 BPF 輔助函式,用於輸出訊息(類別似於printf
)LICENSE
定義:eBPF 程式必須指定授權,只有 GPL 相容的授權才能使用某些核心功能
編譯這段程式碼後,會產生 eBPF 位元組碼,大致如下(使用 llvm-objdump -S
可以檢視):
0: r1 = 0x0
1: call bpf_trace_printk
2: r0 = 0x0
3: exit
雖然這是簡化的表示,但實際的位元組碼會更複雜,包含完整的記憶體操作、引數準備等指令。
JIT 編譯過程
當 eBPF 程式載入到核心時,驗證器會先檢查程式的安全性。透過驗證後,如果系統支援 JIT 編譯(現代 Linux 核心幾乎都支援),核心會立即將 eBPF 位元組碼編譯為原生機器碼。
JIT 編譯器會針對不同的 CPU 架構(如 x86_64、ARM64)產生最佳化的機器碼。這個過程會將 eBPF 虛擬暫存器對映到實體 CPU 暫存器,並將 eBPF 指令轉換為等效的 CPU 指令。
JIT 編譯的主要優勢是顯著提升執行效能。在我的測試中,JIT 編譯的 eBPF 程式比解釋執行的程式快 3-5 倍。此外,JIT 編譯還能避免某些與 Spectre 漏洞相關的安全問題。
真實案例:追蹤系統呼叫
讓我們回顧上一章的 hello-map.py
範例的輸出,更深入理解 eBPF 程式的實際運作:
$ ./hello-map.py
ID 104: 6 ID 0: 225
ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 332
ID 501: 19
ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 368
ID 501: 38
ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 533
ID 501: 57
這個輸出顯示了不同程式(由 ID 表示)執行系統呼叫的次數。每當系統呼叫發生時,eBPF 程式就會更新雜湊表中對應程式 ID 的計數器。
在底層,這個程式使用了 eBPF 對映(BPF maps)來儲存狀態資訊。當系統呼叫觸發 eBPF 程式時,程式會:
- 從上下文中取得程式 ID
- 在 BPF 對映中查詢該 ID 的計數器
- 將計數器加一
- 將更新後的值存回映射
這個過程涉及多條 eBPF 指令,包括載入上下文、呼叫輔助函式、更新對映等操作。
使用 RAW_TRACEPOINT_PROBE 簡化程式碼
BCC 框架提供了許多巨集來簡化 eBPF 程式的開發。例如,我們可以使用 RAW_TRACEPOINT_PROBE
巨集來替代手動附加到追蹤點的程式碼。
將 hello-tail.py
中的程式碼:
def hello(ctx):
# 處理邏輯...
b.attach_raw_tracepoint(event="sys_enter", fn_name="hello")
簡化為:
@RAW_TRACEPOINT_PROBE(sys_enter)
def hello(ctx):
# 處理邏輯...
這樣,BCC 會自動處理附加到追蹤點的邏輯,讓程式碼更加簡潔。在開發 eBPF 程式時,利用這些高階抽象可以大幅提高生產力,同時也能減少錯誤。
進階應用:追蹤特定系統呼叫
我們可以進一步修改 hello_map.py
,讓它追蹤特定系統呼叫的執行次數,而不是按照使用者 ID 進行統計。這需要修改雜湊表的鍵,從使用者 ID 改為系統呼叫 ID。
修改後的程式碼可能如下所示:
from bcc import BPF
program = """
BPF_HASH(counter_table);
int hello(void *ctx) {
u64 syscall_id = bpf_get_current_syscalls_id();
u64 init_val = 1;
u64 *count = counter_table.lookup(&syscall_id);
if (count) {
(*count)++;
} else {
counter_table.update(&syscall_id, &init_val);
}
return 0;
}
"""
b = BPF(text=program)
b.attach_raw_tracepoint(event="sys_enter", fn_name="hello")
try:
while True:
for k, v in b["counter_table"].items():
print(f"Syscall ID {k.value}: {v.value}")
time.sleep(1)
except KeyboardInterrupt:
pass
這個程式會追蹤所有系統呼叫,並按照系統呼叫 ID 統計執行次數。關鍵修改是使用 bpf_get_current_syscalls_id()
來取得系統呼叫 ID,並將其作為雜湊表的鍵。
Linux 系統中有約 300 個系統呼叫,透過這種方式,我們可以清楚地瞭解哪些系統呼叫最常被使用,這對於效能分析和安全監控非常有價值。
eBPF 程式的限制與最佳化
開發 eBPF 程式時,需要注意一些限制:
- 程式大小限制:eBPF 程式的指令數量有上限(在較新的核心版本中已經從最初的 4096 提高到 100 萬條指令)
- 堆積積疊大小限制:eBPF 程式的堆積積疊大小通常限制在 512 位元組
- 無法使用遞迴:eBPF 驗證器禁止遞迴呼叫,以防止無限迴圈
- 有限的迴圈支援:早期 eBPF 不支援迴圈,現在雖然支援,但迴圈必須有確定的上限
- 有限的函式呼叫:只能呼叫特定的 BPF 輔助函式和尾呼叫(tail calls)其他 BPF 程式
這些限制主要是為了確保 eBPF 程式的安全性和可預測的執行時間。在開發過程中,我經常遇到的挑戰是如何在這些限制下實作複雜的功能。
一個重要的最佳化技巧是使用尾呼叫(tail calls)來擴充套件程式功能。尾呼叫允許一個 eBPF 程式呼叫另一個 eBPF 程式,這樣可以突破單個程式的大小限制。不過,在使用尾呼叫時需要注意,它需要 JIT 編譯器的支援。在我撰寫這篇文章時使用的核心版本中,只有 x86 架構的 JIT 編譯器支援從 BPF 子程式進行尾呼叫,而 ARM 架構則在核心 6.0 版本中增加了這項支援。
eBPF 程式的執行流程是一個從高階語言到核心執行的複雜旅程。瞭解 eBPF 虛擬機器的工作原理、指令集結構和暫存器模型,對於開發高效的 eBPF 程式至關重要。
透過 JIT 編譯技術,eBPF 程式能夠以接近原生效能的速度執行,同時保持核心的安全性和穩定性。這使得 eBPF 成為實作系統監控、網路過濾和安全稽核等功能的強大工具。
隨著 Linux 核心的不斷發展,eBPF 的功能和效能還在持續提升。在未來的文章中,我將探討更多 eBPF 的進階應用和最佳實踐。
eBPF 程式的完整生命週期解析
eBPF (extended Berkeley Packet Filter) 技術已成為 Linux 核心中不可或缺的一部分,它允許開發者在核心空間安全地執行程式碼,而不需要修改核心或載入核心模組。在這篇文章中,我將帶大家探索 eBPF 程式的完整生命週期,從原始 C 程式碼開始,一直到它被轉換成 eBPF 位元組碼,最後執行於 Linux 核心中。
網路介面的 eBPF “Hello World”
與常見的系統呼叫觸發型 eBPF 程式不同,這次我們將實作一個由網路封包到達時觸發的 eBPF 程式。封包處理是 eBPF 最常見的應用場景之一。
在這個簡單範例中,程式不會對網路封包進行任何操作,它只是在每次接收到網路封包時,向追蹤管道寫入 “Hello World” 和一個計數器。
以下是這個範例程式的完整程式碼 (hello.bpf.c
):
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
int counter = 0;
SEC("xdp")
int hello(void *ctx) {
bpf_printk("Hello World %d", counter);
counter++;
return XDP_PASS;
}
char LICENSE[] SEC("license") = "Dual BSD/GPL";
這段程式碼雖然簡短,但包含了 eBPF 程式的幾個重要元素:
- 標頭檔案:包含了 eBPF 相關的定義和輔助函式。
- 全域變數:
counter
變數會在每次程式執行時遞增。 - SEC 巨集:定義了名為 “xdp” 的段落,這表明這是一個 XDP (eXpress Data Path) 型別的 eBPF 程式。XDP 允許在網路封包剛抵達網路介面時就進行處理。
- 程式主體:
hello
函式是實際的 eBPF 程式,它接收一個ctx
引數(包含封包相關資訊),使用bpf_printk
輔助函式輸出訊息,遞增計數器,並回傳XDP_PASS
表示核心應該正常處理這個封包。 - 授權宣告:eBPF 程式必須宣告授權方式,尤其是當使用某些標記為 “GPL only” 的核心輔助函式時。
值得注意的是,bpf_printk
是 libbpf 函式庫提供的包裝函式,底層實際上呼叫的是核心的 bpf_trace_printk
函式。這個函式允許 eBPF 程式向追蹤管道寫入訊息,是除錯的好幫手。
編譯 eBPF 物件檔案
將 eBPF 原始碼編譯成核心能理解的物件檔案需要使用 LLVM 專案的 Clang 編譯器,並指定 -target bpf
引數。以下是一個 Makefile 中的編譯指令片段:
hello.bpf.o: %.o: %.c
clang \
-target bpf \
-I/usr/include/$(shell uname -m)-linux-gnu \
-g \
-O2 -c $< -o $@
這條指令會將 hello.bpf.c
編譯成 hello.bpf.o
物件檔案。-g
引數會生成除錯資訊,這樣在檢視物件檔案時就能同時看到原始碼和位元組碼。
檢視 eBPF 物件檔案
編譯完成後,我們可以使用一些工具來檢視生成的物件檔案。首先,使用 file
命令檢視檔案型別:
$ file hello.bpf.o
hello.bpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), with debug_info, not stripped
這顯示它是一個包含 eBPF 程式碼的 ELF (Executable and Linkable Format) 檔案,適用於 64 位元 LSB (Least Significant Bit) 架構,包含除錯資訊。
接著,我們可以使用 llvm-objdump
工具檢視 eBPF 指令:
$ llvm-objdump -S hello.bpf.o
輸出結果如下:
hello.bpf.o: file format elf64-bpf
Disassembly of section xdp:
0000000000000000 <hello>:
; bpf_printk("Hello World %d", counter");
0: 18 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r6 = 0 ll
2: 61 63 00 00 00 00 00 00 r3 = *(u32 *)(r6 + 0)
3: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
5: b7 02 00 00 0f 00 00 00 r2 = 15
6: 85 00 00 00 06 00 00 00 call 6
; counter++;
7: 61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
8: 07 01 00 00 01 00 00 00 r1 += 1
9: 63 16 00 00 00 00 00 00 *(u32 *)(r6 + 0) = r1
; return XDP_PASS;
10: b7 00 00 00 02 00 00 00 r0 = 2
11: 95 00 00 00 00 00 00 00 exit
eBPF 指令集解析
從上面的反組譯結果中,我們可以看到 eBPF 位元組碼的實際樣貌。讓我們來解析一下這些指令:
程式結構分析
反組譯輸出首先確認了這是一個 64 位元 ELF 檔案,包含 eBPF 程式碼。接著顯示了名為 xdp
的段落(對應我們在 C 程式碼中使用的 SEC("xdp")
巨集)中的 hello
函式。
整個程式被分解為三個主要部分,與原始 C 程式碼的三行陳述式對應:
- 呼叫
bpf_printk("Hello World %d", counter")
的指令(5 行位元組碼) - 遞增
counter
變數的指令(3 行位元組碼) - 回傳
XDP_PASS
的指令(2 行位元組碼)
指令詳解
讓我分析一些關鍵指令:
暫存器初始化:
r6 = 0 ll
:將暫存器 r6 設為 0,這是一個 16 位元組的「寬」指令r1 = 0 ll
:同樣將暫存器 r1 設為 0
記憶體操作:
r3 = *(u32 *)(r6 + 0)
:從 r6+0 位址讀取一個 32 位元整數值到 r3,這是在讀取 counter 變數*(u32 *)(r6 + 0) = r1
:將 r1 的值寫入 r6+0 位址,這是在更新 counter 變數
算術操作:
r1 += 1
:將暫存器 r1 的值加 1,這是 counter++ 操作的一部分r2 = 15
:將暫存器 r2 設為 15,這可能是bpf_printk
函式的格式字串長度
函式呼叫與回傳:
call 6
:呼叫輔助函式 6,這對應於bpf_printk
r0 = 2
:將暫存器 r0 設為 2,這是 XDP_PASS 的值exit
:結束程式並回傳 r0 中的值
eBPF 指令的記憶體佈局
觀察輸出中的偏移量,我們可以瞭解 eBPF 指令在記憶體中的佈局。大多數 eBPF 指令是 8 位元組長,但某些「寬」指令(如設定 64 位元立即數的指令)需要 16 位元組。這解釋了為什麼某些指令的偏移量增加了 2 而不是 1。
例如,第一條指令在偏移量 0,是一個 16 位元組的寬指令,所以下一條指令的偏移量是 2 而不是 1。同樣,第三條指令也是一個寬指令,使得後續指令的偏移量從 3 跳到 5。