最近才了解到 eBPF 里有 bpf2bpf 这个特性,故特意学习了一番。

bpf2bpf 简介

bpf2bpf 特性要求 4.16 内核版本,参考 BPF Features by Linux Kernel Version

在 bpf2bpf 特性出现之前,eBPF 程序都要求是由单独的一个函数构成,而在 C 代码中强制使用 always_inline 去内联其它函数。这就带来两个问题:

  1. eBPF ELF 文件大小会随着功能的增多而膨胀(因多个 eBPF 程序间不能复用共通的功能逻辑)
  2. 开发者必需将复用的(即使很小的)代码放到头文件里

而 bpf2bpf 特性就是为了解决这两问题而来。它允许多个 eBPF 程序以(C 代码层面)非内联的方式复用多个函数;这就允许开发者不比在头文件里去复用函数;而且多个 eBPF 程序复用同一个函数的时候,该复用的函数在 ELF 文件里只存在一份(独占一个 ELF section)。与此同时,bpf2bpf 特性能带来意外的好处:

  • 将这些需要复用的函数分别编译成独立的 .o 文件,按需加载
  • 在加载阶段即时编译(JIT)一段 eBPF 程序,去替换 C 代码里非内联的函数

bpf2bpf 实现原理

在编码阶段,需要复用的函数不再需要 always_inline,而需要 noinline;以此告诉编译器不要对该函数进行内联。

在编译阶段,为 noinline 的函数生成 BPF_PSEUDO_CALL 指令。

在加载阶段,确保 noinline 的函数在 eBPF 程序中。

在校验阶段,并没有将 noinline 的函数进行内联,也是确保有该函数。

在 JIT 阶段,查找函数地址,生成函数调用指令。

至此,bpf2bpf 特性的实现原理就是一次真实的函数调用,而不是伪装的函数调用。

P.S. 详细的校验过程和 JIT 过程有待进一步梳理。

bpf2bpf 与 bpf_tail_call

其实,bpf2bpf 特性跟 bpf_tail_call() 很像;一开始还以为它们是孪生兄弟。

从 C 代码层面看,它们都是非 always_inline 的。bpf2bpf 的目标函数使用 noinline 属性,而 bpf_tail_call() 的目标函数使用 SEC 属性(即调用另一段 eBPF 程序)。

从代码执行逻辑的顺序看,bpf2bpf 调用函数后能够继续往下执行,而 bpf_tail_call() 在调用后就不能再往下执行当前函数余下的代码(毕竟它就是尾调用嘛)。

bpf2bpf 例子一则

既然已学习了 bpf2bpf 特性,那就搞个 demo 验证一下;毕竟纸上得来终觉浅,绝知此事要躬行。

就以 tcp 连接事件为例吧;当前服务器向外发起 tcp 连接、获取接收 tcp 连接时,就将其中的地址端口打印出来。其中的 C 代码如下:

 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
typedef struct event {
    __be32 saddr, daddr;
    __be16 sport, dport;
} __attribute__((packed)) event_t;

struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} events SEC(".maps");

static __noinline void
handle_new_connection(void *ctx, struct sock *sk)
{
    event_t ev = {};

    ev.saddr = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
    ev.daddr = BPF_CORE_READ(sk, __sk_common.skc_daddr);
    ev.sport = BPF_CORE_READ(sk, __sk_common.skc_num);
    ev.dport = bpf_ntohs(BPF_CORE_READ(sk, __sk_common.skc_dport));

    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &ev, sizeof(ev));
}

SEC("kprobe/tcp_connect")
int k_tcp_connect(struct pt_regs *ctx)
{
    struct sock *sk;
    sk = (typeof(sk))PT_REGS_PARM1(ctx);

    handle_new_connection(ctx, sk);

    return 0;
}

SEC("kprobe/inet_csk_complete_hashdance")
int k_icsk_complete_hashdance(struct pt_regs *ctx)
{
    struct sock *sk;
    sk = (typeof(sk))PT_REGS_PARM2(ctx);

    handle_new_connection(ctx, sk);

    return 0;
}

相比于 always_inline 的实现方式,noinline 的差别仅在于将 __always_inline 换成 __noinline,其它地方并无差异。

效果如下:

1
2
3
4
5
6
# ./bpf2bpf
2022/08/30 15:34:30 Attached kprobe(tcp_connect)
2022/08/30 15:34:30 Attached kprobe(inet_csk_complete_hashdance)
2022/08/30 15:34:30 Listening events...
2022/08/30 15:34:34 new tcp connection: 149.28.12.x:22 -> 118.200.y.45:56389
2022/08/30 15:34:39 new tcp connection: 149.28.12.x:22 -> 101.34.y.134:44702

Go 代码就不贴出来了;完整 demo 代码请查看 bpf2bpf example

小结

在 5.2+ 内核的 Linux 系统中,可以使用命令 bpftool prog dump xlated id ${progID} 来查看已经挂载的 eBPF 程序的汇编指令,带有 C 源代码哟。${prodID} 可通过 bpftool prog list 来查询得到。