eBPF 簡(jiǎn)史
數(shù)日之前,筆者參加某一技術(shù)會(huì)議之時(shí),為人所安利了一款開(kāi)源項(xiàng)目,演講者對(duì)其性能頗為稱(chēng)道,稱(chēng)其乃基于近年在內(nèi)核中炙手可熱的 eBPF 技術(shù)。
對(duì)這 eBPF 的名號(hào),筆者略有些耳熟,會(huì)后遂一番搜索學(xué)習(xí),發(fā)現(xiàn) eBPF 果然源于早年間的成型于 BSD 之上的傳統(tǒng)技術(shù) BPF(Berkeley Packet Filter),但無(wú)論其性能還是功能已然都不是 BPF 可以比擬的了,慨嘆長(zhǎng)江后浪推前浪,前浪死在沙灘上之余,筆者也發(fā)現(xiàn)國(guó)內(nèi)相關(guān)文獻(xiàn)匱乏,導(dǎo)致 eBPF 尚不為大眾所知,遂撰此文,記錄近日所得,希冀可以為廣大讀者打開(kāi)新世界的大門(mén)。
源頭:一篇 1992 年的論文
考慮到 BPF 的知名度,在介紹 eBPF 之前,筆者自覺(jué)還是有必要先來(lái)回答另一個(gè)問(wèn)題:
什么是 BPF?
筆者在前文中說(shuō)過(guò)了,BPF 的全稱(chēng)是 Berkeley Packet Filter,顧名思義,這是一個(gè)用于過(guò)濾網(wǎng)絡(luò)報(bào)文的架構(gòu)。
其實(shí) BPF 可謂是名氣不大,作用不小的典范:如果筆者一開(kāi)始提出 BPF 的同時(shí)還捎帶上大名鼎鼎的?tcpdump?或?wireshark,估計(jì)絕大部分讀者都會(huì)了然了:BPF 即為?tcpdump?抑或?wireshark?乃至網(wǎng)絡(luò)監(jiān)控領(lǐng)域的基石。
今天我們看到的 BPF 的設(shè)計(jì),最早可以追溯到 1992 年刊行在 USENIX conference 上的一篇論文:The BSD Packet Filter: A New Architecture for User-level Packet Capture。由于最初版本的 BPF 是實(shí)現(xiàn)于 BSD 系統(tǒng)之上的,于是在論文中作者稱(chēng)之為“BSD Packet Filter”;后來(lái)由于 BPF 的理念漸成主流,為各大操作系統(tǒng)所接受,B 所代表的 BSD 便也漸漸淡去,最終演化成了今天我們眼中的 Berkeley Packet Filter。
誠(chéng)然,無(wú)論 BSD 和 Berkeley 如何變換,其后的 Packet Filter 總是不變的,這兩個(gè)單詞也基本概括了 BPF 的兩大核心功能:
- 過(guò)濾
:根據(jù)外界輸入的規(guī)則過(guò)濾報(bào)文;
- 復(fù)制
:將符合條件的報(bào)文由內(nèi)核空間復(fù)制到用戶空間;
以?tcpdump?為例:熟悉網(wǎng)絡(luò)監(jiān)控的讀者大抵都知道?tcpdump?依賴(lài)于 pcap 庫(kù),tcpdump?中的諸多核心功能都經(jīng)由后者實(shí)現(xiàn),其整體工作流程如下圖所示:

圖 1. Tcpdump 工作流程
由圖 1 不難看出,位于內(nèi)核之中的 BPF 模塊是整個(gè)流程之中最核心的一環(huán):它一方面接受?tcpdump?經(jīng)由 libpcap 轉(zhuǎn)碼而來(lái)的濾包條件(Pseudo Machine Language),另一方面也將符合條件的報(bào)文復(fù)制到用戶空間最終經(jīng)由 libpcap 發(fā)送給?tcpdump。
讀到這里,估計(jì)有經(jīng)驗(yàn)的讀者已經(jīng)能夠在腦海里大致勾勒出一個(gè) BPF 實(shí)現(xiàn)的大概了,圖 2 引自文獻(xiàn) 1,讀者們可以管窺一下當(dāng)時(shí) BPF 的設(shè)計(jì):

圖 2. BPF Overview
時(shí)至今日,傳統(tǒng) BPF 仍然遵循圖 2 的路數(shù):途經(jīng)網(wǎng)卡驅(qū)動(dòng)層的報(bào)文在上報(bào)給協(xié)議棧的同時(shí)會(huì)多出一路來(lái)傳送給 BPF,再經(jīng)后者過(guò)濾后最終拷貝給用戶態(tài)的應(yīng)用。除開(kāi)本文提及的?tcpdump,當(dāng)時(shí)的 RARP 協(xié)議也可以利用 BPF 工作(Linux 2.2 起,內(nèi)核開(kāi)始提供 RARP 功能,因此如今的 RARP 已經(jīng)不再需要 BPF 了)。
整體來(lái)說(shuō),BPF 的架構(gòu)還是相對(duì)淺顯易懂的,不過(guò)要是深入細(xì)節(jié)的話就沒(méi)那么容易了:因?yàn)槠渲械?filter 的設(shè)計(jì)(也是文獻(xiàn) 1 中著墨最多的地方)要復(fù)雜那么一點(diǎn)點(diǎn)。
Pseudo Machine Language
估計(jì)在閱讀本文之前,相當(dāng)數(shù)量的讀者都會(huì)誤以為所謂的 Filter 是掛在?tcpdump?末尾處的 expression 吧,類(lèi)似于圖 1 中的?tcp and dst port 7070?這樣。但倘若我們?nèi)缦挛倪@樣在?tcpdump?的調(diào)用中加入一個(gè)?-d,還會(huì)發(fā)現(xiàn)其中大有乾坤:
清單 1 tcpdump -d
#以下代碼可以在任意支持?tcpdump?的類(lèi)?Unix?平臺(tái)上運(yùn)行,輸出大同小異???
bash-3.2$?sudo?tcpdump?-d?-i?lo?tcp?and?dst?port?7070
(000)?ldh?[12]
(001)?jeq?#0x86dd?jt?2?jf?6?#檢測(cè)是否為?ipv6?報(bào)文,若為假(jf)則按照?ipv4?報(bào)文處理(L006)
(002)?ldb?[20]
(003)?jeq?#0x6?jt?4?jf?15?#檢測(cè)是否為?tcp?報(bào)文
(004)?ldh?[56]
(005)?jeq?#0x1b9e?jt?14?jf?15?#檢測(cè)是否目標(biāo)端口為?7070(0x1b9e),若為真(jt)則跳轉(zhuǎn)?L014
(006)?jeq?#0x800?jt?7?jf?15?#檢測(cè)是否為?ipv4?報(bào)文
(007)?ldb?[23]
(008)?jeq?#0x6?jt?9?jf?15?#檢測(cè)是否為?tcp?報(bào)文
(009)?ldh?[20]
(010)?jset?#0x1fff?jt?15?jf?11?#檢測(cè)是否為?ip?分片(IP?fragmentation)報(bào)文
(011)?ldxb?4*([14]&0xf)
(012)?ldh?[x?+?16]?#找到?tcp?報(bào)文中?dest?port?的所在位置
(013)?jeq?#0x1b9e?jt?14?jf?15?#檢測(cè)是否目標(biāo)端口為?7070(0x1b9e),若為真(jt)則跳轉(zhuǎn)?L014
(014)?ret?#262144?#該報(bào)文符合要求
(015)?ret?#0?#該報(bào)文不符合要求
根據(jù) man page,tcpdump?的?-d?會(huì)將輸入的 expression 轉(zhuǎn)義為一段“human readable”的“compiled packet-matching code”。當(dāng)然,如清單 1 中的內(nèi)容,對(duì)于很多道行不深的讀者來(lái)說(shuō),基本是“human unreadable”的,于是筆者專(zhuān)門(mén)加入了一些注釋加以解釋?zhuān)窍噍^于?-dd?和?-ddd?反人類(lèi)的輸出,這確可以稱(chēng)得上是“一目了然”的代碼了。
這段看起來(lái)類(lèi)似于匯編的代碼,便是 BPF 用于定義 Filter 的偽代碼,亦即圖 1 中 libpcap 和內(nèi)核交互的 pseudo machine language(也有一種說(shuō)法是,BPF 偽代碼設(shè)計(jì)之初參考過(guò)當(dāng)時(shí)大行其道的 RISC 令集的設(shè)計(jì)理念),當(dāng) BPF 工作時(shí),每一個(gè)進(jìn)出網(wǎng)卡的報(bào)文都會(huì)被這一段代碼過(guò)濾一遍,其中符合條件的(ret #262144)會(huì)被復(fù)制到用戶空間,其余的(ret #0)則會(huì)被丟棄。
BPF 采用的報(bào)文過(guò)濾設(shè)計(jì)的全稱(chēng)是 CFG(Computation Flow Graph),顧名思義是將過(guò)濾器構(gòu)筑于一套基于 if-else 的控制流之上,例如清單 1 中的 filter 就可以用圖 3 來(lái)表示:

圖 3 基于 CFG 實(shí)現(xiàn)的 filter 范例
CFG 模型最大的優(yōu)勢(shì)是快,參考文獻(xiàn) 1 中就比較了 CFG 模型和基于樹(shù)型結(jié)構(gòu)構(gòu)建出的 CSPF 模型的優(yōu)劣,得出了基于 CFG 模型需要的運(yùn)算量更小的結(jié)論;但從另一個(gè)角度來(lái)說(shuō),基于偽代碼的設(shè)計(jì)卻也增加了系統(tǒng)的復(fù)雜性:一方面?zhèn)沃噶罴呀?jīng)足夠讓人眼花繚亂的了;另一方面為了執(zhí)行偽代碼,內(nèi)核中還需要專(zhuān)門(mén)實(shí)現(xiàn)一個(gè)虛擬機(jī),這也在一定程度上提高了開(kāi)發(fā)和維護(hù)的門(mén)檻。
當(dāng)然,或許是為了提升系統(tǒng)的易用性,一方面 BPF 設(shè)計(jì)者們又額外在?tcpdump?中設(shè)計(jì)了我們今天常見(jiàn)的過(guò)濾表達(dá)式(實(shí)際實(shí)現(xiàn)于 libpcap,當(dāng)然兩者也都源于 Lawrence Berkeley Lab),令過(guò)濾器真正意義上“Human Readable”了起來(lái);另一方面,由于設(shè)計(jì)目標(biāo)只是過(guò)濾字節(jié)流形式的報(bào)文,虛擬機(jī)及其偽指令集的設(shè)計(jì)相對(duì)會(huì)簡(jiǎn)單不少:整個(gè)虛擬機(jī)只實(shí)現(xiàn)了兩個(gè) 32 位的寄存器,分別是用于運(yùn)算的累加器 A 和通用寄存器 X;且指令集也只有寥寥 20 來(lái)個(gè),如表 1 所示:


易用性方面的提升很大程度上彌補(bǔ)了 BPF 本身的復(fù)雜度帶來(lái)的缺憾,很大程度上推動(dòng)了 BPF 的發(fā)展,此后數(shù)年,BPF 逐漸稱(chēng)為大眾所認(rèn)同,包括 Linux 在內(nèi)的眾多操作系統(tǒng)都開(kāi)始將 BPF 引入了內(nèi)核。
鑒于 Linux 上 BPF 如火如荼的大好形勢(shì),本文余下的部分筆者將基于 Linux 上的 BPF 實(shí)現(xiàn)進(jìn)行展開(kāi)。
LSF: Linux 下的 BPF 實(shí)現(xiàn)
BPF 是在 1997 年首次被引入 Linux 的,當(dāng)時(shí)的內(nèi)核版本尚為 2.1.75。準(zhǔn)確的說(shuō),Linux 內(nèi)核中的報(bào)文過(guò)濾機(jī)制其實(shí)是有自己的名字的:Linux Socket Filter,簡(jiǎn)稱(chēng) LSF。但也許是因?yàn)?BPF 名聲太大了吧,連內(nèi)核文檔都不大買(mǎi)這個(gè)帳,直言 LSF 其實(shí)就是?BPF。
當(dāng)然,LSF 和 BPF 除了名字上的差異以外,還是有些不同的,首當(dāng)其沖的分歧就是接口:傳統(tǒng)的 BSD 開(kāi)啟 BPF 的方式主要是靠打開(kāi)?/dev/bpfX?設(shè)備,之后利用 ioctl 來(lái)進(jìn)行控制;而 linux 則選擇了利用套接字選項(xiàng)?SO_ATTACH_FILTER/SO_DETACH_FILTER?來(lái)執(zhí)行系統(tǒng)調(diào)用,篇幅所限,這部分內(nèi)容筆者就不深入了,有興趣的讀者可以通過(guò)移步 socket 的 manual page 或內(nèi)核 filter 文檔深入了解。這里筆者只給出一個(gè)例子來(lái)讓讀者們對(duì) Linux 下的 BPF 的開(kāi)發(fā)有一個(gè)直觀的感受:
清單 2 BPF Sample
#include?<……>
//?tcpdump?-dd?生成出的偽代碼塊
//?instruction?format:
//?opcode:?16bits;?jt:?8bits;?jf:?8bits;?k:?32bits
static?struct?sock_filter?code[]?=?{
????{?0x28,?0,?0,?0x0000000c?},?//?(000)?ldh?[12]
????{?0x15,?0,?4,?0x000086dd?},?//?(001)?jeq?#0x86dd?jt?2?jf?6
????{?0x30,?0,?0,?0x00000014?},?//?(002)?ldb?[20]
????{?0x15,?0,?11,?0x00000006?},?//?(003)?jeq?#0x6?jt?4?jf?15
????{?0x28,?0,?0,?0x00000038?},?//?(004)?ldh?[56]
????{?0x15,?8,?9,?0x00000438?},?//?(005)?jeq?#0x438?jt?14?jf?15
????{?0x15,?0,?8,?0x00000800?},?//?(006)?jeq?#0x800?jt?7?jf?15
????{?0x30,?0,?0,?0x00000017?},?//?(007)?ldb?[23]
????{?0x15,?0,?6,?0x00000006?},?//?(008)?jeq?#0x6?jt?9?jf?15
????{?0x28,?0,?0,?0x00000014?},?//?(009)?ldh?[20]
????{?0x45,?4,?0,?0x00001fff?},?//?(010)?jset?#0x1fff?jt?15?jf?11
????{?0xb1,?0,?0,?0x0000000e?},?//?(011)?ldxb?4*([14]&0xf)
????{?0x48,?0,?0,?0x00000010?},?//?(012)?ldh?[x?+?16]
????{?0x15,?0,?1,?0x00000438?},?//?(013)?jeq?#0x438?jt?14?jf?15
????{?0x6,?0,?0,?0x00040000?},?//?(014)?ret?#262144
????{?0x6,?0,?0,?0x00000000?},?//?(015)?ret?#0
};
int?main(int?argc,?char?**argv)
{
????//?……
????struct?sock_fprog?bpf?=?{?sizeof(code)/sizeof(struct?sock_filter),?code?};
????//?……
????//?1.?創(chuàng)建?raw?socket
????s?=?socket(AF_PACKET,?SOCK_RAW,?htons(ETH_P_ALL));
????//?……
????//?2.?將?socket?綁定給指定的?ethernet?dev
????name?=?argv[1];?//?ethernet?dev?由?arg?1?傳入
????memset(&addr,?0,?sizeof(addr));
????addr.sll_ifindex?=?if_nametoindex(name);
????//?……
????if?(bind(s,?(struct?sockaddr?*)&addr,?sizeof(addr)))?{
????????//?……
????}
????//?3.?利用?SO_ATTACH_FILTER?將?bpf?代碼塊傳入內(nèi)核
????if?(setsockopt(s,?SOL_SOCKET,?SO_ATTACH_FILTER,?&bpf,?sizeof(bpf)))?{
????????//?……
????}
????for?(;?;)?{
????????bytes?=?recv(s,?buf,?sizeof(buf),?0);?//?4.?利用?recv()獲取符合條件的報(bào)文
????????//?……
????????ip_header?=?(struct?iphdr?*)(buf?+?sizeof(struct?ether_header));
????????inet_ntop(AF_INET,?&ip_header->saddr,?src_addr_str,?sizeof(src_addr_str));
????????inet_ntop(AF_INET,?&ip_header->daddr,?dst_addr_str,?sizeof(dst_addr_str));
????????printf("IPv%d?proto=%d?src=%s?dst=%s\n",
????????ip_header->version,?ip_header->protocol,?src_addr_str,?dst_addr_str);
????}
????return?0;
}
由于主要是和過(guò)濾報(bào)文打交道,內(nèi)核中( 3.18 之前)的 BPF 的絕大部分實(shí)現(xiàn)都被放在了 net/core/filter.c 下,篇幅原因筆者就不對(duì)代碼進(jìn)行詳述了,文件不長(zhǎng),600 來(lái)行(v2.6),比較淺顯易懂,有興趣的讀者可以移步品評(píng)一下。值得留意的函數(shù)有兩個(gè),sk_attach_filter()?和?sk_run_filter():前者將 filter 偽代碼由用戶空間復(fù)制進(jìn)內(nèi)核空間;后者則負(fù)責(zé)在報(bào)文到來(lái)時(shí)執(zhí)行偽碼解析。篇幅所限,清單 2 中筆者只列出了部分代碼,代碼分析也以注釋為主。有興趣的讀者可以移步這里閱讀完全版。
演進(jìn):JIT For BPF
BPF 被引入 Linux 之后,除了一些小的性能方面的調(diào)整意外,很長(zhǎng)一段時(shí)間都沒(méi)有什么動(dòng)靜。直到 3.0 才首次迎來(lái)了比較大的革新:在一些特定硬件平臺(tái)上,BPF 開(kāi)始有了用于提速的 JIT (Just-In-Time) Compiler。
最先實(shí)現(xiàn) JIT 的是 x86 平臺(tái),其后包括 arm、ppc、S390、mips 等一眾平臺(tái)紛紛跟進(jìn),到今天 Linux 的主流平臺(tái)中支持 JIT For BPF 的已經(jīng)占了絕大多數(shù)了。
BPF JIT 的接口還是簡(jiǎn)單清晰的:各平臺(tái)的 JIT 編譯函數(shù)都實(shí)現(xiàn)于 bpf_jit_compile()?之中(3.16 之后,開(kāi)始逐步改為bpf_int_jit_compile()),如果 CONFIG_BPF_JIT 被打開(kāi),則傳入的 BPF 偽代碼就會(huì)被傳入該函數(shù)加以編譯,編譯結(jié)果被拿來(lái)替換掉默認(rèn)的處理函數(shù)?sk_run_filter()。JIT 的實(shí)現(xiàn)不在本文討論之列,其代碼基本位于 arch/
打開(kāi) BPF 的 JIT 很簡(jiǎn)單,只要向?/proc/sys/net/core/bpf_jit_enable?寫(xiě)入?1?即可;對(duì)于有調(diào)試需求的開(kāi)發(fā)者而言,如果寫(xiě)入?2?的話,還可以在內(nèi)核 log 中看到載入 BPF 代碼時(shí)候 JIT 生成的優(yōu)化代碼,內(nèi)核開(kāi)發(fā)者們還提供了一個(gè)更加方便的工具 bpf_jit_disam,可以將內(nèi)核 log 中的二進(jìn)制轉(zhuǎn)換為匯編以便閱讀。
JIT Compiler 之后,針對(duì) BPF 的小改進(jìn)不斷:如將 BPF 引入 seccomp(3.4);添加一些 debug 工具如 bpf_asm 和 bpf_dbg(3.14)。不過(guò)比較革命性的大動(dòng)作就要等到 3.17 了,這次的改進(jìn)被稱(chēng)為 extended BPF,即 eBPF。
進(jìn)化:extended BPF
自 3.15 伊始,一個(gè)套源于 BPF 的全新設(shè)計(jì)開(kāi)始逐漸進(jìn)入人們的視野,并最終(3.17)被添置到了 kernel/bpf 下。這一全新設(shè)計(jì)最終被命名為了 extended BPF(eBPF):顧名思義,有全面擴(kuò)充既有 BPF 功能之意;而相對(duì)應(yīng)的,為了后向兼容,傳統(tǒng)的 BPF 仍被保留了下來(lái),并被重命名為 classical BPF(cBPF)。
相對(duì)于 cBPF,eBPF 帶來(lái)的改變可謂是革命性的:一方面,它已經(jīng)為內(nèi)核追蹤、應(yīng)用性能調(diào)優(yōu)/監(jiān)控、流控等領(lǐng)域帶來(lái)了激動(dòng)人心的變革;另一方面,在接口的設(shè)計(jì)以及易用性上,eBPF 也有了較大的改進(jìn)。
Linux 內(nèi)核代碼的 samples 目錄下有大量前人貢獻(xiàn)的 eBPF sample,這里筆者先挑選其中相對(duì)簡(jiǎn)單的 sockex1 來(lái)幫助讀者們建立一個(gè) eBPF 的初步印象:
清單 3 sockex1_user.c
#include?<…>
//?篇幅所限,清單?3?和?4?都只羅列出部分關(guān)鍵代碼,有興趣一窺全貌的讀者可以移步?http://elixir.free-electrons.com/linux/v4.12.6/source/samples/bpf深入學(xué)習(xí)
int?main(int?ac,?char?**argv)
{
????//?1.?eBPF?的偽代碼位于?sockex1_kern.o?中,這是一個(gè)由?llvm?生成的?elf?格式文件,指令集為?bpf;
????snprintf(filename,?sizeof(filename),?"%s_kern.o",?argv[0]);
????if?(load_bpf_file(filename))?{
????????//?load_bpf_file()定義于?bpf_load.c,利用?libelf?來(lái)解析?sockex1_kern.o
????????//?并利用?bpf_load_program?將解析出的偽代碼?attach?進(jìn)內(nèi)核;
????}
????//?2.?因?yàn)?sockex1_kern.o?中?bpf?程序的類(lèi)型為?BPF_PROG_TYPE_SOCKET_FILTER
????//?所以這里需要用用?SO_ATTACH_BPF?來(lái)指明程序的?sk_filter?要掛載到哪一個(gè)套接字上
????sock?=?open_raw_sock("lo");
????assert(setsockopt(sock,?SOL_SOCKET,?SO_ATTACH_BPF,?prog_fd,
????sizeof(prog_fd[0]))?==?0);
????//……
????for?(i?=?0;?i?5;?i++)?{
????????//?3.?利用?map?機(jī)制獲取經(jīng)由?lo?發(fā)出的?tcp?報(bào)文的總長(zhǎng)度
????????key?=?IPPROTO_TCP;
????????assert(bpf_map_lookup_elem(map_fd[0],?&key,?&tcp_cnt)?==?0);
????????//?……
????}
????return?0;
}
清單 4 sockex1_kern.c
#include?<……>
//?預(yù)先定義好的?map?對(duì)象
//?這里要注意好其實(shí)?map?是需要由用戶空間程序調(diào)用?bpf_create_map()進(jìn)行創(chuàng)建的
//?在這里定義的?map?對(duì)象,實(shí)際上會(huì)在?load_bpf_file()解析?ELF?文件的同時(shí)被解析和創(chuàng)建出來(lái)
//?這里的?SEC(NAME)宏表示在當(dāng)前?obj?文件中新增一個(gè)段(section)
struct?bpf_map_def?SEC("maps")?my_map?=?{
????.type?=?BPF_MAP_TYPE_ARRAY,
????.key_size?=?sizeof(u32),
????.value_size?=?sizeof(long),
????.max_entries?=?256,
};
SEC("socket1")
int?bpf_prog1(struct?__sk_buff?*skb)
{
????//?這個(gè)例子比較簡(jiǎn)單,僅僅是讀取輸入報(bào)文的包頭中的協(xié)議位而已
????//?這里的?load_byte?實(shí)際指向了?llvm?的?built-in?函數(shù)?asm(llvm.bpf.load.byte)
????//?用于生成?eBPF?指令?BPF_LD_ABS?和?BPF_LD_IND
????int?index?=?load_byte(skb,?ETH_HLEN?+?offsetof(struct?iphdr,?protocol));
????long?*value;
????//?……
????//?根據(jù)?key(&index,注意這是一個(gè)指向函數(shù)的引用)獲取對(duì)應(yīng)的?value
????value?=?bpf_map_lookup_elem(&my_map,?&index);
????if?(value)
????????__sync_fetch_and_add(value,?skb->len);?//這里的__sync_fetch_and_add?是?llvm?中的內(nèi)嵌函數(shù),表示?atomic?加操作
????return?0;
}
//?為了滿足?GPL?毒藥的需求,所有會(huì)注入內(nèi)核的?BPF?代碼都須顯式的支持?GPL?協(xié)議
char?_license[]?SEC("license")?=?"GPL";
對(duì)比一下清單 3&4 以及清單 2 的代碼片段,很容易看出一些 eBPF 顯而易見(jiàn)的革新:
用 C 寫(xiě)成的 BPF 代碼(sockex1_kern.o);
基于 map 的內(nèi)核與用戶空間的交互方式;
全新的開(kāi)發(fā)接口;
除此之外,還有一些不那么明顯的改進(jìn)隱藏在內(nèi)核之中:
全新的偽指令集設(shè)計(jì);
In-kernel verifier;
由一個(gè)文件(net/core/filter.c)進(jìn)化到一個(gè)目錄(kernel/bpf),eBPF 的蛻變?nèi)詢烧Z(yǔ)間很難交代清楚,下面筆者就先基于上述的幾點(diǎn)變化來(lái)幫助大家入個(gè)門(mén),至于個(gè)中細(xì)節(jié),就只能靠讀者以后自己修行了。
再見(jiàn)了匯編
利用高級(jí)語(yǔ)言書(shū)寫(xiě) BPF 邏輯并經(jīng)由編譯器生成出偽代碼來(lái)并不是什么新鮮的嘗試,比如 libpcap 就是在代碼中內(nèi)嵌了一個(gè)小型編譯器來(lái)分析 tcpdump 傳入的 filter expression 從而生成 BPF 偽碼的。只不過(guò)長(zhǎng)久以來(lái)該功能一直沒(méi)有能被獨(dú)立出來(lái)或者做大做強(qiáng),究其原因,主要還是由于傳統(tǒng)的 BPF 所轄領(lǐng)域狹窄,過(guò)濾機(jī)制也不甚復(fù)雜,就算是做的出來(lái),估計(jì)也不堪大用。
然而到了 eBPF 的時(shí)代,情況終于發(fā)生了變化:現(xiàn)行的偽指令集較之過(guò)去已經(jīng)復(fù)雜太多,再用純匯編的開(kāi)發(fā)方式已經(jīng)不合時(shí)宜,于是,自然而然的,利用 C 一類(lèi)的高級(jí)語(yǔ)言書(shū)寫(xiě) BPF 偽代碼的呼聲便逐漸高漲了起來(lái)。
目前,支持生成 BPF 偽代碼的編譯器只有 llvm 一家,即使是通篇使用 gcc 編譯的 Linux 內(nèi)核,samples 目錄下的 bpf 范例也要借用 llvm 來(lái)編譯完成。還是以 sockex1 為例,用戶態(tài)下的代碼 sockex_user.c 是利用 HOSTCC 定義的編譯器編譯的;但 sockex_kern.c 就需要用到 clang 和 llvm 了。在 samples/bpf/Makefile中,可以看到:
清單 5 samples/bpf/Makefile
#?......
#?List?of?programs?to?build
hostprogs-y?:=?test_lru_dist
hostprogs-y?+=?sockex1
#?……
sockex1-objs?:=?bpf_load.o?$(LIBBPF)?sockex1_user.o
#?……
#?注意,這里有一個(gè)小?tip,就是如果在內(nèi)核的?Makefile?中,
#?有某一個(gè)目標(biāo)文件你不希望使用內(nèi)核的通用編譯規(guī)則的話(類(lèi)似于本文的?sockex1_kern.o),
#?可以像這里一樣,并不把該文件加入任何?xxxprogs?或?xxx-objs,
#?而是直接放入 always,這樣內(nèi)核就會(huì)在本地 Makefile 中搜索編譯規(guī)則了。
always?:=?$(hostprogs-y)
always?+=?sockex1_kern.o
#?……
LLC??=?llc
CLANG??=?clang
#?……
#?sockex1_kern.o?就是使用了下述規(guī)則編譯為?BPF?代碼的,請(qǐng)注意筆者加粗的部分
$(obj)/%.o:?$(src)/%.c
$(CLANG)?$(NOSTDINC_FLAGS)?$(LINUXINCLUDE)?$(EXTRA_CFLAGS)?\
-D__KERNEL__?-D__ASM_SYSREG_H?-Wno-unused-value?-Wno-pointer-sign?\
-Wno-compare-distinct-pointer-types?\
-Wno-gnu-variable-sized-type-not-at-end?\
-Wno-address-of-packed-member?-Wno-tautological-compare?\
-Wno-unknown-warning-option?\
-O2?-emit-llvm?-c?$<?-o?-|
$(LLC)?-march=bpf?-filetype=obj?-o
$@
能用 C 書(shū)寫(xiě) BPF 自然是便利了許多,但也不代表余下的開(kāi)發(fā)工作就是一片坦途了:首先 llvm 的輸出是 elf 文件,這也意味著想要獲取能傳入內(nèi)核的代碼,我們還需要額外做一段解析 elf 的工作,這也是為什么 Sample 下的范例幾乎無(wú)一例外地都鏈接了 libelf 庫(kù);其次,同時(shí)也是比較重要的一點(diǎn),不要忘記 BPF 的代碼是跑在內(nèi)核空間中的,因此書(shū)寫(xiě)時(shí)必得煞費(fèi)苦心一番才好,以防一個(gè)不小心就做出個(gè)把內(nèi)核干趴下的漏洞來(lái):下文中提及的 verifier 就是為了這一點(diǎn)而生,每一個(gè)被放進(jìn)內(nèi)核的 BPF 代碼,都須要經(jīng)過(guò)它的檢驗(yàn)才行。
BPF 程序的類(lèi)別以及 Map 機(jī)制
清單 3 中我們看到 sockex1_kern.o 是由?load_bpf_file()?函數(shù)載入內(nèi)存的,但實(shí)際上 eBPF 提供用來(lái)將 BPF 代碼載入內(nèi)核的正式接口函數(shù)其實(shí)是?bpf_load_program(),該接口負(fù)責(zé)通過(guò)參數(shù)向內(nèi)核提供三類(lèi)信息:
BPF 程序的類(lèi)型、
BPF 代碼
代碼運(yùn)行時(shí)所需要的存放 log 的緩存地址(位于用戶空間);
有意思的是,目前所有注入內(nèi)核的 BPF 程序都需要附帶 GPL 協(xié)議支持信息,bpf_load_program()?的 license 參數(shù)就是用來(lái)載入?yún)f(xié)議字串的。
由 eBPF 伊始,BPF 程序開(kāi)始有分類(lèi)了,通過(guò)?bpf_load_program()?的參數(shù)?bpf_prog_type,我們可以看到 eBPF 支持的程序類(lèi)型。這里筆者將一些常用的類(lèi)型羅列于下表之中供讀者參考:
表 2 常見(jiàn) bpf_prog_type 定義

深入對(duì)比清單 3(eBPF)和清單 2(cBPF)的實(shí)現(xiàn)的差異,還會(huì)發(fā)現(xiàn)一個(gè)比較明顯的不同之處:BPF 代碼進(jìn)內(nèi)核之后,cBPF 和內(nèi)核通訊的方式是?recv();而 eBPF 則將 socket 丟到一邊,使用一種名為 map 的全新機(jī)制和內(nèi)核通訊,其大致原理下圖所示:

圖 4 eBPF 的 map 機(jī)制
從圖上看,這套設(shè)計(jì)本身不復(fù)雜:位于用戶空間中的應(yīng)用在內(nèi)核中辟出一塊空間建立起一個(gè)數(shù)據(jù)庫(kù)用以和 eBPF 程序交互(bpf_create_map());數(shù)據(jù)庫(kù)本身以 Key-Value 的形式進(jìn)行組織,無(wú)論是從用戶空間還是內(nèi)核空間都可以對(duì)其進(jìn)行訪問(wèn),兩邊有著相似的接口,最終在邏輯上也都殊途同歸。
不難發(fā)現(xiàn),map 帶來(lái)的最大優(yōu)勢(shì)是效率:相對(duì)于 cBPF 一言不合就把一個(gè)通信報(bào)文從內(nèi)核空間丟出來(lái)的豪放,map 機(jī)制下的通訊耗費(fèi)就要小家碧玉的多了:還是以 sockex1 為例,一次通信從內(nèi)核中僅僅復(fù)制 4 個(gè)字節(jié),而且還是已經(jīng)處理好了可以直接拿來(lái)就用的,做過(guò)內(nèi)核開(kāi)發(fā)的人都知道這對(duì)于性能意味著什么。
map 機(jī)制解決的另一個(gè)問(wèn)題是通信數(shù)據(jù)的多樣性問(wèn)題。cBPF 所覆蓋的功能范圍很簡(jiǎn)單,無(wú)外乎是網(wǎng)絡(luò)監(jiān)控和 seccomp 兩塊,數(shù)據(jù)接口設(shè)計(jì)的粗放一點(diǎn)也就算了;而 eBPF 的利用范圍則要廣的多,性能調(diào)優(yōu)、內(nèi)核監(jiān)控、流量控制什么的應(yīng)有盡有,數(shù)據(jù)接口的多樣性設(shè)計(jì)就顯得很必要了。下表中就列出了現(xiàn)有 eBPF 中的 map 機(jī)制中常見(jiàn)的數(shù)據(jù)類(lèi)型:
表 3. map 機(jī)制下的常見(jiàn)數(shù)據(jù)類(lèi)型

新的指令集
eBPF 對(duì)于既有 cBPF 令集的改動(dòng)量之大,以至于基本上不能認(rèn)為兩者還是同一種語(yǔ)言了。個(gè)中變化,我們可以通過(guò)反匯編清單 4 的源代碼(llvm-objdump --disassemble)略知一二:
清單 6 Disassemble of sockex1_kern.o
sockex1_kern.o:?file?format?ELF64-BPF
Disassembly?of?section?socket1:
bpf_prog1:
0:?bf?16?00?00?00?00?00?00?r6?=?r1
1:?30?00?00?00?17?00?00?00?r0?=?*(u8?*)skb[23]
2:?63?0a?fc?ff?00?00?00?00?*(u32?*)(r10?-?4)?=?r0
3:?61?61?04?00?00?00?00?00?r1?=?*(u32?*)(r6?+?4)
4:?55?01?08?00?04?00?00?00?if?r1?!=?4?goto?8
5:?bf?a2?00?00?00?00?00?00?r2?=?r10
6:?07?02?00?00?fc?ff?ff?ff?r2?+=?-4
7:?18?01?00?00?00?00?00?00?00?00?00?00?00?00?00?00?r1?=?0ll
9:?85?00?00?00?01?00?00?00?call?1
10:?15?00?02?00?00?00?00?00?if?r0?==?0?goto?2
11:?61?61?00?00?00?00?00?00?r1?=?*(u32?*)(r6?+?0)
12:?db?10?00?00?00?00?00?00?lock?*(u64?*)(r0?+?0)?+=?r1
LBB0_3:
13:?b7?00?00?00?00?00?00?00?r0?=?0
14:?95?00?00?00?00?00?00?00?exit
我們不用管這段匯編寫(xiě)了點(diǎn)兒什么,先跟清單 2 開(kāi)頭的那段 cBPF 代碼對(duì)比一下兩者的異同:
寄存器:eBPF 支持更多的寄存器;
cBPF:A, X + stack, 32bit;
eBPF:R1~R10 + stack, 64bit,顯然,如此的設(shè)計(jì)主要針對(duì)現(xiàn)在大行其道的 64 位硬件,同時(shí)更多的寄存器設(shè)計(jì)也便于運(yùn)行時(shí)和真實(shí)環(huán)境下的寄存器進(jìn)行對(duì)應(yīng),以提高效率;
opcode:兩者的格式不同;
cBPF: op 16b, jt 8b, jf 8b, K 32b;
eBPF: op 8b, dstReg 4b, srcReg 4b, off 16b, imm 32b;
其他:sockex1_kern.o 設(shè)計(jì)的比較簡(jiǎn)單,但還是可以從中看出 eBPF 的一大改進(jìn):可以調(diào)用內(nèi)核中預(yù)設(shè)好的函數(shù)(Call 1,這里指向的函數(shù)是?
bpf_map_lookup_elem(),如果需要比較全的預(yù)設(shè)函數(shù)索引的話可以移步這里)。除此之外,eBPF 命令集中比較重要的新晉功能還有:cBPF:僅可以讀 packet(即 skb)以及讀寫(xiě) stack;
eBPF:可以讀寫(xiě)包括 stack/map/context,也即 BPF prog 的傳入?yún)?shù)可讀寫(xiě)。換句話說(shuō),任意傳入 BPF 代碼的數(shù)據(jù)流均可以被修改;
load/store 多樣化:
除開(kāi)預(yù)設(shè)函數(shù)外,開(kāi)發(fā)者還可以自定義 BPF 函數(shù)(JUMP_TAIL_CALL);
除了前向跳轉(zhuǎn)外(cBPF 支持),還可以后向跳轉(zhuǎn);
至于 eBPF 具體的指令表,因?yàn)檫^(guò)于龐雜這里筆者就不作文抄公了。不過(guò) eBPF 中的幾個(gè)寄存器的利用規(guī)則這里還是可以有的,否則要讀懂清單 6 中的代碼略有困難:
R0:一般用來(lái)表示函數(shù)返回值,包括整個(gè) BPF 代碼塊(其實(shí)也可被看做一個(gè)函數(shù))的返回值;
R1~R5:一般用于表示內(nèi)核預(yù)設(shè)函數(shù)的參數(shù);
R6~R9:在 BPF 代碼中可以作存儲(chǔ)用,其值不受內(nèi)核預(yù)設(shè)函數(shù)影響;
R10:只讀,用作棧指針(SP);
In-kernel Verifier
其實(shí)結(jié)合前面那么多的內(nèi)容看下來(lái)不難發(fā)現(xiàn) eBPF 其實(shí)近似于一種改頭換面后的內(nèi)核模塊,只不過(guò)它比內(nèi)核模塊更短小精干,實(shí)現(xiàn)的功能也更新穎一些罷了,但無(wú)論是什么樣的架構(gòu),只要存在注入的代碼就會(huì)有安全隱患,eBPF 也不外如是——畢竟注入的代碼是要在內(nèi)核中運(yùn)行的。
為了最大限度的控制這些隱患,cBPF 時(shí)代就開(kāi)始加入了代碼檢查機(jī)制以防止不規(guī)范的注入代碼;到了 eBPF 時(shí)代則在載入程序(bpf_load_program())時(shí)加入了更復(fù)雜的verifier 機(jī)制,在運(yùn)行注入程序之前,先進(jìn)行一系列的安全檢查,最大限度的保證系統(tǒng)的安全。具體來(lái)說(shuō),verifier 機(jī)制會(huì)對(duì)注入的程序做兩輪檢查:
首輪檢查:
實(shí)現(xiàn)于?check_cfg()可以被認(rèn)為是一次深度優(yōu)先搜索,主要目的是對(duì)注入代碼進(jìn)行一次 DAG(有向無(wú)環(huán)圖)檢測(cè),以保證其中沒(méi)有循環(huán)存在;除此之外,一旦在代碼中發(fā)現(xiàn)以下特征,verifier 也會(huì)拒絕注入:代碼長(zhǎng)度超過(guò)上限,目前(內(nèi)核版本 4.12)eBPF 的代碼長(zhǎng)度上限為 4K 條指令——這在 cBPF 時(shí)代很難達(dá)到,但別忘了 eBPF 代碼是可以用 C 實(shí)現(xiàn)的;
存在可能會(huì)跳出 eBPF 代碼范圍的 JMP,這主要是為了防止惡意代碼故意讓程序跑飛;
存在永遠(yuǎn)無(wú)法運(yùn)行的 eBPF 令,例如位于 exit 之后的指令;
- 次輪檢查:
實(shí)現(xiàn)于?do_check()?較之于首輪則要細(xì)致很多:在本輪檢測(cè)中注入代碼的所有邏輯分支從頭到尾都會(huì)被完全跑上一遍,所有的指令的參數(shù)(寄存器)、訪問(wèn)的內(nèi)存、調(diào)用的函數(shù)都會(huì)被仔細(xì)的捋一遍,任何的錯(cuò)誤都會(huì)導(dǎo)致注入程序被退貨。由于過(guò)分細(xì)致,本輪檢查對(duì)于注入程序的復(fù)雜度也有所限制:首先程序中的分支不允許超過(guò) 1024 個(gè);其次經(jīng)檢測(cè)的指令數(shù)也必須在 96K 以內(nèi)。
Overview: eBPF 的架構(gòu)
誠(chéng)然,eBPF 設(shè)計(jì)的復(fù)雜程度已是超越 cBPF 太多太多,筆者羅里吧嗦了大半天,其實(shí)也就是將將領(lǐng)著大家入門(mén)的程度而已,為了便于讀者們能夠把前文所述的碎片知識(shí)串到一起,這里筆者將 eBPF 的大體架構(gòu)草繪一番,如下圖所示,希望能幫助大家對(duì) eBPF 構(gòu)建一個(gè)整體的認(rèn)識(shí)。

圖 5. Architecture of eBPF
追求極簡(jiǎn):BPF Compiler Collection(BCC)
現(xiàn)在讓我們將目光聚焦到 eBPF 的使用——相信這是大部分讀者最感興趣的部分,畢竟絕大多數(shù)人其實(shí)并沒(méi)有多少機(jī)會(huì)參與 eBPF 的開(kāi)發(fā)——重新回到清單 3&4 中的 sockex1:說(shuō)句良心話,雖然現(xiàn)在可以用 C 來(lái)實(shí)現(xiàn) BPF,但編譯出來(lái)的卻仍然是 ELF 文件,開(kāi)發(fā)者需要手動(dòng)析出真正可以注入內(nèi)核的代碼。這部分工作多少有些麻煩,如果可以有一個(gè)通用的方案一步到位的生成出 BPF 代碼就好了,開(kāi)發(fā)者的注意力應(yīng)該放在其他更有價(jià)值的地方,不是嗎?
于是就有人設(shè)計(jì)了 BPF Compiler Collection(BCC),BCC 是一個(gè) python 庫(kù),但是其中有很大一部分的實(shí)現(xiàn)是基于 C 和 C++的,python 只不過(guò)實(shí)現(xiàn)了對(duì) BCC 應(yīng)用層接口的封裝而已。
使用 BCC 進(jìn)行 BPF 的開(kāi)發(fā)仍然需要開(kāi)發(fā)者自行利用 C 來(lái)設(shè)計(jì) BPF 程序——但也僅此而已,余下的工作,包括編譯、解析 ELF、加載 BPF 代碼塊以及創(chuàng)建 map 等等基本可以由 BCC 一力承擔(dān),無(wú)需多勞開(kāi)發(fā)者費(fèi)心。
限于篇幅關(guān)于 BCC 筆者不再過(guò)多展開(kāi),文章的最后筆者再給出一個(gè)基于 BCC 實(shí)現(xiàn)的 sockex1 的例子,讀者可以感受一下使用 BCC 帶給開(kāi)發(fā)者們的便利性:
清單 7 A sample of BCC
from?bcc?import?BPF
#?和清單?2?一樣,篇幅所限,這里只貼一部分源碼,完全版請(qǐng)移步?https://raw.githubusercontent.com/windywolf/example/master/eBPF/bccsample.py
interface="ens160"
#?BCC?可以接受直接將?BPF?代碼嵌入?python?code?之中
#?為了方便展示筆者使用了這一功能
#?注意:prog?中的中文注釋是由于筆者需要寫(xiě)作之故加入,如果讀者想嘗試運(yùn)行這段代碼,
#?則請(qǐng)將中文全部刪除,因?yàn)槟壳?BCC?還不支持在內(nèi)嵌?C?代碼中使用中文注釋
prog?=?"""
#include?
#include?
//?BCC?中專(zhuān)門(mén)為?map?定義了一系列的宏,以方便使用
//?宏中的?struct?下還定義了相應(yīng)的函數(shù),讓開(kāi)發(fā)者可以如?C++一般操作?map
//?這里筆者定義了一個(gè)?array?類(lèi)型的?map,名為?my_map1
BPF_ARRAY(my_map1,?long);
//?BCC?下的?BPF?程序中不再需要定義把函數(shù)或變量專(zhuān)門(mén)放置于某個(gè)?section?下了
int?bpf_prog1(struct?__sk_buff?*skb)
{
????//?……
????struct?ethernet_t?*eth?=?cursor_advance(cursor,?sizeof(*eth));
????//?……
????struct?ip_t?*ip?=?cursor_advance(cursor,?sizeof(*ip));
????int?index?=?ip->nextp;
????long?zero?=?0;?//?BCC?下的?bpf?書(shū)寫(xiě)還是有很多坑的
????//?例如,這里如果不去定義一個(gè)局部變量?zero,
????//?而是直接用常量?0?作為?lookup_or_init()的變量就會(huì)報(bào)錯(cuò)
????//?map?類(lèi)下的各個(gè)方法的具體細(xì)節(jié)可以參照?reference_guide.md
????value?=?my_map1.lookup_or_init(&index,?&zero);
????if?(value)
????????__sync_fetch_and_add(value,?skb->len);
????return?0;
}
"""
bpf?=?BPF(text=prog,?debug?=?0)
#?注入?bpf_prog1?函數(shù)
function?=?bpf.load_func("bpf_prog1",?BPF.SOCKET_FILTER)
#?這是一段?SOCKET_FILTER?類(lèi)型的?BPF,所以需要掛載到某一個(gè)?interface?上
BPF.attach_raw_socket(function,?interface)
#?利用?map?機(jī)制獲取進(jìn)出?interface?的各個(gè)協(xié)議的報(bào)文總長(zhǎng)
bpf_map?=?bpf["my_map1"]
while?1:
????print?("TCP?:?{},?UDP?:?{},?ICMP:?{}".format(
bpf_map[socket.IPPROTO_TCP].value,
#?…
結(jié)束語(yǔ)
本文從 BPF 的源頭開(kāi)始,一路講到了近年來(lái)剛剛殺青的 eBPF,雖說(shuō)拘泥于篇幅,大多內(nèi)容只能蜻蜓點(diǎn)水、淺嘗輒止,但文中 BPF 的原理、設(shè)計(jì)、實(shí)現(xiàn)和應(yīng)用均有所涉獵,勉強(qiáng)也能拿來(lái)入個(gè)門(mén)了。加之近年來(lái)基于 eBPF 的應(yīng)用層出不窮,希望本文能激發(fā)讀者們的奇思妙想,從而設(shè)計(jì)出更多基于 BPF 的優(yōu)秀應(yīng)用來(lái)。
原文鏈接:https://linux.cn/article-9032-1.html
