eBPF程序开发姿势总结
关于eBPF的讨论已经很火热了,在此不具体讲述什么是ebpf, 而是从ebpf程序开发的角度来直观感受ebpf的魅力。
1、eBPF程序执行流程
一个ebpf程序的执行流程分为以下几个步骤:
-
第一步,使用 C 语言开发一个 eBPF 程序;
-
第二步,借助 LLVM 把 eBPF 程序编译成 BPF 字节码;
-
第三步,通过 bpf 系统调用,把 BPF 字节码提交给内核;
-
第四步,内核验证并运行 BPF 字节码,并把相应的状态保存到 BPF 映射中;
-
第五步,用户程序通过 BPF 映射查询 BPF 字节码的运行状态。
就整体而言,ebpf程序的开发有三个主要步骤:内核态程序的开发、用户态程序、以及用户与内核进行数据交换。
( 1 ) 内核态程序
内核程序以ebpf字节码的方式运行在虚拟机中,虚拟机通过模拟CPU指令执行ebpf字节码效率较低,引入JIT来改善ebpf的执行效率。内核程序的编写可以使用C编写,也可以使用rust来编写,甚至可以手工编写epbf字节码。但是主流方式是使用C来开发ebpf程序。
内核程序的主要功能:挂钩内核函数进行数据采集。即当hook的系统调用等事件被触发后,执行ebpf程序进行数据采集。
( 2 ) 用户态程序
用户态程序通过系统bpf调用来与内核态程序进行交互。用户态可以采用很多流行的框架或者库来开发ebpf程序。比较著名的如BCC、libbpf、cilium等,根据不同的场景选择合适的方式进行开发, 这些库都对bpf系统调用进行了封装,简化了ebpf程序开发复杂度。
用户态程序的功能:用户态程序包含 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等部分。
( 3 ) 数据交换
在 eBPF 程序需要大块存储时,我们还需要根据应用场景,引入特定类型的 BPF 映射,并借助它向用户空间的程序提供运行状态的数据。
2、内核态程序开发
在使用eBPF开发程序之前,先要明确Linux系统中那些内核提供的API函数是可以被挂钩的,不同的内核版本对eBPF的支持会有所差异,这一点一定还要明确。在了解了eBPF可挂钩的内核函数之后,还需要了解目标函数的原型包括形式参数类型,个数,返回值类型等信息,然后才可以开始使用eBPF来实现相应的功能。
2.1 查询内核跟踪点信息
2.2.1 内核函数类型
内核态中的 eBPF 程序则需要通过 BPF 辅助函数完成所需的任务。但并不是所有的辅助函数都可以在 eBPF 程序中随意使用,不同类型的 eBPF 程序所支持的辅助函数是不同的。根据内核头文件 include/uapi/linux/bpf.h 中 bpf_prog_type 的定义,Linux 内核 v5.13 已经支持 30 种不同类型的 eBPF 程序。

对于具体的内核版本,和编译配置选项不同,一个内核并不会支持所有的程序类型。可以使用工具bpftool 来查看当前系统中支持的程序类型。以下是ubuntu22 Server中支持的eBPF 程序类型。

以上eBPF程序类型按照功能和使用场景,可以分为以下三种:
- 第一类是跟踪:跟踪类 eBPF 程序主要用于从系统中提取跟踪信息,进而为监控、排错、性能优化等提供数据支撑。KPROBE、TRACEPOINT 以及 PERF_EVENT 都是最常用的跟踪类 eBPF 程序。
- 第二类是网络:网络类 eBPF 程序主要用于对网络数据包进行过滤和处理,进而实现网络的观测、过滤、流量控制以及性能优化等各种丰富的功能,BPF_PROG_TYPE_XDP、BPF_PROG_TYPE_SCHED_CLS、BPF_PROG_TYPE_SCHED_ACT、BPF_PROG_TYPE_CGROUP_DEVICE等都属于网络类eBPF程序。
- 第三类是其他类型:包括安全控制、BPF 扩展等等。这类程序类型包括如:BPF_PROG_TYPE_LSM、BPF_PROG_TYPE_LWT_IN、BPF_PROG_TYPE_LWT_OUT、BPF_PROG_TYPE_LWT_XMIT、BPF_PROG_TYPE_LIRC_MODE2等。
2.1.1 查看内核函数原型
内核跟踪点信息查询
-
使用内核函数:对于非栈变量地址信息都被抽取到了/proc/kallsyms文件。但是内核函数是非稳定的API,在不断更新中经常变化,不适用该方法。且该文件下的函数只有显示导出的才可以被ebpf程序动态跟踪。
-
调试系统文件:向用户空间提供了内核调试所需的基本信息,如内核符号列表、跟踪点、函数跟踪(ftrace)状态以及参数格式等。你可以在终端中执行 sudo ls /sys/kernel/debug 来查询内核调试文件系统的具体信息。
-
使用perf来查询性能事件列表:sudo perf list [hw|sw|cache|tracepoint|pmu|sdt|metric|metricgroup]
-
利用bpftrace来查询跟踪点:bpftrace 在 eBPF 和 BCC 之上构建了一个简化的跟踪语言,通过简单的几行脚本,就可以实现复杂的跟踪功能。在编写简单的 eBPF 程序,特别是编写的 eBPF 程序用于临时的调试和排错时,你可以考虑直接使用 bpftrace 。安装bpftrace(Ubuntu): sudo apt-get install -y bpftrace。
root@master:/home/flyfish01# sudo bpftrace -l 'tracepoint:syscalls:*'
tracepoint:syscalls:sys_enter_accept
tracepoint:syscalls:sys_enter_accept4
tracepoint:syscalls:sys_enter_access
tracepoint:syscalls:sys_enter_acct
tracepoint:syscalls:sys_enter_add_key
tracepoint:syscalls:sys_enter_adjtimex
tracepoint:syscalls:sys_enter_alarm
tracepoint:syscalls:sys_enter_arch_prctl
tracepoint:syscalls:sys_enter_bind
tracepoint:syscalls:sys_enter_bpf
tracepoint:syscalls:sys_enter_brk
....
使用bpftrace查看系统API入口参数和返回值
当我们掌握了eBPF 可以挂钩哪些内核函数之后,在编写eBPF之前我们还需要知道Hook函数的细节,如函数的参数和返回值信息,以下是如何使用bpftrace来观察系统函数的形参和返回值。
# 查询execve入口参数格式
$ sudo bpftrace -lv tracepoint:syscalls:sys_enter_execve
tracepoint:syscalls:sys_enter_execve
int __syscall_nr
const char * filename
const char *const * argv
const char *const * envp
# 查询execve返回值格式
$ sudo bpftrace -lv tracepoint:syscalls:sys_exit_execve
tracepoint:syscalls:sys_exit_execve
int __syscall_nr
long ret
2.2 编写内核程序
根据使用的用户态框架的不同,eBPF程序的可发稍微有所差别,但整体的思路是很清晰的。这里贴一个bcc/libbpf-tools/bashreadline.bpf.c的示例,该程序挂钩的uretprobe/readline函数,实现bash下用户输入探测功能。
/* SPDX-License-Identifier: GPL-2.0 */
/* Copyright (c) 2021 Facebook */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "bashreadline.h"
#define TASK_COMM_LEN 16
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} events SEC(".maps");
SEC("uretprobe/readline")
int BPF_KRETPROBE(printret, const void *ret) {
struct str_t data;
char comm[TASK_COMM_LEN];
u32 pid;
if (!ret)
return 0;
bpf_get_current_comm(&comm, sizeof(comm));
if (comm[0] != 'b' || comm[1] != 'a' || comm[2] != 's' || comm[3] != 'h' || comm[4] != 0 )
return 0;
pid = bpf_get_current_pid_tgid() >> 32;
data.pid = pid;
bpf_probe_read_user_str(&data.str, sizeof(data.str), ret);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
return 0;
};
char LICENSE[] SEC("license") = "GPL";
3、用户态程序开发
eBPF 工具的用户空间部分至少在理论上可以用任何语言编写,尽管实际上有一些库可以用部分语言来支持:C、Go、Rust 和 Python。eBPF 库主要协助实现两个功能:
- 将 eBPF 程序和 Map 载入内核并执行重定位[9],通过其文件描述符将 eBPF 程序与正确的 Map 进行关联。
- 与 eBPF Map 交互,允许对存储在 Map 中的键/值对进行标准的 CRUD 操作。
下面重点介绍以下BCC、libbpf、Cilium三个eBPF开发eBPF程序的使用。
3.1 BCC项目
3.1.1 项目简介
项目地址:https://github.com/iovisor/bcc
BCC 是一个 BPF 编译器集合,包含了用于构建 BPF 程序的编程框架和库,并提供了大量可以直接使用的工具。使用 BCC 的好处是,它把上述的 eBPF 执行过程通过内置框架抽象了起来,并提供了 Python、C++ 等编程语言接口。
3.1.1 BCC开发eBPF程序
开发准备工作: 必要的开发工具
# 将 eBPF 程序编译成字节码的 LLVM;
# C 语言程序编译工具 make;
# 最流行的 eBPF 工具集 BCC 和它依赖的内核头文件;
# 与内核代码仓库实时同步的 libbpf;
# 同样是内核代码提供的 eBPF 程序管理工具 bpftool。
# For Ubuntu20.10+
sudo apt-get install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)
# For RHEL8.2+
sudo yum install libbpf-devel make clang llvm elfutils-libelf-devel bpftool bcc-tools bcc-devel
内核程序开发
// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>
// 定义数据结构
struct data_t {
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
char fname[NAME_MAX];
};
// 定义性能事件映射
BPF_PERF_OUTPUT(events);
// 定义kprobe处理函数
int hello_world(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
struct data_t data = { };
// 获取PID和时间
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
// 获取进程名
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
}
// 提交性能事件
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
用户程序开发
from bcc import BPF
# 1) load BPF program
b = BPF(src_file="trace-open.c")
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 2) print header
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))
# 3) define the callback for perf event
start = 0
def print_event(cpu, data, size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))
# 4) loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
编写完成eBPF程序的开发之后,执行:sudo python hello.py运行程序。
1.001642440 b'node' 10878 b'/proc/12712/cmdline'
1.202377729 b'node' 10878 b'/proc/12712/cmdline'
1.264331274 b'node' 11049 b'/home/flyfish01/.vscode-server/data/User/workspaceStorage/e3195372342f7ed8e93e9c3d2ce8fecb/vscode.lock'
1.402635821 b'node' 10878 b'/proc/12712/cmdline'
1.569768264 b'node' 11041 b'/proc/meminfo'
1.602818505 b'node' 10878 b'/proc/12712/cmdline'
1.802893539 b'node' 10878 b'/proc/12712/cmdline'
2.003179522 b'node' 10878 b'/proc/12712/cmdline'
2.203337490 b'node' 10878 b'/proc/12712/cmdline'
2.264100392 b'node' 11050 b'/home/flyfish01/.vscode-server/data/User/workspaceStorage/e3195372342f7ed8e93e9c3d2ce8fecb/vscode.lock'
2.403939357 b'node' 10878 b'/proc/12712/cmdline'
2.604170876 b'node' 10878 b'/proc/12712/cmdline'
2.701696318 b'sh' 12757 b'/etc/ld.so.cache'
....
3.2 libbpf项目
3.2.1 项目简介
项目地址: https://github.com/libbpf/libbpf
libbpf 库其实是 linux 内核源码的一部分,位于 /tools/lib/bpf/ 路径下。为了使用这个库,把完整的源码下载下来实在不方便。所以内核的大佬们又新开了一个仓库,将 libbpf 的源码单独拎出来维护(https://github.com/libbpf/libbpf)。
在 eBPF 程序中,由于内核已经支持了 BTF,你不再需要引入众多的内核头文件来获取内核数据结构的定义。取而代之的是一个通过 bpftool 生成的 vmlinux.h 头文件,其中包含了内核数据结构的定义。libbpf 是从内核中抽离出来的标准库,用它开发的 eBPF 程序可以直接分发执行,这样就不需要每台机器都安装 LLVM 和内核头文件了。不过,它要求内核开启 BTF 特性,需要非常新的发行版才会默认开启(如 RHEL 8.2+ 和 Ubuntu 20.10+ 等)。
3.2.2 libpbf开发eBPF程序
使用 libbpf 开发 eBPF 程序也是分为两部分:第一,内核态的 eBPF 程序;第二,用户态的加载、挂载、映射读取以及输出程序等。
-
使用 bpftool 生成内核数据结构定义头文件。BTF 开启后,你可以在系统中找到 /sys/kernel/btf/vmlinux 这个文件,bpftool 正是从它生成了内核数据结构头文件。
-
开发 eBPF 程序部分。为了方便后续通过统一的 Makefile 编译,eBPF 程序的源码文件一般命名为 <程序名>.bpf.c。
-
编译 eBPF 程序为字节码,然后再调用 bpftool gen skeleton 为 eBPF 字节码生成脚手架头文件(Skeleton Header)。这个头文件包含了 eBPF 字节码以及相关的加载、挂载和卸载函数,可在用户态程序中直接调用。
-
最后就是用户态程序引入上一步生成的头文件,开发用户态程序,包括 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等。
前期准备工作:
-
安装Golang,配置相关环境变量、安装clang和llvm编译器:
-
安装libbpf:
sudo apt install build-essential git make libelf-dev strace tar bpfcc-tools libbpf-dev linux-headers-$(uname -r) linux-tools-common gcc-
libbpf开发编译eBPF程序
-
生成内核头文件:
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h#ifndef __VMLINUX_H__ #define __VMLINUX_H__ #ifndef BPF_NO_PRESERVE_ACCESS_INDEX #pragma clang attribute push (__attribute__((preserve_access_index)), apply_to = record) #endif typedef unsigned char __u8; struct hlist_node; struct hlist_head { struct hlist_node *first; }; struct hlist_node { struct hlist_node *next; struct hlist_node **pprev; }; struct callback_head { struct callback_head *next; void (*func)(struct callback_head *); }; ..... -
开发内核态eBPF程序
// 包含头文件 #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) { // 待实现处理逻辑 } // 定义许可证(前述的BCC默认使用GPL) char LICENSE[] SEC("license") = "Dual BSD/GPL"; -
Clang将eBPF程序将源代码编译为.o对象文件, 并将可以将.o转化为.h文件,
# 内核程序开发完成后,会利用 clang & llvm 将源程序编译成eBPF目标程序 clang -O2 -g -target bpf -D__TARGET_ARCH_x86_64 -c exec.bpf.c -o exec.bpf.o # 将可以将.o转化为.h文件。 # 在用户空间代码部分,通过include这个头文件,就可以很方便地访问内核程序中定义的变量,代码主体和 MAP 数据结构,并且提供了加载程序的抽象接口。 bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h -
使用Go编写用户态程序
package main import ( "bytes" "encoding/binary" "fmt" "os" bpf "github.com/aquasecurity/libbpfgo" ) func main() { bpfModule, err := bpf.NewModuleFromFile("simple.bpf.o") if err != nil { os.Exit(-1) } defer bpfModule.Close() bpfModule.BPFLoadObject() prog, err := bpfModule.GetProgram("kprobe_sys_execve") if err != nil { os.Exit(-1) } _, err = prog.AttachKprobe("__x64_sys_execve") if err != nil { os.Exit(-1) } eventsChannle := make(chan []byte) rb, err := bpfModule.InitRingBuf("events", eventsChannle) if err != nil { os.Exit(-1) } rb.Start() for { envent := <-eventsChannle pid := int(binary.LittleEndian.Uint32(envent[0:4])) comm := string(bytes.TrimRight(envent[4:], "\x00")) fmt.Printf("%d: %v\n", pid, comm) } rb.Stop() rb.Close() } -
编译Go程序为二进制文件
# 在go项目目录下执行 go mod init 项目名 #加载所需的module go mod tidy #使用Go进行编译,生成二进制文件 CC=gcc CGO_CFLAGS="-I /usr/include/bpf" CGO_LDFLAGS="/usr/lib/x86_64-linux-gnu/libbpf.a" go build -o 程序名称 # 测试运行 sudo ./程序名
3.3 cilium eBPF
3.3.1 项目介绍
项目地址: https://github.com/cilium/ebpf
cilium/ebpf库是 Cilium项目的一个子项目。仅使用 Go 语言编写的库,提供了加载、编译和调试 eBPF 程序的功能。它具有最小的外部依赖性,适合在长期运行的进程中使用。库主要有由 Cloudflare和 Cilium两家公司维护,由于 Cilium产品的火爆程度,该库的活跃度在社区层面还是会持续演进和发展。
3.3.2 cilium 开发eBPF程序
开发准备工作
准备开发环境:ubuntu22 server 腾讯云
安装GO: sudo apt install golang-go
安装llvm编译器:sudo apt install clang llvm
编译cilium 自带的example程序
# 第一步:下载cilium源代码
git clone https://github.com/cilium/ebpf.git
# 切换到example/kprobe目录下
cd path/ebpf/excamle
//删除之前编译生成的文件, 只保留kprobe.c、 main.go
rm *.o
rm bpf_*.go
//执行go generate
//go generate命令是go 1.4版本里面新添加的一个命令,当运行go generate时,
//它将扫描与当前包相关的源代码文件,找出所有包含"//go:generate"的特殊注释,提取并执行该特殊注释后面的命令,命令为可执行程序
go generate
//执行go build
go build
//成功编译后生成kprobe*可执行文件
sudo ./kprobe*
测试运行结果:(统计系统调用kprobe/sys_execve的次数)

开发编译自定义的ebpf程序
# 第一步:下载cilium源代码
git clone https://github.com/cilium/ebpf.git
# 在example目录下新建test目录,存放我们自己的代码。
cd path/ebpf/excamle
mkdir test
# 复制头文件
cp -r ebpf/examples/headers test/headers
# 新建test/kprobe,复制ebpf/examples/krope中的kprobe.c和main.go到test/kprobe中
cp ebpf/examples/krope/kprobe.c test/kprobe
cp ebpf/examples/krope/main.go test/kprobe
//执行go generate
go generate
//执行go build
go build
//成功编译后生成kprobe*可执行文件
sudo ./kprobe*
剩下的就是基于该测试进行二次开发,修改kprobe.c、 main.go文件以实现自己的需求。
3.4 总结
BCC:BCC编写程序采用c的子集作为前端,编写完毕后自动调用clang进行编译,llvm生成ebpf字节码。加载程序到内核,程序退出时自动从内核卸载bpf程序。
BCC还有各种前端语言辅助进行变成,前端语言主要是用户态用来处理加载到内核态bpf程序的输出和交互。python支持较好,也支持go。但是兼容性问题并不好,首先,bcc是类似一种动态语言的方式,每次执行都会进行编译,编译需要工具链和依赖内核头文件(需要安装kernel-header包),而编译依赖是最脆弱,容易失败的。
libbpf: libbpf的使得bpf程序像其它程序一样,编译好后,可以放在任何一台机器,任何一个kernel版本上运行(当然要对内核版本有一些要求)。此外libbpf前端支持C、Go语言,也有比较著名的libbpf-go、libbpf-bootstrap等框架可快速开发eBPF程序。libbpf的出现解决了以下问题:
- 头文件的问题,依赖内核态特性支持BTF,将内核的数据结构类型构建在内核中。用户态的程序可以导出BTF成一个单独的.h 头文件,bpf程序只要依赖这个头文件就行,不需要安装内核头文件的包了。
- 兼容性问题,使用clang-11的针对ebpf后端专门的特性:preserve_access_index,支持记录数据结构field相对位置信息,可实现relocation,从而解决了数据结构在不同内核版本间的变化带来的无法对应问题。
- 性能提升:内核中bpf模块做了一些增强,bpf verifier支持直接字段访问,不需要call bpf函数的方式来访问结构体字段,这样提升了性能。
cilium:Cilium是由革命性内核技术eBPF驱动,用于提供、保护和观察容器工作负载(云原生)之间的网络连接的网络组件。 Cilium使用eBPF的强大功能来加速网络,并在Kubernetes中提供安全性和可观测性。

浙公网安备 33010602011771号