适合小孩子的 BPF,第一部分:扩展 BPF

一开始有一种技术,叫做BPF。 我们看着她 ,本系列的旧约文章。 2013年,在Alexei Starovoitov和Daniel Borkman的努力下,针对现代64位机器优化的改进版本被开发出来并包含在Linux内核中。 这项新技术被简单地称为“内部 BPF”,然后更名为“扩展 BPF”,现在,几年后,每个人都简单地称之为“BPF”。

粗略地说,BPF 允许您在 Linux 内核空间中运行任意用户提供的代码,并且新架构非常成功,以至于我们还需要十几篇文章来描述它的所有应用程序。 (开发人员唯一做得不好的事情是创建了一个像样的徽标,正如您在下面的性能代码中看到的那样。)

本文介绍了 BPF 虚拟机的结构、使用 BPF 的内核接口、开发工具,以及对现有功能的简要概述,即我们将来深入研究 BPF 的实际应用所需的一切。
适合小孩子的 BPF,第一部分:扩展 BPF

文章摘要

BPF 架构简介。 首先,我们将鸟瞰 BPF 架构并概述主要组件。

BPF虚拟机的寄存器和命令系统。 已经了解了整个架构,我们将描述 BPF 虚拟机的结构。

BPF 对象的生命周期,bpffs 文件系统。 在本节中,我们将仔细研究 BPF 对象的生命周期 - 程序和映射。

使用 bpf 系统调用管理对象。 通过对系统的一些了解,我们最终将了解如何使用特殊的系统调用从用户空间创建和操作对象 - bpf(2).

Пишем программы BPF с помощью libbpf. 当然,您可以使用系统调用来编写程序。 但这很难。 为了更现实的场景,核程序员开发了一个库 libbpf。 我们将创建一个基本的 BPF 应用程序框架,我们将在后续示例中使用它。

内核助手。 在这里,我们将了解 BPF 程序如何访问内核辅助函数 - 与经典 BPF 相比,该工具与映射一起从根本上扩展了新 BPF 的功能。

从 BPF 程序访问地图。 至此,我们已经足够了解如何创建使用地图的程序。 让我们快速浏览一下伟大而强大的验证器。

开发工具。 有关如何组装实验所需的实用程序和内核的帮助部分。

结论。 在文章的最后,读到这里的人会发现激励人心的话语以及对后续文章中将发生的事情的简要描述。 我们还会列出一些自学的链接,供那些没有意愿或能力等待继续的人参考。

BPF架构简介

在我们开始考虑 BPF 架构之前,我们将最后一次(哦)提到 经典带通滤波器,它是为了响应 RISC 机器的出现而开发的,解决了高效数据包过滤的问题。 该架构非常成功,诞生于伯克利 UNIX 的 XNUMX 年代,后来被移植到大多数现有操作系统,一直延续到疯狂的 XNUMX 年代,并且仍在寻找新的应用程序。

新的 BPF 是为了应对 64 位机器、云服务的普遍存在以及对创建 SDN 工具日益增长的需求而开发的(S软件-d确定的 n网络工作)。 新的 BPF 由内核网络工程师开发,作为经典 BPF 的改进替代品,六个月后,它在跟踪 Linux 系统这一艰巨的任务中找到了应用,而现在,在它出现六年后,我们需要一篇完整的下一篇文章来了解列出不同类型的程序。

有趣的图片

从本质上讲,BPF 是一个沙箱虚拟机,允许您在内核空间中运行“任意”代码,而不会影响安全性。 BPF 程序在用户空间中创建,加载到内核中,并连接到某个事件源。 例如,事件可以是向网络接口传送数据包、启动某些内核功能等。 在包的情况下,BPF 程序将有权访问包的数据和元数据(用于读取,可能还可以写入,具体取决于程序的类型);在运行内核函数的情况下,函数,包括指向内核内存的指针等。

让我们仔细看看这个过程。 首先,我们来谈谈与经典 BPF 的第一个区别,经典 BPF 的程序是用汇编语言编写的。 在新版本中,架构得到了扩展,以便可以用高级语言编写程序,当然主要是用 C 语言。为此,开发了 llvm 的后端,它允许您为 BPF 架构生成字节码。

适合小孩子的 BPF,第一部分:扩展 BPF

BPF 架构的设计部分是为了在现代机器上高效运行。 为了在实践中实现这一点,BPF 字节码一旦加载到内核中,就会使用称为 JIT 编译器的组件转换为本机代码(J乌斯 In T我)。 接下来,如果您还记得的话,在经典的 BPF 中,程序被加载到内核中并以原子方式附加到事件源 - 在单个系统调用的上下文中。 在新架构中,这分两个阶段发生 - 首先,使用系统调用将代码加载到内核中 bpf(2)然后,稍后,通过根据程序类型而变化的其他机制,程序附加到事件源。

说到这里,读者可能会有一个疑问:这可能吗? 这样的代码执行安全如何保证呢? 我们通过加载BPF程序的阶段(称为verifier)来保证执行安全(这个阶段在英文中称为verifier,我将继续使用英文单词):

适合小孩子的 BPF,第一部分:扩展 BPF

Verifier 是一个静态分析器,可确保程序不会破坏内核的正常运行。 顺便说一句,这并不意味着程序不能干扰系统的操作 - BPF 程序,根据类型,可以读取和重写内核内存的部分、函数的返回值、修剪、追加、重写甚至转发网络数据包。 验证器保证运行 BPF 程序不会使内核崩溃,并且根据规则具有写访问权限的程序(例如传出数据包的数据)将无法覆盖数据包外部的内核内存。 在熟悉 BPF 的所有其他组件之后,我们将在相应部分更详细地了解验证器。

那么到目前为止我们学到了什么? 用户用C编写程序,使用系统调用将其加载到内核中 bpf(2),由验证者检查并翻译为本机字节码。 然后,同一个或另一个用户将程序连接到事件源并开始执行。 由于多种原因,有必要将启动和连接分开。 首先,运行验证程序相对昂贵,并且多次下载相同的程序会浪费计算机时间。 其次,程序具体如何连接取决于其类型,一年前开发的“通用”接口可能不适合新类型的程序。 (虽然现在架构越来越成熟,但是有一个想法是在层面上统一这个接口 libbpf.)

细心的读者可能会注意到我们的图片还没有完成。 事实上,以上所有内容并不能解释为什么 BPF 与经典 BPF 相比从根本上改变了情况。 显着扩展适用范围的两项创新是使用共享内存和内核辅助函数的能力。 在 BPF 中,共享内存是使用所谓的映射(具有特定 API 的共享数据结构)来实现的。 他们之所以得到这个名字,可能是因为第一种出现的映射类型是哈希表。 然后出现了数组、本地(每个 CPU)哈希表和本地数组、搜索树、包含 BPF 程序指针的映射等等。 现在我们感兴趣的是,BPF 程序现在能够在调用之间保留状态并与其他程序和用户空间共享它。

使用系统调用从用户进程访问地图 bpf(2),以及使用辅助函数在内核中运行的 BPF 程序。 此外,助手的存在不仅可以处理映射,还可以访问其他内核功能。 例如,BPF 程序可以使用辅助函数将数据包转发到其他接口、生成性能事件、访问内核结构等。

适合小孩子的 BPF,第一部分:扩展 BPF

总之,BPF 提供了将任意(即经过验证者测试的)用户代码加载到内核空间的能力。 该代码可以在调用之间保存状态并与用户空间交换数据,并且还可以访问此类程序允许的内核子系统。

这已经和内核模块提供的能力类似了,相比之下BPF有一些优势(当然,你只能比较类似的应用,例如系统跟踪——你不能用BPF编写任意驱动程序)。 你可以注意到较低的入门门槛(一些使用 BPF 的实用程序不需要用户具备内核编程技能,或者一般的编程技能)、运行时安全性(对于那些在编写时没有破坏系统的人请在评论中举手)或测试模块),原子性 - 重新加载模块时存在停机时间,并且 BPF 子系统确保不会错过任何事件(公平地说,并非所有类型的 BPF 程序都是如此)。

这种能力的存在使得BPF成为扩展内核的通用工具,这在实践中得到了证实:越来越多的新类型程序被添加到BPF中,越来越多的大公司在作战服务器上24×7使用BPF,越来越多初创公司在基于 BPF 的解决方案上建立自己的业务。 BPF 无处不在:防御 DDoS 攻击、创建 SDN(例如,为 kubernetes 实现网络)、作为主要系统跟踪工具和统计收集器、入侵检测系统和沙箱系统等。

我们到这里就完成了本文的概述部分,并更详细地了解虚拟机和 BPF 生态系统。

题外话:公用事业

为了能够运行以下部分中的示例,您可能需要许多实用程序,至少 llvm/clang 与 bpf 支持和 bpftool... 在本章 开发工具 您可以阅读有关组装实用程序以及内核的说明。 这部分放在下面是为了不影响我们演示的和谐。

BPF虚拟机寄存器和指令系统

BPF 的架构和命令系统的开发考虑到程序将用 C 语言编写,并在加载到内核后翻译为本机代码。 因此,在选择寄存器的数量和命令集时,要着眼于数学意义上的现代机器能力的交集。 此外,对程序还施加了各种限制,例如,直到最近还无法编写循环和子例程,指令数量限制为 4096 条(现在特权程序最多可以加载一百万条指令)。

BPF 有 64 个用户可访问的 XNUMX 位寄存器 r0 - r10 和一个程序计数器。 登记 r10 包含一个帧指针并且是只读的。 程序可以在运行时访问 512 字节的堆栈以及映射形式的无限量的共享内存。

BPF 程序可以运行一组特定的程序类型内核帮助程序,以及最近的常规函数​​。 每个被调用的函数最多可以接受五个参数,并在寄存器中传递 r1 - r5,并将返回值传递给 r0。 确保从函数返回后,寄存器的内容 r6 - r9 不会改变。

为了高效的程序翻译,寄存器 r0 - r11 考虑到当前架构的 ABI 功能,所有受支持的架构都唯一映射到实际寄存器。 例如,对于 x86_64 寄存器 r1 - r5,用于传递函数参数,显示在 rdi, rsi, rdx, rcx, r8,用于将参数传递给函数 x86_64。 例如,左侧的代码转换为右侧的代码,如下所示:

1:  (b7) r1 = 1                    mov    $0x1,%rdi
2:  (b7) r2 = 2                    mov    $0x2,%rsi
3:  (b7) r3 = 3                    mov    $0x3,%rdx
4:  (b7) r4 = 4                    mov    $0x4,%rcx
5:  (b7) r5 = 5                    mov    $0x5,%r8
6:  (85) call pc+1                 callq  0x0000000000001ee8

登记册 r0 也用于返回程序执行的结果,并在寄存器中 r1 程序被传递一个指向上下文的指针 - 根据程序的类型,这可能是一个结构体 struct xdp_md (对于 XDP)或结构 struct __sk_buff (针对不同的网络程序)或结构 struct pt_regs (针对不同类型的跟踪程序)等。

因此,我们有一组寄存器、内核助手、堆栈、上下文指针和映射形式的共享内存。 并不是说所有这些都是旅行中绝对必要的,但是......

让我们继续描述并讨论处理这些对象的命令系统。 全部 (几乎所有) BPF 指令具有固定的 64 位大小。 如果您查看 64 位 Big Endian 机器上的一条指令,您会看到

适合小孩子的 BPF,第一部分:扩展 BPF

这是 Code - 这是指令的编码, Dst/Src 分别是接收器和源的编码, Off - 16 位有符号缩进,以及 Imm 是一些指令中使用的 32 位有符号整数(类似于 cBPF 常量 K)。 编码 Code 有两种类型之一:

适合小孩子的 BPF,第一部分:扩展 BPF

指令类 0、1、2、3 定义了使用内存的命令。 他们 被称为, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, 分别。 4、7 班(BPF_ALU, BPF_ALU64)构成一组ALU指令。 5、6 班(BPF_JMP, BPF_JMP32) 包含跳转指令。

研究BPF指令系统的进一步计划如下:我们不会详细列出所有指令及其参数,而是在本节中看几个示例,从中可以清楚指令实际上是如何工作的以及如何手动反汇编 BPF 的任何二进制文件。 为了巩固本文后面的内容,我们还将在有关验证器、JIT 编译器、经典 BPF 翻译以及学习映射、调用函数等部分中遇到单独的说明。

当我们谈论单个指令时,我们会参考核心文件 bpf.h и bpf_common.h,定义了 BPF 指令的数字代码。 当您自己研究架构和/或解析二进制文件时,您可以在以下来源中找到语义(按复杂程度排序): 非官方 eBPF 规范, BPF 和 XDP 参考指南、指令集, 文档/网络/filter.txt 当然,还有 Linux 源代码中的验证器、JIT、BPF 解释器。

示例:在你的头脑中拆解 BPF

让我们看一个编译程序的例子 readelf-example.c 并查看生成的二进制文件。 我们将揭晓原创内容 readelf-example.c 下面,我们从二进制代码恢复其逻辑后:

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

输出中的第一列 readelf 是一个缩进,因此我们的程序由四个命令组成:

Code Dst Src Off  Imm
b7   0   0   0000 01000000
15   0   1   0100 00000000
b7   0   0   0000 02000000
95   0   0   0000 00000000

命令码相等 b7, 15, b7 и 95。 回想一下,最低有效的三位是指令类别。 在我们的例子中,所有指令的第四位为空,因此指令类别分别为 7, 5, 7, 5。类别 7 是 BPF_ALU64,5 是 BPF_JMP。 对于这两个类,指令格式是相同的(见上文),我们可以像这样重写我们的程序(同时我们将以人类形式重写其余的列):

Op S  Class   Dst Src Off  Imm
b  0  ALU64   0   0   0    1
1  0  JMP     0   1   1    0
b  0  ALU64   0   0   0    2
9  0  JMP     0   0   0    0

手术 bALU64 - BPF_MOV。 它将一个值分配给目标寄存器。 如果该位已设置 s (source),那么该值是从源寄存器中获取的,如果像我们的例子一样,它没有被设置,那么该值是从字段中获取的 Imm。 所以在第一条和第三条指令中我们执行操作 r0 = Imm。 此外,JMP 1 类操作是 BPF_JEQ (如果相等则跳转)。 在我们的例子中,由于位 S 为零,它将源寄存器的值与字段进行比较 Imm。 如果值一致,则发生转变 PC + Off哪里 PC与往常一样,包含下一条指令的地址。 最后,JMP 9 类操作是 BPF_EXIT。 该指令终止程序,返回内核 r0。 让我们向表中添加一个新列:

Op    S  Class   Dst Src Off  Imm    Disassm
MOV   0  ALU64   0   0   0    1      r0 = 1
JEQ   0  JMP     0   1   1    0      if (r1 == 0) goto pc+1
MOV   0  ALU64   0   0   0    2      r0 = 2
EXIT  0  JMP     0   0   0    0      exit

我们可以用更方便的形式重写它:

     r0 = 1
     if (r1 == 0) goto END
     r0 = 2
END:
     exit

如果我们记得寄存器中的内容 r1 该程序从内核传递一个指向上下文的指针,并在寄存器中 r0 该值返回给内核,然后我们可以看到,如果指向上下文的指针为零,则返回 1,否则返回 - 2。让我们通过查看源代码来检查我们是否正确:

$ cat readelf-example.c
int foo(void *ctx)
{
        return ctx ? 2 : 1;
}

是的,这是一个毫无意义的程序,但它只是翻译成四个简单的指令。

异常示例:16 字节指令

我们前面提到有些指令占用的空间超过 64 位。 例如,这适用于指令 lddw (代码= 0x18 = BPF_LD | BPF_DW | BPF_IMM) — 将字段中的双字加载到寄存器中 Imm。 一点是, Imm 大小为 32,而一个双字为 64 位,因此在一条 64 位指令中将 64 位立即数加载到寄存器中是行不通的。 为此,使用两条相邻指令将 64 位值的第二部分存储在字段中 Imm。 例如:

$ cat x64.c
long foo(void *ctx)
{
        return 0x11223344aabbccdd;
}
$ clang -target bpf -c x64.c -o x64.o -O2
$ llvm-readelf -x .text x64.o
Hex dump of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

二进制程序中只有两条指令:

Binary                                 Disassm
18000000 ddccbbaa 00000000 44332211    r0 = Imm[0]|Imm[1]
95000000 00000000                      exit

我们将根据指示再次见面 lddw,当我们谈论搬迁和使用地图时。

示例:使用标准工具拆卸 BPF

因此,我们已经学会了读取 BPF 二进制代码,并准备好在必要时解析任何指令。 不过,值得一提的是,在实践中使用标准工具反汇编程序更加方便快捷,例如:

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 <foo>:
 0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
 2: 95 00 00 00 00 00 00 00 exit

BPF对象的生命周期,bpffs文件系统

(我首先从这里了解到本小节中描述的一些细节 四旬期 阿列克谢·斯塔罗沃伊托夫 BPF 博客.)

BPF 对象(程序和映射)是使用命令从用户空间创建的 BPF_PROG_LOAD и BPF_MAP_CREATE 系统调用 bpf(2),我们将在下一节中详细讨论这是如何发生的。 这将创建内核数据结构并为每个结构创建内核数据结构 refcount (引用计数)设置为XNUMX,并且指向该对象的文件描述符被返回给用户。 手柄关闭后 refcount 对象减一,当减到零时,对象被销毁。

如果程序使用映射,那么 refcount 这些地图在加载程序后会加一,即它们的文件描述符可以从用户进程中关闭,并且仍然 refcount 不会变为零:

适合小孩子的 BPF,第一部分:扩展 BPF

成功加载程序后,我们通常将其附加到某种事件生成器。 例如,我们可以将其放在网络接口上来处理传入的数据包或将其连接到某些 tracepoint 在核心。 此时,引用计数器也会加一,我们就可以在加载程序中关闭文件描述符了。

如果我们现在关闭引导加载程序会发生什么? 这取决于事件生成器(钩子)的类型。 所有网络钩子都会在加载器完成后存在,这些就是所谓的全局钩子。 并且,例如,跟踪程序将在创建它们的进程终止后被释放(因此称为本地,从“本地到进程”)。 从技术上讲,本地钩子在用户空间中始终有一个相应的文件描述符,因此当进程关闭时也会关闭,但全局钩子则不然。 在下图中,我尝试使用红叉来展示加载程序的终止在本地和全局钩子的情况下如何影响对象的生命周期。

适合小孩子的 BPF,第一部分:扩展 BPF

为什么本地钩子和全局钩子之间存在区别? 在没有用户空间的情况下运行某些类型的网络程序是有意义的,例如,想象一下 DDoS 保护 - 引导加载程序编写规则并将 BPF 程序连接到网络接口,之后引导加载程序可以自行终止。 另一方面,想象一下您在十分钟内跪下编写的调试跟踪程序 - 当它完成时,您希望系统中没有留下任何垃圾,而本地挂钩将确保这一点。

另一方面,假设您想要连接到内核中的跟踪点并收集多年的统计信息。 在这种情况下,您可能希望完成用户部分并不时返回统计信息。 bpf 文件系统提供了这个机会。 它是一个仅内存中的伪文件系统,允许创建引用 BPF 对象的文件,从而增加 refcount 对象。 此后,加载器可以退出,并且它创建的对象将保持活动状态。

适合小孩子的 BPF,第一部分:扩展 BPF

在 bpffs 中创建引用 BPF 对象的文件称为“固定”(如以下短语所示:“进程可以固定 BPF 程序或映射”)。 为 BPF 对象创建文件对象不仅对于延长本地对象的寿命有意义,而且对于全局对象的可用性也有意义 - 回到全局 DDoS 防护程序的示例,我们希望能够来看看统计数据时。

BPF文件系统通常挂载在 /sys/fs/bpf,但也可以本地安装,例如,如下所示:

$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

文件系统名称是使用命令创建的 BPF_OBJ_PIN BPF 系统调用。 为了说明这一点,我们来看一个程序,编译它,上传它,然后将其固定到 bpffs。 我们的程序没有做任何有用的事情,我们只是提供代码,以便您可以重现该示例:

$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
        return 0;
}

char _license[] __attribute__((section("license"), used)) = "GPL";

让我们编译这个程序并创建文件系统的本地副本 bpffs:

$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

现在让我们使用该实用程序下载我们的程序 bpftool 并查看附带的系统调用 bpf(2) (从 strace 输出中删除了一些不相关的行):

$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0

这里我们使用加载程序 BPF_PROG_LOAD,从内核接收到一个文件描述符 3 并使用命令 BPF_OBJ_PIN 将此文件描述符固定为文件 "bpf-mountpoint/test"。 之后是引导加载程序 bpftool 工作完成了,但我们的程序仍然保留在内核中,尽管我们没有将其附加到任何网络接口:

$ sudo bpftool prog | tail -3
783: xdp  name test  tag 5c8ba0cf164cb46c  gpl
        loaded_at 2020-05-05T13:27:08+0000  uid 0
        xlated 24B  jited 41B  memlock 4096B

我们可以正常删除文件对象 unlink(2) 之后相应的程序将被删除:

$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory

删除对象

说到删除对象,需要澄清的是,当我们将程序与钩子(事件生成器)断开连接后,不会有任何新事件触发其启动,但是程序的所有当前实例都将按正常顺序完成。

某些类型的 BPF 程序允许您即时替换程序,即提供序列原子性 replace = detach old program, attach new program。 在这种情况下,旧版本程序的所有活动实例都将完成其工作,并且将从新程序创建新的事件处理程序,这里的“原子性”意味着不会错过任何一个事件。

将程序附加到事件源

在本文中,我们不会单独描述将程序连接到事件源,因为在特定类型的程序的上下文中研究这一点是有意义的。 厘米。 例子 下面,我们展示了 XDP 等程序是如何连接的。

使用 bpf 系统调用操作对象

BPF 计划

所有 BPF 对象都是使用系统调用从用户空间创建和管理的 bpf,具有以下原型:

#include <linux/bpf.h>

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

这是团队 cmd 是 type 的值之一 enum bpf_cmd, attr — 指向特定程序参数的指针以及 size — 根据指针的对象大小,即通常是这个 sizeof(*attr)。 5.8内核中的系统调用 bpf 支持 34 种不同的命令,并且 决心 union bpf_attr 占用200行。 但我们不应该被这一点吓倒,因为我们将在几篇文章的过程中熟悉命令和参数。

让我们从团队开始 BPF_PROG_LOAD,它创建 BPF 程序 - 获取一组 BPF 指令并将其加载到内核中。 加载的瞬间,验证器启动,然后JIT编译器,执行成功后,将程序文件描述符返回给用户。 我们在上一节中看到了他接下来会发生什么 关于BPF对象的生命周期.

我们现在将编写一个自定义程序来加载一个简单的 BPF 程序,但首先我们需要决定要加载哪种程序 - 我们必须选择 类型 并在这种类型的框架内编写一个将通过验证者测试的程序。 但是,为了不使过程复杂化,这里有一个现成的解决方案:我们将采用如下程序 BPF_PROG_TYPE_XDP,这将返回值 XDP_PASS (跳过所有包)。 在 BPF 汇编器中,它看起来非常简单:

r0 = 2
exit

当我们决定之后 我们将上传,我们可以告诉您我们将如何做到:

#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

static inline __u64 ptr_to_u64(const void *ptr)
{
        return (__u64) (unsigned long) ptr;
}

int main(void)
{
    struct bpf_insn insns[] = {
        {
            .code = BPF_ALU64 | BPF_MOV | BPF_K,
            .dst_reg = BPF_REG_0,
            .imm = XDP_PASS
        },
        {
            .code = BPF_JMP | BPF_EXIT
        },
    };

    union bpf_attr attr = {
        .prog_type = BPF_PROG_TYPE_XDP,
        .insns     = ptr_to_u64(insns),
        .insn_cnt  = sizeof(insns)/sizeof(insns[0]),
        .license   = ptr_to_u64("GPL"),
    };

    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

程序中有趣的事件从数组的定义开始 insns - 我们的 BPF 机器代码程序。 在这种情况下,BPF程序的每条指令都被打包到结构体中 bpf_insn。 第一个元素 insns 符合指示 r0 = 2, 第二 - exit.

撤退。 内核定义了更方便的宏来编写机器代码,并使用内核头文件 tools/include/linux/filter.h 我们可以写

struct bpf_insn insns[] = {
    BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
    BPF_EXIT_INSN()
};

但由于用本机代码编写 BPF 程序仅是在内核中编写测试和有关 BPF 的文章所必需的,因此缺少这些宏并不会真正使开发人员的生活变得复杂。

定义 BPF 程序后,我们继续将其加载到内核中。 我们的极简参数集 attr 包括程序类型、指令集和数量、所需的许可证和名称 "woo",我们用它来在下载后在系统上查找我们的程序。 正如所承诺的,该程序使用系统调用加载到系统中 bpf.

在程序结束时,我们最终陷入了模拟有效负载的无限循环。 没有它,当系统调用返回给我们的文件描述符关闭时,程序将被内核杀死 bpf,并且我们不会在系统中看到它。

好了,我们已经准备好进行测试了。 让我们在下面编译并运行程序 strace检查一切是否正常工作:

$ clang -g -O2 simple-prog.c -o simple-prog

$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_V
ERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

一切安好, bpf(2) 返回句柄 3 给我们,我们进入了无限循环 pause()。 让我们尝试在系统中找到我们的程序。 为此,我们将转到另一个终端并使用该实用程序 bpftool:

# bpftool prog | grep -A3 woo
390: xdp  name woo  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-31T24:66:44+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        pids simple-prog(10381)

我们看到系统上已经加载了一个程序 woo 其全局 ID 为 390,目前正在进行中 simple-prog 有一个打开的文件描述符指向该程序(并且如果 simple-prog 将完成工作,然后 woo 会消失)。 正如预期的那样,该计划 woo BPF 架构中的二进制代码占用 16 个字节(两条指令),但在其本机形式 (x86_64) 中,它已经是 40 个字节。 让我们看看我们的程序的原始形式:

# bpftool prog dump xlated id 390
   0: (b7) r0 = 2
   1: (95) exit

没有什么惊喜。 现在让我们看看 JIT 编译器生成的代码:

# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
   0:   nopl   0x0(%rax,%rax,1)
   5:   push   %rbp
   6:   mov    %rsp,%rbp
   9:   sub    $0x0,%rsp
  10:   push   %rbx
  11:   push   %r13
  13:   push   %r14
  15:   push   %r15
  17:   pushq  $0x0
  19:   mov    $0x2,%eax
  1e:   pop    %rbx
  1f:   pop    %r15
  21:   pop    %r14
  23:   pop    %r13
  25:   pop    %rbx
  26:   leaveq
  27:   retq

不太有效 exit(2),但平心而论,我们的程序太简单了,对于不平凡的程序来说,JIT编译器添加的序言和尾声当然是需要的。

地图

BPF 程序可以使用其他 BPF 程序和用户空间中的程序都可以访问的结构化内存区域。 这些对象称为映射,在本节中我们将展示如何使用系统调用来操作它们 bpf.

让我们立即说,映射的功能不仅限于访问共享内存。 有一些特殊用途的映射,例如包含指向 BPF 程序的指针或指向网络接口的指针、用于处理性能事件的映射等。 这里我们不再谈论它们,以免读者感到困惑。 除此之外,我们忽略同步问题,因为这对于我们的示例并不重要。 可用地图类型的完整列表可以在以下位置找到: <linux/bpf.h>,在本节中我们将以历史上第一种类型哈希表为例 BPF_MAP_TYPE_HASH.

如果你用 C++ 创建一个哈希表,你会说 unordered_map<int,long> woo,俄语中的意思是“我需要一张桌子 woo 大小不受限制,其键是类型 int,值是类型 long” 为了创建 BPF 哈希表,我们需要做很多相同的事情,除了我们必须指定表的最大大小,并且我们需要指定它们的大小(以字节为单位),而不是指定键和值的类型。 要创建地图,请使用命令 BPF_MAP_CREATE 系统调用 bpf。 让我们看一下创建地图的或多或少的最小程序。 在上一个加载 BPF 程序的程序之后,这个程序对您来说应该很简单:

$ cat simple-map.c
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

int main(void)
{
    union bpf_attr attr = {
        .map_type = BPF_MAP_TYPE_HASH,
        .key_size = sizeof(int),
        .value_size = sizeof(int),
        .max_entries = 4,
    };
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

这里我们定义一组参数 attr,其中我们说“我需要一个包含键和大小值的哈希表 sizeof(int),其中我最多可以放入四个元素。” 创建BPF映射时,可以指定其他参数,例如,与程序示例中相同,我们将对象的名称指定为 "woo".

让我们编译并运行该程序:

$ clang -g -O2 simple-map.c -o simple-map
$ sudo strace ./simple-map
execve("./simple-map", ["./simple-map"], 0x7ffd40a27070 /* 14 vars */) = 0
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=4, max_entries=4, map_name="woo", ...}, 72) = 3
pause(

这是系统调用 bpf(2) 返回给我们描述符映射编号 3 然后程序按照预期等待系统调用中的进一步指令 pause(2).

现在让我们将程序发送到后台或打开另一个终端并使用实用程序查看我们的对象 bpftool (我们可以通过名称将我们的地图与其他地图区分开来):

$ sudo bpftool map
...
114: hash  name woo  flags 0x0
        key 4B  value 4B  max_entries 4  memlock 4096B
...

数字 114 是我们对象的全局 ID。 系统上的任何程序都可以使用此 ID 使用以下命令打开现有地图 BPF_MAP_GET_FD_BY_ID 系统调用 bpf.

现在我们可以使用我们的哈希表了。 我们来看看它的内容:

$ sudo bpftool map dump id 114
Found 0 elements

空的。 让我们给它赋值 hash[1] = 1:

$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0

我们再看一下表格:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
Found 1 element

万岁! 我们设法添加了一个元素。 请注意,我们必须在字节级别上工作才能做到这一点,因为 bptftool 不知道哈希表中的值是什么类型。 (这些知识可以使用 BTF 转移给她,但现在更多。)

bpftool到底是如何读取和添加元素的? 让我们看看幕后:

$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

首先,我们使用命令通过全局 ID 打开地图 BPF_MAP_GET_FD_BY_ID и bpf(2) 返回描述符 3 给我们。进一步使用命令 BPF_MAP_GET_NEXT_KEY 我们通过传递找到了表中的第一个键 NULL 作为指向“上一个”键的指针。 如果我们有钥匙我们可以做 BPF_MAP_LOOKUP_ELEM它返回一个值给指针 value。 下一步是我们尝试通过传递指向当前键的指针来查找下一个元素,但我们的表仅包含一个元素和命令 BPF_MAP_GET_NEXT_KEY 回报 ENOENT.

好吧,我们通过键1来改变值,假设我们的业务逻辑需要注册 hash[1] = 2:

$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0

正如预期的那样,非常简单:命令 BPF_MAP_GET_FD_BY_ID 通过 ID 打开我们的地图,然后命令 BPF_MAP_UPDATE_ELEM 覆盖该元素。

因此,在从一个程序创建哈希表后,我们可以从另一个程序读取和写入其内容。 请注意,如果我们能够从命令行执行此操作,那么系统上的任何其他程序都可以执行此操作。 除了上述命令之外,为了处理用户空间中的映射, 下面:

  • BPF_MAP_LOOKUP_ELEM:通过键查找值
  • BPF_MAP_UPDATE_ELEM:更新/创造价值
  • BPF_MAP_DELETE_ELEM:删除钥匙
  • BPF_MAP_GET_NEXT_KEY:找到下一个(或第一个)键
  • BPF_MAP_GET_NEXT_ID:允许您浏览所有现有地图,这就是它的工作原理 bpftool map
  • BPF_MAP_GET_FD_BY_ID:通过全局ID打开现有地图
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM:原子地更新对象的值并返回旧的值
  • BPF_MAP_FREEZE:使地图在用户空间中不可变(此操作无法撤消)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: 大规模行动。 例如, BPF_MAP_LOOKUP_AND_DELETE_BATCH - 这是从地图中读取和重置所有值的唯一可靠方法

并非所有这些命令都适用于所有映射类型,但通常从用户空间使用其他类型的映射看起来与使用哈希表完全相同。

为了顺序起见,让我们完成我们的哈希表实验。 还记得我们创建了一个最多可以包含四个键的表吗? 让我们添加更多元素:

$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0

到目前为止,一切都很好:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
key: 02 00 00 00  value: 01 00 00 00
key: 04 00 00 00  value: 01 00 00 00
key: 03 00 00 00  value: 01 00 00 00
Found 4 elements

让我们尝试再添加一个:

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long

正如所料,我们没有成功。 让我们更详细地看看该错误:

$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

一切都很好:正如预期的那样,团队 BPF_MAP_UPDATE_ELEM 尝试创建新的第五个密钥,但崩溃了 E2BIG.

因此,我们可以创建和加载 BPF 程序,以及从用户空间创建和管理映射。 现在我们应该看看如何使用 BPF 程序本身的映射。 我们可以用机器宏代码中难以阅读的程序语言来讨论这一点,但实际上现在是时候展示 BPF 程序实际上是如何编写和维护的了 - 使用 libbpf.

(对于对缺乏低级示例不满意的读者:我们将详细分析使用使用创建的映射和辅助函数的程序 libbpf 并告诉您在指令层面会发生什么。 致不满意的读者 非常,我们添加了 例子 放在文章的适当位置。)

使用 libbpf 编写 BPF 程序

使用机器代码编写 BPF 程序只有第一次会很有趣,然后就会产生满足感。 此时你需要将注意力转向 llvm,它有一个用于为 BPF 架构生成代码的后端,以及一个库 libbpf,它允许您编写 BPF 应用程序的用户端并加载使用生成的 BPF 程序的代码 llvm/clang.

事实上,正如我们将在本文和后续文章中看到的那样, libbpf 没有它(或类似的工具)可以做很多工作 - iproute2, libbcc, libbpf-go等)不可能生存。 该项目的杀手级功能之一 libbpf BPF CO-RE(编译一次,到处运行) - 一个项目,允许您编写可从一个内核移植到另一个内核的 BPF 程序,并且能够在不同的 API 上运行(例如,当内核结构从版本更改时)到版本)。 为了能够使用 CO-RE,您的内核必须使用 BTF 支持进行编译(我们在 一节中描述了如何执行此操作) 开发工具。 您可以通过以下文件的存在来非常简单地检查您的内核是否是使用 BTF 构建的:

$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinux

该文件存储有关内核中使用的所有数据类型的信息,并在我们所有的示例中使用 libbpf。 我们将在下一篇文章中详细讨论 CO-RE,但在这篇文章中 - 只需使用以下命令为自己构建一个内核 CONFIG_DEBUG_INFO_BTF.

图书馆 libbpf 就在目录中 tools/lib/bpf 内核及其开发是通过邮件列表进行的 [email protected]。 然而,为了满足内核之外的应用程序的需要,维护了一个单独的存储库 https://github.com/libbpf/libbpf 其中内核库或多或少被镜像以进行读访问。

在本节中,我们将了解如何创建一个使用 libbpf,让我们编写几个(或多或少没有意义的)测试程序并详细分析它是如何工作的。 这将使我们能够在接下来的章节中更轻松地解释 BPF 程序如何与映射、内核助手、BTF 等交互。

通常项目使用 libbpf 添加 GitHub 存储库作为 git 子模块,我们将执行相同的操作:

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.

即将 libbpf 很简单:

$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pc

我们本节的下一个计划如下:我们将编写一个 BPF 程序,例如 BPF_PROG_TYPE_XDP,与前面的示例相同,但在 C 中,我们使用以下命令编译它 clang,并编写一个帮助程序将其加载到内核中。 在下面的部分中,我们将扩展 BPF 程序和助手程序的功能。

示例:使用 libbpf 创建成熟的应用程序

首先,我们使用该文件 /sys/kernel/btf/vmlinux,上面提到过,并以头文件的形式创建其等效项:

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

该文件将存储我们内核中可用的所有数据结构,例如,这是内核中定义 IPv4 标头的方式:

$ grep -A 12 'struct iphdr {' vmlinux.h
struct iphdr {
    __u8 ihl: 4;
    __u8 version: 4;
    __u8 tos;
    __be16 tot_len;
    __be16 id;
    __be16 frag_off;
    __u8 ttl;
    __u8 protocol;
    __sum16 check;
    __be32 saddr;
    __be32 daddr;
};

现在我们将用 C 语言编写 BPF 程序:

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
        return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

虽然我们的程序结果很简单,但是我们仍然需要注意很多细节。 首先,我们包含的第一个头文件是 vmlinux.h,我们刚刚使用生成 bpftool btf dump - 现在我们不需要安装 kernel-headers 包来了解内核结构。 以下头文件来自库 libbpf。 现在我们只需要它来定义宏 SEC,它将字符发送到 ELF 目标文件的相应部分。 我们的程序包含在该部分中 xdp/simple,在斜杠之前我们定义了程序类型 BPF - 这是使用的约定 libbpf,根据节名称,它将在启动时替换正确的类型 bpf(2)。 BPF 程序本身是 C - 非常简单,由一行组成 return XDP_PASS。 最后,单独的一个部分 "license" 包含许可证的名称。

我们可以使用 llvm/clang 编译我们的程序,版本 >= 10.0.0,或者更好,更高(请参阅部分 开发工具):

$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o

有趣的功能包括:我们指出了目标架构 -target bpf 以及标题的路径 libbpf,我们最近安装的。 另外,不要忘记 -O2,如果没有这个选项,您将来可能会遇到意外。 让我们看看我们的代码,我们是否成功编写了我们想要的程序?

$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       r0 = 2
       1:       exit

是的,它起作用了! 现在,我们有了一个包含该程序的二进制文件,我们想要创建一个应用程序将其加载到内核中。 为此,图书馆 libbpf 为我们提供了两种选择 - 使用较低级别的 API 或较高级别的 API。 我们将采用第二种方式,因为我们希望以最小的努力学习如何编写、加载和连接 BPF 程序,以便后续学习。

首先,我们需要使用相同的实用程序从程序的二进制文件生成程序的“骨架” bpftool ——BPF 世界的瑞士刀(可以从字面上理解,因为 BPF 的创建者和维护者之一 Daniel Borkman 是瑞士人):

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h

在文件中 xdp-simple.skel.h 包含我们程序的二进制代码和用于管理的函数 - 加载、附加、删除我们的对象。 在我们的简单情况下,这看起来有点矫枉过正,但它也适用于目标文件包含许多 BPF 程序和映射的情况,并且要加载这个巨大的 ELF,我们只需要生成骨架并从我们的自定义应用程序中调用一两个函数正在写 现在我们继续吧。

严格来说,我们的加载程序很简单:

#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    pause();

    xdp_simple_bpf__destroy(obj);
}

这是 struct xdp_simple_bpf 文件中定义的 xdp-simple.skel.h 并描述我们的目标文件:

struct xdp_simple_bpf {
    struct bpf_object_skeleton *skeleton;
    struct bpf_object *obj;
    struct {
        struct bpf_program *simple;
    } progs;
    struct {
        struct bpf_link *simple;
    } links;
};

我们可以在这里看到低级 API 的痕迹:结构 struct bpf_program *simple и struct bpf_link *simple。 第一个结构具体描述了我们的程序,写在 xdp/simple,第二个描述程序如何连接到事件源。

功能 xdp_simple_bpf__open_and_load,打开一个ELF对象,解析它,创建所有结构和子结构(除了程序之外,ELF还包含其他部分——数据、只读数据、调试信息、许可证等),然后使用系统将其加载到内核中称呼 bpf,我们可以通过编译并运行程序来检查:

$ clang -O2 -I ./libbpf/src/root/usr/include/ xdp-simple.c -o xdp-simple ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_BTF_LOAD, 0x7ffdb8fd9670, 120)  = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0xdfd580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 8, 0), prog_flags=0, prog_name="simple", prog_ifindex=0, expected_attach_type=0x25 /* BPF_??? */, ...}, 120) = 4

现在让我们看看我们的程序使用 bpftool。 让我们找到她的ID:

# bpftool p | grep -A4 simple
463: xdp  name simple  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-01T01:59:49+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        btf_id 185
        pids xdp-simple(16498)

和转储(我们使用命令的缩写形式 bpftool prog dump xlated):

# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
   0: (b7) r0 = 2
   1: (95) exit

新鲜玩意! 程序打印了我们的 C 源文件的块。这是由库完成的 libbpf,它在二进制文件中找到了调试部分,将其编译成 BTF 对象,然后使用将其加载到内核中 BPF_BTF_LOAD,然后在使用命令加载程序时指定生成的文件描述符 BPG_PROG_LOAD.

内核助手

BPF 程序可以运行“外部”函数——内核助手。 这些辅助函数允许 BPF 程序访问内核结构、管理映射以及与“现实世界”通信 - 创建性能事件、控制硬件(例如,重定向数据包)等。

示例:bpf_get_smp_processor_id

在“通过示例学习”范式的框架内,让我们考虑其中一个辅助函数, bpf_get_smp_processor_id(), 一些 在文件中 kernel/bpf/helpers.c。 它返回调用它的 BPF 程序正在运行的处理器的编号。 但我们对它的语义并不感兴趣,而是对它的实现只需要一行这一事实感兴趣:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

BPF 辅助函数定义与 Linux 系统调用定义类似。 例如,这里定义了一个没有参数的函数。 (一个带有三个参数的函数是使用宏定义的 BPF_CALL_3。 参数的最大数量为五个。)但是,这只是定义的第一部分。 第二部分是定义类型结构 struct bpf_func_proto,其中包含验证者理解的辅助函数的描述:

const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
    .func     = bpf_get_smp_processor_id,
    .gpl_only = false,
    .ret_type = RET_INTEGER,
};

注册辅助函数

为了让特定类型的 BPF 程序使用此函数,它们必须注册它,例如类型 BPF_PROG_TYPE_XDP 内核中定义了一个函数 xdp_func_proto,它根据辅助函数 ID 确定 XDP 是否支持该函数。 我们的函数是 支持:

static const struct bpf_func_proto *
xdp_func_proto(enum bpf_func_id func_id, const struct bpf_prog *prog)
{
    switch (func_id) {
    ...
    case BPF_FUNC_get_smp_processor_id:
        return &bpf_get_smp_processor_id_proto;
    ...
    }
}

新的 BPF 程序类型在文件中“定义” include/linux/bpf_types.h 使用宏 BPF_PROG_TYPE。 用引号定义是因为它是逻辑定义,而在 C 语言术语中,一整套具体结构的定义出现在其他地方。 特别是在文件中 kernel/bpf/verifier.c 文件中的所有定义 bpf_types.h 用于创建结构体数组 bpf_verifier_ops[]:

static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type) 
    [_id] = & _name ## _verifier_ops,
#include <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

即对于每种类型的BPF程序,都定义了一个指向该类型的数据结构的指针 struct bpf_verifier_ops,它是用值初始化的 _name ## _verifier_ops, IE。, xdp_verifier_opsxdp。 结构 xdp_verifier_ops 由...决定 在文件中 net/core/filter.c 如下所示:

const struct bpf_verifier_ops xdp_verifier_ops = {
    .get_func_proto     = xdp_func_proto,
    .is_valid_access    = xdp_is_valid_access,
    .convert_ctx_access = xdp_convert_ctx_access,
    .gen_prologue       = bpf_noop_prologue,
};

这里我们看到我们熟悉的函数 xdp_func_proto,每次遇到挑战时都会运行验证程序 一些 BPF 程序中的函数,请参阅 verifier.c.

让我们看看假设的 BPF 程序如何使用该函数 bpf_get_smp_processor_id。 为此,我们重写了上一节中的程序,如下所示:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
    if (bpf_get_smp_processor_id() != 0)
        return XDP_DROP;
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

符号 bpf_get_smp_processor_id 由...决定 в <bpf/bpf_helper_defs.h> 文库 libbpf

static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;

就是这样 bpf_get_smp_processor_id 是一个函数指针,其值为8,其中8是值 BPF_FUNC_get_smp_processor_id 类型 enum bpf_fun_id,它是在文件中为我们定义的 vmlinux.h (文件 bpf_helper_defs.h 内核中的数字是由脚本生成的,因此“魔术”数字是可以的)。 该函数不带参数并返回类型的值 __u32。 当我们在程序中运行它时, clang 生成一条指令 BPF_CALL “正确的种类” 让我们编译一下程序并查看该部分 xdp/simple:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ llvm-objdump -D --section=xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       bf 01 00 00 00 00 00 00 r1 = r0
       2:       67 01 00 00 20 00 00 00 r1 <<= 32
       3:       77 01 00 00 20 00 00 00 r1 >>= 32
       4:       b7 00 00 00 02 00 00 00 r0 = 2
       5:       15 01 01 00 00 00 00 00 if r1 == 0 goto +1 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

0000000000000038 <LBB0_2>:
       7:       95 00 00 00 00 00 00 00 exit

在第一行我们看到说明 call, 范围 IMM 等于 8,并且 SRC_REG - 零。 根据验证者使用的 ABI 协议,这是对第八个辅助函数的调用。 一旦启动,逻辑就很简单。 从寄存器返回值 r0 复制到 r1 在第 2,3 行,它被转换为类型 u32 — 高 32 位被清除。 在第 4,5,6,7 行我们返回 2 (XDP_PASS) 或 1 (XDP_DROP) 取决于第 0 行的辅助函数是否返回零值或非零值。

让我们测试一下自己:加载程序并查看输出 bpftool prog dump xlated:

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914

$ sudo bpftool p | grep simple
523: xdp  name simple  tag 44c38a10c657e1b0  gpl
        pids xdp-simple(10915)

$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
   0: (85) call bpf_get_smp_processor_id#114128
   1: (bf) r1 = r0
   2: (67) r1 <<= 32
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

好的,验证程序找到了正确的内核帮助程序。

示例:传递参数并最终运行程序!

所有运行级辅助函数都有一个原型

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

辅助函数的参数在寄存器中传递 r1 - r5,并且该值被返回到寄存器中 r0。 没有任何函数需要超过五个参数,并且预计将来不会添加对它们的支持。

让我们看一下新的内核助手以及 BPF 如何传递参数。 让我们重写一下 xdp-simple.bpf.c 如下(其余行没有改变):

SEC("xdp/simple")
int simple(void *ctx)
{
    bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
    return XDP_PASS;
}

我们的程序打印它正在运行的CPU的编号。 我们来编译一下,看看代码:

$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       r1 = 10
       1:       *(u16 *)(r10 - 8) = r1
       2:       r1 = 8441246879787806319 ll
       4:       *(u64 *)(r10 - 16) = r1
       5:       r1 = 2334956330918245746 ll
       7:       *(u64 *)(r10 - 24) = r1
       8:       call 8
       9:       r1 = r10
      10:       r1 += -24
      11:       r2 = 18
      12:       r3 = r0
      13:       call 6
      14:       r0 = 2
      15:       exit

在第 0-7 行中我们写入字符串 running on CPU%un,然后在第 8 行我们运行熟悉的一个 bpf_get_smp_processor_id。 在第 9-12 行,我们准备辅助参数 bpf_printk - 寄存器 r1, r2, r3。 为什么是三个而不是两个? 因为 bpf_printk -  这是一个宏包装器 身边真正的帮手 bpf_trace_printk,需要传递格式字符串的大小。

现在让我们添加几行 xdp-simple.c这样我们的程序就可以连接到该接口了 lo 并真正开始了!

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    __u32 flags = XDP_FLAGS_SKB_MODE;
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    bpf_set_link_xdp_fd(1, -1, flags);
    bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);

cleanup:
    xdp_simple_bpf__destroy(obj);
}

这里我们使用函数 bpf_set_link_xdp_fd,它将 XDP 类型的 BPF 程序连接到网络接口。 我们硬编码了接口编号 lo,始终为 1。我们运行该函数两次,首先将旧程序分离(如果已附加)。 请注意,现在我们不需要挑战 pause 或无限循环:我们的加载程序将退出,但 BPF 程序不会被终止,因为它已连接到事件源。 下载并连接成功后,每个到达的网络数据包都会启动该程序 lo.

我们下载程序看看界面 lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 669

我们下载的程序的ID为669,我们在界面上看到相同的ID lo。 我们将寄几个包裹到 127.0.0.1 (请求+回复):

$ ping -c1 localhost

现在让我们看看调试虚拟文件的内容 /sys/kernel/debug/tracing/trace_pipe,其中 bpf_printk 写下他的留言:

# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0

发现两个包裹 lo 并在 CPU0 上进行处理——我们的第一个成熟的无意义 BPF 程序成功了!

值得一提的是 bpf_printk 它写入调试文件并非毫无意义:这不是在生产中使用的最成功的帮助程序,但我们的目标是展示一些简单的东西。

从 BPF 程序访问地图

示例:使用 BPF 程序中的映射

在前面的部分中,我们学习了如何从用户空间创建和使用映射,现在让我们看看内核部分。 像往常一样,让我们​​从一个例子开始。 让我们重写我们的程序 xdp-simple.bpf.c 如下所示:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 8);
    __type(key, u32);
    __type(value, u64);
} woo SEC(".maps");

SEC("xdp/simple")
int simple(void *ctx)
{
    u32 key = bpf_get_smp_processor_id();
    u32 *val;

    val = bpf_map_lookup_elem(&woo, &key);
    if (!val)
        return XDP_ABORTED;

    *val += 1;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

在程序的开头我们添加了一个地图定义 woo:这是一个 8 元素数组,存储如下值 u64 (在 C 中,我们将这样的数组定义为 u64 woo[8])。 在一个节目中 "xdp/simple" 我们将当前处理器编号放入变量中 key 然后使用辅助函数 bpf_map_lookup_element 我们得到一个指向数组中相应条目的指针,我们将其加一。 翻译成俄语:我们计算有关 CPU 处理传入数据包的统计信息。 让我们尝试运行该程序:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple

让我们检查一下她是否已连接 lo 并发送一些数据包:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 108

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done

现在让我们看看数组的内容:

$ sudo bpftool map dump name woo
[
    { "key": 0, "value": 0 },
    { "key": 1, "value": 400 },
    { "key": 2, "value": 0 },
    { "key": 3, "value": 0 },
    { "key": 4, "value": 0 },
    { "key": 5, "value": 0 },
    { "key": 6, "value": 0 },
    { "key": 7, "value": 46400 }
]

几乎所有进程都在 CPU7 上处理。 这对我们来说并不重要,最主要的是程序可以运行,并且我们了解如何从 BPF 程序访问地图 - 使用 хелперов bpf_mp_*.

神秘指数

因此,我们可以使用以下调用从 BPF 程序访问地图

val = bpf_map_lookup_elem(&woo, &key);

辅助函数的样子

void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)

但我们正在传递一个指针 &woo 到一个未命名的结构 struct { ... }...

如果我们查看程序汇编器,我们会看到该值 &woo 实际上并未定义(第 4 行):

llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
...

并包含在搬迁中:

$ llvm-readelf -r xdp-simple.bpf.o | head -4

Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

但是如果我们查看已经加载的程序,我们会看到一个指向正确映射的指针(第 4 行):

$ sudo bpftool prog dump x name simple
int simple(void *ctx):
   0: (85) call bpf_get_smp_processor_id#114128
   1: (63) *(u32 *)(r10 -4) = r0
   2: (bf) r2 = r10
   3: (07) r2 += -4
   4: (18) r1 = map[id:64]
...

因此,我们可以得出结论,在启动加载程序时,链接到 &woo 被带有图书馆的东西所取代 libbpf。 首先我们看一下输出 strace:

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=8, max_entries=8, map_name="woo", ...}, 120) = 4
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="simple", ...}, 120) = 5

我们看到了 libbpf 创建了一张地图 woo 然后下载我们的程序 simple。 让我们仔细看看如何加载程序:

  • 称呼 xdp_simple_bpf__open_and_load 来自档案 xdp-simple.skel.h
  • 什么导致 xdp_simple_bpf__load 来自档案 xdp-simple.skel.h
  • 什么导致 bpf_object__load_skeleton 来自档案 libbpf/src/libbpf.c
  • 什么导致 bpf_object__load_xattr из libbpf/src/libbpf.c

除其他外,最后一个函数将调用 bpf_object__create_maps,它创建或打开现有的映射,将它们转换为文件描述符。 (这是我们看到的 BPF_MAP_CREATE 在输出中 strace.) 接下来调用该函数 bpf_object__relocate 我们对她感兴趣,因为我们记得我们所看到的 woo 在重定位表中。 探索一下,我们最终发现自己身处函数之中 bpf_program__relocate, 其中和 处理地图重定位:

case RELO_LD64:
    insn[0].src_reg = BPF_PSEUDO_MAP_FD;
    insn[0].imm = obj->maps[relo->map_idx].fd;
    break;

所以我们接受我们的指示

18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll

并将其中的源寄存器替换为 BPF_PSEUDO_MAP_FD,以及映射的文件描述符的第一个 IMM,如果它等于,例如, 0xdeadbeef,那么结果我们会收到指令

18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll

这就是映射信息如何传输到特定加载的 BPF 程序的方式。 在这种情况下,可以使用以下命令创建地图 BPF_MAP_CREATE,并使用 ID 打开 BPF_MAP_GET_FD_BY_ID.

使用时总计 libbpf 算法如下:

  • 在编译期间,会在重定位表中创建记录以链接到地图
  • libbpf 打开 ELF 对象簿,查找所有使用的映射并为它们创建文件描述符
  • 文件描述符作为指令的一部分加载到内核中 LD64

正如你可以想象的那样,还会有更多的事情发生,我们将不得不研究核心。 幸运的是,我们有线索——我们已经写下了它的意思 BPF_PSEUDO_MAP_FD 进入源登记册,我们可以将其埋葬,这将引导我们前往万圣之圣地—— kernel/bpf/verifier.c,其中具有独特名称的函数将文件描述符替换为类型结构的地址 struct bpf_map:

static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
    ...

    f = fdget(insn[0].imm);
    map = __bpf_map_get(f);
    if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
        addr = (unsigned long)map;
    }
    insn[0].imm = (u32)addr;
    insn[1].imm = addr >> 32;

(完整代码可以找到 链接)。 所以我们可以扩展我们的算法:

  • 加载程序时,验证器检查映射的正确使用并写入相应结构的地址 struct bpf_map

使用下载 ELF 二进制文件时 libbpf 还有更多内容,但我们将在其他文章中讨论。

不使用 libbpf 加载程序和地图

正如所承诺的,这里是一个示例,供那些想知道如何创建和加载使用地图的程序而无需帮助的读者使用 libbpf。 当您在无法构建依赖项的环境中工作、无法保存每一点或编写类似这样的程序时,这会很有用 ply,它动态生成 BPF 二进制代码。

为了更容易遵循逻辑,我们将为此目的重写我们的示例 xdp-simple。 本例中讨论的程序的完整且稍微扩展的代码可以在以下位置找到 要旨.

我们的应用程序的逻辑如下:

  • 创建类型映射 BPF_MAP_TYPE_ARRAY 使用命令 BPF_MAP_CREATE,
  • 创建一个使用该地图的程序,
  • 将程序连接到接口 lo,

这翻译成人类

int main(void)
{
    int map_fd, prog_fd;

    map_fd = map_create();
    if (map_fd < 0)
        err(1, "bpf: BPF_MAP_CREATE");

    prog_fd = prog_load(map_fd);
    if (prog_fd < 0)
        err(1, "bpf: BPF_PROG_LOAD");

    xdp_attach(1, prog_fd);
}

这是 map_create 以与我们在第一个关于系统调用的示例中相同的方式创建映射 bpf - “内核,请为我制作一个由 8 个元素组成的数组形式的新地图,例如 __u64 并将文件描述符还给我”:

static int map_create()
{
    union bpf_attr attr;

    memset(&attr, 0, sizeof(attr));
    attr.map_type = BPF_MAP_TYPE_ARRAY,
    attr.key_size = sizeof(__u32),
    attr.value_size = sizeof(__u64),
    attr.max_entries = 8,
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    return syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
}

该程序也很容易加载:

static int prog_load(int map_fd)
{
    union bpf_attr attr;
    struct bpf_insn insns[] = {
        ...
    };

    memset(&attr, 0, sizeof(attr));
    attr.prog_type = BPF_PROG_TYPE_XDP;
    attr.insns     = ptr_to_u64(insns);
    attr.insn_cnt  = sizeof(insns)/sizeof(insns[0]);
    attr.license   = ptr_to_u64("GPL");
    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    return syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
}

棘手的部分 prog_load 是我们的 BPF 程序作为结构体数组的定义 struct bpf_insn insns[]。 但由于我们使用的是 C 语言的程序,所以我们可以做一些欺骗:

$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
       7:       b7 01 00 00 00 00 00 00 r1 = 0
       8:       15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2>
       9:       61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
      10:       07 01 00 00 01 00 00 00 r1 += 1
      11:       63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
      12:       b7 01 00 00 02 00 00 00 r1 = 2

0000000000000068 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

总共,我们需要以结构形式编写 14 条指令,例如 struct bpf_insn (建议: 从上面取出转储,重新阅读说明部分,打开 linux/bpf.h и linux/bpf_common.h 并尝试确定 struct bpf_insn insns[] 靠自己):

struct bpf_insn insns[] = {
    /* 85 00 00 00 08 00 00 00 call 8 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 8,
    },

    /* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
    {
        .code = BPF_MEM | BPF_STX,
        .off = -4,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_10,
    },

    /* bf a2 00 00 00 00 00 00 r2 = r10 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_10,
        .dst_reg = BPF_REG_2,
    },

    /* 07 02 00 00 fc ff ff ff r2 += -4 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_2,
        .imm = -4,
    },

    /* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
    {
        .code = BPF_LD | BPF_DW | BPF_IMM,
        .src_reg = BPF_PSEUDO_MAP_FD,
        .dst_reg = BPF_REG_1,
        .imm = map_fd,
    },
    { }, /* placeholder */

    /* 85 00 00 00 01 00 00 00 call 1 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 1,
    },

    /* b7 01 00 00 00 00 00 00 r1 = 0 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 0,
    },

    /* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

    /* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
    {
        .code = BPF_MEM | BPF_LDX,
        .off = 0,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_1,
    },

    /* 07 01 00 00 01 00 00 00 r1 += 1 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 1,
    },

    /* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
    {
        .code = BPF_MEM | BPF_STX,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* b7 01 00 00 02 00 00 00 r1 = 2 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 2,
    },

    /* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* 95 00 00 00 00 00 00 00 exit */
    {
        .code = BPF_JMP | BPF_EXIT
    },
};

为那些不是自己写这个的人提供的练习 - 查找 map_fd.

我们的计划中还剩下一个未公开的部分—— xdp_attach。 不幸的是,像 XDP 这样的程序无法使用系统调用来连接 bpf。 创建 BPF 和 XDP 的人来自在线 Linux 社区,这意味着他们使用了他们最熟悉的社区(但不是为了 正常 people) 与内核交互的接口: 网络连接套接字, 也可以看看 RFC3549。 最简单的实现方式 xdp_attach 正在复制代码 libbpf,即从文件 netlink.c,这就是我们所做的,稍微缩短了它:

欢迎来到 netlink 套接字的世界

打开netlink套接字类型 NETLINK_ROUTE:

int netlink_open(__u32 *nl_pid)
{
    struct sockaddr_nl sa;
    socklen_t addrlen;
    int one = 1, ret;
    int sock;

    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;

    sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
    if (sock < 0)
        err(1, "socket");

    if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
        warnx("netlink error reporting not supported");

    if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
        err(1, "bind");

    addrlen = sizeof(sa);
    if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
        err(1, "getsockname");

    *nl_pid = sa.nl_pid;
    return sock;
}

我们从这个套接字读取:

static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
    bool multipart = true;
    struct nlmsgerr *errm;
    struct nlmsghdr *nh;
    char buf[4096];
    int len, ret;

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->nlmsg_pid != nl_pid)
                errx(1, "wrong pid");
            if (nh->nlmsg_seq != seq)
                errx(1, "INVSEQ");
            if (nh->nlmsg_flags & NLM_F_MULTI)
                multipart = true;
            switch (nh->nlmsg_type) {
                case NLMSG_ERROR:
                    errm = (struct nlmsgerr *)NLMSG_DATA(nh);
                    if (!errm->error)
                        continue;
                    ret = errm->error;
                    // libbpf_nla_dump_errormsg(nh); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

最后,这是我们打开套接字并向其发送包含文件描述符的特殊消息的函数:

static int xdp_attach(int ifindex, int prog_fd)
{
    int sock, seq = 0, ret;
    struct nlattr *nla, *nla_xdp;
    struct {
        struct nlmsghdr  nh;
        struct ifinfomsg ifinfo;
        char             attrbuf[64];
    } req;
    __u32 nl_pid = 0;

    sock = netlink_open(&nl_pid);
    if (sock < 0)
        return sock;

    memset(&req, 0, sizeof(req));
    req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
    req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    req.nh.nlmsg_type = RTM_SETLINK;
    req.nh.nlmsg_pid = 0;
    req.nh.nlmsg_seq = ++seq;
    req.ifinfo.ifi_family = AF_UNSPEC;
    req.ifinfo.ifi_index = ifindex;

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* add XDP fd */
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FD;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
    nla->nla_len += nla_xdp->nla_len;

    /* if user passed in any flags, add those too */
    __u32 flags = XDP_FLAGS_SKB_MODE;
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FLAGS;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
    nla->nla_len += nla_xdp->nla_len;

    req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);

    if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
        err(1, "send");
    ret = bpf_netlink_recv(sock, nl_pid, seq);

cleanup:
    close(sock);
    return ret;
}

所以,一切准备就绪,可以进行测试了:

$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++

让我们看看我们的程序是否已连接到 lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 160

让我们发送 ping 并查看地图:

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00  value: 90 01 00 00 00 00 00 00
key: 01 00 00 00  value: 00 00 00 00 00 00 00 00
key: 02 00 00 00  value: 00 00 00 00 00 00 00 00
key: 03 00 00 00  value: 00 00 00 00 00 00 00 00
key: 04 00 00 00  value: 00 00 00 00 00 00 00 00
key: 05 00 00 00  value: 00 00 00 00 00 00 00 00
key: 06 00 00 00  value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00  value: 00 00 00 00 00 00 00 00
Found 8 elements

万岁,一切正常。 顺便请注意,我们的地图再次以字节的形式显示。 这是因为,与 libbpf 我们没有加载类型信息(BTF)。 但我们下次会详细讨论这个问题。

开发工具

在本节中,我们将了解最小的 BPF 开发人员工具包。

一般来说,您不需要任何特殊的东西来开发 BPF 程序 - BPF 可以在任何像样的发行版内核上运行,并且程序是使用 clang,可以从包装中提供。 但是,由于 BPF 正在开发中,内核和工具都在不断变化,如果你不想用 2019 年以来的老式方法编写 BPF 程序,那么你将不得不编译

  • llvm/clang
  • pahole
  • 其核心
  • bpftool

(作为参考,本节和本文中的所有示例均在 Debian 10 上运行。)

llvm/clang

BPF 与 LLVM 很友好,虽然最近 BPF 的程序可以使用 gcc 编译,但当前的所有开发都是针对 LLVM 进行的。 因此,首先我们将构建当前版本 clang 来自 git:

$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86" 
                      -DLLVM_ENABLE_PROJECTS="clang" 
                      -DBUILD_SHARED_LIBS=OFF 
                      -DCMAKE_BUILD_TYPE=Release 
                      -DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$

现在我们可以检查一切是否正确组合在一起:

$ ./bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 11.0.0git
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: znver1

  Registered Targets:
    bpf    - BPF (host endian)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bit X86: Pentium-Pro and above
    x86-64 - 64-bit X86: EM64T and AMD64

(组装说明 clang 由我取自 bpf_devel_QA.)

我们不会安装刚刚构建的程序,而是将它们添加到 PATH例如:

export PATH="`pwd`/bin:$PATH"

(这可以添加到 .bashrc 或到一个单独的文件。 就我个人而言,我将这样的内容添加到 ~/bin/activate-llvm.sh 必要时我会这样做 . activate-llvm.sh.)

帕霍尔和 BTF

效用 pahole 构建内核时用于创建 BTF 格式的调试信息。 我们在本文中不会详细讨论 BTF 技术的细节,除了它很方便并且我们想使用它之外。 因此,如果您要构建内核,请先构建 pahole (不 pahole 您将无法使用该选项构建内核 CONFIG_DEBUG_INFO_BTF:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole

用于试验 BPF 的内核

在探索BPF的可能性时,我想组装自己的核心。 一般来说,这不是必需的,因为您将能够在发行版内核上编译和加载 BPF 程序,但是,拥有自己的内核允许您使用最新的 BPF 功能,这些功能最多会在几个月内出现在您的发行版中,或者,就像某些调试工具在可预见的将来根本不会被打包一样。 此外,它自己的核心使得尝试代码变得很重要。

为了构建内核,您首先需要内核本身,其次需要内核配置文件。 为了试验 BPF,我们可以使用通常的 香草 内核或开发内核之一。 从历史上看,BPF 开发是在 Linux 网络社区内进行的,因此所有更改迟早都要经过 Linux 网络维护者 David Miller。 根据其性质 - 编辑或新功能 - 网络更改属于两个核心之一 - net или net-next。 BPF 的更改以相同的方式分布在 bpf и bpf-next,然后分别汇集到 net 和 net-next 中。 有关更多详细信息,请参阅 bpf_devel_QA и 网络开发常见问题解答。 因此,根据您的喜好和您正在测试的系统的稳定性需求来选择内核(*-next 内核是列出的内核中最不稳定的)。

讨论如何管理内核配置文件超出了本文的范围 - 假设您已经知道如何执行此操作,或者 准备学习 靠自己。 但是,以下说明应该或多或少足以为您提供一个支持 BPF 的工作系统。

下载上述内核之一:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next

构建最小的工作内核配置:

$ cp /boot/config-`uname -r` .config
$ make localmodconfig

在文件中启用 BPF 选项 .config 您自己选择的(最有可能的是 CONFIG_BPF 由于 systemd 使用它,因此已经启用)。 以下是本文使用的内核选项列表:

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y

然后我们就可以轻松地组装和安装模块和内核了(顺便说一句,您可以使用新组装的内核来组装内核) clang通过增加 CC=clang):

$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install

并使用新内核重新启动(我为此使用 kexec 从包装中 kexec-tools):

v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

bpf工具

本文中最常用的实用程序是 bpftool,作为 Linux 内核的一部分提供。 它由 BPF 开发人员为 BPF 开发人员编写和维护,可用于管理所有类型的 BPF 对象 - 加载程序、创建和编辑地图、探索 BPF 生态系统的生命周期等。 可以找到手册页源代码形式的文档 在核心 或者,已经编译, 在网络中.

在撰写本文时 bpftool 仅针对 RHEL、Fedora 和 Ubuntu 提供现成的版本(例如,请参阅 这个线程,讲述了包装未完成的故事 bpftool 在 Debian 中)。 但是如果你已经构建了内核,那么构建 bpftool 就像馅饼一样简单:

$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s

Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-detecting system features:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(这里 ${linux} - 这是你的内核目录。)执行这些命令后 bpftool 将被收集在一个目录中 ${linux}/tools/bpf/bpftool 并且可以将其添加到路径中(首先添加到用户 root)或只是复制到 /usr/local/sbin.

收集 bpftool 最好使用后者 clang,如上所述进行组装,并检查其是否正确组装 - 例如使用以下命令

$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...

这将显示您的内核中启用了哪些 BPF 功能。

顺便说一句,前面的命令可以运行为

# bpftool f p k

这是通过类比包中的实用程序来完成的 iproute2,例如,我们可以说 ip a s eth0 而不是 ip addr show dev eth0.

结论

BPF 使您能够有效地测量和即时更改核心的功能。 事实证明,该系统非常成功,继承了 UNIX 的最佳传统:允许对内核进行(重新)编程的简单机制允许大量人员和组织进行实验。 而且,尽管实验以及 BPF 基础设施本身的开发还远未完成,但该系统已经拥有稳定的 ABI,可让您构建可靠且最重要的是有效的业务逻辑。

我想指出的是,在我看来,这项技术之所以如此受欢迎,一方面是因为它可以 (一台机器的架构在一晚上就可以大致了解),另一方面解决它出现之前无法(漂亮地)解决的问题。 这两个组成部分共同迫使人们进行实验和梦想,从而导致越来越多的创新解决方案的出现。

这篇文章虽然不是特别短,但只是对 BPF 世界的介绍,并没有描述该架构的“高级”功能和重要部分。 未来的计划是这样的:下一篇文章将概述 BPF 程序类型(5.8 内核支持 30 种程序类型),然后我们最后看看如何使用内核跟踪程序编写真正的 BPF 应用程序作为一个例子,那么是时候学习关于 BPF 架构的更深入的课程了,然后是 BPF 网络和安全应用程序的示例。

本系列之前的文章

  1. 适合小孩子的 BPF,第 XNUMX 部分:经典 BPF

链接

  1. BPF 和 XDP 参考指南 — 来自 cilium 的 BPF 文档,或者更准确地说,来自 BPF 的创建者和维护者之一 Daniel Borkman。 这是最早的严肃描述之一,与其他描述不同的是,丹尼尔确切地知道他在写什么,而且没有任何错误。 特别是,本文档描述了如何使用众所周知的实用程序来处理 XDP 和 TC 类型的 BPF 程序 ip 从包装中 iproute2.

  2. 文档/网络/filter.txt — 原始文件,包含经典 BPF 和扩展 BPF 的文档。 如果您想深入研究汇编语言和技术架构细节,这是一本很好的读物。

  3. 来自 facebook 的关于 BPF 的博客。 它很少更新,但恰如其分,正如 Alexei Starovoitov(eBPF 的作者)和 Andrii Nakryiko(维护者)在那里写的那样 libbpf).

  4. bpftool 的秘密。 Quentin Monnet 的一个有趣的 Twitter 帖子,其中包含使用 bpftool 的示例和秘密。

  5. 深入了解 BPF:阅读材料列表。 Quentin Monnet 提供的一个巨大(且仍在维护)的 BPF 文档链接列表。

来源: habr.com

添加评论