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