eBPF动手实践系列二:构建基于纯C语言的eBPF项目

发布于:2023-05-15 ⋅ 阅读:(433) ⋅ 点赞:(0)

千里之行,始于足下

了解和掌握纯c语言的eBPF编译和使用,有助于我们加深对于eBPF技术原理的进一步掌握,也有助于开发符合自己业务需求的高性能的ebpf程序。上一篇文章《eBPF动手实践系列一:解构内核源码eBPF样例编译过程》中,我们了解了基于内核源码的ebpf程序的编译步骤。其中编译过程对内核源码的依赖的内容,主要体现在对kernel-devel和kernel-headers两个rpm包的文件内容的依赖(centos环境下)。这给我们脱离内核源码进行独立的ebpf程序编译提供了可能。本文将介绍如何仅依赖于kernel-devel和kernel-headers等rpm包进行纯c语言的eBPF程序的编译和使用。

eBPF开发的基础环境准备

主流的linux发行版大多是基于rpm包或deb包的包管理系统。不同的包管理系统,搭建eBPF开发环境时所依赖的包,也略有差别。本文将分别进行介绍。

2.1  rpm包基础环境初始化

在centos、fedora和anolis等发行版环境,需要安装一些编译过程的基础包、编译工具包、库依赖包和头文件依赖包等。具体安装步骤如下:

$  yum install git make rsync                      # 基础包
$  yum install clang llvm elfutils-libelf-devel    # 编译工具和依赖库包
$  yum install kernel-headers-$(uname -r) kernel-devel-$(uname -r)    # 头文件依赖包

2.2  deb包基础环境初始化

在ubuntu、debian等发行版环境,需要安装一些编译过程的基础包、编译工具包、库依赖包和头文件依赖包等。具体安装步骤如下:

$  apt-get update                                  # 更新apt源信息
$  apt install git make rsync                      # 基础包 
$  apt install clang llvm libelf-dev               # 编译工具和依赖库包
$  apt install linux-libc-dev linux-headers-$(uname -r)     # 头文件依赖包

构建基于纯C语言的eBPF项目

3.1  纯C语言编译

在eBPF基础环境的准备完成之后,就可以开始进行纯C语言的eBPF项目的搭建。这里我们仍然选择使用centos8u+4.18内核为例来说明构建过程。首次构建项目环境还需要依赖一次内核源码。下载内核源码,我们推荐使用阿里云的镜像网站。

$  wget https://mirrors.aliyun.com/linux-kernel/v4.x/linux-4.18.tar.gz
$  tar -zxvf linux-4.18.tar.gz

获取ebpf_purec_newbie git项目的代码。并且通过其中的initialize.sh脚本,初始化eBPF项目。initialize.sh脚本需要两个参数。

  • 参数1用于指定内核源码的路径,
  • 参数2用于指定新初始化的ebpf项目的目录,参数2可省略,省略后将默认设置为 /tmp/ebpf_project。
$  git clone https://github.com/alibaba/sreworks-ext.git -b master
$  cd sreworks-ext/demos/ebpf_purec_newbie
$  ./initialize.sh ~/linux-4.18 /tmp/ebpf_project

初始化后,就可以进入到eBPF项目目录,执行make命令,对内核源码自带的eBPF样例程序trace_output进行编译。

$  cd /tmp/ebpf_project
$  make
$  sudo ./trace_output
recv 662097 events per sec

执行trace_output命令,对编译结果进行验证,验证完美通过。

3.2  一些特殊情况的处理

这里提供的ebpf_purec_newbie的项目源码,包括其中的initialize.sh脚本,适用于4.18及以上各个内核版本。但是其中一些版本的内核源码,也存在一些不完善的地方。实际编译或者运行过程中,可能会存在一些问题。现将一些常见问题及处理方法做一些介绍。

3.2.1  函数test_attr__open定义相关问题

在5.4到5.9版本的内核编译时,可能会遇到undefined reference to `test_attr__open'相关的问题。解决办法是打开Makefile中的HAVE_ATTR_TEST宏。具体可在编译前,执行如下命令修改Makefile文件。

$ sed -i '/DHAVE_ATTR_TEST/{ s/^#//;}' /tmp/ebpf_project/Makefile
3.2.2  执行ebpf程序报Operation not permitted错误

在一些版本的内核,运行编译完的ebpf程序trace_output时,会报Operation not permitted错误。解决办法是调大进程的MEMLOCK资源限制。具体可在trace_output_user.c的main函数中snprintf函数之前,添加如下代码。

+    struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
+    setrlimit(RLIMIT_MEMLOCK, &r);     
snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
````
同时还需要添加相关头文件。

#include <sys/resource.h>

# **ebpf_project初始化脚本解析**

计算机技术是一门建立在实验基础上的学科。很多时候,一行hello world的成功输出,打消了我们对代码的疑虑。

有了前文trace_output命令成功运行的基础,我们可以一鼓作气,深入代码的细节,探究纯C语言的eBPF项目编译的过程,加深我们对于eBPF技术原理的进一步理解。

在ebpf_purec_newbie代码项目下,只包含3个文件:initialize.sh、Makefile和Makefile.libbpf。


```
$  ls ebpf_purec_newbie/initialize.sh  Makefile  Makefile.libbpf

其中initialize.sh脚本是生成新的eBPF项目ebpf_project的脚本。下面介绍ebpf_project项目各个目录或者文件的来源。

  • ebpf_project/tools目录的内容主要是来自于内核源码目录linux-4.18/tools。
  • ebpf_project/helpers目录的内容主要是来自于内核源码中samples/bpf/和tools/testing/selftests/bpf/两个目录中的一些helper类型的文件。内核源码中将这些helper类型的文件和样例文件混杂在一起,给初学者造成一些学习上的混乱。有鉴于此,我们统一集中到一个helpers目录下。
  • trace_output_kern.ctrace_output_user.c这两个是ebpf样例文件,来自于内核源码的samples/bpf/目录。这两个文件是成对出现的,需重点关注,后文我们还会提到。
  • ebpf_project/Makefile文件来自于项目ebpf_purec_newbie/Makefile文件。
  • ebpf_project/tools/lib/bpf/Makefile文件来自于项目ebpf_purec_newbie/Makefile.libbpf文件。

以上ebpf_project项目的内容中,除了两个Makefile文件,其他文件都复制于内核源码。这2个Makefile文件是整个项目的菁华所在,也是我们需要进一步深入理解的地方。其中Makefile.libbpf是用于生成libbpf.a静态库。另外一个Makefile是项目的主Makefile,用于生成项目的可执行文件trace_output和内核态bpf文件trace_output_kern.o。下文将分别针对Makefile.libbpf和主Makefile的代码逻辑进行分析。

ebpf_project项目Makefile解析

5.1  Makefile解析过程提取

通常情况下了解Makefile的解析过程,需要阅读Makefile源码,不过本文提出另外一种分析思路,那就是巧妙地使用make命令的--debug选项参数,SHELL环境变量参数 ,以及makefile语法中的warning控制函数。 依靠这些技巧,我们可以轻松地对makefile的详细解析过程进行提取。

$  cd /tmp/ebpf_project/
$  make clean
$  cd tools/lib/bpf/
$  make --debug=v,m SHELL="bash -x" > libbpf_make.log 2>&1
$  cd /tmp/ebpf_project/
$  make --debug=v,m SHELL="bash -x" > main_make.log 2>&1
````
分别获取了生成libbpf.a静态库的日志文件libbpf_make.log,以及生成ebpf可执行程序的主日志文件main_make.log。

### **5.2  生成libbpf.a静态库的Makefile解析**

通过对'Considering target file'内容的过滤,可以了解到tools/lib/bpf/Makefile展开过程。通过这样的一层一层的构建过程,最终将tools/lib/bpf/目录下的几个文件bpf.c、btf.c、libbpf.c和nlattr.c构建了libbpf.a静态库文件。


$ cat libbpf_make.log | grep 'Considering target file'

Considering target file `all'.
Considering target file `libbpf-in.o'.

Considering target file `precheck'.      
  Considering target file `force'.      
  Considering target file `elfdep'.      
  Considering target file `bpfdep'.    
Considering target file `btf.o'.     
  Considering target file `btf.c'.    
Considering target file `libbpf.o'.      
  Considering target file `libbpf.c'.    
Considering target file `nlattr.o'.      
  Considering target file `nlattr.c'.   
Considering target file `bpf.o'.     
  Considering target file `bpf.c'.    
Considering target file `libbpf_errno.o'.      
  Considering target file `libbpf_errno.c'.    
Considering target file `str_error.o'.      
  Considering target file `str_error.c'.

以libbpf.o target为例,可以看到具体一个target的完整解析过程。通常,在“Must remake target ”后会有“Invoking recipe from Makefile”,再之后便是我们最关心的实际执行的命令(recipe)部分。

Considering target file `libbpf.o'.

 File `libbpf.o' does not exist.
  Considering target file `libbpf.c'.
   Finished prerequisites of target file `libbpf.c'.
  No need to remake target `libbpf.c'.
 Finished prerequisites of target file `libbpf.o'.
Must remake target `libbpf.o'.

Invoking recipe from Makefile:143 to update target `libbpf.o'.
gcc '-DBUILD_STR(s)=#s' -o libbpf.o -c libbpf.c

Successfully remade target file `libbpf.o'.

最终在all这个target下,通过ar rcs libbpf.a libbpf-in.o这个命令(recipe)生成了libbpf.a静态库文件。

### **5.3  项目主Makefile解析**

这里同样也可以通过对'Considering target file'内容的过滤,了解到主Makefile的展开过程。每一个target部分,也会有与其对应的"Invoking recipe from Makefile"部分,以及实际执行的命令(recipe)部分。



```
$  cat main_make.log | grep 'Considering target file'

Considering target file `all'.  
  Considering target file `trace_output'.    
   Considering target file `bpf_prog'.      
    Considering target file `verify_target_bpf'.        
     Considering target file `verify_cmds'.          
      Considering target file `clang'.         
      Considering target file `llc'.      
    Considering target file `trace_output_kern.o'.        
     Considering target file `trace_output_kern.c'.    
   Considering target file `tools/lib/bpf/libbpf.a'.    
   Considering target file `helpers/trace_helpers.o'.      
     Considering target file `helpers/trace_helpers.c'.    
   Considering target file `helpers/bpf_load.o'.      
     Considering target file `helpers/bpf_load.c'.    
   Considering target file `trace_output_user.o'.      
     Considering target file `trace_output_user.c'.

以上一层一层的构建步骤,产出目标文件主要是2个:trace_output_kern.o和trace_output。

  • 其中trace_output_kern.o目标文件主要由样例文件trace_output_kern.c编译产生。
  • 而trace_output目标文件主要由样例文件trace_output_user.c,两个helper文件bpf_load.c和trace_helpers.c,以及上一步的产物libbpf.a静态库编译产生。这里的target libbpf.a的部分的recipe命令,是最终触发libbpf Makefile的make构建过程的代码。

关键编译命令的编译参数解析

理解了makefile的解析过程,再来看下几个关键编译命令(recipe)的编译参数。在第一篇《解构内核源码eBPF样例编译过程》中,我们已经初步介绍了一些编译命令的编译参数含义。这里再做一些必要的补充。

6.1  内核态bpf程序(trace_output_kern.o)编译参数解析

内核态bpf程序trace_output_kern.o文件,是由样例文件trace_output_kern.c文件使用clang命令编译产生。

  1. 编译trace_output_kern.o命令的选项参数中,如下8个选项参数依赖的文件路径正好是kernel-devel这个rpm包的内容,这也是我们脱离内核源码编译的一个地方。
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/arch/x86/include  
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/arch/x86/include/generated 
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/include 
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/arch/x86/include/uapi 
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/arch/x86/include/generated/uapi 
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/include/uapi 
-I/lib/modules/4.18.0-348.7.1.el8_5.x86_64/build/include/generated/uapi 
-include /lib/modules/4.18.0-348.7.1.el8_5.x86_64/build//include/linux/kconfig.h
  1. bpf_helpers.h是编译内核态bpf程序所依赖的关键的helper文件。随着内核版本的变化,此文件放置的位置和内核源码中的样例程序引用的方式也发生了变化。在低版本中,随意的放到了tools/testing/selftests/bpf路径,引用方式为#include "bpf_helpers.h"。而在高版本内核中,放置的相对规范一些,bpf_helpers.h文件被放置到了tools/lib/bpf路径,引用方式也自然改为了#include <bpf/bpf_helpers.h>。同时,在我们这里的头文件include路径里,也有细微差别。低版本内核,我们将bpf_helpers.h文件规范性的拷贝到到了新项目的helpers目录,相应的是“-I./helpers”选项参数起作用。而在高版本内核是”-I./tools/lib/”选项参数起了作用。
  2. 较高版本的clang编译器,在添加-g选项参数后,会编译出带.BTF段的目标文件。但较低版本的clang却没有这个功能,无法直接编译出带BTF段的目标文件。即使这样,仍然可以通过pahole -J命令,将目标文件中的DWARF-2信息,转换出BTF段信息。
  3. 较低版本的clang编译器,不支持'asm goto'语法结构。解决办法是通过“-include asm_goto_workaround.h”选项参数,给内核态bpf文件主动添加asm_goto_workaround.h头文件,绕过这个问题。

6.2  用户态加载程序(trace_output)编译参数解析

在用户态目标文件trace_output的构建过程中,主要使用的编译命令是gcc编译命令。

  1. 编译trace_output的gcc命令中,gcc不用显式的指定系统头文件列表,gcc会默认到系统默认的头文件列表中查找头文件。使用如下命令可以显示系统默认的头文件包含哪些。其中/usr/include文件路径正好是kernel-header这个rpm包的内容所在的目录,这里也是我们脱离内核源码编译的第二个地方。
$  gcc -xc  /dev/null -E -Wp,-v 2>&1 | sed -n 's,^ ,,p'
/usr/lib/gcc/x86_64-redhat-linux/8/include
/usr/local/include
/usr/include
  1. libbpf.h头文件是编译ebpf用户态程序时,必不可少的头文件依赖。随着内核版本的变化,在内核源码的样例程序中引用的方式也发生了细微变化。在较低版本内核源码样例中,引用方式是“#include <libbpf.h>”,在较高版本内核源码样例中,引用方式是“#include <bpf/libbpf.h>”。与此同时libbpf.h在内核源码中的放置位置并没有变化,一直都是tools/lib/bpf/libbpf.h。配合这种引用方式的变化的,是头文件搜索路径的调整,低版本内核源码样例头文件搜索路径是“-I./tools/lib/bpf/”,高版本内核源码样例头文件搜索路径是“-I./tools/lib/”。

进一步探索

本文为eBPF动手实践系列的第二篇,我们一步一步实现了脱离内核源码后的纯C语言eBPF项目的构建。这个构建方案虽然没有特别考虑对CORE的适配,但是通用性更强。针对内核态bpf程序(trace_output_kern.o)和用户态加载程序(trace_output)本文仅是从构建过程和编译参数入手,做了一些分析,下一篇我们会深入到这两个关键的样例程序内部的代码逻辑追本溯源,探寻ebpf程序的核心逻辑。欢迎有想法或者有问题的同学,加群交流eBPF技术以及工程实践。

  • SREWorks数智运维工程群(钉钉群号:35853026)
  • 跟踪诊断技术 SIG 开发者&用户群(钉钉群号:33304007
本文含有隐藏内容,请 开通VIP 后查看