如何用 libbpf 来开发一个 eBPF 程序

简介: 【2月更文挑战第7天】

使用 libbpf 开发 eBPF 程序分为两部分:第一,内核态的 eBPF 程序;第二,用户态的加载、挂载、映射读取以及输出程序等。

在 eBPF 程序中,由于内核已经支持了 BTF,你不再需要引入众多的内核头文件来获取内核数据结构的定义。取而代之的是一个通过 bpftool 生成的  vmlinux.h  头文件,其中包含了内核数据结构的定义。


这样,使用 libbpf 开发 eBPF 程序就可以通过以下四个步骤完成:

  1. 使用 bpftool 生成内核数据结构定义头文件。BTF 开启后,你可以在系统中找到  /sys/kernel/btf/vmlinux  这个文件,bpftool 正是从它生成了内核数据结构头文件。
  2. 开发 eBPF 程序部分。为了方便后续通过统一的 Makefile 编译,eBPF 程序的源码文件一般命名为  <程序名>.bpf.c。
  3. 编译 eBPF 程序为字节码,然后再调用  bpftool gen skeleton  为 eBPF 字节码生成脚手架头文件(Skeleton Header)。这个头文件包含了 eBPF 字节码以及相关的加载、挂载和卸载函数,可在用户态程序中直接调用。
  4. 最后就是用户态程序引入上一步生成的头文件,开发用户态程序,包括 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 的代码也就开发好了。

相关文章
|
2月前
|
存储 Go C语言
如何用Go开发eBPF程序
【2月更文挑战第7天】
|
1月前
|
Linux 编译器 Shell
eBPF动手实践系列三:基于原生libbpf库的eBPF编程改进方案
为了简化 eBPF程序的开发流程,降低开发者在使用 libbpf 库时的入门难度,libbpf-bootstrap 框架应运而生。本文详细介绍基于原生libbpf库的eBPF编程改进方案。
|
2月前
|
Ubuntu 编译器 开发工具
|
5月前
|
监控 JavaScript 前端开发
Go语言编程实践:构建网络限制软件的基本步骤
在今天的互联网世界中,网络限制管理是一个重要的主题。不同的组织和个人都需要一种有效的方式来管理和监控网络流量,以确保网络资源的合理使用。本文将介绍如何使用Go语言构建一个基于Web的网络限制软件管理面板,以便轻松管理网络资源分配。我们将使用Go语言、JavaScript和Node.js来完成这个任务。
202 1
|
6月前
|
开发框架 小程序 前端开发
阿里云小程序框架
阿里云小程序框架
|
7月前
|
前端开发 小程序 JavaScript
开发小程序
HTML、CSS 和 JavaScript 这三种前端技术。然后学习微信小程序开发相关的技术和框架。以下是一个详细的学习路径:
141 0
|
10月前
|
运维 物联网 Linux
嵌入式linux之go语言开发(六)几行代码实现终端的远程日志诊断
嵌入式linux之go语言开发(六)几行代码实现终端的远程日志诊断
|
12月前
|
运维 Shell 编译器
eBPF 动手实践系列一:解构内核源码 eBPF 样例编译过程
基于 4.18 内核的基于内核源码的原生编译方式介绍,开发符合自己业务需求的高性能的 ebpf 程序。
|
Linux Anolis 芯片
如何使用 eunomia 让eBPF 的部署更简单? | 第 49 期
介绍 eunomia 项目的开发和使用,了解它编译和启动运行 eBPF 程序完全分离的思路。
如何使用 eunomia 让eBPF 的部署更简单? | 第 49 期

热门文章

最新文章