概述
tail call
是一个eBPF机制,能够使eBPF的开发者将一个程序的多个功能给解耦出来。与传统的函数调用不一样,传统的函数调用在被调函数(Callee)执行完成以后会返回到调用者函数(Caller),传统的函数调用流程我们视为正常控制流(Control Flow)。但是使用 Tail Calls
进行的调用永远也不会回到Caller中去。
因为 eBPF 虚拟机的限制,导致 eBPF 运行时最大能够使用的堆栈空间只有 512KB。所以当我们需要实现一个非常复杂的 eBPF 观测功能时,会出现堆栈溢出的情况。所以 Tail Calls
机制为了将 512KB 堆栈最大化利用,在进行Tail Call
时,会复用Caller堆栈。在汇编层面,我们可以理解将 sp
指针重新移动到 bp
处,也就是术语”复栈“。
传统开发中将一个大型的功能拆分为多个小功能的做法是采用函数、对象、微服务等方式,在 eBPF 中也有一个对应的机制 BPF-to-BPF
,但是该方式不会复用堆栈,只是将一个功能函数拆分出去,分为多个部分,以实现代码的可维护性、可读性等。
为了避免重复声明 Tail Calls,在文章后续,我将采用 “该机制” 来表示 “Tail Calls”
该机制需要配合BPF_MAP_TYPE_PROG_ARRAY map去使用,BPF_MAP_TYPE_PROG_ARRAY
可以理解为函数指针数组,用以存储所有该机制的跳转目标。
在本文中,会详细描述 3 个内容:
Tail Calls
相关概念BPF_MAP_TYPE_PROG_ARRAY
相关概念Tail Calls
实例
Tail Calls
上文中已经介绍了该机制,该机制的应用场景您可以在学习完成以后琢磨,这里就不再过多赘述。本文不会涉及到 Tail Calls 晦涩难懂的底层原理与实现,先学会如何使用再去研究,这是学习现代计算机技术最好的方法,当你对一个技术的使用已经轻车熟路时,你再去看它的机理会非常的轻松,因为你看到的所有原理,都能够用一个具象的实践来辅助理解。
该机制有些许限制,在通过该机制编写功能的时候是需要注意的:
- 尾调用限制
- 程序数组的匹配要求
- 共享状态
- 尾调用与 BPF-to-BPF 函数结合时的栈大小
尾调用限制
为了防止无限循环或运行时间过长的程序,内核限制了每次初始化调用最多可以进行 32次 尾调用。这意味着总共有33个程序可以依次执行(1个初始程序 + 32 个尾调用程序),之后尾调用辅助函数将拒绝再次跳转。
程序数组的匹配要求
当一个BPF_MAP_TYPE_PROG_ARRAY
数组与一个 eBPF 程序关联时,添加到该映射中的任何程序都必须与初始程序匹配。这意味着它们需要拥有相同的 prog_type(程序类型)
、expected_attach_type(预期附加类型)
、attach_btf_id
、context_args(上下文参数)
等属性。这是为了确保程序数组能够正确地被调用和执行。
现在着重描述一下两个知识点:
- 什么是与 eBPF 程序关联
- Program对象属性
什么是与 eBPF 程序关联:
当一个 PROG_ARRAY
类型的Map第一次在eBPF Program中被调用时,那么该 Map 就会与该Program相关联。该关系仅在加载验证阶段有效,在相关联后所有被存入该Map的Tail Program都必须与被关联。
Program 对象属性:program 对象有四个属性:
prog_type
:程序类型。尾调用链中的所有程序必须是相同类型的。例如,如果初始程序是一个XDP
程序,那么被尾调用的程序也必须是XDP
程序。这是因为不同类型的 eBPF 程序在内核中运行的环境和处理的数据上下文是不同的。expected_attach_type
:预期附加类型。如果程序类型有更细粒度的附加点(例如,某些tracepoint
程序有特定的attach_type
),那么它们也需要一致。attach_btf_id
:BTF(BPF Type Format)提供了类型信息。attach_btf_id
用以标识程序所期望的附加点或上下文的 BTF 类型信息。如果尾调用链中的程序以来特定的上下文结构,那么它们必须对该上下文有相同的理解。context
:上下文兼容性。尾调用会传递当前程序的上下文(ctx)给被调用的程序。因此,被调用的程序必须能够理解并正确处理这个上下文。如果上下文的类型或结构不匹配,会导致验证器拒绝程序或运行时错误。
当出现如下错误时,说明您的Tail Calls函数参数定义出现了问题,该问题只会在使用 ctx
时,才会呈现出来,如果你在 tail 中不使用ctx
那么验证程序将无法捕获该问题。你可以在仓库中将分支切换到 error
分支用以获取该Demo源代码:
2025/05/26 11:06:32 field ParamInt: program param__int: load program: permission denied: 2: (79) r3 = *(u64 *)(r6 +0): invalid bpf_context access off=0 size=8 (6 line(s) omitted)
共享状态
在上文中,我们知道,Tail Call 机制会复用栈帧,所以会出现被调函数使用调用函数堆栈中的数据。那么一般情况下,验证器就需要确保在每个eBPF程序运行之前堆栈和寄存器的状态是可预测的,将原堆栈和寄存器中的内容“清空”,验证器会强制将堆栈清空,不是你做,就是它做。用以确保不会出现因为前一个程序没有将它们置于一致或预期的状态而引入安全漏洞和不可预测行为。所以无论如何,你都不会得到Caller传过来的参数。
下面的图片演示了这一点,仅作为演示使用,真实的 eBPF 栈帧图比下图复杂:
由于上述的验证机制,导致无法直接共享状态,在eBPF文档中提到了如下几种常见的解决方式:
__sk_buff->cb(用于网络程序)
:这是一个在sk_buff
结构(代表一个网络数据包)中预留的小型的不透明字节数组(意味着验证器不了解或不关心其内部结构)。你可以使用这块内存来存储你想要在处理同一个数据包的相关 eBPF 程序之间传递的数据。xdp_md->data_meta(用于XDP程序)
:类似于__sk_buff->cb
,data_meta
为XDP(eXpress Data Path)程序提供了一种存储和共享与数据包相关的少量自定义元数据的方法。
上述的方式只能够适用于特定的eBPF程序类型,只能够在处理网络数据包时,Tail Call直接共享状态。而不是适用于其它的eBPF程序类型。
通过具有单个条目的每个CPU映射:
- 每个CPU映射:这些是特殊的eBPF映射,系统中的每个CPU都已自己的专用条目。这避免了当不同的CPU运行你eBPF程序时对复杂锁机制的需求。
- 单个条目:通过为每个CPU仅设置一个条目,你可以有效地将此映射用作每个CPU的存储位置,用于共享数据。
- 为什么这通常可行:这种方法通常是安全的,因为即使在同一个任务内的尾部调用之间,eBPF程序也保证在同一个CPU核心上执行。因此,如果一个程序将其数据写入其CPU在映射中的条目,则在同一个任务中后续被尾调用的程序将在同一个CPU上运行,并且可以访问相同的数据。
实时 (RT) 内核的重要注意事项:
- 中断和恢复: 在实时内核上,eBPF 程序的执行可能会被更高优先级的任务中断,然后在稍后恢复。
CPU 迁移(潜在问题): 虽然通常在同一个任务内的尾部调用之间,eBPF 程序会保持在同一个 CPU 核心上,但文档强调了在 RT 内核上可能出现的一种理论情况,即一个被中断的 eBPF 程序可能会在不同的 CPU 上恢复执行。 - RT 内核的限制: 为了确保在这种特定的 RT 场景下的数据一致性并避免竞争条件,文档建议,用于在尾部调用之间共享数据的每个 CPU 映射应该只被认为是可靠的,用于在同一个任务的尾部调用序列内共享。你不应该依赖它们在 RT 内核上跨不同任务或独立 eBPF 程序调用进行全局数据共享。
BPF to BPF
PROG_ARRAY Map
BPF_MAP_TYPE_PROG_ARRAY
是用来存储指向多个 Tail Call
目标的数据结构,该Map不直接存储程序代码,而是存储对其他 eBPF 程序的引用。该引用通常是程序的文件描述符或ID,eBPF 的每一个对象都是通过文件描述符来进行引用的,且每一个加载到内核中的 eBPF 对象都有一个ID,可以通过 bpftool prog show --pretty | jq '.[0]'
,来查看被载入内核中的 eBPF Program 的相关信息。
定义一个BPF_MAP_TYPE_PROG_ARRAY
:
struct {
__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
__uint(max_entries, 32);
__type(key, __u32);
__type(value, __u32);
} my_prog_array_map SEC(".map");
上面定义了一个名为 my_prog_array_map
的结构体变量,其中有四个字段域,分别是:
type
:指定 Map 的类型,从<bpf/bpf_helpers.h>
文件头中导入。max_entries
:指定该Map类型的最大键值对数量,你可以存储超过最大调用次数同等数量的eBPF程序,但是在调用时,请时刻注意不要超过 32 次即可。key
:该__type
描述的是Map键值的大小,根据BPF_MAP_TYPE_PROG_ARRAY
的规定它只能为__u32
。value
:同上。
通过 __uint()
和 __type
两种宏来进行定义,这里不阐述这两个宏是什么,可以去看看 eBPF 这篇文章。
bpf_tail_call
bpf_tail_call
是 <bpf/bpf_helpers.h>
头文件中声明的辅助函数,它用于去触发一个尾调用。
定义
在调用该辅助函数时,程序将尝试跳转到一个由index
参数指定的在 prog_array_map
中存储的程序处,并且传递 *ctx
static long (* const bpf_tail_call)(void *ctx, void *prog_array_map, __u32 index) = (void *) 12;
返回值:
0
:成功!0
:失败
在官方文档中有对于跳转后堆栈状态的描述。
Example
案例目录如下:
├── Makefile
├── README.md
├── go.mod
├── go.sum
├── include # 包含 vmlinux.h
├── kbpf
│ ├── c
│ │ └── kbpf.c # eBPF 核心文件
│ └── kbpf.go # 供外部调用,使用脚手架模板
└── main.go # 入口函数
通过如下命令可以查看所有的 bpf_printk
输出:
bpftool prog tracelog
在案例中我们只通过link.Tracepoint
方法附加了名为 syscall__entry
的 eBPF Program,其它程序只是被加载到内存中等待使用,并没有作为 Probe 被附加,这一点可以通过,我们的核心代码看出:
SEC("tracepoint/raw_syscalls/sys_enter")
int syscall__entry(struct trace_event_raw_sys_enter *ctx)
{
__u32 key = 0;
if (ctx->id == SYSCALL_NR) {
bpf_printk("The called at callee");
bpf_tail_call(ctx, &prog_tail_map, 0);
}
return 0;
}
只有当系统调用号ctx-id
为 SYSCALL_EXECVE
时才触发,所以在 param_int
函数中,所有的系统调用号输出都应该是一致的。当你通过bpftool
命令查看时,你应该会看到如下现象: