Table of Contents
本文档部分针对希望深入了解BPF和XDP的开发人员和用户。阅读本参考指南可能有助于拓宽您对Cilium的了解,但不是必须使用Cilium。请参阅 入门指南和体系结构以获得更高级的介绍。
BPF是Linux内核中一种高度灵活且高效的类似于虚拟机的构造,允许以安全的方式在各个挂钩点执行字节码。它被用于许多Linux内核子系统中,最主要的是联网,跟踪和安全性(例如沙箱)。
尽管BPF自1992年以来就存在,但该文档涵盖了扩展的伯克利包过滤器(eBPF)版本,该版本首次出现在内核3.18中,并且使原始版本(如今被称为“经典” BPF(cBPF))如今已过时。许多人都将cBPF称为tcpdump使用的数据包筛选器语言。如今,Linux内核仅运行eBPF,并且在程序执行之前,已加载的cBPF字节码在内核中透明地转换为eBPF表示形式。除非指出eBPF和cBPF之间的明确区别,否则本文档通常将使用术语BPF。
即使名称Berkeley Packet Filter暗示了特定目的的包过滤,但这些指令集如今已经足够通用和灵活,以至于除了网络之外,还有许多BPF用例。请参阅进一步阅读 以获取使用BPF的项目列表。
Cilium在其数据路径中大量使用BPF,请参阅体系结构以获取更多信息。本章的目的是提供一个BPF参考指南,以帮助您了解BPF,其特定于网络的用途,包括使用tc(流量控制)和XDP(eXpress数据路径)加载BPF程序,以及协助开发Cilium的BPF模板。 。
BPF体系结构
BPF不仅通过提供指令集来定义自己,还通过在其周围提供其他基础结构来定义自己,例如充当有效键/值存储的映射,与内核功能交互和利用的辅助函数,调用其他BPF程序的尾部调用,安全强化原语,用于固定对象(映射,程序)的伪文件系统以及用于将BPF卸载到例如网卡的基础结构。
LLVM提供了BPF后端,因此可以使用clang之类的工具将C编译为BPF目标文件,然后将其加载到内核中。BPF与Linux内核紧密相关,并且可以在不牺牲本机内核性能的情况下实现完全可编程性。
最后但并非最不重要的一点是,使用BPF的内核子系统也是BPF基础结构的一部分。本文档中讨论的两个主要子系统是tc和XDP,可以将BPF程序附加到该子系统。XDP BPF程序附加在最早的网络驱动程序阶段,并在收到数据包后触发BPF程序的运行。根据定义,由于无法在软件的更早位置处理数据包,因此可以实现最佳的数据包处理性能。但是,由于此处理如此早在联网堆栈中进行,因此堆栈尚未从包中提取元数据。另一方面,tc BPF程序稍后在内核堆栈中执行,因此它们可以访问更多的元数据和核心内核功能。除了tc和XDP程序,
以下小节提供了有关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程序的联网设备将触发该程序的执行。
- 一旦执行了kprobe并附加了BPF程序的内核地址,该地址就会被捕获,然后将调用kprobe的回调函数进行检测,随后触发所附加的BPF程序的执行。
BPF由11个64位寄存器和32位子寄存器,一个程序计数器和一个512字节大BPF堆栈空间组成。寄存器名为r0
- r10
。默认情况下,操作模式为64位,只能通过特殊的ALU(算术逻辑单元)操作访问32位子寄存器。低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
最初包含该程序的上下文。上下文是程序的输入参数(类似于argc/argv
典型的C程序的对)。BPF仅限于在单个上下文中工作。上下文由程序类型定义,例如,联网程序可以将网络包(skb
)的内核表示作为输入参数。
BPF的一般操作是64位,遵循64位体系结构的自然模型,以便执行指针算术,传递指针但还将64位值传递给辅助函数,并允许64位原子操作。
每个程序的最大指令限制为4096 BPF指令,这意味着,根据设计,这意味着任何程序都会迅速终止。尽管指令集包含向前跳转和向后跳转,但内核BPF验证程序将禁止循环,以便始终保证终止。由于BPF程序在内核中运行,因此验证程序的工作是确保这些程序可以安全运行,而不影响系统的稳定性。这意味着从指令集的角度来看,可以实现循环,但是验证程序将对此加以限制。但是,还有一种尾部调用的概念,它允许一个BPF程序跳入另一个程序。同样,它带有32个调用的嵌套上限,通常用于将程序逻辑的某些部分解耦,例如,分成多个阶段。
指令格式被建模为两个操作数指令,这有助于在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_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(-
)和(&
)或(|
),左移(<<
),右移(>>
),xor(^
),mul(*
),div(/
),mod(%
),neg(~
)操作。在两个操作数模式下,mov()作为两个类的特殊ALU操作被添加。 还包含一个已签名的右移。还包含给定源寄存器上半字/字/双字的字节序转换指令。<X> := <Y>
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如果)。除此之外,此类中还包含三种特殊的跳转操作:退出指令将退出BPF程序,并以返回码的形式返回当前值;调用指令将对可用的BPF中的一个发出函数调用帮助程序功能和隐藏的尾部调用指令,它们将跳转到另一个BPF程序中。DST & SRC
r0
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程序类型,可用的帮助程序功能可能有所不同,例如,与附加到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帮助程序功能都是核心内核的一部分,不能通过内核模块进行扩展或添加。
前述功能签名还允许验证者执行类型检查。上面的代码用于将所有关于帮助者所需的必要信息传递给验证者,以便验证者可以确保来自帮助者的预期类型与BPF程序的已分析寄存器的当前内容匹配。struct bpf_func_proto
参数类型的范围从传递任何类型的值到受限制的内容(例如BPF堆栈缓冲区的指针/大小对),助手应从中读取或写入这些参数。在后一种情况下,验证者还可以执行其他检查,例如,缓冲区是否先前已初始化。
可用的BPF帮助器功能列表相当长且不断增长,例如,在撰写本文时,tc BPF程序可以从38个不同的BPF帮助器中进行选择。内核包含一个 回调函数,该函数为给定的BPF程序类型提供特定于可用助手之一的映射 。struct bpf_verifier_ops
get_func_proto
enum bpf_func_id
地图
映射是驻留在内核空间中的高效键/值存储。可以从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_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_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映射和程序充当内核资源,并且只能通过文件描述符进行访问,该文件描述符由内核中的匿名inode支持。优点与缺点同时存在:
用户空间应用程序可以使用大多数与文件描述符相关的API,可以透明地传递Unix域套接字的文件描述符,等等,但是同时,文件描述符限于进程的生命周期,这使得诸如地图共享之类的选项变得相当麻烦。完成。
因此,对于某些用例(例如iproute2),这会带来许多麻烦,其中tc或XDP会设置程序并将其加载到内核中,并最终终止自身。这样一来,从用户空间一侧也无法访问映射,否则在其他情况下它可能会很有用,例如,当在数据路径的入口和出口位置之间共享映射时。同样,第三方应用程序可能希望在BPF程序运行时监视或更新地图内容。
为了克服此限制,已实现了最小的内核空间BPF文件系统,可以将BPF映射和程序固定在该文件系统上,该过程称为对象固定。因此,已使用两个新命令扩展了BPF系统调用,这两个命令可以固定(BPF_OBJ_PIN
)或检索(BPF_OBJ_GET
)先前固定的对象。
例如,诸如tc之类的工具利用此基础结构来共享入口和出口的地图。与BPF相关的文件系统不是单例的,它确实支持多个安装实例,硬链接和软链接等。
尾叫
可以与BPF一起使用的另一个概念称为尾部调用。尾部调用可以看作是一种机制,它允许一个BPF程序调用另一个程序,而无需返回到旧程序。与函数调用不同,此类调用具有最小的开销,它实现为跳远,并重用了相同的堆栈帧。
此类程序相互独立地进行验证,因此,为了传输状态(按CPU映射作为暂存缓冲区)或在tc程序的情况下,必须使用skb
诸如cb[]
area之类的字段。
只能对相同类型的程序进行尾部调用,并且它们还需要在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
tor5
是临时寄存器,而通常r6
以r9
跨调用的方式保留它们。嵌套调用分别允许的最大调用帧数为8
。调用者可以将指针(例如,指向调用者的堆栈框架)传递给被调用者,但绝不能相反。
BPF到BPF调用当前与BPF尾调用的使用不兼容,因为后者需要按原样重用当前的堆栈设置,而前者增加了额外的堆栈帧,因此更改了尾调用的预期布局。
BPF JIT编译器为每个函数主体发出单独的映像,然后在最后的JIT传递中固定映像中的函数调用地址。事实证明,这需要对JIT进行最小的更改,因为它们可以将BPF到BPF的调用视为常规的BPF辅助调用。
准时制
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来轻松确定eBPF JIT支持HAVE_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解释器映像()以及JIT编译映像()在程序的生命周期内锁定为只读状态,以防止代码潜在损坏。例如,由于某些内核错误而在此时发生的任何损坏都将导致一般的保护错误,从而使内核崩溃,而不是让损坏无声地发生。struct bpf_prog
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编译将对非特权用户生效。在不受信任的用户在系统上运行的情况下,这可以通过减少(潜在的)攻击面来略微权衡其性能。与完全切换到解释器相比,程序执行的减少仍然导致更好的性能。
当前,启用强化将在编译JIT时从BPF程序中屏蔽所有用户提供的32位和64位常量,以防止JIT喷射攻击将本地操作码作为立即值注入。这是有问题的,因为这些立即值驻留在可执行的内核内存中,因此可以从某些内核错误触发的跳转将跳转到立即值的开头,然后将其作为本机指令执行。
JIT常量盲法是通过将实际指令随机化来防止这种情况的发生,这意味着通过将值的实际负载分为两个步骤来重写指令,可将操作从基于立即数的源操作数转换为基于寄存器的寄存器:将立即数盲化到寄存器中,2)将寄存器与之进行异或,以 使原始立即数驻留在寄存器中并可用于实际操作。该示例是为装入操作提供的,但实际上所有通用操作都是盲目的。rnd ^ imm
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
和)的情况下,通常也建议将其广泛用于JIT arm64
。
最后但并非最不重要的一点是,内核提供了一个选项,bpf(2)
用于通过/proc/sys/kernel/unprivileged_bpf_disabled
sysctl旋钮为非特权用户禁用系统调用 。这是一次一次性的kill开关,这意味着一旦设置为1
,就没有选项将其重置回0
新的内核重新启动。设置CAP_SYS_ADMIN
后,从那时起,仅允许初始名称空间之外的特权进程使用bpf(2)
系统调用。启动时,Cilium也会将此旋钮设置1
为。
# echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
减负
BPF中的网络程序(特别是针对tc和XDP的网络程序)确实具有到内核中硬件的卸载接口,以便直接在NIC上执行BPF代码。
当前,nfp
Netronome的驱动程序支持通过JIT编译器卸载BPF,该编译器将BPF指令转换为针对NIC实现的指令集。这还包括将BPF映射卸载到NIC,因此,卸载的BPF程序可以执行映射查找,更新和删除。
工具链
本节讨论了围绕BPF的当前用户空间工具,自检工具和内核控制旋钮。请注意,围绕BPF的工具和基础设施仍在迅速发展,因此可能无法提供所有可用工具的完整描述。
开发环境
可以在以下针对Fedora和Ubuntu的分步指南中找到建立BPF开发环境的指南。这将指导您构建,安装和测试开发内核以及构建和安装iproute2。
考虑到默认情况下主要发行版本已经预装了足够多的内核,通常不需要手动构建iproute2和Linux内核的步骤,但是分别用于测试最新版本或为IProute2和Linux内核贡献BPF补丁将是必需的。同样,出于调试和自省的目的,构建bpftool是可选的,但建议这样做。
软呢帽
以下内容适用于Fedora 25或更高版本:
$ sudo dnf install -y git gcc ncurses-devel elfutils-libelf-devel bc \
openssl-devel libcap-devel clang llvm graphviz bison flex glibc-static
如果您运行的是其他Fedora派生工具,但
dnf
缺少它,请尝试使用yum
代替。
的Ubuntu
以下内容适用于Ubuntu 17.04或更高版本:
$ sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev \
clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex \
graphviz
openSUSE风滚草
以下内容适用于openSUSE Tumbleweed和openSUSE Leap 15.0或更高版本:
$ sudo zypper install -y git gcc ncurses-devel libelf-devel bc libopenssl-devel \
libcap-devel clang llvm graphviz bison flex glibc-devel-static
编译内核
为Linux内核开发新的BPF功能发生在net-next
git树中,树中最新的BPF修复程序net
。以下命令将net-next
通过git获取树的内核源代码:
git clone git://git.kernel.org/pub/scm/linux/kernel/git/davem/net-next.git
如果不关心git提交历史记录,则将git历史记录仅截断为最近的提交,将更快地克隆树。--depth 1
如果net
树是您感兴趣的树,可以从以下URL克隆它:
git clone git://git.kernel.org/pub/scm/linux/kernel/git/davem/net.git
互联网上有许多关于如何构建Linux内核的教程,其中一个很好的资源是Kernel Newbies网站(https://kernelnewbies.org/KernelBuild),上面提到的两个git树之一可以跟在它后面。
确保生成的.config
文件包含以下CONFIG_*
用于运行BPF的条目。Cilium也需要这些条目。
CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_SCH_INGRESS=m
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y
CONFIG_BPF_JIT=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_TEST_BPF=m
有些条目无法通过调整。例如,如果给定的体系结构确实带有eBPF JIT , 则会自动选择。在这种情况下,它是可选的,但强烈建议使用。没有eBPF JIT编译器的体系结构将需要依赖内核解释器,其代价是执行BPF指令的效率较低。make menuconfig
CONFIG_HAVE_EBPF_JIT
CONFIG_HAVE_EBPF_JIT
验证设置
引导到新编译的内核后,导航到BPF自测套件以测试BPF功能(当前工作目录指向克隆的git树的根):
$ cd tools/testing/selftests/bpf/
$ make
$ sudo ./test_verifier
验证程序测试将打印出当前正在执行的所有检查。运行所有测试结束时的摘要将转储测试成功和失败的信息:
Summary: 847 PASSED, 0 SKIPPED, 0 FAILED
对于4.16+内核版本,BPF自测具有LLVM 6.0+的依赖性,这是由BPF函数调用引起的,不再需要内联。有关更多信息,请参阅“ BPF到BPF调用”部分或内核补丁(https://lwn.net/Articles/741773/)中的求职信。如果不是每个BPF程序都不使用LLVM 6.0+,则它不会使用该新功能。如果您的发行版不提供LLVM 6.0+,则可以按照LLVM 部分中的说明进行编译。
为了运行所有BPF自检,需要以下命令:
$ sudo make run_tests
如果发现任何故障,请通过Slack与我们联系并提供完整的测试输出。
编译iproute2
类似于net
(仅修复)和net-next
(新功能)内核树,iproute2 git树具有两个分支,分别是master
和net-next
。该 master
分支是基于net
树的net-next
分支是基于对net-next
内核树。这是必需的,以便可以在iproute2树中同步头文件中的更改。
为了克隆iproute2master
分支,可以使用以下命令:
git clone git://git.kernel.org/pub/scm/linux/kernel/git/iproute2/iproute2.git
同样,要克隆到net-next
iproute2的上述分支中,请运行以下命令:
git clone -b net-next git://git.kernel.org/pub/scm/linux/kernel/git/iproute2/iproute2.git
之后,继续进行构建和安装:
$ cd iproute2/
$ ./configure --prefix=/usr
TC schedulers
ATM no
libc has setns: yes
SELinux support: yes
ELF support: yes
libmnl support: no
Berkeley DB: no
docs: latex: no
WARNING: no docs can be built from LaTeX files
sgml2html: no
WARNING: no HTML docs can be built from SGML
$ make
[...]
$ sudo make install
确保configure
脚本显示,以便iproute2可以处理LLVM的BPF后端的ELF文件。在较早的Fedora和Ubuntu情况下,安装依赖项的说明中列出了libelf。ELF support: yes
编译bpftool
bpftool是围绕BPF程序和映射的调试和自省的基本工具。它是内核树的一部分,可在下找到tools/bpf/bpftool/
。
确保已如前所述克隆net
或net-next
内核树。为了构建和安装bpftool,需要执行以下步骤:
$ cd <kernel-tree>/tools/bpf/bpftool/
$ make
Auto-detecting system features:
... libbfd: [ on ]
... disassembler-four-args: [ OFF ]
CC xlated_dumper.o
CC prog.o
CC common.o
CC cgroup.o
CC main.o
CC json_writer.o
CC cfg.o
CC map.o
CC jit_disasm.o
CC disasm.o
make[1]: Entering directory '/home/foo/trees/net/tools/lib/bpf'
Auto-detecting system features:
... libelf: [ on ]
... bpf: [ on ]
CC libbpf.o
CC bpf.o
CC nlattr.o
LD libbpf-in.o
LINK libbpf.a
make[1]: Leaving directory '/home/foo/trees/bpf/tools/lib/bpf'
LINK bpftool
$ sudo make install
虚拟机
LLVM是当前唯一提供BPF后端的编译器套件。gcc目前不支持BPF。
BPF后端已合并到LLVM的3.7版本中。主要发行版在打包LLVM时默认情况下会启用BPF后端,因此在最新发行版中安装clang和llvm就足以开始将C编译为BPF目标文件。
典型的工作流程是BPF程序用C编写,由LLVM编译成对象/ ELF文件,这些文件由用户空间BPF ELF加载程序(例如iproute2或其他)解析,然后通过BPF系统调用推入内核。内核会验证BPF指令并对其进行JIT,从而为程序返回一个新的文件描述符,然后可以将该文件描述符附加到子系统(例如,网络)。如果支持,则子系统可以进一步将BPF程序卸载到硬件(例如NIC)。
对于LLVM,可以例如通过以下方法检查BPF目标支持:
$ llc --version
LLVM (http://llvm.org/):
LLVM version 3.8.1
Optimized build.
Default target: x86_64-unknown-linux-gnu
Host CPU: skylake
Registered Targets:
[...]
bpf - BPF (host endian)
bpfeb - BPF (big endian)
bpfel - BPF (little endian)
[...]
默认情况下,bpf
目标使用编译时所用的CPU的字节序,这意味着,如果CPU的字节序为little endian,则程序也将以little endian格式表示;如果CPU的字节序为big endian,则程序将以以下格式表示:大端。这也与BPF的运行时行为相匹配,后者是通用的,并使用其运行的CPU的字节顺序,以便不损害任何格式的体系结构。
对于交叉编译,这两个目标bpfeb
,并bpfel
进行了介绍,这要归功于BPF程序可以在一个字节顺序运行的节点上编译(例如,在x86小端),并在另一端标记格式的节点上运行(例如,在手臂大端) 。请注意,前端(clang)也需要以目标字节序运行。
bpf
在不应用字节序混合的情况下,将其用作目标是首选方法。例如,对目标x86_64
结果的编译会产生与目标相同的输出,bpf
并且bpfel
由于字节序少,因此触发编译的脚本也不必知道字节序。
一个最小的独立XDP投递程序可能类似于以下示例(xdp-example.c
):
#include <linux/bpf.h>
#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif
__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return XDP_DROP;
}
char __license[] __section("license") = "GPL";
然后可以将其编译并加载到内核中,如下所示:
$ clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o
# ip link set dev em1 xdp obj xdp-example.o
如上所述,将XDP BPF程序附加到网络设备时,要求Linux 4.11和支持XDP的设备或Linux 4.12或更高版本。
对于生成的目标文件,LLVM(> = 3.9)使用官方的BPF机器值,即EM_BPF
(decimal:247
/ hex:)0xf7
。在此示例中,该程序已使用bpf
下的target进行编译x86_64
,因此显示了有关字节顺序的信息LSB
(与相对MSB
):
$ file xdp-example.o
xdp-example.o: ELF 64-bit LSB relocatable, *unknown arch 0xf7* version 1 (SYSV), not stripped
readelf -a xdp-example.o
将转储有关ELF文件的更多信息,有时这对于自省生成的节标题,重定位条目和符号表很有用。
在不太可能需要重新编译clang和LLVM的情况下,可以使用以下命令:
$ git clone http://llvm.org/git/llvm.git
$ cd llvm/tools
$ git clone --depth 1 http://llvm.org/git/clang.git
$ cd ..; mkdir build; cd build
$ cmake .. -DLLVM_TARGETS_TO_BUILD="BPF;X86" -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DLLVM_BUILD_RUNTIME=OFF
$ make -j $(getconf _NPROCESSORS_ONLN)
$ ./bin/llc --version
LLVM (http://llvm.org/):
LLVM version x.y.zsvn
Optimized build.
Default target: x86_64-unknown-linux-gnu
Host CPU: skylake
Registered Targets:
bpf - BPF (host endian)
bpfeb - BPF (big endian)
bpfel - BPF (little endian)
x86 - 32-bit X86: Pentium-Pro and above
x86-64 - 64-bit X86: EM64T and AMD64
$ export PATH=$PWD/bin:$PATH # add to ~/.bashrc
确保--version
提及,否则将LLVM置于调试模式时,程序的编译时间将大大增加(例如,增加10倍或更多)。Optimized build.
为了进行调试,clang可以生成汇编程序输出,如下所示:
$ clang -O2 -S -Wall -target bpf -c xdp-example.c -o xdp-example.S
$ cat xdp-example.S
.text
.section prog,"ax",@progbits
.globl xdp_drop
.p2align 3
xdp_drop: # @xdp_drop
# BB#0:
r0 = 1
exit
.section license,"aw",@progbits
.globl __license # @__license
__license:
.asciz "GPL"
从LLVM 6.0版开始,还提供了汇编器解析器支持。您可以直接使用BPF汇编器进行编程,然后使用llvm-mc将其汇编为目标文件。例如,您可以使用以下命令将上面列出的xdp-example.S组合回到目标文件中:
llvm-mc -triple bpf -filetype=obj -o xdp-example.o xdp-example.S
此外,最新的LLVM版本(> = 4.0)也可以将矮化格式的调试信息存储到目标文件中。可以通过添加-g
编译来通过通常的工作流程来完成。
$ clang -O2 -g -Wall -target bpf -c xdp-example.c -o xdp-example.o
$ llvm-objdump -S -no-show-raw-insn xdp-example.o
xdp-example.o: file format ELF64-BPF
Disassembly of section prog:
xdp_drop:
; {
0: r0 = 1
; return XDP_DROP;
1: exit
llvm-objdump
然后,该工具可以使用在编译中使用的原始C代码对汇编器输出进行注释。在这种情况下,这个简单的示例不包含太多C代码,但是,行号显示为0:
,1:
直接对应于内核的验证程序日志。
这意味着,如果BPF程序被验证者拒绝,则llvm-objdump
可以帮助将指令与原始C代码相关联,这对于分析非常有用。
# ip link set dev em1 xdp obj xdp-example.o verb
Prog section 'prog' loaded (5)!
- Type: 6
- Instructions: 2 (0 over limit)
- License: GPL
Verifier analysis:
0: (b7) r0 = 1
1: (95) exit
processed 2 insns
从验证程序分析中可以看出,llvm-objdump
输出转储与内核相同的BPF汇编程序代码。
省略该-no-show-raw-insn
选项还会将原始文件以十六进制形式转储 到程序集的前面:struct bpf_insn
$ llvm-objdump -S xdp-example.o
xdp-example.o: file format ELF64-BPF
Disassembly of section prog:
xdp_drop:
; {
0: b7 00 00 00 01 00 00 00 r0 = 1
; return foo();
1: 95 00 00 00 00 00 00 00 exit
对于LLVM IR调试,BPF的编译过程可以分为两个步骤,生成一个二进制LLVM IR中间文件xdp-example.bc
,以后可以将其传递给llc:
$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -filetype=obj -o xdp-example.o
生成的LLVM IR也可以通过以下方式以人类可读的格式转储:
clang -O2 -Wall -emit-llvm -S -c xdp-example.c -o -
LLVM能够将调试信息(例如程序中使用的数据类型的描述)附加到生成的BPF目标文件中。默认情况下,它是DWARF格式。
BPF使用的高度简化的版本称为BTF(BPF类型格式)。可以将生成的DWARF转换为BTF,然后再通过BPF对象加载程序将其加载到内核中。然后,内核将验证BTF数据的正确性,并跟踪BTF数据包含的数据类型。
然后,可以使用BTF数据中的键和值类型对BPF映射进行注释,以便以后的映射转储将映射数据与相关的类型信息一起导出。这样可以进行更好的自省,调试和有价值的打印。请注意,BTF数据是一种通用的调试数据格式,因此,可以加载任何DWARF到BTF转换的数据(例如,可以将内核的vmlinux DWARF数据转换为BTF并进行加载)。后一种情况对于将来的BPF跟踪特别有用。
为了从DWARF调试信息生成BTF,需要使用elfutils(> = 0.173)。如果不可用,则在编译期间需要将-mattr=dwarfris
选项添加到llc
命令中:
$ llc -march=bpf -mattr=help |& grep dwarfris
dwarfris - Disable MCAsmInfo DwarfUsesRelocationsAcrossSections.
[...]
使用的原因-mattr=dwarfris
是因为标志dwarfris
()禁用了DWARF和ELF的符号表之间的DWARF横截面重定位,因为libdw没有适当的BPF重定位支持,因此类似的工具否则将无法从对象正确转储结构。dwarf relocation in section
pahole
elfutils(> = 0.173)实现了正确的BPF重定位支持,因此无需-mattr=dwarfris
选择即可实现相同的目的。从DWARF或BTF信息中可以完成从目标文件中转储结构的操作。pahole
此时使用LLVM发出的DWARF信息,但是,将来的pahole
版本可能会依赖BTF(如果可用)。
要将DWARF转换为BTF,需要使用最新的pahole版本(> = 1.12)。如果不能从以下分发软件包之一获得最新的pahole版本,则可以从其官方git存储库中获得:
git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
pahole
带有-J
将DWARF从目标文件转换为BTF的选项。pahole
可以按以下方式探查BTF支持(请注意,该llvm-objcopy
工具也是必需的pahole
,因此也请检查其是否存在):
$ pahole --help | grep BTF
-J, --btf_encode Encode as BTF
生成调试信息还需要前端通过传递-g
给clang
命令行来生成源级别的调试信息。需要注意的是-g
独立的是否需要llc
的 dwarfris
选项时使用。生成目标文件的完整示例:
$ clang -O2 -g -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -mattr=dwarfris -filetype=obj -o xdp-example.o
或者,通过仅使用clang来构建具有调试信息的BPF程序(同样,具有适当的elfutils版本时,可以省略dwarfris标志):
clang -target bpf -O2 -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o
成功编译后,pahole
可用于基于DWARF信息正确转储BPF程序的结构:
$ pahole xdp-example.o
struct xdp_md {
__u32 data; /* 0 4 */
__u32 data_end; /* 4 4 */
__u32 data_meta; /* 8 4 */
/* size: 12, cachelines: 1, members: 3 */
/* last cacheline: 12 bytes */
};
通过该选项-J
pahole
最终可以从DWARF生成BTF。在目标文件中,DWARF数据仍将与新添加的BTF数据一起保留。完全clang
和pahole
合并例如:
$ clang -target bpf -O2 -Wall -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o
$ pahole -J xdp-example.o
.BTF
可以通过readelf
工具查看部分的存在:
$ readelf -a xdp-example.o
[...]
[18] .BTF PROGBITS 0000000000000000 00000671
[...]
更多内容请参见:https://docs.cilium.io/en/v1.6/bpf/
《深入理解 Cilium 的 eBPF(XDP)收发包路径:数据包在Linux网络协议栈中的路径》
《eBPF.io eBPF文档:扩展的数据包过滤器(BPF)》
《介绍Calico eBPF数据平面:Linux内核网络、安全性和跟踪(Kubernetes、kube-proxy)》
《Linux eBPF和XDP高速处理数据包;使用EBPF编写XDP网络过滤器;高性能ACL》
《Understanding (and Troubleshooting) the eBPF Datapath in Cilium》
《kubernetes(K8s):管理云平台中多个主机上的容器化的应用》