eBPF动手实践系列三:基于原生libbpf库的eBPF编程改进方案

作者:闻茂泉

一、 欲穷千里目,更上一层楼

在上一篇文章《eBPF动手实践系列二:构建基于纯C语言的eBPF项目》中,我们初步实现了脱离内核源码进行纯C语言eBPF项目的构建。libbpf库在早期和内核源码结合的比较紧密,如今的libbpf库更加成熟,已经完全脱离内核源码独立发展。

为了更加具体的理解linux内核版本演进和libbpf版本演进的关系,本文在"附录A"中总结了各个内核版本源码示例中所依赖的libbpf库的对应版本信息。

大部分版本的内核获取libbpf版本的方法如下,从libbpf库目录的libbpf.map文件中提取最大的版本号信息。这里的"source"为内核源码所在目录。

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

较早版本的内核在./tools/lib/bpf/Makefile文件中直接定义了libbpf的版本信息。

ini 复制代码
$ 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 等。

详细安装步骤如下:

shell 复制代码
$  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 等发行版及其兼容环境。

详细安装步骤如下:

shell 复制代码
$  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用来存储工作目录。

shell 复制代码
$ 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 系统调用产生的事件。

shell 复制代码
$ 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库以外的文件,其余都由本项目原创贡献。
bash 复制代码
$ 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目录下。

bash 复制代码
$ 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解析

bash 复制代码
$ 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文件的头文件依赖如下。

arduino 复制代码
$ 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程序的编译命令。

shell 复制代码
$ 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文件的头文件依赖如下。

arduino 复制代码
$ 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"

从前面项目构建过程中,也可以提取出完整的用户态程序的编译命令。

bash 复制代码
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项目的构建。但截止到目前,此方案还有一个明显的缺陷。让我们继续上一篇的案例来分析,在搭建完开发环境后执行如下步骤。

shell 复制代码
$ 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工具,并且生成符合自己期望的头文件。

swift 复制代码
$ hexdump -v -e '"\\x" 1/1 "%02x"' trace_execve.bpf.o > trace_execve.hex

5.3、 深入构建基于原生libbpf库的eBPF项目

下面我们就尝试依靠hexdump命令实现一个单一可执行文件的解决方案。开始体验我们基于第 2 代编程方案改进的eBPF项目,进入项目代码。

shell 复制代码
$ 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目标文件时的情形。

shell 复制代码
$ 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构建的详细过程。

shell 复制代码
$ cd $NATIVE_LIBBPF                                    # 返回工作目录
$ cd hexdump_skel_libbpf130                            # 进入项目目录 
$ make clean
$ make --debug=v,m SHELL="bash -x" > make.log 2>&1

对于构建日志的分析可以参考前面文章,我们把关键环节提取出来。

matlab 复制代码
$ 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已经提前编译好了,就不再做过多的说明了。最终的用户态可执行加载程序的主要依赖链条如下。
bash 复制代码
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)。

ruby 复制代码
$(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文件的集合,进行局部字符串替换获得。

makefile 复制代码
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目标文件方式,相关代码如下:

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

改进后的读取memory方式,相关代码如下:

arduino 复制代码
#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函数中,代码如下:

rust 复制代码
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代码为例。

arduino 复制代码
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跟踪点的实例。

shell 复制代码
$ 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模拟程序的代码做一些介绍。

arduino 复制代码
$ 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包。

ruby 复制代码
$ 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包。

bash 复制代码
$ 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,进入项目代码。

shell 复制代码
$ 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"等,代码如下:

arduino 复制代码
bpf_program__attach_usdt(bpf_prog1, -1, "/usr/bin/umark", "groupa", "probe1", NULL);

第二个静态探针点实例,选择在bpf目标文件中,通过SEC宏的方式指定静态探针点的相关信息。包括跟踪的进程信息"/usr/bin/umark",usdt组名信息"groupb",usdt名称信息"probe2"等,代码如下:

scss 复制代码
SEC("usdt//usr/bin/umark:groupb:probe2")

6.4、 BPF_USDT宏函数解析

目前主流的USDT类型的ebpf代码实例,在bpf目标文件中都使用BPF_USDT宏函数来定义ebpf的处理函数,例如本项目实例中。

java 复制代码
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目标文件中的使用来解析它。代码实例的关键部分如下:

scss 复制代码
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/目录下的。

shell 复制代码
$ cd $NATIVE_LIBBPF                                    # 返回工作目录
$ cd trace_user_libbpf130                              # 进入项目目录
$ find . -name usdt.bpf.h
./tools/lib/bpf/usdt.bpf.h
./helpers/usdt.bpf.h

6.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文件内。相关代码如下:

ini 复制代码
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库会自动计算这个偏移量。代码如下:

scss 复制代码
SEC("uprobe//usr/bin/umark:func_uprobe2")

6.6、 BPF_KPROBE 函数解析

目前主流的Uprobe类型的ebpf代码实例,在bpf目标文件中都使用BPF_KPROBE宏函数来定义ebpf的处理函数,例如本项目实例中。

arduino 复制代码
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目标文件中的使用来解析它。关键的代码实例如下:

arduino 复制代码
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。

七、技术交流

本文为eBPF动手实践系列的第三篇,我们实现了基于libbpf库的纯C语言eBPF项目的构建。

下一篇我们会进一步深入到 ebpf 程序内部的代码逻辑追本溯源,探寻ebpf程序的核心逻辑。

欢迎有想法或者有问题的同学,加群交流eBPF技术以及工程实践。

  • SREWorks数智运维工程群(钉钉群号:35853026)

  • 跟踪诊断技术 SIG 开发者&用户群(钉钉群号:33304007)

附录

附录A

各个内核版本源码示例所依赖的libbpf库的对应版本信息。

kernel版本 libbpf版本 备注
kernel-6.5 LIBBPF_1.3.0
kernel-6.4 LIBBPF_1.2.0
kernel-6.3 LIBBPF_1.2.0
kernel-6.2 LIBBPF_1.1.0
kernel-6.1 LIBBPF_1.0.0
kernel-6.0 LIBBPF_1.0.0
kernel-5.19 LIBBPF_1.0.0 开始有usdt.bpf.h文件
kernel-5.18 LIBBPF_0.8.0
kernel-5.17 LIBBPF_0.7.0
kernel-5.16 LIBBPF_0.6.0
kernel-5.15 LIBBPF_0.5.0
kernel-5.14 LIBBPF_0.5.0
kernel-5.13 LIBBPF_0.4.0
kernel-5.12 LIBBPF_0.3.0
kernel-5.11 LIBBPF_0.3.0
kernel-5.10 LIBBPF_0.2.0
kernel-5.9 LIBBPF_0.1.0
kernel-5.8 LIBBPF_0.0.9
kernel-5.7 LIBBPF_0.0.8
kernel-5.6 LIBBPF_0.0.7
kernel-5.5 LIBBPF_0.0.6
kernel-5.4 LIBBPF_0.0.5
kernel-5.3 LIBBPF_0.0.4
kernel-5.2 LIBBPF_0.0.3
kernel-5.1 LIBBPF_0.0.2
kernel-5.0 LIBBPF_0.0.1
kernel-4.19 LIBBPF_0.0.1
kernel-4.18 LIBBPF_0.0.1
kernel-4.9 LIBBPF_0.0.1
相关推荐
七夜zippoe11 小时前
CANN Runtime任务描述序列化与持久化源码深度解码
大数据·运维·服务器·cann
Fcy64812 小时前
Linux下 进程(一)(冯诺依曼体系、操作系统、进程基本概念与基本操作)
linux·运维·服务器·进程
袁袁袁袁满12 小时前
Linux怎么查看最新下载的文件
linux·运维·服务器
代码游侠12 小时前
学习笔记——设备树基础
linux·运维·开发语言·单片机·算法
Harvey90313 小时前
通过 Helm 部署 Nginx 应用的完整标准化步骤
linux·运维·nginx·k8s
珠海西格电力科技14 小时前
微电网能量平衡理论的实现条件在不同场景下有哪些差异?
运维·服务器·网络·人工智能·云计算·智慧城市
释怀不想释怀14 小时前
Linux环境变量
linux·运维·服务器
zzzsde14 小时前
【Linux】进程(4):进程优先级&&调度队列
linux·运维·服务器
聆风吟º16 小时前
CANN开源项目实战指南:使用oam-tools构建自动化故障诊断与运维可观测性体系
运维·开源·自动化·cann
NPE~16 小时前
自动化工具Drissonpage 保姆级教程(含xpath语法)
运维·后端·爬虫·自动化·网络爬虫·xpath·浏览器自动化