BPF CO-RE(三)——在用户开发中的应用
本节介绍一些实际BPF应用开发需要面临的一些经典场景,以及BPF CO-RE的作用。裁剪翻译自https://nakryiko.com/posts/bpf-portability-and-co-re/的对应小节。个人翻译,水平有限,如有错漏或想要交流的问题,欢迎评论。
摆脱内核头文件依赖
内核BTF信息不仅可用于字段重定向,其也可用于生成一个包含所有内部内核类型的大型头文件(“vmlinux.h”),从而完全避免对系统范围的内核头文件依赖。可通过bpftool生成 :
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
当使用vmlinux.h
后,就无需再#include <linux/shed.h>
、#include <linux/fs.h>
之类的,只需用#include "vmlinux.h"
并不再考虑kernel-devel
包。
vmlinux.h
包括所有内核类型:
- 作为UAPI的一部分暴露的;
- 所有通过
kernel-devel
可获取的内部类型; - 一些无法通过其他途径获得的内部内核类型。
但是,BTF(如DWARF一样)不记录#define
宏,所以很多常用宏可能在vmlinux.h
中缺失。而大多数缺失的宏会作为libbpf的bpf_helpers.h
(内核侧的“库”)的一部分提供。
读取内核结构字段
以读取内核结构体task_struct的pid为例。
通过BCC读取十分简便:
pid_t pid = task->pid;
BCC会重写task->pid
为一个bpf_probe_read()
的调用。然而有时其可能不生效,这取决于使用的表达式的复杂性。
而使用libbpf时,其不具备BCC的代码重写魔术。有以下几种方式来实现:
-
若使用添加了
BTF_PROG_TYPE_TRACING
的BPF程序,BPF验证器将会智能化,能够本地理解并追踪BTF类型,并允许追踪指针和直接读取内核内存,同时避免bpf_probe_read()
调用,因此无需编译器重写魔术。
libbpf+BPF_PROG_TYPE_TRACING方式:pid_t pid = task->pid;
-
要将该功能与BPF CO-RE配对以支持可移植性字段读取,需要将该代码包含在
__builtin_preserve_access_index
编译器build-in中。BPF_PROG_TYPE_TRACING+BPF CO-RE方式:
pid_t pid = __builtin_preserve_access_index(({ task->pid; }));
-
但是,考虑到
BPF_PROG_TYPE_TRACING
的前沿性,可能尚不能使用;故需使用bpf_probe_read()
替代。non-CO-RE libbpf方式:
pid_t pid; bpf_probe_read(&pid, sizeof(pid), &task->pid);
-
而CO-RE+libbpf则有两种方式:
-
直接使用
bpf_core_read()
替代bpf_probe_read()
:pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);
bpf_core_read()
是一个将所有参数直接传给bpf_probe_read()
的简单宏,但它也使Clang为第三个参数(&task->pid
)记录字段偏移重定位,方式是将其(第三个参数)通过__builtin_preserve_access_index()
传递。 -
使用上述过程即为第二种方式:
pid_t pid; bpf_probe_read(&pid, sizeof(pid), __builtin_preserve_access_index(&task->pid));
然而,这些
bpf_probe_read()
/bpf_core_read()
调用很快就会过时,特别是当处理一堆通过指针链接在一起的结构时。例如,要获取当前进程的可执行二进制文件的索引节点号,必须使用 BCC 执行以下操作:u64 inode = task->mm->exe_file->f_inode->i_ino;
使用普通的
bpf_probe_read()
/bpf_core_read()
,这个过程会很繁琐。但BPF CO-RE则提供了一个帮助器宏:u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);
或者,如果已有一个想要读入的变量,可以执行以下操作并避免额外的中间变量(隐藏在
BPF_CORE_READ
内):u64 inode; BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino);
有一个相应的
bpf_core_read_str()
,它是bpf_probe_read_str()
的直接替代品。还有一个BPF_CORE_READ_STR_INTO()
宏,其工作方式与BPF_CORE_READ_INTO()
类似,但将为最后一个字段执行bpf_probe_read_str()
调用。还可以使用适当命名的
bpf_core_field_exists()
宏来检查目标内核中是否存在字段,并根据它是否存在执行不同的操作:pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;
此外,可以使用
bpf_core_field_size()
宏来捕获任意字段的大小,以防目标字段的大小在不同内核版本间变化:u32 comm_sz = bpf_core_field_size(task->comm);
最重要的是,对于必须从内核结构中读取位字段的罕见(但很难支持跨内核)情况,有特殊的
BPF_CORE_READ_BITFIELD()
(使用直接内存读取)和BPF_CORE_READ_BITFIELD_PROBED()
(依赖于bpf_probe_read ()
调用) 宏。 它们抽象出了提取位字段的痛苦的细节,同时保留了跨内核版本的可移植性:struct tcp_sock *s = ...; /* with direct reads */ bool is_cwnd_limited = BPF_CORE_READ_BITFILED(s, is_cwnd_limited); /* with bpf_probe_read()-based reads */ u64 is_cwnd_limited; BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &is_cwnd_limited);
-
处理内核版本和配置中的不同
一些情况下,BPF程序需要处理的内核不同点不止是常见内核结构体的直接结构变化。还可能有:
- 字段重命名,语义不变;
- 字段不变,语义更换;例如,从4.6内核之后的某个时间开始,
task_struct
中的utime
和stime
字段的单位从jiffies切换为纳秒。 - 其他时候,您想要提取的数据存在于某些内核配置中,但在其他内核配置中未编译(compile out);
- 可能还有许多其他场景,其中不可能有一个适合所有内核的通用类型定义;
对于此类情况,BPF CO-RE供了两种补充解决方案:libbpf提供的extern Kconfig变量和结构风格(struct flavors)。
BPF程序可以使用一个熟知的名字(例如,“LINUX_KERNEL_VERSION”以提取正在运行的内核版本)或使用一个匹配Kconfig的一个键(key)的名字(例如,“ CONFIG_HZ”以获得构建内核所用的HZ值)定义一个外部变量,libbpf 会发挥其魔力,设置所有内容,使您的 BPF 程序可以像使用任何其他全局变量一样使用此类外部变量。这些变量将具有正确的值,与执行 BPF 程序的活动内核相匹配。此外,BPF 验证程序将跟踪这些变量作为已知常量,并能够使用它们进行高级控制流分析和死代码消除。
例如,使用BPF CO-RE提取线程的CPU用户时间:
extern u32 LINUX_KERNEL_VERSION __kconfig;
extern u32 CONFIG_HZ __kconfig;
u64 utime_ns;
if(LINUX_KERNEL_VERSION >= KERNEL_VERSION(4, 11, 0))
utime_ns = BPF_CORE_READ(task, utime);
else
/* convert jiffies to nanoseconds **/
utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ);
另一种机制,结构风格,有助于解决不同内核具有不兼容类型,因此不可能使用单个公共结构定义为两个内核编译单个 BPF 程序的情况。以下是一个有点人为的示例,展示如何使用结构风格来提取fs
/fsbase
(已重命名,如上所述)以进行一些线程本地数据处理:
/* up-to-date thread_struct definition matching */
struct thread_struct {
...
u64 fsbase;
...
};
/* legacy thread_struct definition for <=4.6 kernels */
struct thread_struct___v46 { /* ___v64 is a "flavor" part */
...
u64 fs;
...
};
extern int LINUX_KERNEL_VERSION __kconfig;
...
struct thread_struct *thr = ...;
u64 fsbase;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(4, 6, 0))
fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs);
else
fsbase = BPF_CORE_READ(thr, fsbase);
在此示例中,BPF应用程序将 <= 4.6 内核的“旧版”struct thread_struct
定义定义为struct thread_struct___v46
。类型名称中的三个下划线及其后面的所有内容都被认为是该结构的“风格”。这个风格部分将被libbpf忽略,这意味着在执行必要的重定位时,该类型定义仍将与实际运行的内核的struct thread_struct
相匹配。这种约定允许在单个 C 程序中对同一内核类型有多个替代(且不兼容)的定义,并且能够在运行时选择最合适的一个(例如,通过内核版本特定的逻辑,如上例所示)使用类型转换为结构风格来提取必要的字段。
如果没有结构风格,就不可能真正拥有一个可以在上述情况下在多个内核上运行的“一次编译”程序。需要使用#ifdef
的源代码,编译成两个独立的BPF程序变体,并由控制应用程序在运行时手动选择适当的变体。所有这些都只会增加不必要的复杂性和痛苦。虽然不透明,但BPF CO-RE允许使用熟悉的C代码结构来解决这个问题,即使对于这种高级场景也是如此。
根据用户提供的配置调整行为
了解BPF程序中的内核版本和配置有时仍然不足以就从内核中获取什么数据以及如何获取数据做出正确的决定。在这种情况下,用户空间控制应用程序可能是唯一知道到底需要做什么以及需要启用或禁用哪些功能的一方。这通常通过某种配置数据进行通信,在用户空间和BPF程序之间共享。
如今,在不依赖BPF CO-RE的情况下实现这一目标的一种方法是使用BPF映射作为配置数据的容器。BPF程序执行BPF映射查找以提取配置并根据此配置更改其控制流。
以下为该方式的主要缺陷:
- 每次 BPF 程序尝试获取配置值时进行映射查找的运行时开销。这可能会迅速增加,并且在某些高性能BPF应用程序中是无法接受的。
- 配置值虽然在BPF程序启动后不可变且只读,但在验证阶段仍被BPF验证器视为未知的黑盒值。这意味着验证者无法删除死代码并执行其他高级代码分析。这使得BPF程序逻辑的可配置部分不可能使用仅在新内核上支持的前沿功能,而不会在旧内核上运行时破坏相同的程序。这是由于BPF验证者必须悲观地假设配置可以是任何内容,并且无论如何都可能会调用这种“未知”功能,尽管用户配置显然使之不可能。
这种(诚然很复杂)用例的解决方案是使用只读全局数据。在BPF程序加载到内核之前,它由控制应用程序设置一次。从BPF程序方面来看,这看起来就像正常的全局变量访问。不会有任何BPF映射查找开销——全局变量被实现为直接内存访问。控制应用程序端将在加载BPF程序之前设置初始配置值,因此当BPF验证程序验证程序时,配置值将是众所周知的且只读的。这将允许BPF验证器将它们作为已知常量进行跟踪,并使用其高级控制流分析来执行死代码消除。
因此,对于上面的例子,在较旧的内核上,BPF验证器将证明,未知的BPF帮助器永远不会被使用,并将完全消除该代码。不过,在较新的应用程序上,应用程序提供的配置将有所不同,并且将允许使用新的BPF帮助器,并且该逻辑将由BPF验证程序成功验证。例如:
/* global read-only variables, set up by control application */
const bool use_fancy_helper;
const u32 fallback_value;
...
u32 value;
if(use_fancy_helper)
value = bpf_fancy_helper(ctx);
else
value = bpf_default_helper(ctx) * fallback_value;
在用户空间侧,应用很容易通过BPF骨架提供对应的配置。
回顾
BPF CO-RE的目标是帮助BPF开发人员以简单的方式解决简单的可移植性问题(例如读取结构字段),并使其仍然可以解决复杂的可移植性问题(例如不兼容的数据结构更改,复杂的用户空间控制条件等)。这使得BPF开发人员能够保持“编译一次—到处运行”范例。这是通过组合一些BPFCO-RE构建块来实现的,如上所述:
vmlinux.h
消除对于内核头文件的依赖;- 字段重定位(字段偏移量、存在性、大小等)使得从内核提取数据具备可移植性;
- libbpf提供的Kconfig外部变量使得BPF程序能够容纳多样的内核版本和配置专有的变化;
- 当其他所有都未生效时,app提供的只读配置和结构风格是解决应用需要处理的任何场景的根本工具。
成功编写、部署和维护可移植BPF程序并不需要CO-RE的所有功能。所有这些提供了良好的可用性和熟悉的将C代码编译为二进制文件并分发轻量级二进制文件的工作流程。不再需要拖拽重量级编译器库并为运行时编译支付宝贵的运行时资源。也不再需要在运行时捕获微不足道的编译错误。