eBPF 程序从源代码到执行过程所经历的各个阶段:

C(或 Rust)源代码会被编译为 eBPF 字节码,而这些 eBPF 字节码又会通过即时编译(JIT) 或解释的方式,转换为原生的机器码指令。
eBPF 程序是一组 eBPF 字节码指令。目前,绝大多数 eBPF 代码是用 C 语言编写的,然后编译成 eBPF 字节码。这些字节码运行在内核中的 eBPF 虚拟机内。
3.1 The eBPF Virtual Machine
eBPF 虚拟机接收以 eBPF 字节码指令形式表示的程序,并将这些指令转换为在 CPU 上运行的原生机器指令。
早期,字节码指令是在内核中解释执行的。目前,很大程度上被即时(just-in-time,JIT)编译替代。编译(compilation)意味着当程序加载到内核时,从字节码到本机机器指令的转换只发生一次。
eBPF 字节码由一组指令组成,这些指令作用于(虚拟的)eBPF 寄存器。
3.1.1 eBPF Registers
eBPF 虚拟机使用 10 个通用寄存器,编号从 0 到 9。此外,还有一个寄存器 10 被用作栈帧指针(只能读取,不能写入)。在执行 BPF 程序时,这些寄存器中存储的值用于跟踪状态。
可以在 Linux 内核源代码的 include/uapi/linux/bpf.h 头文件中看到 BPF_REG_0 到 BPF_REG_10 的定义:
1 | /* Register numbers */ |
在调用 eBPF 代码中的函数之前,该函数的参数被放置在 BPF_REG_1 到 BPF_REG_5 中(如果参数少于五个,则不会使用所有寄存器)。
3.1.2 eBPF Instructions
include/uapi/linux/bpf.h 中定义了一个名为 bpf_insn 的结构体,代表一个 BPF 指令。
1 | struct bpf_insn { |
code: 每条指令都包含一个操作码(opcode),该操作码定义了这条指令需要执行的操作:例如,将一个数值加到寄存器的内容中,或者跳转到程序内的另一条指令处。- Unofficial eBPF spec 中列出了有效指令的列表
dst_reg和src_reg: 不同的操作可能涉及最多两个寄存器。off和imm: 根据操作的不同,可能还会有一个偏移值off和/或 一个“立即” (immediate) 整数值
bpf_insn 结构体的长度为 64 位(8 字节)。然而,有时一条指令可能需要多于 8 字节的空间。在这些情况下,指令使用总长度为 16 字节的宽指令编码 (wide instruction encoding)。
当加载到内核中时,eBPF 程序的字节码由一系列 bpf_insn 结构体表示。验证器 (verifier) 对这些信息进行多项检查,以确保代码的运行安全。
相关阅读:
3.2 eBPF “Hello World” for a Network Interface
为了将 eBPF 程序与可能存在于相同源代码目录中的用户空间 C 代码区分开来,将 eBPF 程序放在以文件名以 bpf.c 结尾的文件中。
示例 [hello.bpf.c]: 这是一个附加到网络接口上的 XDP 钩子点的 eBPF 程序示例。您可以将 XDP 事件视为在网络数据包到达(物理或虚拟)网络接口时立即触发。
1 |
|
XDP_PASS: 这是一条告知内核的判定结果,指示其按常规流程处理该网络数据包。SEC("license"):- 定义许可证字符串的
SEC()宏,这是 eBPF 程序的关键要求。 - 内核中的一些 BPF 辅助函数被定义为“仅限 GPL(GPL only)”。如果您想使用这些函数,您的 BPF 代码必须声明为具有 GPL 兼容的许可证。
- 如果声明的许可证与程序使用的函数不兼容,验证器会拒绝加载。
- 定义许可证字符串的
3.3 Compiling an eBPF Object File
eBPF 源代码需要编译成 eBPF 虚拟机能理解的机器指令:eBPF 字节码。Clang 编译器需要指定 -target bpf。
以下是从 Makefile 中截取的用于进行编译的部分:
1 | %.bpf.o: %.bpf.c |
这将从 hello.bpf.c 源代码生成一个名为 hello.bpf.o 的目标文件。
这里的 -g 标志是可选的,它可以生成调试信息,这样当你查看目标文件时,可以同时看到源代码与字节码。
3.4 Inspecting an eBPF Object File
使用 file命令来确定文件的内容:
1 | $ file hello.bpf.o |
这表明它是一个 ELF(Executable and Linkable Format,可执行和可链接格式)文件,包含 eBPF 代码,适用于具有 LSB(最低有效位)架构的 64 位平台。
如果在编译步骤中使用了 -g 标志,它将包含调试信息。
可以使用 llvm-objdump 进一步检查此目标文件,以查看其中的 eBPF 指令:
1 | $ llvm-objdump -S hello.bpf.o |
在每行字节码的左侧,可以看到该指令在内存中相对于 hello 所在位置的偏移量。eBPF 指令长度通常是 8 字节,在 64 位平台上,每个内存位置可以容纳 8 字节,因此偏移量通常会每条指令递增 1。
然而,该程序中的第一条指令恰好需要 16 字节的宽指令编码,以便将寄存器 6 设置为 64 位值 0。因此,输出的第二行指令的偏移量为 2。
1 | 5: b7 02 00 00 0f 00 00 00 r2 = 0xf |
- 操作码 (opcode) 是
0xb7, 查阅 Unofficial eBPF spec 其对应的伪代码是dst = imm,可以理解为将目标寄存器设置为立即数。 0x02代表寄存器 20x0f是立即数,代表十进制中的 15
因此,这条指令可以理解为:将 Register 2 设置为值 15。
1 | 10: b7 00 00 00 02 00 00 00 r0 = 0x2 |
与之类似的,该指令表示将 Register 0 设置为值 2。
3.5 Loading the Program into the Kernel
您可能需要以 root 身份(或使用 sudo)获得 bpftool 所需的 BPF 权限。
使用 bpftool 将程序加载到内核。该操作从已编译的目标文件中加载 eBPF 程序,并将其固定到路径 /sys/fs/bpf/hello 下。
1 | $ bpftool prog load hello.bpf.o /sys/fs/bpf/hello |
查看是否加载成功:
1 | $ ls /sys/fs/bpf |
3.6 Inspecting the Loaded Program
查看加载到内核中的所有程序:
1 | $ bpftool prog list |
将输出内容整理为格式化的 JSON 格式:
1 | $ bpftool prog show id 174 --pretty |
"uid": 0,表示 root 用户加载的程序。"bytes_xlated": 96,此程序中有 96 字节的翻译后的 eBPF 字节码。"jited": true,"bytes_jited": 71,该程序已经过 JIT 编译,编译产生了 71 字节的机器码。"bytes_memlock": 4096,此程序保留了 4096 字节的内存,这些内存不会被分页。"map_ids": [90,91],该程序引用了 ID 为 90 和 91 的 BPF map。(与全局变量有关)。"btf_id": 187表示该程序有一个 BTF 信息块。只有在使用-g标志进行编译时,才会将此信息包含在目标文件中。
3.6.1 The BPF Program Tag
标签(tag)是所有程序指令的 SHA(Secure Hashing Algorithm,安全哈希算法)散列值,可以用作程序的另一个标识符。
每次加载或卸载程序时,ID 可能会变化,但标签(tag)将保持不变。
1 | bpftool prog show id 174 |
3.6.2 The Translated Bytecode
bytes_xlated 字段告诉我们有多少字节的“翻译后”eBPF 代码。这是 eBPF 字节码在通过验证器之后(并可能被内核修改)得到的结果。
查看翻译后的 eBPF 代码:
1 | $ bpftool prog dump xlated name hello |
这与之前从 llvm-objdump 输出中看到的反汇编代码非常相似。偏移地址相同,指令也相似。例如,可以看到偏移量为 5 的指令是 r2 = 15。
3.6.3 The JIT-Compiled Machine Code
翻译后的字节码非常底层,但它还不完全是机器码。eBPF 会使用即时编译器(JIT),将 eBPF 字节码转换为可在目标 CPU 上原生运行的机器码。
bytes_jited 字段显示,经过该转换后,程序的长度为 71 字节。
bpftool 工具可以生成这份即时编译(JIT)代码的汇编语言 dump 文件(即汇编代码快照):
1 | $ bpftool prog dump jited name hello |
注:
我在执行该命令时,并没有执行成功,而是返回如下错误信息:
1 | $ bpftool prog dump jited name hello |
结合查到的信息,应该是我使用的内核(Ubuntu 24.04)没有编译这些功能。
1 | $ zcat /proc/config.gz | grep -E 'CONFIG_BPF_JIT|CONFIG_BPF_JIT_DISASM|CONFIG_DEBUG_INFO_BPF' |
3.7 Attaching to an Event
Hello World 这个 eBPF 程序被加载到了内核中,但此时它还没有与任何事件 (Event) 相关联,因此不会有任何触发条件使其运行。它需要被挂载到某个事件(Event)上。
eBPF 程序类型必须与其要挂载的事件类型相匹配。
使用 bpftool 将示例 eBPF 程序附加到网络接口上的 XDP 事件:
1 | $ bpftool net attach xdp tag d35b94b4c0c10efb dev eth0 |
查看挂载到网络协议栈 (network-attached) 的 BPF 程序:
1 | $ bpftool net list |
这份输出中还可以看到网络协议栈中其他可挂载 eBPF 程序的潜在事件,如 tc 和 flow_dissector。
查看网络接口:
1 | $ ip link |
查看输出信息:
1 | $ cat /sys/kernel/debug/tracing/trace_pipe |
或者:
1 | $ bpftool prog tracelog |
3.8 全局变量
eBPF map 是一种可以从 eBPF 程序或者用户空间访问的数据结构。 同一程序的不同流程可以多次访问同一个 map,多个程序也可以访问同一个 map。由于这些特性,eBPF map 可以作为全局变量使用。
eBPF 在 2019 年才支持全局变量。
- 查看加载到内核中的 map
1 | $ bpftool map list |
- 查看 map 的内容
在从 C 程序编译的目标文件中,bss 段通常保存全局变量
1 | $ bpftool map dump name hello.bss |
只有当 BTF 信息可用时,bpftool 才能美观地打印出 map 中的字段名;而要包含该 BTF 信息,需在编译时添加 -g 标志。
1 | $ bpftool map dump name hello.rodata |
3.9 Detaching the Program
- 将程序从网络接口分离(detach)
1 | $ bpftool net detach xdp dev eth0 |
- 列出挂载到网络栈的 BPF 程序
1 | $ bpftool net list |
但是,程序仍加载在内核中:
1 | $ bpftool prog show name hello |
3.10 Unloading the Program
目前,还没有 bpftool prog load 的反向命令,可以通过删除固定的伪文件来从内核中移除该程序:
1 | $ rm /sys/fs/bpf/hello |
3.11 BPF to BPF Calls
在上一章中看到了尾调用的应用,现在还可以从 eBPF 程序中调用函数。
示例:[hello-func.bpf.c]
1 | static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args *ctx) { |
__attribute((noinline))确保编译器不会内联该函数
调用该函数的 eBPF 函数如下所示:
1 | SEC("raw_tp") |
将其编译为 eBPF 目标文件后,可以使用 bpftool 将其加载到内核中,并确认它已加载:
1 | $ bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello |
值得注意的是在 eBPF 字节码中查看 get_opcode() 函数:
1 | $ bpftool prog dump xlated name hello |
其中,
1 | 0: (85) call pc+7#bpf_prog_cbacc90865b1b9a5_get_opcode |
0x85在 Unofficial eBPF spec 中可以看到该指令是函数调用 (Function call)。
因此,接下来不会继续执行下一条指令(即偏移量为 1 的指令),而是会跳过七条指令(pc+7),这意味着将执行偏移量为 8 的指令。
函数调用 (Function call) 指令需要将当前状态放在 eBPF 虚拟机的栈空间,以便在被调用函数退出时,可以在调用函数中继续执行。由于栈大小限制为 512 字节,因此 BPF 到 BPF 的调用不能嵌套得太深。
相关阅读:
3.12 Summary
- JIT (just-in-time) compilation: 即时编译