原文:Andrii Nakryiko’s Blog --BPF tips & tricks: the guide to bpf_trace_printk() and bpf_printk()
任何BPF 程序总是需要一些调试才能使其正常工作。 不幸的是,目前还没有 BPF 调试器,所以下一个最好的办法是在周围撒上类似 printf()
的语句,看看 BPF 程序中发生了什么。 printf()
的 BPF 等价物是 bpf_trace_printk()
。 在这篇博文中,我们将了解如何使用它、它的局限性是什么以及如何解决这些局限性。 我们还将描述最近几个内核版本中 bpf_trace_printk()
发生的一些重要变化,以及如何使用 BPF CO-RE 来检测和处理这些变化。
我将使用 libbpf-bootstrap 的minimal
示例作为所有示例的基础。 它连接了所有东西并触发了一个简单的 BPF 程序,我们将用它来测试 bpf_trace_printk()
输出。 如果您想继续,请确保克隆 libbpf-bootstrap
并在您的编辑器中打开 minimap.bpf.c
:
$ # note --recursive to checkout libbpf submodule
$ git clone --recursive https://github.com/libbpf/libbpf-bootstrap
$ cd libbpf-bootstrap/examples/c
$ vim minimal.bpf.c
$ make minimal
$ sudo ./minimal
Linux 内核提供了 BPF helper函数 bpf_trace_printk()
,其定义如下:
long bpf_trace_printk(const char *fmt, __u32 fmt_size, ...);
它的第一个参数 fmt
是一个指向 printf
兼容格式字符串的指针(具有一些特定于内核的扩展和限制)。 fmt_size
是该字符串的大小,包括终止 \0
。 varargs
是从格式字符串引用的参数。
bpf_trace_printk()
支持 libc 的 printf()
实现的有限子集。 诸如 %s
、%d
和 %c
之类的基本内容有效,但是,比如说,位置参数 (%1$s
) 无效。 参数宽度说明符(%10d
、%-20s
等)仅适用于最近的内核,但不适用于较早的内核。 此外,还支持一堆特定于内核的修饰符(如 %pi6
打印出 IPv6 地址或 %pks
内核字符串)。
如果格式字符串无效或使用不受支持的功能,bpf_trace_printk()
将返回负错误代码。
不幸的是,对 bpf_trace_printk()
的使用有一些更重要的限制。
首先,使用 bpf_trace_printk()
的 BPF 程序必须具有 GPL 兼容的许可证。 对于基于 libbpf 的 BPF 应用程序,这意味着使用特殊变量指定许可证:
char LICENSE[] SEC("license") = "GPL";
为完整起见,以下是内核识别的所有GPL兼容许可证:
另一个硬性限制是 bpf_trace_printk()
最多只能接受 3 个输入参数(除了 fmt
和 fmt_size
)。 这通常是非常有限的,可能需要使用多个 bpf_trace_printk()
调用来记录所有数据。 这个限制源于 BPF helper总共只能接受最多 5 个输入参数的能力。
但是,一旦克服了这些限制, bpf_trace_printk()
会根据格式字符串尽职尽责地将数据发送到位于 /sys/kernel/debug/tracing/trace_pipe
的特殊文件中。 该文件需要以 root 身份阅读,因此请使用 sudo cat
查看调试日志:
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-2328034 [007] d... 5344927.816042: bpf_trace_printk: Hello, world, from BPF! My PID is 2328034
<...>-2328034 [007] d... 5344928.816147: bpf_trace_printk: Hello, world, from BPF! My PID is 2328034
^C
让我们来剖析一下。 <...>-2328034 [007] d... 5344927.816042:bpf_trace_printk:
内核为每次 bpf_trace_printk()
调用自动发出部分。 它包含进程名称(有时缩写为 <...>
)、PID (2328034)、系统启动后的时间戳 (5344927.816042) 等信息。但是Hello, world, from BPF! My PID is 2328034
是由 BPF 程序控制的部分,并通过这样的简单代码发出:
int pid = bpf_get_current_pid_tgid() >> 32;
const char fmt_str[] = "Hello, world, from BPF! My PID is %d\n";
bpf_trace_printk(fmt_str, sizeof(fmt_str), pid);
请注意 fmt_str
如何定义为堆栈上的变量。 不幸的是,由于 libbpf 的限制目前你不能做 bpf_trace_printk("Hello, world!", ...);
之类的事情。 但即使有可能,需要显式指定 fmt_size 也很不方便。 不过,Libbpf 提供了一个简单的包装宏 bpf_printk(fmt, ...)
,它负责处理这些细节。 它目前在
中定义如下:
/* Helper macro to print out debug messages */
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), \
##__VA_ARGS__); \
})
有了它,上面的"Hello, world!" 示例变得更加简洁和方便:
int pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("Hello, world, from BPF! My PID is %d\n", pid);
好多了! 不幸的是,虽然方便,但这种实现并不理想,因为它必须在每次调用 bpf_printk()
时使用格式字符串的内容初始化堆栈上的 char 数组。 由于向后兼容性问题,libbpf 被困在这种次优实现上,因为它是唯一可以在旧内核上可靠地工作的实现,因此不会破坏任何 BPF 应用程序,这是 libbpf 作为通用库的高优先级。
另一方面,我在这篇博文中不受向后兼容性的限制。 因此,我可以并且将在本文的其余部分展示如何显着改进此实现。
在 Linux 5.9 之前,bpf_trace_printk()
将采用格式字符串并按原样使用它。 因此,如果忘记(或选择不)将 \n
添加到格式字符串中,trace_pipe 输出会变得一团糟。 bpf_printk("Hello, world!")
执行几次将导致:
<...>-179528 [065] .... 1863682.484368: 0: Hello, world! <...>-179528 [065] .... 1863682.484381: 0: Hello, world! <...>-179528 [065] .... 1863683.484447: 0: Hello, world!
从 ac5a72ea5c89 (“bpf: Use dedicated bpf_trace_printk event instead of trace_printk()”)开始(进入上游 Linux 5.9),bpf_trace_printk() 现在将始终在末尾附加换行符,因此对于 bpf_printk(“Hello, world!”); 你会看到一个整洁的输出:
<...>-200501 [001] .... 1863840.478848: 0: Hello, world!
<...>-200501 [002] .... 1863841.478916: 0: Hello, world!
<...>-200501 [002] .... 1863842.478991: 0: Hello, world!
这很好,但是如果你之前小心(应该这样做)地在格式字符串的末尾添加 \n
,那么在 Linux 5.9 之前的内核上使用 bpf_printk("Hello, world!\n")
会产生不错的输出像上面一样。 但是从 Linux 5.9 开始,你会得到一个令人讨厌的稀疏浪费的输出:
<...>-3658431 [048] d... 5362570.510814: bpf_trace_printk: Hello, world!
<...>-3658431 [048] d... 5362571.510933: bpf_trace_printk: Hello, world!
<...>-3658431 [048] d... 5362572.511048: bpf_trace_printk: Hello, world!
虽然不是世界末日,但在处理讨厌的 \n
时保持一致的行为并且不关心内核版本差异会很棒,不是吗?
好消息是,在 BPF CO-RE 的帮助下,我们可以透明地检测和适应这些内核差异。 如果您查看上面提到的提交 ac5a72ea5c89,您会看到它添加了一个新的内核跟踪点 bpf_trace_printk,并巧妙地使用它向 /sys/kernel/debug/tracing/trace_pipe
发送数据。 还要注意内核中的每个跟踪点都有一个对应的 struct trace_event_raw_
类型。 我们将使用 struct trace_event_raw_bpf_trace_printk
的存在来检测 bpf_trace_printk()
是否添加了换行符。 如果没有,我们将确保在我们自己的 bpf_printk()
宏中默默地和透明地添加一个换行符。 让我们看看所有这些是如何组合在一起的:
[1] #include <bpf/bpf_core_read.h>
/* define our own struct definition if our vmlinux.h is outdated */
[2] struct trace_event_raw_bpf_trace_printk___x {};
#undef bpf_printk
#define bpf_printk(fmt, ...) \
({ \
[3] static char ____fmt[] = fmt "\0"; \
[4] if (bpf_core_type_exists(struct trace_event_raw_bpf_trace_printk___x)) {\
[5] bpf_trace_printk(____fmt, sizeof(____fmt) - 1, ##__VA_ARGS__); \
} else { \
[6] ____fmt[sizeof(____fmt) - 2] = '\n'; \
[7] bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
} \
})
让我们把它分解一下。
[1] 包括 libbpf 的 bpf_core_read.h
头文件,它定义了所有 BPF CO-RE 宏。
[2] 定义了我们自己的 bpf_trace_printk
跟踪点结构的本地最小(空)定义,以避免依赖最新的 vmlinux.h
。 这对于在 Linux 5.9 之前从内核 BTF 生成的 vmlinux.h
标头可能稍微过时的情况很重要。 添加 ___x
后缀确保它不会与最新的 vmlinux.h
中的定义冲突。 Libbpf 和 BPF CO-RE 将忽略 ___ 及其后的所有内容,因此这仍将与内核中的实际结构 trace_event_raw_bpf_trace_printk
匹配。 如果你确定你的 vmlinux.h
足够新,你可以跳过这一步。
[3] 有两个变化。 我们删除了 const
修饰符,因为我们将在运行时(在较旧的内核上)修改这个字符串,因此必须在可写的 .data
ELF部分和相应的 BPF 映射中分配它。 我们还在末尾附加了额外的 \0
来为 \n
保留一个空格,如果我们碰巧需要的话。 替换现有字符比在运行时添加一个简单得多,所以这就是我们在这里要做的。
[4] 是基于 BPF CO-RE 的跟踪点存在检测。 如果内核中存在指定的结构体 bpf_core_type_exists()
计算结果为 1,否则替换为 0。
[5]是Linux 5.9+的情况,所以我们不需要添加换行符。 我们唯一应该注意的是不要在格式字符串中传递两个 \0
,因为某些内核会在运行时拒绝它(并且您不会在 trace_pipe
文件中看到任何输出)。 这就是为什么将 sizeof(____fmt) - 1
指定为格式字符串的大小,跳过编译器在分配字符串时添加的隐式 '\0'
。
[6]-[7] 是较旧的 Linux 的情况,因此我们必须将显式保留的 \0
替换为 \n
以确保我们将获得正确包装的输出。 我们将完整的 ____fmt
大小传递给 bpf_trace_printk()
,包括隐含的 \0
。
有了这个, bpf_printk("Hello, world!")
总是会在最后发出一个换行符,调用者不必关心内核版本。 只需要确保始终传递没有显式“\n”
的格式字符串。
bpf_trace_printk()
在(即将发布的)Linux 5.13 版本中,由于 Florent Revest 在 d9c9e4db186a (“bpf: Factorize bpf_trace_printk and bpf_seq_printf”).
中的工作,bpf_trace_printk()
实现的功能得到了非常好的提升。
以前,bpf_trace_printk()
只允许使用一个字符串 (%s
) 参数,这是非常有限的。 Linux 5.13 版本解除了这个限制并允许多个字符串参数,只要总格式化输出不超过 512 字节。 另一个恼人的限制是缺乏对宽度说明符的支持,例如 %10d
或 %-20s
。 这个限制现在也没有了。 以下是其他重大改进的列表(来自上述提交的描述):
这意味着在最近的内核上,可以使用 bpf_trace_printk()
做更多的事情。 但是,如果想支持较旧的内核,则需要回退到更简单的逻辑。 问题是是否有可能可靠地检测是否可以预期更强大的 bpf_trace_printk()
行为。
BPF CO-RE 和 libbpf 实际上可以很好地帮助解决这个问题。 一种方法是使用 extern int LINUX_KERNEL_VERSION __kconfig;
变量显式检查上游 Linux 版本,但在 Linux 内核中存在向后移植的情况下,这不是很可靠。 对于此类向后移植的功能,Linux 内核版本与内核中包含的功能不对应。 因此,如果可能,最好直接检测所需的功能支持。
bpf_trace_printk()
重构恰好与添加一个新的bpf helper 函数bpf_snprintf()
相吻合,这些重构和改进是首先完成的。因此,我们不再依赖于内核版本检查,而是要检测对bpf_snprintf()
helper函数的支持。
每个 BPF helper 在 enum bpf_func_id
中都有一个对应的 BPF_FUNC_
枚举值。 因此,通过检查 vmlinux BTF 中是否存在给定的枚举值,可以确定相应的 BPF helper函数是否存在。 让我们看看如何在代码中做到这一点:
/* don't rely on up-to-date vmlinux.h */
[1] enum bpf_func_id___x { BPF_FUNC_snprintf___x = 42 /* avoid zero */ };
[2] #define printk_is_powerful \
(bpf_core_enum_value_exists(enum bpf_func_id___x, BPF_FUNC_snprintf___x))
...
const char power[] = "POWER";
int pid = bpf_get_current_pid_tgid() >> 32;
if (printk_is_powerful)
[4] bpf_printk("I've got the =%%= %7s, %s, %-7s =%%=!", power, power, power);
else
[5] bpf_printk("Sorry, NO %s! :( But my PID is %d", power, pid);
[1] 定义了我们自己的枚举 bpf_func_id
和 BPF_FUNC_snprintf
枚举值的最小定义。 同样,这是为了避免依赖于最新的 vmlinux.h,所以如果这与您无关,请随意跳过它。 注意在枚举和枚举值上都使用了 ___x
后缀,在这两种情况下 ___x
后缀都将被 libbpf 忽略。 实际值 42
也无关紧要,但最好避免使用零(默认值,除非明确指定),因为某些旧版本的 Clang 存在问题。
[2] 使用 bpf_core_enum_value_exists()
来检测正在运行的内核中是否存在 BPF_FUNC_snprintf
枚举值。 除了适用于枚举之外,它类似于以前使用的 bpf_core_type_exists()
。 如果枚举值存在,它将为 1,否则将返回 0。
[4] 处理了功能更丰富的 bpf_trace_printk()
实现的情况,并展示了使用 3 个字符串参数和一些更漂亮的格式。 此外,只是为了好玩,它使用 %%
转义。
[5] 是使用更原始和受限格式的后备案例。
就这样。 如果在 Linux 5.13+ 上运行,应该看到:
minimal-2167 [002] d..5 20804.858999: bpf_trace_printk: I've got the =%= POWER, POWER, POWER =%=!
minimal-2167 [002] d..5 20805.859180: bpf_trace_printk: I've got the =%= POWER, POWER, POWER =%=!
bpf_trace_printk()
(或者更确切地说,实际上是 bpf_printk()
包装器)是一种非常有用的工具,可以极大地简化 BPF 应用程序的调试。 它允许你从 BPF 应用程序的 BPF 端转储大量有用的信息,并通过 trace_pipe
文件观察它。 不幸的是,bpf_trace_printk()
的行为和功能的逐渐变化会带来不便,但希望这篇博文展示了如何通过谨慎使用 BPF CO-RE 和其他 libbpf 功能来合理地、足够透明地抽象出来( 例如,BPF 静态变量)。 希望这些信息可以在将来为您节省一些时间,并让您从 BPF 应用程序中获得更多收益。
bpf_trace_printk()
演化的逻辑延续是支持传入 3 个以上的输入参数,类似于现代的 printf() 之类的 BPF helper, 例如bpf_seq_printf()
和 bpf_snprintf()
来做到这一点。 毫无疑问,这将很快被添加,所以请留意 [email protected] 邮件列表。