揭祕 BPF map 前生今世

語言: CN / TW / HK

本文地址: 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.csockex1_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 fdstruct 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 物件指標的關聯關係。

俗話說一圖勝千言,這裡我們用一張圖進行整個流程的總結:

原始圖片來自於 這裡 ,略有修改。

參考