无论好坏,C语言已经是内核开发领域的通用语言了。Linux 内核的核心逻辑完全是用 C 语言编写的(加上一点汇编),它的驱动程序和 module 也是如此。虽然 C 语言因其强大而简单的语义而受到赞誉,但它是一种古老的语言,缺乏现代语言(如 Rust)中的许多特性。另一方面,BPF 子系统也提供了一个编程环境,工程师能够编写可以在内核空间安全运行的程序。在爱尔兰都柏林举行的 2022 年 Linux Plumbers Conference 上,Alexei Starovoitov 概述了 BPF 多年来的发展,为内核编程提供了一个新的参考模型。
BPF的使命
Starovoitov 首先描述了他对 BPF 的 "mission statement, 使命宣言":"创新、并启发大家创新"。内核中的编程历来是在两种情况下进行的:
core kernel 开发,包括主要的核心子系统,如内存管理、调度器、read-copy-update,等等。
kernel-module 开发,指的是构建那些不被编译到 main kernel image 里的内容,由 module loader 在后续加载。例如,驱动程序被写成一些内核 module,也有其他功能是这么做的,如文件系统、网络协议等等。
这是内核在很长一段时间内的状态,直到 3.15 版的内核中加入了最早版本的 extended BPF(eBPF)虚拟机。有了它之后,BPF program 可以用一个受到严格限制的 C 语言来编写,并被编译成 BPF 字节码,这将允许用户编写的代码可以经过验证确保安全,然后再在内核空间运行。
从那时起,BPF 在代码的规模、用户及贡献者社区的规模方面都稳步增长。根据 Starovoitov 的说法,BPF 邮件列表上每天都会收到 50-70 条信息,每月大约收到 2000 封邮件。平均每月里活跃贡献的 BPF 贡献者的数量也在同步增长,截至 2022 年 9 月,已达到约 140 人。目前来说,对 BPF 子系统的大部分贡献都不是来自 Meta BPF 小组了。
BPF编程环境
虽然大多数 BPF 程序是用 C 语言编写的,并用 LLVM Clang 编译器编译,但 BPF program 只是二进制 BPF 字节码对象文件,并未规定要用某种特定的语言来写。比如说,BPF 程序可以使用 Aya 来采用 Rust 编写,甚至可以直接用 BPF 汇编语言编写。也就是说,C是 BPF 程序的典型(canonical)编程语言;Starovoitov 的演讲继续概述了 BPF program 开发中 C 编程环境是如何演进的。
这个新的编程环境混合使用了 C 语言扩展以及运行时环境的组合实现的,这个运行时环境包含了 Clang、用户空间的 BPF 加载器库(libbpf)和内核中的 BPF 子系统。要想创建一个 BPF 程序,用户只要用 C 语言写一个程序,由 Clang 的 backend 实现来转换成 BPF 指令。在运行程序时,libbpf 将 BPF 程序加载到内存中,对程序进行重定位以使其可以跨平台以及不同的内核版本从而具备良好的可移植性,然后调用 kernel 来加载程序。最后在内核中,verifier 会采用静态方式验证该程序是否可以安全运行,然后启用之。
然而,BPF 的编程环境并不是一上来就这么丰富的。在 BPF 的早期,程序被要求使用 Starovoitov 所说的 "restricted C"。BPF 程序中的所有函数都必须完全是 inline 的,loop 循环、静态变量和全局变量以及内存分配都是不允许的。也没有类型信息(type information),所以 BPF 程序只能接收单一的、固定的 input context,用于 tracing 以及 network-filtering 相关功能。
尽管在这样一个高度限制性的环境中编写 BPF 程序也是很有用的,但很明显, BPF 所支持的使用场景还可以得到很大的扩展。其中一个扩展就是允许在 BPF 程序中使用静态函数。这样做需要使用 libbpf 在程序加载时对内核 BPF 程序进行重定位。经过多年的设计和尝试,最终也增加了对有限循环的支持,此外也支持了 iterator。
Extending the programming environment past full C
虽然这些使得 BPF 更接近于完整的 C 语言了,但最终可以看到,BPF 程序需要的一些功能甚至在完整的 C 语言标准中都没有。于是 BPF 社区开始扩展 BPF 编程环境,从而包括一些传统 C 语言没有的新特性。其中一个扩展功能就是 "一次编译-到处运行"(CO-RE, Compile Once - Run Everywhere)。
CO-RE 使 BPF 程序可以在不同的内核版本和平台上都可以运行。在 BPF 程序中,访问内核数据结构是很常见的行为。然而,内核没有为 struct layer 确保 ABI 不变,因此,如果内核结构在未来的版本或不同的 config 下发生了变化,在固定偏移的地方对内核结构进行读取的 BPF 程序可能就会读到错误的值。CO-RE 通过利用运行中的内核中的 BPF 类型格式(BTF)数据来解决这个问题。在加载一个程序时,libbpf 对所有的 struct 的访问都会进行重定位,以便根据当前运行的内核的 BTF 信息让被访问的字段的偏移量匹配上。
Starovoitov 还描述了 BPF 编程环境的其他一些有趣的新增功能。其中一个是 kptrs,它允许将内核内存的指针存储在 BPF map 中。另一个功能是允许程序在加载时访问内核 config 参数。内核 module 只能使用编译时设置的 config 值,但 BPF 程序在加载时可以根据当前内核的配置来决定自己的行为。还有一个特点是 "type tags",可以让程序能对变量进行 annotation,从而描述它们的使用方式。例如,kptrs 可以用 __kptr 和 __kptr_ref type tags 来进行标注,从而表明它们分别是 unreferenced 或者 referenced kptr。当然指针也可以用 __user 或 __percpu 标准,来告诉编译器和 verifier 这个指针分别指向用户内存或 per-CPU 内存。
Plans for the future
目前正在设计和实现更多的扩展,包括 lock-correctness 正确性验证,以及支持 BPF 程序包含 assertion。lock 的验证乍一看似乎是一个很难解决的问题,而 Dave Marchevsky 和 Kumar Kartikeya Dwivedi 都已经发出了 RFC patch set 来实现用于 lock 验证的新 map type。Marchevsky 的 patch set 提出了一个新的红黑树 map type,而 Dwivedi 的 patch set 提出了一个 list map type。这两个 patch set 都实现了共同的效果,允许 BPF 程序执行由 verifier 检查和验证过的 locking 机制。
assertion 验证仍处于规划阶段,实现起来可能会很复杂。assertion 将作为给编译器和 verifier 的信号,assertion 被用来指示程序中的一些不变的因素,这些不变因素的失败将导致程序中止。Starovoitov 声称,弄清如何让程序中止,这会是一个 "有趣" 的问题,因为它需要安全地对堆栈进行 unwind,调用 kptr destructor,以及其他收尾工作。
Starovoitov 在演讲的最后分享了他对 BPF 未来的观点:会取代内核模块成为扩展内核的有效方式。早期版本的 BPF 程序看起来更像是带有固定的 BPF helper function 和固定的 map type 的用户空间程序,而如今新的 BPF 已经可以让用户在更多个性化使用场景下对内核进行扩展。事实上,这样的使用场景已经在 upstream 社区被提出来了。在 Starovoitov 之后在 LPC 发言的 Benjamin Tissoires,一直在开发一个 patch set,希望用 BPF 程序来 fix 人类输入设备(HID)的 quirk。到目前为止,还没有一个内核 module 被 BPF 程序完全取代掉,不过,很期待看到内核的其他一些功能可以在 BPF 程序中实现。
一位听众要求了解 Starovoitov 所提到的 lock-correctness 验证的更多细节。Starovoitov 说,这个工作还在进行当中,但他乐观地认为可以找到一种方法来进行 static lock checking,从而验证数据保护是正确的,并保证不会发生死锁。Dave Miller 回应说,如果锁可以由 verifier 进行静态检查,那么可能可以研究一下 locking 逻辑是否可以由 verifier 自动生成。Starovoitov 回答说,这就是他们希望实现的目标,目前的设计中将 lock 和受保护的数据在同一次 allocation 中放在一起。对于不能跟 lock 放在一起的数据,可以用 BTF type tag 来指定它需要明确进行锁保护。
审核编辑 :李倩
全部0条评论
快来发表一下你的评论吧 !