揭祕 BPF map 前生今世

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 程序雙向的數據結構,那麼問題來了:

畢竟數據交換跨越了用戶空間和內核空間,本文將從深入淺出爲各位看官揭開 map 整個生命管理的 "大瓜"。

2. 簡單的使用樣例

本樣例來自於 samples/bpf/sockex1_user.c[1] 和 sockex1_kern.c[2],略有修改和刪除。

sockex1_user.c[3] 用戶空間程序主要內容如下(爲方便展示,部分內容有刪除和修改):

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[4] 中:

int bpf_map_lookup_elem(int fd, const void *key, void *value)

函數底層通過 sys_bpf(cmd=BPF_MAP_LOOKUP_ELEM,...) 實現,爲我們方便 map 操作的用戶空間封裝函數, bpf 系統調用可參考 man 2 bpf[5]。

其中 sockex1_kern.c[6] 主要內容如下:

// 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[7]:

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() 函數調用涉及的指令:

       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 字段)

第 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 對象指針的關聯關係。

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

原始圖片來自於這裏 [8],略有修改。

參考

參考資料

[1] samples/bpf/sockex1_user.c: https://elixir.bootlin.com/linux/v5.13/source/samples/bpf/sockex1_user.c

[2] sockex1_kern.c: https://elixir.bootlin.com/linux/v5.13/source/samples/bpf/sockex1_kern.c

[3] sockex1_user.c: https://elixir.bootlin.com/linux/v5.13/source/samples/bpf/sockex1_user.c

[4] tools/lib/bpf/bpf.c: https://elixir.bootlin.com/linux/v5.13/source/tools/lib/bpf/bpf.c

[5] man 2 bpf: https://man7.org/linux/man-pages/man2/bpf.2.html

[6] sockex1_kern.c: https://elixir.bootlin.com/linux/v5.13/source/samples/bpf/sockex1_kern.c

[7] man 7 bpf-helper: https://man7.org/linux/man-pages/man7/bpf-helpers.7.html

[8] 這裏 : https://github.com/qmonnet/echo-bpftool/blob/main/slides/loading.svg

[9] Linux bpf map internals: https://mechpen.github.io/posts/2019-08-03-bpf-map/index.html

[10] eCHO episode 11: Exploring bpftool with Quentin Monnet: https://www.youtube.com/watch?v=1EOLh3zzWP4&t=1527s

[11] ebpf: BPF_FUNC_map_lookup_elem calling convention: https://stackoverflow.com/questions/67440821/ebpf-bpf-func-map-lookup-elem-calling-convention

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pKDn9DrvKYwDlu88Ojg5Pg