eBPF 編程指北

開發環境

這裏以 Ubuntu 20.04 爲例構建 eBPF 開發環境:

$ uname -a
Linux VM-1-3-ubuntu 5.4.0-42-generic #46-Ubuntu SMP Fri Jul 10 00:24:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ sudo apt install build-essential git make libelf-dev clang llvm strace tar bpfcc-tools linux-headers-$(uname -r) gcc-multilib  flex  bison libssl-dev -y

主流的發行版在對 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 到硬件(例如網卡)。

查看 LLVM 支持的 BPF target:

$ llc --version
LLVM (http://llvm.org/):
  LLVM version 10.0.0

  Optimized build.
  Default target: x86_64-pc-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

編程限制

用 C 語言編寫 BPF 程序不同於用 C 語言做應用開發,有一些陷阱需要注意。本節列出了 二者的一些不同之處。

所有函數都需要內聯(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";

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

不允許全局變量

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

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

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

(目前還)不支持循環

內核中的 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(存儲空間),然後用尾調用的方式調用函數自身。雖然這種方式更加動態,但目前最大隻支持 32 層嵌套調用。

將來 BPF 可能會提供一些更加原生、但有一定限制的循環。

尾調用的用途

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

BPF 最大棧空間 512 字節

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

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

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

通過未驗證的引用(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
}

開發工具鏈

libbpf

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

從上面的輸出可以看到:

另外,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.dot,bpf_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’)。

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_)。如果啓用了 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                          |
+-------+-------------------------------------------------------------------+

內核測試

Linux 內核自帶了一個 selftest 套件,在內核源碼樹中的路徑是 tools/testing/selftests/bpf/。

cd tools/testing/selftests/bpf/
$ make
$ make run_tests

測試用例包括:

JIT Debugging

對於執行審計或編寫擴展的 JIT 開發人員,每次編譯運行都可以通過以下方式將生成的 JIT 鏡像輸出到內核日誌中:

echo 2 > /proc/sys/net/core/bpf_jit_enable

每當加載新的 BPF 程序時,JIT 編譯器都會轉儲輸出,然後可以使用 dmesg 檢查,例如:

[ 3389.935842] flen=proglen=70 pass=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 是 BPF 程序的長度(這裏是 6 個 BPF 指令),proglen 告訴 JIT 爲操作碼圖像生成的字節數(這裏是 70 字節大小)。pass 意味着圖像是在 3 次編譯器 pass 中生成的,

例如,x86_64 可以有各種優化 pass 以在可能的情況下進一步減小圖像大小。image 包含生成的 JIT 鏡像的地址,from 和 pid 分別是用戶空間應用程序名稱和 PID,它們觸發了編譯過程。eBPF 和 cBPF JIT 的轉儲輸出格式相同。

在 tools/bpf/ 下的內核樹中,有一個名爲 bpf_jit_disasm 的工具。它讀出最新的轉儲並打印反彙編以供進一步檢查:

$ ./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

或者,該工具還可以將相關操作碼與反彙編一起轉儲。

$ ./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

最近,bpftool 採用了相同的功能,即根據系統中已加載的給定 BPF 程序 ID 轉儲 BPF JIT 鏡像。

對於 JITed BPF 程序的性能分析,perf 可以照常使用。作爲先決條件,需要通過 kallsyms 基礎設施導出 JIT 程序。

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

啓用或禁用 bpf_jit_kallsyms 不需要重新加載相關的 BPF 程序。接下來,提供了一個小型工作流示例來分析 BPF 程序。一個精心製作的 tc BPF 程序用於演示目的,其中 perf 在 bpf_clone_redirect() 幫助程序中記錄了失敗的分配。

由於使用直接寫入,bpf_try_make_head_writable() 失敗,然後會再次釋放克隆的 skb 並返回錯誤消息。因此 perf 記錄了所有 kfree_skb 事件。

$ 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)

perf 記錄的堆棧跟蹤將顯示 bpf_prog_8227addf251b7543() 符號作爲調用跟蹤的一部分,這意味着帶有標籤 8227addf251b7543 的 BPF 程序與 kfree_skb 事件相關,並且該程序在入口掛鉤上附加到 netdevice em1 爲 由 tc 顯示。

內省

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=key=val=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=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=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]

異常在下面情況下會觸發:

這兩類 tracepoint 也都可以通過 attach BPF 程序,用這個 BPF 程序本身來收集進一步 信息,將結果放到一個 BPF map 或以事件的方式發送到用戶空間收集器,例如利用 bpf_perf_event_output() 輔助函數。

其他

和 perf 類似,BPF 程序和 map 佔用的內存是算在 RLIMIT_MEMLOCK 中的。可以用 ulimit -l 查看當前鎖定到內存中的頁面大小。setrlimit() 系統調用的 man page 提 供了進一步的細節。

默認的限制通常導致無法加載複雜的程序或很大的 BPF map,此時 BPF 系統調用會返回 EPERM 錯誤碼。這種情況就需要將限制調大,或者用 ulimit -l unlimited 來臨時解 決。RLIMIT_MEMLOCK 主要是針對非特權用戶施加限制。根據實際場景不同,爲特權 用戶設置一個較高的閾值通常是可以接受的。

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