运行

1
2
3
$ cd examples/c
$ make minimal
$ sudo ./minimal
1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: BPF triggered from PID 3840345.
<...>-3840345 [010] d... 3220702.101265: bpf_trace_printk: BPF triggered from PID 3840345.

Q&A

你可能会发现 `sudo cat /sys/kernel/debug/tracing/trace_pipe` 没有任何的输出信息。

这是因为在 WSL 中运行的进程处于一个 PID 命名空间(PID namespace)内,因此从用户空间(userspace)获取的 PID 是该命名空间内的 PID,而通过 eBPF 代码中的 bpf_get_current_pid_tgid 函数获取的 PID 则是全局 PID(global PID)。

以下是我的分析和调试过程:

  1. 使用 strace 确认 sys_enter_write 是否被调用,通过以下命令重新运行 minimal
1
2
# 使用 root 用户
$ strace ./minimal

可以看到如下输出信息,确认 sys_enter_write 被调用:

1
2
3
4
5
6
7
8
9
10
execve("./minimal", ["./minimal"], 0x7ffc2c4a8660 /* 35 vars */) = 0
...
write(2, ".", 1.) = 1
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffd02023650) = 0
write(2, ".", 1.) = 1
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffd02023650) = 0
write(2, ".", 1.) = 1
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffd02023650) = 0
write(2, ".", 1.) = 1
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffd02023650) = 0
  1. handle_tp 的函数入口和函数返回前,通过 bpf_printk 增加打印信息,确认函数的执行流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
+ bpf_printk("handle_tp\n");
int pid = bpf_get_current_pid_tgid() >> 32;

if (pid != my_pid)
+ bpf_printk("pid != my_pid\n");
return 0;

bpf_printk("BPF triggered from PID %d.\n", pid);

return 0;
}
  1. 通过查找代码库的 Issues ,我发现如下两条相关信息:

  2. 实际上代码库已经给出了解决方案😅,那就是使用 minimal_ns

代码梳理

这是一个实用的最小化 BPF 应用程序示例。该示例不使用也不依赖 BPF CO-RE(BPF 编译时重定位)技术,因此可在相当老旧的内核版本上运行。

它会安装一个跟踪点(tracepoint)处理程序,该处理程序每秒触发一次。示例中使用 bpf_printk() 这个 BPF 辅助函数与外部环境进行通信。

The BPF side

1
2
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

#include <linux/bpf.h> 导入了一些基础的、必要的 BPF 相关的类型和常量,以便使用内核侧的 BPF API。

#include <bpf/bpf_helpers.h>libbpf 提供的,包含了大多数常用的宏、常量和 BPF helper 的定义,几乎会在每个 BPF 应用中用到。

1
char LICENSE[] SEC("license") = "Dual BSD/GPL";

LICENSE 变量定义了你的 BPF 代码的 license。在内核开发中,明确 license 是必须的。一些 BPF 功能对于不兼容 GPL 的代码是不可用的。

1
int my_pid = 0;

定义了一个全局变量,BPF 代码可以读取和更新它。这样的全局变量能够从用户侧读写。这个特性是从 Linux 5.5 之后才支持的。它也经常用于在内核中的 BPF 代码和用户侧的控制代码之间传递数据。

1
2
3
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{ ... }

定义了一个 tracepoint BPF 程序,每次用户空间的应用调用了系统调用 write() 的时候,就会触发它。

在同一个 BPF C 程序文件中,可能有多个 BPF 程序。他们可以是不同类型的,有着不同的 SEC() 宏。在同一个 BPF C 代码文件中的所有的 BPF 程序共享所有的全局状态,例如上面例子中的 my_pid 变量,如果使用了 BPF map,它也是共享的。这常常用在 BPF 程序的协作中。

下面看下 BPF 程序 handle_tp 在做什么:

1
2
3
4
int pid = bpf_get_current_pid_tgid() >> 32;

if (pid != my_pid)
return 0;

这部分获取了 PID,或者说是内核术语中的 “TGID” ,它存储在 bpf_get_current_pid_tgid() 返回值的高 32 位。

接着,查看触发了 write() 系统调用的进程是否是我们的 minimal 进程。

全局变量 my_pid 是通过下面的用户空间的代码进行初始化的,它会被初始化成真实的 PID 值。

1
bpf_printk("BPF triggered from PID %d.\n", pid);

这就是 BPF 中的 printf(“Hello, world!\n”)。它输出格式化的字符串到一个特殊的文件,叫作 /sys/kernel/debug/tracing/trace_pipe

bpf_printk()trace_pipe 文件一般不在生产环境中使用,它们是用来辅助 BPF 程序的 debug 的,帮助开发者知道自己的代码到底干了些什么事情。

The user-space side

1
#include "minimal.skel.h"

这里导入了 BPF 代码 minimal.bpf.c 中的 BPF skeleton。

它是在 Makefile中的某一步,由 bpftool 自动生成的文件,像这样:

1
bpftool gen skeleton minimal.bpf.o > minimal.skel.h

它高度抽象了 minimal.bpf.c 的结构,也简化了 BPF 代码部署的逻辑,将编译出的 BPF 目标代码嵌入到了头文件中,该头文件又会被用户空间的代码所引用。你的应用程序的二进制文件中不会有其他多余的文件了,只导入它就好了。

关于 BPF skeleton 的更多信息可以参考:

minimal 应用的 main() 函数在做什么:

1
2
3
4
5
6
7
8
9
10
11
12
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}

int main(int argc, char **argv)
{
struct minimal_bpf *skel;
int err;

/* Set up libbpf errors and debug info callback */
libbpf_set_print(libbpf_print_fn);

libbpf_set_print() 提供了一个自定义的回调给所有的 libbpf 日志输出。它允许捕获有用的 libbpf 调试日志。默认情况下,libbpf 将只打印错误级别的信息。调试日志则会帮助我们更快地定位问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Open BPF application */
skel = minimal_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}

/* ensure BPF program only handles write() syscalls from our process */
skel->bss->my_pid = getpid();

/* Load & verify BPF programs */
err = minimal_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}

使用自动生成的 BPF skeleton,加载 BPF 程序到内核中,然后让 BPF verifier 校验它是否合法,如果这步成功了,你的 BPF 代码就是正确的。

我们需要与 BPF 传递我们的用户态程序的 PID,以便它能够过滤掉不相关的进程触发的 write() 事件。上面的代码会直接设置映射过的内存区域的 BPF 全局变量 my_pid

关于 my_pid 存储在 bss 段的原因,可参考:

1
2
3
4
5
6
/* Attach tracepoint handler */
err = minimal_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}

handle_tp BPF 程序附加到对应的内核跟踪点(tracepoint)上。这一操作会 “激活” 该 BPF 程序,此后每当有 write() 系统调用被触发时,内核便会在内核上下文中执行我们自定义的 BPF 代码。

1
2
3
4
5
for (;;) {
/* trigger our BPF program */
fprintf(stderr, ".");
sleep(1);
}

周期性地(每秒)调用 fprintf(stderr, "."),从而触发 write() 系统调用。

1
2
3
cleanup:
minimal_bpf__destroy(skel);
return -err;

在内核和用户空间清除所有的资源。也有一些类型的 BPF 程序,会在内核中一直保持活跃,即使它自己的用户空间的进程已经结束了。