【译文】使用BPF控制内核的ops结构体

Linux内核5.6版本的众多令人惊喜的功能之一是:TCP拥塞控制算法(congestion control algorithm)可作为用户空间的BPF(Berkeley Packet Filter)程序进行加载和执行。网络开发者Toke Høiland-Jørgensen将这项功能描述为内核正向成为支持BPF运行时的微内核迈进(march towards becoming BPF runtime-powered microkernel)”的延续性动作。从外表上看,这是赋予给BPF的一项重要的新功能,使得拥塞控制将远远超过现有能力。但当我们深入研究后发现,其令人惊喜之处远不止这些,因为该功能的实现在多个方面都取得了新的进展。

该功能的使用场景和用例似乎都比较明确,因为有大量不同的拥塞控制算法已在使用中,且每种算法都适合于不同的网络环境。利用该功能,我们有充足方法来分发更新或改进后的控制算法,因为使用者能够在无需重新构建内核甚至无需重启的情况下使用新算法,使得网络功能开发者可从运行中的拥塞控制代码中获得好处。有人可能会质疑,拥塞控制功能在概念上与BPF现有的其它功能(例如flow dissectionInfrared协议解码)没有本质的不同,但需要指出的是,拥塞控制确实涉及到相当高的复杂性。

如果看一下Martin KaFai Lau发布的patch集合,你就会发现5.6版本内核将要合并的代码不仅仅是一项能够hook住TCP拥塞控制的机制,其真实威力远不止于此。具体地说,这种新架构可用于允许BPF程序替换内核中的任何“ops结构(struct xxx_ops)”——一个由函数指针组成的结构。目前,虽然它只能替换用于拥塞控制的struct tcp_congestion_ops结构,但大量的经验表明,在内核其他地方的应用将很快涌现。

用户空间API

在用户空间中,加载新的ops结构需要如下几个步骤。首先,使用bpf()系统调用以单独的BPF程序对每个函数的实现进行加载,这些BPF程序已经可以使用新的BPF_PROG_TYPE_STRUCT_OPS类型定义ops。用户空间在每个程序提供的属性中,必须提供与要替换的结构相对应的BPF类型格式(BPF Type Format,BTF)的ID(同时用于指定稍后要实现的实际功能)。 BTF是一项较新的特性,它描述了正在运行的内核中的函数和数据结构,目前用于追踪函数的类型检查

用户空间还必须指定一个整数偏移量,以标识此程序将要替换的函数。例如,struct tcp_congestion_ops的函数指针字段ssthresh()在结构中位于第六个字段,因此将5作为偏移量进行传递(偏移量从0开始)。目前还不明确该API如何与结构布局随机化(structure layout randomization)进行交互。

在加载每个结构字段对应的程序时,内核将返回与每个结构字段相对应的文件描述符。为了使用此描述符,用户空间还必须填充如下的结构:

1
2
3
4
5
struct bpf_tcp_congestion_ops {
refcount_t refcnt;
enum bpf_struct_ops_state state;
struct tcp_congestion_ops data;
};

上面的代码中,data字段的类型是将要替换的结构——在拥塞控制中也就是struct tcp_congestion_ops,但是,此结构应包含已加载用于实现对应拥塞控制功能的程序的文件描述符,而非函数指针。尽管内核可以按如下所述覆盖内容,但也应根据需要设置该结构中的非函数字段。

最后一步,是将该结构加载到内核中,有多种方式来达到该目的,因此实际的实现几乎可以肯定是另外的方式。用户空间必须使用新添加的BPF_MAP_TYPE_STRUCT_OPS类型创建一个特殊的BPF map,与该map相关联的是内核中特殊结构的BTF类型ID(如下所述),这就是将map与要替换的结构连接在一起的方式。实际的结构替换是通过将上面的bpf_tcp_congestion_ops结构存储到零填充的map中来完成的,此外还支持的操作包括:查询map(以获取引用计数和状态字段)和通过删除元素0来删除结构。

近年来,BPF maps相关的功能和特性不断的出现,即便如此,这次添加的新功能似乎是map作首次在内核产生类似副作用的方法。也许本功能不是最优雅的接口,但大多数用户空间的开发者将永远看不到它背后的大部分细节,因为它就像其他大多数BPF的API一样,隐藏在libbpf库中的一系列宏和对象的背后。

内核空间

由于用户空间无权限任意替换结构,所以替换ops结构需要内核的支持,为了支持这样的替换,内核态必须新添加如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define BPF_STRUCT_OPS_MAX_NR_MEMBERS 64
struct bpf_struct_ops {
const struct bpf_verifier_ops *verifier_ops;
int (*init)(struct btf *btf);
int (*check_member)(const struct btf_type *t,
const struct btf_member *member);
int (*init_member)(const struct btf_type *t,
const struct btf_member *member,
void *kdata, const void *udata);
int (*reg)(void *kdata);
void (*unreg)(void *kdata);
const struct btf_type *type;
const struct btf_type *value_type;
const char *name;
struct btf_func_model func_models[BPF_STRUCT_OPS_MAX_NR_MEMBERS];
u32 type_id;
u32 value_id;
};

本文无法包含所有这些代码的细节,并且由于宏的存在,它自动填充此结构的某些字段。 值得说明的是,verifier_ops结构中有多个函数,可用于验证各个替换功能是否可安全执行。在即将合并的补丁集中,该结构中添加了一个新字段:struct_access(),其用于控制BPF函数可以更改ops结构本身的哪些部分(如果有的话)。

内核在获取到用户空间的请求后,首先调用init()函数,来进行一切所必需的全局设置。check_member()函数决定是否允许目标结构的特定成员在BPF中实现,而init_member()则用于验证该结构中所有字段的确切值,特别地,init_member()可以验证非函数字段(例如flag字段)。 在检查通过后,则通过reg()函数进行实际地注册替换结构,具体地,在拥塞控制的场景下,该函数将tcp_congestion_ops结构(和用于函数指针的BPF相关的trampoline)安装在网络栈中将要使用的位置。相反地,unreg()则用于撤消操作。

这种类型的结构应使用特定名称创建,即添加bpf_前缀。因此,用于替换tcp_congestion_ops结构的ops结构的名字为bpf_tcp_congestion_ops,这是加载新的ops结构时用户空间必须(通过BTF的ID)引用的“特殊结构”。最后,在kernel/bpf/bpf_struct_ops_types.h中添加如下的一行代码:

1
BPF_STRUCT_OPS_TYPE(tcp_congestion_ops)

借助宏操作,以及将此文件四次include到bpf_struct_ops.c中,便可处理好所有设置,而无需特殊的函数注册该结构类型。

总结

tcp_congestion_ops替换机制中内核态的实现可以在net/ipv4/bpf_tcp_ca.c文件中找到,源码树中已有两种不同控制算法的实现(DCTCPCUBIC)。

可替换内核中任意ops结构是一项潜在的强大功能,因为内核中很大一部分代码是通过这种类型的结构调用的。比如说,如果可以替换全部或部分security_hook_heads结构,则可以以任意方式修改安全策略,例如,实现类似于KRSI的功能。还有,替换file_operations结构几乎可以重写内核I/O子系统的任何部分。

目前还没有任何人提出类似的方法,但是这样的功能肯定会吸引感兴趣的开发者。将来会有某个时刻,几乎任何内核功能都可以被用户空间的BPF代码hook或替换,那时用户将拥有改变系统运行方式的强大能力,但是我们认为“Linux内核”将变得更加充满不确定性,这也取决于从用户空间加载了哪些代码。结果可能会很有趣。

(译者注:本文原地址为 https://lwn.net/Articles/811631/)