运行
1 | $ cd examples/c |
1 | $ sudo cat /sys/kernel/debug/tracing/trace_pipe |
Q&A
这是因为在 WSL 中运行的进程处于一个 PID 命名空间(PID namespace)内,因此从用户空间(userspace)获取的 PID 是该命名空间内的 PID,而通过 eBPF 代码中的 bpf_get_current_pid_tgid 函数获取的 PID 则是全局 PID(global PID)。
以下是我的分析和调试过程:
- 使用
strace确认sys_enter_write是否被调用,通过以下命令重新运行minimal
1 | # 使用 root 用户 |
可以看到如下输出信息,确认 sys_enter_write 被调用:
1 | execve("./minimal", ["./minimal"], 0x7ffc2c4a8660 /* 35 vars */) = 0 |
- 在
handle_tp的函数入口和函数返回前,通过bpf_printk增加打印信息,确认函数的执行流程
1 | SEC("tp/syscalls/sys_enter_write") |
通过查找代码库的 Issues ,我发现如下两条相关信息:
实际上代码库已经给出了解决方案😅,那就是使用 minimal_ns 。
代码梳理
这是一个实用的最小化 BPF 应用程序示例。该示例不使用也不依赖 BPF CO-RE(BPF 编译时重定位)技术,因此可在相当老旧的内核版本上运行。
它会安装一个跟踪点(tracepoint)处理程序,该处理程序每秒触发一次。示例中使用 bpf_printk() 这个 BPF 辅助函数与外部环境进行通信。
The BPF side
1 |
#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 | SEC("tp/syscalls/sys_enter_write") |
定义了一个 tracepoint BPF 程序,每次用户空间的应用调用了系统调用 write() 的时候,就会触发它。
在同一个 BPF C 程序文件中,可能有多个 BPF 程序。他们可以是不同类型的,有着不同的 SEC() 宏。在同一个 BPF C 代码文件中的所有的 BPF 程序共享所有的全局状态,例如上面例子中的 my_pid 变量,如果使用了 BPF map,它也是共享的。这常常用在 BPF 程序的协作中。
下面看下 BPF 程序 handle_tp 在做什么:
1 | int pid = bpf_get_current_pid_tgid() >> 32; |
这部分获取了 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 |
这里导入了 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 | static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) |
libbpf_set_print() 提供了一个自定义的回调给所有的 libbpf 日志输出。它允许捕获有用的 libbpf 调试日志。默认情况下,libbpf 将只打印错误级别的信息。调试日志则会帮助我们更快地定位问题。
1 | /* Open BPF application */ |
使用自动生成的 BPF skeleton,加载 BPF 程序到内核中,然后让 BPF verifier 校验它是否合法,如果这步成功了,你的 BPF 代码就是正确的。
我们需要与 BPF 传递我们的用户态程序的 PID,以便它能够过滤掉不相关的进程触发的 write() 事件。上面的代码会直接设置映射过的内存区域的 BPF 全局变量 my_pid。
关于 my_pid 存储在 bss 段的原因,可参考:
1 | /* Attach tracepoint handler */ |
将 handle_tp BPF 程序附加到对应的内核跟踪点(tracepoint)上。这一操作会 “激活” 该 BPF 程序,此后每当有 write() 系统调用被触发时,内核便会在内核上下文中执行我们自定义的 BPF 代码。
1 | for (;;) { |
周期性地(每秒)调用 fprintf(stderr, "."),从而触发 write() 系统调用。
1 | cleanup: |
在内核和用户空间清除所有的资源。也有一些类型的 BPF 程序,会在内核中一直保持活跃,即使它自己的用户空间的进程已经结束了。