一文看懂eBPF|eBPF實現原理

語言: CN / TW / HK

在上一篇文章中,我們主要簡單介紹了什麼是 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 結構各個欄位的作用:

  1. code :指令操作碼,如 mov、add 等。
  2. dst_reg :目標暫存器,用於指定要操作哪個暫存器。
  3. src_reg :源暫存器,用於指定資料來源於哪個暫存器。
  4. off :偏移量,用於指定某個結構體的成員。
  5. 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 虛擬機器的規範:

  1. 暫存器  r1-r5 :作為函式呼叫引數使用。在 eBPF 程式啟動時,暫存器  r1 包含 "上下文" 引數指標。

  2. 暫存器  r0 :儲存函式的返回值,包括函式呼叫和當前程式退出。

  3. 暫存器  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() 函式主要完成以下幾個工作:

  1. 建立一個  bpf_prog 物件,用於儲存 eBPF 位元組碼和 eBPF 程式的相關資訊。
  2. bpf_prog
    insns
    insns
    bpf_insn
    
  3. socket
    kprobes
    xdp
    helper
    
  4. 檢查 eBPF 位元組碼是否合法。由於 eBPF 程式執行在核心態,所以要保證其安全性,否則將會導致核心崩潰。

  5. 修正  helper 函式的偏移量(下面會介紹)。
  6. 嘗試將 eBPF 位元組碼編譯成本地機器碼,主要為了提高 eBPF 程式的執行效率。

  7. 申請一個檔案控制代碼用於與  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 函式的地址,其工作原理如下:

  1. 遍歷 eBPF 程式的所有位元組碼。

  2. 如果位元組碼指令是一個函式呼叫,那麼將進行函式地址修正,修正過程如下:

  • 根據  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 進行核心函式追蹤的。