查看原文
其他

eBPF编程指北

houmin GoCN 2022-09-09
 1 
开发环境


这里以 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 对象文件了。

典型的工作流是:

  1. 用 C 编写 BPF 程序

  2. 用 LLVM 将 C 程序编译成对象文件(ELF)

  3. 用户空间 BPF ELF 加载器(例如 iproute2)解析对象文件

  4. 加载器通过 bpf() 系统调用将解析后的对象文件注入内核

  5. 内核验证 BPF 指令,然后对其执行即时编译(JIT),返回程序的一个新文件描述符

  6. 利用文件描述符 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 是合法的、能够处理的重定位项。

#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 会分别使用 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 指令序列:

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),因此结构体最终的大小可能会比预想中大。

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 成员来显式填充,这样既避免了自动填充的问题,又解决了非对齐访问的问题。

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,可能会修改包的大小。校验器无法跟踪这类改动,因此它会将所有之前对包数据的引用都视为过期的(未验证的) 。因此,为避免程序被校验器拒绝,在访问数据之外需要先更新相应的引用。

来看下面的例子:

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/。

这个工具可以完成:

  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 程序:

$ 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


想要了解更多相关的内容,欢迎扫描下方👇 关注 公众号,回复关键词 [实战群]  ,就有机会进群和我们进行交流~


分享、在看与点赞,至少我要拥有一个叭~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存