[译] Cilium:BPF 和 XDP 参考指南(2021)

Posted rtoax

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[译] Cilium:BPF 和 XDP 参考指南(2021)相关的知识,希望对你有一定的参考价值。

Cilium:BPF和XDP参考指南_RToax-CSDN博客Table of ContentsBPF体系结构指令系统辅助功能地图对象固定尾叫BPF到BPF呼叫准时制硬化减负工具链开发环境虚拟机本文档部分针对希望深入了解BPF和XDP的开发人员和用户。阅读本参考指南可能有助于拓宽您对Cilium的了解,但不是必须使用Cilium。请参阅入门指南和体系结构以获得更高级的介绍。BPF是Linux内核中一种高度灵活且高效的类似于虚拟机的构造,允许以安全的方式在各个挂钩点执行字节码。它被用于许多Linux..https://rtoax.blog.csdn.net/article/details/109393441[译] Cilium:BPF 和 XDP 参考指南(2021)http://arthurchiao.art/blog/cilium-bpf-xdp-reference-guide-zh/

Published at 2021-07-18 | Last Update 2021-08-07

译者序

本文翻译自 Cilium 1.10 的官方文档: BPF and XDP Reference Guide

几年前翻译过一版:Cilium:BPF 和 XDP 参考指南(2019), 对应 Cilium v1.6。

本文对排版做了一些调整,以更适合网页阅读。

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。


本文的目标读者是 “希望在技术层面对 BPF 和 XDP 有更深入理解的开发者和用户”。虽 然阅读本文有助于拓宽读者对 Cilium 的认识,但这并不是使用 Cilium 的前提条件。

BPF 是 Linux 内核中一个非常灵活与高效的类虚拟机(virtual machine-like)组件, 能够在许多内核 hook 点安全地执行字节码(bytecode )。很多 内核子系统都已经使用了 BPF,例如常见的网络(networking)、跟踪( tracing)与安全(security ,例如沙盒)。

BPF 其实早在 1992 年就出现了,但本文介绍的是扩展的 BPF(extended Berkeley Packet Filter,eBPF)。eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为 “经典” BPF(classic BPF, cBPF),cBPF 现在基本已经过时了。很多人知道 cBPF 是因为它是 tcpdump 的包过滤语言。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码 透明地转换成 eBPF 再执行。如无特殊说明,本文中所说的 BPF 都是泛指 BPF 技术。

虽然“伯克利包过滤器”(Berkeley Packet Filter)这个名字听起来像是专用于数据包过 滤的,但如今这个指令集已经足够通用和灵活,因此现在 BPF 也有很多网络之外的使用案例, 下文会列出一些项目。

Cilium 在其数据平面(datapath)中重度使用了 BPF 技术,更多信息可参考其 eBPF datapath 架构 文档。本文的目标是提供一份 BPF 参考指南,这份指南能帮助我们更 深入地理解 BPF、BPF 网络相关的使用方式(例如用 tc 加载 BPF 程序,XDP 程序 ),以及更好地开发 Cilium 中的 BPF 模板。

目录

译者序

1 BPF 架构

1.1 指令集

1.1.1 指令集

1.1.2 BPF 寄存器和调用约定

1.1.3 BPF 指令格式

1.2 辅助函数

1.3 Maps

1.4 Object Pinning(钉住对象)

1.5 尾调用(Tail Calls)

1.6 BPF to BPF Calls

1.7 JIT

1.8 加固(Hardening)

1.9 Offloads

2 工具链

2.1 开发环境

Fedora

Ubuntu

openSUSE Tumbleweed

编译 Linux 内核

验证编译好的内核

编译 iproute2

编译 bpftool

2.2 LLVM

2.2.1 BPF Target(目标平台)

2.2.2 调试信息(DWARF、BTF)

2.2.3 BPF 指令集

2.2.4 指令和寄存器位宽(64/32 位)

2.2.5 C BPF 代码注意事项

2.3 iproute2

2.3.1 加载 XDP BPF 对象文件

2.3.2 加载 tc BPF 对象文件

2.3.3 通过 netdevsim 驱动测试 BPF offload

2.4 bpftool

2.5 BPF sysctls

2.6 内核测试

2.7 JIT Debugging

2.8 内省(Introspection)

2.9 Tracing pipe

2.10 其他(Miscellaneous)

3 程序类型

3.1 XDP

BPF 程序返回码

XDP 使用案例

XDP 工作模式

驱动支持

3.2 tc

tc 和 XDP BPF 程序的不同

cls_bpf 分类器

tc BPF 程序返回码

tc BPF FAQ

tc BPF 使用案例

驱动支持


1 BPF 架构

BPF 不仅仅是一个指令集,它还提供了围绕自身的一些基础设施,例如:

  1. BPF map:高效的 key/value 存储
  2. 辅助函数(helper function):可以更方便地利用内核功能或与内核交互
  3. 尾调用(tail call):高效地调用其他 BPF 程序
  4. 安全加固原语(security hardening primitives)
  5. 用于 pin/unpin 对象(例如 map、程序)的伪文件系统bpffs),实现持久存储
  6. 支持 BPF offload(例如 offload 到网卡)的基础设施

LLVM 提供了一个 BPF 后端(back end),因此使用 clang 这样的工具就可以将 C 代 码编译成 BPF 对象文件(object file),然后再加载到内核。BPF 深度绑定 Linux 内核,可以在 不牺牲原生内核性能的前提下,实现对内核的完全可编程 (full programmability)。

另外, 使用了 BPF 的内核子系统也是 BPF 基础设施的一部分。本文将主要讨论 tc和 XDP 这两个子系统,二者都支持 attach(附着)BPF 程序。

  • XDP BPF 程序会被 attach 到网络驱动的最早阶段(earliest networking driver stage),驱动收到包之后就会触发 BPF 程序的执行。从定义上来说,这可以取得 最好的包处理性能,因为这已经是软件中最早可以处理包的位置了。但也正因为 这一步的处理在网络栈中是如此之早,协议栈此时还没有从包中提取出元数据(因此 XDP BPF 程序无法利用这些元数据)。
  • tc BPF 程序在内核栈中稍后面的一些地方执行,因此它们能够访问更多的元数据和一些核心的内核功能

除了 tc 和 XDP 程序之外,还有很多其他内核子系统也在使用 BPF,例如跟踪子系统( kprobes、uprobes、tracepoints 等等)。

下面的各小节进一步介绍 BPF 架构。

1.1 指令集

1.1.1 指令集

BPF 是一个通用目的 RISC 指令集,其最初的设计目标是:

  1. 用 C 语言的一个子集编写程序,
  2. 然后用一个编译器后端(例如 LLVM)将其编译成 BPF 指令,
  3. 稍后内核再通过一个位于内核中的(in-kernel)即时编译器(JIT Compiler) 将 BPF 指令映射成处理器的原生指令(opcode ),以获得在内核中的最佳执行性能。

将这些指令下放到内核中可以带来如下好处

  • 无需在内核/用户空间切换就可以实现内核的可编程。例如,Cilium 这种和网络相关 的 BPF 程序能直接在内核中实现灵活的容器策略、负载均衡等功能,而无需将包送先 到用户空间,处理之后再送回内核。需要在 BPF 程序之间或内核/用户空间之间共享状 态时,可以使用 BPF map。
  • 可编程 datapath 具有很大的灵活性,因此程序能在编译时将不需要的特性禁用掉, 从而极大地优化程序的性能。例如,如果容器不需要 IPv4,那编写 BPF 程序时就可以 只处理 IPv6 的情况,从而节省了快速路径(fast path)中的资源。
  • 对于网络场景(例如 tc 和 XDP),BPF 程序可以在无需重启内核、系统服务或容器的 情况下实现原子更新,并且不会导致网络中断。另外,更新 BPF map 不会导致程序 状态(program state)的丢失
  • BPF 给用户空间提供了一个稳定的 ABI,而且不依赖任何第三方内核模块。BPF 是 Linux 内核的一个核心组成部分,而 Linux 已经得到了广泛的部署,因此可以保证现 有的 BPF 程序能在新的内核版本上继续运行。这种保证与系统调用(内核提供给用 户态应用的接口)是同一级别的。另外,BPF 程序在不同平台上是可移植的
  • BPF 程序与内核协同工作复用已有的内核基础设施(例如驱动、netdevice、 隧道、协议栈和 socket)和工具(例如 iproute2),以及内核提供的安全保证。和内 核模块不同,BPF 程序会被一个位于内核中的校验器(in-kernel verifier)进行校验, 以确保它们不会造成内核崩溃、程序永远会终止等等。例如,XDP 程序会复用已有的内 核驱动,能够直接操作存放在 DMA 缓冲区中的数据帧,而不用像某些模型(例如 DPDK) 那样将这些数据帧甚至整个驱动暴露给用户空间。而且,XDP 程序复用内核协议栈而 不是绕过它。BPF 程序可以看做是内核设施之间的通用“胶水代码”, 基于 BPF 可以设计巧妙的程序,解决特定的问题。

BPF 程序在内核中的执行总是事件驱动的!例如:

  • 如果网卡的 ingress 路径上 attach 了 BPF 程序,那当网卡收到包之后就会触发这 个 BPF 程序的执行。
  • 在某个有 kprobe 探测点的内核地址 attach 一段 BPF 程序后,当 内核执行到这个地址时会发生陷入(trap),进而唤醒 kprobe 的回调函数,后 者又会触发 attach 的 BPF 程序的执行。

1.1.2 BPF 寄存器和调用约定

BPF 由下面几部分组成:

  1. 11 个 64 位寄存器(这些寄存器包含 32 位子寄存器)
  2. 一个程序计数器(program counter,PC)
  3. 一个 512 字节大小的 BPF 栈空间(从实现的层面理解为什么有 512 字节的限制, 可参考 (译) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021),译注。)

寄存器的名字从 r0 到 r10默认的运行模式是 64 位,32 位子寄存器只能 通过特殊的 ALU(arithmetic logic unit)访问。向 32 位子寄存器写入时,会用 0 填充 到 64 位。

r10 是唯一的只读寄存器,其中存放的是访问 BPF 栈空间的栈帧指针(frame pointer) 地址。r0 - r9 是可以被读/写的通用目的寄存器。

BPF 程序可以调用核心内核(而不是内核模块)预定义的一些辅助函数。BPF 调用约定 定义如下:

  • r0 存放被调用的辅助函数的返回值
  • r1 - r5 存放 BPF 调用内核辅助函数时传递的参数
  • r6 - r9 由被调用方(callee)保存,在函数返回之后调用方(caller)可以读取

BPF 调用约定足够通用,能够直接映射到 x86_64arm64 和其他 ABI,因此所有 的 BPF 寄存器可以一一映射到硬件 CPU 寄存器,JIT 只需要发出一条调用指令,而不 需要额外的放置函数参数(placing function arguments)动作。这套约定在不牺牲性能的 前提下,考虑了尽可能通用的调用场景。目前不支持 6 个及以上参数的函数调用,内核中 BPF 相关的辅助函数(从 BPF_CALL_0() 到 BPF_CALL_5() 函数)也特意设计地与此相 匹配。

r0 寄存器还用于保存 BPF 程序的退出值。退出值的语义由程序类型决定。另外, 当将执行权交回内核时,退出值是以 32 位传递的。

r1 - r5 寄存器是 scratch registers,意思是说,如果要在多次辅助函数调用之 间重用这些寄存器内的值,那 BPF 程序需要负责将这些值临时转储(spill)到 BPF 栈上 ,或者保存到被调用方(callee)保存的寄存器中。Spilling(倒出/转储) 的意思是这些寄存器内的变量被移到了 BPF 栈中。相反的操作,即将变量从 BPF 栈移回寄 存器,称为 filling(填充)。spilling/filling 的原因是寄存器数量有限

BPF 程序开始执行时,r1 寄存器中存放的是程序的上下文(context)。上下文就是 程序的输入参数(和典型 C 程序的 argc/argv 类似)。BPF 只能在单个上下文中 工作(restricted to work on a single context)。这个上下文是由程序类型定义的, 例如,网络程序可以将网络包的内核表示(skb)作为输入参数。

BPF 的通用操作都是 64 位的,这和默认的 64 位架构模型相匹配,这样可以对指针进 行算术操作,以及在调用辅助函数时传递指针和 64 位值;另外,BPF 还支持 64 位原子操 作。

每个 BPF 程序的最大指令数限制在 4096 条以内,这意味着从设计上就可以保证每 个程序都会很快结束对于内核 5.1+,这个限制放大到了 100 万条。 虽然指令集中包含前向和后向跳转,但内核中的 BPF 校验器禁止 程序中有循环,因此可以永远保证程序会终止。因为 BPF 程序运行在内核,校验器的工作 是保证这些程序在运行时是安全的,不会影响到系统的稳定性。这意味着,从指令集的角度 来说循环是可以实现的,但校验器会对其施加限制。另外,BPF 中有尾调用的概念,允许一 个 BPF 程序调用另一个 BPF 程序。类似地,这种调用也是有限制的,目前上限是 33 层调 用;现在这个功能常用来对程序逻辑进行解耦,例如解耦成几个不同阶段。

1.1.3 BPF 指令格式

BPF 指令格式(instruction format)建模为两操作数指令(two operand instructions), 这种格式可以在 JIT 阶段将 BPF 指令映射(mapping)为原生指令。指令集是固定长 度的,这意味着每条指令都是 64 比特编码的。目前已经实现了 87 条指令,并且在需要时 可以对指令集进行进一步扩展。一条 64 位指令在大端机器上的编码格式如下,从重要性最 高比特(most significant bit,MSB)到重要性最低比特(least significant bit,LSB):

op:8, dst_reg:4, src_reg:4, off:16, imm:32

off 和 imm 都是有符号类型。编码信息定义在内核头文件 linux/bpf.h 中,这个头 文件进一步 include 了 linux/bpf_common.h

op 定了将要执行的操作。op 复用了大部分 cBPF 的编码定义。操作可以基于寄存器值 ,也可以基于立即操作数(immediate operands)。op 自身的编码信息中包含了应该使 用的模式类型:

  • BPF_X 指基于寄存器的操作数(register-based operations)
  • BPF_K 指基于立即操作数(immediate-based operations)

对于后者,目的操作数永远是一个寄存器(destination operand is always a register)。 dst_reg 和 src_reg 都提供了寄存器操作数(register operands,例如 r0 - r9)的额外信息。在某些指令中,off 用于表示一个相对偏移量(offset), 例如,对那些 BPF 可用的栈或缓冲区(例如 map values、packet data 等等)进行寻 址,或者跳转指令中用于跳转到目标。imm 存储一个常量/立即值。

所有的 op 指令可以分为若干类别。类别信息也编码到了 op 字段。op 字段分为( 从 MSB 到 LSB):code:4source:1 和 class:3

  • class 是指令类型
  • code 指特定类型的指令中的某种特定操作码(operational code)
  • source 可以告诉我们源操作数(source operand)是一个寄存器还是一个立即数

可能的指令类别包括:

  • BPF_LDBPF_LDX加载操作(load operations)

    • BPF_LD 用于加载double word 长度的特殊指令(占两个指令长度,源于 imm:32 的限制),或byte / half-word / word 长度的包数据(packet data )。后者是从 cBPF 中延续过来的,主要为了保证 cBPF 到 BPF 翻译的高效,因为 这里的 JIT code 是优化过的。对于 native BPF 来说,这些包加载指令在今天已经 用的很少了。
    • BPF_LDX 用于从内存中加载 byte / half-word / word / double-word,这里的内 存包括栈内存、map value data、packet data 等等。
  • BPF_STBPF_STX存储操作(store operations)

    • BPF_STX 与 BPF_LDX 相对,将某个寄存器中的值存储到内存中,同样,这里的 内存可以是栈内存、map value、packet data 等等。BPF_STX 类包含一些 word 和 double-word 相关的原子加操作,例如,可以用于计数器。
    • BPF_ST 类与 BPF_STX 类似,提供了将数据存储到内存的操作,只不过其源操作 数(source operand)必须是一个立即值(immediate value)。
  • BPF_ALUBPF_ALU64逻辑运算操作(ALU operations)

    Generally, BPF_ALU operations are in 32 bit mode and BPF_ALU64 in 64 bit mode. Both ALU classes have basic operations with source operand which is register-based and an immediate-based counterpart. Supported by both are add (+), sub (-), and (&), or (|), left shift (<<), right shift (>>), xor (^), mul (*), div (/), mod (%), neg (~) operations. Also mov (<X> := <Y>) was added as a special ALU operation for both classes in both operand modes. BPF_ALU64 also contains a signed right shift. BPF_ALU additionally contains endianness conversion instructions for half-word / word / double-word on a given source register.

  • BPF_JMP跳转操作(jump operations)

    Jumps can be unconditional and conditional. Unconditional jumps simply move the program counter forward, so that the next instruction to be executed relative to the current instruction is off + 1, where off is the constant offset encoded in the instruction. Since off is signed, the jump can also be performed backwards as long as it does not create a loop and is within program bounds. Conditional jumps operate on both, register-based and immediate-based source operands. If the condition in the jump operations results in true, then a relative jump to off + 1 is performed, otherwise the next instruction (0 + 1) is performed. This fall-through jump logic differs compared to cBPF and allows for better branch prediction as it fits the CPU branch predictor logic more naturally. Available conditions are jeq (==), jne (!=), jgt (>), jge (>=), jsgt (signed >), jsge (signed >=), jlt (<), jle (<=), jslt (signed <), jsle (signed <=) and jset (jump if DST & SRC). Apart from that, there are three special jump operations within this class: the exit instruction which will leave the BPF program and return the current value in r0 as a return code, the call instruction, which will issue a function call into one of the available BPF helper functions, and a hidden tail call instruction, which will jump into a different BPF program.

Linux 内核中内置了一个 BPF 解释器,该解释器能够执行由 BPF 指令组成的程序。即 使是 cBPF 程序,也可以在内核中透明地转换成 eBPF 程序,除非该架构仍然内置了 cBPF JIT,还没有迁移到 eBPF JIT。

目前下列架构都内置了内核 eBPF JIT 编译器:x86_64arm64ppc64s390x 、mips64sparc64 和 arm

所有的 BPF 操作,例如加载程序到内核,或者创建 BPF map, 都是通过核心的 bpf() 系统调用完成的。它还用于管理 map 表项(查 找/更新/删除),以及通过 pinning 将程序和 map 持久化到 BPF 文件系统。

1.2 辅助函数

辅助函数(Helper functions)使得 BPF 能够通过一组内核定义的函数调用(function call)来从内核中查询数据,或者将数据推送到内核。不同类型的 BPF 程序能够使用的 辅助函数可能是不同的,例如,与 attach 到 tc 层的 BPF 程序相比,attach 到 socket 的 BPF程序只能够调用前者可以调用的辅助函数的一个子集。另外一个例子是, 轻量级隧道(lightweight tunneling )使用的封装和解封装(Encapsulation and decapsulation)辅助函数,只能被更低的 tc 层(lower tc layers)使用;而推送通知到 用户态所使用的事件输出辅助函数,既可以被 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 允许其加载指令(load instructions)进行 超出范围的访问(overload),以便从一个看似不可能的包偏移量(packet offset,负的)位置 获取数据以唤醒多功能辅助函数,但每个 cBPF JIT 仍然需要为这个 cBPF extension 实现对应的支持。

更多关于 Linux BPF extension 的内容,可参考 (译) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021), 译注中附录了一些相关的内核实现。译注。

而在 eBPF 中,JIT 编译器会以一种透明和高效的方式编译新加入的辅助函数,这意味着 JIT 编 译器只需要发射(emit)一条调用指令(call instruction),因为寄存器映射的方式使得 BPF 排列参数的方式(assignments)已经和底层架构的调用约定相匹配了。这使得基于辅 助函数扩展核心内核(core kernel)非常方便。所有的 BPF 辅助函数都是核心内核的一 部分,无法通过内核模块(kernel module)来扩展或添加

前面提到的函数签名还允许校验器执行类型检测(type check)。上面的 struct bpf_func_proto 用于存放校验器必需知道的所有关于该辅助函数的信息,这 样校验器可以确保辅助函数期望的类型和 BPF 程序寄存器中的当前内容是匹配的。

参数类型范围很广,从任意类型的值,到限制只能为特定类型,例如 BPF 栈缓冲区(stack buffer)的 pointer/size 参数对,辅助函数可以从这个位置读取数据或向其写入数据。 对于这种情况,校验器还可以执行额外的检查,例如,缓冲区是否已经初始化过了。

当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加,例如,写作本文时,tc BPF 程序可以使用38 种不同的 BPF 辅助函数。对于一个给定的 BPF 程序类型,内核的 struct bpf_verifier_ops 包含了 get_func_proto 回调函数,这个函数提供了从某个 特定的enum bpf_func_id 到一个可用的辅助函数的映射。

1.3 Maps

map 是驻留在内核空间中的高效键值仓库(key/value store)。map 中的数据可以被 BPF 程序访问,如果想在 多次 BPF 程序调用(invoke)之间保存状态,可以将状态信 息放到 map。map 还可以从用户空间通过文件描述符访问,可以在任意 BPF 程序以及用 户空间应用之间共享。

共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map。单个 BPF 程序目前最多可直接访问 64 个不同 map

map 的实现由核心内核(core kernel)提供。有 per-CPU 及 non-per-CPU 的通用 map,这些 map 可以读/写任意数据,也有一些和辅助函数一起使用的非通用 map。

当前可用的 通用 map 有:

  • 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

以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不 同的后端,这些后端各有不同的语义和性能特点。

当前内核中的 非通用 map 有:

  • 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 是一个数组 map,用于持有(hold)其他的 BPF 程序 。BPF_MAP_TYPE_ARRAY_OF_MAPS 和 BPF_MAP_TYPE_HASH_OF_MAPS 都用于持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换。这些类型的 map 都针对 特定的问题,不适合单单通过一个 BPF 辅助函数实现,因为它们需要在各次 BPF 程序调用 (invoke)之间时保持额外的(非数据)状态。

1.4 Object Pinning(钉住对象)

BPF map 和程序作为内核资源只能通过文件描述符访问,其背后是内核中的匿名 inode。这带来了很多优点,例如:

  • 用户空间应用能够使用大部分文件描述符相关的 API,
  • 在 Unix socket 中传递文件描述符是透明的,等等。

但同时,也有很多缺点:文件描述符受限于进程的生命周期,使得 map 共享之类的操作非常笨重

因此,这给某些特定的场景带来了很多复杂性,例如 iproute2,其中的 tc 或 XDP 在准备 环境、加载程序到内核之后最终会退出。在这种情况下,从用户空间也无法访问这些 map 了,而本来这些 map 其实是很有用的,例如,在 data path 的 ingress 和 egress 位置共 享的 map(可以统计包数、字节数、PPS 等信息)。另外,第三方应用可能希望在 BPF 程 序运行时监控或更新 map。

为了解决这个问题,内核实现了一个最小内核空间 BPF 文件系统,BPF map 和 BPF 程序 都可以钉到(pin)这个文件系统内,这个过程称为 object pinning(钉住对象)。相应 地,BPF 系统调用进行了扩展,添加了两个新命令,分别用于钉住(BPF_OBJ_PIN)一个 对象和获取(BPF_OBJ_GET)一个被钉住的对象(pinned objects)。

例如,tc 之类的工具可以利用这个基础设施在 ingress 和 egress 之间共享 map。BPF 相关的文件系统不是单例模式(singleton),它支持多挂载实例、硬链接、软连接等 等。

1.5 尾调用(Tail Calls)

BPF 相关的另一个概念是尾调用(tail calls)。尾调用的机制是:一个 BPF 程序可以调 用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。和普通函数调用相比,这种 调用方式开销最小,因为它是用长跳转(long jump)实现的,复用了原来的栈帧 (stack frame)。

BPF 程序都是独立验证的,因此要传递状态,要么使用 per-CPU map 作为 scratch 缓冲区 ,要么如果是 tc 程序的话,还可以使用 skb 的某些字段(例如 cb[])。

类型相同的 BPF 程序才可以尾调用,而且还要与 JIT 编译器相匹配, 因此一个给定的 BPF 程序 要么是 JIT编译执行,要么是解释器执行(invoke interpreted programs),而不能同时使用两种方式。

尾调用执行涉及两个步骤

  1. 设置一个称为“程序数组”(program array)的特殊 map(map 类型 BPF_MAP_TYPE_PROG_ARRAY ),这个 map 可以从用户空间通过 key/value 操作,
  2. 调用辅助函数 bpf_tail_call()。两个参数:一个对程序数组的引用(a reference to the program array),一个查询 map 所用的 key。内核将这个辅助函数调用内联( inline)到一个特殊的 BPF 指令内。目前,这样的程序数组在用户空间侧是只写模式( write-only from user space side)。

内核根据传入的文件描述符查找相关的 BPF 程序,自动替换给定的 map slot(槽) 处的 程序指针。如果没有找到给定的 key 对应的 value,内核会跳过(fall through)这一步 ,继续执行 bpf_tail_call() 后面的指令。尾调用是一个强大的功能,例如,可以通 过尾调用结构化地解析网络头(network headers)。还可以在运行时(runtime)原子地 添加或替换功能,即,动态地改变 BPF 程序的执行行为。

1.6 BPF to BPF Calls

除了 BPF 辅助函数和 BPF 尾调用之外,BPF 核心基础设施最近刚加入了一个新特性:BPF 到 BPF 调用(BPF to BPF calls)。在这个特性引入内核之前,典型的 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 声明了。因此,上面的代码可以更自然地重写为:

#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 到 BPF 调用是一个重要的性能优化,极大减小了生成的 BPF 代码大小,因此对 CPU 指令缓存(instruction cache,i-cache)更友好

BPF 辅助函数的调用约定也适用于 BPF 函数间调用,即 r1 - r5 用于传递参数,返回 结果放到 r0r1 - r5 是 scratch registers,r6 - r9 像往常一样是保留寄 存器。最大嵌套调用深度是 8。调用方可以传递指针(例如,指向调用方的栈帧的指针) 给被调用方,但反过来不行。

BPF JIT 编译器为每个函数体发射独立的镜像(emit separate images for each function body),稍后在最后一通 JIT 处理(final JIT pass)中再修改镜像中函数调用的地址 。已经证明,这种方式需要对各种 JIT 做最少的修改,因为在实现中它们可以将 BPF 函数 间调用当做常规的 BPF 辅助函数调用。

内核 5.9 版本之前,BPF 尾调用和 BPF-to-BPF 调用是互斥的,只能二选一。 尾调用的缺点是生成的程序镜像大、加载时间长。 内核 5.10 最终解决了这一问题,允许同时使用者两种调用类型,充分利用二者各自的优点。

这里给个例子

eBPF 入门之编程_RToax-CSDN博客eBPF 入门之编程 • Feiskyhttps://feisky.xyz/posts/2021-01-29-ebpf-program/目录BCClibbpf-bootstrap内核源码小结eBPF 提供了强大的跟踪、探测以及高效内核网络等功能,但由于其接口处于操作系统底层,新手入门起来还是有很大难度,特别是如何编写 eBPF 程序是入门的一大难点。本文将介绍一些常用的 eBPF 编程框架。BCC上篇文章介绍的 BCC 其实就提供了对 eBPF 的封装,前端提供 Pythonhttps://rtoax.blog.csdn.net/article/details/120250788

但混合使用者两种调用类型是有限制的,否则会导致内核栈溢出(kernel stack overflow)。 来看下面的例子:

如上图所示,尾调用在真正跳转到目标程序(func3)之前,只会展开(unwind)它当前 所处层级的栈帧(stack frame)。也就是说,如果尾调用是从某个子函数发起的(occurs from within the sub-function),例如 subfunc1 --tailcall--> func2,那当程序在执行 func2 时, 所有 subfunc1 之前的栈帧(在这里是 func1 的栈帧)都会出现在栈上。只有当最后 一个函数(这里是 func3)执行结束时,所有前面的栈帧才将被展开(unwinded),然后控制返回 到 BPF 程序的调用者(BPF program caller)。

内核引入了额外的逻辑来检测这种混用的情况。整个调用链中,每个子程序的栈空间( stack size)不能超过 256 字节(如果校验器检测到 bpf2bpf 调用,那主函数也会被当做 子函数)。有了这个限制,BPF 程序调用链最多能使用 8KB 的栈空间,计算方式:256 byte/stack 乘以尾调用数量上限 33。如果没有这个限制,BPF 程序将使用 512 字节栈空 间,最终消耗最多 16KB 的总栈空间,在某些架构上会导致栈溢出。

另外需要说明,这种混合调用目前只有 x86-64 架构支持

1.7 JIT

64 位的 x86_64arm64ppc64s390xmips64sparc64 和 32 位的 arm 、x86_32 架构都内置了 in-kernel eBPF JIT 编译器,它们的功能都是一样的,可 以用如下方式打开

$ echo 1 > /proc/sys/net/core/bpf_jit_enable

32 位的 mipsppc 和 sparc 架构目前内置的是一个 cBPF JIT 编译器。这些只有 cBPF JIT 编译器的架构,以及那些甚至完全没有 BPF JIT 编译器的架构, 需要通过内核中的解释器(in-kernel interpreter)执行 eBPF 程序。

要判断哪些平台支持 eBPF JIT,可以在内核源文件中 grep 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 程序的执行,因为与解释器相比,它们可以降低每个指令的 开销(reduce the per instruction cost)。通常,指令可以 1:1 映射到底层架构的原生 指令。另外,这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好。特别 地,对于 CISC 指令集(例如 x86),JIT 做了很多特殊优化,目的是为给定的指令产生 可能的最短操作码(emitting the shortest possible opcodes),以降低程序翻译过程所 需的空间。

1.8 加固(Hardening)

为了避免代码被损坏,BPF 会在程序的生命周期内,在内核中将下面两个镜像锁定为只读的(read-only):

  • 经过 BPF 解释器解释(翻译)之后的整个镜像struct bpf_prog
  • JIT 编译之后的镜像struct bpf_binary_header)。

在这些位置发生的任何数据损坏(例如某些内核 bug 导致的)会触发通用的保护机制,因 此会造成内核崩溃(crash),而不会让这种损坏静默地发生。

查看哪些平台支持将镜像内存(image memory)设置为只读的,可以通过下面的搜索:

$ 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,尾调用的间接跳转( indirect jump)就会用 retpoline 实现。写作本文时,在大部分现代 Linux 发行版上 这个配置都是打开的。

将 /proc/sys/net/core/bpf_jit_harden 设置为 1 会为非特权用户( unprivileged users)的 JIT 编译做一些额外的加固工作。这些额外加固会稍微降低程序 的性能,但在有非受信用户在系统上进行操作的情况下,能够有效地减小(潜在的)受攻击 面。但与完全切换到解释器相比,这些性能损失还是比较小的。

当前,启用加固会在 JIT 编译时盲化(blind)BPF 程序中用户提供的所有 32 位和 64 位常量,以防御 JIT spraying(喷射)攻击,这些攻击会将原生操作码(native opcodes)作为立即数(immediate values)注入到内核。这种攻击有效是因为:立即数 驻留在可执行内核内存(executable kernel memory)中,因此某些内核 bug 可能会触 发一个跳转动作,如果跳转到立即数的开始位置,就会把它们当做原生指令开始执行。

盲化 JIT 常量通过对真实指令进行随机化(randomizing the actual instruction)实现 。在这种方式中,通过对指令进行重写(rewriting the instruction),将原来基于立 即数的操作转换成基于寄存器的操作。指令重写将加载值的过程分解为两部分:

  1. 加载一个盲化后的(blinded)立即数 rnd ^ imm 到寄存器
  2. 将寄存器和 rnd 进行异或操作(xor)

这样原始的 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 攻击开发的,如果应用在一个基于虚拟机的环境,客户机内核(guest kernel)将不会复用 内核的 BPF 解释器,因此可以避免某些相关的攻击。如果是基于容器的环境,这个配置是 可选的,如果 JIT 功能打开了,解释器仍然可能会在编译时被去掉,以降低内核的复杂度 。因此,对于主流架构(例如 x86_64 和 arm64)上的 JIT 通常都建议打开这个开关。

另外,内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来 禁止非特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改。 比较特殊的一点是,这个配置项特意设计为“一次性开关”(one-time kill switch), 这意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核。一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。 Cilium 启动后也会将这个配置项设为 1

$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled

1.9 Offloads

BPF 网络程序,尤其是 tc 和 XDP BPF 程序在内核中都有一个 offload 到硬件的接口,这 样就可以直接在网卡上执行 BPF 程序。

当前,Netronome 公司的 nfp 驱动支持通过 JIT 编译器 offload BPF,它会将 BPF 指令 翻译成网卡实现的指令集。另外,它还支持将 BPF maps offload 到网卡,因此 offloaded BPF 程序可以执行 map 查找、更新和删除操作。

2 工具链

本节介绍 BPF 相关的用户态工具、内省设施(introspection facilities)和内核控制选项。 注意,围绕 BPF 的工具和基础设施还在快速发展当中,因此本文提供的内容可能只覆 盖了其中一部分。

2.1 开发环境

Fedora

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

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 Tumbleweed

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 源码树中开发的。获取 net-netxt 源码树:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git

如果不关心提交历史,可以指定 --depth 1,这会下载当前最新的版本,节省大量时间和 磁盘空间。

最新的 BPF fix 都在 net 源码树

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git

网络已经有大量关于如何编译 Linux 内核的教程,推荐 Kernel Newbies website

要运行 BPF,需要确保生成的 .config 文件包含下列配置(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

以上的某些配置项是无法通过 make menuconfig 修改的。例如, CONFIG_HAVE_EBPF_JIT 是根据当前架构是否支持 eBPF JIT 自动设置的。在本节中, CONFIG_HAVE_EBPF_JIT 是可选但强烈推荐的配置。没有 eBPF JIT 编译器的架构只能 fallback 到内核解释器,执行效率会大大降低。

验证编译好的内核

用编译好的内核启动之后,进入 BPF 测试目录来验证 BPF 的功能:

$ cd tools/testing/selftests/bpf/
$ make
$ sudo ./test_verifier

正常的话,会打印如下类似的结果:

Summary: 847 PASSED, 0 SKIPPED, 0 FAILED

注意:For kernel releases 4.16+ the BPF selftest has a dependency on LLVM 6.0+ caused by the BPF function calls which do not need to be inlined anymore. See section bpf_

以上是关于[译] Cilium:BPF 和 XDP 参考指南(2021)的主要内容,如果未能解决你的问题,请参考以下文章

kubernetes 集群中 cilium 的实践及其网络通信解析

ebpf的简单学习

XDP/eBPF — BPF

XDP/eBPF — BPF

xdp-project / bpf-examples

Flannel,Calico 和 Cilium的优缺点和差别