這個簡單的例子只是 eBPF 在網路處理中應用的冰山一角。XDP(eXpress Data Path)允許在網路封包剛到達網路介面時就進行處理,這為高效能網路應用開啟了無限可能。

XDP 程式可以:

  • 檢查和修改封包內容
  • 決定核心應如何處理封包(透過、丟棄或重定向)
  • 實作高效能的 DDoS 防護、防火牆或負載平衡

更令人興奮的是,某些網路卡支援將 XDP 程式解除安裝到硬體上執行,這意味著每個網路封包可以直接在網路卡上處理,無需佔用主機 CPU 資源,大幅提升效能。

深入理解 eBPF 程式的生命週期

從上面的範例中,我們可以清晰地看到 eBPF 程式的生命週期:

  1. 開發階段:使用 C 語言編寫 eBPF 程式,利用特定的巨集和輔助函式
  2. 編譯階段:使用 Clang 編譯器將 C 程式碼轉換為 eBPF 位元組碼
  3. 檢查階段:可以使用工具檢視和分析生成的 eBPF 位元組碼
  4. 載入階段:將 eBPF 物件檔案載入到核心中(這部分在我們的範例中未展示)
  5. 執行階段:當觸發事件(如網路封包到達)發生時,核心執行 eBPF 程式

理解這個生命週期對於開發和除錯 eBPF 程式至關重要。特別是在處理更複雜的應用場景時,能夠檢視和理解生成的位元組碼可以幫助我們最佳化程式效能和解決問題。

結語

eBPF 技術為 Linux 核心開發帶來了革命性的變化,它允許開發者以安全、高效的方式擴充套件核心功能,而無需修改核心程式碼或編譯核心模組。

透過這個簡單的 XDP “Hello World” 範例,我們深入瞭解了 eBPF 程式從 C 原始碼到位元組碼的轉換過程,以及如何檢視和分析這些位元組

eBPF 程式的解碼與載入:深入理解核心互動機制

在上一篇文章中,我們已經瞭解了 eBPF 的基礎概念,現在讓我們更探討 eBPF 程式的內部運作機制。當我第一次接觸 eBPF 時,最讓我著迷的就是它如何將高階程式碼轉換為核心可執行的位元組碼,以及核心如何安全地執行這些程式。今天,我將帶大家一步解析這個過程。

解讀 eBPF 指令集:位元組碼的奧秘

eBPF 程式編譯後會產生位元組碼,這些位元組碼是一系列指令,告訴 Linux 核心要執行什麼操作。每行指令的第一個位元組是操作碼(opcode),它定義了要執行的操作型別,而指令行右側則是該指令的人類可讀解釋。

讓我們分析一個具體的指令範例:

5: b7 02 00 00 0f 00 00 00 r2 = 15

這行指令中:

  • 操作碼是 0xb7,對應的虛擬碼是 dst = imm,意為「將目標設定為立即值」
  • 第二個位元組 0x02 定義了目標是「暫存器 2」
  • 立即值(或稱字面值)是 0x0f,十進位制為 15
  • 所以這條指令告訴核心「將暫存器 2 設為值 15」,這正對應了右側的 r2 = 15

再看另一條類別似的指令:

10: b7 00 00 00 02 00 00 00 r0 = 2

這行同樣使用操作碼 0xb7,但這次是將暫存器 0 設為值 2。在 eBPF 程式執行完成時,暫存器 0 會儲存回傳碼,而 XDP_PASS 的值恰好是 2。這與我們的原始碼始終回傳 XDP_PASS 是一致的。

這些位元組碼指令展示了 eBPF 程式如何在低層級運作。每個指令都有特定格式:操作碼決定執行什麼操作,後續位元組定義操作的引數(如目標暫存器、源暫存器、立即值等)。理解這些指令對於除錯 eBPF 程式至關重要,特別是當你需要確認編譯器是否正確轉換了你的原始碼時。

將程式載入到核心:bpftool 實戰

既然我們已經瞭解了 hello.bpf.o 檔案包含了 eBPF 位元組碼,下一步就是將它載入到核心中。這裡我會使用 bpftool 工具,不過在後續文章中我們也會討論如何以程式方式載入 eBPF 程式。

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

這個命令將編譯好的物件檔載入核心,並將其「釘」到 /sys/fs/bpf/hello 位置。如果命令沒有輸出任何內容,就表示成功了。我們可以使用 ls 來確認程式已經被正確載入:

$ ls /sys/fs/bpf
hello

bpftool 是一個強大的工具,它簡化了 eBPF 程式的載入過程。當我們執行 prog load 命令時,它會進行以下操作:

  1. 讀取物件檔案中的 eBPF 位元組碼
  2. 透過系統呼叫將程式提交給核心
  3. 核心會驗證程式的安全性(透過 eBPF 驗證器)
  4. 如果驗證透過,程式會被載入並「釘」到指定的檔案系統路徑

「釘」(pinning)是 eBPF 的一個重要概念,它允許 eBPF 物件(如程式和對映)在核心中保持載入狀態,即使載入它們的程式已經結束。這使得其他程式可以透過檔案系統路徑參照這些物件。

檢查已載入的程式:深入瞭解 eBPF 程式狀態

一旦程式被載入,我們可以使用 bpftool 來檢查它在核心中的狀態:

$ bpftool prog list
...
540: xdp name hello tag d35b94b4c0c10efb gpl
loaded_at 2022-08-02T17:39:47+0000 uid 0
xlated 96B jited 148B memlock 4096B map_ids
165,166
btf_id 254

這個輸出顯示了我們的程式被分配了 ID 540。知道了 ID,我們可以請求 bpftool 顯示更多關於這個程式的訊息,這次使用 JSON 格式以便更清晰地檢視欄位名稱和值:

$ bpftool prog show id 540 --pretty
{
  "id": 540,
  "type": "xdp",
  "name": "hello",
  "tag": "d35b94b4c0c10efb",
  "gpl_compatible": true,
  "loaded_at": 1659461987,
  "uid": 0,
  "bytes_xlated": 96,
  "jited": true,
  "bytes_jited": 148,
  "bytes_memlock": 4096,
  "map_ids": [165,166],
  "btf_id": 254
}

這個輸出提供了豐富的訊息:

  • id: 程式的唯一識別碼,每次載入/解除安裝程式時可能會變化
  • type: 表示這個程式可以附加到網路介面上使用 XDP 事件
  • name: 程式名稱,對應原始碼中的函式名
  • tag: 程式指令的 SHA 雜湊值,作為另一個識別符
  • jited: 表示程式已被 JIT 編譯成機器碼
  • bytes_jited: JIT 編譯後的機器碼大小
  • map_ids: 程式參照的 BPF 對映 ID

雖然我們的 Hello World 原始碼中沒有明確參照任何對映,但系統為全域資料(如 counter 變數)自動建立了對映。這是 eBPF 處理全域資料的方式,我們將在後續文章中更深入討論這點。

eBPF 程式標籤:身份的另一種形式

標籤(tag)是程式指令的 SHA 雜湊值,可以作為程式的另一種識別符。與 ID 不同,每次載入或解除安裝程式時 ID 可能會變化,但標籤會保持不變。bpftool 可以接受多種方式參照 BPF 程式:

bpftool prog show id 540
bpftool prog show name hello
bpftool prog show tag d35b94b4c0c10efb
bpftool prog show pinned /sys/fs/bpf/hello

這些命令會產生相同的輸出。雖然你可能有多個同名程式,甚至多個具有相同標籤的程式例項,但 ID 和釘路徑始終是唯一的。

標籤提供了一種更穩定的方式來識別 eBPF 程式,特別是在自動化指令碼或長時間執行的系統中。當我在開發複雜的 eBPF 應用時,經常使用標籤來確保我正在操作正確的程式版本,而不是依賴可能變化的 ID。

翻譯後的位元組碼:驗證器處理後的程式

bytes_xlated 欄位告訴我們有多少位元組的「翻譯」eBPF 碼。這是 eBPF 位元組碼在透過驗證器(可能被核心修改)後的樣子。讓我們使用 bpftool 顯示這個翻譯版本:

$ bpftool prog dump xlated name hello
int hello(struct xdp_md * ctx):
; bpf_printk("Hello World %d", counter);
0: (18) r6 = map[id:165][0]+0
2: (61) r3 = *(u32 *)(r6 +0)
3: (18) r1 = map[id:166][0]+0
5: (b7) r2 = 15
6: (85) call bpf_trace_printk#-78032
; counter++;
7: (61) r1 = *(u32 *)(r6 +0)
8: (07) r1 += 1
9: (63) *(u32 *)(r6 +0) = r1
; return XDP_PASS;
10: (b7) r0 = 2
11: (95) exit

這看起來與我們之前從 llvm-objdump 看到的反彙程式設計式碼非常相似。偏移地址相同,指令看起來也相似 - 例如,我們可以看到偏移量 5 處的指令是 r2=15

翻譯後的位元組碼展示了核心驗證器處理後的 eBPF 程式。注意到程式開頭有幾條指令在處理對映(r6 = map[id:165][0]+0),這證實了我之前提到的,系統為全域變數 counter 建立了 BPF 對映。

這些指令的流程是:

  1. 取得指向計數器對映的指標
  2. 讀取當前計數器值
  3. 設定 bpf_printk 的引數
  4. 呼叫 bpf_printk 函式
  5. 再次讀取計數器值,增加它,然後寫回映射
  6. 設定回傳值 XDP_PASS (2) 並結束

JIT 編譯後的機器碼:最終執行形式

翻譯後的位元組碼雖然已經很低層級,但還不是真正的機器碼。eBPF 使用 JIT 編譯器將 eBPF 位元組碼轉換為在目標 CPU 上原生執行的機器碼。bytes_jited 欄位顯示,經過這個轉換後,程式長度為 148 位元組。

bpftool 可以生成這個 JIT 編譯後程式碼的組合語言轉儲:

$ bpftool prog dump jited name hello
int hello(struct xdp_md * ctx):
bpf_prog_d35b94b4c0c10efb_hello:
; bpf_printk("Hello World %d", counter);
0: hint #34
4: stp x29, x30, [sp, #-16]!
8: mov x29, sp
...

這是真正在 CPU 上執行的指令。雖然對不熟悉組合語言的人來說可能難以理解,但它展示了 eBPF 程式從原始碼到可執行機器指令的完整轉換過程。

JIT 編譯是 eBPF 效能的關鍵。透過將 eBPF 位元組碼轉換為原生機器碼,核心可以高效執行這些程式,而不需要執行時解釋。eBPF 指令集和暫存器設計得與原生機器指令非常接近,這使得 JIT 編譯相對簡單與高效。

在我的實際專案中,JIT 編譯的 eBPF 程式通常比解釋執行快 3-5 倍,這對於高效能網路應用尤為重要。目前大多數架構都支援 eBPF JIT 編譯,這使得 eBPF 成為跨平台低層級程式設計的理想選擇。

eBPF 程式載入流程總結

透過這個 Hello World 範例,我們已經深入瞭解了 eBPF 程式從編譯到執行的完整流程:

  1. 編譯: 將 C 原始碼編譯成 eBPF 位元組碼
  2. 載入: 使用工具(如 bpftool)將位元組碼載入核心
  3. 驗證: 核心驗證器檢查程式的安全性
  4. JIT 編譯: 將位元組碼轉換為原生機器碼
  5. 執行: 程式在核心中執行,可以附加到各種事件(如 XDP)

這種機制允許使用者空間程式安全地擴充套件核心功能,而無需修改核心原始碼或載入不安全的核心模組。

在我多年使用 eBPF 的經驗中,理解這個流程是掌握 eBPF 程式設計的基礎。雖然初學者可能會被這些低層級細節嚇到,但一旦掌握了這些概念,你就能夠開發出強大而高效的 eBPF 應用程式。

在下一篇文章中,我將探討如何將 eBPF 程式附加到實際的網路介面,以及如何使用 eBPF 對映來在使用者空間和核心空間之間分享資料。這些技術是構建實用 eBPF 應用程式的關鍵組成部分。

ARM64組合語言中的eBPF程式解析

在深入瞭解eBPF程式如何與系統互動前,讓我們先來分析一段已被JIT編譯成ARM64組合語言的eBPF程式。這段程式碼展示了eBPF程式如何在ARM架構上實際執行。

10: stp x21, x22, [sp, #-16]!
14: stp x25, x26, [sp, #-16]!
18: mov x25, sp
1c: mov x26, #0
20: hint #36
24: sub sp, sp, #0
28: mov x19, #-140733193388033
2c: movk x19, #2190, lsl #16
30: movk x19, #49152
34: mov x10, #0
38: ldr w2, [x19, x10]
3c: mov x0, #-205419695833089
40: movk x0, #709, lsl #16
44: movk x0, #5904
48: mov x1, #15
4c: mov x10, #-6992
50: movk x10, #29844, lsl #16
54: movk x10, #56832, lsl #32
58: blr x10
5c: add x7, x0, #0
60: mov x10, #0
64: ldr w0, [x19, x10]
68: add x0, x0, #1
6c: mov x10, #0
70: str w0, [x19, x10]
74: mov x7, #2
78: mov sp, sp
7c: ldp x25, x26, [sp], #16
80: ldp x21, x22, [sp], #16
84: ldp x19, x20, [sp], #16
88: ldp x29, x30, [sp], #16
8c: add x0, x7, #0
90: ret

這段ARM64組合語言展示了一個簡單eBPF程式的JIT編譯結果。讓我拆解其中關鍵部分:

  1. 開頭的stp指令(10-14行)用於儲存暫存器到堆積積疊,這是函式呼叫約定的一部分,確保函式能正確回傳。

  2. 中間部分(28-38行)設定了全域變數的存取。特別是movmovk指令組合構建了一個64位元的地址值,存放在x19暫存器中,這很可能指向計數器變數。

  3. 程式碼的核心功能(60-70行)實作了計數器增加:

    • 從x19指向的記憶體位置載入計數器值
    • 將計數器值加1
    • 將更新後的值存回原位置
  4. 結尾部分(74-90行)設定回傳值並還原堆積積疊。特別是mov x7, #2指令設定回傳值為2,對應於XDP程式中的XDP_PASS,表示允許封包透過。

這段組合語言顯示了eBPF程式如何在ARM64架構上執行計數和日誌記錄功能,同時實作了XDP封包處理決策。

將eBPF程式附加到網路事件

雖然我們已經將"Hello World"程式載入核心,但此時它尚未與任何事件關聯,因此不會被觸發執行。eBPF程式需要附加到特定事件才能發揮作用。

附加到XDP事件

程式型別必須與它要附加的事件型別比對。在這個例子中,我們有一個XDP(eXpress Data Path)程式,可以使用bpftool將其附加到網路介面的XDP事件:

$ bpftool net attach xdp id 540 dev eth0

這個命令將ID為540的eBPF程式附加到eth0網路介面的XDP事件上。除了使用ID,你也可以使用程式名稱(如果名稱是唯一的)或標籤來識別程式。XDP是Linux核心中的一個高效能網路資料路徑,允許在網路封包到達網路堆積積疊之前就進行處理,提供極低的延遲和高吞吐量。

值得注意的是,bpftool工具並不支援附加所有型別的程式,但最近已擴充套件為支援自動附加k(ret)probes、u(ret)probes和tracepoints。

檢視網路附加的eBPF程式

附加程式後,可以使用bpftool檢視所有網路附加的eBPF程式:

$ bpftool net list
xdp:
  eth0(2) driver id 540
tc:
flow_dissector:

這個輸出顯示ID為540的程式已附加到eth0介面的XDP事件。同時,輸出也提示了其他可以附加eBPF程式的網路堆積積疊事件:tc(流量控制)和flow_dissector(流量分類別器)。

你也可以使用ip link命令檢查網路介面,會看到類別似這樣的輸出:

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT
   group default qlen 1000
   ...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc fq_codel state UP
   mode DEFAULT group default qlen 1000
   ...
   prog/xdp id 540 tag 9d0e949f89f1a82c jited

這個輸出顯示了兩個介面:

  1. lo:環回介面,用於傳送流量到本機的處理程式
  2. eth0:連線此機器到外部世界的介面

輸出還顯示eth0介面已附加了一個已JIT編譯的eBPF程式,ID為540,標籤為9d0e949f89f1a82c。

除了bpftool,你也可以使用ip link命令來附加和分離XDP程式到網路介面。

追蹤eBPF程式的執行

此時,hello eBPF程式應該在每次接收網路封包時產生追蹤輸出。你可以透過執行以下命令來檢查:

$ cat /sys/kernel/debug/tracing/trace_pipe

這應該會顯示類別似這樣的大量輸出:

<idle>-0 [003] d.s.. 655370.944105: bpf_trace_printk: Hello World 4531
<idle>-0 [003] d.s.. 655370.944587: bpf_trace_printk: Hello World 4532
<idle>-0 [003] d.s.. 655370.944896: bpf_trace_printk: Hello World 4533

如果你記不住trace_pipe的位置,也可以使用bpftool prog tracelog命令取得相同的輸出。

這個輸出與傳統的系統呼叫追蹤不同,沒有關聯的命令或程式ID。取而代之的是每行開頭的<idle>-0。這是因為XDP事件是由網路封包的到達觸發的,而不是由使用者空間程式執行命令觸發的。

當XDP程式被觸發時,系統除了在記憶體中接收封包外,尚未對封包做任何處理,也不知道封包是什麼或它要去哪裡。

從輸出中可以看到,每次追蹤輸出的計數器值都按預期遞增了1。在原始碼中,counter是一個全域變數,讓我們看它在eBPF中是如何透過map實作的。

eBPF中的全域變數實作

在eBPF中,map是一種可以從eBPF程式或使用者空間存取的資料結構。由於同一個map可以被同一程式的不同執行例項重複存取,它可以用來儲存一次執行到下一次執行的狀態。多個程式也可以存取同一個map。因為這些特性,map語義可以被重新用於全域變數。

檢查eBPF程式使用的map

之前bpftool顯示這個範例程式使用了ID為165和166的兩個map。讓我們探索這些map中的內容:

$ bpftool map list
165: array name hello.bss flags 0x400
    key 4B value 4B max_entries 1 memlock 4096B
    btf_id 254
166: array name hello.rodata flags 0x80
    key 4B value 15B max_entries 1 memlock 4096B
    btf_id 254 frozen

在目標檔案中,.bss段通常儲存全域變數,我們可以使用bpftool檢查其內容:

$ bpftool map dump name hello.bss
[{
    "value": {
        ".bss": [{
            "counter": 11127
        }
        ]
    }
}
]

我也可以使用bpftool map dump id 165取得相同的訊息。如果再次執行這些命令,會看到計數器增加了,因為每次接收網路封包時程式都會執行。

bpftool能夠美化列印map中的欄位名稱(這裡是變數名counter)是因為BTF(BPF Type Format)訊息可用,這些訊息只有在使用-g標誌編譯時才會包含。如果在編譯步驟中省略了該標誌,輸出會更像這樣:

$ bpftool map dump name hello.bss
key: 00 00 00 00  value: 19 01 00 00
Found 1 element

沒有BTF訊息,bpftool無法知道原始碼中使用了什麼變數名。但可以推斷,由於這個map中只有一個專案,十六進位制值19 01 00 00必定是counter的當前值(十進位制為281,因為位元組順序從最低有效位元組開始)。

靜態資料的儲存

Map不僅用於讀寫全域變數,還用於儲存靜態資料。我們可以檢查另一個名為hello.rodata的map,這暗示它可能包含與hello程式相關的只讀資料:

$ bpftool map dump name hello.rodata
[{
    "value": {
        ".rodata": [{
            "hello.____fmt": "Hello World %d"
        }
        ]
    }
}
]

如果沒有使用-g標誌編譯物件,輸出會像這樣:

$ bpftool map dump id 166
key: 00 00 00 00  value: 48 65 6c 6c 6f 20 57 6f 72 6c 64 20 25 64 00
Found 1 element

這個map中有一個鍵值對,值包含12個位元組的資料,以0結尾。這些位元組實際上是"Hello World %d"字元串的ASCII碼表示。

eBPF程式的工作機制深析

透過以上分析,我們可以看到eBPF程式的工作機制具有以下特點:

  1. 事件驅動:eBPF程式透過附加到特定事件(如XDP)來觸發執行,而不是作為獨立程式執行。

  2. 全域狀態:透過map機制實作全域變數,使程式能夠在多次執行之間保持狀態。

  3. 高效執行:JIT編譯將eBPF位元組碼轉換為本地機器碼(如ARM64),實作接近原生的執行效率。

  4. 安全設計:eBPF程式在載入時經過驗證器檢查,確保不會造成系統不穩定或安全問題。

在網路應用中,XDP程式特別有價值,因為它們允許在網路堆積積疊的最早期階段處理封包,實作高效能的封包過濾、修改和轉發功能,而無需將封包複製到使用者空間。

當我們開發更複雜的eBPF應用時,理解這些基礎機制至關重要。無論是構建網路安全工具、效能監控系統還是自定義網路協定處理器,都需要深入瞭解eBPF程式如何與核心互動、如何管理狀態以及如何高效執行。

eBPF技術為系統程式設計帶來了革命性的變化,讓開發者能夠在保持安全性和穩定性的前提下,以前所未有的方式擴充套件核心功能。透過掌握eBPF程式的開發和佈署,我們可以實作更高效、更靈活的系統和網路解決方案。

BPF 程式的生命週期管理

在開發 eBPF 程式時,瞭解如何正確管理程式的生命週期至關重要。當我們完成程式的測試後,適當地解除安裝程式不僅是良好習慣,更能防止系統資源的浪費與潛在的安全問題。

從觸發事件中分離 BPF 程式

當我們不再需要 BPF 程式時,第一步是將它從觸發事件中分離。以 XDP(eXpress Data Path)程式為例,我們可以使用 bpftool 命令將程式從網路介面中分離:

bpftool net detach xdp dev eth0

這個命令執行成功時不會有輸出。要確認程式已成功分離,我們可以檢查 bpftool net list 的輸出,確認 XDP 專案下沒有任何條目:

bpftool net list
xdp:
tc:
flow_dissector:

解除安裝 BPF 程式

即使程式已從觸發事件中分離,它仍然載入在核心中。我們可以使用 bpftool prog show 命令來確認:

bpftool prog show name hello
395: xdp name hello tag 9d0e949f89f1a82c gpl
loaded_at 2022-12-19T18:20:32+0000 uid 0
xlated 48B jited 108B memlock 4096B map_ids 4

雖然目前沒有直接對應 bpftool prog load 的解除安裝命令,但我們可以透過刪除程式的釘選偽檔案來從核心中移除程式:

rm /sys/fs/bpf/hello

刪除後,再次執行 bpftool prog show name hello 將不會有任何輸出,表示程式已從核心中解除安裝。

在 eBPF 的世界中,程式的生命週期管理與傳統應用程式有很大不同。當我們使用 bpftool net detach 命令時,實際上是解除了 BPF 程式與特定事件(如網路封包到達)的關聯,但程式本身仍保留在核心中。只有當我們刪除釘選檔案(pinned file)時,核心才會真正解除安裝該程式。這種設計允許多個事件分享同一個 BPF 程式,提高了資源使用效率。

BPF to BPF 函式呼叫

除了之前介紹的尾呼叫(tail calls)功能外,現代 eBPF 還支援在程式內部呼叫函式。這大提升了程式的模組化和可維護性。

簡單的函式呼叫範例

讓我們看一個簡單的範例,這個程式可以附加到 sys_enter 追蹤點,並追蹤系統呼叫的操作碼。以下是 chapter3/hello-func.bpf.c 的程式碼:

static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args *ctx) {
    return ctx->args[1];
}

SEC("raw_tp")
int hello(struct bpf_raw_tracepoint_args *ctx) {
    int opcode = get_opcode(ctx);
    bpf_printk("Syscall: %d", opcode);
    return 0;
}

這段程式碼展示了 BPF to BPF 函式呼叫的基本用法。我們定義了一個名為 get_opcode 的函式,它從追蹤點引數中提取系統呼叫操作碼。然後在 hello 函式中呼叫它。注意 __attribute((noinline)) 的使用 - 這是為了防止編譯器將這個簡單函式行內化,因為在這個例子中我們想明確展示函式呼叫。在實際應用中,通常應該讓編譯器決定是否行內,以獲得最佳效能。

載入與檢查函式呼叫程式

編譯後,我們可以將程式載入核心並確認它已正確載入:

bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello
bpftool prog list name hello
893: raw_tracepoint name hello tag 3d9eb0c23d4ab186 gpl
loaded_at 2023-01-05T18:57:31+0000 uid 0
xlated 80B jited 208B memlock 4096B map_ids 204
btf_id 302

檢查 BPF 位元組碼

這個範例最有趣的部分是檢查 eBPF 位元組碼,看 get_opcode() 函式是如何被呼叫的:

bpftool prog dump xlated name hello

輸出結果:

int hello(struct bpf_raw_tracepoint_args * ctx):
; int opcode = get_opcode(ctx);
0: (85) call pc+7#bpf_prog_cbacc90865b1b9a5_get_opcode
; bpf_printk("Syscall: %d", opcode);
1: (18) r1 = map[id:193][0]+0
3: (b7) r2 = 12
4: (bf) r3 = r0
5: (85) call bpf_trace_printk#-73584
; return 0;
6: (b7) r0 = 0
7: (95) exit

int get_opcode(struct bpf_raw_tracepoint_args * ctx):
; return ctx->args[1];
8: (79) r0 = *(u64 *)(r1 +8)
; return ctx->args[1];
9: (95) exit

從這個位元組碼輸出中,我們可以清楚地看到函式呼叫的機制:

  1. 在偏移量 0 處,指令 (85) call pc+7#bpf_prog_cbacc90865b1b9a5_get_opcode 是一個函式呼叫指令(操作碼 0x85)。它指示 BPF 虛擬機器跳到當前位置後 7 條指令的位置(pc+7),也就是偏移量 8 處。

  2. 在偏移量 8 處,我們看到 get_opcode() 函式的第一條指令,它從 ctx 結構體中提取操作碼。

  3. 函式執行完後,控制流回傳到呼叫點之後的指令(偏移量 1)繼續執行。

函式呼叫指令會將當前狀態儲存到 eBPF 虛擬機器的堆積積疊中,以便被呼叫函式回傳時能夠繼續執行。由於堆積積疊大小限制為 512 位元組,BPF to BPF 呼叫不能巢狀太深。

實用工具與技巧

除了基本的 BPF 程式管理外,還有一些實用的技巧可以幫助我們更有效地使用 eBPF:

除了 bpftool 外,我們也可以使用 ip link 命令來附加和分離 XDP 程式:

# 附加 XDP 程式
ip link set dev eth0 xdp obj hello.bpf.o sec xdp

# 分離 XDP 程式
ip link set dev eth0 xdp off

ip link 命令是 Linux 網路設定工具包的一部分,提供了一種更直接的方式來管理與網路介面相關的 XDP 程式。與 bpftool 相比,它的語法更簡潔,特別適合網路管理員使用。在背後,這些命令最終都會轉換為相同的系統呼叫來載入和附加 BPF 程式。

檢查正在執行的 BPF 程式

當 BPF 程式正在執行時(例如透過 BCC 工具載入的程式),我們可以在另一個終端視窗中使用 bpftool 來檢查該程式:

bpftool prog show name hello
197: kprobe name hello tag ba73a317e9480a37 gpl
loaded_at 2022-08-22T08:46:22+0000 uid 0
xlated 296B jited 328B memlock 4096B map_ids 65
btf_id 179
pids hello-map.py(2785)

我們還可以使用 bpftool prog dump 命令檢視程式的位元組碼和機器碼版本。

這個功能非常有用,尤其是在除錯和最佳化 BPF 程式時。透過檢查正在執行的程式,我們可以瞭解它的載入時間、記憶體使用情況、關聯的對映 ID 等訊息。pids 欄位顯示了哪個使用者空間程式載入了這個 BPF 程式,這在複雜環境中特別有幫助,可以追蹤 BPF 程式的來源。

檢查尾呼叫程式

當執行使用尾呼叫的程式(如 hello-tail.py)時,我們可以看到每個尾呼叫程式都被單獨列出:

bpftool prog list
...
120: raw_tracepoint name hello tag b6bfd0e76e7f9aac gpl
loaded_at 2023-01-05T14:35:32+0000 uid 0
xlated 160B jited 272B memlock 4096B map_ids 29
btf_id 124
pids hello-tail.py(3590)
121: raw_tracepoint name ignore_opcode tag a04f5eef06a7f555 gpl
loaded_at 2023-01-05T14:35:32+0000 uid 0
xlated 16B jited 72B memlock 4096B
btf_id 124
pids hello-tail.py(3590)

這個輸出顯示了尾呼叫程式的一個重要特性:每個尾呼叫目標都是一個完全獨立的 BPF 程式,有自己的名稱、標籤和記憶體分配。這與 BPF to BPF 函式呼叫不同,後者是在同一個程式內部的函式之間跳轉。尾呼叫允許更複雜的程式結構和更大的程式總大小,因為每個尾呼叫目標都有自己的指令限制。

XDP 程式的回傳值與網路影響

在開發 XDP 程式時,瞭解回傳值的含義非常重要。如果 XDP 程式回傳 0(對應於 XDP_ABORTED),這會告訴核心中止對該封包的任何進一步處理,實際上就是丟棄該封包。

這可能與 C 語言中 0 通常表示成功的慣例相反,但在 XDP 中就是這樣設計的。因此,如果你修改程式回傳 0 並將其附加到虛擬機器的 eth0 介面,所有網路封包都會被丟棄。

如果你透過 SSH 連線到該機器,這可能會導致連線中斷,需要重新啟動機器才能還原存取。一個更安全的方法是在容器內執行程式,這樣 XDP 程式只會附加到影響該容器的虛擬乙太網介面,而不是整個虛擬機器。

XDP 程式的回傳值直接決定了封包的命運,這使得它成為高效能網路過濾和處理的強大工具,但也需要謹慎使用。以下是 XDP 程式可能的回傳值及其含義:

  • XDP_ABORTED (0):丟棄封包,中止處理
  • XDP_DROP (1):丟棄封包,但可能會有一些統計訊息記錄
  • XDP_PASS (2):允許封包繼續透過正常的網路堆積積疊處理
  • XDP_TX (3):將修改後的封包傳送回收到它的同一介面
  • XDP_REDIRECT (4):將封包重定向到另一個介面

在測試 XDP 程式時,建議使用隔離的測試環境或確保有備用存取方法,以防程式意外中斷網路連線。

BPF 程式開發的未來趨勢

雖然 C 語言仍然是 eBPF 程式開發的主要語言,但 Rust 語言正在成為一個越來越受歡迎的替代選擇。Rust 編譯器支援將程式碼編譯為 eBPF 位元組碼,同時提供記憶體安全和更現代的語言特性。

此外,eBPF 指令集也在不斷發展。例如,在核心 5.12 中引入了一組原子指令,包括算術操作(ADD、AND、OR、XOR)。這些新功能使 eBPF 程式能夠實作更複雜的功能,同時保持高效能。

eBPF 的應用範圍也在不斷擴大,從最初的網路和安全領域,擴充套件到了效能監控、可觀測性和服務網格等領域。隨著更多工具和框架的出現,eBPF 開發正變得越來越容易和強大。

在本文中,我們探討了 BPF 程式的生命週期管理,學習瞭如何從觸發事件中分離程式以及如何從核心中解除安裝程式。我們還探索了 BPF to BPF 函式呼叫的機制,這是組織複雜 eBPF 程式邏輯的重要工具。

透過實際範例,我們看到了如何使用 bpftool 檢查載入到核心中的程式和對映,以及如何附加到 XDP 事件。我們還討論了不同型別的 eBPF 程式如何由不同型別的事件觸發,從網路封包到達(XDP)到核心程式碼中的特定點(kprobe 和追蹤點)。

最後,我們探討了 XDP 程式的回傳值對網路流量的影響,以及 eBPF 開發的未來趨勢。這些知識對於開發高效、可靠的 eBPF 程式至關重要,無論是用於網路過濾、效能監控還是安全強化。

隨著 eBPF 技術的不斷發展,掌握這些基礎知識將幫助開發者充分利用這個強大的技術,建立創新的系統級應用程式。

認識 bpf() 系統呼叫:使用者空間與 eBPF 的橋樑

在 Linux 系統中,當使用者空間應用程式需要核心執行特定任務時,會透過系統呼叫 API 向核心發出請求。對於 eBPF 程式的載入和操作也不例外,其核心機制就是 bpf() 系統呼叫。這個系統呼叫提供了一個統一的介面,讓使用者空間程式能夠與核心中的 eBPF 程式和對映進行互動。

bpf() 系統呼叫的基本簽名如下:

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

這個系統呼叫的設計非常靈活:

  • cmd 引數指定要執行的命令型別
  • attr 引數包含命令所需的引數資料
  • size 引數指定 attr 結構的大小

值得注意的是,雖然 bpf() 系統呼叫是使用者空間與 eBPF 互動的基本方式,但在實際開發中,你可能會使用更高階的抽象函式庫而非直接呼叫它。不過,瞭解這個底層機制對於理解 eBPF 技術的運作方式至關重要。

eBPF 程式與對映的互動模型

使用者空間程式透過系統呼叫與核心中的 eBPF 程式和對映互動

上圖展示了使用者空間程式如何透過 bpf() 系統呼叫與核心中的 eBPF 程式和對映互動。這種互動包括幾個關鍵操作:

  1. 載入 eBPF 程式到核心
  2. 建立對映
  3. 將程式附加到事件
  4. 存取對映中的鍵值對

在核心中執行的 eBPF 程式不使用系統呼叫來存取對映,而是使用輔助函式。系統呼叫介面僅供使用者空間應用程式使用。

例項分析:透過 strace 觀察 bpf() 系統呼叫

為了更直觀地理解 bpf() 系統呼叫的使用方式,我們可以透過 strace 工具來觀察一個實際執行的 eBPF 程式。以下範例根據一個名為 hello-buffer-config.py 的 BCC 程式,它會在 execve() 系統呼叫事件發生時,將訊息傳送到效能緩衝區。

程式碼解析

這個範例程式的 eBPF 部分如下:

struct user_msg_t {
    char message[12];
};

BPF_HASH(config, u32, struct user_msg_t);
BPF_PERF_OUTPUT(output);

struct data_t {
    int pid;
    int uid;
    char command[16];
    char message[12];
};

int hello(void *ctx) {
    struct data_t data = {};
    struct user_msg_t *p;
    char message[12] = "Hello World";
    
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    bpf_get_current_comm(&data.command, sizeof(data.command));
    
    p = config.lookup(&data.uid);
    if (p != 0) {
        bpf_probe_read_kernel(&data.message, sizeof(data.message), p->message);
    } else {
        bpf_probe_read_kernel(&data.message, sizeof(data.message), message);
    }
    
    output.perf_submit(ctx, &data, sizeof(data));
    return 0;
}
  1. user_msg_t 結構定義了一個包含 12 字元訊息的資料型態
  2. BPF_HASH 巨集定義了一個名為 config 的雜湊表對映,以 u32 型別(適合儲存使用者 ID)為鍵,以 user_msg_t 結構為值
  3. BPF_PERF_OUTPUT 定義了一個效能緩衝區輸出通道
  4. data_t 結構定義了要傳送到使用者空間的資料格式
  5. hello() 函式是主要的 eBPF 程式,它會:
    • 取得目前處理程式的 PID 和使用者 ID
    • 查詢 config 對映是否有對應此使用者 ID 的自訂訊息
    • 如果有,使用自訂訊息;否則使用預設的 “Hello World”
    • 將資料提交到效能緩衝區

Python 程式碼會為特定使用者 ID 設定自訂訊息:

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!")

使用 strace 觀察 bpf() 系統呼叫

當我們使用 strace -e bpf 執行這個程式時,可以觀察到一系列的 bpf() 系統呼叫:

bpf(BPF_BTF_LOAD, ...) = 3
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(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE,...prog_name="hello",...}) = 6
bpf(BPF_MAP_UPDATE_ELEM, ...)
...

讓我們逐一分析這些系統呼叫的作用。