eBPF编程指北
开发环境
这里以 Ubuntu 20.04 为例构建 eBPF 开发环境:
$ 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 对象文件了。
典型的工作流是:
-
用 C 编写 BPF 程序
-
用 LLVM 将 C 程序编译成对象文件(ELF)
-
用户空间 BPF ELF 加载器(例如 iproute2)解析对象文件
-
加载器通过 bpf() 系统调用将解析后的对象文件注入内核
-
内核验证 BPF 指令,然后对其执行即时编译(JIT),返回程序的一个新文件描述符
-
利用文件描述符 attach 到内核子系统(例如网络子系统)
某些子系统还支持将 BPF 程序 offload 到硬件(例如网卡)。
查看 LLVM 支持的 BPF target:
$ 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 程序可以在大端节点上编译,在小端节点上运行,或者相反,因此对于交叉编译, 引入了两个新目标 bpfeb 和 bpfel。注意前端也需要以相应的大小端方式运行。
在不存在大小端混用的场景下,建议使用 bpf target。例如,在 x86_64 平台上(小端 ),指定 bpf 和 bpfel 会产生相同的结果,因此触发编译的脚本不需要感知到大小端 。
下面是一个最小的完整 XDP 程序,实现丢弃包的功能(xdp-example.c):
#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";
用下面的命令编译并加载到内核:
$ clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o
$ ip link set dev em1 xdp obj xdp-example.o
— 2 —
编程限制
用 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 是合法的、能够处理的重定位项。
多个程序可以放在同一 C 文件中的不同 section#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";
BPF C 程序大量使用 section annotations。一个 C 文件典型情况下会分为 3 个或更 多个 section。BPF ELF 加载器利用这些名字来提取和准备相关的信息,以通过 bpf() 系统调用加载程序和 maps。
例如,查找创建 map 所需的元数据和 BPF 程序的 license 信息 时,iproute2 会分别使用 maps 和 license 作为默认的 section 名字。注意在程序创建时 license section 也会加载到内核,如果程序使用的是兼容 GPL 的协议,这些信息就可以启用那些 GPL-only 的辅助函数,例如 bpf_ktime_get_ns() 和 bpf_probe_read() 。
其余的 section 名字都是和特定的 BPF 程序代码相关的,例如,下面经过修改之后的代码包含两个程序 section:ingress 和 egress。这个非常简单的示例展示了不同 section (这里是 ingress 和 egress)之间可以共享 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 的流量统计。
#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 以及其他的任意参数。然后用这个宏声明了一 个 NAME 为 map_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 加载:
$ 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):
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)的内置函数 ,这些函数永远都会被内联:
#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 指令实现常量的、不超过一定上限的循环。下面是一个例子:
#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)原子地改变程序行为的灵活性。为了选择要跳转到哪个程序,尾调用使用了程序数组 map( BPF_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。
一个使用尾调用的最小程序示例:
[...]
#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_map 的 id 字段会 跟__section_tail() 中的 id 字段(这里初始化为常量 JMP_MAP_ID)做匹配,因此程 序能加载到用户指定的索引(位置),在上面的例子中这个索引是 0。
然后,所有的尾调用 section 将会被 iproute2 加载器处理,关联到 map 中。这个机制并不是 tc 特有的, iproute2 支持的其他 BPF 程序类型(例如 XDP、lwt)也适用。
生成的 elf 包含 section headers,描述 map id 和 map 内的条目:
$ 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)的例子:
$ 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/。测试代码:
#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 指令序列:
用 #pragma pack 禁止结构体填充(struct padding)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
现代编译器默认会对数据结构进行内存对齐(align),以实现更加高效的访问。结构体成员会被对齐到数倍于其自身大小的内存位置,不足的部分会进行填充(padding),因此结构体最终的大小可能会比预想中大。
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 错误。
示例代码:
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)开始的地方。这里再次画出内存布局图以方便对比:
// 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) 原语实现的:
#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 成员来显式填充,这样既避免了自动填充的问题,又解决了非对齐访问的问题。
通过未验证的引用(invalidated references)访问包数据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.
某些网络相关的 BPF 辅助函数,例如 bpf_skb_store_bytes,可能会修改包的大小。校验器无法跟踪这类改动,因此它会将所有之前对包数据的引用都视为过期的(未验证的) 。因此,为避免程序被校验器拒绝,在访问数据之外需要先更新相应的引用。
来看下面的例子:
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):
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 的地址:
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
}
— 3 —
开发工具链
libbpf
bpftool
bpftool 是查看和调试 BPF 程序的主要工具。它随内核一起开发,在内核中的路径是 tools/bpf/bpftool/。
这个工具可以完成:
-
dump 当前已经加载到系统中的所有 BPF 程序和 map
-
列出和指定程序相关的所有 BPF map
-
dump 整个 map 中的 key/value 对
-
查看、更新、删除特定 key
-
查看给定 key 的相邻 key(neighbor key)
要执行这些操作可以指定 BPF 程序、map ID,或者指定 BPF 文件系统中程序或 map 的位 置。另外,这个工具还提供了将 map 或程序钉(pin)到 BPF 文件系统的功能。
查看系统当前已经加载的 BPF 程序:
$ 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:
$ 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 会使得打印更加美观,看起来更清楚。
$ bpftool prog --json --pretty
要 dump 特定 BPF 程序的 post-verifier BPF 指令镜像(instruction image),可以先 从查看一个具体程序开始,例如,查看 attach 到 tc ingress hook 上的程序:
$ 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 可以提供一些关于这个程序的上层元数据:
$ 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_cls(BPF_PROG_TYPE_SCHED_CLS),有一个 tag 为 e0362f5bd9163a0a(指令序列的 SHA sum)
-
这个程序被 root uid 0 在 Apr 09/16:24 加载
-
BPF 指令序列有 11,144 bytes 长,JIT 之后的镜像有 7,721 bytes
-
程序自身(不包括 maps)占用了 12,288 bytes,这部分空间使用的是 uid 0 用户 的配额
-
BPF 程序使用了 ID 为 18、20 8 5 6 和 14 的 BPF map。可以用这些 ID 进一步 dump map 自身或相关信息
另外,bpftool 可以 dump 出运行中程序的 BPF 指令:
$ 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 查看:
$ 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 开发者比较有用:
$ 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,有时在内核中进行调试时会比较有用:
$ 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 格式的图片:
$ bpftool prog dump xlated id 406 visual &> output.dot
$ dot -Tpng output.dot -o output.png
也可以用 dotty 打开生成的点图文件:dotty output.dot,bpf_host.o 程序的效果如 下图所示(一部分):
注意,xlated 中 dump 出来的指令是经过校验器之后(post-verifier)的 BPF 指令镜 像,即和 BPF 解释器中执行的版本是一样的。
在内核中,校验器会对 BPF 加载器提供的原始指令执行各种重新(rewrite)。一个例子就 是对辅助函数进行内联化(inlining)以提高运行时性能,下面是对一个哈希表查找的优化:
$ 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):
$ 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),表示就是子程序的地址。
$ 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 版本:
$ 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 还是会将它们作为辅助函数进 行关联,以方便调试:
$ 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 会以十六进制格式输出:
$ 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):
$ 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:
$ 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:
$ 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 编译器。
+-------+-------------------------------------------------------------------+
| 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)立即值是没有必要的(也是没有去做的)。
+-------+-------------------------------------------------------------------+
| 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,这个特性就会自动被禁用。
+-------+-------------------------------------------------------------------+
| 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 过滤器 加载程序到内核。
内核测试+-------+-------------------------------------------------------------------+
| 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/。
$ cd tools/testing/selftests/bpf/
$ make
$ make run_tests
测试用例包括:
-
BPF 校验器、程序 tags、BPF map 接口和 map 类型的很多测试用例
-
用于 LLVM 后端的运行时测试,用 C 代码实现
-
用于解释器和 JIT 的测试,运行在内核,用 eBPF 和 cBPF 汇编实现
JIT Debugging
对于执行审计或编写扩展的 JIT 开发人员,每次编译运行都可以通过以下方式将生成的 JIT 镜像输出到内核日志中:
$ echo 2 > /proc/sys/net/core/bpf_jit_enable
每当加载新的 BPF 程序时,JIT 编译器都会转储输出,然后可以使用 dmesg 检查,例如:
[ 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 是 BPF 程序的长度(这里是 6 个 BPF 指令),proglen 告诉 JIT 为操作码图像生成的字节数(这里是 70 字节大小)。pass 意味着图像是在 3 次编译器 pass 中生成的,
例如,x86_64 可以有各种优化 pass 以在可能的情况下进一步减小图像大小。image 包含生成的 JIT 镜像的地址,from 和 pid 分别是用户空间应用程序名称和 PID,它们触发了编译过程。eBPF 和 cBPF JIT 的转储输出格式相同。
在 tools/bpf/ 下的内核树中,有一个名为 bpf_jit_disasm 的工具。它读出最新的转储并打印反汇编以供进一步检查:
$ ./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
或者,该工具还可以将相关操作码与反汇编一起转储。
$ ./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
最近,bpftool 采用了相同的功能,即根据系统中已加载的给定 BPF 程序 ID 转储 BPF JIT 镜像。
对于 JITed BPF 程序的性能分析,perf 可以照常使用。作为先决条件,需要通过 kallsyms 基础设施导出 JIT 程序。
$ echo 1 > /proc/sys/net/core/bpf_jit_enable
$ echo 1 > /proc/sys/net/core/bpf_jit_kallsyms
启用或禁用 bpf_jit_kallsyms 不需要重新加载相关的 BPF 程序。接下来,提供了一个小型工作流示例来分析 BPF 程序。一个精心制作的 tc BPF 程序用于演示目的,其中 perf 在 bpf_clone_redirect() 帮助程序中记录了失败的分配。
由于使用直接写入,bpf_try_make_head_writable() 失败,然后会再次释放克隆的 skb 并返回错误消息。因此 perf 记录了所有 kfree_skb 事件。
$ 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)
perf 记录的堆栈跟踪将显示 bpf_prog_8227addf251b7543() 符号作为调用跟踪的一部分,这意味着带有标签 8227addf251b7543 的 BPF 程序与 kfree_skb 事件相关,并且该程序在入口挂钩上附加到 netdevice em1 为 由 tc 显示。
内省
Linux 内核围绕 BPF 和 XDP 提供了多种 tracepoints,这些 tracepoints 可以用于进一 步查看系统内部行为,例如,跟踪用户空间程序和 bpf 系统调用的交互。
BPF 相关的 tracepoints:
$ 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 等命令):
$ 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,在抛异常的时候触发:
$ 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 主要是针对非特权用户施加限制。根据实际场景不同,为特权 用户设置一个较高的阈值通常是可以接受的。
END
想要了解更多相关的内容,欢迎扫描下方 关注 公众号,回复关键词 [实战群] ,就有机会进群和我们进行交流~
分享、在看与点赞,至少我要拥有一个叭~