eBPF動手實踐系列三:基於原生libbpf庫的eBPF編程改進方案

一、欲窮千里目,更上一層樓

在上一篇文章《eBPF動手實踐系列二:構建基於純C語言的eBPF項目》中,我們初步實現了脫離內核源碼進行純C語言eBPF項目的構建。libbpf庫在早期和內核源碼結合的比較緊密,如今的libbpf庫更加成熟,已經完全脫離內核源碼獨立發展。

爲了更加具體的理解linux內核版本演進和libbpf版本演進的關係,本文在“附錄A”中總結了各個內核版本源碼示例中所依賴的libbpf庫的對應版本信息。

大部分版本的內核獲取libbpf版本的方法如下,從libbpf庫目錄的libbpf.map文件中提取最大的版本號信息。這裏的"source"爲內核源碼所在目錄。

$ cat ./source/tools/lib/bpf/libbpf.map | grep -oE '^LIBBPF_([0-9.]+)' | sort -rV | head -n1 | cut -d'_' -f2

較早版本的內核在./tools/lib/bpf/Makefile文件中直接定義了libbpf的版本信息。

$ cat ./source/tools/lib/bpf/Makefile
BPF_VERSION = 0
BPF_PATCHLEVEL = 0
BPF_EXTRAVERSION = 2

二、eBPF編程方案簡介

爲了簡化 eBPF程序的開發流程,降低開發者在使用 libbpf 庫時的入門難度,libbpf-bootstrap 框架應運而生。基於libbpf-bootstrap框架的編程方案是目前網絡上看到的最主流編程方案。此外,網絡上也偶見比較古老的僅依賴一個bpf_load.c文件的C語言編程方案,這個方案並不需要依賴libbpf庫的支持。

主流的C語言實現的eBPF編程方案,大體上就是以下三種,筆者總共將其歸納爲3代。

代際 方案指稱 識別方法 備註
第1代 bpf_load.c文件方案 代碼中有bpf_load.c文件,還有load_bpf_file函數。 Linux 4.x 系列早期內核版本的源碼實例大多基於此文件,這個舊 API 方案已經在內核中被逐步廢棄。
第2代 原生libbpf庫方案 代碼中有libbpf.c文件 Linux 5.x版本內核的源碼實例很多使用以libbpf.c爲核心的原生libbpf庫方案,是本文重點闡述的方案。
第3代 libbpf-bootstrap骨架方案 代碼中除了libbpf.c文件,還有libbpf-bootstrap、skeleton和*.skel.h關鍵詞 最新版本內核的源碼實例已經開始採用此方案。業界最新的eBPF介紹文章較多基於此方案。

除了經典的C語言編程方案,一些編程框架還選擇使用Python語言,Go語言,或者Rust語言作爲用戶態加載的實現語言。

儘管libbpf-bootstrap骨架C語言方案、基於libbpfgo庫的go語言方案等已經被大家廣泛使用和接受。但筆者認爲基於原生libbpf庫的eBPF編程方案仍然具備很多獨特的優勢。以下是原生libbpf庫eBPF編程方案的一些獨特優勢:

  1. 更深的控制和靈活性:直接使用原生libbpf 庫的方案意味着可以與更底層交互,實現更多的控制,定製加載和管理 eBPF 程序和 maps 過程,滿足更復雜的需求。
  2. 更好的學習和理解:libbpf-bootstrap封裝抽象屏蔽了很多細節,直接使用原生libbpf可以對 eBPF 子系統有更深入的理解,有利於開發者對 eBPF 內部工作原理的理解。
  3. 更細粒度的依賴管理:直接使用原生libbpf庫能夠指定依賴的 libbpf 庫版本和功能,進而更精細化地管理項目依賴關係。
  4. 更好的低版本內核適應性:基於原生libbpf庫的方案,在低版本操作系統發行版和低版本內核上可以有更好的兼容性。

本文將由淺入深介紹第 2 代原生libbpf庫的eBPF編程方案,並提出一種改進思路。

三、準備eBPF開發的基礎環境

主流的linux發行版大多是基於rpm包或deb包的包管理系統。不同的包管理系統,初始化eBPF開發環境時所依賴的包,也略有差別。本文將分別進行介紹。

3.1、rpm包基礎環境初始化

在RPM包發行版環境,需要安裝一些編譯過程的基礎包、編譯工具包、庫依賴包和頭文件依賴包等。我們推薦使用如下一些發行版及其兼容環境:Anolis 8.8、Kylin V10、CentOS 8.5、和 Fedora 39 等。

詳細安裝步驟如下:

$  yum install git make                               # 基礎包
$  yum install kernel-headers-$(uname -r)             # 頭文件依賴包
$  yum install clang llvm elfutils-libelf-devel       # 編譯工具和依賴庫包

## 依次選擇如下命令之一,安裝bpftool工具
$  yum install bpftool-$(uname -r)
$  yum install bpftool

3.2、deb包基礎環境初始化

在 DEB 包發行版環境,需要安裝一些編譯過程的基礎包、編譯工具包、庫依賴包和頭文件依賴包等。推薦使用Ubuntu 22.04 或Debian 12 等發行版及其兼容環境。

詳細安裝步驟如下:

$  apt-get update                                     # 更新apt源信息
$  apt install git make                               # 基礎包 
$  apt install linux-libc-dev                         # 頭文件依賴包
$  apt install clang llvm libelf-dev                  # 編譯工具和依賴庫包

## 依次選擇如下命令之一,安裝bpftool工具
$  apt install linux-tools-common linux-tools-$(uname -r)
$  apt install linux-tools-common linux-tools-generic
$  apt install linux-tools-$(uname -r) linux-cloud-tools-$(uname -r)
$  apt install bpftool

四、構建基於原生libbpf庫的eBPF項目

本文的目的是向大家分享一個以第2代 ebpf 編程方案爲基礎的改進ebpf編譯構建方案。本節先用一些篇幅內容,對第2代方案本身的構建編譯過程做一些介紹。

libbpf庫具有一定的向下兼容能力,可以選擇使用截至目前最新的歸檔版本libbpf-1.3.0來搭建編程環境。以 libbpf-1.3.0版本libbpf庫爲基礎,下文會提供若干實例代碼,來剖析ebpf構建原理。完成了基礎環境的初始化,就可以開始搭建我們的eBPF項目。所有的代碼示例都可以通過如下git項目獲取。爲了後面訪問方便,這裏用一個shell變量NATIVE_LIBBPF用來存儲工作目錄。

$ cd ~
$ git clone https://github.com/alibaba/sreworks-ext.git
$ NATIVE_LIBBPF=~/sreworks-ext/demos/native_libbpf_guide/

4.1、初步構建基於原生libbpf庫的eBPF項目

首先來看一個基於原生libbpf庫的第2代eBPF構建實例。ebpf初學者,可以考慮選擇跟蹤 execve 系統調用產生的事件。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_execve_libbpf130                            # 進入項目目錄   
$ make
$ sudo ./trace_execve
trace_execve 15836221 5501 bash 1534 bash 0 /usr/bin/ls
trace_execve 15914126 5502 bash 1534 bash 0 /usr/bin/ps

$ make clean

執行trace_execve命令,對編譯結果進行驗證,完美驗證通過。

4.2、eBPF項目的目錄結構解析

介紹下trace_execve_libbpf130的目錄結構。

trace_execve_libbpf130目錄 說明
./ 項目用戶態代碼和主Makefile
./progs 項目內核態bpf程序代碼
./include 項目的業務代碼相關的頭文件
./helpers 非來自於libbpf庫的一些helpler文件
./tools/lib/bpf/ 來自於libbpf-1.3.0/src/
./tools/include/ 來自於libbpf-1.3.0/include/
./tools/build/ 項目構建時一些feature探測代碼
./tools/scripts/ 項目Makefile所依賴的一些功能函數

再介紹下本項目trace_execve_libbpf130和libbpf-1.3.0庫的對應關係。下載libbpf-1.3.0庫解壓後,使用diff命令進行目錄對比。

  1. 目錄native_libbpf_guide/trace_execve_libbpf130/tools/lib/bpf/內容,除Makefile內容外都來自目錄~/libbpf-1.3.0/src/。
  2. 目錄native_libbpf_guide/trace_execve_libbpf130/tools/include/來自目錄~/libbpf-1.3.0/include/,所有內容都完全一致。
  3. 除以上兩部分來自libbpf-1.3.0庫以外的文件,其餘都由本項目原創貢獻。
$ cd ~
$ wget http://github.com/libbpf/libbpf/archive/refs/tags/v1.3.0.tar.gz
$ tar -zxvf v1.3.0.tar.gz
$ diff -qr $NATIVE_LIBBPF/trace_execve_libbpf130/tools/lib/bpf/ ~/libbpf-1.3.0/src/
Only in ~/libbpf-1.3.0/src/: .gitignore
Files ~/native_libbpf_guide/trace_execve_libbpf130/tools/lib/bpf/Makefile and ~/libbpf-1.3.0/src/Makefile differ

$ diff -qr $NATIVE_LIBBPF/trace_execve_libbpf130/tools/include/ ~/libbpf-1.3.0/include/

在這個項目中添加ebpf的代碼,可以遵循這樣的目錄結構。用戶態加載文件放到根目錄下,內核態bpf文件放到progs目錄下,用戶態和內核態公共的頭文件放到include目錄下。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_execve_libbpf130                            # 進入項目目錄  
$ find . -name "trace_execve*"
./trace_execve.c
./progs/trace_execve.bpf.c
./include/trace_execve.h

4.3、eBPF項目的Makefile解析

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_execve_libbpf130                            # 進入項目目錄
$ find . -name Makefile 
./Makefile
./progs/Makefile
./tools/lib/bpf/Makefile
./tools/build/feature/Makefile

trace_execve_libbpf130項目有4個Makefile,分別如下:

  1. ./Makefile是主文件,用於生成用戶態eBPF程序trace_execve。
  2. ./progs/Makefile 用於生成內核態BPF程序trace_execve.bpf.o。
  3. ./tools/lib/bpf/Makefile 用於生成libbpf.a靜態庫。
  4. ./tools/build/feature/Makefile 用於一些feature的探測。

在項目空間的根目錄運行make命令進行項目構建時,會首先執行Makefile文件。在Makefile文件中會通過make的-C選項間接觸發progs/Makefile和tools/lib/bpf/Makefile的執行。

感興趣的同學可以通過上一章節中提到的make --debug=v,m SHELL="bash -x" 命令逐步debug這些makefile的執行過程。

下文重點分析下編譯過程的一些編譯參數,讓我們加深對eBPF構建過程的理解。

4.4、C語言編譯器的目錄搜索選項

在開始分析eBPF程序的編譯參數之前,先要簡單說一下C語言編譯器(gcc/clang)的目錄搜索選項。C語言的頭文件都需要按照目錄搜索選項的指引,才能正確找到它所在位置。

除了日常我們熟知的-I選項,clang/gcc的目錄搜索選項還有很多,它們優先級的順序依次如下:

  1. 頭文件引用方式include "myheader.h",則在當前文件所在目錄查找myheader.h頭文件。
  2. 頭文件引用方式include "myheader.h",如果有-iquote mydir選項,則在mydir目錄查找myheader.h頭文件。
  3. 頭文件引用方式include ,如果有-I mydir選項,則在mydir目錄查找myheader.h頭文件。
  4. 頭文件引用方式include ,如果有-isystem mydir選項,則在mydir目錄查找myheader.h頭文件。
  5. 頭文件引用方式include ,繼續在標準系統目錄(Standard system directories)查找myheader.h頭文件。標準系統目錄是指/usr/lib64/clang/15.0.7/include 、/usr/local/include 和/usr/include。
  6. 頭文件引用方式include ,如果有-idirafter mydir選項,則在mydir目錄查找myheader.h頭文件。

4.5、內核態bpf程序編譯參數解析

內核態bpf程序trace_execve.bpf.o文件,是由 bpf 文件trace_execve.bpf.c使用clang命令編譯產生。trace_execve.bpf.c文件的頭文件依賴如下。

$ cat progs/trace_execve.bpf.c
// SPDX-License-Identifier: GPL-2.0
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#include "common.h"
#include "trace_execve.h"

從前面項目構建過程中,可以提取出完整的內核態bpf程序的編譯命令。

$ clang -iquote ./../include/ -iquote ./../helpers -I./../tools/lib/ -I./../tools/include/uapi -idirafter /usr/lib64/clang/15.0.7/include \
  -idirafter /usr/include -idirafter /usr/include/x86_64-linux-gnu/ -DENABLE_ATOMICS_TESTS -D__KERNEL__ -D__BPF_TRACING__ \
  -D__TARGET_ARCH_x86 -g -Werror -O2 -mlittle-endian -target bpf -mcpu=v3 -c trace_execve.bpf.c -o trace_execve.bpf.o

下面對一些關鍵環節做一些解析:

  1. 頭文件vmlinux.h由bpftool工具在編譯時動態生成,vmlinux.h包含了絕大多數bpf程序的內核態和用戶態(uapi)依賴。通過編譯選項-I./../tools/lib/可以搜索到vmlinux.h頭文件。
  2. 通過-I./../tools/lib/編譯選項,可以在./tools/lib/目錄下的bpf子目錄中查找到bpf_helpers.h和bpf_tracing.h頭文件,這些頭文件都是對vmlinux.h頭文件內核態依賴的補充。
  3. 通過-iquote ./../include/編譯選項,可以在./include/目錄中查找到trace_execve.h和common.h頭文件。
  4. 在上面這些頭文件依賴的預處理過程中,會依賴一些宏變量來決定預處理的展開邏輯。上面編譯命令中的宏就是起這些作用,-DENABLE_ATOMICS_TESTS -D__KERNEL__ -D__BPF_TRACING__ -D__TARGET_ARCH_x86。比如在bpf_tracing.h頭文件中,就有#if defined(__TARGET_ARCH_x86)的宏判斷語句,來決定預處理展開邏輯走x86分支。
  5. 編譯選項-target bpf,指示Clang將代碼生成爲針對eBPF目標的目標代碼。 編譯選項-mcpu=v3,指示Clang生成針對v3版本的eBPF處理器的目標代碼。 編譯選項-mlittle-endian:指示Clang生成適用於小端序處理器的目標代碼。
  6. 通過-I./../tools/include/uapi編譯選項,可以在./tools/include/uapi/目錄下的linux子目錄中查找到bpf.h頭文件。同時kernel-headers包引入的/usr/include/linux/目錄下也有bpf.h,./tools/include/uapi下的bpf.h優先級會覆蓋它。此外,目錄./tools/include/uapi/linux下的頭文件和vmlinux.h頭文件存在一定的重疊,通常情況下同時加載會出現編譯衝突。如果在一些簡單的 ebpf 使用場景,可以使用替代。

4.6、用戶態加載程序編譯參數解析

用戶態eBPF程序trace_execve文件,是由源文件trace_execve.c文件使用gcc命令編譯。trace_execve.c文件的頭文件依賴如下。

$ cat trace_execve.c
// from kernel-headers
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <unistd.h>
#include <linux/limits.h>
#include <linux/perf_event.h>
#include <sys/resource.h>

// from libbpf
#include <linux/ring_buffer.h>
#include <bpf/libbpf.h>
#include "common.h"
#include "trace_execve.h"

從前面項目構建過程中,也可以提取出完整的用戶態程序的編譯命令。

gcc -iquote ./helpers/ -iquote ./include/ -I./tools/lib/ -I./tools/include/ -g -c -o trace_execve.o trace_execve.c
  1. 通過-I./tools/include/編譯選項,可以在./tools/include/目錄下的linux子目錄中查找到頭文件。
  2. 通過-I./tools/lib/編譯選項,可以在./tools/lib/目錄下的bpf子目錄中查找到頭文件。在一些古老的代碼示例中,有這樣使用頭文件的用法,目前最新的ebpf項目實例,都會將libbpf庫的libbpf.h以及同目錄的頭文件都放到bpf子目錄下,因此推薦統一使用的用法。
  3. 通過-iquote ./include/編譯選項,可以在./include/目錄中查找到trace_execve.h和common.h頭文件。
  4. 其他頭文件都可以在由kerne-headers包提供的標準系統目錄(Standard system directories)的/usr/include/目錄及子目錄中查找到。所以,<linux/perf_event.h>最終會在/usr/include/linux/perf_event.h位置被查找到。可以看出同樣是形式的頭文件,<linux/perf_event.h>和卻在兩個完全不同的搜索路徑查找到。

4.7、libbpf.a靜態庫編譯解析

關於libbpf.a靜態庫的編譯過程,上一篇文章已經有所介紹。這裏僅再次強調下,在本項目中,我們完全實現了libbpf庫的自主可控,可控源代碼,可控編譯構建過程。這至少給我們帶來如下兩方面好處:

  1. 對於一些ebpf的資深人士,可以自主修改libbpf庫中不盡如人意的地方,實現滿足自己業務需求的優化。
  2. 對於一些ebpf的初學者,完全可以在libbpf庫中任意感興趣的地方,通過插入printf或其他斷點方式,深入學習libbpf庫的原理。

五、改進基於原生libbpf庫的eBPF項目構建

5.1、傳統方案美中不足

在上文中,我們初步實現了基於libbpf庫的第 2 代 eBPF項目的構建。但截止到目前,此方案還有一個明顯的缺陷。讓我們繼續上一篇的案例來分析,在搭建完開發環境後執行如下步驟。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_execve_libbpf130                            # 進入項目目錄
$ make clean
$ make
$ sudo ./trace_execve
trace_execve 160646349 5503 sa1 1 systemd 0 /usr/lib64/sa/sa1
trace_execve 160646371 5503 sa1 1 systemd 0 /usr/lib64/sa/sadc

$ mv progs/trace_execve.bpf.o progs/trace_execve.bpf.o.bak
$ sudo ./trace_execve
libbpf: elf: failed to open progs/trace_execve.bpf.o: No such file or directory
ERROR: failed to open prog: 'No such file or directory'

$ mv progs/trace_execve.bpf.o.bak progs/trace_execve.bpf.o
$ sudo ./trace_execve
trace_execve 190767474 5566 crond 5565 crond 0 /bin/bash
trace_execve 190767486 5566 bash  5565 crond 0 /bin/run-parts

從實驗結果可以看出,當我們把bpf目標文件trace_execve.bpf.o改名爲trace_execve.bpf.o.bak後,trace_execve程序執行會報錯,提示讀取trace_execve.bpf.o文件不存在。而當我們再次將備份後的bpf目標文件trace_execve.bpf.o.bak改回原名trace_execve.bpf.o後,重新執行trace_execve程序又一切正常了。這說明,當前方案構建後,需要將trace_execve程序和bpf目標文件trace_execve.bpf.o這一組文件一起進行分發,才能正常執行。這給我們在工程的實現上帶來了很大的挑戰。

爲了解決上面提到的問題,第 3 代 ebpf 編程方案 libbpf-bootstrap框架發明了skeleton骨架,即使用*.skel.h頭文件的方式,將bpf目標文件trace_execve.bpf.o的內容編譯進trace_execve程序。這樣後續只需分發trace_execve二進制程序文件即可。

如果不依賴libbpf-bootstrap編程框架,繼續僅依賴 libbpf 庫是否可以做到這一點呢?答案是可以的,本文獨闢蹊徑,給大家分享一個使用hexdump命令輕鬆實現*.skel.h頭文件的方式。

5.2、使用hexdump生成skel.h頭文件

簡單歸納一下使用libbpf-bootstrap框架編程過程中的構建步驟。

步驟 libbpf-bootstrap框架構建 可改進機會點
1 bpftool btf dump file vmlinux format c > vmlinux.h  
2 clang -O2 -target bpf -c trace_execve.bpf.c -o trace_execve.bpf.o  
3 bpftool gen skeleton trace_execve.bpf.o > trace_execve.skel.h 此步驟用hexdump替換bpftool
4 gcc -o trace_execve trace_execve.c -lbpf -lelf 此步驟更改加載函數爲libbpf標準函數

分析libbpf-bootstrap編程框架的實現原理,可以瞭解到。在第3步會依靠bpftool工具將trace_execve.bpf.o這個目標文件轉換成十六進制格式的文本,並將這個文本內容作爲trace_execve.skel.h頭文件中的一個變量的值,最後還需要讓trace_execve.c用戶態加載文件包含這個trace_execve.skel.h頭文件。這其中將bpf目標文件轉換成十六進制文本並生成skel.h頭文件的過程最爲關鍵。

libbpf-bootstrap編程框架非常成熟,但方案使用中必須遵循他的一些規則,比如頭文件trace_execve.skel.h的命令必須包含程序的關鍵詞trace_execve,再比如加載函數trace_execve_bpf__load()也必須包含程序的關鍵詞trace_execve。如何能不依賴這個規範,實現一個更加輕量級的編程方案呢?這讓我們想到了hexdump命令,可以用它替換bpftool工具,並且生成符合自己期望的頭文件。

$ hexdump -v -e '"\\\x" 1/1 "%02x"' trace_execve.bpf.o > trace_execve.hex

5.3、深入構建基於原生libbpf庫的eBPF項目

下面我們就嘗試依靠hexdump命令實現一個單一可執行文件的解決方案。開始體驗我們基於第 2 代編程方案改進的eBPF項目,進入項目代碼。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd hexdump_skel_libbpf130                            # 進入項目目錄
$ make
$ sudo ./trace_execve
trace_execve bash su 74113 74112 0 /usr/bin/bash
trace_execve bash su 74113 74112 0 /usr/bin/bash

$ sudo ./probe_execve
probe_execve 19076757 5572 0anacron 5570 0anacron 0
probe_execve 19076758 5573 0anacron 5570 0anacron 0

分別執行trace_execve和probe_execve兩個命令,對編譯結果進行驗證,均完美驗證通過。這裏我們在trace_execve實例基礎上又增加了一個probe_execve實例,說明hexdump_skel_libbpf130項目是支持多實例編譯的。

下面我們來驗證下本文開頭的情況,看看沒有了bpf目標文件時的情形。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd hexdump_skel_libbpf130                            # 進入項目目錄 
$ rm -fr progs/trace_execve.bpf.o progs/probe_execve.bpf.o
$ sudo ./trace_execve
trace_execve 19076759 5574 run-parts 5566 run-parts 0 /bin/basename
trace_execve 19076760 5575 run-parts 5566 run-parts 0 /bin/logger

$ sudo ./probe_execve
probe_execve sh python 78841 78838 0 
probe_execve sh python 78841 78838 0

從運行結果看,雖然刪除了兩個bpf目標文件trace_execve.bpf.o和probe_execve.bpf.o,僅僅依靠trace_execve和probe_execve兩個文件即可成功執行。可以再嘗試將trace_execve 可執行文件拷貝到其他目錄,結果依然可行。

5.4、改進的eBPF項目Makefile解析

hexdump_skel_libbpf130項目也是同樣的4個Makefile,其中將bpf目標文件編譯到用戶態加載進程中的環節主要在項目的主Makefile中實現。還是老辦法獲取make構建的詳細過程。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd hexdump_skel_libbpf130                            # 進入項目目錄 
$ make clean
$ make --debug=v,m SHELL="bash -x" > make.log 2>&1

對於構建日誌的分析可以參考前面文章,我們把關鍵環節提取出來。

$ cat make.log | grep -n "Considering target file"
14:Considering target file 'all'.
16:  Considering target file 'tools/lib/bpf/libbpf.a'.
21:  Considering target file 'helpers/uprobe_helper.o'.
23:    Considering target file 'helpers/uprobe_helper.c'.
31:  Considering target file 'probe_execve'.
33:    Considering target file 'probe_execve.o'.
35:      Considering target file 'probe_execve.c'.
38:      Considering target file 'probe_execve.skel.h'.
40:        Considering target file 'probe_execve.hex'.
42:          Considering target file 'progs/probe_execve.bpf.o'.
44:            Considering target file 'progs/probe_execve.bpf.c'.
145:  Considering target file 'trace_execve'.
147:    Considering target file 'trace_execve.o'.
149:      Considering target file 'trace_execve.c'.
152:      Considering target file 'trace_execve.skel.h'.
154:        Considering target file 'trace_execve.hex'.
156:          Considering target file 'progs/trace_execve.bpf.o'.
158:            Considering target file 'progs/trace_execve.bpf.c'.

從關鍵構建步驟中,我們可以瞭解到:

  1. probe_execve和trace_execve兩個target都是all目標的下級目標,並且probe_execve和trace_execve是串行的。這個裏隱含的一個意思是,當trace_execve開始構建的時候,probe_execve已經完全構建完畢,probe_execve這個最終可執行文件已經生成完畢。此時,probe_execve構建過程中所依賴的所有中間文件都不再需要了。所以,probe_execve和trace_execve構建過程中依賴的中間文件是可以重名的。
  2. tools/lib/bpf/libbpf.a和helpers/uprobe_helper.o已經提前編譯好了,就不再做過多的說明了。最終的用戶態可執行加載程序的主要依賴鏈條如下。
trace_execve
├── trace_execve.o
│   ├── trace_execve.c
│   ├── trace_execve.skel.h
│   │   ├── trace_execve.hex
│   │   │   ├──progs/trace_execve.bpf.o
│   │   │   │   └── progs/trace_execve.bpf.c

再看一下主Makefile的源碼,爲了實現以上的目標依賴,我們連用了5個靜態模式規則(Static Pattern Rules)。

$(HELPER_OBJECTS): %.o:%.c

$(BPF_OBJECT):./progs/%.bpf.o:./progs/%.bpf.c

$(HEX_OBJECT):%.hex:./progs/%.bpf.o

$(SKEL_OBJECT):%.skel.h:%.hex

$(USER_OBJECT):%.o:%.c %.skel.h

$(LOADER_OBJECT): %:%.o

其中任何一個靜態模式規則的目標集合,都是通過項目根目錄下*.c文件的集合,進行局部字符串替換獲得。

SOURCES := $(wildcard *.c)
HELPER_OBJECTS := $(patsubst %.c,%.o,$(wildcard $(HELPERS_PATH)/*.c))
LOADER_OBJECT  := $(patsubst %.c,%,$(SOURCES))
USER_OBJECT    := $(patsubst %.c,%.o,$(SOURCES))
SKEL_OBJECT    := $(patsubst %.c,%.skel.h,$(SOURCES))
HEX_OBJECT     := $(patsubst %.c,%.hex,$(SOURCES))
BPF_OBJECT     := $(patsubst %.c,./progs/%.bpf.o,$(SOURCES))5.5、從file到memory實現讀取elf的轉變

本方案的主要邏輯是在主Makefile中實現,但也需要c代碼中做一些調整。bpf文件trace_execve.bpf.c並不需要任何修改,只需要在用戶態加載程序trace_execve.c做一些調整。

傳統的讀取bpf目標文件方式,相關代碼如下:

char filename[256] = "progs/trace_execve.bpf.o";
struct bpf_object * bpf_obj = bpf_object__open_file(filename, NULL);

改進後的讀取memory方式,相關代碼如下:

#include "skeleton.skel.h"

struct bpf_object * bpf_obj = bpf_object__open_mem(obj_buf, obj_buf_sz, NULL);

很明顯libbpf庫提供了bpf_object__open_file(bpf_object__open)和bpf_object__open_mem兩個函數用於讀取elf格式的bpf目標文件trace_execve.bpf.o。區別是bpf_object__open_file是在trace_execve運行時,再去讀取trace_execve.bpf.o文件內容,而bpf_object__open_mem是在編譯時,已經把elf內容編譯進trace_execve程序。至於bpf_object__open函數在libbpf庫的libbpf.c文件中是對bpf_object__open_file函數的封裝。

這兩個libbpf庫函數,最終都是調用elf標準庫函數實現了相關功能,具體代碼實現是在libbpf庫的libbpf.c文件中的bpf_object__elf_init函數中,代碼如下:

static int bpf_object__elf_init(struct bpf_object *obj){
        ......
        if (obj->efile.obj_buf_sz > 0) {
                elf = elf_memory((char *)obj->efile.obj_buf, obj->efile.obj_buf_sz);
        } else {
                obj->efile.fd = open(obj->path, O_RDONLY | O_CLOEXEC);
                ...... 
                elf = elf_begin(obj->efile.fd, ELF_C_READ_MMAP, NULL);
        }
        ......
}

可以看出,bpf_object__open_mem函數的最終實現是elf的elf_memory函數,bpf_object__open_file函數的最終實現是elf的elf_begin函數。

5.6、原生libbpf庫與libbpf-bootstrap的若干區別

相比較第3代的 libbpf-bootstrap框架方案和第2代的傳統libbpf庫方案,使用hexdump命令的原生libbpf庫第 2 代改進方案方案在實現方法上,有一些獨特的優勢。

這裏將這三種方案的主要區別歸納總結如下:

比較項 傳統libbpf庫的2代方案 libbpf-bootstrap的3代方案 hexdump的libbpf庫的2代改進方案
生成頭文件 bpftool gen skeleton hexdump
使用頭文件 將程序名trace_execve添加到頭文件名稱中trace_execve.skel.h 統一成一個固定的名稱skeleton.skel.h
加載函數 使用libbpf庫標準加載函數bpf_object__open_file();bpf_object__load();bpf_program__attach(); 將程序名添加到加載函數名稱中trace_execve_bpf__open();trace_execve_bpf__load();trace_execve_bpf__attach(); 使用libbpf庫標準加載函數bpf_object__open_mem();bpf_object__load();bpf_program__attach();

這裏補充下,trace_execve_bpf__open()函數的實現,也是間接通過libbpf庫的bpf_object__open_skeleton()函數,最終也調用了bpf_object__open_mem()函數。

5.7、使用attach_tracepoint替代attach

在ebpf用戶態程序的加載過程中,有一個attach的步驟。細心的讀者應該已經發現了,在trace_execve_libbpf130項目中,我們使用的是bpf_program__attach()函數實現的靜態探針點的attach。而在hexdump_skel_libbpf130項目中,我們使用的卻是bpf_program__attach_tracepoint()函數實現的靜態探針點的attach。區別是bpf_program__attach_tracepoint函數的參數中會指定靜態探針點的具體信息,而bpf_program__attach不用指定靜態探針點的信息。進一步閱讀bpf_program__attach函數的源代碼可以瞭解到,它是依靠內核態的bpf目標文件中SEC的節名稱信息來獲取和確定靜態探針點的信息的。總結這兩種方法如下:

  trace_execve.c中相關代碼 trace_execve.bpf.c中相關代碼
attach方案A bpf_program__attach(bpf_prog) SEC("tracepoint/syscalls/sys_enter_execve")
attach方案B bpf_program__attach_tracepoint(bpf_prog, "syscalls", "sys_enter_execve") SEC("tracepoint")

很明顯,在trace_execve.c和trace_execve.bpf.c的代碼中,只要有一處設置靜態探針點即可。如果兩處都設置,而且兩處設置的靜態探針點信息衝突的情況下,會以用戶態的bpf_program__attach_tracepoint函數設置的信息爲準。

libbpf庫中的bpf_link__destroy()函數是負責對attach函數生成的link進行銷燬的函數。attach和destroy的過程實際上就是對內核靜態探針點開啓和關閉的過程。

在這裏特別推薦使用方案B中的bpf_program__attach_tracepoint替代方案A中的bpf_program__attach方法,這樣方便我們在用戶態代碼中靈活的開關ebpf的採集。除了專門用於靜態探針點的bpf_program__attach_tracepoint()函數,還有適用於其他類型的專用的attach函數,例如bpf_program__attach_kprobe()、bpf_program__attach_kprobe()、bpf_program__attach_uprobe()和bpf_program__attach_usdt()等。

5.8、使用by_name替代by_title

在稍早一些libbpf庫中提供2個函數用於獲取bpf progam 類型數據,分別是bpf_object__find_program_by_name()函數和bpf_object__find_program_by_title()函數。以trace_execve_libbpf130項目的 bpf代碼爲例。

SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve_enter(struct syscalls_enter_execve_args *ctx){
    ......
}

其中tracepoint/syscalls/sys_enter_execve這個字符串就稱爲title,trace_execve_enter這個函數名就稱爲name。結合上文的結論,後續推薦bpf內核態代碼中都使用SEC("tracepoint")的語法格式,那麼使用by_title函數將不再能做出區分。因此這裏特別推薦大家今後使用by_name的函數替代by_titile的函數。而且,在最新版的libbpf庫中,也徹底移除了bpf_object__find_program_by_title()函數。

六、基於原生libbpf庫改進方案構建USDT和Uprobe項目

基於hexdump命令的改進型原生libbpf庫編程方案不但在內核態跟蹤診斷上表現完美,在用戶態應用進程的跟蹤診斷上依然可以表現得非常出色。本節內容將在上文的基礎上,繼續分析如何使用原生libbpf庫開發和構建USDT和Uprobe項目。

6.1、用戶態模擬程序

用戶態應用程序的ebpf,還需要準備一個模擬程序。尤其是針對USDT類型,還需要在模擬程序中進行靜態打點。本小節將提供一個如何打USDT跟蹤點的實例。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd mark_usdt_uprobe                                  # 進入項目目錄
$ make
$ sudo cp umark /usr/bin/
$ sudo umark >/dev/null 2>/dev/null &
$ make clean

執行完以上步驟,就啓動了用戶態模擬程序umark,後續即可通過USDT和Uprobe方式,追蹤umark進程的運行情況。

下面初步對umark模擬程序的代碼做一些介紹。

$ ls 
Makefile  README.md  sdt.h  umark.c

$ cat umark.c 
#include <unistd.h>
#include <stdio.h>
//#include <sys/sdt.h>
#include "sdt.h"

unsigned long long int func_uprobe1(unsigned long long int x){
    return x + 1;
}
unsigned long long int func_uprobe2(unsigned long long int x, unsigned long long int y){
    return x + y;
}
int main(int argc, char const *argv[]) {
    unsigned long long int i;
    int var1 = 10, var2 = 20, var3 = 30;
    for (i = 0; i < 86400000; i++) {
        sleep(1);
        DTRACE_PROBE1(groupa, probe1, var1);
        DTRACE_PROBE2(groupb, probe2, var2, var3);
        printf("hit uprobe1 %llu\n", func_uprobe1(i));
        printf("hit uprobe2 %llu\n", func_uprobe2(i + 3, i + 8));
    }
    return 0;
}

其中func_uprobe1和func_uprobe2是兩個C語言函數用於下文的uprobe跟蹤實例的追蹤。DTRACE_PROBE1和DTRACE_PROBE2是兩個宏函數,用於在umark.c程序中打USDT的靜態跟蹤點。最多支持傳入12個跟蹤點參數,即DTRACE_PROBE1、DTRACE_PROBE2,一直到DTRACE_PROBE12。probe1和probe2是這個靜態跟蹤點的name,groupa和groupb是跟蹤點name的分組名,可以省略。

DTRACE_PROBE1宏函數定義在std.h頭文件內,需要提前安裝頭文件所在包。

在rpm包環境,sdt.h頭文件屬於systemtap-sdt-devel這個rpm包。

$ find /usr/include/ -name sdt.h
/usr/include/sys/sdt.h

$ rpm -qf /usr/include/sys/sdt.h
systemtap-sdt-devel-4.8-2.0.2.al8.x86_64

在deb包環境,sdt.h頭文件屬於systemtap-sdt-dev這個deb包。

$ find /usr/include/ -name sdt.h
/usr/include/x86_64-linux-gnu/sys/sdt.h

$ dpkg -S /usr/include/x86_64-linux-gnu/sys/sdt.h
systemtap-sdt-dev:amd64: /usr/include/x86_64-linux-gnu/sys/sdt.h

令人欣慰的是,這個sdt.h頭文件並無太多額外依賴,簡單修改後,可以獨立維護。於是,我們可以將其拷貝到本項目根目錄。並將的頭文件引用方式改爲"sdt.h"。

6.2、構建基於libbpf庫的USDT和Uprobe項目

下面我們就進一步介紹下使用第 2 代改進編程方案的ebpf跟蹤用戶態進程的解決方案。開始體驗我們的eBPF項目trace_user_libbpf130,進入項目代碼。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_user_libbpf130                              # 進入項目目錄
$ make
$ sudo ./uprobe_test
func_uprobe1 2374242 4604 umark 1534 bash 0 23368 23373
func_uprobe2 2374242 4604 umark 1534 bash 0 23371 23376

$ sudo ./usdt_test
func_usdt1 2375442 4604 umark 1534 bash 0 10 17
func_usdt2 2375442 4604 umark 1534 bash 0 20 30

分別執行uprobe_test和usdt_test兩個命令,對編譯結果進行驗證,均完美驗證通過。

trace_user_libbpf130項目的構建和編譯過程與前面項目hexdump_skel_libbpf130無太多差異,不再做過多贅述。下文將着重對本項目中USDT和Uprobe的相關C語言源碼進行解析。

6.3、USDT代碼解析

trace_user_libbpf130項目中的USDT部分,開啓了2個usdt靜態探針點的跟蹤,這2個靜態探針點分別是probe1和probe2。

第一個靜態探針點實例,選擇在attach時,通過bpf_program__attach_usdt函數的參數指定靜態探針點的相關信息。包括跟蹤的進程信息"/usr/bin/umark",usdt組名信息"groupa",usdt名稱信息"probe1"等,代碼如下:

bpf_program__attach_usdt(bpf_prog1, -1, "/usr/bin/umark", "groupa", "probe1", NULL);

第二個靜態探針點實例,選擇在bpf目標文件中,通過SEC宏的方式指定靜態探針點的相關信息。包括跟蹤的進程信息"/usr/bin/umark",usdt組名信息"groupb",usdt名稱信息"probe2"等,代碼如下:

SEC("usdt//usr/bin/umark:groupb:probe2")

6.4、BPF_USDT宏函數解析

目前主流的USDT類型的ebpf代碼實例,在bpf目標文件中都使用BPF_USDT宏函數來定義ebpf的處理函數,例如本項目實例中。

int BPF_USDT(usdt_probe1, int x)

在這裏,宏函數BPF_USDT的第1個參數"usdt_probe1"纔是真正的函數名,也就是前文所述by_name的name信息。宏函數的第2個參數"int x"纔是usdt_probe1函數的第一個參數,依次類推。

各種USDT類型的ebpf代碼實例中,很少見到對這個宏函數BPF_USDT原理的分析。此處,我們藉助第二個USDT靜態探針點在bpf目標文件中的使用來解析它。代碼實例的關鍵部分如下:

int usdt_probe2(struct pt_regs *ctx);

static inline __attribute__((always_inline)) typeof(usdt_probe2(0)) ____usdt_probe2(struct pt_regs *ctx, int x, int y);

typeof(usdt_probe2(0)) usdt_probe2(struct pt_regs *ctx) {
    return ____usdt_probe2(ctx, ({ long _x; bpf_usdt_arg(ctx, 0, &_x); (void *)_x; }), ({ long _x; bpf_usdt_arg(ctx, 1, &_x); (void *)_x; }));
}

static inline __attribute__((always_inline)) typeof(usdt_probe2(0)) ____usdt_probe2(struct pt_regs *ctx, int x, int y)
{
    ......
}

這4行代碼,前兩行是函數聲明,後兩行是函數定義。usdt_probe2函數內部調用了____usdt_probe2函數。一些代碼解讀:

  1. always_inline,意味着無論優化設置如何,編譯器都應該始終將這個函數內聯到任何調用它的地方。
  2. typeof(usdt_probe2(0)) 用於確定 usdt_probe2 的返回類型,從而確保 ____usdt_probe2 與 usdt_probe2 有相同的返回類型。
  3. ({ long _x; bpf_usdt_arg(ctx, 0, &_x); (void *)_x; }) 這個複合語句用於獲取USDT探針的參數值。
  4. 使用 bpf_usdt_arg 輔助函數來獲取探針的第一個參數,並將其存儲到局部變量 _x 中。再將 _x 強制轉換爲 void * 類型並傳遞給 ____usdt_probe2 函數。同樣的操作也對第二個參數 y 進行。

特別強調一下bpf_usdt_arg輔助函數來自於usdt.bpf.h頭文件,但本項目有2個usdt.bpf.h頭文件,其中一個在libbpf庫中,另外一個在./helpers/目錄下,helpers 目錄下的是經過本項目改造過的。此示例中生效的是./helpers/目錄下的。

$ cd $NATIVE_LIBBPF                                    # 返回工作目錄
$ cd trace_user_libbpf130                              # 進入項目目錄
$ find . -name usdt.bpf.h
./tools/lib/bpf/usdt.bpf.h
./helpers/usdt.bpf.h6.5、Uprobe代碼解析

trace_user_libbpf130項目中的Uprobe部分,開啓了2個uprobe類型探針點的跟蹤,這2個uprobe探針點分別是probe1和probe2。

第一個uprobe探針點實例,選擇在attach時,通過bpf_program__attach_uprobe函數的參數指定uprobe探針點的相關信息。包括uprobe的類型(0表示函數進入時,1表示函數返回時),跟蹤的進程信息"/usr/bin/umark",被跟蹤的函數在進程中的偏移量 func_off1等。需要提前通過get_elf_func_offset()函數計算出這個偏移量,此函數定義在了helpers/uprobe_helper.c文件內。相關代碼如下:

func_off1 = get_elf_func_offset("/usr/bin/umark", "func_uprobe1");
bpf_program__attach_uprobe(bpf_prog1, 0, -1, "/usr/bin/umark", func_off1);

第二個uprobe探針點實例,選擇在bpf目標文件中,通過SEC宏的方式指定uprobe探針點的相關信息。包括跟蹤的進程信息"/usr/bin/umark",被跟蹤的應用進程中的函數"func_uprobe2"等。此種情況,libbpf庫會自動計算這個偏移量。代碼如下:

SEC("uprobe//usr/bin/umark:func_uprobe2")

6.6、BPF_KPROBE函數解析

目前主流的Uprobe類型的ebpf代碼實例,在bpf目標文件中都使用BPF_KPROBE宏函數來定義ebpf的處理函數,例如本項目實例中。

int BPF_KPROBE(user_probe1, unsigned long long int x)

在這裏,宏函數BPF_KPROBE的第1個參數"user_probe1"纔是真正的函數名,也就是前文所述by_name的name信息。宏函數的第2個參數"unsigned long long int x"纔是user_probe1函數的第一個參數,依次類推。

各種Uprobe類型的ebpf代碼實例中,也同樣很少見到對這個宏函數BPF_KPROBE原理的分析。此處,我們藉助第二個Uprobe探針點在bpf目標文件中的使用來解析它。關鍵的代碼實例如下:

long user_probe2(struct pt_regs *ctx);

inline typeof(user_probe2(0)) ____user_probe2(struct pt_regs *ctx, unsigned long long int x, unsigned long long int y);

inline typeof(user_probe2(0)) ____user_probe2(struct pt_regs *ctx, unsigned long long int x, unsigned long long int y)
{
    ......
}

typeof(user_probe2(0)) user_probe2(struct pt_regs *ctx) {
    return ____user_probe2(ctx, (unsigned long long int)PT_REGS_PARM1(ctx), (unsigned long long int)PT_REGS_PARM2(ctx));
}

這4行代碼,前兩行是函數聲明,後兩行是函數定義。user_probe2函數內部調用了____user_probe2函數。一些代碼解讀:

  1. inline typeof(user_probe2(0)) ____user_probe2(struct pt_regs *ctx, unsigned long long int x, unsigned long long int y); 這是內聯函數____user_probe2的聲明。
  2. typeof(user_probe2(0))用於確定____user_probe2函數的返回類型,保證與user_probe2函數的返回類型一致。
  3. typeof(user_probe2(0)) user_probe2(struct pt_regs *ctx) { return ____user_probe2(ctx, (unsigned long long int)PT_REGS_PARM1(ctx), (unsigned long long int)PT_REGS_PARM2(ctx)); } 這是user_probe2函數的定義。它使用PT_REGS_PARM1(ctx)和PT_REGS_PARM2(ctx)宏來獲取用戶空間探針傳遞給eBPF程序的前兩個參數。

如果對於以上的代碼解讀如果還有不明白的地方,可以嘗試問問GPT。

作者:聞茂泉

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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