一开始有一种技术,叫做BPF。 我们看着她
粗略地说,BPF 允许您在 Linux 内核空间中运行任意用户提供的代码,并且新架构非常成功,以至于我们还需要十几篇文章来描述它的所有应用程序。 (开发人员唯一做得不好的事情是创建了一个像样的徽标,正如您在下面的性能代码中看到的那样。)
本文介绍了 BPF 虚拟机的结构、使用 BPF 的内核接口、开发工具,以及对现有功能的简要概述,即我们将来深入研究 BPF 的实际应用所需的一切。
文章摘要
bpf(2)
.
Пишем программы BPF с помощью libbpf
.libbpf
。 我们将创建一个基本的 BPF 应用程序框架,我们将在后续示例中使用它。
BPF架构简介
在我们开始考虑 BPF 架构之前,我们将最后一次(哦)提到
新的 BPF 是为了应对 64 位机器、云服务的普遍存在以及对创建 SDN 工具日益增长的需求而开发的(S软件-d确定的 n网络工作)。 新的 BPF 由内核网络工程师开发,作为经典 BPF 的改进替代品,六个月后,它在跟踪 Linux 系统这一艰巨的任务中找到了应用,而现在,在它出现六年后,我们需要一篇完整的下一篇文章来了解列出不同类型的程序。
有趣的图片
从本质上讲,BPF 是一个沙箱虚拟机,允许您在内核空间中运行“任意”代码,而不会影响安全性。 BPF 程序在用户空间中创建,加载到内核中,并连接到某个事件源。 例如,事件可以是向网络接口传送数据包、启动某些内核功能等。 在包的情况下,BPF 程序将有权访问包的数据和元数据(用于读取,可能还可以写入,具体取决于程序的类型);在运行内核函数的情况下,函数,包括指向内核内存的指针等。
让我们仔细看看这个过程。 首先,我们来谈谈与经典 BPF 的第一个区别,经典 BPF 的程序是用汇编语言编写的。 在新版本中,架构得到了扩展,以便可以用高级语言编写程序,当然主要是用 C 语言。为此,开发了 llvm 的后端,它允许您为 BPF 架构生成字节码。
BPF 架构的设计部分是为了在现代机器上高效运行。 为了在实践中实现这一点,BPF 字节码一旦加载到内核中,就会使用称为 JIT 编译器的组件转换为本机代码(J乌斯 In T我)。 接下来,如果您还记得的话,在经典的 BPF 中,程序被加载到内核中并以原子方式附加到事件源 - 在单个系统调用的上下文中。 在新架构中,这分两个阶段发生 - 首先,使用系统调用将代码加载到内核中 bpf(2)
然后,稍后,通过根据程序类型而变化的其他机制,程序附加到事件源。
说到这里,读者可能会有一个疑问:这可能吗? 这样的代码执行安全如何保证呢? 我们通过加载BPF程序的阶段(称为verifier)来保证执行安全(这个阶段在英文中称为verifier,我将继续使用英文单词):
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中,越来越多的大公司在作战服务器上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
struct __sk_buff
struct pt_regs
因此,我们有一组寄存器、内核助手、堆栈、上下文指针和映射形式的共享内存。 并不是说所有这些都是旅行中绝对必要的,但是......
让我们继续描述并讨论处理这些对象的命令系统。 全部 (
这是 Code
- 这是指令的编码, Dst
/Src
分别是接收器和源的编码, Off
- 16 位有符号缩进,以及 Imm
是一些指令中使用的 32 位有符号整数(类似于 cBPF 常量 K)。 编码 Code
有两种类型之一:
指令类 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
让我们看一个编译程序的例子 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
手术 b
类 ALU64
- s
(source),那么该值是从源寄存器中获取的,如果像我们的例子一样,它没有被设置,那么该值是从字段中获取的 Imm
。 所以在第一条和第三条指令中我们执行操作 r0 = Imm
。 此外,JMP 1 类操作是 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_PROG_LOAD
и BPF_MAP_CREATE
系统调用 bpf(2)
,我们将在下一节中详细讨论这是如何发生的。 这将创建内核数据结构并为每个结构创建内核数据结构 refcount
(引用计数)设置为XNUMX,并且指向该对象的文件描述符被返回给用户。 手柄关闭后 refcount
对象减一,当减到零时,对象被销毁。
如果程序使用映射,那么 refcount
这些地图在加载程序后会加一,即它们的文件描述符可以从用户进程中关闭,并且仍然 refcount
不会变为零:
成功加载程序后,我们通常将其附加到某种事件生成器。 例如,我们可以将其放在网络接口上来处理传入的数据包或将其连接到某些 tracepoint
在核心。 此时,引用计数器也会加一,我们就可以在加载程序中关闭文件描述符了。
如果我们现在关闭引导加载程序会发生什么? 这取决于事件生成器(钩子)的类型。 所有网络钩子都会在加载器完成后存在,这些就是所谓的全局钩子。 并且,例如,跟踪程序将在创建它们的进程终止后被释放(因此称为本地,从“本地到进程”)。 从技术上讲,本地钩子在用户空间中始终有一个相应的文件描述符,因此当进程关闭时也会关闭,但全局钩子则不然。 在下图中,我尝试使用红叉来展示加载程序的终止在本地和全局钩子的情况下如何影响对象的生命周期。
为什么本地钩子和全局钩子之间存在区别? 在没有用户空间的情况下运行某些类型的网络程序是有意义的,例如,想象一下 DDoS 保护 - 引导加载程序编写规则并将 BPF 程序连接到网络接口,之后引导加载程序可以自行终止。 另一方面,想象一下您在十分钟内跪下编写的调试跟踪程序 - 当它完成时,您希望系统中没有留下任何垃圾,而本地挂钩将确保这一点。
另一方面,假设您想要连接到内核中的跟踪点并收集多年的统计信息。 在这种情况下,您可能希望完成用户部分并不时返回统计信息。 bpf 文件系统提供了这个机会。 它是一个仅内存中的伪文件系统,允许创建引用 BPF 对象的文件,从而增加 refcount
对象。 此后,加载器可以退出,并且它创建的对象将保持活动状态。
在 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
。 在这种情况下,旧版本程序的所有活动实例都将完成其工作,并且将从新程序创建新的事件处理程序,这里的“原子性”意味着不会错过任何一个事件。
将程序附加到事件源
在本文中,我们不会单独描述将程序连接到事件源,因为在特定类型的程序的上下文中研究这一点是有意义的。 厘米。
使用 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_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 支持进行编译(我们在 一节中描述了如何执行此操作)
$ 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]
。 然而,为了满足内核之外的应用程序的需要,维护了一个单独的存储库
在本节中,我们将了解如何创建一个使用 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_ops
为 xdp
。 结构 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
为了更容易遵循逻辑,我们将为此目的重写我们的示例 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) 与内核交互的接口: 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
由我取自
我们不会安装刚刚构建的程序,而是将它们添加到 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,我们可以使用通常的 net
net-next
bpf
bpf-next
*-next
内核是列出的内核中最不稳定的)。
讨论如何管理内核配置文件超出了本文的范围 - 假设您已经知道如何执行此操作,或者
下载上述内核之一:
$ 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 网络和安全应用程序的示例。
本系列之前的文章
链接
-
BPF 和 XDP 参考指南 — 来自 cilium 的 BPF 文档,或者更准确地说,来自 BPF 的创建者和维护者之一 Daniel Borkman。 这是最早的严肃描述之一,与其他描述不同的是,丹尼尔确切地知道他在写什么,而且没有任何错误。 特别是,本文档描述了如何使用众所周知的实用程序来处理 XDP 和 TC 类型的 BPF 程序ip
从包装中iproute2
. -
文档/网络/filter.txt — 原始文件,包含经典 BPF 和扩展 BPF 的文档。 如果您想深入研究汇编语言和技术架构细节,这是一本很好的读物。 -
来自 facebook 的关于 BPF 的博客 。 它很少更新,但恰如其分,正如 Alexei Starovoitov(eBPF 的作者)和 Andrii Nakryiko(维护者)在那里写的那样libbpf
). -
bpftool 的秘密 。 Quentin Monnet 的一个有趣的 Twitter 帖子,其中包含使用 bpftool 的示例和秘密。 -
深入了解 BPF:阅读材料列表 。 Quentin Monnet 提供的一个巨大(且仍在维护)的 BPF 文档链接列表。
来源: habr.com