【譯】eBPF 概述:第 4 部分:在嵌入式系統執行

語言: CN / TW / HK

本系列導航:

  1. eBPF 概述:第 1 部分:介紹
  2. eBPF 概述:第 2 部分:機器和位元組碼
  3. eBPF 概述:第 3 部分:軟體開發生態
  4. eBPF 概述:第 4 部分:在嵌入式系統執行
  5. eBPF 概述:第 5 部分:跟蹤使用者程序

原文地址: https://www.collabora.com/news-and-blog/blog/2019/05/06/an-ebpf-overview-part-4-working-with-embedded-systems/

首發地址: https://ebpf.top/post/ebpf-overview-part-4/

釋出時間: 2019-05-06

作者:Adrian Ratiu

翻譯: 狄衛華

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。

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 程式至核心並與與之互動。

值得注意的是,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,並驗證產生的二進位制檔案已經被正確地交叉編譯:

[[email protected]]$ 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 程式碼,載入器就可以重複使用,用於執行不同的跟蹤。

[[email protected] 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 技術棧。

繼續閱讀(eBPF 概述:第 5 部分:跟蹤使用者程序)