eBPF 概述:第 2 部分:機(jī)器和字節(jié)碼
1. 前言
我們?cè)诘?1 篇文章中介紹了 eBPF 虛擬機(jī),包括其有意的設(shè)計(jì)限制以及如何從用戶空間進(jìn)程中進(jìn)行交互。如果你還沒有讀過這篇文章,建議你在繼續(xù)之前讀一下,因?yàn)闆]有適當(dāng)?shù)慕榻B,直接開始接觸機(jī)器和字節(jié)碼的細(xì)節(jié)是比較困難的。如果有疑問,請(qǐng)看第 1 部分開頭的流程圖。
本系列的第 2 部分對(duì)第 1 部分中研究的 eBPF 虛擬機(jī)和程序進(jìn)行了更深入的探討。掌握這些低層次的知識(shí)并不是強(qiáng)制性的,但可以為本系列的其他部分打下非常有用的基礎(chǔ),我們將在這些機(jī)制的基礎(chǔ)上研究更高層次的工具。
2. 虛擬機(jī)
eBPF 是一個(gè) RISC 寄存器機(jī),共有?11 個(gè) 64 位寄存器,一個(gè)程序計(jì)數(shù)器和 512 字節(jié)的固定大小的棧。9 個(gè)寄存器是通用讀寫的,1 個(gè)是只讀棧指針,程序計(jì)數(shù)器是隱式的,也就是說,我們只能跳轉(zhuǎn)到它的某個(gè)偏移量。VM 寄存器總是 64 位寬(即使在 32 位 ARM 處理器內(nèi)核中運(yùn)行?。?,如果最重要的 32 位被清零,則支持 32 位子寄存器尋址 - 這在第 4 部分交叉編譯和在嵌入式設(shè)備上運(yùn)行 eBPF 程序時(shí)非常有用。
這些寄存器是:
r0: | 存儲(chǔ)返回值,包括函數(shù)調(diào)用和當(dāng)前程序退出代碼 |
|---|---|
r1-r5: | 作為函數(shù)調(diào)用參數(shù)使用,在程序啟動(dòng)時(shí),r1 包含 "上下文" 參數(shù)指針 |
r6-r9: | 這些在內(nèi)核函數(shù)調(diào)用之間被保留下來 |
r10: | 每個(gè) eBPF 程序 512 字節(jié)棧的只讀指針 |
在加載時(shí)提供的 eBPF?程序類型決定了哪些內(nèi)核函數(shù)的子集可以被調(diào)用,以及在程序啟動(dòng)時(shí)通過 r1 提供的"上下文"參數(shù)。存儲(chǔ)在 r0 中的程序退出值的含義也由程序類型決定。
每個(gè)函數(shù)調(diào)用在寄存器 r1-r5 中最多可以有 5 個(gè)參數(shù);這適用于 ebpf 到 ebpf 的調(diào)用和內(nèi)核函數(shù)調(diào)用。寄存器 r1-r5 只能存儲(chǔ)數(shù)字或指向棧的指針(作為函數(shù)的參數(shù)),不能直接指向任意的內(nèi)存。所有的內(nèi)存訪問必須在 eBPF 程序中使用之前首先將數(shù)據(jù)加載到 eBPF 棧。這一限制有助于 eBPF 驗(yàn)證器,它簡(jiǎn)化了內(nèi)存模型,使其更容易進(jìn)行內(nèi)核檢查。
BPF 可訪問的內(nèi)核 “輔助”(helper) 函數(shù)是由內(nèi)核通過類似于定義 syscalls 的 API 定義的(不能通過模塊擴(kuò)展),定義使用?BPF_CALL_*?宏。bpf.h?試圖為所有 BPF 可訪問的內(nèi)核輔助函數(shù)提供參考。例如,bpf_trace_printk?的定義使用了 BPF_CALL_5 和 5 對(duì)類型 / 參數(shù)名稱。定義參數(shù)數(shù)據(jù)類型是非常重要的,因?yàn)樵诿看?eBPF 程序加載時(shí),eBPF 驗(yàn)證器會(huì)確保寄存器的數(shù)據(jù)類型與被調(diào)用者的參數(shù)類型相符。
eBPF 指令也是固定大小的 64 位編碼,目前大約有 100 條指令,被分組為?8 類。該虛擬機(jī)支持從通用內(nèi)存(map、棧、如數(shù)據(jù)包緩沖區(qū)等的 “上下文”,)進(jìn)行 1-8 字節(jié)的加載/存儲(chǔ),前/后(非)條件跳轉(zhuǎn)、算術(shù)/邏輯操作和函數(shù)調(diào)用。操作碼格式格式深入研究的文檔,請(qǐng)參考 Cilium 項(xiàng)目指令集文檔。IOVisor 項(xiàng)目也維護(hù)了一個(gè)有用的指令規(guī)格。
在本系列第 1 部分研究的例子中,我們使用了部分有用的內(nèi)核宏,使用以下結(jié)構(gòu)創(chuàng)建了一個(gè) eBPF 字節(jié)碼指令數(shù)組(所有指令都是這樣編碼的):
struct?bpf_insn?{
?__u8?code;??/*?opcode?*/
?__u8?dst_reg:4;?/*?dest?register?*/
?__u8?src_reg:4;?/*?source?register?*/
?__s16?off;??/*?signed?offset?*/
?__s32?imm;??/*?signed?immediate?constant?*/
};
/*
msb????????????????????????????????????????????????????????lsb
+------------------------+----------------+----+----+--------+
|immediate???????????????|offset??????????|src?|dst?|opcode??|
+------------------------+----------------+----+----+--------+
*/讓我們看看?BPF_JMP_IMM?指令,它編碼了一個(gè)針對(duì)立即值的條件跳轉(zhuǎn)。下面的宏注釋對(duì)指令的邏輯應(yīng)該是不言自明的。操作碼編碼了指令類別 BPF_JMP,操作(通過 BPF_OP 位域以確保正確)和一個(gè)標(biāo)志 BPF_K,表示它是對(duì)直接/常量值的操作。
#define?BPF_OP(code)????((code)?&?0xf0)
#define?BPF_K??0x00
/*?Conditional?jumps?against?immediates,?if?(dst_reg?'op'?imm32)?goto?pc?+?off16?*/
#define?BPF_JMP_IMM(OP,?DST,?IMM,?OFF)????\
?((struct?bpf_insn)?{?????\
??.code??=?BPF_JMP?|?BPF_OP(OP)?|?BPF_K,??\
??.dst_reg?=?DST,?????\
??.src_reg?=?0,?????\
??.off???=?OFF,?????\
??.imm???=?IMM?})如果我們?nèi)ビ?jì)算該指令的值,或者拆解一個(gè)包含 BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2) 的 eBPF 字節(jié)碼,我們會(huì)發(fā)現(xiàn)它是 0x020015。這個(gè)特定的字節(jié)碼非常頻繁地被用來測(cè)試存儲(chǔ)在 r0 中的函數(shù)調(diào)用的返回值;如果 r0 == 0,它就會(huì)跳過接下來的 2 條指令。
3. 重新認(rèn)識(shí)字節(jié)碼
現(xiàn)在我們已經(jīng)有了必要的知識(shí)來完全理解本系列第 1 部分中 eBPF 例子中使用的字節(jié)碼,現(xiàn)在我們將一步一步地進(jìn)行詳解。記住,sock_example.c?是一個(gè)簡(jiǎn)單的用戶空間程序,使用 eBPF 來統(tǒng)計(jì)回環(huán)接口上收到多少個(gè) TCP、UDP 和 ICMP 協(xié)議包。
在更高層次上,代碼所做的是從接收到的數(shù)據(jù)包中讀取協(xié)議號(hào),然后把它推到 eBPF 棧中,作為 map_lookup_elem 調(diào)用的索引,從而得到各自協(xié)議的數(shù)據(jù)包計(jì)數(shù)。map_lookup_elem 函數(shù)在 r0 接收一個(gè)索引(或鍵)指針,在 r1 接收一個(gè) map 文件描述符。如果查找調(diào)用成功,r0 將包含一個(gè)指向存儲(chǔ)在協(xié)議索引的 map 值的指針。然后我們?cè)邮降卦黾?map 值并退出。
BPF_MOV64_REG(BPF_REG_6,?BPF_REG_1),當(dāng)一個(gè) eBPF 程序啟動(dòng)時(shí),r1 中的地址指向 context 上下文(當(dāng)前情況下為數(shù)據(jù)包緩沖區(qū))。r1 將在函數(shù)調(diào)用時(shí)用于參數(shù),所以我們也將其存儲(chǔ)在 r6 中作為備份。
BPF_LD_ABS(BPF_B,?ETH_HLEN?+?offsetof(struct?iphdr,?protocol)?/*?R0?=?ip->proto?*/),這條指令從 context 上下文緩沖區(qū)的偏移量向 r0 加載一個(gè)字節(jié)(BPF_B),當(dāng)前情況下是網(wǎng)絡(luò)數(shù)據(jù)包緩沖區(qū),所以我們從一個(gè)?iphdr 結(jié)構(gòu)?中提供協(xié)議字節(jié)的偏移量,以加載到 r0。
BPF_STX_MEM(BPF_W,?BPF_REG_10,?BPF_REG_0,?-4),?/*?*(u32?*)(fp?-?4)?=?r0?*/將包含先前讀取的協(xié)議的字(BPF_W)加載到棧上(由 r10 指出,從偏移量 -4 字節(jié)開始)。
BPF_MOV64_REG(BPF_REG_2,?BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD,?BPF_REG_2,?-4),?/*?r2?=?fp?-?4?*/將棧地址指針移至 r2 并減去 4,所以現(xiàn)在 r2 指向協(xié)議值,作為下一個(gè) map 鍵查找的參數(shù)。
BPF_LD_MAP_FD(BPF_REG_1,?map_fd),將本地進(jìn)程中的文件描述符引用包含協(xié)議包計(jì)數(shù)的 map 加載到 r1。
BPF_RAW_INSN(BPF_JMP?|?BPF_CALL,?0,?0,?0,?BPF_FUNC_map_lookup_elem),執(zhí)行 map 查找調(diào)用,將棧中由 r2 指向的協(xié)議值作為 key。結(jié)果存儲(chǔ)在 r0 中:一個(gè)指向由 key 索引的值的指針地址。
BPF_JMP_IMM(BPF_JEQ,?BPF_REG_0,?0,?2),還記得?0x020015?嗎?這和第一節(jié)的字節(jié)碼是一樣的。如果 map 查找沒有成功,r0 == 0,所以我們跳過下面兩條指令。
BPF_MOV64_IMM(BPF_REG_1,?1),?/*?r1?=?1?*/
BPF_RAW_INSN(BPF_STX?|?BPF_XADD?|?BPF_DW,?BPF_REG_0,?BPF_REG_1,?0,?0),?/*?xadd?r0?+=?r1?*/遞增 r0 所指向的地址的 map 值。
BPF_MOV64_IMM(BPF_REG_0,?0),?/*?r0?=?0?*/
BPF_EXIT_INSN(),將 eBPF 的 retcode 設(shè)置為 0 并退出。
盡管這個(gè) sock_example 邏輯是非常簡(jiǎn)單(它只是在一個(gè)映射中增加一些數(shù)字),但在原始字節(jié)碼中實(shí)現(xiàn)或理解它也是很難做到的。更加復(fù)雜的任務(wù)在像這樣的匯編程序中完成會(huì)變得非常困難。展望未來,我們將準(zhǔn)備使用更高級(jí)別的語言和工具來實(shí)現(xiàn)更強(qiáng)大的 eBPF 用例,而不費(fèi)吹灰之力。
4. 總結(jié)
在這一部分中,我們仔細(xì)觀察了 eBPF 虛擬機(jī)的寄存器和指令集,了解了 eBPF 可訪問的內(nèi)核函數(shù)是如何從字節(jié)碼中調(diào)用的,以及它們是如何被核心內(nèi)核通過類似 syscall 的特殊目的 API 定義的。我們也完全理解了第 1 部分例子中使用的字節(jié)碼。還有一些未探索的領(lǐng)域,如創(chuàng)建多個(gè) eBPF 程序函數(shù)或鏈?zhǔn)?eBPF 程序以繞過 Linux 發(fā)行版的 4096 條指令限制。也許我們會(huì)在以后的文章中探討這些。
現(xiàn)在,主要的問題是編寫原始字節(jié)碼是很困難的,這非常像編寫匯編代碼,而且編寫效果不高。在第 3 部分中,我們將開始研究使用高級(jí)語言編譯成 eBPF 字節(jié)碼,到此為止我們已經(jīng)了解了虛擬機(jī)工作的底層基礎(chǔ)知識(shí)。
