本文翻译自 Cilium 出品的 BPF and XDP Reference Guide,翻译了其中的 tailcallbpf2bpf 部分。

  • tailcall:尾调用。
  • bpf2bpf:BPF 到 BPF 函数调用。

tailcall

tailcall 可以看作允许一个 BPF 程序调用另一个程序的机制,而且该调用无需返回原程序。这样的调用并不像常规的函数调用,它实际由一个长跳转(long jump)来实现,并复用同一个栈帧(stack frame)。

这样的程序会各自独自进行校验。因此如果要在它们之间传数据,要么使用 per-CPU map 当作暂时缓冲,要么使用 skbcb[] 区域(对于 tc 程序而言)。

只有相同类型的程序才能相互尾调用,而且它们需要使用同款 JIT 编译。因此,要么 JIT 编译的程序、要么只解释的程序可以相互调用,但不能混着来。

tailcall 涉及两个组件:程序数组 BPF_MAP_TYPE_PROG_ARRAYbpf_tail_call()BPF_MAP_TYPE_PROG_ARRAY 是一个 tailcall 专用的 map,需要由用户态程序填充 key/value;key 是 32 位无符号整型 uint32 的数组索引;value 是尾调用的 BPF 程序的文件描述符。bpf_tail_call() 帮助函数,其参数分别是上下文(context)、程序数组引用和数组索引。然后,内核直接将 tailcall 内联(inline)成特定的 BPF 指令。然而,在当下,该程序数组只能由用户态侧可写(BPF 程序不可写)。

当用户态写入文件描述符到程序数组时,内核查询其关联的 BPF 程序,并原子性地将程序指针替换到指定的程序数组槽位中。而在运行的时候,如果程序数组中给定 key 的槽位为空,内核就会“跳过 tailcall”并继续执行原有程序中 bpf_tail_call() 后面的指令。tailcall 是一个非常强大的工具,譬如可以通过多个 tailcall 来解析网络头部。在运行的时候,一些功能可以被原子性地添加进来,并因此变更 BPF 程序的执行行为。

bpf2bpf

除了 BPF 帮助函数调用和 BPF 尾调用以外,最近添加到 BPF 核心基础架构的特性是 BPF 到 BPF 调用(bpf2bpf)。在该特性引入内核之前,典型的 BPF C 程序想要复用代码,就需要在头文件中使用 always_inline;以此让 LLVM 在编译、生成 BPF 对象文件时内联所有这些函数;但因此使得一个 always_inline 的函数在一个对象文件里重复多次,并导致对象文件大小极速膨胀:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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 代码片段可以更自然地改写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#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";

x86_64arm64 等主流 BPF JIT 编译器已支持 bpf2bpf,其它 JIT 编译器会在不久的将来跟进。bpf2bpf 带来了非常重要的性能优化,因为它极大地减小了生成的 BPF 代码大小,并因此对 CPU 指令缓存更加友好。

BPF 帮助函数的调用规约同样适用于 bpf2bpf,意味着 r1r5 用于传递参数给被调用者、返回结果保存在 r0。通常而言,r1r5 是暂存器,r6r9 保留给调用之间使用。各自嵌套调用的调用帧最大数量是 8。调用者可以传递指针(比如调用者的栈帧)给被调用者,但不能反着来。

BPF JIT 编译器为每个函数生成各自的镜像(image),并在最后一轮的 JIT 处理中修复镜像里的函数调用地址。这已被证明所需的 JIT 变更最小,并能跟常规的 BPF 帮助函数调用同样对待 bpf2bpf

组合使用 tailcallbpf2bpf

直至内核 5.9,tailcallbpf2bpf 之间是互斥的。使用 tailcall 的 BPF 程序不能享受更小的程序镜像大小和更快的加载时间等收益。Linux 内核 5.10 终于允许用户组合两个世界之最,增加了 BPF 子程序组合 tailcall 的能力。

尽管这次改进伴随着一些限制。组合使用这两个特性可能会导致内核栈溢出。为了搞明白这一点,看下组合使用 bpf2bpftailcall 的插图:

在真正进入目标程序之前,tailcall 会释放它所在的栈帧。如上图,如果在子函数内进行 tailcall,当 func2 程序执行时会使用函数(func1)栈帧。一旦最后的函数(func3)结束,前面所有的栈帧都会被释放掉,并将控制权给回到 BPF 程序调用者的调用者。

内核使用额外逻辑去处理这特性组合。贯穿整个调用链,每个子程序的栈大小上限为 256 字节(注:如果校验器检测到 bpf2bpf,主函数也会被当作子函数对待)。总之,在这限制下,BPF 程序调用链最多消耗 8KB 栈空间;由每个栈帧 256 字节乘以 tailcall 数量限制(33)计算得到。如果没有该限制,BPF 程序可以操作 512 字节栈大小,导致最大数量的 tailcall 总共消耗 16KB 大小;在某些 CPU 架构上会导致栈溢出。

译者注:这里的栈空间指的是内核线程栈空间,一般内核线程栈空间大小为 8KB。内核线程栈空间的大小取决于具体的 CPU 架构。

One more thing: 目前只有 x86-64 架构支持组合使用 tailcallbpf2bpf