https://www.cnblogs.com/charlieroro/p/14206214.html
在上一篇文章中介绍了提高socket性能的几个socket选项,其中给出了几个源于内核源码树中的例子,如果选择使用内核树中的Makefile进行编译的话,可能会出现与本地头文件冲突的情况,如重复定义变量,结构体类型不对等错误。这些问题大大影响了BPF程序的可移植性。
本文将介绍BPF可移植性存在的问题,以及如何使用BPF CO-RE(Compile Once – Run Everywhere)解决这些问题。
自BPF成立以来,BPF社区将尽可能简化BPF应用程序的开发作为工作重点,目的是将BPF的使用变得与用户空间的应用一样简单明了。伴随着BPF可编程性的稳步发展,BPF程序的开发也越来越简单。
尽管BPF提升了使用上的便利性,但却忽略了BPF程序开发中的一个方面:可移植性。"BPF可移植性"意味着什么?我们将BPF可移植性定义为成功编写并通过内核验证的一个BPF程序,且跨内核版本可用,无需针对特定的内核重新编译。
本文描述了BPF的可移植性问题以及解决方案:BPF CO-RE(Compile Once – Run Everywhere)。首先会调研BPF本身的可移植性问题,描述为什么这是个问题,以及为什么解决它很重要。然后,我们将介绍解决方案中的高级组件:BPF CO-RE,并简要介绍实现这一目标所需要解决的难题。最后,我们将以各种教程作为结尾,介绍BPF CO-RE方法的用户API,并提供相关示例。
BPF程序是用户提供的一部分代码,这些代码会直接注入到内核,一旦经过加载和验证,BPF程序就可以在内核上下文中运行。这些程序运行在内核的内存空间中,并能够访问所有可用的内核内部状态,功能非常强大,这也是为什么BPF技术成功落地到多个应用中的原因。然而,在使用其强大的能力的同时也带来了一些负担:BPF程序无法控制周围内核环境的内存布局,因此必须依赖独立开发,编译和部署的内核。
此外,内核类型和数据结构会不断变化。不同的内核版本会在结构体内部混用结构体字段,甚至会转移到新的内部结构体中。结构体中的字段可能会被重命名或删除,类型可能会改变(变为微兼容或完全不同的类型)。结构体和其他类型可以被重命名,被条件编译(取决于内核配置),或直接从内核版本中移除。
换句话讲,不同内核发布版本中的所有内容都有可能发生变化,BPF应用开发者应该能够预料到这个问题。考虑到不断变化的内核环境,那么该如何利用BPF做有用的事?有如下几点原因:
首先,并不是所有的BPF程序都需要访问内部的内核数据结构。一个例子是opensnoop
工具,该工具依靠kprobes /tracepoints来跟踪哪个进程打开了哪些文件,仅需要捕获少量的系统调用就可以工作。由于系统调用提供了稳定的ABI,不会随着内核版本而变化,因此不用考虑这类BPF程序的可移植性。不幸的是,这类应用非常少,且这类应用的功能也会大大受限。
此外,内核内部的BPF机器提供了有限的“稳定接口”集,BPF程序可以依靠这些稳定接口在内核间保持稳定。事实上,不同版本的内核的底层结构和机制是会发生变化的,但BPF提供的稳定接口从用户程序中抽象了这些细节。
例如,网络应用会通过查看少量的sk_buff
(即报文数据)中的属性来获得非常有用且通用的信息。为此,BPF校验器提供了一个稳定的__sk_buff
视图(注意前面的下划线),该视图为BPF程序屏蔽了struct sk_buff
结构体的变更。所有对__sk_buff
字段访问都可以透明地重写为对实际sk_buff
的访问(有时非常复杂-在获取最终请求的字段之前需要追踪一堆内部指针)。类似的机制同样适用于不同的BPF程序类型,通过BPF校验器来识别特定类型的BPF上下文。如果使用这类上下文开发BPF程序,就可以不用担心可移植性问题。
但有时候需要访问原始的内核数据(如经常会访问到的 struct task_struct
,表示一个进程或线程,包含大量进程信息),此时就只能靠自己了。跟踪,监视和分析应用程序通常是这种情况,这些应用程序是一类非常有用的BPF程序。
在这种情况下,如果某些内核在需要采集的字段(如从struct task_struct
开始的第16个字节的偏移处)前添加了一个新的字段,那么此时如何保证不会读取到垃圾数据?如果一个字段重命名了又如何处理(如内核4.6和4.7的thread_struct
的fs
字段的名称是不同的)?或者如果需要基于一个内核的两种配置来运行程序,其中一个配置会禁用某些特性,并编译出部分结构(一种常见的场景是解释字段,这些字段是可选的,但如果存在则非常有用)?所有这些条件意味着无法使用本地开发服务器上的头文件编译出一个BPF程序,然后分发到其他系统上运行。这是因为不同内核版本的头文件中的数据的内存布局可能是不同的。
迄今为止,人们编译这类BPF程序会依赖BCC (BPF Compiler Collection)。使用BCC,可以将BPF程序的C代码以字符串的形式嵌入到用户空间的程序中,当程序最终部署并运行在目标主机上后,BCC会唤醒其嵌入的Clang/LLVM,提取本地内核头文件(必须确保已从正确的kernel-devel软件包中将其安装在系统上),并即时进行编译。通过这种方式来确保BPF程序期望的内存布局和主机运行的内核的内存布局是相同的。如果需要处理一些选项和内核编译出来的潜在产物,则可以在自己的源代码中添加#ifdef
/#else
来适应重命名字段、不同的数值语义或当前配置导致的不可用内容等带来的风险。嵌入的Clang会移除代码中无关的内容,并调整BPF程序代码,以匹配到特定的内核。
这种方式听起来很不错,但实际并非没有缺点:
总之, BCC是一个很好的工具,尤其适合快速原型制作,实验和小型工具,但在用于广泛部署的生产BPF应用程序时,它无疑具有很多缺点。
我们正在使用BPF CO-RE来增强BPF的可移植性,并相信这是未来BPF程序开发的趋势,尤其对于复杂的实际应用的BPF程序。
BPF CO-RE在软件堆栈的各个级别汇集了必要的功能和数据:内核,用户空间的BPF加载器库(libbpf),和编译器(Clang)。通过这些组件来支持编写可移植的BPF程序,使用相同的预编译的BPF程序来处理不同内核之间的差异。BPF CO-RE需要以下组件的集成和合作:
这些组件可以集成到一起工作,提供了前所未有的便捷性,适应性和表达性(来开发可移植BPF程序,以前只能在运行时通过BCC编译BPF程序的C代码来实现),而无需像BCC一样付出高昂的代价。
整个BPF CO-RE方法的关键推动因素之一是BTF。BTF (BPF Type Format) 是作为一个更通用,更详细的DWARF调试信息的替代品而出现的。BTF是一种节省空间,紧凑但依然具有足够表达能力的格式,可以描述C程序的所有类型信息。由于其简单性和使用的重复数据删除算法,与DWARF相比,BTF的大小可减少多达100倍。现在,已经可以在内核运行时显示地嵌入BPF类型信息:只需要启用CONFIG_DEBUG_INFO_BTF=y
内核选项即可。内核本身可以使用BTF功能,用于增强BPF验证程序自身的功能。
关于BPF CO-RE更重要的是,内核还通过/sys/kernel/btf/vmlinux
上的sysfs公开了这种自描述的权威BTF信息(定义了确切的结构布局)。尝试如下命令:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c
某些unix系统下安装的bpftool默认不支持btf命令选项,可以在linux内核源码的
/tools/bpf/bpftool
目录下执行make
命令进行编译。如果遇到linux/if.h
和net/if.h
头文件定义冲突的话,可以将/tools/bpf/bpftool/net.c
中的这一行注释掉再编译:
#include <linux/if.h>
目前很多内核默认并不会打开BTF内核选项,因此需要自己编译内核。基本步骤如下:
首先升级gcc;
编译带BTF选项的内核前需要安装pahole,可以从github官方下载源码编译即可。需要注意的是,该编译过程需要依赖
git
,因此需要通过git clone
代码编译,而不能下载源码压缩包编译;按照官方编译步骤直接执行make
时可能会遇到错误"Performing Test HAVE_REALLOCARRAY_SUPPORT - Failed",其实仅需要执行make pahole
编译出pahole即可。导出当前内核配置:
$ cd linux-5.10.1 $ cp -v /boot/config-$(uname -r) .config
在linux-5.10.1目录中使用
make menuconfig
命令修改系统配置文件,并保存。可以使用"/"直接查找需要修改的内核选项;编译并创建内核镜像,如果仅需要vmlinux的话,在编译完之后执行
make vmlinux
即可
$ make #可以使用多核方式加速编译,指定使用4个核 $ make -j 4 #使用nproc命令获取到的核数 $ make -j $(nproc)
安装内核:
$ sudo make modules_install
安装内核:
$ sudo make install
更新 grub config文件
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg $ sudo grubby --set-default /boot/vmlinuz-5.6.9
重启
通过上述命令可以获得到一个可兼容的C头文件(即"vmlinux.h"),包含所有的内核类型("所有"意味着包含那些不会通过kernel-devel
包暴露的头文件)。
为了启用BPF CO-RE,并让BPF加载程序(即libbpf)将BPF程序调整为在目标主机上运行的特定内核,Clang扩展了一些内置功能,通过这些扩展功能可以发出BTF重定位,捕获有关BPF程序代码打算读取哪些信息的高级描述。例如要读取task_struct->pid
字段,Clang会记录一个名为"pid"的字段,类型为"pid_t",位于struct task_struct
中。这样,即使目标内核的task_struct
结构中的"pid"字段在task_struct
结构体内部发生了偏移(如,由于"pid"字段前面添加了额外的字段),或即使该字段转移到了某个嵌套的匿名结构或联合体中,这样也能够通过其名称和类型信息找到它。这种方式称为字段偏移量重定位。
通过这种方式可以捕获不仅一个字段的偏移量,也可以捕获字段的其他属性,如字段的存在性或大小。即使对于比特字段(众所周知,它们是C语言中“拒绝合作”的数据类型),也能够捕获足够多的数据来重定位这些字段,所有这些对BPF程序开发人员都是透明的。
前面的所有数据最终会集合到一起,由libbpf进行处理,libbpf作为BPF程序的加载器。它会使用编译好的BPF ELF文件,必要时对其进行后处理,配置各种内核对象(maps,programs等),然后触发BPF程序的加载和验证。
libbpf知道如何将BPF程序代码匹配到特定的内核。它会查看程序记录的BTF类型和重定位信息,然后将这些信息与内核提供的BTF信息进行匹配。libbpf解析并匹配所有的类型和字段,更新必要的偏移以及重定位数据,确保BPF程序能够正确地运行在特定的内核上。如果一切顺利,则BPF应用开发人员会获得一个BPF程序,这种方式可以针对目标主机上的内核进行“量身定制”,就好像程序是专门针对这个内核编译的,但无需在应用程序中分发Clang以及在目标主机上的运行时中执行编译,就可以实现所有这些目标。
令人惊奇的是,内核无需太多变动就可以支持BPF CO-RE。归功于一个好的关注点分离(separation of concerns,SOC),当libbpf处理完BPF程序代码之后,在内核看来,它与其他有效的BPF程序代码一样,与使用最新内核头文件在主机上直接编译的BPF程序并没有区别,这意味着BPF CO-RE的许多功能都不需要先进的内核功能,因此可以更广泛,更迅速地进行调整。
有可能在某些场景下需求较新内核的支持,但这种情况很少。在下一部分中,我们将在解释BPF CO-RE面向用户的机制时讨论这种情况,其中将详细介绍BPF CO-RE面向用户的API。
现在我们将看一下BPF应用的一些典型场景,以及如何通过BPF CO-RE解决兼容性问题。下面可以看到,一些可移植性问题(如兼容结构体布局差异)可以透明地进行处理,但其他一些场景则需要更加显示地处理,如if
/else
条件判断(与编译时BCC程序中的#ifdef
/#else
构造相反)和BPF CO-RE提供的一些额外机制。
除了使用内核的BTF信息进行字段的重定位意外,还可以将BTF信息生成一个大(基于5.10.1版本生成的长度有106382行)的头文件("vmlinux.h"),其中包含了所有的内核内部类型,可以避免对系统范围的内核头文件的依赖。可以使用如下方式生成vmlinux.h:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
当使用了vmlinux.h,此时就不需要依赖像#include <linux/sched.h>
, #include <linux/fs.h>
这样的头文件,仅需要#include "vmlinux.h"
即可。该头文件包含了所有的内核类型:暴露了UAPI,通过kernel-devel
提供的内部类型,以及其他一些更加内部的内核类型。
不幸的是,BTF(即DWARF)不会记录#define
宏,因此在vmlinux.h中丢失一些常用的宏。但大多数通常不存在的宏可以通过libbpf的bpf_helpers.h(即libbpf提供的内核侧的库)头文件提供。
大多数场景下会从某个内核结构中读取一个字段。假设我们期望读取task_struct
结构体的pid字段。使用BCC时非常简单:
pid_t pid = task->pid;
BCC会将task->pid重写为对bpf_probe_read()
的调用,非常方便(虽然有时候不会成功,具体取决于使用的表达式的复杂度)。当使用libbpf时,由于它没有BCC的代码重写功能,因此需要使用其他方式来得到相同的结果。
如果添加了BTF_PROG_TYPE_TRACING
程序,那么就可以轻松掌握BPF验证程序,允许理解和跟踪BTF类型的本质,并允许使用指针直接读取内核内存,避免使用bpf_probe_read()
调用。
Libbpf + BPF_PROG_TYPE_TRACING 方式:
pid_t pid = task->pid;
将该功能与BPF CO-RE配合使用,可以支持可移植(即可重定位)的字段读取,此时需要将此代码封装到编译器内置的__builtin_preserve_access_index
中
BPF_PROG_TYPE_TRACING + BPF CO-RE 方式:
pid_t pid = __builtin_preserve_access_index(({ task->pid; }));
这种方式能够正常工作,同时也支持不同内核版本间的可移植性。但鉴于BPF_PROG_TYPE_TRACING
的前沿性,因此必须显式地使用bpf_probe_read()
。
非CO-RE libbpf方式:
pid_t pid;
bpf_probe_read(&pid, sizeof(pid), &task->pid);
现在,使用CO-RE+libbpf,我们有两种方式来实现访问pid字段的值。一种是直接使用bpf_core_read()
替换bpf_probe_read()
:
pid_t pid;
bpf_core_read(&pid, sizeof(pid), &task->pid);
bpf_core_read()
是一个简单的宏,它会将所有的参数直接传递给bpf_probe_read()
,但也会使Clang通过__builtin_preserve_access_index()
记录第三个参数(&task->pid
)的字段的偏移量。
bpf_probe_read(&pid, **sizeof**(pid), __builtin_preserve_access_index(&task->pid));
但像bpf_probe_read()
/bpf_core_read()
这样的调用方式很快就会变得难以维护,特别是获取通过指针连在一起的结构体时。例如,获取当前进程的可执行文件的inode
号时,可以使用BCC获取:
u64 inode = task->mm->exe_file->f_inode->i_ino;
当使用 bpf_probe_read()
/bpf_core_read()
时,将会变为4个调用,并使用一个临时变量来保存这些中间指针,才能最终获得i_ino
字段。当使用BPF CO-RE时,我们可以使用一个辅助宏来使用类似BCC的方式获得该字段的值:
BPF CO-RE方式:
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);
还有一个对应的 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); /* will set comm_sz to 16 */
除此之外,在某些情况下,当读取一个内核结构体的比特位字段时,可以使用特殊的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_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);
字段重定位和相关的宏是BFP CO-RE提供的主要能力。它涵盖了很多实际的使用案例。
在一些场景下,BPF程序不得不处理内核间的差异。如某些字段名称的变更导致其变为了一个完全不同的字段(但具有相同的意义)。反之亦然,当字段不变,但其含义发生了变化。如在内核4.6之后,task_struct
结构体的utime
和stime
字段从以秒为单位换为以纳秒为单位,这种情况下,不得不进行一些转换工作。有时,需要提取的数据存在于某些内核配置中,但已在其他内核配置中进行了编译。还有在很多其他场景下,不可能有一个适合所有内核的通用类型。
为了处理上述问题,BPF CO-RE提出了两种补充方案:libbpf提供了extern Kconfig variables 和struct flavors.
Libbpf提供的外部变量很简单。BPF程序可以使用一个知名名称(如LINUX_KERNEL_VERSION,用于获取允许的内核的版本)定义一个外部变量,或使用Kconfig的键(如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);
其他机制,如struct flavors,可以用于不同内核间类型不兼容的场景。这种场景下,无法使用一个通用的结构体定义来为多个内核提供相同的BPF程序。下面是一个人为构造的例子,看下struct flavors如何抽取fs
/fsbase
(已经重命名)来作一些线程本地数据的处理:
/* 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);
本例中,BPF应用将<= 4.6内核的“旧版” thread_struct
定义为struct thread_struct___v46
。类型名称中的三个下划线以及其后的所有内容均被视为此结构的“flavor”。libbpf会忽略这个flavor部分,即在执行重定位时,该类型定义会匹配到实际运行的内核的struct thread_struct
。这样的约定允许在一个C程序中具有可替代(且不兼容)的定义,并在运行时选择最合适的定义(例如,上面示例中的特定于内核版本的处理逻辑),然后使用类型强转为struct flavor来提取必要的字段。
如果没有structural flavors,则不能实现编译一次就可以在多个内核上运行的目标,否则就需要将#ifdef
源代码编译成两个单独的BPF程序,并在运行时由控制应用程序手动选择适当的BPF程序,这些操作增加了复杂度和维护的成本。尽管不是透明的,但BPF CO-RE甚至可以使用这种高级方案,通过熟悉的C代码构造来解决此问题。
有时候,在BPF程序了解内核版本和配置之后仍然无法决定如何从内核获取数据。这种情况下,用户空间的控制程序可能是唯一知道确切需要做什么的一方,以及需要启用或禁用那些特性。通常是通过某种配置数据进行通信,在用户空间和BPF程序之间共享数据。现今,一种不需要依赖BPF CO-RE的实现方式是使用BPF map作为配置数据的容器。BPF程序通过查找BPF map来抽取配置,并根据配置变更控制流,但这种方法有很多缺点:
解决此类(公认复杂)场景的方法是使用只读全局数据。在BPF程序加载到内核之前由控制应用进行设置。从BPF程序侧看,这部分数据就像访问普通的全局变量。由于全局变量使用直接内存访问方式,因此不会产生BPF map查询的开销。控制语言侧需要在BPF程序加载之前设置初始的配置值,这样当BPF校验器进行程序校验时,会将配置值认为是只读的,这样BPF校验器会将这部分内容认为是已知的常量,并使用高级控制流分析来执行无用代码的删除。
上例中,在老版本的BPF校验器下,将不会使用未知的BPF辅助功能,且这部分代码会被移除。在新版本BPF校验器下,应用提供不同的配置后,允许使用新的BPF辅助功能,这部分逻辑会通过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框架轻松地提供此配置。BPF框架讨论不在本文讨论范围之内,请参阅内核代码库中的runqslower 工具来展示如何使用它来简化BPF应用程序。
BPF CO-RE的目标是帮助BPF开发者使用一个简单的方式解决简单的可移植性问题(如读取结构体字段),并使用它来定位复杂的可移植性问题(如不兼容的数据结构,复杂的用户空间控制条件等)。使得开发者的BPF程序能够"一次编译–随处运行", 这是通过结合一些上述的BPF CO-RE构建块来实现的:
vmlinux.h
消除了对内核头文件的依赖;Kconfig
外部变量允许BPF程序适应各种内核版本以及特定配置的更改;struct flavors
,作为解决任何应用程序必须处理的复杂场景的最终大锤。不需要CO-RE功能也可以成功编写,部署和维护可以支持的BPF程序,但在需要时,BPF CO-RE可提供最简单的方式来帮助解决问题。所有这些功能仍然提供了良好的可用性和熟悉的工作流程,可将C代码编译为二进制文件,并进行轻量级的分发。不再需要繁琐的编译器库并为运行时编译付出宝贵的运行时资源。 同样,也不再需要在运行时捕获琐碎的编译错误。
/tools/lib/bpf
目录下本文来自博客园,作者:charlieroro,转载请注明原文链接:https://www.cnblogs.com/charlieroro/p/14206214.html