eBPF 基础知识-BPF 指令集 (Instruction Set)

212 阅读18分钟

BPF 指令集 (Instruction Set)

概述

BPF 是一个通用的 RISC 指令集 (RISC instruction set),最初设计用于编写 C 语言子集的程序,这些程序可以通过编译器后端 (compiler back end)(例如 LLVM)编译成 BPF 指令,然后内核可以通过内核内的 JIT 编译器 (in-kernel JIT compiler) 将它们映射为原生操作码 (native opcodes),从而在内核内部实现最佳的执行性能。

内核内执行的优势

将这些指令推送到内核的优势包括:

  • 使内核可编程,而无需跨越内核/用户空间边界 (kernel / user space boundaries)。例如,与网络相关的 BPF 程序,如 Cilium 的情况,可以实现灵活的容器策略 (container policies)、负载平衡 (load balancing) 和其他手段,而无需将数据包移动到用户空间再返回内核。BPF 程序与内核/用户空间之间的状态仍然可以在需要时通过 maps 共享。
  • 考虑到可编程数据路径 (programmable data path) 的灵活性,程序可以通过编译排除程序解决的用例不需要的功能来大幅优化性能。例如,如果容器不需要 IPv4,那么 BPF 程序可以构建为只处理 IPv6,以便在快速路径 (fast-path) 中节省资源。
  • 在网络情况下(例如 tc 和 XDP),BPF 程序可以原子性地更新 (updated atomically),而无需重启内核、系统服务或容器,也不会中断流量。此外,任何程序状态也可以通过 BPF maps 在更新过程中保持。
  • BPF 向用户空间提供稳定的 ABI,并且不需要任何第三方内核模块 (third party kernel modules)。BPF 是 Linux 内核的核心部分,随处发布,并保证现有的 BPF 程序在较新的内核版本中继续运行。这个保证与内核为用户空间应用程序的系统调用 (system calls) 提供的保证相同。此外,BPF 程序在不同架构之间是可移植的 (portable)。
  • BPF 程序与内核协同工作,它们利用现有的内核基础设施(例如 drivers、netdevices、tunnels、protocol stack、sockets)和工具(例如 iproute2)以及内核提供的安全保证。与内核模块不同,BPF 程序通过内核内验证器 (in-kernel verifier) 进行验证,以确保它们不会导致内核崩溃、总是终止等。例如,XDP 程序重用现有的内核内驱动程序,并操作包含数据包帧 (packet frames) 的提供的 DMA 缓冲区,而不像其他模型那样将它们或整个驱动程序暴露给用户空间。此外,XDP 程序重用现有的堆栈而不是绕过它。BPF 可以被认为是内核设施之间的通用"胶水代码 (glue code)",用于制作程序来解决特定用例。

事件驱动执行模式

内核内 BPF 程序的执行总是事件驱动的 (event-driven)!示例:

  • 在其入口路径 (ingress path) 上附加了 BPF 程序的网络设备将在接收到数据包时触发程序的执行。
  • 附加了 BPF 程序的 kprobe 的内核地址将在该地址的代码被执行时陷阱 (trap),然后调用 kprobe 的回调函数进行检测,随后触发附加的 BPF 程序的执行。

寄存器架构

BPF 由十一个 64 位寄存器 (64 bit registers) 和 32 位子寄存器 (32 bit subregisters)、一个程序计数器 (program counter) 和一个 512 字节大的 BPF 栈空间组成。寄存器命名为 r0 - r10。操作模式默认为 64 位,32 位子寄存器只能通过特殊的 ALU(算术逻辑单元 arithmetic logic unit)操作访问。32 位低位子寄存器在写入时会零扩展 (zero-extend) 到 64 位。

寄存器 r10 是唯一的只读寄存器,包含帧指针地址 (frame pointer address) 以访问 BPF 栈空间。其余的 r0 - r9 寄存器是通用的,具有读/写性质。

调用约定 (Calling Convention)

BPF 程序可以调用预定义的辅助函数 (helper function),这些函数由核心内核定义(从不由模块定义)。BPF 调用约定 (calling convention) 定义如下:

  • r0 包含辅助函数调用的返回值。
  • r1 - r5 保存从 BPF 程序传递给内核辅助函数的参数。
  • r6 - r9 是被调用者保存的寄存器 (callee saved registers),在辅助函数调用后会被保留。

BPF 调用约定足够通用,可以直接映射到 x86_64arm64 和其他 ABI,因此所有 BPF 寄存器都可以一对一映射到硬件 CPU 寄存器,JIT 只需要发出调用指令,但不需要额外的移动操作来放置函数参数。这个调用约定为覆盖常见调用情况而建模,而不会产生性能损失。目前不支持具有 6 个或更多参数的调用。内核中专用于 BPF 的辅助函数(BPF_CALL_0() 到 BPF_CALL_5() 函数)是专门考虑到这个约定而设计的。

寄存器 r0 也是包含 BPF 程序退出值的寄存器。退出值的语义由程序类型定义。此外,当将执行权交还给内核时,退出值作为 32 位值传递。

寄存器 r1 - r5 是临时寄存器 (scratch registers),这意味着如果这些参数要在多个辅助函数调用中重用,BPF 程序需要将它们溢出 (spill) 到 BPF 栈或移动到被调用者保存的寄存器。溢出意味着寄存器中的变量被移动到 BPF 栈。将变量从 BPF 栈移动到寄存器的相反操作称为填充 (filling)。溢出/填充的原因是由于寄存器数量有限。

程序上下文和参数传递

在进入 BPF 程序执行时,寄存器 r1 最初包含程序的上下文。上下文是程序的输入参数(类似于典型 C 程序的 argc/argv 对)。BPF 被限制为处理单个上下文。上下文由程序类型定义,例如,网络程序可以将网络数据包 (skb) 的内核表示作为输入参数。

以下示例显示了 XDP BPF 程序:

int xdp_drop(struct xdp_md *ctx)
{
    return XDP_DROP;
}

上下文参数在这种情况下是类型为 struct xdp_mdctx。如果将程序从 C 编译为 BPF,则上下文参数将始终位于 r1 中。

在 64 位架构上,BPF 程序调用内核函数的通用参数限制为最多 5 个参数。参数从参数 1 到参数 5 依次传递,并在寄存器 r1r5 中传递。

在 32 位架构上,BPF 程序调用内核函数的通用参数限制为最多 5 个参数,总大小最多 6 个 32 位字。例如,在 32 位架构上,一个具有 3 个参数的函数,其中参数 1 和参数 3 是 32 位,参数 2 是 64 位,将占用 4 个 32 位字,因此适合参数限制。在这种情况下,参数 1 在 r1 中传递,参数 2 在 r2r3 中传递,参数 3 在 r4 中传递。

BPF 辅助函数 (Helper Functions)

BPF 程序不能调用任意的内核函数,只能调用暴露为 BPF 辅助函数的函数。可用的 BPF 辅助函数集根据程序类型而定义,例如,附加到 tc 层的 BPF 程序被允许调用与附加到 XDP 层的 BPF 程序不同的内核函数集。

通用的 BPF 辅助函数集在所有程序类型中共享(例如 maps 辅助函数,用于生成伪随机数的辅助函数等)。

BPF 辅助函数调用是从 BPF 程序到内核的高效调用,因为它们在编译时被实现为直接调用,而不是通过某种陷阱到内核。JIT 编译器发出这些调用作为对实际函数的直接调用,因此调用开销与对常规内核函数的调用相当,这意味着性能比系统调用要好得多。

辅助函数的设计是扩展性和高效的。辅助函数提供了一个稳定的 API,并且允许将更多的功能"插入"到 BPF 中。数百个辅助函数已经可用。

除了 BPF 辅助函数调用,BPF 还支持 BPF 到 BPF 的调用,这意味着从一个 BPF 函数到另一个 BPF 函数的调用。这允许实现函数调用以更好地分解程序。

BPF JIT 编译器为每个架构发出原生操作码,以便优化执行速度。JIT 编译器在程序加载时发出这些操作码,因此 BPF 程序运行在原生性能上。

64位操作和指令限制

BPF 的一般操作是 64 位,以遵循 64 位架构的自然模型,以便执行指针算术、传递指针,同时也向辅助函数传递 64 位值,并允许 64 位原子操作。

每个程序的最大指令限制被限制为 4096 个 BPF 指令,这在设计上意味着任何程序都会快速终止。对于 5.1 以后的内核,这个限制被提升到 100 万个 BPF 指令。虽然指令集包含前向和后向跳转,但内核内的 BPF 验证器将禁止循环,以便始终保证终止。由于 BPF 程序在内核内运行,验证器的工作是确保这些程序运行安全,不影响系统的稳定性。这意味着从指令集的角度来看,可以实现循环,但验证器会限制这一点。然而,还有一个尾调用 (tail calls) 的概念,允许一个 BPF 程序跳转到另一个程序。这也有 33 次调用的嵌套上限,通常用于解耦程序逻辑的各个部分,例如分成阶段。

指令格式和编码

指令格式建模为双操作数指令,这有助于在 JIT 阶段将 BPF 指令映射到原生指令。指令集具有固定大小,意味着每个指令都有 64 位编码。目前已实现 87 个指令,编码也允许在需要时用更多指令扩展集合。

基本指令编码

eBPF 程序是一个 64 位指令的序列。所有 eBPF 指令都具有相同的基本编码:

msb                                                        lsb
+------------------------+----------------+----+----+--------+
|immediate               |offset          |src |dst |opcode  |
+------------------------+----------------+----+----+--------+

从最低有效位到最高有效位:

  • 8 位操作码 (opcode)
  • 4 位目标寄存器 (dst)
  • 4 位源寄存器 (src)
  • 16 位偏移量 (offset)
  • 32 位立即数 (imm)

在大端机器上单个 64 位指令的指令编码定义为从最高有效位 (MSB) 到最低有效位 (LSB) 的位序列:op:8、dst_reg:4、src_reg:4、off:16、imm:32。off 和 imm 是有符号类型。编码是内核头文件的一部分,定义在 linux/bpf.h 头文件中,该文件也包含 linux/bpf_common.h。

操作码结构

操作码字段的低 3 位是"指令类",这将相关的操作码组合在一起。

LD/LDX/ST/STX 操作码结构:

msb      lsb
+---+--+---+
|mde|sz|cls|
+---+--+---+

sz 字段指定内存位置的大小。mde 字段是内存访问模式。

ALU/ALU64/JMP 操作码结构:

msb      lsb
+----+-+---+
|op  |s|cls|
+----+-+---+

如果 s 位为零,则源操作数为 imm。如果 s 为一,则源操作数为 srcop 字段指定要执行的 ALU 或分支操作。

op 定义要执行的实际操作。op 的大部分编码已从 cBPF 重用。操作可以基于寄存器或立即操作数。op 本身的编码提供了使用哪种模式的信息(BPF_X 表示基于寄存器的操作,BPF_K 表示基于立即数的操作)。在后一种情况下,目标操作数始终是寄存器。dst_reg 和 src_reg 都提供关于要用于操作的寄存器操作数的附加信息(例如 r0 - r9)。off 在某些指令中用于提供相对偏移,例如,用于寻址栈或 BPF 可用的其他缓冲区(例如 map 值、数据包数据等),或跳转指令中的跳转目标。imm 包含常量/立即值。

指令分类详解

可用的 op 指令可以分类为各种指令类。这些类也编码在 op 字段内。op 字段分为(从 MSB 到 LSB)code:4、source:1 和 class:3。class 是更通用的指令类,code 表示该类内的特定操作代码,source 告诉源操作数是寄存器还是立即值。

BPF_LD, BPF_LDX - 加载指令

两个类都用于加载操作。BPF_LD 用于加载双字作为跨越两个指令的特殊指令(由于 imm:32 分割),以及数据包数据的字节/半字/字加载。后者主要从 cBPF 承继,以保持 cBPF 到 BPF 转换的高效,因为它们具有优化的 JIT 代码。对于原生 BPF,这些数据包加载指令现在不太相关。BPF_LDX 类包含从内存中进行字节/半字/字/双字加载的指令。这里的内存是通用的,可以是栈内存、map 值数据、数据包数据等。

内存加载指令表:

操作码助记符伪代码
0x18lddw dst, immdst = imm
0x20ldabsw src, dst, imm参见内核文档
0x28ldabsh src, dst, imm...
0x30ldabsb src, dst, imm...
0x38ldabsdw src, dst, imm...
0x40ldindw src, dst, imm...
0x48ldindh src, dst, imm...
0x50ldindb src, dst, imm...
0x58ldinddw src, dst, imm...
0x61ldxw dst, [src+off]dst = *(uint32_t *) (src + off)
0x69ldxh dst, [src+off]dst = *(uint16_t *) (src + off)
0x71ldxb dst, [src+off]dst = *(uint8_t *) (src + off)
0x79ldxdw dst, [src+off]dst = *(uint64_t *) (src + off)

BPF_ST, BPF_STX - 存储指令

两个类都用于存储操作。类似于 BPF_LDX,BPF_STX 是存储对应物,用于将数据从寄存器存储到内存中,同样可以是栈内存、map 值、数据包数据等。BPF_STX 还包含用于执行基于字和双字的原子加操作的特殊指令,例如可用于计数器。BPF_ST 类类似于 BPF_STX,提供将数据存储到内存的指令,只是源操作数是立即值。

内存存储指令表:

操作码助记符伪代码
0x62stw [dst+off], imm*(uint32_t *) (dst + off) = imm
0x6asth [dst+off], imm*(uint16_t *) (dst + off) = imm
0x72stb [dst+off], imm*(uint8_t *) (dst + off) = imm
0x7astdw [dst+off], imm*(uint64_t *) (dst + off) = imm
0x63stxw [dst+off], src*(uint32_t *) (dst + off) = src
0x6bstxh [dst+off], src*(uint16_t *) (dst + off) = src
0x73stxb [dst+off], src*(uint8_t *) (dst + off) = src
0x7bstxdw [dst+off], src*(uint64_t *) (dst + off) = src

BPF_ALU, BPF_ALU64 - 算术逻辑单元指令

两个类都包含 ALU 操作。通常,BPF_ALU 操作处于 32 位模式,BPF_ALU64 处于 64 位模式。两个 ALU 类都有基于寄存器的源操作数的基本操作和基于立即数的对应操作。两者都支持加 (+)、减 (-)、与 (&)、或 (|)、左移 (<<)、右移 (>>)、异或 (^)、乘 (*)、除 (/)、模 (%)、取反 (~) 操作。还为两个类的两种操作数模式添加了移动 (mov)( := )作为特殊 ALU 操作。BPF_ALU64 还包含有符号右移。BPF_ALU 另外包含给定源寄存器上半字/字/双字的字节序转换指令。

64位 ALU 指令表:

操作码助记符伪代码
0x07add dst, immdst += imm
0x0fadd dst, srcdst += src
0x17sub dst, immdst -= imm
0x1fsub dst, srcdst -= src
0x27mul dst, immdst *= imm
0x2fmul dst, srcdst *= src
0x37div dst, immdst /= imm
0x3fdiv dst, srcdst /= src
0x47or dst, immdst= imm
0x4for dst, srcdst= src
0x57and dst, immdst &= imm
0x5fand dst, srcdst &= src
0x67lsh dst, immdst <<= imm
0x6flsh dst, srcdst <<= src
0x77rsh dst, immdst >>= imm (逻辑)
0x7frsh dst, srcdst >>= src (逻辑)
0x87neg dstdst = -dst
0x97mod dst, immdst %= imm
0x9fmod dst, srcdst %= src
0xa7xor dst, immdst ^= imm
0xafxor dst, srcdst ^= src
0xb7mov dst, immdst = imm
0xbfmov dst, srcdst = src
0xc7arsh dst, immdst >>= imm (算术)
0xcfarsh dst, srcdst >>= src (算术)

32位 ALU 指令表:

这些指令只使用其操作数的低 32 位,并将目标寄存器的高 32 位归零。

操作码助记符伪代码
0x04add32 dst, immdst += imm
0x0cadd32 dst, srcdst += src
0x14sub32 dst, immdst -= imm
0x1csub32 dst, srcdst -= src
0x24mul32 dst, immdst *= imm
0x2cmul32 dst, srcdst *= src
0x34div32 dst, immdst /= imm
0x3cdiv32 dst, srcdst /= src
0x44or32 dst, immdst= imm
0x4cor32 dst, srcdst= src
0x54and32 dst, immdst &= imm
0x5cand32 dst, srcdst &= src
0x64lsh32 dst, immdst <<= imm
0x6clsh32 dst, srcdst <<= src
0x74rsh32 dst, immdst >>= imm (逻辑)
0x7crsh32 dst, srcdst >>= src (逻辑)
0x84neg32 dstdst = -dst
0x94mod32 dst, immdst %= imm
0x9cmod32 dst, srcdst %= src
0xa4xor32 dst, immdst ^= imm
0xacxor32 dst, srcdst ^= src
0xb4mov32 dst, immdst = imm
0xbcmov32 dst, srcdst = src
0xc4arsh32 dst, immdst >>= imm (算术)
0xccarsh32 dst, srcdst >>= src (算术)

字节序交换指令表:

操作码助记符伪代码
0xd4 (imm == 16)le16 dstdst = htole16(dst)
0xd4 (imm == 32)le32 dstdst = htole32(dst)
0xd4 (imm == 64)le64 dstdst = htole64(dst)
0xdc (imm == 16)be16 dstdst = htobe16(dst)
0xdc (imm == 32)be32 dstdst = htobe32(dst)
0xdc (imm == 64)be64 dstdst = htobe64(dst)

BPF_JMP - 跳转指令

这个类专用于跳转操作。跳转可以是无条件的和有条件的。无条件跳转简单地向前移动程序计数器,使得相对于当前指令执行的下一个指令是 off + 1,其中 off 是指令中编码的常量偏移。由于 off 是有符号的,只要它不创建循环并且在程序边界内,跳转也可以向后执行。条件跳转在基于寄存器和基于立即数的源操作数上操作。如果跳转操作中的条件结果为真,则执行到 off + 1 的相对跳转,否则执行下一个指令 (0 + 1)。这种直通跳转逻辑与 cBPF 不同,允许更好的分支预测,因为它更自然地适合 CPU 分支预测器逻辑。

可用的条件是 jeq (==)、jne (!=)、jgt (>)、jge (>=)、jsgt (有符号 >)、jsge (有符号 >=)、jlt (<)、jle (<=)、jslt (有符号 <)、jsle (有符号 <=) 和 jset (如果 DST & SRC 则跳转)。除此之外,这个类中还有三个特殊的跳转操作:exit 指令将离开 BPF 程序并返回 r0 中的当前值作为返回代码,call 指令将对可用的 BPF 辅助函数之一发出函数调用,以及隐藏的尾调用指令,将跳转到不同的 BPF 程序。

分支指令表:

操作码助记符伪代码
0x05ja +offPC += off
0x15jeq dst, imm, +offPC += off if dst == imm
0x1djeq dst, src, +offPC += off if dst == src
0x25jgt dst, imm, +offPC += off if dst > imm
0x2djgt dst, src, +offPC += off if dst > src
0x35jge dst, imm, +offPC += off if dst >= imm
0x3djge dst, src, +offPC += off if dst >= src
0xa5jlt dst, imm, +offPC += off if dst < imm
0xadjlt dst, src, +offPC += off if dst < src
0xb5jle dst, imm, +offPC += off if dst <= imm
0xbdjle dst, src, +offPC += off if dst <= src
0x45jset dst, imm, +offPC += off if dst & imm
0x4djset dst, src, +offPC += off if dst & src
0x55jne dst, imm, +offPC += off if dst != imm
0x5djne dst, src, +offPC += off if dst != src
0x65jsgt dst, imm, +offPC += off if dst > imm (有符号)
0x6djsgt dst, src, +offPC += off if dst > src (有符号)
0x75jsge dst, imm, +offPC += off if dst >= imm (有符号)
0x7djsge dst, src, +offPC += off if dst >= src (有符号)
0xc5jslt dst, imm, +offPC += off if dst < imm (有符号)
0xcdjslt dst, src, +offPC += off if dst < src (有符号)
0xd5jsle dst, imm, +offPC += off if dst <= imm (有符号)
0xddjsle dst, src, +offPC += off if dst <= src (有符号)
0x85call imm函数调用
0x95exitreturn r0

BPF 解释器和 JIT 支持

Linux 内核附带一个 BPF 解释器 (interpreter),该解释器执行以 BPF 指令汇编的程序。甚至 cBPF 程序也在内核中透明地转换为 eBPF 程序,除了仍然附带 cBPF JIT 且尚未迁移到 eBPF JIT 的架构。

目前 x86_64、arm64、ppc64、s390x、mips64、sparc64 和 arm 架构都带有内核内 eBPF JIT 编译器。

系统调用管理

所有 BPF 处理,例如将程序加载到内核或创建 BPF maps,都通过中央 bpf() 系统调用进行管理。它还用于管理 map 条目(查找/更新/删除),以及通过固定 (pinning) 使程序和 maps 在 BPF 文件系统中持久化。