当你真正认识一门科学时,你才会感受到它的魅力。
eBPF技术已经不再小众。从Linux3.18的初次亮相,现在的生态算得上是“内核关联技术”里的翘楚。其中代表性的有BCC、libbpf、cilium、Katran等等,被广泛用于解决不同的问题。
虽然用起来不难,但想系统性的掌握却并非件易事。其中,Cilium官方文档中的BPF and XDP Reference Guide是一份好资料。于是,就有了译文的想法,愿一同在技术层面再认识eBPF。
转译过程中有参考其他资料,亦有基于个人认知补充的背景知识。主旨是为了更好地介绍BPF和XDP,亦可以帮助快速上手Cilium。
BPF 是 Linux 内核中一个灵活且高效的类虚拟机组件, 能够在许多内核 hook 点安全地执行字节码(这个特点基于cBPF)。很多内核的子系统都使用BPF,例如:最常见的网络、跟踪与安全(例如沙盒)。
(介绍下cBPF)BPF 在 1992 年就出现了,但本文介绍的是eBPF。eBPF 最早出现在 3.18 内核中,所以原来的BPF就被称为 “经典” BPF(classic BPF, cBPF),现在cBPF 技术大部分已经过时。多数人了解cBPF是因为它是tcpdump的包过滤语言(tcpdump虽然利用cBPF,只存储过滤后的数据,但数据依然要存储在buffer和storage两次,相比于eBPF,这显然并不高效!)。如今,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行。如无特殊说明,本文所说的 BPF 均是泛指 BPF 技术。
虽然“伯克利包过滤器”这个名字听起来像是专用于数据包过滤的,但发展到现在,这个指令集已经变得通用和灵活(不仅局限于过滤目的了),现在 BPF 也有很多除网络外的使用案例,可以阅读更多使用eBPF的项目 Further Reading。
Cilium在数据面广泛应用BPF技术,参照eBPF Datapath看更多的信息。文章的主旨是提供一份BPF参考指南,能够帮助更好的理解BPF,对于网络方向的案例有导入BPF程序到tc(traffic control)和XDP(eXpress Data Path),和帮助开发cilium的BPF模版。
BPF的定义不仅是指令集,它还提供了围绕自身的一些基础设施,例如:maps,其有着有效的键值对;helper functions,最大化利用kernel的能力和与内核交互;tail call,在一个BPF程序结束后调用另一个BPF程序;security hardening primitives,安全原子性;伪文件系统,用于pin/unpin内核对象(map、prog),这实现了持久存储,常见于/sys/fs/bpf路径;还有也支持了BPF的基础设施,如网卡。(BPF技术被认可)
BPF的后端是LLVM,所以像clang这类编译器工具常被用于由C编译到BPF对象文件,然后加载到内核。BPF和kernel深度耦合,kernel允许其在不伤害原本内核性能的前提下,实现对内核的完全可编程。
此外,可以认为kerenl中使用了BPF技术的子系统也是BPF基础设施的一部分。本文会讨论两个主要加载BPF程序的子系统,tc和xdp(其中,BPF能加载的子系统大致有kprobes、uporbes、tracepoints、perf、cgroup、tc、lwt、xdp等)。XDP BPF 程序会被 attach 到网络驱动阶段(主机端收包最早的阶段),一般驱动收到包之后就会触发 BPF 程序的执行。从定义而言,此时是最好的包处理时期,因为这是软件层面最早处理包的地方。但XDP处理发生在网络栈的最早阶段,协议栈此时还没有参与从数据包提取元数据的操作,所以XDP获取不到很多的元数据(重要的信息);tc BPF程序在XDP后一些执行,所以接触到更多的元数据和内核核心功能。
除了tc和xdp程序外,对于使用BPF的内核子系统还有很多。例如跟踪子系统(kprobes、uprobes、tracepoints等等)。
下面的内容将更细节的介绍BPF结构的各个部分。
BPF 是一个通用目的RISC指令集。 最初的设计目的是其应该是C语言的一个子集编写程序,可以用编译器后端(LLVM)编译成BPF指令(BPF bytecode),并且可以利用BPF类虚拟机的即时编译器将BPF指令映射成处理器上的原生指令(BPF Object),以获取内核中的最佳执行性能。
C(用户层代码)==== =》BPF bytecode(通用BPF指令集)==== =》BPF Object(处理器上的BPF原生指令)
指令集推送到kernel有这些好处:
内核BPF程序的执行是事件驱动的。这很重要!!!
BPF一般由11个“包含32位子寄存器”的64位寄存器、程序计数器和512字节的BPF栈大小组成。寄存器对应从r1到r10。默认是64位运行模式,32位子寄存器仅能通过特殊的ALU(算术逻辑单元)访问。向32 位子寄存器写入时,会用0填充满64位。
r10 是唯一的只读寄存器,其中存放的是访问 BPF 栈空间的帧指针(frame pointer) 地址(这意味着BPF程序在内核里是存放在专门的栈空间的,暴露指针让用户层能访问)。r0 - r9 是可以被读/写的通用目的寄存器。
BPF程序能调用内核提供的预定义辅助函数,而不是内核模块的。BPF程序调用约定是这样定义的:
BPF 调用约定是通用的,能直接映射到 x86_64、arm64 和其他 ABIs,因此所有的BPF寄存器可以一对一映射到硬件CPU寄存器,如此JIT只需要发出一条调用指令,而不需要额外的放置函数参数动作。这套约定在不牺牲性能的前提下,考虑了尽可能通用的调用场景。目前还不支持 6 个及以上参数的函数调用,内核与BPF相关的辅助函数也特意设计成BPF(从BPF_CALL_0()到BPF_CALL_5()函数)与BPF调用约定相匹配。(BPF_CALL_x是提供给BPF prog的内核辅助函数映射到真正内核定义函数的通道。其中第一个参数是辅助函数地址,后面的是传递的参数。)
//被调用函数
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,
};
上面的代码,反映了bpf_map_update_elem
是内核提供给用户空间的辅助函数,BPF_CALL_4
是内核映射的辅助函数,map->ops->map_update_elem
是内核真正执行的定义函数。
寄存器r0还用于保存 BPF 程序的退出值(常用于判断辅助函数执行成功或否)。退出值的语义由程序类型决定。另外,当将执行权交回内核时,退出值是以 32 位传递的。
寄存器r1-r5是 scratch registers,意思是说,若有多个辅助函数调用重用这些参数(这些参数就是BPF程序传递给辅助函数的实参),那么BPF程序需要将这些值转储到BPF栈上,或是移动到被调用方保存的寄存器上(r6-r9)。
Spilling(倒出/转储) 的意思是这些寄存器内的变量被移到了 BPF 栈中。相反,将变量从 BPF 栈移回寄存器,称为 filling(填充)。spilling/filling 的原因是寄存器数量有限。
原因:寄存器存储有限;目的:供多个BPF辅助函数复用
BPF 程序开始执行时,寄存器r1最初存放的是程序的上下文。上下文就是程序的输入参数(和典型 C 程序的 argc/argv 类似)。BPF 只能在单个上下文中工作。这个上下文是由程序类型定义的, 例如,网络程序可以将网络包的内核表示(skb)作为输入参数。
BPF 的通用操作都是 64 位的,这和默认的 64 位架构模型相匹配,这样设计的目的是可以对指针进行算术操作、传递指针和64位值到辅助函数和支持64位原子操作。
BPF 程序的最大指令数限制在 4096 条以内,这意味着从设计上就可以保证每个程序都将会很快结束。对于内核5.1以上的,这个限制被放大到1百万条BPF指令。尽管指令集包含前向和尾向跳跃,但内核的BPF验证器依旧会禁止循环,因为这样终端总会被保证。BPF程序是运行在内核里的,验证器的工作就是确保这些程序是安全地运行,不会影响系统的稳定。这也就意味着,从指令集的角度来说循环是可以实现的,但验证器将限制它(循环)。然后,BPF中有尾调用这个概念,允许一个BPF程序调用另一个BPF程序,但这种调用也有限制,上限是33层调用,和这个能力经常用来解耦程序的逻辑,例如,解偶成多个不同的阶段。
BPF 指令格式建模为两操作数指令(two operand instructions),这种格式将在JIT阶段将BPF指令映射成寄存器的原生指令。指令集的长度是固定的,这也意味着每条指令都是64位编码,如今,已实现了87条指令,并且在需要时编码允许扩展更多的指令集。一条64位指令的编码在大端序上是这样定义的,顺序从MSB到LSB:op:8, dst_reg:4, src_reg:4, off:16, imm:32,off和imm都是signed类型。这个编码是内核头文件的一部分,被定义在linux/bpf.h头文件,linux/bpf.h包含linux/bpf_common.h。
所以任何一个BPF程序必须包含linux/bpf.h和linux/bpf_common.h文件
op 定义了将要执行的实际操作。 大部分op的编码是复用了cBPF。操作基于寄存器或者是立即操作数(immediate operands)。op 自身的编码提供了使用什么模式的信息(BPF_X
指是基于寄存器的操作,BPF_K指是基于立即操作数)。对于后者而言,目的操作数总是一个寄存器。dst_reg和src_reg都提供了操作时(op)会使用的寄存器操作数的额外信息(e.g.r0-r9)。 off经常被用于一些指令来提供相对偏移量(realtive offset), 例如,寻址对BPF有用的栈空间和缓冲区(如:读map值,包数据,等等),或者在跳转指令上跳转到目标。imm则是一个常量/立即值(immediate)。
目的是为了执行实际操作
所有的 op 指令可以被分类成若干指令类别。这些类别信息也被编码到了 op 字段。op 字段分为( 从 MSB 到 LSB):code:4, source:1 和 class:3。class是通用的指令类型,code是指定类型的某些特定操作码,和source告诉源操作数是一个寄存器还是一个立即数。 指令类别分类(class包含):
BPF_LD
, BPF_LDX
:两者都是加载操作imm:32
分离导致的跨越两个指令的特殊指令,和byte/half_word/word长度的包数据。后者是主要从cBPF延续而来的,为了保证cBPF到BPF翻译的高效,因为它们有优化的JIT代码。对于原生的BPF,这些包载入指令已经用的很少了。BPF_LDX类的指令用于从内存中加载byte / half-word / word / double-word,这里的内存是泛指的,可以是栈内存,map值,包数据等等。BPF_ST
,BPF_STX
:两者均是存储操作可见,BPF_LDX和BPF_STX是相反的,是在内存和寄存器间的op操作
BPF_ALU, BPF_ALU64:两者都包含逻辑运算操作
通常来讲,BPF_ALU操作是32位,BPF_ALU是64位模式。两个ALU类都有相同的操作在源操作数,无论是基于寄存器或是立即值的部分。两个都支持加,减,与,或,左偏移,右偏移,异或,解地址,除,余,反操作。甚至两个操作数模式为两个类添加一个特殊的ALU操作,mov(:=),BPF_ALU64 包含一个signed右偏移,BPF_ALU此外包含half-word / word / double-word 类型的端序的转换。
BPF_JMP:该类归属于跳转操作
跳转可以是没有条件的和有条件的。无条件跳转是简单的向前移动程序计数器,这样相比于当前指令,下一个指令(off+1)将被执行,这里的off是指令编程的常量偏移数。因为off是有符号的,只要跳转不是依赖程序循环且在程序范围内,跳转操作也支持向后(off-1)。有条件的跳转操作可以在基于寄存器和基于立即值的源操作数上工作。如果跳转操作的条件结果是true,相对应的跳转off+1,否则就是下一条指令。相比于cBPF,这种直通的跳转逻辑是不同的,允许更好的分支预测,因此天然更适合CPU分支预测器逻辑。有用的条件逻辑有 jeq (==), jne (!=), jgt (>), jge (>=), jsgt (signed >), jsge (signed >=), jlt (<), jle (<=), jslt (signed <), jsle (signed <=) and jset (jump if DST & SRC).
跳转操作是不是和条件运算符很相像
除此之外,还有三个特殊的跳转操作符,退出指令将离开B PF程序和返回r0存放的当前值作为返回code,调用指令,将发布一个函数调用到一个有用的BPF辅助函数,还有一个隐藏尾调用指令,将直接跳转到不同的BPF程序。
Linux 内核中内置了一个 BPF 解释器,解释器将执行用指令汇编的程序。即便是cBPF程序,也可以在内核内透明的转换成eBPF 程序,除非架构仍然采用的是cBPF JIT,还未迁移到eBPF JIT。
目前下列架构都内置了内核 eBPF JIT 编译器:x86_64、arm64、ppc64、s390x 、mips64、sparc64 和 arm。
所有的 BPF 句柄,例如加载程序到内核,又或者创建 BPF map, 都是通过核心的 bpf() 系统调用完成的。它还用于管理map实体(查找/更新/删除),以及通过 pinning 将程序和 map 持久化到 BPF 文件系统。
辅助函数使得 BPF 程序能够通过一组内核定义的函数调用来从内核中查询数据,或者将数据推送到内核(内核提供给BPF程序,来跟内核交互的能力)。 每一种BPF程序类型的辅助函数都不太相同,例如:相比较于attach到tc层的BPF程序,attach到socket的BPF程序能用到的辅助函数只是前者可用的辅助函数的一个子集。对于轻量级隧道类别的程序,使用的封装和解封装辅助函数,只是在更低的tc层是有用的,然而推送通知到用户态的事件输出辅助函数,是既可以被tc程序使用又可以被XDP程序使用的。
所有的辅助函数都共享同一个通用的、和系统调用类似的函数签名。 签名定义如下:
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
前一节介绍的调用约定适用于所有的 BPF 辅助函数。
内核将辅助函数抽象成宏BPF_CALL_0()
到BPF_CALL_5()
,这和系统调用很相像。下面的例子是是从某个辅助函数中抽取出来的,可以看出它通过调用相应map的回调函数完成更新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允许其加载指令进行超出范围的访问,只是为了从一个不太可能的包偏移量位置获取数据来唤醒辅助函数,但这需要每一个cBPF JIT的支持才能完成这种cBPF扩展。eBPF,每一个新的辅助函数都会被eBPF JIT用一种透明且高效的形式编译,这意味着 JIT 编译器只需要发出一条调用指令,因为寄存器映射的方式是采用已经和底层架构的调用约定相匹配的BPF寄存器排列方式。这使得很容易扩展核心内核和新的辅助函数。可以说,所有的BPF辅助函数都是核心内核的一部分,这也相当于说辅助函数不是内核模块扩展和添加的。
上面提及的函数签名也允许验证器执行类型检查,struct bpf_func_proto
常用于存放验证器必需知道的所有有关该辅助函数的信息(钩子的作用),这样验证器能保证辅助函数预期的类型匹配上BPF程序分析寄存器上的当前内容。
参数类型定义含广泛,从任意类型的值,乃至限制的特定类型,如BPF栈缓冲区的pointer/size对类型,辅助函数可以从该缓冲区读和写。对于后面这种情况,验证器还能处理额外的检查,例如,这个缓冲区是否已经提前初始化过了。
对于辅助函数的参数需要认真对待,防止验证器验证不通过
有用的BPF辅助函数列表很长且一直在增长,例如,写本文时,tc BPF程序能有38种不同的BPF辅助函数的选择。内核struct bpf_verifier_ops
包含一个回调函数get_func_proto
,它提供了从某个特定的enum bpf_func_id
到给定的BPF程序类型一个有效辅助函数的映射。
Maps是存储在内核态有效的key/value仓库。 maps能被BPF程序访问,为了在多个BPF程序保持状态,可以把状态信息放到map(这个不会译)。map也能透过文件描述符暴露给用户态,这使得可以在任意的BPF程序和用户空间应用间共享maps。
共享maps的BPF程序不需要是相同的程序类型,例如跟踪程序能共享maps给网络程序。一个BPF程序目前能直接访问64种不同的maps。
Maps的能力是核心内核提供的。 有 per-CPU 及 non-per-CPU 的通用 map可以读/写任意的数据,但这也有一些非通用的maps,它们主要被辅助函数所使用。
通用的maps目前有: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辅助函数来执行查找、更新和删除操作,但各自执行不同的后端,有着不同的语义和性能特点。
目前内核支持的不通用maps
有: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程序的数组map,BPF_MAP_TYPE_ARRAY_OF_MAPS
和 BPF_MAP_TYPE_HASH_OF_MAPS
都是存放只想其他maps的指针(有点像指针数组),这样整个BPF maps能在运行期间被原子替代。这类maps因为针对特殊的问题,不是很适合通过一个BPF辅助函数就能实现的,因为在不同的BPF程序间需要额外的(非数据)状态。
通用和非通用maps的区别是不同的BPF程序间共享的是否是通用的数据。
BPF maps和prog作为一种内核资源,只能通过文件描述符的形式被访问,其背后是内核中的匿名inode。 这有优势,也有一些劣势:
用户层应用能通过APIs使用大部分文件描述符,文件描述符在Unix sockets中能透明的传递等。但与此同时,文件描述符受限于进程的生命周期,这使得map共享这类的选项变得相当的笨重。
因此,这给某些特定的场景带来了很多复杂性,例如iproute2,tc或xdp设置,加载程序到内核,最终会退出。在这种情况下,用户层是没有办法再访问maps的,尽管它可能是有用的。例如:在数据传输路径下ingress和egress位置的maps应该是共享的(某一个程序退出可能导致关闭maps,即便另一个还有用),还有,第三方应用希望在BPF程序运行期间监控和更新map的内容。
为了克服这个限制,内核层有实现了一个最小的BPF文件系统,它允许BPF map和程序的pin,这个行为称之为钉住对象。BPF系统调用因为也被扩展成两个命令,分别是钉住BPF_OBJ_PIN
和获取BPF_OBJ_GET
前面钉住的对象。
例如,工具如tc就利用这个基础设施在 ingress 和 egress 之间共享 map。BPF关联的文件系统不是一个单例,它也支持多个挂载实例,硬连接和软链接等。
另一个被BPF使用的概念是尾调用。尾调用可以被视为一种允许一个BPF程序调用另一个BPF程序的机制,并且在调用完成后不再返回原来的程序。 这类调用跟普通的函数调用不同,它的调用开销最小,它作为“长跳转”实现,复用了相同的栈帧。
这类程序都是独立验证的,因此要传递状态,要么使用per-CPU maps作为暂存缓冲区,要么是tc程序,可以使用skb的某些字段(例如cb[])。
只有类型相同的BPF程序才可以尾调用,而且它们还需要匹配JIT编译器,因此一个给定的BPF程序要么是JIT编译执行,要么是解释器执行,但不能混用这两种方式。
执行尾调用涉及两个步骤:第一部分需要设置一个特殊的map,该map称为程序数组(BPF_MAP_TYPE_PROG_ARRAY
),这个map是从用户空间通过健/值操作的,这里的值是尾调用BPF程序的文件描述符;第二部分是 bpf_tail_call
辅助函数,对应上下文(两个参数),一个是程序数组的引用,一个是查找的key。内核将这个辅助函数调用内联到一个特殊的BPF指令内。目前,这类程序数组在用户空间只支持写操作。
内核根据传入的文件描述符查找相关的 BPF 程序,并自动替换给定map槽的程序指针。若根据提供的键没有办法找到map实体,内核将跳过和继续执行bpf_tail_call
后面的指令。尾调用是一个强大的工具,例如:通过尾调用可以结构化的解析网络头。运行期间,可以原子性地添加和替换功能,因此改变BPF程序的执行行为。
本质上BPF尾调用是通过一种特殊的map表和一个辅助函数实现的“长跳转”。
除了BPF辅助函数调用和BPF尾调用,BPF核心基础设施最近新加入一个新特性,从BPF到BPF的调用。在这个特性引入内核以前,典型的BPF C程序不得不声明所有的重复使用代码,例如,在头文件中声明always_inline
,如此,在LLVM编译和生成BPF对象时,关联所有这样的函数为内联函数,因此在生成的结果对象文件里函数有重复多次,人为的膨胀代码量。
所有的重复使用代码全部要声明为内联函数。(但这是个鸡肋,毕竟已经声明过了函数,为什么还有再声明成内联函数呢)
include
ifndef __section
define __section(NAME) \
__attribute__((section(NAME), used))
endif
ifndef __inline
define __inline \
inline __attribute__((always_inline))
endif
//因为foo被xdp_drop程序调用了,所以需要声明为内联
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中都是如此。(重要节点)从Linux4.16和LLVM6.0开始, 这个限制被移除了,BPF程序不在需要到处使用always_inline
,因此,之前的BPF样例代码可以被自然的重写成下面的样子:
#inlcude
#ifndef __section
#define __section(NAME) \
__attribute__((section(NAME),used))
#endif
static int foo(void)
{
return XDP_DROP;
}
__section("porg")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}
char __license[] __section("license") ="GPL";
主流的x86_64和arm64 BPF JIT编译器已经支持BPF到BPF的调用(支持自然的函数调用),其他的计算机结构也将在不久跟上。BPF到BPF的调用是一个重要的性能优化,因为它极大减少了生成的BPF代码量和变得对CPU指令缓存友好的。
BPF辅助函数的调用约定也适用于BPF到BPF的调用,意味着r1到r5是传输参数到被调用方,结果通过r0返回。r1到r5是暂存寄存器,而r6到r9和往常一样是保留寄存器。最大嵌套调用深度是 8。 一个调用方可以传递至真给被调用方,但反之不可行。
内核 5.9 版本之前, BPF 尾调用和 BPF-to-BPF 调用是互斥的,只能二选一。 尾调用的缺点是生成的程序镜像大、加载时间长。内核 5.10(另一个重要节点) 最终解决了这一问题,允许同时使用两种调用类型,充分利用二者各自的优点。
尽管,这种提升也有一些限制。混用这两个特征可能会引起内核栈溢出。为了观察什么可能发生,看下面的图片解释bpf2bpf调用和尾调用混用的结果。
注意看!bpf2bpf的调用是call ,bpf尾调用是jmpq
尾调用,在实际跳转到目标程序之前,都将仅展开它当前的栈帧。正如我们看见的上面的例子,如果一个尾调用出现在子函数,当func2执行时,fun1的栈帧将会在栈上呈现出来。一旦最终函数func3执行结束,所有之前的栈帧才将被展开并将控制返回给BPF程序调用的调用者。(导致内存溢出)
内核引入额外的逻辑检测这个特征联合(尾调和bpf2bpf的混用)。完整的调用链,栈大小是有限制的,每个子程序大小低于256字节(注意如果验证器检测到bpf2bpf调用,主函数也会被认为是一个子函数)。总体算起来,根据这个限制,BPF程序的调用链最多能消费8KB的栈空间。这个限制是每个栈256字节乘上尾调用的计数限制(33)得到的。没有这个,BPF程序将在512字节的栈大小上操作,最终可能最多消费16KB的总栈空间,这在某些架构上会导致栈溢出。
尾调用是跳转,bpf2bpf是嵌套。两者都有计数上限,一个是33,一个是8。两者可以混用,但是每个子函数只能使用256字节,总体字节不得超过8KB。最后,所有bpf程序字节都是在BPF栈上存储的。
64位的x86_64、arm64、ppc64、s390x、mips64、sparc64 和 32 位的arm 、x86_32架构都内置了in-kernel eBPF JIT编译器,它们的功能相同,都用下面的方式打开:
打开JIT编译器
echo 1 > /proc/sys/net/core/bpf_jit_enable
32位的mips、ppc、和sparc计算机架构目前有cBPF JIT 编译器。这些提及的架构仍然适用cBPF JIT和没有BPF JIT编译器的Linux内核支持的计算机架构,需要通过内核的解释器执行eBPF程序。
在内核源代码树上,eBPF JIT 支持哪些平台能够通过对HAVE_EBPF_JIT
执行grep
判断:
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
arch/ 文件夹放的是Linux内核支持的所有计算机架构
1.JIT 编译器显著加速 BPF 程序的执行,与解释器相比,它们可以降低每个指令的开销。 通用指令可以 1:1 映射到底层架构的原生指令。2.另外,这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好。 特别地,对于 CISC 指令集(例如 x86),3.JITs 为了给定的指令集发出最短操作码做了很多优化,以降低程序翻译过程所需的总空间。
所以,对于eBPF,JIT编译器是比解释器更好的转换翻译器。
BPF在内核锁住完整的BPF解释器镜像struct bpf_porg
和JIT编译器镜像struct bpf_binary_header
,在整个程序生命周期上作为“只读的”部分,这样做是为了防止代码被潜在的中断和破坏。在这个位置上发生的任何破坏,例如,由于一些内核的bu gs都将导致通用保护机制的错误,因此导致内核崩溃,而不是允许这类崩溃静默地发生。
支持镜像内存设置为“只读的”的架构能被发现:
$ 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 实现。写本文时,大部分现代Linux发行版上这个配置都是打开的。
将 /proc/sys/net/core/bpf_jit_harden
(说明harden是针对JIT编译器而言的)设置为 1 会为非特权用户使用JIT编译时做一些额外的加固步骤。(加固等于安全) 这有效的利用少部分的程序性能减少潜在的攻击面,防备非信任的用户在系统上操作。这种程序执行的降低相比于直接用解释器编程,仍然有更好的性能。
当前,启用加固会在 JIT 编译时盲化BPF程序中所有用户提供的32 位和 64 位常量,用来防止将原生操作码作为立即值注入内核的JIT喷射攻击,这种攻击有效是因为立即值是在可执行内核内存上,因此某些内核的bug可能触发跳转,那么将跳转到立即值的开始,然后执行这些作为原生的指令(原生操作码和立即值不能混为一谈,原生操作码可以被视为是bpf的op)。
盲化JIT常量是对真实指令进行随机化实现的,这意味着,通过对指令的重写,将基于源操作数的立即值转换成基于寄存器的操作。指令重写分离真实的加载值为两个部分:1)加载一个盲化的立即值rnd^imm
(异或操作后)到寄存器上,2)在对寄存器和rnd进行异或操作。这样原始的imm立即值就位于寄存器上和能被真实的操作使用。这个例子是加载操作提供(加载操作的盲化过程),实际上所有的通用操作都被盲化了。
下面是一个hardening关闭,JIT编译程序的例子:
echo 0 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f5e9 + :
[...]
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
[...]
相同的程序在打开hardening,被某个非特权用户通过 BPF 加载的结果:
echo 1 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f1e5 + :
[...]
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
[...]
上面两个程序语义是相同的,但在第二种方式中,原来的立即值在反汇编之后的程序中不可见。
同时,hardening还会禁止任何 JIT 内核符号暴露给特权用户(root级别), JIT镜像地址不再出现在 /proc/kallsyms 中。
更多的是,Linux内核提供选项CONFIG_BPF_JIT_ALWAYS_ON
,它能从内核中移除完整的BPF解释器并且永远启动JIT编译器。这是在Spectre v2攻击背景下移植开发的,当使用虚拟机时,客户机的内核不会复用内核的BPF解释器,从而避免某些相关的攻击。对于容器的环境而言,CONFIG_BPF_JIT_ALWAYS_ON
选项是可选的,但假如JITs功能打开,解释器也会在编译中被去掉,以降低内核的复杂度。因此,对于主流架构(例如 x86_64 和 arm64)上的JITs通常都建议打开这个开关。
Hardening是对安全的考量,利用随机化策略使得JIT编译器的私密性更好。相比于BPF解释器,JIT编译后的机器码是安全的,高效的,空间复杂度低的。
另外,内核提供一个选项关闭非特权用户使用bpf(2)
系统调用,它通过/proc/sys/kernel/unprivileged_bpf_disabled
系统配置。 比较特殊的一点是,这个配置特意设计为“一次性开关”,意味着一旦设置成1,就没有任何选项能将它设置回0,除非将新的内核重启。一旦这个点设置为 1 之后,只有最初的命令空间中有设置CAP_SYS_ADMIN
特权的进程才可以调用bpf(2)
系统调用。在最开始,Cilium也设置这个配置为1(目的是防止非特权用户的破坏)。
# echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
BPF程序是特权用户的游戏!!!
这里的Offload和通常的Offload不同。通常的Offload是卸载某个程序/模块,而BPF的Offload是将BPF程序通过offload-interface传递给硬件执行(有点下位机的感觉)
BPF的网络程序,尤其是tc和XDP,在内核中都有一个offload到硬件的接口,这样就可以在网卡上直接执行BPF程序了(BPF程序编译成机器码后通过接口offload给网卡)。
这说明网卡不具备BPF的能力,BPF程序的能力是在内核编译后,赋予网卡的。这跟DPDK等有本质的差异。
当前,Netronome 公司的 nfp
驱动支持通过JIT编译器offloading BPF,它会将 BPF 通用指令翻译成网卡实现的指令集(以往是计算机架构寄存器上的指令集)。另外,它还支持将 BPF maps offload 到网卡,因此 网卡的offloaded BPF 程序可以执行 map 查找、更新和删除等操作。
普通的BPF程序是CPU的寄存器执行,现在网卡也支持了BPF的执行操作,但主要是BPF的tc和XDP程序。
简单通过cilium的官网文档完整的解释了下eBPF这门技术和实现原理。通过eBPF的特点也梳理了下BPF和XDP都是内核协议栈的一部分。接下来将会继续对该官方文档后面的部分进行翻译。
翻译过程中有参考:
cilium document
Cilium:BPF和XDP参考指南(2021)