经典 BPF 与 eBPF¶
eBPF 设计为通过一对一映射进行 JIT 编译,这也为 GCC/LLVM 编译器通过 eBPF 后端生成优化的 eBPF 代码打开了可能性,其性能几乎与本机编译的代码一样快。
eBPF 格式相对于经典 BPF 的一些核心变化
寄存器数量从 2 个增加到 10 个
旧格式有两个寄存器 A 和 X,以及一个隐藏的帧指针。新布局将其扩展为 10 个内部寄存器和一个只读帧指针。由于 64 位 CPU 通过寄存器将参数传递给函数,因此从 eBPF 程序到内核函数的参数数量被限制为 5 个,并且一个寄存器用于接收来自内核函数的返回值。在原生情况下,x86_64 在寄存器中传递前 6 个参数,aarch64/sparcv9/mips64 有 7 - 8 个寄存器用于参数;x86_64 有 6 个被调用者保存的寄存器,而 aarch64/sparcv9/mips64 有 11 个或更多被调用者保存的寄存器。
因此,所有 eBPF 寄存器都与 x86_64、aarch64 等上的硬件寄存器一一对应,并且 eBPF 调用约定直接映射到 64 位架构上内核使用的 ABI。
在 32 位架构上,JIT 可以映射仅使用 32 位算术的程序,并允许解释更复杂的程序。
R0 - R5 是临时寄存器,如果需要在调用之间使用,eBPF 程序需要进行溢出/填充。请注意,只有一个 eBPF 程序(即一个 eBPF 主例程),它不能调用其他 eBPF 函数,它只能调用预定义的内核函数。
寄存器宽度从 32 位增加到 64 位
尽管如此,原始 32 位 ALU 操作的语义仍然通过 32 位子寄存器保留。所有 eBPF 寄存器都是 64 位的,带有 32 位较低的子寄存器,如果写入这些子寄存器,它们会零扩展为 64 位。此行为直接映射到 x86_64 和 arm64 子寄存器定义,但使其他 JIT 更难实现。
32 位架构通过解释器运行 64 位 eBPF 程序。它们的 JIT 可以将仅使用 32 位子寄存器的 BPF 程序转换为本机指令集,并让其余部分被解释。
操作是 64 位的,因为在 64 位架构上,指针也是 64 位宽的,并且我们希望在内核函数中/外传递 64 位值,因此 32 位 eBPF 寄存器将需要定义寄存器对 ABI,因此,将无法使用直接的 eBPF 寄存器到硬件寄存器的映射,并且 JIT 需要为进出函数的每个寄存器执行组合/拆分/移动操作,这既复杂又容易出错且速度慢。另一个原因是使用原子 64 位计数器。
有条件的 jt/jf 目标被替换为 jt/fall-through
虽然最初的设计具有诸如
if (cond) jump_true; else jump_false;
之类的结构,但它们被替换为诸如if (cond) jump_true; /* else fall-through */
之类的替代结构。引入 bpf_call 指令以及从/到其他内核函数的零开销调用的寄存器传递约定
在内核函数调用之前,eBPF 程序需要将函数参数放入 R1 到 R5 寄存器以满足调用约定,然后解释器将从寄存器中获取它们并传递给内核函数。如果 R1 - R5 寄存器映射到给定架构上用于参数传递的 CPU 寄存器,则 JIT 编译器不需要发出额外的移动指令。函数参数将位于正确的寄存器中,并且 BPF_CALL 指令将被 JIT 编译为单个“call”硬件指令。选择此调用约定是为了涵盖常见的调用情况,而不会造成性能损失。
在内核函数调用之后,R1 - R5 将重置为不可读,而 R0 具有该函数的返回值。由于 R6 - R9 是被调用者保存的,因此它们的状态会在调用中保留。
例如,考虑三个 C 函数
u64 f1() { return (*_f2)(1); } u64 f2(u64 a) { return f3(a + 1, a); } u64 f3(u64 a, u64 b) { return a - b; }
GCC 可以将 f1 和 f3 编译为 x86_64
f1: movl $1, %edi movq _f2(%rip), %rax jmp *%rax f3: movq %rdi, %rax subq %rsi, %rax ret
eBPF 中的函数 f2 可能如下所示
f2: bpf_mov R2, R1 bpf_add R1, 1 bpf_call f3 bpf_exit
如果 f2 被 JIT 编译并存储到
_f2
的指针。调用 f1 -> f2 -> f3 和返回将是无缝的。如果没有 JIT,则需要使用 __bpf_prog_run() 解释器来调用 f2。出于实际原因,所有 eBPF 程序只有一个参数 “ctx”,该参数已放置在 R1 中(例如,在 __bpf_prog_run() 启动时),并且程序可以使用最多 5 个参数调用内核函数。目前不支持使用 6 个或更多参数的调用,但如果将来需要,可以取消这些限制。
在 64 位架构上,所有寄存器都与硬件寄存器一一对应。例如,x86_64 JIT 编译器可以将它们映射为 ...
R0 - rax R1 - rdi R2 - rsi R3 - rdx R4 - rcx R5 - r8 R6 - rbx R7 - r13 R8 - r14 R9 - r15 R10 - rbp
... 因为 x86_64 ABI 要求 rdi、rsi、rdx、rcx、r8、r9 用于参数传递,而 rbx、r12 - r15 是被调用者保存的。
然后,以下 eBPF 伪程序
bpf_mov R6, R1 /* save ctx */ bpf_mov R2, 2 bpf_mov R3, 3 bpf_mov R4, 4 bpf_mov R5, 5 bpf_call foo bpf_mov R7, R0 /* save foo() return value */ bpf_mov R1, R6 /* restore ctx for next call */ bpf_mov R2, 6 bpf_mov R3, 7 bpf_mov R4, 8 bpf_mov R5, 9 bpf_call bar bpf_add R0, R7 bpf_exit
经过 JIT 到 x86_64 后可能如下所示
push %rbp mov %rsp,%rbp sub $0x228,%rsp mov %rbx,-0x228(%rbp) mov %r13,-0x220(%rbp) mov %rdi,%rbx mov $0x2,%esi mov $0x3,%edx mov $0x4,%ecx mov $0x5,%r8d callq foo mov %rax,%r13 mov %rbx,%rdi mov $0x6,%esi mov $0x7,%edx mov $0x8,%ecx mov $0x9,%r8d callq bar add %r13,%rax mov -0x228(%rbp),%rbx mov -0x220(%rbp),%r13 leaveq retq
在此示例中,它在 C 中等效于
u64 bpf_filter(u64 ctx) { return foo(ctx, 2, 3, 4, 5) + bar(ctx, 6, 7, 8, 9); }
具有原型:u64 (*)(u64 arg1, u64 arg2, u64 arg3, u64 arg4, u64 arg5); 的内核函数 foo() 和 bar() 将在正确的寄存器中接收参数,并将其返回值放入 eBPF 中的
%rax
(即 R0)。序言和尾声由 JIT 发出,并且在解释器中是隐式的。R0-R5 是临时寄存器,因此 eBPF 程序需要按照调用约定在调用之间保留它们。例如,以下程序无效
bpf_mov R1, 1 bpf_call foo bpf_mov R0, R1 bpf_exit
调用后,寄存器 R1-R5 包含垃圾值,无法读取。内核 eBPF 验证器 用于验证 eBPF 程序。
此外,在新设计中,eBPF 被限制为 4096 条指令,这意味着任何程序都会快速终止,并且只会调用固定数量的内核函数。原始 BPF 和 eBPF 都是双操作数指令,这有助于在 JIT 期间实现 eBPF 指令和 x86 指令之间的一对一映射。
用于调用解释器函数的输入上下文指针是通用的,其内容由特定的用例定义。对于 seccomp,寄存器 R1 指向 seccomp_data,对于转换后的 BPF 过滤器,R1 指向 skb。
内部转换的程序由以下元素组成
op:16, jt:8, jf:8, k:32 ==> op:8, dst_reg:4, src_reg:4, off:16, imm:32
到目前为止,已实现 87 条 eBPF 指令。8 位 “op” 操作码字段为新指令留有空间。其中一些可以使用 16/24/32 字节编码。新指令必须是 8 字节的倍数,以保持向后兼容性。
eBPF 是一种通用 RISC 指令集。并非在从原始 BPF 转换为 eBPF 的过程中使用每个寄存器和每条指令。例如,套接字过滤器不使用 exclusive add
指令,但跟踪过滤器可能会使用它来维护事件的计数器。寄存器 R9 也不被套接字过滤器使用,但更复杂的过滤器可能会耗尽寄存器,并且必须求助于溢出/填充到堆栈。
eBPF 可以用作最后一步性能优化的通用汇编器,套接字过滤器和 seccomp 将其用作汇编器。跟踪过滤器可以使用它作为汇编器来生成来自内核的代码。内核中的使用可能不受安全考虑的限制,因为生成的 eBPF 代码可能正在优化内部代码路径,而不会暴露给用户空间。eBPF 验证器可以保证 eBPF 的安全。在如上所述的用例中,它可以被用作安全的指令集。
与原始 BPF 一样,eBPF 在受控环境中运行,是确定性的,并且内核可以轻松地证明这一点。程序的安全性可以通过两个步骤确定:第一步执行深度优先搜索以禁止循环和其他 CFG 验证;第二步从第一条指令开始并下降所有可能的路径。它模拟每条指令的执行并观察寄存器和堆栈的状态变化。
操作码编码¶
eBPF 重用经典中的大多数操作码编码,以简化将经典 BPF 转换为 eBPF 的过程。
对于算术和跳转指令,8 位“code”字段分为三个部分
+----------------+--------+--------------------+
| 4 bits | 1 bit | 3 bits |
| operation code | source | instruction class |
+----------------+--------+--------------------+
(MSB) (LSB)
三个 LSB 位存储指令类,它是以下之一
经典 BPF 类
eBPF 类
BPF_LD 0x00
BPF_LD 0x00
BPF_LDX 0x01
BPF_LDX 0x01
BPF_ST 0x02
BPF_ST 0x02
BPF_STX 0x03
BPF_STX 0x03
BPF_ALU 0x04
BPF_ALU 0x04
BPF_JMP 0x05
BPF_JMP 0x05
BPF_RET 0x06
BPF_JMP32 0x06
BPF_MISC 0x07
BPF_ALU64 0x07
第 4 位编码源操作数 ...
BPF_K 0x00 BPF_X 0x08
在经典 BPF 中,这意味着
BPF_SRC(code) == BPF_X - use register X as source operand BPF_SRC(code) == BPF_K - use 32-bit immediate as source operand在 eBPF 中,这意味着
BPF_SRC(code) == BPF_X - use 'src_reg' register as source operand BPF_SRC(code) == BPF_K - use 32-bit immediate as source operand
... 和四个 MSB 位存储操作码。
如果 BPF_CLASS(code) == BPF_ALU 或 BPF_ALU64 [在 eBPF 中],则 BPF_OP(code) 是以下之一
BPF_ADD 0x00
BPF_SUB 0x10
BPF_MUL 0x20
BPF_DIV 0x30
BPF_OR 0x40
BPF_AND 0x50
BPF_LSH 0x60
BPF_RSH 0x70
BPF_NEG 0x80
BPF_MOD 0x90
BPF_XOR 0xa0
BPF_MOV 0xb0 /* eBPF only: mov reg to reg */
BPF_ARSH 0xc0 /* eBPF only: sign extending shift right */
BPF_END 0xd0 /* eBPF only: endianness conversion */
如果 BPF_CLASS(code) == BPF_JMP 或 BPF_JMP32 [在 eBPF 中],则 BPF_OP(code) 是以下之一
BPF_JA 0x00 /* BPF_JMP only */
BPF_JEQ 0x10
BPF_JGT 0x20
BPF_JGE 0x30
BPF_JSET 0x40
BPF_JNE 0x50 /* eBPF only: jump != */
BPF_JSGT 0x60 /* eBPF only: signed '>' */
BPF_JSGE 0x70 /* eBPF only: signed '>=' */
BPF_CALL 0x80 /* eBPF BPF_JMP only: function call */
BPF_EXIT 0x90 /* eBPF BPF_JMP only: function return */
BPF_JLT 0xa0 /* eBPF only: unsigned '<' */
BPF_JLE 0xb0 /* eBPF only: unsigned '<=' */
BPF_JSLT 0xc0 /* eBPF only: signed '<' */
BPF_JSLE 0xd0 /* eBPF only: signed '<=' */
因此,BPF_ADD | BPF_X | BPF_ALU 在经典 BPF 和 eBPF 中都表示 32 位加法。经典 BPF 中只有两个寄存器,所以它表示 A += X。在 eBPF 中,它表示 dst_reg = (u32) dst_reg + (u32) src_reg;类似地,BPF_XOR | BPF_K | BPF_ALU 在经典 BPF 中表示 A ^= imm32,而在 eBPF 中则表示 src_reg = (u32) src_reg ^ (u32) imm32。
经典 BPF 使用 BPF_MISC 类来表示 A = X 和 X = A 的移动操作。eBPF 则使用 BPF_MOV | BPF_X | BPF_ALU 代码代替。由于 eBPF 中没有 BPF_MISC 操作,因此使用类 7 作为 BPF_ALU64,表示与 BPF_ALU 完全相同的操作,但使用 64 位宽的操作数。因此,BPF_ADD | BPF_X | BPF_ALU64 表示 64 位加法,即:dst_reg = dst_reg + src_reg。
经典 BPF 浪费了整个 BPF_RET 类来表示单个 ret
操作。经典 BPF_RET | BPF_K 表示将 imm32 复制到返回寄存器并执行函数退出。eBPF 的建模是为了匹配 CPU,因此 eBPF 中的 BPF_JMP | BPF_EXIT 仅表示函数退出。eBPF 程序需要在执行 BPF_EXIT 之前将返回值存储到寄存器 R0 中。eBPF 中的类 6 用作 BPF_JMP32,表示与 BPF_JMP 完全相同的操作,但比较时使用 32 位宽的操作数。
对于加载和存储指令,8 位 “code” 字段被划分为:
+--------+--------+-------------------+
| 3 bits | 2 bits | 3 bits |
| mode | size | instruction class |
+--------+--------+-------------------+
(MSB) (LSB)
大小修饰符是以下之一 ...
BPF_W 0x00 /* word */
BPF_H 0x08 /* half word */
BPF_B 0x10 /* byte */
BPF_DW 0x18 /* eBPF only, double word */
... 它编码了加载/存储操作的大小
B - 1 byte
H - 2 byte
W - 4 byte
DW - 8 byte (eBPF only)
模式修饰符是以下之一
BPF_IMM 0x00 /* used for 32-bit mov in classic BPF and 64-bit in eBPF */
BPF_ABS 0x20
BPF_IND 0x40
BPF_MEM 0x60
BPF_LEN 0x80 /* classic BPF only, reserved in eBPF */
BPF_MSH 0xa0 /* classic BPF only, reserved in eBPF */
BPF_ATOMIC 0xc0 /* eBPF only, atomic operations */