eBPF
eBPF
目录
BPF架构
BPF 不仅仅通过提供其指令集来定义自身,还通过提供围绕其的进一步基础设施,例如充当高效键/值存储的映射、与内核功能交互和利用内核功能的辅助函数、用于调用其他 BPF 程序的尾部调用、安全强化原语、用于固定对象(地图、程序)的伪文件系统以及允许 BPF 卸载的基础设施(例如,卸载到网卡)。
LLVM提供了BPF后端,因此可以使用clang等工具将C编译为BPF目标文件,然后将其加载到内核中。 BPF 与 Linux 内核紧密相连,并允许在不牺牲本机内核性能的情况下实现完全可编程性。
最后但并非最不重要的一点是,使用 BPF 的内核子系统也是 BPF 基础设施的一部分。本文档中讨论的两个主要子系统是 tc 和 XDP,BPF 程序可以附加到其中。 XDP BPF 程序附加在最早的网络驱动程序阶段,并在接收到数据包时触发 BPF 程序的运行。根据定义,这可以实现最佳的数据包处理性能,因为数据包无法在软件中更早的时间点得到处理。然而,由于此处理在网络堆栈中发生得如此之早,因此堆栈尚未从数据包中提取元数据。另一方面,tc BPF 程序稍后在内核堆栈中执行,因此它们可以访问更多元数据和核心内核功能。除了 tc 和 XDP 程序之外,还有各种其他内核子系统也使用 BPF,例如跟踪(kprobes、uprobes、tracepoint 等)。
以下小节提供了有关 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 可以被认为是内核设施的通用“粘合代码”,用于编写程序来解决特定的用例。
内核中 BPF 程序的执行始终是事件驱动的!例子:
- 一旦接收到数据包,在其入口路径上附加了 BPF 程序的网络设备将触发该程序的执行。
- 具有附加 BPF 程序的 kprobe 的内核地址,一旦执行该地址处的代码,就会捕获该地址,然后调用 kprobe 的回调函数进行检测,随后触发附加 BPF 程序的执行。
BPF 由 11 个 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 寄存器都一对一映射到 HW CPU 寄存器,因此 JIT 只需要发出调用指令,但不需要额外的额外动作来放置函数参数。此调用约定经过建模以涵盖常见的调用情况,而不会影响性能。目前不支持具有 6 个或更多参数的调用。内核中专用于 BPF 的辅助函数( BPF_CALL_0()
到 BPF_CALL_5()
函数)是专门根据此约定设计的。
寄存器 r0
也是包含BPF程序退出值的寄存器。退出值的语义由程序类型定义。此外,当将执行交还给内核时,退出值将作为 32 位值传递。
寄存器 r1
- r5
是暂存寄存器,这意味着 BPF 程序需要将它们溢出到 BPF 堆栈或将它们移动到被调用者保存的寄存器(如果要在多个助手之间重用这些参数)函数调用。溢出意味着寄存器中的变量被移动到BPF堆栈中。将变量从 BPF 堆栈移至寄存器的逆操作称为填充。溢出/填充的原因是由于寄存器数量有限。
当进入 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 位指令的指令编码定义为 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_reg
和 src_reg
都提供有关操作要使用的寄存器操作数的附加信息(例如 r0
- r9
)。 off
在某些指令中用于提供相对偏移量,例如,用于寻址堆栈或 BPF 可用的其他缓冲区(例如映射值、数据包数据等),或跳转指令中的跳转目标。 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
类保存从内存中加载字节/半字/字/双字的指令。这种情况下的内存是通用的,可以是堆栈内存、映射值数据、数据包数据等。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 类都具有源操作数的基本操作,源操作数是基于寄存器的,并且是基于立即数的对应操作数。两者都支持 add (+
)、sub (-
) 和 (&
) 或 (|
)、左移(<<
)、右移 (>>
)、异或 (^
)、mul (*
)、div (/
), neg (~
) 操作。此外,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 (有符号>
), jsge (签名>=
), jlt (<
), jle (<=
), jslt (签名<
)和 jset(如果DST & SRC
则跳转)。除此之外,该类中还有三个特殊的跳转操作:退出指令,它将离开 BPF 程序并返回r0
中的当前值作为返回码;调用指令,它将发出一个函数调用可用的 BPF 辅助函数之一,以及隐藏的尾部调用指令,该指令将跳转到不同的 BPF 程序。
Linux 内核附带了一个 BPF 解释器,它执行用 BPF 指令组装的程序。即使 cBPF 程序也会在内核中透明地转换为 eBPF 程序,但仍然附带 cBPF JIT 且尚未迁移到 eBPF JIT 的架构除外。
目前 x86_64
、 arm64
、 ppc64
、 s390x
、 mips64
、 sparc64
和 < b6> 架构带有内核内 eBPF JIT 编译器。
所有 BPF 处理(例如将程序加载到内核中或创建 BPF 映射)都是通过中央 bpf()
系统调用进行管理的。它还用于管理映射条目(查找/更新/删除),并通过固定使程序和映射持久保存在 BPF 文件系统中。
辅助函数
辅助函数是一个概念,它使 BPF 程序能够查阅核心内核定义的函数调用集,以便从内核检索数据/将数据推送到内核。对于每个 BPF 程序类型,可用的辅助函数可能有所不同,例如,与附加到 tc 层的 BPF 程序相比,附加到套接字的 BPF 程序只允许调用辅助程序的子集。用于轻量级隧道的封装和解封装助手构成了仅适用于较低 tc 层的功能示例,而用于将通知推送到用户空间的事件输出助手可用于 tc 和 XDP 程序。
每个辅助函数都是通过类似于系统调用的公共共享函数签名来实现的。签名定义为:
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
上一节中描述的调用约定适用于所有 BPF 辅助函数。
内核将辅助函数抽象为宏 BPF_CALL_0()
到 BPF_CALL_5()
,这些宏与系统调用类似。以下示例是辅助函数的摘录,该函数通过调用相应的地图实现回调来更新地图元素:
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 重载了其加载指令,以便在不可能的数据包偏移处获取数据以调用辅助辅助函数,但每个 cBPF JIT 都需要实现对此类 cBPF 扩展的支持。对于 eBPF,每个新添加的辅助函数都将以透明且高效的方式进行 JIT 编译,这意味着 JIT 编译器只需要发出一条调用指令,因为寄存器映射的方式使得 BPF 寄存器分配已经与底层架构的调用约定。这允许使用新的帮助程序功能轻松扩展核心内核。所有 BPF 辅助函数都是核心内核的一部分,不能通过内核模块扩展或添加。
上述函数签名还允许验证者执行类型检查。上面的 struct bpf_func_proto
用于将helper需要知道的所有必要信息交给验证者,以便验证者可以确保来自helper的预期类型与BPF的当前内容相匹配程序分析的寄存器。
参数类型的范围可以从传入任何类型的值到受限制的内容,例如 BPF 堆栈缓冲区的指针/大小对,助手应该从中读取或写入。在后一种情况下,验证者还可以执行额外的检查,例如缓冲区是否先前已初始化。
可用的 BPF 帮助器函数列表相当长,并且在不断增长,例如,在撰写本文时,tc BPF 程序可以从 38 个不同的 BPF 帮助器中进行选择。内核的 struct bpf_verifier_ops
包含一个 get_func_proto
回调函数,该函数提供特定 enum bpf_func_id
到给定 BPF 程序类型的可用帮助程序之一的映射。
映射
映射是驻留在内核空间中的高效键/值存储。可以从 BPF 程序访问它们,以便在多个 BPF 程序调用之间保持状态。它们还可以通过用户空间的文件描述符进行访问,并且可以与其他 BPF 程序或用户空间应用程序任意共享。
相互共享映射的 BPF 程序不需要具有相同的程序类型,例如,跟踪程序可以与网络程序共享映射。目前,单个 BPF 程序最多可以直接访问 64 个不同的映射。
映射实现由核心内核提供。有一些具有每 CPU 和非每 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_HASH
。它们都使用相同的一组通用的 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_MAPS
, BPF_MAP_TYPE_HASH_OF_MAPS
。例如, BPF_MAP_TYPE_PROG_ARRAY
是一个数组映射,它保存其他BPF程序, BPF_MAP_TYPE_ARRAY_OF_MAPS
和 BPF_MAP_TYPE_HASH_OF_MAPS
都保存指向其他映射的指针,以便可以原子地替换整个BPF映射在运行时。这些类型的映射解决了一个特定问题,该问题不适合仅通过 BPF 辅助函数来实现,因为需要在 BPF 程序调用之间保存附加(非数据)状态。
对象固定
BPF 映射和程序充当内核资源,只能通过文件描述符进行访问,并由内核中的匿名索引节点支持。虽然有优点,但也有一些缺点:
用户空间应用程序可以利用大多数与文件描述符相关的 API,Unix 域套接字的文件描述符传递透明地工作等,但同时,文件描述符仅限于进程的生命周期,这使得诸如映射共享之类的选项变得相当麻烦执行。
因此,它给某些用例带来了许多复杂性,例如 iproute2,其中 tc 或 XDP 设置程序并将其加载到内核中并最终自行终止。这样,从用户空间侧也无法访问地图,否则它可能很有用,例如,当地图在数据路径的入口和出口位置之间共享时。此外,第三方应用程序可能希望在 BPF 程序运行时监视或更新地图内容。
为了克服这个限制,我们实现了最小内核空间 BPF 文件系统,其中 BPF 映射和程序可以固定到其中,这个过程称为对象固定。因此,BPF 系统调用已扩展为两个新命令,它们可以固定 ( BPF_OBJ_PIN
) 或检索 ( BPF_OBJ_GET
) 先前固定的对象。
例如,tc 等工具利用此基础设施来共享入口和出口的地图。 BPF相关的文件系统不是单例的,它确实支持多个挂载实例、硬链接和软链接等。
尾部调用
另一个可以与 BPF 一起使用的概念称为尾调用。尾部调用可以看作是一种允许一个 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 呼叫
除了 BPF 辅助调用和 BPF 尾部调用之外,BPF 核心基础设施中添加的一项最新功能是 BPF 到 BPF 调用。在此功能引入内核之前,典型的 BPF C 程序必须声明任何可重用代码,例如,驻留在标头中的 always_inline
,以便当 LLVM 编译并生成 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";
这样做的主要原因是 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 调用,这意味着 r1
到 r5
用于将参数传递给被调用者,结果在 r0
。 r1
到 r5
是暂存寄存器,而 r6
到 r9
以通常的方式在调用之间保留。分别允许的调用帧的最大嵌套调用数为 8
。调用者可以将指针(例如,指向调用者的堆栈帧)向下传递给被调用者,但反之则不然。
BPF JIT 编译器为每个函数体发出单独的映像,然后在最终的 JIT 传递中修复映像中的函数调用地址。事实证明,这需要对 JIT 进行最少的更改,因为它们可以将 BPF 到 BPF 的调用视为传统的 BPF 帮助程序调用。
直到内核 5.9,BPF 尾调用和 BPF 子程序相互排斥。使用尾部调用的 BPF 程序无法获得减小程序映像大小和加快加载时间的好处。 Linux 内核 5.10 最终允许用户结合两个世界的优点,并添加了将 BPF 子程序与尾调用相结合的能力。
不过,这种改进也有一些限制。混合这两个功能可能会导致内核堆栈溢出。要了解可能发生的情况,请参阅下图,该图说明了 bpf2bpf 调用和尾部调用的组合:

在实际跳转到目标程序之前,尾部调用将仅展开其当前堆栈帧。正如我们在上面的示例中看到的,如果子函数内部发生尾调用,则当程序执行在 func2 时,函数的 (func1) 堆栈帧将出现在堆栈上。一旦最终函数 (func3) 函数终止,所有先前的堆栈帧将被展开,控制权将返回给 BPF 程序调用者的调用者。
内核引入了额外的逻辑来检测此功能组合。整个调用链的堆栈大小有限制,每个子程序最多 256 字节(请注意,如果验证程序检测到 bpf2bpf 调用,则主函数也将被视为子函数)。总的来说,在这个限制下,BPF 程序的调用链最多可以消耗 8KB 的堆栈空间。此限制来自每个堆栈帧 256 字节乘以尾部调用计数限制 (33)。如果没有这个,BPF 程序将在 512 字节的堆栈大小上运行,从而产生总共 16KB 大小的尾部调用的最大数量,这会在某些体系结构上溢出堆栈。
还要一提的是,目前仅在 x86-64 架构上支持此功能组合。
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 程序。
在内核的源代码树中,可以通过发出 HAVE_EBPF_JIT
的 grep 轻松确定 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 经过优化,可以为给定指令发出尽可能短的操作码,以缩小程序翻译所需的总大小。
Hardening
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
,则通过 retpoline 实现使用尾调用的间接跳转的 JITing在大多数现代 Linux 发行版中编写的内容。
如果 /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
Offloads 卸载
BPF 中的网络程序(特别是 tc 和 XDP)确实有一个到内核中硬件的卸载接口,以便直接在 NIC 上执行 BPF 代码。
目前,Netronome 的 nfp
驱动程序支持通过 JIT 编译器卸载 BPF,该编译器将 BPF 指令转换为针对 NIC 实现的指令集。这还包括将 BPF 映射卸载到 NIC,因此卸载的 BPF 程序可以执行映射查找、更新和删除。
BPF 系统
Linux 内核提供了一些与 BPF 相关并在本节中介绍的 sysctls。
/proc/sys/net/core/bpf_jit_enable
:启用或禁用 BPF JIT 编译器。Value Description 0 禁用 JIT 并仅使用解释器(内核的默认值) 1 启用 JIT 编译器 2 启用 JIT 并将调试跟踪发送到内核日志 如后续部分所述,当 JIT 编译器设置为调试模式(选项
2
)时,bpf_jit_disasm
工具可用于处理调试跟踪。/proc/sys/net/core/bpf_jit_harden
:启用或禁用 BPF JIT 强化。请注意,启用强化会牺牲性能,但可以通过屏蔽 BPF 程序的即时值来减轻 JIT 喷射。对于通过解释器处理的程序,不需要/执行立即值的盲化。
Value | Description |
---|---|
0 | 禁用 JIT 强化(内核的默认值) |
1 | 仅为非特权用户启用 JIT 强化 |
2 | 为所有用户启用 JIT 强化 |
/proc/sys/net/core/bpf_jit_kallsyms
:启用或禁用将 JIT 程序作为内核符号导出到/proc/kallsyms
,以便它们可以与perf
工具一起使用,并使这些地址知道用于堆栈展开的内核,例如用于转储堆栈跟踪。符号名称包含 BPF 程序标记 (bpf_prog_<tag>
)。如果启用bpf_jit_harden
,则此功能被禁用。
Value | Description |
---|---|
0 | 禁用 JIT kallsyms 导出(内核的默认值) |
1 | 仅为特权用户启用 JIT kallsyms 导出 |
/proc/sys/kernel/unprivileged_bpf_disabled
:启用或禁用bpf(2)
系统调用的非特权使用。 Linux 内核默认启用bpf(2)
的非特权使用。一旦该值设置为 1,非特权使用将被永久禁用,直到下次重新启动,应用程序和管理员都无法再重置该值。
该值也可以设置为 2,这意味着稍后可以在运行时将其更改为 0 或 1,同时禁用目前使用的非特权。这个值是在 Linux 5.13 中添加的。如果在内核配置中启用了
BPF_UNPRIV_DEFAULT_OFF
,则此旋钮将默认为 2 而不是 0。此旋钮不会影响任何 cBPF 程序,例如 seccomp 或不使用
bpf(2)
系统调用将程序加载到内核中的传统套接字过滤器。Value Description 0 启用 bpf 系统调用的非特权使用(内核的默认值) 1 禁用 bpf 系统调用的非特权使用(直到重新启动) 2 禁用 bpf 系统调用的非特权使用(如果在内核配置中启用 BPF_UNPRIV_DEFAULT_OFF
,则默认)