BPF 是 Linux 中的重要技術之一,是允許使用者動態載入程式碼到 kernel 中運行的利器。從導入 eBPF 以來,社群一直致力於提升其使用性(usability),例如簡易化載入 BPF code 的方式、提供更易於撰寫 BPF code 的函式介面等等。然而除此以外,增加使用性的一項重點是可移植性(portability)的提升。
由於 BPF 是被編譯為 bytecode 執行,在不同 CPU 架構間的可移植性並不成嚴重的問題。然而 BPF 需考量的移植不止是跨平台的,更重要的是核心版本間的跨動。BPF bytecode 既然可以載入到核心中,也就意味著其可以存取到 kernel 中的數據。但是 kernel 的 memory layout、型別定義、struct 定義可能隨版本更動,這些皆非 BPF 可以控制,也就因此一段 BPF bytecode 若受限於這些問題,將被侷限只能在開發和編譯之的環境上運行。
為了降低這些可移植性上的缺失,CO-RE(Compile Once – Run Everywhere) 機制在 bpfconf 2019 被提出來討論,並隨後引入至 Linux 的後續版本。本文將以 BPF CO-RE (Compile Once – Run Everywhere) 這篇文章為基礎,並延伸之來探討 CO-RE 機制的目的、原理以及使用方式。
kernel 隨著版本一直在變化是無可避免的,那麼 BPF 的開發者是否可能不受這些變化影響呢?在某些需求上,有幾個因素讓這件事是可能的:
opensnoop
主要是透過跟蹤幾個 system call 參數運作的,而 system call 的介面通常相對穩定,不容易隨核心版本變動但是,如果 BPF 的開發者想查看的就是原始的 kernel 資料呢? 這在 BPF 的移植性上就顯得棘手。舉例來說,以下的幾種狀況
field
,參考下圖),相異版本的 kernel 中存在 offset 差異:/* 1.0 */
struct s {
u8 dummy;
u64 field;
}''
/* 2.0 */
struct s {
u64 dummy;
u64 field;
};
在這些狀況下,都意味著如果你的 BPF code 是依賴當下環境的 kernel header 編譯,那麼直接放到其他機器上運行的話,是可能得到不正確的結果的。
最早要取得可移植性的方式是透過 BCC(BPF Compiler Collection)。簡而來說,BCC 的運作方式是:
通過這種方式可以確保產生 bytecode 期望的 memory layout 和實際運行的核心版本保持一致。
然而使用 BCC 有幾個主要的缺點:
正是這些問題促成了 BPF-CORE,後者被設計以更完整地解決 BPF 移植性問題,以適用於在更複雜的環境中的開發與使用 BPF。
BPF CO-RE 將它所依賴的 kernel, userspace 的 BPF loader(libbpf), 編譯器(clang) 三者整合在了一起。其旨在讓使用者們能以輕鬆的方式編寫可移植的 BPF 應用。BPF CO-RE 需要協同並整合下列元件:
通過這些元件的互相搭配,BPF 的開發可以具備與 BCC 相似的簡單性和可移植性,同時沒有 BCC 所需的大量開銷。下面我們將深入這些元件在 CO-RE 底下的細節。
BTF 是一種類似 DWARF 的 metadata 格式,用以描述與 BPF program/BPF map 相關的信息。但相對 DWARF,BTF 更為通用,並且有更高的空間效率。
我們可以在編譯時設定 CONFIG_DEBUG_INFO_BTF=y
來讓 Linux 在運行的同時攜帶 BTF 信息。這些 BTF 除了被 kernel 自身使用,還可以用於增強 BPF verifier 的能力。(例如允許了直接讀取 kernel memory 的能力,不再需要通過 bpf_probe_read()
間接讀取)。最為關鍵的是,kernel 可以利用 BTF 信息來將當前 kernel 中的型別、struct 定義提取出來,這些都透過 sysfs (/sys/kernel/btf/vmlinux
) 揭露。
下面的命令可以讓我們取得與當前核心 「所有的」(真的是「所有」! 包含在 kernel-devel
底下沒有涵蓋的) kernel 型別相容的 header file。
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c
你可以通過以下方式從 GitHub 上取得 bpftool:
$ git clone https://github.com/libbpf/bpftool.git
$ cd bpftool
$ git submodule update --init
$ cd src
$ make
$ sudo make install
為了支援 BPF CO-RE 並讓 BPF loader(libbpf) 能將 BPF 程式在目標機器的 kernel 環境上運行,Clang 增加了幾個 builtin。這些 builtin 的功能是導出 BTF relocations,後者是 BPF 程序想讀取的資料的高級描述。例如我們打算存取 task_struct->pid
時,Clang 就記錄要存取的是在結構 task_struct
中、類型為 pid_t
,命名為 pid
的字段(field)。
struct task_struct {
...
pid_t pid;
...
}
透過這種記錄方式,即便日後 pid
在 task_struct
底下的位置發生改變,甚至是移動到巢狀的 struct 結構或 union 中(這種改變對 C 語言的開發是透明的,見如下範例),CO-RE 仍然可以僅通過名稱和類型信息找到正確的數據。這種方式被稱為 field offset relocation
struct task_struct {
...
struct {
pid_t pid;
}
...
}
struct task_struct {
...
union {
pid_t pid;
}
...
}
透過這種資訊,不僅可以取得 field offset,還可以用來得到如 field 的大小或者是否存在的信息。甚至是 bitfields 也可以透過 BTF 來 relocation。
回顧前述,BTF 攜帶 kernel 的資訊,而 Clang 負責導入 relocation 信息,這些都聚集在一起並由 BPF loader - libbpf 處理。 它讀取編譯得到 BPF ELF,根據需要對其進行後處理(post-processes),設置各種 kernel objects(map、program 等),然後觸發 BPF 的載入和驗證。
libbpf 知道如何將 BPF 程式適配到目標機器上。它查看 BPF 程式中記錄的 BTF 類型和重定位信息,並將它們與正在運行的 kernel 之 BTF 信息匹配。如果匹配上沒有問題,BPF 應用開發者將得到一個針對目標機器「量身訂做」的 BPF 程式。但這種方式不需如同 BCC 那樣將 Clang 與 BPF 一同打包來部署,也沒有在目標機器上運行時編譯的開銷。
在 kernel 部分,不需要太多改動就能支持 BPF CO-RE。換句話說,對 kernel 來說,經 libbpf 處理後的 BPF 程式與其他在目標機器上依賴最新的 kernel header 編寫出的 BPF 程式並無區別。
在前面的章節中,我們已經探究了在 CO-RE 以前 eBPF programming 具有的問題,並敘述了 CO-RE 能帶來的改變 。下面就讓我們透過實際的應用場景,來看看 CO-RE 如何可以解決這些問題。
前面有提到,我們可以透過 BTF 和 bpftool
來產生與當前核心 「所有的」 kernel 型別相容的 header file,後者會被命名為 vmlinux.h
。有了 vmlinux.h
,就無需再像過安裝 kernel-devel
,然後為 BPF 程式 include Linux 內部 header file。例如 #include <linux/sched.h>
、#include <linux/fs.h>
。只需要 #include "vmlinux.h"
就足夠。
換句話說 vmlinux.h
包含了以下:
kernel-devel
揭露的內部型別不幸的是 vmlinux.h
的不足之處是 BPF 並不記錄 #define
macro,也因此這在vmlinux.h
中是缺失的。但最常見的一些已經有在 bpf_helpers.h
(libbpf 提供給 eBPF programming 使用的函式庫)提供。
在 eBPF 最常見的情況是要 kernel 結構中讀取一個字段。舉例來說,我們想讀取 task_struct
的 pid
一欄。作為對照,BCC 的方式可以透過以下寫法:
pid_t pid = task->pid;
BCC 會藉由強大的代碼重寫能力,將以上程式碼轉換成一次 bpf_probe_read()
(注意到重寫之後的程式碼並不一定能正確,具體取決於表達式的複雜程度)。
libbpf 沒有 BCC 這樣的重寫機制,但提供了幾種其他方式來實現同樣的目的:
如果你使用的是 BTF_PROG_TYPE_TRACING
類型的 BPF 程式,那麼 BPF verifier 將有能力可以直接理解 BTF 信息,並將下列的程式碼轉換成對 kernel 記憶體的直接讀取,不需要依賴於編譯器的重寫。
pid_t pid = task->pid;
根據 eBPF 所追蹤的事件類型會被分類為某種 bpf_prog_type
,BTF_PROG_TYPE_TRACING
是其中的一種,可以參考 這段敘述 中如何寫出一段為 BTF_PROG_TYPE_TRACING
類的程式
但上述的寫法是不可移植的,要改進成可移植的版本,可以將 task->pid
放到編譯器內置的 __builtin_preserve_access_index()
中:
pid_t pid = __builtin_preserve_access_index(({ task->pid; }));
如果需透過非 BPF_PROG_TYPE_TRACING
的方式,在非 CO-RE 的情況下必須顯示的使用 bpf_probe_read()
来讀取。
/* Non CO-RE(not portable) */
pid_t pid;
bpf_probe_read(&pid, sizeof(pid), &task->pid);
而在 CO-RE 我們可以轉用 bpf_core_read()
。
/* CO-RE */
bpf_core_read(&pid, sizeof(pid), &task->pid);
後者實際上效果如同被展開為 bpf_probe_read()
,但加上剛剛介紹的 __builtin_preserve_access_index()
來實現可移植。
bpf_probe_read(&pid, sizeof(pid), __builtin_preserve_access_index(&task->pid));
然而,這些 bpf_probe_read()
/bpf_core_read()
實際上並不太理想,很快就會變成過時的 API。這主要是因為當你處理一堆通過 pointer 鏈接在一起的結構時,這個 API 將顯得有些笨重。例如,假設要獲取當前 process 對應 executable 的 inode 編號,在 BCC 下我們可以如下操作:
u64 inode = task->mm->exe_file->f_inode->i_ino;
BCC 會將這進行重寫,但實際上這被展開而成的是 4 次的 bpf_probe_read()
/bpf_core_read()
。換句話說,在使用沒有重寫魔法的 libbpf 的,我們就要用如下的方式操作:
struct task_struct *t = ...;
struct mm_struct *mm;
struct file *exe_file;
struct inode *f_inode;
u64 inode;
bpf_core_read(&mm, sizeof(struct mm_struct *), &t->mm);
bpf_core_read(&exe_file, sizeof(struct file *), &mm->exe_file);
bpf_core_read(&f_inode, sizeof(struct inode *), &exe_file->path.dentry);
bpf_core_read(&inode, sizeof(u64), &f_inode>i_ino);
因此實際上 BPF CO-RE 提供如下更為簡潔的寫法,且不需依賴編譯器的代碼重寫:
u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);
如果已經有一個你想讀入的變數,而不是作為返回值回傳,也可以改為以下的寫法。
u64 inode;
BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino);
其他還有用來讀取 C string 的 bpf_core_read_str()
,作為 bpf_probe_read_str()
的直接替代品。類似的 BPF_CORE_READ_STR_INTO()
則與 BPF_CORE_READ_INTO()
相近,但會對最後一個 field 改用 bpf_probe_read_str()
去讀。
除了讀取 struct 中的 field,我們前面也提到 CO-RE 由 BTF 得以提供檢查一個 field 是否存在的功能。具體的介面是 bpf_core_field_exists()
。
pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;
對於可能隨版本變動的 field,bpf_core_field_size()
可以提供可移植的方式。
u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */
如果有需要在 kernel 結構中存取 bitfield 類型的 field,BPF_CORE_READ_BITFIELD()
(直接的記憶體存取) 和 BPF_CORE_READ_BITFIELD_PROBED()
(底層透過 bpf_probe_read()
存取) 可以幫得上忙。
struct tcp_sock *s = ...;
/* with direct reads */
bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(s, is_cwnd_limited);
/* with bpf_probe_read()-based reads */
u64 is_cwnd_limited;
BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &is_cwnd_limited);
在上述範例中,我們看到了 libbpf 提供了一些函式和 macro,來滿足可以移植於各版本 kernel 的寫法。但某些情況下,libbpf 對提供各版本一致的方式是無能為力的。比如有時某個字段會被重命名,或者某個字段雖然名稱無異,卻變成了一個完全不同涵義的字段。 或者,你想要存取的資料只存在於某些配置中。
對此,libbpf 提供兩種可互補的辦法。
第一種是透過 extern Kconfig variables 的方式: BPF 程式可以透過 extern variable 來取得一些知名的 kconfig 變量,例如以下的 LINUX_KERNEL_VERSION
和 CONFIG_HZ
。這些變量將具有與 BPF 程式運作的 kernel 相匹配的值。而 BPF verifier 可以將這些變數作為已知常數進行分析與 dead code elimination。舉例來說,下面的程式碼示範了 BPF CO-RE 中如何拿到 thread 的 CPU usertime。因為在 Linux 4.11 的版本後,utime
從以 jiffies 為單位的記錄方式切換為以 nano second 為單位(patch),這樣的寫法使我們引入可移植性。
具體是 4.xx 版還需要釐清,因為示例雖然是寫 4.11,但原文章敘述說是 4.6
extern u32 LINUX_KERNEL_VERSION __kconfig;
extern u32 CONFIG_HZ __kconfig;
u64 utime_ns;
if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(4, 11, 0))
utime_ns = BPF_CORE_READ(task, utime);
else
/* convert jiffies to nanoseconds */
utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ);
struct flavors 是另一種方式。它用來處理在兩個版本有不相容的型別的情況。如以下例子,需知道的事實是: thread_struct
這個結構型別中,fs
這個名稱在 4.6 版本後變成了 fsbase
。BPF 應用將 <= 4.6 kernel 的舊 thread_struct
定義為 thread_struct___v46
。名稱中的三個底線及其後的所有內容都被稱為是該結構的 "flavor"。在使用時,libbpf 會忽略 flavor 部分,這意味著在執行必要的重定位時,thread_struct___v46
仍將與實際運行的 kernel 的 thread_struct
匹配。 這樣的作法讓 BPF 開發可以在單個 C 程式中為相同的型別提供多種定義,並能夠在運行時根據版本選擇最合適的。
/* up-to-date thread_struct definition matching newer kernels */
struct thread_struct {
...
u64 fsbase;
...
};
/* legacy thread_struct definition for <= 4.6 kernels */
struct thread_struct___v46 { /* ___v46 is a "flavor" part */
...
u64 fs;
...
};
extern int LINUX_KERNEL_VERSION __kconfig;
...
struct thread_struct *thr = ...;
u64 fsbase;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(4, 6, 0))
fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs);
else
fsbase = BPF_CORE_READ(thr, fsbase);
如果沒有 struct flavors,我們就無法做到 compile once, run everywhere 的效果了。因為透過 #ifdef
的方式,我們就需要編譯成兩個獨立的 BPF 程式,這樣的作法是相對繁瑣且複雜的。
有些場景中,根據 kernel 版本和配置來決定 BPF 程式的行為是不足的。我們可能得從 userspace 的 control application 才能知道實際的需求。一種可以不依賴於 CO-RE 的方式是透過 BPF map。將 BPF map 作為一個儲存配置的空間。然後 BPF 程式從 map 中提取資料來決定控制流如何執行。
但這種方式有幾個主要的缺點:
這種場景下,BPF CO-RE 提供另一種作法: 使用 read-only 的 global 變數。此變數只會在 BPF 程序被載入前設置一次。我們可以在 userapce 通過 BPF skeleton 來設置這個 global 變數。
則對於此前提到的兩個缺點的改進:
TODO