BPF 设计问答

BPF 在 Linux 内核中的可扩展性和对网络、跟踪、安全性的适用性,以及 BPF 虚拟机的一些用户空间实现,导致了对 BPF 实际是什么的许多误解。这个简短的问答试图解决这个问题,并概述 BPF 的长期发展方向。

问题和答案

问:BPF 是类似于 x64 和 arm64 的通用指令集吗?

答:不是。

问:BPF 是一个通用的虚拟机吗?

答:不是。

BPF 是具有 C 调用约定的通用指令集。

问:为什么选择 C 调用约定?

答:因为 BPF 程序设计为在用 C 编写的 Linux 内核中运行,因此 BPF 定义了与两种最常用的架构 x64 和 arm64 兼容的指令集(并考虑了其他架构的重要怪癖),并定义了与这些架构上 Linux 内核的 C 调用约定兼容的调用约定。

问:将来可以支持多个返回值吗?

答:不能。BPF 只允许将寄存器 R0 用作返回值。

问:将来可以支持超过 5 个函数参数吗?

答:不能。BPF 调用约定只允许使用寄存器 R1-R5 作为参数。BPF 不是独立的指令集。(不像 x64 ISA 允许 msft、cdecl 和其他约定)

问:BPF 程序可以访问指令指针或返回地址吗?

答:不是。

问:BPF 程序可以访问堆栈指针吗?

答:不是。

只有帧指针(寄存器 R10)是可访问的。从编译器的角度来看,必须要有堆栈指针。例如,LLVM 在其 BPF 后端中将寄存器 R11 定义为堆栈指针,但它确保生成的代码永远不会使用它。

问:C 调用约定会减少可能的用例吗?

答:是的。

BPF 设计强制以内核辅助函数和内核对象(如 BPF 映射)的形式添加主要功能,它们之间具有无缝的互操作性。它让内核调用 BPF 程序,程序调用内核助手,零开销,因为它们都是原生 C 代码。对于 JIT 编译的 BPF 程序,情况尤其如此,它们与原生内核 C 代码无法区分。

问:这是否意味着不允许对 BPF 代码进行“创新”扩展?

答:在某种程度上是的。

至少目前是这样,直到 BPF 核心支持 bpf-to-bpf 调用、间接调用、循环、全局变量、跳转表、只读段以及 C 代码可以生成的所有其他正常结构。

问:可以安全地支持循环吗?

答:目前尚不清楚。

BPF 开发人员正在努力寻找一种支持有界循环的方法。

问:验证器的限制是什么?

答:用户空间已知的唯一限制是 BPF_MAXINSNS (4096)。这是非特权 bpf 程序可以拥有的最大指令数。验证器有各种内部限制。例如,程序分析期间可以探索的最大指令数。目前,该限制设置为 100 万。这实际上意味着最大的程序可以由 100 万条 NOP 指令组成。对后续分支的最大数量、嵌套的 bpf-to-bpf 调用的数量、每个指令的验证器状态的数量、程序使用的映射的数量都有限制。所有这些限制都可能被足够复杂的程序命中。还有可能导致程序被拒绝的非数值限制。验证器曾经只识别指针 + 常量表达式。现在它可以识别指针 + bounded_register。bpf_lookup_map_elem(key) 要求 “key” 必须是指向堆栈的指针。现在,“key” 可以是指向 map 值的指针。验证器正在稳步变得 “更智能”。限制正在被取消。要知道程序是否会被验证器接受的唯一方法是尝试加载它。bpf 开发过程保证未来的内核版本将接受早期版本接受的所有 bpf 程序。

指令级问题

问:LD_ABS 和 LD_IND 指令与 C 代码

问:为什么 BPF 中存在 LD_ABS 和 LD_IND 指令,而 C 代码无法表达它们并且必须使用内置的内联函数?

答:这是与经典 BPF 兼容的产物。BPF 中的现代网络代码在没有它们的情况下表现更好。请参阅“直接数据包访问”。

问:BPF 指令与原生 CPU 不是一对一映射

问:似乎并非所有 BPF 指令都与原生 CPU 一对一映射。例如,为什么 BPF_JNE 和其他比较和跳转不是像 CPU 那样的?

答:这是为了避免在 ISA 中引入标志,而这些标志不可能在 CPU 架构之间实现通用和高效。

问:为什么 BPF_DIV 指令不映射到 x64 div?

答:因为如果我们选择与 x64 一对一的关系,会使在 arm64 和其他架构上的支持更加复杂。它还需要除零运行时检查。

问:为什么 BPF 有隐式的前言和后言?

答:因为像 sparc 这样的架构有寄存器窗口,一般来说架构之间存在足够细微的差异,因此将返回地址幼稚地存储到堆栈中是行不通的。另一个原因是 BPF 必须防止除零(以及 LD_ABS 指令的旧异常路径)。这些指令需要隐式调用后言并返回。

问:为什么 BPF_JLT 和 BPF_JLE 指令在最初没有引入?

答:因为经典 BPF 没有它们,BPF 作者认为编译器解决方法是可以接受的。事实证明,由于缺乏这些比较指令,程序会损失性能,因此添加了它们。这两个指令是很好的例子,说明了什么样的 BPF 新指令是可以接受的,并且可以在将来添加。这两个已经在原生 CPU 中具有等效的指令。没有与硬件指令一对一映射的新指令将不会被接受。

问:BPF 32 位子寄存器要求

问:BPF 32 位子寄存器要求将 BPF 寄存器的高 32 位清零,这使得 BPF 对于 32 位 CPU 架构和 32 位硬件加速器而言效率低下。未来能否将真正的 32 位寄存器添加到 BPF 中?

答:不是。

但是,对于 BPF 寄存器,有一些关于将高 32 位清零的优化可用,可以利用这些优化来提高 32 位架构上 JIT 编译的 BPF 程序的性能。

从版本 7 开始,LLVM 能够生成对 32 位子寄存器进行操作的指令,前提是编译程序时传递了选项 -mattr=+alu32。此外,验证器现在可以标记需要将目标寄存器的高位清零的指令,并插入显式的零扩展 (zext) 指令(mov32 变体)。这意味着对于没有 zext 硬件支持的架构,JIT 后端不需要清除 alu32 指令或窄加载写入的子寄存器的高位。相反,后端只需要支持该 mov32 变体的代码生成,并覆盖 bpf_jit_needs_zext() 使其返回“true”(以便在验证器中启用 zext 插入)。

请注意,JIT 后端可能对 zext 有部分硬件支持。在这种情况下,如果启用了验证器 zext 插入,则可能导致插入不必要的 zext 指令。可以通过在 JIT 后端创建一个简单的窥孔来删除这些指令:如果一条指令具有 zext 的硬件支持,并且下一条指令是显式的 zext,那么在进行代码生成时可以跳过后者。

问:BPF 是否具有稳定的 ABI?

答:是。BPF 指令、BPF 程序的参数、辅助函数集及其参数、可识别的返回代码都是 ABI 的一部分。但是,对于使用 bpf_probe_read() 等辅助函数来遍历内核内部数据结构并使用内核内部头文件进行编译的跟踪程序,存在一个特殊的例外。这些内核内部结构都可能发生变化,并且可能在新内核中中断,因此需要相应地调整程序。

新的 BPF 功能通常通过使用 kfuncs 而不是新的辅助函数来添加。Kfuncs 不被认为是稳定 API 的一部分,并且具有其自己的生命周期期望,如 3. kfunc 生命周期期望 中所述。

问:跟踪点是稳定 ABI 的一部分吗?

答:否。跟踪点与内部实现细节相关联,因此它们可能会发生变化,并且可能会在新内核中中断。发生这种情况时,BPF 程序需要相应地进行更改。

问:kprobes 可以附加到的位置是稳定 ABI 的一部分吗?

答:否。kprobes 可以附加到的位置是内部实现细节,这意味着它们可能会发生变化,并且可能会在新内核中中断。发生这种情况时,BPF 程序需要相应地进行更改。

问:一个 BPF 程序使用多少堆栈空间?

答:目前,所有程序类型都限制为 512 字节的堆栈空间,但验证器会计算实际使用的堆栈量,并且解释器和大多数 JIT 编译的代码都会消耗必要的数量。

问:BPF 可以卸载到硬件吗?

答:是。NFP 驱动程序支持 BPF 硬件卸载。

问:经典的 BPF 解释器仍然存在吗?

答:否。经典的 BPF 程序会转换为扩展的 BPF 指令。

问:BPF 可以调用任意内核函数吗?

答:否。BPF 程序只能调用作为 BPF 辅助函数或 kfuncs 公开的特定函数。可用的函数集是为每种程序类型定义的。

问:BPF 可以覆盖任意内核内存吗?

答:不是。

跟踪 bpf 程序可以使用 bpf_probe_read() 和 bpf_probe_read_str() 辅助函数读取任意内存。网络程序无法读取任意内存,因为它们无权访问这些辅助函数。程序永远不能直接读取或写入任意内存。

问:BPF 可以覆盖任意用户内存吗?

答:某种程度上可以。

跟踪 BPF 程序可以使用 bpf_probe_write_user() 覆盖当前任务的用户内存。每次加载此类程序时,内核都会打印警告消息,因此此辅助函数仅适用于实验和原型。跟踪 BPF 程序仅限 root 用户使用。

问:通过内核模块添加新功能?

问:是否可以通过内核模块代码添加新的 BPF 功能,例如新的程序或映射类型、新的辅助函数等?

答:是的,通过 kfuncs 和 kptrs。

核心 BPF 功能(如程序类型、映射和辅助函数)不能通过模块添加。但是,模块可以通过导出 kfuncs(可以返回指向模块内部数据结构的指针,作为 kptrs)来向 BPF 程序公开功能。

问:直接调用内核函数是 ABI 吗?

问:某些内核函数(例如 tcp_slow_start)可以由 BPF 程序调用。这些内核函数是否成为 ABI?

答:不是。

内核函数原型将会更改,并且 BPF 程序将被验证器拒绝。此外,例如,某些 BPF 可调用的内核函数已被其他内核 tcp cc(拥塞控制)实现使用。如果这些内核函数中的任何一个发生更改,则必须更改树内和树外的内核 tcp cc 实现。BPF 程序也是如此,必须进行相应的调整。有关详细信息,请参阅 3. kfunc 生命周期期望

问:附加到任意内核函数是 ABI 吗?

问:BPF 程序可以附加到许多内核函数。这些内核函数是否成为 ABI 的一部分?

答:不是。

内核函数原型将会更改,并且附加到它们的 BPF 程序将需要更改。应使用 BPF 一次编译、到处运行 (CO-RE),以便更容易地使您的 BPF 程序适应不同版本的内核。

问:用 BTF_ID 标记一个函数会使该函数成为 ABI 吗?

答:不是。

BTF_ID 宏不会导致一个函数成为 ABI 的一部分,就像 EXPORT_SYMBOL_GPL 宏一样。

问:映射值中特殊 BPF 类型的兼容性如何?

问:允许用户在他们的 BPF 映射值中嵌入 bpf_spin_lock、bpf_timer 字段(当使用对 BPF 映射的 BTF 支持时)。这允许在映射值内对这些字段使用辅助函数。还允许用户嵌入指向某些内核类型的指针(带有 __kptr_untrusted 和 __kptr BTF 标签)。内核是否会保持这些功能的向后兼容性?

答:这取决于情况。对于 bpf_spin_lock、bpf_timer:是的;对于 kptr 和其他所有内容:否,但请参见下文。

对于已添加的结构类型(如 bpf_spin_lock 和 bpf_timer),内核将保持向后兼容性,因为它们是 UAPI 的一部分。

对于 kptrs,它们也是 UAPI 的一部分,但仅就 kptr 机制而言。您可以在结构中使用带有 __kptr_untrusted 和 __kptr 标记的指针的类型不是 UAPI 合同的一部分。支持的类型可能会并且将在内核版本之间发生变化。但是,对于支持的类型,跨内核版本仍然支持诸如访问 kptr 字段和 bpf_kptr_xchg() 辅助函数之类的操作。

对于任何其他支持的结构类型,除非在本文档中明确声明并添加到 bpf.h UAPI 头文件中,否则此类类型的大小、类型和对齐方式,或任何其他用户可见的 API 或 ABI 细节可能会在内核版本之间任意更改。用户必须使他们的 BPF 程序适应新的变化并更新它们,以确保他们的程序继续正常工作。

注意:BPF 子系统专门为类型名称保留“bpf_”前缀,以便将来引入更多特殊字段。因此,用户程序必须避免定义带有“bpf_”前缀的类型,以避免在未来的版本中中断。换句话说,如果使用 BTF 中带有“bpf_”前缀的类型,则不保证向后兼容性。

问:已分配对象中特殊 BPF 类型的兼容性如何?

问:与上述相同,但针对已分配的对象(即使用 bpf_obj_new 为用户定义的类型分配的对象)。内核是否会保持这些功能的向后兼容性?

答:不是。

与映射值类型不同,用于处理已分配对象的 API 以及对其中特殊字段的任何支持都通过 kfuncs 公开,因此具有与 kfuncs 本身相同的生命周期期望。有关详细信息,请参阅 3. kfunc 生命周期期望