一文看懂eBPF|eBPF實現原理
在上一篇文章中,我們主要簡單介紹了什麼是 eBPF 和 eBPF 的簡單 使用 ,而本文重點介紹 eBPF 的實現原理。
在介紹 eBPF 的實現原理前,我們先來回顧一下 eBPF 的架構圖:
這幅圖對理解 eBPF 實現原理有非常大的作用,在分析 eBPF 實現原理時,要經常參照這幅圖來進行分析。
eBPF虛擬機器
其實我不太想介紹 eBPF 虛擬機器的,因為一般來說很少會用到 eBPF 彙編來寫程式。但是,不介紹 eBPF 虛擬機器的話,又不能說清 eBPF 的原理。
所以,還是先簡單介紹一下 eBPF 虛擬機器的原理,這樣對分析 eBPF 實現有很大的幫助。
eBPF彙編
eBPF 本質上是一個虛擬機器(Virtual Machine),可以執行 eBPF 位元組碼。
使用者可以使用 eBPF 彙編或者 C 語言來編寫程式,然後編譯成 eBPF 位元組碼,再由 eBPF 虛擬機器執行。
什麼是虛擬機器?
官方的解釋是: 虛擬機器(VM)是一種創建於物理硬體系統(位於外部或內部)、充當虛擬計算機系統的虛擬環境,它模擬出了自己的整套硬體,包括 CPU、記憶體、網路介面和儲存器。 通過名為虛擬機器監控程式的軟體,使用者可以將機器的資源與硬體分開並進行適當 設定 ,以供虛擬機器使用。
通俗的解釋:虛擬機器就是模擬計算機的執行環境,你可以把它當成是一臺虛擬出來的計算機。
計算機的最本質功能就是執行程式碼,所以 eBPF 虛擬機器也一樣,可以執行 eBPF 位元組碼。
使用者編寫的 eBPF 程式最終會被編譯成 eBPF 位元組碼,eBPF 位元組碼使用 bpf_insn
結構來表示,如下:
struct bpf_insn {
__u8 code; // 操作碼
__u8 dst_reg:4; // 目標暫存器
__u8 src_reg:4; // 源暫存器
__s16 off; // 偏移量
__s32 imm; // 立即運算元
};
下面介紹一下 bpf_insn
結構各個欄位的作用:
-
code
:指令操作碼,如 mov、add 等。 -
dst_reg
:目標暫存器,用於指定要操作哪個暫存器。 -
src_reg
:源暫存器,用於指定資料來源於哪個暫存器。 -
off
:偏移量,用於指定某個結構體的成員。 -
imm
:立即運算元,當資料是一個常數時,直接在這裡指定。
eBPF 程式會被 LLVM/Clang 編譯成 bpf_insn
結構陣列,當核心要執行 eBPF 位元組碼時,會呼叫 __bpf_prog_run()
函式來執行。
如果開啟了 JIT(即時編譯技術),核心會將 eBPF 位元組碼編譯成本地機器碼(Native Code)。這樣就可以直接執行,而不需要虛擬機器來執行。
關於 eBPF 彙編相關的知識點可以參考《eBPF彙編指令介紹》,這裡就不作深入的分析,我們只需要記住 eBPF 程式會被編譯成 eBPF 位元組碼即可。
eBPF虛擬機器
eBPF 虛擬機器的作用就是執行 eBPF 位元組碼,eBPF 虛擬機器比較簡單(只有300行程式碼左右),由 __bpf_prog_run()
函式實現。
通用虛擬機器因為要模擬真實的計算機,所以通常來說實現比較複雜(如Qemu、Virtual Box等)。
但像 eBPF 虛擬機器這種用於特定功能的虛擬機器,由於只需要模擬計算機的小部分功能,所以實現通常比較簡單。
eBPF 虛擬機器的執行環境只有 1 個 512KB 的棧和 11 個暫存器(還有一個 PC 暫存器,用於指向當前正在執行的 eBPF 位元組碼)。如下圖所示:
如果核心支援 JIT(Just In Time)執行模式,那麼核心將會把 eBPF 位元組碼編譯成本地機器碼,這時可以直接執行這些機器碼,而不需要使用虛擬機器來執行。
可以通過以下命令開啟 JIT 執行模式:
$ echo 1 > /proc/sys/net/core/bpf_jit_enable
將 C 程式編譯成 eBPF 位元組碼
由於使用 eBPF 彙編編寫程式比較麻煩,所以 eBPF 提供了功能受限的 C 語言來編寫 eBPF 程式,並且可以使用 Clang/LLVM 將 C 程式編譯成 eBPF 位元組碼。
使用 Clang 編譯 eBPF 程式時,需要加上 -target bpf
引數才能編譯成功。
下面我們用一個簡單的例子來介紹怎麼使用 Clang 編譯 eBPF 程式,我們新建一個檔案 hello.c
並且輸入以下程式碼:
#include <linux/bpf.h>
static int (*bpf_trace_printk)(const char *fmt, int fmtsize, ...)
= (void *)BPF_FUNC_trace_printk;
int hello_world(void *ctx)
{
char msg[] = "Hello World\n";
bpf_trace_printk(msg, sizeof(msg)-1);
return 0;
}
然後我們使用以下命令編譯程式:
$ clang -target bpf -Wall -O2 -c hello.c -o hello.o
編譯後會得到一個名為 hello.o
的檔案,我們可以通過下面命令來看到編譯後的位元組碼:
$ readelf -x .text hello.o
Hex dump of section '.text':
0x00000000 18010000 00000000 00000000 00000000 ................
0x00000010 b7020000 0c000000 85000000 06000000 ................
0x00000020 b7000000 00000000 95000000 00000000 ................
由於編譯出來的位元組碼是二進位制的,不利於人類查閱。所以,可以通過以下命令將 eBPF 程式編譯成 eBPF 彙編程式碼:
$ clang -target bpf -S -o hello.s hello.c
編譯後會得到一個名為 hello.s
的檔案,我們可以使用文字編輯器來檢視其彙編程式碼:
...
hello_world:
*(u64 *)(r10 - 8) = r1 # 把r1的值儲存到棧
r1 = bpf_trace_printk ll #
r1 = *(u64 *)(r1 + 0) # r1賦值為 bpf_trace_printk 函式地址
r2 = .L.str ll # r2賦值為 "Hello World\n"
r3 = 12 # r3賦值為12
*(u64 *)(r10 - 16) = r1 # 把r1的值儲存到棧
r1 = r2 # 呼叫 bpf_trace_printk 函式的引數1
r2 = r3 # 呼叫 bpf_trace_printk 函式的引數2
r3 = *(u64 *)(r10 - 16) # 獲取 bpf_trace_printk 函式地址
callx r3 # 呼叫 bpf_trace_printk 函式
r1 = 0 # r1賦值為0
*(u64 *)(r10 - 24) = r0 # 把r0的值儲存到棧
r0 = r1 # 返回0
exit # 退出eBPF程式
...
eBPF 虛擬機器的規範:
-
暫存器
r1-r5
:作為函式呼叫引數使用。在 eBPF 程式啟動時,暫存器r1
包含 "上下文" 引數指標。 -
暫存器
r0
:儲存函式的返回值,包括函式呼叫和當前程式退出。 -
暫存器
r10
:eBPF程式的棧指標。
eBPF 載入器
eBPF 程式是由使用者編寫的,編譯成 eBPF 位元組碼後,需要載入到核心才能被核心使用。
使用者態可以通過呼叫 sys_bpf()
系統呼叫把 eBPF 程式載入到核心,而 sys_bpf()
系統呼叫會通過呼叫 bpf_prog_load()
核心函式載入 eBPF 程式。
我們來看看 bpf_prog_load()
函式的實現(經過精簡後):
static int bpf_prog_load(union bpf_attr *attr)
{
enum bpf_prog_type type = attr->prog_type;
struct bpf_prog *prog;
int err;
...
// 建立 bpf_prog 物件,用於儲存 eBPF 位元組碼和相關資訊
prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
...
prog->len = attr->insn_cnt; // eBPF 位元組碼長度(也就是有多少條 eBPF 位元組碼)
err = -EFAULT;
// 把 eBPF 位元組碼從使用者態複製到 bpf_prog 物件中
if (copy_from_user(prog->insns, u64_to_ptr(attr->insns),
prog->len * sizeof(struct bpf_insn)) != 0)
goto free_prog;
...
// 這裡主要找到特定模組的相關處理函式(如修正helper函式)
err = find_prog_type(type, prog);
// 檢查 eBPF 位元組碼是否合法
err = bpf_check(&prog, attr);
// 修正helper函式的偏移量
fixup_bpf_calls(prog);
// 嘗試將 eBPF 位元組碼編譯成本地機器碼(JIT模式)
err = bpf_prog_select_runtime(prog);
// 申請一個檔案控制代碼用於與 bpf_prog 物件關聯
err = bpf_prog_new_fd(prog);
return err;
...
}
bpf_prog_load()
函式主要完成以下幾個工作:
-
建立一個
bpf_prog
物件,用於儲存 eBPF 位元組碼和 eBPF 程式的相關資訊。 -
bpf_prog insns insns bpf_insn
-
socket kprobes xdp helper
-
檢查 eBPF 位元組碼是否合法。由於 eBPF 程式執行在核心態,所以要保證其安全性,否則將會導致核心崩潰。
-
修正
helper
函式的偏移量(下面會介紹)。 -
嘗試將 eBPF 位元組碼編譯成本地機器碼,主要為了提高 eBPF 程式的執行效率。
-
申請一個檔案控制代碼用於與
bpf_prog
物件關聯,這個檔案控制代碼將會返回給使用者態,使用者態可以通過這個檔案控制代碼來讀取核心中的 eBPF 程式。
修正 helper 函式
helper
函式是 eBPF 提供給使用者使用的一些輔助函式。
由於 eBPF 程式執行在核心態,所為了安全,eBPF 程式中不能隨意呼叫核心函式,只能呼叫 eBPF 提供的輔助函式(helper functions)。
呼叫 eBPF 的 helper
函式與呼叫普通的函式並不一樣,呼叫 helper
函式時並不是直接呼叫的,而是通過 helper
函式的編號來進行呼叫。
每個 eBPF 的 helper
函式都有一個編號(通過列舉型別 bpf_func_id
來定義),定義在 include/uapi/linux/bpf.h
檔案中,定義如下(只列出一部分):
enum bpf_func_id {
BPF_FUNC_unspec, // 0
BPF_FUNC_map_lookup_elem, // 1
BPF_FUNC_map_update_elem, // 2
BPF_FUNC_map_delete_elem, // 3
BPF_FUNC_probe_read, // 4
BPF_FUNC_ktime_get_ns, // 5
BPF_FUNC_trace_printk, // 6
BPF_FUNC_get_prandom_u32, // 7
BPF_FUNC_get_smp_processor_id, // 8
BPF_FUNC_skb_store_bytes, // 9
BPF_FUNC_l3_csum_replace, // 10
BPF_FUNC_l4_csum_replace, // 11
BPF_FUNC_tail_call, // 12
BPF_FUNC_clone_redirect, // 13
BPF_FUNC_get_current_pid_tgid, // 14
BPF_FUNC_get_current_uid_gid, // 15
...
__BPF_FUNC_MAX_ID,
};
下面我們來看看在 eBPF 程式中怎麼呼叫 helper
函式:
#include <linux/bpf.h>
// 宣告要呼叫的helper函式為:BPF_FUNC_trace_printk
static int (*bpf_trace_printk)(const char *fmt, int fmtsize, ...)
= (void *)BPF_FUNC_trace_printk;
int hello_world(void *ctx)
{
char msg[] = "Hello World\n";
// 呼叫helper函式
bpf_trace_printk(msg, sizeof(msg)-1);
return 0;
}
從上面的程式碼可以知道,當要呼叫 helper
函式時,需要先定義一個函式指標,並且將函式指標賦值為 helper
函式的編號,然後才能呼叫這個 helper
函式。
定義函式指標的原因是:指定呼叫函式時的引數。
所以,呼叫的 helper
函式其實並不是真實的函式地址。那麼核心是怎麼找到真實的 helper
函式地址呢?
這裡就是通過上面說的修正 helper
函式來實現的。
在介紹載入 eBPF 程式時說過,載入器會通過呼叫 fixup_bpf_calls()
函式來修正 helper
函式的地址。我們來看看 fixup_bpf_calls()
函式的實現:
static void fixup_bpf_calls(struct bpf_prog *prog)
{
const struct bpf_func_proto *fn;
int i;
// 遍歷所有的 eBPF 位元組碼
for (i = 0; i < prog->len; i++) {
struct bpf_insn *insn = &prog->insnsi[i];
// 如果是函式呼叫指令
if (insn->code == (BPF_JMP | BPF_CALL)) {
...
// 通過 helper 函式的編號獲取其真實地址
fn = prog->aux->ops->get_func_proto(insn->imm);
...
// 由於 bpf_insn 結構的 imm 欄位型別為 int,
// 為了能夠將 helper 函式的地址(64位)儲存到一個 int 中,
// 所以減去一個基礎函式地址,呼叫的時候加上這個基礎函式地址即可。
insn->imm = fn->func - __bpf_call_base;
}
}
}
fixup_bpf_calls()
函式主要完成修正 helper
函式的地址,其工作原理如下:
-
遍歷 eBPF 程式的所有位元組碼。
-
如果位元組碼指令是一個函式呼叫,那麼將進行函式地址修正,修正過程如下:
-
根據
helper
函式的編號獲取其真實的函式地址。 -
helper __bpf_call_base imm
從上面修正 helper
函式地址的過程可知,當呼叫 helper
函式時需要加上 __bpf_call_base
函式的地址。
eBPF 程式執行時機
上面介紹了 eBPF 程式的執行機制,現在來說說核心什麼時候執行 eBPF 程式。
在《eBPF的簡單使用》一文中介紹過,eBPF 程式需要掛載到某個核心路徑(掛在點)才能被執行。
根據掛載點功能的不同,大概可以分為以下幾個模組:
-
效能跟蹤(kprobes/uprobes/tracepoints)
-
網路(socket/xdp)
-
容器(cgroup)
-
安全(seccomp)
比如要將 eBPF 程式掛載在 socket(套接字) 上,可以使用 setsockopt()
函式來實現,程式碼如下:
setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
下面說說 setsockopt()
函式各個引數的意義:
-
sock:要掛載 eBPF 程式的 socket 控制代碼。
-
SOL_SOCKET :設定的選項的級別,如果想要在套接字級別上設定選項,就必須設定為
SOL_SOCKET
。 -
SO_ATTACH_BPF:表示掛載 eBPF 程式到 socket 上。
-
prog_fd :通過呼叫
bpf()
系統呼叫載入 eBPF 程式到核心後返回的檔案控制代碼。
通過上面的程式碼,就能將 eBPF 程式掛載到 socket 上,當 socket 接收到資料包時,將會執行這個 eBPF 程式對資料包進行過濾。
我們看看當 socket 接收到資料包時的操作:
// file: net/packet/af_packet.c
static int
packet_rcv(struct sk_buff *skb,
struct net_device *dev,
struct packet_type *pt,
struct net_device *orig_dev)
{
...
// 執行 eBPF 程式
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop_n_restore;
...
}
當 socket 接收到資料包時,會呼叫 run_filter()
函式執行 eBPF 程式。
總結
本文主要介紹了 eBPF 的實現原理,當然本文只是按大體思路去分析,有很多細節需要讀者自己閱讀原始碼來了解。
下篇文章將會介紹 kprobes 是怎麼結合 eBPF 進行核心函式追蹤的。
- Linux核心除錯利器|kprobe 原理與實現
- 自己動手寫一個GDB|基本功能
- 怎樣學好計算機底層技術?
- 一文讀懂eBPF|即時編譯(JIT)實現原理
- 一文看懂eBPF|eBPF實現原理
- 一文看懂eBPF|eBPF的簡單使用
- 學習計算機底層原理,我推薦幾個大佬!
- 搞懂程序組、會話、控制終端關係,才能明白守護程序幹嘛的?
- 跟大佬們一起起飛!
- 一文讀懂|Linux 程序管理之CFS負載均衡
- Linux 多核 SMP 系統的引導
- 深入理解Linux核心之記憶體定址
- 圖解|Linux 組排程
- 網際網路圈,年末小聚
- 手把手教你|攔截系統呼叫
- KSM機制剖析 — Linux 核心中的記憶體去耦合
- 使用 GDB Qemu 除錯 Linux 核心
- eBPF 概述:第 1 部分:介紹
- 圖解 | Linux記憶體效能優化核心思想
- 一文看懂 | fork 系統呼叫