[譯] LLVM eBPF 彙編編程(2020)

語言: CN / TW / HK

[譯] LLVM eBPF 彙編編程(2020) Published at 2021-08-15 | Last Update 2021-08-15

譯者序

本文翻譯自 2020 年 Quentin Monnet 的一篇英文博客: eBPF assembly with LLVM。 Quentin Monnet 是 Cilium 開發者之一,此前也在從事網絡、eBPF 相關的開發。

文章介紹瞭如何直接基於 LLVM eBPF 彙編開發 BPF 程序,雖然給出的 兩個例子極其簡單,但開發更大的程序,流程也是類似的。為什麼不用 C,而用匯編 這麼不友好的編程方式呢?至少有兩個特殊場景:

測試特定的 eBPF 指令流 對程序的某個特定部分進行深度調優

原文歷時(開頭之後拖延)了好幾年,因此文中存在一些(文件名等)前後不一致之處,翻譯時已經改正; 另外,譯文基於 clang/llvm 10.0 驗證了其中的每個步驟,因此代碼、輸出等與原文不完全一致。

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

以下是譯文。

譯者序 1 引言

1.1 主流開發方式:從 C 代碼直接生成 eBPF 字節碼 1.2 特殊場景需求:eBPF 彙編編程更合適 1.3 幾種 eBPF 彙編編程方式

2 Clang/LLVM 編譯 eBPF 基礎

2.1 將 C 程序編譯成 BPF 目標文件 1.2 查看 ELF 文件中的 eBPF 字節碼

3 方式一:C 生成 eBPF 彙編 + 手工修改彙編

3.1 將 C 編譯成 eBPF 彙編(clang) 3.2 手工修改彙編程序 3.3 將彙編程序 assemble 成 ELF 對象文件(llvm-mc) 3.4 查看對象文件中的 eBPF 字節碼(readelf) 3.5 以更加人類可讀的方式查看 eBPF 字節碼(llvm-objdump -d) 3.6 編譯時嵌入調試符號或 C 源碼(clang -g + llvm-objdump -S)

4 方式二:內聯彙編(inline assembly)

4.1 C 內聯彙編示例 4.2 編譯及查看生成的字節碼 4.3 小結

5 結束語

1 引言

1.1 主流開發方式:從 C 代碼直接生成 eBPF 字節碼

eBPF 相比於 cBPF(經典 BPF)的優勢之一是:Clang/LLVM 為它提供了一個編譯後端, 能從 C 源碼直接生成 eBPF 字節碼(bytecode)。(寫作本文時,GCC 也提供了一個類似 的後端,但各方面都沒有 Clang/LLVM 完善,因此後者仍然是生成 eBPF 字節碼 的最佳參考工具)。

將 C 代碼編譯成 eBPF 目標文件非常有用,因為 直接用字節碼編寫高級程序是非常耗時的。此外,截至本文寫作時, 還無法直接編寫字節碼程序來使用 CO-RE 等複雜特性。

(譯) BPF 可移植性和 CO-RE(一次編譯,到處運行)(Facebook,2020)。 譯註。

因此,Clang 和 LLVM 仍然是 eBPF 工作流不可或缺的部分。

1.2 特殊場景需求:eBPF 彙編編程更合適

但是,C 方式不適用於某些特殊的場景,例如:

只是想測試特定的 eBPF 指令流 對程序的某個特定部分進行深度調優

在這些情況下,就需要直接編寫或修改 eBFP 彙編程序。

1.3 幾種 eBPF 彙編編程方式

直接編寫 eBPF 字節碼程序。也就是編寫可直接加載運行的

二進制 eBPF 程序,

這肯定是可行的,但過程非常宂長無聊,對開發者極其不友好。
  此外,為保證與 tc 等工具的兼容,還要將寫好的程序轉換成目標文件(object file),因此工作量又多了一些。



直接用 eBPF 彙編語言編寫,然後用專門的彙編器

(例如 ebpf_asm)將其彙編(assemble)成字節碼。

相比字節碼(二進制),彙編語言(文本)至少可讀性還是好很多的。



用 LLVM 將 C 編譯成 eBPF 彙編,然後手動修改生成的彙編程序,

最後再將其彙編(assemble)成字節碼放到對象文件。

在 C 中插入內聯彙編,然後統一用 clang/llvm 編譯。

以上幾種方式 Clang/LLVM 都支持!先用可讀性比較好的方式寫, 然後再將其彙編(assembling)成另字節碼程序。此外,甚至能 dump 對象文件中包含的程序。

本文將會展示第三種和第四種方式,第二種可以認為是第三種的更加徹底版,開發的流程 、步驟等已經包括在第三種了。

2 Clang/LLVM 編譯 eBPF 基礎

在開始彙編編程之前,先來熟悉一下 clang/llvm 將 C 程序編譯成 eBPF 程序的過程。

2.1 將 C 程序編譯成 BPF 目標文件

下面是個 eBPF 程序:沒做任何事情,直接返回零,

$ cat bpf.c int func() { return 0; }

如下命令可以將其編譯成對象文件(目標文件):

注意 target 類型指定為 bpf

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

某些複雜的程序可能需要用下面的命令來編譯:

$ clang -O2 -emit-llvm -c bpf.c -o - |

llc -march=bpf -mcpu=probe -filetype=obj -o bpf.o

以上命令會將 C 源碼編譯成字節碼,然後生成一個 ELF 格式的目標文件。

1.2 查看 ELF 文件中的 eBPF 字節碼

默認情況下,代碼位於 ELF 的 .text 區域(section):

$ readelf -x .text bpf.o Hex dump of section '.text': 0x00000000 b7000000 00000000 95000000 00000000 ................

這就是編譯生成的字節碼!

以上字節碼包含了兩條 eBPF 指令:

b7 0 0 0000 00000000 # r0 = 0 95 0 0 0000 00000000 # exit and return r0

如果對 eBPF 彙編語法不熟悉,可參考:

iovisor/bpf-docs 中的簡潔文檔 更詳細的內核文檔 networking/filter.txt。

有了以上基礎,接下來看如何開發 eBPF 彙編程序。

3 方式一:C 生成 eBPF 彙編 + 手工修改彙編

本節需要 Clang/LLVM 6.0+ 版本(clang -v)。

譯文基於 10.0,結果與原文略有差異。

C 源碼:

$ cat bpf.c int func() { return 0; }

3.1 將 C 編譯成 eBPF 彙編(clang)

其實前面已經看到了,與將普通 C 程序編譯成彙編類似,只是這裏指定 target 類型是 bpf (bpf target 與默認 target 的不同,見 Cilium 文檔 BPF and XDP Reference Guide ):

(譯) Cilium:BPF 和 XDP 參考指南(2021)。 譯註。

$ clang -target bpf -S -o bpf.s bpf.c

查看生成的彙編代碼:

$ cat bpf.s .text .file "bpf.c" .globl func # -- Begin function func .p2align 3 .type func,@function func: # @func

%bb.0:

r0 = 0
    exit

.Lfunc_end0: .size func, .Lfunc_end0-func # -- End function .addrsig

接下來就可以修改這段彙編代碼了。

3.2 手工修改彙編程序

因為彙編程序是文本文件,因此編輯起來很容易。 作為練手,我們在程序最後加上一行彙編指令 r0 = 3:

$ cat bpf.s .text .file "bpf.c" .globl func # -- Begin function func .p2align 3 .type func,@function func: # @func

%bb.0:

r0 = 0
    exit
    r0 = 3                          # -- 這行是我們手動加的

.Lfunc_end0: .size func, .Lfunc_end0-func # -- End function .addrsig

這行放在了 exit 之後,因此實際上沒任何作用。

3.3 將彙編程序 assemble 成 ELF 對象文件(llvm-mc)

接下來將 bpf.s 彙編(assemble)成包含字節碼的 ELF 對象文件。這 裏需要用到 LLVM 自帶的與機器碼(machine code,mc)打交道的工具 llvm-mc:

$ llvm-mc -triple bpf -filetype=obj -o bpf.o bpf.s

bpf.o 就是生成的 ELF 文件!

3.4 查看對象文件中的 eBPF 字節碼(readelf)

查看 bpf.o 中的字節碼:

$ readelf -x .text bpf.o

Hex dump of section '.text': 0x00000000 b7000000 00000000 95000000 00000000 ................ 0x00000010 b7000000 03000000 ........

看到和之前相比,

第一行(包含前兩條指令)一樣, 第二行是新多出來的(對應的正是我們新加的一行彙編指令),作用:將常量 3 load 到寄存器 r0 中。

至此,我們已經成功地修改了指令流。接下來就可以用 bpftool 之 類的工具將這個程序加載到內核,任務完成!

3.5 以更加人類可讀的方式查看 eBPF 字節碼(llvm-objdump -d)

LLVM 還能以人類可讀的方式 dump eBPF 對象文件中的指令,這裏就要用到 llvm-objdump:

-d : alias for --disassemble

--disassemble: display assembler mnemonics for the machine instructions

$ llvm-objdump -d bpf.o bpf.o: file format ELF64-BPF

Disassembly of section .text:

0000000000000000 func: 0: b7 00 00 00 00 00 00 00 r0 = 0 1: 95 00 00 00 00 00 00 00 exit 2: b7 00 00 00 03 00 00 00 r0 = 3

最後一列顯示了對應的 LLVM 使用的彙編指令(也是前面我們手工編輯時使用的 eBPF 指令)。

3.6 編譯時嵌入調試符號或 C 源碼(clang -g + llvm-objdump -S)

除了字節碼和彙編指令,LLVM 還能將調試信息(debug symbols)嵌入到對象文件, 更具體説就是能在字節碼旁邊同時顯示對應的 C 源碼,對調試非常有用,也是 觀察 C 指令如何映射到 eBPF 指令的好機會。

在 clang 編譯時加上 -g 參數:

-g: generate debug information.

$ clang -target bpf -g -S -o bpf.s bpf.c $ llvm-mc -triple bpf -filetype=obj -o bpf.o bpf.s

-S : alias for --source

--source: display source inlined with disassembly. Implies disassemble object

$ llvm-objdump -S bpf.o Disassembly of section .text:

0000000000000000 func: ; int func() { 0: b7 00 00 00 00 00 00 00 r0 = 0 ; return 0; 1: 95 00 00 00 00 00 00 00 exit

注意這裏用的是 -S(顯示源碼),不是 -d(反彙編)。

4 方式二:內聯彙編(inline assembly)

接下來看另一種生成和編譯 eBPF 彙編的方式:直接在 C 程序中嵌入 eBPF 彙編。

4.1 C 內聯彙編示例

下面是個非常簡單的例子,受 Cilium 文檔 BPF and XDP Reference Guide 的啟發:

(譯) Cilium:BPF 和 XDP 參考指南(2021)。 譯註。

$ cat inline_asm.c int func() { unsigned long long foobar = 2, r3 = 3, *foobar_addr = &foobar;

asm volatile("lock *(u64 *)(%0+0) += %1" : // 等價於:foobar += r3
     "=r"(foobar_addr) :
     "r"(r3), "0"(foobar_addr));

return foobar;

}

關鍵字 asm 用於插入彙編代碼。

4.2 編譯及查看生成的字節碼

$ clang -target bpf -Wall -O2 -c inline_asm.c -o inline_asm.o

反彙編:

$ llvm-objdump -d inline_asm.o Disassembly of section .text:

0000000000000000 func: 0: b7 01 00 00 02 00 00 00 r1 = 2 1: 7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1 2: b7 01 00 00 03 00 00 00 r1 = 3 3: bf a2 00 00 00 00 00 00 r2 = r10 4: 07 02 00 00 f8 ff ff ff r2 += -8 5: db 12 00 00 00 00 00 00 lock *(u64 *)(r2 + 0) += r1 6: 79 a0 f8 ff 00 00 00 00 r0 = *(u64 *)(r10 - 8) 7: 95 00 00 00 00 00 00 00 exit

對應到最後一列的彙編,大家應該大致能看懂。

4.3 小結

這種方式的好處是:源碼仍然是 C,因此無需像前一種方式那樣必須手動執行編譯( compile)和彙編(assemble)兩個分開的過程。

5 結束語

本文通過兩個極簡的例子展示了兩種 eBPF 彙編編程方式:

手動生成並修改一段特定的指令流 在 C 中插入內聯彙編

這兩種方式我認為都是有用的,比如在 Netronome,我們經常用前一種方式做單元測試, 檢查 nfp 驅動中的 eBPF hw offload 特性。

LLVM 支持編寫任意的 eBPF 彙編程序(但提醒一下:編譯能通過是一回事,能不能通過校驗器是另一回事)。 有興趣自己試試吧!

« [譯] [論文] XDP (eXpress Data Path):在操作系統內核中實現快速、可編程包處理(ACM,2018)