BTF 資料載入

第一個系統呼叫使用 BPF_BTF_LOAD 命令:

bpf(BPF_BTF_LOAD, {btf="\237\353\1\0...}, 128) = 3

BTF(BPF Type Format)是一種用於描述 BPF 程式中型別資訊的格式。它對於啟用 CO-RE(Compile Once, Run Everywhere)功能至關重要,這種功能允許 eBPF 程式在不同的核心版本上執行而無需重新編譯。

在編譯階段,可以透過 -g 標誌生成 BTF 資訊。這些資訊在載入階段被傳遞給核心,以便核心能夠正確理解 eBPF 程式中使用的資料型別。

值得注意的是,在某些環境中,你可能不會看到這個系統呼叫,這取決於你使用的核心版本和 BCC 版本。

對映建立

接下來的兩個系統呼叫使用 BPF_MAP_CREATE 命令,分別建立了兩種不同型別的對映:

bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_PERF_EVENT_ARRAY...}) = 4
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH...}) = 5

這些系統呼叫對應於我們程式中的 BPF_PERF_OUTPUTBPF_HASH 定義。第一個建立了一個效能事件陣列對映,用於從核心向使用者空間傳送資料;第二個建立了一個雜湊表對映,用於儲存使用者 ID 與自訂訊息之間的對應關係。

系統呼叫的回傳值(4 和 5)是這些對映的檔案描述符,使用者空間程式可以使用這些描述符來參照和操作相應的對映。

eBPF 程式載入

第四個系統呼叫使用 BPF_PROG_LOAD 命令載入 eBPF 程式:

bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE,...prog_name="hello",...}) = 6

這個系統呼叫將編譯好的 eBPF 程式載入到核心中。prog_type 引數指定了程式的型別,在這個例子中是 BPF_PROG_TYPE_KPROBE,表示這個程式將被附加到核心探針上。prog_name 引數設定了程式的名稱,對應於我們程式碼中的 hello 函式。

載入過程中,核心會對 eBPF 程式進行驗證,確保其符合安全要求。如果驗證透過,系統呼叫會回傳一個檔案描述符(在這個例子中是 6),使用者空間程式可以使用這個描述符來參照該 eBPF 程式。

對映更新

最後,我們看到了使用 BPF_MAP_UPDATE_ELEM 命令的系統呼叫:

bpf(BPF_MAP_UPDATE_ELEM, ...)

這個系統呼叫對應於 Python 程式碼中的對映更新操作,它將自訂訊息存入 config 對映中,以便 eBPF 程式使用。

JIT 編譯與效能考量

在 eBPF 程式載入過程中,核心可能會對程式進行即時(JIT)編譯,將 eBPF 位元組碼轉換為本機器碼,以提高執行效率。這需要核心設定 CONFIG_BPF_JIT 被啟用,該設定可以在編譯核心時設定,也可以透過 net.core.bpf_jit_enable sysctl 設定在執行時啟用或停用。

JIT 編譯的支援程度取決於不同的晶片架構,詳細資訊可以在核心檔案中找到。

BPF 程式與對映的固定

在某些情況下,你可能需要將 eBPF 程式或對映固定到檔案系統中的一個位置,以便其他程式可以找到並使用它們。這通常是透過 BPF 檔案系統(通常掛載在 /sys/fs/bpf/)實作的。

雖然對於一般的 eBPF 程式來說,固定是可選的,但對於 bpftool 這樣的工具來說,它總是需要固定它所載入的程式。這是因為 bpftool 需要一種方式來參照這些程式,而檔案系統路徑提供了一個方便的參照方式。

對映參考與程式互動

eBPF 程式和對映之間的互動是透過檔案描述符實作的。當一個對映被建立時,核心會回傳一個檔案描述符,使用者空間程式和 eBPF 程式都可以使用這個描述符來參照該對映。

在我們的範例程式中,config 對映被 eBPF 程式用來查詢使用者 ID 對應的自訂訊息。這種互動是透過 BPF 輔助函式實作的,而不是透過系統呼叫。

深入理解 bpf() 系統呼叫的命令集

bpf() 系統呼叫支援多種命令,每種命令都有特定的用途。以下是一些常見的命令:

  • BPF_MAP_CREATE:建立一個新的 BPF 對映
  • BPF_MAP_LOOKUP_ELEM:在對映中查詢元素
  • BPF_MAP_UPDATE_ELEM:更新或插入對映中的元素
  • BPF_MAP_DELETE_ELEM:刪除對映中的元素
  • BPF_PROG_LOAD:載入 eBPF 程式到核心
  • BPF_PROG_ATTACH:將 eBPF 程式附加到特定的事件
  • BPF_PROG_DETACH:從事件中分離 eBPF 程式
  • BPF_BTF_LOAD:載入 BTF 資料

這些命令共同構成了一個完整的 API,使用者空間程式能夠管理 eBPF 程式和對映的生命週期,並且它們互動。

實際應用中的考量

在實際開發 eBPF 應用程式時,你可能不會直接使用 bpf() 系統呼叫,而是使用更高階的函式庫,如 libbpf、BCC 或 bpftool。這些工具提供了更友好的 API,簡化了 eBPF 程式的開發和佈署過程。

然而,理解底層的 bpf() 系統呼叫對於深入掌握 eBPF 技術仍然至關重要。它可以幫助你理解這些高階抽象的工作原理,並在需要時進行底層最佳化或除錯。

在開發過程中,strace 工具是一個寶貴的資源,它可以幫助你觀察程式與核心之間的互動,理解 eBPF 程式的載入和執行過程。

eBPF 技術的強大之處在於它提供了一種安全、高效的方式,讓使用者空間程式能夠擴充套件核心的功能,而無需修改核心原始碼或載入核心模組。bpf() 系統呼叫作為這種互動的基礎,為 eBPF 的靈活性和強大功能提供了關鍵支援。

透過深入理解 bpf() 系統呼叫及其相關命令,開發者可以更好地利用 eBPF 技術的潛力,開發出更高效、更強大的系統工具和應用程式。

在 eBPF 的生態系統中,理解使用者空間和核心空間之間的互動模型是掌握這項技術的關鍵。bpf() 系統呼叫提供了這種互動的基礎機制,而各種高階抽象則在此基礎上提供了更便捷的開發體驗。無論採用哪種開發方式,深入瞭解底層機制都將有助於開發更高效、更可靠的 eBPF 應用程式。

eBPF 程式的可攜性與 BTF 機制

eBPF (Extended Berkeley Packet Filter) 是 Linux 核心中強大的程式執行環境,其中 BTF (BPF Type Format) 扮演著關鍵角色。BTF 實作了 eBPF 程式的可攜性,使得你能在一台機器上編譯程式,然後在使用不同核心版本的另一台機器上執行它。

BTF 的核心價值

BTF 解決了 eBPF 開發中的一個關鍵挑戰:不同核心版本間的資料結構差異。在實際開發中,我發現 BTF 能有效解決以下問題:

  1. 核心版本差異:不同版本的 Linux 核心可能有不同的資料結構定義
  2. 編譯環境限制:避免必須在每個目標環境重新編譯程式的麻煩
  3. 結構資訊儲存:保留資料結構的詳細資訊,使得除錯和分析更容易

當系統呼叫 bpf() 載入 BTF 資料時,會回傳一個檔案描述符 (file descriptor),這個描述符將用於後續操作中參照該 BTF 資料。

檔案描述符的本質

檔案描述符本質上是開啟檔案(或類別檔案物件)的識別碼。當你開啟一個檔案(透過 open()openat() 系統呼叫),回傳的就是一個檔案描述符,後續可用於 read()write() 等操作。

在 eBPF 的情境中,雖然 BTF 資料並非真正的檔案,但核心仍然為其分配檔案描述符,作為後續操作的參考識別碼。這種設計使得 BTF 資料能夠融入 Linux 檔案操作的統一介面中。

建立 eBPF Maps

eBPF 程式通常需要與使用者空間分享資料,這時就需要用到 maps。Maps 是 eBPF 程式與使用者空間程式之間通訊的主要方式。

建立輸出效能緩衝區 Map

讓我們看如何使用 bpf() 系統呼叫建立一個效能緩衝區 map:

bpf(BPF_MAP_CREATE,
{map_type=BPF_MAP_TYPE_PERF_EVENT_ARRAY, key_size=4,
value_size=4, max_entries=4, ... map_name="output", ...},
128) = 4

這個系統呼叫使用 BPF_MAP_CREATE 命令建立了一個新的 eBPF map。從引數可以看出:

  • map_type=BPF_MAP_TYPE_PERF_EVENT_ARRAY:這是一個效能事件陣列 map,通常用於從 eBPF 程式向使用者空間傳送事件資料
  • key_size=4:鍵的大小為 4 個位元組(通常是 CPU ID)
  • value_size=4:值的大小也是 4 個位元組(通常是指向緩衝區的指標)
  • max_entries=4:map 最多可以容納 4 個鍵值對,這通常對應於系統的 CPU 核心數
  • map_name="output":map 的名稱設為 “output”

系統呼叫回傳值 4 是一個檔案描述符,使用者空間程式可以透過這個描述符存取輸出 map。

建立設定 Hash Map

接下來看建立設定 map 的系統呼叫:

bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH,
key_size=4, value_size=12,
max_entries=10240... map_name="config", ...btf_fd=3,...},
128) = 5

這個系統呼叫建立了一個 hash 表型別的 map:

  • map_type=BPF_MAP_TYPE_HASH:hash 表型別,適合用於鍵值查詢操作
  • key_size=4:鍵的大小為 4 個位元組,剛好能容納一個 32 位元的使用者 ID
  • value_size=12:值的大小為 12 個位元組,與 msg_t 結構的大小相符
  • max_entries=10240:map 最多可以包含 10,240 個專案(這是 BCC 的預設大小)
  • map_name="config":map 的名稱為 “config”
  • btf_fd=3:使用前面載入的 BTF 資料(檔案描述符為 3)

這個呼叫回傳的檔案描述符是 5,後續操作中將使用這個描述符參照 config map。

值得注意的是 btf_fd=3 引數,它告訴核心使用之前載入的 BTF 資料。BTF 資訊描述了資料結構的佈局,將其包含在 map 定義中,意味著有關鍵和值型別佈局的資訊。這使得像 bpftool 這樣的工具能夠以人類可讀的方式格式化 map 內容。

載入 eBPF 程式

建立完 maps 之後,接下來需要將 eBPF 程式載入到核心中:

bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE,
insn_cnt=44,
insns=0xffffa836abe8, license="GPL", ... prog_name="hello",
...
expected_attach_type=BPF_CGROUP_INET_INGRESS,
prog_btf_fd=3,...}, 128) = 6

這個系統呼叫使用 BPF_PROG_LOAD 命令載入 eBPF 程式。引數中有幾個關鍵欄位:

  • prog_type=BPF_PROG_TYPE_KPROBE:指定程式型別為 kprobe,表示這個程式會附加到一個核心探測點
  • insn_cnt=44:程式包含 44 條位元組碼指令
  • insns=0xffffa836abe8:指向記憶體中位元組碼指令的指標
  • license="GPL":指定程式的授權為 GPL,這允許程式使用 GPL 授權的 BPF 輔助函式
  • prog_name="hello":程式的名稱為 “hello”
  • expected_attach_type=BPF_CGROUP_INET_INGRESS:這個欄位看起來與網路流量入口有關,但實際上對於 kprobe 型別的程式並不使用,它的值為 0,恰好對應於 BPF_CGROUP_INET_INGRESS
  • prog_btf_fd=3:指定使用前面載入的 BTF 資料(與 config map 使用相同的 BTF 資料)

如果程式未能透過驗證(這是 eBPF 安全機制的一部分),這個系統呼叫會回傳一個負值。在這個例子中,它回傳了檔案描述符 6,表示程式載入成功。

到目前為止,我們已經獲得了以下檔案描述符:

檔案描述符代表
3BTF 資料
4output 效能緩衝區 map
5config hash 表 map
6hello eBPF 程式

從使用者空間修改 Map

在 Python 使用者空間程式中,我們看到了以下程式碼,為 root 使用者(ID 為 0)和 ID 為 501 的使用者設定特殊訊息:

b["config"][ct.c_int(0)] = ct.create_string_buffer(b"Hey root!")
b["config"][ct.c_int(501)] = ct.create_string_buffer(b"Hi user 501!")

這些操作對應的系統呼叫如下:

bpf(BPF_MAP_UPDATE_ELEM, {map_fd=5, key=0xffffa7842490,
value=0xffffa7a2b410,
flags=BPF_ANY}, 128) = 0

這個系統呼叫使用 BPF_MAP_UPDATE_ELEM 命令更新 map 中的鍵值對:

  • map_fd=5:指定要操作的 map,這裡是之前建立的 config map
  • key=0xffffa7842490:指向鍵的指標(在此例中是使用者 ID)
  • value=0xffffa7a2b410:指向值的指標(在此例中是訊息字串)
  • flags=BPF_ANY:表示如果鍵不存在則建立,如果存在則更新

由於 key 和 value 都是指標,從 strace 輸出中無法直接看到它們的實際值。不過,我們可以使用 bpftool 檢視 map 的內容:

$ bpftool map dump name config
[{
"key": 0,
"value": {
"message": "Hey root!"
}
},{
"key": 501,
"value": {
"message": "Hi user 501!"
}
}
]

bpftool 能夠正確格式化這個輸出是因為它使用了 BTF 資訊,這些資訊在建立 map 時透過 btf_fd 引數提供。BTF 資料告訴 bpftool 值是一個結構,其中包含一個名為 message 的字串欄位。

檔案描述符與資源管理

在 eBPF 程式中,檔案描述符扮演著關鍵角色,它們是使用者空間程式與核心中 eBPF 物件互動的橋樑。值得注意的是:

  1. 描述符的作用域:檔案描述符是由核心為特定行程分配的,因此描述符值 5 只對執行 Python 程式的特定使用者空間行程有效。

  2. 分享資源:多個使用者空間程式(和核心中的多個 eBPF 程式)可以存取相同的 map。兩個存取核心中相同 map 結構的使用者空間程式可能會被分配不同的檔案描述符值;同樣,兩個使用者空間程式可能有相同的檔案描述符值,但實際上指向完全不同的 maps。

  3. 程式附加:到目前為止,我們看到的系統呼叫序列中,程式尚未附加到任何事件。這一步驟必須發生,否則程式永遠不會被觸發。

  4. 附加方式的多樣性:不同型別的 eBPF 程式以不同的方式附加到不同的事件。在本例中,附加到 kprobe 事件的系統呼叫不涉及 bpf()。相比之下,附加到原始追蹤點事件的程式可能會使用 bpf() 系統呼叫。

  5. 資源自動釋放:當你結束執行程式時,程式和 maps 會自動解除安裝。這是因為核心會追蹤這些資源的參照計數。

eBPF 程式流程解析

綜合上述內容,我們可以看到一個完整的 eBPF 程式載入和執行流程:

  1. 使用 BPF_BTF_LOAD 載入 BTF 資料
  2. 使用 BPF_MAP_CREATE 建立所需的 maps
  3. 使用 BPF_PROG_LOAD 載入 eBPF 程式
  4. 使用 BPF_MAP_UPDATE_ELEM 初始化或更新 maps 中的資料
  5. 將程式附加到特定事件(不同程式型別有不同的附加方式)
  6. 程式執行,當事件觸發時執行
  7. 程式結束時,核心自動清理資源

這種設計使得 eBPF 能夠安全與高效地在核心中執行使用者定義的程式碼,同時提供了與使用者空間程式互動的機制。

BTF 的進階價值

BTF 不僅是為了實作程式的可攜性,它還提供了更多的價值:

  1. 自描述性:BTF 資料包含了型別和結構的完整資訊,使得工具能夠理解並正確呈現資料
  2. 除錯支援:有了 BTF,除錯工具可以顯示結構化的資料,而不僅是記憶體轉儲
  3. 檔案自動化:BTF 資訊可用於自動生成 API 檔案
  4. 程式碼生成:可以根據 BTF 資訊自動生成程式碼,如序列化/反序列化函式

在實際開發中,我發現 BTF 是 eBPF 生態系統中不可或缺的一部分,它大簡化了跨核心版本的 eBPF 程式開發和佈署。

eBPF 的系統呼叫機制和資源管理方式體現了 Linux 核心設計的優雅之處。透過檔案描述符這一統一介面,eBPF 能夠無縫融入 Linux 的資源管理框架,同時提供強大的可程式化能力。瞭解這些底層機制對於掌握 eBPF 技術至關重要,也有助於開發更高效、更可靠的 eBPF 應用程式。

BPF程式的生命週期與參考計數管理

當我們透過bpf()系統呼叫將BPF程式載入核心時,系統會回傳一個檔案描述符(file descriptor)。在核心內部,這個檔案描述符實際上是程式的一個參考。這種參考計數機制對於理解BPF程式的生命週期管理至關重要,特別是在複雜系統中更需要掌握這些細節。

參考計數的基本原理

參考計數是核心追蹤BPF資源使用的主要機制。這個機制遵循以下原則:

  • 發起bpf()系統呼叫的使用者空間程式擁有檔案描述符
  • 當該程式結束時,檔案描述符會被釋放,對程式的參考計數會遞減
  • 當程式沒有任何參考時,核心會自動移除該程式

這個機制確保了資源的適時釋放,避免了記憶體洩漏。不過,在實務上我們通常希望BPF程式能持續執行,而不受載入程式生命週期的限制。這就是釘選(pinning)機制的用處。

釘選機制:讓BPF程式持久化

釘選的工作原理

釘選是在BPF虛擬檔案系統中建立一個持久參考,使BPF程式或對映能夠在載入程式結束後繼續存在。在實作中,我發現這是保持BPF程式執行的最可靠方式之一。

以下是一個使用bpftool進行釘選的例子:

bpftool prog load hello.bpf.o /sys/fs/bpf/hello

這個命令做了兩件事:首先將編譯好的BPF物件檔hello.bpf.o載入到核心,然後將其釘選到BPF虛擬檔案系統的/sys/fs/bpf/hello路徑。這個釘選操作建立了一個額外的參考,確保程式在bpftool命令結束後仍然保留在核心中。

值得注意的是,這些釘選的物件並非真正持久化到磁碟的檔案。它們存在於一個虛擬檔案系統中,雖然行為類別似於常規檔案系統,有目錄和檔案結構,但實際上是存放在記憶體中的。這意味著系統重新啟動後,這些釘選的物件不會保留。

為什麼釘選很重要?

如果bpftool只是載入程式而不釘選,那麼當命令執行完畢時,檔案描述符會被釋放,程式的參考計數會降為零,核心會立即移除該程式。這樣的操作基本沒有實用價值。釘選建立了一個額外的參考,確保程式在命令完成後仍然保留。

BPF程式附加與參考計數

當BPF程式被附加到會觸發它的鉤子(hook)時,參考計數也會增加。不過,這些參考計數的行為取決於BPF程式的型別。

不同程式型別的參考計數行為

根據我的經驗,BPF程式型別大致可分為兩類別:

  1. 與使用者空間程式關聯的程式:如kprobes和tracepoints等追蹤相關的程式型別,總是與一個使用者空間程式關聯。對於這些型別,當關聯的使用者空間程式結束時,核心的參考計數會遞減。

  2. 獨立於使用者空間的程式:附加到網路堆積積疊或cgroups(控制組)的程式不與任何使用者空間程式關聯,因此即使載入它們的使用者空間程式結束,它們仍然保持在原位。

XDP程式的附加範例

以XDP(eXpress Data Path)程式為例,我們可以使用ip命令將其附加到網路介面:

ip link set dev eth0 xdp obj hello.bpf.o sec xdp

這個命令將hello.bpf.o中的xdp段載入為XDP程式,並將其附加到eth0網路介面。雖然ip命令執行完成,與沒有定義釘選位置,但程式仍然載入在核心中。

執行bpftool prog list可以確認程式仍在執行:

$ bpftool prog list
...
1255: xdp name hello tag 9d0e949f89f1a82c gpl
loaded_at 2022-11-01T19:21:14+0000 uid 0
xlated 48B jited 108B memlock 4096B map_ids 612

這是因為程式被附加到XDP鉤子上,即使ip link命令已經完成,這個附加操作也會保持參考計數大於零,從而使程式保留在核心中。

BPF對映的參考計數

BPF對映(maps)也有參考計數機制,當計數降為零時會被清理。每個使用對映的BPF程式都會增加計數,使用者空間程式持有的每個指向該對映的檔案描述符也會增加計數。

對映與程式的關聯

有時BPF程式的原始碼可能定義了一個對映,但程式實際上並未參考它。例如,若要儲存關於程式的一些元資料,可以將其定義為全域變數,這些資訊會儲存在對映中。但如果BPF程式沒有實際使用該對映,則不會自動建立從程式到對映的參考計數。

在這種情況下,可以使用BPF(BPF_PROG_BIND_MAP)系統呼叫將對映與程式關聯起來,這樣即使用者空間載入程式結束與不再持有對映的檔案描述符參考,對映也不會被清理。

對映也可以被釘選到檔案系統,使用者空間程式可以透過知道對映的路徑來存取它。

BPF連結:程式附加的抽象層

BPF連結(BPF links)提供了BPF程式與其附加事件之間的抽象層。BPF連結本身可以被釘選到檔案系統,這會為程式建立一個額外的參考。

BPF連結的優勢

使用BPF連結有幾個關鍵優勢:

  1. 程式持久化:載入程式的使用者空間程式可以終止,但程式仍然保持載入狀態
  2. 參考管理:使用者空間載入程式的檔案描述符被釋放,減少了對程式的參考計數,但由於BPF連結的存在,參考計數仍然大於零
  3. 抽象層隔離:連結提供了程式與觸發事件之間的隔離層,使管理更加靈活

這種機制特別適合需要長期執行的BPF程式,如網路過濾器或安全監控工具。

深入理解eBPF相關的系統呼叫

在處理perf緩衝區、環形緩衝區、kprobes和對映迭代時,會涉及一系列複雜的系統呼叫。讓我們以hello-buffer-config.py為例,詳細分析這些操作。

初始化Perf緩衝區

在將條目增加到設定對映後,程式會執行一系列看起來像這樣的bpf()呼叫:

bpf(BPF_MAP_UPDATE_ELEM, {map_fd=4, key=0xffffa7842490, value=0xffffa7a2b410, flags=BPF_ANY}, 128) = 0

這些呼叫與定義設定對映條目的呼叫非常相似,不同之處在於此處對映的檔案描述符是4,代表輸出perf緩衝區對映。

這個系統呼叫重複了四次,引數值完全相同。這引出了一些問題:

  1. 為什麼需要四次BPF_MAP_UPDATE_ELEM呼叫?這是否與輸出對映建立時設定的最大四個條目有關?
  2. 在這四個BPF_MAP_UPDATE_ELEM例項之後,strace輸出中沒有更多的bpf()系統呼叫。但程式明顯能夠顯示資料,這些資料顯然不是透過bpf()系統呼叫從對映中檢索的,那麼它們是如何取得的?
  3. 還沒有看到任何證據表明BPF程式是如何附加到觸發它的kprobe事件的。

要解答這些問題,我們需要使用更詳細的strace命令:

$ strace -e bpf,perf_event_open,ioctl,ppoll ./hello-buffer-config.py

附加到Kprobe事件

前面我們看到檔案描述符6被分配給載入到核心的BPF程式hello。要將BPF程式附加到事件,還需要一個代表該特定事件的檔案描述符。

從strace輸出中可以看到,建立execve()kprobe的檔案描述符的命令如下:

perf_event_open({type=0x6 /* PERF_TYPE_??? */, ...},...) = 7

根據perf_event_open()系統呼叫的手冊頁,它「建立一個允許測量效能資訊的檔案描述符」。從輸出中可以看到,strace不知道如何解釋值為6的type引數。

透過檢視Linux的效能測量單元(PMU)設定,我們可以找到答案:

$ cat /sys/bus/event_source/devices/kprobe/type
6

這表明perf_event_open()呼叫的type設定為6,對應於kprobe事件型別。這是將BPF程式附加到核心函式的關鍵步驟。

使用perf緩衝區的資料傳輸機制

Perf緩衝區是BPF程式與使用者空間程式之間高效傳輸資料的機制。當我在設計效能關鍵的BPF應用時,理解這一機制的工作原理對於最佳化資料路徑至關重要。

緩衝區設定與事件監聽

Perf緩衝區的設定涉及多個步驟:

  1. 建立一個BPF_MAP_TYPE_PERF_EVENT_ARRAY型別的對映
  2. 使用BPF_MAP_UPDATE_ELEM將perf事件檔案描述符關聯到此對映
  3. 設定使用者空間事件監聽器來接收資料

使用者空間程式通常會使用poll()或類別似機制來等待事件,當BPF程式寫入資料時,這些事件會觸發回呼函式。

這種機制避免了持續輪詢對映的需要,大提高了效率,特別是在事件發生頻率不高的情況下。

實用技巧與最佳實踐

在處理BPF程式和對映的參考計數時,有幾個實用技巧值得分享:

釘選管理

  1. 有組織的釘選路徑:為不同型別的BPF物件(程式、對映、連結)建立獨立的目錄結構,便於管理
  2. 命名慣例:使用描述性名稱,包含版本或日期資訊,方便追蹤和更新
  3. 清理指令碼:開發清理指令碼以移除未使用的釘選物件,避免累積過多廢舊物件

參考計數除錯

當遇到BPF物件意外消失或無法移除的問題時,理解參考計數機制非常重要:

  1. 使用bpftool檢查bpftool prog show id <id>可以顯示程式的詳細資訊,包括參考計數
  2. 追蹤檔案描述符:使用lsof工具識別哪些程式持有BPF物件的檔案描述符
  3. 核心日誌檢查:核心日誌中可能包含有關BPF物件生命週期的有用資訊

對映分享模式

當多個BPF程式需要分享資料時:

  1. 集中式對映:建立一個集中式對映並將其釘選,讓多個程式參考它
  2. 參考繫結:使用BPF_PROG_BIND_MAP確保對映在所有使用它的程式移除前不會被清理
  3. 對映名稱空間:考慮使用名稱空間隔離不同應用程式的對映,避免衝突

BPF程式和對映的參考計數機制為資源管理提供了強大而靈活的框架。理解這些機制如何工作,可以幫助開發者建立更穩健、更高效的BPF應用。無論是開發簡單的監控工具還是複雜的網路過濾系統,掌握這些核心概念都是成功的基礎。

透過釘選、BPF連結和適當的參考計數管理,我們可以確保BPF程式在需要時保持執行,並在不再需要時正確清理,從而建立既高效又可靠的系統。

eBPF 與核心互動的內部機制

在開發 eBPF 應用程式時,瞭解其與 Linux 核心互動的底層機制非常重要。雖然大多數時候我們會使用高階框架來處理這些細節,但深入理解這些系統呼叫能幫助我們更好地診斷問題並最佳化程式。

追蹤 kprobe 事件的附加過程

當我們使用 strace 工具追蹤 eBPF 程式時,雖然輸出中沒有直接顯示 kprobe 已附加到 execve() 系統呼叫的詳細證據,但有足夠的線索可以確認這一點。

在追蹤輸出中,我們可以看到 perf_event_open() 系統呼叫回傳檔案描述符 7,這代表 kprobe 的 perf 事件。而檔案描述符 6 則代表我們的 eBPF 程式。根據 perf_event_open() 的手冊頁說明,我們可以使用 ioctl() 來建立兩者之間的連線:

// PERF_EVENT_IOC_SET_BPF 允許將 BPF 程式附加到現有的 kprobe 追蹤點事件
// 引數是由先前的 bpf(2) 系統呼叫建立的 BPF 程式檔案描述符
ioctl(7, PERF_EVENT_IOC_SET_BPF, 6) = 0

這解釋了為什麼在 strace 輸出中會看到這個 ioctl() 系統呼叫,它參照了兩個檔案描述符。接著,另一個 ioctl() 呼叫啟用了 kprobe 事件:

ioctl(7, PERF_EVENT_IOC_ENABLE, 0) = 0

透過這些操作,eBPF 程式就會在機器上每次執行 execve() 時被觸發。這種機制讓我們能夠深入觀察系統行為,而不必修改核心程式碼。

Perf 事件緩衝區的設定與讀取

在我分析的 eBPF 程式中,發現有四次 bpf(BPF_MAP_UPDATE_ELEM) 呼叫與輸出 perf 緩衝區相關。當追蹤額外的系統呼叫時,strace 輸出顯示了四個類別似的序列:

perf_event_open({type=PERF_TYPE_SOFTWARE, size=0 /* PERF_ATTR_SIZE_??? */,
config=PERF_COUNT_SW_BPF_OUTPUT, ...}, -1, X, -1, PERF_FLAG_FD_CLOEXEC) = Y
ioctl(Y, PERF_EVENT_IOC_ENABLE, 0) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=4, key=0xffffa7842490, value=0xffffa7a2b410,
flags=BPF_ANY}, 128) = 0

這裡的 X 在四次呼叫中分別是 0、1、2 和 3,對應於我的筆電上四個 CPU 核心。根據 perf_event_open() 的手冊頁,當 pid == -1cpu >= 0 時,這會測量指定 CPU 上的所有處理程式/執行緒。

這解釋了為什麼 “output” perf 緩衝區對映中有四個條目:每個 CPU 核心對應一個。這也說明瞭對映型別名稱 BPF_MAP_TYPE_PERF_EVENT_ARRAY 中 “array” 部分的含義 - 該對映不僅表示一個 perf 環形緩衝區,而是代表一個緩衝區陣列,每個核心一個。

這種設計反映了 eBPF 的核心特性之一 - 它能在多核系統上高效運作,每個核心都有自己的資料通道。當編寫 eBPF 程式時,通常不需要擔心核心數量等細節,因為這些會由 eBPF 函式庫(如後續章節討論的)自動處理。但瞭解這一點有助於理解系統行為和效能特性。

每次 perf_event_open() 呼叫都回傳一個檔案描述符(在範例中為 8、9、10 和 11)。ioctl() 系統呼叫啟用每個檔案描述符的 perf 輸出。BPF_MAP_UPDATE_ELEMbpf() 系統呼叫將對映條目設定為指向每個 CPU 核心的 perf 環形緩衝區,指示其可以提交資料的位置。

使用者空間程式碼可以在所有四個輸出流檔案描述符上使用 ppoll(),以便取得資料輸出,無論哪個核心恰好為任何給定的 execve() kprobe 事件執行 eBPF 程式。以下是 ppoll() 的系統呼叫:

ppoll([{fd=8, events=POLLIN}, {fd=9, events=POLLIN}, {fd=10, events=POLLIN},
{fd=11, events=POLLIN}], 4, NULL, NULL, 0) = 1 ([{fd=8, revents=POLLIN}])

如果自己執行範例程式,這些 ppoll() 呼叫會阻塞,直到其中一個檔案描述符有可讀取的內容。在有東西觸發 execve() 之前,不會看到寫入螢幕的回傳碼,因為這會導致 eBPF 程式寫入資料,使用者空間使用 ppoll() 呼叫檢索這些資料。

Ring Buffer:更現代的選擇

在 Linux 核心 5.8 或更高版本中,BPF Ring Buffer 現在比 Perf Buffer 更受推薦。Ring Buffer 不僅在效能上有優勢,還能確保資料的順序保持一致,即使資料由不同的 CPU 核心提交。與 Perf Buffer 不同,Ring Buffer 只有一個緩衝區,由所有核心分享。

將使用 Perf Buffer 的範例程式轉換為使用 Ring Buffer 並不需要太多更改。下表顯示了兩者之間的主要差異:

hello-buffer-config.pyhello-ring-buffer-config.py
BPF_PERF_OUTPUT(output);BPF_RINGBUF_OUTPUT(output, 1);
output.perf_submit(ctx, &data, sizeof(data));output.ringbuf_output(&data, sizeof(data), 0);
b[“output”].open_perf_buffer(print_event)b[“output”].open_ring_buffer(print_event)
b.perf_buffer_poll()b.ring_buffer_poll()

正如預期,由於這些更改僅與輸出緩衝區相關,因此與載入程式和設定對映以及將程式附加到 kprobe 事件相關的系統呼叫都保持不變。

建立輸出環形緩衝區對映的 bpf() 系統呼叫如下所示:

bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_RINGBUF, key_size=0, value_size=0,
max_entries=4096, ... map_name="output", ...}, 128) = 4

strace 輸出的主要區別是沒有一系列四個不同的 perf_event_open()ioctl()bpf(BPF_MAP_UPDATE_ELEM) 系統呼叫,這些是在設定 Perf Buffer 時觀察到的。對於 Ring Buffer,只有一個檔案描述符由所有 CPU 核心分享。

ppoll vs epoll:等待機制的演進

目前,BCC 對 Perf Buffer 使用我之前展示的 ppoll 機制,但對 Ring Buffer 則使用更新的 epoll 機制來等待資料。這是理解 ppollepoll 之間差異的好機會。

在 Perf Buffer 範例中,hello-buffer-config.py 生成了一個 ppoll() 系統呼叫:

ppoll([{fd=8, events=POLLIN}, {fd=9, events=POLLIN}, {fd=10, events=POLLIN},
{fd=11, events=POLLIN}], 4, NULL, NULL, 0) = 1 ([{fd=8, revents=POLLIN}])

注意,這傳入了檔案描述符集 8、9、10 和 11,使用者空間處理程式想從中檢索資料。每次此輪詢事件回傳資料時,必須再次呼叫 ppoll() 來設定相同的檔案描述符集。而使用 epoll 時,檔案描述符集由核心物件管理。

hello-ring-buffer-config.py 設定對輸出環形緩衝區的存取時,可以看到以下與 epoll 相關的系統呼叫序列:

首先,使用者空間程式請求在核心中建立一個新的 epoll 例項:

epoll_create1(EPOLL_CLOEXEC) = 8

這回傳檔案描述符 8。然後是對 epoll_ctl() 的呼叫,它告訴核心將檔案描述符 4(輸出緩衝區)增加到該 epoll 例項中的檔案描述符集:

epoll_ctl(8, EPOLL_CTL_ADD, 4, {events=EPOLLIN, data={u32=0, u64=0}}) = 0

使用者空間程式使用 epoll_pwait() 等待環形緩衝區中的資料可用。此呼叫僅在資料可用時回傳:

epoll_pwait(8, [{events=EPOLLIN, data={u32=0, u64=0}}], 1, -1, NULL, 8) = 1

epoll 機制相比 ppoll 有顯著優勢,特別是在處理大量檔案描述符時。使用 ppoll,每次呼叫都需要傳遞完整的檔案描述符集,這在描述符數量很多時效率低下。而 epoll 將這些資訊儲存在核心中,使用者空間程式只需要參考這個核心物件,大幅減少系統呼叫的開銷。

這種差異在高效能網路伺服器等需要監控數千個連線的應用中尤為重要。對於 eBPF 應用程式,雖然描述符數量通常不會很多,但使用更高效的 epoll 機制仍然提供了更好的擴充套件性和效能。

實用觀點:選擇適合的緩衝區機制

從實用角度出發,如果你正在使用 BCC、libbpf 或其他 eBPF 函式庫,通常不需要了解使用者空間應用程式如何透過 perf 或 ring 緩衝區從核心取得資訊的底層細節。這些框架已經為你處理好了這些複雜性。

然而,瞭解這些機制有幾個實際好處:

  1. 效能最佳化:瞭解底層機制可以幫助你選擇最適合你應用場景的緩衝區型別
  2. 問題診斷:當遇到效能問題或奇怪行為時,瞭解系統呼叫如何工作可以幫助診斷
  3. 資源管理:理解檔案描述符的使用方式有助於更好地管理系統資源

對於大多數應用場景,我建議遵循以下指導原則:

  • 如果你的核心版本是 5.8 或更高,優先選擇 Ring Buffer
  • 如果你需要保證事件順序(特別是在多核系統上),使用 Ring Buffer
  • 如果你需要向後相容較舊的核心版本,則使用 Perf Buffer

在實際開發中,你可能會編寫從使用者空間存取對映的程式碼。在前面的內容中,我使用 bpftool 檢查了 config 對映的內容。由於它是在使用者空間執行的工具,我們可以使用 strace 來瞭解它如何與核心互動。

eBPF 對映存取的底層機制

當使用者空間程式需要存取 eBPF 對映時,它使用 bpf() 系統呼叫與各種命令來執行操作。以下是一些常見操作的底層實作:

  1. 查詢對映條目

    bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=4, key=ptr, value=ptr, ...})
    
  2. 更新對映條目

    bpf(BPF_MAP_UPDATE_ELEM, {map_fd=4, key=ptr, value=ptr, flags=BPF_ANY})
    
  3. 刪除對映條目

    bpf(BPF_MAP_DELETE_ELEM, {map_fd=4, key=ptr, ...})
    
  4. 取得下一個鍵

    bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=4, key=ptr, next_key=ptr, ...})
    

這些系統呼叫提供了使用者空間程式與 eBPF 對映互動的基本機制。在實際應用中,通常會使用高階函式庫來簡化這些操作,但瞭解底層機制有助於進行更精確的效能調整和問題診斷。

效能考量與最佳實踐

在選擇和使用 eBPF 事件緩衝區時,有幾個效能考量和最佳實踐值得注意:

  1. 緩衝區大小

    • Perf Buffer:透過 open_perf_buffer()page_cnt 引數控制
    • Ring Buffer:透過 BPF_RINGBUF_OUTPUT() 的第二個引數控制(以頁為單位)
  2. 事件批次處理

    • 使用批次處理可以減少系統呼叫開銷,提高吞吐量
    • 對於 Perf Buffer,可以設定 perf_buffer_poll()timeout 引數
    • 對於 Ring Buffer,可以使用 ring_buffer_poll()timeout 引數
  3. 記憶體使用

    • Ring Buffer 通常比 Perf Buffer 更節省記憶體,因為它只需要一個分享緩衝區
    • 在高負載系統上,適當增加緩衝區大小可以防止事件丟失
  4. CPU 親和性

    • 在多核系統上,考慮設定 CPU 親和性以減少核心間的資料移動
    • 這對於 Perf Buffer 特別重要,因為每個核心有自己的緩衝區

在實際佈署中,建議進行基準測試以找到最適合特定工作負載的設定。不同的應用場景(如低延遲監控、高吞吐量資料收集)可能需要不同的最佳化策略。

深入瞭解 eBPF 程式與 Linux 核心互動的底層機制,不僅有助於理解這項強大技術的工作原理,還能幫助我們做出更明智的設計決策。從 kprobe 事件的附加過程到不同緩衝區機制的比較,這些知識為我們提供了更全面的視角。

Perf Buffer 和 Ring Buffer 各有優勢,選擇哪一個取決於你的具體需求和核心版本。Ring Buffer 作為較新的機制,通常提供更好的效能和更簡單的程式設計模型,特別是在保持事件順序方面。然而,Perf Buffer 在較舊的核心版本上仍然是可靠的選擇。

隨著 eBPF 生態系統的不斷發展,瞭解這些底層機制將使我們能夠更有效地利用這一強大工具,無論是用於效能分析、安全監控還是網路最佳化。

深入 eBPF 系統呼叫:從 Map 讀取到程式操作

eBPF 作為 Linux 核心中的強大機制,為開發者提供了前所未有的系統觀測與程式化控制能力。而這一切的基礎,都建立在 bpf() 系統呼叫之上。在本文中,玄貓將帶領各位深入探索這個關鍵系統呼叫的運作機制,特別是如何透過它來讀取 Map 中的資料。

追蹤 bpftool 的 Map 讀取過程

要了解 bpf() 系統呼叫如何運作,最直觀的方式就是觀察實際工具的行為。讓我們以 bpftool 讀取 Map 內容為例,使用 strace 來追蹤它的系統呼叫:

$ strace -e bpf bpftool map dump name config

透過這個命令,我們能看到 bpftool 在讀取名為 config 的 Map 時所執行的 bpf() 系統呼叫序列。整個過程主要分為兩個步驟:

  1. 遍歷所有 Map,尋找名稱為 config 的 Map
  2. 找到目標 Map 後,遍歷其中所有的元素

尋找目標 Map

首先,bpftool 需要找出名為 config 的 Map。它透過一系列重複的系統呼叫來完成這項工作:

bpf(BPF_MAP_GET_NEXT_ID, {start_id=0,...}, 12) = 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=48...}, 12) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, ...}}, 16) = 0

這段系統呼叫序列展示了 bpftool 如何遍歷系統中所有的 BPF Map:

  • BPF_MAP_GET_NEXT_ID:取得在 start_id 之後的下一個 Map ID。第一次呼叫時 start_id=0,表示取得系統中第一個 Map 的 ID。
  • BPF_MAP_GET_FD_BY_ID:根據取得的 Map ID 取得對應的檔案描述符(file descriptor)。
  • BPF_OBJ_GET_INFO_BY_FD:使用檔案描述符取得該 Map 的詳細資訊,包括名稱,這樣 bpftool 就能確認是否為目標 Map。

這個序列會不斷重複,每次都從上一個找到的 Map ID 開始尋找下一個,直到找不到更多 Map 為止。當沒有更多 Map 時,BPF_MAP_GET_NEXT_ID 會回傳 ENOENT 錯誤:

bpf(BPF_MAP_GET_NEXT_ID, {start_id=133,...}, 12) = -1 ENOENT (No such file or directory)

如果找到了比對的 Map,bpftool 會保留其檔案描述符,以便後續讀取其中的元素。

讀取 Map 元素

找到目標 Map 後,bpftool 需要讀取其中的所有元素。這是透過另一個系統呼叫序列完成的:

bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0xaaaaf7a63960}, 24) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0xaaaaf7a63960, value=0xaaaaf7a63980, flags=BPF_ANY}, 32) = 0

這段系統呼叫展示了 Map 元素的讀取過程:

  • BPF_MAP_GET_NEXT_KEY:尋找 Map 中的有效鍵值。第一次呼叫時 key=NULL,表示請求 Map 中的第一個有效鍵值。核心會將找到的鍵寫入 next_key 指向的記憶體位置。
  • BPF_MAP_LOOKUP_ELEM:根據找到的鍵值查詢對應的值。核心會將值寫入 value 指向的記憶體位置。

取得第一個鍵值對後,bpftool 會繼續尋找下一個有效鍵值,重複這個過程直到遍歷完 Map 中的所有元素:

bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0xaaaaf7a63960, next_key=0xaaaaf7a63960}, 24) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0xaaaaf7a63960, value=0xaaaaf7a63980, flags=BPF_ANY}, 32) = 0

當沒有更多元素時,BPF_MAP_GET_NEXT_KEY 會回傳 ENOENT 錯誤,表示遍歷結束:

bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0xaaaaf7a63960, next_key=0xaaaaf7a63960}, 24) = -1 ENOENT (No such file or directory)

這個分析過程清楚地展示了使用者空間程式如何遍歷可用的 Map 以及其中儲存的鍵值對。值得注意的是,檔案描述符是程式特定的,同一個 Map 在不同程式中可能對應不同的檔案描述符。