BPF架构(一)指令集

原文链接:https://docs.cilium.io/en/latest/bpf/architecture/

BPF并不仅仅通过提供指令集来定义自身,还提供了进一步的基础设施,例如作为高效的键值存储的映射(maps),用于和内核功能进行交互的辅助函数(helper functions),用于调用其他BPF程序的尾调用(tail calls),用于安全加固的原语,用于固定对象(maps、programs)的伪文件系统,以及用于实现BPF卸载(offload)的基础设施,例如加载到网卡中。

LLVM提供了一个BPF后端,以便可以使用类似clang的工具将C编译为BPF目标文件,然后将其加载到内核中。BPF与Linux内核紧密结合,允许在不牺牲本地内核性能的情况下进行完全的可编程性。

最后,使用BPF的内核子系统也是BPF基础设施的一部分。本文档中讨论的两个主要子系统是tc和XDP,可以在这些子系统上附加BPF程序。XDP BPF程序在最早的网络驱动程序阶段进行附加(attached),并在数据包接收时触发BPF程序运行。根据定义,这实现了最佳的数据包处理性能,因为数据包无法在软件的更早阶段进行处理。然而,由于此处理发生在网络堆栈的早期阶段,堆栈尚未从数据包中提取元数据。另一方面,tc BPF程序在内核堆栈的较后阶段执行,因此它们可以访问更多的元数据和核心内核功能。除了tc和XDP程序之外,还有其他各种内核子系统也使用BPF,例如跟踪(kprobes、uprobes、tracepoints等)。

下面的小节详细介绍了BPF架构的各个方面。

指令集

BPF是一种通用的RISC指令集,最初是为了在C的一个子集中编写程序而设计的,可以通过编译器后端(如LLVM)将其编译为BPF指令,从而使内核能够通过内核中的JIT编译器将其映射到原生的操作码中,以实现内核内部的最佳执行性能。

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

  • 在不需要跨内核/用户空间边界的情况下使内核可编程。例如,与网络相关的BPF程序(如Cilium)可以实现灵活的容器策略、负载均衡等功能,而无需将数据包移动到用户空间再返回内核。BPF程序和内核/用户空间之间的状态仍可以通过映射进行共享,根据需要进行传递。

  • 由于可编程的数据路径的灵活性,程序可以通过编译掉不适用于解决程序用例的功能从而大幅度优化性能。例如,如果容器不需要IPv4,则BPF程序可以构建为仅处理IPv6,以节省在快速路径中的资源。

  • 在网络方面(例如tc和XDP),BPF程序可以在不重启内核、系统服务或容器,并且不中断流量的情况下原子更新。此外,通过BPF映射,任何程序状态在更新期间也可以保持。

  • BPF为用户空间提供了稳定的ABI,并且不需要任何第三方内核模块。BPF是Linux内核的核心部分,它随处可用,并保证现有的BPF程序在较新的内核版本上继续运行。这个保证类似于内核对于用户空间应用程序的系统调用的保证。此外,BPF程序可以在不同的体系结构之间进行移植。

  • BPF程序与内核协同工作,利用现有的内核基础设施(如驱动程序、网络设备、隧道、协议栈、套接字)和工具(如iproute2),以及内核提供的安全性保证。与内核模块不同,BPF程序通过内核中的验证器进行验证,以确保它们不会崩溃内核,并始终正常终止等。例如,XDP程序重用现有的内核驱动程序,并在提供的DMA缓冲区中操作包含数据包帧的数据包,而不像其他模型中将其或整个驱动程序暴露给用户空间。此外,XDP程序重用现有的堆栈而不是绕过它。可以将BPF视为用于解决特定用例的编排程序与内核功能之间的通用"粘合代码(glue code)"。

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

  • 附加了BPF程序的网络设备在接收到数据包时会触发程序的执行。

  • 具有附加了BPF程序的kprobe的内核地址在执行该地址处的代码时会进行捕获,然后调用kprobe的回调函数进行插桩,并随后触发附加的BPF程序的执行。

BPF由11个64位寄存器和32位子寄存器、一个程序计数器和一个512字节大小的BPF栈空间组成。寄存器的命名为r0 - r10。默认情况下,操作模式为64位,32位子寄存器只能通过特殊的算术逻辑单元(ALU)操作来访问。当写入32位低字节子寄存器时,它们会被零扩展(zero-extend)为64位。

寄存器r10是唯一一个只读的寄存器,其包含帧指针地址,以便访问BPF栈空间。剩余的r0 - r9寄存器为通用寄存器,可读可写。

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

r0包含辅助函数调用的返回值。

r1 - r5保存BPF程序向内核辅助函数传递的参数。

r6 - r9是被调用者保存的寄存器,在调用辅助函数时将被保留。

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

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

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

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

BPF的一般操作是64位的,以遵循64位体系结构的自然模型进行指针运算、传递指针,但也可以将64位值传递给辅助函数,并允许进行64位原子操作。

每个程序的最大指令限制为4096条BPF指令,这意味着根据设计,任何程序都会很快终止。对于内核版本大于5.1的情况,这个限制被提高到了100万条BPF指令。虽然指令集包含正向和反向跳转,但内核中的BPF验证器(verifier)将禁止循环,以确保始终能够终止。由于BPF程序在内核内部运行,验证器的工作是确保这些程序的运行安全,不影响系统的稳定性。这意味着从指令集的角度来看,可以实现循环,但验证器将加以限制。但是,还有一个尾调用的概念,允许一个BPF程序跳转到另一个程序。这也有一个上限的嵌套调用次数限制为33次,并且通常用于将程序逻辑拆分为不同的阶段(stage)。

指令格式被建模为两个操作数指令,这有助于在JIT阶段将BPF指令映射到原生(native)指令。指令集是固定大小的,意味着每个指令都有64位的编码。目前已经实现了87条指令,并且编码也允许在需要时扩展指令集。单个64位指令在大端机器上的编码定义为从最高有效位(MSB)到最低有效位(LSB)的位序列:op:8dst_reg:4src_reg:4off:16imm:32offimm是有符号类型。编码是内核头文件的一部分,并在linux/bpf.h头文件中定义,该头文件还包括linux/bpf_common.h

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

可用的op指令可以分为各种指令类。这些类别也在op字段中进行了编码。op字段分为(从MSB最高有效位到LSB最低有效位)code:4source:1class:3class是更通用的指令类,code表示类中的特定操作码,source告诉源操作数是寄存器还是立即数值。可能的指令类别包括:

  • BPF_LDBPF_LDX:这两个类用于加载操作。BPF_LD用于加载双字作为一个特殊指令,由于imm:32的拆分,会跨越两个指令,以及用于字节/半字/字加载报文数据。后者主要从cBPF中继承,主要是为了保持cBPF到BPF的翻译效率高,因为它们有优化的JIT代码。对于本机BPF,这些报文加载指令现在不太重要。BPF_LDX类包含了从存储器中加载字节/半字/字/双字的指令。在这个上下文中,存储器是通用的,可以是堆栈存储器、映射值数据、报文数据等。

  • BPF_STBPF_STX:这两个类用于存储操作。与BPF_LDX类似,BPF_STX是存储操作的对应部分,用于将寄存器中的数据存储到存储器中,这也可以是堆栈存储器、映射值、报文数据等。BPF_STX还包含执行基于字和双字的原子加法操作的特殊指令,例如用于计数器。BPF_ST类似于BPF_STX,只是提供了将数据存储到存储器中的指令,源操作数是一个立即值。

  • BPF_ALUBPF_ALU64:这两个类包含ALU操作。一般情况下,BPF_ALU操作在32位模式下,BPF_ALU64在64位模式下。两个ALU类都具有基本操作,其源操作数基于寄存器,以及基于立即数的对应操作。两者都支持加法(+)、减法(-)、按位与(&)、按位或(|)、左移(<<)、右移(>>)、按位异或(^)、乘法(*)、除法(/)、取模(%)、取反(~)操作。对于两个类别,还增加了mov(<X> := <Y>)作为特殊的ALU操作。BPF_ALU64还包含有符号右移操作。BPF_ALU还包含了在给定源寄存器上进行半字/字/双字的字节序转换指令。

  • BPF_JMP:这个类专门用于跳转操作。跳转可以是无条件的和有条件的。无条件跳转简单地将程序计数器向前移动,使得相对于当前指令要执行的下一条指令是off + 1,其中off是指令中编码的常数偏移量。由于off是有符号的,跳转也可以向后执行,只要不创建循环并且在程序范围内。有条件跳转操作同时操作基于寄存器和立即数的源操作数。如果跳转操作的条件为true,则执行相对跳转到off + 1,否则执行下一条指令(0 + 1)。这种通过跳过执行的跳转逻辑与cBPF不同,并且允许更好的分支预测,因为它更符合CPU分支预测器的逻辑。可用的条件包括jeq(==)、jne(!=)、jgt(>)、jge(>=)、jsgt(signed >)、jsge(signed >=)、jlt(<)、jle(<=)、jslt(signed <)、jsle(signed <=)和jset(jump if DST & SRC)。此类中还有三个特殊的跳转操作:退出指令将离开BPF程序并将r0中的当前值作为返回代码返回,调用指令将调用其中一个可用的BPF辅助函数,以及隐藏的尾调用指令,将跳转到另一个BPF程序。

Linux内核附带了一个BPF解释器,用于执行BPF指令组装的程序。即使是cBPF程序也会在内核中透明地转换为eBPF程序,但还有一个例外,那就是那些仍附带cBPF JIT并尚未迁移到eBPF JIT的体系结构。

目前 x86_64, arm64, ppc64, s390x, mips64, sparc64arm 体系结构都带有内核eBPF JIT编译器。

所有BPF处理,如将程序加载到内核或创建BPF映射,都通过一个核心的bpf()系统调用来管理。它还用于管理映射条目(查找/更新/删除),并通过固定来使程序和映射在BPF文件系统中持久化存储。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值