总体架构
用户态
- 用户编写 eBPF 程序,可以使用 eBPF 汇编或者 eBPF 特有的 C 语言来编写。
- 使用 LLVM/CLang 编译器,将 eBPF 程序编译成 eBPF 字节码。
- 调用 bpf() 系统调用把 eBPF 字节码加载到内核。
内核态
- 当用户调用 bpf() 系统调用把 eBPF 字节码加载到内核时,内核先会对 eBPF 字节码进行安全验证。
- 使用 JIT(Just In Time)技术将 eBPF 字节编译成本地机器码(Native Code)。
- 然后根据 eBPF 程序的功能,将 eBPF 机器码挂载到内核的不同运行路径上(如用于跟踪内核运行状态的 eBPF 程序将会挂载在 kprobes 的运行路径上)。当内核运行到这些路径时,就会触发执行相应路径上的 eBPF 机器码。
Bytecode
eBPF 程序最终会被编译成 eBPF 字节码,eBPF 字节码使用 bpf_insn 结构来表示,如下:
struct bpf_insn {
__u8 code; // 操作码
__u8 dst_reg:4; // 目标寄存器
__u8 src_reg:4; // 源寄存器
__s16 off; // 偏移量
__s32 imm; // 立即操作数
};
- code:指令操作码,如 mov、add 等。
- dst_reg:目标寄存器,用于指定要操作哪个寄存器。
- src_reg:源寄存器,用于指定数据来源于哪个寄存器。
- off:偏移量,用于指定某个结构体的成员。
- imm:立即操作数,当数据是一个常数时,直接在这里指定。
- ebpf程序会被LLVM/clang编译成bpf_insn数组,当内核要执行 eBPF 字节码时,会调用 __bpf_prog_run() 函数来执行。
- 如果开启了JIT(Just-In-Time),内核会将 eBPF 字节码编译成本地机器码(Native Code)。这样就可以直接执行,而不需要虚拟机来执行。
ebpf虚拟机
- eBPF 虚拟机的作用就是执行 eBPF 字节码,eBPF 虚拟机比较简单(只有300行代码左右),由 __bpf_prog_run() 函数实现。
- eBPF 虚拟机的运行环境只有 1 个 512KB 的栈和 11 个寄存器(还有一个 PC 寄存器,用于指向当前正在执行的 eBPF 字节码)。如下图所示
ebpf加载器
用户态可以通过调用 sys_bpf() 系统调用把 eBPF 程序加载到内核,而 sys_bpf() 系统调用会通过调用 bpf_prog_load() 内核函数加载 eBPF 程序
用户态 -> 系统调用 -> sys_bpf -> bpf_prog_load
bpf_prog_load() 函数主要完成以下几个工作:
1. 创建一个 bpf_prog 对象,用于保存 eBPF 字节码和 eBPF 程序的相关信息。
- 把 eBPF 字节码从用户态复制到 bpf_prog 对象的 insns 成员中,insns 成员是一个类型为 bpf_insn 结构的数组。
- 根据 eBPF 程序所属的类型(如 socket、kprobes 或 xdp 等),找到其相关处理函数(如 helper 函数对应的修正函数,下面会介绍)。
- 检查 eBPF 字节码是否合法。由于 eBPF 程序运行在内核态,所以要保证其安全性,否则将会导致内核崩溃。
- 修正 helper 函数的偏移量(下面会介绍)。
- 尝试将 eBPF 字节码编译成本地机器码,主要为了提高 eBPF 程序的执行效率。
- 申请一个文件句柄用于与 bpf_prog 对象关联,这个文件句柄将会返回给用户态,用户态可以通过这个文件句柄来读取内核中的 eBPF 程序。
修正 helper 函数 (ebpf程序如何调用一些内核函数?)
helper 函数是 eBPF 提供给用户使用的一些辅助函数。
由于 eBPF 程序运行在内核态,所为了安全,eBPF 程序中不能随意调用内核函数,只能调用 eBPF 提供的辅助函数(helper functions)。
注意,这里的helper函数和架构图中的kernel function不是同一个。
每个 eBPF 的 helper 函数都有一个编号(通过枚举类型 bpf_func_id 来定义),定义在 include/uapi/linux/bpf.h 文件中,定义如下(只列出一部分):
在 eBPF 程序中怎么调用 helper 函数:
上述指定的函数(BPF_FUNC_trace_printk)需要重定向要内核函数。
加载器会通过调用 fixup_bpf_calls() 函数来修正 helper 函数的地址。我们来看看 fixup_bpf_calls() 函数的实现(通过修改bytecode中 call/jump #addr为 #真实的内核函数地址):
开发简单示例
使用 C 编写 eBPF 内核程序
- 新建一个 hello.c 文件,并输入下面的内容:
int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
使用 Python 和 BCC 工具开发一个用户态程序
- 新建一个 hello.py 文件,并输入下面的内容:
#!/usr/bin/env python3
# 1) 加载 BCC 库
from bcc import BPF
# 2) 加载 eBPF 内核态程序
b = BPF(src_file="hello.c")
# 3) 将 eBPF 程序挂载到 kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 4) 读取并且打印 eBPF 内核态程序输出的数据
b.trace_print()
- 导入了 BCC 库的 BPF 模块,以便接下来调用
- 调用 BPF() 函数加载 eBPF 内核态程序(也就是我们编写的hello.c,会自动编译成bytecode)。
- 将ebpf程序挂载到内核探针(kprobe),do_sys_openat2是要hook的内核函数,相应的handler为hello_world函数。
- 读取内核调试文件 /sys/kernel/debug/tracing/trace_pipe 的内容(bpf_trace_printk() 函数会将信息写入到此文件),并打印到标准输出中。
更丰富的开发程序见[3]。
参考资料
[1] 一文看懂eBPF、eBPF的使用(超详细) - 知乎