eBPF 动手实践系列二:构建基于纯 C 语言的 eBPF 项目
千里之行,始于足下
了解和掌握纯 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 等发行版环境,需要安装一些编译过程的基础包、编译工具包、库依赖包和头文件依赖包等。具体安装步骤如下:
2.2 deb 包基础环境初始化
在 ubuntu、debian 等发行版环境,需要安装一些编译过程的基础包、编译工具包、库依赖包和头文件依赖包等。具体安装步骤如下:
构建基于纯 C 语言的 eBPF 项目
3.1 纯 C 语言编译
在 eBPF 基础环境的准备完成之后,就可以开始进行纯 C 语言的 eBPF 项目的搭建。这里我们仍然选择使用 centos8u+4.18 内核为例来说明构建过程。首次构建项目环境还需要依赖一次内核源码。下载内核源码,我们推荐使用阿里云的镜像网站。
获取 ebpf_purec_newbie git 项目的代码。并且通过其中的 initialize.sh 脚本,初始化 eBPF 项目。initialize.sh 脚本需要两个参数。
参数 1 用于指定内核源码的路径,
参数 2 用于指定新初始化的 ebpf 项目的目录,参数 2 可省略,省略后将默认设置为 /tmp/ebpf_project。
初始化后,就可以进入到 eBPF 项目目录,执行 make 命令,对内核源码自带的 eBPF 样例程序 trace_output 进行编译。
执行 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 文件。
3.2.2 执行 ebpf 程序报 Operation not permitted 错误
在一些版本的内核,运行编译完的 ebpf 程序 trace_output 时,会报 Operation not permitted 错误。解决办法是调大进程的 MEMLOCK 资源限制。具体可在 trace_output_user.c 的 main 函数中 snprintf 函数之前,添加如下代码。
同时还需要添加相关头文件。
ebpf_project 初始化脚本解析
计算机技术是一门建立在实验基础上的学科。很多时候,一行 hello world 的成功输出,打消了我们对代码的疑虑。
有了前文 trace_output 命令成功运行的基础,我们可以一鼓作气,深入代码的细节,探究纯 C 语言的 eBPF 项目编译的过程,加深我们对于 eBPF 技术原理的进一步理解。
在 ebpf_purec_newbie 代码项目下,只包含 3 个文件: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.c 和 trace_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 的详细解析过程进行提取。
分别获取了生成 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 静态库文件。
以 libbpf.o target 为例,可以看到具体一个 target 的完整解析过程。通常,在“Must remake target ”后会有“Invoking recipe from Makefile”,再之后便是我们最关心的实际执行的命令(recipe)部分。
最终在 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)部分。
以上一层一层的构建步骤,产出目标文件主要是 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 命令编译产生。
编译 trace_output_kern.o 命令的选项参数中,如下 8 个选项参数依赖的文件路径正好是 kernel-devel 这个 rpm 包的内容,这也是我们脱离内核源码编译的一个地方。
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/”选项参数起了作用。
较高版本的 clang 编译器,在添加-g 选项参数后,会编译出带.BTF 段的目标文件。但较低版本的 clang 却没有这个功能,无法直接编译出带 BTF 段的目标文件。即使这样,仍然可以通过 pahole -J 命令,将目标文件中的 DWARF-2 信息,转换出 BTF 段信息。
较低版本的 clang 编译器,不支持'asm goto'语法结构。解决办法是通过“-include asm_goto_workaround.h”选项参数,给内核态 bpf 文件主动添加 asm_goto_workaround.h 头文件,绕过这个问题。
6.2 用户态加载程序(trace_output)编译参数解析
在用户态目标文件 trace_output 的构建过程中,主要使用的编译命令是 gcc 编译命令。
编译 trace_output 的 gcc 命令中,gcc 不用显式的指定系统头文件列表,gcc 会默认到系统默认的头文件列表中查找头文件。使用如下命令可以显示系统默认的头文件包含哪些。其中/usr/include 文件路径正好是 kernel-header 这个 rpm 包的内容所在的目录,这里也是我们脱离内核源码编译的第二个地方。
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)
版权声明: 本文为 InfoQ 作者【阿里云大数据AI技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/60f9eebde162dbc7b30f0456c】。文章转载请联系作者。
评论