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

http://arthurchiao.art/blog/cilium-bpf-xdp-reference-guide-zh/

 

譯者序

本文翻譯自 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 架構

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

  1. BPF map:高效的 key/value 存儲
  2. 輔助函數(helper function):可以更方便地利用內核功能或與內核交互
  3. 尾調用(tail call):高效地調用其他 BPF 程序
  4. 安全加固原語(security hardening primitives)
  5. 用於 pin/unpin 對象(例如 map、程序)的僞文件系統(bpffs),實現持久存儲
  6. 支持 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 指令集,其最初的設計目標是:

  1. 用 C 語言的一個子集編寫程序,
  2. 然後用一個編譯器後端(例如 LLVM)將其編譯成 BPF 指令,
  3. 稍後內核再通過一個位於內核中的(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 由下面幾部分組成:

  1. 11 個 64 位寄存器(這些寄存器包含 32 位子寄存器)
  2. 一個程序計數器(program counter,PC)
  3. 一個 512 字節大小的 BPF 棧空間(從實現的層面理解爲什麼有 512 字節的限制, 可參考 (譯) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021),譯註。)

寄存器的名字從 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_64arm64 和其他 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:4source:1 和 class:3

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

可能的指令類別包括:

  • BPF_LDBPF_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_STBPF_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_ALUBPF_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 (<X> := <Y>) 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_64arm64ppc64s390x 、mips64sparc64 和 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 extension 實現對應的支持。

更多關於 Linux BPF extension 的內容,可參考 (譯) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021), 譯註中附錄了一些相關的內核實現。譯註。

而在 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),而不能同時使用兩種方式。

尾調用執行涉及兩個步驟:

  1. 設置一個稱爲“程序數組”(program array)的特殊 map(map 類型 BPF_MAP_TYPE_PROG_ARRAY ),這個 map 可以從用戶空間通過 key/value 操作,
  2. 調用輔助函數 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 用於傳遞參數,返回 結果放到 r0r1 - 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_64arm64ppc64s390xmips64sparc64 和 32 位的 arm 、x86_32 架構都內置了 in-kernel eBPF JIT 編譯器,它們的功能都是一樣的,可 以用如下方式打開:

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

32 位的 mipsppc 和 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),將原來基於立 即數的操作轉換成基於寄存器的操作。指令重寫將加載值的過程分解爲兩部分:

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

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

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

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

  ffffffffa034f5e9 + <x>:
  [...]
  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 + <x>:
  [...]
  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 <kernel-tree>/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 對象文件了。

典型的工作流:

  1. 用 C 編寫 BPF 程序
  2. 用 LLVM 將 C 程序編譯成對象文件(ELF)
  3. 用戶空間 BPF ELF 加載器(例如 iproute2)解析對象文件
  4. 加載器通過 bpf() 系統調用將解析後的對象文件注入內核
  5. 內核驗證 BPF 指令,然後對其執行即時編譯(JIT),返回程序的一個新文件描述符
  6. 利用文件描述符 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_64arm64 或其他),-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";

2. 多個程序可以放在同一 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 中的計數器就會遞增。

3. 不允許全局變量

出於第 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 就可以了。

4. 不支持常量字符串或數組(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()

5. 使用 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() 有某些邊界場景下無法內聯,因此在這 個問題解決之前不推薦使用這個函數。

6. (目前還)不支持循環

內核中的 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 可能會提供一些更加原生、但有一定限制的循環。

7. 尾調用的用途

尾調用能夠從一個程序調到另一個程序,提供了在運行時(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。

8. BPF 最大棧空間 512 字節

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

9. 嘗試使用 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

10. 用 #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.

11. 通過未驗證的引用(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 加載對象文件,以及加載器的一些通用機制。本節 不會覆蓋所有細節,但對於入門來說足夠了。

iproute2/tc 加載 BPF 程序到內核的底層實現,可參考 Firewalling with BPF/XDP: Examples and Deep Dive,譯註。

2.3.1 加載 XDP BPF 對象文件

給定一個爲 XDP 編譯的 BPF 對象文件 prog.o,可以用 ip 命令加載到支持 XDP 的 netdevice em1

$ ip link set dev em1 xdp obj prog.o # 等價於 ip link set dev em1 xdp obj prog.o sec prog

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

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

注意,我們還可以從默認的 .text section 加載程序: 修改程序,從 xdp_drop 入口去掉 __section() 註解(這樣程序默認就會放到 .text 區域):

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

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

    • ingress hook:__netif_receive_skb_core() -> sch_handle_ingress()
    • egress hook:__dev_queue_xmit() -> sch_handle_egress()
  3. 類似地,將程序 attach 到 egress hook 的命令:tc filter add dev em1 egress bpf da obj prog.o
  4. clsact qdisc 在 ingress 和 egress 方向以無鎖(lockless)方式執行, 而且可以 attach 到虛擬的、無隊列的設備(virtual, queue-less devices),例如連接容器和宿主機的 veth 設備。

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

更多關於 da 模式的信息,可參考: (譯) 深入理解 tc ebpf 的 direct-action (da) 模式(2020) 譯註。

配置了這兩條命令之後,程序就 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_OKTC_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 的例子。

  1. 打印更多 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
    
  2. 加載已經 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 是不會覆蓋這個目錄的。因此對於 lwttc 和 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/

這個工具可以完成:

  1. dump 當前已經加載到系統中的所有 BPF 程序和 map
  2. 列出和指定程序相關的所有 BPF map
  3. dump 整個 map 中的 key/value 對
  4. 查看、更新、刪除特定 key
  5. 查看給定 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_clsBPF_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 爲 1820 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.dotbpf_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_<tag>)。如果啓用了 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 + <x>:
   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 + <x>:
   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 程序和 BPF map 佔用的內存是受 RLIMIT_MEMLOCK 限制的。

  • ulimit -l 可以查看當前能鎖定的內存大小,單位是頁面(system pages)。
  • setrlimit() 系統調用的 man page 提供了更多細節。

這個限制通常導致無法加載複雜的 BPF 程序或很大的 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 大會上做了分享:

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

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 的 markpkt_type、 protocolpriorityqueue_mappingnapi_idcb[]hashtc_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 程序,以達到互補的效果。

2. hook 觸發點

tc BPF 程序在數據路徑上的 ingress 和 egress 點都可以觸發;而 XDP BPF 程序 只能在 ingress 點觸發。

內核兩個 hook 點:

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

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

3. 是否依賴驅動支持

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_mqsch_fqsch_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_STOLENTC_ACT_QUEUED and TC_ACT_TRAP 返回碼也是類似的情況。因此, 對於這些情況,我們只描述 TC_ACT_OK 和 TC_ACT_STOLEN 操作碼。

TC_ACT_UNSPEC 和 TC_ACT_OK

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

  1. attach 了一個 offloaded tc BPF 程序,tc ingress hook 正在運行,被 offload 的 程序的 cls_bpf 表示會返回 TC_ACT_UNSPEC
  2. 爲了在 cls_bpf 多程序的情況下,繼續下一個 tc BPF 程序。這種情況可以和 第一種情況中提到的 offloaded tc BPF 程序一起使用,此時第一種情況返回的 TC_ACT_UNSPEC 繼續執行下一個沒有被 offloaded BPF 程序?
  3. 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_bpfact_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,用來實現安全策略、負載均衡和監控。

驅動支持

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

支持 offload tc BPF 程序的驅動

  • Netronome

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章