BPF CO-RE(一次编译,随处运行)是一种编写可移植 BPF 应用程序的现代方法,这些应用程序无需修改即可在多个内核版本和配置上运行,也无需在目标机器上进行运行时源代码编译。这与 BCC 框架提供的更传统的方法截然相反,在 BCC 框架中,BPF 应用程序源代码的编译被推迟到目标主机运行时进行,并使用重量级的编译器工具链来实现这一点。请参阅介绍 BPF CO-RE 概念的博客文章 ,其中解释了为什么这一切对于许多实际的 BPF 应用程序如此重要且必要,以及在没有内核 BTF 的情况下,实现 BPF 会面临哪些困难。
随着 BPF CO-RE 方法的成熟,关于其所有功能以及如何在实践中使用它们的一些实用指导却严重缺失。在这篇博文中,我将尝试填补这一空白,并介绍 BPF CO-RE(以及作为其规范实现的 libbpf )提供的所有不同功能。如果您之前编写过 BPF CO-RE 应用程序,那么您很可能至少使用过其中描述的一些功能。但不幸的是,其中一些功能仍然鲜为人知。然而,正是这些鲜为人知的 BPF CO-RE 秘密有时使得现实世界的 BPF 应用程序可行、简单且易于实现和支持,避免了在主机上编译或预编译同一 BPF 应用程序的多个变体(风格)的复杂性,每个变体都针对不同的内核。
这篇文章篇幅很长,但它也旨在提供某种参考指南,因此与其将其拆分成几个部分并在几周内逐步发布,不如将其整合成一个整体。它分为三个部分,从最常用的功能到更高级、不太常用的功能,希望能够帮助那些刚开始在 BPF CO-RE 范式中编写 BPF 应用程序的人自然地掌握其中的技巧。
在本文中,我将假设您使用 vmlinux.h ,它为内核提供了 CO-RE 可重定位的类型定义,并且可以通过 bpftool 工具生成。如果您不熟悉 vmlinux.h ,请参阅 libbpf-bootstrap 博客文章 。在文章结尾附近的“更高级用法”部分,我还将介绍如何在没有 vmlinux.h 的情况下使用 BPF CO-RE。
在接下来的内容中,我会尽量保持高层次的阐述,除非绝对必要,否则不会深入探讨具体的实现细节。如果您想了解更多信息,建议您查看 bpf_core_read.h 头文件,并在 BPF 邮件列表中提问。
Reading kernel data 读取内核数据
到目前为止,最常见的 BPF CO-RE 操作是从某些内核结构中读取字段的值。libbpf 提供了一系列辅助函数,使读取字段变得简单且可 CO-RE 重定位。CO -RE 可重定位意味着,无论结构体的实际内存布局如何(这可能会根据实际使用的_内核版本_和_内核配置而_变化),BPF 程序都会进行调整,以相对于结构体起始位置的正确实际偏移量读取字段。
bpf_core_read()
以 CO-RE 可重定位方式读取字段的最基本助手是 bpf_core_read(dst, sz, src) ,它将从 src 引用的字段中读取 sz 字节到 dst 指向的内存中:
struct task_struct *task = (void *)bpf_get_current_task();
struct task_struct *parent_task;
int err;
err = bpf_core_read(&parent_task, sizeof(void *), &task->parent);
if (err) {
/* handle error */
}
/* parent_task contains the value of task->parent pointer */
bpf_core_read() 与 bpf_probe_read_kernel() BPF 辅助函数类似,不同之处在于它记录了目标内核中需要重定位的字段信息。例如,如果由于在 struct task_struct 前面添加了新字段,导致 parent 字段在 struct task_struct 中移到了不同的偏移量,libbpf 会自动将实际偏移量调整为合适的值。
需要牢记的一点是,字段的大小不会自动重定位 ,只有其偏移量会重定位。因此,如果您读取的字段(例如 struct )的大小发生变化,则可能会遇到麻烦。请参阅 “调整内核类型和字段的大小” 部分,了解如何处理这种情况。一般建议尽可能不要读取整个结构体字段。最好只读取您最终感兴趣的原始字段。
bpf_core_read_str()
就像 BPF 辅助函数有两个: bpf_probe_read_kernel() 和 bpf_probe_read_kernel_str() 一样,前者读取指定数量的字节,而后者读取一个长度可变的以零结尾的 C 字符串。bpf_core_read_str bpf_core_read_str() 也与 bpf_core_read() 相对应。它的工作原理与 bpf_probe_read_kernel_str() 类似,只是它记录了源字符数组字段(包含一个以零结尾的 C 字符串)的 CO-RE 重定位信息。因此, bpf_core_read_str() 是 bpf_probe_read_kernel_str() 的 CO-RE 可重定位版本。
请注意字符数组字段和字符指针字段之间重要但微妙的区别。在 C 语言中,读取字符串值时它们可以互换使用,因为编译器会自动将数组视为指针。然而,这种区别在 CO-RE 环境中_非常重要_ 。
让我们看一下我们想要读取的假设内核类型:
struct my_kernel_type {
const char *name;
char type[32];
};
name 字段指向字符串的存储位置,但 type 字段实际上是包含字符串的内存。如果需要使用 CO-RE 读取 name 指向的字符串,正确的处理方法是先以 CO-RE 可重定位的方式读取指针的值 ,然后执行普通(非 CO-RE) bpf_probe_read_kernel_str() 读取(下面的示例为简洁起见忽略了错误处理):
struct my_kernel_type *t = ...;
const char *p;
char str[32];
/* get string pointer, CO-RE-relocatable */
// 先获取指针的地址
bpf_core_read(&p, sizeof(p), &t->name);
/* read the string, non-CO-RE-relocatable, pointer is valid regardless */
// 在根据字符串指针读取对应的字符串
bpf_probe_read_kernel_str(str, sizeof(str), p);
如果我们需要读取字符串 type 则等效示例如下:
struct my_kernel_type *t = ...;
char str[32];
/* read string as CO-RE-relocatable */
bpf_core_read_str(str, sizeof(str), &t->type);
花点时间想想为什么第一个例子不能用 bpf_core_read_str() 来处理( 提示:你会把指针值解释成一个 C 字符串本身 ),为什么第二个例子不能先读取指针,再读取字符串( 提示:字符串本身是结构体的一部分,所以没有专门的指针,它位于 t 指针指向的偏移量处 )。这很微妙,幸好并不经常出现,但如果人们没有意识到其中的区别,在实践中就会非常令人困惑。
BPF_CORE_READ()
bpf_core_read() 虽然进行了大量控制和仔细的错误处理,但直接使用却相当麻烦,尤其是在读取需要通过更长的指针取消引用链访问的字段时。
让我们看一个读取正在运行的进程的主可执行文件名称的示例。如果你用 C 语言编写了一个简单的内核代码,并且想要这样做,那么你必须这样做:
struct task_struct *t = ...;
const char *name;
name = t->mm->exe_file->path.dentry->d_name.name;
/* now read string contents with bpf_probe_read_kernel_str() */
请注意,指针解引用的序列,与一些子结构访问(例如 path.dentry 和 d_name.name )混合在一起。使用 bpf_core_read() 执行类似的操作很快就会变得一团糟:
struct task_struct *t = ...;
struct mm_struct *mm;
struct file *exe_file;
struct dentry *dentry;
const char *name;
bpf_core_read(&mm, 8, &t->mm);
bpf_core_read(&exe_file, 8, &mm->exe_file);
bpf_core_read(&dentry, 8, &exe_file->path.dentry);
bpf_core_read(&name, 8, &dentry->d_name.name);
/* now read string contents with bpf_probe_read_kernel_str() */
诚然,这是一个相当极端的例子,通常指针解引用链不会这么长,但关键在于:使用这种方法很痛苦。尽管上例中完全忽略了错误处理,但结果仍然如此。
为了使此类多步骤读取操作更易于编写,libbpf 提供了 BPF_CORE_READ() 宏。让我们看一下如何使用 BPF_CORE_READ() 简化上述代码:
struct task_struct *t = ...;
const char *name;
name = BPF_CORE_READ(t, mm, exe_file, path.dentry, d_name.name);
/* now read string contents with bpf_probe_read_kernel_str() */
比较“本机 C”示例与使用 BPF_CORE_READ() 示例:
/* direct pointer dereference */
name = t->mm->exe_file->path.dentry->d_name.name;
/* using BPF_CORE_READ() helper */
name = BPF_CORE_READ(t, mm, exe_file, path.dentry, d_name.name);
基本上,每个指针的解引用在宏调用中都会变成逗号。每个子结构的访问都保持原样(path.dentry)。非常简单
您可能已经注意到, BPF_CORE_READ() 直接返回读取的值,而不会传播错误。如果任何指针为 NULL 或指向无效内存,则返回 0 (或 NULL )。但是,如果您确实需要错误传播和处理,则必须使用低级 bpf_core_read() 原语并显式处理错误。在实践中,这通常不是问题,也不是必需的。
BPF_CORE_READ_INTO()
在某些情况下,将结果读入目标内存是必要的,或者更方便的做法是将其读入目标内存,而不是像 BPF_CORE_READ() 那样直接返回。例如,直接返回值不起作用的一个常见情况是读取 C 数组(例如,从套接字结构体中读取 IPv4 地址),因为 C 语言不允许直接从表达式返回数组。对于这种情况,libbpf 提供了 BPF_CORE_READ_INTO() 宏,其行为与 BPF_CORE_READ() 类似,但会将 final 字段的值读入目标内存。将上一个示例转换为 BPF_CORE_READ_INTO() 后,我们得到:
struct task_struct *t = ...;
const char *name;
int err;
err = BPF_CORE_READ_INTO(&name, t, mm, binfmt, executable, path.dentry, d_name.name);
if (err) { /* handle errors */ }
/* now `name` contains the pointer to the string */
请注意 BPF_CORE_READ_INTO() 中新增的 &name 参数,以及可以获取上一次操作(例如读取 d_name.name )的错误代码。总的来说, BPF_CORE_READ() 在实际使用中更加方便,也更易于阅读。
BPF_CORE_READ_STR_INTO()
对于最后一个字段是字符数组字段的情况(就像上面假设的 name 与 type 示例一样),有一个 BPF_CORE_READ_STR_INTO() 宏,现在您应该已经大致了解它的工作原理了。如果没有,请重新阅读 bpf_core_read_str() 部分。
BTF-enabled BPF program types with direct memory reads
上面讨论了 BPF_CORE_READ() 宏系列,需要注意的是,并不总是需要使用它们来执行 CO-RE 可重定位读取。或者说,并不总是需要 “探测读取” (即使用 BPF 辅助函数读取)内存。有时,您可以_直接访问_内核内存。
某些 BPF 程序类型是 “BTF-enabled”,这意味着内核中的 BPF 验证器知道传入 BPF 程序的输入参数的类型信息。这使得 BPF 验证器能够知道哪些内存可以直接从内核安全地读取,而无需调用 bpf_core_read() 或 bpf_probe_read_kernel() 。此类启用 BTF 的 BPF 程序类型包括:
- 启用 BTF 的原始跟踪点(libbpf 术语中的
SEC("tp_btf/...")); fentry/fexit/fmod_retBPF 程序- BPF LSM programs; BPF LSM 程序;
- 可能还有一些,但我懒得去检查。
对于这样的程序,如果它们获取指向某个内核类型(例如 struct task_struct * )的指针,BPF 程序代码就可以执行直接内存解引用,甚至跟踪这些指针。因此,对于我们上面用来演示 BPF_CORE_READ() 用法的复杂示例,当使用 fentry BPF 程序时,您只需执行以下操作:
struct task_struct *t = ...;
const char *name;
name = t->mm->binfmt->executable->path.dentry->d_name.name;
是的,它与“原生 C”假设示例完全相同。不过请记住, 要获取字符串本身的内容 ,仍然需要使用 bpf_probe_read_kernel_str() 。
这种直接内存访问快速、方便、简单,你绝对应该尽可能地使用它。遗憾的是,在现实世界中仍然有很多情况需要你明确地依赖“探测读取”,所以在可预见的未来, BPF_CORE_READ() 将会是你的好朋友,所以一定要熟悉它。
读取不同大小的位域和整数
从 BPF 读取位字段一直是一项挑战。BPF 应用程序开发者必须竭尽全力,编写难以维护且繁琐的代码,才能从内核类型中提取位字段值。以 struct tcp_sock 为例,它包含许多以位字段形式编码的有用信息。即使使用 BCC 的源代码编译方法,提取这些位字段仍然是一项繁琐且繁重的维护工作。
幸运的是,libbpf 提供了两个易于使用的宏,用于以 CO-RE 可重定位的方式读取位域: BPF_CORE_READ_BITFIELD() 和 BPF_CORE_READ_BITFIELD_PROBED() 。当要读取的数据必须进行“探测读取”时,必须使用 _PROBED 变体,就像 BPF_CORE_READ() 一样。BPF_CORE_READ_BITFIELD BPF_CORE_READ_BITFIELD() 应仅在可直接访问内存时使用(例如,从 fentry/ BPF 程序中,请参阅上文 “支持 BTF 的 BPF 程序类型,具有直接内存读取功能” 部分)。这两个宏都将位域的值返回为 u64 整数。以下是从 struct tcp_sock 读取其中一个位域的示例:
static u64 sk_get_syn_data(const struct tcp_sock* tp)
{
/* extract tp->syn_data bitfield value */
return BPF_CORE_READ_BITFIELD_PROBED(tp, syn_data);
}
就这么简单。使用 BCC 实现同样的效果,结果如下(至于为什么有效以及什么时候会失效,留给读者练习吧):
static u64 sk_get_syn_data(const struct tcp_sock* tp)
{
u8 s;
/* get byte before tlp_high_seq */
bpf_probe_read(&s, 1, &(tp->tlp_high_seq) - 1);
/* syn_data is the third bit of that byte in little-endian */
return (s >> 2) & 0x1;
}
由于 struct tcp_sock 在不同内核版本间会发生变化,编写、读取和维护起来非常麻烦。有了 BPF_CORE_READ_BITFIELD_PROBED() ,这变得轻而易举。
值得注意的是 BPF_CORE_READ_BITFIELD() 和 BPF_CORE_READ_BITFIELD_PROBED() 的另一个重要特性。它们不仅可以读取位域,还可以读取任何整数域。无论域的实际性质如何(位域或最大 8 字节的任意大小的整数),宏都会返回正确带符号扩展的 8 字节整数。即使域从整数变为位域,反之亦然,它也能继续工作。即使域从 int 变为 u8 ,它也能继续工作。因此, BPF_CORE_READ_BITFIELD() 宏是一种读取任何整数域的通用方法, 无论其性质或大小如何。
Sizing kernel types and fields
如前面一节所述, BPF_CORE_READ() 不会自动使不同大小的读取字段(例如整个结构或数组)可 CO-RE 重定位,因为通常很难预先分配适当数量的目标内存以适应内核中任何可能的大小变化。
然而,在某些情况下,了解字段或类型的大小非常重要。为了满足这些需求,BPF CO-RE 提供了两个辅助函数: bpf_core_type_size() 和 bpf_core_field_size() 。它们的用法类似于 bpf_core_type_exists() 和 bpf_core_field_exists() (将在下一节中介绍),但它们不是返回 0 或 1,而是返回字段或类型的大小(以字节为单位)。
如何处理该值完全由您决定:您可以将其作为第二个参数传递给 bpf_core_read() ,以使读取操作完全支持 CO-RE 重定位。如果您正在处理结构体数组,并且需要跳过前几个实例,则可以使用 bpf_core_type_size() 计算正确的字节偏移量,以到达第 N 个元素的开头。或者,您也可以仅将其用于调试和报告目的,这完全由您决定,BPF CO-RE 不会规定如何使用其功能。
处理内核变更和特性检测
BPF_CORE_READ() 宏系列是 BPF CO-RE 的主力,但使用 BPF CO-RE 构建实用的 BPF 应用程序还有很多其他功能。
BPF 应用程序经常遇到的一个问题是需要执行特性检测。例如,检测某个主机内核是否支持某些新的可选特性,以便 BPF 应用程序获取更多信息或提高效率。如果不支持,BPF 应用程序宁愿回退到支持旧内核的代码,也不愿直接失败。
BPF CO-RE 提供了一系列不同的机制来满足此类需求。当然,除了功能检测之外,还可以将下面描述的机制用于其他用例,但我将以功能检测作为主要用例来描述所有内容。
bpf_core_field_exists()
bpf_core_field_exists() 允许检查给定的内核类型是否包含指定的字段。在内核特性检测的上下文中,如果在向某个内核类型添加某个特定字段的同时添加了某个所需的内核特性,则可以通过直接使用 bpf_core_field_exists() 检查来检测此类特性。
作为一个具体的例子,检测内核是否支持基于 perf 的 BPF 程序类型(tracepoints、kprobes、 uprobes )的 BPF cookie 的一种方法是:
union bpf_attr *attr = ... /* could be NULL */;
if (bpf_core_field_exists(attr->link_create.perf_event.bpf_cookie)) {
/* bpf_cookie is supported */
} else {
/* bpf_cookie is NOT supported */
}
上面的示例假设 BPF 程序有一个 union bpf_attr * 类型的变量。它可以是 NULL ,但这无关紧要,因为指针本身永远不会被读取,它只需要向编译器传递类型信息。对于没有可用所需类型的变量的情况,可以编写等效的检查方法(使用 C 类型系统特性):
if (bpf_core_field_exists(
((union bpf_attr *)0)->link_create.perf_event.bpf_cookie) {
/* bpf_cookie is supported */
} else {
/* bpf_cookie is NOT supported */
}
这里,如果主机内核中的 union bpf_attr 中没有 link_create.perf_event.bpf_cookie ,则 if / else 第一个分支中的代码_永远不会被执行_ (也_不会被验证_ )。
值得重申的是,此类代码会被 BPF 验证器正确检测为死代码 ,因此永远不会被验证 。这意味着此类代码可以使用主机内核中不存在的内核和 BPF 功能(例如,新的 BPF 辅助程序),而不必担心 BPF 验证失败。例如,如果上面的第一个分支使用 bpf_get_attach_cookie() 辅助程序来实现 BPF cookie 功能,那么该程序在尚不支持该辅助程序的旧内核上也能正确验证。
bpf_core_type_exists()
对于类型存在本身至关重要的情况,BPF CO-RE 提供了一种检查类型存在的方法 bpf_core_type_exists() 辅助函数。以下是检测内核是否支持 BPF 环形缓冲区的示例:
if (bpf_core_type_exists(struct bpf_ringbuf)) {
/* BPF ringbuf helpers (e.g., bpf_ringbuf_reserve()) exist */
}
请务必确保在某个地方定义了 struct bpf_ringbuf 定义(即使为空),否则您将需要检查 bpf_ringuf 前向声明是否存在,这几乎肯定不是您想要的。对于较新的 vmlinux.h 来说,这应该不是问题,但请注意。
bpf_core_enum_value_exists()
能够检测给定枚举值是否存在非常有用。这种检查的一个重要实际应用是检测对 BPF 辅助程序的支持 。
每个 BPF 帮助器在 enum bpf_func_id 中都有一个对应的枚举值:
enum bpf_func_id {
...
BPF_FUNC_ringbuf_output = 130,
BPF_FUNC_ringbuf_reserve = 131,
...
};
因此,检查 BPF 辅助函数 bpf_xxx() 是否存在的最直接方法是检查 enum bpf_func_id 中是否存在 BPF_FUNC_xxx 。因此,我们不必像上例中那样使用 bpf_core_type_exists(struct bpf_ringbuf) 进行类型检查,而是可以更明确地说明我们的意图:
if (bpf_core_enum_value_exists(enum bpf_func_id, BPF_FUNC_ringbuf_reserve)) {
/* use bpf_ringbuf_reserve() safely */
} else {
/* fall back to using bpf_perf_event_output() */
}
许多其他 BPF 功能也可以通过类似的方式检测。BPF 程序类型和 BPF 映射类型支持只是其中一个例子。
当然,此功能不仅限于与 BPF 相关的功能。任何可以通过字段、类型或枚举器值的存在来检测的内核功能都可以通过 BPF CO-RE 轻松执行。
特性检测并不仅限于基于类型系统的检查。在接下来的几节中,我们将介绍一些其他可用于执行内核特性检测的 BPF CO-RE 机制。它们不仅能够进行特性检测,还能在运行时提取内核特定的信息(例如 Kconfig 值),而这些信息通常无法提前知道。
LINUX_KERNEL_VERSION
有时,检测必要功能是否存在的唯一方法是检查 Linux 内核版本。Libbpf 允许使用一个特殊的 extern 变量,从 BPF 程序代码中执行此操作:
extern int LINUX_KERNEL_VERSION __kconfig;
一旦声明, LINUX_KERNEL_VERSION 就会以与内核本身完全相同的方式编码正在运行的内核版本。该变量的使用方式与其他变量一样:您可以与其进行比较、打印、记录并将其发送到用户空间等等。在所有这些情况下,BPF 验证器都知道它的确切值,因此它可以检测死代码,就像上面描述的基于类型系统的检查一样。
Libbpf 还提供了一个方便的 KERNEL_VERSION(major, minor, patch) 宏,用于与 LINUX_KERNEL_VERSION 进行比较:
#include <bpf/bpf_helpers.h>
extern int LINUX_KERNEL_VERSION __kconfig;
...
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(5, 15, 0)) {
/* we are on v5.15+ */
}
Kconfig extern variables
实际上,libbpf 允许为任何内核配置 (Kconfig) 值声明特殊的 extern 变量。请记住,只有_当内核通过 /proc/config.gz 公开其内核配置时才_支持此功能,幸运的是,这在现代 Linux 发行版中非常常见。支持几种不同类型的变量。它们的使用取决于实际的 Kconfig 值类型:
- for
y/n/mtri-state Kconfig values, you can useextern enum libbpf_tristatevariables which have three possible values defined:TRI_YES,TRI_NO,TRI_MODULE, respectively. Alternatively, declaring anextern charvariable would capture the character value as is (i.e., you literally will have a variable with one of'y','n','m'character values).
对于y/n/m三态 Kconfig 值,可以使用extern enum libbpf_tristate变量,这些变量分别定义了三个可能的值:TRI_YES、TRI_NO、TRI_MODULE。或者,声明一个extern char变量将按原样捕获字符值(即,您实际上将拥有一个包含'y'、'n'、'm'字符值之一的变量)。 - for
y/ntwo-state (boolean) Kconfig values, you can also usebooltype (in addition to already describedcharandenum libbpf_tristatetypes). In such a case,ymaps totrueandnwill be turned intofalse.
对于y/n双状态(布尔值)的 Kconfig 值,您还可以使用bool类型(除了已描述的char和enum libbpf_tristate类型之外)。在这种情况下,y映射到true,而n将变为false。 - for integer Kconfig values, use one of the C integer types: all 1-, 2-, 4-, and 8-byte signed and unsigned integers are supported. If the actual Kconfig value doesn’t fit into a declared integer type, libbpf will emit an error instead of truncating the value.
对于整数 Kconfig 值,请使用 C 整数类型之一:所有 1、2、4 和 8 字节有符号和无符号整数均受支持。如果实际的 Kconfig 值不适合声明的整数类型,libbpf 将发出错误而不是截断该值。 - for string Kconfig values, use
const char[N]array variable. If the actual value doesn’t fit, it will be truncated and zero-terminated, but libbpf will emit a warning.
对于字符串 Kconfig 值,使用const char[N]数组变量。如果实际值不合适,它将被截断并以零结尾,但 libbpf 会发出警告。
请记住,如果 /proc/config.gz 中缺少请求的 Kconfig 值,libbpf 将中止程序加载并显示错误。为了优雅地处理这种情况,请使用 __weak 属性将此类 Kconfig 外部变量声明为弱变量。在这种情况下,如果缺少该值,则会根据所使用的类型假定其为 false 、 TRI_NO 、 '\0' (零字符)、 0 或 "" (空字符串)。
以下是声明和使用不同类型的 Kconfig 外部变量的简单示例:
extern int LINUX_KERNEL_VERSION __kconfig;
extern enum libbpf_tristate CONFIG_BPF_PRELOAD __kconfig __weak;
extern bool CONFIG_BPF_JIT_ALWAYS_ON __kconfig __weak;
extern char CONFIG_BPF_JIT_DEFAULT_ON __kconfig __weak;
extern int CONFIG_HZ __kconfig;
extern const char CONFIG_MODPROBE_PATH[256] __kconfig __weak;
...
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(5, 15, 0)) { ... }
switch (CONFIG_BPF_PRELOAD) {
case TRI_NO: ...; break;
case TRI_YES: ...; break;
case TRI_MODULE: ...; break;
}
if (!CONFIG_BPF_JIT_ALWAYS_ON)
bpf_printk("BPF_JIT_DEFAULT_ON: %c\n", CONFIG_BPF_JIT_DEFAULT_ON ?: 'n');
bpf_printk("HZ is %d, MODPROBE_PATH: %s\n", CONFIG_HZ, CONFIG_MODPROBE_PATH);
Relocatable enums 可重定位枚举
一些 BPF 应用程序遇到的一个有趣挑战是需要处理“不稳定”的内核内部枚举。也就是说,这些枚举没有分配固定的常量和/或整数值。一个很好的例子是 enum cgroup_subsys_id ,它定义在 include/linux/cgroup-defs.h 中,其定义可能因内核编译期间启用的 cgroup 功能而异(详情请参阅 include/linux/cgroup_subsys.h )。因此,如果您需要知道 cgroup_subsys_id::cpu_cgrp_id 的实际整数值,这可能是一个大问题,因为这个枚举位于内核内部并且是动态生成的。
再次,BPF CO-RE 来帮忙了。它允许借助 bpf_core_enum_value() 宏来捕获实际值:
int id = bpf_core_enum_value(enum cgroup_subsys_id, cpu_cgrp_id);
/* id will contain the actual integer value in the host kernel */
Guarding potentially failing relocations
某些内核中某些字段缺失的情况并不少见。如果 BPF 程序尝试使用 BPF_CORE_READ() 读取缺失的字段,则会导致 BPF 验证过程中出现错误。同样,当获取主机内核中不存在的枚举器(或类型)的枚举值(或类型大小)时,CO-RE 重定位也会失败。
不幸的是,目前这个错误相当隐晦(但 libbpf 很快就会改进),所以最好注意它,以防万一你不小心遇到它。如果遇到类似下面的错误,请知道这是因为 CO-RE 重定位找不到相应的字段/类型/枚举:
1: (85) call unknown#195896080
invalid func unknown#195896080
195896080 十六进制表示为 0xbad2310 (代表“bad relo”),它是一个常量,libbpf 用它来标记 CO-RE 重定位失败的指令。libbpf 之所以不会立即报告此类错误,是因为如果需要,BPF 应用程序可以优雅地处理缺少的字段/类型/枚举以及相应的 CO-RE 重定位失败。这使得仅使用一个 BPF 应用程序就可以适应内核类型的剧烈变化(这是“一次编译,随处运行”理念的关键目标)。
当某些字段/类型/枚举可能缺失时,您可以使用处理内核变更部分中描述的检查之一来保护此类代码路径。如果保护得当,BPF 验证器将知道此类代码路径不可能在特定内核中命中,从而将其作为死代码剔除。
如果实际运行的内核确实包含这些片段,这种方法允许选择性地捕获内核信息片段。否则,BPF 应用程序可以干净地回退到其他逻辑,并优雅地处理缺失的功能或数据。只要潜在失败的 CO-RE 重定位得到妥善保护,所有这些都能正常工作。此处的 CO-RE 重定位指的是任何使用 BPF_CORE_READ() 宏系列、类型/字段大小重定位或枚举器值捕获的操作。如果目标字段/类型/枚举不存在或存在不兼容的定义,则任何操作都毫无意义。
继续前面 cpu_cgrp_id 枚举值的示例,为了处理可能未定义此类枚举器的内核(例如,由于未设置 CONFIG_CGROUP_PIDS Kconfig 切换),可以使用 bpf_core_enum_value_exists() 检查( 存在性检查永远不会失败! ),它返回 true / false (严格来说,在 C 语言中为 0 或 1 ):
int id;
if (bpf_core_enum_value_exists(enum cgroup_subsys_id, cpu_cgrp_id))
id = bpf_core_enum_value(enum cgroup_subsys_id, cpu_cgrp_id);
else
id = -1; /* fallback value */
/* use id even if cpu_cgrp_id isn't defined */
上述示例在任何内核上都能正常工作,无论是否存在 cpu_cgrp_id 枚举器,即使 bpf_core_enum_value() 在没有 cpu_cgrp_id 枚举器的内核上会失败。这一切都归功于代码路径的妥善保护。
Advanced topics 高级主题
前面的部分介绍了最常见的 CO-RE 功能。本节将介绍一些您可能需要处理的更高级的主题,具体取决于您的 BPF 应用程序需要处理的内部内核状态的复杂程度以及不同内核版本之间的变化。
定义自己的 CO-RE 可重定位类型定义
到目前为止,我们假设以上所有示例中使用的内核类型都来自 vmlinux.h 头文件,该头文件由最新且足够完整的内核 BTF 生成。但使用 vmlinux.h 并非 BPF CO-RE 的要求。它主要只是为了方便 BPF 应用程序开发者。
此外,有时在更高级的情况下, vmlinux.h 可能不够用。要么是因为所需的类型(目前)不在内核 BTF 中,要么是因为内核中的某些内容发生了不兼容的变化(例如,字段被重命名了),现在您需要处理同一内核类型的两个不兼容定义(我们将在下文中讨论这种不幸的情况)。
无论出于何种原因,定义您自己的内核类型_预期_并使其可 CO-RE 重定位都非常容易。让我们以 struct task_struct 为例。它是一个庞大而复杂的结构体,但通常只需要其整个定义中的几个简单字段。使用 BPF CO-RE,只需声明您需要的字段即可,跳过所有其他字段,从而保持类型定义简洁明了。
假设你只关心 pid 、 group_leader 和 comm 字段。声明如下 struct task_struct 就足以让一切正常工作:
struct task_struct {
int pid;
char comm[16];
struct task_struct *group_leader;
} __attribute__((preserve_access_index));
首先, 字段的顺序根本不重要 。
其次,对于允许直接内存读取的 BPF 程序, __attribute__((preserve_access_index)) 是必需的。例如,启用 BTF 的原始跟踪点 ( SEC(tp_btf) ) 和 fentry / fexit BPF 程序。使用此属性,任何_使用此结构体定义的直接内存读取都将自动实现 CO-RE-relocatable_ 。
当使用显式的 BPF_CORE_READ() 宏系列时, __attribute__((preserve_access_index)) 不是必需的,因为这些宏会自动强制执行。但是,如果您直接使用普通的旧版 bpf_probe_read_kernel() 辅助函数,并且该结构体具有 preserve_access_index 属性,则此类探测读取也会被 CO-RE 重定位。因此,简而言之, 指定此属性始终是一个好主意 。
Handling incompatible field and type changes
如前几节所述,在某些情况下,内核类型和字段的更改会导致两个不同内核的类型定义不兼容。例如,考虑结构体内的字段重命名。作为一个非常真实且具体的例子,让我们以最近在此提交中将 task_struct 的 state 字段重命名为 __state 。如果您要编写需要读取任务状态的 BPF 应用程序,那么根据内核版本的不同,您需要使用两个不同的名称来获取相同的字段 。让我们看看 BPF CO-RE 如何处理这个问题。
BPF CO-RE 有一个重要的命名约定(我称之为 “忽略后缀规则” )。这是一个相对鲜为人知的特性,但它是处理上述情况的关键机制。 对于任何类型、字段、枚举或枚举器,如果实体的名称包含 ___something 形式的后缀(三个下划线加上其后的一些文本),则出于 CO-RE 重定位的目的,将忽略此类名称后缀,就好像它从未存在过一样 。
这意味着,如果您要定义一个 struct task_struct___my_own_copy 并在 BPF 应用程序中使用它,就 BPF CO-RE 而言,该结构体等同于内核 struct task_struct ,并将进行相应的匹配和重定位。字段名称(因此 state 或 state___custom 实际上相同)和枚举(枚举类型名称本身以及该枚举中的枚举器名称)也同样适用。实际上,它是双向的,因此,例如,如果内核具有 struct task_struct 和 struct task_struct___2 (有时由于 C 类型系统和内核源代码中的头文件交互,确实如此),则这两个结构体都将成为 BPF 程序源代码中定义的 struct task_struct___my 的匹配候选。
这在实践中意味着,您现在可以拥有相同内核类型/字段/枚举的多个独立且冲突的定义,但仍然能够将代码编译为有效的 C,并根据所使用的任何特性检测方法在运行时选择正确的定义。
我们来看一个例子,了解如何将提到的 task_struct->state 重命名为 task_struct->__state :
/* latest kernel task_struct definition, which can also come from vmlinux.h */
struct task_struct {
int __state;
} __attribute__((preserve_access_index));
struct task_struct___old {
long state;
} __attribute__((preserve_access_index));
...
struct task_struct *t = (void *)bpf_get_current_task();
int state;
if (bpf_core_field_exists(t->__state)) {
state = BPF_CORE_READ(t, __state);
} else {
/* recast pointer to capture task_struct___old type for compiler */
struct task_struct___old *t_old = (void *)t;
/* now use old "state" name of the field */
state = BPF_CORE_READ(t_old, state);
}
...
上述例子中有两个最关键的部分。
首先,基于最新的 struct task_struct 定义进行字段存在性检查。如果正在运行的内核较旧,且尚无 __state 字段,则 bpf_core_field_exists(t->__state) 将返回 0,BPF 验证器将跳过 if 语句的第一个分支并将其作为死代码消除,因此永远不会尝试读取 t->__state 字段。
其次,将 struct task_struct * 指针重铸为 struct task_struct___old * 指针。这是必要的,以便 C 编译器能够跟踪 struct task_struct 的“替代定义”(在本例中为 struct task_struct___old )的类型信息。编译器将识别并将 t_old->state 字段引用(隐藏在 BPF_CORE_READ() 实现中)编译为有效的 C 表达式,并记录相应的 CO-RE 重定位信息,以便让 libbpf 知道 BPF 程序需要读取哪种类型和字段。
有了 ___suffix 规则,一切都正常工作。当 libbpf 准备好 BPF 程序并发送到内核进行验证时,libbpf 将执行 CO-RE 重定位并正确调整偏移量。其中一个 CO-RE 重定位将无法解析(因为 __state 或 state 不能同时存在于内核中),并导致相应 BPF 指令“中毒”(回想一下前面描述的 0xbad2310 ),但该指令将受到字段存在逻辑的保护,并在程序加载期间被验证程序消除
随着 BPF CO-RE 应用程序数量和复杂性的增长,以及 Linux 内核的演进和不可避免的内部更改和重构,处理不兼容内核更改的能力将变得越来越重要,因此请注意这项技术。上面的描述略过了一些实现细节,但希望它有助于理解如何在实践中使用该功能。
Reading kernel data structures from user-space memory
某些应用程序中可能会出现一个(诚然不常见)的需求,那就是需要从用户空间内存读取内核类型。它很可能是内核 UAPI 类型之一,作为系统调用的输入参数传入。为了适应这种情况(以及为了完整性),libbpf 提供了其 BPF_CORE_READ() 系列宏的用户空间等效项:
bpf_core_read_user();bpf_core_read_user_str();BPF_CORE_READ_USER_STR_INTO();BPF_CORE_READ_USER_INTO();BPF_CORE_READ_USER().
它们的功能和行为与非 user 变体完全相同,唯一的区别是所有内存读取都是通过 bpf_probe_read_user() 和 bpf_probe_read_user_str() BPF 帮助程序完成的,因此应该传递用户空间指针。
Capturing BTF type IDs
如果您熟悉 BTF ,您就会知道 BTF 中的任何类型定义都有相应的 BTF 类型 ID。无论是出于调试和日志记录目的,还是作为某些 BPF API 的一部分,了解 BPF 程序正在使用的类型/字段/枚举的 BTF 类型 ID 都可能很重要。BPF CO-RE 提供了一种从 BPF 程序代码内部捕获这些 BTF 类型 ID 作为整数值的方法。实际上,它提供了一种捕获两种不同 BTF 类型 ID 的方法。一个用于目标内核 BTF( 内核类型 ID ),另一个用于 BPF 程序自己的 BTF( 本地类型 ID ):
bpf_core_type_id_kernel()returns resolved type ID from running kernel’s BTF;bpf_core_type_id_local()captures a type ID as captured by the compiler during BPF program compilation.
注意,BPF CO-RE 重定位始终涉及两种 BTF 类型。一种是 BPF 程序对类型定义的本地期望 (例如,vmlinux.h类型或使用preserve_access_index属性手动定义的类型)。这种本地 BTF 类型为 libbpf 提供了在内核 BTF 中搜索内容的方法。因此,它可以是类型/字段/枚举的最小定义,仅包含必要的字段和枚举器子集。
然后,Libbpf 可以使用本地 BTF 类型定义来查找匹配的实际完整内核 BTF 类型。上述帮助程序允许捕获 CO-RE 重定位中涉及的两种类型的 BTF 类型 ID。它们可能有助于在运行时区分不同的内核或本地类型,用于调试和日志记录,或者可能用于未来接受 BTF 类型 ID 作为输入参数的 BPF API。此类 API 目前尚不存在,但肯定会在不久的将来推出
Conclusion 结论
我希望这篇文章能够提供足够的信息和实用指导,帮助您有效地使用 BPF CO-RE 技术。您可以根据自己的 BPF 需求,自由地运用它。如果发现任何异常或无法正常工作,请向 BPF 邮件列表报告问题。

498

被折叠的 条评论
为什么被折叠?



