[譯] Cilium:BPF 和 XDP 參考指南(2021)

語言: CN / TW / HK

[譯] Cilium:BPF 和 XDP 參考指南(2021) Published at 2021-07-18 | Last Update 2021-07-18

譯者序

本文翻譯自 Cilium 1.10 的官方文件: BPF and XDP Reference Guide。

幾年前翻譯過一版:Cilium:BPF 和 XDP 參考指南(2019), 對應 Cilium v1.6。

本文對排版做了一些調整,以更適合網頁閱讀。

由於譯者水平有限,本文不免存在遺漏或錯誤之處。如有疑問,請查閱原文。

以下是譯文。

本文的目標讀者是 “希望在技術層面對 BPF 和 XDP 有更深入理解的開發者和使用者”。雖 然閱讀本文有助於拓寬讀者對 Cilium 的認識,但這並不是使用 Cilium 的前提條件。

BPF 是 Linux 核心中一個非常靈活與高效的類虛擬機器(virtual machine-like)元件, 能夠在許多核心 hook 點安全地執行位元組碼(bytecode )。很多 核心子系統都已經使用了 BPF,例如常見的網路(networking)、跟蹤( tracing)與安全(security ,例如沙盒)。

BPF 其實早在 1992 年就出現了,但本文介紹的是擴充套件的 BPF(extended Berkeley Packet Filter,eBPF)。eBPF 最早出現在 3.18 核心中,此後原來的 BPF 就被稱為 “經典” BPF(classic BPF, cBPF),cBPF 現在基本已經過時了。很多人知道 cBPF 是因為它是 tcpdump 的包過濾語言。現在,Linux 核心只執行 eBPF,核心會將載入的 cBPF 位元組碼 透明地轉換成 eBPF 再執行。如無特殊說明,本文中所說的 BPF 都是泛指 BPF 技術。

雖然“伯克利包過濾器”(Berkeley Packet Filter)這個名字聽起來像是專用於資料包過 濾的,但如今這個指令集已經足夠通用和靈活,因此現在 BPF 也有很多網路之外的使用案例, 下文會列出一些專案。

Cilium 在其資料平面(datapath)中重度使用了 BPF 技術,更多資訊可參考其 eBPF datapath 架構 文件。本文的目標是提供一份 BPF 參考指南,這份指南能幫助我們更 深入地理解 BPF、BPF 網路相關的使用方式(例如用 tc 載入 BPF 程式,XDP 程式 ),以及更好地開發 Cilium 中的 BPF 模板。

譯者序 1 BPF 架構

1.1 指令集

1.1.1 指令集 1.1.2 BPF 暫存器和呼叫約定 1.1.3 BPF 指令格式

1.2 輔助函式
  1.3 Maps
  1.4 Object Pinning(釘住物件)
  1.5 尾呼叫(Tail Calls)
  1.6 BPF to BPF Calls
  1.7 JIT
  1.8 加固(Hardening)
  1.9 Offloads

2 工具鏈

2.1 開發環境

Fedora Ubuntu openSUSE Tumbleweed 編譯 Linux 核心 驗證編譯好的核心 編譯 iproute2 編譯 bpftool

2.2 LLVM        
      2.2.1 BPF Target(目標平臺)
      2.2.2 除錯資訊(DWARF、BTF)            
          DWARF 格式和 llvm-objdump
          LLVM IR
          BTF
          pahole
          readelf
        
      
      2.2.3 BPF 指令集
      2.2.4 指令和暫存器位寬(64/32 位)
      2.2.5 C BPF 程式碼注意事項            
          1. 所有函式都需要內聯(inlined)、沒有函式呼叫(對於老版本 LLVM)或共享庫呼叫
          2. 多個程式可以放在同一 C 檔案中的不同 section                
              示例程式
              其他程式說明
            
          
          3. 不允許全域性變數
          4. 不支援常量字串或陣列(const strings or arrays)
          5. 使用 LLVM 內建的函式做記憶體操作
          6. (目前還)不支援迴圈
          7. 尾呼叫的用途
          8. BPF 最大棧空間 512 位元組
          9. 嘗試使用 BPF 內聯彙編
          10. 用 #pragma pack 禁止結構體填充(struct padding)
          11. 通過未驗證的引用(invalidated references)訪問包資料
        
      
    
  
  2.3 iproute2        
      2.3.1 載入 XDP BPF 物件檔案            
          XDP 工作模式
        
      
      2.3.2 載入 tc BPF 物件檔案            
          用 tc 載入 BPF 程式
          程式優先順序(pref)和控制代碼(handle)
          用 tc 刪除 BPF 程式
          offload 到網絡卡
        
      
      2.3.3 通過 netdevsim 驅動測試 BPF offload
    
  
  2.4 bpftool
  2.5 BPF sysctls
  2.6 核心測試
  2.7 JIT Debugging
  2.8 內省(Introspection)
  2.9 Tracing pipe
  2.10 其他(Miscellaneous)

3 程式型別

3.1 XDP

BPF 程式返回碼 XDP 使用案例 XDP 工作模式 驅動支援

支援 native XDP 的驅動 支援 offloaded XDP 的驅動

3.2 tc        
      tc 和 XDP BPF 程式的不同            
          1. 輸入上下文
          2. hook 觸發點
          3. 是否依賴驅動支援                
              Ingress
              Egress
            
          
        
      
      cls_bpf 分類器
      tc BPF 程式返回碼            
          TC_ACT_UNSPEC 和 TC_ACT_OK
          TC_ACT_SHOT 和 TC_ACT_STOLEN
          TC_ACT_REDIRECT
        
      
      tc BPF FAQ
      tc BPF 使用案例
      驅動支援            
          支援 offload tc BPF 程式的驅動

1 BPF 架構

BPF 不僅僅是一個指令集,它還提供了圍繞自身的一些基礎設施,例如:

BPF map:高效的 key/value 儲存 輔助函式(helper function):可以更方便地利用核心功能或與核心互動 尾呼叫(tail call):高效地呼叫其他 BPF 程式 安全加固原語(security hardening primitives) 用於 pin/unpin 物件(例如 map、程式)的偽檔案系統(bpffs),實現持久儲存 支援 BPF offload(例如 offload 到網絡卡)的基礎設施

LLVM 提供了一個 BPF 後端(back end),因此使用 clang 這樣的工具就可以將 C 代 碼編譯成 BPF 物件檔案(object file),然後再載入到核心。BPF 深度繫結 Linux 核心,可以在 不犧牲原生核心效能的前提下,實現對核心的完全可程式設計 (full programmability)。

另外, 使用了 BPF 的核心子系統也是 BPF 基礎設施的一部分。本文將主要討論 tc和 XDP 這兩個子系統,二者都支援 attach(附著)BPF 程式。

XDP BPF 程式會被 attach 到網路驅動的最早階段(earliest networking driver stage),驅動收到包之後就會觸發 BPF 程式的執行。從定義上來說,這可以取得 最好的包處理效能,因為這已經是軟體中最早可以處理包的位置了。但也正因為 這一步的處理在網路棧中是如此之早,協議棧此時還沒有從包中提取出元資料(因此 XDP BPF 程式無法利用這些元資料)。 tc BPF 程式在核心棧中稍後面的一些地方執行,因此它們能夠訪問更多的元資料和一些核心的核心功能。

除了 tc 和 XDP 程式之外,還有很多其他核心子系統也在使用 BPF,例如跟蹤子系統( kprobes、uprobes、tracepoints 等等)。

下面的各小節進一步介紹 BPF 架構。

1.1 指令集

1.1.1 指令集

BPF 是一個通用目的 RISC 指令集,其最初的設計目標是:

用 C 語言的一個子集編寫程式, 然後用一個編譯器後端(例如 LLVM)將其編譯成 BPF 指令, 稍後核心再通過一個位於核心中的(in-kernel)即時編譯器(JIT Compiler) 將 BPF 指令對映成處理器的原生指令(opcode ),以獲得在核心中的最佳執行效能。

將這些指令下放到核心中可以帶來如下好處:

無需在核心/使用者空間切換就可以實現核心的可程式設計。例如,Cilium 這種和網路相關 的 BPF 程式能直接在核心中實現靈活的容器策略、負載均衡等功能,而無需將包送先 到使用者空間,處理之後再送回核心。需要在 BPF 程式之間或核心/使用者空間之間共享狀 態時,可以使用 BPF map。 可程式設計 datapath 具有很大的靈活性,因此程式能在編譯時將不需要的特性禁用掉, 從而極大地優化程式的效能。例如,如果容器不需要 IPv4,那編寫 BPF 程式時就可以 只處理 IPv6 的情況,從而節省了快速路徑(fast path)中的資源。 對於網路場景(例如 tc 和 XDP),BPF 程式可以在無需重啟核心、系統服務或容器的 情況下實現原子更新,並且不會導致網路中斷。另外,更新 BPF map 不會導致程式 狀態(program state)的丟失。 BPF 給使用者空間提供了一個穩定的 ABI,而且不依賴任何第三方核心模組。BPF 是 Linux 核心的一個核心組成部分,而 Linux 已經得到了廣泛的部署,因此可以保證現 有的 BPF 程式能在新的核心版本上繼續執行。這種保證與系統呼叫(核心提供給用 戶態應用的介面)是同一級別的。另外,BPF 程式在不同平臺上是可移植的。 BPF 程式與核心協同工作,複用已有的核心基礎設施(例如驅動、netdevice、 隧道、協議棧和 socket)和工具(例如 iproute2),以及核心提供的安全保證。和內 核模組不同,BPF 程式會被一個位於核心中的校驗器(in-kernel verifier)進行校驗, 以確保它們不會造成核心崩潰、程式永遠會終止等等。例如,XDP 程式會複用已有的內 核驅動,能夠直接操作存放在 DMA 緩衝區中的資料幀,而不用像某些模型(例如 DPDK) 那樣將這些資料幀甚至整個驅動暴露給使用者空間。而且,XDP 程式複用核心協議棧而 不是繞過它。BPF 程式可以看做是核心設施之間的通用“膠水程式碼”, 基於 BPF 可以設計巧妙的程式,解決特定的問題。

BPF 程式在核心中的執行總是事件驅動的!例如:

如果網絡卡的 ingress 路徑上 attach 了 BPF 程式,那當網絡卡收到包之後就會觸發這 個 BPF 程式的執行。 在某個有 kprobe 探測點的核心地址 attach 一段 BPF 程式後,當 核心執行到這個地址時會發生陷入(trap),進而喚醒 kprobe 的回撥函式,後 者又會觸發 attach 的 BPF 程式的執行。

1.1.2 BPF 暫存器和呼叫約定

BPF 由下面幾部分組成:

11 個 64 位暫存器(這些暫存器包含 32 位子暫存器) 一個程式計數器(program counter,PC) 一個 512 位元組大小的 BPF 棧空間

暫存器的名字從 r0 到 r10。預設的執行模式是 64 位,32 位子暫存器只能 通過特殊的 ALU(arithmetic logic unit)訪問。向 32 位子暫存器寫入時,會用 0 填充 到 64 位。

r10 是唯一的只讀暫存器,其中存放的是訪問 BPF 棧空間的棧幀指標(frame pointer) 地址。r0 - r9 是可以被讀/寫的通用目的暫存器。

BPF 程式可以呼叫核心核心(而不是核心模組)預定義的一些輔助函式。BPF 呼叫約定 定義如下:

r0 存放被呼叫的輔助函式的返回值 r1 - r5 存放 BPF 呼叫核心輔助函式時傳遞的引數 r6 - r9 由被呼叫方(callee)儲存,在函式返回之後呼叫方(caller)可以讀取

BPF 呼叫約定足夠通用,能夠直接對映到 x86_64、arm64 和其他 ABI,因此所有 的 BPF 暫存器可以一一對映到硬體 CPU 暫存器,JIT 只需要發出一條呼叫指令,而不 需要額外的放置函式引數(placing function arguments)動作。這套約定在不犧牲效能的 前提下,考慮了儘可能通用的呼叫場景。目前不支援 6 個及以上引數的函式呼叫,核心中 BPF 相關的輔助函式(從 BPF_CALL_0() 到 BPF_CALL_5() 函式)也特意設計地與此相 匹配。

r0 暫存器還用於儲存 BPF 程式的退出值。退出值的語義由程式型別決定。另外, 當將執行權交回核心時,退出值是以 32 位傳遞的。

r1 - r5 暫存器是 scratch registers,意思是說,如果要在多次輔助函式呼叫之 間重用這些暫存器內的值,那 BPF 程式需要負責將這些值臨時轉儲(spill)到 BPF 棧上 ,或者儲存到被呼叫方(callee)儲存的暫存器中。Spilling(倒出/轉儲) 的意思是這些暫存器內的變數被移到了 BPF 棧中。相反的操作,即將變數從 BPF 棧移回寄 存器,稱為 filling(填充)。spilling/filling 的原因是暫存器數量有限。

BPF 程式開始執行時,r1 暫存器中存放的是程式的上下文(context)。上下文就是 程式的輸入引數(和典型 C 程式的 argc/argv 類似)。BPF 只能在單個上下文中 工作(restricted to work on a single context)。這個上下文是由程式型別定義的, 例如,網路程式可以將網路包的核心表示(skb)作為輸入引數。

BPF 的通用操作都是 64 位的,這和預設的 64 位架構模型相匹配,這樣可以對指標進 行算術操作,以及在呼叫輔助函式時傳遞指標和 64 位值;另外,BPF 還支援 64 位原子操 作。

每個 BPF 程式的最大指令數限制在 4096 條以內,這意味著從設計上就可以保證每 個程式都會很快結束。對於核心 5.1+,這個限制放大到了 100 萬條。 雖然指令集中包含前向和後向跳轉,但核心中的 BPF 校驗器禁止 程式中有迴圈,因此可以永遠保證程式會終止。因為 BPF 程式執行在核心,校驗器的工作 是保證這些程式在執行時是安全的,不會影響到系統的穩定性。這意味著,從指令集的角度 來說迴圈是可以實現的,但校驗器會對其施加限制。另外,BPF 中有尾呼叫的概念,允許一 個 BPF 程式呼叫另一個 BPF 程式。類似地,這種呼叫也是有限制的,目前上限是 33 層調 用;現在這個功能常用來對程式邏輯進行解耦,例如解耦成幾個不同階段。

1.1.3 BPF 指令格式

BPF 指令格式(instruction format)建模為兩運算元指令(two operand instructions), 這種格式可以在 JIT 階段將 BPF 指令對映(mapping)為原生指令。指令集是固定長 度的,這意味著每條指令都是 64 位元編碼的。目前已經實現了 87 條指令,並且在需要時 可以對指令集進行進一步擴充套件。一條 64 位指令在大端機器上的編碼格式如下,從重要性最 高位元(most significant bit,MSB)到重要性最低位元(least significant bit,LSB):

op:8, dst_reg:4, src_reg:4, off:16, imm:32

off 和 imm 都是有符號型別。編碼資訊定義在核心標頭檔案 linux/bpf.h 中,這個頭 檔案進一步 include 了 linux/bpf_common.h。

op 定了將要執行的操作。op 複用了大部分 cBPF 的編碼定義。操作可以基於暫存器值 ,也可以基於立即運算元(immediate operands)。op 自身的編碼資訊中包含了應該使 用的模式型別:

BPF_X 指基於暫存器的運算元(register-based operations) BPF_K 指基於立即運算元(immediate-based operations)

對於後者,目的運算元永遠是一個暫存器(destination operand is always a register)。 dst_reg 和 src_reg 都提供了暫存器運算元(register operands,例如 r0 - r9)的額外資訊。在某些指令中,off 用於表示一個相對偏移量(offset), 例如,對那些 BPF 可用的棧或緩衝區(例如 map values、packet data 等等)進行尋 址,或者跳轉指令中用於跳轉到目標。imm 儲存一個常量/立即值。

所有的 op 指令可以分為若干類別。類別資訊也編碼到了 op 欄位。op 欄位分為( 從 MSB 到 LSB):code:4, source:1 和 class:3。

class 是指令型別 code 指特定型別的指令中的某種特定操作碼(operational code) source 可以告訴我們源運算元(source operand)是一個暫存器還是一個立即數

可能的指令類別包括:

BPF_LD, BPF_LDX:載入操作(load operations)


  BPF_LD 用於載入double word 長度的特殊指令(佔兩個指令長度,源於

imm:32 的限制),或byte / half-word / word 長度的包資料(packet data )。後者是從 cBPF 中延續過來的,主要為了保證 cBPF 到 BPF 翻譯的高效,因為 這裡的 JIT code 是優化過的。對於 native BPF 來說,這些包載入指令在今天已經 用的很少了。 BPF_LDX 用於從記憶體中載入 byte / half-word / word / double-word,這裡的內 存包括棧記憶體、map value data、packet data 等等。

BPF_ST, BPF_STX:儲存操作(store operations)


  BPF_STX 與 BPF_LDX 相對,將某個暫存器中的值儲存到記憶體中,同樣,這裡的

記憶體可以是棧記憶體、map value、packet data 等等。BPF_STX 類包含一些 word 和 double-word 相關的原子加操作,例如,可以用於計數器。 BPF_ST 類與 BPF_STX 類似,提供了將資料儲存到記憶體的操作,只不過其源操作 數(source operand)必須是一個立即值(immediate value)。

BPF_ALU, BPF_ALU64:邏輯運算操作(ALU operations)

Generally,

BPF_ALU operations are in 32 bit mode and BPF_ALU64 in 64 bit mode. Both ALU classes have basic operations with source operand which is register-based and an immediate-based counterpart. Supported by both are add (+), sub (-), and (&), or (|), left shift (<<), right shift (>>), xor (^), mul (*), div (/), mod (%), neg (~) operations. Also mov ( := ) was added as a special ALU operation for both classes in both operand modes. BPF_ALU64 also contains a signed right shift. BPF_ALU additionally contains endianness conversion instructions for half-word / word / double-word on a given source register.

BPF_JMP:跳轉操作(jump operations)

Jumps can be unconditional

and conditional. Unconditional jumps simply move the program counter forward, so that the next instruction to be executed relative to the current instruction is off + 1, where off is the constant offset encoded in the instruction. Since off is signed, the jump can also be performed backwards as long as it does not create a loop and is within program bounds. Conditional jumps operate on both, register-based and immediate-based source operands. If the condition in the jump operations results in true, then a relative jump to off + 1 is performed, otherwise the next instruction (0 + 1) is performed. This fall-through jump logic differs compared to cBPF and allows for better branch prediction as it fits the CPU branch predictor logic more naturally. Available conditions are jeq (==), jne (!=), jgt (>), jge (>=), jsgt (signed >), jsge (signed >=), jlt (<), jle (<=), jslt (signed <), jsle (signed <=) and jset (jump if DST & SRC). Apart from that, there are three special jump operations within this class: the exit instruction which will leave the BPF program and return the current value in r0 as a return code, the call instruction, which will issue a function call into one of the available BPF helper functions, and a hidden tail call instruction, which will jump into a different BPF program.

Linux 核心中內建了一個 BPF 直譯器,該直譯器能夠執行由 BPF 指令組成的程式。即 使是 cBPF 程式,也可以在核心中透明地轉換成 eBPF 程式,除非該架構仍然內建了 cBPF JIT,還沒有遷移到 eBPF JIT。

目前下列架構都內建了核心 eBPF JIT 編譯器:x86_64、arm64、ppc64、s390x 、mips64、sparc64 和 arm。

所有的 BPF 操作,例如載入程式到核心,或者建立 BPF map, 都是通過核心的 bpf() 系統呼叫完成的。它還用於管理 map 表項(查 找/更新/刪除),以及通過 pinning 將程式和 map 持久化到 BPF 檔案系統。

1.2 輔助函式

輔助函式(Helper functions)使得 BPF 能夠通過一組核心定義的函式呼叫(function call)來從核心中查詢資料,或者將資料推送到核心。不同型別的 BPF 程式能夠使用的 輔助函式可能是不同的,例如,與 attach 到 tc 層的 BPF 程式相比,attach 到 socket 的 BPF程式只能夠呼叫前者可以呼叫的輔助函式的一個子集。另外一個例子是, 輕量級隧道(lightweight tunneling )使用的封裝和解封裝(Encapsulation and decapsulation)輔助函式,只能被更低的 tc 層(lower tc layers)使用;而推送通知到 使用者態所使用的事件輸出輔助函式,既可以被 tc 程式使用也可以被 XDP 程式使用。

所有的輔助函式都共享同一個通用的、和系統呼叫類似的函式簽名。簽名定義如下:

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

前一節介紹的呼叫約定適用於所有的 BPF 輔助函式。

核心將輔助函式抽象成 BPF_CALL_0() 到 BPF_CALL_5() 幾個巨集,形式和相應型別的系 統呼叫類似。下面的例子是從某個輔助函式中抽取出來的,可以看到它通過呼叫相應 map 的回撥函式完成更新 map 元素的操作:

BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key, void *, value, u64, flags) { WARN_ON_ONCE(!rcu_read_lock_held()); return map->ops->map_update_elem(map, key, value, flags); }

const struct bpf_func_proto bpf_map_update_elem_proto = { .func = bpf_map_update_elem, .gpl_only = false, .ret_type = RET_INTEGER, .arg1_type = ARG_CONST_MAP_PTR, .arg2_type = ARG_PTR_TO_MAP_KEY, .arg3_type = ARG_PTR_TO_MAP_VALUE, .arg4_type = ARG_ANYTHING, };

這種方式有很多優點:雖然 cBPF 允許其載入指令(load instructions)進行超出範圍的 訪問(overload),以便從一個看似不可能的包偏移量(packet offset)獲取資料以喚醒 多功能輔助函式,但每個 cBPF JIT 仍然需要為這個 cBPF 擴充套件實現對應的支援。而在 eBPF 中,JIT 編譯器會以一種透明和高效的方式編譯新加入的輔助函式,這意味著 JIT 編 譯器只需要發射(emit)一條呼叫指令(call instruction),因為暫存器對映的方式使得 BPF 排列引數的方式(assignments)已經和底層架構的呼叫約定相匹配了。這使得基於輔 助函式擴充套件核心核心(core kernel)非常方便。所有的 BPF 輔助函式都是核心核心的一 部分,無法通過核心模組(kernel module)來擴充套件或新增。

前面提到的函式簽名還允許校驗器執行型別檢測(type check)。上面的 struct bpf_func_proto 用於存放校驗器必需知道的所有關於該輔助函式的資訊,這 樣校驗器可以確保輔助函式期望的型別和 BPF 程式暫存器中的當前內容是匹配的。

引數類型範圍很廣,從任意型別的值,到限制只能為特定型別,例如 BPF 棧緩衝區(stack buffer)的 pointer/size 引數對,輔助函式可以從這個位置讀取資料或向其寫入資料。 對於這種情況,校驗器還可以執行額外的檢查,例如,緩衝區是否已經初始化過了。

當前可用的 BPF 輔助函式已經有幾十個,並且數量還在不斷增加,例如,寫作本文時,tc BPF 程式可以使用38 種不同的 BPF 輔助函式。對於一個給定的 BPF 程式型別,核心的 struct bpf_verifier_ops 包含了 get_func_proto 回撥函式,這個函式提供了從某個 特定的enum bpf_func_id 到一個可用的輔助函式的對映。

1.3 Maps

map 是駐留在核心空間中的高效鍵值倉庫(key/value store)。map 中的資料可以被 BPF 程式訪問,如果想在 多次 BPF 程式呼叫(invoke)之間儲存狀態,可以將狀態信 息放到 map。map 還可以從使用者空間通過檔案描述符訪問,可以在任意 BPF 程式以及用 戶空間應用之間共享。

共享 map 的 BPF 程式不要求是相同的程式型別,例如 tracing 程式可以和網路程式共享 map。單個 BPF 程式目前最多可直接訪問 64 個不同 map。

map 的實現由核心核心(core kernel)提供。有 per-CPU 及 non-per-CPU 的通用 map,這些 map 可以讀/寫任意資料,也有一些和輔助函式一起使用的非通用 map。

當前可用的 通用 map 有:

BPF_MAP_TYPE_HASH BPF_MAP_TYPE_ARRAY BPF_MAP_TYPE_PERCPU_HASH BPF_MAP_TYPE_PERCPU_ARRAY BPF_MAP_TYPE_LRU_HASH BPF_MAP_TYPE_LRU_PERCPU_HASH BPF_MAP_TYPE_LPM_TRIE

以上 map 都使用相同的一組 BPF 輔助函式來執行查詢、更新或刪除操作,但各自實現了不 同的後端,這些後端各有不同的語義和效能特點。

當前核心中的 非通用 map 有:

BPF_MAP_TYPE_PROG_ARRAY BPF_MAP_TYPE_PERF_EVENT_ARRAY BPF_MAP_TYPE_CGROUP_ARRAY BPF_MAP_TYPE_STACK_TRACE BPF_MAP_TYPE_ARRAY_OF_MAPS BPF_MAP_TYPE_HASH_OF_MAPS

例如,BPF_MAP_TYPE_PROG_ARRAY 是一個數組 map,用於持有(hold)其他的 BPF 程式 。BPF_MAP_TYPE_ARRAY_OF_MAPS 和 BPF_MAP_TYPE_HASH_OF_MAPS 都用於持有(hold) 其他 map 的指標,這樣整個 map 就可以在執行時實現原子替換。這些型別的 map 都針對 特定的問題,不適合單單通過一個 BPF 輔助函式實現,因為它們需要在各次 BPF 程式呼叫 (invoke)之間時保持額外的(非資料)狀態。

1.4 Object Pinning(釘住物件)

BPF map 和程式作為核心資源只能通過檔案描述符訪問,其背後是核心中的匿名 inode。這帶來了很多優點,例如:

使用者空間應用能夠使用大部分檔案描述符相關的 API, 在 Unix socket 中傳遞檔案描述符是透明的,等等。

但同時,也有很多缺點:檔案描述符受限於程序的生命週期,使得 map 共享之類的操作非常笨重。

因此,這給某些特定的場景帶來了很多複雜性,例如 iproute2,其中的 tc 或 XDP 在準備 環境、載入程式到核心之後最終會退出。在這種情況下,從使用者空間也無法訪問這些 map 了,而本來這些 map 其實是很有用的,例如,在 data path 的 ingress 和 egress 位置共 享的 map(可以統計包數、位元組數、PPS 等資訊)。另外,第三方應用可能希望在 BPF 程 序執行時監控或更新 map。

為了解決這個問題,核心實現了一個最小核心空間 BPF 檔案系統,BPF map 和 BPF 程式 都可以釘到(pin)這個檔案系統內,這個過程稱為 object pinning(釘住物件)。相應 地,BPF 系統呼叫進行了擴充套件,添加了兩個新命令,分別用於釘住(BPF_OBJ_PIN)一個 物件和獲取(BPF_OBJ_GET)一個被釘住的物件(pinned objects)。

例如,tc 之類的工具可以利用這個基礎設施在 ingress 和 egress 之間共享 map。BPF 相關的檔案系統不是單例模式(singleton),它支援多掛載例項、硬連結、軟連線等 等。

1.5 尾呼叫(Tail Calls)

BPF 相關的另一個概念是尾呼叫(tail calls)。尾呼叫的機制是:一個 BPF 程式可以調 用另一個 BPF 程式,並且呼叫完成後不用返回到原來的程式。和普通函式呼叫相比,這種 呼叫方式開銷最小,因為它是用長跳轉(long jump)實現的,複用了原來的棧幀 (stack frame)。

BPF 程式都是獨立驗證的,因此要傳遞狀態,要麼使用 per-CPU map 作為 scratch 緩衝區 ,要麼如果是 tc 程式的話,還可以使用 skb 的某些欄位(例如 cb[])。

型別相同的 BPF 程式才可以尾呼叫,而且還要與 JIT 編譯器相匹配, 因此一個給定的 BPF 程式 要麼是 JIT編譯執行,要麼是直譯器執行(invoke interpreted programs),而不能同時使用兩種方式。

尾呼叫執行涉及兩個步驟:

設定一個稱為“程式陣列”(program array)的特殊 map(map 型別 BPF_MAP_TYPE_PROG_ARRAY ),這個 map 可以從使用者空間通過 key/value 操作, 呼叫輔助函式 bpf_tail_call()。兩個引數:一個對程式陣列的引用(a reference to the program array),一個查詢 map 所用的 key。核心將這個輔助函式呼叫內聯( inline)到一個特殊的 BPF 指令內。目前,這樣的程式陣列在使用者空間側是隻寫模式( write-only from user space side)。

核心根據傳入的檔案描述符查詢相關的 BPF 程式,自動替換給定的 map slot(槽) 處的 程式指標。如果沒有找到給定的 key 對應的 value,核心會跳過(fall through)這一步 ,繼續執行 bpf_tail_call() 後面的指令。尾呼叫是一個強大的功能,例如,可以通 過尾呼叫結構化地解析網路頭(network headers)。還可以在執行時(runtime)原子地 新增或替換功能,即,動態地改變 BPF 程式的執行行為。

1.6 BPF to BPF Calls

除了 BPF 輔助函式和 BPF 尾呼叫之外,BPF 核心基礎設施最近剛加入了一個新特性:BPF 到 BPF 呼叫(BPF to BPF calls)。在這個特性引入核心之前,典型的 BPF C 程式必須 將所有需要複用的程式碼進行特殊處理,例如,在標頭檔案中宣告為 always_inline。當 LLVM 編譯和生成 BPF 物件檔案時,所有這些函式將被內聯,因此會在生成的物件檔案中重 復多次,導致程式碼尺寸膨脹:

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

#ifndef __inline

define __inline \

inline attribute ((always_inline)) #endif

static __inline int foo(void) { return XDP_DROP; }

__section("prog") int xdp_drop(struct xdp_md *ctx) { return foo(); }

char __license[] __section("license") = "GPL";

之所以要這樣做是因為 BPF 程式的載入器、校驗器、直譯器和 JIT 中都缺少對函式呼叫的 支援。從 Linux 4.16 和 LLVM 6.0 開始,這個限制得到了解決,BPF 程式不再需 要到處使用 always_inline 聲明瞭。因此,上面的程式碼可以更自然地重寫為:

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

static int foo(void) { return XDP_DROP; }

__section("prog") int xdp_drop(struct xdp_md *ctx) { return foo(); }

char __license[] __section("license") = "GPL";

BPF 到 BPF 呼叫是一個重要的效能優化,極大減小了生成的 BPF 程式碼大小,因此對 CPU 指令快取(instruction cache,i-cache)更友好。

BPF 輔助函式的呼叫約定也適用於 BPF 函式間呼叫,即 r1 - r5 用於傳遞引數,返回 結果放到 r0。r1 - r5 是 scratch registers,r6 - r9 像往常一樣是保留寄 存器。最大巢狀呼叫深度是 8。呼叫方可以傳遞指標(例如,指向呼叫方的棧幀的指標) 給被呼叫方,但反過來不行。

BPF JIT 編譯器為每個函式體發射獨立的映象(emit separate images for each function body),稍後在最後一通 JIT 處理(final JIT pass)中再修改映象中函式呼叫的地址 。已經證明,這種方式需要對各種 JIT 做最少的修改,因為在實現中它們可以將 BPF 函式 間呼叫當做常規的 BPF 輔助函式呼叫。

核心 5.9 版本之前,BPF 尾呼叫和 BPF-to-BPF 呼叫是互斥的,只能二選一。 尾呼叫的缺點是生成的程式映象大、載入時間長。 核心 5.10 最終解決了這一問題,允許同時使用者兩種呼叫型別,充分利用二者各自的優點。

但混合使用者兩種呼叫型別是有限制的,否則會導致核心棧溢位(kernel stack overflow)。 來看下面的例子:

如上圖所示,尾呼叫在真正跳轉到目標程式(func3)之前,只會展開(unwind)它當前 所處層級的棧幀(stack frame)。也就是說,如果尾呼叫是從某個子函式發起的(occurs from within the sub-function),例如 subfunc1 --tailcall--> func2,那當程式在執行 func2 時, 所有 subfunc1 之前的棧幀(在這裡是 func1 的棧幀)都會出現在棧上。只有當最後 一個函式(這裡是 func3)執行結束時,所有前面的棧幀才將被展開(unwinded),然後控制返回 到 BPF 程式的呼叫者(BPF program caller)。

核心引入了額外的邏輯來檢測這種混用的情況。整個呼叫鏈中,每個子程式的棧空間( stack size)不能超過 256 位元組(如果校驗器檢測到 bpf2bpf 呼叫,那主函式也會被當做 子函式)。有了這個限制,BPF 程式呼叫鏈最多能使用 8KB 的棧空間,計算方式:256 byte/stack 乘以尾呼叫數量上限 33。如果沒有這個限制,BPF 程式將使用 512 位元組棧空 間,最終消耗最多 16KB 的總棧空間,在某些架構上會導致棧溢位。

另外需要說明,這種混合呼叫目前只有 x86-64 架構支援。

1.7 JIT

64 位的 x86_64、arm64、ppc64、s390x、mips64、sparc64 和 32 位的 arm 、x86_32 架構都內建了 in-kernel eBPF JIT 編譯器,它們的功能都是一樣的,可 以用如下方式開啟:

$ echo 1 > /proc/sys/net/core/bpf_jit_enable

32 位的 mips、ppc 和 sparc 架構目前內建的是一個 cBPF JIT 編譯器。這些只有 cBPF JIT 編譯器的架構,以及那些甚至完全沒有 BPF JIT 編譯器的架構, 需要通過核心中的直譯器(in-kernel interpreter)執行 eBPF 程式。

要判斷哪些平臺支援 eBPF JIT,可以在核心原始檔中 grep HAVE_EBPF_JIT:

$ git grep HAVE_EBPF_JIT arch/ arch/arm/Kconfig: select HAVE_EBPF_JIT if !CPU_ENDIAN_BE32 arch/arm64/Kconfig: select HAVE_EBPF_JIT arch/powerpc/Kconfig: select HAVE_EBPF_JIT if PPC64 arch/mips/Kconfig: select HAVE_EBPF_JIT if (64BIT && !CPU_MICROMIPS) arch/s390/Kconfig: select HAVE_EBPF_JIT if PACK_STACK && HAVE_MARCH_Z196_FEATURES arch/sparc/Kconfig: select HAVE_EBPF_JIT if SPARC64 arch/x86/Kconfig: select HAVE_EBPF_JIT if X86_64

JIT 編譯器可以極大加速 BPF 程式的執行,因為與直譯器相比,它們可以降低每個指令的 開銷(reduce the per instruction cost)。通常,指令可以 1:1 對映到底層架構的原生 指令。另外,這也會減少生成的可執行映象的大小,因此對 CPU 的指令快取更友好。特別 地,對於 CISC 指令集(例如 x86),JIT 做了很多特殊優化,目的是為給定的指令產生 可能的最短操作碼(emitting the shortest possible opcodes),以降低程式翻譯過程所 需的空間。

1.8 加固(Hardening)

為了避免程式碼被損壞,BPF 會在程式的生命週期內,在核心中將下面兩個映象鎖定為只讀的(read-only):

經過 BPF 直譯器解釋(翻譯)之後的整個映象(struct bpf_prog) JIT 編譯之後的映象(struct bpf_binary_header)。

在這些位置發生的任何資料損壞(例如某些核心 bug 導致的)會觸發通用的保護機制,因 此會造成核心崩潰(crash),而不會讓這種損壞靜默地發生。

檢視哪些平臺支援將映象記憶體(image memory)設定為只讀的,可以通過下面的搜尋:

$ git grep ARCH_HAS_SET_MEMORY | grep select arch/arm/Kconfig: select ARCH_HAS_SET_MEMORY arch/arm64/Kconfig: select ARCH_HAS_SET_MEMORY arch/s390/Kconfig: select ARCH_HAS_SET_MEMORY arch/x86/Kconfig: select ARCH_HAS_SET_MEMORY

CONFIG_ARCH_HAS_SET_MEMORY 選項是不可配置的,因此平臺要麼內建支援,要麼不支援 。那些目前還不支援的架構未來可能也會支援。

對於 x86_64 JIT 編譯器,如果設定了 CONFIG_RETPOLINE,尾呼叫的間接跳轉( indirect jump)就會用 retpoline 實現。寫作本文時,在大部分現代 Linux 發行版上 這個配置都是開啟的。

將 /proc/sys/net/core/bpf_jit_harden 設定為 1 會為非特權使用者( unprivileged users)的 JIT 編譯做一些額外的加固工作。這些額外加固會稍微降低程式 的效能,但在有非受信使用者在系統上進行操作的情況下,能夠有效地減小(潛在的)受攻擊 面。但與完全切換到直譯器相比,這些效能損失還是比較小的。

當前,啟用加固會在 JIT 編譯時盲化(blind)BPF 程式中使用者提供的所有 32 位和 64 位常量,以防禦 JIT spraying(噴射)攻擊,這些攻擊會將原生操作碼(native opcodes)作為立即數(immediate values)注入到核心。這種攻擊有效是因為:立即數 駐留在可執行核心記憶體(executable kernel memory)中,因此某些核心 bug 可能會觸 發一個跳轉動作,如果跳轉到立即數的開始位置,就會把它們當做原生指令開始執行。

盲化 JIT 常量通過對真實指令進行隨機化(randomizing the actual instruction)實現 。在這種方式中,通過對指令進行重寫(rewriting the instruction),將原來基於立 即數的操作轉換成基於暫存器的操作。指令重寫將載入值的過程分解為兩部分:

載入一個盲化後的(blinded)立即數 rnd ^ imm 到暫存器 將暫存器和 rnd 進行異或操作(xor)

這樣原始的 imm 立即數就駐留在暫存器中,可以用於真實的操作了。這裡介紹的只是加 載操作的盲化過程,實際上所有的通用操作都被盲化了。

下面是加固關閉的情況下,某個程式的 JIT 編譯結果:

$ echo 0 > /proc/sys/net/core/bpf_jit_harden

ffffffffa034f5e9 + : [...] 39: mov $0xa8909090,%eax 3e: mov $0xa8909090,%eax 43: mov $0xa8ff3148,%eax 48: mov $0xa89081b4,%eax 4d: mov $0xa8900bb0,%eax 52: mov $0xa810e0c1,%eax 57: mov $0xa8908eb4,%eax 5c: mov $0xa89020b0,%eax [...]

加固開啟之後,以上程式被某個非特權使用者通過 BPF 載入的結果(這裡已經進行了常 量盲化):

$ echo 1 > /proc/sys/net/core/bpf_jit_harden

ffffffffa034f1e5 + : [...] 39: mov $0xe1192563,%r10d 3f: xor $0x4989b5f3,%r10d 46: mov %r10d,%eax 49: mov $0xb8296d93,%r10d 4f: xor $0x10b9fd03,%r10d 56: mov %r10d,%eax 59: mov $0x8c381146,%r10d 5f: xor $0x24c7200e,%r10d 66: mov %r10d,%eax 69: mov $0xeb2a830e,%r10d 6f: xor $0x43ba02ba,%r10d 76: mov %r10d,%eax 79: mov $0xd9730af,%r10d 7f: xor $0xa5073b1f,%r10d 86: mov %r10d,%eax 89: mov $0x9a45662b,%r10d 8f: xor $0x325586ea,%r10d 96: mov %r10d,%eax [...]

兩個程式在語義上是一樣的,但在第二種方式中,原來的立即數在反彙編之後的程式中不再 可見。

同時,加固還會禁止任何 JIT 核心符號(kallsyms)暴露給特權使用者, JIT 映象地址不再出現在 /proc/kallsyms 中。

另外,Linux 核心提供了 CONFIG_BPF_JIT_ALWAYS_ON 選項,開啟這個開關後 BPF 解釋 器將會從核心中完全移除,永遠啟用 JIT 編譯器。此功能部分是為防禦 Spectre v2 攻擊開發的,如果應用在一個基於虛擬機器的環境,客戶機核心(guest kernel)將不會複用 核心的 BPF 直譯器,因此可以避免某些相關的攻擊。如果是基於容器的環境,這個配置是 可選的,如果 JIT 功能打開了,直譯器仍然可能會在編譯時被去掉,以降低核心的複雜度 。因此,對於主流架構(例如 x86_64 和 arm64)上的 JIT 通常都建議開啟這個開關。

另外,核心提供了一個配置項 /proc/sys/kernel/unprivileged_bpf_disabled 來 禁止非特權使用者使用 bpf(2) 系統呼叫,可以通過 sysctl 命令修改。 比較特殊的一點是,這個配置項特意設計為“一次性開關”(one-time kill switch), 這意味著一旦將它設為 1,就沒有辦法再改為 0 了,除非重啟核心。一旦設定為 1 之後,只有初始名稱空間中有 CAP_SYS_ADMIN 特權的程序才可以呼叫 bpf(2) 系統呼叫 。 Cilium 啟動後也會將這個配置項設為 1:

$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled

1.9 Offloads

BPF 網路程式,尤其是 tc 和 XDP BPF 程式在核心中都有一個 offload 到硬體的介面,這 樣就可以直接在網絡卡上執行 BPF 程式。

當前,Netronome 公司的 nfp 驅動支援通過 JIT 編譯器 offload BPF,它會將 BPF 指令 翻譯成網絡卡實現的指令集。另外,它還支援將 BPF maps offload 到網絡卡,因此 offloaded BPF 程式可以執行 map 查詢、更新和刪除操作。

2 工具鏈

本節介紹 BPF 相關的使用者態工具、內省設施(introspection facilities)和核心控制選項。 注意,圍繞 BPF 的工具和基礎設施還在快速發展當中,因此本文提供的內容可能只覆 蓋了其中一部分。

2.1 開發環境

Fedora

Fedora 25+:

$ sudo dnf install -y git gcc ncurses-devel elfutils-libelf-devel bc

openssl-devel libcap-devel clang llvm graphviz bison flex glibc-static

Ubuntu

Ubuntu 17.04+:

$ sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev

clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex

graphviz

openSUSE Tumbleweed

openSUSE Tumbleweed 和 openSUSE Leap 15.0+:

$ sudo zypper install -y git gcc ncurses-devel libelf-devel bc libopenssl-devel

libcap-devel clang llvm graphviz bison flex glibc-devel-static

編譯 Linux 核心

新的 BPF 特性都是在核心 net-next 原始碼樹中開發的。獲取 net-netxt 原始碼樹:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git

如果不關心提交歷史,可以指定 --depth 1,這會下載當前最新的版本,節省大量時間和 磁碟空間。

最新的 BPF fix 都在 net 原始碼樹:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git

網路已經有大量關於如何編譯 Linux 核心的教程,推薦 Kernel Newbies website。

要執行 BPF,需要確保生成的 .config 檔案包含下列配置(Cilium 也需要這些配置):

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_SCH_INGRESS=m
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y
CONFIG_BPF_JIT=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_TEST_BPF=m

以上的某些配置項是無法通過 make menuconfig 修改的。例如, CONFIG_HAVE_EBPF_JIT 是根據當前架構是否支援 eBPF JIT 自動設定的。在本節中, CONFIG_HAVE_EBPF_JIT 是可選但強烈推薦的配置。沒有 eBPF JIT 編譯器的架構只能 fallback 到核心直譯器,執行效率會大大降低。

驗證編譯好的核心

用編譯好的核心啟動之後,進入 BPF 測試目錄來驗證 BPF 的功能:

$ cd tools/testing/selftests/bpf/ $ make $ sudo ./test_verifier

正常的話,會列印如下類似的結果:

Summary: 847 PASSED, 0 SKIPPED, 0 FAILED

注意:For kernel releases 4.16+ the BPF selftest has a dependency on LLVM 6.0+ caused by the BPF function calls which do not need to be inlined anymore. See section bpf_to_bpf_calls or the cover letter mail from the kernel patch (https://lwn.net/Articles/741773/) for more information. Not every BPF program has a dependency on LLVM 6.0+ if it does not use this new feature. If your distribution does not provide LLVM 6.0+ you may compile it by following the instruction in the tooling_llvm section.

執行所有 BPF selftests:

$ sudo make run_tests

編譯 iproute2

與 net (fixes only) 和 net-next (new features) 核心樹類似, iproute2 原始碼樹有兩個分支:master 和 net-next。

master 分支基於 net 核心原始碼樹, net-next 分支基於 net-next 核心樹。這樣,標頭檔案的改動就會同步到 iproute2 原始碼樹。

下載 iproute2 master 分支程式碼:

$ git clone https://git.kernel.org/pub/scm/network/iproute2/iproute2.git

下週 net-next 分支程式碼:

$ git clone -b net-next https://git.kernel.org/pub/scm/network/iproute2/iproute2.git

編譯和安裝:

$ cd iproute2/ $ ./configure --prefix=/usr TC schedulers ATM no

libc has setns: yes SELinux support: yes ELF support: yes libmnl support: no Berkeley DB: no

docs: latex: no WARNING: no docs can be built from LaTeX files sgml2html: no WARNING: no HTML docs can be built from SGML $ make [...] $ sudo make install

確保 configure 指令碼打印出了 ELF support: yes,這樣 iproute2 才能處理 LLVM BPF 後端產生的 ELF 檔案。

編譯 bpftool

bpftool 對除錯和檢視(introspect)BPF 程式及 BPF map 非常有用。它是核心原始碼樹的 一部分,程式碼位於 tools/bpf/bpftool/。

Make sure to have cloned either the net or net-next kernel tree as described earlier. In order to build and install bpftool, the following steps are required:

$ cd /tools/bpf/bpftool/ $ make Auto-detecting system features: ... libbfd: [ on ] ... disassembler-four-args: [ OFF ]

CC xlated_dumper.o CC prog.o CC common.o CC cgroup.o CC main.o CC json_writer.o CC cfg.o CC map.o CC jit_disasm.o CC disasm.o make[1]: Entering directory '/home/foo/trees/net/tools/lib/bpf'

Auto-detecting system features: ... libelf: [ on ] ... bpf: [ on ]

CC libbpf.o CC bpf.o CC nlattr.o LD libbpf-in.o LINK libbpf.a make[1]: Leaving directory '/home/foo/trees/bpf/tools/lib/bpf' LINK bpftool $ sudo make install

2.2 LLVM

寫作本文時,LLVM 是唯一提供 BPF 後端的編譯器套件。gcc 目前還不支援。

主流的發行版在對 LLVM 打包的時候就預設啟用了 BPF 後端,因此,在大部分發行版上安 裝 clang 和 llvm 就可以將 C 程式碼編譯為 BPF 物件檔案了。

典型的工作流:

用 C 編寫 BPF 程式 用 LLVM 將 C 程式編譯成物件檔案(ELF) 使用者空間 BPF ELF 載入器(例如 iproute2)解析物件檔案 載入器通過 bpf() 系統呼叫將解析後的物件檔案注入核心 核心驗證 BPF 指令,然後對其執行即時編譯(JIT),返回程式的一個新檔案描述符 利用檔案描述符 attach 到核心子系統(例如網路子系統)

某些子系統還支援將 BPF 程式 offload 到硬體(例如網絡卡)。

2.2.1 BPF Target(目標平臺)

檢視 LLVM 支援的 BPF target:

$ llc --version LLVM (http://llvm.org/): LLVM version 3.8.1 Optimized build. Default target: x86_64-unknown-linux-gnu Host CPU: skylake

Registered Targets: [...] bpf - BPF (host endian) bpfeb - BPF (big endian) bpfel - BPF (little endian) [...]

預設情況下,bpf target 使用編譯時所在的 CPU 的大小端格式,即,如果 CPU 是小 端,BPF 程式就會用小端表示;如果 CPU 是大端,BPF 程式就是大端。這也和 BPF 的運 行時行為相匹配,這樣的行為比較通用,而且大小端格式一致可以避免一些因為格式導致的 架構劣勢。

BPF 程式可以在大端節點上編譯,在小端節點上執行,或者相反,因此對於交叉編譯, 引入了兩個新目標 bpfeb 和 bpfel。注意前端也需要以相應的大小端方式執行。

在不存在大小端混用的場景下,建議使用 bpf target。例如,在 x86_64 平臺上(小端 ),指定 bpf 和 bpfel 會產生相同的結果,因此觸發編譯的指令碼不需要感知到大小端 。

下面是一個最小的完整 XDP 程式,實現丟棄包的功能(xdp-example.c):

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

__section("prog") int xdp_drop(struct xdp_md *ctx) { return XDP_DROP; }

char __license[] __section("license") = "GPL";

用下面的命令編譯並載入到核心:

$ clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o $ ip link set dev em1 xdp obj xdp-example.o

以上命令將一個 XDP 程式 attach 到一個網路裝置,需要是 Linux 4.11 核心中支援 XDP 的裝置,或者 4.12+ 版本的核心。

LLVM(>= 3.9) 使用正式的 BPF 機器值(machine value),即 EM_BPF(十進位制 247 ,十六進位制 0xf7),來生成物件檔案。在這個例子中,程式是用 bpf target 在 x86_64 平臺上編譯的,因此下面顯示的大小端標識是 LSB (和 MSB 相反):

$ file xdp-example.o xdp-example.o: ELF 64-bit LSB relocatable, unknown arch 0xf7 version 1 (SYSV), not stripped

readelf -a xdp-example.o 能夠列印 ELF 檔案的更詳細資訊,有時在檢查生成的 section header、relocation entries 和符號表時會比較有用。

2.2.2 除錯資訊(DWARF、BTF)

若是要 debug,clang 可以生成下面這樣的彙編器輸出:

$ clang -O2 -S -Wall -target bpf -c xdp-example.c -o xdp-example.S $ cat xdp-example.S .text .section prog,"ax",@progbits .globl xdp_drop .p2align 3 xdp_drop: # @xdp_drop

BB#0:

r0 = 1
exit

.section    license,"aw",@progbits
.globl    __license               # @__license

__license: .asciz "GPL"

LLVM 從 6.0 開始,還包括了彙編解析器(assembler parser)的支援。可以直接使用 BPF 彙編指令程式設計,然後使用 llvm-mc 將其彙編成一個目標檔案。 例如,可以將前面的 xdp-example.S 重新變回物件檔案:

$ llvm-mc -triple bpf -filetype=obj -o xdp-example.o xdp-example.S

DWARF 格式和 llvm-objdump

另外,較新版本(>= 4.0)的 LLVM 還可以將除錯資訊以 dwarf 格式儲存到物件 檔案中。只要在編譯時加上 -g:

$ clang -O2 -g -Wall -target bpf -c xdp-example.c -o xdp-example.o $ llvm-objdump -S --no-show-raw-insn xdp-example.o

xdp-example.o: file format ELF64-BPF

Disassembly of section prog: xdp_drop: ; { 0: r0 = 1 ; return XDP_DROP; 1: exit

llvm-objdump 工具能夠用編譯的 C 原始碼對彙編輸出添加註解(annotate )。這裡 的例子過於簡單,沒有幾行 C 程式碼;但注意上面的 0 和 1 行號,這些行號直接對 應到核心的校驗器日誌(見下面的輸出)。這意味著假如 BPF 程式被校驗器拒絕了, llvm-objdump能幫助你將 BPF 指令關聯到原始的 C 程式碼,對於分析來說非常有用。

$ ip link set dev em1 xdp obj xdp-example.o verb

Prog section 'prog' loaded (5)!

  • Type: 6
  • Instructions: 2 (0 over limit)
  • License: GPL

Verifier analysis:

0: (b7) r0 = 1 1: (95) exit processed 2 insns

從上面的校驗器分析可以看出,llvm-objdump 的輸出和核心中的 BPF 彙編是相同的。

去掉 -no-show-raw-insn 選項還可以以十六進位制格式在每行彙編程式碼前面列印原始的 struct bpf_insn:

$ llvm-objdump -S xdp-example.o

xdp-example.o: file format ELF64-BPF

Disassembly of section prog: xdp_drop: ; { 0: b7 00 00 00 01 00 00 00 r0 = 1 ; return foo(); 1: 95 00 00 00 00 00 00 00 exit

LLVM IR

對於 LLVM IR 除錯,BPF 的編譯過程可以分為兩個步驟:首先生成一個二進位制 LLVM IR 臨 時檔案 xdp-example.bc,然後將其傳遞給 llc:

$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc $ llc xdp-example.bc -march=bpf -filetype=obj -o xdp-example.o

生成的 LLVM IR 還可以 dump 成人類可讀的格式:

$ clang -O2 -Wall -emit-llvm -S -c xdp-example.c -o -

BTF

LLVM 能將除錯資訊(例如對程式使用的資料的描述)attach 到 BPF 物件檔案。預設情況 下使用 DWARF 格式。

BPF 使用了一個高度簡化的版本,稱為 BTF (BPF Type Format)。生成的 DWARF 可以 轉換成 BTF 格式,然後通過 BPF 物件載入器載入到核心。核心驗證 BTF 資料的正確性, 並跟蹤 BTF 資料中包含的資料型別。

這樣的話,就可以用鍵和值對 BPF map 打一些註解(annotation)儲存到 BTF 資料中,這 樣下次 dump map 時,除了 map 內的資料外還會打印出相關的型別資訊。這對內省( introspection)、除錯和格式良好的列印都很有幫助。注意,BTF 是一種通用的除錯資料 格式,因此任何從 DWARF 轉換成的 BTF 資料都可以被載入(例如,核心 vmlinux DWARF 數 據可以轉換成 BTF 然後載入)。後者對於未來 BPF 的跟蹤尤其有用。

將 DWARF 格式的除錯資訊轉換成 BTF 格式需要用到 elfutils (>= 0.173) 工具。 如果沒有這個工具,那需要在 llc 編譯時開啟 -mattr=dwarfris 選項:

$ llc -march=bpf -mattr=help |& grep dwarfris dwarfris - Disable MCAsmInfo DwarfUsesRelocationsAcrossSections. [...]

使用 -mattr=dwarfris 是因為 dwarfris (dwarf relocation in section) 選項禁 用了 DWARF 和 ELF 的符號表之間的 DWARF cross-section 重定位,因為 libdw 不支援 BPF 重定位。不開啟這個選項的話,pahole 這類工具將無法正確地從物件中 dump 結構。

elfutils (>= 0.173) 實現了合適的 BPF 重定位,因此沒有開啟 -mattr=dwarfris 選 項也能正常工作。它可以從物件檔案中的 DWARF 或 BTF 資訊 dump 結構。目前 pahole 使用 LLVM 生成的 DWARF 資訊,但未來它可能會使用 BTF 資訊。

pahole

將 DWARF 轉換成 BTF 格式需要使用較新的 pahole 版本(>= 1.12),然後指定 -J 選項。 檢查所用的 pahole 版本是否支援 BTF(注意,pahole 會用到 llvm-objcopy,因此 也要檢查後者是否已安裝):

$ pahole --help | grep BTF -J, --btf_encode Encode as BTF

生成除錯資訊還需要前端的支援,在 clang 編譯時指定 -g 選項,生成原始碼級別的調 試資訊。注意,不管 llc 是否指定了 dwarfris 選項,-g 都是需要指定的。生成目 標檔案的完整示例:

$ clang -O2 -g -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc $ llc xdp-example.bc -march=bpf -mattr=dwarfris -filetype=obj -o xdp-example.o

或者,只使用 clang 這一個工具來編譯帶除錯資訊的 BPF 程式(同樣,如果有合適的 elfutils 版本,dwarfris 選項可以省略):

$ clang -target bpf -O2 -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o

基於 DWARF 資訊 dump BPF 程式的資料結構:

$ pahole xdp-example.o struct xdp_md { __u32 data; /* 0 4 / __u32 data_end; / 4 4 / __u32 data_meta; / 8 4 */

/* size: 12, cachelines: 1, members: 3 */
    /* last cacheline: 12 bytes */

};

在物件檔案中,DWARF 資料將仍然伴隨著新加入的 BTF 資料一起保留。完整的 clang 和 pahole 示例:

$ clang -target bpf -O2 -Wall -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o $ pahole -J xdp-example.o

readelf

通過 readelf 工具可以看到多了一個 .BTF section:

$ readelf -a xdp-example.o [...] [18] .BTF PROGBITS 0000000000000000 00000671 [...]

BPF 載入器(例如 iproute2)會檢測和載入 BTF section,因此給 BPF map 註釋( annotate)型別資訊。

2.2.3 BPF 指令集

LLVM 預設用 BPF 基礎指令集(base instruction set)來生成程式碼,以確保這些生成的對 象檔案也能夠被稍老的 LTS 核心(例如 4.9+)載入。

但是,LLVM 提供了一個 BPF 後端選項 -mcpu,可以指定不同版本的 BPF 指令集,即 BPF 基礎指令集之上的指令集擴充套件(instruction set extensions),以生成更高效和體積 更小的程式碼。

可用的 -mcpu 型別:

$ llc -march bpf -mcpu=help Available CPUs for this target:

generic - Select the generic processor. probe - Select the probe processor. v1 - Select the v1 processor. v2 - Select the v2 processor. [...]

generic processor 是預設的 processor,也是 BPF v1 基礎指令集。 v1 和 v2 processor 通常在交叉編譯 BPF 的環境下比較有用,即編譯 BPF 的平臺 和最終執行 BPF 的平臺不同(因此 BPF 核心特性可能也會不同)。

推薦使用 -mcpu=probe ,這也是 Cilium 內部在使用的型別。使用這種型別時, LLVM BPF 後端會向核心詢問可用的 BPF 指令集擴充套件,如果找到可用的,就會使用相應的指 令集來編譯 BPF 程式。

使用 llc 和 -mcpu=probe 的完整示例:

$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc $ llc xdp-example.bc -march=bpf -mcpu=probe -filetype=obj -o xdp-example.o

2.2.4 指令和暫存器位寬(64/32 位)

通常來說,LLVM IR 生成是架構無關的。但使用 clang 編譯時是否指定 -target bpf 是有幾點小區別的,取決於不同的平臺架構(x86_64、arm64 或其他),-target 的 預設配置可能不同。

引用核心文件 Documentation/bpf/bpf_devel_QA.txt:

BPF 程式可以巢狀 include 標頭檔案,只要標頭檔案中都是檔案作用域的內聯彙編程式碼(

file scope inline assembly codes)。大部分情況下預設 target 都可以處理這種情況, 但如果 BPF 後端彙編器無法理解這些彙編程式碼,那 bpf target 會失敗。

如果編譯時沒有指定 -g,那額外的 elf sections(例如 .eh_frame

和 .rela.eh_frame)可能會以預設 target 格式出現在物件檔案中,但不會是 bpf target。

預設 target 可能會將一個 C switch 宣告轉換為一個 switch 表的查詢和跳轉操作。

由於 switch 表位於全域性的只讀 section,因此 BPF 程式的載入會失敗。 bpf target 不支援 switch 表優化。clang 的 -fno-jump-tables 選項可以禁止生成 switch 表。

如果 clang 指定了 -target bpf,那指標或 long/unsigned long 型別將永遠

是 64 位的,不管底層的 clang 可執行檔案或預設的 target(或核心)是否是 32 位。但如果使用的是 native clang target,那 clang 就會根據底層的架構約定( architecture’s conventions)來編譯這些型別,這意味著對於 32 位的架構,BPF 上下 文中的指標或 long/unsigned long 型別會是 32 位的,但此時的 BPF LLVM 後端仍 然工作在 64 位模式。

native target 主要用於跟蹤(tracing)核心中的 struct pt_regs,這個結構體對 CPU 暫存器進行對映,或者是跟蹤其他一些能感知 CPU 暫存器位寬(CPU’s register width)的核心結構體。除此之外的其他場景,例如網路場景,都建議使用 clang -target bpf。

另外,LLVM 從 7.0 開始支援 32 位子暫存器和 BPF ALU32 指令。另外,新加入了一個代 碼生成屬性 alu32。當指定這個引數時,LLVM 會嘗試儘可能地使用 32 位子暫存器,例 如當涉及到 32 位操作時。32 位子暫存器及相應的 ALU 指令組成了 ALU32 指令。例如, 對於下面的示例程式碼:

$ cat 32-bit-example.c void cal(unsigned int *a, unsigned int *b, unsigned int *c) { unsigned int sum = *a + *b; *c = sum; }

使用預設的程式碼生成選項,產生的彙編程式碼如下:

$ clang -target bpf -emit-llvm -S 32-bit-example.c $ llc -march=bpf 32-bit-example.ll $ cat 32-bit-example.s cal: r1 = *(u32 *)(r1 + 0) r2 = *(u32 *)(r2 + 0) r2 += r1 *(u32 *)(r3 + 0) = r2 exit

可以看到預設使用的是 r 系列暫存器,這些都是 64 位暫存器,這意味著其中的加法都 是 64 位加法。現在,如果指定 -mattr=+alu32 強制要求使用 32 位,生成的彙編程式碼 如下:

$ llc -march=bpf -mattr=+alu32 32-bit-example.ll $ cat 32-bit-example.s cal: w1 = *(u32 *)(r1 + 0) w2 = *(u32 *)(r2 + 0) w2 += w1 *(u32 *)(r3 + 0) = w2 exit

可以看到這次使用的是 w 系列暫存器,這些是 32 位子暫存器。

使用 32 位子暫存器可能會減小(最終生成的程式碼中)型別擴充套件指令(type extension instruction)的數量。另外,它對 32 位架構的核心 eBPF JIT 編譯器也有所幫助,因為 原來這些編譯器都是用 32 位模擬 64 位 eBPF 暫存器,其中使用了很多 32 位指令來操作 高 32 bit。即使寫 32 位子暫存器的操作仍然需要對高 32 位清零,但只要確保從 32 位 子暫存器的讀操作只會讀取低 32 位,那隻要 JIT 編譯器已經知道某個暫存器的定義只有 子暫存器讀操作,那對高 32 位的操作指令就可以避免。

2.2.5 C BPF 程式碼注意事項

用 C 語言編寫 BPF 程式不同於用 C 語言做應用開發,有一些陷阱需要注意。本節列出了 二者的一些不同之處。

  1. 所有函式都需要內聯(inlined)、沒有函式呼叫(對於老版本 LLVM)或共享庫呼叫

BPF 不支援共享庫(Shared libraries)。但是,可以將常規的庫程式碼(library code)放 到標頭檔案中,然後在主程式中 include 這些標頭檔案,例如 Cilium 就大量使用了這種方式 (可以檢視 bpf/lib/ 資料夾)。另外,也可以 include 其他的一些標頭檔案,例如核心 或其他庫中的標頭檔案,複用其中的靜態行內函數(static inline functions)或巨集/定義( macros / definitions)。

核心 4.16+ 和 LLVM 6.0+ 之後已經支援 BPF-to-BPF 函式呼叫。對於任意給定的程式片段 ,在此之前的版本只能將全部程式碼編譯和內聯成一個扁平的 BPF 指令序列(a flat sequence of BPF instructions)。在這種情況下,最佳實踐就是為每個庫函式都使用一個 像 __inline 一樣的註解(annotation ),下面的例子中會看到。推薦使用 always_inline,因為編譯器可能會對只註解為 inline 的長函式仍然做 uninline 操 作。

如果是後者,LLVM 會在 ELF 檔案中生成一個重定位項(relocation entry),BPF ELF 載入器(例如 iproute2)無法解析這個重定位項,因此會產生一條錯誤,因為對載入器 來說只有 BPF maps 是合法的、能夠處理的重定位項。

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

#ifndef __inline

define __inline \

inline attribute ((always_inline)) #endif

static __inline int foo(void) { return XDP_DROP; }

__section("prog") int xdp_drop(struct xdp_md *ctx) { return foo(); }

char __license[] __section("license") = "GPL";

  1. 多個程式可以放在同一 C 檔案中的不同 section

BPF C 程式大量使用 section annotations。一個 C 檔案典型情況下會分為 3 個或更 多個 section。BPF ELF 載入器利用這些名字來提取和準備相關的資訊,以通過 bpf()系 統呼叫載入程式和 maps。例如,查詢建立 map 所需的元資料和 BPF 程式的 license 資訊 時,iproute2 會分別使用 maps 和 license 作為預設的 section 名字。注意在程式 建立時 license section 也會載入到核心,如果程式使用的是相容 GPL 的協議,這些信 息就可以啟用那些 GPL-only 的輔助函式,例如 bpf_ktime_get_ns() 和 bpf_probe_read() 。

其餘的 section 名字都是和特定的 BPF 程式程式碼相關的,例如,下面經過修改之後的程式碼 包含兩個程式 section:ingress 和 egress。這個非常簡單的示例展示了不同 section (這裡是 ingress 和 egress)之間可以共享 BPF map 和常規的靜態內聯輔助函式( 例如 account_data())。

示例程式

這裡將原來的 xdp-example.c 修改為 tc-example.c,然後用 tc 命令載入,attach 到 一個 netdevice 的 ingress 或 egress hook。該程式對傳輸的位元組進行計數,儲存在一 個名為 acc_map 的 BPF map 中,這個 map 有兩個槽(slot),分別用於 ingress hook 和 egress hook 的流量統計。

#include <linux/bpf.h> #include <linux/pkt_cls.h> #include <stdint.h> #include <iproute2/bpf_elf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

#ifndef __inline

define __inline \

inline attribute ((always_inline)) #endif

#ifndef lock_xadd

define lock_xadd(ptr, val) \

((void)__sync_fetch_and_add(ptr, val)) #endif

#ifndef BPF_FUNC

define BPF_FUNC(NAME, ...) \

(*NAME)( VA_ARGS ) = (void *)BPF_FUNC_##NAME #endif

static void *BPF_FUNC(map_lookup_elem, void *map, const void *key);

struct bpf_elf_map acc_map __section("maps") = { .type = BPF_MAP_TYPE_ARRAY, .size_key = sizeof(uint32_t), .size_value = sizeof(uint32_t), .pinning = PIN_GLOBAL_NS, .max_elem = 2, };

static __inline int account_data(struct __sk_buff *skb, uint32_t dir) { uint32_t *bytes;

bytes = map_lookup_elem(&acc_map, &dir);
if (bytes)
        lock_xadd(bytes, skb->len);

return TC_ACT_OK;

}

__section("ingress") int tc_ingress(struct __sk_buff *skb) { return account_data(skb, 0); }

__section("egress") int tc_egress(struct __sk_buff *skb) { return account_data(skb, 1); }

char __license[] __section("license") = "GPL";

其他程式說明

這個例子還展示了其他一些很有用的東西,在開發過程中要注意。

首先,include 了核心標頭檔案、標準 C 標頭檔案和一個特定的 iproute2 標頭檔案 iproute2/bpf_elf.h,後者定義了struct bpf_elf_map。iproute2 有一個通用的 BPF ELF 載入器,因此 struct bpf_elf_map的定義對於 XDP 和 tc 型別的程式是完全一樣的 。

其次,程式中每條 struct bpf_elf_map 記錄(entry)定義一個 map,這個記錄包含了生成一 個(ingress 和 egress 程式需要用到的)map 所需的全部資訊(例如 key/value 大 小)。這個結構體的定義必須放在 maps section,這樣載入器才能找到它。可以用這個 結構體宣告很多名字不同的變數,但這些宣告前面必須加上 __section("maps") 註解。

結構體 struct bpf_elf_map 是特定於 iproute2 的。不同的 BPF ELF 載入器有不同 的格式,例如,核心原始碼樹中的 libbpf(主要是 perf 在用)就有一個不同的規範 (結構體定義)。iproute2 保證 struct bpf_elf_map 的後向相容性。Cilium 採用的 是 iproute2 模型。

另外,這個例子還展示了 BPF 輔助函式是如何對映到 C 程式碼以及如何被使用的。這裡首先定義了 一個巨集 BPF_FUNC,接受一個函式名 NAME 以及其他的任意引數。然後用這個巨集聲明瞭一 個 NAME 為 map_lookup_elem 的函式,經過巨集展開後會變成 BPF_FUNC_map_lookup_elem 列舉值,後者以輔助函式的形式定義在 uapi/linux/bpf.h 。當隨後這個程式被載入到核心時,校驗器會檢查傳入的引數是否是期望的型別,如果是, 就將輔助函式呼叫重新指向(re-points)某個真正的函式呼叫。另外, map_lookup_elem() 還展示了 map 是如何傳遞給 BPF 輔助函式的。這裡,maps section 中的 &acc_map 作為第一個引數傳遞給 map_lookup_elem()。

由於程式中定義的陣列 map (array map)是全域性的,因此計數時需要使用原子操作,這裡 是使用了 lock_xadd()。LLVM 將 __sync_fetch_and_add() 作為一個內建函式對映到 BPF 原子加指令,即 BPF_STX | BPF_XADD | BPF_W(for word sizes)。

另外,struct bpf_elf_map 中的 .pinning 欄位初始化為 PIN_GLOBAL_NS,這意味 著 tc 會將這個 map 作為一個節點(node)釘(pin)到 BPF 偽檔案系統。預設情況下, 這個變數 acc_map 將被釘到 /sys/fs/bpf/tc/globals/acc_map。

如果指定的是 PIN_GLOBAL_NS,那 map 會被放到 /sys/fs/bpf/tc/globals/。 globals 是一個跨物件檔案的全域性名稱空間。 如果指定的是 PIN_OBJECT_NS,tc 將會為物件檔案建立一個它的本地目錄(local to the object file)。例如,只要指定了 PIN_OBJECT_NS,不同的 C 檔案都可以像上 面一樣定義各自的 acc_map。在這種情況下,這個 map 會在不同 BPF 程式之間共享。 PIN_NONE 表示 map 不會作為節點(node)釘(pin)到 BPF 檔案系統,因此當 tc 退 出時這個 map 就無法從使用者空間訪問了。同時,這還意味著獨立的 tc 命令會創建出獨 立的 map 例項,因此後執行的 tc 命令無法用這個 map 名字找到之前被釘住的 map。 在路徑 /sys/fs/bpf/tc/globals/acc_map 中,map 名是 acc_map。

因此,在載入 ingress 程式時,tc 會先查詢這個 map 在 BPF 檔案系統中是否存在,不 存在就建立一個。建立成功後,map 會被釘(pin)到 BPF 檔案系統,因此當 egress 程 序通過 tc 載入之後,它就會發現這個 map 存在了,接下來會複用這個 map 而不是再建立 一個新的。在 map 存在的情況下,載入器還會確保 map 的屬性(properties)是匹配的, 例如 key/value 大小等等。

就像 tc 可以從同一 map 獲取資料一樣,第三方應用也可以用 bpf 系統呼叫中的 BPF_OBJ_GET 命令建立一個指向某個 map 例項的新檔案描述符,然後用這個描述 符來檢視/更新/刪除 map 中的資料。

通過 clang 編譯和 iproute2 載入:

$ clang -O2 -Wall -target bpf -c tc-example.c -o tc-example.o

$ tc qdisc add dev em1 clsact $ tc filter add dev em1 ingress bpf da obj tc-example.o sec ingress $ tc filter add dev em1 egress bpf da obj tc-example.o sec egress

$ tc filter show dev em1 ingress filter protocol all pref 49152 bpf filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

$ tc filter show dev em1 egress filter protocol all pref 49152 bpf filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[egress] direct-action id 2 tag b2fd5adc0f262714

$ mount | grep bpf sysfs on /sys/fs/bpf type sysfs (rw,nosuid,nodev,noexec,relatime,seclabel) bpf on /sys/fs/bpf type bpf (rw,relatime,mode=0700)

$ tree /sys/fs/bpf/ /sys/fs/bpf/ +-- ip -> /sys/fs/bpf/tc/ +-- tc |   +-- globals |   +-- acc_map +-- xdp -> /sys/fs/bpf/tc/

4 directories, 1 file

以上步驟指向完成後,當包經過 em 裝置時,BPF map 中的計數器就會遞增。

  1. 不允許全域性變數

出於第 1 條中提到的原因(只支援 BPF maps 重定位,譯者注),BPF 不能使用全域性變數 ,而常規 C 程式中是可以的。

但是,我們有間接的方式實現全域性變數的效果:BPF 程式可以使用一個 BPF_MAP_TYPE_PERCPU_ARRAY 型別的、只有一個槽(slot)的、可以存放任意型別資料( arbitrary value size)的 BPF map。這可以實現全域性變數的效果原因是,BPF 程式在執行期間不會被核心搶佔,因此可以用單個 map entry 作為一個 scratch buffer 使用,儲存臨時資料,例如擴充套件 BPF 棧的限制(512 位元組)。這種方式在尾呼叫中也是可 以工作的,因為尾呼叫執行期間也不會被搶佔。

另外,如果要在不同次 BPF 程式執行之間保持狀態,使用常規的 BPF map 就可以了。

  1. 不支援常量字串或陣列(const strings or arrays)

BPF C 程式中不允許定義 const 字串或其他陣列,原因和第 1 點及第 3 點一樣,即 ,ELF 檔案中生成的重定位項(relocation entries)會被載入器拒絕,因為不符合加 載器的 ABI(載入器也無法修復這些重定位項,因為這需要對已經編譯好的 BPF 序列進行 大範圍的重寫)。

將來 LLVM 可能會檢測這種情況,提前將錯誤拋給使用者。現在可以用下面的輔助函式來作為 短期解決方式(work around):

static void BPF_FUNC(trace_printk, const char *fmt, int fmt_size, ...);

#ifndef printk

define printk(fmt, ...) \

({                                                         \
    char ____fmt[] = fmt;                                  \
    trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
})

#endif

有了上面的定義,程式就可以自然地使用這個巨集,例如 printk("skb len:%u\n", skb->len);。 輸出會寫到 trace pipe,用 tc exec bpf dbg 命令可以獲取這些列印的訊息。

不過,使用 trace_printk() 輔助函式也有一些不足,因此不建議在生產環境使用。每次 呼叫這個輔助函式時,常量字串(例如 "skb len:%u\n")都需要載入到 BPF 棧,但這 個輔助函式最多隻能接受 5 個引數,因此使用這個函式輸出資訊時只能傳遞三個引數。

因此,雖然這個輔助函式對快速除錯很有用,但(對於網路程式)還是推薦使用 skb_event_output() 或 xdp_event_output() 輔助函式。這兩個函式接受從 BPF 程式 傳遞自定義的結構體型別引數,然後將引數以及可選的包資料(packet sample)放到 perf event ring buffer。例如,Cilium monitor 利用這些輔助函式實現了一個除錯框架,以及 在發現違反網路策略時發出通知等功能。這些函式通過一個無鎖的、記憶體對映的、 per-CPU 的 perf ring buffer 傳遞資料,因此要遠快於 trace_printk()。

  1. 使用 LLVM 內建的函式做記憶體操作

因為 BPF 程式除了呼叫 BPF 輔助函式之外無法執行任何函式呼叫,因此常規的庫程式碼必須 實現為行內函數。另外,LLVM 也提供了一些可以用於特定大小(這裡是 n)的內建函式 ,這些函式永遠都會被內聯:

#ifndef memset

define memset(dest, chr, n) __builtin_memset((dest), (chr), (n))

#endif

#ifndef memcpy

define memcpy(dest, src, n) __builtin_memcpy((dest), (src), (n))

#endif

#ifndef memmove

define memmove(dest, src, n) __builtin_memmove((dest), (src), (n))

#endif

LLVM 後端中的某個問題會導致內建的 memcmp() 有某些邊界場景下無法內聯,因此在這 個問題解決之前不推薦使用這個函式。

  1. (目前還)不支援迴圈

核心中的 BPF 校驗器除了對其他的控制流進行圖驗證(graph validation)之外,還會對 所有程式路徑執行深度優先搜尋(depth first search),確保其中不存在迴圈。這樣做的 目的是確保程式永遠會結束。

但可以使用 #pragma unroll 指令實現常量的、不超過一定上限的迴圈。下面是一個例子 :

#pragma unroll for (i = 0; i < IPV6_MAX_HEADERS; i++) { switch (nh) { case NEXTHDR_NONE: return DROP_INVALID_EXTHDR; case NEXTHDR_FRAGMENT: return DROP_FRAG_NOSUPPORT; case NEXTHDR_HOP: case NEXTHDR_ROUTING: case NEXTHDR_AUTH: case NEXTHDR_DEST: if (skb_load_bytes(skb, l3_off + len, &opthdr, sizeof(opthdr)) < 0) return DROP_INVALID;

nh = opthdr.nexthdr;
        if (nh == NEXTHDR_AUTH)
            len += ipv6_authlen(&opthdr);
        else
            len += ipv6_optlen(&opthdr);
        break;
    default:
        *nexthdr = nh;
        return len;
    }
}

另外一種實現迴圈的方式是:用一個 BPF_MAP_TYPE_PERCPU_ARRAY map 作為本地 scratch space(儲存空間),然後用尾呼叫的方式呼叫函式自身。雖然這種方式更加動態,但目前 最大隻支援 34 層(原始程式,外加 33 次尾呼叫)巢狀呼叫。

將來 BPF 可能會提供一些更加原生、但有一定限制的迴圈。

  1. 尾呼叫的用途

尾呼叫能夠從一個程式調到另一個程式,提供了在執行時(runtime)原子地改變程式行 為的靈活性。為了選擇要跳轉到哪個程式,尾呼叫使用了 程式陣列 map( BPF_MAP_TYPE_PROG_ARRAY),將 map 及其索引(index)傳遞給將要跳轉到的程式。跳 轉動作一旦完成,就沒有辦法返回到原來的程式;但如果給定的 map 索引中沒有程式(無 法跳轉),執行會繼續在原來的程式中執行。

例如,可以用尾呼叫實現解析器的不同階段,可以在執行時(runtime)更新這些階段的新 解析特性。

尾呼叫的另一個用處是事件通知,例如,Cilium 可以在執行時(runtime)開啟或關閉丟棄 包的通知(packet drop notifications),其中對 skb_event_output() 的呼叫就是發 生在被尾呼叫的程式中。因此,在常規情況下,執行的永遠是從上到下的路徑( fall-through path),當某個程式被加入到相關的 map 索引之後,程式就會解析元資料, 觸發向用戶空間守護程序(user space daemon)傳送事件通知。

程式陣列 map 非常靈活, map 中每個索引對應的程式可以實現各自的動作(actions)。 例如,attach 到 tc 或 XDP 的 root 程式執行初始的、跳轉到程式陣列 map 中索引為 0 的程式,然後執行流量抽樣(traffic sampling),然後跳轉到索引為 1 的程式,在那個 程式中應用防火牆策略,然後就可以決定是丟地包還是將其送到索引為 2 的程式中繼續 處理,在後者中,可能可能會被 mangle 然後再次通過某個介面傳送出去。在程式資料 map 之中是可以隨意跳轉的。當達到尾呼叫的最大呼叫深度時,核心最終會執行 fall-through path。

一個使用尾呼叫的最小程式示例:

[...]

#ifndef __stringify

define __stringify(X) #X

#endif

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

#ifndef __section_tail

define __section_tail(ID, KEY) \

__section(__stringify(ID) "/" __stringify(KEY)) #endif

#ifndef BPF_FUNC

define BPF_FUNC(NAME, ...) \

(*NAME)( VA_ARGS ) = (void *)BPF_FUNC_##NAME #endif

#define BPF_JMP_MAP_ID 1

static void BPF_FUNC(tail_call, struct __sk_buff *skb, void *map, uint32_t index);

struct bpf_elf_map jmp_map __section("maps") = { .type = BPF_MAP_TYPE_PROG_ARRAY, .id = BPF_JMP_MAP_ID, .size_key = sizeof(uint32_t), .size_value = sizeof(uint32_t), .pinning = PIN_GLOBAL_NS, .max_elem = 1, };

__section_tail(BPF_JMP_MAP_ID, 0) int looper(struct __sk_buff *skb) { printk("skb cb: %u\n", skb->cb[0]++); tail_call(skb, &jmp_map, 0); return TC_ACT_OK; }

__section("prog") int entry(struct __sk_buff *skb) { skb->cb[0] = 0; tail_call(skb, &jmp_map, 0); return TC_ACT_OK; }

char __license[] __section("license") = "GPL";

載入這個示例程式時,tc 會建立其中的程式陣列(jmp_map 變數),並將其釘(pin)到 BPF 檔案系統中全域性名稱空間下名為的 jump_map 位置。而且,iproute2 中的 BPF ELF 載入器也會識別出標記為 __section_tail() 的 section。 jmp_map 的 id 欄位會 跟__section_tail() 中的 id 欄位(這裡初始化為常量 JMP_MAP_ID)做匹配,因此程 序能載入到使用者指定的索引(位置),在上面的例子中這個索引是 0。然後,所有的尾呼叫 section 將會被 iproute2 載入器處理,關聯到 map 中。這個機制並不是 tc 特有的, iproute2 支援的其他 BPF 程式型別(例如 XDP、lwt)也適用。

生成的 elf 包含 section headers,描述 map id 和 map 內的條目:

$ llvm-objdump -S --no-show-raw-insn prog_array.o | less prog_array.o: file format ELF64-BPF

Disassembly of section 1/0: looper: 0: r6 = r1 1: r2 = *(u32 *)(r6 + 48) 2: r1 = r2 3: r1 += 1 4: *(u32 *)(r6 + 48) = r1 5: r1 = 0 ll 7: call -1 8: r1 = r6 9: r2 = 0 ll 11: r3 = 0 12: call 12 13: r0 = 0 14: exit Disassembly of section prog: entry: 0: r2 = 0 1: *(u32 *)(r1 + 48) = r2 2: r2 = 0 ll 4: r3 = 0 5: call 12 6: r0 = 0 7: exi

在這個例子中,section 1/0 表示 looper() 函式位於 map 1 中,在 map 1 內的 位置是 0。

被釘住(pinned)map 可以被使用者空間應用(例如 Cilium daemon)讀取,也可以被 tc 本 身讀取,因為 tc 可能會用新的程式替換原來的程式,此時可能需要讀取 map 內容。 更新是原子的。

tc 執行尾呼叫 map 更新(tail call map updates)的例子:

$ tc exec bpf graft m:globals/jmp_map key 0 obj new.o sec foo

如果 iproute2 需要更新被釘住(pinned)的程式陣列,可以使用 graft 命令。上面的 例子中指向的是 globals/jmp_map,那 tc 將會用一個新程式更新位於 index/key 為 0 的 map, 這個新程式位於物件檔案 new.o 中的 foo section。

  1. BPF 最大棧空間 512 位元組

BPF 程式的最大棧空間是 512 位元組,在使用 C 語言實現 BPF 程式時需要考慮到這一點。 但正如在第 3 點中提到的,可以通過一個只有一條記錄(single entry)的 BPF_MAP_TYPE_PERCPU_ARRAY map 來繞過這限制,增大 scratch buffer 空間。

  1. 嘗試使用 BPF 內聯彙編

LLVM 6.0 以後支援 BPF 內聯彙編,在某些場景下可能會用到。下面這個玩具示例程式( 沒有實際意義)展示了一個 64 位原子加操作。

由於文件不足,要獲取更多資訊和例子,目前可能只能參考 LLVM 原始碼中的 lib/Target/BPF/BPFInstrInfo.td 以及 test/CodeGen/BPF/。測試程式碼:

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

__section("prog") int xdp_test(struct xdp_md *ctx) { __u64 a = 2, b = 3, c = &a; / just a toy xadd example to show the syntax */ asm volatile("lock *(u64 *)(%0+0) += %1" : "=r"(c) : "r"(b), "0"(c)); return a; }

char __license[] __section("license") = "GPL";

上面的程式會被編譯成下面的 BPF 指令序列:

Verifier analysis:

0: (b7) r1 = 2 1: (7b) *(u64 *)(r10 -8) = r1 2: (b7) r1 = 3 3: (bf) r2 = r10 4: (07) r2 += -8 5: (db) lock *(u64 *)(r2 +0) += r1 6: (79) r0 = *(u64 *)(r10 -8) 7: (95) exit processed 8 insns (limit 131072), stack depth 8

  1. 用 #pragma pack 禁止結構體填充(struct padding)

現代編譯器預設會對資料結構進行記憶體對齊(align),以實現更加高效的訪問。結構 體成員會被對齊到數倍於其自身大小的記憶體位置,不足的部分會進行填充(padding),因 此結構體最終的大小可能會比預想中大。

struct called_info { u64 start; // 8-byte u64 end; // 8-byte u32 sector; // 4-byte }; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info // 0x0(0) 0x8(8) // ↓________________________↓ // | start (8) | // |____________________ | // | end (8) | // |______________________ | // | sector(4) | PADDING | <= address aligned to 8 // |___________ | ____| with 4-byte PADDING.

核心中的 BPF 校驗器會檢查棧邊界(stack boundary),BPF 程式不會訪問棧邊界外的空 間,或者是未初始化的棧空間。如果將結構體中填充出來的記憶體區域作為一個 map 值進行 訪問,那呼叫 bpf_prog_load() 時就會報 invalid indirect read from stack 錯誤。

示例程式碼:

struct called_info { u64 start; u64 end; u32 sector; };

struct bpf_map_def SEC("maps") called_info_map = { .type = BPF_MAP_TYPE_HASH, .key_size = sizeof(long), .value_size = sizeof(struct called_info), .max_entries = 4096, };

SEC("kprobe/submit_bio") int submit_bio_entry(struct pt_regs *ctx) { char fmt[] = "submit_bio(bio=0x%lx) called: %llu\n"; u64 start_time = bpf_ktime_get_ns(); long bio_ptr = PT_REGS_PARM1(ctx); struct called_info called_info = { .start = start_time, .end = 0, .bi_sector = 0 };

bpf_map_update_elem(&called_info_map, &bio_ptr, &called_info, BPF_ANY);
bpf_trace_printk(fmt, sizeof(fmt), bio_ptr, start_time);
return 0;

}

// On bpf_load_program bpf_load_program() err=13 0: (bf) r6 = r1 ... 19: (b7) r1 = 0 20: (7b) *(u64 *)(r10 -72) = r1 21: (7b) *(u64 *)(r10 -80) = r7 22: (63) *(u32 *)(r10 -64) = r1 ... 30: (85) call bpf_map_update_elem#2 invalid indirect read from stack off -80+20 size 24

在 bpf_prog_load() 中會呼叫 BPF 校驗器的 bpf_check() 函式,後者會呼叫 check_func_arg() -> check_stack_boundary() 來檢查棧邊界。從上面的錯誤可以看出 ,struct called_info 被編譯成 24 位元組,錯誤資訊提示從 +20 位置讀取資料是“非 法的間接讀取”(invalid indirect read)。從我們更前面給出的記憶體佈局圖中可以看到, 地址 0x14(20) 是填充(PADDING )開始的地方。這裡再次畫出記憶體佈局圖以方便對比:

// Actual compiled composition of struct called_info // 0x10(16) 0x14(20) 0x18(24) // ↓____________↓___________↓ // | sector(4) | PADDING | <= address aligned to 8 // |___________ | __________| with 4-byte PADDING.

check_stack_boundary() 會遍歷每一個從開始指標出發的 access_size (24) 位元組, 確保它們位於棧邊界內部,並且棧內的所有元素都初始化了。因此填充的部分是不允許使用 的,所以報了 “invalid indirect read from stack” 錯誤。要避免這種錯誤,需要將結 構體中的填充去掉。這是通過 #pragma pack(n) 原語實現的:

#pragma pack(4) struct called_info { u64 start; // 8-byte u64 end; // 8-byte u32 sector; // 4-byte }; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 20-byte

// Actual compiled composition of packed struct called_info // 0x0(0) 0x8(8) // ↓________________________↓ // | start (8) | // |________________________| // | end (8) | // |________________________| // | sector(4) | <= address aligned to 4 // |____________| with no PADDING.

在 struct called_info 前面加上 #pragma pack(4) 之後,編譯器會以 4 位元組為單位 進行對齊。上面的圖可以看到,這個結構體現在已經變成 20 位元組大小,沒有填充了。

但是,去掉填充也是有弊端的。例如,編譯器產生的程式碼沒有原來優化的好。去掉填充之後 ,處理器訪問結構體時觸發的是非對齊訪問(unaligned access),可能會導致效能下降。 並且,某些架構上的校驗器可能會直接拒絕非對齊訪問。

不過,我們也有一種方式可以避免產生自動填充:手動填充。我們簡單地在結構體中加入一 個 u32 pad 成員來顯式填充,這樣既避免了自動填充的問題,又解決了非對齊訪問的問 題。

struct called_info { u64 start; // 8-byte u64 end; // 8-byte u32 sector; // 4-byte u32 pad; // 4-byte }; // size of 24-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info with explicit padding // 0x0(0) 0x8(8) // ↓________________________↓ // | start (8) | // |____________________ | // | end (8) | // |______________________ | // | sector(4) | pad (4) | <= address aligned to 8 // |___________ | ____| with explicit PADDING.

  1. 通過未驗證的引用(invalidated references)訪問包資料

某些網路相關的 BPF 輔助函式,例如 bpf_skb_store_bytes,可能會修改包的大小。校驗 器無法跟蹤這類改動,因此它會將所有之前對包資料的引用都視為過期的(未驗證的) 。因此,為避免程式被校驗器拒絕,在訪問資料之外需要先更新相應的引用。

來看下面的例子:

struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

if (ip4->protocol == IPPROTO_TCP) { // do something }

校驗器會拒絕這段程式碼,因為它認為在 skb_store_bytes 執行之後,引用 ip4->protocol 是未驗證的(invalidated):

R1=pkt_end(id=0,off=0,imm=0) R2=pkt(id=0,off=34,r=34,imm=0) R3=inv0 R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=4294967295,var_off=(0x0; 0xffffffff)) R8=inv4294967162 R9=pkt(id=0,off=0,r=34,imm=0) R10=fp0,call_-1 ... 18: (85) call bpf_skb_store_bytes#9 19: (7b) *(u64 *)(r10 -56) = r7 R0=inv(id=0) R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=2,var_off=(0x0; 0x3)) R8=inv4294967162 R9=inv(id=0) R10=fp0,call_-1 fp-48=mmmm???? fp-56=mmmmmmmm 21: (61) r1 = *(u32 *)(r9 +23) R9 invalid mem access 'inv'

要解決這個問題,必須更新(重新計算) ip4 的地址:

struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

ip4 = (struct iphdr *) skb->data + ETH_HLEN;

if (ip4->protocol == IPPROTO_TCP) { // do something }

2.3 iproute2

很多前端工具,例如 bcc、perf、iproute2,都可以將 BPF 程式載入到核心。Linux 核心原始碼樹中還提供了一個使用者空間庫 tools/lib/bpf/,目前主要是 perf 在使用,用 於載入 BPF 程式到核心,這個庫的開發也主要是由 perf 在驅動。但這個庫是通用的,並非 只能被 perf 使用。bcc 是一個 BPF 工具套件,裡面提供了很多有用的 BPF 程式,主要用 於跟蹤(tracing);這些程式通過一個專門的 Python 介面載入,Python 程式碼中內嵌了 BPF C 程式碼。

但通常來說,不同前端在實現 BPF 程式時,語法和語義稍有不同。另外,核心原始碼樹( samples/bpf/)中也有一些示例程式,它們解析生成的物件檔案,通過系統呼叫直接 載入程式碼到核心。

本節和前一節主要關注如何使用 iproute2 提供的 BPF 前端載入 XDP、tc 或 lwt 型別的網路程式,因為 Cilium 的 BPF 程式就是面向這個載入器實現的。將來 Cilium 會實現自己原生的 BPF 載入器,但為了開發和除錯方便,程式仍會保持與 iproute2 套件的相容性。

所有 iproute2 支援的 BPF 程式都共享相同的 BPF 載入邏輯,因為它們使用相同的載入器 後端(以函式庫的形式,在 iproute2 中對應的程式碼是 lib/bpf.c)。

前面 LLVM 小節介紹了一些和編寫 BPF C 程式相關的 iproute2 內容,本文接下來將關注 編寫這些程式時,和 tc 與 XDP 特定的方面。因此,本節將關注焦點放置使用例子上,展示 如何使用 iproute2 載入物件檔案,以及載入器的一些通用機制。本節不會覆蓋所有細 節,但對於入門來說足夠了。

2.3.1 載入 XDP BPF 物件檔案

給定一個為 XDP 編譯的 BPF 物件檔案 prog.o,可以用 ip 命令載入到支援 XDP 的 netdevice em1:

$ ip link set dev em1 xdp obj prog.o

以上命令假設程式程式碼儲存在預設的 section,在 XDP 的場景下就是 prog section。如 果是在其他 section,例如 foobar,那就需要用如下命令:

$ ip link set dev em1 xdp obj prog.o sec foobar

注意,我們還可以將程式載入到 .text section。修改程式,從 xdp_drop 入口去掉 __section() 註解:

#include <linux/bpf.h>

#ifndef __section

define __section(NAME) \

attribute((section(NAME), used)) #endif

int xdp_drop(struct xdp_md *ctx) { return XDP_DROP; }

char __license[] __section("license") = "GPL";

然後通過如下命令載入:

$ ip link set dev em1 xdp obj prog.o sec .text

預設情況下,如果 XDP 程式已經 attach 到網路介面,那再次載入會報錯,這樣設計是為 了防止程式被無意中覆蓋。要強制替換當前正在執行的 XDP 程式,必須指定 -force 引數:

$ ip -force link set dev em1 xdp obj prog.o

今天,大部分支援 XDP 的驅動都支援在不會引起流量中斷(traffic interrupt)的前提 下原子地替換執行中的程式。出於效能考慮,支援 XDP 的驅動只允許 attach 一個程式 ,不支援程式鏈(a chain of programs)。但正如上一節討論的,如果有必要的話,可以 通過尾呼叫來對程式進行拆分,以達到與程式鏈類似的效果。

如果一個介面上有 XDP 程式 attach,ip link 命令會顯示一個 xdp 標記。因 此可以用 ip link | grep xdp 檢視所有有 XDP 程式執行的介面。ip -d link 可以查 看進一步資訊;另外,bpftool 指定 BPF 程式 ID 可以獲取 attached 程式的資訊,其 中程式 ID 可以通過 ip link 看到。

要從介面刪除 XDP 程式,執行下面的命令:

$ ip link set dev em1 xdp off

要將驅動的工作模式從 non-XDP 切換到 native XDP ,或者相反,通常情況下驅動都需要 重新配置它的接收(和傳送)環形緩衝區,以保證接收的資料包在單個頁面內是線性排列的, 這樣 BPF 程式才可以讀取或寫入。一旦完成這項配置後,大部分驅動只需要執行一次原子 的程式替換,將新的 BPF 程式載入到裝置中。

XDP 工作模式

XDP 總共支援三種工作模式(operation mode),這三種模式 iproute2 都實現了:

xdpdrv

xdpdrv 表示 native XDP(原生 XDP), 意味著 BPF 程式直接在驅動的接收路

徑上執行,理論上這是軟體層最早可以處理包的位置(the earliest possible point)。這是常規/傳統的 XDP 模式,需要驅動實現對 XDP 的支援,目前 Linux 核心中主流的 10G/40G 網絡卡都已經支援。

xdpgeneric

xdpgeneric 表示 generic XDP(通用 XDP),用於給那些還沒有原生支援 XDP

的驅動進行試驗性測試。generic XDP hook 位於核心協議棧的主接收路徑(main receive path)上,接受的是 skb 格式的包,但由於 這些 hook 位於 ingress 路 徑的很後面(a much later point),因此與 native XDP 相比效能有明顯下降。因 此,xdpgeneric 大部分情況下只能用於試驗目的,很少用於生產環境。

xdpoffload

最後,一些智慧網絡卡(例如支援 Netronome’s nfp 驅動的網絡卡)實現了 xdpoffload 模式

,允許將整個 BPF/XDP 程式 offload 到硬體,因此程式在網絡卡收到包時就直接在網絡卡進行 處理。這提供了比 native XDP 更高的效能,雖然在這種模式中某些 BPF map 型別 和 BPF 輔助函式是不能用的。BPF 校驗器檢測到這種情況時會直 接報錯,告訴使用者哪些東西是不支援的。除了這些不支援的 BPF 特性之外,其他方面與 native XDP 都是一樣的。

執行 ip link set dev em1 xdp obj [...] 命令時,核心會先嚐試以 native XDP 模 式載入程式,如果驅動不支援再自動回退到 generic XDP 模式。如果顯式指定了 xdpdrv 而不是 xdp,那驅動不支援 native XDP 時載入就會直接失敗,而不再嘗試 generic XDP 模式。

一個例子:以 native XDP 模式強制載入一個 BPF/XDP 程式,列印鏈路詳情,最後再解除安裝程式:

$ ip -force link set dev em1 xdpdrv obj prog.o $ ip link show [...] 6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc mq state UP mode DORMANT group default qlen 1000 link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff prog/xdp id 1 tag 57cd311f2e27366b [...] $ ip link set dev em1 xdpdrv off

還是這個例子,但強制以 generic XDP 模式載入(即使驅動支援 native XDP),另外用 bpftool 列印 attached 的這個 dummy 程式內具體的 BPF 指令:

$ ip -force link set dev em1 xdpgeneric obj prog.o $ ip link show [...] 6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc mq state UP mode DORMANT group default qlen 1000 link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff prog/xdp id 4 tag 57cd311f2e27366b <-- BPF program ID 4 [...] $ bpftool prog dump xlated id 4 <-- Dump of instructions running on em1 0: (b7) r0 = 1 1: (95) exit $ ip link set dev em1 xdpgeneric off

最後解除安裝 XDP,用 bpftool 列印程式資訊,檢視其中的一些元資料:

$ ip -force link set dev em1 xdpoffload obj prog.o $ ip link show [...] 6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpoffload qdisc mq state UP mode DORMANT group default qlen 1000 link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff prog/xdp id 8 tag 57cd311f2e27366b [...]

$ bpftool prog show id 8 8: xdp tag 57cd311f2e27366b dev em1 <-- Also indicates a BPF program offloaded to em1 loaded_at Apr 11/20:38 uid 0 xlated 16B not jited memlock 4096B

$ ip link set dev em1 xdpoffload off

注意,每個程式只能選擇用一種 XDP 模式載入,無法同時使用多種模式,例如 xdpdrv 和 xdpgeneric。

無法原子地在不同 XDP 模式之間切換,例如從 generic 模式切換到 native 模式。但 重複設定為同一種模式是可以的:

$ ip -force link set dev em1 xdpgeneric obj prog.o $ ip -force link set dev em1 xdpoffload obj prog.o RTNETLINK answers: File exists

$ ip -force link set dev em1 xdpdrv obj prog.o RTNETLINK answers: File exists

$ ip -force link set dev em1 xdpgeneric obj prog.o <-- Succeeds due to xdpgeneric

在不同模式之間切換時,需要先退出當前的操作模式,然後才能進入新模式:

$ ip -force link set dev em1 xdpgeneric obj prog.o $ ip -force link set dev em1 xdpgeneric off $ ip -force link set dev em1 xdpoffload obj prog.o

$ ip l [...] 6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpoffload qdisc mq state UP mode DORMANT group default qlen 1000 link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff prog/xdp id 17 tag 57cd311f2e27366b [...]

$ ip -force link set dev em1 xdpoffload off

2.3.2 載入 tc BPF 物件檔案

用 tc 載入 BPF 程式

給定一個為 tc 編譯的 BPF 物件檔案 prog.o, 可以通過 tc 命令將其載入到一個網 絡裝置(netdevice)。但與 XDP 不同,裝置是否支援 attach BPF 程式並不依賴驅動 (即任何網路裝置都支援 tc BPF)。下面的命令可以將程式 attach 到 em1 的 ingress 網路:

$ tc qdisc add dev em1 clsact $ tc filter add dev em1 ingress bpf da obj prog.o

第一步建立了一個 clsact qdisc (Linux 排隊規則,Linux queueing discipline)。

clsact 是一個 dummy qdisc,和 ingress qdisc 類似,可以持有(hold)分類器和 動作(classifier and actions),但不執行真正的排隊(queueing)。後面 attach bpf 分類器需要用到它。clsact qdisc 提供了兩個特殊的 hook:ingress and egress,分類器可以 attach 到這兩個 hook 點。這兩個 hook 都位於 datapath 的 關鍵收發路徑上,裝置 em1 的每個包都會經過這兩個點。這兩個 hook 分別會被下面的內 核函式呼叫:

ingress hook:__netif_receive_skb_core() -> sch_handle_ingress() egress hook:__dev_queue_xmit() -> sch_handle_egress()

類似地,將程式 attach 到 egress hook:

$ tc filter add dev em1 egress bpf da obj prog.o

clsact qdisc 在 ingress 和 egress 方向以無鎖(lockless)方式執行,而且 可以 attach 到虛擬的、無佇列的裝置(virtual, queue-less devices),例如連線容器和 宿主機的 veth 裝置。

第二條命令,tc filter 選擇了在 da(direct-action)模式中使用 bpf。da 是 推薦的模式,並且應該永遠指定這個引數。粗略地說,da 模式表示 bpf 分類器不需 要呼叫外部的 tc action 模組。事實上 bpf 分類器也完全不需要呼叫外部模組,因 為所有的 packet mangling、轉發或其他型別的 action 都可以在這單個 BPF 程式內完成 ,因此執行會明顯更快。

配置了這兩條命令之後,程式就 attach 完成了,接下來只要有包經過這個裝置,就會觸發 這個程式執行。和 XDP 類似,如果沒有使用預設 section 名字,那可以在載入時指定,例 如指定 section 為 foobar:

$ tc filter add dev em1 egress bpf da obj prog.o sec foobar

iproute2 BPF 載入器的命令列語法對不同的程式型別都是一樣的,因此 obj prog.o sec foobar 命令列格式和前面看到的 XDP 的載入是類似的。

檢視已經 attach 的程式:

$ tc filter show dev em1 ingress filter protocol all pref 49152 bpf filter protocol all pref 49152 bpf handle 0x1 prog.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

$ tc filter show dev em1 egress filter protocol all pref 49152 bpf filter protocol all pref 49152 bpf handle 0x1 prog.o:[egress] direct-action id 2 tag b2fd5adc0f262714

輸出中的 prog.o:[ingress] 表示 section ingress 中的程式是從 檔案 prog.o 加 載的,而且 bpf 工作在 direct-action 模式。上面還列印了程式的 id 和 tag, 其中 tag 是指令流(instruction stream)的雜湊,可以關聯到對應的物件檔案或用 perf 檢視呼叫棧資訊。id 是一個作業系統層唯一的 BPF 程式識別符號,可以用 bpftool 進一步檢視或 dump 相關的程式資訊。

tc 可以 attach 多個 BPF 程式,並提供了其他的一些分類器,這些分類器可以 chain 到 一起使用。但是,attach 單個 BPF 程式已經完全足夠了,因為有了 da 模式,所有的包 操作都可以放到同一個程式中,這意味著 BPF 程式自身將會返回 tc action verdict,例 如 TC_ACT_OK、TC_ACT_SHOT 等等。出於最佳效能和靈活性考慮,這(da 模式)是推 薦的使用方式。

程式優先順序(pref)和控制代碼(handle)

在上面的 show 命令中,tc 還打印出了 pref 49152 和 handle 0x1。如果之前沒有 通過命令列顯式指定,這兩個資料就會自動生成。pref 表示優先順序,如果指定了多個分 類器,它們會按照優先順序從高到低依次執行;handle 是一個識別符號,在載入了同一分類器的多 個例項並且它們的優先順序(pref)都一樣的情況下會用到這個識別符號。因為 在 BPF 的場景下,單個程式就足夠了,因此 pref 和 handle 通常情況下都可以忽略。

除非打算後面原子地替換 attached BPF 程式,否則不建議在載入時顯式指定 pref 和 handle。顯式指定這兩個引數的好處是,後面執行 replace 操作時,就不需要再去動 態地查詢這兩個值。顯式指定 pref 和 handle 時的載入命令:

$ tc filter add dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

$ tc filter show dev em1 ingress filter protocol all pref 1 bpf filter protocol all pref 1 bpf handle 0x1 prog.o:[foobar] direct-action id 1 tag c5f7825e5dac396f

對應的原子 replace 命令:將 ingress hook 處的已有程式替換為 prog.o 檔案中 foobar section 中的新 BPF 程式,

$ tc filter replace dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

用 tc 刪除 BPF 程式

最後,要分別從 ingress 和 egress 刪除所有 attach 的程式,執行:

$ tc filter del dev em1 ingress $ tc filter del dev em1 egress

要從 netdevice 刪除整個 clsact qdisc(會隱式地刪除 attach 到 ingress 和 egress hook 上面的所有程式),執行:

$ tc qdisc del dev em1 clsact

offload 到網絡卡

和 XDP BPF 程式類似,如果網絡卡驅動支援 tc BPF 程式,那也可以將它們 offload 到網絡卡 。Netronome 的 nfp 網絡卡對 XDP 和 tc BPF 程式都支援 offload。

$ tc qdisc add dev em1 clsact $ tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o Error: TC offload is disabled on net device. We have an error talking to the kernel

如果顯式以上錯誤,那需要先啟用網絡卡的 hw-tc-offload 功能:

$ ethtool -K em1 hw-tc-offload on

$ tc qdisc add dev em1 clsact $ tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o $ tc filter show dev em1 ingress filter protocol all pref 1 bpf filter protocol all pref 1 bpf handle 0x1 prog.o:[classifier] direct-action skip_sw in_hw id 19 tag 57cd311f2e27366b

其中的 in_hw 標誌表示這個程式已經被 offload 到網絡卡了。

注意,tc 和 XDP offload 無法同時載入,因此必須要指明是 tc 還是 XDP offload 選項 。

2.3.3 通過 netdevsim 驅動測試 BPF offload

netdevsim 驅動是 Linux 核心的一部分,它是一個 dummy driver,實現了 XDP BPF 和 tc BPF 程式的 offload 介面,以及其他一些設施,這些設施可以用來測試核心的改動,或者 某些利用核心的 UAPI 實現了一個控制平面功能的底層使用者空間程式。

可以用如下命令建立一個 netdevsim 裝置:

$ modprobe netdevsim // [ID] [PORT_COUNT] $ echo "1 1" > /sys/bus/netdevsim/new_device

$ devlink dev netdevsim/netdevsim1

$ devlink port netdevsim/netdevsim1/0: type eth netdev eth0 flavour physical

$ ip l [...] 4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff

然後就可以載入 XDP 或 tc BPF 程式,命令和前面的一些例子一樣:

$ ip -force link set dev eth0 xdpoffload obj prog.o $ ip l [...] 4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 xdpoffload qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff prog/xdp id 16 tag a04f5eef06a7f555

這是用 iproute2 載入 XDP/tc BPF 程式的兩個標準步驟。

還有很多對 XDP 和 tc 都適用的 BPF 載入器高階選項,下面列出其中一些。為簡單 起見,這裡只列出了 XDP 的例子。

列印更多 log(Verbose),即使命令執行成功

在命令最後加上 verb 選項可以列印校驗器的日誌:

 $ ip link set dev em1 xdp obj xdp-example.o verb

Prog section 'prog' loaded (5)!

  • Type: 6
  • Instructions: 2 (0 over limit)
  • License: GPL

Verifier analysis:

0: (b7) r0 = 1 1: (95) exit processed 2 insns

載入已經 pin 在 BPF 檔案系統中的程式

除了從物件檔案載入程式之外,iproute2 還可以從 BPF 檔案系統載入程式。在某些場

景下,一些外部實體會將 BPF 程式 pin 在 BPF 檔案系統並 attach 到裝置。載入命 令:

$ ip link set dev em1 xdp pinned /sys/fs/bpf/prog


iproute2 還可以使用更簡短的相對路徑方式(相對於 BPF 檔案系統的掛載點):

 $ ip link set dev em1 xdp pinned m:prog

在載入 BPF 程式時,iproute2 會自動檢測掛載的檔案系統例項。如果發現還沒有掛載,tc 就會自動將其掛載到預設位置 /sys/fs/bpf/。

如果發現已經掛載了一個 BPF 檔案系統例項,接下來就會使用這個例項,不會再掛載新的 了:

$ mkdir /var/run/bpf $ mount --bind /var/run/bpf /var/run/bpf $ mount -t bpf bpf /var/run/bpf

$ tc filter add dev em1 ingress bpf da obj tc-example.o sec prog

$ tree /var/run/bpf /var/run/bpf +-- ip -> /run/bpf/tc/ +-- tc |   +-- globals |   +-- jmp_map +-- xdp -> /run/bpf/tc/

4 directories, 1 file

預設情況下,tc 會建立一個如上面所示的初始目錄,所有子系統的使用者都會通過符號 連結(symbolic links)指向相同的位置,也是就是 globals 名稱空間,因此 pinned BPF maps 可以被 iproute2 中不同型別的 BPF 程式使用。如果檔案系統例項已經掛載、 目錄已經存在,那 tc 是不會覆蓋這個目錄的。因此對於 lwt, tc 和 xdp 這幾種類 型的 BPF maps,可以從 globals 中分離出來,放到各自的目錄存放。

在前面的 LLVM 小節中簡要介紹過,安裝 iproute2 時會向系統中安裝一個頭檔案,BPF 程 序可以直接以標準路(standard include path)徑來 include 這個標頭檔案:

#include <iproute2/bpf_elf.h>

這個標頭檔案中提供的 API 可以讓程式使用 maps 和預設 section 名字。它是 iproute2 和 BPF 程式之間的一份穩定契約(contract )。

iproute2 中 map 的定義是 struct bpf_elf_map。這個結構體內的成員變數已經在 LLVM 小節中介紹過了。

When parsing the BPF object file, the iproute2 loader will walk through all ELF sections. It initially fetches ancillary sections like maps and license. For maps, the struct bpf_elf_map array will be checked for validity and whenever needed, compatibility workarounds are performed. Subsequently all maps are created with the user provided information, either retrieved as a pinned object, or newly created and then pinned into the BPF file system. Next the loader will handle all program sections that contain ELF relocation entries for maps, meaning that BPF instructions loading map file descriptors into registers are rewritten so that the corresponding map file descriptors are encoded into the instructions immediate value, in order for the kernel to be able to convert them later on into map kernel pointers. After that all the programs themselves are created through the BPF system call, and tail called maps, if present, updated with the program’s file descriptors.

2.4 bpftool

bpftool 是檢視和除錯 BPF 程式的主要工具。它隨核心一起開發,在核心中的路徑是 tools/bpf/bpftool/。

這個工具可以完成:

dump 當前已經載入到系統中的所有 BPF 程式和 map 列出和指定程式相關的所有 BPF map dump 整個 map 中的 key/value 對 檢視、更新、刪除特定 key 檢視給定 key 的相鄰 key(neighbor key)

要執行這些操作可以指定 BPF 程式、map ID,或者指定 BPF 檔案系統中程式或 map 的位 置。另外,這個工具還提供了將 map 或程式釘(pin)到 BPF 檔案系統的功能。

檢視系統當前已經載入的 BPF 程式:

$ bpftool prog 398: sched_cls tag 56207908be8ad877 loaded_at Apr 09/16:24 uid 0 xlated 8800B jited 6184B memlock 12288B map_ids 18,5,17,14 399: sched_cls tag abc95fb4835a6ec9 loaded_at Apr 09/16:24 uid 0 xlated 344B jited 223B memlock 4096B map_ids 18 400: sched_cls tag afd2e542b30ff3ec loaded_at Apr 09/16:24 uid 0 xlated 1720B jited 1001B memlock 4096B map_ids 17 401: sched_cls tag 2dbbd74ee5d51cc8 loaded_at Apr 09/16:24 uid 0 xlated 3728B jited 2099B memlock 4096B map_ids 17 [...]

類似地,檢視所有的 active maps:

$ bpftool map 5: hash flags 0x0 key 20B value 112B max_entries 65535 memlock 13111296B 6: hash flags 0x0 key 20B value 20B max_entries 65536 memlock 7344128B 7: hash flags 0x0 key 10B value 16B max_entries 8192 memlock 790528B 8: hash flags 0x0 key 22B value 28B max_entries 8192 memlock 987136B 9: hash flags 0x0 key 20B value 8B max_entries 512000 memlock 49352704B [...]

bpftool 的每個命令都提供了以 json 格式列印的功能,在命令末尾指定 --json 就行了。 另外,--pretty 會使得列印更加美觀,看起來更清楚。

$ bpftool prog --json --pretty

要 dump 特定 BPF 程式的 post-verifier BPF 指令映象(instruction image),可以先 從檢視一個具體程式開始,例如,檢視 attach 到 tc ingress hook 上的程式:

$ tc filter show dev cilium_host egress filter protocol all pref 1 bpf chain 0 filter protocol all pref 1 bpf chain 0 handle 0x1 bpf_host.o:[from-netdev]

direct-action not_in_hw id 406 tag e0362f5bd9163a0a jited

這個程式是從物件檔案 bpf_host.o 載入來的,程式位於物件檔案的 from-netdev section,程式 ID 為 406。基於以上資訊 bpftool 可以提供一些關於這個程式的上層元 資料:

$ bpftool prog show id 406 406: sched_cls tag e0362f5bd9163a0a loaded_at Apr 09/16:24 uid 0 xlated 11144B jited 7721B memlock 12288B map_ids 18,20,8,5,6,14

從上面的輸出可以看到:

程式 ID 為 406,型別是 sched_cls(BPF_PROG_TYPE_SCHED_CLS),有一個 tag 為 e0362f5bd9163a0a(指令序列的 SHA sum) 這個程式被 root uid 0 在 Apr 09/16:24 載入 BPF 指令序列有 11,144 bytes 長,JIT 之後的映象有 7,721 bytes 程式自身(不包括 maps)佔用了 12,288 bytes,這部分空間使用的是 uid 0 使用者 的配額 BPF 程式使用了 ID 為 18、20 8 5 6 和 14 的 BPF map。可以用這些 ID 進一步 dump map 自身或相關資訊

另外,bpftool 可以 dump 出執行中程式的 BPF 指令:

$ bpftool prog dump xlated id 406 0: (b7) r7 = 0 1: (63) *(u32 *)(r1 +60) = r7 2: (63) *(u32 *)(r1 +56) = r7 3: (63) *(u32 *)(r1 +52) = r7 [...] 47: (bf) r4 = r10 48: (07) r4 += -40 49: (79) r6 = *(u64 *)(r10 -104) 50: (bf) r1 = r6 51: (18) r2 = map[id:18] <-- BPF map id 18 53: (b7) r5 = 32 54: (85) call bpf_skb_event_output#5656112 <-- BPF helper call 55: (69) r1 = *(u16 *)(r6 +192) [...]

如上面的輸出所示,bpftool 將指令流中的 BPF map ID、BPF 輔助函式或其他 BPF 程式都 做了關聯。

和核心的 BPF 校驗器一樣,bpftool dump 指令流時複用了同一個使輸出更美觀的列印程式 (pretty-printer)。

由於程式被 JIT,因此真正執行的是生成的 JIT 映象(從上面 xlated 中的指令生成的 ),這些指令也可以通過 bpftool 檢視:

$ bpftool prog dump jited id 406 0: push %rbp 1: mov %rsp,%rbp 4: sub $0x228,%rsp b: sub $0x28,%rbp f: mov %rbx,0x0(%rbp) 13: mov %r13,0x8(%rbp) 17: mov %r14,0x10(%rbp) 1b: mov %r15,0x18(%rbp) 1f: xor %eax,%eax 21: mov %rax,0x20(%rbp) 25: mov 0x80(%rdi),%r9d [...]

另外,還可以指定在輸出中將反彙編之後的指令關聯到 opcodes,這個功能主要對 BPF JIT 開發者比較有用:

$ bpftool prog dump jited id 406 opcodes 0: push %rbp 55 1: mov %rsp,%rbp 48 89 e5 4: sub $0x228,%rsp 48 81 ec 28 02 00 00 b: sub $0x28,%rbp 48 83 ed 28 f: mov %rbx,0x0(%rbp) 48 89 5d 00 13: mov %r13,0x8(%rbp) 4c 89 6d 08 17: mov %r14,0x10(%rbp) 4c 89 75 10 1b: mov %r15,0x18(%rbp) 4c 89 7d 18 [...]

同樣,也可以將常規的 BPF 指令關聯到 opcodes,有時在核心中進行除錯時會比較有用:

$ bpftool prog dump xlated id 406 opcodes 0: (b7) r7 = 0 b7 07 00 00 00 00 00 00 1: (63) *(u32 *)(r1 +60) = r7 63 71 3c 00 00 00 00 00 2: (63) *(u32 *)(r1 +56) = r7 63 71 38 00 00 00 00 00 3: (63) *(u32 *)(r1 +52) = r7 63 71 34 00 00 00 00 00 4: (63) *(u32 *)(r1 +48) = r7 63 71 30 00 00 00 00 00 5: (63) *(u32 *)(r1 +64) = r7 63 71 40 00 00 00 00 00 [...]

此外,還可以用 graphviz 以視覺化的方式展示程式的基本組成部分。bpftool 提供了一 個 visual dump 模式,這種模式下輸出的不是 BPF xlated 指令文字,而是一張點圖( dot graph),後者可以轉換成 png 格式的圖片:

$ bpftool prog dump xlated id 406 visual &> output.dot

$ dot -Tpng output.dot -o output.png

也可以用 dotty 開啟生成的點圖檔案:dotty output.dot,bpf_host.o 程式的效果如 下圖所示(一部分):

注意,xlated 中 dump 出來的指令是經過校驗器之後(post-verifier)的 BPF 指令鏡 像,即和 BPF 直譯器中執行的版本是一樣的。

在核心中,校驗器會對 BPF 載入器提供的原始指令執行各種重新(rewrite)。一個例子就 是對輔助函式進行內聯化(inlining)以提高執行時效能,下面是對一個雜湊表查詢的優化:

$ bpftool prog dump xlated id 3 0: (b7) r1 = 2 1: (63) *(u32 *)(r10 -4) = r1 2: (bf) r2 = r10 3: (07) r2 += -4 4: (18) r1 = map[id:2] <-- BPF map id 2 6: (85) call __htab_map_lookup_elem#77408 <-+ BPF helper inlined rewrite 7: (15) if r0 == 0x0 goto pc+2 | 8: (07) r0 += 56 | 9: (79) r0 = *(u64 *)(r0 +0) <-+ 10: (15) if r0 == 0x0 goto pc+24 11: (bf) r2 = r10 12: (07) r2 += -4 [...]

bpftool 通過 kallsyms 來對輔助函式或 BPF-to-BPF 呼叫進行關聯。因此,確保 JIT 之 後的 BPF 程式暴露到了 kallsyms(bpf_jit_kallsyms),並且 kallsyms 地址是明確的 (否則呼叫顯示的就是 call bpf_unspec#0):

$ echo 0 > /proc/sys/kernel/kptr_restrict $ echo 1 > /proc/sys/net/core/bpf_jit_kallsyms

BPF-to-BPF 呼叫在直譯器和 JIT 映象中也做了關聯。對於後者,子程式的 tag 會顯示為 呼叫目標(call target)。在兩種情況下,pc+2 都是呼叫目標的程式計數器偏置( pc-relative offset),表示就是子程式的地址。

$ bpftool prog dump xlated id 1 0: (85) call pc+2#__bpf_prog_run_args32 1: (b7) r0 = 1 2: (95) exit 3: (b7) r0 = 2 4: (95) exit

對應的 JIT 版本:

$ bpftool prog dump xlated id 1 0: (85) call pc+2#bpf_prog_3b185187f1855c4c_F 1: (b7) r0 = 1 2: (95) exit 3: (b7) r0 = 2 4: (95) exit

在尾呼叫中,核心會將它們對映為同一個指令,但 bpftool 還是會將它們作為輔助函式進 行關聯,以方便除錯:

$ bpftool prog dump xlated id 2 [...] 10: (b7) r2 = 8 11: (85) call bpf_trace_printk#-41312 12: (bf) r1 = r6 13: (18) r2 = map[id:1] 15: (b7) r3 = 0 16: (85) call bpf_tail_call#12 17: (b7) r1 = 42 18: (6b) *(u16 *)(r6 +46) = r1 19: (b7) r0 = 0 20: (95) exit

$ bpftool map show id 1 1: prog_array flags 0x0 key 4B value 4B max_entries 1 memlock 4096B

map dump 子命令可以 dump 整個 map,它會遍歷所有的 map 元素,輸出 key/value。

如果 map 中沒有可用的 BTF 資料,那 key/value 會以十六進位制格式輸出:

$ bpftool map dump id 5 key: f0 0d 00 00 00 00 00 00 0a 66 00 00 00 00 8a d6 02 00 00 00 value: 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 key: 0a 66 1c ee 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 value: 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [...] Found 6 elements

如果有 BTF 資料,map 就有了關於 key/value 結構體的除錯資訊。例如,BTF 資訊加上 BPF map 以及 iproute2 中的 BPF_ANNOTATE_KV_PAIR() 會產生下面的輸出(核心 selftests 中的 test_xdp_noinline.o):

$ cat tools/testing/selftests/bpf/test_xdp_noinline.c [...] struct ctl_value { union { __u64 value; __u32 ifindex; __u8 mac[6]; }; };

struct bpf_map_def attribute ((section("maps"), used)) ctl_array = { .type = BPF_MAP_TYPE_ARRAY, .key_size = sizeof(__u32), .value_size = sizeof(struct ctl_value), .max_entries = 16, .map_flags = 0, }; BPF_ANNOTATE_KV_PAIR(ctl_array, __u32, struct ctl_value);

[...]

BPF_ANNOTATE_KV_PAIR() 巨集強制每個 map-specific ELF section 包含一個空的 key/value,這樣 iproute2 BPF 載入器可以將 BTF 資料關聯到這個 section,因此在載入 map 時可用從 BTF 中選擇響應的型別。

使用 LLVM 編譯,並使用 pahole 基於除錯資訊產生 BTF:

$ clang [...] -O2 -target bpf -g -emit-llvm -c test_xdp_noinline.c -o - | llc -march=bpf -mcpu=probe -mattr=dwarfris -filetype=obj -o test_xdp_noinline.o

$ pahole -J test_xdp_noinline.o

載入到核心,然後使用 bpftool dump 這個 map:

$ ip -force link set dev lo xdp obj test_xdp_noinline.o sec xdp-test $ ip a 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric/id:227 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever [...]

$ bpftool prog show id 227 227: xdp tag a85e060c275c5616 gpl loaded_at 2018-07-17T14:41:29+0000 uid 0 xlated 8152B not jited memlock 12288B map_ids 381,385,386,382,384,383

$ bpftool map dump id 386 [{ "key": 0, "value": { "": { "value": 0, "ifindex": 0, "mac": [] } } },{ "key": 1, "value": { "": { "value": 0, "ifindex": 0, "mac": [] } } },{ [...]

針對 map 的某個 key,也可用通過 bpftool 檢視、更新、刪除和獲取下一個 key(’get next key’)。

如果帶 BTF 除錯資訊的 BPF 程式已經成功載入,prog show 命令的 btf_id 欄位顯示 的就是 BTF ID:

$ bpftool prog show id 72 72: xdp name balancer_ingres tag acf44cabb48385ed gpl loaded_at 2020-04-13T23:12:08+0900 uid 0 xlated 19104B jited 10732B memlock 20480B map_ids 126,130,131,127,129,128 btf_id 60

此外,還可以用 btf show 命令來 dump 系統中已經載入的所有 BTF 物件 ;

bpftool btf show

60: size 12243B prog_ids 72 map_ids 126,130,131,127,129,128

還可以用子命令 btf dump 來檢查 BTF 中攜帶了哪些 debug 資訊。format 型別可以 是 ‘raw’ 或 ‘c’:

$ bpftool btf dump id 60 format c [...] struct ctl_value { union { __u64 value; __u32 ifindex; __u8 mac[6]; }; };

typedef unsigned int u32; [...]

2.5 BPF sysctls

Linux 核心提供了一些 BPF 相關的 sysctl 配置。

/proc/sys/net/core/bpf_jit_enable:啟用或禁用 BPF JIT 編譯器。

  +-------+-------------------------------------------------------------------+

| Value | Description | +-------+-------------------------------------------------------------------+ | 0 | Disable the JIT and use only interpreter (kernel's default value) | +-------+-------------------------------------------------------------------+ | 1 | Enable the JIT compiler | +-------+-------------------------------------------------------------------+ | 2 | Enable the JIT and emit debugging traces to the kernel log | +-------+-------------------------------------------------------------------+

後面會介紹到,當 JIT 編譯設定為除錯模式(option 2)時,bpf_jit_disasm 工

具能夠處理除錯跟蹤資訊(debugging traces)。

/proc/sys/net/core/bpf_jit_harden:啟用會禁用 BPF JIT 加固。

注意,啟用加固會降低效能,但能夠降低 JIT spraying(噴射)攻擊,因為它會禁止

(blind)BPF 程式使用立即值(immediate values)。對於通過直譯器處理的程式, 禁用(blind)立即值是沒有必要的(也是沒有去做的)。

+-------+-------------------------------------------------------------------+

| Value | Description | +-------+-------------------------------------------------------------------+ | 0 | Disable JIT hardening (kernel's default value) | +-------+-------------------------------------------------------------------+ | 1 | Enable JIT hardening for unprivileged users only | +-------+-------------------------------------------------------------------+ | 2 | Enable JIT hardening for all users | +-------+-------------------------------------------------------------------+

/proc/sys/net/core/bpf_jit_kallsyms:是否允許 JIT 後的程式作為核心符號暴露到

/proc/kallsyms。

啟用後,這些符號可以被 perf 這樣的工具識別,使核心在做 stack unwinding 時

能感知到這些地址,例如,在 dump stack trace 的時候,符合名中會包含 BPF 程式 tag(bpf_prog_ )。如果啟用了 bpf_jit_harden,這個特性就會自動被禁用 。

+-------+-------------------------------------------------------------------+

| Value | Description | +-------+-------------------------------------------------------------------+ | 0 | Disable JIT kallsyms export (kernel's default value) | +-------+-------------------------------------------------------------------+ | 1 | Enable JIT kallsyms export for privileged users only | +-------+-------------------------------------------------------------------+

/proc/sys/kernel/unprivileged_bpf_disabled:是否允許非特權使用者使用 bpf(2)

系統呼叫。

核心預設允許非特權使用者使用 bpf(2) 系統呼叫,但一旦將這個開關關閉,必須重啟

核心才能再次將其開啟。因此這是一個一次性開關(one-time switch),一旦關閉, 不管是應用還是管理員都無法再次修改。這個開關不影響 cBPF 程式(例如 seccomp) 或 傳統的沒有使用 bpf(2) 系統呼叫的 socket 過濾器 載入程式到核心。

+-------+-------------------------------------------------------------------+

| Value | Description | +-------+-------------------------------------------------------------------+ | 0 | Unprivileged use of bpf syscall enabled (kernel's default value) | +-------+-------------------------------------------------------------------+ | 1 | Unprivileged use of bpf syscall disabled | +-------+-------------------------------------------------------------------+

2.6 核心測試

Linux 核心自帶了一個 selftest 套件,在核心原始碼樹中的路徑是 tools/testing/selftests/bpf/。

$ cd tools/testing/selftests/bpf/ $ make $ make run_tests

測試用例包括:

BPF 校驗器、程式 tags、BPF map 介面和 map 型別的很多測試用例 用於 LLVM 後端的執行時測試,用 C 程式碼實現 用於直譯器和 JIT 的測試,執行在核心,用 eBPF 和 cBPF 彙編實現

2.7 JIT Debugging

For JIT developers performing audits or writing extensions, each compile run can output the generated JIT image into the kernel log through:

$ echo 2 > /proc/sys/net/core/bpf_jit_enable

Whenever a new BPF program is loaded, the JIT compiler will dump the output, which can then be inspected with dmesg, for example:

[ 3389.935842] flen=6 proglen=70 pass=3 image=ffffffffa0069c8f from=tcpdump pid=20583 [ 3389.935847] JIT code: 00000000: 55 48 89 e5 48 83 ec 60 48 89 5d f8 44 8b 4f 68 [ 3389.935849] JIT code: 00000010: 44 2b 4f 6c 4c 8b 87 d8 00 00 00 be 0c 00 00 00 [ 3389.935850] JIT code: 00000020: e8 1d 94 ff e0 3d 00 08 00 00 75 16 be 17 00 00 [ 3389.935851] JIT code: 00000030: 00 e8 28 94 ff e0 83 f8 01 75 07 b8 ff ff 00 00 [ 3389.935852] JIT code: 00000040: eb 02 31 c0 c9 c3

flen is the length of the BPF program (here, 6 BPF instructions), and proglen tells the number of bytes generated by the JIT for the opcode image (here, 70 bytes in size). pass means that the image was generated in 3 compiler passes, for example, x86_64 can have various optimization passes to further reduce the image size when possible. image contains the address of the generated JIT image, from and pid the user space application name and PID respectively, which triggered the compilation process. The dump output for eBPF and cBPF JITs is the same format.

In the kernel tree under tools/bpf/, there is a tool called bpf_jit_disasm. It reads out the latest dump and prints the disassembly for further inspection:

$ ./bpf_jit_disasm 70 bytes emitted from JIT compiler (pass:3, flen:6) ffffffffa0069c8f + : 0: push %rbp 1: mov %rsp,%rbp 4: sub $0x60,%rsp 8: mov %rbx,-0x8(%rbp) c: mov 0x68(%rdi),%r9d 10: sub 0x6c(%rdi),%r9d 14: mov 0xd8(%rdi),%r8 1b: mov $0xc,%esi 20: callq 0xffffffffe0ff9442 25: cmp $0x800,%eax 2a: jne 0x0000000000000042 2c: mov $0x17,%esi 31: callq 0xffffffffe0ff945e 36: cmp $0x1,%eax 39: jne 0x0000000000000042 3b: mov $0xffff,%eax 40: jmp 0x0000000000000044 42: xor %eax,%eax 44: leaveq 45: retq

Alternatively, the tool can also dump related opcodes along with the disassembly.

$ ./bpf_jit_disasm -o 70 bytes emitted from JIT compiler (pass:3, flen:6) ffffffffa0069c8f + : 0: push %rbp 55 1: mov %rsp,%rbp 48 89 e5 4: sub $0x60,%rsp 48 83 ec 60 8: mov %rbx,-0x8(%rbp) 48 89 5d f8 c: mov 0x68(%rdi),%r9d 44 8b 4f 68 10: sub 0x6c(%rdi),%r9d 44 2b 4f 6c 14: mov 0xd8(%rdi),%r8 4c 8b 87 d8 00 00 00 1b: mov $0xc,%esi be 0c 00 00 00 20: callq 0xffffffffe0ff9442 e8 1d 94 ff e0 25: cmp $0x800,%eax 3d 00 08 00 00 2a: jne 0x0000000000000042 75 16 2c: mov $0x17,%esi be 17 00 00 00 31: callq 0xffffffffe0ff945e e8 28 94 ff e0 36: cmp $0x1,%eax 83 f8 01 39: jne 0x0000000000000042 75 07 3b: mov $0xffff,%eax b8 ff ff 00 00 40: jmp 0x0000000000000044 eb 02 42: xor %eax,%eax 31 c0 44: leaveq c9 45: retq c3

More recently, bpftool adapted the same feature of dumping the BPF JIT image based on a given BPF program ID already loaded in the system (see bpftool section).

For performance analysis of JITed BPF programs, perf can be used as usual. As a prerequisite, JITed programs need to be exported through kallsyms infrastructure.

$ echo 1 > /proc/sys/net/core/bpf_jit_enable $ echo 1 > /proc/sys/net/core/bpf_jit_kallsyms

Enabling or disabling bpf_jit_kallsyms does not require a reload of the related BPF programs. Next, a small workflow example is provided for profiling BPF programs. A crafted tc BPF program is used for demonstration purposes, where perf records a failed allocation inside bpf_clone_redirect() helper. Due to the use of direct write, bpf_try_make_head_writable() failed, which would then release the cloned skb again and return with an error message. perf thus records all kfree_skb events.

$ tc qdisc add dev em1 clsact $ tc filter add dev em1 ingress bpf da obj prog.o sec main $ tc filter show dev em1 ingress filter protocol all pref 49152 bpf filter protocol all pref 49152 bpf handle 0x1 prog.o:[main] direct-action id 1 tag 8227addf251b7543

$ cat /proc/kallsyms [...] ffffffffc00349e0 t fjes_hw_init_command_registers [fjes] ffffffffc003e2e0 d __tracepoint_fjes_hw_stop_debug_err [fjes] ffffffffc0036190 t fjes_hw_epbuf_tx_pkt_send [fjes] ffffffffc004b000 t bpf_prog_8227addf251b7543

$ perf record -a -g -e skb:kfree_skb sleep 60 $ perf script --kallsyms=/proc/kallsyms [...] ksoftirqd/0 6 [000] 1004.578402: skb:kfree_skb: skbaddr=0xffff9d4161f20a00 protocol=2048 location=0xffffffffc004b52c 7fffb8745961 bpf_clone_redirect (/lib/modules/4.10.0+/build/vmlinux) 7fffc004e52c bpf_prog_8227addf251b7543 (/lib/modules/4.10.0+/build/vmlinux) 7fffc05b6283 cls_bpf_classify (/lib/modules/4.10.0+/build/vmlinux) 7fffb875957a tc_classify (/lib/modules/4.10.0+/build/vmlinux) 7fffb8729840 __netif_receive_skb_core (/lib/modules/4.10.0+/build/vmlinux) 7fffb8729e38 __netif_receive_skb (/lib/modules/4.10.0+/build/vmlinux) 7fffb872ae05 process_backlog (/lib/modules/4.10.0+/build/vmlinux) 7fffb872a43e net_rx_action (/lib/modules/4.10.0+/build/vmlinux) 7fffb886176c __do_softirq (/lib/modules/4.10.0+/build/vmlinux) 7fffb80ac5b9 run_ksoftirqd (/lib/modules/4.10.0+/build/vmlinux) 7fffb80ca7fa smpboot_thread_fn (/lib/modules/4.10.0+/build/vmlinux) 7fffb80c6831 kthread (/lib/modules/4.10.0+/build/vmlinux) 7fffb885e09c ret_from_fork (/lib/modules/4.10.0+/build/vmlinux)

The stack trace recorded by perf will then show the bpf_prog_8227addf251b7543() symbol as part of the call trace, meaning that the BPF program with the tag 8227addf251b7543 was related to the kfree_skb event, and such program was attached to netdevice em1 on the ingress hook as shown by tc.

2.8 內省(Introspection)

Linux 核心圍繞 BPF 和 XDP 提供了多種 tracepoints,這些 tracepoints 可以用於進一 步檢視系統內部行為,例如,跟蹤使用者空間程式和 bpf 系統呼叫的互動。

BPF 相關的 tracepoints:

$ perf list | grep bpf: bpf:bpf_map_create [Tracepoint event] bpf:bpf_map_delete_elem [Tracepoint event] bpf:bpf_map_lookup_elem [Tracepoint event] bpf:bpf_map_next_key [Tracepoint event] bpf:bpf_map_update_elem [Tracepoint event] bpf:bpf_obj_get_map [Tracepoint event] bpf:bpf_obj_get_prog [Tracepoint event] bpf:bpf_obj_pin_map [Tracepoint event] bpf:bpf_obj_pin_prog [Tracepoint event] bpf:bpf_prog_get_type [Tracepoint event] bpf:bpf_prog_load [Tracepoint event] bpf:bpf_prog_put_rcu [Tracepoint event]

使用 perf 跟蹤 BPF 系統呼叫(這裡用 sleep 只是展示用法,實際場景中應該 執行 tc 等命令):

$ perf record -a -e bpf:* sleep 10 $ perf script sock_example 6197 [005] 283.980322: bpf:bpf_map_create: map type=ARRAY ufd=4 key=4 val=8 max=256 flags=0 sock_example 6197 [005] 283.980721: bpf:bpf_prog_load: prog=a5ea8fa30ea6849c type=SOCKET_FILTER ufd=5 sock_example 6197 [005] 283.988423: bpf:bpf_prog_get_type: prog=a5ea8fa30ea6849c type=SOCKET_FILTER sock_example 6197 [005] 283.988443: bpf:bpf_map_lookup_elem: map type=ARRAY ufd=4 key=[06 00 00 00] val=[00 00 00 00 00 00 00 00] [...] sock_example 6197 [005] 288.990868: bpf:bpf_map_lookup_elem: map type=ARRAY ufd=4 key=[01 00 00 00] val=[14 00 00 00 00 00 00 00] swapper 0 [005] 289.338243: bpf:bpf_prog_put_rcu: prog=a5ea8fa30ea6849c type=SOCKET_FILTER

對於 BPF 程式,以上命令會打印出每個程式的 tag。

對於除錯,XDP 還有一個 xdp:xdp_exception tracepoint,在拋異常的時候觸發:

$ perf list | grep xdp: xdp:xdp_exception [Tracepoint event]

異常在下面情況下會觸發:

BPF 程式返回一個非法/未知的 XDP action code. BPF 程式返回 XDP_ABORTED,這表示非優雅的退出(non-graceful exit) BPF 程式返回 XDP_TX,但傳送時發生錯誤,例如,由於埠沒有啟用、傳送緩衝區已 滿、分配記憶體失敗等等

這兩類 tracepoint 也都可以通過 attach BPF 程式,用這個 BPF 程式本身來收集進一步 資訊,將結果放到一個 BPF map 或以事件的方式傳送到使用者空間收集器,例如利用 bpf_perf_event_output() 輔助函式。

2.9 Tracing pipe

在 BPF 程式中執行 bpf_trace_printk(),輸出會打到核心的跟蹤管道(tracing pipe)。 使用者可以在使用者態讀取這些輸出:

$ tail -f /sys/kernel/debug/tracing/trace_pipe ...

2.10 其他(Miscellaneous)

和 perf 類似,BPF 程式和 map 佔用的記憶體是算在 RLIMIT_MEMLOCK 中的。可以用 ulimit -l 檢視當前鎖定到記憶體中的頁面大小。setrlimit() 系統呼叫的 man page 提 供了進一步的細節。

預設的限制通常導致無法載入複雜的程式或很大的 BPF map,此時 BPF 系統呼叫會返回 EPERM 錯誤碼。這種情況就需要將限制調大,或者用 ulimit -l unlimited 來臨時解 決。RLIMIT_MEMLOCK 主要是針對非特權使用者施加限制。根據實際場景不同,為特權 使用者設定一個較高的閾值通常是可以接受的。

3 程式型別

寫作本文時,一共有 18 種不同的 BPF 程式型別,本節接下來進一步介紹其中兩種和 網路相關的型別,即 XDP BPF 程式和 tc BPF 程式。這兩種型別的程式在 LLVM、 iproute2 和其他工具中使用的例子已經在前一節“工具鏈”中介紹過了。本節將關注其架 構、概念和使用案例。

3.1 XDP

XDP(eXpress Data Path)提供了一個核心態、高效能、可程式設計 BPF 包處理框架(a framework for BPF that enables high-performance programmable packet processing in the Linux kernel)。這個框架在軟體中最早可以處理包的位置(即網絡卡驅動收到包的 時刻)執行 BPF 程式。

XDP hook 位於網路驅動的快速路徑上,XDP 程式直接從接收緩衝區(receive ring)中將 包拿下來,無需執行任何耗時的操作,例如分配 skb 然後將包推送到網路協議棧,或者 將包推送給 GRO 引擎等等。因此,只要有 CPU 資源,XDP BPF 程式就能夠在最早的位置執 行處理。

XDP 和 Linux 核心及其基礎設施協同工作,這意味著 XDP 並不會繞過(bypass)核心 ;作為對比,很多完全執行在使用者空間的網路框架(例如 DPDK)是繞過核心的。將包留在 核心空間可以帶來幾方面重要好處:

XDP 可以複用所有上游開發的核心網路驅動、使用者空間工具,以及其他一些可用的核心 基礎設施,例如 BPF 輔助函式在呼叫自身時可以使用系統路由表、socket 等等。 因為駐留在核心空間,因此 XDP 在訪問硬體時與核心其他部分有相同的安全模型。 無需跨核心/使用者空間邊界,因為正在被處理的包已經在核心中,因此可以靈活地將 其轉發到核心內的其他實體,例如容器的名稱空間或核心網路棧自身。Meltdown 和 Spectre 漏洞尤其與此相關(Spectre 論文中一個例子就是用 ebpf 實現的,譯者注 )。 將包從 XDP 送到核心中非常簡單,可以複用核心中這個健壯、高效、使用廣泛的 TCP/IP 協議棧,而不是像一些使用者態框架一樣需要自己維護一個獨立的 TCP/IP 協 議棧。 基於 BPF 可以實現核心的完全可程式設計,保持 ABI 的穩定,保持核心的系統呼叫 ABI “永遠不會破壞使用者空間的相容性”(never-break-user-space)的保證。而且,與核心 模組(modules)方式相比,它還更加安全,這來源於 BPF 校驗器,它能保證核心操作 的穩定性。 XDP 輕鬆地支援在執行時(runtime)原子地建立(spawn)新程式,而不會導致任何網 絡流量中斷,甚至不需要重啟核心/系統。 XDP 允許對負載進行靈活的結構化(structuring of workloads),然後整合到核心。例 如,它可以工作在“不停輪詢”(busy polling)或“中斷驅動”(interrupt driven)模 式。不需要顯式地將專門 CPU 分配給 XDP。沒有特殊的硬體需求,它也不依賴 hugepage(大頁)。 XDP 不需要任何第三方核心模組或許可(licensing)。它是一個長期的架構型解決 方案(architectural solution),是 Linux 核心的一個核心元件,而且是由核心社 區開發的。 主流發行版中,4.8+ 的核心已經內建並啟用了 XDP,並支援主流的 10G 及更高速網路 驅動。

作為一個在驅動中執行 BPF 的框架,XDP 還保證了包是線性放置並且可以匹配到單 個 DMA 頁面,這個頁面對 BPF 程式來說是可讀和可寫的。XDP 還提供了額外的 256 字 節 headroom 給 BPF 程式,後者可以利用 bpf_xdp_adjust_head() 輔助函式實現自定義 封裝頭,或者通過 bpf_xdp_adjust_meta() 在包前面新增自定義元資料。

下一節會深入介紹 XDP 動作碼(action code),BPF 程式會根據返回的動作碼來指導驅動 接下來應該對這個包做什麼,而且它還使得我們可以原子地替換執行在 XDP 層的程式。XDP 在設計上就是定位於高效能場景的。BPF 允許以“直接包訪問”(direct packet access)的 方式訪問包中的資料,這意味著程式直接將資料的指標放到了暫存器中,然後將內容載入 到暫存器,相應地再將內容從暫存器寫到包中。

資料包在 XDP 中的表示形式是 xdp_buff,這也是傳遞給 BPF 程式的結構體(BPF 上下 文):

struct xdp_buff { void *data; void *data_end; void *data_meta; void *data_hard_start; struct xdp_rxq_info *rxq; };

data 指向頁面(page)中包資料的起始位置,從名字可以猜出,data_end 執行包資料 的結尾位置。XDP 支援 headroom,因此 data_hard_start 指向頁面中最大可能的 headroom 開始位置,即,當對包進行封裝(加 header)時,data 會逐漸向 data_hard_start 靠近,這是通過 bpf_xdp_adjust_head() 實現的,該輔助函式還支 持解封裝(去 header)。

data_meta 開始時指向與 data 相同的位置,bpf_xdp_adjust_meta() 能夠將其朝著 data_hard_start 移動,這樣可以給自定義元資料提供空間,這個空間對核心網 絡棧是不可見的,但對 tc BPF 程式可見,因為 tc 需要將它從 XDP 轉移到 skb。 反之亦然,這個輔助函式也可以將 data_meta 移動到離 data_hard_start 比較遠的位 置,這樣就可以達到刪除或縮小這個自定義空間的目的。 data_meta 還可以單純用於在尾呼叫時傳遞狀態,和 tc BPF 程式中用 skb->cb[] 控 制塊(control block)類似。

這樣,我們就可以得到這樣的結論,對於 struct xdp_buff 中資料包的指標,有: data_hard_start <= data_meta <= data < data_end.

rxq 欄位指向某些額外的、和每個接收佇列相關的元資料:

struct xdp_rxq_info { struct net_device *dev; u32 queue_index; u32 reg_state; } ____cacheline_aligned;

這些元資料是在緩衝區設定時確定的(並不是在 XDP 執行時)。

BPF 程式可以從 netdevice 自身獲取 queue_index 以及其他資訊,例如 ifindex。

BPF 程式返回碼

XDP BPF 程式執行結束後會返回一個判決結果(verdict),告訴驅動接下來如何處理這個 包。在系統標頭檔案 linux/bpf.h 中列出了所有的判決型別。

enum xdp_action { XDP_ABORTED = 0, XDP_DROP, XDP_PASS, XDP_TX, XDP_REDIRECT, };

XDP_DROP 表示立即在驅動層將包丟棄。這樣可以節省很多資源,對於 DDoS mitigation 或通用目的防火牆程式來說這尤其有用。 XDP_PASS 表示允許將這個包送到核心網路棧。同時,當前正在處理這個包的 CPU 會 分配一個 skb,做一些初始化,然後將其送到 GRO 引擎。這是沒有 XDP 時默 認的包處理行為是一樣的。 XDP_TX 是 BPF 程式的一個高效選項,能夠在收到包的網絡卡上直接將包再發送出去。對 於實現防火牆+負載均衡的程式來說這非常有用,因為這些部署了 BPF 的節點可以作為一 個 hairpin (髮卡模式,從同一個裝置進去再出來)模式的負載均衡器叢集,將收到的 包在 XDP BPF 程式中重寫(rewrite)之後直接傳送回去。 XDP_REDIRECT 與 XDP_TX 類似,但是通過另一個網絡卡將包發出去。另外, XDP_REDIRECT 還可以將包重定向到一個 BPF cpumap,即,當前執行 XDP 程式的 CPU 可以將這個包交給某個遠端 CPU,由後者將這個包送到更上層的核心棧,當前 CPU 則繼 續在這個網絡卡執行接收和處理包的任務。這和 XDP_PASS 類似,但當前 CPU 不用去 做將包送到核心協議棧的準備工作(分配 skb,初始化等等),這部分開銷還是很大的。 XDP_ABORTED 表示程式產生異常,其行為和 XDP_DROP,但 XDP_ABORTED 會經過 trace_xdp_exception tracepoint,因此可以通過 tracing 工具來監控這種非正常行為。

XDP 使用案例

本節列出了 XDP 的幾種主要使用案例。這裡列出的並不全,而且考慮到 XDP 和 BPF 的可 程式設計性和效率,人們能容易地將它們適配到其他領域。

DDoS 防禦、防火牆

XDP BPF 的一個基本特性就是用 XDP_DROP 命令驅動將包丟棄,由於這個丟棄的位置

非常早,因此這種方式可以實現高效的網路策略,平均到每個包的開銷非常小( per-packet cost)。這對於那些需要處理任何形式的 DDoS 攻擊的場景來說是非常理 想的,而且由於其通用性,使得它能夠在 BPF 內實現任何形式的防火牆策略,開銷幾乎為零, 例如,作為 standalone 裝置(例如通過 XDP_TX 清洗流量);或者廣泛部署在節點 上,保護節點的安全(通過 XDP_PASS 或 cpumap XDP_REDIRECT 允許“好流量”經 過)。

Offloaded XDP 更進一步,將本來就已經很小的 per-packet cost 全部下放到網絡卡以

線速(line-rate)進行處理。

轉發和負載均衡

XDP 的另一個主要使用場景是包轉發和負載均衡,這是通過 XDP_TX 或

XDP_REDIRECT 動作實現的。

XDP 層執行的 BPF 程式能夠任意修改(mangle)資料包,即使是 BPF 輔助函式都能增

加或減少包的 headroom,這樣就可以在將包再次傳送出去之前,對包進行任何的封裝/解封裝。

利用 XDP_TX 能夠實現 hairpinned(髮卡)模式的負載均衡器,這種均衡器能夠

在接收到包的網絡卡再次將包傳送出去,而 XDP_REDIRECT 動作能夠將包轉發到另一個 網絡卡然後傳送出去。

XDP_REDIRECT 返回碼還可以和 BPF cpumap 一起使用,對那些目標是本機協議棧、

將由 non-XDP 的遠端(remote)CPU 處理的包進行負載均衡。

棧前(Pre-stack)過濾/處理

除了策略執行,XDP 還可以用於加固核心的網路棧,這是通過 XDP_DROP 實現的。

這意味著,XDP 能夠在可能的最早位置丟棄那些與本節點不相關的包,這個過程發生在 核心網路棧看到這些包之前。例如假如我們已經知道某臺節點只接受 TCP 流量,那任 何 UDP、SCTP 或其他四層流量都可以在發現後立即丟棄。

這種方式的好處是包不需要再經過各種實體(例如 GRO 引擎、核心的

flow dissector 以及其他的模組),就可以判斷出是否應該丟棄,因此減少了核心的 受攻擊面。正是由於 XDP 的早期處理階段,這有效地對核心網路棧“假裝”這些包根本 就沒被網路裝置看到。

另外,如果核心接收路徑上某個潛在 bug 導致 ping of death 之類的場景,那我們能

夠利用 XDP 立即丟棄這些包,而不用重啟核心或任何服務。而且由於能夠原子地替換 程式,這種方式甚至都不會導致宿主機的任何流量中斷。

棧前處理的另一個場景是:在核心分配 skb 之前,XDP BPF 程式可以對包進行任意

修改,而且對核心“假裝”這個包從網路裝置收上來之後就是這樣的。對於某些自定義包 修改(mangling)和封裝協議的場景來說比較有用,在這些場景下,包在進入 GRO 聚 合之前會被修改和解封裝,否則 GRO 將無法識別自定義的協議,進而無法執行任何形 式的聚合。

XDP 還能夠在包的前面 push 元資料(非包內容的資料)。這些元資料對常規的核心棧

是不可見的(invisible),但能被 GRO 聚合(匹配元資料),稍後可以和 tc ingress BPF 程 序一起處理,tc BPF 中攜帶了 skb 的某些上下文,例如,設定了某些 skb 欄位。

流抽樣(Flow sampling)和監控

XDP 還可以用於包監控、抽樣或其他的一些網路分析,例如作為流量路徑中間節點

的一部分;或執行在終端節點上,和前面提到的場景相結合。對於複雜的包分析,XDP 提供了設施來高效地將網路包(截斷的或者是完整的 payload)或自定義元資料 push 到 perf 提供的一個快速、無鎖、per-CPU 記憶體對映緩衝區,或者是一 個使用者空間應用。

這還可以用於流分析和監控,對每個流的初始資料進行分析,一旦確定是正常流量,這個流隨

後的流量就會跳過這個監控。感謝 BPF 帶來的靈活性,這使得我們可以實現任何形式 的自定義監控或採用。

XDP BPF 在生產環境使用的一個例子是 Facebook 的 SHIV 和 Droplet 基礎設施,實現了 它們的 L4 負載均衡和 DDoS 測量。從基於 netfilter 的 IPV(IP Virtual Server)遷移到 XDP BPF 使它們的生產基礎設施獲得了 10x 的效能提升。這方面 的工作最早在 netdev 2.1 大會上做了分享:

演講 Slides 演講影片

另一個例子是 Cloudflare 將 XDP 整合到它們的 DDoS 防禦流水線中,替換了原來基於 cBPF 加 iptables 的 xt_bpf 模組所做的簽名匹配(signature matching)。 基於 iptables 的版本在發生攻擊時有嚴重的效能問題,因此它們考慮了基於使用者態、 bypass 核心的一個方案,但這種方案也有自己的一些缺點,並且需要不停輪詢(busy poll )網絡卡,並且在將某些包重新注入核心協議棧時代價非常高。遷移到 eBPF/XDP 之後,兩種 方案的優點都可以利用到,直接在核心中實現了高效能、可程式設計的包處理過程:

Slides Video

XDP 工作模式

XDP 有三種工作模式,預設是 native(原生)模式,當討論 XDP 時通常隱含的都是指這 種模式。

Native XDP

預設模式,在這種模式中,XDP BPF 程式直接執行在網路驅動的早期接收路徑上(

early receive path)。大部分廣泛使用的 10G 及更高速的網絡卡都已經支援這種模式 。

Offloaded XDP

在這種模式中,XDP BPF 程式直接 offload 到網絡卡,而不是在主機的 CPU 上執行。

因此,本來就已經很低的 per-packet 開銷完全從主機下放到網絡卡,能夠比執行在 native XDP 模式取得更高的效能。這種 offload 通常由智慧網絡卡實現,這些網絡卡有多 執行緒、多核流處理器(flow processors),一個位於核心中的 JIT 編譯器( in-kernel JIT compiler)將 BPF 翻譯成網絡卡的原生指令。

支援 offloaded XDP 模式的驅動通常也支援 native XDP 模式,因為 BPF 輔助函式可

能目前還只支援後者。

Generic XDP

對於還沒有實現 native 或 offloaded XDP 的驅動,核心提供了一個 generic XDP 選

項,這種模式不需要任何驅動改動,因為相應的 XDP 程式碼執行在網路棧很後面的一個 位置(a much later point)。

這種設定主要面向的是用核心的 XDP API 來編寫和測試程式的開發者,並且無法達到

前面兩種模式能達到的效能。對於在生產環境使用 XDP,推薦要麼選擇 native 要麼選擇 offloaded 模式。

驅動支援

由於 BPF 和 XDP 的特性和驅動支援還在快速發展和變化,因此這裡的列表只統計到了 4.17 核心支援的 native 和 offloaded XDP 驅動。

支援 native XDP 的驅動

Broadcom


  bnxt



Cavium


  thunderx



Intel


  ixgbe
  ixgbevf
  i40e



Mellanox


  mlx4
  mlx5



Netronome


  nfp



Others


  tun
  virtio_net



Qlogic


  qede



Solarflare


  sfc (XDP for sfc available via out of tree driver as of kernel 4.17, but

will be upstreamed soon)

支援 offloaded XDP 的驅動

Netronome


  nfp (Some BPF helper functions such as retrieving the current CPU number

will not be available in an offloaded setting)

3.2 tc

除了 XDP 等型別的程式之外,BPF 還可以用於核心資料路徑的 tc (traffic control,流 量控制)層。

tc 和 XDP BPF 程式的不同

從高層看,tc BPF 程式和 XDP BPF 程式有三點主要不同:

  1. 輸入上下文

BPF 的輸入上下文(input context)是一個 sk_buff 而不是 xdp_buff。當核心 協議棧收到一個包時(說明包通過了 XDP 層),它會分配一個緩衝區,解析包,並存儲包 的元資料。表示這個包的結構體就是 sk_buff。這個結構體會暴露給 BPF 輸入上下文, 因此 tc ingress 層的 BPF 程式就可以利用這些(由協議棧提取的)包的元資料。這些元 資料很有用,但在包達到 tc 的 hook 點之前,協議棧執行的緩衝區分配、元資料提取和 其他處理等過程也是有開銷的。從定義來看,xdp_buff 不需要訪問這些元資料,因為 XDP hook 在協議棧之前就會被呼叫。這是 XDP 和 tc hook 效能差距的重要原因之一 。

因此,attach 到 tc BPF hook 的 BPF 程式可以讀取 skb 的 mark、pkt_type、 protocol、priority、queue_mapping、napi_id、cb[]、hash、tc_classid 、tc_index、vlan 元資料、XDP 層傳過來的自定義元資料以及其他資訊。 tc BPF 的 BPF 上下文中使用了 struct __sk_buff,這個結構體中的所有成員欄位都定 義在 linux/bpf.h 系統標頭檔案。

通常來說,sk_buff 和 xdp_buff 完全不同,二者各有有略。例如,sk_buff 修改 與其關聯的元資料(its associated metadata)非常方便,但它包含了大量協議相關的信 息(例如 GSO 相關的狀態),這使得無法僅僅通過重寫包資料來切換協議(switch protocols by solely rewriting the packet data)。這是因為協議棧是基於元資料處 理包的,而不是每次都去讀包的內容。因此,BPF 輔助函式需要額外的轉換,並且還要正 確處理 sk_buff 內部資訊。xdp_buff 沒有這些問題,因為它所處的階段非常早,此時 核心還沒有分配 sk_buff,因此很容易實現各種型別的資料包重寫(packet rewrite)。 但是,xdp_buff 的缺點是在它這個階段進行 mangling 的時候,無法利用到 sk_buff 元資料。解決這個問題的方式是從 XDP BPF 傳遞自定義的元資料到 tc BPF。這樣,根據使 用場景的不同,可以同時利用這兩者 BPF 程式,以達到互補的效果。

  1. hook 觸發點

tc BPF 程式在資料路徑上的 ingress 和 egress 點都可以觸發;而 XDP BPF 程式 只能在 ingress 點觸發。

核心兩個 hook 點:

ingress hook sch_handle_ingress():由 __netif_receive_skb_core() 觸發 egress hook sch_handle_egress():由 __dev_queue_xmit() 觸發

__netif_receive_skb_core() 和 __dev_queue_xmit() 是 data path 的主要接收和 傳送函式,不考慮 XDP 的話(XDP 可能會攔截或修改,導致不經過這兩個 hook 點), 每個網路進入或離開系統的網路包都會經過這兩個點,從而使得 tc BPF 程式具備完全可 觀測性。

  1. 是否依賴驅動支援

tc BPF 程式不需要驅動做任何改動,因為它們執行在網路棧通用層中的 hook 點。因 此,它們可以 attach 到任何型別的網路裝置上。

Ingress

這提供了很好的靈活性,但跟執行在原生 XDP 層的程式相比,效能要差一些。然而,tc BPF 程式仍然是核心的通用 data path 做完 GRO 之後、且處理任何協議之前 最早的 處理點。傳統的 iptables 防火牆也是在這裡處理的,例如 iptables PREROUTING 或 nftables ingress hook 或其他資料包包處理過程。

However, tc BPF programs still come at the earliest point in the generic kernel’s networking data path after GRO has been run but before any protocol processing, traditional iptables firewalling such as iptables PREROUTING or nftables ingress hooks or other packet processing takes place.

Egress

類似的,對於 egress,tc BPF 程式在將包交給驅動之前的最晚的地方(latest point)執 行,這個地方在傳統 iptables 防火牆 hook 之後(例如 iptables POSTROUTING), 但在核心 GSO 引擎之前。

Likewise on egress, tc BPF programs execute at the latest point before handing the packet to the driver itself for transmission, meaning after traditional iptables firewalling hooks like iptables POSTROUTING, but still before handing the packet to the kernel’s GSO engine.

唯一需要驅動做改動的場景是:將 tc BPF 程式 offload 到網絡卡。形式通常和 XDP offload 類似,只是特性列表不同,因為二者的 BPF 輸入上下文、輔助函式和返回碼( verdict)不同。

cls_bpf 分類器

執行在 tc 層的 BPF 程式使用的是 cls_bpf 分類器。在 tc 術語中 “BPF 附著點”被 稱為“分類器”,但這個詞其實有點誤導,因為它少描述了(under-represent)前者可以 做的事情。attachment point 是一個完全可程式設計的包處理器,不僅能夠讀取 skb 元資料 和包資料,還可以任意 mangle 這兩者,最後結束 tc 處理過程,返回一個裁定結果( verdict)。因此,cls_bpf 可以認為是一個管理和執行 tc BPF 程式的自包含實體( self-contained entity)。

cls_bpf 可以持有(hold)一個或多個 tc BPF 程式。Cilium 在部署 cls_bpf 程式時 ,對於一個給定的 hook 點只會附著一個程式,並且用的是 direct-action 模式。 典型情況下,在傳統 tc 方案中,分類器(classifier )和動作模組(action modules) 之間是分開的,每個分類器可以 attach 多個 action,當匹配到這個分類器時這些 action 就會執行。在現代世界,在軟體 data path 中使用 tc 做複雜包處理時這種模型擴充套件性不好。 考慮到附著到 cls_bpf 的 tc BPF 程式 是完全自包含的,因此它們有效地將解析和 action 過程融合到了單個單元(unit)中。得 益於 cls_bpf 的 direct-action 模式,它只需要返回 tc action 判決結果,然後立即 終止處理流水線。這使得能夠在網路 data path 中實現可擴充套件可程式設計的包處理,避免動作 的線性迭代。cls_bpf 是 tc 層中唯一支援這種快速路徑(fast-path)的一個分類器模組。

和 XDP BPF 程式類似,tc BPF 程式能在執行時(runtime)通過 cls_bpf 原子地更新, 而不會導致任何網路流量中斷,也不用重啟服務。

cls_bpf 可以附著的 tc ingress 和 egress hook 點都是由一個名為 sch_clsact 的 偽 qdisc 管理的,它是 ingress qdisc 的一個超集(superset),可以無縫替換後 者,因為它既可以管理 ingress tc hook 又可以管理 egress tc hook。對於 __dev_queue_xmit() 內的 tc egress hook,需要注意的是這個 hook 並不是在核心的 qdisc root lock 下執行的。因此,ingress 和 egress hook 都是在快速路徑中以無鎖( lockless)方式執行的。不管是 ingress 還是 egress,搶佔(preemption )都被關閉, 執行發生在 RCU 讀側(execution happens under RCU read side)。

通常在 egress 的場景下,有很多型別的 qdisc 會 attach 到 netdevice,例如 sch_mq, sch_fq, sch_fq_codel or sch_htb,其中某些是 classful qdiscs,這些 qdisc 包 含 subclasses 因此需要一個對包進行分類的機制,決定將包 demux 到哪裡。這個機制是 由呼叫 tcf_classify() 實現的,這個函式會進一步呼叫 tc 分類器(如果提供了)。在 這種場景下, cls_bpf 也可以被 attach 和使用。這種操作通常發生在 qdisc root lock 下面,因此會面臨鎖競爭的問題。sch_clsact qdisc 的 egress hook 點位於更前 面,沒有落入這個鎖的範圍內,因此完全獨立於常規 egress qdisc 而執行。 因此對於 sch_htb 這種場景,sch_clsact qdisc 可以將繁重的包分類工作放到 tc BPF 程式,在 qdisc root lock 之外執行,在這些 tc BPF 程式中設定 skb->mark 或 skb->priority ,因此隨後 sch_htb 只需要一個簡單的對映,沒有原來在 root lock 下面昂貴的包分類開銷,還減少了鎖競爭。

在 sch_clsact in combination with cls_bpf 場景下支援 Offloaded tc BPF 程式, 在這種場景下,原來載入到智慧網絡卡驅動的 BPF 程式被 JIT,在網絡卡原生執行。 只有工作在 direct-action 模式的 cls_bpf 程式支援 offload。 cls_bpf 只支援 offload 單個程式,不支援同時 offload 多個程式。另外,只有 ingress hook 支援 offloading BPF 程式。

一個 cls_bpf 例項內部可以 hold 多個 tc BPF 程式。如果由多個程式, TC_ACT_UNSPEC 程式返回碼就是讓繼續執行列表中的下一個程式。但這種方式的缺點是: 每個程式都需要解析一遍資料包,效能會下降。

tc BPF 程式返回碼

tc ingress 和 egress hook 共享相同的返回碼(動作判決),定義在 linux/pkt_cls.h 系統標頭檔案:

#define TC_ACT_UNSPEC (-1) #define TC_ACT_OK 0 #define TC_ACT_SHOT 2 #define TC_ACT_STOLEN 4 #define TC_ACT_REDIRECT 7

系統標頭檔案中還有一些 TC_ACT_* 動作判決,也用在了這兩個 hook 中。但是,這些判決 和上面列出的那幾個共享相同的語義。這意味著,從 tc BPF 的角度看, TC_ACT_OK 和 TC_ACT_RECLASSIFY 有相同的語義, TC_ACT_STOLEN, TC_ACT_QUEUED and TC_ACT_TRAP 返回碼也是類似的情況。因此, 對於這些情況,我們只描述 TC_ACT_OK 和 TC_ACT_STOLEN 操作碼。

TC_ACT_UNSPEC 和 TC_ACT_OK

TC_ACT_UNSPEC 表示“未指定的動作”(unspecified action),在三種情況下會用到:

attach 了一個 offloaded tc BPF 程式,tc ingress hook 正在執行,被 offload 的 程式的 cls_bpf 表示會返回 TC_ACT_UNSPEC 為了在 cls_bpf 多程式的情況下,繼續下一個 tc BPF 程式。這種情況可以和 第一種情況中提到的 offloaded tc BPF 程式一起使用,此時第一種情況返回的 TC_ACT_UNSPEC 繼續執行下一個沒有被 offloaded BPF 程式? TC_ACT_UNSPEC 還用於單個程式從場景,只是通知核心繼續執行 skb 處理,但不要帶 來任何副作用(without additional side-effects)。

TC_ACT_UNSPEC 在某些方面和 TC_ACT_OK 非常類似,因為二者都是將 skb 向下一個 處理階段傳遞,在 ingress 的情況下是傳遞給核心協議棧的更上層,在 egress 的情況下 是傳遞給網路裝置驅動。唯一的不同是 TC_ACT_OK 基於 tc BPF 程式設定的 classid 來 設定 skb->tc_index,而 TC_ACT_UNSPEC 是通過 tc BPF 程式之外的 BPF 上下文中的 skb->tc_classid 設定。

TC_ACT_SHOT 和 TC_ACT_STOLEN

這兩個返回碼指示核心將包丟棄。這兩個返回碼很相似,只有少數幾個區別:

TC_ACT_SHOT 提示核心 skb 是通過 kfree_skb() 釋放的,並返回 NET_XMIT_DROP 給呼叫方,作為立即反饋 TC_ACT_STOLEN 通過 consume_skb() 釋放 skb,返回 NET_XMIT_SUCCESS 給上 層假裝這個包已經被正確傳送了

perf 的丟包監控(drop monitor)是跟蹤的 kfree_skb(),因此在 TC_ACT_STOLEN 的 場景下它無法看到任何丟包統計,因為從語義上說,此時這些 skb 是被”consumed” 或 queued 而不是被 dropped。

TC_ACT_REDIRECT

這個返回碼加上 bpf_redirect() 輔助函式,允許重定向一個 skb 到同一個或另一個 裝置的 ingress 或 egress 路徑。能夠將包注入另一個裝置的 ingress 或 egress 路徑使 得基於 BPF 的包轉發具備了完全的靈活性。對目標網路裝置沒有額外的要求,只要本身是 一個網路裝置就行了,在目標裝置上不需要執行 cls_bpf 例項或其他限制。

tc BPF FAQ

本節列出一些經常被問的、與 tc BPF 程式有關的問題。

用 act_bpf 作為 tc action module 怎麼樣,現在用的還多嗎?

不多。雖然對於 tc BPF 程式來說 cls_bpf 和 act_bpf 有相同的功能

,但前者更加靈活,因為它是後者的一個超集(superset)。tc 的工作原理是將 tc actions attach 到 tc 分類器。要想實現與 cls_bpf 一樣的靈活性,act_bpf 需要 被 attach 到 cls_matchall 分類器。如名字所示,為了將包傳遞給 attached tc action 去處理,這個分類器會匹配每一個包。相比於工作在 direct-action 模式的 cls_bpf,act_bpf 這種方式會導致較低的包處理效能。如果 act_bpf 用在 cls_bpf or cls_matchall 之外的其他分類器,那效能會更差,這是由 tc 分類器的 操作特性(nature of operation of tc classifiers)決定的。同時,如果分類器 A 未 匹配,那包會傳給分類器 B,B 會重新解析這個包以及重複後面的流量,因此這是一個線 性過程,在最壞的情況下需要遍歷 N 個分類器才能匹配和(在匹配的分類器上)執行 act_bpf。因此,act_bpf 從未大規模使用過。另外,和 cls_bpf 相比, act_bpf 也沒有提供 tc offload 介面。

是否推薦在使用 cls_bpf 時選擇 direct-action 之外的其他模式?

不推薦。原因和上面的問題類似,選擇其他模式無法應對更加複雜的處理情況。tc BPF

程式本身已經能以一種高效的方式做任何處理,因此除了 direct-action 這個模式 之外,不需要其他的任何東西了。

offloaded cls_bpf 和 offloaded XDP 有效能差異嗎?

沒有。二者都是由核心內的同一個編譯器 JIT 的,這個編譯器負責 offload 到智慧網

卡以及,並且對二者的載入機制是非常相似的。因此,要在 NIC 上原生執行,BPF 程 序會被翻譯成相同的目標指令。

tc BPF 和 XDP BPF 這兩種程式型別有不同的特性集合,因此根據使用場景的不同,你

可以選擇 tc BPF 或者是 XDP BPF,例如,二者的在 offload 場景下的輔助函式可能 會有差異。

tc BPF 使用案例

本節列出了 tc BPF 程式的主要使用案例。但要注意,這裡列出的並不是全部案例,而且考 慮到 tc BPF 的可程式設計性和效率,人們很容易對它進行定製化(tailor)然後整合到編排系 統,用來解決特定的問題。XDP 的一些案例可能有重疊,但 tc BPF 和 XDP BPF 大部分情 況下都是互補的,可以單獨使用,也可以同時使用,就看哪種情況更適合解決給定的問題了 。

為容器落實策略(Policy enforcement)

tc BPF 程式適合用來給容器實現安全策略、自定義防火牆或類似的安全工具。在傳統方

式中,容器隔離是通過網路名稱空間時實現的,veth pair 的一端連線到宿主機的初始命 名空間,另一端連線到容器的名稱空間。因為 veth pair 的 一端移動到了容器的名稱空間,而另一端還留在宿主機上(預設名稱空間),容器所有的 網路流量都需要經過主機端的 veth 裝置,因此可以在這個 veth 裝置的 tc ingress 和 egress hook 點 attach tc BPF 程式。目標地址是容器的網路流量會經過主機端的 veth 的 tc egress hook,而從容器出來的網路流量會經過主機端的 veth 的 tc ingress hook。

對於像 veth 這樣的虛擬裝置,XDP 在這種場景下是不合適的,因為核心在這裡只操作

skb,而通用 XDP 有幾個限制,導致無法操作克隆的 skb。而克隆 skb 在 TCP/IP 協議棧中用的非常多,目的是持有(hold)準備重傳的資料片(data segments),而通 用 XDP hook 在這種情況下回被直接繞過。另外,generic XDP 需要順序化(linearize )整個 skb 導致嚴重的效能下降。相比之下, tc BPF 非常靈活,因為設計中它就是工作在接 收 skb 格式的輸入上下文中,因此沒有 generic XDP 遇到的那些問題。

轉發和負載均衡

轉發和負載均衡的使用場景和 XDP 很類似,只是目標更多的是在東西向容器流量而不是

南北向(雖然兩者都可以用於東西向或南北向場景)。XDP 只能在 ingress 方向使用, tc BPF 程式還可以在 egress 方向使用,例如,可以在初始名稱空間內(宿主機上的 veth 裝置上),通過 BPF 對容器的 egress 流量同時做地址轉化(NAT)和負載均衡, 整個過程對容器是透明的。由於在核心網路棧的實現中,egress 流量已經是 sk_buff 形式的了,因此很適合 tc BPF 對其進行重寫(rewrite)和重定向(redirect)。 使用 bpf_redirect() 輔助函式,BPF 就可以接管轉發邏輯,將包推送到另一個網路設 備的 ingress 或 egress 路徑上。因此,有了 tc BPF 程式實現的轉發網格( forwarding fabric),網橋裝置都可以不用了。

流抽樣(Flow sampling)、監控

和 XDP 類似,可以通過高效能無鎖 per-CPU 記憶體對映 perf 環形緩衝區(ring buffer

)實現流抽樣(flow sampling)和監控,在這種場景下,BPF 程式能夠將自定義資料、 全部或截斷的包內容或者二者同時推送到一個使用者空間應用。在 tc BPF 程式中這是通過 bpf_skb_event_output() BPF 輔助函式實現的,它和 bpf_xdp_event_output() 有相 同的函式簽名和語義。

考慮到 tc BPF 程式可以同時 attach 到 ingress 和 egress,而 XDP 只能 attach 到

ingress,另外,這兩個 hook 都在(通用)網路棧的更低層,這使得可以監控每臺節點 的所有雙向網路流量。這和 tcpdump 和 Wireshark 使用的 cBPF 比較相關,但是,不 需要克隆 skb,而且因為其可程式設計性而更加靈活,例如。BPF 能夠在核心中完成聚合 ,而不用將所有資料推送到使用者空間;也可以對每個放到 ring buffer 的包新增自定義 的 annotations。Cilium 大量使用了後者,對被 drop 的包進一步 annotate,關聯到 容器標籤以及 drop 的原因(例如因為違反了安全策略),提供了更豐富的資訊。

包排程器預處理(Packet scheduler pre-processing)

sch_clsact’s egress hook 被 sch_handle_egress() 呼叫,在獲得核心的 qdisc

root lock 之前執行,因此 tc BPF 程式可以在包被髮送到一個真實的 full blown qdis (例如 sch_htb)之前,用來執行包分類和 mangling 等所有這些高開銷工作。 這種 sch_clsact 和後面的傳送階段的真實 qdisc(例如 sch_htb) 之間的互動, 能夠減少傳送時的鎖競爭,因為 sch_clsact 的 egress hook 是在無鎖的上下文中執行的。

同時使用 tc BPF 和 XDP BPF 程式的一個具體例子是 Cilium。Cilium 是一個開源軟體, 透明地對(K8S 這樣的容器編排平臺中的)容器之間的網路連線進行安全保護,工作在 L3/L4/L7。Cilium 的核心基於 BPF,用來實現安全策略、負載均衡和監控。

Slides Video Github

驅動支援

由於 tc BPF 程式是從核心網路棧而不是直接從驅動觸發的,因此它們不需要任何額外的驅 動改動,因此可以執行在任何網路裝置之上。唯一的例外是當需要將 tc BPF 程式 offload 到網絡卡時。

支援 offload tc BPF 程式的驅動

Netronome


  nfp







 
  « BPF 進階筆記(三):BPF Map 核心實現