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

描述

01

在上一篇文章《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
     

 

02

eBPF编程方案简介

为了简化 eBPF程序的开发流程,降低开发者在使用 libbpf 库时的入门难度,libbpf-bootstrap 框架应运而生。基于libbpf-bootstrap框架的编程方案是目前网络上看到的最主流编程方案。此外,网络上也偶见比较古老的仅依赖一个bpf_load.c文件的C语言编程方案,这个方案并不需要依赖libbpf库的支持。

主流的C语言实现的eBPF编程方案,大体上就是以下三种,笔者总共将其归纳为3代。

LINUX内核

除了经典的C语言编程方案,一些编程框架还选择使用Python语言,Go语言,或者Rust语言作为用户态加载的实现语言。

尽管libbpf-bootstrap骨架C语言方案、基于libbpfgo库的go语言方案等已经被大家广泛使用和接受。但笔者认为基于原生libbpf库的eBPF编程方案仍然具备很多独特的优势。以下是原生libbpf库eBPF编程方案的一些独特优势:

更深的控制和灵活性:直接使用原生libbpf 库的方案意味着可以与更底层交互,实现更多的控制,定制加载和管理 eBPF 程序和 maps 过程,满足更复杂的需求。

更好的学习和理解:libbpf-bootstrap封装抽象屏蔽了很多细节,直接使用原生libbpf可以对 eBPF 子系统有更深入的理解,有利于开发者对 eBPF 内部工作原理的理解。

更细粒度的依赖管理:直接使用原生libbpf库能够指定依赖的 libbpf 库版本和功能,进而更精细化地管理项目依赖关系。

更好的低版本内核适应性:基于原生libbpf库的方案,在低版本操作系统发行版和低版本内核上可以有更好的兼容性。

本文将由浅入深介绍第 2 代原生libbpf库的eBPF编程方案,并提出一种改进思路。

03

准备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
     

 

04

构建基于原生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命令进行目录对比。

目录native_libbpf_guide/trace_execve_libbpf130/tools/lib/bpf/内容,除Makefile内容外都来自目录~/libbpf-1.3.0/src/。

目录native_libbpf_guide/trace_execve_libbpf130/tools/include/来自目录~/libbpf-1.3.0/include/,所有内容都完全一致。

除以上两部分来自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,分别如下:

./Makefile是主文件,用于生成用户态eBPF程序trace_execve。

./progs/Makefile 用于生成内核态BPF程序trace_execve.bpf.o。

./tools/lib/bpf/Makefile 用于生成libbpf.a静态库。

./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的目录搜索选项还有很多,它们优先级的顺序依次如下:

头文件引用方式include "myheader.h",则在当前文件所在目录查找myheader.h头文件。

头文件引用方式include "myheader.h",如果有-iquote mydir选项,则在mydir目录查找myheader.h头文件。

头文件引用方式include ,如果有-I mydir选项,则在mydir目录查找myheader.h头文件。

头文件引用方式include ,如果有-isystem mydir选项,则在mydir目录查找myheader.h头文件。

头文件引用方式include ,继续在标准系统目录(Standard system directories)查找myheader.h头文件。标准系统目录是指/usr/lib64/clang/15.0.7/include 、/usr/local/include 和/usr/include。

头文件引用方式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 
#include 
#include 


#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  

 

下面对一些关键环节做一些解析:

头文件vmlinux.h由bpftool工具在编译时动态生成,vmlinux.h包含了绝大多数bpf程序的内核态和用户态(uapi)依赖。通过编译选项-I./../tools/lib/可以搜索到vmlinux.h头文件。

通过-I./../tools/lib/编译选项,可以在./tools/lib/目录下的bpf子目录中查找到bpf_helpers.h和bpf_tracing.h头文件,这些头文件都是对vmlinux.h头文件内核态依赖的补充。

通过-iquote ./../include/编译选项,可以在./include/目录中查找到trace_execve.h和common.h头文件。

在上面这些头文件依赖的预处理过程中,会依赖一些宏变量来决定预处理的展开逻辑。上面编译命令中的宏就是起这些作用,-DENABLE_ATOMICS_TESTS -D__KERNEL__ -D__BPF_TRACING__ -D__TARGET_ARCH_x86。比如在bpf_tracing.h头文件中,就有#if defined(__TARGET_ARCH_x86)的宏判断语句,来决定预处理展开逻辑走x86分支。

编译选项-target bpf,指示Clang将代码生成为针对eBPF目标的目标代码。编译选项-mcpu=v3,指示Clang生成针对v3版本的eBPF处理器的目标代码。编译选项-mlittle-endian:指示Clang生成适用于小端序处理器的目标代码。

通过-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 
#include 
#include 
#include 
#include 
#include 
#include 


// from libbpf
#include 
#include 


#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

 

通过-I./tools/include/编译选项,可以在./tools/include/目录下的linux子目录中查找到头文件。

通过-I./tools/lib/编译选项,可以在./tools/lib/目录下的bpf子目录中查找到头文件。在一些古老的代码示例中,有这样使用头文件的用法,目前最新的ebpf项目实例,都会将libbpf库的libbpf.h以及同目录的头文件都放到bpf子目录下,因此推荐统一使用的用法。

通过-iquote ./include/编译选项,可以在./include/目录中查找到trace_execve.h和common.h头文件。

其他头文件都可以在由kerne-headers包提供的标准系统目录(Standard system directories)的/usr/include/目录及子目录中查找到。所以,最终会在/usr/include/linux/perf_event.h位置被查找到。可以看出同样是形式的头文件,却在两个完全不同的搜索路径查找到。

4.7  libbpf.a静态库编译解析

关于libbpf.a静态库的编译过程,上一篇文章已经有所介绍。这里仅再次强调下,在本项目中,我们完全实现了libbpf库的自主可控,可控源代码,可控编译构建过程。这至少给我们带来如下两方面好处:

对于一些ebpf的资深人士,可以自主修改libbpf库中不尽如人意的地方,实现满足自己业务需求的优化。

对于一些ebpf的初学者,完全可以在libbpf库中任意感兴趣的地方,通过插入printf或其他断点方式,深入学习libbpf库的原理。

05

改进基于原生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'.

 

从关键构建步骤中,我们可以了解到:

probe_execve和trace_execve两个target都是all目标的下级目标,并且probe_execve和trace_execve是串行的。这个里隐含的一个意思是,当trace_execve开始构建的时候,probe_execve已经完全构建完毕,probe_execve这个最终可执行文件已经生成完毕。此时,probe_execve构建过程中所依赖的所有中间文件都不再需要了。所以,probe_execve和trace_execve构建过程中依赖的中间文件是可以重名的。

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 代改进方案方案在实现方法上,有一些独特的优势。

这里将这三种方案的主要区别归纳总结如下:

LINUX内核

这里补充下,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()函数。

06

基于原生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 
#include 
//#include 
#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
", func_uprobe1(i));
        printf("hit uprobe2 %llu
", 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/umarkprobe2")

 

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函数。一些代码解读:

always_inline,意味着无论优化设置如何,编译器都应该始终将这个函数内联到任何调用它的地方。

typeof(usdt_probe2(0)) 用于确定 usdt_probe2 的返回类型,从而确保 ____usdt_probe2 与 usdt_probe2 有相同的返回类型。

({ long _x; bpf_usdt_arg(ctx, 0, &_x); (void *)_x; }) 这个复合语句用于获取USDT探针的参数值。

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

 

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函数。一些代码解读:

inline typeof(user_probe2(0)) ____user_probe2(struct pt_regs *ctx, unsigned long long int x, unsigned long long int y);  这是内联函数____user_probe2的声明。

typeof(user_probe2(0))用于确定____user_probe2函数的返回类型,保证与user_probe2函数的返回类型一致。

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。

07

技术交流

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




审核编辑:刘清

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分