此文裁剪翻译自https://nakryiko.com/posts/bcc-to-libbpf-howto-guide/#helper-sub-programs。个人翻译,水平有限。
BCC to libbpf
libbpf支持很多BCC不具备的特性,如全局变量 、BPF骨架。
BCC依赖于运行时编译,并内置了整个LLVM/Clang库,导致:
- 编译时高额资源消耗;
- 对内核头文件包的依赖,需在每个目标主机上安装。若用到内核中非公开头文件的内容,还需手动复制类型定义到BPF代码中;
- 错误只能在运行时发现。
基础
思路:BPF程序需与正常用户态程序类似——编译一次,以紧凑且未更改的形式部署到目标主机上。
libbpf:BPF程序加载器,完成基础设置工作(重定位,加载与验证BPF程序,创建BPF映射,挂载BPF钩子等),用户只需考虑BPF程序正确性与性能。
设置用户空间部分
构建步骤
使用BPF CO-RE构建基于libbpf的BPF程序包含以下步骤:
- 生成搜用内核类型的
vmlinux.h
头文件; - 使用Clang(10以上版本)编译BPF程序源码为
.o
文件; - including生成的BPF骨架头以在用户空间代码中使用;
- 至少,编译用户空间代码,将BPF对象代码嵌入其中。
上述步骤的具体运行依赖于具体的设置与构建系统。
当BPF代码被编译且BPF骨架被生成后,在用户空间代码中include libbpf和骨架头文件来使用必要的API。
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "path/to/your/skeleton.skel.h"
锁定的内存限制
BPF为BPF映射等使用锁定内存(locked memory)。默认情况下,限定值很低以至于实验性的BPF程序也无法成功加载入内核。BCC将该限定值无条件设定为无穷,但libbpf并非如此。
最好根据生产环境进行设定,没有更好办法的话则需手动在程序最开始通过setrlimit
系统调用设定:
#include <sys/resource.h>
rlimit rlim = {
.rlim_cur = 512UL << 20, /* 512 MBs */
.rlim_max = 512UL << 20,
};
err = setrliit(RLIMIT_MEMLOCK, &rlim);
if (err)
/* handle err */
Libbpf log
libbpf输出一系列不同可见性的日志。缺省情况下,libbpf将在控制台呈现错误级别的输出。推荐使用定制化日志回调,并设定开关调试级输出的能力:
int print_libbpf_log(enum libbpf_print_level lvl, const char *fmt, va_list args)
{
if (!FLAGS_bpf_libbpf_debug && lvl >= LIBBPF_DEBUG)
return 0;
return vfprintf(stderr, fmt, args);
}
/* ... */
libbpf_set_print(print_libbpf_log);
BPF骨架(skeleton)与BPF应用生命周期
一个BPF应用包含若干合作或独立的BPF程序,以及所有BPF程序共享的BPF映射、全局变量。其中,BPF映射与全局变量也可在用户空间部分使用。
BPF应用一般经历以下阶段:
- Open phase:BPF object文件被解析:BPF 映射,BPF程序与全局变量被发现但尚未创建。当BPF应用被打开后,在所有实体被创建与加载之前,可以进行附加性的调整(设置BPF程序类型,预设全局变量的初始值等);
- Load phase:创建BPF映射,解决各种重定向问题,将BPF程序加载入内核中并验证。BPF应用的所有部分都被验证并存在于内核中,但BPF程序尚未被执行。在load phase之后可以设置初始BPF映射状态,而无需考虑BPF程序代码执行。
- Attachment phase:BPF程序被挂载于各种BPF挂载点上。BPF开始正常工作,并进行BPF映射与全局变量的读取与更新。
- Tear down phase:在内核中分离并卸载BPF程序。摧毁BPF映射,释放BPF使用的资源。
生成的BPF骨架有对应的函数来触发各阶段:
<name>__open()
:创建并打开BPF应用;<name>__load()
:实例化、加载并验证BPF应用部分;<name>__attach()
:挂载所用自动可挂载BPF应用(这是可选的,在需要直接使用libbpf API进行更多控制时);<name>__destroy()
:卸载所用BPF程序并释放所用资源。
BPF代码转换
(从BCC转换到libbpf/BPF CO-RE)
检测BCC vs libbpf模式
当需要同时支持BCC和libbpf“模式”时,检测BPF程序代码正在编译为哪个模式是有用的。最简单的办法是借助BCC中的BCC_SEC
宏:
#ifdef BCC_SEC
#DEFINE __BCC__
#endif
之后,在BPF代码中,可以:
#ifdef __BCC__
/* BCC-specific code */
#else
/* libbpf-specific code */
#endif
从而可以创建一个通用的BPF源代码,只有BCC或libbpf的必要逻辑部分。
头文件包含
当使用libbpf/BOF CO-RE时,无需包含内核头文件(如,所有类似#include <linux/whatever.h>
),作为替代,仅需包含一个vmlinux.h
以及少量libbpf辅助头:
#indef __BCC__
/* linux headers needed for BCC only */
#else
#include "vmlinux.h" /* all kernel types */
#include <bpf/bpf_helpers.h> /* most used helpers: SEC, __always_inline, etc */
#include <bpf/bpf_core_read.h> /* for BPF CO-RE helpers */
#include <bpf/bpf_tracing.h> /* for getting kprobe arguments */
#endif
vmlinux.h
可能未包含一些有用的内核#define
常量,在这些情况下,需要在这里重新声明他们。同时,bpf_helpers.h
提供了大多数常用的变量集。
字段访问(Field accesses)
BCC隐性重写BPF代码,并将如tsk->parent->pid
的字段访问转化为一系列bpf_probe_read()
调用。
Libbpf/BPF CO-RE不具备这种功能,但是bpf_core_read.h
提供一系列帮助器来接近原始C语言。如tsk->parent->pid
将变为BPF_CORE_READ(tsk, parent, pid)
。
使用自Linux 5.5以来的tp_btf
和fentry/fexit
BPF程序类型,自然C语法也是可行的。但为更早的内核或其他BPF程序类型(如tracepoints与kprobe),最好转为使用BPF_CORE_READ
.
此外,BPF_CORE_READ
宏在BCC模式下也可用,所以为避免在每个字段访问时重复#ifdef __BCC__ / #else / #endif
,可以将BCC和libbpf模式下的所有字段读取转换为BPF_CORE_READ
。在BCC下,确保bpf_core_read.h
头文件被在最终程序中包含。
BPF映射
BCC和libbpf定义BPF映射的方式很不同,但转换是很直接的,例如:
/* Array */
#ifdef __BCC__
BPF_ARRAY(my_array_map, struct my_vlaue, 128);
#else
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 128);
__type(key, u32);
__type(vlaue, struct my_value);
} my_array_map SEC(".maps");
/* Hashmap */
#ifdef __BCC__
BPF_HASH(my_hash_map, u32, struct my_value);
#else
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1);
__type(key, u32);
__type(vlue, struct my_value);
} my_hash_map SEC(".maps");
#endif
/* Per-CPU array */
#ifdef __BCC__
BPF_PERCPU_ARRAY(heap, struct my_value, 1);
#else
struct {
__uint(type, BPF_MAP_TYPE_PRERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, struct my_value);
} heap SEC(".maps");
#endif
注:需注意BCC中映射的默认大小,通常为10240,而libbpf可以具体设定。
PERF_EVENT_ARRAY
,STACK_TRACE
与一些其他专用映射(如DEVMAP
、CPUMAP
等)尚不支持BTF类型,因此直接指定key_size
/value_size
即可:
/* perf event array (for use with perf_buffer API) */
#ifdef __BCC__
BPF_PERF_OUTPUT(events);
#else
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
#endif
在BPF代码中访问BPF映射
BCC使用伪C++风格来操作映射,并使用真实BPF帮助器调用进行暗中重写。一般地,使用以下模式
some_map.operation(some, args);
这需要被重写为:
bpf_map_operation_elem(&some_map, some, args);
一些例子:
#ifdef __BCC__
struct event *data = heap.lookup(&zero);
#else
struct event *data = bpf_map_lookup_elem(&heap, &zero);
#endif
#ifdef __BCC__
my_hash_map.update(&id, my_val);
#else
bpf_map_update_elem(&my_hash_map, &id, &my_val, 0 /* flags */);
#endif
#ifdef __BCC__
events.perf_submit(args, data, data_len);
#else
bpf_perf_event_output(args, &events, BPF_F_CURRENT_CPU, data, data_len);
#endif
BPF程序
所有表示BPF程序的函数都需要使用来自bpf_helpers.h
的SEC()
宏进行定制化节名称标记,例如:
#if !defined(__BCC__)
SEC("tracepoint/sched/sched_process_exec")
#endif
int tracepoint__sched__sched_process_exec(
#ifdef __BCC__
struct tracepoint__sched__sched__process_exec *args
#else
struct trace_event_raw_sched_process_exec *args
#endif
) {
/* ... */
}
这只是一个惯例,但遵从libbpf段命名会有助于带来更好的体验。具体的惯例名可见于libbpf/src/libbpf.c at 787abf721ec8fac1a4a0a7b075acc79a927afed9 · libbpf/libbpf。最常见的一些有:
tp/<category>/<name>
:tracepoint;kprobe/<func_name>
:kprobe;kretprobe/<func_name>
:kretprobe;raw_tp/<name>
:raw tracepoint;cgroup_skb/ingress
,cgroup_skb/egress
以及一整簇cgroup/<subtype>
程序。
Tracepoints
在“BPF程序”中的例子中,注意跟踪点上下文类型的类型名之间的细微差别。
BCC为tracepoint ”/“遵从tracepoint__<category>__<name>
命名模式。BCC在运行时编译时(during runtime compilation)自动创建相符的类型。
libbpf并不具备,但内核已经提供与所有tracepoint数据类似的类型。一般地,其将会被命名为trace_event_raw_<name>
,但是有时一些内核中的tracepoint会重用通用类型,所以如果上述模式不起效,则需尝试在内核源码或vmlinux.h
中查找确切类型名。例如,需要使用trace_event_raw_sched_process_template
而非trace_event_raw_sched_process_exit
。
在大多数情况下,访问跟踪点上下文数据的代码是完全相同的,除了特殊变长字符串字段。对于这种情况(相同代码),转换是直接的:
date_loc_<some_field>
转换为__date_loc_<some_field>
。
Kprobes
BCC也具备一系列声明kprobe的魔法。事实上,这类BPF程序接受指向struct pt_regs
的单指针作为上下文参数,但BCC允许假装内核函数参数在BPF程序中直接可用。
而使用libbpf,可以借助BPF_KPROBE
宏实现类似的效果。该宏目前是内核子测试的bpf_trace_helpers.h
头中的一部分,但很快会成为libbpf的一部分:
#ifdef __BCC__
int kprobe__acct_collect(struct pt_regs *ctx, long exit_code, int group_dead)
#else
int BPF_KPROBE(kprobe__acct_collect, long exit_code, int group_dead)
#endif
{
/* BPF code accessing exit_code and group_dead here */
}
对于返回探针,也有相应的BPF_KRETPROBE
宏。
**注意!**syscall调用函数在4.17内核中被重命名了。自4.17版本开始,曾被成为如
sys_kill
等的syscall探针如今名为__x64_sys_kill
(在x64系统上,当然其他架构有不同的前缀)。当尝试挂载kprobe或kretprobe时需要注意这一点。然而,如果可能的话,建议尝试使用tracepoint。
注:如果开发需要tracepoint/kprobe/kretprobe的BPF应用,检查新的raw_tp/fentry/fexit探针,他们提供更好的性能与可用性,且在5.5内核开始可用。
处理BCC中的编译时#if
在BCC代码中依赖编译器的#ifdef
和#if
条件是非常流行的。这么做的最常见原因是出于内核版本的不同或是启用/禁用可选的逻辑部分(取决于应用配置)。
此外,BCC允许从用户空间一侧提供定制的#define
,并在BPF代码编译期间进行运行时切换。这常用于定制化可变参数。
在libbpf+BPF CO-RE以相同方式实现这种效果是不可能的(使用编译时逻辑),因为整体思路是BPF程序应该只被编译一次并能够处理内核和应用配置的所有可能变化。
为处理内核版本不同,BPF CO-RE支持两种补充机制:Kconfig externs和struct “flavors”。BPF代码通过声明以下外部变量知道它正在处理哪一个内核版本:
#define KERNER_VERSION(a, b, c)(((a) << 16) + ((b) << 8) + (c))
extern int LINUX_KERNEL_VERSION __kconfig;
if (LINUX_KERNEL_VERSION < KERNER_VERSION(5,2,0)) {
/* deal with older kernels */
} else {
/* 5.2 or newer */
}
与获取内核版本类似,可以通过Kconfig提取任意CONFIG_xxx
值:
extern int CONFIG_HZ __kconfig;
通常,如果一些字段被重命名或移动至子结构,仅需通过检测该字段是否存在于目标内核即可发现该事实。可以通过使用一个帮助器bpf_core_file_exists(<field>)
来实现。当具体字段在目标内核中存在时,其返回1,否则返回0。
与struct flavor配合使用,这允许处理内核结构布局的主要变化。
以下为一个如何适应不同内核版本间struct kernfs_iattrs
的不同的简短事例:
/* struct kernfs_iattrs will com from vmlinux.h */
struct kernfs_iattrs___old {
struct iattr ia_iattr;
};
if (bpf_core_field_exists(root_kernfs->iattr->ia_mtime)) {
date->cgroup_root_mtime = BPF_CORE_READ(root_kernfs, iattr, ia_mtime.tv_nsec);
} else {
struct kernfs_iattrs___old *root_iattr = (void *)BPF_CORE_READ(root_kernfs, iattr);
data->cgroup_root_mtime = BPF_CORE_READ(root_iattr, ia_iattr.ia_mtime.tv_nsec);
}
应用配置
BPF CO-RE通过全局变量来定制化程序的行为。
全局变量使得用户空间控制应用能够在BPF程序被加载和验证前预设必要的参数和标志。
全局变量可以是常量或变量:
- 常量(只读)用于在BPF程序被加载与验证前指定其一次性配置;
- 变量用于在BPF程序被加载与验证后BPF程序与用户空间对应部分的双向数据交换。
在BPF代码一侧,可使用const volatile
声明只读全局变量。(变量则删除该限定符):
const volatile struct {
bool feature_enabled;
int pid_to_filter;
} my_cfg = {};
注意:
- 需指定
const volatile
以免编译器优化(编译器可能错误假定0值并酱紫内嵌入代码中); - 如果定义一个变量(mutable),确定其不能被
static
修饰,非静态变量与编译器的互操作性最好。此时,volatile
通常是不必要的; - 变量必须初始化,否则libbpf将拒绝加载该BPF应用。初始化值将成为变量的默认值,除非被控制应用修改。
在BPF代码中使用全局变量如下:
if (my_cfg.feature_enabled) {
/* ... */
}
if(my_cfg.pid_to_filter && pid == my_cfg.pid_to_filter) {
/* .. */
}
全局变量避免BPF映射查找开销。
常量的值对BPF验证器是熟知的,在程序验证期间可以被视为常量,从而允许BPF验证器来精确验证,同时高效消除死代码分支。
使用BPF骨架来在控制应用中提供这类变量的值:
struct <name> *skel = <name>__open();
if (!skel)
/* handle errors */
skel->rodata->my_cfg.feature_enabled = true;
skel->rodate->my_cfg.pid_to_filter = 123;
if (<name>__load(skel))
/* handle errors */
只读型变量,在用户空间中只能在BPF骨架被加载前设定与修正。加载后用户态或BPF均不可更改。
这种保证允许BPF验证器在验证期间将这些变量视为常量并在死代码消除中表现得更好。
非常量变量,则在BPF骨架被加载后BPF程序的整个生命周期均可被修改,无论是用户空间还是BPF侧。
常见问题
全局变量
BPF全局变量与用户空间变量表现完全一致:
- 可用于表达式
- 非常量可被更新
- 可获取其地址并传递给帮助器函数
但以上仅限于BPF代码侧。
在用户侧,BPF全局变量只能通过BPF骨架读取和更新:
skel->rodata
用于只读变量;skel->bss
用于初始值为0的变量;skel->data
用于初始值非零的变量;
在用户侧,BPF全局变量并非全局变量,而是BPF骨架的成员,在BPF骨架的加载阶段被初始化。即,在用户空间代码中声明同名全局变量是与BPF代码中对应百纳零互相独立的。
循环展开
除非目标内核在5.3版本以上,所有BPF代码中的循环需使用#pragma unroll
标记来强制Clang展开它们并消除任何可能的控制流循环。
#pragma unroll
for (i = 0; i < 10; i++) { ... }
若无循环展开,或循环不能在固定次数迭代后终止,则会发生验证器错误,显示“back-edge from insn X to Y”,意为BPF验证器检测到了无线循环或无法验证循环可以在有限次迭代后终止。
帮助器子程序
如果使用静态函数,并在4.16之前的内核中运行,需要使用内嵌的static __always_inline
标记,从而BPF验证器会将其视为单一的大函数:
static __always_inline unsigned long
probe_read_lim(void *dst, void *src, unsigned long len, unsigned long max)
{
...
}
4.16内核支持BPF-to-BPF函数调用。libbpf(v0.2+)也对该特性提供完全通用支持,能够确保所有正确的代码重定向和调整。故可删掉__always_inline
。甚至需考虑使用__noinline
强制非内嵌,从而提升代码生成并避免一些由于不希望的register-to-stack溢出导致的常见BPF验证错误。
static __noinline unsigned long
probe_read_lim(void *dst, void *src, unsigned long len, unsigned long max)
{
...
}
非内嵌全局函数也自5.5内核开始被支持,但具有与静态函数不同的约束和检查条件。
bpf_printk调试
BPF程序没有惯例的能够设置断点、检查变量和BPF映射,或单步运行的调试器,但有时没有这种工具是无法检测出BPF代码中的错误的。
在这种情况下,最好的方法是记录多余的调试信息。
使用bpf_printk(fmt, args...)
来输出额外数据,其支持类似printf
的格式化字符串,但只能处理至多3个参数。其代价高昂,不易用于生产环境,主要用于临时调试。用法:
char comm[16];
u64 ts = bpf_ktime_get_ns();
u32 pid = bpf_get_current_pid_tgid();
bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("ts: %lu, comm: %s, pid: %d\n", ts, comm, pid);
记录的信息可见于/sys/kernel/debug/tracing/trace_pipe
文件。