龙空技术网

eBPF初见(一次编译,到处运行)「译」

云原生实战 164

前言:

此刻我们对“反编译elf”大致比较重视,我们都想要学习一些“反编译elf”的相关知识。那么小编也在网上收集了一些关于“反编译elf””的相关内容,希望小伙伴们能喜欢,各位老铁们快快来学习一下吧!

eBPF支持的可编程性是非常大的一个创新,而且能够在验证器的保证下,对内核没有破坏性,就像编写普通的程序一样,能保证无害,但却无法保证能写出逻辑正确的程序。只有程序员能够更全面的了解各种机制和限制后,才能完全驾驭eBPF并实现自己期望的逻辑。

从前面两篇文章中,可以对eBPF的整体能力和生态有个初步的了解:

eBPF初见(入门篇)eBPF初见(生态篇)

但如果让你立即写一个eBPF程序实现一些功能,相信依然有很多细节比较疑惑,直到读到一篇BPF作者的文章豁然开朗,很多问题都明白了,网上有一些旧版本的翻译,作者更新了新版,所以翻译一下。

新版原文地址:

标题 BPF Portability and CO-RE, 作者 Andrii Nakryiko。

BPF CO-RE 使我们回到了熟悉、自然的工作流程:将 BPF C 源码编译成二进制,然后将 二进制文件分发到目标机器进行部署和运行 —— 无需再随着应用一起分发重量级的编译器库、无需消耗宝贵的运行时资源做运行时编译,也无需等到运行之前才能捕捉一些细微的编译时错误了。

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

BPF应用程序的可移植性意味着什么?为什么BPF实际上很难做到?在这篇文章中,我们将看到编写能够跨多个内核版本工作的BPF程序的挑战,以及BPF CO-RE(编译一次-到处运行)如何帮助解决这个问题。

1 BPF:最新状态

自(e)BPF成立以来,BPF社区一直优先考虑尽可能简化BPF应用程序开发,使其与用户空间应用程序一样简单和熟悉。随着BPF可编程性的稳步发展,编写BPF程序从未如此简单。

尽管有这些可用性改进,但BPF应用程序开发的一个方面被忽略了(主要是技术原因):可移植性。然而,“BPF可移植性”意味着什么?BPF可移植性是一种编写BPF程序的能力,该程序将成功编译、通过内核验证,并在不同的内核版本之间正确工作,而无需为每个特定内核重新编译。

本说明描述了BPF可移植性问题及其解决方案:BPF CO-RE(编译一次–到处运行)。首先,我们将研究BPF可移植性问题本身,描述为什么它是一个问题,以及为什么解决它很重要。然后,我们将概述解决方案的高级组成部分,BPF CO-RE,并将简要介绍需要组合起来才能实现的难题。最后,我们将以各种教程结束,描述BPF CO-RE方法的用户可见API,并用示例演示其应用。

2 BPF 可移植性面临的问题

2.1 BPF程序不控制周围内核环境的内存布局

BPF程序是一段用户提供的代码,经加载并验证后,在内核上下文中执行。这些程序在内核内存空间内运行,可以访问其可用的所有内部内核状态,这种能力非常强大,也是BPF技术成功应用于众多不同应用的原因之一。然而,这种强大的能力也造成了我们今天所面临的BPF可移植性难题:BPF程序不控制周围内核环境的内存布局。因此,BPF 程序只能运行在开发和编译这些程序时所在的内核。

2.2 不同版本不同配置的内核内存布局是不一致的

此外,内核类型和数据结构也在不断变化。不同的内核版本将使结构字段在结构内部来回移动,甚至移动到新的内部结构中,字段可能重命名或删除,它们的类型也可以更改,可以是一些普通的兼容字段,也可以是完全不同的字段。结构和其他类型可以重命名,也可以有条件地编译(取决于内核配置),或者在内核版本之间直接删除。

换言之,在内核发布之间,情况总是会发生变化,但BPF应用程序开发人员应该以某种方式解决这个问题。考虑到这个不断变化的内核环境,如何做才能使BPF能实现可移植性目标呢?实际上,这是可能的:

首先,并非所有BPF程序都需要依赖内部内核数据结构。一个例子是opensnoop工具,它依赖于kprobe/tracepoints来跟踪哪些进程打开了哪些文件,只需要捕获几个syscall参数即可工作。由于系统调用参数提供了一个稳定的ABI,这些参数不会在内核版本之间发生变化,因此这种可移植性从一开始就不受关注。不幸的是,像这样的应用程序非常罕见,这些类型的应用程序通常也非常有限。

另外,内核为BPF机制提供了一组有限的“稳定接口”,BPF程序可以依靠这些接口在内核之间保持稳定。实际上,底层结构和机制确实发生了变化,但这些BPF提供的稳定接口从用户程序中抽象出了这些细节。

作为一个例子,对于网络应用程序,通常只需查看一组有限的sk_buff属性(当然还有数据包数据)就可以非常有用和通用。为此,BPF验证器提供了一个稳定的__sk_buff“视图”(注意前面的下划线),它保护BPF程序不受结构sk_buf布局的影响。所有__sk_buff字段访问都被透明地重写为实际的sk_buf访问(有时非常复杂——在最终获取请求的字段之前进行一系列内部指针跟踪)。类似的机制可用于一系列不同的BPF程序类型。它们作为BPF验证者理解的特定于程序类型的BPF上下文来完成。所以,如果你在这样的环境下开发一个BPF程序,请认为自己是幸运的,你可以幸福地生活在一个稳定的美好幻想中。

但是,一旦需要查看原始的内核内部数据, 例如 常见的表示进程或线程的 struct task_struct,这个结构体中有非常详细的进程信息,那你就只能靠自己了。对于 tracing、monitoring 和 profiling 应用来说这个需求非常常见,而这类 BPF 程序也是极其有用的。

在这种情况下,当某些内核在您认为的字段之前添加了一个额外的字段时,例如,在距structtask_struct开始位置偏移16处,如何确保您没有读取垃圾数据?突然,对于该内核,您需要从例如偏移量24读取数据。问题还不止于此:如果一个字段被重命名了呢,thread_struct的fs字段(用于访问线程本地存储)就是这样,它在4.6和4.7内核之间被重命名为fsbase。或者,如果您必须在内核的两种不同配置上运行,其中一种配置禁用了某些特定功能,并完全编译出了结构的一部分(这是其他会计字段的常见情况,这些字段是可选的,但如果存在则非常有用),该怎么办?所有这些都意味着,您不能再使用开发服务器的内核头在本地编译BPF程序,并将其以编译后的形式分发给其他系统,同时期望它能够工作并产生正确的结果。这是因为不同内核版本的内核头将指定程序所依赖的数据的不同内存布局。

2.3 BCC方案

到目前为止,人们一直依靠BCC(BPF编译器集合)来解决这个问题。使用BCC,可以将BPF程序C源代码作为纯字符串嵌入到用户空间程序(控制应用程序)中。当控制应用程序最终在目标主机上部署和执行时,BCC调用其嵌入式Clang/LLVM,拉入本地内核头(您必须确保从正确的内核开发包将其安装在系统上),并实时执行编译。这将确保BPF程序期望的内存布局与目标主机运行内核中的内存布局完全相同。如果您必须处理内核中的一些可选和可能编译出来的东西,您只需在源代码中执行#ifdef/#else保护,以适应重命名字段、不同值语义或当前配置中不可用的任何可选东西等危害。嵌入的Clang将很高兴地删除代码中不相关的部分,并将根据特定内核定制BPF程序代码。

这听起来很棒,不是吗?不幸的是,情况并非如此。虽然这个工作流程有效,但它也有很大的缺点。

Clang/LLVM组合是一个大库,导致需要随应用程序一起分发的大二进制文件。

Clang/LLVM组合是资源密集型的,因此当您在启动时编译BPF代码时,您将使用大量的资源,可能会导致仔细平衡的生产工作负载。反之亦然,在繁忙的主机上,编译一个小BPF程序在某些情况下可能需要几分钟。

您可以打赌,目标系统将存在内核头文件,这在大多数情况下不是问题,但有时会引起很多麻烦。对于内核开发人员来说,这也是一个特别令人讨厌的要求,因为他们通常必须在开发过程中构建和部署定制的一次性内核。如果没有一个定制的内核头包,任何基于BCC的应用程序都无法在这样的内核上运行,从而使开发人员无法使用一组有用的调试和监控工具。

BPF程序测试和开发迭代也是相当痛苦的,因为一旦重新编译并重新启动用户空间控制应用程序,即使在运行时也会出现最轻微的编译错误。这当然会增加摩擦,也无助于快速迭代。

总的来说,虽然BCC是一个很好的工具,特别是用于快速原型、实验和小型工具,但当用于广泛部署的生产BPF应用程序时,它有很多缺点。

BPF CO-RE正在加强BPF可移植性的能力,可以说是BPF程序开发的未来,尤其是对于复杂的现实世界BPF应用程序。

3 BPF CO-RE:高层机制

BPF CO-RE在软件堆栈的各个级别(内核、用户空间BPF加载程序库(libbpf)和编译器(Clang))汇集了必要的功能和数据,以便于以可移植的方式编写BPF程序,处理同一预编译BPF程序中不同内核之间的差异。BPF CO-RE仔细整合以下组成部分:

BTF类型信息,它允许捕获有关内核、BPF程序类型和代码的关键信息,这也是下面其他部分的基础;编译器(Clang)为BPF程序C代码提供了表达意图和记录重新定位信息的手段;BPF加载器(libbpf)将来自内核的BTF和BPF程序绑定在一起,将编译的BPF代码适配到目标主机上的特定内核;内核,在保持完全不依赖BPF CO-RE的同时,提供了一些高级BPF特性,以支持一些更高级的场景。

这些组件以集成方式工作,使开发可移植BPF程序的能力前所未有地轻松、适应性和表现力,实现了BCC在运行时编译BPF程序C代码才能实现的可移植性,并且且克服了BCC的高昂代价。

3.1 BTF(BPF Type Format)

BTF 是 BPF CO-RE 的核心之一, 它是是一种与 DWARF 类似的调试信息,但

更通用、表达更丰富,用于描述 C 程序的所有类型信息。更简单,空间效率更高(使用 BTF 去重算法), 占用空间比 DWARF 低 100x。

如今,让 Linux 内核在运行时(runtime)一直携带 BTF 信息是可行的, 只需在编译时指定 CONFIG_DEBUG_INFO_BTF=y。内核的 BTF 除了被内核自身使用, 现在还用于增强 BPF 校验器自身的能力 —— 某些能力甚至超越了一年之前我们的想象力所及(例 如,已经有了直接读取内核内存的能力,不再需要通过 bpf_probe_read() 间接读取了)。

更重要的是,内核已经将这个自描述的权威 BTF 信息(定义结构体的精确内存布局等信息) 通过 sysfs 暴露出来,在 /sys/kernel/btf/vmlinux。 下面的命令将生成一个与所有内核类型兼容的 C 头文件(通常称为 “vmlinux.h"):

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c

这里说的 ”所有“ 真的是 ”所有“:包括那些并未通过 kernel-devel package 导出的类型!

3.2 编译器支持

为了启用BPF CO-RE并让BPF加载器(即libbpf)将BPF程序调整到目标主机上运行的特定内核,Clang使用了一些内置程序进行了扩展。它们发出BTF重定位,捕获BPF程序代码要读取的信息的高级描述。如果要访问task_struct->pid字段,Clang将记录它正是一个名为“pid”、类型为“pid_t”的字段,位于结构task_struct中。这样做的目的是,即使目标内核有一个task_struct布局,其中“pid”字段被移动到task_struc结构中的不同偏移量(例如,由于在“pid”域之前添加了额外的字段),或者即使它被移动到某个嵌套的匿名结构或联合体中(这在C代码中是完全透明的,因此没有人关注过这样的细节),您仍然可以通过其名称和类型信息找到它。这称为字段偏移重新定位。

不仅可以捕获(并随后重新定位)场偏移,还可以捕获其他场方面,如场的存在或大小。即使对于位字段(在C语言中,位字段是出了名的“不合作”类型的数据,抵制了使其可重定位的努力),仍然有可能捕获足够的信息,使其可重新定位,而且对BPF程序开发人员透明。

3.3 BPF 加载器(libbpf)

之前的所有数据(内核BTF和Clang重定位)都汇集在一起,由libbpf处理,libbpf充当BPF程序加载器。它获取已编译的BPF ELF对象文件,根据需要对其进行后处理,设置各种内核对象(映射、程序等),并触发BPF程序加载和验证。

Libbpf知道如何为主机上特定的运行内核定制BPF程序代码。它查看BPF程序记录的BTF类型和重新定位信息,并将它们与运行内核提供的BTF信息相匹配。Libbpf解析并匹配所有类型和字段,根据需要更新必要的偏移量和其他可重定位数据,以确保BPF程序的逻辑对于主机上的特定内核正确运行。如果一切顺利,您(BPF应用程序开发人员)将得到一个BPF程序,它是针对目标主机上的内核“定制的”,就像您的程序是专门为它编译的一样。但是,所有这些都是在不支付随应用程序分发Clang和在目标主机上运行时执行编译的开销的情况下实现的。

3.4 内核

令人惊讶的是,内核不需要太多更改就可以支持BPF CO-RE。由于良好的关注点分离,在libbpf处理BPF程序代码之后,对于内核来说,它看起来像任何其他有效的BPF程序码。它与在主机上用最新内核头编译的BPF程序没有区别。这意味着BPF CO-RE的许多功能不需要前沿内核功能,因此可以更广泛、更快地进行调整。

一些高级场景可能需要更新的内核,但这种情况应该很少。在下一部分中,我们将在解释BPF CO-RE面向用户的机制时讨论这些场景,其中将详细介绍BPF CO-RE面向用户API。

4 BPF CO-RE:用户侧经验

接下来看几个真实世界中 BPF CO-RE 的典型场景,以及它是如何解决面临的一些问题的。 我们将看到,

一些可移植性问题(例如,兼容 struct 内存布局差异)能够处理地非常透明和自然,而另一些则需要通过显式处理的,具体包括,通过 if/else 条件判断(而不是 BCC 中的那种条件编译 #ifdef/#else)。BPF CO-RE 提供的其他一些额外机制。

4.1 摆脱内核头文件依赖

内核 BTF 信息除了用来做字段重定位之外,还可以用来生成一个大的头文件("vmlinux.h"), 这个头文件中包含了所有的内核内部类型,从而避免了依赖系统层面的内核头文件

通过 bpftool 获得 vmlinux.h:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

有了 vmlinux.h,就无需再像通常的 BPF 程序那样 #include <linux/sched.h>、#include <linux/fs.h> 等等头文件, 现在只需要 #include "vmlinux.h",也不用再安装 kernel-devel 了。

vmlinux.h 包含了所有的内核类型:

作为 UAPI 的一部分暴露的 API通过 kernel-devel 暴露的内部类型其他一些通过任何其他方式都无法获取的内部内核类型

不幸的是,BPF(以及 DWARF)并不记录 #define 宏,因此某些常用 的宏可能在 vmlinux.h 中是缺失的。但这些没有记录的宏中 ,最常见的一些已经在 bpf_helpers.h (libbpf 提供的内核侧”库“)提供了。

4.2 读取内核结构体字段

最常见和最典型的场景就是从某些内核结构体中读取一个字段。

4.2.1 例子:读取task_struct->pid字段

假设我们想读取 task_struct 中的 pid 字段。

方式一:BCC(可移植)

用 BCC 实现,代码很简单:

pid_t pid = task->pid;

BCC 有强大的代码重写(rewrite)能力,能自动将以上代码转换成一次 bpf_probe_read() 调用 (但有时重写之后的代码并不能正确,具体取决于表达式的复杂程度)。

libbpf 没有 BCC 的代码重写魔法(code-rewriting magic),但提供了几种其他方式来实现同样的目的。

方式二:libbpf+BPF_PROG_TYPE_TRACING(不可移植)

如果使用的是最近新加的 BTF_PROG_TYPE_TRACING 类型 BPF 程序,那校验器已经足够智能了,能原生地理解和记录 BTF 类型、跟踪指针,直接(安全地)读取内核内存 ,

    pid_t pid = task->pid;

从而避免了调用 bpf_probe_read(),格式和语法更为自然,而且无需编译器重写(rewrite)。 但此时,这段代码还不是可移植的。

方式三:BPF_PROG_TYPE_TRACING+ CO-RE(可移植)

要将以上 BPF_PROG_TYPE_TRACING 代码其变成可移植的,只需将待访问字段 task->pid 放到编译器内置的一个名为 __builtin_preserve_access_index() 的宏中:

pid_t pid = __builtin_preserve_access_index(({ task->pid; }));

这就是全部工作了:这样的程序在不同内核版本之间是可移植的。

方式四:libbpf + CO-RE bpf_core_read()(可移植)

如果使用的内核版本还没支持 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_probe_read() 替换成 bpf_core_read():

pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);

bpf_core_read() 是一个很简单的宏,直接展开成以下形式:

bpf_probe_read(&pid, sizeof(pid), __builtin_preserve_access_index(&task->pid));

可以看到,第三个参数(&task->pid)放到了前面已经介绍过的编译器 built-int 中, 这样 clang 就能记录该字段的重定位信息,实现可移植。

第二种方式是使用 BPF_CORE_READ() 宏,我们通过下面的例子来看。

4.2.2 例子:读取task->mm->exe_file->f_inode->i_ino字段

这个字段表示的是当前进程的可执行文件的 inode。 来看一下访问嵌套层次如此深的结构体字段时,面临哪些问题。

方式一:BCC(可移植)

用 BCC 实现的话可能是下面这样:

u64 inode = task->mm->exe_file->f_inode->i_ino;

BCC 会对这个表达式进行重写(rewrite),转换成 4 次 bpf_probe_read()/bpf_core_read() 调用, 并且每个中间指针都需要一个额外的临时变量来存储。

方式二:BPF CO-RE(可移植)

下面是 BPF CO-RE 的方式,仍然很简洁,但无需 BCC 的代码重写(code-rewriting magic):

u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);

另外一个变种是:

u64 inode; BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino);

4.2.3 其他与字段读取相关的 CO-RE 宏

bpf_core_read_str():可以直接替换 Non-CO-RE 的 bpf_probe_read_str()。BPF_CORE_READ_STR_INTO():与 BPF_CORE_READ_INTO() 类似,但会对最后一个字段执行 bpf_probe_read_str()。bpf_core_field_exists():判断字段是否存在,1 pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;bpf_core_field_size():判断字段大小,同一字段在不同版本的内核中大小可能会发生变化,1 u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */BPF_CORE_READ_BITFIELD():通过直接内存读取(direct memory read)方式,读取比特位字段BPF_CORE_READ_BITFIELD_PROBED():底层会调用 bpf_probe_read()1 2 3 4 5 6 7 8 struct tcp_sock *s = ...; /* with direct reads */ bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(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);

4.3 处理内核版本和配置差异

某些情况下,BPF 程序必须处理不同内核版本之间常用内核结构体的细微差异。例如,

字段被重命名了:对依赖这个字段的调用方来说,这其实变成了一个新字段(但语义没变)。字段名字没变,但表示的意思变了:例如,从 4.6 之后的某个内核版本开始, task_struct 的 utime 和 stime 字段,原来单位是 jiffies,现在变成了 nanoseconds,因此 调用方必须自己转换单位。需要从内核提取的某些数据是与内核配置有直接关系,某些内核在编译时并没有将相关代码编译进来。其他一些无法用单个、通用的类型定义来适用于所有内核版本的场景。

对于这些场景,BPF CO-RE 提供了两种互补的解决方式;

libbpf 提供的 extern Kconfig 变量struct flavors

libbpf 提供的externsKconfig 全局变量

系统中已经有一些”知名的“变量,例如 LINUX_KERNEL_VERSION,表示当前内核的版本。 BPF 程序能用 extern 关键字声明这些变量。另外,BPF 还能用 extern 的方式声明 Kconfig 的某些 key 的名字(例如 CONFIG_HZ,表示内核的 HZ 数)。

接下来的事情交给 libbpf,它会将这些变量分别匹配到系统中相应的值(都是常量), 并保证这些 extern 变量与全局变量的效果是一样的。

此外,由于这些 extern ”变量“都是常量,因此 BPF 校验器能用它们来做一些 高级控制流分析和死代码消除。

下面是个例子,如何用 BPF CO-RE 来提取线程的 CPU user time:

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);

struct flavors

有些场景中,不同版本的内核中有不兼容的类型,无法用单个通用结构体来为所有内核 编译同一个 BPF 程序。struct flavor 在这种情况下可以派上用场。

下面是一个例子,提取 fs/fsbase(前面提到过,字段名字在内核版本升级时改了)来 做一些 thread-local 的数据处理:

/* up-to-date thread_struct definition matching newer kernels */struct thread_struct {    ...    u64 fsbase;    ...};/* legacy thread_struct definition for <= 4.6 kernels */struct thread_struct___v46 { /* ___v46 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);

在这个例子中,对于 <=4.6 的内核,我们将原来的 thread_struct 定义为了 struct thread_struct___v46。 双下划线及其之后的部分,即 ___v46,称为这个 struct 的 “flavor”。

flavor 部分会被 libbpf 忽略,这意味着在目标机器上执行字段重定位时, struct thread_struct__v46 匹配的仍然是真正的 struct thread_struct。

这种方式使得我们能在单个 C 程序内,为同一个内核类型定义不同的(而且是不兼容的) 类型,然后在运行时(runtime)取出最合适的一个,这就是用 type cast to a struct flavor 来提取字段的方式。

没有 struct flavor 的话,就无法真正实现像上面那样“编译一次”,然后就能在不同内核 上都能运行的 BPF 程序 —— 而只能用#ifdef 来控制源代码,编译成两个独立的 BPF 程序变种,在运行时(runtime)由控制应用根据所在机器的内核版本选择其中某个变种。 所有这些都添加了不必要的复杂性和痛苦。 相比之下,以上 BPF CO-RE 方式虽然不是透明的(上面的代码中也包含了内核 版本相关的逻辑),但允许用熟悉的 C 代码结构解决即便是这样的高级场景的问题。

4.4 根据用户提供的配置修改程序行为

BPF 程序知道内核版本和配置信息,有时还不足以判断如何 —— 以及以何种方式 —— 从该版本的内核获取数据。 在这些场景中,用户空间控制应用(control application)可能是唯一知道究竟需要做哪些事情,以及需要启用或禁用哪些特性的主体。 这通常是在用户空间和 BPF 程序之间通过某种形式的配置数据来通信的。

BPF map 方式

要实现这种目的,一种不依赖 BPF CO-RE 的方式是:将 BPF map 作为一个存储配置 数据的地方。BPF 程序从 map 中提取配置信息,然后基于这些信息改变它的控制流。

但这种方式有几个主要的缺点:

BPF 程序每次执行 map 查询操作,都需要运行时开销(runtime overhead)。多次查询累积起来,开销就会比较比较明显,尤其在一些高性能 BPF 应用的场景。配置内容(config value),虽然在 BPF 程序启动之后就是不可变和只读 (immutable and read-only)的了,但 BPF 校验器在校验时扔把它们当作未知的黑盒值。这意味着校验器无法消除死代码,也无法执行其他高级代码分析。进一步, 这意味着我们无法将代码逻辑放到 map 中,例如,能处理不同内核版本差异的 BPF 代 码,因为 map 中的内容对校验器都是黑盒,因此校验器对它们是不信任的 —— 即使用户配置信息是安全的。

只读的全局数据方式

这种(确实复杂的)场景的解决方案:使用只读的全局数据(read-only global data)。 这些数据是在 BPF 程序加载到内核之前,由控制应用设置的。

从 BPF 程序的角度看,这就是正常的全局变量访问,没有任何 BPF map lookup 开销 —— 全局变量实现为一次直接内存访问。控制应用方面,在 BPF 程序加载到内核之前设置初始的配置值,此后配置值就是全局可 访问且只读(well known and read-only)的了。这使得 BPF 校验器能将它们作为常量对待,然后就能执行高级控制流分析 (advanced control flow analysis)来消除死代码。

因此,针对上面那个例子,

某些老内核的 BPF 校验器就能推断出,例如,代码中某个未知的 BPF helper 不可能会用到,接下来就可以将相关代码直接移除。而对于新内核来说,应用提供的配置(application-provided configuration)会所有不 同,因此 BPF 程序就能用到功能更强大的 BPF helper,而且这个逻辑能成功通过 BPF 校验器的验证。

下面的 BPF 代码例子展示了这种用法:

/* global read-only variables, set up by control app */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 skeleton 可以很方便地做这种配置。BPF skeleton 的讨论不在 本文讨论范围内,使用它来简化 BPF 应用的例子,可参考内核源码中的 runqslower tool。

5 总结

BPF CO-RE 的目标是:

作为一种简单的方式帮助 BPF 开发者解决简单的移植性问题(例如读取结构体的字段),并且作为一种仍然可行(不是最优,但可容忍)的方式 解决复杂的移植性问题(例如不兼容的数据结构改动、复杂的用户空间控制条件等)。使得开发者能遵循”一次编译、到处运行“(Compile Once – Run Everywhere)范式。

这是通过几个 BPF CO-RE 模块的组合实现的:

vmlinux.h 消除了对内核头文件的依赖;字段重定位信息(字段偏置、字段是否存在、字段大小等等)使得从内核提取数据这个过程变得可移植;libbpf 提供的 Kconfig extern 变量允许 BPF 程序适应不同的内核版本 —— 以及配置相关的差异;当其他方式都失效时,应用提供的只读配置和 struct flavor 最终救场,能解决任何需要复杂处理的场景。

要成功地编写、部署和维护可移植 BPF 程序,并不是必须用到所有这些 CO-RE 特性。 只需选择若干,用最简单的方式解决你的问题。

BPF CO-RE 使我们回到了熟悉、自然的工作流程:将 BPF C 源码编译成二进制,然后将 二进制文件分发到目标机器进行部署和运行 —— 无需再随着应用一起分发重量级的编译器库、无需消耗宝贵的运行时资源做运行时编译,也无需等到运行之前才能捕捉一些细微的编译时错误了。

BPF CO-RE 2021

到2021年,BPF CO-RE已成为一项成熟的技术,广泛应用于各种项目。

在Facebook,BPF CO-RE成功地为多个基于BPF的生产应用程序提供支持,既可以处理更改字段偏移的简单情况,也可以处理内核数据结构被删除、重命名或完全更改的更高级情况。所有这些都在一个编译一次的BPF应用程序中。

自从引入BPF CO-RE以来,超过50个BCC工具被转换为libbpf和BPF CO-RE。随着越来越多的Linux发行版默认启用内核BTF(见后面系统列表),基于BPF CO RE的工具变得更广泛,更有效地取代了基于Python的BCC工具。正如Brendan Gregg在其“BPF二进制文件:BTF、CO-RE和BPF性能工具的未来”博客文章中所强调的那样,这就是前进的方向。

BPF CO-RE在各个领域得到迅速采用,为高效的BPF应用提供了动力。它用于跟踪和性能监控、安全和审计,甚至用于BPF应用程序的联网。从小型嵌入式系统到大型生产服务器。创建libbpf boostrap项目是为了简化使用libbpf和BPF CO-RE启动BPF开发。因此,如果您感兴趣,请务必查看“使用libbpf boostrap构建BPF应用程序”博客文章。

在更技术层面上,除了已经描述的字段重定位,BPF CO-RE还获得了以下方面的支持:

类型大小和位置。当添加、删除或重命名类型时,能够检测到这一点并相应地调整BPF应用程序逻辑非常重要。请参见libbpf提供的bpf_core_type_exists()和bpf_core_type_size()宏。

枚举重定位(存在和值)。一些内部的非UAPI内核enum会在内核版本之间发生变化,甚至取决于用于内核编译的确切配置(例如,enum cgroup_subsys_id,请参阅BPF自我测试处理它),因此无法可靠地硬编码任何特定值。枚举重定位(bpf_core_enum_value_exists()和bpf_core_Enum_value()宏,由libbpf提供)允许检查特定枚举值的存在并捕获其值。其中一个重要的应用是检测新BPF助手的可用性,如果内核太旧,则返回到旧的BPF助手。

当使用只读全局变量进行编译时,这两者对于从BPF端执行简单可靠的内核特征检测是必不可少的。

现在还有一个专门的BPF CO-RE参考指南帖子,为BPF CO-ReE的所有功能提供实用指导,并提供如何在开发实际BPF应用程序时应用这些功能的提示。

开启BTF的系统列表

Fedora 31+RHEL 8.2+OpenSUSE Tumbleweed (in the next release, as of 2020-06-04)Arch Linux (from kernel 5.7.1.arch1-1)Manjaro (from kernel 5.4 if compiled after 2021-06-18)Ubuntu 20.10Debian 11 (amd64/arm64)

支持Clang/LLVM 10+的系统列表

Fedora 32+Ubuntu 20.04+Arch LinuxUbuntu 20.10 (LLVM 11)Debian 11 (LLVM 11)Alpine 3.13+参考资料BPF CO-RE presentation from LSF/MM2019 conference:summary, slides.Arnaldo Carvalho de Melo’s presentation “BPF: The Status of BTF” dives deep into BPF CO-RE and dissects the runqslower tool quite nicely.BTF deduplication algorithm

标签: #反编译elf