01-BPF架构

BPF不仅仅通过提供指令集来定义自己,还围绕它提供了进一步的基础设施,如作为高效键值存储的映射(maps)、与内核功能交互和利用的辅助函数(helper functions)、调用其他BPF程序的尾调用(tail calls)、安全强化原语(security hardening primitives)、用于固定对象(maps、programs)的伪文件系统(pseudo file system),以及允许将BPF卸载到例如网络卡等基础设施。

LLVM提供了BPF后端,因此可以使用像clang这样的工具将C语言编译成BPF目标文件,然后将其加载到内核中。BPF与Linux内核紧密相连,允许在不牺牲原生内核性能的情况下实现完全的可编程性。

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

以下各小节将提供BPF架构各个方面的进一步详细信息。

指令集

BPF 是一种通用 RISC 指令集,最初设计用于编写 C 语言的一个子集的程序,这些程序可以通过编译器后端(例如 LLVM)编译成 BPF 指令,以便内核稍后通过内核中的 JIT(即时编译)编译器将它们映射成本地操作码,从而在内核内部实现最佳的执行性能。

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

  • 无需跨越内核/用户空间边界即可使内核可编程。例如,与 Cilium 中的网络相关的 BPF 程序可以实现灵活的容器策略、负载均衡和其他手段,而无需将数据包移至用户空间再返回内核。BPF 程序与内核/用户空间之间的状态仍可以通过映射在需要时共享。

  • 鉴于可编程数据路径的灵活性,程序还可以通过编译出不需要用于解决程序用例的功能来大量优化性能。例如,如果一个容器不需要 IPv4,那么 BPF 程序可以仅处理 IPv6 以节省快速路径上的资源。

  • 在网络方面(例如 tc 和 XDP),BPF 程序可以原子地更新,无需重启内核、系统服务或容器,也无需中断流量。此外,任何程序状态都可以通过 BPF 映射在整个更新过程中得以维持。

  • BPF 向用户空间提供了稳定的 ABI(应用程序二进制接口),并且不需要任何第三方内核模块。BPF 是 Linux 内核的核心部分,随处可用,并保证现有的 BPF 程序能够在更新的内核版本上继续运行。这一保证与内核针对用户空间应用程序提供的系统调用保证相同。此外,BPF 程序可在不同的架构之间移植。

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

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

  • 一个网络设备在其入站路径上附加了 BPF 程序,一旦收到数据包,就会触发该程序的执行。

  • 一个内核地址如果附加了带有 BPF 程序的 kprobe,那么当该地址的代码被执行时,就会触发捕获(trap),从而调用 kprobe 的回调函数进行插桩(instrumentation),随后触发附加的 BPF 程序的执行。

BPF 包含十一个 64 位寄存器,每个寄存器都有 32 位的子寄存器,一个程序计数器和一个 512 字节的 BPF 栈空间。寄存器被命名为 r0 到 r10。默认操作模式是 64 位,32 位子寄存器只能通过特殊的 ALU(算术逻辑单元)操作来访问。当写入时,32 位低位子寄存器会自动零扩展到 64 位。

寄存器 r10 是唯一的只读寄存器,包含帧指针地址,用于访问 BPF 栈空间。其余的 r0 到 r9 寄存器是通用寄存器,具有读写性质。

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

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

  • r1 到 r5 持有从 BPF 程序传递给内核辅助函数的参数。

  • r6 到 r9 是被调用者保存的寄存器,它们在辅助函数调用期间会被保留。

BPF 的调用约定足够通用,可以直接映射到 x86_64、arm64 和其他 ABI,因此所有的 BPF 寄存器都与硬件 CPU 寄存器一一对应。这样,即时编译器(JIT)就只需要发出一个调用指令,而无需为放置函数参数进行额外的移动操作。这种调用约定是为了覆盖常见的调用情况而不产生性能损失而设计的。目前不支持带有 6 个或更多参数的调用。内核中专门为 BPF 设计的辅助函数(BPF_CALL_0() 到 BPF_CALL_5() 函数)特别考虑了这种约定。

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

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

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

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

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

指令格式被设计为双操作数指令,这有助于在JIT阶段将BPF指令映射到原生指令。指令集是固定大小的,意味着每条指令都有64位编码。目前,已经实现了87条指令,并且编码也允许在需要时通过添加更多指令来扩展该集合。在大端机器上,单个64位指令的指令编码被定义为从最高有效位(MSB)到最低有效位(LSB)的位序列,包括op:8位、dst_reg:4位、src_reg:4位、off:16位和imm:32位。其中off和imm是带符号类型。这些编码是内核头文件的一部分,并在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_LD, BPF_LDX:这两个类别都是用于加载操作的。BPF_LD用于加载一个双字(double word),由于imm:32的分割,它作为一个跨越两个指令的特殊指令,以及用于加载数据包中的字节/半字/字。后者主要是从cBPF继承过来的,以保持cBPF到BPF转换的效率,因为它们有优化的JIT代码。对于原生BPF,这些数据包加载指令现在不那么重要了。BPF_LDX类别包含从内存中加载字节/半字/字/双字的指令。这里的内存是通用的,可能是栈内存、映射值数据、数据包数据等。
  • BPF_ST, BPF_STX:这两个类别都是用于存储操作的。与BPF_LDX类似,BPF_STX是存储操作的对应部分,用于将寄存器中的数据存储到内存中,这里的内存同样可以是栈内存、映射值、数据包数据等。BPF_STX还包含用于执行基于字和双字的原子加操作的特殊指令,这些指令可以用于计数器,例如。BPF_ST类别与BPF_STX类似,提供了将数据存储到内存中的指令,但源操作数是立即数值。
  • BPF_ALU, BPF_ALU64:这两个类别都包含算术逻辑单元(ALU)操作。通常,BPF_ALU操作在32位模式下进行,而BPF_ALU64在64位模式下进行。两个ALU类别都有基于寄存器的源操作数和基于立即数的对应操作。两者都支持加(+)、减(-)、与(&)、或(|)、左移(<<)、右移(>>)、异或(^)、乘(*)、除(/)、模(%)和取反(~)操作。此外,在两个类别的两种操作数模式下,都添加了mov( := )作为特殊的ALU操作。BPF_ALU64还包含有符号右移操作。BPF_ALU还包含在给定的源寄存器上进行半字/字/双字的字节序转换指令。
  • 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辅助函数之一发出函数调用。
    • 隐藏的尾调用指令(tail call),它将跳转到另一个不同的BPF程序。
      这些跳转指令为BPF程序提供了丰富的控制流选项,使其能够执行复杂的逻辑和决策。

Linux内核附带了一个BPF解释器,用于执行以BPF指令组装的程序。即使在内核中,cBPF程序也会被透明地转换为eBPF程序,但除了那些仍然提供cBPF JIT且尚未迁移到eBPF JIT的架构之外。

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

所有BPF操作,如将程序加载到内核中或创建BPF映射,都是通过中央bpf()系统调用来管理的。该系统调用还用于管理映射条目(查找/更新/删除),以及通过固定操作将程序和映射持久化到BPF文件系统中。

辅助函数

辅助函数是一个概念,它允许 BPF 程序咨询内核核心定义的一组函数调用,以便从内核检索/推送数据。可用的辅助函数可能因 BPF 程序类型而异,例如,附加到套接字的 BPF 程序相比附加到 tc 层的 BPF 程序只允许调用一组子集的辅助函数。轻量级隧道的封装和解封装辅助函数是仅对较低 tc 层可用的函数的一个例子,而用于将通知推送到用户空间的事件输出辅助函数对 tc 和 XDP 程序都是可用的。

这些辅助函数为 BPF 程序提供了与内核交互的能力,使它们能够执行诸如数据包处理、系统调用跟踪、性能监控等任务,而无需直接修改内核代码。通过限制 BPF 程序只能调用预定义的辅助函数,内核可以确保 BPF 程序的安全性和稳定性。

每个辅助函数都使用类似于系统调用的通用共享函数签名来实现。该签名定义为:

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

前面部分描述的调用约定适用于所有 BPF 辅助函数。这意味着在调用这些辅助函数时,BPF 程序需要将参数放在寄存器 r1 到 r5 中,并且辅助函数的返回值将放在寄存器 r0 中。此外,r6 到 r9 是被调用者保存的寄存器,它们在辅助函数调用期间将保持不变。

这种标准化的函数签名和调用约定允许 BPF 虚拟机(VM)在不同的硬件架构上以一种一致的方式工作,同时也简化了 BPF 程序的编写和维护。

内核将辅助函数抽象为宏 BPF_CALL_0() 到 BPF_CALL_5(),这些宏类似于系统调用的宏。以下是一个示例,它展示了如何通过调用相应的映射(map)实现回调来更新映射元素的辅助函数提取:

BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key,
           void *, value, u64, flags)
{
    WARN_ON_ONCE(!rcu_read_lock_held());
    return map->ops->map_update_elem(map, key, value, flags);
}

const struct bpf_func_proto bpf_map_update_elem_proto = {
    .func           = bpf_map_update_elem,
    .gpl_only       = false,
    .ret_type       = RET_INTEGER,
    .arg1_type      = ARG_CONST_MAP_PTR,
    .arg2_type      = ARG_PTR_TO_MAP_KEY,
    .arg3_type      = ARG_PTR_TO_MAP_VALUE,
    .arg4_type      = ARG_ANYTHING,
};

这种方法的优势在于多个方面:在 cBPF(经典 BPF)中,为了调用辅助帮助函数,它重载了加载指令以在不可能的包偏移量处获取数据。这导致每个 cBPF 的即时编译器(JIT)都需要实现对这种 cBPF 扩展的支持。然而,在 eBPF(扩展 BPF)中,每个新添加的辅助函数都会以透明且高效的方式被 JIT 编译。这意味着 JIT 编译器只需要发出一个调用指令,因为寄存器映射已经被设计成使得 BPF 寄存器分配与底层架构的调用约定相匹配。

这种设计允许核心内核轻松地通过新的辅助函数功能进行扩展。所有的 BPF 辅助函数都是核心内核的一部分,不能通过内核模块进行扩展或添加。这种设计确保了 BPF 辅助函数的一致性和安全性,因为所有的功能都是作为核心内核代码的一部分被审查和验证的。这也简化了 BPF 程序的编写和维护,因为开发者可以依赖一组稳定且功能丰富的辅助函数来执行各种任务,而无需担心底层实现细节或兼容性问题。

上述的函数签名还允许验证器执行类型检查。结构体 bpf_func_proto 被用来向验证器提供关于辅助函数所需的所有必要信息,以便验证器可以确保辅助函数期望的类型与 BPF 程序当前分析的寄存器内容匹配。

参数类型可以是从传递任何类型的值到限制内容,例如指向 BPF 栈缓冲区的指针/大小对,辅助函数应该从中读取或写入。在后一种情况下,验证器还可以执行额外的检查,例如缓冲区是否之前已经被初始化。

可用的 BPF 辅助函数列表相当长并且不断增长。例如,在撰写本文时,tc BPF 程序可以选择使用 38 个不同的 BPF 辅助函数。内核的 struct bpf_verifier_ops 结构体包含一个 get_func_proto 回调函数,该函数为给定的 BPF 程序类型提供特定 enum bpf_func_id 到可用辅助函数之一的映射。

这种设计确保了 BPF 程序的正确性和安全性,因为验证器可以在程序加载到内核之前检查所有对辅助函数的调用,以确保它们符合预期的参数类型和用法。此外,随着新的辅助函数的添加,验证器也会相应地更新,以确保对新的函数进行正确的类型检查和验证。

Map

1
映射(Maps)是高效的键值对存储,它们驻留在内核空间中。BPF 程序可以访问映射,以便在多次 BPF 程序调用之间保持状态。映射也可以通过文件描述符从用户空间进行访问,并且可以任意地与其他 BPF 程序或用户空间应用程序共享。

共享映射的 BPF 程序不需要是相同类型的程序,例如,追踪程序可以与网络程序共享映射。目前,单个 BPF 程序可以直接访问最多 64 个不同的映射。

BPF 映射为 BPF 程序提供了在内核和用户空间之间共享数据的强大机制。它们可以用于跟踪状态、收集统计数据、在多个 BPF 程序之间传递信息等。由于映射驻留在内核空间中,因此它们能够提供高效的数据访问性能,并且可以与 BPF 程序的性能特性相匹配。

此外,BPF 映射的灵活性使得它们成为 BPF 生态系统中不可或缺的组成部分。通过共享映射,不同的 BPF 程序可以协同工作,共同实现复杂的系统级任务,例如网络监控、性能分析、安全审计等。

映射(Maps)的实现由内核核心提供。有通用的映射,包括每 CPU(per-CPU)和非每 CPU(non-per-CPU)类型的映射,这些映射可以读写任意数据。但也有一些非通用的映射,它们与辅助函数一起使用。

目前可用的通用映射类型包括 BPF_MAP_TYPE_HASH、BPF_MAP_TYPE_ARRAY、BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY、BPF_MAP_TYPE_LRU_HASH、BPF_MAP_TYPE_LRU_PERCPU_HASHBPF_MAP_TYPE_LPM_TRIE。这些映射类型都使用相同的 BPF 辅助函数集来执行查找、更新或删除操作,但它们在实现上使用了不同的后端,具有不同的语义和性能特性。

非通用映射类型,目前内核中提供的包括 BPF_MAP_TYPE_PROG_ARRAY、BPF_MAP_TYPE_PERF_EVENT_ARRAY、BPF_MAP_TYPE_CGROUP_ARRAY、BPF_MAP_TYPE_STACK_TRACE、BPF_MAP_TYPE_ARRAY_OF_MAPSBPF_MAP_TYPE_HASH_OF_MAPS。这些非通用映射类型针对特定的用途而设计,不适合仅通过 BPF 辅助函数来实现,因为它们需要在 BPF 程序调用之间保持额外的(非数据)状态。

例如,BPF_MAP_TYPE_PROG_ARRAY 是一个包含其他 BPF 程序的数组映射。BPF_MAP_TYPE_ARRAY_OF_MAPSBPF_MAP_TYPE_HASH_OF_MAPS 分别保存指向其他映射的指针,这样可以在运行时原子地替换整个 BPF 映射。这些映射类型解决了特定的问题,使得 BPF 程序能够以更加灵活和高效的方式管理和组织其数据。

对象锁定

2
BPF 映射和程序作为内核资源,只能通过由内核中匿名 inode 支持的文件描述符进行访问。这样做既有优势,也有一系列劣势:

用户空间应用程序可以利用大多数与文件描述符相关的 API,文件描述符通过 Unix 域套接字进行传递时工作透明等,但与此同时,文件描述符仅限于进程的生命周期,这使得像映射共享这样的操作变得相当繁琐。

因此,对于某些用例(如iproute2),BPF 映射和程序作为内核资源带来了许多复杂性。在这些用例中,tc 或 XDP 将程序设置并加载到内核中,最终自身会终止。这样一来,从用户空间侧访问映射就变得不可行了,尽管在诸如在数据路径的入口和出口位置之间共享映射的情况下,这样做可能非常有用。此外,第三方应用程序可能希望在 BPF 程序运行时监视或更新映射内容。

为了克服这一限制,实现了一个最小化的内核空间 BPF 文件系统,可以将 BPF 映射和程序钉住(pinned)到该文件系统上,这个过程称为对象钉住(object pinning)。因此,BPF 系统调用得到了扩展,增加了两个新命令,分别用于钉住(BPF_OBJ_PIN)或检索(BPF_OBJ_GET)之前已钉住的对象。

例如,tc 等工具利用此基础设施在入口和出口上共享映射。BPF 相关的文件系统不是单例的,它支持多个挂载实例、硬链接和软链接等。

尾递归

3
与BPF一起使用的另一个概念称为尾调用(tail calls)。尾调用可以被视为一种机制,它允许一个BPF程序调用另一个BPF程序,而无需返回到原来的程序。这种调用具有最小的开销,因为它不同于函数调用,它是通过长跳转实现的,重用了相同的栈帧。

这些程序是独立验证的,因此为了传递状态,可以使用每CPU映射作为临时缓冲区,或者在tc程序的情况下,使用skb字段(如cb[]区域)。

只有相同类型的程序才能使用尾调用,并且它们在JIT(即时编译)编译方面也需要匹配,因此要么都是JIT编译的程序,要么都是只解释执行的程序,但不能混用。

执行尾调用涉及两个组件:第一部分需要设置一个特殊的映射,称为程序数组(BPF_MAP_TYPE_PROG_ARRAY),用户空间可以通过键/值对来填充这个映射,其中值是尾调用的BPF程序的文件描述符。第二部分是一个bpf_tail_call()辅助函数,其中传入了上下文、对程序数组的引用和查找键。然后,内核直接将这个辅助函数调用内联到一个特定的BPF指令中。目前,这种程序数组只能从用户空间写入。

内核根据传入的文件描述符查找相关的BPF程序,并在给定的映射槽位上原子性地替换程序指针。如果在提供的键下没有找到映射条目,内核将“直接跳过”并继续执行旧程序,从bpf_tail_call()之后的指令开始。尾调用是一个强大的工具,例如,可以通过尾调用来结构化网络头部的解析。在运行时,可以原子性地添加或替换功能,从而改变BPF程序的执行行为。

BPF到BPF调用

4
除了BPF辅助函数调用和BPF尾调用之外,BPF核心基础架构最近添加了一个新特性,即BPF到BPF的调用。在这个特性被引入到内核之前,典型的BPF C程序必须声明任何可重用的代码,例如,位于头文件中的代码需要使用always_inline,以便在LLVM编译并生成BPF目标文件时,所有这些函数都会被内联,从而在生成的目标文件中多次重复,人为地增加了代码大小。

BPF到BPF的调用允许一个BPF程序直接调用另一个BPF程序,而无需像之前那样通过内联所有函数来重复代码。这种机制减少了代码冗余,提高了BPF程序的可读性和可维护性,同时减少了生成的BPF对象文件的大小。通过使用BPF到BPF的调用,开发者可以更加模块化地构建BPF程序,并更容易地重用和组合现有的BPF组件。

#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   inline __attribute__((always_inline))
#endif

static __inline int foo(void)
{
    return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return foo();
}

char __license[] __section("license") = "GPL";

之前必须采用always_inline的方式主要是因为在BPF程序加载器、验证器、解释器和JIT编译器中缺乏函数调用支持。从Linux内核4.16和LLVM 6.0开始,这个限制被取消了,BPF程序不再需要在所有地方都使用always_inline。因此,之前展示的BPF示例代码现在可以更自然地重写为:

#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

static int foo(void)
{
    return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return foo();
}

char __license[] __section("license") = "GPL";

主流的BPF JIT编译器,如x86_64和arm64,目前已经支持BPF到BPF的调用,其他架构的支持也将在不久的将来跟进。BPF到BPF的调用是一个重要的性能优化手段,因为它可以显著减少生成的BPF代码大小,因此更友好地适应CPU的指令缓存。

通过允许BPF程序直接调用其他BPF程序,这种机制避免了重复编写相同的辅助函数或重复实现相同的功能。

从BPF辅助函数所知的调用约定同样适用于BPF到BPF的调用,这意味着r1到r5用于向被调用者传递参数,结果返回在r0中。r1到r5是临时寄存器,而r6到r9在调用过程中以常规方式被保留。分别允许的最大嵌套调用层数和调用帧数是8。调用者可以向被调用者传递指针(例如指向调用者的栈帧),但反之则不行。

BPF JIT编译器为每个函数体生成单独的映像,并在最终的JIT阶段中修复映像中的函数调用地址。这已被证明只需要对JIT进行最小的更改,因为它们可以将BPF到BPF的调用视为常规的BPF辅助函数调用。

在Linux内核5.9之前,BPF尾调用和BPF子程序是相互排斥的。使用尾调用的BPF程序无法享受减少程序映像大小和加快加载时间的优势。Linux内核5.10最终允许用户结合两者的优点,并添加了将BPF子程序与尾调用结合使用的能力。

然而,这一改进也带来了一些限制。混合使用这两种特性可能会导致内核栈溢出。为了了解可能发生的情况,请参考下面的图片,它说明了bpf2bpf调用和尾调用的混合使用。
5
尾调用在实际跳转到目标程序之前,只会释放其当前的栈帧。如上面的例子所示,如果在子函数(func1)内部发生尾调用,当程序执行到func2时,func1的栈帧仍会存在于栈上。一旦最终的函数(func3)终止,所有之前的栈帧都将被释放,控制权将返回到BPF程序调用者的调用者。

内核引入了额外的逻辑来检测这种特性组合。在整个调用链中,每个子程序的栈大小限制为256字节(请注意,如果验证器检测到bpf2bpf调用,则主函数也将被视为子函数)。总的来说,由于这个限制,BPF程序的调用链最多可以消耗8KB的栈空间。这个限制来自于每个栈帧256字节的限制乘以尾调用次数的限制(33次)。没有这个限制,BPF程序将在512字节的栈大小上运行,对于某些架构来说,这会导致尾调用的最大计数达到16KB,从而可能溢出栈。

还有一点要提的是,这种特性组合目前仅在x86-64架构上得到支持。

JIT

JIT
64位的x86_64、arm64、ppc64、s390x、mips64、sparc64和32位的arm、x86_32架构都内置了eBPF JIT编译器,这些架构在功能上是等效的,并且可以通过以下命令启用它们:

echo 1 > /proc/sys/net/core/bpf_jit_enable

目前,32位的mips、ppc和sparc架构拥有cBPF JIT编译器。这些提到的架构仍然使用cBPF JIT,以及Linux内核支持的所有其他架构,如果它们没有BPF JIT编译器,那么它们需要通过内核解释器来运行eBPF程序。

在内核的源代码树中,可以通过执行grep命令搜索HAVE_EBPF_JIT来轻松确定是否支持eBPF JIT。

# git grep HAVE_EBPF_JIT arch/
arch/arm/Kconfig:       select HAVE_EBPF_JIT   if !CPU_ENDIAN_BE32
arch/arm64/Kconfig:     select HAVE_EBPF_JIT
arch/powerpc/Kconfig:   select HAVE_EBPF_JIT   if PPC64
arch/mips/Kconfig:      select HAVE_EBPF_JIT   if (64BIT && !CPU_MICROMIPS)
arch/s390/Kconfig:      select HAVE_EBPF_JIT   if PACK_STACK && HAVE_MARCH_Z196_FEATURES
arch/sparc/Kconfig:     select HAVE_EBPF_JIT   if SPARC64
arch/x86/Kconfig:       select HAVE_EBPF_JIT   if X86_64

JIT编译器显著加速了BPF程序的执行,因为它们降低了每条指令相对于解释器的成本。通常,指令可以与底层架构的原生指令进行1:1的映射。这也减少了生成的可执行映像的大小,因此对CPU的指令缓存更加友好。特别是在使用CISC指令集(如x86)的情况下,JIT编译器被优化为针对给定指令发出尽可能短的操作码,以减小程序翻译所需的总大小。

加固

BPF在程序的生命周期内将BPF解释器映像(struct bpf_prog)以及JIT编译后的映像(struct bpf_binary_header)在内核中锁定为只读,以防止代码可能受到的破坏。如果在此期间发生任何破坏,例如由于某些内核错误,将会导致一般保护错误,从而使内核崩溃,而不是允许破坏无声地发生。

可以通过以下方式确定哪些架构支持将映像内存设置为只读:

$ git grep ARCH_HAS_SET_MEMORY | grep select
arch/arm/Kconfig:    select ARCH_HAS_SET_MEMORY
arch/arm64/Kconfig:  select ARCH_HAS_SET_MEMORY
arch/s390/Kconfig:   select ARCH_HAS_SET_MEMORY
arch/x86/Kconfig:    select ARCH_HAS_SET_MEMORY

由于CONFIG_ARCH_HAS_SET_MEMORY选项不可配置,这种保护始终是内置的。未来可能会有其他架构也支持此功能。

对于x86_64 JIT编译器,如果在编译时设置了CONFIG_RETPOLINE(在大多数现代Linux发行版中,这是默认设置),则通过retpoline实现尾调用中使用的间接跳转的JIT编译。

如果将/proc/sys/net/core/bpf_jit_harden设置为1,则对于非特权用户,JIT编译会采取额外的加固步骤。这实际上是通过减少(潜在的)攻击面来略微牺牲他们的性能,以防不受信任的用户在系统上操作。尽管程序执行速度有所下降,但与完全切换到解释器相比,性能仍然更好。

目前,启用加固会在BPF程序进行JIT编译时屏蔽用户提供的所有32位和64位常量,以防止JIT喷射攻击,这种攻击将原生操作码作为立即值注入。这是一个问题,因为这些立即值位于可执行内核内存中,因此,由某些内核错误触发的跳转可能会跳转到立即值的开头,然后将其作为原生指令执行。

JIT 常量遮蔽通过随机化实际指令来防止这种情况,这意味着将操作从基于立即数的源操作数转换为基于寄存器的操作数,通过重写指令,将实际加载值的过程拆分为两个步骤:1) 将遮蔽后的立即数 rnd ^ imm 加载到寄存器中;2) 将该寄存器与 rnd 进行异或操作,使得原始的 imm 立即数存储在寄存器中,并可用于实际操作。这个例子是针对加载操作的,但实际上所有通用操作都会被遮蔽。

以下是禁用加固功能的程序JIT编译的示例

# echo 0 > /proc/sys/net/core/bpf_jit_harden

  ffffffffa034f5e9 + <x>:
  [...]
  39:   mov    $0xa8909090,%eax
  3e:   mov    $0xa8909090,%eax
  43:   mov    $0xa8ff3148,%eax
  48:   mov    $0xa89081b4,%eax
  4d:   mov    $0xa8900bb0,%eax
  52:   mov    $0xa810e0c1,%eax
  57:   mov    $0xa8908eb4,%eax
  5c:   mov    $0xa89020b0,%eax
  [...]

在启用加固功能的情况下,当以非特权用户身份通过BPF加载相同的程序时,该程序中的常量会被遮蔽。

# echo 1 > /proc/sys/net/core/bpf_jit_harden

  ffffffffa034f1e5 + <x>:
  [...]
  39:   mov    $0xe1192563,%r10d
  3f:   xor    $0x4989b5f3,%r10d
  46:   mov    %r10d,%eax
  49:   mov    $0xb8296d93,%r10d
  4f:   xor    $0x10b9fd03,%r10d
  56:   mov    %r10d,%eax
  59:   mov    $0x8c381146,%r10d
  5f:   xor    $0x24c7200e,%r10d
  66:   mov    %r10d,%eax
  69:   mov    $0xeb2a830e,%r10d
  6f:   xor    $0x43ba02ba,%r10d
  76:   mov    %r10d,%eax
  79:   mov    $0xd9730af,%r10d
  7f:   xor    $0xa5073b1f,%r10d
  86:   mov    %r10d,%eax
  89:   mov    $0x9a45662b,%r10d
  8f:   xor    $0x325586ea,%r10d
  96:   mov    %r10d,%eax
  [...]

两个程序在语义上是相同的,只是第二个程序的反汇编中不再显示任何原始的立即数值。

同时,加固功能还禁用了特权用户的任何JIT kallsyms暴露,以防止JIT映像地址不再暴露给/proc/kallsyms

此外,Linux内核提供了CONFIG_BPF_JIT_ALWAYS_ON选项,该选项从内核中移除了整个BPF解释器,并永久启用JIT编译器。这是作为Spectre v2漏洞缓解措施的一部分开发的,以便在基于VM的设置中使用时,客户机内核在挂载攻击时将不再重用主机内核的BPF解释器。对于基于容器的环境,CONFIG_BPF_JIT_ALWAYS_ON配置选项是可选的,但如果JIT编译器无论如何都已启用,则也可以编译出解释器以减少内核的复杂性。因此,对于主流架构(如x86_64和arm64)中广泛使用的JIT编译器,也通常推荐启用此选项。

最后但同样重要的是,内核提供了一个选项,通过/proc/sys/kernel/unprivileged_bpf_disabled sysctl 控制旋钮来禁止非特权用户使用bpf(2)系统调用。这是一个一次性的禁用开关,意味着一旦设置为1,直到内核重新启动之前,都无法将其重置为0。设置后,只有来自初始命名空间的具有CAP_SYS_ADMIN特权的进程才允许从此时起使用bpf(2)系统调用。在启动时,Cilium也会将此控制旋钮设置为1。

echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled

卸载

6
BPF中的网络程序,特别是用于tc和XDP的程序,在内核中具有一个卸载接口到硬件,以便直接在NIC上执行BPF代码。

目前,Netronome的nfp驱动程序支持通过JIT编译器卸载BPF,该编译器将BPF指令转换为针对NIC实现的指令集。这还包括将BPF映射卸载到NIC,因此卸载的BPF程序可以执行映射查找、更新和删除操作。

BPF sysctls

Linux内核提供了一些与BPF相关的sysctl参数,并在本节中进行介绍。

  • /proc/sys/net/core/bpf_jit_enable:启用或禁用BPF JIT编译器。
描述
0禁用JIT,仅使用解释器(内核的默认值)
1启用JIT编译器
2启用JIT,并将调试跟踪信息输出到内核日志

如后续章节所述,当JIT编译器设置为调试模式(选项2)时,可以使用bpf_jit_disasm工具来处理调试跟踪信息。

  • /proc/sys/net/core/bpf_jit_harden:启用或禁用BPF JIT加固。请注意,启用加固会影响性能,但可以通过遮蔽BPF程序的立即值来减轻JIT喷涂攻击。对于通过解释器处理的程序,不需要/不会进行立即值的遮蔽。
描述
0禁用JIT加固(内核的默认值)
1仅对非特权用户启用JIT加固
2对所有用户启用JIT加固

/proc/sys/net/core/bpf_jit_kallsyms:启用或禁用将JIT编译后的程序作为内核符号导出到/proc/kallsyms,以便它们可以与perf工具一起使用,并使内核了解这些地址以便进行堆栈展开,例如,在转储堆栈跟踪时使用。符号名称包含BPF程序的标签(bpf_prog_)。如果启用了bpf_jit_harden,则此功能将被禁用。

描述
0禁用JIT kallsyms导出(内核的默认值)
1仅对特权用户启用JIT kallsyms导出

/proc/sys/kernel/unprivileged_bpf_disabled:启用或禁用非特权用户对bpf(2)系统调用的使用。Linux内核默认允许非特权用户使用bpf(2)。将此参数设置为1将禁止非特权用户使用bpf(2)系统调用,而设置为0则允许非特权用户使用。

一旦将值设置为1,非特权使用将被永久禁用,直到下一次重启,无论是应用程序还是管理员都无法再重置该值。

该值也可以设置为2,这意味着在运行时可以将其更改为0或1,同时现在禁用非特权使用。这个值在Linux 5.13版本中被添加。如果在内核配置中启用了BPF_UNPRIV_DEFAULT_OFF,那么这个控制旋钮的默认值将是2而不是0。

这个控制旋钮不会影响任何不使用bpf(2)系统调用来将程序加载到内核的cBPF程序,如seccomp或传统的套接字过滤器。

描述
0启用非特权使用bpf系统调用(内核的默认值)
1禁用非特权使用bpf系统调用(直到重启)
2禁用非特权使用bpf系统调用(如果在内核配置中启用了BPF_UNPRIV_DEFAULT_OFF,则此为默认值)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值