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 對象文件了。
典型的工作流是:
-
用 C 編寫 BPF 程序
-
用 LLVM 將 C 程序編譯成對象文件(ELF)
-
用戶空間 BPF ELF 加載器(例如 iproute2)解析對象文件
-
加載器通過 bpf() 系統調用將解析後的對象文件注入內核
-
內核驗證 BPF 指令,然後對其執行即時編譯(JIT),返回程序的一個新文件描述符
-
利用文件描述符 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/。
這個工具可以完成:
-
dump 當前已經加載到系統中的所有 BPF 程序和 map
-
列出和指定程序相關的所有 BPF map
-
dump 整個 map 中的 key/value 對
-
查看、更新、刪除特定 key
-
查看給定 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
從上面的輸出可以看到:
-
程序 ID 爲 406,類型是 sched_cls(BPF_PROG_TYPE_SCHED_CLS),有一個 tag 爲 e0362f5bd9163a0a(指令序列的 SHA sum)
-
這個程序被 root uid 0 在 Apr 09/16:24 加載
-
BPF 指令序列有 11,144 bytes 長,JIT 之後的鏡像有 7,721 bytes
-
程序自身(不包括 maps)佔用了 12,288 bytes,這部分空間使用的是 uid 0 用戶 的配額
-
BPF 程序使用了 ID 爲 18、20 8 5 6 和 14 的 BPF map。可以用這些 ID 進一步 dump map 自身或相關信息
另外,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
測試用例包括:
-
BPF 校驗器、程序 tags、BPF map 接口和 map 類型的很多測試用例
-
用於 LLVM 後端的運行時測試,用 C 代碼實現
-
用於解釋器和 JIT 的測試,運行在內核,用 eBPF 和 cBPF 彙編實現
JIT Debugging
對於執行審計或編寫擴展的 JIT 開發人員,每次編譯運行都可以通過以下方式將生成的 JIT 鏡像輸出到內核日誌中:
$ echo 2 > /proc/sys/net/core/bpf_jit_enable
每當加載新的 BPF 程序時,JIT 編譯器都會轉儲輸出,然後可以使用 dmesg 檢查,例如:
[ 3389.935842] flen=6 proglen=70 pass=3 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=4 key=4 val=8 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=4 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=4 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]
異常在下面情況下會觸發:
-
BPF 程序返回一個非法 / 未知的 XDP action code
-
BPF 程序返回 XDP_ABORTED,這表示非優雅的退出(non-graceful exit)
-
BPF 程序返回 XDP_TX,但發送時發生錯誤,例如,由於端口沒有啓用、發送緩衝區已 滿、分配內存失敗等等
這兩類 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