0%

eBPF 编程指北

在前几篇文章介绍了 eBPF 的基本概念eBPF Map 操作 等基础知识后,本文将真正 Get hands dirty,介绍 eBPF 编程相关的环境准备、编程规范、开发工具链等内容,文中所有的代码都可以在我的 Github 中找到。

开发环境

这里以 Ubuntu 20.04 为例构建 eBPF 开发环境:

1
2
3
$ 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 build-essential git make libelf-dev clang llvm strace tar bpfcc-tools linux-headers-$(uname -r) gcc-multilib flex bison libssl-dev -y

主流的发行版在对 LLVM 打包的时候就默认启用了 BPF 后端,因此,在大部分发行版上安 装 clang 和 llvm 就可以将 C 代码编译为 BPF 对象文件了。

典型的工作流是:

  1. 用 C 编写 BPF 程序
  2. 用 LLVM 将 C 程序编译成对象文件(ELF)
  3. 用户空间 BPF ELF 加载器(例如 iproute2)解析对象文件
  4. 加载器通过 bpf() 系统调用将解析后的对象文件注入内核
  5. 内核验证 BPF 指令,然后对其执行即时编译(JIT),返回程序的一个新文件描述符
  6. 利用文件描述符 attach 到内核子系统(例如网络子系统)

某些子系统还支持将 BPF 程序 offload 到硬件(例如网卡)。

查看 LLVM 支持的 BPF target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ llc --version
LLVM (http://llvm.org/):
LLVM version 10.0.0

Optimized build.
Default target: x86_64-pc-linux-gnu
Host CPU: skylake

Registered Targets:
# ...
bpf - BPF (host endian)
bpfeb - BPF (big endian)
bpfel - BPF (little endian)
# ...

默认情况下,bpf target 使用编译时所在的 CPU 的大小端格式,即,如果 CPU 是小端,BPF 程序就会用小端表示;如果 CPU 是大端,BPF 程序就是大端。这也和 BPF 的运行时行为相匹配,这样的行为比较通用,而且大小端格式一致可以避免一些因为格式导致的架构劣势。BPF 程序可以在大端节点上编译,在小端节点上运行,或者相反,因此对于交叉编译, 引入了两个新目标 bpfebbpfel。注意前端也需要以相应的大小端方式运行。

在不存在大小端混用的场景下,建议使用 bpf target。例如,在 x86_64 平台上(小端 ),指定 bpfbpfel 会产生相同的结果,因此触发编译的脚本不需要感知到大小端 。

下面是一个最小的完整 XDP 程序,实现丢弃包的功能(xdp-example.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <linux/bpf.h>

#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return XDP_DROP;
}

char __license[] __section("license") = "GPL";

用下面的命令编译并加载到内核:

1
2
$ clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o
$ ip link set dev em1 xdp obj xdp-example.o

编程限制

用 C 语言编写 BPF 程序不同于用 C 语言做应用开发,有一些陷阱需要注意。本节列出了 二者的一些不同之处。

所有函数都需要内联(inlined)、没有函数调用(对于老版本 LLVM)或共享库调用

BPF 不支持共享库(Shared libraries)。但是,可以将常规的库代码(library code)放到头文件中,然后在主程序中 include 这些头文件,例如 Cilium 就大量使用了这种方式 (可以查看 bpf/lib/ 文件夹)。另外,也可以 include 其他的一些头文件,例如内核 或其他库中的头文件,复用其中的静态内联函数(static inline functions)或宏/定义( macros / definitions)。

内核 4.16+ 和 LLVM 6.0+ 之后已经支持 BPF-to-BPF 函数调用。对于任意给定的程序片段 ,在此之前的版本只能将全部代码编译和内联成一个扁平的 BPF 指令序列(a flat sequence of BPF instructions)。在这种情况下,最佳实践就是为每个库函数都使用一个 像 __inline 一样的注解(annotation ),下面的例子中会看到。推荐使用 always_inline,因为编译器可能会对只注解为 inline 的长函数仍然做 uninline 操 作。

如果是后者,LLVM 会在 ELF 文件中生成一个重定位项(relocation entry),BPF ELF 加载器(例如 iproute2)无法解析这个重定位项,因此会产生一条错误,因为对加载器 来说只有 BPF maps 是合法的、能够处理的重定位项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <linux/bpf.h>

#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline \
inline __attribute__((always_inline))
#endif

static __inline int foo(void)
{
return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}

char __license[] __section("license") = "GPL";

多个程序可以放在同一 C 文件中的不同 section

BPF C 程序大量使用 section annotations。一个 C 文件典型情况下会分为 3 个或更 多个 section。BPF ELF 加载器利用这些名字来提取和准备相关的信息,以通过 bpf()系 统调用加载程序和 maps。例如,查找创建 map 所需的元数据和 BPF 程序的 license 信息 时,iproute2 会分别使用 mapslicense 作为默认的 section 名字。注意在程序 创建时 license section 也会加载到内核,如果程序使用的是兼容 GPL 的协议,这些信 息就可以启用那些 GPL-only 的辅助函数,例如 bpf_ktime_get_ns()bpf_probe_read()

其余的 section 名字都是和特定的 BPF 程序代码相关的,例如,下面经过修改之后的代码 包含两个程序 section:ingressegress。这个非常简单的示例展示了不同 section (这里是 ingressegress)之间可以共享 BPF map 和常规的静态内联辅助函数( 例如 account_data())。

示例程序

这里将原来的 xdp-example.c 修改为 tc-example.c,然后用 tc 命令加载,attach 到 一个 netdevice 的 ingress 或 egress hook。该程序对传输的字节进行计数,存储在一 个名为 acc_map 的 BPF map 中,这个 map 有两个槽(slot),分别用于 ingress hook 和 egress hook 的流量统计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <stdint.h>
#include <iproute2/bpf_elf.h>

#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline \
inline __attribute__((always_inline))
#endif

#ifndef lock_xadd
# define lock_xadd(ptr, val) \
((void)__sync_fetch_and_add(ptr, val))
#endif

#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...) \
(*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

static void *BPF_FUNC(map_lookup_elem, void *map, const void *key);

struct bpf_elf_map acc_map __section("maps") = {
.type = BPF_MAP_TYPE_ARRAY,
.size_key = sizeof(uint32_t),
.size_value = sizeof(uint32_t),
.pinning = PIN_GLOBAL_NS,
.max_elem = 2,
};

static __inline int account_data(struct __sk_buff *skb, uint32_t dir)
{
uint32_t *bytes;

bytes = map_lookup_elem(&acc_map, &dir);
if (bytes)
lock_xadd(bytes, skb->len);

return TC_ACT_OK;
}

__section("ingress")
int tc_ingress(struct __sk_buff *skb)
{
return account_data(skb, 0);
}

__section("egress")
int tc_egress(struct __sk_buff *skb)
{
return account_data(skb, 1);
}

char __license[] __section("license") = "GPL";
其他程序说明

这个例子还展示了其他一些很有用的东西,在开发过程中要注意。

首先,include 了内核头文件、标准 C 头文件和一个特定的 iproute2 头文件 iproute2/bpf_elf.h,后者定义了struct bpf_elf_map。iproute2 有一个通用的 BPF ELF 加载器,因此 struct bpf_elf_map的定义对于 XDP 和 tc 类型的程序是完全一样的 。

其次,程序中每条 struct bpf_elf_map 记录(entry)定义一个 map,这个记录包含了生成一 个(ingress 和 egress 程序需要用到的)map 所需的全部信息(例如 key/value 大 小)。这个结构体的定义必须放在 maps section,这样加载器才能找到它。可以用这个 结构体声明很多名字不同的变量,但这些声明前面必须加上 __section("maps") 注解。

结构体 struct bpf_elf_map 是特定于 iproute2 的。不同的 BPF ELF 加载器有不同的格式,例如,内核源码树中的 libbpf(主要是 perf 在用)就有一个不同的规范 (结构体定义)。iproute2 保证 struct bpf_elf_map 的后向兼容性。Cilium 采用的 是 iproute2 模型

另外,这个例子还展示了 BPF 辅助函数是如何映射到 C 代码以及如何被使用的。这里首先定义了 一个宏 BPF_FUNC,接受一个函数名 NAME 以及其他的任意参数。然后用这个宏声明了一 个 NAMEmap_lookup_elem 的函数,经过宏展开后会变成 BPF_FUNC_map_lookup_elem 枚举值,后者以辅助函数的形式定义在 uapi/linux/bpf.h 。当随后这个程序被加载到内核时,校验器会检查传入的参数是否是期望的类型,如果是, 就将辅助函数调用重新指向(re-points)某个真正的函数调用。另外, map_lookup_elem() 还展示了 map 是如何传递给 BPF 辅助函数的。这里,maps section 中的 &acc_map 作为第一个参数传递给 map_lookup_elem()

由于程序中定义的数组 map (array map)是全局的,因此计数时需要使用原子操作,这里 是使用了 lock_xadd()。LLVM 将 __sync_fetch_and_add() 作为一个内置函数映射到 BPF 原子加指令,即 BPF_STX | BPF_XADD | BPF_W(for word sizes)。

另外,struct bpf_elf_map 中的 .pinning 字段初始化为 PIN_GLOBAL_NS,这意味 着 tc 会将这个 map 作为一个节点(node)钉(pin)到 BPF 伪文件系统。默认情况下, 这个变量 acc_map 将被钉到 /sys/fs/bpf/tc/globals/acc_map

  • 如果指定的是 PIN_GLOBAL_NS,那 map 会被放到 /sys/fs/bpf/tc/globals/globals 是一个跨对象文件的全局命名空间。
  • 如果指定的是 PIN_OBJECT_NS,tc 将会为对象文件创建一个它的本地目录(local to the object file)。例如,只要指定了 PIN_OBJECT_NS,不同的 C 文件都可以像上 面一样定义各自的 acc_map。在这种情况下,这个 map 会在不同 BPF 程序之间共享。
  • PIN_NONE 表示 map 不会作为节点(node)钉(pin)到 BPF 文件系统,因此当 tc 退 出时这个 map 就无法从用户空间访问了。同时,这还意味着独立的 tc 命令会创建出独 立的 map 实例,因此后执行的 tc 命令无法用这个 map 名字找到之前被钉住的 map。 在路径 /sys/fs/bpf/tc/globals/acc_map 中,map 名是 acc_map

因此,在加载 ingress 程序时,tc 会先查找这个 map 在 BPF 文件系统中是否存在,不 存在就创建一个。创建成功后,map 会被钉(pin)到 BPF 文件系统,因此当 egress 程 序通过 tc 加载之后,它就会发现这个 map 存在了,接下来会复用这个 map 而不是再创建 一个新的。在 map 存在的情况下,加载器还会确保 map 的属性(properties)是匹配的, 例如 key/value 大小等等。

就像 tc 可以从同一 map 获取数据一样,第三方应用也可以用 bpf 系统调用中的 BPF_OBJ_GET 命令创建一个指向某个 map 实例的新文件描述符,然后用这个描述 符来查看/更新/删除 map 中的数据。

通过 clang 编译和 iproute2 加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ clang -O2 -Wall -target bpf -c tc-example.c -o tc-example.o

$ tc qdisc add dev em1 clsact
$ tc filter add dev em1 ingress bpf da obj tc-example.o sec ingress
$ tc filter add dev em1 egress bpf da obj tc-example.o sec egress

$ tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

$ tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[egress] direct-action id 2 tag b2fd5adc0f262714

$ mount | grep bpf
sysfs on /sys/fs/bpf type sysfs (rw,nosuid,nodev,noexec,relatime,seclabel)
bpf on /sys/fs/bpf type bpf (rw,relatime,mode=0700)

$ tree /sys/fs/bpf/
/sys/fs/bpf/
+-- ip -> /sys/fs/bpf/tc/
+-- tc
| +-- globals
| +-- acc_map
+-- xdp -> /sys/fs/bpf/tc/

4 directories, 1 file

以上步骤指向完成后,当包经过 em 设备时,BPF map 中的计数器就会递增。

不允许全局变量

出于第 1 条中提到的原因(只支持 BPF maps 重定位,译者注),BPF 不能使用全局变量 ,而常规 C 程序中是可以的。

但是,我们有 间接的方式 实现全局变量的效果:BPF 程序可以使用一个 BPF_MAP_TYPE_PERCPU_ARRAY 类型的、只有一个槽(slot)的、可以存放任意类型数据( arbitrary value size)的 BPF map。这可以实现全局变量的效果原因是BPF 程序在执行期间不会被内核抢占,因此可以用单个 map entry 作为一个 scratch buffer 使用,存储临时数据,例如扩展 BPF 栈的限制(512 字节)。这种方式在尾调用中也是可 以工作的,因为尾调用执行期间也不会被抢占

另外,如果要在不同次 BPF 程序执行之间保持状态,使用常规的 BPF map 就可以了。

不支持常量字符串或数组(const strings or arrays)

BPF C 程序中不允许定义 const 字符串或其他数组,原因和第 1 点及第 3 点一样,即 ,ELF 文件中生成的 重定位项(relocation entries)会被加载器拒绝,因为不符合加载器的 ABI(加载器也无法修复这些重定位项,因为这需要对已经编译好的 BPF 序列进行大范围的重写)。

将来 LLVM 可能会检测这种情况,提前将错误抛给用户。现在可以用下面的辅助函数来作为短期解决方式(work around):

1
2
3
4
5
6
7
8
9
static void BPF_FUNC(trace_printk, const char *fmt, int fmt_size, ...);

#ifndef printk
# define printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
})
#endif

有了上面的定义,程序就可以自然地使用这个宏,例如 printk("skb len:%u\n", skb->len);输出会写到 trace pipe,用 tc exec bpf dbg 命令可以获取这些打印的消息。

不过,使用 trace_printk() 辅助函数也有一些不足,因此不建议在生产环境使用。每次 调用这个辅助函数时,常量字符串(例如 "skb len:%u\n")都需要加载到 BPF 栈,但这 个辅助函数最多只能接受 5 个参数,因此使用这个函数输出信息时只能传递三个参数。

因此,虽然这个辅助函数对快速调试很有用,但(对于网络程序)还是推荐使用 skb_event_output()xdp_event_output() 辅助函数。这两个函数接受从 BPF 程序 传递自定义的结构体类型参数,然后将参数以及可选的包数据(packet sample)放到 perf event ring buffer。例如,Cilium monitor 利用这些辅助函数实现了一个调试框架,以及 在发现违反网络策略时发出通知等功能。这些函数通过一个无锁的、内存映射的、 per-CPU 的 perf ring buffer 传递数据,因此要远快于 trace_printk()

使用 LLVM 内置的函数做内存操作

因为 BPF 程序除了调用 BPF 辅助函数之外无法执行任何函数调用,因此常规的库代码必须 实现为内联函数。另外,LLVM 也提供了一些可以用于特定大小(这里是 n)的内置函数 ,这些函数永远都会被内联:

1
2
3
4
5
6
7
8
9
10
11
#ifndef memset
# define memset(dest, chr, n) __builtin_memset((dest), (chr), (n))
#endif

#ifndef memcpy
# define memcpy(dest, src, n) __builtin_memcpy((dest), (src), (n))
#endif

#ifndef memmove
# define memmove(dest, src, n) __builtin_memmove((dest), (src), (n))
#endif

LLVM 后端中的某个问题会导致内置的 memcmp() 有某些边界场景下无法内联,因此在这 个问题解决之前不推荐使用这个函数。

(目前还)不支持循环

内核中的 BPF 校验器除了对其他的控制流进行图验证(graph validation)之外,还会对 所有程序路径执行深度优先搜索(depth first search),确保其中不存在循环。这样做的目的是确保程序永远会结束。

但可以使用 #pragma unroll 指令实现常量的、不超过一定上限的循环。下面是一个例子 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#pragma unroll
for (i = 0; i < IPV6_MAX_HEADERS; i++) {
switch (nh) {
case NEXTHDR_NONE:
return DROP_INVALID_EXTHDR;
case NEXTHDR_FRAGMENT:
return DROP_FRAG_NOSUPPORT;
case NEXTHDR_HOP:
case NEXTHDR_ROUTING:
case NEXTHDR_AUTH:
case NEXTHDR_DEST:
if (skb_load_bytes(skb, l3_off + len, &opthdr, sizeof(opthdr)) < 0)
return DROP_INVALID;

nh = opthdr.nexthdr;
if (nh == NEXTHDR_AUTH)
len += ipv6_authlen(&opthdr);
else
len += ipv6_optlen(&opthdr);
break;
default:
*nexthdr = nh;
return len;
}
}

另外一种实现循环的方式是:用一个 BPF_MAP_TYPE_PERCPU_ARRAY map 作为本地 scratch space(存储空间),然后用尾调用的方式调用函数自身。虽然这种方式更加动态,但目前最大只支持 32 层嵌套调用。

将来 BPF 可能会提供一些更加原生、但有一定限制的循环。

尾调用的用途

尾调用能够从一个程序调到另一个程序,提供了在运行时(runtime)原子地改变程序行为的灵活性。为了选择要跳转到哪个程序,尾调用使用了 程序数组 mapBPF_MAP_TYPE_PROG_ARRAY),将 map 及其索引(index)传递给将要跳转到的程序。跳转动作一旦完成,就没有办法返回到原来的程序;但如果给定的 map 索引中没有程序(无法跳转),执行会继续在原来的程序中执行。

例如,可以用尾调用实现解析器的不同阶段,可以在运行时(runtime)更新这些阶段的新解析特性。

尾调用的另一个用处是事件通知,例如,Cilium 可以在运行时(runtime)开启或关闭丢弃包的通知(packet drop notifications),其中对 skb_event_output() 的调用就是发 生在被尾调用的程序中。因此,在常规情况下,执行的永远是从上到下的路径( fall-through path),当某个程序被加入到相关的 map 索引之后,程序就会解析元数据, 触发向用户空间守护进程(user space daemon)发送事件通知。

程序数组 map 非常灵活, map 中每个索引对应的程序可以实现各自的动作(actions)。 例如,attach 到 tc 或 XDP 的 root 程序执行初始的、跳转到程序数组 map 中索引为 0 的程序,然后执行流量抽样(traffic sampling),然后跳转到索引为 1 的程序,在那个 程序中应用防火墙策略,然后就可以决定是丢地包还是将其送到索引为 2 的程序中继续 处理,在后者中,可能可能会被 mangle 然后再次通过某个接口发送出去。在程序数据 map 之中是可以随意跳转的。当达到尾调用的最大调用深度时,内核最终会执行 fall-through path。

一个使用尾调用的最小程序示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
[...]

#ifndef __stringify
# define __stringify(X) #X
#endif

#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif

#ifndef __section_tail
# define __section_tail(ID, KEY) \
__section(__stringify(ID) "/" __stringify(KEY))
#endif

#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...) \
(*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

#define BPF_JMP_MAP_ID 1

static void BPF_FUNC(tail_call, struct __sk_buff *skb, void *map,
uint32_t index);

struct bpf_elf_map jmp_map __section("maps") = {
.type = BPF_MAP_TYPE_PROG_ARRAY,
.id = BPF_JMP_MAP_ID,
.size_key = sizeof(uint32_t),
.size_value = sizeof(uint32_t),
.pinning = PIN_GLOBAL_NS,
.max_elem = 1,
};

__section_tail(JMP_MAP_ID, 0)
int looper(struct __sk_buff *skb)
{
printk("skb cb: %u\n", skb->cb[0]++);
tail_call(skb, &jmp_map, 0);
return TC_ACT_OK;
}

__section("prog")
int entry(struct __sk_buff *skb)
{
skb->cb[0] = 0;
tail_call(skb, &jmp_map, 0);
return TC_ACT_OK;
}

char __license[] __section("license") = "GPL";

加载这个示例程序时,tc 会创建其中的程序数组(jmp_map 变量),并将其钉(pin)到 BPF 文件系统中全局命名空间下名为的 jump_map 位置。而且,iproute2 中的 BPF ELF 加载器也会识别出标记为 __section_tail() 的 section。 jmp_mapid 字段会 跟__section_tail() 中的 id 字段(这里初始化为常量 JMP_MAP_ID)做匹配,因此程 序能加载到用户指定的索引(位置),在上面的例子中这个索引是 0。然后,所有的尾调用 section 将会被 iproute2 加载器处理,关联到 map 中。这个机制并不是 tc 特有的, iproute2 支持的其他 BPF 程序类型(例如 XDP、lwt)也适用。

生成的 elf 包含 section headers,描述 map id 和 map 内的条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ llvm-objdump -S --no-show-raw-insn prog_array.o | less
prog_array.o: file format ELF64-BPF

Disassembly of section 1/0:
looper:
0: r6 = r1
1: r2 = *(u32 *)(r6 + 48)
2: r1 = r2
3: r1 += 1
4: *(u32 *)(r6 + 48) = r1
5: r1 = 0 ll
7: call -1
8: r1 = r6
9: r2 = 0 ll
11: r3 = 0
12: call 12
13: r0 = 0
14: exit
Disassembly of section prog:
entry:
0: r2 = 0
1: *(u32 *)(r1 + 48) = r2
2: r2 = 0 ll
4: r3 = 0
5: call 12
6: r0 = 0
7: exi

在这个例子中,section 1/0 表示 looper() 函数位于 map 1 中,在 map 1 内的 位置是 0

被钉住(pinned)map 可以被用户空间应用(例如 Cilium daemon)读取,也可以被 tc 本 身读取,因为 tc 可能会用新的程序替换原来的程序,此时可能需要读取 map 内容。 更新是原子的。

tc 执行尾调用 map 更新(tail call map updates)的例子

1
$ tc exec bpf graft m:globals/jmp_map key 0 obj new.o sec foo

如果 iproute2 需要更新被钉住(pinned)的程序数组,可以使用 graft 命令。上面的 例子中指向的是 globals/jmp_map,那 tc 将会用一个新程序更新位于 index/key 为 0 的 map, 这个新程序位于对象文件 new.o 中的 foo section。

BPF 最大栈空间 512 字节

BPF 程序的最大栈空间是 512 字节,在使用 C 语言实现 BPF 程序时需要考虑到这一点。 但正如在第 3 点中提到的,可以通过一个只有一条记录(single entry)的 BPF_MAP_TYPE_PERCPU_ARRAY map 来绕过这限制,增大 scratch buffer 空间。

尝试使用 BPF 内联汇编

LLVM 6.0 以后支持 BPF 内联汇编,在某些场景下可能会用到。下面这个玩具示例程序( 没有实际意义)展示了一个 64 位原子加操作。

由于文档不足,要获取更多信息和例子,目前可能只能参考 LLVM 源码中的 lib/Target/BPF/BPFInstrInfo.td 以及 test/CodeGen/BPF/。测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <linux/bpf.h>

#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif

__section("prog")
int xdp_test(struct xdp_md *ctx)
{
__u64 a = 2, b = 3, *c = &a;
/* just a toy xadd example to show the syntax */
asm volatile("lock *(u64 *)(%0+0) += %1" : "=r"(c) : "r"(b), "0"(c));
return a;
}

char __license[] __section("license") = "GPL";

上面的程序会被编译成下面的 BPF 指令序列:

1
2
3
4
5
6
7
8
9
10
11
Verifier analysis:

0: (b7) r1 = 2
1: (7b) *(u64 *)(r10 -8) = r1
2: (b7) r1 = 3
3: (bf) r2 = r10
4: (07) r2 += -8
5: (db) lock *(u64 *)(r2 +0) += r1
6: (79) r0 = *(u64 *)(r10 -8)
7: (95) exit
processed 8 insns (limit 131072), stack depth 8

#pragma pack 禁止结构体填充(struct padding)

现代编译器默认会对数据结构进行内存对齐(align),以实现更加高效的访问。结构体成员会被对齐到数倍于其自身大小的内存位置,不足的部分会进行填充(padding),因此结构体最终的大小可能会比预想中大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct called_info {
u64 start; // 8-byte
u64 end; // 8-byte
u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info
// 0x0(0) 0x8(8)
// ↓________________________↓
// | start (8) |
// |________________________|
// | end (8) |
// |________________________|
// | sector(4) | PADDING | <= address aligned to 8
// |____________|___________| with 4-byte PADDING.

内核中的 BPF 校验器会检查栈边界(stack boundary),BPF 程序不会访问栈边界外的空 间,或者是未初始化的栈空间。如果将结构体中填充出来的内存区域作为一个 map 值进行 访问,那调用 bpf_prog_load() 时就会报 invalid indirect read from stack 错误。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct called_info {
u64 start;
u64 end;
u32 sector;
};

struct bpf_map_def SEC("maps") called_info_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(long),
.value_size = sizeof(struct called_info),
.max_entries = 4096,
};

SEC("kprobe/submit_bio")
int submit_bio_entry(struct pt_regs *ctx)
{
char fmt[] = "submit_bio(bio=0x%lx) called: %llu\n";
u64 start_time = bpf_ktime_get_ns();
long bio_ptr = PT_REGS_PARM1(ctx);
struct called_info called_info = {
.start = start_time,
.end = 0,
.bi_sector = 0
};

bpf_map_update_elem(&called_info_map, &bio_ptr, &called_info, BPF_ANY);
bpf_trace_printk(fmt, sizeof(fmt), bio_ptr, start_time);
return 0;
}

// On bpf_load_program
bpf_load_program() err=13
0: (bf) r6 = r1
...
19: (b7) r1 = 0
20: (7b) *(u64 *)(r10 -72) = r1
21: (7b) *(u64 *)(r10 -80) = r7
22: (63) *(u32 *)(r10 -64) = r1
...
30: (85) call bpf_map_update_elem#2
invalid indirect read from stack off -80+20 size 24

bpf_prog_load() 中会调用 BPF 校验器的 bpf_check() 函数,后者会调用 check_func_arg() -> check_stack_boundary() 来检查栈边界。从上面的错误可以看出 ,struct called_info 被编译成 24 字节,错误信息提示从 +20 位置读取数据是“非 法的间接读取”(invalid indirect read)。从我们更前面给出的内存布局图中可以看到, 地址 0x14(20) 是填充(PADDING )开始的地方。这里再次画出内存布局图以方便对比:

1
2
3
4
5
// Actual compiled composition of struct called_info
// 0x10(16) 0x14(20) 0x18(24)
// ↓____________↓___________↓
// | sector(4) | PADDING | <= address aligned to 8
// |____________|___________| with 4-byte PADDING.

check_stack_boundary() 会遍历每一个从开始指针出发的 access_size (24) 字节, 确保它们位于栈边界内部,并且栈内的所有元素都初始化了。因此填充的部分是不允许使用 的,所以报了 “invalid indirect read from stack” 错误。要避免这种错误,需要将结 构体中的填充去掉。这是通过 #pragma pack(n) 原语实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#pragma pack(4)
struct called_info {
u64 start; // 8-byte
u64 end; // 8-byte
u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 20-byte

// Actual compiled composition of packed struct called_info
// 0x0(0) 0x8(8)
// ↓________________________↓
// | start (8) |
// |________________________|
// | end (8) |
// |________________________|
// | sector(4) | <= address aligned to 4
// |____________| with no PADDING.

struct called_info 前面加上 #pragma pack(4) 之后,编译器会以 4 字节为单位 进行对齐。上面的图可以看到,这个结构体现在已经变成 20 字节大小,没有填充了。

但是,去掉填充也是有弊端的。例如,编译器产生的代码没有原来优化的好。去掉填充之后 ,处理器访问结构体时触发的是非对齐访问(unaligned access),可能会导致性能下降。 并且,某些架构上的校验器可能会直接拒绝非对齐访问。

不过,我们也有一种方式可以避免产生自动填充:手动填充。我们简单地在结构体中加入一 个 u32 pad 成员来显式填充,这样既避免了自动填充的问题,又解决了非对齐访问的问 题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct called_info {
u64 start; // 8-byte
u64 end; // 8-byte
u32 sector; // 4-byte
u32 pad; // 4-byte
}; // size of 24-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info with explicit padding
// 0x0(0) 0x8(8)
// ↓________________________↓
// | start (8) |
// |________________________|
// | end (8) |
// |________________________|
// | sector(4) | pad (4) | <= address aligned to 8
// |____________|___________| with explicit PADDING.

通过未验证的引用(invalidated references)访问包数据

某些网络相关的 BPF 辅助函数,例如 bpf_skb_store_bytes,可能会修改包的大小。校验器无法跟踪这类改动,因此它会将所有之前对包数据的引用都视为过期的(未验证的) 。因此,为避免程序被校验器拒绝,在访问数据之外需要先更新相应的引用。

来看下面的例子:

1
2
3
4
5
6
7
struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

if (ip4->protocol == IPPROTO_TCP) {
// do something
}

校验器会拒绝这段代码,因为它认为在 skb_store_bytes 执行之后,引用 ip4->protocol 是未验证的(invalidated):

1
2
3
4
5
6
7
8
9
10
R1=pkt_end(id=0,off=0,imm=0) R2=pkt(id=0,off=34,r=34,imm=0) R3=inv0
R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=4294967295,var_off=(0x0; 0xffffffff))
R8=inv4294967162 R9=pkt(id=0,off=0,r=34,imm=0) R10=fp0,call_-1
...
18: (85) call bpf_skb_store_bytes#9
19: (7b) *(u64 *)(r10 -56) = r7
R0=inv(id=0) R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=2,var_off=(0x0; 0x3))
R8=inv4294967162 R9=inv(id=0) R10=fp0,call_-1 fp-48=mmmm???? fp-56=mmmmmmmm
21: (61) r1 = *(u32 *)(r9 +23)
R9 invalid mem access 'inv'

要解决这个问题,必须更新(重新计算) ip4 的地址:

1
2
3
4
5
6
7
8
9
struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

ip4 = (struct iphdr *) skb->data + ETH_HLEN;

if (ip4->protocol == IPPROTO_TCP) {
// do something
}

开发工具链

libbpf

bpftool

bpftool 是查看和调试 BPF 程序的主要工具。它随内核一起开发,在内核中的路径是 tools/bpf/bpftool/

这个工具可以完成

  1. dump 当前已经加载到系统中的所有 BPF 程序和 map
  2. 列出和指定程序相关的所有 BPF map
  3. dump 整个 map 中的 key/value 对
  4. 查看、更新、删除特定 key
  5. 查看给定 key 的相邻 key(neighbor key)

要执行这些操作可以指定 BPF 程序、map ID,或者指定 BPF 文件系统中程序或 map 的位 置。另外,这个工具还提供了将 map 或程序钉(pin)到 BPF 文件系统的功能。

查看系统当前已经加载的 BPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ bpftool prog
398: sched_cls tag 56207908be8ad877
loaded_at Apr 09/16:24 uid 0
xlated 8800B jited 6184B memlock 12288B map_ids 18,5,17,14
399: sched_cls tag abc95fb4835a6ec9
loaded_at Apr 09/16:24 uid 0
xlated 344B jited 223B memlock 4096B map_ids 18
400: sched_cls tag afd2e542b30ff3ec
loaded_at Apr 09/16:24 uid 0
xlated 1720B jited 1001B memlock 4096B map_ids 17
401: sched_cls tag 2dbbd74ee5d51cc8
loaded_at Apr 09/16:24 uid 0
xlated 3728B jited 2099B memlock 4096B map_ids 17
[...]

类似地,查看所有的 active maps:

1
2
3
4
5
6
7
8
9
10
11
12
$ bpftool map
5: hash flags 0x0
key 20B value 112B max_entries 65535 memlock 13111296B
6: hash flags 0x0
key 20B value 20B max_entries 65536 memlock 7344128B
7: hash flags 0x0
key 10B value 16B max_entries 8192 memlock 790528B
8: hash flags 0x0
key 22B value 28B max_entries 8192 memlock 987136B
9: hash flags 0x0
key 20B value 8B max_entries 512000 memlock 49352704B
[...]

bpftool 的每个命令都提供了以 json 格式打印的功能,在命令末尾指定 --json 就行了。 另外,--pretty 会使得打印更加美观,看起来更清楚。

1
$ bpftool prog --json --pretty

要 dump 特定 BPF 程序的 post-verifier BPF 指令镜像(instruction image),可以先 从查看一个具体程序开始,例如,查看 attach 到 tc ingress hook 上的程序:

1
2
3
4
$ tc filter show dev cilium_host egress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 bpf_host.o:[from-netdev] \
direct-action not_in_hw id 406 tag e0362f5bd9163a0a jited

这个程序是从对象文件 bpf_host.o 加载来的,程序位于对象文件的 from-netdev section,程序 ID 为 406。基于以上信息 bpftool 可以提供一些关于这个程序的上层元 数据:

1
2
3
4
$ bpftool prog show id 406
406: sched_cls tag e0362f5bd9163a0a
loaded_at Apr 09/16:24 uid 0
xlated 11144B jited 7721B memlock 12288B map_ids 18,20,8,5,6,14

从上面的输出可以看到:

  • 程序 ID 为 406,类型是 sched_clsBPF_PROG_TYPE_SCHED_CLS),有一个 tage0362f5bd9163a0a(指令序列的 SHA sum)
  • 这个程序被 root uid 0Apr 09/16:24 加载
  • BPF 指令序列有 11,144 bytes 长,JIT 之后的镜像有 7,721 bytes
  • 程序自身(不包括 maps)占用了 12,288 bytes,这部分空间使用的是 uid 0 用户 的配额
  • BPF 程序使用了 ID 为 1820 8 5 614 的 BPF map。可以用这些 ID 进一步 dump map 自身或相关信息

另外,bpftool 可以 dump 出运行中程序的 BPF 指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ bpftool prog dump xlated id 406
0: (b7) r7 = 0
1: (63) *(u32 *)(r1 +60) = r7
2: (63) *(u32 *)(r1 +56) = r7
3: (63) *(u32 *)(r1 +52) = r7
[...]
47: (bf) r4 = r10
48: (07) r4 += -40
49: (79) r6 = *(u64 *)(r10 -104)
50: (bf) r1 = r6
51: (18) r2 = map[id:18] <-- BPF map id 18
53: (b7) r5 = 32
54: (85) call bpf_skb_event_output#5656112 <-- BPF helper call
55: (69) r1 = *(u16 *)(r6 +192)
[...]

如上面的输出所示,bpftool 将指令流中的 BPF map ID、BPF 辅助函数或其他 BPF 程序都 做了关联。

和内核的 BPF 校验器一样,bpftool dump 指令流时复用了同一个使输出更美观的打印程序 (pretty-printer)。

由于程序被 JIT,因此真正执行的是生成的 JIT 镜像(从上面 xlated 中的指令生成的 ),这些指令也可以通过 bpftool 查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ bpftool prog dump jited id 406
0: push %rbp
1: mov %rsp,%rbp
4: sub $0x228,%rsp
b: sub $0x28,%rbp
f: mov %rbx,0x0(%rbp)
13: mov %r13,0x8(%rbp)
17: mov %r14,0x10(%rbp)
1b: mov %r15,0x18(%rbp)
1f: xor %eax,%eax
21: mov %rax,0x20(%rbp)
25: mov 0x80(%rdi),%r9d
[...]

另外,还可以指定在输出中将反汇编之后的指令关联到 opcodes,这个功能主要对 BPF JIT 开发者比较有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ bpftool prog dump jited id 406 opcodes
0: push %rbp
55
1: mov %rsp,%rbp
48 89 e5
4: sub $0x228,%rsp
48 81 ec 28 02 00 00
b: sub $0x28,%rbp
48 83 ed 28
f: mov %rbx,0x0(%rbp)
48 89 5d 00
13: mov %r13,0x8(%rbp)
4c 89 6d 08
17: mov %r14,0x10(%rbp)
4c 89 75 10
1b: mov %r15,0x18(%rbp)
4c 89 7d 18
[...]

同样,也可以将常规的 BPF 指令关联到 opcodes,有时在内核中进行调试时会比较有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ bpftool prog dump xlated id 406 opcodes
0: (b7) r7 = 0
b7 07 00 00 00 00 00 00
1: (63) *(u32 *)(r1 +60) = r7
63 71 3c 00 00 00 00 00
2: (63) *(u32 *)(r1 +56) = r7
63 71 38 00 00 00 00 00
3: (63) *(u32 *)(r1 +52) = r7
63 71 34 00 00 00 00 00
4: (63) *(u32 *)(r1 +48) = r7
63 71 30 00 00 00 00 00
5: (63) *(u32 *)(r1 +64) = r7
63 71 40 00 00 00 00 00
[...]

此外,还可以用 graphviz 以可视化的方式展示程序的基本组成部分。bpftool 提供了一 个 visual dump 模式,这种模式下输出的不是 BPF xlated 指令文本,而是一张点图( dot graph),后者可以转换成 png 格式的图片:

1
2
3
$ bpftool prog dump xlated id 406 visual &> output.dot

$ dot -Tpng output.dot -o output.png

也可以用 dotty 打开生成的点图文件:dotty output.dotbpf_host.o 程序的效果如 下图所示(一部分):

img

注意,xlated 中 dump 出来的指令是经过校验器之后(post-verifier)的 BPF 指令镜 像,即和 BPF 解释器中执行的版本是一样的。

在内核中,校验器会对 BPF 加载器提供的原始指令执行各种重新(rewrite)。一个例子就 是对辅助函数进行内联化(inlining)以提高运行时性能,下面是对一个哈希表查找的优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ bpftool prog dump xlated id 3
0: (b7) r1 = 2
1: (63) *(u32 *)(r10 -4) = r1
2: (bf) r2 = r10
3: (07) r2 += -4
4: (18) r1 = map[id:2] <-- BPF map id 2
6: (85) call __htab_map_lookup_elem#77408 <-+ BPF helper inlined rewrite
7: (15) if r0 == 0x0 goto pc+2 |
8: (07) r0 += 56 |
9: (79) r0 = *(u64 *)(r0 +0) <-+
10: (15) if r0 == 0x0 goto pc+24
11: (bf) r2 = r10
12: (07) r2 += -4
[...]

bpftool 通过 kallsyms 来对辅助函数或 BPF-to-BPF 调用进行关联。因此,确保 JIT 之 后的 BPF 程序暴露到了 kallsyms(bpf_jit_kallsyms),并且 kallsyms 地址是明确的 (否则调用显示的就是 call bpf_unspec#0):

1
2
$ echo 0 > /proc/sys/kernel/kptr_restrict
$ echo 1 > /proc/sys/net/core/bpf_jit_kallsyms

BPF-to-BPF 调用在解释器和 JIT 镜像中也做了关联。对于后者,子程序的 tag 会显示为 调用目标(call target)。在两种情况下,pc+2 都是调用目标的程序计数器偏置( pc-relative offset),表示就是子程序的地址。

1
2
3
4
5
6
$ bpftool prog dump xlated id 1
0: (85) call pc+2#__bpf_prog_run_args32
1: (b7) r0 = 1
2: (95) exit
3: (b7) r0 = 2
4: (95) exit

对应的 JIT 版本:

1
2
3
4
5
6
$ bpftool prog dump xlated id 1
0: (85) call pc+2#bpf_prog_3b185187f1855c4c_F
1: (b7) r0 = 1
2: (95) exit
3: (b7) r0 = 2
4: (95) exit

在尾调用中,内核会将它们映射为同一个指令,但 bpftool 还是会将它们作为辅助函数进 行关联,以方便调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ bpftool prog dump xlated id 2
[...]
10: (b7) r2 = 8
11: (85) call bpf_trace_printk#-41312
12: (bf) r1 = r6
13: (18) r2 = map[id:1]
15: (b7) r3 = 0
16: (85) call bpf_tail_call#12
17: (b7) r1 = 42
18: (6b) *(u16 *)(r6 +46) = r1
19: (b7) r0 = 0
20: (95) exit

$ bpftool map show id 1
1: prog_array flags 0x0
key 4B value 4B max_entries 1 memlock 4096B

map dump 子命令可以 dump 整个 map,它会遍历所有的 map 元素,输出 key/value。

如果 map 中没有可用的 BTF 数据,那 key/value 会以十六进制格式输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ bpftool map dump id 5
key:
f0 0d 00 00 00 00 00 00 0a 66 00 00 00 00 8a d6
02 00 00 00
value:
00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
key:
0a 66 1c ee 00 00 00 00 00 00 00 00 00 00 00 00
01 00 00 00
value:
00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
[...]
Found 6 elements

如果有 BTF 数据,map 就有了关于 key/value 结构体的调试信息。例如,BTF 信息加上 BPF map 以及 iproute2 中的 BPF_ANNOTATE_KV_PAIR() 会产生下面的输出(内核 selftests 中的 test_xdp_noinline.o):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ cat tools/testing/selftests/bpf/test_xdp_noinline.c
[...]
struct ctl_value {
union {
__u64 value;
__u32 ifindex;
__u8 mac[6];
};
};

struct bpf_map_def __attribute__ ((section("maps"), used)) ctl_array = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(struct ctl_value),
.max_entries = 16,
.map_flags = 0,
};
BPF_ANNOTATE_KV_PAIR(ctl_array, __u32, struct ctl_value);

[...]

BPF_ANNOTATE_KV_PAIR() 宏强制每个 map-specific ELF section 包含一个空的 key/value,这样 iproute2 BPF 加载器可以将 BTF 数据关联到这个 section,因此在加载 map 时可用从 BTF 中选择响应的类型。

使用 LLVM 编译,并使用 pahole 基于调试信息产生 BTF:

1
2
3
4
$ clang [...] -O2 -target bpf -g -emit-llvm -c test_xdp_noinline.c -o - |
llc -march=bpf -mcpu=probe -mattr=dwarfris -filetype=obj -o test_xdp_noinline.o

$ pahole -J test_xdp_noinline.o

加载到内核,然后使用 bpftool dump 这个 map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ ip -force link set dev lo xdp obj test_xdp_noinline.o sec xdp-test
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric/id:227 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
[...]

$ bpftool prog show id 227
227: xdp tag a85e060c275c5616 gpl
loaded_at 2018-07-17T14:41:29+0000 uid 0
xlated 8152B not jited memlock 12288B map_ids 381,385,386,382,384,383

$ bpftool map dump id 386
[{
"key": 0,
"value": {
"": {
"value": 0,
"ifindex": 0,
"mac": []
}
}
},{
"key": 1,
"value": {
"": {
"value": 0,
"ifindex": 0,
"mac": []
}
}
},{
[...]

针对 map 的某个 key,也可用通过 bpftool 查看、更新、删除和获取下一个 key(’get next key’)。

BPF sysctls

Linux 内核提供了一些 BPF 相关的 sysctl 配置。

  • /proc/sys/net/core/bpf_jit_enable:启用或禁用 BPF JIT 编译器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    +-------+-------------------------------------------------------------------+
    | Value | Description |
    +-------+-------------------------------------------------------------------+
    | 0 | Disable the JIT and use only interpreter (kernel's default value) |
    +-------+-------------------------------------------------------------------+
    | 1 | Enable the JIT compiler |
    +-------+-------------------------------------------------------------------+
    | 2 | Enable the JIT and emit debugging traces to the kernel log |
    +-------+-------------------------------------------------------------------+

    后面会介绍到,当 JIT 编译设置为调试模式(option 2)时,bpf_jit_disasm 工 具能够处理调试跟踪信息(debugging traces)。

  • /proc/sys/net/core/bpf_jit_harden:启用会禁用 BPF JIT 加固。

    注意,启用加固会降低性能,但能够降低 JIT spraying(喷射)攻击,因为它会禁止 (blind)BPF 程序使用立即值(immediate values)。对于通过解释器处理的程序, 禁用(blind)立即值是没有必要的(也是没有去做的)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    +-------+-------------------------------------------------------------------+
    | Value | Description |
    +-------+-------------------------------------------------------------------+
    | 0 | Disable JIT hardening (kernel's default value) |
    +-------+-------------------------------------------------------------------+
    | 1 | Enable JIT hardening for unprivileged users only |
    +-------+-------------------------------------------------------------------+
    | 2 | Enable JIT hardening for all users |
    +-------+-------------------------------------------------------------------+
  • /proc/sys/net/core/bpf_jit_kallsyms:是否允许 JIT 后的程序作为内核符号暴露到 /proc/kallsyms

    启用后,这些符号可以被 perf 这样的工具识别,使内核在做 stack unwinding 时 能感知到这些地址,例如,在 dump stack trace 的时候,符合名中会包含 BPF 程序 tag(bpf_prog_<tag>)。如果启用了 bpf_jit_harden,这个特性就会自动被禁用 。

    1
    2
    3
    4
    5
    6
    7
    +-------+-------------------------------------------------------------------+
    | Value | Description |
    +-------+-------------------------------------------------------------------+
    | 0 | Disable JIT kallsyms export (kernel's default value) |
    +-------+-------------------------------------------------------------------+
    | 1 | Enable JIT kallsyms export for privileged users only |
    +-------+-------------------------------------------------------------------+
  • /proc/sys/kernel/unprivileged_bpf_disabled:是否允许非特权用户使用 bpf(2) 系统调用。

    内核默认允许非特权用户使用 bpf(2) 系统调用,但一旦将这个开关关闭,必须重启 内核才能再次将其打开。因此这是一个一次性开关(one-time switch),一旦关闭, 不管是应用还是管理员都无法再次修改。这个开关不影响 cBPF 程序(例如 seccomp) 或 传统的没有使用 bpf(2) 系统调用的 socket 过滤器 加载程序到内核。

    1
    2
    3
    4
    5
    6
    7
    +-------+-------------------------------------------------------------------+
    | Value | Description |
    +-------+-------------------------------------------------------------------+
    | 0 | Unprivileged use of bpf syscall enabled (kernel's default value) |
    +-------+-------------------------------------------------------------------+
    | 1 | Unprivileged use of bpf syscall disabled |
    +-------+-------------------------------------------------------------------+

内核测试

Linux 内核自带了一个 selftest 套件,在内核源码树中的路径是 tools/testing/selftests/bpf/

1
2
3
$ cd tools/testing/selftests/bpf/
$ make
$ make run_tests

测试用例包括:

  • BPF 校验器、程序 tags、BPF map 接口和 map 类型的很多测试用例
  • 用于 LLVM 后端的运行时测试,用 C 代码实现
  • 用于解释器和 JIT 的测试,运行在内核,用 eBPF 和 cBPF 汇编实现

JIT Debugging

For JIT developers performing audits or writing extensions, each compile run can output the generated JIT image into the kernel log through:

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

Whenever a new BPF program is loaded, the JIT compiler will dump the output, which can then be inspected with dmesg, for example:

1
2
3
4
5
6
[ 3389.935842] flen=6 proglen=70 pass=3 image=ffffffffa0069c8f from=tcpdump pid=20583
[ 3389.935847] JIT code: 00000000: 55 48 89 e5 48 83 ec 60 48 89 5d f8 44 8b 4f 68
[ 3389.935849] JIT code: 00000010: 44 2b 4f 6c 4c 8b 87 d8 00 00 00 be 0c 00 00 00
[ 3389.935850] JIT code: 00000020: e8 1d 94 ff e0 3d 00 08 00 00 75 16 be 17 00 00
[ 3389.935851] JIT code: 00000030: 00 e8 28 94 ff e0 83 f8 01 75 07 b8 ff ff 00 00
[ 3389.935852] JIT code: 00000040: eb 02 31 c0 c9 c3

flen is the length of the BPF program (here, 6 BPF instructions), and proglen tells the number of bytes generated by the JIT for the opcode image (here, 70 bytes in size). pass means that the image was generated in 3 compiler passes, for example, x86_64 can have various optimization passes to further reduce the image size when possible. image contains the address of the generated JIT image, from and pid the user space application name and PID respectively, which triggered the compilation process. The dump output for eBPF and cBPF JITs is the same format.

In the kernel tree under tools/bpf/, there is a tool called bpf_jit_disasm. It reads out the latest dump and prints the disassembly for further inspection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ ./bpf_jit_disasm
70 bytes emitted from JIT compiler (pass:3, flen:6)
ffffffffa0069c8f + <x>:
0: push %rbp
1: mov %rsp,%rbp
4: sub $0x60,%rsp
8: mov %rbx,-0x8(%rbp)
c: mov 0x68(%rdi),%r9d
10: sub 0x6c(%rdi),%r9d
14: mov 0xd8(%rdi),%r8
1b: mov $0xc,%esi
20: callq 0xffffffffe0ff9442
25: cmp $0x800,%eax
2a: jne 0x0000000000000042
2c: mov $0x17,%esi
31: callq 0xffffffffe0ff945e
36: cmp $0x1,%eax
39: jne 0x0000000000000042
3b: mov $0xffff,%eax
40: jmp 0x0000000000000044
42: xor %eax,%eax
44: leaveq
45: retq

Alternatively, the tool can also dump related opcodes along with the disassembly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ ./bpf_jit_disasm -o
70 bytes emitted from JIT compiler (pass:3, flen:6)
ffffffffa0069c8f + <x>:
0: push %rbp
55
1: mov %rsp,%rbp
48 89 e5
4: sub $0x60,%rsp
48 83 ec 60
8: mov %rbx,-0x8(%rbp)
48 89 5d f8
c: mov 0x68(%rdi),%r9d
44 8b 4f 68
10: sub 0x6c(%rdi),%r9d
44 2b 4f 6c
14: mov 0xd8(%rdi),%r8
4c 8b 87 d8 00 00 00
1b: mov $0xc,%esi
be 0c 00 00 00
20: callq 0xffffffffe0ff9442
e8 1d 94 ff e0
25: cmp $0x800,%eax
3d 00 08 00 00
2a: jne 0x0000000000000042
75 16
2c: mov $0x17,%esi
be 17 00 00 00
31: callq 0xffffffffe0ff945e
e8 28 94 ff e0
36: cmp $0x1,%eax
83 f8 01
39: jne 0x0000000000000042
75 07
3b: mov $0xffff,%eax
b8 ff ff 00 00
40: jmp 0x0000000000000044
eb 02
42: xor %eax,%eax
31 c0
44: leaveq
c9
45: retq
c3

More recently, bpftool adapted the same feature of dumping the BPF JIT image based on a given BPF program ID already loaded in the system (see bpftool section).

For performance analysis of JITed BPF programs, perf can be used as usual. As a prerequisite, JITed programs need to be exported through kallsyms infrastructure.

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

Enabling or disabling bpf_jit_kallsyms does not require a reload of the related BPF programs. Next, a small workflow example is provided for profiling BPF programs. A crafted tc BPF program is used for demonstration purposes, where perf records a failed allocation inside bpf_clone_redirect() helper. Due to the use of direct write, bpf_try_make_head_writable() failed, which would then release the cloned skb again and return with an error message. perf thus records all kfree_skb events.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ tc qdisc add dev em1 clsact
$ tc filter add dev em1 ingress bpf da obj prog.o sec main
$ tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[main] direct-action id 1 tag 8227addf251b7543

$ cat /proc/kallsyms
[...]
ffffffffc00349e0 t fjes_hw_init_command_registers [fjes]
ffffffffc003e2e0 d __tracepoint_fjes_hw_stop_debug_err [fjes]
ffffffffc0036190 t fjes_hw_epbuf_tx_pkt_send [fjes]
ffffffffc004b000 t bpf_prog_8227addf251b7543

$ perf record -a -g -e skb:kfree_skb sleep 60
$ perf script --kallsyms=/proc/kallsyms
[...]
ksoftirqd/0 6 [000] 1004.578402: skb:kfree_skb: skbaddr=0xffff9d4161f20a00 protocol=2048 location=0xffffffffc004b52c
7fffb8745961 bpf_clone_redirect (/lib/modules/4.10.0+/build/vmlinux)
7fffc004e52c bpf_prog_8227addf251b7543 (/lib/modules/4.10.0+/build/vmlinux)
7fffc05b6283 cls_bpf_classify (/lib/modules/4.10.0+/build/vmlinux)
7fffb875957a tc_classify (/lib/modules/4.10.0+/build/vmlinux)
7fffb8729840 __netif_receive_skb_core (/lib/modules/4.10.0+/build/vmlinux)
7fffb8729e38 __netif_receive_skb (/lib/modules/4.10.0+/build/vmlinux)
7fffb872ae05 process_backlog (/lib/modules/4.10.0+/build/vmlinux)
7fffb872a43e net_rx_action (/lib/modules/4.10.0+/build/vmlinux)
7fffb886176c __do_softirq (/lib/modules/4.10.0+/build/vmlinux)
7fffb80ac5b9 run_ksoftirqd (/lib/modules/4.10.0+/build/vmlinux)
7fffb80ca7fa smpboot_thread_fn (/lib/modules/4.10.0+/build/vmlinux)
7fffb80c6831 kthread (/lib/modules/4.10.0+/build/vmlinux)
7fffb885e09c ret_from_fork (/lib/modules/4.10.0+/build/vmlinux)

The stack trace recorded by perf will then show the bpf_prog_8227addf251b7543() symbol as part of the call trace, meaning that the BPF program with the tag 8227addf251b7543 was related to the kfree_skb event, and such program was attached to netdevice em1 on the ingress hook as shown by tc.

内省

Linux 内核围绕 BPF 和 XDP 提供了多种 tracepoints,这些 tracepoints 可以用于进一 步查看系统内部行为,例如,跟踪用户空间程序和 bpf 系统调用的交互。

BPF 相关的 tracepoints:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ perf list | grep bpf:
bpf:bpf_map_create [Tracepoint event]
bpf:bpf_map_delete_elem [Tracepoint event]
bpf:bpf_map_lookup_elem [Tracepoint event]
bpf:bpf_map_next_key [Tracepoint event]
bpf:bpf_map_update_elem [Tracepoint event]
bpf:bpf_obj_get_map [Tracepoint event]
bpf:bpf_obj_get_prog [Tracepoint event]
bpf:bpf_obj_pin_map [Tracepoint event]
bpf:bpf_obj_pin_prog [Tracepoint event]
bpf:bpf_prog_get_type [Tracepoint event]
bpf:bpf_prog_load [Tracepoint event]
bpf:bpf_prog_put_rcu [Tracepoint event]

使用 perf 跟踪 BPF 系统调用(这里用 sleep 只是展示用法,实际场景中应该 执行 tc 等命令):

1
2
3
4
5
6
7
8
9
$ perf record -a -e bpf:* sleep 10
$ perf script
sock_example 6197 [005] 283.980322: bpf:bpf_map_create: map type=ARRAY ufd=4 key=4 val=8 max=256 flags=0
sock_example 6197 [005] 283.980721: bpf:bpf_prog_load: prog=a5ea8fa30ea6849c type=SOCKET_FILTER ufd=5
sock_example 6197 [005] 283.988423: bpf:bpf_prog_get_type: prog=a5ea8fa30ea6849c type=SOCKET_FILTER
sock_example 6197 [005] 283.988443: bpf:bpf_map_lookup_elem: map type=ARRAY ufd=4 key=[06 00 00 00] val=[00 00 00 00 00 00 00 00]
[...]
sock_example 6197 [005] 288.990868: bpf:bpf_map_lookup_elem: map type=ARRAY ufd=4 key=[01 00 00 00] val=[14 00 00 00 00 00 00 00]
swapper 0 [005] 289.338243: bpf:bpf_prog_put_rcu: prog=a5ea8fa30ea6849c type=SOCKET_FILTER

对于 BPF 程序,以上命令会打印出每个程序的 tag。

对于调试,XDP 还有一个 xdp:xdp_exception tracepoint,在抛异常的时候触发:

1
2
$ perf list | grep xdp:
xdp:xdp_exception [Tracepoint event]

异常在下面情况下会触发:

  • BPF 程序返回一个非法/未知的 XDP action code.
  • BPF 程序返回 XDP_ABORTED,这表示非优雅的退出(non-graceful exit)
  • BPF 程序返回 XDP_TX,但发送时发生错误,例如,由于端口没有启用、发送缓冲区已 满、分配内存失败等等

这两类 tracepoint 也都可以通过 attach BPF 程序,用这个 BPF 程序本身来收集进一步 信息,将结果放到一个 BPF map 或以事件的方式发送到用户空间收集器,例如利用 bpf_perf_event_output() 辅助函数。

其他

perf 类似,BPF 程序和 map 占用的内存是算在 RLIMIT_MEMLOCK 中的。可以用 ulimit -l 查看当前锁定到内存中的页面大小。setrlimit() 系统调用的 man page 提 供了进一步的细节。

默认的限制通常导致无法加载复杂的程序或很大的 BPF map,此时 BPF 系统调用会返回 EPERM 错误码。这种情况就需要将限制调大,或者用 ulimit -l unlimited 来临时解 决。RLIMIT_MEMLOCK 主要是针对非特权用户施加限制。根据实际场景不同,为特权 用户设置一个较高的阈值通常是可以接受的。