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 代码。对于 JITed BPF 程序尤其如此,这些程序与本机内核 C 代码无法区分。
问:这是否意味着不允许对 BPF 代码进行“创新”扩展?¶
答:软是。
至少目前是这样,直到 BPF 核心支持 bpf 到 bpf 调用、间接调用、循环、全局变量、跳转表、只读部分以及 C 代码可以生成的所有其他正常构造。
问:可以安全地支持循环吗?¶
答:目前尚不清楚。
BPF 开发人员正在努力寻找支持有界循环的方法。
问:验证器的限制是什么?¶
答:用户空间已知的唯一限制是 BPF_MAXINSNS (4096)。这是非特权 bpf 程序可以拥有的最大指令数。验证器有各种内部限制。比如在程序分析期间可以探索的最大指令数。目前,该限制设置为 100 万。这实际上意味着最大的程序可以由 100 万条 NOP 指令组成。对后续分支的最大数量、嵌套 bpf 到 bpf 调用的数量、每个指令的验证器状态的数量、程序使用的 map 的数量都有一个限制。所有这些限制都可以被足够复杂的程序命中。还有非数值限制可能会导致程序被拒绝。验证器曾经只识别指针 + 常量表达式。现在它可以识别指针 + bounded_register。bpf_lookup_map_elem(key) 要求 “key” 必须是指向堆栈的指针。现在,“key” 可以是指向 map 值的指针。验证器正在稳步变得“更智能”。限制正在被删除。要知道程序是否会被验证器接受的唯一方法是尝试加载它。bpf 开发过程保证未来的内核版本将接受早期版本接受的所有 bpf 程序。
指令级别问题¶
问:LD_ABS 和 LD_IND 指令与 C 代码¶
问:为什么 LD_ABS 和 LD_IND 指令存在于 BPF 中,而 C 代码无法表达它们,必须使用内置内联函数?
答:这是与经典 BPF 兼容的产物。BPF 中现代网络代码在没有它们的情况下表现更好。请参阅“直接数据包访问”。
问:BPF 指令映射不是一对一到本机 CPU¶
问:似乎不是所有 BPF 指令都是一对一到本机 CPU 的。例如,为什么 BPF_JNE 和其他比较和跳转不像 CPU 那样?
答:这是必要的,以避免将标志引入 ISA,这些标志无法在 CPU 架构之间实现通用和高效。
问:为什么 BPF_DIV 指令不映射到 x64 div?¶
答:因为如果我们选择与 x64 的一对一关系,那么在 arm64 和其他 archs 上支持它会更加复杂。还需要除以零的运行时检查。
问:为什么 BPF 有隐式序言和尾声?¶
答:因为像 sparc 这样的架构有寄存器窗口,并且通常架构之间存在足够的细微差异,因此将返回地址天真地存储到堆栈中是行不通的。另一个原因是 BPF 必须免受零除的影响(以及 LD_ABS insn 的遗留异常路径)。这些指令需要隐式调用尾声并返回。
问:为什么 BPF_JLT 和 BPF_JLE 指令最初没有引入?¶
答:因为经典的 BPF 没有它们,BPF 作者认为编译器解决方法是可以接受的。事实证明,由于缺少这些比较指令,程序会损失性能,因此添加了它们。这两个指令是完美示例,说明了哪种新的 BPF 指令是可以接受的,并且可以在将来添加。这两个指令已经在本机 CPU 中具有等效的指令。不具有与 HW 指令一对一映射的新指令将不被接受。
问:BPF 32 位子寄存器要求¶
问:BPF 32 位子寄存器要求将 BPF 寄存器的较高 32 位清零,这使得 BPF 对于 32 位 CPU 架构和 32 位 HW 加速器来说效率较低的虚拟机。将来可以在 BPF 中添加真正的 32 位寄存器吗?
答:不是。
但是,可以使用一些在 BPF 寄存器上将较高 32 位清零的优化,并且可以利用这些优化来提高 32 位架构的 JITed 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() 等辅助函数来遍历内核内部数据结构并使用内核内部标头编译的跟踪程序,有一个特定的例外。这两个内核内部结构都可能会发生变化,并且可能会与较新的内核发生中断,因此需要相应地调整程序。
通常通过使用 kfuncs 而不是新的辅助函数来添加新的 BPF 功能。Kfuncs 不被认为是稳定 API 的一部分,并且具有自己的生命周期期望,如 3. kfunc 生命周期期望 中所述。
问:跟踪点是稳定 ABI 的一部分吗?¶
答:否。跟踪点与内部实现细节相关联,因此它们可能会发生变化,并且可能会与较新的内核发生中断。BPF 程序需要在此发生时进行相应更改。
问:kprobes 可以附加到的位置是稳定 ABI 的一部分吗?¶
答:否。kprobes 可以附加到的位置是内部实现细节,这意味着它们可能会发生变化,并且可能会与较新的内核发生中断。BPF 程序需要在此发生时进行相应更改。
问:BPF 程序使用多少堆栈空间?¶
答:目前所有程序类型都限制为 512 字节的堆栈空间,但是验证器会计算实际使用的堆栈量,并且解释器和大多数 JITed 代码都会消耗必要的量。
问:BPF 可以卸载到硬件吗?¶
答:是。NFP 驱动程序支持 BPF HW 卸载。
问:经典 BPF 解释器仍然存在吗?¶
答:否。经典 BPF 程序已转换为扩展 BPF 指令。
问:BPF 可以调用任意内核函数吗?¶
答:否。BPF 程序只能调用作为 BPF 辅助函数或 kfuncs 公开的特定函数。可用函数集是为每种程序类型定义的。
问:BPF 可以覆盖任意内核内存吗?¶
答:不是。
跟踪 bpf 程序可以使用 bpf_probe_read() 和 bpf_probe_read_str() 辅助函数读取任意内存。网络程序无法读取任意内存,因为它们无权访问这些辅助函数。程序永远不能直接读取或写入任意内存。
问:BPF 可以覆盖任意用户内存吗?¶
答:有点。
跟踪 BPF 程序可以使用 bpf_probe_write_user() 覆盖当前任务的用户内存。每次加载此类程序时,内核都会打印警告消息,因此此辅助函数仅对实验和原型有用。跟踪 BPF 程序只能由 root 用户使用。
问:通过内核模块的新功能?¶
问:诸如新程序或 map 类型、新辅助函数等 BPF 功能可以从内核模块代码中添加吗?
答:是的,通过 kfuncs 和 kptrs
诸如程序类型、map 和辅助函数之类的核心 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 宏并不会比 EXPORT_SYMBOL_GPL 宏更会导致函数成为 ABI 的一部分。
问:map 值中特殊 BPF 类型的兼容性如何?¶
问:允许用户在其 BPF map 值中嵌入 bpf_spin_lock、bpf_timer 字段(使用 BPF map 的 BTF 支持时)。这允许对 map 值内的这些字段使用辅助函数。还允许用户嵌入指向某些内核类型的指针(带有 __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 为用户定义的类型分配的对象)。内核是否会保留这些功能的向后兼容性?
答:不是。
与 map 值类型不同,用于处理已分配对象以及它们内部特殊字段的任何支持的 API 都通过 kfuncs 公开,因此具有与 kfuncs 相同的生命周期期望。有关详细信息,请参见 3. kfunc 生命周期期望。