公众号关注 「奇妙的 Linux 世界」

设为「星标」,每天带你玩转 Linux !

eBPF 源于 BPF[1],本质上是处于内核中的一个高效与灵活的虚类虚拟机组件,以一种安全的方式在许多内核 hook 点执行字节码。BPF 最初的目的是用于高效网络报文过滤,经过重新设计,eBPF 不再局限于网络协议栈,已经成为内核顶级的子系统,演进为一个通用执行引擎。

开发者可基于 eBPF 开发性能分析工具、软件定义网络、安全等诸多场景。本文将介绍 eBPF 的前世今生,并构建一个 eBPF 环境进行开发实践,文中所有的代码可以在我的 Github[2] 中找到。

技术背景

发展历史

BPF,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。1992 年,Steven McCanne 和 Van Jacobson 写了一篇名为 The BSD Packet Filter: A New Architecture for User-level Packet Capture[3] 的论文。在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。

BPF 在数据包过滤上引入了两大革新:

  • 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上

  • 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息,这样可以最大程度地减少 BPF 处理的数据

由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术,直到今天,许多 Unix 内核的派生系统中(包括 Linux 内核)仍使用该实现。tcpdump 的底层采用 BPF 作为底层包过滤技术,我们可以在命令后面增加 -d 来查看 tcpdump 过滤条件的底层汇编指令。

$ tcpdump -d 'ip and tcp port 8080'
(000) ldh      [12]
(001) jeq      #0x800           jt 2 jf 12
(002) ldb      [23]
(003) jeq      #0x6             jt 4 jf 12
(004) ldh      [20]
(005) jset     #0x1fff          jt 12 jf 6
(006) ldxb     4*([14]&0xf)
(007) ldh      [x + 14]
(008) jeq      #0x1f90          jt 11 jf 9
(009) ldh      [x + 16]
(010) jeq      #0x1f90          jt 11 jf 12
(011) ret      #262144
(012) ret      #0

2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行

eBPF 与 cBPF

eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的 2 个 32 位寄存器增加到 10 个 64 位寄存器。由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使 eBPF 版本的速度比原来的 BPF 提高了 4 倍。

维度 cBPF eBPF
内核版本 Linux 2.1.75(1997 年) Linux 3.18(2014 年)[4.x for kprobe/uprobe/tracepoint/perf-event]
寄存器数目 2 个:A, X 10 个:R0–R9, 另外 R10 是一个只读的帧指针 - R0 eBPF 中内核函数的返回值和退出值 - R1 - R5 eBF 程序在内核中的参数值 - R6 - R9 内核函数将保存的被调用者 callee 保存的寄存器 - R10 一个只读的堆栈帧指针
寄存器宽度 32 位 64 位
存储 16 个内存位: M[0–15] 512 字节堆栈,无限制大小的 map 存储
限制的内核调用 非常有限,仅限于 JIT 特定 有限,通过 bpf_call 指令调用
目标事件 数据包、 seccomp-BPF 数据包、内核函数、用户函数、跟踪点 PMCs 等

2014 年 6 月,eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。正如 Alexei 在提交补丁的注释中写到:「这个补丁展示了 eBPF 的潜力」。当前,eBPF 不再局限于网络栈,已经成为内核顶级的子系统。

eBPF 与内核模块

对比 Web 的发展,eBPF 与内核的关系有点类似于 JavaScript 与浏览器内核的关系,eBPF 相比于直接修改内核和编写内核模块提供了一种新的内核可编程的选项。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。

维度 Linux 内核模块 eBPF
kprobes/tracepoints 支持 支持
安全性 可能引入安全漏洞或导致内核 Panic 通过验证器进行检查,可以保障内核安全
内核函数 可以调用内核函数 只能通过 BPF Helper 函数调用
编译性 需要编译内核 不需要编译内核,引入头文件即可
运行 基于相同内核运行 基于稳定 ABI 的 BPF 程序可以编译一次,各处运行
与应用程序交互 打印日志或文件 通过 perf_event 或 map 结构
数据结构丰富性 一般 丰富
入门门槛
升级 需要卸载和加载,可能导致处理流程中断 原子替换升级,不会造成处理流程中断
内核内置 视情况而定 内核内置支持

eBPF 架构

eBPF 分为用户空间程序和内核程序两部分:

  • 用户空间程序负责加载 BPF 字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情

  • 内核中的 BPF 字节码负责在内核中执行特定事件,如需要也会将执行的结果通过 maps 或者 perf-event 事件发送至用户空间

  • 其中用户空间程序与内核 BPF 字节码程序可以使用 map 结构实现双向通信,这为内核中运行的 BPF 字节码程序提供了更加灵活的控制

eBPF 整体结构图如下:

用户空间程序与内核中的 BPF 字节码交互的流程主要如下:

  1. 使用 LLVM 或者 GCC 工具将编写的 BPF 代码程序编译成 BPF 字节码

  2. 使用加载程序 Loader 将字节码加载至内核

  3. 内核使用验证器(Verfier) 组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后将其加载对应的内核模块执行

  4. 内核中运行的 BPF 字节码程序可以使用两种方式将数据回传至用户空间

  • maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;

  • perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析;

eBPF 限制

eBPF 技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的 eBPF 技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。

  • eBPF 程序不能调用任意的内核参数,只限于内核模块中列出的 BPF Helper 函数,函数支持列表也随着内核的演进在不断增加。

  • eBPF 程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。

  • eBPF 程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在 kprobes 中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux 5.3 在 BPF 中包含了对有界循环的支持,它有一个可验证的运行时间上限。

  • eBPF 堆栈大小被限制在 MAX_BPF_STACK,截止到内核 Linux 5.8 版本,被设置为 512;参见 include/linux/filter.h[4],这个限制特别是在栈上存储多个字符串缓冲区时:一个 char[256]缓冲区会消耗这个栈的一半。目前没有计划增加这个限制,解决方法是改用 bpf 映射存储,它实际上是无限的。

    /* BPF program can access up to 512 bytes of stack space. */
    #define MAX_BPF_STACK 512
  • eBPF 字节码大小最初被限制为 4096 条指令,截止到内核 Linux 5.8 版本, 当前已将放宽至 100 万指令( BPF_COMPLEXITY_LIMIT_INSNS),参见:include/linux/bpf.h[5],对于无权限的 BPF 程序,仍然保留 4096 条限制 ( BPF_MAXINSNS );新版本的 eBPF 也支持了多个 eBPF 程序级联调用,虽然传递信息存在某些限制,但是可以通过组合实现更加强大的功能。

    #define BPF_COMPLEXITY_LIMIT_INSNS      1000000 /* yes. 1M insns */

eBPF 实战

在深入介绍 eBPF 特性之前,让我们 Get Hands Dirty,切切实实的感受 eBPF 程序到底是什么,我们该如何开发 eBPF 程序。随着 eBPF 生态的演进,现在已经有越来越多的工具链用于开发 eBPF 程序,在后文也会详细介绍:

  • 基于 bcc 开发:bcc 提供了对 eBPF 开发,前段提供 Python API,后端 eBPF 程序通过 C 实现。特点是简单易用,但是性能较差。

  • 基于 libebpf-bootstrap 开发:libebpf-bootstrap 提供了一个方便的脚手架

  • 基于内核源码开发:内核源码开发门槛较高,但是也更加切合 eBPF 底层原理,所以这里以这个方法作为示例

内核源码编译

系统环境如下,采用腾讯云 CVM,Ubuntu 20.04,内核版本 5.4.0

$ uname -a
Linux VM-1-3-ubuntu 5.4.0-42-generic #46-Ubuntu SMP Fri Jul 10 00:24:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

首先安装必要依赖:

$ sudo apt install -y bison build-essential cmake flex git libedit-dev pkg-config libmnl-dev \python zlib1g-dev libssl-dev libelf-dev libcap-dev libfl-dev llvm clang pkg-config \gcc-multilib luajit libluajit-5.1-dev libncurses5-dev libclang-dev clang-tools

一般情况下推荐采用 apt 方式的安装源码,安装简单而且只安装当前内核的源码,源码的大小在 200M 左右。

$ apt-cache search linux-source
$ apt install linux-source-5.4.0

源码安装至 /usr/src/ 目录下。

$ ls -hl
total 4.0K
drwxr-xr-x 4 root root 4.0K Nov  9 13:22 linux-source-5.4.0
lrwxrwxrwx 1 root root   45 Oct 15 10:28 linux-source-5.4.0.tar.bz2 -> linux-source-5.4.0/linux-source-5.4.0.tar.bz2
$ tar -jxvf linux-source-5.4.0.tar.bz2
$ cd linux-source-5.4.0$ cp -v /boot/config-$(uname -r) .config # make defconfig 或者 make menuconfig
$ make headers_install
$ make modules_prepare
$ make scripts     # 可选
$ make M=samples/bpf  # 如果配置出错,可以使用 make oldconfig && make prepare 修复

编译成功后,可以在 samples/bpf 目录下看到一系列的目标文件和二进制文件。

Hello World

前面说到 eBPF 通常由内核空间程序和用户空间程序两部分组成,现在 samples/bpf 目录下有很多这种程序,内核空间程序以 _kern.c 结尾,用户空间程序以 _user.c 结尾。先不看这些复杂的程序,我们手动写一个 eBPF 程序的 Hello World。

内核中的程序 hello_kern.c

#include <linux/bpf.h>
#include "bpf_helpers.h"#define SEC(NAME) __attribute__((section(NAME), used))SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx)
{char msg[] = "Hello BPF from houmin!\n";bpf_trace_printk(msg, sizeof(msg));return 0;
}char _license[] SEC("license") = "GPL";

函数入口

上述代码和普通的 C 语言编程有一些区别。

  1. 程序的入口通过编译器的 pragama __section("tracepoint/syscalls/sys_enter_execve") 指定的。

  2. 入口的参数不再是 argc, argv, 它根据不同的 prog type 而有所差别。我们的例子中,prog type 是 BPF_PROG_TYPE_TRACEPOINT, 它的入口参数就是 void *ctx

头文件

#include <linux/bpf.h>

这个头文件的来源是 kernel source header file 。它安装在 /usr/include/linux/bpf.h中。

它提供了 bpf 编程需要的很多 symbol。例如

  1. enum bpf_func_id 定义了所有的 kerne helper function 的 id

  2. enum bpf_prog_type 定义了内核支持的所有的 prog 的类型。

  3. struct __sk_buff 是 bpf 代码中访问内核 struct sk_buff 的接口。

等等

#include “bpf_helpers.h”

来自 libbpf ,需要自行安装。我们引用这个头文件是因为调用了 bpf_printk()。这是一个 kernel helper function。

程序解释

这里我们简单解读下内核态的 ebpf 程序,非常简单:

  • bpf_trace_printk 是一个 eBPF helper 函数,用于打印信息到 trace_pipe (/sys/kernel/debug/tracing/trace_pipe),详见这里[6]

  • 代码声明了 SEC 宏,并且定义了 GPL 的 License,这是因为加载进内核的 eBPF 程序需要有 License 检查,类似于内核模块

加载 BPF 代码

用户态程序 hello_user.c

#include <stdio.h>
#include "bpf_load.h"int main(int argc, char **argv)
{if(load_bpf_file("hello_kern.o") != 0){printf("The kernel didn't load BPF program\n");return -1;}read_trace_pipe();return 0;
}

在用户态 ebpf 程序中,解读如下:

  • 通过 load_bpf_file 将编译出的内核态 ebpf 目标文件加载到内核

  • 通过 `read_trace_pipe`[7] 从 trace_pipe 读取 trace 信息,打印到控制台中

修改 samples/bpf 目录下的 Makefile 文件,在对应的位置添加以下三行:

hostprogs-y += hello
hello-objs := bpf_load.o hello_user.o
always += hello_kern.o

重新编译,可以看到编译成功的文件

$ make M=samples/bpf
$ ls -hl samples/bpf/hello*
-rwxrwxr-x 1 ubuntu ubuntu 404K Mar 30 17:48 samples/bpf/hello
-rw-rw-r-- 1 ubuntu ubuntu  317 Mar 30 17:47 samples/bpf/hello_kern.c
-rw-rw-r-- 1 ubuntu ubuntu 3.8K Mar 30 17:48 samples/bpf/hello_kern.o
-rw-rw-r-- 1 ubuntu ubuntu  246 Mar 30 17:47 samples/bpf/hello_user.c
-rw-rw-r-- 1 ubuntu ubuntu 2.2K Mar 30 17:48 samples/bpf/hello_user.o

进入到对应的目录运行 hello 程序,可以看到输出结果如下:

$ sudo ./hello<...>-102735 [001] ....  6733.481740: 0: Hello BPF from houmin!<...>-102736 [000] ....  6733.482884: 0: Hello BPF from houmin!<...>-102737 [002] ....  6733.483074: 0: Hello BPF from houmin!

代码解读

前面提到 load_bpf_file 函数将 LLVM 编译出来的 eBPF 字节码加载进内核,这到底是如何实现的呢?

  • 经过搜查,可以看到 load_bpf_file 也是在 samples/bpf 目录下实现的,具体的参见 `bpf_load.c`[8]

  • 阅读 load_bpf_file 代码可以看到,它主要是解析 ELF 格式的 eBPF 字节码,然后调用 `load_and_attach`[9] 函数

  • 在 load_and_attach 函数中,我们可以看到其调用了 bpf_load_program 函数,这是 libbpf 提供的函数。

  • 调用的 bpf_load_program 中的 licensekern_version 等参数来自于解析 eBPF ELF 文件,prog_type 来自于 bpf 代码里面 SEC 字段指定的类型。

static int load_and_attach(const char *event, struct bpf_insn *prog, int size)
{bool is_socket = strncmp(event, "socket", 6) == 0;bool is_kprobe = strncmp(event, "kprobe/", 7) == 0;bool is_kretprobe = strncmp(event, "kretprobe/", 10) == 0;bool is_tracepoint = strncmp(event, "tracepoint/", 11) == 0;bool is_raw_tracepoint = strncmp(event, "raw_tracepoint/", 15) == 0;bool is_xdp = strncmp(event, "xdp", 3) == 0;bool is_perf_event = strncmp(event, "perf_event", 10) == 0;bool is_cgroup_skb = strncmp(event, "cgroup/skb", 10) == 0;bool is_cgroup_sk = strncmp(event, "cgroup/sock", 11) == 0;bool is_sockops = strncmp(event, "sockops", 7) == 0;bool is_sk_skb = strncmp(event, "sk_skb", 6) == 0;bool is_sk_msg = strncmp(event, "sk_msg", 6) == 0;//...fd = bpf_load_program(prog_type, prog, insns_cnt, license, kern_version,bpf_log_buf, BPF_LOG_BUF_SIZE);if (fd < 0) {printf("bpf_load_program() err=%d\n%s", errno, bpf_log_buf);return -1;}//...
}

eBPF 特性

Hook Overview

eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。

如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。

Verification

With great power there must also come great responsibility.

每一个 eBPF 程序加载到内核都要经过 Verification,用来保证 eBPF 程序的安全性,主要包括:

  • 要保证 加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged 特性,只有特权级的程序才能够加载 eBPF 程序

    • 内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来禁止非特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改

    • 比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核

    • 一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。Cilium 启动后也会将这个配置项设为 1:

$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
  • 要保证 eBPF 程序不会崩溃或者使得系统出故障

  • 要保证 eBPF 程序不能陷入死循环,能够 runs to completion

  • 要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核

  • 要保证 eBPF 程序的复杂度有限,Verifier 将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析

JIT Compilation

Just-In-Time(JIT) 编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行:

  • 与解释器相比,它们可以降低每个指令的开销。通常,指令可以 1:1 映射到底层架构的原生指令

  • 这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好

  • 特别地,对于 CISC 指令集(例如 x86),JIT 做了很多特殊优化,目的是为给定的指令产生可能的最短操作码,以降低程序翻译过程所需的空间

64 位的 x86_64arm64ppc64s390xmips64sparc64 和 32 位的 arm 、x86_32 架构都内置了 in-kernel eBPF JIT 编译器,它们的功能都是一样的,可以用如下方式打开:

$ echo 1 > /proc/sys/net/core/bpf_jit_enable

32 位的 mipsppc 和 sparc 架构目前内置的是一个 cBPF JIT 编译器。这些只有 cBPF JIT 编译器的架构,以及那些甚至完全没有 BPF JIT 编译器的架构,需要通过内核中的解释器(in-kernel interpreter)执行 eBPF 程序。

要判断哪些平台支持 eBPF JIT,可以在内核源文件中 grep HAVE_EBPF_JIT

$ git grep HAVE_EBPF_JIT arch/
arch/arm/Kconfig:       select HAVE_EBPF_JIT   if !CPU_ENDIAN_BE32
arch/arm64/Kconfig:     select HAVE_EBPF_JIT
arch/powerpc/Kconfig:   select HAVE_EBPF_JIT   if PPC64
arch/mips/Kconfig:      select HAVE_EBPF_JIT   if (64BIT && !CPU_MICROMIPS)
arch/s390/Kconfig:      select HAVE_EBPF_JIT   if PACK_STACK && HAVE_MARCH_Z196_FEATURES
arch/sparc/Kconfig:     select HAVE_EBPF_JIT   if SPARC64
arch/x86/Kconfig:       select HAVE_EBPF_JIT   if X86_64

Maps

BPF Map 是驻留在内核空间中的高效 Key/Value store,包含多种类型的 Map,由内核实现其功能,具体实现可以参考 我的这篇博文[10]

BPF Map 的交互场景有以下几种:

  • BPF 程序和用户态程序的交互:BPF 程序运行完,得到的结果存储到 map 中,供用户态程序通过文件描述符访问

  • BPF 程序和内核态程序的交互:和 BPF 程序以外的内核程序交互,也可以使用 map 作为中介

  • BPF 程序间交互:如果 BPF 程序内部需要用全局变量来交互,但是由于安全原因 BPF 程序不允许访问全局变量,可以使用 map 来充当全局变量

  • BPF Tail call:Tail call 是一个 BPF 程序跳转到另一 BPF 程序,BPF 程序首先通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个 BPF 程序的指针,然后调用 tail_call() 的 helper function 来执行 Tail call

共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map,单个 BPF 程序目前最多可直接访问 64 个不同 map

当前可用的 通用 map 有:

  • BPF_MAP_TYPE_HASH

  • BPF_MAP_TYPE_ARRAY

  • BPF_MAP_TYPE_PERCPU_HASH

  • BPF_MAP_TYPE_PERCPU_ARRAY

  • BPF_MAP_TYPE_LRU_HASH

  • BPF_MAP_TYPE_LRU_PERCPU_HASH

  • BPF_MAP_TYPE_LPM_TRIE

以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不同的后端,这些后端各有不同的语义和性能特点。随着多 CPU 架构的成熟发展,BPF Map 也引入了 per-cpu 类型,如BPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的 BPF Map 时,每个 CPU 都会存储并看到它自己的 Map 数据,从属于不同 CPU 之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的 BPF 程序主要是在做收集时间序列型数据,如流量数据或指标等。

当前内核中的 非通用 map 有:

  • BPF_MAP_TYPE_PROG_ARRAY:一个数组 map,用于 hold 其他的 BPF 程序

  • BPF_MAP_TYPE_PERF_EVENT_ARRAY

  • BPF_MAP_TYPE_CGROUP_ARRAY:用于检查 skb 中的 cgroup2 成员信息

  • BPF_MAP_TYPE_STACK_TRACE:用于存储栈跟踪的 MAP

  • BPF_MAP_TYPE_ARRAY_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换

  • BPF_MAP_TYPE_HASH_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换

Helper Calls

eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functionsHelper functions 使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加,你可以在 Linux Manual Page: bpf-helpers[11] 看到当前 Linux 支持的 Helper functions

不同类型的 BPF 程序能够使用的辅助函数可能是不同的,例如:

  • 与 attach 到 tc 层的 BPF 程序相比,attach 到 socket 的 BPF 程序只能够调用前者可以调用的辅助函数的一个子集

  • lightweight tunneling 使用的封装和解封装辅助函数,只能被更低的 tc 层使用;而推送通知到用户态所使用的事件输出辅助函数,既可以被 tc 程序使用也可以被 XDP 程序使用

所有的辅助函数都共享同一个通用的、和系统调用类似的函数方法,其定义如下:

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

内核将辅助函数抽象成 BPF_CALL_0() 到 BPF_CALL_5() 几个宏,形式和相应类型的系统调用类似,这里宏的定义可以参见 include/linux/filter.h[12] 。以 `bpf_map_update_elem`[13] 为例,可以看到它通过调用相应 map 的回调函数完成更新 map 元素的操作:

/* /kernel/bpf/helpers.c */
BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key,void *, value, u64, flags)
{WARN_ON_ONCE(!rcu_read_lock_held());return map->ops->map_update_elem(map, key, value, flags);
}const struct bpf_func_proto bpf_map_update_elem_proto = {.func           = bpf_map_update_elem,.gpl_only       = false,.ret_type       = RET_INTEGER,.arg1_type      = ARG_CONST_MAP_PTR,.arg2_type      = ARG_PTR_TO_MAP_KEY,.arg3_type      = ARG_PTR_TO_MAP_VALUE,.arg4_type      = ARG_ANYTHING,
};

这种方式有很多优点:

虽然 cBPF 允许其加载指令(load instructions)进行超出范围的访问(overload),以便从一个看似不可能的包偏移量(packet offset)获取数据以唤醒多功能辅助函数,但每个 cBPF JIT 仍然需要为这个 cBPF 扩展实现对应的支持。而在 eBPF 中,JIT 编译器会以一种透明和高效的方式编译新加入的辅助函数,这意味着 JIT 编 译器只需要发射(emit)一条调用指令(call instruction),因为寄存器映射的方式使得 BPF 排列参数的方式(assignments)已经和底层架构的调用约定相匹配了。这使得基于辅助函数扩展核心内核(core kernel)非常方便。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块(kernel module)来扩展或添加

前面提到的函数签名还允许校验器执行类型检测(type check)。上面的 struct bpf_func_proto 用于存放校验器必需知道的所有关于该辅助函数的信息,这 样校验器可以确保辅助函数期望的类型和 BPF 程序寄存器中的当前内容是匹配的。

参数类型范围很广,从任意类型的值,到限制只能为特定类型,例如 BPF 栈缓冲区(stack buffer)的 pointer/size 参数对,辅助函数可以从这个位置读取数据或向其写入数据。对于这种情况,校验器还可以执行额外的检查,例如,缓冲区是否已经初始化过了。

Tail Calls

尾调用的机制是指:一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。

  • 和普通函数调用相比,这种调用方式开销最小,因为它是用长跳转(long jump)实现的,复用了原来的栈帧 (stack frame)

  • BPF 程序都是独立验证的,因此要传递状态,要么使用 per-CPU map 作为 scratch 缓冲区 ,要么如果是 tc 程序的话,还可以使用 skb 的某些字段(例如 cb[]

  • 相同类型的程序才可以尾调用,而且它们还要与 JIT 编译器相匹配,因此要么是 JIT 编译执行,要么是解释器执行(invoke interpreted programs),但不能同时使用两种方式

BPF to BPF Calls

除了 BPF 辅助函数和 BPF 尾调用之外,BPF 核心基础设施最近刚加入了一个新特性:BPF to BPF calls。**在这个特性引入内核之前,典型的 BPF C 程序必须 将所有需要复用的代码进行特殊处理,例如,在头文件中声明为 always_inline**。当 LLVM 编译和生成 BPF 对象文件时,所有这些函数将被内联,因此会在生成的对象文件中重 复多次,导致代码尺寸膨胀:

#include <linux/bpf.h>#ifndef __section
# define __section(NAME)                  \__attribute__((section(NAME), used))
#endif#ifndef __inline
# define __inline                         \inline __attribute__((always_inline))
#endifstatic __inline int foo(void)
{return XDP_DROP;
}__section("prog")
int xdp_drop(struct xdp_md *ctx)
{return foo();
}char __license[] __section("license") = "GPL";

之所以要这样做是因为 BPF 程序的加载器、校验器、解释器和 JIT 中都缺少对函数调用的支持。从 Linux 4.16 和 LLVM 6.0 开始,这个限制得到了解决,BPF 程序不再需要到处使用 always_inline 声明了。因此,上面的代码可以更自然地重写为:

#include <linux/bpf.h>#ifndef __section
# define __section(NAME)                  \__attribute__((section(NAME), used))
#endifstatic int foo(void)
{return XDP_DROP;
}__section("prog")
int xdp_drop(struct xdp_md *ctx)
{return foo();
}char __license[] __section("license") = "GPL";

BPF 到 BPF 调用是一个重要的性能优化,极大减小了生成的 BPF 代码大小,因此 对 CPU 指令缓存(instruction cache,i-cache)更友好

BPF 辅助函数的调用约定也适用于 BPF 函数间调用:

  • r1 - r5 用于传递参数,返回结果放到 r0

  • r1 - r5 是 scratch registers,r6 - r9 像往常一样是保留寄存器

  • 最大嵌套调用深度是 8

  • 调用方可以传递指针(例如,指向调用方的栈帧的指针) 给被调用方,但反过来不行

当前,BPF 函数间调用和 BPF 尾调用是不兼容的,因为后者需要复用当前的栈设置( stack setup),而前者会增加一个额外的栈帧,因此不符合尾调用期望的布局。

BPF JIT 编译器为每个函数体发射独立的镜像(emit separate images for each function body),稍后在最后一通 JIT 处理(final JIT pass)中再修改镜像中函数调用的地址 。已经证明,这种方式需要对各种 JIT 做最少的修改,因为在实现中它们可以将 BPF 函数间调用当做常规的 BPF 辅助函数调用。

Object Pinning

BPF map 和程序作为内核资源只能通过文件描述符访问,其背后是内核中的匿名 inode。这带来了很多优点:

  • 用户空间应用程序能够使用大部分文件描述符相关的 API

  • 传递给 Unix socket 的文件描述符是透明工作等等

但同时,文件描述符受限于进程的生命周期,使得 map 共享之类的操作非常笨重,这给某些特定的场景带来了很多复杂性。

例如 iproute2,其中的 tc 或 XDP 在准备环境、加载程序到内核之后最终会退出。在这种情况下,从用户空间也无法访问这些 map 了,而本来这些 map 其实是很有用的。例如,在 data path 的 ingress 和 egress 位置共享的 map(可以统计包数、字节数、PPS 等信息)。另外,第三方应用可能希望在 BPF 程序运行时监控或更新 map。

为了解决这个问题,内核实现了一个最小内核空间 BPF 文件系统,BPF map 和 BPF 程序 都可以 pin 到这个文件系统内,这个过程称为 object pinning。BPF 相关的文件系统不是单例模式(singleton),它支持多挂载实例、硬链接、软连接等等。

相应的,BPF 系统调用扩展了两个新命令,如下图所示:

  • BPF_OBJ_PIN:钉住一个对象

  • BPF_OBJ_GET:获取一个被钉住的对象

Hardening

Protection Execution Protection

为了避免代码被损坏,BPF 会在程序的生命周期内,在内核中将 BPF 解释器解释后的整个镜像struct bpf_prog)和 JIT 编译之后的镜像struct bpf_binary_header)锁定为只读的。在这些位置发生的任何数据损坏(例如由于某些内核 bug 导致的)会触发通用的保护机制,因此会造成内核崩溃而不是允许损坏静默地发生。

查看哪些平台支持将镜像内存(image memory)设置为只读的,可以通过下面的搜索:

$ git grep ARCH_HAS_SET_MEMORY | grep select
arch/arm/Kconfig:    select ARCH_HAS_SET_MEMORY
arch/arm64/Kconfig:  select ARCH_HAS_SET_MEMORY
arch/s390/Kconfig:   select ARCH_HAS_SET_MEMORY
arch/x86/Kconfig:    select ARCH_HAS_SET_MEMORY

CONFIG_ARCH_HAS_SET_MEMORY 选项是不可配置的,因此平台要么内置支持,要么不支持,那些目前还不支持的架构未来可能也会支持。

Mitigation Against Spectre

为了防御 

万字干货,eBPF 中文入门指南相关推荐

  1. Linux 内核观测技术 eBPF 中文入门指南

    公众号关注 「奇妙的 Linux 世界」 设为「星标」,每天带你玩转 Linux ! 很早前就想写一篇关于 eBPF 的文章,但是迟迟没有动手,这两天有点时间,所以就来写一篇.这文章主要还是简单的介绍 ...

  2. 3万字干货HTML+CSS入门指南(建议收藏)

    什么是浏览器 浏览器是安装在电脑里面的一个软件, 能够将网页内容呈现给用户查看,并让用户与网页交互的一种软件. 就好比QQ一样都是安装在电脑里面的一个软件, 只不过功能不同而已 常见主流浏览器 浏览器 ...

  3. Linux 网络流量监控利器 iftop 中文入门指南

    iftop 是什么 在 Linux 系统下即时监控服务器的网络带宽使用情况,有很多工具,比如 iptraf.nethogs 等等,但是推荐使用小巧但功能很强大的 iftop 工具. iftop 是 L ...

  4. jQuery中文入门指南,翻译加实例,jQuery的起点教程

    中文版译者:Keel 此文以实例为基础一步步说明了jQuery的工作方式.现以中文翻译(添加我的补充说明)如下.如有相关意见或建议请 EMAIL 告知.或者在 BLOG中留言. 英文原版:http:/ ...

  5. 分布式服务发现与注册中心 Consul 中文入门指南

    公众号关注 「奇妙的 Linux 世界」 设为「星标」,每天带你玩转 Linux ! 基础概念 什么是注册中心 随着微服务理论发展的成熟,越来越多互联网公司采用微服务架构来支持业务发展.各个微服务之间 ...

  6. 102-首发URSINA中文入门指南-打开山河社稷图

    看<哪吒之魔童降世>有个印象很深的点,就是太乙真人有个法宝,叫山河社稷图,里面包含了一个完整的世界. URSINA也是,不比其他大型3D引擎少,该有的都有,可以在里面创建一个完整的世界.( ...

  7. 万字多图 | UML 入门指南

    ▲点击上方公众号名称,置顶或星标@蜗牛互联网 这是蜗牛互联网的第 77 期原创. 作者 l 白色蜗牛 来源 l 蜗牛互联网(ID: woniu_internet) 转载请联系授权(微信ID: 9192 ...

  8. cass生成曲线要素_干货在线 | CASS入门指南——道路断面计算土方

    CASS操作指南--道路断面计算土方法 小伙伴们赶紧学起来! 道路类的土方工程,主要用CASS的断面法土方计算之道路断面来计算.整个计算过程主要分为以下四步: 菜单截图 第一步:绘制道路中心线 道路的 ...

  9. 101-首发URSINA中文入门指南-众里寻它

    首先我想说的是,我是工科出身,没有系统学过软件工程,计算机图形学.所以我找的方法,写的教程是真的简单,因为太复杂的东西我也搞不懂. 一直想找一个简单好用的3D可视化引擎,用于算法开发,快速原型.最好要 ...

  10. UI组件库Kendo UI for Vue中文入门指南(四)

    在本文中,您将通过构建一个包含 Grid.DropDownList.Window 和设计主题的小应用程序来学习如何使用Kendo UI for Vue组件. Kendo UI最新官方正式版下载 7. ...

最新文章

  1. 广州限购后首场车展明日开幕
  2. Spark RDD/Core 编程 API入门系列之动手实战和调试Spark文件操作、动手实战操作搜狗日志文件、搜狗日志文件深入实战(二)...
  3. Java内部类详解(使用场景和好处、相关内部类的笔试面试题)
  4. QML基础类型之point
  5. android动态权限封装,Android 动态权限申请的封装
  6. android基础知识(2)
  7. 数据库常用增删改查语句
  8. cadence Virtuoso ADE原理图AnalogLib库中的switch使用
  9. Java UTC(GMT)时间和Date时间转换
  10. 亲测可用[转]官方17ce老毛子Padavan华硕固件router插件安装方法|集成不占空间k1斐讯k2...
  11. 分别求数组中奇数和偶数的和
  12. 中科大计算机辅助图形实验室,Prof. Ligang Liu at USTC (中科大刘利刚教授)
  13. 软件测试用例执行日报,软件测试之测试用例执行.docx
  14. 从金山毒霸看这个社会的“底线”
  15. 如何解析xml报文获取数据集合
  16. psd 将分组合并导出png图片
  17. Dex文件格式扫描器:特征API的检测和扫描(小工具一枚)
  18. RISC-V向量扩展指令(一)
  19. IPv6下的DAD检测
  20. Delphi下实现鼠标自动点击器

热门文章

  1. 尚硅谷RabbitMQ学习笔记(上)
  2. python入门经典100题
  3. 青岛鑫江东方城购物中心远程预付费电能管理系统的应用
  4. matlab cplex 死机,matlab Cplex使用
  5. python3 根据种子文件torrent获取磁力链接
  6. 如何无信用卡申请谷歌云
  7. 数字图像处理与机器视觉,机器视觉算法与应用 pdf电子版
  8. 手柄游戏之《恶魔城暗影之王》推荐
  9. mac vulkan_基于 mac 的 ncnn vulkan iOS集成参考
  10. qtouch跨平台组态软件