经典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() 将在正确的寄存器中接收参数,并将它们的返回值放入
%rax
,这在 eBPF 中是 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的大部分操作码编码,以简化经典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_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位宽。
经典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 */