B.10 程序类型与附加类型
在 B.3 中构建的 hello 程序中,如果将节声明 SEC("tp/syscalls/sys_enter_write") 替换为 SEC("xdp"),代码在 clang 编译时依然能够顺利通过,bpftool gen skeleton 也同样不会产生任何警告;倘若函数体内部没有访问任何上下文指针字段,单独执行程序加载甚至也能一次性成功。然而,这个程序自此便再也不会在系统发生 write() 调用时被触发,因为 XDP 钩子的执行时机深植于网卡驱动接收报文的极早阶段。更进一步,假若函数体像 B.5 那样去解引用 ctx->args[1],验证器就会毫不留情地予以拦截:因为 XDP 所对应的上下文并非跟踪点的结构体,而是专属于网络包元数据的 struct xdp_md,验证器正是以此结构为基准来严密校验每一次内存访问的。决定这些底层运行行为与校验规则的基石,正是 eBPF 的程序类型(program type)。
程序类型与附加类型(attach type)共同构成了 eBPF 运行时的分类基石。程序类型从根本上规范了代码能够感知的上下文结构、允许调用的内核辅助函数边界、返回值的控制含义、支持的挂载机制以及睡眠能力与权限要求;而附加类型则进一步在同一大类程序中精细刻画具体的挂载位置。明确 SEC 名称如何映射到具体的程序与附加类型、厘清哪些程序能够由骨架自动化挂载,是编写跨越跟踪、网络与安全领域的综合 eBPF 程序的前提。
文中的演示示例收录于 code/系统编程/拓展B_eBPF/B_10_程序类型与附加类型/many_types.bpf.c 文件中,它在同一个编译目标单元内紧凑地定义了七种不同类型的 eBPF 程序。相关编译流程沿用前文约定的工具链配置;下文中引用的 readelf、bpftool gen skeleton 与 bpftool -d 诊断输出均来自系统实际运行结果(基于 clang 18 与 bpftool 7.5 生成)。
1. 程序类型决定了什么
程序类型是程序在载入内核时通过系统调用显式提交的一个整型枚举,定义于内核头文件的 enum bpf_prog_type。同一个 C 语言函数体,仅仅由于程序类型的更迭,其所置身的内核执行环境便会发生根本性的转变。
| 由程序类型决定 | tracepoint 程序(B.3) | XDP 程序(B.12) |
|---|---|---|
| 上下文 ctx 的结构 | struct trace_event_raw_sys_enter | struct xdp_md |
| 可调用的辅助函数与 kfunc | 跟踪类:bpf_get_current_comm、bpf_get_stack 等 | 网络类:bpf_xdp_adjust_head、bpf_redirect_map 等 |
| 返回值的含义 | 没有含义,按惯例返回 0 | XDP_PASS、XDP_DROP、XDP_TX、XDP_REDIRECT、XDP_ABORTED |
| 附加方式 | perf_event_open 加 BPF_LINK_CREATE | 把程序挂到某块网卡上 |
| 加载所需权限 | CAP_BPF 与 CAP_PERFMON | CAP_BPF 与 CAP_NET_ADMIN |
| 能否睡眠 | 否 | 否 |
在上述属性中,「能否睡眠」具有特殊的工程意义。大多数 eBPF 程序运行在严格禁止调度的原子上下文(例如中断处理例程或 RCU 读保护临界区)中,任何引发睡眠的操作都会破坏系统内核状态。直到近年来的内核演进中,可睡眠能力才被有选择地引入跟踪与安全领域:诸如 fentry、fexit、fmod_ret、iter、struct_ops、lsm 以及 uprobe 逐步衍生出了对应的可睡眠变体,而专门用于承载长耗时系统调用逻辑的 syscall 类型则强制要求处于可睡眠状态。在 libbpf 的命名约定中,通常通过在 SEC 名称后追加 .s 后缀来声明可睡眠属性,如 fentry.s/do_unlinkat 或 lsm.s/file_open。这类程序唯有在目标挂载点本身处于安全可调度上下文时方可获准加载,且其在执行期间仅被允许调用带有可睡眠标记的辅助函数。
程序类型决定的东西
程序类型在 BPF_PROG_LOAD 时随程序一起提交,它决定上下文的结构、可调用的辅助函数与 kfunc、返回值的含义、允许的附加方式,以及加载所需的权限和能否睡眠。验证器按程序类型和附加类型检查程序,不按 SEC 名称检查;SEC 名称只是 libbpf 用来推断这两者的文本。
判断一个辅助函数究竟是否可用,最终取决于内核中为该程序类型单独维护的函数白名单表,而不能仅凭本地头文件是否存在声明来推断。例如 bpf_get_current_comm 仅被登记在跟踪类程序的合法辅助函数表中,若在 XDP 程序中强行调用,便会在加载期遭遇 program of this type cannot use helper bpf_get_current_comm#16 错误;同理,专用于解析网络缓冲区的 bpf_skb_load_bytes 仅对处理 sk_buff 的网络类型开放,tracepoint 程序完全无权使用。由于通用的 bpf_helper_defs.h 头文件为了兼顾全局声明而将所有辅助函数尽数罗列,编译器在前端阶段无法捕捉这类类型越界行为,只有在验证器介入推演时,类型不匹配的错误才会被彻底暴露。
加载后,一个 eBPF 程序拿到的 ctx 是什么结构,由什么决定?
2. 附加类型:同一类型,不同挂载位置
在 eBPF 的架构设计中,仅仅依靠程序类型有时仍不足以精准约束程序的全部运行细节。以 BPF_PROG_TYPE_CGROUP_SKB 为例,这类程序既可以用来拦截并过滤离开当前 cgroup 的出站报文(egress),也可以挂载在入站路径上审查抵达该 cgroup 的进站报文(ingress)。虽然在这两种场景下,程序接收到的上下文参数在类型上均体现为 struct __sk_buff,但它们所绑定的物理触发点属于内核网络协议栈中完全不同的两个钩子,且内核对出入站返回值所允许的动作空间也存在微妙差异。为了在同一个程序类型内部进一步细化挂载位置与控制语义,内核引入了附加类型(attach type)机制。
2.1 常见映射对比
| 程序类型 | 附加类型 | SEC 名称 | 区别 |
|---|---|---|---|
CGROUP_SKB | BPF_CGROUP_INET_INGRESS | cgroup_skb/ingress | 进入 cgroup 的报文 |
CGROUP_SKB | BPF_CGROUP_INET_EGRESS | cgroup_skb/egress | 从 cgroup 发出的报文 |
CGROUP_SOCK_ADDR | BPF_CGROUP_INET4_CONNECT | cgroup/connect4 | connect() 之前调用 |
CGROUP_SOCK_ADDR | BPF_CGROUP_INET4_BIND | cgroup/bind4 | bind() 之前调用 |
CGROUP_SOCK_ADDR | BPF_CGROUP_UDP4_SENDMSG | cgroup/sendmsg4 | sendmsg() 之前调用 |
TRACING | BPF_TRACE_FENTRY | fentry/函数名 | 函数入口 |
TRACING | BPF_TRACE_FEXIT | fexit/函数名 | 函数出口 |
TRACING | BPF_TRACE_RAW_TP | tp_btf/事件名 | 带类型的跟踪点 |
TRACING | BPF_TRACE_ITER | iter/task | 迭代器 |
BPF_PROG_TYPE_CGROUP_SOCK_ADDR 这一个类型下面就有二十来种附加类型:IPv4 与 IPv6 各一套,bind、connect、sendmsg、recvmsg、getpeername、getsockname 各一个,加上 Unix 域套接字的一套。它们的回调时机不同,ctx 里的字段含义也略有差别。BPF_PROG_TYPE_TRACING 也一样,fentry、fexit、fmod_ret、tp_btf、iter 共用同一个程序类型,全靠附加类型分开。
2.2 为什么必须在加载前定下来
验证器在处理加载请求时,必须预先明确程序对上下文结构体的访问是否合法、所调用的辅助函数是否合规。以 cgroup 套接字地址程序为例,cgroup/connect4 的上下文类型是 struct bpf_sock_addr,程序被允许读写其中的 user_port 字段以实施动态端口重定向;而挂载在 cgroup/sendmsg4 上的程序虽然接收同一结构体作为入参,但其能够调用的辅助函数列表以及各字段的读写权限均与前者有所不同。假若内核等到运行期真正执行附加操作时才知晓程序具体挂载在哪个钩子上,验证器在加载阶段就根本无法建立起严密且确定的证明前提。内核头文件中针对 bpf_attr 内部 expected_attach_type 字段的注释极其直白地揭示了这一设计初衷:
/* For some prog types expected attach type must be known at
* load time to verify attach type specific parts of prog
* (context accesses, allowed helpers, etc).
*/
__u32 expected_attach_type;2
3
4
5
因此附加类型被安排在 bpf_attr 的 expected_attach_type 字段中,与程序类型一并提交给内核。在使用 libbpf 框架时,通常无需开发者手动填报该值:SEC 节名称中斜杠后方的标识符已经确立了这一映射,通过 bpf_program__expected_attach_type() 即可读取推算出的类型常量。唯有在直接调用 bpf() 系统调用裸载入、或者需要显式覆写 SEC 默认推导规则时,才需要使用 bpf_program__set_expected_attach_type() 进行手动干预。
附加类型的作用
附加类型是程序类型之下的二级分类,指出程序挂在同一种程序类型下的哪一个钩子上。它必须在加载时提交,因为上下文访问的合法性、可用的辅助函数和返回值的允许范围都要按具体钩子来检查。同一个程序类型下,附加类型不同,程序看到的 ctx 字段和能做的事就不同。
SEC("tcx/ingress") 对应的程序类型和附加类型是什么?
3. SEC 名称与 libbpf
3.1 SEC 名称的组成
libbpf 深度依赖 ELF 文件中的节名称(SEC name)来自动化推导程序类型、预期附加类型以及具体的附加目标。在 libbpf 内部维护的映射表中,SEC 名称的匹配规则主要遵循两大范式:
- 精确匹配模式:诸如
xdp、socket、cgroup/connect4等标准节名,必须与内部登记的表项完全一致才能完成命中。 - 前缀加斜杠模式:在表项中通常声明为带有加号的形式(如
type+)。此时节名称既可以是纯粹的类型前缀本身,也可以在前缀后通过正斜杠追加自定义字符串。斜杠后方承载的字符串被称为附加目标(attach target),例如kprobe/do_unlinkat中的do_unlinkat函数名,或是uprobe//bin/ls:main中所指定的二进制文件绝对路径与符号名称。
这种前缀后是否显式携带附加目标的差异,直接决定了程序后续能否借助骨架实现免配置的自动化附加。以 fentry/do_unlinkat 为例,节名中携带的目标函数名会在加载前夕被 libbpf 自动解析为内核 BTF 中对应的函数类型 ID,进而在附加阶段水到渠成地完成挂载;反之,若在源码中仅书写了笼统的 SEC("fentry"),libbpf 无法仅凭节名推测具体的挂载目标,开发者就必须在用户态代码中显式调用 bpf_program__set_attach_target() 来补充指定目标符号。
3.2 常用 SEC 名称
下表按 libbpf 1.5 的 section_defs 整理,只列常用的部分。「自动附加」一栏表示 many_types_bpf__attach() 或 bpf_program__attach() 能否直接处理。
| 类别 | SEC 名称 | 程序类型 | 附加类型 | 自动附加 |
|---|---|---|---|---|
| 跟踪 | kprobe/函数、kretprobe/函数 | KPROBE | — | 是 |
| 跟踪 | uprobe/路径:函数、uretprobe/路径:函数 | KPROBE | — | 是 |
| 跟踪 | ksyscall/系统调用名、kretsyscall/系统调用名 | KPROBE | — | 是 |
| 跟踪 | usdt/路径:提供者:名称 | KPROBE | — | 是 |
| 跟踪 | tp/类别/事件、tracepoint/类别/事件 | TRACEPOINT | — | 是 |
| 跟踪 | raw_tp/事件 | RAW_TRACEPOINT | — | 是 |
| 跟踪 | tp_btf/事件名 | TRACING | BPF_TRACE_RAW_TP | 是 |
| 跟踪 | fentry/函数、fexit/函数、fmod_ret/函数 | TRACING | BPF_TRACE_FENTRY 等 | 是 |
| 跟踪 | perf_event | PERF_EVENT | — | 否 |
| 跟踪 | iter/类型 | TRACING | BPF_TRACE_ITER | 是 |
| 网络 | socket | SOCKET_FILTER | — | 否 |
| 网络 | xdp | XDP | BPF_XDP | 否 |
| 网络 | tcx/ingress、tcx/egress | SCHED_CLS | BPF_TCX_INGRESS 等 | 否 |
| 网络 | tc、classifier | SCHED_CLS | 无(值为 0) | 否 |
| 网络 | cgroup_skb/ingress、cgroup_skb/egress | CGROUP_SKB | BPF_CGROUP_INET_INGRESS 等 | 否 |
| 网络 | sk_skb/stream_parser、sk_msg | SK_SKB、SK_MSG | 对应附加类型 | 否 |
| 网络 | sockops、sk_lookup、flow_dissector、netfilter | 各自类型 | 对应附加类型 | 否 |
| cgroup | cgroup/sock、cgroup/sock_create | CGROUP_SOCK | 对应附加类型 | 否 |
| cgroup | cgroup/connect4、cgroup/bind4、cgroup/sendmsg4 | CGROUP_SOCK_ADDR | 对应附加类型 | 否 |
| cgroup | cgroup/dev、cgroup/sysctl、cgroup/getsockopt | 各自类型 | 对应附加类型 | 否 |
| 其他 | lsm/钩子名、lsm.s/钩子名 | LSM | BPF_LSM_MAC | 是 |
| 其他 | lsm_cgroup/钩子名 | LSM | BPF_LSM_CGROUP | 否 |
| 其他 | struct_ops、struct_ops.s | STRUCT_OPS | — | 特殊 |
| 其他 | syscall | SYSCALL | — | 否 |
| 其他 | freplace/函数 | EXT | — | 需先指定目标程序 |
tc 和 classifier 在 libbpf 源码里带着一行注释:/* deprecated / legacy, use tcx */。新代码写 tcx/ingress,读旧代码时知道 tc 是什么就行。
3.3 哪些程序能自动附加
骨架头文件中生成的 attach 系列包装函数在执行时会遍历目标对象中登记的每一个程序实例,但它并非无差别地尝试挂载所有程序,而是逐一检索 libbpf 的节定义表;唯有那些已在表中注册了专用自动附加回调函数(prog_attach_fn)的程序,才会触发挂载流程,其余程序则会被安全跳过。libbpf 内部的具体过滤逻辑如下:
/* auto-attaching not supported for this program */
if (!prog->sec_def || !prog->sec_def->prog_attach_fn)
continue;2
3
程序是否具备自动附加能力,根本上取决于两项前置条件:一是 SEC 节名称中是否已经内蕴了定位挂载点所需的全部元数据,二是内核在执行具体的附加动作时是否需要引入额外的宿主环境对象。
- 跟踪类程序绝大多数支持自动附加。无论是
kprobe/函数、tp/类别/事件还是fentry/函数,其挂载所需的内核符号名或静态事件标识均已完整内嵌在节名称中,libbpf 可以自行完成符号解析与事件注册。 - 网络链路类程序(如
xdp与tcx/ingress)则无法自动附加。这类程序在挂载时必须明确指定归属于哪一块具体的物理或虚拟网络接口(通过网卡索引ifindex标识),而 ELF 的节名称无法固化特定主机的网卡编号。因此用户态代码必须先在程序集合中定位目标程序,再主动调用bpf_program__attach_xdp(prog, ifindex)或bpf_program__attach_tcx(prog, ifindex, NULL)进行绑定。其中ifindex可通过标准库的if_nametoindex("lo")等函数动态解析;tcx的可选配置参数在不需要定制执行次序时直接传入NULL即可。 - cgroup 相关的程序同样无法自动挂载。将程序绑定至特定的 control group 需要提供该 cgroup 节点在虚拟文件系统中的目录文件描述符,用户态通常需要先使用
open()打开/sys/fs/cgroup/...目录,随后调用bpf_program__attach_cgroup(prog, cgroup_fd)完成挂载。 - 类似地,针对套接字层面的
socket、sockops、sk_msg等程序,附加时必须绑定特定的网络套接字句柄或套接字映射表,因而同样属于手动附加的范畴。
用 bpftool -d 看 libbpf 的判断
bpftool -d gen skeleton 会把 libbpf 的调试日志打到标准错误。日志里每个程序一行 sec '...': found program ...,SEC 名称不认识时会有 unrecognized ELF section name。这条命令不需要 root,也不加载程序,适合在写完 .bpf.c 之后先跑一遍。
骨架的 attach() 为什么不会自动附加 SEC("xdp") 的程序?
3.4 示例:一个文件里放七种程序
many_types.bpf.c 在一个文件里放了七个程序,函数体都是空的,只为让每个节里有一段指令:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
char LICENSE[] SEC("license") = "Dual BSD/GPL";
SEC("tp/syscalls/sys_enter_write")
int tp_write(void *ctx)
{
(void)ctx;
return 0;
}
SEC("kprobe/do_unlinkat")
int kp_unlink(void *ctx)
{
(void)ctx;
return 0;
}
SEC("fentry/do_unlinkat")
int BPF_PROG(fe_unlink, struct filename *name)
{
(void)ctx;
(void)name;
return 0;
}
SEC("xdp")
int xdp_pass(struct xdp_md *ctx)
{
(void)ctx;
return XDP_PASS;
}
SEC("tcx/ingress")
int tcx_ingress(struct __sk_buff *skb)
{
(void)skb;
return TCX_PASS;
}
SEC("cgroup_skb/egress")
int cg_egress(struct __sk_buff *skb)
{
(void)skb;
return 1;
}
SEC("lsm/file_mprotect")
int BPF_PROG(lsm_mprotect, struct vm_area_struct *vma, unsigned long reqprot,
unsigned long prot, int ret)
{
(void)ctx;
(void)vma;
(void)reqprot;
(void)prot;
(void)ret;
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
编译用 B.3 的统一命令:
clang -g -O2 -target bpf -mcpu=v3 -D__TARGET_ARCH_x86 -I. -c many_types.bpf.c -o many_types.bpf.o每个 SEC 名称都会变成一个可执行节。readelf -SW 列出来是这样:
$ readelf -SW many_types.bpf.o
There are 27 section headers, starting at offset 0xd81e8:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .strtab STRTAB 0000000000000000 0d8070 000177 00 0 0 1
[ 2] .text PROGBITS 0000000000000000 000040 000000 00 AX 0 0 4
[ 3] tp/syscalls/sys_enter_write PROGBITS 0000000000000000 000040 000010 00 AX 0 0 8
[ 4] kprobe/do_unlinkat PROGBITS 0000000000000000 000050 000010 00 AX 0 0 8
[ 5] fentry/do_unlinkat PROGBITS 0000000000000000 000060 000010 00 AX 0 0 8
[ 6] xdp PROGBITS 0000000000000000 000070 000010 00 AX 0 0 8
[ 7] tcx/ingress PROGBITS 0000000000000000 000080 000010 00 AX 0 0 8
[ 8] cgroup_skb/egress PROGBITS 0000000000000000 000090 000010 00 AX 0 0 8
[ 9] lsm/file_mprotect PROGBITS 0000000000000000 0000a0 000010 00 AX 0 0 8
[10] license PROGBITS 0000000000000000 0000b0 00000d 00 WA 0 0 1
...2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
节号 3 到 9 就是七个程序,每个 16 字节,也就是两条指令(写入 R0 再退出)。11 到 16 与 21 到 24 是调试信息,17 是 .BTF,19 是 .BTF.ext,最后是符号表;license 节里存的是许可证字符串,长度 13 字节,正好是 Dual BSD/GPL 加结尾的零字节。去掉 -g 之后节的总数从 27 降到 13,.BTF 和 .BTF.ext 一起消失,gen skeleton 照样能生成骨架,但 BTF 相关的信息就没有了。
bpftool gen skeleton 生成的骨架把七个程序都收进 progs,并为它们各留一个 link 指针:
$ bpftool gen skeleton many_types.bpf.o > many_types.skel.h
$ wc -l many_types.skel.h
37039 many_types.skel.h
$ grep -n -A 20 '^struct many_types_bpf {' many_types.skel.h
13:struct many_types_bpf {
14- struct bpf_object_skeleton *skeleton;
15- struct bpf_object *obj;
16- struct {
17- struct bpf_program *tp_write;
18- struct bpf_program *kp_unlink;
19- struct bpf_program *fe_unlink;
20- struct bpf_program *xdp_pass;
21- struct bpf_program *tcx_ingress;
22- struct bpf_program *cg_egress;
23- struct bpf_program *lsm_mprotect;
24- } progs;
25- struct {
26- struct bpf_link *tp_write;
27- struct bpf_link *kp_unlink;
28- struct bpf_link *fe_unlink;
29- struct bpf_link *xdp_pass;
30- struct bpf_link *tcx_ingress;
31- struct bpf_link *cg_egress;
32- struct bpf_link *lsm_mprotect;
33- } links;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
骨架文件有三万多行,绝大部分是内嵌的目标文件字节和反汇编注释。程序名取自函数名,links 里的指针在附加成功后才被填上。
3.5 SEC 写错与 license 写错
当开发者不慎将 SEC 名称拼写错误(例如将 xdp 误写为 xpd)时,编译器前端对此完全无感知,bpftool gen skeleton 也能顺畅执行完毕并正常生成骨架头文件,命令返回码依然为 0:
$ bpftool gen skeleton many_types_bad_sec.bpf.o > many_types_bad_sec.skel.h
$ echo $?
0
$ bpftool -d gen skeleton many_types_bad_sec.bpf.o > /dev/null
libbpf: loading object 'many_types_bad_sec_bpf' from buffer
libbpf: elf: section(3) xpd, size 16, link 0, flags 6, type=1
libbpf: sec 'xpd': found program 'xdp_pass' at insn offset 0 (0 bytes), code size 2 insns (16 bytes)
libbpf: elf: section(4) license, size 13, link 0, flags 3, type=1
libbpf: license of many_types_bad_sec_bpf is Dual BSD/GPL
libbpf: looking for externs among 9 symbols...
libbpf: collected 0 externs total
libbpf: prog 'xdp_pass': unrecognized ELF section name 'xpd'2
3
4
5
6
7
8
9
10
11
12
unrecognized ELF section name 会记录在调试日志流中,此时该程序在内部未能匹配到任何合法的程序类型,停留在 BPF_PROG_TYPE_UNSPEC 状态。在这一阶段,骨架的 open() 步骤依然能够成功完成,加载失败的现象被推迟到了 load() 阶段:libbpf 的 bpf_object_load_prog() 在真正向内核派发 bpf() 系统调用前会严格核验类型,一旦发现程序类型仍停留在 UNSPEC,就会立即向标准错误打印诊断信息并返回 -EINVAL 错误:
prog 'xdp_pass': missing BPF prog type, check ELF section name 'xpd'gen skeleton 的核心职责是提取 ELF 结构并生成 C 代码接口,它并不负责拦截未知的节名称:拼写错误的前缀只会导致程序“缺失类型定义”,代码生成本身依然正常推进。因此排查此类问题,既可以依赖 bpftool -d 打印的内部调试日志,也可以通过实际发起一次内核加载请求来暴露隐患。
另一处容易引发编译期困惑的地方在于许可证对象的命名声明。如果声明对象的符号名恰好与目标节名重合,写成 char license[] SEC("license"),clang 和 GNU 汇编器都会中断编译并抛出错误:
error: license changed binding to STB_GLOBAL
error: symbol 'license' is already defined2
Assembler messages:
Error: symbol `license' is already defined2
这一现象的原因脱离了 eBPF 机制本身,属于底层 ELF 汇编器处理符号标签时的语法冲突:全局符号 license 恰好要被安放在同名为 license 的目标节中,汇编器无法区分同名的节名与全局符号标签,因而报出符号重定义。解决这一冲突只需将符号名称略作调整,改写为 LICENSE、__license 或 my_license 等任意不重名的合法标识符即可,因为内核与 libbpf 在加载时只会检索 ELF 节头部表中的节名称字符串 license,完全不关心该对象在 C 源码中使用了何种局部标识符。libbpf-bootstrap 和本章示例统一采用大写 LICENSE,也有项目习惯写成 __license,二者在底层效果上完全一致。
char license[] SEC("license") = "Dual BSD/GPL";char LICENSE[] SEC("license") = "Dual BSD/GPL";4. 程序类型版图
把已知的程序类型按用途分组,可以得到下面这张图。图中每个分支对应一大类钩子,具体写法分别在 B.11、B.12、B.13 展开。
跟踪类覆盖 B.11 的全部内容:kprobe 与 uprobe 挂在内核函数和用户函数上,tracepoint 与 raw_tp 挂在预先埋好的静态点上,fentry 与 fexit 经由 BPF 蹦床挂在函数入口和出口,perf_event 按频率采样。它们的共同点是 ctx 直接描述被观测的那次调用。
网络类覆盖 B.12。XDP 在网卡驱动刚收到包时运行,看到的是原始以太网帧;tc 与 tcx 在协议栈的入口和出口运行,看到的是 struct __sk_buff;sk_skb、sk_msg、sockops 和 sk_lookup 与套接字层配合,做重定向和查找;netfilter 用来替代或补充 iptables 的钩子。
cgroup 类覆盖 B.13 的后半部分。程序附加到一个 cgroup 之后,对组里的所有进程生效:cgroup_skb 过滤进出的报文,cgroup/sock_addr 在 connect、bind、sendmsg 等调用发生前决定放行或拒绝,cgroup/dev 控制设备访问,cgroup/sysctl 拦截对 sysctl 的读写。
安全类只有 LSM 一族。lsm/钩子名 挂在 Linux 安全模块的钩子上,lsm_cgroup/钩子名 把范围缩小到某个 cgroup。BPF LSM 的启用条件写在 B.13。
扩展内核接口这一类,目前主要是 struct_ops:程序把自己的函数填进内核的一张操作表里,替换 TCP 拥塞控制算法或者调度器(sched_ext)。这类程序的写法与前面几类差别很大,本拓展只点名,不展开。
其他一类里,syscall 类型的程序不挂在任何事件上,由用户态通过 BPF_PROG_TEST_RUN 主动运行,程序里可以调用 bpf_sys_bpf 和 bpf_sys_close 这两个辅助函数,相当于替用户态执行 bpf() 和 close(),运行完即返回;这个类型必须写成可睡眠的。freplace 用来替换另一个已加载程序的某个函数,用于热修复,或者给没有源码的 BPF 程序追加逻辑。
还有一类程序由内核的遍历过程触发:BPF 迭代器(BPF iterator)。SEC("iter/task") 的程序在内核遍历任务列表时被逐个调用。用户态用 bpf_program__attach_iter() 得到 BPF link,再用 bpf_iter_create() 换成可读的文件描述符,read() 出来的就是程序写进 seq_file 的内容;用 bpftool iter 创建并固定到 bpffs 的 link 同样可以这样读。libbpf-bootstrap 的 task_iter 示例遍历系统里的所有任务:
$ sudo ./task_iter
Task Info. Pid: 3647645. Process Name: TTLSFWorker59. Kernel Stack Len: 3. State: INTERRUPTIBLE
Task Info. Pid: 1600495. Process Name: tmux: client. Kernel Stack Len: 6. State: INTERRUPTIBLE
Task Info. Pid: 1600497. Process Name: tmux: server. Kernel Stack Len: 0. State: RUNNING
Task Info. Pid: 1600498. Process Name: bash. Kernel Stack Len: 5. State: INTERRUPTIBLE2
3
4
5
迭代器的 ctx 是 struct bpf_iter__task,里面只有 meta 和 task 两个指针;程序返回 0 表示继续遍历,返回非 0 结束。它不适合观测“某个调用发生了”,适合回答“现在系统里有什么”。
5. 返回值的含义
返回值的含义完全由程序类型决定,同一个 return 0 在 tracepoint 里是“什么都不做”,在 cgroup/connect4 里是“拒绝连接”。
| 程序类型(SEC 名称) | 返回值 |
|---|---|
tracepoint、kprobe(tp/、kprobe/) | 不影响被观测的代码,按惯例返回 0 |
fentry、fexit(fentry/、fexit/) | 必须是 0 |
XDP(xdp) | XDP_ABORTED 0、XDP_DROP 1、XDP_PASS 2、XDP_TX 3、XDP_REDIRECT 4 |
tcx(tcx/ingress、tcx/egress) | TCX_NEXT -1、TCX_PASS 0、TCX_DROP 2、TCX_REDIRECT 7 |
cgroup_skb(cgroup_skb/ingress) | 1 放行、0 丢弃 |
cgroup_skb(cgroup_skb/egress) | 1 放行、0 丢弃;2 丢弃并通知拥塞、3 放行并通知拥塞 |
| cgroup/connect4、sendmsg4 等 sock_addr | 1 放行、0 拒绝,connect() 返回 EPERM |
LSM(lsm/钩子) | 0 放行,负的 errno 拒绝(如 -EPERM) |
| lsm_cgroup | 1 放行、0 拒绝 |
iter(iter/task) | 0 继续遍历,1 结束;验证器只允许这两个值 |
XDP 的五个动作在 enum xdp_action 里,XDP_ABORTED 表示程序自身出错,XDP_DROP 丢弃,XDP_PASS 交给协议栈,XDP_TX 从同一块网卡发回,XDP_REDIRECT 送到别的网卡或 CPU。tcx 的四个动作在 enum tcx_action_base 里,TCX_NEXT 表示交给同一挂载点上的下一个程序。
验证器会检查一部分类型的返回值范围:fentry 与 fexit 必须返回 0;cgroup_skb 的 ingress 接受 0 和 1,egress 接受 0~3;cgroup 的 sock_addr 程序默认接受 0 和 1,其中 bind 放宽到 0~3(高位用来携带标志),recvmsg、getpeername、getsockname 则必须是 1;LSM 按钩子的返回值类型限定范围(布尔钩子 0 或 1,其余 0 或负 errno)。超出范围时加载失败,日志形如:
At program exit the register R0 has smin=... smax=... should have been in [0, 1]XDP 和 tcx 的返回值不做范围检查。运行时遇到不认识的动作,XDP 会丢弃报文并打印一次 Illegal XDP return value ... expect packet loss!,tcx 则把它当作 TCX_NEXT。靠验证器兜底不如照着枚举写。
6. 查询当前内核支持什么
同一个内核镜像编进了哪些程序类型、每个类型能用哪些辅助函数,取决于内核配置和版本。查这些信息用 bpftool feature probe:
sudo bpftool feature probe
sudo bpftool feature probe kernel macros prefix BPF_2
输出按节分组,每节以 Scanning ... 开头,每一项一行。程序类型那一节的行形如 eBPF program_type xdp is available,不支持时写成 is NOT available;辅助函数那节先打印 eBPF helpers supported for program type <名称>:,随后逐行列出可用的辅助函数。默认情况下 bpf_probe_write_user() 和 bpf_trace_printk() 这两个会产生内核日志的探测不运行,加上 full 才全测。以非 root 身份运行时要加 unprivileged,否则探测结果会因为权限不足而失真。
只想看 bpftool 自己认识哪些名字,用 list_builtins。它不探测系统,直接列出编译时从 libbpf 和 UAPI 头文件里带进来的清单:
$ bpftool feature list_builtins prog_types
unspec
socket_filter
kprobe
sched_cls
sched_act
tracepoint
xdp
perf_event
cgroup_skb
cgroup_sock
lwt_in
lwt_out
lwt_xmit
sock_ops
sk_skb
cgroup_device
sk_msg
raw_tracepoint
cgroup_sock_addr
lwt_seg6local
lirc_mode2
sk_reuseport
flow_dissector
cgroup_sysctl
raw_tracepoint_writable
cgroup_sockopt
tracing
struct_ops
ext
lsm
sk_lookup
syscall
netfilter2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
一共 33 项,第一项 unspec 是无效值,其余 32 项是这一版内核头文件里的全部程序类型。同一个子命令还接受 map_types、attach_types、link_types、helpers 四组名字,写脚本时可以用它遍历。
在 C 程序里查同样的信息,用 libbpf 的两个探测函数:
int libbpf_probe_bpf_prog_type(enum bpf_prog_type prog_type, const void *opts);
int libbpf_probe_bpf_helper(enum bpf_prog_type prog_type,
enum bpf_func_id helper_id, const void *opts);2
3
两者都返回 1 表示支持、0 表示不支持,参数不合法时返回负值;opts 目前传 NULL。它们的做法是构造一个最小的程序并尝试加载,用完即弃,所以需要与加载真实程序相同的权限。权限不够时加载失败,函数把这些失败一并算作“不支持”,于是 libbpf_probe_bpf_prog_type(BPF_PROG_TYPE_XDP, NULL) 在缺少 CAP_NET_ADMIN 的进程里返回 0;libbpf 头文件里的说明也是“探测时确保进程具有所需的 CAP_* 权限”。这也是 bpftool feature probe 在非 root 下要加 unprivileged 的原因。
查一个辅助函数能不能用
想知道某个辅助函数在哪些程序类型里可用,先用 bpftool feature list_builtins helpers 拿到名字和编号,再用 libbpf_probe_bpf_helper() 逐个类型试。这比翻内核源码快,也不依赖手边的内核版本。
7. 习题
7.1 巩固
写出下面五个 SEC 名称对应的程序类型和附加类型,并判断骨架的 attach() 能否直接附加:
SEC("fentry/do_unlinkat")SEC("cgroup/connect4")SEC("tp/sched/sched_process_exec")SEC("xdp")SEC("lsm/file_mprotect")
可以对照前文 3.2 节的对应表进行核验。
下面四个程序片段都只写了返回语句,判断哪些会在加载时被验证器拒绝,哪些能通过;能通过的,说明返回值在运行时代表何种控制语义。
/* A:SEC("cgroup/connect4") */ return 2;
/* B:SEC("cgroup_skb/egress") */ return 4;
/* C:SEC("xdp") */ return 7;
/* D:SEC("fentry/do_unlinkat") */ return 1;2
3
4
code/系统编程/拓展B_eBPF/B_10_程序类型与附加类型/many_types.bpf.c 里的许可证写成 char LICENSE[] SEC("license")。把它改成小写的 license,用前述统一编译命令进行编译,观察报错;然后分别用 __license、my_license、lic 三种符号名编译,确认三种都能通过,并用 readelf -SW 确认节名仍然是 license。说明为什么节名不能跟着改。
7.2 编程
写一个 one_xdp.bpf.c,只含一个 SEC("xdp") 程序,返回 XDP_PASS。
- 编译后运行
readelf -SW,列出程序节、license、.BTF、.BTF.ext的名字。 - 用
bpftool gen skeleton生成骨架,指出progs和links里各有几个成员。 - 把 SEC 改成
xpd,重复上面的步骤,比较bpftool gen skeleton两次的输出,再用bpftool -d gen skeleton找出哪一行日志指出了问题。
验收标准:能说出“生成骨架成功”和“程序类型被正确识别”是两件事。
写一个命令行工具 prog_types,接受一个 .bpf.o 路径,打印其中每个程序的 SEC 名称、程序类型名、附加类型名和函数名。在 many_types.bpf.o 上应当得到七行,例如:
SEC TYPE ATTACH FUNC
tp/syscalls/sys_enter_write tracepoint cgroup_inet_ingress tp_write
kprobe/do_unlinkat kprobe cgroup_inet_ingress kp_unlink
fentry/do_unlinkat tracing trace_fentry fe_unlink
xdp xdp xdp xdp_pass
tcx/ingress sched_cls tcx_ingress tcx_ingress
cgroup_skb/egress cgroup_skb cgroup_inet_egress cg_egress
lsm/file_mprotect lsm lsm_mac lsm_mprotect2
3
4
5
6
7
8
提示:用 bpf_object__open_file() 打开目标文件,用 bpf_object__for_each_program() 遍历,用 bpf_program__section_name()、bpf_program__type()、bpf_program__expected_attach_type() 读取属性,用 libbpf_bpf_prog_type_str() 和 libbpf_bpf_attach_type_str() 转成名字。
注意前两行的附加类型一栏:这两个程序没有专门的附加类型,bpf_program__expected_attach_type() 返回 0,而 0 恰好是 BPF_CGROUP_INET_INGRESS 的枚举值,libbpf_bpf_attach_type_str(0) 会老实地给出 cgroup_inet_ingress。所以“没有附加类型”在 API 里表现为 0,要结合程序类型判断这一栏是否有意义,不要指望拿到 NULL。
验收:与自己用 bpftool -d gen skeleton 观察到的 section 列表逐行对上。
写一个 mixed.bpf.c,里面放三个程序:SEC("tp/syscalls/sys_enter_openat")、SEC("cgroup/connect4")、SEC("xdp")。再写一个用户态加载程序,完成下面的流程:
- 打开并加载骨架;
- 用
bpf_object__find_program_by_name()找到 XDP 程序,用bpf_program__attach_xdp(prog, if_nametoindex("lo"))附加到回环网卡; - 打开
/sys/fs/cgroup目录,用bpf_program__attach_cgroup()附加 connect4 程序; - 让 tracepoint 程序由骨架的
attach()自动附加; - 按 Ctrl-C 退出时依次销毁 link 和骨架。
只要求编译通过、能说清每一步为什么要手动做。加载与附加需要 root,按 B.2 的提醒在自己的虚拟机里做。
7.3 综合与挑战
要拒绝某个服务进程连接 127.0.0.1:20002,同时记录每次被拒绝的进程名。设计一个方案:
- 从程序类型版图里选出两个候选类型,说明各自的挂载点和能力;
- 说明为什么不能用
tracepoint/syscalls/sys_enter_connect实现“拒绝”(提示:跟踪类程序的返回值没有意义); - 写出最终方案的程序骨架和用户态附加步骤;
- 说明这个程序需要哪些权限,以及附加到哪个 cgroup 才能只影响目标进程。
写一个工具 probe_matrix,用 libbpf_probe_bpf_prog_type() 遍历 bpf_prog_type 直到 __MAX_BPF_PROG_TYPE,对每个支持的类型再用 libbpf_probe_bpf_helper() 检查 bpf_get_current_comm、bpf_skb_load_bytes、bpf_xdp_adjust_head、bpf_ringbuf_output 四个辅助函数是否可用,输出一张表。
要求:
- 类型名用
libbpf_bpf_prog_type_str()转换;辅助函数名 libbpf 没有提供转换函数,是自己写一张只有四个名字的表,还是从bpftool feature list_builtins helpers的输出里取,任选一种; - 在支持 KPROBE、XDP、CGROUP_SKB 的内核上运行,核对结果与本节“辅助函数取决于程序类型”的说法一致;
- 用
bpftool feature probe的输出交叉验证其中三项。
这个工具会加载探测程序,必须在自己的机器上以 root 运行。
libbpf 允许注册自定义的 SEC 前缀。阅读 libbpf_register_prog_handler() 和 struct libbpf_prog_handler_opts 的文档,写一个用户态程序,为 myprog/ 前缀注册一个处理函数:把匹配到的程序设成 BPF_PROG_TYPE_XDP,并从 SEC 名称里取出网卡名,附加时自动找到对应的 ifindex。
- 写一个
.bpf.c,程序写成SEC("myprog/lo"); - 在用户态注册处理函数,用
bpf_object__open_file()打开后检查bpf_program__type()是否已经是xdp; - 用
bpf_object__attach_skeleton()或自己遍历程序完成附加; - 说明自定义前缀会不会影响
bpftool gen skeleton生成的骨架,以及为什么。
8. 参考资料
- BPF program types and ELF sections,libbpf 文档中的 SEC 名称与程序类型对照表。
- Program types,ebpf.io 对各程序类型的上下文、附加方式和返回值的说明。
- bpf(2) man page,
BPF_PROG_LOAD的prog_type与expected_attach_type字段。 - bpftool-feature(8),
feature probe与list_builtins的用法。 - libbpf-bootstrap,
task_iter示例及其 README 中的输出。 - Linux 内核源码,
check_return_code()、can_be_sleepable()与加载权限检查。 - include/linux/bpf.h,
enum bpf_prog_type与enum bpf_attach_type。