BPF 迭代器¶
概述¶
BPF 支援兩種獨立的實體,統稱為“BPF 迭代器”:BPF 迭代器程式型別和開放式編碼BPF 迭代器。前者是一種獨立的 BPF 程式型別,當用戶附加並激活時,它將對每個被迭代的實體(如 task_struct、cgroup 等)呼叫一次。後者是一組實現迭代器功能並在多種 BPF 程式型別中可用的 BPF 端 API。開放式編碼的迭代器提供了與 BPF 迭代器程式類似的功能,但為所有其他 BPF 程式型別提供了更大的靈活性和控制。另一方面,BPF 迭代器程式可以用於實現匿名或 BPF 檔案系統掛載的特殊檔案,其內容由附加的 BPF 迭代器程式生成,並由 seq_file 功能支援。兩者都根據具體需求而有用。
當新增新的 BPF 迭代器程式時,為了最大程度的靈活性,預期會以開放式編碼迭代器的方式新增類似的功能。同時,迭代邏輯和程式碼預期在兩種迭代器 API 介面之間實現最大限度的共享和重用。
開放式編碼 BPF 迭代器¶
開放式編碼的 BPF 迭代器被實現為緊密耦合的 kfunc 三元組(建構函式、下一個元素獲取、解構函式),以及描述棧上迭代器狀態的迭代器特定型別,BPF 校驗器保證該狀態不會在相應的建構函式/解構函式/next API 之外被篡改。
每種開放式編碼的 BPF 迭代器都有其關聯的 struct bpf_iter_<type>,其中 <type> 表示特定型別的迭代器。bpf_iter_<type> 狀態需要存在於 BPF 程式棧上,因此請確保它足夠小以便適應 BPF 棧。出於效能原因,最好避免為迭代器狀態進行動態記憶體分配,並將狀態結構體大小設計得足夠大以容納所有必要內容。但如果需要,動態記憶體分配是繞過 BPF 棧限制的一種方法。請注意,狀態結構體的大小是迭代器使用者可見 API 的一部分,因此更改它會破壞向後相容性,因此在設計時務必慎重考慮。
所有 kfunc(建構函式、next、解構函式)必須分別一致地命名為 bpf_iter_<type>_{new,next,destroy}()。<type> 表示迭代器型別,迭代器狀態應表示為匹配的 struct bpf_iter_<type> 狀態型別。此外,所有迭代器 kfunc 的第一個引數都應是指向此 struct bpf_iter_<type> 的指標。
- 此外
建構函式,即 bpf_iter_<type>_new(),可以有任意數量的額外引數。返回值型別也沒有強制要求。
next 方法,即 bpf_iter_<type>_next(),必須返回指標型別,並且應該只有一個引數:struct bpf_iter_<type> *(const/volatile/restrict 和 typedef 會被忽略)。
解構函式,即 bpf_iter_<type>_destroy(),應返回 void,並且應該只有一個引數,類似於 next 方法。
struct bpf_iter_<type> 的大小被強制為正數且是 8 位元組的倍數(以正確適應棧槽)。
這種嚴格性和一致性允許構建通用輔助函式,抽象出重要但重複的細節,從而能夠有效且符合人體工程學地使用開放式編碼迭代器(參見 libbpf 的 bpf_for_each() 宏)。這在核心的 kfunc 註冊點強制執行。
- 建構函式/next/解構函式的實現約定如下:
建構函式 bpf_iter_<type>_new() 總是初始化棧上的迭代器狀態。如果任何輸入引數無效,建構函式應確保仍然對其進行初始化,以便後續的 next() 呼叫將返回 NULL。即,在錯誤時,返回錯誤並構造一個空迭代器。建構函式 kfunc 標有 KF_ITER_NEW 標誌。
next 方法 bpf_iter_<type>_next() 接受指向迭代器狀態的指標並生成一個元素。next 方法應始終返回一個指標。BPF 校驗器之間的約定是 next 方法保證當元素耗盡時最終會返回 NULL。一旦返回 NULL,後續的 next 呼叫應該繼續返回 NULL。next 方法標有 KF_ITER_NEXT(當然,也應具有 KF_RET_NULL 作為返回 NULL 的 kfunc)。
解構函式 bpf_iter_<type>_destroy() 總是被呼叫一次。即使建構函式失敗或 next 什麼都沒返回。解構函式會釋放所有資源,並將 struct bpf_iter_<type> 使用的棧空間標記為可用於其他用途。解構函式標有 KF_ITER_DESTROY 標誌。
任何開放式編碼的 BPF 迭代器實現都必須至少實現這三種方法。對於任何給定型別的迭代器,只允許呼叫適用的建構函式/解構函式/next。即,校驗器確保您不能將數字迭代器狀態傳遞給,比如說,cgroup 迭代器的 next 方法。
從 BPF 校驗的高層視角來看,next 方法是分叉校驗狀態的點,其概念上類似於校驗器在驗證條件跳轉時所做的事情。校驗器會對 call bpf_iter_<type>_next 指令進行分支,並模擬兩種結果:NULL(迭代完成)和非 NULL(返回新元素)。首先模擬 NULL 情況,預期會不進入迴圈而直接達到退出。之後,驗證非 NULL 情況,它要麼達到退出(對於沒有實際迴圈的簡單示例),要麼達到另一個 call bpf_iter_<type>_next 指令,其狀態與已(部分)驗證的狀態等效。此時的狀態等效性意味著我們理論上會無限迴圈,而不會“跳出”已建立的“狀態包絡”(即,後續迭代不會給校驗器狀態新增任何新知識或約束,因此執行 1 次、2 次、10 次或一百萬次都沒有關係)。但考慮到迭代器 next 方法最終必須返回 NULL 的約定,我們可以得出結論,迴圈體是安全的,並且最終會終止。鑑於我們驗證了迴圈外的邏輯(NULL 情況),並得出迴圈體是安全的結論(儘管可能迴圈多次),校驗器可以宣告整個程式邏輯的安全性。
BPF 迭代器的動機¶
有幾種現有方法可以將核心資料轉儲到使用者空間。最流行的是 /proc 系統。例如,cat /proc/net/tcp6 轉儲系統中所有 tcp6 套接字,cat /proc/net/netlink 轉儲系統中所有 netlink 套接字。然而,它們的輸出格式往往是固定的,如果使用者想獲取這些套接字的更多資訊,他們必須修補核心,這通常需要時間才能向上遊釋出和發行。對於 ss 等流行工具也是如此,任何額外資訊都需要核心補丁。
為了解決這個問題,drgn 工具常用於在不更改核心的情況下挖掘核心資料。然而,drgn 的主要缺點是效能,因為它無法在核心內部進行指標追蹤。此外,drgn 無法驗證指標值,如果指標在核心內部變得無效,它可能會讀取到無效資料。
BPF 迭代器透過為每個核心資料物件呼叫 BPF 程式,提供收集哪些資料(例如,任務、bpf_map 等)的靈活性,從而解決了上述問題。
BPF 迭代器的工作原理¶
BPF 迭代器是一種 BPF 程式,它允許使用者迭代特定型別的核心物件。與傳統的 BPF 追蹤程式不同,傳統程式允許使用者定義在核心執行特定點觸發的回撥,而 BPF 迭代器允許使用者定義應為各種核心資料結構中的每個條目執行的回撥。
例如,使用者可以定義一個 BPF 迭代器,遍歷系統中的每個任務,並轉儲每個任務當前使用的 CPU 執行時總量。另一個 BPF 任務迭代器可能會轉儲每個任務的 cgroup 資訊。這種靈活性是 BPF 迭代器的核心價值。
BPF 程式總是在使用者空間程序的請求下載入到核心中。使用者空間程序透過開啟並根據需要初始化程式骨架,然後呼叫系統呼叫讓核心驗證並載入 BPF 程式來載入它。
在傳統的追蹤程式中,程式透過使用者空間使用 bpf_program__attach() 獲取到程式的 bpf_link 來啟用。一旦啟用,每當主核心中的追蹤點被觸發時,程式回撥就會被呼叫。對於 BPF 迭代器程式,透過 bpf_link_create() 獲取到程式的 bpf_link,並透過從使用者空間發出系統呼叫來呼叫程式回撥。
接下來,讓我們看看如何使用迭代器來迭代核心物件並讀取資料。
如何使用 BPF 迭代器¶
BPF 自測(selftests)是說明如何使用迭代器的一個很好的資源。在本節中,我們將介紹一個 BPF 自測,它展示瞭如何載入和使用 BPF 迭代器程式。首先,我們將檢視 bpf_iter.c,它說明了如何在使用者空間載入和觸發 BPF 迭代器。稍後,我們將檢視一個在核心空間執行的 BPF 程式。
從使用者空間將 BPF 迭代器載入到核心中通常涉及以下步驟:
BPF 程式透過
libbpf載入到核心中。一旦核心驗證並載入了該程式,它會向用戶空間返回一個檔案描述符 (fd)。透過呼叫
bpf_link_create()並指定從核心收到的 BPF 程式檔案描述符,獲取到 BPF 程式的link_fd。接下來,透過呼叫
bpf_iter_create()並指定從步驟 2 收到的bpf_link,獲取一個 BPF 迭代器檔案描述符 (bpf_iter_fd)。透過呼叫
read(bpf_iter_fd)觸發迭代,直到沒有資料可用。使用
close(bpf_iter_fd)關閉迭代器 fd。如果需要重新讀取資料,請獲取一個新的
bpf_iter_fd並再次進行讀取。
以下是幾個自測 BPF 迭代器程式的示例:
讓我們看看在核心空間中執行的 bpf_iter_task_file.c:
以下是 bpf_iter__task_file 在 vmlinux.h 中的定義。vmlinux.h 中任何以 bpf_iter__<iter_name> 格式命名的結構體都代表一個 BPF 迭代器。字尾 <iter_name> 代表迭代器的型別。
struct bpf_iter__task_file {
union {
struct bpf_iter_meta *meta;
};
union {
struct task_struct *task;
};
u32 fd;
union {
struct file *file;
};
};
在上述程式碼中,‘meta’ 欄位包含元資料,該元資料對於所有 BPF 迭代器程式都是相同的。其餘欄位特定於不同的迭代器。例如,對於 task_file 迭代器,核心層提供 ‘task’、‘fd’ 和 ‘file’ 欄位值。‘task’ 和 ‘file’ 是引用計數的,因此當 BPF 程式執行時它們不會消失。
以下是 bpf_iter_task_file.c 檔案中的一個片段:
SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
struct file *file = ctx->file;
__u32 fd = ctx->fd;
if (task == NULL || file == NULL)
return 0;
if (ctx->meta->seq_num == 0) {
count = 0;
BPF_SEQ_PRINTF(seq, " tgid gid fd file\n");
}
if (tgid == task->tgid && task->tgid != task->pid)
count++;
if (last_tgid != task->tgid) {
last_tgid = task->tgid;
unique_tgid_count++;
}
BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd,
(long)file->f_op);
return 0;
}
在上面的示例中,節名稱 SEC(iter/task_file),表明該程式是一個 BPF 迭代器程式,用於迭代所有任務中的所有檔案。程式的上下文是 bpf_iter__task_file 結構體。
使用者空間程式透過發出 read() 系統呼叫來呼叫核心中執行的 BPF 迭代器程式。一旦呼叫,BPF 程式可以使用各種 BPF 輔助函式將資料匯出到使用者空間。您可以根據需要格式化輸出還是僅二進位制資料,分別使用 bpf_seq_printf()(和 BPF_SEQ_PRINTF 輔助宏)或 bpf_seq_write() 函式。對於二進位制編碼資料,使用者空間應用程式可以根據需要處理來自 bpf_seq_write() 的資料。對於格式化資料,在將 BPF 迭代器固定到 bpffs 掛載點後,您可以使用 cat <path> 列印結果,類似於 cat /proc/net/netlink。稍後,使用 rm -f <path> 刪除固定的迭代器。
例如,您可以使用以下命令從 bpf_iter_ipv6_route.o 物件檔案建立一個 BPF 迭代器,並將其固定到 /sys/fs/bpf/my_route 路徑:
$ bpftool iter pin ./bpf_iter_ipv6_route.o /sys/fs/bpf/my_route
然後使用以下命令列印結果:
$ cat /sys/fs/bpf/my_route
實現 BPF 迭代器程式型別的核心支援¶
要在核心中實現 BPF 迭代器,開發者必須對 bpf.h 檔案中定義的以下關鍵資料結構進行一次性更改。
struct bpf_iter_reg {
const char *target;
bpf_iter_attach_target_t attach_target;
bpf_iter_detach_target_t detach_target;
bpf_iter_show_fdinfo_t show_fdinfo;
bpf_iter_fill_link_info_t fill_link_info;
bpf_iter_get_func_proto_t get_func_proto;
u32 ctx_arg_info_size;
u32 feature;
struct bpf_ctx_arg_aux ctx_arg_info[BPF_ITER_CTX_ARG_MAX];
const struct bpf_iter_seq_info *seq_info;
};
填寫資料結構欄位後,呼叫 bpf_iter_reg_target() 將迭代器註冊到主要的 BPF 迭代器子系統。
以下是 struct bpf_iter_reg 中每個欄位的詳細說明。
欄位 |
描述 |
|---|---|
target |
指定 BPF 迭代器的名稱。例如: |
attach_target 和 detach_target |
允許目標特定的 |
show_fdinfo 和 fill_link_info |
當用戶嘗試獲取與迭代器關聯的連結資訊時,呼叫此函式以填充目標特定資訊。 |
get_func_proto |
允許 BPF 迭代器訪問特定於該迭代器的 BPF 輔助函式。 |
ctx_arg_info_size 和 ctx_arg_info |
指定與 BPF 迭代器關聯的 BPF 程式引數的校驗器狀態。 |
feature |
指定核心 BPF 迭代器基礎設施中的某些操作請求。目前,僅支援 BPF_ITER_RESCHED。這意味著呼叫核心函式 cond_resched() 以避免其他核心子系統(例如 rcu)出現異常行為。 |
seq_info |
指定 BPF 迭代器和輔助函式用於初始化/釋放相應 |
點選此處檢視核心中 task_vma BPF 迭代器的實現。
BPF 任務迭代器引數化¶
預設情況下,BPF 迭代器遍歷整個系統中指定型別(程序、cgroup、對映等)的所有物件,以讀取相關的核心資料。但通常情況下,我們只關心可迭代核心物件的一個小得多的子集,例如只迭代特定程序內的任務。因此,BPF 迭代器程式支援透過允許使用者空間在附加時配置迭代器程式來過濾掉迭代中的物件。
BPF 任務迭代器程式¶
以下程式碼是一個 BPF 迭代器程式,用於透過迭代器的 seq_file 列印檔案和任務資訊。它是一個標準的 BPF 迭代器程式,訪問迭代器的每個檔案。我們將在後面的示例中使用此 BPF 程式。
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
char _license[] SEC("license") = "GPL";
SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
struct file *file = ctx->file;
__u32 fd = ctx->fd;
if (task == NULL || file == NULL)
return 0;
if (ctx->meta->seq_num == 0) {
BPF_SEQ_PRINTF(seq, " tgid pid fd file\n");
}
BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd,
(long)file->f_op);
return 0;
}
建立帶引數的檔案迭代器¶
現在,讓我們看看如何建立一個只包含某個程序檔案的迭代器。
首先,如下所示填充 bpf_iter_attach_opts 結構體:
LIBBPF_OPTS(bpf_iter_attach_opts, opts);
union bpf_iter_link_info linfo;
memset(&linfo, 0, sizeof(linfo));
linfo.task.pid = getpid();
opts.link_info = &linfo;
opts.link_info_len = sizeof(linfo);
linfo.task.pid 如果非零,則指示核心建立一個迭代器,該迭代器僅包含指定 pid 程序的已開啟檔案。在此示例中,我們將只迭代我們程序的檔案。如果 linfo.task.pid 為零,迭代器將訪問每個程序的所有已開啟檔案。類似地,linfo.task.tid 指示核心建立一個迭代器,該迭代器訪問特定執行緒的已開啟檔案,而不是程序的。在此示例中,linfo.task.tid 與 linfo.task.pid 不同僅當執行緒具有單獨的檔案描述符表時。在大多數情況下,所有程序執行緒共享一個檔案描述符表。
現在,在使用者空間程式中,將結構體指標傳遞給 bpf_program__attach_iter()。
link = bpf_program__attach_iter(prog, &opts);
iter_fd = bpf_iter_create(bpf_link__fd(link));
如果 tid 和 pid 都為零,則從此 struct bpf_iter_attach_opts 建立的迭代器將包含系統中每個任務的所有已開啟檔案(實際上是在名稱空間中)。這與將 NULL 作為第二個引數傳遞給 bpf_program__attach_iter() 相同。
整個程式程式碼如下所示:
#include <stdio.h>
#include <unistd.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "bpf_iter_task_ex.skel.h"
static int do_read_opts(struct bpf_program *prog, struct bpf_iter_attach_opts *opts)
{
struct bpf_link *link;
char buf[16] = {};
int iter_fd = -1, len;
int ret = 0;
link = bpf_program__attach_iter(prog, opts);
if (!link) {
fprintf(stderr, "bpf_program__attach_iter() fails\n");
return -1;
}
iter_fd = bpf_iter_create(bpf_link__fd(link));
if (iter_fd < 0) {
fprintf(stderr, "bpf_iter_create() fails\n");
ret = -1;
goto free_link;
}
/* not check contents, but ensure read() ends without error */
while ((len = read(iter_fd, buf, sizeof(buf) - 1)) > 0) {
buf[len] = 0;
printf("%s", buf);
}
printf("\n");
free_link:
if (iter_fd >= 0)
close(iter_fd);
bpf_link__destroy(link);
return 0;
}
static void test_task_file(void)
{
LIBBPF_OPTS(bpf_iter_attach_opts, opts);
struct bpf_iter_task_ex *skel;
union bpf_iter_link_info linfo;
skel = bpf_iter_task_ex__open_and_load();
if (skel == NULL)
return;
memset(&linfo, 0, sizeof(linfo));
linfo.task.pid = getpid();
opts.link_info = &linfo;
opts.link_info_len = sizeof(linfo);
printf("PID %d\n", getpid());
do_read_opts(skel->progs.dump_task_file, &opts);
bpf_iter_task_ex__destroy(skel);
}
int main(int argc, const char * const * argv)
{
test_task_file();
return 0;
}
以下是程式的輸出行。
PID 1859
tgid pid fd file
1859 1859 0 ffffffff82270aa0
1859 1859 1 ffffffff82270aa0
1859 1859 2 ffffffff82270aa0
1859 1859 3 ffffffff82272980
1859 1859 4 ffffffff8225e120
1859 1859 5 ffffffff82255120
1859 1859 6 ffffffff82254f00
1859 1859 7 ffffffff82254d80
1859 1859 8 ffffffff8225abe0
無引數¶
讓我們看看不帶引數的 BPF 迭代器如何跳過系統中其他程序的檔案。在這種情況下,BPF 程式必須檢查任務的 pid 或 tid,否則它將接收系統中每個已開啟的檔案(實際上是在當前 pid 名稱空間中)。因此,我們通常在 BPF 程式中新增一個全域性變數,將 pid 傳遞給 BPF 程式。
BPF 程式程式碼塊如下所示:
...... int target_pid = 0; SEC("iter/task_file") int dump_task_file(struct bpf_iter__task_file *ctx) { ...... if (task->tgid != target_pid) /* Check task->pid instead to check thread IDs */ return 0; BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd, (long)file->f_op); return 0; }
使用者空間程式程式碼塊如下所示:
...... static void test_task_file(void) { ...... skel = bpf_iter_task_ex__open_and_load(); if (skel == NULL) return; skel->bss->target_pid = getpid(); /* process ID. For thread id, use gettid() */ memset(&linfo, 0, sizeof(linfo)); linfo.task.pid = getpid(); opts.link_info = &linfo; opts.link_info_len = sizeof(linfo); ...... }
target_pid 是 BPF 程式中的一個全域性變數。使用者空間程式應使用程序 ID 初始化該變數,以在 BPF 程式中跳過其他程序的已開啟檔案。當您引數化 BPF 迭代器時,迭代器呼叫 BPF 程式的次數會減少,這可以節省大量資源。
VMA 迭代器引數化¶
預設情況下,BPF VMA 迭代器包含每個程序中的所有 VMA。但是,您仍然可以指定一個程序或執行緒,以僅包含其 VMA。與檔案不同,執行緒不能擁有獨立的地址空間(自 Linux 2.6.0-test6 起)。在這裡,使用 tid 與使用 pid 沒有區別。
任務迭代器引數化¶
帶有 pid 的 BPF 任務迭代器包括程序的所有任務(執行緒)。BPF 程式會逐一接收這些任務。您可以指定一個帶有 tid 引數的 BPF 任務迭代器,以僅包含與給定 tid 匹配的任務。