一文看懂eBPF|eBPF實(shí)現(xiàn)原理
在上一篇文章中,我們主要簡(jiǎn)單介紹了什么是 eBPF 和 eBPF 的簡(jiǎn)單使用,而本文重點(diǎn)介紹 eBPF 的實(shí)現(xiàn)原理。
在介紹 eBPF 的實(shí)現(xiàn)原理前,我們先來(lái)回顧一下 eBPF 的架構(gòu)圖:

這幅圖對(duì)理解 eBPF 實(shí)現(xiàn)原理有非常大的作用,在分析 eBPF 實(shí)現(xiàn)原理時(shí),要經(jīng)常參照這幅圖來(lái)進(jìn)行分析。
eBPF虛擬機(jī)
其實(shí)我不太想介紹 eBPF 虛擬機(jī)的,因?yàn)橐话銇?lái)說(shuō)很少會(huì)用到 eBPF 匯編來(lái)寫程序。但是,不介紹 eBPF 虛擬機(jī)的話,又不能說(shuō)清 eBPF 的原理。
所以,還是先簡(jiǎn)單介紹一下 eBPF 虛擬機(jī)的原理,這樣對(duì)分析 eBPF 實(shí)現(xiàn)有很大的幫助。
eBPF匯編
eBPF 本質(zhì)上是一個(gè)虛擬機(jī)(Virtual Machine),可以執(zhí)行 eBPF 字節(jié)碼。
用戶可以使用 eBPF 匯編或者 C 語(yǔ)言來(lái)編寫程序,然后編譯成 eBPF 字節(jié)碼,再由 eBPF 虛擬機(jī)執(zhí)行。
什么是虛擬機(jī)?
官方的解釋是:虛擬機(jī)(VM)是一種創(chuàng)建于物理硬件系統(tǒng)(位于外部或內(nèi)部)、充當(dāng)虛擬計(jì)算機(jī)系統(tǒng)的虛擬環(huán)境,它模擬出了自己的整套硬件,包括 CPU、內(nèi)存、網(wǎng)絡(luò)接口和存儲(chǔ)器。通過名為虛擬機(jī)監(jiān)控程序的軟件,用戶可以將機(jī)器的資源與硬件分開并進(jìn)行適當(dāng)設(shè)置,以供虛擬機(jī)使用。
通俗的解釋:虛擬機(jī)就是模擬計(jì)算機(jī)的運(yùn)行環(huán)境,你可以把它當(dāng)成是一臺(tái)虛擬出來(lái)的計(jì)算機(jī)。
計(jì)算機(jī)的最本質(zhì)功能就是執(zhí)行代碼,所以 eBPF 虛擬機(jī)也一樣,可以運(yùn)行 eBPF 字節(jié)碼。
用戶編寫的 eBPF 程序最終會(huì)被編譯成 eBPF 字節(jié)碼,eBPF 字節(jié)碼使用?bpf_insn?結(jié)構(gòu)來(lái)表示,如下:
struct?bpf_insn?{
????__u8????code;???????//?操作碼
????__u8????dst_reg:4;??//?目標(biāo)寄存器
????__u8????src_reg:4;??//?源寄存器
????__s16???off;????????//?偏移量
????__s32???imm;????????//?立即操作數(shù)
};
下面介紹一下?bpf_insn?結(jié)構(gòu)各個(gè)字段的作用:
code:指令操作碼,如 mov、add 等。dst_reg:目標(biāo)寄存器,用于指定要操作哪個(gè)寄存器。src_reg:源寄存器,用于指定數(shù)據(jù)來(lái)源于哪個(gè)寄存器。off:偏移量,用于指定某個(gè)結(jié)構(gòu)體的成員。imm:立即操作數(shù),當(dāng)數(shù)據(jù)是一個(gè)常數(shù)時(shí),直接在這里指定。
eBPF 程序會(huì)被 LLVM/Clang 編譯成?bpf_insn?結(jié)構(gòu)數(shù)組,當(dāng)內(nèi)核要執(zhí)行 eBPF 字節(jié)碼時(shí),會(huì)調(diào)用?__bpf_prog_run()?函數(shù)來(lái)執(zhí)行。
如果開啟了 JIT(即時(shí)編譯技術(shù)),內(nèi)核會(huì)將 eBPF 字節(jié)碼編譯成本地機(jī)器碼(Native Code)。這樣就可以直接執(zhí)行,而不需要虛擬機(jī)來(lái)執(zhí)行。
關(guān)于 eBPF 匯編相關(guān)的知識(shí)點(diǎn)可以參考《eBPF匯編指令介紹》,這里就不作深入的分析,我們只需要記住 eBPF 程序會(huì)被編譯成 eBPF 字節(jié)碼即可。
eBPF虛擬機(jī)
eBPF 虛擬機(jī)的作用就是執(zhí)行 eBPF 字節(jié)碼,eBPF 虛擬機(jī)比較簡(jiǎn)單(只有300行代碼左右),由?__bpf_prog_run()?函數(shù)實(shí)現(xiàn)。
通用虛擬機(jī)因?yàn)橐M真實(shí)的計(jì)算機(jī),所以通常來(lái)說(shuō)實(shí)現(xiàn)比較復(fù)雜(如Qemu、Virtual Box等)。
但像 eBPF 虛擬機(jī)這種用于特定功能的虛擬機(jī),由于只需要模擬計(jì)算機(jī)的小部分功能,所以實(shí)現(xiàn)通常比較簡(jiǎn)單。
eBPF 虛擬機(jī)的運(yùn)行環(huán)境只有 1 個(gè) 512KB 的棧和 11 個(gè)寄存器(還有一個(gè) PC 寄存器,用于指向當(dāng)前正在執(zhí)行的 eBPF 字節(jié)碼)。如下圖所示:

如果內(nèi)核支持 JIT(Just In Time)運(yùn)行模式,那么內(nèi)核將會(huì)把 eBPF 字節(jié)碼編譯成本地機(jī)器碼,這時(shí)可以直接運(yùn)行這些機(jī)器碼,而不需要使用虛擬機(jī)來(lái)運(yùn)行。
可以通過以下命令打開 JIT 運(yùn)行模式:
$?echo?1?>?/proc/sys/net/core/bpf_jit_enable
將 C 程序編譯成 eBPF 字節(jié)碼
由于使用 eBPF 匯編編寫程序比較麻煩,所以 eBPF 提供了功能受限的 C 語(yǔ)言來(lái)編寫 eBPF 程序,并且可以使用 Clang/LLVM 將 C 程序編譯成 eBPF 字節(jié)碼。
使用 Clang 編譯 eBPF 程序時(shí),需要加上?-target bpf?參數(shù)才能編譯成功。
下面我們用一個(gè)簡(jiǎn)單的例子來(lái)介紹怎么使用 Clang 編譯 eBPF 程序,我們新建一個(gè)文件?hello.c?并且輸入以下代碼:
#include?
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
編譯后會(huì)得到一個(gè)名為?hello.o?的文件,我們可以通過下面命令來(lái)看到編譯后的字節(jié)碼:
$?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?................
由于編譯出來(lái)的字節(jié)碼是二進(jìn)制的,不利于人類查閱。所以,可以通過以下命令將 eBPF 程序編譯成 eBPF 匯編代碼:
$?clang?-target?bpf?-S?-o?hello.s?hello.c
編譯后會(huì)得到一個(gè)名為?hello.s?的文件,我們可以使用文本編輯器來(lái)查看其匯編代碼:
...
hello_world:
????*(u64?*)(r10?-?8)?=?r1???#?把r1的值保存到棧
????r1?=?bpf_trace_printk?ll?#
????r1?=?*(u64?*)(r1?+?0)????#?r1賦值為?bpf_trace_printk?函數(shù)地址
????r2?=?.L.str?ll???????????#?r2賦值為?"Hello?World\n"
????r3?=?12??????????????????#?r3賦值為12
????*(u64?*)(r10?-?16)?=?r1??#?把r1的值保存到棧
????r1?=?r2??????????????????#?調(diào)用?bpf_trace_printk?函數(shù)的參數(shù)1
????r2?=?r3??????????????????#?調(diào)用?bpf_trace_printk?函數(shù)的參數(shù)2
????r3?=?*(u64?*)(r10?-?16)??#?獲取?bpf_trace_printk?函數(shù)地址
????callx?r3?????????????????#?調(diào)用?bpf_trace_printk?函數(shù)
????r1?=?0???????????????????#?r1賦值為0
????*(u64?*)(r10?-?24)?=?r0??#?把r0的值保存到棧
????r0?=?r1??????????????????#?返回0
????exit?????????????????????#?退出eBPF程序
...
eBPF 虛擬機(jī)的規(guī)范:
寄存器?
r1-r5:作為函數(shù)調(diào)用參數(shù)使用。在 eBPF 程序啟動(dòng)時(shí),寄存器?r1?包含 "上下文" 參數(shù)指針。寄存器?
r0:存儲(chǔ)函數(shù)的返回值,包括函數(shù)調(diào)用和當(dāng)前程序退出。寄存器?
r10:eBPF程序的棧指針。
eBPF 加載器
eBPF 程序是由用戶編寫的,編譯成 eBPF 字節(jié)碼后,需要加載到內(nèi)核才能被內(nèi)核使用。
用戶態(tài)可以通過調(diào)用?sys_bpf()?系統(tǒng)調(diào)用把 eBPF 程序加載到內(nèi)核,而?sys_bpf()?系統(tǒng)調(diào)用會(huì)通過調(diào)用?bpf_prog_load()?內(nèi)核函數(shù)加載 eBPF 程序。
我們來(lái)看看?bpf_prog_load()?函數(shù)的實(shí)現(xiàn)(經(jīng)過精簡(jiǎn)后):
static?int?bpf_prog_load(union?bpf_attr?*attr)
{
????enum?bpf_prog_type?type?=?attr->prog_type;
????struct?bpf_prog?*prog;
????int?err;
????...
????//?創(chuàng)建?bpf_prog?對(duì)象,用于保存?eBPF?字節(jié)碼和相關(guān)信息
????prog?=?bpf_prog_alloc(bpf_prog_size(attr->insn_cnt),?GFP_USER);
????...
????prog->len?=?attr->insn_cnt;?//?eBPF?字節(jié)碼長(zhǎng)度(也就是有多少條?eBPF?字節(jié)碼)
????err?=?-EFAULT;
????//?把?eBPF?字節(jié)碼從用戶態(tài)復(fù)制到?bpf_prog?對(duì)象中
????if?(copy_from_user(prog->insns,?u64_to_ptr(attr->insns),
???????????????????????prog->len?*?sizeof(struct?bpf_insn))?!=?0)
????????goto?free_prog;
????...
????//?這里主要找到特定模塊的相關(guān)處理函數(shù)(如修正helper函數(shù))
????err?=?find_prog_type(type,?prog);
????//?檢查?eBPF?字節(jié)碼是否合法
????err?=?bpf_check(&prog,?attr);
????//?修正helper函數(shù)的偏移量
????fixup_bpf_calls(prog);
????//?嘗試將?eBPF?字節(jié)碼編譯成本地機(jī)器碼(JIT模式)
????err?=?bpf_prog_select_runtime(prog);
????//?申請(qǐng)一個(gè)文件句柄用于與?bpf_prog?對(duì)象關(guān)聯(lián)
????err?=?bpf_prog_new_fd(prog);
????return?err;
????...
}
bpf_prog_load()?函數(shù)主要完成以下幾個(gè)工作:
- 創(chuàng)建一個(gè)?
bpf_prog?對(duì)象,用于保存 eBPF 字節(jié)碼和 eBPF 程序的相關(guān)信息。 - 把 eBPF 字節(jié)碼從用戶態(tài)復(fù)制到?
bpf_prog?對(duì)象的?insns?成員中,insns?成員是一個(gè)類型為?bpf_insn?結(jié)構(gòu)的數(shù)組。 - 根據(jù) eBPF 程序所屬的類型(如?
socket、kprobes?或?xdp?等),找到其相關(guān)處理函數(shù)(如?helper?函數(shù)對(duì)應(yīng)的修正函數(shù),下面會(huì)介紹)。 - 檢查 eBPF 字節(jié)碼是否合法。由于 eBPF 程序運(yùn)行在內(nèi)核態(tài),所以要保證其安全性,否則將會(huì)導(dǎo)致內(nèi)核崩潰。
- 修正?
helper?函數(shù)的偏移量(下面會(huì)介紹)。 - 嘗試將 eBPF 字節(jié)碼編譯成本地機(jī)器碼,主要為了提高 eBPF 程序的執(zhí)行效率。
- 申請(qǐng)一個(gè)文件句柄用于與?
bpf_prog?對(duì)象關(guān)聯(lián),這個(gè)文件句柄將會(huì)返回給用戶態(tài),用戶態(tài)可以通過這個(gè)文件句柄來(lái)讀取內(nèi)核中的 eBPF 程序。
修正 helper 函數(shù)
helper?函數(shù)是 eBPF 提供給用戶使用的一些輔助函數(shù)。
由于 eBPF 程序運(yùn)行在內(nèi)核態(tài),所為了安全,eBPF 程序中不能隨意調(diào)用內(nèi)核函數(shù),只能調(diào)用 eBPF 提供的輔助函數(shù)(helper functions)。
調(diào)用 eBPF 的?helper?函數(shù)與調(diào)用普通的函數(shù)并不一樣,調(diào)用?helper?函數(shù)時(shí)并不是直接調(diào)用的,而是通過?helper?函數(shù)的編號(hào)來(lái)進(jìn)行調(diào)用。
每個(gè) eBPF 的?helper?函數(shù)都有一個(gè)編號(hào)(通過枚舉類型?bpf_func_id?來(lái)定義),定義在?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,
};
下面我們來(lái)看看在 eBPF 程序中怎么調(diào)用?helper?函數(shù):
#include?
//?聲明要調(diào)用的helper函數(shù)為: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";
????
????//?調(diào)用helper函數(shù)
????bpf_trace_printk(msg,?sizeof(msg)-1);
????return?0;
}
從上面的代碼可以知道,當(dāng)要調(diào)用?helper?函數(shù)時(shí),需要先定義一個(gè)函數(shù)指針,并且將函數(shù)指針賦值為?helper?函數(shù)的編號(hào),然后才能調(diào)用這個(gè)?helper?函數(shù)。
定義函數(shù)指針的原因是:指定調(diào)用函數(shù)時(shí)的參數(shù)。
所以,調(diào)用的?helper?函數(shù)其實(shí)并不是真實(shí)的函數(shù)地址。那么內(nèi)核是怎么找到真實(shí)的?helper?函數(shù)地址呢?
這里就是通過上面說(shuō)的修正?helper?函數(shù)來(lái)實(shí)現(xiàn)的。
在介紹加載 eBPF 程序時(shí)說(shuō)過,加載器會(huì)通過調(diào)用?fixup_bpf_calls()?函數(shù)來(lái)修正?helper?函數(shù)的地址。我們來(lái)看看?fixup_bpf_calls()?函數(shù)的實(shí)現(xiàn):
static?void?fixup_bpf_calls(struct?bpf_prog?*prog)
{
????const?struct?bpf_func_proto?*fn;
????int?i;
????//?遍歷所有的?eBPF?字節(jié)碼
????for?(i?=?0;?i?len;?i++)?{
????????struct?bpf_insn?*insn?=?&prog->insnsi[i];
????????//?如果是函數(shù)調(diào)用指令
????????if?(insn->code?==?(BPF_JMP?|?BPF_CALL))?{
????????????...
????????????//?通過?helper?函數(shù)的編號(hào)獲取其真實(shí)地址
????????????fn?=?prog->aux->ops->get_func_proto(insn->imm);
????????????
????????????...
????????????//?由于?bpf_insn?結(jié)構(gòu)的?imm?字段類型為?int,
????????????//?為了能夠?qū)?helper?函數(shù)的地址(64位)保存到一個(gè)?int?中,
????????????//?所以減去一個(gè)基礎(chǔ)函數(shù)地址,調(diào)用的時(shí)候加上這個(gè)基礎(chǔ)函數(shù)地址即可。
????????????insn->imm?=?fn->func?-?__bpf_call_base;
????????}
????}
}
fixup_bpf_calls()?函數(shù)主要完成修正?helper?函數(shù)的地址,其工作原理如下:
- 遍歷 eBPF 程序的所有字節(jié)碼。
- 如果字節(jié)碼指令是一個(gè)函數(shù)調(diào)用,那么將進(jìn)行函數(shù)地址修正,修正過程如下:
- 根據(jù)?
helper?函數(shù)的編號(hào)獲取其真實(shí)的函數(shù)地址。 - 將?
helper?函數(shù)的真實(shí)地址減去?__bpf_call_base?函數(shù)的地址,并且保存到字節(jié)碼的?imm?字段中。
從上面修正?helper?函數(shù)地址的過程可知,當(dāng)調(diào)用?helper?函數(shù)時(shí)需要加上?__bpf_call_base?函數(shù)的地址。
eBPF 程序運(yùn)行時(shí)機(jī)
上面介紹了 eBPF 程序的運(yùn)行機(jī)制,現(xiàn)在來(lái)說(shuō)說(shuō)內(nèi)核什么時(shí)候執(zhí)行 eBPF 程序。
在《eBPF的簡(jiǎn)單使用》一文中介紹過,eBPF 程序需要掛載到某個(gè)內(nèi)核路徑(掛在點(diǎn))才能被執(zhí)行。
根據(jù)掛載點(diǎn)功能的不同,大概可以分為以下幾個(gè)模塊:
- 性能跟蹤(kprobes/uprobes/tracepoints)
- 網(wǎng)絡(luò)(socket/xdp)
- 容器(cgroup)
- 安全(seccomp)
比如要將 eBPF 程序掛載在 socket(套接字) 上,可以使用?setsockopt()?函數(shù)來(lái)實(shí)現(xiàn),代碼如下:
setsockopt(sock,?SOL_SOCKET,?SO_ATTACH_BPF,?&prog_fd,?sizeof(prog_fd));
下面說(shuō)說(shuō)?setsockopt()?函數(shù)各個(gè)參數(shù)的意義:
- sock:要掛載 eBPF 程序的 socket 句柄。
- SOL_SOCKET:設(shè)置的選項(xiàng)的級(jí)別,如果想要在套接字級(jí)別上設(shè)置選項(xiàng),就必須設(shè)置為?
SOL_SOCKET。 - SO_ATTACH_BPF:表示掛載 eBPF 程序到 socket 上。
- prog_fd:通過調(diào)用?
bpf()?系統(tǒng)調(diào)用加載 eBPF 程序到內(nèi)核后返回的文件句柄。
通過上面的代碼,就能將 eBPF 程序掛載到 socket 上,當(dāng) socket 接收到數(shù)據(jù)包時(shí),將會(huì)執(zhí)行這個(gè) eBPF 程序?qū)?shù)據(jù)包進(jìn)行過濾。
我們看看當(dāng) socket 接收到數(shù)據(jù)包時(shí)的操作:
//?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)
{
????...
????//?執(zhí)行?eBPF?程序
????res?=?run_filter(skb,?sk,?snaplen);
????if?(!res)
????????goto?drop_n_restore;
????...
}
當(dāng) socket 接收到數(shù)據(jù)包時(shí),會(huì)調(diào)用?run_filter()?函數(shù)執(zhí)行 eBPF 程序。
總結(jié)
本文主要介紹了 eBPF 的實(shí)現(xiàn)原理,當(dāng)然本文只是按大體思路去分析,有很多細(xì)節(jié)需要讀者自己閱讀源碼來(lái)了解。
下篇文章將會(huì)介紹 kprobes 是怎么結(jié)合 eBPF 進(jìn)行內(nèi)核函數(shù)追蹤的。
