使用 libbpf 开发 eBPF 程序分为两部分:第一,内核态的 eBPF 程序;第二,用户态的加载、挂载、映射读取以及输出程序等。
在 eBPF 程序中,由于内核已经支持了 BTF,你不再需要引入众多的内核头文件来获取内核数据结构的定义。取而代之的是一个通过 bpftool 生成的 vmlinux.h 头文件,其中包含了内核数据结构的定义。
这样,使用 libbpf 开发 eBPF 程序就可以通过以下四个步骤完成:
- 使用 bpftool 生成内核数据结构定义头文件。BTF 开启后,你可以在系统中找到 /sys/kernel/btf/vmlinux 这个文件,bpftool 正是从它生成了内核数据结构头文件。
- 开发 eBPF 程序部分。为了方便后续通过统一的 Makefile 编译,eBPF 程序的源码文件一般命名为 <程序名>.bpf.c。
- 编译 eBPF 程序为字节码,然后再调用 bpftool gen skeleton 为 eBPF 字节码生成脚手架头文件(Skeleton Header)。这个头文件包含了 eBPF 字节码以及相关的加载、挂载和卸载函数,可在用户态程序中直接调用。
- 最后就是用户态程序引入上一步生成的头文件,开发用户态程序,包括 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等。
通常,这几个步骤里面的编译、库链接、执行 bpftool 命令等,都可以放到 Makefile 中,这样就可以通过一个 make 命令去执行所有的步骤。比如,下面是一个简化版本的 Makefile:
APPS = execsnoop .PHONY: all all: $(APPS) $(APPS): clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 -I/usr/include/x86_64-linux-gnu -I. -c $@.bpf.c -o $@.bpf.o bpftool gen skeleton $@.bpf.o > $@.skel.h clang -g -O2 -Wall -I . -c $@.c -o $@.o clang -Wall -O2 -g $@.o -static -lbpf -lelf -lz -o $@ vmlinux: $(bpftool) btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
有了这个 Makefile 之后,你执行 make vmlinux 命令就可以生成 vmlinux.h 文件,再执行 make 就可以编译 APPS 里面配置的所有 eBPF 程序(多个程序之间以空格分隔)。
接下来四个步骤开发跟踪短时进程的 eBPF 程序。
1、内核头文件生成
首先,对于第一步,我们只需要执行下面的命令,即可生成内核数据结构的头文件:
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
如果命令执行失败了,并且错误说 BTF 不存在,那说明当前系统内核没有开启 BTF 特性。这时候,你需要开启 CONFIG_DEBUG_INFO_BTF=y 和 CONFIG_DEBUG_INFO=y 这两个编译选项,然后重新编译和安装内核。
2、eBPF 程序定义
第二步就是开发 eBPF 程序,包括定义哈希映射、性能事件映射以及跟踪点的处理函数等,而对这些数据结构和跟踪函数的定义都可以通过 SEC() 宏定义来完成。在编译时,通过 SEC() 宏定义的数据结构和函数会放到特定的 ELF 段中,这样后续在加载 BPF 字节码时,就可以从这些段中获取所需的元数据。
比如,你可以使用下面的代码来定义映射和跟踪点处理函数:
// 包含头文件 #include "vmlinux.h" #include <bpf/bpf_helpers.h> // 定义进程基本信息数据结构 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]; }; // 定义哈希映射 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) { // 待实现处理逻辑 } // sys_exit_execve跟踪点 SEC("tracepoint/syscalls/sys_exit_execve") int tracepoint__syscalls__sys_exit_execve(struct trace_event_raw_sys_exit *ctx) { // 待实现处理逻辑 } // 定义许可证(前述的BCC默认使用GPL) char LICENSE[] SEC("license") = "Dual BSD/GPL";
来看看这段代码的具体含义:
- 头文件 vmlinux.h 包含了内核数据结构,而 bpf/bpf_helpers.h 包含了BPF 辅助函数;
- struct event 定义了进程基本信息数据结构,它会用在后面的哈希映射中;SEC(".maps") 定义了哈希映射和性能事件映射;
- SEC("tracepoint/<跟踪点名称>") 定义了跟踪点处理函数,系统调用跟踪点的格式是 tracepoint/syscalls/<系统调用名称>"。以后你需要定义内核插桩和用户插桩的时候,也是以类似的格式定义,比如 kprobe/do_unlinkat 或 uprobe/func;
- 最后的 SEC("license") 定义了 eBPF 程序的许可证。在上述的 BCC eBPF 程序中,我们并没有定义许可证,这是因为 BCC 自动帮你使用了 GPL 许可。
3、入口跟踪点处理
对于入口跟踪点 sys_enter_execve 的处理,先获取进程的 PID、进程名称和参数列表之后,再存入刚刚定义的哈希映射中。
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; } // 查询其他参数 event->args_count++; #pragma unrollfor (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; }
需要注意这三点:
- 第一,程序使用了 bpf_probe_read_user() 来查询参数。由于它把 \0 也算到了已读取参数的长度里面,所以最终 event->args 中保存的各个参数是以 \0 分隔的。在用户态程序输出参数之前,需要用空格替换 \0。
- 第二,程序在一开始的时候向哈希映射存入了一个空事件,在后续出口跟踪点处理的时候需要确保空事件也能正确清理。
- 第三,程序在最后又尝试多读取了一次参数列表。如果还有未读取参数,参数数量增加了 1。用户态程序可以根据参数数量来决定是不是需要在参数结尾输出一个 ...。
4、出口跟踪点处理
出口跟踪点的处理方法,也是查询进程基本信息、填充返回值、提交到性能事件映射这三个步骤。
由于刚才入口跟踪点的处理中没有读取进程名称,所以在提交性能事件之前还需要先查询一下进程名称。
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; }
到这里,新建一个目录,并把上述代码存入 execsnoop.bpf.c 文件中,eBPF 的代码也就开发好了。