BPF的可移植性和CO-RE (Compile Once – Run Everywhere)

charlieroro發表於2020-12-30

BPF的可移植性和CO-RE (Compile Once – Run Everywhere)

在上一篇文章中介紹了提高socket效能的幾個socket選項,其中給出了幾個源於核心原始碼樹中的例子,如果選擇使用核心樹中的Makefile進行編譯的話,可能會出現與本地標頭檔案衝突的情況,如重複定義變數,結構體型別不對等錯誤。這些問題大大影響了BPF程式的可移植性。

本文將介紹BPF可移植性存在的問題,以及如何使用BPF CO-RE(Compile Once – Run Everywhere)解決這些問題。

BPF:最前沿的技術

自BPF成立以來,BPF社群將盡可能簡化BPF應用程式的開發作為工作重點,目的是將BPF的使用變得與使用者空間的應用一樣簡單明瞭。伴隨著BPF可程式設計性的穩步發展,BPF程式的開發也越來越簡單。

儘管BPF提升了使用上的便利性,但卻忽略了BPF程式開發中的一個方面:可移植性。"BPF可移植性"意味著什麼?我們將BPF可移植性定義為成功編寫並通過核心驗證的一個BPF程式,且跨核心版本可用,無需針對特定的核心重新編譯。

本文描述了BPF的可移植性問題以及解決方案:BPF CO-RE(Compile Once – Run Everywhere)。首先會調研BPF本身的可移植性問題,描述為什麼這是個問題,以及為什麼解決它很重要。然後,我們將介紹解決方案中的高階元件:BPF CO-RE,並簡要介紹實現這一目標所需要解決的難題。最後,我們將以各種教程作為結尾,介紹BPF CO-RE方法的使用者API,並提供相關示例。

BPF可移植性的問題

BPF程式是使用者提供的一部分程式碼,這些程式碼會直接注入到核心,一旦經過載入和驗證,BPF程式就可以在核心上下文中執行。這些程式執行在核心的記憶體空間中,並能夠訪問所有可用的核心內部狀態,這種功能非常強大,這也是為什麼BPF技術成功落地到多個應用中的原因。然而,在使用其強大的能力的同時也帶來了一些負擔:BPF程式無法控制周圍核心環境的記憶體佈局,因此必須依賴獨立的開發,編譯和部署的核心。

此外,核心型別和資料結構會不斷變化。不同的核心版本會在結構體內部混用結構體欄位,甚至會轉移到新的內部結構體中。結構體中的欄位可能會被重新命名或刪除,型別可能會改變(變為微相容或完全不同的型別)。結構體和其他型別可以被重新命名,被條件編譯(取決於核心配置),或直接從核心版本中移除。

換句話講,不同核心釋出版本中的所有內容都有可能發生變化,BPF應用開發者應該能夠預料到這個問題。考慮到不斷變化的核心環境,那麼該如何利用BPF做有用的事?有如下幾點原因:

首先,並不是所有的BPF程式都需要訪問內部的核心資料結構。一個例子是opensnoop工具,該工具依靠kprobes /tracepoints來跟蹤哪個程式開啟了哪些檔案,僅需要捕獲少量的系統呼叫就可以工作。由於系統呼叫提供了穩定的ABI,不會隨著核心版本而變化,因此不用考慮這類BPF程式的可移植性。不幸的是,這類應用非常少,且這類應用的功能也大大受限。

此外,核心內部的BPF機器提供了有限的“穩定介面”集,BPF程式可以依靠這些穩定介面在核心間保持穩定。事實上,不同版本的核心的底層結構和機制是會發生變化的,但BPF提供的穩定介面從使用者程式中抽象了這些細節。

例如,網路應用會通過檢視少量的sk_buff(即報文資料)中的屬性來獲得非常有用且通用的資訊。為此,BPF校驗器提供了一個穩定的__sk_buff 檢視(注意前面的下劃線),該檢視為BPF程式遮蔽了struct sk_buff結構體的變更。所有對__sk_buff欄位訪問都可以透明地重寫為對實際sk_buff的訪問(有時非常複雜-在獲取最終請求的欄位之前需要追蹤一堆內部指標)。類似的機制同樣適用於不同的BPF程式型別,通過BPF校驗器來識別特定型別的BPF上下文。如果使用這類上下文開發BPF程式,就可以不用擔心可移植性問題。

但有時候需要訪問原始的核心資料(如經常會訪問到的 struct task_struct,表示一個程式或執行緒,包含大量程式資訊),此時就只能靠自己了。跟蹤,監視和分析應用程式通常是這種情況,這些應用程式是一類非常有用的BPF程式。

在這種情況下,如果某些核心在需要採集的欄位(如從struct task_struct開始的第16個位元組的偏移處)前新增了一個新的欄位,那麼此時如何保證不會讀取到垃圾資料?如果一個欄位重新命名了又如何處理(如核心4.6和4.7的thread_struct的fs欄位的名稱是不同的)?或者如果需要基於一個核心的兩種配置來執行程式,其中一個配置會禁用某些特性,並編譯出部分結構(一種常見的場景是解釋欄位,這些欄位是可選的,但如果存在則非常有用)?所有這些條件意味著無法使用本地開發伺服器上的標頭檔案編譯出一個BPF程式,然後分發到其他系統上執行。這是因為不同核心版本的頭文字中的資料的記憶體佈局可能是不同的。

迄今為止,人們編譯這類BPF程式會依賴BCC (BPF Compiler Collection)。使用BCC,可以將BPF程式的C程式碼以字串的形式嵌入到使用者空間的程式中,當程式最終部署並執行在目標主機上後,BCC會喚醒其嵌入的Clang/LLVM,提取本地核心標頭檔案(必須確保已從正確的kernel-devel軟體包中將其安裝在系統上),並即時進行編譯。通過這種方式來確保BPF程式期望的記憶體佈局和主機執行的核心的記憶體佈局是相同的。如果需要處理一些選項和核心編譯出來的潛在產物,則可以在自己的原始碼中新增#ifdef/#else來適應重新命名欄位、不同的數值語義或當前配置導致的不可用內容等帶來的風險。嵌入的Clang會移除程式碼中無關的內容,並調整BPF程式程式碼,以匹配到特定的核心。

這種方式聽起來很不錯,但實際並非沒有缺點:

  • Clang/LLVM組合是一個很大的庫,導致釋出的應用的庫會比較大
  • Clang/LLVM組合使用的資源比較多,因此當編譯的BPF程式碼啟動時會消耗大量資源,可能會推翻已均衡的生產負載
  • 這樣做其實也是在賭目標系統將存在核心標頭檔案,大多數情況下這不是問題,但有時可能會引起很多麻煩。這也是核心開發人員感到特別麻煩的要求,因為他們經常必須在開發過程中構建和部署自定義的一次性核心。如果沒有自定義構建的核心標頭檔案包,則基於BCC的應用將無法在這種核心上執行,從而剝奪了開發人員用於除錯和監視的工具集。
  • BPF程式的測試和開發迭代也相當痛苦,因為一旦重新編譯並重啟使用者空間控制應用程式,甚至會在執行時遇到各種瑣碎的編譯錯誤。這無疑會增加難度,且無益於快速迭代。

總之, BCC是一個很好的工具,尤其適用於快速原型製作,實驗和小型工具,但在用於廣泛部署的生產BPF應用程式時,它無疑具有很多缺點。

我們正在使用BPF CO-RE來增強BPF的可移植性,並相信這是未來BPF程式開發的趨勢,尤其是對於複雜的實際應用的BPF程式。

高階BFP CO-RE機制

BPF CO-RE在軟體堆疊的各個級別彙集了必要的功能和資料:核心,使用者空間的BPF載入器庫(libbpf),和編譯器(Clang)。通過這些元件來支援編寫可移植的BPF程式,使用相同的預編譯的BPF程式來處理不同核心之間的差異。BPF CO-RE需要以下元件的整合和合作:

  • BTF型別資訊,用於允許獲取關於核心和BPF程式型別和程式碼的關鍵資訊,進而為解決BPF CO-RE的其他難題提供了可能性;
  • 編譯器(Clang)為BPF程式C程式碼提供了表達意圖和記錄重定位資訊的方法;
  • BPF載入器(libbpf)將核心和BPF程式中的BTF繫結在一起,用於將編譯後的BPF程式碼調整為目標主機上的特定核心程式碼;
  • 核心,在完全不依賴BPF CO-RE的情況下,提供了高階BPF功能來啟用某些更高階的場景。

這些元件可以整合到一起工作,提供前所未有的便捷性,適應性和表達性(來開發可移植BPF程式,以前只能在執行時通過BCC編譯BPF程式的C程式碼來實現),而無需像BCC一樣付出高昂的代價。

BTF

整個BPF CO-RE方法的關鍵推動因素之一是BTF。BTF (BPF Type Format) 是作為一個更通用,更詳細的DWARF除錯資訊的替代品而建立的。BTF是一種節省空間,緊湊但仍具有足夠表達能力的格式,可以描述C程式的所有型別資訊。由於其簡單性和使用的重複資料刪除演算法,與DWARF相比,BTF的大小可減少多達100倍。現在,已經可以在核心執行時使用顯示地嵌入BPF型別資訊:只需要啟用CONFIG_DEBUG_INFO_BTF=y核心選項即可。核心本身可以使用BTF功能,用於增強BPF驗證程式自身的功能。

關於BPF CO-RE更重要的是,核心還通過/sys/kernel/btf/vmlinux上的sysfs公開了這種自描述的權威BTF資訊(定義了確切的結構佈局)。嘗試如下命令:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c

某些unix系統下安裝的bpftool預設不支援btf命令選項,可以在linux核心原始碼的/tools/bpf/bpftool目錄下執行make命令進行編譯。如果遇到linux/if.hnet/if.h標頭檔案定義衝突的話,可以將/tools/bpf/bpftool/net.c中的這一行註釋掉再編譯:

#include <linux/if.h>

目前很多核心預設並不會開啟對BTF核心選項,因此需要自己編譯核心。基本步驟如下:

  1. 首先升級gcc

  2. 編譯帶BTF選項的核心前需要安裝pahole,可以從github官方下載原始碼編譯即可。需要注意的是,該編譯過程需要依賴git,因此需要通過git clone程式碼編譯,而不能下載原始碼壓縮包編譯

  3. 將匯出當前系統配置:

    $ cd linux-5.10.1
    $ cp -v /boot/config-$(uname -r) .config
    
  4. 在linux-5.10.1目錄中使用make menuconfig命令修改系統配置檔案,並儲存。可以使用"/"直接查詢需要修改的核心選項;

  5. 編譯並建立核心映象,如果僅需要vmlinux的話,在編譯完之後執行make vmlinux即可

    $ make
    #可以使用多核方式加速編譯,指定使用4個核
    $ make -j 4
    #使用nproc命令獲取到的核數
    $ make -j $(nproc)
    
  6. 安裝核心:

    $ sudo make modules_install
    
  7. 安裝核心:

    $ sudo make install
    
  8. 更新 grub config檔案

    $ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
    $ sudo grubby --set-default /boot/vmlinuz-5.6.9
    
  9. 重啟

通過上述命令可以獲得到一個可相容的C標頭檔案(即"vmlinux.h"),包含所有的核心型別("所有"意味著包含那些不會通過kernel-devel包暴露的標頭檔案)。

編譯器支援

為了啟用BPF CO-RE,並讓BPF載入程式(即libbpf)將BPF程式調整為在目標主機上執行的特定核心,Clang擴充套件了一些內建功能,通過這些擴充套件功能可以發出BTF重定位,捕獲有關BPF程式程式碼打算讀取哪些資訊的高階描述。例如要讀取task_struct->pid欄位,Clang會記錄一個名為"pid"的欄位,型別為"pid_t",位於struct task_struct中。這樣,即使目標核心的task_struct結構中的"pid"欄位在task_struct結構體內部發生了偏移(如,由於"pid"欄位前面新增了額外的欄位),或即使該欄位轉移到了某個巢狀的匿名結構或聯合體中,這樣也能夠通過其名稱和型別資訊找到它。這種方式稱為欄位偏移量重定位

通過這種方式可以捕獲不僅一個欄位的偏移量,也可以捕獲欄位的其他屬性,如欄位的存在性或大小。即使對於位元欄位(眾所周知,它們是C語言中“拒絕合作”的資料),也能夠捕獲足夠多的資料來使這些欄位可重定位,所有這些對於BPF程式開發人員都是透明的。

BPF載入器(libbpf)

前面的所有資料最終會集合到一起,由libbpf進行處理,libbpf作為BPF程式的載入器。它會使用編譯好的BPF ELF檔案,必要時對其進行後處理,配置各種核心物件(maps,programs等),然後觸發BPF程式的載入和驗證。

libbpf知道如何將BPF程式程式碼匹配到特定的核心。它會檢視程式記錄的BTF型別和重定位資訊,然後將這些資訊與核心提供的BTF資訊進行匹配。libbpf解析並匹配所有的型別和欄位,更新必要的偏移以及重定位資料,確保BPF程式的邏輯能夠正確地執行在特定的核心上。如果一切順利,則BPF應用開發人員會獲得一個BPF程式,這種方式可以針對目標主機上的核心進行“量身定製”,就好像程式是專門針對這個核心編譯的,但無需在應用程式中分發Clang以及在目標主機上的執行時中執行編譯,就可以實現所有這些目標。

核心

令人驚奇的是,核心無需太多變動就可以支援BPF CO-RE。歸功於一個好的關注點分離(separation of concerns,SOC),當libbpf處理完BPF程式程式碼之後,在核心看來,它與其他有效的BPF程式程式碼一樣,與使用最新核心標頭檔案在主機上直接編譯的BPF程式並沒有區別,這意味著BPF CO-RE的許多功能都不需要先進的核心功能,因此可以更廣泛,更迅速地進行調整。

有可能在某些場景下需求較新核心的支援,但這種情況很少。在下一部分中,我們將在解釋BPF CO-RE面向使用者的機制時討論這種情況,其中將詳細介紹BPF CO-RE面向使用者的API。

BPF CO-RE:面向使用者的體驗

現在我們將看一下BPF應用的一些典型場景,以及如何通過BPF CO-RE解決相容性問題。下面可以看到,一些可移植性問題(如相容結構體佈局差異)可以透明地進行處理,但其他一些場景則需要更加顯示地處理,如if/else條件判斷(與編譯時BCC程式中的#ifdef/#else構造相反)和BPF CO-RE提供的一些額外機制。

擺脫對核心標頭檔案的依賴

除了使用核心的BTF資訊進行欄位的重定位意外,還可以將BTF資訊生成一個大(基於5.10.1版本生成的長度有106382行)的標頭檔案("vmlinux.h"),其中包含了所有的核心內部型別,可以避免對系統範圍的核心標頭檔案的依賴。可以使用如下方式生成vmlinux.h:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

當使用了vmlinux.h,此時就不需要依賴像#include <linux/sched.h>, #include <linux/fs.h>這樣的標頭檔案,僅需要\#include "vmlinux.h"即可。該標頭檔案包含了所有的核心型別:暴露了UAPI,通過kernel-devel提供的內部型別,以及其他一些更加內部的核心型別

不幸的是,BTF(即DWARF)不會記錄#define巨集,因此在vmlinux.h中丟失一些常用的巨集。但大多數丟失的巨集可以通過libbpf的bpf_helpers.h(即libbpf提供的核心側的庫)標頭檔案提供。

讀取核心結構體欄位

大多數場景下會從某個核心結構中讀取一個欄位。假設我們期望讀取task_struct結構體的pid欄位。使用BCC時非常簡單:

pid_t pid = task->pid;

BCC會將task->pid重寫為對bpf_probe_read()的呼叫,非常方便(雖然有時候不會成功,具體取決於使用的表示式的複雜度)。當使用libbpf時,由於它沒有BCC的程式碼重寫功能,因此需要使用其他方式來得到相同的結果。

如果新增了BTF_PROG_TYPE_TRACING 程式,那麼就可以輕鬆掌握BPF驗證程式,允許理解和跟蹤BTF型別的本質,並允許使用指標直接讀取核心記憶體,避免使用bpf_probe_read()呼叫。

Libbpf + BPF_PROG_TYPE_TRACING 方式:

pid_t pid = task->pid;

將該功能與BPF CO-RE配合使用,可以支援可移植(即可重定位)的欄位讀取,此時需要將此程式碼封裝到編譯器內建的__builtin_preserve_access_index

BPF_PROG_TYPE_TRACING + BPF CO-RE 方式:

pid_t pid = __builtin_preserve_access_index(({ task->pid; }));

這種方式能夠正常工作,同時也支援不同核心版本間的可移植性。但鑑於BPF_PROG_TYPE_TRACING的前沿性,因此必須顯式地使用bpf_probe_read()

非CO-RE libbpf方式:

pid_t pid;
bpf_probe_read(&pid, sizeof(pid), &task->pid);

現在,使用CO-RE+libbpf,我們有兩種方式來實現訪問pid欄位的值。一種是直接使用bpf_core_read()替換bpf_probe_read():

pid_t pid;
bpf_core_read(&pid, sizeof(pid), &task->pid);

bpf_core_read()是一個簡單的巨集,它會將所有的引數直接傳遞給bpf_probe_read(),但也會使Clang通過__builtin_preserve_access_index()記錄第三個引數(&task->pid)的欄位的偏移量。

bpf_probe_read(&pid, **sizeof**(pid), __builtin_preserve_access_index(&task->pid));

但像bpf_probe_read()/bpf_core_read()這樣的呼叫方式很快就會變得難以維護,特別是獲取通過指標連在一起的結構體時。例如,獲取當前程式的可執行檔案的inode號時,可以使用BCC獲取:

u64 inode = task->mm->exe_file->f_inode->i_ino;

當使用 bpf_probe_read()/bpf_core_read()時,將會變為4個呼叫,並使用一個臨時變數來儲存這些中間指標,才能最終獲得i_ino欄位。當使用BPF CO-RE時,我們可以使用一個輔助巨集來使用類似BCC的方式獲得該欄位的值:

BPF CO-RE方式

u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);

此外,如果想要使用一個變數儲存內容,則可以使用如下方式,避免使用額外的中間變數:

u64 inode;
BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino);

還有一個對應的 bpf_core_read_str(),可以直接替換bpf_probe_read_str();還有一個BPF_CORE_READ_STR_INTO()巨集,其工作方式與BPF_CORE_READ_INTO()類似,但會在最後一個欄位執行bpf_probe_read_str()呼叫。

可以通過bpf_core_field_exists()巨集校驗目標核心是否存在某個欄位,並以此作相應的處理。

pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;

此外,可以通過bpf_core_field_size()巨集捕獲任意欄位的大小,以此來保證不同核心版本間的欄位大小沒有發生變化。

u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */

除此之外,在某些情況下,當讀取一個核心結構體的位元位欄位時,可以使用特殊的BPF_CORE_READ_BITFIELD() (使用直接記憶體讀取) 和BPF_CORE_READ_BITFIELD_PROBED() (依賴bpf_probe_read() 呼叫)巨集。它們抽象了提取位元位欄位繁瑣而痛苦的細節,同時保留了跨核心版本的可移植性:

struct tcp_sock *s = ...;

/* with direct reads */
bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(s, is_cwnd_limited);

/* with bpf_probe_read()-based reads */
u64 is_cwnd_limited;
BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &is_cwnd_limited);

欄位重定位和相關的巨集是BFP CO-RE提供的主要能力。它涵蓋了很多實際的使用案例。

處理核心版本和配置差異

在一些場景下,BPF程式不得不處理核心間的差異。如某些欄位名稱的變更導致其變為了一個完全不同的欄位(但具有相同的意義)。反之亦然,當欄位不變,但其含義發生了變化。如在核心4.6之後,task_struct結構體的utimestime欄位從以秒為單位換為以納秒為單位,這種情況下,不得不進行一些轉換工作。有時,需要提取的資料存在於某些核心配置中,但已在其他核心配置中進行了編譯。還有在很多其他場景下,不可能有一個適合所有核心的通用型別。

為了處理上述問題,BPF CO-RE提出了兩種補充方案:libbpf提供了extern Kconfig variablesstruct flavors.

Libbpf提供的外部變數很簡單。BPF程式可以使用一個知名名稱(如LINUX_KERNEL_VERSION,用於獲取允許的核心的版本)定義一個外部變數,或使用Kconfig的鍵(如CONFIG_HZ,用於獲取核心的HZ值),libbpf會使BPF程式可以將這類外部變數用作任何其他全域性變數。這些變數具有正確的值,與執行BPF程式的活動核心相匹配。此外,BPF校驗器會跟蹤這些變數,並能夠使用它們進行高階控制流分析和消除無效程式碼。檢視如下例子,瞭解如何使用BPF CO-RE抽取執行緒的CPU使用者時間:

extern u32 LINUX_KERNEL_VERSION __kconfig;
extern u32 CONFIG_HZ __kconfig;

u64 utime_ns;

if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(4, 11, 0))
    utime_ns = BPF_CORE_READ(task, utime);
else
    /* convert jiffies to nanoseconds */
    utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ);

其他機制,如struct flavors,可以用於不同核心間型別不相容的場景。這種場景下,無法使用一個通用的結構體定義來為多個核心提供相同的BPF程式。下面是一個人為構造的例子,看下struct flavors如何抽取fs/fsbase(已經重新命名)來作一些執行緒本地資料的處理:

/* up-to-date thread_struct definition matching newer kernels */
struct thread_struct {
    ...
    u64 fsbase;
    ...
};

/* legacy thread_struct definition for <= 4.6 kernels */
struct thread_struct___v46 {   /* ___v46 is a "flavor" part */
    ...
    u64 fs;
    ...
};

extern int LINUX_KERNEL_VERSION __kconfig;
...

struct thread_struct *thr = ...;
u64 fsbase;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(4, 6, 0))
    fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs);
else
    fsbase = BPF_CORE_READ(thr, fsbase);

本例中,BPF應用將<= 4.6核心的“舊版” thread_struct定義為struct thread_struct___v46。型別名稱中的三個下劃線以及其後的所有內容均被視為此結構的“flavor”。libbpf會忽略這個flavor部分,即在執行重定位時,該型別定義會匹配到實際執行的核心的struct thread_struct。這樣的約定允許在一個C程式中具有可替代(且不相容)的定義,並在執行時選擇最合適的定義(例如,上面示例中的特定於核心版本的處理邏輯),然後使用型別強轉為struct flavor來提取必要的欄位。

如果沒有structural flavors,則不能實現編譯一次就可以在多個核心上執行的目標,否則就需要將#ifdef原始碼編譯成兩個單獨的BPF程式,並在執行時由控制應用程式手動選擇適當的BPF程式,這些操作增加了複雜度和維護的成本。儘管不是透明的,但BPF CO-RE甚至可以使用這種高階方案,通過熟悉的C程式碼構造來解決此問題。

根據使用者提供的配置變更行為

有時候,在BPF程式瞭解核心版本和配置之後仍然無法決定如何從核心獲取資料。這種情況下,使用者空間的控制程式可能是唯一知道確切需要做什麼的一方,以及需要啟用或禁用那些特性。通常是通過某種配置資料進行通訊,在使用者空間和BPF程式之間共享資料。現今,一種不需要依賴BPF CO-RE的實現方式是使用BPF map作為配置資料的容器。BPF程式通過查詢BPF map來抽取配置,並根據配置變更控制流,但這種方法有很多缺點:

  • BPF程式每次進行map查詢配置值時都會造成執行時開銷。這部分開銷可能會快速增大,某些高效能BPF應用禁止這種方式。
  • 配置值是不變的,且在BPF程式啟動之後是隻讀的,但這部分資料仍然在BPF校驗器在校驗階段仍然被認為是黑盒資料。意味著校驗器無法清理無用程式碼以及執行其他高階程式碼分析,使得無法使用BPF程式邏輯的可配置部分(這部分功能是最前沿的功能,僅在新核心中支援,當執行在老核心上時不會破壞該程式)。由於BPF驗證程式必須悲觀地認為配置可以是任何東西,且有可能會使用該"未知"的功能(儘管使用者明確配置不會發生這種情況)。

解決此類(公認複雜)場景的方法是使用只讀全域性資料。在BPF程式載入到核心之前由控制應用進行設定。從BPF程式側看,這部分資料就像訪問普通的全域性變數。由於全域性變數使用直接記憶體訪問方式,因此不會產生BPF map查詢的開銷。控制語言側需要在BPF程式載入之前設定初始的配置值,這樣當BPF校驗器進行程式校驗時,會將配置值認為是隻讀的,這樣BPF校驗器會將這部分內容認為是已知的常量,並使用高階控制流分析來執行無用程式碼的刪除。

上例中,在老版本的BPF校驗器下,將不會使用未知的BPF輔助功能,且這部分程式碼會被移除。在新版本BPF校驗器下,應用提供不同的配置後,允許使用新的BPF輔助功能,這部分邏輯會通過BPF校驗器的校驗。下面BPF程式碼例子很好地展示了這種行為:

/* global read-only variables, set up by control app */
const bool use_fancy_helper;
const u32 fallback_value;

...

u32 value;
if (use_fancy_helper)
    value = bpf_fancy_helper(ctx);
else
    value = bpf_default_helper(ctx) * fallback_value;

從使用者空間看,應用程式將能夠通過BPF框架輕鬆地提供此配置。BPF框架討論不在本文討論範圍之內,請參閱核心程式碼庫中的runqslower 工具來展示如何使用它來簡化BPF應用程式。

回顧

BPF CO-RE的目標是幫助BPF開發者使用一個簡單的方式解決簡單的可移植性問題(如讀取結構體欄位),並使用它來定位複雜的可移植性問題(如不相容的資料結構,複雜的使用者空間控制條件等)。使得開發者的BPF程式能夠"一次編譯–隨處執行", 這是通過結合一些上述的BPF CO-RE構建塊來實現的:

  • vmlinux.h消除了對核心標頭檔案的依賴;
  • 欄位重定位(欄位偏移,存在性,大小等)使得可以從核心中抽取資料;
  • libbpf提供的Kconfig外部變數允許BPF程式適應各種核心版本以及特定配置的更改;
  • 當上述都不適合時,app提供了只讀的配置和struct flavors,作為解決任何應用程式必須處理的複雜場景的最終大錘。

不需要CO-RE功能也可以成功編寫,部署和維護可以支援的BPF程式,但在需要時,BPF CO-RE可提供最簡單的方式來幫助解決問題。所有這些功能仍然提供了良好的可用性和熟悉的工作流程,可將C程式碼編譯為二進位制檔案,並進行輕量級的分發。不再需要繁瑣的編譯器庫併為執行時編譯付出寶貴的執行時資源。 同樣,也不再需要在執行時捕獲瑣碎的編譯錯誤。

TIPS

參考

相關文章