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