揭祕 BPF map 前生今世
本文地址: https://www.ebpf.top/post/map_internal
1. 前言
眾所周知,map 可用於核心 BPF 程式和使用者應用程式之間實現雙向的資料交換, 為 BPF 技術中的重要基礎資料結構。
在 BPF 程式中可以通過宣告 struct bpf_map_def
結構完成建立,這其實帶給我們一種錯覺,感覺這和普通的 C 語言變數沒有區別,然而事實真的是這樣的嗎? 事情遠沒有這麼簡單,讀完本文以後相信你會有更大的驚喜。
struct bpf_map_def SEC("maps") my_map = { .type = BPF_MAP_TYPE_ARRAY, // ... };
我們知道最終 BPF 程式是需要在核心中執行,但是 map 資料結構是用於使用者空間和核心 BPF 程式雙向的資料結構,那麼問題來了:
-
通過
struct bpf_map_def
定義的變數究竟是如何建立的,是在使用者空間建立還是核心中直接建立的? -
如何實現建立後的 map 的結構,在使用者空間與核心中 BPF 程式關聯?你可能注意到在使用者空間中對於 map 的訪問是通過 map 檔案控制代碼 fd 完成(型別為 int),但是在 BPF 程式中是通過
struct bpf_map *
結構完成的。
畢竟資料交換跨越了使用者空間和核心空間,本文將從深入淺出為各位看官揭開 map 整個生命管理的 “大瓜”。
2. 簡單的使用樣例
本樣例來自於 samples/bpf/sockex1_user.c 和 sockex1_kern.c ,略有修改和刪除。
sockex1_user.c 使用者空間程式主要內容如下(為方便展示,部分內容有刪除和修改):
int main(int argc, char **argv) { struct bpf_object *obj; int map_fd, prog_fd; // ... // 載入 BPF 程式至 bpf_object 物件中, bpf_prog_load("sockex_kern.o", BPF_PROG_TYPE_SOCKET_FILTER, &obj, &prog_fd)) // 獲取 my_map 對應的 map_fd 控制代碼 map_fd = bpf_object__find_map_fd_by_name(obj, "my_map"); // == 本次關注 == // 通過 setsockopt 將 BPF 位元組碼載入到核心中 sock = open_raw_sock("lo"); setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd)); popen("ping -4 -c5 localhost", "r"); // 產生報文 // 從 my_map 中讀取 5 次 IPPROTO_TCP 的統計 for (i = 0; i < 5; i++) { long long tcp_cnt; int key = IPPROTO_TCP; assert(bpf_map_lookup_elem(map_fd, &key, &tcp_cnt) == 0); // == 本次關注 == // ... sleep(1); } return 0; }
sockex1_user.c 檔案中的 bpf_map_lookup_elem
呼叫的函式原型如下,定義在檔案 tools/lib/bpf/bpf.c 中:
int bpf_map_lookup_elem(int fd, const void *key, void *value)
函式底層通過 sys_bpf(cmd=BPF_MAP_LOOKUP_ELEM,...)
實現,為我們方便 map 操作的使用者空間封裝函式, bpf 系統呼叫可參考 man 2 bpf 。
其中 sockex1_kern.c 主要內容如下:
// map 定義 struct bpf_map_def SEC("maps") my_map = { .type = BPF_MAP_TYPE_ARRAY, .key_size = sizeof(u32), .value_size = sizeof(long), .max_entries = 256, }; // BPF 程式,獲取到報文協議型別並進行計數更新 SEC("socket1") int bpf_prog1(struct __sk_buff *skb) { int index = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol)); long *value; value = bpf_map_lookup_elem(&my_map, &index); // 查詢索引並更新 map 對應的值,== 本次關注 == if (value) __sync_fetch_and_add(value, skb->len); return 0; } char _license[] SEC("license") = "GPL";
sockex1_kern.c 檔案中的 bpf_map_lookup_elem
函式為核心中提供的 BPF 輔助函式,原型宣告如下,詳情可參考 man 7 bpf-helper :
void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)
使用者空間與核心 BPF 輔助函式引數對比
通過分析 sockex1_user.c 和 sockex1_kern.c 函式中的 bpf_map_lookup_elem
使用姿勢,這裡我們做個簡單對比:
// 使用者空間 map 查詢函式 int bpf_map_lookup_elem(int fd, const void *key, void *value) // 核心中 BPF 輔助函式 map 查詢函式 void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)
那麼如何將 int fd
與 struct bpf_map *map
共同關聯一個物件呢? 這需要我們通過分析 BPF 位元組碼來進行解密。
3. 深入指令分析
首先我們將 sockex1_kern.c 檔案使用 llvm/clang
將之編譯成 ELF 的 BPF 位元組碼。對於生成的 sockex1_kern.o
檔案可以用 llvm-objdump
來檢視相對應的檔案格式,這裡我們僅關注 map 相關的部分。
3.1 檢視 BPF 指令
$ clang -O2 -target bpf -c sockex1_kern.c -o sockex1_kern.o $ llvm-objdump -S sockex1_kern.o 0000000000000000 <bpf_prog1>: // ... ; value = bpf_map_lookup_elem(&my_map, &index); # 備註:編譯的機器啟用了 BTF 7: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll 9: 85 00 00 00 01 00 00 00 call 1 // ...
上述結果展示了 BPF 程式中 socket1
部分的函式 bpf_prog1
的 BPF 指令, 但是其中對於涉及到的變數 my_map
的引用都未有解決 。上述的反彙編部分列印了 map_lookup_elem()
函式呼叫涉及的指令:
- 根據 BPF 程式呼叫的約定,暫存器
r1
為函式呼叫的第 1 個引數,這裡即bpf_map_lookup_elem(&my_map, &index)
呼叫中的my_map
。
7: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll # 64 位直接數賦值 , r1 = 0 9: 85 00 00 00 01 00 00 00 call 1 # 呼叫 bpf_map_lookup_elem,編號為 1
上述 “7:" 行 表了為一條 16 個位元組的 BPF 指令,表示載入一個 64 位立即數。
這裡無需擔心相關的 BPF 指令集,後續我們會詳細展開解釋。1 個 BPF 指令有 8 個位元組組成,格式定義如下:
struct bpf_insn { __u8 code; /* opcode */ __u8 dst_reg:4; /* dest register */ __u8 src_reg:4; /* source register */ __s16 off; /* signed offset */ __s32 imm; /* signed immediate constant */ };
通過上述結構對應拆解一下 ”7:“ 行(其中包含了 2 條 BPF 指令,為 BPF 指令中的特殊指令,執行時會被解析成 1 條指令執行) ,第 1 條 BPF 指令詳細的資訊如下:(這裡忽略了 off 欄位)
-
opcode
為 0x18,即BPF_LD | BPF_IMM | BPF_DW
。該 opcode 表示要將一個 64 位的立即數載入到目標暫存器。 -
dst_reg
是 1(4 個 bit 位),代表暫存器r1
。 -
src_reg
是 0(4 個 bit 位),表示立即數在指令內。 -
imm
為 0, 因為my_map
的值在生成 BPF 位元組碼的時候還未進行建立 。
第 2 條指令主要負責儲存 imm 的高 32 位。
3.2 載入器建立 map 物件
當載入器(loader)在載入 ELF 物件 sockex1_kern.o
時,其首先會從 ELF 格式的 maps
區域獲取到定義的 map 物件 my_map
及相關的屬性, 然後通過呼叫 bpf()
系統呼叫來建立 my_map
物件,如果建立成功,那麼 bpf()
系統呼叫返回一個檔案描述符 (map fd)。
同時,載入器也會對於基於 map 元資訊(比如名稱 my_map
)與通過 bpf()
系統呼叫建立 map 後返回的 map fd 建立起對應關係,此後使用者空間空間程式就可以使用 my_map
作為關鍵字獲取到其對應的 fd,具體程式碼如下:
map_fd = bpf_object__find_map_fd_by_name(obj, "my_map");
使用者空間獲取到了 map 物件的 fd,後續可用於 map_lookup_elem(map_fd, ...)
函式進行 map 的查詢等操作。
3.3 第一次變身: map fd 替換
以上完成了 my_map 物件的建立,但是在 BPF 位元組碼程式載入到核心前,還需要將 map fd 在 BPF 指令集中完成第一次變身,如函式 lib/bpf.c: bpf_apply_relo_map()
的程式碼片段所示:
prog->insns[insn_off].src_reg = BPF_PSEUDO_MAP_FD; // 值在核心中定義為 1 prog->insns[insn_off].imm = ctx->map_fds[map_idx]; // ctx->map_fds[map_idx] 即為儲存的 map fd 值。
這裡假設獲取到的 map 檔案描述符為 6,那麼在載入的 BPF 程式完成 bpf_apply_relo_map
的替換後上述的指令對比如下:
ELF 檔案中的位元組碼:
7: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll # 64 位直接數賦值 , r1 = 0 9: 85 00 00 00 01 00 00 00 call 1 # 呼叫 bpf_map_lookup_elem,編號為 1
替換 map fd 後的位元組碼:
7: 18 11 00 00 06 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll # 64 位直接數賦值 , r1 = 6 9: 85 00 00 00 01 00 00 00 call 1 # 呼叫 bpf_map_lookup_elem,編號為 1
3.4 第二次變身: map fd 替換成 map 結構指標
當上述經過第一次變身的 BPF 位元組碼載入到核心後,還需要進行一次變身,才能真正在核心中工作,這次 BPF 驗證器(verifier)扛過大旗。
驗證器將載入器注入到指令中的 map fd 替換成核心中的 map 物件指標。呼叫堆疊的情況如下:
sys_bpf() --> bpf_prog_load() --> bpf_check() --> replace_map_fd_with_map_ptr() --> do_check() --> check_ld_imm() ==> check_func_arg() --> convert_pseudo_ld_imm64()
函式 replace_map_fd_with_map_ptr()
通過以下程式碼完成第二次大變身,實現了核心中 BPF 位元組碼的 imm
搖身一變成為 map ptr
地址。
f = fdget(insn[0].imm); // 從第 1 條指令中的 imm 欄位獲取到載入器設定的 map fd map = __bpf_map_get(f); // 基於 map fd 獲取到 map 物件指標 addr = (unsigned long)map; insn[0].imm = (u32)addr; // 將 map 物件指標低 32 位放入第一條指令中的 imm 欄位 insn[1].imm = addr >> 32; // 將 map 物件指標高 32 位放入第二條指令中的 imm 欄位
於此同時,函式 convert_pseudo_ld_imm64()
還需要清理載入器設定的 src_reg = BPF_PSEUDO_MAP_FD
操作( prog->insns[insn_off].src_reg = BPF_PSEUDO_MAP_FD;
), 用於表明完成了整個指令的重寫工作:
if (insn->code == (BPF_LD | BPF_IMM | BPF_DW)) insn->src_reg = 0;
如果這裡的 my_map
在核心中 64 位地址為 0xffff8881384aa200
,那麼驗證器完成第二次變身後的 BPF 位元組碼對比如下。
替換 map fd 後的位元組碼:
7: 18 11 00 00 06 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll # 64 位直接數賦值 , r1 = 6 9: 85 00 00 00 01 00 00 00 call 1 # 呼叫 bpf_map_lookup_elem,編號為 1
替換為 map 物件指標後的位元組碼如下:
7: 18 01 00 00 00 a2 4a 38 00 00 00 00 81 88 ff ff # 64 位直接數賦值 , r1 = 0xffff8881384aa200 9: 85 00 00 00 30 86 01 00 # 呼叫 bpf_map_lookup_elem,編號為 1
在完成了上述兩次變身後,當在核心中呼叫 map_lookup_elem()
時,第一個引數 my_map
的值為 0xffff8881384aa200
,
從而實現了從最早的 ELF 中的 0 ,替換成了 map_fd (6),直到最後的 map 物件 struct bpf_map * (0xffff8881384aa200)
。
提示,核心中 bpf_map_lookup_elem
輔助函式的原型定義為:
static void *(*bpf_map_lookup_elem)(struct bpf_map *map, void *key)
4. 整個流程總結
通過上述 map 訪問指令的 2 次大變身,我們可以清晰瞭解 map 建立、map fd 指令重寫和 map ptr 物件的重寫,也能夠徹底明白使用者空間 map fd 與核心中 map 物件指標的關聯關係。
俗話說一圖勝千言,這裡我們用一張圖進行整個流程的總結:
原始圖片來自於 這裡 ,略有修改。
參考
- 一道思考題所引起動態跟蹤 ‘學案’
- 問題排查利器:Linux 原生跟蹤工具 Ftrace 必知必會
- BumbleBee: 如絲般順滑構建、交付和執行 eBPF 程式
- 【譯】聊聊對 BPF 程式至關重要的 vmlinux.h 檔案
- 揭祕 BPF map 前生今世
- 【譯】神奇的 eBPF
- 【譯】eBPF 概述:第 4 部分:在嵌入式系統執行
- 深入淺出 BPF TCP 擁塞演算法實現原理
- 【BPF入門系列-12】【譯】eBPF 和 Go 經驗初探
- Ubuntu 20.04 Kdump Crash 初體驗
- 【BPF入門系列-10】使用 tracepoint 跟蹤檔案 open 系統呼叫
- 【BPF入門系列-9】檔案開啟記錄結果跟蹤篇
- 在 Windows 平臺上啟用 eBPF【譯】
- BPF 二進位制檔案:BTF,CO-RE 和 BPF 效能工具的未來【譯】