尾调用
尾调用是一种形式机制,允许将一个 eBPF 程序的逻辑分解为多个部分,并从一个部分转移到另一个部分。与传统函数调用不同,控制流永远不会返回到进行尾调用的代码,它的工作方式更像是一个 goto
语句。
要使用尾调用,需要在程序中添加一个 BPF_MAP_TYPE_PROG_ARRAY
映射。在满足一些条件的情况下,该映射可以填充对其他程序的引用。然后,程序可以使用 bpf_tail_call
帮助函数,配合映射的引用和索引来执行实际的尾调用。
尾调用的一个流行用途是在多个程序之间分散“复杂性”。每个尾调用的目标被视为一个独立的 eBPF 程序,从零堆栈开始,只有 R1 中的上下文。因此,每个程序都必须独立通过验证器,并且也得到了自己的复杂性预算。现在我们可以通过将程序分解成多个部分,使程序复杂性增加多倍。
另一个用例是替换或扩展逻辑。通过替换正在使用的程序数组的内容。例如,更新程序版本而无需停机,或启用/禁用逻辑。
限制
为了防止无限循环或程序运行时间过长,内核限制了每次初始调用的尾调用数量为 32
,因此在尾调用帮助函数拒绝跳转之前,总共可以执行33
个程序。
如果程序数组与程序关联,添加到映射中的任何程序都应该“匹配”该程序。因此,它们必须具有相同的 type
、expected_attach_type
、attached_btf
等。
虽然共享相同的栈帧,但验证器会阻止您在不重新初始化的情况下使用任何现有的栈状态,对于寄存器也是如此。因此,没有直接共享状态的方法。解决这个问题的常见方法是使用元数据中的不透明字段,如 __sk_buff->cb
或 xdp_md->data_meta
内存。或者,可以使用具有单个条目的每个 CPU 映射来共享数据,这之所以有效,是因为 eBPF 程序即使在尾调用之间也不会迁移到不同的 CPU。然而,在RT(实时)内核中,eBPF 程序可能会被中断并在稍后重新启动,因此这些映射应该只在同一任务的尾调用之间共享,而不是全局共享。
当尾调用与 BPF 到 BPF 函数调用结合使用时,每个程序的可用栈大小将从 512
字节缩小到 256
字节。这是为了限制内核所需的栈分配,正如内核中的以下注释所解释的:
防止可能发生的堆栈溢出,这可能发生在 bpf2bpf 调用与尾调用结合时。 将调用者的栈深度限制在 256 字节,以便最坏情况下会导致 8k 的栈大小(32 是尾调用限制 * 256 = 8k)。
要了解可能发生的情况,请看一个例子:
bashfunc1 -> sub rsp, 128 subfunc1 -> sub rsp, 256 tailcall1 -> add rsp, 256 func2 -> sub rsp, 192 (总栈大小 = 128 + 192 = 320) subfunc2 -> sub rsp, 64 subfunc22 -> sub rsp, 128 tailcall2 -> add rsp, 128 func3 -> sub rsp, 32 (总栈大小 128 + 192 + 64 + 32 = 416)
尾调用将解开当前的栈帧,但它不会清除调用者的栈,如上面的例子所示。