[TOC]

译者注: BPF CO-RE 项目在未来将会是非常流行的技术,其让 BPF 编写的工具能够像用户编写的其他程序一样进行二进制分发,不再需要目标机器在 BCC 框架下的 llvm/clang/linux-dev 等包的安装,并且可以跨越不同版本的内核。唯一的要求是需要运行的程序的内核采用了支持 CONFIG_DEBUG_INFO_BTF 内核编译选项,可喜的是当前不少 Linux 发行版已经内置该参数编译,比如 Ubuntu 20.10,相信 BPF CO-RE 会让 BPF 的技术推广和普及更加便利。

本站相关的文章:BPF CO-RE

原文地址:https://nakryiko.com/posts/bcc-to-libbpf-howto-guide/ 作者: Andrii Nakryiko BPF CO-RE 项目负责人

本文是将基于 BCC 的 BPF 应用程序转换为 libbpf 和 BPF CO-RE 的实践指南。

这篇文章最初发布在 Facebook 的 BPF 博客上*。此版本有一些小的修复和调整。*本文还更新了关于使用 BPF 子程序来反映自初始发布以来添加 libbpf 新功能的部分。

1. 为什么是 libbpf 和 BPF CO-RE?

过去,当我们开发必须 BPF 应用程序时,BCC 是首选框架;基于 BCC 实现的各种跟踪 BPF 程序,可以查看到必要的内核的内部结构。BCC 提供了内置的 Clang 编译器,可以在运行时编译 BPF 代码,以实现运行在目标主机上的特定内核中。开发可维护的 BPF 程序的唯一方法就是程序必须处理不断变化的内核内部结构。可参阅 “BPF 可移植性和 CO-RE ” 帖子,以更加详细地了解为什么会这样,以及为什么此前 BCC 几乎是唯一可行的选择。本文还解释了为什么现在 libbpf 会是一个不错的选择。去年,Libbpf 在功能和复杂性方面得到了重大提升,这弥补了 libbpf 与 BCC 之间的诸多差距(尤其是在跟踪应用程序方面),基于此 libbpf 还获得了许多 BCC 不具备的新的强大功能(例如,全局变量和 BPF 框架)。

诚然,BCC 会竭尽全力简化 BPF 开发人员的工作,但有时这种额外的便利会妨碍我们真正找出问题和得知如何修复问题。BCC 给我们的感觉就是有时隐藏了太多的魔法。你必须记住跟踪点的命名约定和自动生成的结构。你必须依靠代码重写来读取内核数据并获取 kprobe 参数。在使用 BPF map 时,你将编写半面向对象的 C 代码,但这与内核中实际发生的情况并不完全匹配。尽管拥有如此多的魔法,BCC 仍然需要我们在应用程序的用户空间部分编写一堆样板代码,手动设置最琐碎的部分。

如上所述,BCC 依赖于运行时编译,因此需要将整个庞大的 LLVM/Clang 库引入并嵌入到自身中。这会产生很多不理想的后果:

  • 编译期间占用大量资源(内存和 CPU),这可能会中断繁忙服务器上的主要工作流程;

  • 依赖于内核头文件库,其必须安装在每个目标主机上。即便如此,如果你需要内核中没有通过公共头文件导出——你仍然需要手动将类型定义复制/粘贴到对应的 BPF 代码中来完成工作;

  • 即使是微不足道的编译时错误,你也只能在重新启动用户空间程序之后的运行时才能检测到;这显着减少了开发迭代时间(并增加了挫败感……)

Libbpf + BPF CO-RE(一次编译,到处运行)选择了不同的方式。其理念是 BPF 程序与任何“普通”用户空间程序没有太大区别:它们应该一次编译成较小的二进制文件,然后以紧凑的形式未经修改地部署到目标主机。Libbpf 扮演 BPF 程序加载器的角色:执行普通的设置工作(重定位、加载和验证 BPF 程序、创建 BPF map 、附加到 BPF 钩子等),而开发人员只需要关心 BPF 程序的正确性和性能。这种方法将开销保持在最低限度,消除了严重的依赖关系,这使得开发人员的整体体验更加顺畅。

在 API 和代码约定方面,libbpf 坚持最不意外的哲学,这意味着大多数事情必须明确说明:不会有任何隐含的头文件,也没有代码重写。只需简单的 C 代码和少量的辅助宏即可消除大多数平凡的部分。除此之外,你编写的是执行的内容,并且对应的 BPF 应用程序的结构与内核最终验证和执行的内容是对应的。

编写这些指南是为了使 BCC 到 libbpf + BPF CO-RE 的转换过程更容易、更快、更少痛苦。本文详细解释了各种初步的设置步骤,概述了常见模式,解释了由于 BCC 和 libbpf 之间的差异而不可避免地会遇到的差异、问题和陷阱。

从 BCC 切换到 libbpf + BPF CO-RE 一开始可能会让你感到不寻常和令人困惑,但你会很快掌握它,并且下次遇到编译或验证问题时会感受到 libbpf 的明确性和直接性。

另外,请记住,用于 BPF CO-RE 的一系列 Clang 功能非常新,因此你需要 Clang 10 或更新版本才能完成所有这些工作。

2. 设置用户空间部分

2.1 完整构建

使用 BPF CO-RE 构建基于 libbpf 的 BPF 应用程序包括以下几个步骤:

  • 生成包含所有内核类型的 vmlinux.h 头文件;
  • 使用最新的 Clang(版本 10 或更新版本)将 BPF 程序源代码编译为 .o 目标文件;
  • 从已编译的 BPF 目标文件生成 BPF skeleton 头文件;
  • 包含生成的 BPF skeleton 头文件,以便从用户空间代码使用;
  • 然后,最后编译用户空间代码,这会将 BPF 目标代码嵌入其中,这样你就不必随应用程序分发额外的文件。

具体如何完成取决于你的特定设置和构建系统,此处无法详细说明。其中一种方法可参考 BCC 的 libbpf-tools,它设置了一个通用的 Makefile 规则,以简洁的方式为处理了所有这些。

编译 BPF 代码并生成 BPF skeleton 时,在用户空间代码中包含 libbpf 和 skeleton 头文件,以便使用必要的 API:

1
2
3
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "path/to/your/skeleton.skel.h"

2.2 锁定内存限制

BPF 为 BPF map 和其他各种资源使用锁定的内存。默认情况下,这个限制非常低,需要进行对应的调整,否则即使是一个简单的 BPF 程序也可能不会成功加载到内核中。BCC 已经无条件地将此限制设置为无穷大,但 libbpf 不会自动执行此操作(按照设计)。

根据生产环境,我们可能有更好和更受欢迎的方法来执行此操作。但是为了快速实验或者如果没有更好的方法时,你可以通过 setrlimit(2) 系统调用完成,它应该在程序的最开始被调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <sys/resource.h>

rlimit rlim = {
    .rlim_cur = 512UL << 20, /* 512 MBs */
    .rlim_max = 512UL << 20, /* 512 MBs */
};

err = setrlimit(RLIMIT_MEMLOCK, &rlim);
if (err)
     /* handle error */

2.3 Libbpf 日志

当某些功能没有按预期工作时,排查的最佳方法是查看 libbpf 日志输出。Libbpf 以不同的详细程度输出对应的有用的日志。默认情况下,libbpf 会向控制台发送错误级别的输出。我们建议安装自定义日志回调函数,并设置打开/关闭详细调试级别输出的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int print_libbpf_log(enum libbpf_print_level lvl, const char *fmt, va_list args)
{
    if (!FLAGS_bpf_libbpf_debug && lvl >= LIBBPF_DEBUG)
        return 0;
    return vfprintf(stderr, fmt, args);
}

/* ... */

libbpf_set_print(print_libbpf_log); /* set custom log handler */

2.4 BPF skeleton 和 BPF 程序生命周期

使用 BPF 框架(以及一般的 libbpf API)的详细解释超出了本文档的范围,现有的内核自测和 BCC libbpf-tools 示例可能是学习的最佳方式。查看 runqslower 示例也可作为一个使用 skeleton 的简单但真实的工具。

尽管如此,解释每个 BPF 应用程序所涉及的 libbpf 概念和对应的阶段还是很有用的。BPF 应用程序由一组协作或完全独立的 BPF 程序以及 BPF map 和全局变量组成,map 及全局变量可在所有 BPF 程序之间共享(允许它们在一组公共数据上进行协作)。BPF map 和全局变量也可以从用户空间访问(我们可以将应用程序的用户空间部分称为 ”控制程序” ),其允许控制程序获取或设置任何必要的额外数据。BPF 程序通常会经历以下阶段:

  • 打开阶段。解析 BPF 目标文件:发现 BPF map 、BPF 程序和全局变量,但此时尚未创建。在打开 BPF 程序后,可以在创建和加载所有实体之前进行其他调整(如有必要:设置 BPF 程序类型;预先设置全局变量的初始值等)。
  • 加载阶段。创建 BPF map ,解析各种重定位,将 BPF 程序加载到内核中并进行验证。此时,BPF 程序的所有部分都已验证并存在于内核中,但尚未执行任何 BPF 程序。在加载阶段之后,可在不与 BPF 程序代码执行竞争的情况下设置初始 BPF map 状态。
  • 附着阶段。这是 BPF 程序附加到各种 BPF 挂钩点的阶段(挂载点包括:tracepoints、kprobes、cgroup hook、网络数据包处理管道等等)。这是 BPF 开始执行功能、读取/更新 BPF map 和全局变量的阶段。
  • 拆除阶段。BPF 程序从内核中分离并卸载。BPF map 被销毁,BPF 程序使用的所有资源都被释放。

生成的 BPF skeleton 有相应的函数来实现每个阶段的触发:

  • <name>__open() – 创建并打开 BPF 应用程序;

  • <name>__load() – 实例化、加载和验证 BPF 应用程序部分;

  • <name>__attach() – 附加所有可自动附加的 BPF 程序(它是可选的,你可以通过直接使用 libbpf API 获得更多控制);

  • <name>__destroy() – 分离所有BPF 程序并释放所有使用的资源。

3. BPF代码转换

在这一部分中,我们将回顾典型的转换流程,并概述 BCC 和 libbpf/BPF CO-RE 实现对比的典型不匹配场景。希望这能让你轻松地将 BPF 代码转换为 BCC 和 BPF CO-RE 兼容的版本。

3.1 检测 BCC 与 libbpf 模式

对于需要同时需要支持 BCC 和 libbpf “模式” 的情况,需要我们能够检测 BPF 程序代码的编译模式。最简单的方法是依靠 BCC 中存在的 BCC_SEC 宏:

1
2
3
#ifdef BCC_SEC
#define __BCC__
#endif

在此之后,在整个 BPF 代码中,你可以执行以下操作:

1
2
3
4
5
#ifdef __BCC__
/* BCC-specific code */
#else
/* libbpf-specific code */
#endif

这允许有一个通用的 BPF 源代码,其中只有必要的逻辑是 BCC 或 libbpf 特定的。

3.2 头文件包括

使用 libbpf/BPF CO-RE,你不需要包含内核头文件(即所有这些 #include <linux/whatever.h> ),而是包含一个 vmlinux.h和几个 libbpf 帮助头文件:

1
2
3
4
5
6
7
8
#ifdef __BCC__
/* linux headers needed for BCC only */
#else /* __BCC__ */
#include "vmlinux.h"               /* all kernel types */
#include <bpf/bpf_helpers.h>       /* most used helpers: SEC, __always_inline, etc */
#include <bpf/bpf_core_read.h>     /* for BPF CO-RE helpers */
#include <bpf/bpf_tracing.h>       /* for getting kprobe arguments */
#endif /* __BCC__ */

vmlinux.h 可能不包含一些有用的内核 #define 常量,因此对于这些情况,你也需要在此处重新声明它们。不过,最常见的常量将在 bpf_helpers.h 中提供。

3.3 字段访问

BCC 会默默地重写你的 BPF 代码,并将诸如 tsk->parent->pid 之类的字段访问转换为一系列 bpf_probe_read() 调用。Libbpf/BPF CO-RE 没有对等的功能,但是 bpf_core_read.h 提供了一组帮助程序来尽可能接近 C 语言中的实现。上面的 tsk->parent->pid 可变成 BPF_CORE_READ(tsk, parent, pid) 的方式。使用 tp_btffentry/fexit BPF 程序类型( Linux 5.5+ 可用),C 语法也是可能使用的。但是对于较旧的内核和其他 BPF 程序类型(例如 tracepoints 和 kprobes),最好的办法是转换为 BPF_CORE_READ

此外,BPF_CORE_READ 宏也可在 BCC 模式下工作,所以要避免与各个领域获得重复的 #ifdef __BCC__/#else/#endif ,你可以在 BCC 和 libbpf 模式下将所有的字段读取使用 BPF_CORE_READ 。但是,在 BCC 中使用,请确保 bpf_core_read.h 头文件包含在最终 BPF 程序中。

3.4 BPF map

BCC 和 libbpf 以声明方式定义 BPF map 的方式有所不同,但转换非常简单。以下是一些示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* Array */
#ifdef __BCC__
BPF_ARRAY(my_array_map, struct my_value, 128);
#else
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 128);
    __type(key, u32);
    __type(value, struct my_value);
} my_array_map SEC(".maps");
#endif

/* Hashmap */
#ifdef __BCC__
BPF_HASH(my_hash_map, u32, struct my_value);
#else
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, struct my_value);
} my_hash_map SEC(".maps")
#endif

/* Per-CPU array */
#ifdef __BCC__
BPF_PERCPU_ARRAY(heap, struct my_value, 1);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, struct my_value);
} heap SEC(".maps");
#endif

注意 BCC 中 Map 的默认大小是10240 。使用 libbpf,你必须明确指定大小。

PERF_EVENT_ARRAYSTACK_TRACE 和其他一些专门的 map (DEVMAPCPUMAP等)尚不支持键/值的 BTF 类型,因此需要直接指定 key_size/value_size 代替:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* Perf event array (for use with perf_buffer API) */
#ifdef __BCC__
BPF_PERF_OUTPUT(events);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");
#endif

3.5 从 BPF 代码访问 BPF map

BCC 使用伪 C++ 语法来处理 map ,它在底层被重写为实际的 BPF 帮助函数调用。通常,以下模式:

1
some_map.operation(some, args)

需要改写成如下形式:

1
bpf_map_operation_elem(&some_map, some, args);

这里有些例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#ifdef __BCC__
    struct event *data = heap.lookup(&zero);
#else
    struct event *data = bpf_map_lookup_elem(&heap, &zero);
#endif

#ifdef __BCC__
    my_hash_map.update(&id, my_val);
#else
    bpf_map_update_elem(&my_hash_map, &id, &my_val, 0 /* flags */);
#endif

#ifdef __BCC__
    events.perf_submit(args, data, data_len);
#else
    bpf_perf_event_output(args, &events, BPF_F_CURRENT_CPU, data, data_len);
#endif

3.6 BPF 程序

所有表示 BPF 程序的函数都需要通过使用来自 bpf_helpers.hSEC() 宏来标记自定义节名称,如下例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#if !defined(__BCC__)
SEC("tracepoint/sched/sched_process_exec")
#endif
int tracepoint__sched__sched_process_exec(
#ifdef __BCC__
    struct tracepoint__sched__sched_process_exec *args
#else
    struct trace_event_raw_sched_process_exec *args
#endif
) {
/* ... */
}

这只是一个约定,但如果你遵循 libbpf 的部分命名,你将获得更好的整体体验。可在此处找到预期名称的详细列表。比较最常见的是:

  • tp/<category>/<name> 用于 tracepoints;

  • kprobe/<func_name> 用于 kprobe 和 kretprobe/<func_name> 用于 kretprobe;

  • raw_tp/<name> 用于原始 tracepoint;

  • cgroup_skb/ingresscgroup_skb/egress 和整个系列的 cgroup/<subtype> 程序。

3.7 Tracepoints

从上面的示例中,请注意 tracepoint 上下文类型的类型名称之间的细微差别。BCC 遵循 “tracepoint/” 对应 tracepoint__<category>__<name> 命名模式。BCC 在运行时编译期间自动生成相应的类型。Libbpf 没有这种附加功能,但幸运的是,内核已经为所有跟踪点数据提供了非常相似的类型。通常,它会被命名为 trace_event_raw_<name> ,但有时内核中少数跟踪点会重用公共类型,因此如果对应的模式不起作用,你可能需要在内核源代码中查找它(或检查 vmlinux.h )以找出确切的类型名称。例如,不能使用对应的 trace_event_raw_sched_process_exit ,你需要使用替代的 trace_event_raw_sched_process_template

注意:在大多数情况下,访问 tracepoint 的上下文数据的代码完全相同,除了特殊的可变长度字符串字段。对他们来说,转换很简单:data_loc_<some_field> 变成 __data_loc_<some_field> (注意双下划线)。

3.8 Kprobes

BCC 也有很多用用于声明 kprobes 的魔法。实际上,此类 BPF 程序接受指向 struct pt_regs 的单个指针作为上下文参数,但 BCC 允许你假装内核函数参数可直接用于 BPF 程序。使用 libbpf,你可以借助 BPF_KPROBE 宏来实现类似功能,该宏目前是内核自测的bpf_trace_helpers.h 头文件的一部分,但很快就会成为 libbpf 的一部分 [当前已经是其中一部分]

1
2
3
4
5
6
7
8
9
#ifdef __BCC__
int kprobe__acct_collect(struct pt_regs *ctx, long exit_code, int group_dead)
#else
SEC("kprobe/acct_collect")
int BPF_KPROBE(kprobe__acct_collect, long exit_code, int group_dead)
#endif
{
    /* BPF code accessing exit_code and group_dead here */
}

对于 kertporbe ,也有相应的 BPF_KRETPROBE 宏。

注意!系统调用函数在 4.17 内核中被重新命名。从 4.17 版本,例如系统调用 kprobe 对应的 sys_kill ,现在被称为__x64_sys_kill (这是 x64 系统上,当然其他架构都会有不同的前缀)。在尝试附加 kprobe/kretprobe 时,你必须考虑到这一点。但是,如果可能,请尝试坚持使用 tracepoints

注意 如果你正在开发一个需要 tracepoint/kprobe/kretprobe 的新 BPF 程序,请参看新的 raw_tp/fentry/fexit 探测点。它们提供更好的性能和可用性,并且从 5.5 内核开始可用。

3.9 处理 BCC 中的编译时 #if

在 BCC 代码中经常依赖预处理器 #ifdef#if 条件。最常见的是,这是由于内核版本之间的差异或启用/禁用可选部分(取决于应用程序配置)造成的。此外,BCC 允许从用户空间端提供自定义 #define ,并在 BPF 代码编译期间在运行时替换它们。这通常用于自定义各种参数。

使用 libbpf + BPF CO-RE 以相同的方式(通过使用编译时逻辑)不可能做到这一点,因为整个想法是你的 BPF 程序必须编译一次,并且能够处理内核和应用程序配置。

为了处理内核版本差异,BPF CO-RE 提供了两种互补机制:Kconfig externsstruct “flavors” 。BPF 代码可以通过声明以下 extern 变量来知道它正在处理内核的版本:

1
2
3
4
5
6
7
8
9
#define KERNEL_VERSION(a, b, c) (((a) << 16) + ((b) << 8) + (c))

extern int LINUX_KERNEL_VERSION __kconfig;

if (LINUX_KERNEL_VERSION < KERNEL_VERSION(5, 2, 0)) {
  /* deal with older kernels */
} else {
  /* 5.2 or newer */
}

与获取内核版本类似,你可以从 Kconfig 中提取任何 CONFIG_xxx 值:

1
2
3
extern int CONFIG_HZ __kconfig;

/* now you can use CONFIG_HZ in calculations */

通常,如果某个字段被重命名或移动到子结构,只需通过检查该字段是否存在于目标内核中就足够了。你可以使用帮助函数 bpf_core_field_exists(<field>) 来做到这一点,如果目标内核中存在指定的字段,它将返回 1,否则为 0。与 struct flavors 搭配使用,这允许处理内核结构布局的重大变化(有关使用结构风格的更多详细信息,请参阅 “BPF 可移植性和 CO-RE” 文章)。下面是一个关于如何适应最近内核版本之间的 struct kernfs_iattrs 差异的简短示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* struct kernfs_iattrs will come from vmlinux.h */

struct kernfs_iattrs___old {
    struct iattr ia_iattr;
};

if (bpf_core_field_exists(root_kernfs->iattr->ia_mtime)) {
    data->cgroup_root_mtime = BPF_CORE_READ(root_kernfs, iattr, ia_mtime.tv_nsec);
} else {
    struct kernfs_iattrs___old *root_iattr = (void *)BPF_CORE_READ(root_kernfs, iattr);
    data->cgroup_root_mtime = BPF_CORE_READ(root_iattr, ia_iattr.ia_mtime.tv_nsec);
}

3.10 应用配置

BPF CO-RE 自定义程序行为的方法是使用全局变量。全局变量允许用户空间控制应用程序在加载和验证 BPF 程序之前预先设置必要的参数和标识。全局变量可以是可变的,也可以是常量。常量(只读)变量对于指定 BPF 程序的一次性配置最有用,就在它被加载到内核和验证之前。在 BPF 程序加载和运行后,可变的可用于 BPF 程序与其用户空间对应部分之间的双向数据交换。

在 BPF 代码方面,你可以使用 const volatile 全局变量声明只读全局变量(对于可变变量,只需删除 const volatile 限定符):

1
2
3
4
const volatile struct {
    bool feature_enabled;
    int pid_to_filter;
} my_cfg = {};

这里有几个非常重要的事情需要注意:

  • 必须指定 const volatile 以防止过于聪明的编译器优化(编译器可能并且会错误地假定零值并将它们内联到代码中);

  • 如果你正在定义一个可变(非 const )变量,请确保它们没有被标记为静态:非静态全局变量与编译器的互操作最佳。在这种情况下,通常不需要 volatile

  • 变量必须被初始化,否则 libbpf 将拒绝加载 BPF 程序。初始化可以为零或你需要的任何其他值。除非从控制应用程序覆盖,否则此类值将是变量的默认值。

使用 BPF 代码中的全局变量很简单:

1
2
3
4
5
6
7
if (my_cfg.feature_enabled) {
    /* … */
}

if (my_cfg.pid_to_filter && pid == my_cfg.pid_to_filter) {
    /* … */
}

全局变量提供更好的用户体验并避免 BPF map 查找开销。另外,对于常量变量,它们的值对于 BPF 验证器来说是众所周知的,并且在程序验证时被视为常量,这使得 BPF 验证器能够更精确地验证代码并有效地消除无用代码分支。

通过使用 BPF skeleton,控制应用程序为此类变量提供值的方式简单而自然:

1
2
3
4
5
6
7
8
9
struct <name> *skel = <name>__open();
if (!skel)
    /* handle errors */

skel->rodata->my_cfg.feature_enabled = true;
skel->rodata->my_cfg.pid_to_filter = 123;

if (<name>__load(skel))
    /* handle errors */

只读变量,只能在加载 BPF 框架之前从用户空间设置和修改。一旦加载了 BPF 程序,BPF 和用户空间代码都无法修改它。这种保证允许 BPF 验证器在验证期间将此类变量视为常量并执行更好的无用代码消除。另一方面,非常量变量可以在 BPF skeleton 在 BPF 程序的整个生命周期加载后修改,无论是从 BPF 还是用户空间。它们可用于交换可变配置、统计信息等。

4. 常见问题

你可能会遇到很多意外。有时这只是一种流行的误解,有时是在 BCC 中如何实现某事与如何使用 libbpf 完成某事之间的差异。这不是一个详尽的列表,但它应该可以帮助你进行 BCC 到 libbpf + BPF CO-RE 的转换。

4.1 全局变量

BPF 全局变量的外观和行为与用户空间变量完全一样:它们可以在表达式中使用、更新(非 const 变量),你甚至可以获取它们的地址并传递给辅助函数。但这仅适用于 BPF 代码方面。从用户空间,它们只能通过 BPF skeleton 读取和更新:

  • skel->rodata 用于只读变量;

  • skel->bss 用于可变的零初始化变量;

  • skel->data 用于非零初始化的可变变量。

你仍然可以从用户空间读取/更新它们,这些更新将立即反映在 BPF 端。但它们不是用户空间端的全局变量,它们只是 BPF skeleton 的rodatabssdata 的成员,它们在 skeleton 加载阶段被初始化。这随后意味着在 BPF 代码和用户空间代码中声明完全相同的全局变量将声明完全独立的变量,它们不会以任何方式连接。

4.2 循环展开

除非你的目标是 5.3+ 内核,否则你的 BPF 代码中的所有循环都必须用 #pragma unroll 标记以强制 Clang 展开它们并消除任何可能的控制流循环:

#pragma unroll
for (i = 0; i < 10; i++) { ... }

如果没有循环展开或者循环没有在固定的迭代次数内终止,你会得到一个关于 “back-edge from insn X to Y” 的验证器错误,这意味着 BPF 验证器检测到一个无限循环(或无法证明)该循环将在有限的迭代次数内完成)。

4.3 辅助子程序

如果你使用静态函数并在 4.16 之前的内核上运行,则必须将此类函数标记为始终内联 static __always_inline ,以便 BPF 验证程序将它们视为单个大函数:

static __always_inline unsigned long
probe_read_lim(void *dst, void *src, unsigned long len, unsigned long max)
{
    ...
}

但是从 4.16 开始(参见 commit),内核支持 BPF 应用程序中的 BPF-to-BPF 函数调用。libbpf (v0.2+) 也完全支持此功能,确保执行所有正确的代码重定位和调整。所以请随意删除 __always_inline 。你甚至可以考虑使用 __noinline 强制不内联,这通常会改进代码生成并避免一些由于不必要的寄存器到堆栈溢出而导致的常见 BPF 验证失败:

static __noinline unsigned long
probe_read_lim(void *dst, void *src, unsigned long len, unsigned long max)
{
    ...
}

从 5.5 内核开始也支持非内联全局函数,但它们具有与静态函数不同的语义和验证约束。一定要检查出来!

4.4 bpf_printk 调试

没有可用于 BPF 程序的常规调试器,允许我们在 BPF 程序上设置断点、检查变量和 BPF map ,或单步执行代码。但是通常情况下,如果没有这样的工具,几乎不可能找出 BPF 代码的问题所在。

对于这种情况,记录额外的调试信息是最好的选择。使用 bpf_printk(fmt, args...) 记录额外的数据片段以帮助了解发生了什么。它接受类似 printf 的格式字符串,并且最多只能处理 3 个参数。它简单易用,但成本比较高,不适合在生产中使用。所以它主要只适用于临时调试。用法:

译者注: bpf_printk 只能有 3 个参数,其中包含一个为 %s

1
2
3
4
5
6
char comm[16];
u64 ts = bpf_ktime_get_ns();
u32 pid = bpf_get_current_pid_tgid();

bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("ts: %lu, comm: %s, pid: %d\n", ts, comm, pid);

可以从特殊的 /sys/kernel/debug/tracing/trace_pipe 文件中读取记录的消息:

$ sudo cat /sys/kernel/debug/tracing/trace_pipe
...
      [...] ts: 342697952554659, comm: runqslower, pid: 378
      [...] ts: 342697952587289, comm: kworker/3:0, pid: 320
...

5. 如何贡献?

如果所有这些都激起了你的兴趣,并且你想使用 libbpf 和 BPF CO-RE,那么帮助 BCC 工具转换可能是最好的开始方式。请参阅首先添加此类转换工具 (runqslower) 的 BCC PR,并设置构建脚本以轻松添加更多此类工具。只需选择你喜欢或使用的任何工具,然后尝试将其转换为 libbpf。如果你有任何问题,BPF 邮件列表 是提出问题和发送关于 BPF、libbpf 和 CO-RE 的错误报告的最佳场所。BCC 工具相关的问题最好发送到 BCC 项目本身。玩得开心!