在開發 eBPF 程式時,瞭解常見的驗證失敗原因可以幫助我們更快地解決問題。以下是一些典型的失敗情況:

1. 未初始化的暫存器

如果嘗試使用未初始化的暫存器,驗證器會拒絕程式。例如,如果你嘗試讀取一個尚未指定的暫存器,驗證將失敗,日誌會顯示類別似 “R2 !read_ok” 的錯誤。

2. 無效的記憶體存取

eBPF 程式只能存取特定範圍內的記憶體。如果嘗試存取這些範圍外的記憶體,驗證將失敗。例如,嘗試存取超出對映值大小的偏移量,或者使用未經檢查的指標。

3. 無效的輔助函式使用

不同型別的 eBPF 程式可以使用不同的輔助函式。如果程式嘗試使用對該程式型別不可用的輔助函式,驗證將失敗。例如,處理網路封包的程式不能使用 bpf_get_current_pid_tgid() 輔助函式。

4. 程式過於複雜

如果程式的複雜性超過驗證器的處理能力(例如,太多的分支或太深的迴圈),驗證也會失敗。這是因為驗證器需要分析所有可能的執行路徑,如果路徑太多或太複雜,可能會超出其限制。

CO-RE(編譯一次,到處執行)與 BTF 的關係

在談到 eBPF 程式的可移植性時,不能不提 CO-RE(Compile Once, Run Everywhere)技術。CO-RE 允許 eBPF 程式在不同的核心版本上執行,而無需重新編譯。這一技術的關鍵是 BTF(BPF Type Format)資料。

BTF 是一種緊湊的格式,用於描述核心中的資料結構。它允許 eBPF 程式在載入時根據目標核心的實際結構進行調整。這解決了一個長期存在的問題:核心資料結構在不同版本間可能變化,這使得 eBPF 程式難以相容多種核心版本。

要使用 CO-RE,目標核心需要啟用 CONFIG_DEBUG_INFO_BTF 選項。這使得核心能夠生成 BTF 資料,eBPF 程式可以利用這些資料進行自適應。

實用建議:處理驗證錯誤

當你遇到驗證錯誤時,這裡有一些實用的建議:

  1. 仔細閱讀驗證日誌:驗證日誌會指出問題所在,包括失敗的指令和相關的暫存器狀態。

  2. 從簡單開始:如果你的程式很複雜,嘗試從一個簡單的版本開始,然後逐步增加功能,這樣可以更容易定位問題。

  3. 使用 bpftoolbpftool 提供了許多有用的命令來檢查和分析 eBPF 程式,包括檢視驗證日誌。

  4. 利用 libbpf 的錯誤處理:如果你使用 libbpf 開發,設定適當的錯誤處理回呼函式,以捕捉和處理驗證錯誤。

  5. 注意核心版本差異:不同的核心版本可能有不同的驗證規則和限制,所以要注意你的目標核心版本。

  6. 使用 CO-RE 技術:如果可能,使用 CO-RE 技術來提高程式的可移植性,減少因核心版本差異導致的驗證問題。

驗證器的進化與未來發展

eBPF 驗證器不斷演進,隨著 Linux 核心的每個新版本,它都在變得更加強大和高效。最近的改進包括:

  1. 狀態剪枝最佳化:如前所述,驗證器現在使用更高效的狀態剪枝策略,每10條指令儲存一次剪枝狀態,而不是在每個跳轉指令前後。

  2. 增強的型別檢查:驗證器現在能夠進行更複雜的型別檢查,減少誤報並提高安全性。

  3. 支援更多程式型別:隨著新程式型別的引入,驗證器也在擴充套件以支援這些新型別的安全檢查。

  4. 改進的錯誤報告:驗證器現在提供更詳細、更有用的錯誤訊息,幫助開發者更快地解決問題。

未來,我們可以期待驗證器在以下方面繼續改進:

  1. 更高的效率:處理更複雜的程式,同時保持較低的驗證開銷。

  2. 更好的開發者體驗:提供更清晰的錯誤訊息和更多的除錯工具。

  3. 更廣泛的程式支援:支援更多型別的 eBPF 程式和更複雜的程式結構。

eBPF 驗證器是確保 eBPF 程式安全執行的關鍵元件。它透過分析所有可能的執行路徑,確保程式不會執行危險操作。瞭解驗證器的工作原理和常見的驗證失敗原因,可以幫助我們更有效地開發和除錯 eBPF 程式。

當我們編寫 eBPF 程式時,不僅要考慮功能實作,還要考慮程式是否能透過驗證器的檢查。這需要我們瞭解驗證器的限制和要求,以及如何編寫符合這些要求的程式。

隨著 eBPF 技術的不斷發展和應用場景的擴大,驗證器也在不斷演進,以支援更複雜、更強大的 eBPF 程式。掌握驗證器的工作原理,將使我們能夠更好地利用 eBPF 技術的潛力,開發出更安全、更高效的系統工具和應用。

在實際開發中,當遇到驗證問題時,不要氣餒。仔細閱讀驗證日誌,理解錯誤原因,然後有針對性地修改程式。隨著經驗的積累,你會越來越熟悉驗證器的工作方式,能夠更快地解決問題,甚至預防問題的發生。

eBPF 技術正在改變我們觀察和互動 Linux 系統的方式,而驗證器則是確保這一切安全進行的守門員。透過深入瞭解驗證器,我們能夠更好地掌握 eBPF 技術,發揮其最大潛力。

eBPF 驗證器的核心機制與安全保障

在開發 eBPF 程式時,核心驗證器扮演著至關重要的角色,它確保了我們的程式不會對系統造成危害。透過多年開發 eBPF 應用的經驗,我發現理解驗證器的運作原理不僅能幫助我們寫出更高效的程式,還能避免許多令人困惑的錯誤訊息。

暫存器狀態追蹤:驗證器的根本

驗證器會密切追蹤程式執行過程中每個暫存器的狀態。在日誌輸出中,我們可以看到類別似以下的資訊:

R1_w=map_value
R6_w=ctx(id=0,off=0,imm=0)
R10_w=fp

這段日誌顯示了暫存器的當前狀態:暫存器 R1 包含一個對映值,R6 儲存了上下文(context),而 R10 是框架指標(frame pointer),用於存取區域變數。這些資訊對於理解程式執行流程和偵錯非常重要。

另一個暫存器狀態範例可能會顯示更多細節:

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))

暫存器環境與功能解析

讓我們探討暫存器的用途。當 eBPF 程式被呼叫時,R1 總是包含傳遞給程式的上下文引數。為什麼需要將它複製到 R6 呢?

這是因為當呼叫 BPF 輔助函式時,引數會透過 R1 到 R5 傳遞。輔助函式不會修改 R6 到 R9 的內容,所以將上下文儲存到 R6 意味著程式可以呼叫輔助函式而不會失去對上下文的存取。

暫存器 R0 用於儲存輔助函式的回傳值,也用於 eBPF 程式本身的回傳值。R10 永遠指向 eBPF 堆積積疊框架(與 eBPF 程式不能修改它)。

數值範圍分析與路徑最佳化

驗證器不僅追蹤暫存器的型別,還會追蹤可能的值範圍。例如,在指令 6 之後:

R2_w=inv(id=1,umax_value=4294967295,var_off=(0x0; 0xffffffff))

這表示 R2 可能包含從 0 到 0xFFFFFFFF 的任何值。

而對於 R3:

R3_w=inv(id=0,umin_value=1,umax_value=4294967296,var_off=(0x0; 0x1ffffffff))

這顯示 R3 的最小值為 1,這是因為在指令 4 中,R2 的內容被複製到 R3,然後指令 5 將該值加 1。

驗證器使用這些資訊來確定程式的所有可能路徑,並進行狀態剪枝:如果驗證器已經評估過相同程式位置、相同型別和可能值範圍的暫存器狀態,就不需要再次評估該路徑。

視覺化控制流程:理解程式執行路徑

當我們嘗試除錯時,能夠視覺化程式的所有可能路徑會很有幫助。bpftool 工具可以產生程式的控制流程圖,格式為 DOT,然後可以轉換為影像格式:

$ bpftool prog dump xlated name kprobe_exec visual > out.dot
$ dot -Tpng out.dot > out.png

這會產生類別似圖 6-1 所示的控制流程視覺化表示。透過這種視覺化,我們可以更容易地理解程式的執行路徑,特別是在處理複雜的條件分支時。

輔助函式驗證:確保正確使用核心功能

eBPF 程式不能直接呼叫任何核心函式(除非它已註冊為 kfunc,我們將在下一章中介紹),但 eBPF 提供了許多輔助函式,使程式能夠存取核心中的資訊。

不同的輔助函式適用於不同的 BPF 程式型別。例如,輔助函式 bpf_get_current_pid_tgid() 可以取得當前使用者空間的行程 ID 和執行緒 ID,但在 XDP 程式中呼叫它是沒有意義的,因為 XDP 程式是由網路介面接收到封包時觸發的,與使用者空間行程無關。

將 hello eBPF 程式的 SEC() 定義從 kprobe 改為 xdp 會導致以下錯誤:

16: (85) call bpf_get_current_pid_tgid#14
unknown func bpf_get_current_pid_tgid#14

這裡的 “unknown func” 並不意味著該函式完全未知,只是表示它對於此 BPF 程式型別是未知的。

輔助函式引數:型別安全的保障

每個輔助函式都有一個 bpf_func_proto 結構,定義了引數和回傳值的約束條件。以 bpf_map_lookup_elem() 為例:

const struct bpf_func_proto bpf_map_lookup_elem_proto = {
    .func = bpf_map_lookup_elem,
    .gpl_only = false,
    .pkt_access = true,
    .ret_type = RET_PTR_TO_MAP_VALUE_OR_NULL,
    .arg1_type = ARG_CONST_MAP_PTR,
    .arg2_type = ARG_PTR_TO_MAP_KEY,
};

驗證器會追蹤每個暫存器中儲存的值的型別,所以如果嘗試向輔助函式傳遞錯誤型別的引數,它會發現這個問題。例如,如果將 bpf_map_lookup_elem() 的引數從 &my_config(指向對映的指標)改為 &data(指向區域變數結構的指標),會得到以下錯誤:

27: (85) call bpf_map_lookup_elem#1
R1 type=fp expected=map_ptr

這表示 R1 包含指向框架指標(fp)區域的指標,但函式期望的是指向對映的指標。

授權檢查與記憶體存取安全

授權相容性檢查

驗證器還會檢查,如果使用了在 GPL 下授權的 BPF 輔助函式,程式本身也必須具有與 GPL 相容的授權。在範例程式碼中,最後一行定義了一個包含字串 “Dual BSD/GPL” 的 “license” 區段。如果移除此行,驗證器會報錯:

37: (85) call bpf_probe_read_kernel#113
cannot call GPL-restricted function from non-GPL compatible program

這是因為 bpf_probe_read_kernel() 輔助函式的 gpl_only 欄位設為 true。程式中較早呼叫的其他輔助函式不受 GPL 授權,因此驗證器不反對使用它們。

記憶體存取檢查:安全的邊界

驗證器執行一系列檢查,確保 BPF 程式只存取它應該有許可權存取的記憶體位置。

例如,在處理網路封包時,XDP 程式只允許存取構成該網路封包的記憶體位置。大多數 XDP 程式開始時都類別似於以下程式碼:

SEC("xdp")
int xdp_load_balancer(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    ...

傳遞給程式的 xdp_md 結構描述了接收到的網路封包。ctx->data 欄位是封包在記憶體中的起始位置,而 ctx->data_end 是封包的最後位置。驗證器會確保程式只存取這個範圍內的記憶體,防止越界存取可能導致的安全問題。

驗證器的實際應用與最佳實踐

在開發 eBPF 程式時,我發現以下做法能夠有效避開驗證器的常見陷阱:

  1. 明確定義記憶體邊界 - 特別是處理網路封包或自訂結構時,總是明確檢查邊界條件。

  2. 保持暫存器型別一致 - 避免在暫存器中混合不同型別的值,這可能導致驗證器拒絕程式。

  3. 注意輔助函式的程式型別相容性 - 在使用輔助函式前,確認它與當前的 BPF 程式型別相容。

  4. 利用驗證器日誌進行除錯 - 當程式被拒絕時,詳細的驗證器日誌能提供寶貴的線索。

  5. 視覺化控制流程 - 對於複雜程式,使用 bpftool 產生控制流程圖有助於理解程式結構和潛在問題。

驗證器是 eBPF 安全模型的關鍵組成部分,透過靜態分析確保程式不會危害系統穩定性。雖然有時可能會感到受限,但這些限制實際上保護了系統免受潛在的不安全程式碼的影響。

隨著對驗證器行為的深入理解,我們能夠編寫更高效、更可靠的 eBPF 程式,充分利用這一強大技術的潛力,同時確保系統的安全和穩定性。

eBPF 驗證器是一個精心設計的安全機制,它透過暫存器狀態追蹤、路徑分析、輔助函式驗證和記憶體存取檢查,確保 eBPF 程式能夠安全地在核心中執行。理解這些機制不僅能幫助我們寫出更好的程式,還能更有效地診斷和解決問題。

eBPF驗證器的記憶體安全保障機制

在開發eBPF程式時,驗證器(verifier)扮演著至關重要的角色,它確保我們的程式不會造成系統不穩定或安全漏洞。經過多年研究與實作eBPF程式,玄貓發現記憶體安全是驗證器最關注的核心問題之一。

資料邊界檢查的重要性

當處理網路封包時,eBPF驗證器會特別關注datadata_end指標的使用。以下是一個合法的XDP程式範例:

SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    bpf_printk("%x", data_end);
    return XDP_PASS;
}

這段程式碼定義了一個XDP(eXpress Data Path)型別的eBPF程式,用於高效率網路封包處理。程式從上下文中取出封包的起始位置(data)和結束位置(data_end),並將結束位置印出來,最後回傳XDP_PASS讓封包繼續正常處理流程。這裡的關鍵是eBPF驗證器能夠識別data_end代表封包邊界,任何超出此邊界的存取都會被阻止。

驗證器的智慧邊界保護

驗證器對於封包邊界的保護非常嚴格,甚至不允許修改data_end的值。如果我們嘗試在上面的程式中加入以下程式碼:

data_end++;

驗證器會立即拒絕程式載入,並顯示錯誤:

; data_end++;
1: (07) r3 += 1
R3 pointer arithmetic on pkt_end prohibited

這個錯誤訊息非常明確 - 驗證器禁止對代表封包結束位置的指標進行算術運算。這是因為修改data_end可能會導致程式存取到封包以外的記憶體區域,造成安全風險。我在實作高效能封包處理時,曾嘗試各種方式最佳化邊界檢查,但最終發現遵循驗證器的規則才是最佳方案,因為這些限制正是保障系統安全的根本。

陣列邊界檢查與常見錯誤

陣列存取是另一個需要特別注意的安全領域。eBPF驗證器會嚴格檢查是否有可能存取超出陣列範圍的索引。

邊界檢查正確實作

以下是一個正確檢查陣列邊界的範例:

if (c < sizeof(message)) {
    char a = message[c];
    bpf_printk("%c", a);
}

這段程式碼在存取message陣列前,先確認索引c小於陣列的大小,這是標準與安全的做法。條件c < sizeof(message)確保c最大隻能是sizeof(message)-1,恰好是陣列的最後一個有效索引。

常見的「差一錯誤」(Off-by-one error)

然而,只要稍微調整條件判斷,就會導致驗證器拒絕程式:

if (c <= sizeof(message)) {
    char a = message[c];
    bpf_printk("%c", a);
}

驗證器會給出錯誤:

invalid access to map value, value_size=16 off=16 size=1
R2 max value is outside of the allowed memory range

這個錯誤源於經典的「差一錯誤」。使用<=而非<意味著c可能等於sizeof(message),這將超出陣列的有效索引範圍(從0開始,到sizeof(message)-1結束)。驗證器檢測到最壞情況下,程式可能會嘗試存取超出陣列範圍的記憶體,因此拒絕載入。

解讀驗證器錯誤日誌

當遇到這類別錯誤時,驗證器日誌是寶貴的除錯資源。以下是上述錯誤的部分日誌分析:

; if (c <= sizeof(message)) {
30: (25) if r1 > 0xc goto pc+10
...
; char a = message[c];
31: (18) r2 = 0xffff800008e00004
33: (0f) r2 += r1
...
34: (71) r3 = *(u8 *)(r2 +0)

從日誌中可以看出,驗證器追蹤了暫存器的狀態變化:

  1. 在指令30,條件判斷檢查r1(存放c的值)是否大於0xc(十進位制的12)
  2. 在指令31-33,r2被設定為message陣列的基址,然後加上r1的值
  3. 在指令34,程式嘗試從計算出的地址讀取一個位元組

問題在於message是一個12位元組的陣列,有效索引範圍是0-11。但條件檢查允許c等於12,這將導致存取超出陣列範圍的記憶體。

這種分析方法在最佳化eBPF程式時非常有用。玄貓在處理大規模網路監控系統時,經常需要仔細分析驗證器日誌來找出效能瓶頸和安全問題。

指標檢查與空指標處理

指標解參照前的檢查

C語言中最常見的錯誤之一是解參照空指標(null pointer)。eBPF驗證器要求所有指標在解參照前必須經過檢查,以避免這類別錯誤。

以下是一個從雜湊表中查詢資料的範例:

p = bpf_map_lookup_elem(&my_config, &uid);

如果雜湊表中沒有對應的專案,p將被設為0(null)。若我們直接嘗試解參照:

char a = p->message[0];
bpf_printk("%c", a);

驗證器會拒絕程式並顯示:

; p = bpf_map_lookup_elem(&my_config, &uid);
25: (18) r1 = 0xffff263ec2fe5000
27: (85) call bpf_map_lookup_elem#1
28: (bf) r7 = r0
; char a = p->message[0];
29: (71) r3 = *(u8 *)(r7 +0)
R7 invalid mem access 'map_value_or_null'

驗證器在這裡檢測到潛在的空指標解參照問題。從日誌可以看出:

  1. 指令27呼叫bpf_map_lookup_elem函式
  2. 指令28將回傳值(存在r0)移到r7
  3. 指令29嘗試解參照r7,但驗證器知道此時r7可能是null(‘map_value_or_null’狀態)

正確的做法是在解參照前檢查指標是否為null:

if (p != 0) {
    char a = p->message[0];
    bpf_printk("%c", a);
}

安全的輔助函式設計

某些eBPF輔助函式已經內建了指標檢查機制。例如bpf_probe_read_kernel()

long bpf_probe_read_kernel(void *dst, u32 size, const void *unsafe_ptr)

函式簽名中的unsafe_ptr引數表明它可以安全地接受可能為null的指標,因為函式內部會進行檢查。這類別設計大簡化了eBPF程式的編寫。

上下文存取限制

每個eBPF程式都會收到一些上下文資訊作為引數,但根據程式型別和掛載點的不同,可能只允許存取部分上下文資訊。

例如,tracepoint程式接收一個指向tracepoint資料的指標。這些資料的格式取決於特定的tracepoint,但它們都以一些共同欄位開始 - 然而,eBPF程式不能存取這些共同欄位,只能存取後面的tracepoint特定欄位。

嘗試讀取或寫入錯誤的欄位會導致invalid bpf_context access錯誤。

迴圈限制與程式完成保證

eBPF驗證器確保程式能夠執行完成,避免無限消耗系統資源的風險。它透過限制處理的指令總數來實作這一點,目前限制設定為100萬條指令。

迴圈處理的演進

在Linux核心5.3版之前,eBPF對迴圈有嚴格限制。迴圈需要向後跳轉到較早的指令,而驗證器過去不允許這樣做。

開發者通常使用#pragma unroll編譯器指令來解決這個問題,告訴編譯器為每次迴圈展開相同(或非常相似)的位元組碼指令。這節省了開發者重複輸入相同程式碼的工作,但實際上會產生多組類別似的指令。

#pragma unroll
for (int i = 0; i < 5; i++) {
    // 迴圈體
}

這段程式碼使用#pragma unroll指示編譯器展開迴圈。編譯後,它不會生成真正的迴圈結構,而是將迴圈體重複5次,相當於:

// i = 0
// 迴圈體
// i = 1
// 迴圈體
// ...以此類別推

這種方法的缺點是迴圈次數必須在編譯時確定,無法根據執行時情況動態調整。

現代eBPF的迴圈支援

從Linux 5.3開始,eBPF支援有界迴圈(bounded loops)。驗證器現在能夠證明迴圈會在有限次數內結束,從而允許真正的迴圈結構。

這使得eBPF程式設計更加靈活,特別是在處理可變長度資料(如封包負載、字串等)時。不過,迴圈仍然必須有明確的上限,以確保程式最終會結束。

實用的eBPF驗證器最佳化策略

在多年的eBPF開發經驗中,玄貓總結了幾個實用的最佳化策略:

  1. 預先檢查邊界條件 - 在程式開始時進行全面的邊界檢查,可以簡化後續程式碼並提高可讀性

  2. 利用驗證器狀態追蹤 - 理解驗證器如何追蹤暫存器狀態,設計程式碼使驗證器能夠推斷出更多安全保證

  3. 合理使用輔助函式 - 許多eBPF輔助函式已經內建安全檢查,合理使用可以簡化程式碼

  4. 小心處理迴圈 - 即使在支援迴圈的新版核心中,也要確保迴圈有明確的上限和結束條件

  5. 詳細分析驗證器日誌 - 當程式被拒絕時,驗證器日誌包含豐富的除錯資訊,仔細分析可以快速找出問題

這些策略不僅有助於透過驗證器檢查,還能幫助開發者編寫更高效、更安全的eBPF程式。

eBPF驗證器的未來發展

eBPF驗證器在每個新版本的Linux核心中都在不斷改進。從最初的簡單檢查到現在能夠處理複雜程式流程和有界迴圈,驗證器的能力已經大幅提升。

未來,我們可能會看到更人工智慧的靜態分析技術應用於eBPF驗證器,使其能夠證明更複雜的程式屬性,同時減少誤報。這將進一步擴充套件eBPF的應用範圍,特別是在安全監控、網路加速和系統可觀測性等領域。

eBPF驗證器的嚴格限制雖然有時讓開發者感到挫折,但這些限制正是eBPF能夠安全地在核心空間執行的關鍵保障。隨著對這些限制和最佳化策略的深入理解,開發者可以充分發揮eBPF的強大功能,同時確保系統的安全和穩定。

在eBPF程式設計中,與驗證器和平相處是成功的關鍵。理解它的工作原理和限制,不僅能幫助我們編寫更好的程式,也能讓我們更深入地理解Linux核心的安全機制。

eBPF 驗證器的進階運作機制

eBPF 驗證器是確保 eBPF 程式安全執行的關鍵守門員。隨著核心版本的推進,驗證器的能力也在不斷增強。從 Linux 5.3 版本開始,驗證器不僅向前追蹤執行路徑,還會向後分析,這大幅提升了它處理迴圈的能力。

迴圈處理的演進

在早期版本的 eBPF 中,迴圈幾乎是不可能透過驗證的,因為驗證器無法確定迴圈會在有限步驟內結束。不過,現在的驗證器可以接受某些有明確邊界的迴圈,只要執行路徑不超過一百萬條指令的複雜度限制。

以下是一個能夠透過驗證的簡單迴圈範例:

for (int i=0; i < 10; i++) {
    bpf_printk("Looping %d", i);
}

這段程式碼展示了一個簡單的固定次數迴圈。驗證器會展開這個迴圈並追蹤所有 10 次迭代的執行路徑。由於迭代次數有明確的上限,驗證器可以確定這個程式不會無限執行,因此能夠透過驗證。在驗證日誌中,你會看到驗證器實際上追蹤了這個迴圈 10 次完整的執行過程。

現代迴圈處理機制

Linux 5.17 版本引入了 bpf_loop() 輔助函式,這是處理迴圈的一大突破。這個函式接收最大迭代次數作為第一個引數,以及一個在每次迭代時呼叫的函式。關鍵優勢在於驗證器只需要驗證該函式一次,無論它被呼叫多少次。

// bpf_loop() 使用範例
bpf_loop(10, callback_function, &context, 0);

類別似地,還有一個 bpf_for_each_map_elem() 輔助函式,它會為對映中的每個元素呼叫一個回呼函式。這些新的輔助函式大簡化了迴圈處理,同時保持了程式的安全性。

常見驗證錯誤與解決方案

在開發 eBPF 程式時,與驗證器打交道是不可避免的。以下是一些常見的驗證錯誤及其解決方案。

回傳碼檢查

eBPF 程式的回傳碼儲存在 Register 0 (R0) 中。如果程式沒有初始化 R0,驗證器會失敗並顯示:

R0 !read_ok

例如,如果你的 XDP 程式像這樣:

SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    // 沒有回傳值
}

這會導致驗證失敗。有趣的是,如果你在函式中呼叫了任何輔助函式,如 bpf_printk(),驗證器就不會抱怨,因為輔助函式的回傳值會儲存在 R0 中,使其不再未初始化。

無效指令

驗證器會檢查程式中的指令是否為有效的 eBPF 位元組碼指令。一般來說,如果使用現代編譯器,這類別錯誤不太常見。不過,如果你使用了較新的指令(如原子操作)但在較舊的核心上執行,這些指令可能會被視為無效。

無法到達的指令

驗證器會拒絕包含無法到達指令的程式。通常,這些指令會被編譯器最佳化掉,但有時可能會出現在手寫的 eBPF 程式中。

記憶體存取錯誤

記憶體存取是 eBPF 驗證中的重要部分。驗證器會嚴格檢查所有記憶體存取是否在安全範圍內。例如,以下程式碼可能會導致「invalid variable-offset read from stack R2」錯誤:

if (c <= sizeof(data.message)) {  // 注意這裡使用了 <= 而非 <
    char a = data.message[c];
    bpf_printk("%c", a);
}

這段程式碼有一個常見的「差一錯誤」(off-by-one error)。當 c 等於 sizeof(data.message) 時,我們嘗試存取陣列之外的記憶體,這會被驗證器捕捉並拒絕。正確的寫法應該是使用 < 而非 <=。驗證器在這方面非常嚴格,這有助於防止潛在的記憶體安全問題。

迴圈驗證例項分析

讓我們深入分析一些迴圈驗證的例項,看驗證器如何處理不同型別的迴圈。

成功的固定迴圈

前面提到的固定次數迴圈能夠透過驗證:

for (int i=0; i < 10; i++) {
    bpf_printk("Looping %d", i);
}

在驗證日誌中,你會看到驗證器追蹤了整個執行路徑,包括 10 次迴圈迭代。

失敗的不確定迴圈

相比之下,以下迴圈會失敗,因為迭代次數取決於一個未知變數:

for (int i=0; i < c; i++) {  // c 是一個全域變數,其值在執行時不確定
    bpf_printk("Looping %d", i);
}

這個迴圈的問題在於變數 c 的值在編譯時無法確定。驗證器會嘗試展開這個迴圈,但由於無法確定迴圈的邊界,它會在達到指令複雜度限制(一百萬條指令)前無法完成驗證。這類別迴圈通常需要使用 bpf_loop() 輔助函式重構,或者確保迴圈變數有明確的上限。

上下文存取限制

eBPF 程式接收的上下文(context)引數因程式型別而異。例如,連線到 tracepoint 的程式會接收特定的上下文結構。如果你嘗試存取未定義的上下文欄位,驗證器會失敗並顯示「invalid bpf_context access」錯誤。

例如,tracepoint 上下文結構的開頭通常包含這些欄位:

unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;

如果你在程式中定義自己的結構並嘗試存取這些欄位,但結構定義不比對,驗證器會拒絕你的程式。

eBPF 程式型別與附加點

隨著 eBPF 的發展,支援的程式型別和附加點不斷增加。目前在 Linux 核心中大約有 30 種程式型別和 40 多種附加型別。

程式型別定義了 eBPF 程式的基本行為和功能,而附加型別則更具體地指定程式在核心中的連線點。在許多情況下,附加型別可以從程式型別推斷出來,但有些程式型別可以附加到核心中的多個不同點,因此需要明確指定附加型別。

常見的程式型別

以下是一些常見的 eBPF 程式型別:

  1. XDP (eXpress Data Path) - 用於高效率的網路封包處理
  2. Socket Filter - 用於過濾網路通訊端上的流量
  3. Kprobe/Kretprobe - 用於動態追蹤核心函式
  4. Tracepoint - 用於連線到核心中的靜態追蹤點
  5. Perf Event - 用於效能監控
  6. Cgroup - 用於控制群組操作
  7. LSM (Linux Security Module) - 用於安全策略執行

每種程式型別都有其特定的用途和限制,瞭解這些差異對於選擇正確的程式型別至關重要。

實際應用與除錯技巧

當你開始編寫自己的 eBPF 程式時,可能會遇到各種驗證器錯誤。以下是一些實用技巧:

  1. 仔細閱讀驗證器日誌 - 驗證器日誌包含了有關錯誤的寶貴訊息,包括哪個暫存器出了問題,以及出錯的指令位置。

  2. 理解 eBPF 虛擬機器 - 瞭解 eBPF 虛擬機器如何使用暫存器暫存值,以及它如何一步執行程式,這有助於理解驗證器的錯誤訊息。

  3. 從簡單開始 - 從簡單的程式開始,然後逐步增加複雜性,這樣更容易定位問題。

  4. 使用社群資源 - eBPF 社群 Slack 頻道和 StackOverflow 是尋求幫助的好地方。

  5. 利用現代工具 - 使用 BCC、libbpf 或 bpftrace 等工具可以簡化 eBPF 程式的開發和除錯過程。

#與展望

eBPF 驗證器雖然嚴格,但它的嚴格性確保了 eBPF 程式的安全執行。隨著核心版本的更新,驗證器不斷改進,能夠處理更複雜的程式,同時提供更有用的錯誤訊息。

當初接觸 eBPF 時,透過驗證器似乎是一門深奧的藝術,看似有效的程式碼可能會被拒絕,丟擲看似任意的錯誤。但隨著時間的推移,驗證器得到了許多改進,本文中展示的例子說明瞭驗證器日誌如何提供線索,幫助你找出問題所在。

這些提示在你瞭解 eBPF 虛擬機器如何工作時更有幫助,它使用一組暫存器進行臨時值儲存,同時逐步執行 eBPF 程式。驗證器追蹤每個暫存器的型別和可能值範圍,以確保 eBPF 程式可以安全執行。

隨著對 eBPF 的深入瞭解和驗證器的不斷改進,開發 eBPF 程式將變得越來越直觀和高效,為系統觀察、網路處理和安全增強提供強大的工具。無論是初學者還是有經驗的開發者,掌握 eBPF 驗證器的工作原理都是成功開發 eBPF 應用的關鍵。

eBPF程式型別的核心概念與設計原理

在深入研究eBPF技術時,我發現程式型別是整個eBPF架構的核心設計元素之一。eBPF程式型別決定了程式可以附加到哪些事件、可以使用哪些輔助函式,以及程式回傳值的含義。這種設計不僅確保了安全性,還提供了足夠的彈性來應對各種使用場景。

程式上下文引數的設計邏輯

所有eBPF程式在執行時都會接收一個上下文引數(context argument),這個引數是一個指標,但它指向的結構取決於觸發該程式的事件型別。這種設計有其深刻的邏輯:

// XDP程式的上下文引數
SEC("xdp")
int xdp_example(struct xdp_md *ctx) {
    // ctx指向網路封包相關資訊
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    // ...
}

// 追蹤點程式的上下文引數
SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint_example(struct trace_event_raw_sys_enter *ctx) {
    // ctx包含系統呼叫相關資訊
    const char *pathname = (const char *)ctx->args[0];
    // ...
}

上面的程式碼展示了不同eBPF程式型別接收不同上下文引數的情況。XDP程式接收的是包含網路封包資訊的xdp_md結構,而追蹤點程式則接收包含系統呼叫資訊的trace_event_raw_sys_enter結構。這種設計確保了eBPF程式能夠取得與其附加事件相關的正確資訊,同時也讓驗證器能夠確保程式正確處理上下文資料。

當我設計eBPF程式時,必須確保程式接受適當型別的上下文引數。如果我嘗試在一個網路相關的程式中像處理追蹤點事件那樣處理上下文,這顯然是不合理的。不同的程式型別定義使驗證器能夠確保上下文資訊被適當處理,並強制執行關於哪些輔助函式可用的規則。

輔助函式與回傳碼的關聯性

eBPF驗證器會檢查程式使用的所有輔助函式是否與其程式型別相容。這種檢查機制非常合理,因為不同的程式型別執行在不同的上下文中,可用的資源和能力也不同。

以XDP程式為例,嘗試呼叫bpf_get_current_pid_tgid()輔助函式是不允許的。這是因為在接收封包並觸發XDP程式的時刻,並沒有相關的使用者空間行程或執行緒參與,所以呼叫這個函式來取得當前行程和執行緒ID在這個上下文中沒有意義。

程式型別同樣決定了程式回傳碼的含義:

// XDP程式的回傳碼決定封包的處理方式
SEC("xdp")
int xdp_drop_all(struct xdp_md *ctx) {
    return XDP_DROP; // 丟棄封包
}

// 追蹤點程式的回傳碼通常沒有特殊含義
SEC("tracepoint/syscalls/sys_enter_open")
int trace_open(struct trace_event_raw_sys_enter *ctx) {
    // 處理邏輯...
    return 0; // 在追蹤程式中,回傳值通常不影響系統行為
}

這兩個程式碼片段展示了不同程式型別中回傳碼的不同含義。在XDP程式中,回傳XDP_DROP告訴核心丟棄該封包,這是一個有實際影響的決定。而在追蹤點程式中,回傳值通常沒有特殊含義,不會影響系統的行為。這種差異反映了不同程式型別的設計目的:XDP用於網路封包處理,而追蹤點程式主要用於監控和資料收集。

輔助函式與核心函式的差異與應用

在eBPF程式設計中,輔助函式(Helper Functions)和核心函式(Kfuncs)是兩種不同的機制,用於擴充套件eBPF程式的能力。這兩種機制有著根本的差異,對於eBPF開發者來說,理解這些差異對於選擇正確的函式至關重要。

輔助函式的穩定性保證

輔助函式被視為Linux核心的外部穩定介面(UAPI)的一部分。這意味著一旦一個輔助函式在核心中被定義,它在未來的版本中不應該改變,即使核心的內部函式和資料結構可能會變化。這種穩定性保證使得eBPF程式能夠在不同版本的核心上保持相容性。

要檢視你的核心版本中每種程式型別可用的輔助函式,可以使用bpftool feature命令:

$ bpftool feature
...
eBPF program types:
- socket_filter (id 1)
  max program size 1048576 bytes, max instruction count 4096
  supported helper functions:
    bpf_map_lookup_elem (id 1)
    bpf_map_update_elem (id 2)
    bpf_map_delete_elem (id 3)
    ...
- kprobe (id 2)
  ...

bpftool feature命令輸出顯示了系統設定以及所有可用的程式型別、對映型別,甚至列出了每種程式型別支援的所有輔助函式。這對於開發者來說是非常有用的資訊,可以幫助確定特定程式型別可以使用哪些輔助函式。

Kfuncs:直接存取核心內部函式

儘管有版本間變化的風險,但eBPF開發人員仍然希望能夠從eBPF程式中存取一些核心內部函式。這可以透過稱為BPF核心函式(Kfuncs)的機制來實作。

Kfuncs允許將內部核心函式註冊到BPF子系統,這樣驗證器就會允許eBPF程式呼叫這些函式。每個被允許呼叫給定kfunc的eBPF程式型別都有一個註冊。

與輔助函式不同,kfuncs不提供相容性保證,因此eBPF開發者必須考慮不同核心版本之間可能的變化。

目前有一組"核心"BPF kfuncs,包括允許eBPF程式取得和釋放對任務和cgroups的核心參照的函式。

// 使用kfunc取得對任務的參照
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    struct task_struct *task;
    
    // 使用kfunc取得當前任務的參照
    task = bpf_get_current_task_btf();
    
    // 使用任務資訊...
    
    // 釋放參照
    bpf_task_release(task);
    
    return 0;
}

這個例子展示瞭如何使用kfuncs來取得和釋放對核心任務結構的參照。bpf_get_current_task_btf()bpf_task_release()是核心BPF kfuncs,允許eBPF程式安全地操作核心內部結構。這種能力非常強大,但也需要謹慎使用,因為這些函式不提供版本間的相容性保證。

總結來說,程式型別決定了eBPF程式可以附加到哪些事件,進而定義了它接收的上下文資訊型別。程式型別還定義了它可以呼叫的輔助函式和kfuncs集合。這種設計確保了eBPF程式能夠安全與高效地與核心互動。

追蹤相關程式型別的特性與應用

eBPF程式型別大致可分為兩類別:追蹤(或perf)相關程式型別和網路相關程式型別。讓我們先深入瞭解追蹤相關的程式型別。

追蹤程式的設計目的與演進

附加到kprobes、追蹤點、原始追蹤點、fentry/fexit探針和perf事件的程式都被設計用來提供一種高效的方式,讓核心中的eBPF程式將事件的追蹤資訊報告給使用者空間。這些追蹤相關的型別最初並不期望影響核心對它們所附加的事件的回應方式(雖然後續有了一些創新)。

這些程式型別有時被稱為"perf相關"程式。例如,bpftool perf子命令可以讓你檢視附加到perf相關事件的程式:

$ sudo bpftool perf show
pid 232272 fd 16: prog_id 392 kprobe func __x64_sys_execve offset 0
pid 232272 fd 17: prog_id 394 kprobe func do_execve offset 0
pid 232272 fd 19: prog_id 396 tracepoint sys_enter_execve
pid 232272 fd 20: prog_id 397 raw_tracepoint sched_process_exec
pid 232272 fd 21: prog_id 398 raw_tracepoint sched_process_exec

上面的輸出顯示了附加到各種與execve()相關事件的不同程式。這些包括:

  1. 附加到execve()系統呼叫入口點的kprobe
  2. 附加到核心函式do_execve()的kprobe
  3. 放置在execve()系統呼叫入口的追蹤點
  4. 兩個版本的在處理execve()期間呼叫的原始追蹤點,其中一個是BTF啟用的版本

使用任何追蹤相關的eBPF程式型別,你需要擁有CAP_PERFMONCAP_BPFCAP_SYS_ADMIN能力。

Kprobes和Kretprobes的靈活性與限制

Kprobes可以附加到核心中的幾乎任何位置,這為開發者提供了極大的靈活性。通常,kprobes附加到函式的入口點,而kretprobes附加到函式的結束點,但你也可以使用kprobes附加到函式入口點之後的某個特定偏移量的指令。

// 附加到系統呼叫入口點的kprobe
SEC("ksyscall/execve")
int BPF_KPROBE_SYSCALL(kprobe_sys_execve, char *pathname)
{
    // 處理邏輯...
    return 0;
}

// 附加到核心函式的kprobe
SEC("kprobe/do_execve")
int BPF_KPROBE(kprobe_do_execve, struct filename *filename)
{
    // 處理邏輯...
    return 0;
}

這兩個程式碼片段展示瞭如何將kprobe附加到系統呼叫入口點和核心函式。第一個使用BPF_KPROBE_SYSCALL巨集附加到execve系統呼叫,第二個使用BPF_KPROBE巨集附加到do_execve核心函式。這種靈活性使得kprobes成為強大的除錯和監控工具,但也帶來了一些挑戰。

使用kprobes時需要注意幾個重要限制:

  1. 行內函式問題:當核心編譯時,編譯器可能選擇"行內"任何給定的核心函式。這意味著編譯器可能會在呼叫函式的地方直接生成實作該函式功能的機器碼,而不是跳轉到函式入口點。如果一個函式被行內了,你的eBPF程式將無法附加到它的kprobe入口點。

  2. 核心版本相依性:如果你選擇附加到函式入口點之後的特定偏移量,你需要確保你執行的核心版本中你想要附加的指令位於你認為它應該在的位置。附加到核心函式的入口點和結束點相對穩定,但任意程式碼行可能在不同發行版之間輕易被修改。

  3. 安全性考量:雖然附加到系統呼叫是常見做法,但對於安全工具來說,不應該僅依賴系統呼叫kprobes,因為它們可能被繞過。

從實務角度來看,我傾向於在可能的情況下使用追蹤點而非kprobes,因為追蹤點提供了更穩定的介面。但kprobes的優勢在於它們可以附加到幾乎任何核心函式,而追蹤點只存在於核心開發者明確放置它們的地方。

追蹤點與原始追蹤點的優勢與差異

除了kprobes和kretprobes外,eBPF還支援追蹤點和原始追蹤點,這些提供了與核心互動的不同方式,各有其優勢和特點。

追蹤點的穩定性與結構化資料

追蹤點是核心開發者在核心程式碼中特意放置的標記點,用於追蹤和除錯。與kprobes相比,追蹤點有兩個主要優勢:

  1. 穩定性:追蹤點是核心的穩定API的一部分,不會在不同版本間改變。這意味著附加到追蹤點的eBPF程式在不同核心版本上更可能保持功能。

  2. 結構化資料:追蹤點提供結構化的事件資料,使得存取事件相關資訊更加容易和可靠。

SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint_execve(struct trace_event_raw_sys_enter *ctx)
{
    char *filename = (char *)ctx->args[0];
    // 使用結構化資料...
    return 0;
}

這個例子展示瞭如何使用追蹤點來監控execve系統呼叫。追蹤點程式接收一個指向trace_event_raw_sys_enter結構的指標,該結構包含系統呼叫的引數。與kprobes相比,這種方式更加結構化和可靠,因為資料格式是由核心明確定義的。

要檢視系統中可用的追蹤點,可以使用以下命令:

$ ls /sys/kernel/debug/tracing/events/syscalls/

原始追蹤點的效能優勢

原始追蹤點(Raw Tracepoints)是eBPF提供的一種更高效的追蹤機制。與普通追蹤點相比,原始追蹤點直接提供核心事件的原始資料,而不進行任何格式轉換,這帶來了效能優勢。

SEC("raw_tracepoint/sched_process_exec")
int raw_tp_exec(struct bpf_raw_tracepoint_args *ctx)
{
    // 直接存取原始引數
    struct filename *filename = (struct filename *)ctx->args[0];
    // 使用原始資料...
    return 0;
}

這個例子顯示瞭如何使用原始追蹤點來監控行程執行事件。原始追蹤點程式接收一個指向bpf_raw_tracepoint_args結構的指標,該結構包含事件的原始引數。這種方式更加高效,但需要開發者更深入地瞭解核心內部結構。

BTF啟用的原始追蹤點

隨著BTF(BPF Type Format)的引入,eBPF生態系統獲得了更強大的型別資訊能力。BTF啟用的原始追蹤點允許eBPF程式直接使用核心結構,而不需要在使用者空間重新定義這些結構。

SEC("raw_tracepoint.btf/sched_process_exec")
int btf_tp_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    // 直接使用BTF定義的結構
    struct task_struct *task = (struct task_struct *)ctx->task;
    // 使用BTF啟用的結構...
    return 0;
}

這個例子展示瞭如何使用BTF啟用的原始追蹤點。注意SEC定義中的.btf字尾,這表明這是一個BTF啟用的追蹤點。這種方式結合了原始追蹤點的效能優勢和BTF的型別安全性,使得開發更加便捷和安全。

在實際應用中,追蹤點和原始追蹤點各有其適用場景:

  • 當穩定性和易用性是首要考慮因素時,普通追蹤點是更好的選擇。
  • 當效能至關重要與開發者熟悉核心內部結構時,原始追蹤點可能更適合。
  • 當既需要效能又需要型別安全性時,BTF啟用的原始追蹤點提供了最佳平衡。

fentry/fexit與LSM程式型別的創新

除了前面討論的程式型別外,eBPF還引入了一些更新的程式型別,如fentry/fexit和LSM(Linux安全模組)程式,這些為開發者提供了新的能力和使用場景。

fentry/fexit的效能優勢

fentry和fexit程式型別是對kprobes和kretprobes的改進版本。它們提供了類別似的功能(分別附加到函式入口和結束點),但具有更好的效能特性。

SEC("fentry/do_execve")
int BPF_PROG(fentry_execve, struct filename *filename)
{
    // 處理函式入口邏輯...
    return 0;
}

SEC("fexit/do_execve")
int BPF_PROG(fexit_execve, struct filename *filename, int ret)
{
    // 處理函式結束邏輯...
    // ret引數包含函式的回傳值
    return 0;
}

這些例子展示瞭如何使用fentry和fexit程式型別。注意fexit程式多接收一個引數,即被追蹤函式的回傳值。與kprobes相比,fentry/fexit的主要優勢在於:

  1. 更低的開銷:fentry/fexit使用BPF追蹤點的機制,避免了kprobes使用的中斷機制,從而降低了開銷。

  2. 更好的上下文存取:fentry/fexit程式可以更容易地存取函式引數和回傳值。

  3. BTF支援:fentry/fexit程式利用BTF資訊來提供更好的型別安全性。

然而,fentry/fexit也有其限制:它們需要較新的核心版本(5.5+),與只能附加到有BTF資訊的函式。

LSM程式的安全應用

Linux安全模組(LSM)程式型別允許eBPF程式附加到核心的安全檢查點,這些檢查點在執行潛在敏感操作之前被呼叫。

SEC("lsm/file_open")
int BPF_PROG(lsm_file_open, struct file *file)
{
    // 安全檢查邏輯...
    if (should_deny_access(file)) {
        return -EPERM; // 拒絕存取
    }
    return 0; // 允許存取
}

這個例子展示了一個LSM程式,它附加到檔案開啟操作的安全檢查點。程式可以檢查檔案存取請求,並決定是允許還是拒絕該請求。這種能力使eBPF成為實作安全策略的強大工具。

LSM程式的特點包括:

  1. 安全決策能力:LSM程式可以影響系統的安全決策,允許或拒絕特定操作。

  2. 細粒度控制:LSM程式可以附加到多種安全檢查點,提供細粒度的安全控制。

  3. 與現有安全機制整合:LSM程式可以與Linux的其他安全機制(如SELinux、AppArmor)協同工作。

在安全敏感的環境中,LSM程式提供了一種靈活與強大的方式來實作自定義安全策略,而不需要修改核心程式碼或載入核心模組。

網路相關程式型別的特性與應用

除了追蹤相關程式型別外,eBPF的另一個主要應用領域是網路。eBPF提供了多種網路相關的程式型別,這些型別允許在網路堆積積疊的不同層級攔截和處理網路流量。

XDP:高效的封包處理

XDP(eXpress Data Path)是eBPF最強大的網路程式型別之一,它允許在網路驅動程式層級處理封包,甚至在它們到達核心網路堆積積疊之前。這提供了極低的延遲和極高的效能。

SEC("xdp")
int xdp_filter(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    struct ethhdr *eth = data;
    
    // 確保封包至少有乙太網頭部
    if (data + sizeof(*eth) > data_end)
        return XDP_ABORTED;
    
    // 根據MAC位址過濾
    if (is_blacklisted_mac(eth->h_source))
        return XDP_DROP;
    
    return XDP_PASS;
}

這個例子展示了一個XDP程式,它檢查進入的封包並根據源MAC位址決定是否丟棄它。程式的回傳值決定了封包的命運:

  • XDP_DROP:丟棄封包
  • XDP_PASS:允許封包繼續到網路堆積積疊
  • XDP_TX:將封包發回它來的介面
  • XDP_REDIRECT:將封包重定向到另一個介面
  • XDP_ABORTED:丟棄封包並可能記錄錯誤

XDP的主要優勢在於其效能:它可以在每秒數百萬封包的速率下執行,使其非常適合DDoS緩解、負載平衡和封包過濾等應用。

TC BPF:靈活的流量控制

TC(Traffic Control)BPF程式附加到Linux的流量控制子系統,允許在網路堆積積疊的較高層級控制流量。與XDP相比,TC BPF可以存取更多的封包上下文資訊。

SEC("tc")
int tc_egress(struct __sk_buff *skb)
{
    // 取得封包型別
    int proto = skb->protocol;
    
    // 對IPv4封包應用特定處理
    if (proto == htons(ETH_P_IP)) {
        // IPv4處理邏輯...
        return TC_ACT_OK;
    }
    
    return TC_ACT_OK;
}

這個例子展示了一個TC BPF程式,它檢查出站封包的協定型別並可以對IPv4封包應用特定處理。程式的回傳值決定了封包的處理方式:

  • TC_ACT_OK:允許封包繼續
  • TC_ACT_SHOT:丟棄封包
  • TC_ACT_REDIRECT:重定向封包
  • 其他各種動作如修改、延遲等

TC BPF的優勢在於其靈活性:它可以在網路堆積積疊的多個點附加,包括入站(ingress)和出站(egress)路徑,並且可以存取和修改更多的封包資訊。

Socket篩選器與其他網路程式型別

除了XDP和TC BPF外,eBPF還提供了多種其他網路相關的程式型別:

  • Socket篩選器:附加到特定通訊端,過濾或修改進出該通訊端的流量。
  • Cgroup通訊端程式:控制cgroup中行程的網路存取。
  • 流解析器:分析TCP流量並提取元資料。
  • Socket對映程式:實作高效的通訊端重定向。
SEC("socket")
int socket_filter(struct __sk_buff *skb)
{
    // 過濾邏輯...
    if (should_drop_packet(skb))
        return 0; // 丟棄封包
    
    return skb->len; // 接受封包
}

這個例子展示了一個通訊端篩選器程式,它決定是接受還是丟棄到達通訊端的封包。回傳0表示丟棄封包,而回傳封包長度表示接受封包。

網路相關的eBPF程式型別提供了前所未有的網路可程式性,使開發者能夠實作自定義的網路功能,如負載平衡、防火牆、流量監控等,而不需要修改核心程式碼或載入核心模組。

在實際應用中,我發現不同的網路程式型別適合不同的使用場景:

  • XDP適合需要極高效能的場景,如DDoS緩解和封包過濾。
  • TC BPF適合需要更多封包上下文的場景,如流量塑形和QoS。
  • Socket相關程式型別適合應用層面的網路控制,如應用層防火牆和流量監控。