Contents

使用 libbpf 开发 eBPF 程序

1. eBPF 程序的工作流程

一般来说,eBPF程序分为用户空间程序内核程序两部分。

内核程序以eBPF字节码的形式运行在虚拟机中。虚拟机执行字节码的过程本质上就是在模拟 CPU 执行机器码的过程,所以比直接执行机器码效率低很多。不过可以使用JIT来解决虚拟机执行效率不够高的问题,它先把eBPF字节码编译成对应的机器码,运行时直接执行机器码即可。

当程序绑定的事件触发后,内核程序就开始执行。执行逻辑一般是采集一些数据,然后通过特定的数据结构传回用户空间。

开发内核程序时,我们肯定不会直接编写eBPF字节码。更通用的做法是采用 C 语言去编写代码,然后使用 clang & llvm 工具将 C 源码编译成eBPF字节码。

对于用户空间程序,它负责将eBPF字节码加载到内核,从内核中读取采集到的数据。既然要和内核交互,必然要通过系统调用来完成,这里会使用到的系统调用就是bpf()。直接使用这个系统调用开发难度很大,所以为了开发方便,有一些库对该调用进行了抽象和封装,向开发者提供更容易使用的 Api。

2. libbpf 库

2.1 libbpf 介绍

libbpf 库其实是 linux 内核源码的一部分,位于 tools/lib/bpf/ 路径下。为了使用这个库,把完整的源码下载下来实在不方便。所以内核的大佬们又新开了一个仓库,将 libbpf 的源码单独拎出来维护(https://github.com/libbpf/libbpf)。README 的第一行也作了说明。

2.2 libbpf 安装

在上一篇文章(在 WSL 上使用 eBPF)中,我们下载了完整的内核源码,因此可以直接在WSL/tools/lib/bpf/目录下进行编译安装。但是WSL这部分代码应该没有和社区版的内核保持同步更新,编译后的版本太低了。为了保证 eBPF 程序正常运行,且能体验到一些新特性,这里直接从(https://github.com/libbpf/libbpf)下载源码,进行编译安装。

1
2
3
4
git clone https://github.com/libbpf/libbpf
cd libbpf/src
make
sudo make install

make install主要做了两件事:将头文件拷贝到了系统默认搜索路径/usr/include/bpf/目录下,将静态库和动态库拷贝到了/lib64/目录下。

接下来,就可以进行愉快的程序开发了。

3. eBPF 程序开发

下面将使用 libbpf 开发一个 eBPF 程序,用来监测系统上的新生进程,获取新进程的可执行文件路径启动参数。涉及的内容比较多,并不是 hello world 级别,需要一定开发经验。建议观看完整代码:execve.bpf.c

3.1 内核部分代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// exec.bpf.c
#include "../vmlinux.h"
#include "common.h"

#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";  // 指定协议,否则加载进内核验证器不给过

struct {  // 定义需要的 MAP 结构,这里使用 PERF_EVENT_ARRAY 与用户空间实时交互
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __type(key, u32);
    __type(value, u32);
} event_buf SEC(".maps");

// ...

// 使用 tracepoint,选择进程产生时刻作为 hook 点
SEC("tp/sched/sched_process_exec")
int execve_lite(struct trace_event_raw_sched_process_exec *ctx) // 结构体在 vmlinux.h 中定义
{
    struct event *e;
    int zero = 0;
    e = bpf_map_lookup_elem(&heap, &zero);
    if (!e) {
        return 0;
    }

    // 采集关键信息...

    // 写入信息,随后在用户空间获取
    bpf_perf_event_output(ctx, &event_buf, BPF_F_CURRENT_CPU, e, sizeof(*e));
    return 0;
}

首先要找到程序的 hook 点(可以简单理解为某个内核函数或者某个执行路径),然后编写我们自定义的代码逻辑。也就是说,当这个内核函数被调用,或者路径被执行到时,就会触发我们的程序。程序的逻辑一般都是抓取一些信息,然后上报到用户空间进行处理(打印输出,进一步保存等)。

编写程序主要是利用头文件中的helper functions来获取需要的信息,通过特定的 MAP 数据结构来保存、传递信息。

3.2 生成 skeleton 头文件

一般来说,内核程序开发完成后,会利用 clang & llvm 将源程序编译成eBPF目标程序。它是一个内核可加载,ELF 格式的二进制文件。随后在用户空间代码中,将这个.o文件加载进内核。

通过 readelf 工具,我们可以查看详细信息。

1
clang -O2 -g -target bpf -D__TARGET_ARCH_x86_64 -c exec.bpf.c -o exec.bpf.o

./Snipaste_2022-08-19_00-37-01.jpg

这里是代码中用SEC()宏自定义塞进去的两个 section。

./Snipaste_2022-08-19_00-41-47.jpg

这种传统的加载方式比较复杂,也不利于程序分发。bpftool工具提供了一种方法,它能根据目标文件生成 skeleton 类型的.h头文件,它本质上是eBPF内核部分程序的 C 语言抽象。

这样一来,在用户空间代码部分,通过include这个头文件,就可以很方便地访问内核程序中定义的变量,代码主体和 MAP 数据结构,并且提供了加载程序的抽象接口。更详细的介绍可以参考:bpftool-genhttps://nakryiko.com/posts/libbpf-bootstrap/#the-user-space-side

使用如下命令,可以将exec.bpf.o转化为exec.skel.h

1
bpftool gen skeleton exec.bpf.o > exec.skel.h

skeleton 文件的核心就是:1. 一个结构体exec_bpf,代表着对内核部分程序的抽象。从中可以看到 maps,skeleton 等结构,这就是程序使用的 MAP 和代码主体(以字节序列的形式保存起来了);2. 一些接口,用来加载 eBPF 程序。从面向对象的角度来看,它们其实就是exec_bpf的成员函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// exec.skel.h
struct exec_bpf {
	struct bpf_object_skeleton *skeleton;
	struct bpf_object *obj;
	struct {
		struct bpf_map *heap;
		struct bpf_map *event_buf;
		struct bpf_map *rodata;
	} maps;
	struct {
		struct bpf_program *execve_lite;
	} progs;
	struct {
		struct bpf_link *execve_lite;
	} links;
	struct exec_bpf__rodata {
		char args_builder_____fmt[6];
	} *rodata;
};
// ...
exec_bpf__open()
exec_bpf__load()
exec_bpf__attach()
exec_bpf__detach()
exec_bpf__destroy()

3.3 用户空间代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// exec.c
#include "exec.skel.h"

int main()
{
    // ...
    struct exec_bpf *skel = exec_bpf__open();  // 获得一个 exec_bpf 结构体
    exec_bpf__load(skel);  // 装载到内核
    exec_bpf__attach(skel);  // 加载到对应的挂载点

    int pb_fd = bpf_map__fd(skel->maps.event_buf);  // 使用 map 
    struct perf_buffer *pb = perf_buffer__new(pb_fd, 8, handle_event, NULL, NULL, NULL);

    while (1) {
        perf_buffer__poll(pb, 100);  // 取数据
    }
    return 0;
}

这里省略了核心代码,只保留了主要逻辑,可以看到整个过程行云流水,一气呵成。最后编译生成可执行文件。

1
clang exec.c -lelf -lbpf -o exec

如果libelf这个库不存在,通过如下命令进行安装:

1
sudo apt install libelf-dev

4. 实现效果

要使用 root 权限启动eBPF程序,设置资源上限和加载程序到内核都需要root权限。

./Snipaste_2022-08-19_02-25-10.jpg

源码:https://github.com/cheneytianx/ebpf_demo/tree/main/execve