eBPF 概述:第 4 部分:在嵌入式系統運行

1. 前言

在本系列的第 1 部分和第 2 部分,我們介紹了 eBPF 虛擬機內部工作原理,在第 3 部分我們研究了基於底層虛擬機機制之上開發和使用 eBPF 程序的主流方式。

在這一部分中,我們將從另外一個視角來分析項目,嘗試解決嵌入式 Linux 系統所面臨的一些獨特的問題:如需要非常小的自定義操作系統鏡像,不能容納完整的 BCC LLVM 工具鏈/python 安裝,或試圖避免同時維護主機的交叉編譯(本地)工具鏈和交叉編譯的目標編譯器工具鏈,以及其相關的構建邏輯,即使在使用像 OpenEmbedded/Yocto 這樣的高級構建系統時也很重要。

2. 關於可移植性

在第 3 部分研究的運行 eBPF/BCC 程序的主流方式中,可移植性並不是像在嵌入式設備上面臨的問題那麼大:eBPF 程序是在被加載的同一臺機器上編譯的,使用已經運行的內核,而且頭文件很容易通過發行包管理器獲得。嵌入式系統通常運行不同的 Linux 發行版和不同的處理器架構,與開發人員的計算機相比,有時具有重度修改或上游分歧的內核,在構建配置上也有很大的差異,或還可能使用了只有二進制的模塊。

eBPF 虛擬機的字節碼是通用的(並未與特定機器相關),所以一旦編譯好 eBPF 字節碼,將其從 x86_64 移動到 ARM 設備上並不會引起太多問題。當字節碼探測內核函數和數據結構時,問題就開始了,這些函數和數據結構可能與目標設備的內核不同或者會不存在,所以至少目標設備的內核頭文件必須存在於構建 eBPF 程序字節碼的主機上。新的功能或 eBPF 指令也可能被添加到以後的內核中,這可以使 eBPF 字節碼向前兼容,但不能在內核版本之間向後兼容(參見內核版本與 eBPF 功能)。建議將 eBPF 程序附加到穩定的內核 ABI 上,如跟蹤點 tracepoint,這可以緩解常見的可移植性。

最近一個重要的工作已經開始,通過在 LLVM 生成的 eBPF 對象代碼中嵌入數據類型信息,通過增加 BTF(BTF 類型格式)數據,以增加 eBPF 程序的可移植性(CO-RE 一次編譯,到處運行)。更多信息見這裏的 補丁文章。這很重要,因爲 BTF 涉及到 eBPF 軟件技術棧的所有部分(內核虛擬機和驗證器、clang/LLVM 編譯器、BCC 等),但這種方式可帶來很大的便利,允許重複使用現有的 BCC 工具,而不需要特別的 eBPF 交叉編譯和在嵌入式設備上安裝 LLVM 或運行 BPFd。截至目前,CO-RE BTF 工作仍處於早期開發階段,還需要付出相當多的工作才能可用【譯者注:當前在高版本內核已經可以使用或者編譯內核時啓用了 BTF 編譯選項】。也許我們會在其完全可用後再發表一篇博文。

3. BPFd

BPFd (項目地址 https://github.com/joelagnel/bpfd )更像是一個爲 Android 設備開發的概念驗證,後被放棄,轉而通過 adeb 包運行一個完整的設備上的 BCC 工具鏈【譯者注:BCC 在 adeb 的編譯文檔參見這裏】。如果一個設備足夠強大,可以運行 Android 和 Java,那麼它也可能可以安裝 BCC/LLVM/python。儘管這個實現有些不完整(通信是通過 Android USB 調試橋或作爲一個本地進程完成的,而不是通過一個通用的傳輸層),但這個設計很有趣,有足夠時間和資源的人可以把它拿起來合併,繼續擱置的 PR 工作

簡而言之,BPFd 是一個運行在嵌入式設備上的守護程序,作爲本地內核/libbpf 的一個遠程過程調用(RPC)接口。Python 在主機上運行,調用 BCC 來編譯/部署 eBPF 字節碼,並通過 BPFd 創建/讀取 map。BPFd 的主要優點是,所有的 BCC 基礎設施和腳本都可以工作,而不需要在目標設備上安裝 BCC、LLVM 或 python,BPFd 二進制文件只有 100kb 左右的大小,並依賴 libc。

image

4. Ply

ply 項目實現了一種與 BPFtrace 非常相似的高級領域特定語言(受到 AWK 和 C 的啓發),其明確的目的是將運行時的依賴性降到最低。它只依賴於一個現代的 libc(不一定是 GNU 的 libc)和 shell(與 sh 兼容)。Ply 本身實現了一個 eBPF 編譯器,需要根據目標設備的內核頭文件進行構建,然後作爲一個單一的二進制庫和 shell 包裝器部署到目標設備上。

爲了更好解釋 ply,我們把第 3 部分中的 BPFtrace 例子和與 ply 實現進行對比:

  • BPFtrace:要運行該例子,你需要數百 MB 的 LLVM/clang、libelf 和其他依賴項:
bpftrace -e 'tracepoint:raw_syscalls:sys_enter {@[pid, comm] = count();}'
  • ply:你只需要一個 ~50kb 的二進制文件,它產生的結果是相同的,語法幾乎相同:
ply 'tracepoint:raw_syscalls/sys_enter {@[pid, comm] = count();}'

Ply 仍在大量開發中(最近的 v2.0 版本是完全重寫的)【譯者注:當前最新版本爲 2.1.1,最近一次代碼提交是 8 個月前,活躍度一般】,除了一些示例之外,該語言還不不穩定或缺乏文檔,它不如完整的 BCC 強大,也沒有 BPFtrace 豐富的功能特性,但它對於通過 ssh 或串行控制檯快速調試遠程嵌入式設備仍然非常有用。

5. Gobpf

Gobpf 及其合併的子項目(goebpf, gobpf-elf-loader),是 IOVisor 項目的一部分,爲 BCC 提供 Golang 語言綁定。eBPF 的內核邏輯仍然用 “限制性 C” 編寫,並由 LLVM 編譯,只有標準的 python/lua 用戶空間腳本被 Go 取代。這個項目對嵌入式設備的意義在於它的 eBPF elf 加載模塊,其可以被交叉編譯並在嵌入式設備上獨立運行,以加載 eBPF 程序至內核並與與之交互。

image

值得注意的是,go 加載器可以被寫成通用的(我們很快就會看到),因此它可以加載和運行任何 eBPF 字節碼,並在本地重新用於多個不同的跟蹤會話。

使用 gobpf 很痛苦的,主要是因爲缺乏文檔。目前最好的 “文檔” 是 tcptracer 的源代碼,它相當複雜(他們使用 kprobes 而不依賴於特定的內核版本!),但從它可以學到很多。Gobpf 本身也是一項正在進行的工作:雖然 elf 加載器相當完整,並支持加載帶有套接字、(k|u)probes、tracepoints、perf 事件等加載的 eBPF ELF 對象,但 bcc go 綁定模塊還不容易支持所有這些功能。例如,儘管你可以寫一個 socket_ilter ebpf 程序,將其編譯並加載到內核中,但你仍然不能像 BCC 的 python 那樣從 go 用戶空間輕鬆地與 eBPF 進行交互,BCC 的 API 更加成熟和用戶友好。無論如何,gobpf 仍然比其他具有類似目標的項目處於更好的狀態。

讓我們研究一個簡單的例子來說明 gobpf 如何工作的。首先,我們將在本地 x86_64 機器上運行它,然後交叉編譯並在 32 位 ARMv7 板上運行它,比如流行的 Beaglebone 或 Raspberry Pi。我們的文件目錄結構如下:

$ find . -type f
./src/open-example.go
./src/open-example.c
./Makefile

open-example.go:這是建立在 gobpf/elf 之上的 eBPF ELF 加載器。它把編譯好的 “限制性 C” ELF 對象作爲參數,加載到內核並運行,直到加載器進程被殺死,這時內核會自動卸載 eBPF 邏輯【譯者注:通常情況是這樣的,也有場景加載器退出,ebpf 程序繼續運行的】。我們有意保持加載器的簡單性和通用性(它加載在對象文件中發現的任何探針),因此加載器可以被重複使用。更復雜的邏輯可以通過使用 gobpf 綁定 模塊添加到這裏。

package main

import (
    "fmt"
    "os"
    "os/signal"
    "github.com/iovisor/gobpf/elf"
)

func main() {mod := elf.NewModule(os.Args[1])

    err := mod.Load(nil);
    if err != nil {fmt.Fprintf(os.Stderr, "Error loading'%s'ebpf object: %v\n", os.Args[1], err)os.Exit(1)
    }

    err = mod.EnableKprobes(0)
    if err != nil {fmt.Fprintf(os.Stderr, "Error loading kprobes: %v\n", err)
        os.Exit(1)
    }

    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt, os.Kill)
    // ...
}

open-example.c:這是上述加載器加載至內核的 “限制性 C” 源代碼。它掛載在 do_sys_open 函數,並根據 ftrace format 將進程命令、PID、CPU、打開文件名和時間戳打印到跟蹤環形緩衝區,(詳見 “輸出格式” 一節)。打開的文件名作爲 do_sys_open call 的第二個參數傳遞,可以從代表函數入口的 CPU 寄存器的上下文結構中訪問。


#include <uapi/linux/bpf.h>
#include <uapi/linux/ptrace.h>
#include <bpf/bpf_helpers.h>

SEC("kprobe/do_sys_open")
int kprobe__do_sys_open(struct pt_regs *ctx)
{char file_name[256];

    bpf_probe_read(file_name, sizeof(file_name), PT_REGS_PARM2(ctx));

    char fmt[] = "file %s\n";
    bpf_trace_printk(fmt, sizeof(fmt), &file_name);

    return 0;
}

char _license[] SEC("license") = "GPL";
__u32 _version SEC("version") = 0xFFFFFFFE;

在上面的代碼中,我們定義了特定的 “SEC” 區域,這樣 gobpf 加載器就可獲取到哪裏查找或加載內容的信息。在我們的例子中,區域爲 kprobe、license 和 version。特殊的 0xFFFFFFFE 值告訴加載器,這個 eBPF 程序與任何內核版本都是兼容的,因爲打開系統調用而破壞用戶空間的機會接近於 0。

Makefile:這是上述兩個文件的構建邏輯。注意我們是如何在 include 路徑中加入 “arch/x86/…” 的;在 ARM 上它將是 “arch/arm/…"。

SHELL=/bin/bash -o pipefail
LINUX_SRC_ROOT="/home/adi/workspace/linux"
FILENAME="open-example"

ebpf-build: clean go-build
	clang \
	-D__KERNEL__ -fno-stack-protector -Wno-int-conversion \
	-O2 -emit-llvm -c "src/${FILENAME}.c" \
	-I ${LINUX_SRC_ROOT}/include \
	-I ${LINUX_SRC_ROOT}/tools/testing/selftests \
	-I ${LINUX_SRC_ROOT}/arch/x86/include \
	-o - | llc -march=bpf -filetype=obj -o "${FILENAME}.o"

go-build:
	go build -o ${FILENAME} src/${FILENAME}.go

clean:
	rm -f ${FILENAME}*

運行上述 makefile 在當前目錄下產生兩個新文件:

  • open-example:這是編譯後的 src/*.go 加載器。它只依賴於 libc 並且可以被複用來加載多個 eBPF ELF 文件運行多個跟蹤。

  • open-example.o:這是編譯後的 eBPF 字節碼,將在內核中加載。

“open-example” 和 “open-example.o” ELF 二進制文件可以進一步合併成一個;加載器可以包括 eBPF 二進制文件作爲資產,也可以像 tcptracer 那樣在其源代碼中直接存儲爲字節數。然而,這超出了本文的範圍。

運行例子顯示以下輸出(見 ftrace 文檔 中的 “輸出格式” 部分)。

# (./open-example open-example.o &) && cat /sys/kernel/debug/tracing/trace_pipe
electron-17494 [007] ...3 163158.937350: 0: file /proc/self/maps
systemd-1      [005] ...3 163160.120796: 0: file /proc/29261/cgroup
emacs-596      [006] ...3 163163.501746: 0: file /home/adi/
(...)

沿用我們在本系列的第 3 部分中定義的術語,我們的 eBPF 程序有以下部分組成:

  • 後端:是 open-example.o ELF 對象。它將數據寫入內核跟蹤環形緩衝區。

  • 加載器:這是編譯過的 open-example 二進制文件,包含 gobpf/elf 加載器模塊。只要它運行,數據就會被添加到跟蹤緩衝區中。

  • 前端:這就是 cat /sys/kernel/debug/tracing/trace_pipe。非常 UNIX 風格。

  • 數據結構:內核跟蹤環形緩衝區。

現在將我們的例子交叉編譯爲 32 位 ARMv7。 基於你的 ARM 設備運行的內核版本:

  • 內核版本>=5.2:只需改變 makefile,就可以交叉編譯與上述相同的源代碼。
  • 內核版本<5.2:除了使用新的 makefile 外,還需要將 PT_REGS_PARM* 宏從 這個 patch 複製到 “受限制 C” 代碼。

新的 makefile 告訴 LLVM/Clang,eBPF 字節碼以 ARMv7 設備爲目標,使用 32 位 eBPF 虛擬機子寄存器地址模式,以便虛擬機可以正確訪問本地處理器提供的 32 位尋址內存(還記得第 2 部分中介紹的所有 eBPF 虛擬機寄存器默認爲 64 位寬),設置適當的包含路徑,然後指示 Go 編譯器使用正確的交叉編譯設置。在運行這個 makefile 之前,需要一個預先存在的交叉編譯器工具鏈,它被指向 CC 變量。

SHELL=/bin/bash -o pipefail
LINUX_SRC_ROOT="/home/adi/workspace/linux"
FILENAME="open-example"

ebpf-build: clean go-build
	clang \
		--target=armv7a-linux-gnueabihf \
		-D__KERNEL__ -fno-stack-protector -Wno-int-conversion \
		-O2 -emit-llvm -c "src/${FILENAME}.c" \
		-I ${LINUX_SRC_ROOT}/include \
		-I ${LINUX_SRC_ROOT}/tools/testing/selftests \
		-I ${LINUX_SRC_ROOT}/arch/arm/include \
		-o - | llc -march=bpf -filetype=obj -o "${FILENAME}.o"

go-build:
	GOOS=linux GOARCH=arm CGO_ENABLED=1 CC=arm-linux-gnueabihf-gcc \
	go build -o ${FILENAME} src/${FILENAME}.go

clean:
	rm -f ${FILENAME}*

運行新的 makefile,並驗證產生的二進制文件已經被正確地交叉編譯:

[adi@iwork]$ file open-example*
open-example:   ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter (...), stripped
open-example.o: ELF 64-bit LSB relocatable, *unknown arch 0xf7* version 1 (SYSV), not stripped

然後將加載器和字節碼複製到設備上,與在 x86_64 主機上使用上述相同的命令來運行。記住,只要修改和重新編譯 C eBPF 代碼,加載器就可以重複使用,用於運行不同的跟蹤。

[root@ionelpi adi]# (./open-example open-example.o &) && cat /sys/kernel/debug/tracing/trace_pipe
ls-380     [001] d..2   203.410986: 0: file /etc/ld-musl-armhf.path
ls-380     [001] d..2   203.411064: 0: file /usr/lib/libcap.so.2
ls-380     [001] d..2   203.411922: 0: file /
zcat-397   [002] d..2   432.676010: 0: file /etc/ld-musl-armhf.path
zcat-397   [002] d..2   432.676237: 0: file /usr/lib/libtinfo.so.5
zcat-397   [002] d..2   432.679431: 0: file /usr/bin/zcat
gzip-397   [002] d..2   432.693428: 0: file /proc/
gzip-397   [002] d..2   432.693633: 0: file config.gz

由於加載器和字節碼加起來只有 2M 大小,這是一個在嵌入式設備上運行 eBPF 的相當好的方法,而不需要完全安裝 BCC/LLVM。

6. 總結

在本系列的第 4 部分,我們研究了可以用於在小型嵌入式設備上運行 eBPF 程序的相關項目。不幸的是,當前使用這些項目還是比較很困難的:它們有的被遺棄或缺乏人力,在早期開發時一切都在變化,或缺乏基本的文檔,需要用戶深入到源代碼中並自己想辦法解決。正如我們所看到的,gobpf 項目作爲 BCC/python 的替代品是最有活力的,而 ply 也是一個有前途的 BPFtrace 替代品,其佔用空間最小。隨着更多的工作投入到這些項目中以降低使用者的門檻,eBPF 的強大功能可以用於資源受限的嵌入式設備,而無需移植/安裝整個 BCC/LLVM/python/Hover 技術棧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章