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
}

开发工具链

BCC

BCC 是 BPF 的编译工具集合,前端提供 Python/Lua API,本身通过 C/C++ 语言实现,集成 LLVM/Clang 对 BPF 程序进行重写、编译和加载等功能, 提供一些更人性化的函数给用户使用。

虽然 BCC 竭尽全力地简化 BPF 程序开发人员的工作,但其“黑魔法” (使用 Clang 前端修改了用户编写的 BPF 程序)使得出现问题时,很难找到问题的所在以及解决方法。必须记住命名约定和自动生成的跟踪点结构 。且由于 libbcc 库内部集成了庞大的 LLVM/Clang 库,使其在使用过程中会遇到一些问题:

  1. 在每个工具启动时,都会占用较高的 CPU 和内存资源来编译 BPF 程序,在系统资源已经短缺的服务器上运行可能引起问题;
  2. 依赖于内核头文件包,必须将其安装在每个目标主机上。即便如此,如果需要内核中未 export 的内容,则需要手动将类型定义复制/粘贴到 BPF 代码中;
  3. 由于 BPF 程序是在运行时才编译,因此很多简单的编译错误只能在运行时检测到,影响开发体验。

随着 BPF CO-RE 的落地,我们可以直接使用内核开发人员提供的 libbpf 库来开发 BPF 程序,开发方式和编写普通 C 用户态程序一样:一次编译生成小型的二进制文件。Libbpf 作为 BPF 程序加载器,接管了重定向、加载、验证等功能,BPF 程序开发者只需要关注 BPF 程序的正确性和性能即可。这种方式将开销降到了最低,且去除了庞大的依赖关系,使得整体开发流程更加顺畅。

性能优化大师 Brendan Gregg 在用 libbpf + BPF CO-RE 转换一个 BCC 工具后给出了性能对比数据:

As my colleague Jason pointed out, the memory footprint of opensnoop as CO-RE is much lower than opensnoop.py. 9 Mbytes for CO-RE vs 80 Mbytes for Python.

我们可以看到在运行时相比 BCC 版本,libbpf + BPF CO-RE 版本节约了近 9 倍的内存开销,这对于物理内存资源已经紧张的服务器来说会更友好。

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.

内省(Introspection)

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() 辅助函数。

其他(Miscellaneous)

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

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

BPF的可移植性和CO-RE (Compile Once – Run Everywhere)

在上一篇文章中介绍了提高socket性能的几个socket选项,其中给出了几个源于内核源码树中的例子,如果选择使用内核树中的Makefile进行编译的话,可能会出现与本地头文件冲突的情况,如重复定义变量,结构体类型不对等错误。这些问题大大影响了BPF程序的可移植性。

本文将介绍BPF可移植性存在的问题,以及如何使用BPF CO-RE(Compile Once – Run Everywhere)解决这些问题。

BPF:最前沿的技术

自BPF成立以来,BPF社区将尽可能简化BPF应用程序的开发作为工作重点,目的是将BPF的使用变得与用户空间的应用一样简单明了。伴随着BPF可编程性的稳步发展,BPF程序的开发也越来越简单。

尽管BPF提升了使用上的便利性,但却忽略了BPF程序开发中的一个方面:可移植性。”BPF可移植性”意味着什么?我们将BPF可移植性定义为成功编写并通过内核验证的一个BPF程序,且跨内核版本可用,无需针对特定的内核重新编译。

本文描述了BPF的可移植性问题以及解决方案:BPF CO-RE(Compile Once – Run Everywhere)。首先会调研BPF本身的可移植性问题,描述为什么这是个问题,以及为什么解决它很重要。然后,我们将介绍解决方案中的高级组件:BPF CO-RE,并简要介绍实现这一目标所需要解决的难题。最后,我们将以各种教程作为结尾,介绍BPF CO-RE方法的用户API,并提供相关示例。

BPF可移植性的问题

BPF程序是用户提供的一部分代码,这些代码会直接注入到内核,一旦经过加载和验证,BPF程序就可以在内核上下文中运行。这些程序运行在内核的内存空间中,并能够访问所有可用的内核内部状态,功能非常强大,这也是为什么BPF技术成功落地到多个应用中的原因。然而,在使用其强大的能力的同时也带来了一些负担:BPF程序无法控制周围内核环境的内存布局,因此必须依赖独立开发,编译和部署的内核。

此外,内核类型和数据结构会不断变化。不同的内核版本会在结构体内部混用结构体字段,甚至会转移到新的内部结构体中。结构体中的字段可能会被重命名或删除,类型可能会改变(变为微兼容或完全不同的类型)。结构体和其他类型可以被重命名,被条件编译(取决于内核配置),或直接从内核版本中移除。

换句话讲,不同内核发布版本中的所有内容都有可能发生变化,BPF应用开发者应该能够预料到这个问题。考虑到不断变化的内核环境,那么该如何利用BPF做有用的事?有如下几点原因:

首先,并不是所有的BPF程序都需要访问内部的内核数据结构。一个例子是opensnoop工具,该工具依靠kprobes /tracepoints来跟踪哪个进程打开了哪些文件,仅需要捕获少量的系统调用就可以工作。由于系统调用提供了稳定的ABI,不会随着内核版本而变化,因此不用考虑这类BPF程序的可移植性。不幸的是,这类应用非常少,且这类应用的功能也会大大受限。

此外,内核内部的BPF机器提供了有限的“稳定接口”集,BPF程序可以依靠这些稳定接口在内核间保持稳定。事实上,不同版本的内核的底层结构和机制是会发生变化的,但BPF提供的稳定接口从用户程序中抽象了这些细节。

例如,网络应用会通过查看少量的sk_buff(即报文数据)中的属性来获得非常有用且通用的信息。为此,BPF校验器提供了一个稳定的__sk_buff 视图(注意前面的下划线),该视图为BPF程序屏蔽了struct sk_buff结构体的变更。所有对__sk_buff字段访问都可以透明地重写为对实际sk_buff的访问(有时非常复杂-在获取最终请求的字段之前需要追踪一堆内部指针)。类似的机制同样适用于不同的BPF程序类型,通过BPF校验器来识别特定类型的BPF上下文。如果使用这类上下文开发BPF程序,就可以不用担心可移植性问题。

但有时候需要访问原始的内核数据(如经常会访问到的 struct task_struct,表示一个进程或线程,包含大量进程信息),此时就只能靠自己了。跟踪,监视和分析应用程序通常是这种情况,这些应用程序是一类非常有用的BPF程序。

在这种情况下,如果某些内核在需要采集的字段(如从struct task_struct开始的第16个字节的偏移处)前添加了一个新的字段,那么此时如何保证不会读取到垃圾数据?如果一个字段重命名了又如何处理(如内核4.6和4.7的thread_structfs字段的名称是不同的)?或者如果需要基于一个内核的两种配置来运行程序,其中一个配置会禁用某些特性,并编译出部分结构(一种常见的场景是解释字段,这些字段是可选的,但如果存在则非常有用)?所有这些条件意味着无法使用本地开发服务器上的头文件编译出一个BPF程序,然后分发到其他系统上运行。这是因为不同内核版本的头文件中的数据的内存布局可能是不同的。

迄今为止,人们编译这类BPF程序会依赖BCC (BPF Compiler Collection)。使用BCC,可以将BPF程序的C代码以字符串的形式嵌入到用户空间的程序中,当程序最终部署并运行在目标主机上后,BCC会唤醒其嵌入的Clang/LLVM,提取本地内核头文件(必须确保已从正确的kernel-devel软件包中将其安装在系统上),并即时进行编译。通过这种方式来确保BPF程序期望的内存布局和主机运行的内核的内存布局是相同的。如果需要处理一些选项和内核编译出来的潜在产物,则可以在自己的源代码中添加#ifdef/#else来适应重命名字段、不同的数值语义或当前配置导致的不可用内容等带来的风险。嵌入的Clang会移除代码中无关的内容,并调整BPF程序代码,以匹配到特定的内核。

这种方式听起来很不错,但实际并非没有缺点:

  • Clang/LLVM组合是一个很大的库,导致发布的应用的库会比较大;
  • Clang/LLVM组合使用的资源比较多,因此当编译的BPF代码启动时会消耗大量资源,可能会推翻已均衡的生产负载;
  • 这样做其实也是在赌目标系统将存在内核头文件,大多数情况下这不是问题,但有时可能会引起很多麻烦。这也是内核开发人员感到特别麻烦的点,因为他们经常必须在开发过程中构建和部署自定义的内核。如果没有自定义构建的内核头文件包,则基于BCC的应用将无法在这种内核上运行,从而剥夺了开发人员用于调试和监视的工具集;
  • BPF程序的测试和开发迭代也相当痛苦,因为一旦重新编译并重启用户空间控制应用程序,甚至会在运行时遇到各种琐碎的编译错误。这无疑会增加难度,且无益于快速迭代。

总之, BCC是一个很好的工具,尤其适合快速原型制作,实验和小型工具,但在用于广泛部署的生产BPF应用程序时,它无疑具有很多缺点。

我们正在使用BPF CO-RE来增强BPF的可移植性,并相信这是未来BPF程序开发的趋势,尤其对于复杂的实际应用的BPF程序。

高级BFP CO-RE机制

BPF CO-RE在软件堆栈的各个级别汇集了必要的功能和数据:内核,用户空间的BPF加载器库(libbpf),和编译器(Clang)。通过这些组件来支持编写可移植的BPF程序,使用相同的预编译的BPF程序来处理不同内核之间的差异。BPF CO-RE需要以下组件的集成和合作:

  • BTF类型信息,用于允许获取关于内核和BPF程序类型和代码的关键信息,进而为解决BPF CO-RE的其他难题提供了可能性;
  • 编译器(Clang)为BPF程序C代码提供了表达意图和记录重定位信息的方法;
  • BPF加载器(libbpf)将内核和BPF程序中的BTF绑定在一起,用于将编译后的BPF代码调整为目标主机上的特定内核代码;
  • 内核,在完全不依赖BPF CO-RE的情况下,提供了高级BPF功能来启用某些更高级的场景。

这些组件可以集成到一起工作,提供了前所未有的便捷性,适应性和表达性(来开发可移植BPF程序,以前只能在运行时通过BCC编译BPF程序的C代码来实现),而无需像BCC一样付出高昂的代价。

BTF

整个BPF CO-RE方法的关键推动因素之一是BTF。BTF (BPF Type Format) 是作为一个更通用,更详细的DWARF调试信息的替代品而出现的。BTF是一种节省空间,紧凑但依然具有足够表达能力的格式,可以描述C程序的所有类型信息。由于其简单性和使用的重复数据删除算法,与DWARF相比,BTF的大小可减少多达100倍。现在,已经可以在内核运行时显示地嵌入BPF类型信息:只需要启用CONFIG_DEBUG_INFO_BTF=y内核选项即可。内核本身可以使用BTF功能,用于增强BPF验证程序自身的功能。

关于BPF CO-RE更重要的是,内核还通过/sys/kernel/btf/vmlinux上的sysfs公开了这种自描述的权威BTF信息(定义了确切的结构布局)。尝试如下命令:

1
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c

某些unix系统下安装的bpftool默认不支持btf命令选项,可以在linux内核源码的/tools/bpf/bpftool目录下执行make命令进行编译。如果遇到linux/if.hnet/if.h头文件定义冲突的话,可以将/tools/bpf/bpftool/net.c中的这一行注释掉再编译:

1
#include <linux/if.h>

目前很多内核默认并不会打开BTF内核选项,因此需要自己编译内核。基本步骤如下:

  1. 首先升级gcc

  2. 编译带BTF选项的内核前需要安装pahole,可以从github官方下载源码编译即可。需要注意的是,该编译过程需要依赖git,因此需要通过git clone代码编译,而不能下载源码压缩包编译;按照官方编译步骤直接执行make时可能会遇到错误”Performing Test HAVE_REALLOCARRAY_SUPPORT - Failed”,其实仅需要执行make pahole编译出pahole即可。

  3. 导出当前内核配置:

    1
    2
    $ cd linux-5.10.1
    $ cp -v /boot/config-$(uname -r) .config
  4. 在linux-5.10.1目录中使用make menuconfig命令修改系统配置文件,并保存。可以使用”/“直接查找需要修改的内核选项;

  5. 编译并创建内核镜像,如果仅需要vmlinux的话,在编译完之后执行make vmlinux即可

    1
    2
    3
    4
    5
    $ make
    #可以使用多核方式加速编译,指定使用4个核
    $ make -j 4
    #使用nproc命令获取到的核数
    $ make -j $(nproc)
  6. 安装内核:

    1
    $ sudo make modules_install
  7. 安装内核:

    1
    $ sudo make install
  8. 更新 grub config文件

    1
    2
    $ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
    $ sudo grubby --set-default /boot/vmlinuz-5.6.9
  9. 重启

通过上述命令可以获得到一个可兼容的C头文件(即”vmlinux.h”),包含所有的内核类型(“所有”意味着包含那些不会通过kernel-devel包暴露的头文件)。

编译器支持

为了启用BPF CO-RE,并让BPF加载程序(即libbpf)将BPF程序调整为在目标主机上运行的特定内核,Clang扩展了一些内置功能,通过这些扩展功能可以发出BTF重定位,捕获有关BPF程序代码打算读取哪些信息的高级描述。例如要读取task_struct->pid字段,Clang会记录一个名为”pid”的字段,类型为”pid_t”,位于struct task_struct中。这样,即使目标内核的task_struct结构中的”pid”字段在task_struct结构体内部发生了偏移(如,由于”pid”字段前面添加了额外的字段),或即使该字段转移到了某个嵌套的匿名结构或联合体中,这样也能够通过其名称和类型信息找到它。这种方式称为字段偏移量重定位

通过这种方式可以捕获不仅一个字段的偏移量,也可以捕获字段的其他属性,如字段的存在性或大小。即使对于比特字段(众所周知,它们是C语言中“拒绝合作”的数据类型),也能够捕获足够多的数据来重定位这些字段,所有这些对BPF程序开发人员都是透明的。

BPF加载器(libbpf)

前面的所有数据最终会集合到一起,由libbpf进行处理,libbpf作为BPF程序的加载器。它会使用编译好的BPF ELF文件,必要时对其进行后处理,配置各种内核对象(maps,programs等),然后触发BPF程序的加载和验证。

libbpf知道如何将BPF程序代码匹配到特定的内核。它会查看程序记录的BTF类型和重定位信息,然后将这些信息与内核提供的BTF信息进行匹配。libbpf解析并匹配所有的类型和字段,更新必要的偏移以及重定位数据,确保BPF程序能够正确地运行在特定的内核上。如果一切顺利,则BPF应用开发人员会获得一个BPF程序,这种方式可以针对目标主机上的内核进行“量身定制”,就好像程序是专门针对这个内核编译的,但无需在应用程序中分发Clang以及在目标主机上的运行时中执行编译,就可以实现所有这些目标。

内核

令人惊奇的是,内核无需太多变动就可以支持BPF CO-RE。归功于一个好的关注点分离(separation of concerns,SOC),当libbpf处理完BPF程序代码之后,在内核看来,它与其他有效的BPF程序代码一样,与使用最新内核头文件在主机上直接编译的BPF程序并没有区别,这意味着BPF CO-RE的许多功能都不需要先进的内核功能,因此可以更广泛,更迅速地进行调整。

有可能在某些场景下需求较新内核的支持,但这种情况很少。在下一部分中,我们将在解释BPF CO-RE面向用户的机制时讨论这种情况,其中将详细介绍BPF CO-RE面向用户的API。

BPF CO-RE:面向用户的体验

现在我们将看一下BPF应用的一些典型场景,以及如何通过BPF CO-RE解决兼容性问题。下面可以看到,一些可移植性问题(如兼容结构体布局差异)可以透明地进行处理,但其他一些场景则需要更加显示地处理,如if/else条件判断(与编译时BCC程序中的#ifdef/#else构造相反)和BPF CO-RE提供的一些额外机制。

摆脱对内核头文件的依赖

除了使用内核的BTF信息进行字段的重定位意外,还可以将BTF信息生成一个大(基于5.10.1版本生成的长度有106382行)的头文件(“vmlinux.h”),其中包含了所有的内核内部类型,可以避免对系统范围的内核头文件的依赖。可以使用如下方式生成vmlinux.h:

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

当使用了vmlinux.h,此时就不需要依赖像#include <linux/sched.h>, #include <linux/fs.h>这样的头文件,仅需要#include "vmlinux.h"即可。该头文件包含了所有的内核类型:暴露了UAPI,通过kernel-devel提供的内部类型,以及其他一些更加内部的内核类型。

不幸的是,BTF(即DWARF)不会记录#define宏,因此在vmlinux.h中丢失一些常用的宏。但大多数通常不存在的宏可以通过libbpf的bpf_helpers.h(即libbpf提供的内核侧的库)头文件提供。

读取内核结构体字段

大多数场景下会从某个内核结构中读取一个字段。假设我们期望读取task_struct结构体的pid字段。使用BCC时非常简单:

1
pid_t pid = task->pid;

BCC会将task->pid重写为对bpf_probe_read()的调用,非常方便(虽然有时候不会成功,具体取决于使用的表达式的复杂度)。当使用libbpf时,由于它没有BCC的代码重写功能,因此需要使用其他方式来得到相同的结果。

如果添加了BTF_PROG_TYPE_TRACING 程序,那么就可以轻松掌握BPF验证程序,允许理解和跟踪BTF类型的本质,并允许使用指针直接读取内核内存,避免使用bpf_probe_read()调用。

Libbpf + BPF_PROG_TYPE_TRACING 方式:

1
pid_t pid = task->pid;

将该功能与BPF CO-RE配合使用,可以支持可移植(即可重定位)的字段读取,此时需要将此代码封装到编译器内置的__builtin_preserve_access_index

BPF_PROG_TYPE_TRACING + BPF CO-RE 方式:

1
pid_t pid = __builtin_preserve_access_index(({ task->pid; }));

这种方式能够正常工作,同时也支持不同内核版本间的可移植性。但鉴于BPF_PROG_TYPE_TRACING的前沿性,因此必须显式地使用bpf_probe_read()

非CO-RE libbpf方式:

1
2
pid_t pid;
bpf_probe_read(&pid, sizeof(pid), &task->pid);

现在,使用CO-RE+libbpf,我们有两种方式来实现访问pid字段的值。一种是直接使用bpf_core_read()替换bpf_probe_read():

1
2
pid_t pid;
bpf_core_read(&pid, sizeof(pid), &task->pid);

bpf_core_read()是一个简单的宏,它会将所有的参数直接传递给bpf_probe_read(),但也会使Clang通过__builtin_preserve_access_index()记录第三个参数(&task->pid)的字段的偏移量。

1
bpf_probe_read(&pid, **sizeof**(pid), __builtin_preserve_access_index(&task->pid));

但像bpf_probe_read()/bpf_core_read()这样的调用方式很快就会变得难以维护,特别是获取通过指针连在一起的结构体时。例如,获取当前进程的可执行文件的inode号时,可以使用BCC获取:

1
u64 inode = task->mm->exe_file->f_inode->i_ino;

当使用 bpf_probe_read()/bpf_core_read()时,将会变为4个调用,并使用一个临时变量来保存这些中间指针,才能最终获得i_ino字段。当使用BPF CO-RE时,我们可以使用一个辅助宏来使用类似BCC的方式获得该字段的值:

BPF CO-RE方式:

1
u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);

此外,如果想要使用一个变量保存内容,则可以使用如下方式,避免使用额外的中间变量:

1
2
u64 inode;
BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino);

还有一个对应的 bpf_core_read_str(),可以直接替换bpf_probe_read_str();还有一个BPF_CORE_READ_STR_INTO()宏,其工作方式与BPF_CORE_READ_INTO()类似,但会对最后一个字段执行bpf_probe_read_str()调用。

可以通过bpf_core_field_exists()宏校验目标内核是否存在某个字段,并以此作相应的处理。

1
pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;

此外,可以通过bpf_core_field_size()宏捕获任意字段的大小,以此来保证不同内核版本间的字段大小没有发生变化。

1
u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */

除此之外,在某些情况下,当读取一个内核结构体的比特位字段时,可以使用特殊的BPF_CORE_READ_BITFIELD() (使用直接内存读取) 和BPF_CORE_READ_BITFIELD_PROBED() (依赖bpf_probe_read() 调用)宏。它们抽象了提取比特位字段繁琐而痛苦的细节,同时保留了跨内核版本的可移植性:

1
2
3
4
5
6
7
8
struct tcp_sock *s = ...;

/* with direct reads */
bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(s, is_cwnd_limited);

/* with bpf_probe_read()-based reads */
u64 is_cwnd_limited;
BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &is_cwnd_limited);

字段重定位和相关的宏是BFP CO-RE提供的主要能力。它涵盖了很多实际的使用案例。

处理内核版本和配置差异

在一些场景下,BPF程序不得不处理内核间的差异。如某些字段名称的变更导致其变为了一个完全不同的字段(但具有相同的意义)。反之亦然,当字段不变,但其含义发生了变化。如在内核4.6之后,task_struct结构体的utimestime字段从以秒为单位换为以纳秒为单位,这种情况下,不得不进行一些转换工作。有时,需要提取的数据存在于某些内核配置中,但已在其他内核配置中进行了编译。还有在很多其他场景下,不可能有一个适合所有内核的通用类型。

为了处理上述问题,BPF CO-RE提出了两种补充方案:libbpf提供了extern Kconfig variablesstruct flavors.

Libbpf提供的外部变量很简单。BPF程序可以使用一个知名名称(如LINUX_KERNEL_VERSION,用于获取允许的内核的版本)定义一个外部变量,或使用Kconfig的键(如CONFIG_HZ,用于获取内核的HZ值),libbpf会使BPF程序可以将这类外部变量用作任何其他全局变量。这些变量具有正确的值,与执行BPF程序的活动内核相匹配。此外,BPF校验器会跟踪这些变量,并能够使用它们进行高级控制流分析和消除无效代码。查看如下例子,了解如何使用BPF CO-RE抽取线程的CPU用户时间:

1
2
3
4
5
6
7
8
9
10
extern u32 LINUX_KERNEL_VERSION __kconfig;
extern u32 CONFIG_HZ __kconfig;

u64 utime_ns;

if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(4, 11, 0))
utime_ns = BPF_CORE_READ(task, utime);
else
/* convert jiffies to nanoseconds */
utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ);

其他机制,如struct flavors,可以用于不同内核间类型不兼容的场景。这种场景下,无法使用一个通用的结构体定义来为多个内核提供相同的BPF程序。下面是一个人为构造的例子,看下struct flavors如何抽取fs/fsbase(已经重命名)来作一些线程本地数据的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* up-to-date thread_struct definition matching newer kernels */
struct thread_struct {
...
u64 fsbase;
...
};

/* legacy thread_struct definition for <= 4.6 kernels */
struct thread_struct___v46 { /* ___v46 is a "flavor" part */
...
u64 fs;
...
};

extern int LINUX_KERNEL_VERSION __kconfig;
...

struct thread_struct *thr = ...;
u64 fsbase;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(4, 6, 0))
fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs);
else
fsbase = BPF_CORE_READ(thr, fsbase);

本例中,BPF应用将<= 4.6内核的“旧版” thread_struct定义为struct thread_struct___v46。类型名称中的三个下划线以及其后的所有内容均被视为此结构的“flavor”。libbpf会忽略这个flavor部分,即在执行重定位时,该类型定义会匹配到实际运行的内核的struct thread_struct。这样的约定允许在一个C程序中具有可替代(且不兼容)的定义,并在运行时选择最合适的定义(例如,上面示例中的特定于内核版本的处理逻辑),然后使用类型强转为struct flavor来提取必要的字段。

如果没有structural flavors,则不能实现编译一次就可以在多个内核上运行的目标,否则就需要将#ifdef源代码编译成两个单独的BPF程序,并在运行时由控制应用程序手动选择适当的BPF程序,这些操作增加了复杂度和维护的成本。尽管不是透明的,但BPF CO-RE甚至可以使用这种高级方案,通过熟悉的C代码构造来解决此问题。

根据用户提供的配置变更行为

有时候,在BPF程序了解内核版本和配置之后仍然无法决定如何从内核获取数据。这种情况下,用户空间的控制程序可能是唯一知道确切需要做什么的一方,以及需要启用或禁用那些特性。通常是通过某种配置数据进行通信,在用户空间和BPF程序之间共享数据。现今,一种不需要依赖BPF CO-RE的实现方式是使用BPF map作为配置数据的容器。BPF程序通过查找BPF map来抽取配置,并根据配置变更控制流,但这种方法有很多缺点:

  • BPF程序每次进行map查询配置值时都会造成运行时开销。这部分开销可能会快速增大,某些高性能BPF应用禁止这种方式。
  • 配置值是不变的,且在BPF程序启动之后是只读的,但这部分数据仍然在BPF校验器在校验阶段仍然被认为是黑盒数据。意味着校验器无法清理无用代码以及执行其他高级代码分析,使得无法使用BPF程序逻辑的可配置部分(这部分功能是最前沿的功能,仅在新内核中支持,当运行在老内核上时不会破坏该程序)。由于BPF验证程序必须悲观地认为配置可以是任何东西,且有可能会使用该”未知”的功能(尽管用户明确配置不会发生这种情况)。

解决此类(公认复杂)场景的方法是使用只读全局数据。在BPF程序加载到内核之前由控制应用进行设置。从BPF程序侧看,这部分数据就像访问普通的全局变量。由于全局变量使用直接内存访问方式,因此不会产生BPF map查询的开销。控制语言侧需要在BPF程序加载之前设置初始的配置值,这样当BPF校验器进行程序校验时,会将配置值认为是只读的,这样BPF校验器会将这部分内容认为是已知的常量,并使用高级控制流分析来执行无用代码的删除。

上例中,在老版本的BPF校验器下,将不会使用未知的BPF辅助功能,且这部分代码会被移除。在新版本BPF校验器下,应用提供不同的配置后,允许使用新的BPF辅助功能,这部分逻辑会通过BPF校验器的校验。下面BPF代码例子很好地展示了这种行为:

1
2
3
4
5
6
7
8
9
10
11
/* global read-only variables, set up by control app */
const bool use_fancy_helper;
const u32 fallback_value;

...

u32 value;
if (use_fancy_helper)
value = bpf_fancy_helper(ctx);
else
value = bpf_default_helper(ctx) * fallback_value;

从用户空间看,应用程序将能够通过BPF框架轻松地提供此配置。BPF框架讨论不在本文讨论范围之内,请参阅内核代码库中的runqslower 工具来展示如何使用它来简化BPF应用程序。

回顾

BPF CO-RE的目标是帮助BPF开发者使用一个简单的方式解决简单的可移植性问题(如读取结构体字段),并使用它来定位复杂的可移植性问题(如不兼容的数据结构,复杂的用户空间控制条件等)。使得开发者的BPF程序能够”一次编译–随处运行”, 这是通过结合一些上述的BPF CO-RE构建块来实现的:

  • vmlinux.h消除了对内核头文件的依赖;
  • 字段重定位(字段偏移,存在性,大小等)使得可以从内核中抽取数据;
  • libbpf提供的Kconfig外部变量允许BPF程序适应各种内核版本以及特定配置的更改;
  • 当上述都不适合时,app提供了只读的配置和struct flavors,作为解决任何应用程序必须处理的复杂场景的最终大锤。

不需要CO-RE功能也可以成功编写,部署和维护可以支持的BPF程序,但在需要时,BPF CO-RE可提供最简单的方式来帮助解决问题。所有这些功能仍然提供了良好的可用性和熟悉的工作流程,可将C代码编译为二进制文件,并进行轻量级的分发。不再需要繁琐的编译器库并为运行时编译付出宝贵的运行时资源。 同样,也不再需要在运行时捕获琐碎的编译错误。

TIPS

参考

调试信息(DWARF、BTF)

若是要 debug,clang 可以生成下面这样的汇编器输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ clang -O2 -S -Wall -target bpf -c xdp-example.c -o xdp-example.S
$ cat xdp-example.S
.text
.section prog,"ax",@progbits
.globl xdp_drop
.p2align 3
xdp_drop: # @xdp_drop
# BB#0:
r0 = 1
exit

.section license,"aw",@progbits
.globl __license # @__license
__license:
.asciz "GPL"

LLVM 从 6.0 开始,还包括了汇编解析器的支持。你可以直接使用 BPF 汇编指令编程,然后使用 llvm-mc 将其汇编成一个目标文件。例如,你可以将前面的 xdp-example.S 重新变回对象文件:

1
$ llvm-mc -triple bpf -filetype=obj -o xdp-example.o xdp-example.S
DWARF 格式和 llvm-objdump

LLVM 还可以将调试信息以 dwarf 格式存储到对象文件中。 只要在编译时加上 -g

1
2
3
4
5
6
7
8
9
10
11
12
$ clang -O2 -g -Wall -target bpf -c xdp-example.c -o xdp-example.o
$ llvm-objdump -S -no-show-raw-insn xdp-example.o

xdp-example.o: file format ELF64-BPF


Disassembly of section prog:

0000000000000000 xdp_drop:
; return XDP_DROP;
0: r0 = 1
1: exit

llvm-objdump 工具能够用编译的 C 源码对汇编输出添加注解(annotate )。这里的例子过于简单,没有几行 C 代码;但注意上面的 01 行号,这些行号直接对应到内核的校验器日志(见下面的输出)。这意味着假如 BPF 程序被校验器拒绝了, llvm-objdump能帮助你将 BPF 指令关联到原始的 C 代码,对于分析来说非常有用。

1
2
3
4
5
6
7
8
9
10
11
12
$ ip link set dev em1 xdp obj xdp-example.o verb

Prog section 'prog' loaded (5)!
- Type: 6
- Instructions: 2 (0 over limit)
- License: GPL

Verifier analysis:

0: (b7) r0 = 1
1: (95) exit
processed 2 insns

从上面的校验器分析可以看出,llvm-objdump 的输出和内核中的 BPF 汇编是相同的。

去掉 -no-show-raw-insn 选项还可以以十六进制格式在每行汇编代码前面打印原始的 struct bpf_insn

1
2
3
4
5
6
7
8
9
10
11
$ llvm-objdump -S xdp-example.o

xdp-example.o: file format ELF64-BPF


Disassembly of section prog:

0000000000000000 xdp_drop:
; return XDP_DROP;
0: b7 00 00 00 01 00 00 00 r0 = 1
1: 95 00 00 00 00 00 00 00 exit
LLVM IR

对于 LLVM IR 调试,BPF 的编译过程可以分为两个步骤:首先生成一个二进制 LLVM IR 临 时文件 xdp-example.bc,然后将其传递给 llc

1
2
$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -filetype=obj -o xdp-example.o

生成的 LLVM IR 还可以 dump 成人类可读的格式:

1
$ clang -O2 -Wall -emit-llvm -S -c xdp-example.c -o -
BTF

LLVM 能将调试信息(例如对程序使用的数据的描述)attach 到 BPF 对象文件。默认情况 下使用 DWARF 格式。

BPF 使用了一个高度简化的版本,称为 BTF (BPF Type Format)。生成的 DWARF 可以 转换成 BTF 格式,然后通过 BPF 对象加载器加载到内核。内核验证 BTF 数据的正确性, 并跟踪 BTF 数据中包含的数据类型。

这样的话,就可以用键和值对 BPF map 打一些注解(annotation)存储到 BTF 数据中,这样下次 dump map 时,除了 map 内的数据外还会打印出相关的类型信息。这对内省( introspection)、调试和格式良好的打印都很有帮助。注意,BTF 是一种通用的调试数据格式,因此任何从 DWARF 转换成的 BTF 数据都可以被加载(例如,内核 vmlinux DWARF 数 据可以转换成 BTF 然后加载)。后者对于未来 BPF 的跟踪尤其有用。

将 DWARF 格式的调试信息转换成 BTF 格式需要用到 elfutils (>= 0.173) 工具。 如果没有这个工具,那需要在 llc 编译时打开 -mattr=dwarfris 选项:

1
2
3
$ llc -march=bpf -mattr=help |& grep dwarfris
dwarfris - Disable MCAsmInfo DwarfUsesRelocationsAcrossSections.
[...]

使用 -mattr=dwarfris 是因为 dwarfris (dwarf relocation in section) 选项禁 用了 DWARF 和 ELF 的符号表之间的 DWARF cross-section 重定位,因为 libdw 不支持 BPF 重定位。不打开这个选项的话,pahole 这类工具将无法正确地从对象中 dump 结构。

elfutils (>= 0.173) 实现了合适的 BPF 重定位,因此没有打开 -mattr=dwarfris 选项也能正常工作。它可以从对象文件中的 DWARF 或 BTF 信息 dump 结构。目前 pahole 使用 LLVM 生成的 DWARF 信息,但未来它可能会使用 BTF 信息。

pahole

将 DWARF 转换成 BTF 格式需要使用较新的 pahole 版本(>= 1.12),然后指定 -J 选项。 检查所用的 pahole 版本是否支持 BTF(注意,pahole 会用到 llvm-objcopy,因此 也要检查后者是否已安装):

1
2
$ pahole --help | grep BTF
-J, --btf_encode Encode as BTF

生成调试信息还需要前端的支持,在 clang 编译时指定 -g 选项,生成源码级别的调 试信息。注意,不管 llc 是否指定了 dwarfris 选项,-g 都是需要指定的。生成目标文件的完整示例:

1
2
$ clang -O2 -g -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -mattr=dwarfris -filetype=obj -o xdp-example.o

或者,只使用 clang 这一个工具来编译带调试信息的 BPF 程序(同样,如果有合适的 elfutils 版本,dwarfris 选项可以省略):

1
$ clang -target bpf -O2 -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o

基于 DWARF 信息 dump BPF 程序的数据结构:

1
2
3
4
5
6
7
8
9
$ pahole xdp-example.o
struct xdp_md {
__u32 data; /* 0 4 */
__u32 data_end; /* 4 4 */
__u32 data_meta; /* 8 4 */

/* size: 12, cachelines: 1, members: 3 */
/* last cacheline: 12 bytes */
};

在对象文件中,DWARF 数据将仍然伴随着新加入的 BTF 数据一起保留。完整的 clangpahole 示例:

1
2
$ clang -target bpf -O2 -Wall -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o
$ pahole -J xdp-example.o
readelf

通过 readelf 工具可以看到多了一个 .BTF section:

1
2
3
4
$ readelf -a xdp-example.o
[...]
[18] .BTF PROGBITS 0000000000000000 00000671
[...]

BPF 加载器(例如 iproute2)会检测和加载 BTF section,因此给 BPF map 注释( annotate)类型信息。