least significant bit(BPF架构一指令集)

BPF 架构 BPF 不仅仅是一个指令集,它还提供了围绕自身的一些基础设施,例如:BPF map:高效的 key/value 存储辅助函数(helper function):可以更方便地利用内核功能或与内核交互尾调用(tai...

BPF 架构

BPF 不仅仅是一个指令集,它还提供了围绕自身的一些基础设施,例如:

BPF map:高效的 key/value 存储辅助函数(helper function):可以更方便地利用内核功能或与内核交互尾调用(tail call):高效地调用其他 BPF 程序安全加固原语(security hardening primitives)用于 pin/unpin 对象(例如 map、程序)的伪文件系统(bpffs),实现持久存储支持 BPF offload(例如 offload 到网卡)的基础设施

LLVM 提供了一个 BPF 后端(back end),因此使用 clang 这样的工具就可以将 C 代 码编译成 BPF 对象文件(object file),然后再加载到内核。BPF 深度绑定 Linux 内核,可以在 不牺牲原生内核性能的前提下,实现对内核的完全可编程 (full programmability)。

另外, 使用了 BPF 的内核子系统也是 BPF 基础设施的一部分。本文将主要讨论 tc和 XDP 这两个子系统,二者都支持 attach(附着)BPF 程序。

XDP BPF 程序会被 attach 到网络驱动的最早阶段(earliest networking driver stage),驱动收到包之后就会触发 BPF 程序的执行。从定义上来说,这可以取得最好的包处理性能,因为这已经是软件中最早可以处理包的位置了。但也正因为 这一步的处理在网络栈中是如此之早,协议栈此时还没有从包中提取出元数据(因此 XDP BPF 程序无法利用这些元数据)。tc BPF 程序在内核栈中稍后面的一些地方执行,因此它们能够访问更多的元数据和一些核心的内核功能。

除了 tc 和 XDP 程序之外,还有很多其他内核子系统也在使用 BPF,例如跟踪子系统( kprobes、uprobes、tracepoints 等等)。

BPF架构(一)指令集

1.1 指令集1.1.1 指令集

BPF 是一个通用目的 RISC 指令集,其最初的设计目标是:

用 C 语言的一个子集编写程序,然后用一个编译器后端(例如 LLVM)将其编译成 BPF 指令,稍后内核再通过一个位于内核中的(in-kernel)即时编译器(JIT Compiler) 将 BPF 指令映射成处理器的原生指令(opcode ),以获得在内核中的最佳执行性能。

将这些指令推送到内核中可以带来如下增强:

无需在内核/用户空间切换就可以实现内核的可编程。例如,Cilium 这种和网络相关 的 BPF 程序能直接在内核中实现灵活的容器策略、负载均衡等功能,而无需将包送先 到用户空间,处理之后再送回内核。需要在 BPF 程序之间或内核/用户空间之间共享状态时,可以使用 BPF map。可编程 datapath 具有很大的灵活性,因此程序能在编译时将不需要的特性禁用掉, 从而极大地优化程序的性能。例如,如果容器不需要 IPv4,那编写 BPF 程序时就可以 只处理 IPv6 的情况,从而节省了快速路径(fast path)中的资源。对于网络场景(例如 tc 和 XDP),BPF 程序可以在无需重启内核、系统服务或容器的 情况下实现原子更新,并且不会导致网络中断。另外,更新 BPF map 不会导致程序 状态(program state)的丢失。BPF 给用户空间提供了一个稳定的 ABI,而且不依赖任何第三方内核模块。BPF 是 Linux 内核的一个核心组成部分,而 Linux 已经得到了广泛的部署,因此可以保证现 有的 BPF 程序能在新的内核版本上继续运行。这种保证与系统调用(内核提供给用户态应用的接口)是同一级别的。另外,BPF 程序在不同平台上是可移植的。BPF 程序与内核协同工作,复用已有的内核基础设施(例如驱动、netdevice、 隧道、协议栈和 socket)和工具(例如 iproute2),以及内核提供的安全保证。和内核模块不同,BPF 程序会被一个位于内核中的校验器(in-kernel verifier)进行校验, 以确保它们不会造成内核崩溃、程序永远会终止等等。例如,XDP 程序会复用已有的内核驱动,能够直接操作存放在 DMA 缓冲区中的数据帧,而不用像某些模型(例如 DPDK) 那样将这些数据帧甚至整个驱动暴露给用户空间。而且,XDP 程序复用内核协议栈而 不是绕过它。BPF 程序可以看做是内核设施之间的通用“胶水代码”, 基于 BPF 可以设计巧妙的程序,解决特定的问题。

BPF 程序在内核中的执行总是事件驱动的!例如:

如果网卡的 ingress 路径上 attach 了 BPF 程序,那当网卡收到包之后就会触发这 个 BPF 程序的执行。在某个有 kprobe 探测点的内核地址 attach 一段 BPF 程序后,当内核执行到这个地址时会发生陷入(trap),进而唤醒 kprobe 的回调函数,后者又会触发 attach 的 BPF 程序的执行。1.1.2 BPF 寄存器和调用约定

BPF 由下面几部分组成:

11 个 64 位寄存器(这些寄存器包含 32 位子寄存器)一个程序计数器(program counter,PC)一个 512 字节大小的 BPF 栈空间(从实现的层面理解为什么有 512 字节的限制, 可参考 (译) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021),译注。)

寄存器的名字从 r0 到 r10。默认的运行模式是 64 位,32 位子寄存器只能 通过特殊的 ALU(arithmetic logic unit)访问。向 32 位子寄存器写入时,会用 0 填充 到 64 位。

r10 是唯一的只读寄存器,其中存放的是访问 BPF 栈空间的栈帧指针(frame pointer) 地址。r0 - r9 是可以被读/写的通用目的寄存器。

BPF 程序可以调用核心内核(而不是内核模块)预定义的一些辅助函数。BPF 调用约定 定义如下:

r0 存放被调用的辅助函数的返回值r1 - r5 存放 BPF 调用内核辅助函数时传递的参数r6 - r9 由被调用方(callee)保存,在函数返回之后调用方(caller)可以读取

BPF 调用约定非常通用,能够直接映射到 x86_64、arm64 和其他 ABI,因此所有 的 BPF 寄存器可以一一映射到硬件 CPU 寄存器,JIT 只需要发出一条调用指令,而不 需要额外的放置函数参数(placing function arguments)动作。这套约定在不牺牲性能的 前提下,考虑了尽可能通用的调用场景。目前不支持 6 个及以上参数的函数调用,内核中 BPF 相关的辅助函数(从 BPF_CALL_0() 到 BPF_CALL_5() 函数)也特意设计地与此相匹配。

r0 寄存器还用于保存 BPF 程序的退出值。退出值的语义由程序类型决定。另外, 当将执行权交回内核时,退出值是以 32 位传递的。

r1 - r5 寄存器是 scratch registers,意思是说,如果要在多次辅助函数调用之间重用这些寄存器内的值,那 BPF 程序需要负责将这些值临时转储(spill)到 BPF 栈上 ,或者保存到被调用方(callee)保存的寄存器中。Spilling(倒出/转储) 的意思是这些寄存器内的变量被移到了 BPF 栈中。相反的操作,即将变量从 BPF 栈移回寄 存器,称为 filling(填充)。spilling/filling 的原因是寄存器数量有限。

BPF 程序开始执行时,r1 寄存器中存放的是程序的上下文(context)。上下文就是程序的输入参数(和典型 C 程序的 argc/argv 类似)。BPF 只能在单个上下文中 工作(restricted to work on a single context)。这个上下文是由程序类型定义的, 例如,网络程序可以将网络包的内核表示(skb)作为输入参数。

BPF 的通用操作都是 64 位的,这和默认的 64 位架构模型相匹配,这样可以对指针进 行算术操作,以及在调用辅助函数时传递指针和 64 位值;另外,BPF 还支持 64 位原子操 作。

每个 BPF 程序的最大指令数限制在 4096 条以内,这意味着从设计上就可以保证每 个程序都会很快结束。对于内核 5.1+,这个限制放大到了 100 万条。 虽然指令集中包含前向和后向跳转,但内核中的 BPF 校验器禁止程序中有循环,因此可以保证程序会终止。因为 BPF 程序运行在内核,校验器的工作是保证这些程序在运行时是安全的,不会影响到系统的稳定性。这意味着,从指令集的角度来说循环是可以实现的,但校验器会对其施加限制。另外,BPF 中有tail calls的概念,允许一 个 BPF 程序调用另一个 BPF 程序。类似地,这种调用也是有限制的,目前上限是 33 层调用;现在这个功能常用来对程序逻辑进行解耦,例如解耦成几个不同阶段。

1.1.3 BPF 指令格式

BPF 指令格式(instruction format)建模为两操作数指令(two operand instructions), 这种格式可以在 JIT 阶段将 BPF 指令映射为原生指令。指令集是固定长度的,这意味着每条指令都是 64 比特编码的。目前已经实现了 87 条指令,并且在需要时可以对指令集进行进一步扩展。一条 64 位指令在大端机器上的编码格式如下,从重要性最高比特(most significant bit,MSB)到重要性最低比特(least significant bit,LSB):

op:8, dst_reg:4, src_reg:4, off:16, imm:32

off 和 imm 都是有符号类型。编码信息定义在内核头文件 linux/bpf.h 中,这个头 文件进一步 include 了 linux/bpf_common.h。

op 定了将要执行的操作,占8位。op 复用了大部分 cBPF 的编码定义。操作可以基于寄存器值 ,也可以基于立即操作数(immediate operands)。op 自身的编码信息中包含了应该使 用的模式类型:

BPF_X 指基于寄存器的操作数(register-based operations)BPF_K 指基于立即操作数(immediate-based operations)

对于后者,目的操作数永远是一个寄存器(destination operand is always a register)。 dst_reg 和 src_reg 都提供了寄存器操作数(register operands,例如 r0 - r9)的额外信息。在某些指令中,off 用于表示一个相对偏移量(offset), 例如,对那些 BPF 可用的栈或缓冲区(例如 map values、packet data 等等)进行寻址,或者跳转指令中用于跳转到目标。imm 存储一个常量/立即值。

op 指令可以分为若干类别。类别信息也编码到了 op 字段。op 字段分为( 从 MSB 到 LSB):指令从高到底位依次是code:4, source:1 和 class:3。

class 是指令类型code 指特定类型的指令中的某种特定操作码(operational code)source 可以告诉我们源操作数(source operand)是一个寄存器还是一个立即数

可能的指令类别包括:

BPF_LD, BPF_LDX:加载操作(load operations)BPF_LD 用于加载两个字长(double word)的特殊指令(占两个指令长度,源于 imm:32 的限制),或byte / half-word / word 长度的包数据(packet data )。后者是从 cBPF 延续过来的,主要为了保证 cBPF 到 BPF 翻译得高效,因为这里的 JIT code 是优化过的。对于 native BPF 来说,这些包加载指令在今天已经用得很少了。BPF_LDX 用于从内存中加载 byte / half-word / word / double-word,这里的内存包括栈内存、map value data、packet data 等等。BPF_ST, BPF_STX:存储操作(store operations)BPF_STX 与 BPF_LDX 相对,将某个寄存器中的值存储到内存中,同样,这里的 内存可以是栈内存、map value、packet data 等等。BPF_STX 类包含一些 word 和 double-word 相关的原子加操作,例如,可以用于计数器。BPF_ST 类与 BPF_STX 类似,提供了将数据存储到内存的操作,只不过其源操作数(source operand)必须是一个立即值(immediate value)。BPF_ALU, BPF_ALU64:逻辑运算操作(ALU operations)BPF_ALU是32位模式的操作符,BPF_ALU64是64位模式的操作符。source通常是基于寄存器,而不是立即操作数。支持加(+)、减(-)、逻辑与(&)、逻辑或(|),左移位(<<)、右移位(>>)、异或(^)、乘(*)、除(/)、模(%)、否(~)运算。此外 mov (<X> := <Y>) 被视作两个类的特殊的运算类型。BPF_ALU64还包含了符号右移运算。BPF_ALU包含给定源寄存器上 半字/字/双字 的字节序转换指令。BPF_JMP:跳转操作(jump operations)可以是有条件的或无条件的无条件跳转只需将程序计数器向前移动,以便相对于当前指令执行的下一条指令为off+1,其中off是指令中编码的常量偏移量。因为off是有符号的,所以跳转也可以向后执行,只要它不创建循环并且在程序边界内,都是合法的。有条件跳转可以同时处理基于寄存器和立即操作数的source。如果跳转操作中的条件为真,则执行到off+1的相对跳转,否则执行下一条指令(0+1)。这种full-through跳转逻辑与cBPF相比有所不同,它更适合CPU分支预测器逻辑,所以做分支预测更友好。可用的条件有 jeq (==), jne (!=), jgt (>), jge (>=), jsgt (有符号 >), jsge (有符号 >=), jlt (<), jle (<=), jslt (有符号 <), jsle (有符号 <=) and jset (jump if DST & SRC)。除此之外,这个类中还有三个特殊的跳转操作:退出指令,它将离开BPF程序并返回r0中的当前值作为返回代码;调用指令,它会向一个可用的BPF辅助函数发出函数调用;尾部调用指令,将跳转到另一个BPF程序。

Linux 内核中内置了一个 BPF 解释器,该解释器能够执行由 BPF 指令组成的程序。即 使是 cBPF 程序,也可以在内核中透明地转换成 eBPF 程序,除非该架构仍然内置了 cBPF JIT,还没有迁移到 eBPF JIT。

目前下列架构都内置了内核 eBPF JIT 编译器:x86_64、arm64、ppc64、s390x 、mips64、sparc64 和 arm。

所有的 BPF 操作,例如加载程序到内核,或者创建 BPF map, 都是通过核心的 bpf() 系统调用完成的。它还用于管理 map 表项(查 找/更新/删除),以及通过 pinning 将程序和 map 持久化到 BPF 文件系统。

  • 发表于 2022-12-07 15:16
  • 阅读 ( 103 )
  • 分类:互联网

0 条评论

请先 登录 后评论
ztj123
ztj123

722 篇文章

你可能感兴趣的文章

相关问题