经典 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 */