在 2017 年澳大利亚 Linux 大会(linux.conf.au)上,Brendan Gregg 在关于 eBPF 内核虚拟机的演讲中宣称:“超级能力终于降临到 Linux 了”。eBPF 发展到如今的阶段,经历了漫长的演进和设计过程。最初,eBPF 用于网络数据包过滤,事实证明,在经过健全性检查的虚拟机中运行用户空间代码,对内核开发者和生产工程师来说是一个强大的工具。随着时间的推移,越来越多的新用户开始利用 eBPF 的高性能和便利性。本文将解释 eBPF 是如何演进的、它的工作原理以及它在内核中的应用方式。
eBPF 的演进
最初的伯克利数据包过滤器(BPF)旨在捕获和过滤符合特定规则的网络数据包。过滤器以程序的形式实现,在基于寄存器的虚拟机上运行。
事实证明,允许在内核中运行用户提供的程序是一个非常有用的设计决策,但原始 BPF 设计的其他方面表现不佳。一方面,随着现代处理器转向 64 位寄存器,并且为多处理器系统发明了新的指令(如原子交换并相加指令 XADD),虚拟机及其指令集架构(ISA)的设计逐渐落后。BPF 专注于提供少量精简指令集计算机(RISC)指令的做法,已不再符合现代处理器的实际情况。
因此,Alexei Starovoitov 提出了扩展 BPF(eBPF)设计,以利用现代硬件的进步。eBPF 虚拟机更接近当代处理器,使得 eBPF 指令能够更紧密地映射到硬件 ISA,从而提高性能。最显著的变化之一是转向 64 位寄存器,并且寄存器数量从两个增加到十个。由于现代架构的寄存器数量远不止两个,这使得参数可以像在原生硬件上一样,通过 eBPF 虚拟机寄存器传递给函数。此外,新的BPF_CALL指令使得能够以较低的开销调用内核函数。
eBPF 易于映射到原生指令,这使其适合即时编译,从而提高了性能。在 3.15 内核中添加 eBPF 支持的最初补丁显示,在一些网络过滤微基准测试中,x86-64 架构上的 eBPF 比旧的经典 BPF(cBPF)实现快达四倍,大多数情况下快 1.5 倍。许多架构都支持即时(JIT)编译器(x86-64、SPARC、PowerPC、ARM、arm64、MIPS 和 s390)。
最初,eBPF 仅在内核内部使用,cBPF 程序会在底层无缝转换。但在 2014 年提交的代码之后,eBPF 虚拟机直接向用户空间开放。
eBPF 能做什么?
eBPF 程序会“附加”到内核中指定的代码路径上。当执行到该代码路径时,所有附加的 eBPF 程序都会被执行。鉴于其起源,eBPF 特别适合编写网络程序。可以编写程序附加到网络套接字上,用于过滤流量、对流量进行分类以及执行网络分类操作。甚至可以使用 eBPF 程序修改已建立网络套接字的设置。特别是 XDP 项目,通过在网络栈的最底层(数据包刚被接收后)运行 eBPF 程序,利用 eBPF 进行高性能数据包处理。
内核执行的另一种过滤是限制进程可以使用的系统调用,这通过 seccomp BPF 实现。
eBPF 还可用于内核调试和性能分析;程序可以附加到跟踪点、kprobe 和性能事件上。由于 eBPF 程序可以访问内核数据结构,开发者无需重新编译内核就能编写和测试新的调试代码。对于在运行中的实时系统上调试问题的工程师来说,其意义不言而喻。甚至可以通过用户空间静态定义跟踪点,使用 eBPF 调试用户空间程序。
eBPF 的强大之处源于两个优势:速度快且安全。要充分理解它,你需要了解它的工作原理。
eBPF 内核验证器
允许用户空间代码在内核中运行存在固有的安全和稳定性风险。因此,每个 eBPF 程序在加载之前都要经过一系列检查。第一项检查要确保 eBPF 程序能够终止,并且不包含任何可能导致内核锁定的循环。这是通过对程序的控制流图(CFG)进行深度优先搜索来实现的。严格禁止出现无法到达的指令;任何包含无法到达指令的程序都将加载失败。
第二阶段的检查更为复杂,验证器需要逐指令模拟 eBPF 程序的执行过程。在每条指令执行前后都会检查虚拟机的状态,以确保寄存器和栈状态有效。禁止越界跳转,也禁止访问超出范围的数据。
验证器不需要遍历程序中的每一条路径,它足够智能,能够判断程序的当前状态是否是已检查过的状态的子集。由于之前的所有路径都必须是有效的(否则程序已经加载失败),所以当前路径也一定是有效的。于是验证器便可以“剪枝”当前分支,跳过其模拟过程,大幅提升验证效率。
验证器还有一种“安全模式”,该模式禁止进行指针算术运算。只要没有CAP_SYS_ADMIN权限的用户加载 eBPF 程序,就会启用安全模式。这样做的目的是确保内核地址不会泄露给无特权的用户,并且指针不能被写入内存。如果未启用安全模式,则允许进行指针算术运算,但前提是要进行额外的检查。例如,会检查所有指针访问的类型、对齐方式以及是否越界。
不能读取内容未初始化(即从未被写入过)的寄存器;这样做会导致程序加载失败。通过存储一个特殊值来标记寄存器 R0-R5 的内容在函数调用过程中不可读,以捕获对未初始化寄存器的任何读取操作。对于读取栈上的变量也会进行类似的检查,并且要确保没有指令会写入只读的帧指针寄存器。
最后,验证器会根据 eBPF 程序的类型(后续会介绍)来限制 eBPF 程序可以调用哪些内核函数,以及可以访问哪些数据结构。例如,某些程序类型被允许直接访问网络数据包数据。
bpf() 系统调用
程序是通过bpf()系统调用并使用BPF_PROG_LOAD命令来加载的。该系统调用的原型如下:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
bpf_attr联合体允许在内核和用户空间之间传递数据;具体格式取决于cmd参数。size参数表示bpf_attr联合体对象的字节大小。
有一些命令可用于创建和修改 eBPF 映射;映射是一种通用的键/值数据结构,用于 eBPF 程序与内核或用户空间之间的通信。其他命令还允许将 eBPF 程序附加到控制组目录或套接字文件描述符上,遍历所有映射和程序,以及将 eBPF 对象固定到文件中,这样在加载它们的进程终止时,这些对象不会被销毁(流量控制分类器/动作代码会使用后一种功能,以便 eBPF 程序可以持续存在,而无需加载进程一直运行)。完整的命令列表可以在bpf()的手册页中找到。
虽然看起来有很多不同的命令,但它们可以分为三类:用于处理 eBPF 程序的命令、用于处理 eBPF 映射的命令,以及用于同时处理程序和映射(统称为对象)的命令。
eBPF 程序类型
使用BPF_PROG_LOAD加载的程序类型决定了四件事:程序可以附加的位置、验证器允许调用的内核辅助函数、是否可以直接访问网络数据包数据,以及作为程序第一个参数传递的对象类型。实际上,程序类型本质上定义了一个应用程序编程接口。甚至还专门创建了新的程序类型,以区分不同的可调用函数列表。
内核当前支持的 eBPF 程序类型如下:
- BPF_PROG_TYPE_SOCKET_FILTER:网络数据包过滤器
- BPF_PROG_TYPE_KPROBE:确定 kprobe 是否应该触发
- BPF_PROG_TYPE_SCHED_CLS:网络流量控制分类器
- BPF_PROG_TYPE_SCHED_ACT:网络流量控制动作
- BPF_PROG_TYPE_TRACEPOINT:确定跟踪点是否应该触发
- BPF_PROG_TYPE_XDP:从设备驱动接收路径运行的网络数据包过滤器
- BPF_PROG_TYPE_PERF_EVENT:确定性能事件处理程序是否应该触发
- BPF_PROG_TYPE_CGROUP_SKB:用于控制组的网络数据包过滤器
- BPF_PROG_TYPE_CGROUP_SOCK:用于控制组的网络数据包过滤器,允许修改套接字选项
- BPF_PROG_TYPELWT*:用于轻量级隧道的网络数据包过滤器
- BPF_PROG_TYPE_SOCK_OPS:用于设置套接字参数的程序
- BPF_PROG_TYPE_SK_SKB:用于在套接字之间转发数据包的网络数据包过滤器
- BPF_PROG_CGROUP_DEVICE:确定是否允许进行设备操作
随着新的程序类型不断添加,内核开发者也发现有必要添加新的数据结构。
eBPF 数据结构
eBPF 程序使用的主要数据结构是 eBPF 映射,这是一种通用的数据结构,允许数据在内核内部或内核与用户空间之间来回传递。正如“映射”这个名称所暗示的,数据通过键(key)来存储和检索。
映射是使用bpf()系统调用创建和操作的。当一个映射成功创建时,会返回一个与该映射关联的文件描述符。通常,通过关闭关联的文件描述符来销毁映射。每个映射由四个值定义:类型、最大元素数量、值的字节大小和键的字节大小。有不同的映射类型,每种类型都有不同的行为和权衡:
- BPF_MAP_TYPE_HASH:哈希表
- BPF_MAP_TYPE_ARRAY:数组映射,针对快速查找速度进行了优化,常用于计数器
- BPF_MAP_TYPE_PROG_ARRAY:与 eBPF 程序对应的文件描述符数组;用于实现跳转表和子程序以处理特定的数据包协议
- BPF_MAP_TYPE_PERCPU_ARRAY:每个 CPU 一个的数组,用于实现延迟直方图
- BPF_MAP_TYPE_PERF_EVENT_ARRAY:存储指向
struct perf_event 的指针,用于读取和存储性能事件计数器
- BPF_MAP_TYPE_CGROUP_ARRAY:存储指向控制组的指针
- BPF_MAP_TYPE_PERCPU_HASH:每个 CPU 一个的哈希表
- BPF_MAP_TYPE_LRU_HASH:仅保留最近使用项的哈希表
- BPF_MAP_TYPE_LRU_PERCPU_HASH:每个 CPU 一个、仅保留最近使用项的哈希表
- BPF_MAP_TYPE_LPM_TRIE:最长前缀匹配字典树,适合将 IP 地址与某个范围进行匹配
- BPF_MAP_TYPE_STACK_TRACE:存储栈跟踪信息
- BPF_MAP_TYPE_ARRAY_OF_MAPS:一种嵌套映射的数据结构
- BPF_MAP_TYPE_HASH_OF_MAPS:一种嵌套映射的数据结构
- BPF_MAP_TYPE_DEVICE_MAP:用于存储和查找网络设备引用
- BPF_MAP_TYPE_SOCKET_MAP:存储和查找套接字,并允许使用 BPF 辅助函数进行套接字重定向
所有映射都可以通过bpf_map_lookup_elem()和bpf_map_update_elem()函数从 eBPF 或用户空间程序中访问。某些映射类型,如套接字映射,还可以与执行特殊任务的其他 eBPF 辅助函数配合使用。
如何编写 eBPF 程序
过去,必须手动编写 eBPF 汇编代码,并使用内核的bpf_asm汇编器来生成 BPF 字节码。幸运的是,LLVM Clang 编译器已经增加了对 eBPF 后端的支持,它可以将 C 代码编译成字节码。包含这种字节码的目标文件随后可以使用bpf()系统调用和BPF_PROG_LOAD命令直接加载。
你可以使用 Clang 编译器并带上-march=bpf参数来编写自己的 eBPF 程序。在内核的samples/bpf/目录中有大量的 eBPF 程序示例;大多数示例文件的名称带有_kern.c后缀。Clang 生成的目标文件(eBPF 字节码)需要由在你机器上本地运行的程序加载(这些示例通常在文件名中带有_user.c)。为了更方便地编写 eBPF 程序,内核提供了libbpf库,它包含用于加载程序以及创建和操作 eBPF 对象的辅助函数。例如,使用libbpf的 eBPF 程序和用户程序的高级流程可能如下:
- 在用户应用程序中将 eBPF 字节码读入缓冲区,并将其传递给
bpf_load_program()。
- 当内核运行 eBPF 程序时,它会调用
bpf_map_lookup_elem() 来查找映射中的元素并存储新值。
- 用户应用程序调用
bpf_map_lookup_elem() 来读取 eBPF 程序在内核中存储的值。
然而,所有示例代码都有一个主要缺点:你需要在内核源代码树中编译你的 eBPF 程序。幸运的是,BCC 项目就是为了解决这个问题而创建的。它包含一个完整的工具链,用于编写 eBPF 程序并加载它们,而无需与内核源代码树进行链接。