上一节,我们讲解了使用BCC开发eBPF程序,不过,在我们想要分发这个程序到客户环境时,又会碰到一个新的难题:BCC 依赖于 LLVM 和内核头文件才可以动态编译和加载 eBPF 程序,然而,我们在客户环境中通常没有这些开发工具,甚至不允许安装这些开发工具。
解决办法有三种:
第一种方法就是是把 BCC 和开发工具都安装到容器中,容器本身不提供对外服务,这样可以降低安全风险。
第二种方法就是linux内核源代码的samples/bpf目录下中有eBPF示例,在那里开发一个匹配当前内核版本的 eBPF 程序,并编译为字节码,再分发到生产环境中。
第三种方法就是用 libbpf 来开发一个 eBPF 程序。只要内核已经支持了 BPF 类型格式,可以使用从内核源码中抽离出来的 libbpf 进行开发,这样可以借助 BTF 获得更好的移植性。
在 eBPF 程序中,由于内核已经支持了 BTF,你不再需要引入众多的内核头文件来获取内核数据结构的定义。取而代之的是一个通过 bpftool 生成的 vmlinux.h 头文件,其中包含了内核数据结构的定义。
使用 libbpf 开发 eBPF 程序也是需要内核态的 eBPF 程序和用户态的加载、挂载、映射读取以及输出程序的,可以通过以下步骤完成:
1.内核头文件生成
我们使用 bpftool 生成内核数据结构定义头文件。BTF 开启后,你可以在系统中找到 /sys/kernel/btf/vmlinux 这个文件,bpftool 正是从它生成了内核数据结构头文件。 生成命令如下:
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
如果运行失败,需要根据提示使用apt安装linux-tools-5.13.0-37-generic和linux-cloud-tools-5.13.0-37-generic包。
2.eBPF 程序开发
eBPF 程序,包括定义哈希映射、性能事件映射以及跟踪点的处理函数等,而对这些数据结构和跟踪函数的定义都可以通过 SEC() 宏定义来完成。在编译时,通过 SEC() 宏定义的数据结构和函数会放到特定的 ELF 段中,这样后续在加载 BPF 字节码时,就可以从这些段中获取所需的元数据。这种方法非常方便。
我们看看那eBPF程序代码:
execsnoop.bpf.c:
//execsnoop.bpf.c
// 包含头文件
#include "vmlinux.h"
#include "execsnoop.h"
#include <bpf/bpf_helpers.h>
static const struct event empty_event = { };
// 定义哈希映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, pid_t);
__type(value, struct event);
} execs SEC(".maps");
// 定义性能事件映射
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
}events SEC(".maps");
// 定义sys_enter_execve跟踪点函数
SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter
*ctx)
{
struct event *event;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
// 查询PID
u64 id = bpf_get_current_pid_tgid();
pid_t pid = (pid_t) id;
// 保存一个空的event到哈希映射中
if (bpf_map_update_elem(&execs, &pid, &empty_event, BPF_NOEXIST)) {
return 0;
}
event = bpf_map_lookup_elem(&execs, &pid);
if (!event) {
return 0;
}
// 初始化event变量
event->pid = pid;
event->args_count = 0;
event->args_size = 0;
// 查询第一个参数
unsigned int ret = bpf_probe_read_user_str(event->args, ARGSIZE,
(const char *)ctx->args[0]);
if (ret <= ARGSIZE) {
event->args_size += ret;
} else {
/* write an empty string */
event->args[0] = '\0';
event->args_size++;
}
// 查询其他参数,使用pragma unroll控制循环次数
event->args_count++;
#pragma unroll
for (int i = 1; i < TOTAL_MAX_ARGS; i++) {
bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (!argp)
return 0;
if (event->args_size > LAST_ARG)
return 0;
ret =
bpf_probe_read_user_str(&event->args[event->args_size],
ARGSIZE, argp);
if (ret > ARGSIZE)
return 0;
event->args_count++;
event->args_size += ret;
}
// 再尝试一次,确认是否还有未读取的参数
bpf_probe_read_user(&argp, sizeof(argp), &args[TOTAL_MAX_ARGS]);
if (!argp)
return 0;
// 如果还有未读取参数,则增加参数数量(用于输出"...")
event->args_count++;
return 0;
}
// 定义sys_exit_execve跟踪点函数
SEC("tracepoint/syscalls/sys_exit_execve")
int tracepoint__syscalls__sys_exit_execve(struct trace_event_raw_sys_exit *ctx)
{
u64 id;
pid_t pid;
int ret;
struct event *event;
// 从哈希映射中查询进程基本信息
id = bpf_get_current_pid_tgid();
pid = (pid_t) id;
event = bpf_map_lookup_elem(&execs, &pid);
if (!event)
return 0;
// 更新返回值和进程名称
ret = ctx->ret;
event->retval = ret;
bpf_get_current_comm(&event->comm, sizeof(event->comm));
// 提交性能事件
size_t len = EVENT_SIZE(event);
if (len <= sizeof(*event))
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, event,
len);
// 清理哈希映射
bpf_map_delete_elem(&execs, &pid);
return 0;
}
// 定义许可证(前述的BCC默认使用GPL)
char LICENSE[] SEC("license") = "GPL";
execsnoop.h:
#ifndef __EXECSNOOP_H
#define __EXECSNOOP_H
#define ARGSIZE 128
#define TASK_COMM_LEN 16
#define TOTAL_MAX_ARGS 60
#define FULL_MAX_ARGS_ARR (TOTAL_MAX_ARGS * ARGSIZE)
#define BASE_EVENT_SIZE (size_t)(&((struct event*)0)->args)
#define EVENT_SIZE(e) (BASE_EVENT_SIZE + e->args_size)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARGSIZE)
struct event {
char comm[TASK_COMM_LEN];
pid_t pid;
int retval;
int args_count;
unsigned int args_size;
char args[FULL_MAX_ARGS_ARR];
};
#endif /* __EXECSNOOP_H */
代码分析:
- 头文件 vmlinux.h 是第一步生成的,他包含了内核数据结构;execsnoop.h是自己编写的,我们不可以把这部分直接写到c文件中,因为这些结构体和宏定义我们在后面的用户态程序中需要用到;bpf/bpf_helpers.h 包含了BPF 辅助函数,包括SEC、BPF_MAP_TYPE_HASH、BPF_MAP_TYPE_PERF_EVENT_ARRAY等宏定义和bpf_get_current_pid_tgid等函数;
- struct event 定义了进程基本信息数据结构,它会用在后面的哈希映射中;
- SEC(“.maps”) 定义了哈希映射和性能事件映射;会把相应的变量放入ELF的数据段中;
- SEC(“tracepoint/<跟踪点名称>”) 定义了跟踪点处理函数,系统调用跟踪点函数的格式是 “tracepoint_syscalls_<系统调用名称>”。简单来说就是把"/“换成”__"。ruguo 需要定义内核插桩和用户插桩,也是以类似的格式定义;
- SEC(“license”) 定义了 eBPF 程序的许可证。在之前的 BCC eBPF 程序中,我们并没有定义许可证,这是因为 BCC 自动使用了 GPL 许可。
从这些代码中你可以看到,它的处理逻辑跟上述的 BCC 程序基本上是相同的。不过,详细对比一下,你会发现它们之间还是有不同的,不同点主要在两个方面:第一、函数名的定义格式不同。BCC 程序使用的是 TRACEPOINT_PROBE 宏,而 libbpf 程序用的则是 SEC 宏。第二、映射的访问方法不同。BCC 封装了很多更易用的映射访问函数(如 tasks.lookup()),而 libbpf 程序则需要调用BPF 辅助函数(比如查询要使用 bpf_map_lookup_elem())。
3.编译 eBPF 程序为字节码
有了 eBPF 程序,就可以使用 clang 和 bpftool 将其编译成 BPF 字节码,然后再生成其头文件 execsnoop.skel.h :
clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 -I/usr/include/x86_64-linux-gnu -I. -c execsnoop.bpf.c -o execsnoop.bpf.o
bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h
4.开发用户态程序
同 BCC 的 Python 前端程序类似,libbpf 用户态程序也需要 eBPF 程序加载、挂载到跟踪点,以及通过 BPF 映射获取和打印执行结果等几个步骤。虽然 C 语言听起来可能比 Python 语言麻烦一些,但实际上,这几个步骤都可以通过字节码头文件中自动生成的函数来完成。
execsnoop.c:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include <time.h>
#include "execsnoop.h"
#include "execsnoop.skel.h"
// libbpf错误和调试信息回调的处理程序。
static int libbpf_print_fn(enum libbpf_print_level level, const char *format,
va_list args)
{
#ifdef DEBUGBPF
return vfprintf(stderr, format, args);
#else
return 0;
#endif
}
// 丢失事件的处理程序。
void handle_lost_events(void *ctx, int cpu, __u64 lost_cnt)
{
fprintf(stderr, "Lost %llu events on CPU #%d!\n", lost_cnt, cpu);
}
// 打印参数(替换'\0'为空格)
static void print_args(const struct event *e)
{
int args_counter = 0;
for (int i = 0; i < e->args_size && args_counter < e->args_count; i++) {
char c = e->args[i];
if (c == '\0') {
args_counter++;
putchar(' ');
} else {
putchar(c);
}
}
if (e->args_count > TOTAL_MAX_ARGS) {
fputs(" ...", stdout);
}
}
// 性能事件回调函数(向终端中打印进程名、PID、返回值以及参数)
void handle_event(void *ctx, int cpu, void *data, __u32 data_sz)
{
const struct event *e = data;
printf("%-16s %-6d %3d ", e->comm, e->pid, e->retval);
print_args(e);
putchar('\n');
}
// Bump RLIMIT_MEMLOCK,允许BPF子系统做任何它需要的事情。
static void bump_memlock_rlimit(void)
{
struct rlimit rlim_new = {
.rlim_cur = RLIM_INFINITY,
.rlim_max = RLIM_INFINITY,
};
if (setrlimit(RLIMIT_MEMLOCK, &rlim_new)) {
fprintf(stderr, "Failed to increase RLIMIT_MEMLOCK limit!\n");
exit(1);
}
}
int main(int argc, char **argv)
{
struct execsnoop_bpf *skel;
struct perf_buffer_opts pb_opts;
struct perf_buffer *pb = NULL;
int err;
// 1. 设置调试输出函数,libbpf发生错误会回调libbpf_print_fn
libbpf_set_print(libbpf_print_fn);
// 2. 增大进程限制的内存,默认值通常太小,不足以存入BPF映射的内容
bump_memlock_rlimit();
// 3. 打开BPF程序
skel = execsnoop_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
// 4. 加载BPF字节码
err = execsnoop_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}
// 5. 挂载BPF字节码到跟踪点
err = execsnoop_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}
// 6. 配置性能事件回调函数
pb_opts.sample_cb = handle_event;
pb_opts.lost_cb = handle_lost_events;
pb = perf_buffer__new(bpf_map__fd(skel->maps.events), 64, &pb_opts);
err = libbpf_get_error(pb);
if (err) {
pb = NULL;
fprintf(stderr, "failed to open perf buffer: %d\n", err);
goto cleanup;
}
printf("%-16s %-6s %3s %s\n", "COMM", "PID", "RET", "ARGS");
// 7. 从缓冲区中循环读取数据
while ((err = perf_buffer__poll(pb, 100)) >= 0) ;
printf("Error polling perf buffer: %d\n", err);
cleanup:
perf_buffer__free(pb);
execsnoop_bpf__destroy(skel);
return err != 0;
}
代码分析:
- 头文件execsnoop.h记录着struct event结构体信息;头文件execsnoop.skel.h是上一步生成的,包含了 BPF 字节码和相关的管理函数。
- 第 1 步设置调试输出函数,可以调用 printf() 把调试信息输出到终端中。
- 第 2 步增大进程锁定内存限制,因为系统默认的锁定内存通常过小,无法满足 BPF 映射的需要。
- 第 3~5 步直接调用execsnoop.skel.h文件中的函数,加载 BPF 字节码并挂载到跟踪点。
- 第 6~7 步设置性能事件的回调函数,回调函数会从缓冲区中循环读取数据。
最后使用下面命令编译用户态程序,得到execsnoop应用程序:
clang -g -O2 -Wall -I . -c execsnoop.c -o execsnoop.o
clang -Wall -O2 -g execsnoop.o -static -lbpf -lelf -lz -o execsnoop
可以直接把这个execsnoop应用程序复制到开启了 BTF 的其他机器中,无需安装额外的 LLVM 开发工具和内核头文件,也可以直接执行。如果命令失败,并且你看到如下的错误,这说明当前机器没有开启 BTF,需要重新编译内核开启 BTF 才可以运行:
Failed to load and verify BPF skeleton