BPF 进阶笔记(四):调试 BPF 程序
关于 “BPF 进阶笔记” 系列
平时学习和使用 BPF 时所整理。由于是笔记而非教程,因此内容不会追求连贯,有基础的 同学可作查漏补缺之用。
文中涉及的代码,如无特殊说明,均基于内核 5.10。
- BPF 进阶笔记(一):BPF 程序(BPF Prog)类型详解:使用场景、函数签名、执行位置及程序示例
- BPF 进阶笔记(二):BPF Map 类型详解:使用场景、程序示例
- BPF 进阶笔记(三):BPF Map 内核实现
- BPF 进阶笔记(四):调试 BPF 程序
- BPF 进阶笔记(五):几种 TCP 相关的 BPF(sockops、struct_ops、header options)
1 打印日志
1.1 日志路径及格式
本节将介绍的几种打印日志方式最终都会输出到 debugfs 路径 /sys/kernel/debug/tracing/trace
:
$ sudo tail /sys/kernel/debug/tracing/trace
# 字段说明 <taskname>-<pid> <cpuid> <opts> <timestamp> <fake by bpf> <log content>
telnet-470 [001] .N.. 419421.045894: 0x00000001: <formatted msg>
以上看到的是默认 trace 输出格式,
- 可通过
/sys/kernel/debug/tracing/trace_options
定制化 trace 输出格式(打印哪些列);- 另外还可参考
/sys/kernel/debug/tracing/README
,其中有更详细的说明。
字段说明:
telnet
:进程名;470
:进程 ID;001
:进程所在的 CPU;-
.N..
:每个字符表示一组配置选项,依次为,- 是否启用了中断(irqs);
- 调度选项,这里 N 表示设置了
TIF_NEED_RESCHED
和PREEMPT_NEED_RESCHED
标志位; - 硬中断/软中断是否正在运行;
- level of preempt_disabled
419421.045894
:时间戳;0x00000001
:BPF 使用的一个 fake value,for instruction pointer register;<formatted msg>
:日志内容。
1.2 bpf_printk()
:kernel 5.2+
使用方式
这是内核 libbpf 库提供的一个宏:
// https://github.com/torvalds/linux/blob/v5.10/tools/lib/bpf/bpf_helpers.h#L17
/* Helper macro to print out debug messages */
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), \
##__VA_ARGS__); \
})
使用非常方便,和 C 的 printf()
差不多,例如,
bpf_printk("tcp_v4_connect latency_us: %u", latency_us);
使用限制
-
需要内核 5.2+,否则编译能通过,但执行时会报错:
map .rodata: map create: read- and write-only maps not supported (requires >= v5.2)
这个错误提示非常奇怪(实际上目前来说,大部分 BPF 错误提示都不那么直接)。
简单来说,BPF 的栈空间非常小,每次调用
bpf_printk()
都会动态声明一个char ____fmt[] = fmt;
并放到栈上,导致性能很差。 5.2 引入了 BPF global (and static) 变量,因此 clang 在编译时 可以直接将这些变量放到 ELF 的只读区域(.rodata
,read-only data),libbpf 加载程序时将这些数据放到一个.rodata
BPF map 中,程序在用到这些变量时,背后执行一次 map lookup 即可。 相比于每次都在栈上创建一个字符数组(字符串),这样更加快速和高效。更多内容,见 Andrii Nakryiko 的博客 Improving
bpf_printk()
。 -
最多只能带 3 个参数,即
bpf_printk(fmt, arg1, arg2, arg3)
。这是由
bpf_trace_printk()
的限制决定的,下一节有具体解释。
内核实现
前面已经看到 bpf_printk()
非常简单,只是单纯封装了一下 bpf_trace_printk()
,
后者定义在 include/uapi/linux/bpf.h
,具体实现见下文。
1.3 bpf_trace_printk()
对于 5.2 以下的内核,打印日志可以用 bpf_trace_printk()
,它比 bpf_printk()
要麻烦一点:要提前声明格式字符串 fmt
。
使用方式
// https://github.com/torvalds/linux/blob/v5.10/include/uapi/linux/bpf.h#L772
/**
* long bpf_trace_printk(const char *fmt, u32 fmt_size, ...)
*/
-
功能与
printk()
类似,按指定格式将日志打印到/sys/kernel/debug/tracing/trace
中; 但支持的格式比 printk() 少;5.10
支持%d
,%i
,%u
,%x
,%ld
,%li
,%lu
,%lx
,%lld
,%lli
,%llu
,%llx
,%p
,%s
。不支持指定字符串或数字长度等,否则会返回-EINVAL
(同时什么都不打印)。5.13
有进一步增强,见 Detecting full-powered bpf_trace_printk()。
- 每次调用这个函数时,会往 trace 中追加一行;当
/sys/kernel/debug/tracing/trace
is open,日志会被丢弃, 可使用/sys/kernel/debug/tracing/trace_pipe
来避免这种情况; - 这个函数执行很慢,因此只应在调试时使用;
-
fmt
格式串是否有默认换行:5.9
之前没有,需要自己加\n
;5.9+
会默认加一个换行符,patch 见 bpf: Use dedicated bpf_trace_printk event instead of trace_printk()。
函数的返回值是写到 buffer 的字节数,出错时返回负的 error code。
例子:
char fmt[] = "tcp_v4_connect latency_us: %u";
bpf_trace_printk(fmt, sizeof(fmt), latency_us);
使用限制
- 最多只能带 3 个参数(这是因为 eBPF helpers 最多只能带 5 个参数,前面
fmt
和fmt_size
已经占了两个了); - 使用该函数的代码必须是 GPL 兼容的;
- 前面已经提到,格式字符串支持的类型有限,但
5.13
有进一步改进,详见 Detecting full-powered bpf_trace_printk()。
内核实现
实现:
// https://github.com/torvalds/linux/blob/v5.10/kernel/trace/bpf_trace.c#L428
BPF_CALL_5(bpf_trace_printk, char *, fmt, u32, fmt_size, u64, arg1, u64, arg2, u64, arg3)
{
...
}
其中 BPF_CALL_5 的定义:
// https://github.com/torvalds/linux/blob/v5.10/include/linux/filter.h#L485
#define BPF_CALL_x(x, name, ...) \
static __always_inline u64 ____##name(__BPF_MAP(x, __BPF_DECL_ARGS, __BPF_V, __VA_ARGS__)); \
typedef u64 (*btf_##name)(__BPF_MAP(x, __BPF_DECL_ARGS, __BPF_V, __VA_ARGS__)); \
u64 name(__BPF_REG(x, __BPF_DECL_REGS, __BPF_N, __VA_ARGS__)); \
u64 name(__BPF_REG(x, __BPF_DECL_REGS, __BPF_N, __VA_ARGS__)) { \
return ((btf_##name)____##name)(__BPF_MAP(x,__BPF_CAST,__BPF_N,__VA_ARGS__));\
} \
static __always_inline u64 ____##name(__BPF_MAP(x, __BPF_DECL_ARGS, __BPF_V, __VA_ARGS__))
#define BPF_CALL_5(name, ...) BPF_CALL_x(5, name, __VA_ARGS__)
2 用 BPF 程序 trace 另一个 BPF 程序
2.1 使用场景
BPF trampoline 是 内核函数和 BPF 程序之间、BPF 程序和其他 BPF 程序之间的桥梁(更多介绍见附录)。 使用场景之一是 tracing 其他 BPF 程序,例如 XDP 程序。 现在能向任何网络类型的 BPF 程序 attach 类似 fentry/fexit 的 BPF 程序,因 此能够看到 XDP、TC、LWT、cgroup 等任何类型 BPF 程序中包的进进出出,而不会影 响到这些程序的执行,大大降低了基于 BPF 的网络排障难度。
一些 patch,如果感兴趣:
- bpf: Introduce BPF trampoline
- bpf: Support attaching tracing BPF program to other BPF programs
- libbpf: Add support for attaching BPF programs to other BPF programs
- libbpf: Add support to attach to fentry/fexit tracing progs
- trampoline impl: jit_com, trampoline, verifier, btf, good doc
BPF trampoline 其他使用场景:
- fentry/fexit BPF 程序:功能与 kprobe/kretprobe 类似,但性能更好,几乎没有性能开销(practically zero overhead);
-
动态链接 BPF 程序(dynamicly link BPF programs)。
在 tracing、networking、cgroup BPF 程序中,是比 prog array 和 prog link list 更加通用的机制。 在很多情况下,可直接作为基于 bpf_tail_call 程序链的一种替代方案。
这些特性都需要 root 权限。
2.2 依赖:kernel 5.5+
3 设置断点,单步调试
3.1 bpf_dbg
(仅限 cBPF)
见 (译) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021)。
附录
BPF trampoline 简介
“trampoline” 是意思是“蹦床”,这里是指程序执行时的特殊“适配+跳转”。 BPF trampoline 最初用于 tracing 和 fentry/fexit,但后面扩展到了其他场景,例如 更高效地跟踪 XDP 程序,解决 XDP 程序开发和排障痛点。
这个 patch 引入了 BPF trampoline 概念,将原生调用约定(native calling convention) 转换成 BPF 调用约定(BPF calling convention), 从而使内核代码能几乎零开销地(practically zero overhead)调用 BPF 程序。
BPF 架构和调用约定:
- 64 位 ISA(即使在 32 位架构上),
- R1-R5 用于 BPF function 传参,
- 主 BPF program 只接受一个参数
ctx
,通过 R1 传递。
CPU 原生调用约定:
- x86-64 前 6 个参数通过寄存器传递,其他参数通过栈传递;
- x86-32 前 3 个参数通过寄存器传递;
- sparc64 前 6 个参数通过寄存器传递;
trampoline 是架构相关的,因此其代码生成逻辑因架构而异。
BPF-to-kernel trampoline(BPF 调用内核函数)
这种 trampoline 早就有了:
宏 BPF_CALL_x (定义在 include/linux/filter.h
)将 BPF 中的 trampolines
静态地编译为内核辅助函数(helpers)。
这个过程最多能将 5 个参数转换成内核 C 指针或整数。
- 在 64 位机器上:不需做额外的处理(因为 BPF 本来就是针对 64
架构设计的,尤其关注与底层 ISA 的高效转换),因此 trampolines 都是
nop
指令; - 在 32 位架构上:trampolines 是有实际作用的。
Kernel-to-BPF trampoline(内核函数调用 BPF)
这些反向 trampolines 是由宏 CAST_TO_U64
和 __bpf_trace_##call()
shim functions(定义在 include/trace/bpf_probe.h)完成的。
它们将内核函数的参数们转成 u64 数组,这样 BPF 程序通过 R1=ctx 指针就能消费了。
这个 patch set
所做的工作与 __bpf_trace_##call()
static trampolines 类似,但通过动态方式,支持任何内核函数:
- 内核有 ~22k global 内核函数,能够在进入函数时(at function entry)通过 nop 来 attach。
-
函数参数和类型在 BTF 中有描述。
btf_distill_func_proto()
从 BTF 中提取有用信息,转换成“函数模型”(function model),然后架构相关的 trampoline generators 就能用这些信息来生成汇编代码,将内核函数参数转成 u64 数组。例如,内核函数
eth_type_trans()
有两个指针,它们会被转成 u64 然后存储到生 成的 trampoline 的栈中;指向这个栈空间的指针会放到 R1 传给 BPF 程序。在 x86-64 架构上,这种 generated trampoline 将会占用 16 字节栈空间,并将
%rdi
和%rsi
存储到栈上。 校验器会保证 BPF 程序只能以 read-only 方式访问到两个 u64 参数。此外,校验器 还能精确识别出指针的类型,不允许在 BPF 程序内将其转换(typecast)成其他类型。
fentry/fexit
相比 kprobe/kretprobe
的优势
-
性能更好。
数据中心中的一些真实 tracing 场景显示, 某些关键的内核函数(例如
tcp_retransmit_skb
)有 2 个甚至更多永远活跃的 kprobes, 其他一些函数同时有 kprobe and kretprobe。所以,最大化内核代码和 BPF 程序的执行速度就非常有必要。因此 在每个新程序 attach 时或者 detach 时,BPF trampoline 都会重新生成,以保证最高性能。 (另外在设计上,从 trampoline detach BPF 程序不会失败。)
-
能拿到的信息更多。
- fentry BPF 程序能拿到内核函数参数, 而
- fexit BPF 程序除了能拿到函数参数,还能拿到函数返回值;而 kretprobe 只能拿到返回结果。
kprobe BPF 程序通常将函数参数记录到一个 map 中,然后 kretprobe 从 map 中 拿出参数,并和返回值一起做一些分析处理。fexit BPF 程序加速了这个典型的使用场景。
-
可用性更好。
和普通 C 程序一样,直接对指针参数解引用, 不再需要各种繁琐的 probe read helpers 了。
限制:fentry/fexit BPF 程序需要更高的内核版本(5.5+
)才能支持。