写在前面
- 如果说
BCC更偏"先建立观察直觉",那libbpf更偏"把 eBPF 的主干工程能力真正学扎实" - 重点会放在:object、program、map、link、skeleton、
BTF/CO-RE、事件回传、生命周期和第一次最小改动 - 你真正要学的不是"把示例跑起来",而是"为什么现代 eBPF 工具会长成这样"
一个人知道自己为什么而活,就可以忍受任何一种生活。------尼采
先说清楚:libbpf 这条路线到底在解决什么问题
libbpf 这条路线最重要的价值,不是"API 更底层",而是它把现代 eBPF 工具的主干工程组织方式直接摆在你面前,你会在这条路线里第一次真正碰到这些东西:
- object
- program
- map
- link
- skeleton
BTFCO-RE- 用户态生命周期管理
所以这条路线不只是"又一种写法",而是你开始真正理解现代 eBPF 工程结构的地方。
为什么说 libbpf 是主干路线
因为很多现代 eBPF 工具,不管最后是不是 Go 控制面,本质上都绕不开 libbpf 这套对象组织思路。
它的优势主要在:
- 更贴近内核原生生态
CO-RE支持成熟object/program/map/link生命周期划分清楚skeleton 让 open/load/attach这条链更规整
如果你想真正理解:一个对象是怎么被加载的,一个程序是怎么 attach 的,一个 link 为什么存在、为什么退出后消失,一个事件结构体怎么从内核态走到用户态,那 libbpf 基本绕不过去。
第一次学 libbpf 从哪里开始
最稳的入口还是 libbpf-bootstrap。因为它给你的不只是 demo,而是一套非常适合作为教学样本的最小工程骨架。
更稳的顺序是:
- 先看
examples/c/minimal - 再看
examples/c/bootstrap - 再去看
kprobe / uprobe / fentry / usdt - 最后再补 libbpf 文档细节
为什么这个顺序更稳,因为它先给你最小骨架,再给你真实点的工具骨架。这样你不会一上来就被多个 hook,多个 map,多种 attach 方式,复杂用户态参数搞的头晕。
关于 minimal 和 bootstrap 这最开始的认知部分简单讲解过,这里在过一遍
libbpf-bootstrap 最值钱的教学点是什么
不是"它有几个示例",而是它把现代 eBPF 工具最核心的骨架都摆出来了。你会在里面碰到:
.bpf.c内核态程序- 用户态加载器
- skeleton
ring buffervmlinux.hCO-RE
它不是单纯告诉你:这里挂一个 probe,而是在告诉你,一个现代 eBPF 工具通常由哪几部分组成,它们怎么协作
第一次看 libbpf 相关术语,最该先分清哪些词
第一次一定要先把这 5 个词分开:
object可以先理解成:一整份 BPF 对象的集合视图,里面通常会包含:多个 program,多个 map,相关元信息program可以先理解成:真正会被挂到某个 hook 上执行的那段 eBPF 程序map可以先理解成:程序运行时的内核态数据存储link可以先理解成:某个 program 和某个具体 hook 之间已经建立好的连接skeleton可以先理解成:围绕这份 BPF 对象自动生成的一层用户态辅助外壳,它最大的教学价值在于:把 open/load/attach/destroy 这套流程包装得更统一,让初学者更容易把注意力放在对象关系,而不是一开始就陷进细碎底层 API
minimal 到底在教你什么
minimal 不是在教业务逻辑,而是在教你:一个最小 eBPF 工具到底靠哪些部件活起来,别只看"能不能跑",而要看这 4 件事:
- 用户态程序有没有把对象 open/load/attach 完
trace_pipe里有没有输出bpftool prog show和bpftool link show里有没有新对象- 停掉程序后这些现象会不会消失
minimal 最常见的感觉是:
- 程序启动后不立即退出
trace_pipe里有周期性bpf_printk()输出bpftool能看到对应 program 和 link
第一次把 bpftool prog/map/link show 和 minimal 对起来
很多人第一次会跑 bpftool,但还不会把它和示例现象对应起来。更稳的方式是只先做下面 3 组对照:
bpftool prog show:程序本体到底有没有真的进内核,第一次重点看有没有新的prog,type是不是你预期的类型,名字能不能大概和示例程序对应起来bpftool link show:程序和 hook 到底有没有真的连上,第一次重点看有没有新的link,它是不是关联到刚才那个progbpftool map show在minimal里它不是第一观察重点,因为minimal的教学重点不是复杂 map 协作。但到了bootstrap,它就会明显重要起来。
所以第一次minimal先盯 prog/link,bootstrap再认真盯 map
第一次跑 minimal,trace_pipe 负责告诉你"程序有输出",bpftool prog/link show 负责告诉你"程序和连接对象真的存在过"
第一次读源码,应该怎么分工
你可以先这样记:minimal.bpf.c回答"事件来了,内核里做什么",minimal.c回答"怎么把它装进去、接上去、留在那里",只要这个分工先立住,你后面看别的 libbpf 工具源码就不会那么乱。
https://github.com/libbpf/libbpf-bootstrap/blob/master/examples/c/minimal.bpf.c
第一次建议你只盯住下面 5 个点:
SEC("...")在哪里,这是你第一次定位"它挂在哪"的最快入口。你第一次不用先背 section 语法细节,只要先回答:这段程序是打算挂到哪类 hook 上- 函数参数是什么,这是你第一次理解"上下文从哪里来"的入口。你只要先知道:这不是普通用户态函数,每次触发时,内核会把当前事件对应的上下文传给它
- 函数体里真正做了什么,
minimal.bpf.c会取当前进程相关信息,调用bpf_printk()返回
c
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2020 Facebook */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "Dual BSD/GPL";
int my_pid = 0;
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
int pid = bpf_get_current_pid_tgid() >> 32;
if (pid != my_pid)
return 0;
bpf_printk("BPF triggered from PID %d.\n", pid);
return 0;
}
这会帮你建立一个特别稳的感觉:最小 eBPF 程序本体其实可以非常短,复杂的往往不在这里,而在外围组织
- 输出为什么走
trace_pipe,这一步一定要和前面"实战入门"那篇连起来记,这里不是用户态printf,这是内核态bpf_printk()写 tracing 输出流,你要去trace_pipe里看
minimal.c` 第一次到底该盯哪里 : https://github.com/libbpf/libbpf-bootstrap/blob/master/examples/c/minimal.c
你第一次看 minimal.c,不要被 skeleton 这些名字吓到。它的主流程其实非常规整,基本就是:
open -> 改配置 -> load -> attach -> 保活 -> destroy
下面把这段用户态代码按这个顺序拆开:
c
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
/* Copyright (c) 2020 Facebook */
#include <stdio.h>
#include <unistd.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "minimal.skel.h"
// libbpf 的日志回调:libbpf 内部如果有 debug/info/error,会走到这里
static int libbpf_print_fn(enum libbpf_print_level level,
const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
int main(int argc, char **argv)
{
// skeleton 对象:它把 maps/progs/links/bss 等资源都打包到一起
struct minimal_bpf *skel;
int err;
// 1) 注册 libbpf 日志回调,方便看到 open/load/attach 过程中的报错
libbpf_set_print(libbpf_print_fn);
// 2) open:先把 BPF 对象"打开"成 skeleton 实例
// 这一步更像把用户态可操作的控制对象准备好
skel = minimal_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
// 3) 在 load 之前给 .bss 里的全局变量赋值
// minimal.bpf.c 里有 int my_pid = 0;
// 这里把它改成当前进程 PID,让 BPF 只处理本进程触发的 write
skel->bss->my_pid = getpid();
// 4) load:把 BPF 程序和 map 真正装进内核,并触发 verifier 校验
err = minimal_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}
// 5) attach:把 BPF 程序挂到它声明的 hook 上
// 从这一步开始,内核事件真的会触发你的 BPF 逻辑
err = minimal_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}
printf("Successfully started! Please run "
"`sudo cat /sys/kernel/debug/tracing/trace_pipe` "
"to see output of the BPF programs.\n");
// 6) 保持进程存活,并周期性触发一次 write
// fprintf(stderr, ".") 会调用 write(2)
// 因为 BPF 程序挂在 sys_enter_write 上,所以这里就是触发源
for (;;) {
fprintf(stderr, ".");
sleep(1);
}
cleanup:
// 7) 退出时统一释放 skeleton 相关资源
minimal_bpf__destroy(skel);
return -err;
}
一些说明:
minimal_bpf__open() 不是"加载进内核",这是很多人第一次最容易混掉的地方。
open():把 skeleton 对象准备出来load():真正把程序/map 装进内核并做 verifier 校验attach():把程序挂到具体 hook 点
所以 open/load/attach 最好不要混着记,它们分别对应:拿到对象 -> 让内核接受它 -> 让事件真正能打到它
skel->bss->my_pid = getpid(); 是用户态给内核态"塞配置"
这一句特别值得单独记住,因为它第一次很直观地展示了 skeleton 的一个重要价值:用户态可以直接给 BPF 程序里的全局变量赋值
你在 minimal.bpf.c 里看到的是:
c
int my_pid = 0;
你在用户态看到的是:
c
skel->bss->my_pid = getpid();
这两句是连起来的。意思就是:
- BPF 程序里先定义一个全局变量
- skeleton 把它映射成用户态可访问的
.bss - 用户态在
load()前把值写进去 - 内核态运行时就能按这个值做过滤
所以第一次看 minimal,你会第一次很明确地感受到:用户态不只是负责启动程序,它还在给 BPF 程序注入运行参数
为什么这里明明没有显式调用 write(),BPF 还是会触发
因为这一句:
c
fprintf(stderr, ".");
最终还是会走到 libc -> write(2) 系统调用。
而 minimal.bpf.c 挂的是:
c
SEC("tp/syscalls/sys_enter_write")
所以每次循环打印一个点,其实就是在稳定制造一个 write 事件,让你能在 trace_pipe 里观察到:
text
BPF triggered from PID xxxx.
这也是 minimal 作为最小实验特别好的地方:它自己就顺手把触发源也准备好了。
为什么要看 trace_pipe
因为这里的内核态输出走的是:
c
bpf_printk("BPF triggered from PID %d.\n", pid);
这不是用户态终端标准输出,也不是 ring buffer 事件。它是 tracing 子系统的调试输出,所以观察路径是:
bash
sudo cat /sys/kernel/debug/tracing/trace_pipe
你可以把这条链记成:fprintf(stderr, ".") -> write(2) -> sys_enter_write tracepoint -> handle_tp() -> bpf_printk() -> trace_pipe
#把 minimal.c 压缩成一句最值得记的话,第一次看这段代码,最该形成的感觉不是"libbpf API 有点多",而是:
minimal.c 本质上是在做一件非常标准的事:准备 skeleton,注入参数,加载并挂载 BPF 程序,然后把进程留在现场,方便你观察内核态输出`
最容易把人绕晕的:minimal.bpf.c、minimal.c、minimal.skel.h 到底是什么关系
第一次看 libbpf 项目时,很多人不是卡在 BPF 程序本身,而是卡在:为什么明明我只写了 .bpf.c 和 .c,中间却突然冒出来一个 .skel.h? 你可以先把这 3 个文件记成下面这个分工:
minimal.bpf.c:内核态逻辑定义minimal.skel.h:编译生成的桥接层minimal.c:用户态装载与控制逻辑
如果画成最小关系图,大概就是这样:
text
minimal.bpf.c
|
| clang 编译 -> 生成 BPF ELF 对象
v
minimal.bpf.o
|
| bpftool gen skeleton
v
minimal.skel.h
|
| 被用户态程序 include
v
minimal.c
|
| open / load / attach / destroy
v
内核中的 BPF program + maps + links
三个文件的作用是什么
minimal.bpf.c 它回答的是:事件来了以后,内核里到底执行什么逻辑,挂在哪个 hook 上,取哪些内核上下文,按什么条件过滤,是否 bpf_printk()
minimal.skel.h 它回答的是:怎么把 BPF 对象里的 program/map/全局变量,包装成用户态好操作的 C 接口,它不是你手写出来的业务代码,而是"根据 minimal.bpf.o 自动生成的一层胶水代码"。第一次你不需要把这个头文件所有细节都看完,但最好先知道它通常会帮你生成这些东西:
struct minimal_bpfminimal_bpf__open()minimal_bpf__load()minimal_bpf__attach()minimal_bpf__destroy()skel->bss/skel->rodata/skel->maps/skel->progs/skel->links
也就是说,minimal.c 里你看到的这些 API:
c
skel = minimal_bpf__open();
err = minimal_bpf__load(skel);
err = minimal_bpf__attach(skel);
minimal_bpf__destroy(skel);
其实都不是"libbpf 通用固定函数名",而是 skeleton 根据对象名 minimal 生成出来的专属包装接口。
minimal.c 它回答的是:用户态怎么配置、加载、挂载并控制这份 BPF 程序 比如:给 .bss 变量赋值,调 open/load/attach,保持进程存活,最后释放资源
为什么说 minimal.skel.h 是桥接层
因为它刚好站在 minimal.bpf.c 和 minimal.c 中间:
- 它一头知道 BPF 对象里有哪些 program、map、全局变量
- 另一头把这些东西变成用户态能直接访问的结构和函数
你可以把它想成一个"自动生成的适配器":
text
minimal.bpf.c 说:我有一个全局变量 my_pid
minimal.skel.h 说:好,我把它映射成 skel->bss->my_pid
minimal.c 说:那我就在 load 前给 skel->bss->my_pid 赋值
所以这条链最好一起记:
c
// minimal.bpf.c
int my_pid = 0;
// minimal.c
skel->bss->my_pid = getpid();
如果你第一次能把这两句对上,后面再看更复杂的 skeleton 程序就会顺很多。
minimal.skel.h 本质上是让用户态程序不必自己手搓底层对象装载细节,而是通过一组规整 API 去操作 BPF 对象,minimal.bpf.c 负责"事件来了内核里做什么",minimal.c 负责"怎么把这段逻辑装进去、接上去、留在那里供你观察"
bootstrap 又比 minimal 多教了什么
bootstrap 多出来的不是"更复杂的打印",而是完整的工具化结构。
你会在它里面第一次真正看到多个 hook 协作,map 做跨事件状态保存,ring buffer 做结构化事件回传,用户态回调做事件解释和打印,参数化控制和优雅退出
这类程序正常跑起来后,更像表格化事件流:
text
TIME EVENT COMM PID PPID FILENAME/EXIT CODE
00:21:22 EXEC mkdir 4032379 4032337 /usr/bin/mkdir
00:21:22 EXIT mkdir 4032379 4032337 [0] (1ms)
你第一次该形成的感觉是:
- 这已经不是
trace_pipe调试输出 - 这是"结构化事件 -> 用户态回调 -> 用户态格式化输出"
bootstrap.bpf.c 第一次到底该盯哪里
第一次建议你只盯住下面 6 个点:
- 哪些程序在处理
EXEC,先找和exec相关的 handler。谁负责在进程刚开始执行时收起点信息 - 哪些程序在处理
EXIT,再找和exit相关的 handler。完整信息不是一次拿齐的,而是分阶段拿 - map 定义在哪里,第一次看
bootstrap.bpf.c,map 是一定要定位出来的。 struct event在哪里,内核态不是随便打印一段字符串,它是在认真组装一份结构化事件,用户态之后会按这份结构去解释- ring buffer 发送发生在哪里,
完整事件到底是在哪一刻被真正送回用户态的 - 哪些字段是起点信息,哪些字段是终点信息,这一步会帮助你把
EXEC、map、EXIT三段真正串起来。
bootstrap.c 第一次到底该盯哪里
第一次建议你只盯住下面 6 个点:
- 参数解析:先看它支持哪些过滤、开关或模式。
用户态控制面不是摆设,它决定了工具怎么运行、怎么收窄范围 - 对象在哪里
open/load/attach:这是你把前面 libbpf 抽象步骤和真实代码对上的入口。 ring buffer在哪里初始化,这一步回答的是:用户态是从哪里开始准备接收结构化事件的- 回调函数在哪里,第一次你只要先找到:
收到一条事件后,到底是哪段函数负责把它解释和打印出来 - 打印格式在哪里定义,这一步会帮你明白:终端里的表格不是天然存在的,是用户态拿到事件后自己格式化出来的
- 退出时资源怎么收,这一步很关键,因为它和 link、ring buffer、对象生命周期直接相关。
一个最短的事件链阅读方法
第一次看 bootstrap,最稳的读法不是按文件顺序,而是按下面这条线:
- 进程
EXEC - eBPF 程序收起点信息
- map 暂存
- 进程
EXIT - eBPF 程序补终点信息
- 组装
struct event - ring buffer 送给用户态
- 用户态回调打印成一行
你只要把这 8 步顺下来,bootstrap 看起来就不会是一堆零散函数名。
EXEC 先收起点,map 暂存中间状态,EXIT 再补终点,最后由 ring buffer 把完整事件交给用户态回调打印出来
把 bootstrap 和 minimal 的最大差别压成一句话:minimal 主要让你看到"程序挂上去了",bootstrap 才开始认真教你"事件怎么结构化回传"
第一次读 bootstrap,你最好先把这 3 个角色分清:
map:负责跨事件保存中间状态struct event:负责定义"回传给用户态的一条完整记录长什么样"ring buffer + 回调:负责把结构化记录送回用户态并打印
map 在这里扮演什么角色
在 bootstrap 这种工具里,EXEC 和 EXIT 不是同一时刻发生的,所以你没法在一次触发里把所有字段拿齐。
于是就需要一张中间状态表,大意可以理解成:
c
// 伪代码表达,不是完整原文件
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u32);
__type(value, struct event);
} exec_start SEC(".maps");
它做的事其实很像你在 BCC 里见过的 BPF_HASH(infotmp, ...):
EXEC时先存一份起点信息EXIT时再按 pid/tgid 取出来- 补齐终点字段后发给用户态
所以这里的 map,本质上还是:多阶段事件之间的接力区
struct event 为什么特别重要
这是 bootstrap 第一次真正让你感受到"结构化事件"是什么。
在 minimal 里,内核态只是:
- 触发一下
bpf_printk()打一行调试文本
但在 bootstrap 里,内核态要认真组织一条完整记录,所以会先定义一份结构体,大意像这样:
c
// 伪代码表达,字段名以示意为主
struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
bool exit_event;
char comm[TASK_COMM_LEN];
char filename[256];
};
这份结构的意义非常大,因为它规定了:
- 哪些字段由内核态负责采
- 用户态会按什么格式解释这块内存
- 一条事件能不能稳定地被打印成表格
所以你以后看到 struct event,脑子里第一反应最好是:
这是内核态和用户态之间的数据契约
ring buffer 在这里到底做了什么
如果说 map 是"中间接力区",那 ring buffer 更像:正式投递通道
典型过程你可以先这样记:
c
struct event *e;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
// 给 e 填 pid/ppid/comm/filename/exit_code/duration 等字段
bpf_ringbuf_submit(e, 0);
它和 bpf_printk() 的根本区别在于:
bpf_printk():更像调试输出- ring buffer:是把一份结构化二进制事件送回用户态
也正因为如此,bootstrap 才能在用户态把收到的数据按字段拆开,再打印成你看到的表格。
用户态回调链第一次该怎么看,到了用户态,一般会出现这样一条链:
c
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* timeout_ms */);
}
这里最该抓住的是:
ring_buffer__new(...):把某张 ring buffer map 和一个回调函数绑定起来ring_buffer__poll(...):持续轮询,有事件就触发回调handle_event(...):拿到原始 event 后,解释字段并打印
所以它的用户态心智模型不是"我主动去读某个文本文件",而是:我注册一个事件处理函数,等内核把结构化事件推过来
把 handle_event 也拆开看,第一次就盯 3 件事
- 它是怎么把
data转成struct event *的 - 它是怎么判断这是
EXEC还是EXIT的 - 它最后是按什么格式把字段打印成一行的
大意通常像这样:
c
static int handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event *e = data;
if (e->exit_event) {
printf("%-8s %-5s %-16s %-7d %-7d [%u] (%llums)\n",
ts, "EXIT", e->comm, e->pid, e->ppid,
e->exit_code, e->duration_ns / 1000000);
} else {
printf("%-8s %-5s %-16s %-7d %-7d %s\n",
ts, "EXEC", e->comm, e->pid, e->ppid, e->filename);
}
return 0;
}
你第一次不用太在意格式串细节,但一定要抓住一个关键转变:这里已经不是"内核直接打印给你看",而是"内核送结构,用户态决定怎么显示",为什么 EXEC 和 EXIT 要拆开处理,因为它们本来就是不同时间点发生的事件。EXEC 那一刻你能拿到的是起点信息:
- pid
- ppid
- comm
- filename
- 开始时间
但那时你还拿不到:
- 退出码
- 运行了多久
而 EXIT 那一刻正好补上终点信息。
map 在中间扮演什么角色,你可以把它理解成"中间接力区"。流程大致是:
EXEC先把起点信息存进 map- 进程继续运行
EXIT发生时再把起点信息取出来- 补齐退出码和耗时
- 组装完整事件
- 通过 ring buffer 送给用户态
所以这里的 map 不是可选装饰,而是:多事件协作时的状态接力点
BTF / vmlinux.h / CO-RE 这几个词到底怎么串起来
第一次你可以先把它们串成下面这条线:
BTF:当前内核类型信息的说明书vmlinux.h:把这些类型信息展开成 C 头文件后的常见使用形式CO-RE:利用这些类型信息,让同一份程序更容易适配不同但相近的内核
如果你想读 task_struct 里的某个字段,不同内核版本里这个结构周围布局可能有变化。如果你死记偏移量,就会很脆。而 BTF/CO-RE 这套思路,是在尽量减少这种脆弱性。先记住关系就够了:
BTF提供"这台内核长什么样"的信息CO-RE借这些信息让程序更稳地工作
第一次在源码里看到 vmlinux.h 时该怎么想
不要把它先当成普通头文件。第一次更稳的理解是:
- 它把当前内核大量类型定义展开出来
- 让 eBPF 程序能用更自然的方式引用内核结构体字段
- 它经常和
BTF/CO-RE放在同一条链上理解
所以你以后在 bootstrap.bpf.c 里看到它时,脑子里先冒出的应该是:这份程序正在认真依赖当前内核类型信息,而不是死写偏移
那第一次在源码里看到 BPF_CORE_READ(...) 时,到底该怎么想,很多人第一次看到 CO-RE 相关源码,真正卡住的不是 vmlinux.h,而是这一类宏:
c
BPF_CORE_READ(task, real_parent, tgid)
或者:
c
BPF_CORE_READ(task, mm, exe_file)
第一次不要先把它当成"我要背语法的神秘宏",更稳的理解是:我想从某个内核结构里取字段,但我不想把字段偏移写死,所以我让 CO-RE 帮我按目标内核类型信息去适配,也就是说,它表达的不是"某个新功能",而是一个很现实的工程诉求:同样都是 task_struct,不同内核版本布局可能变了,但我还是想尽量用同一份源码去读它
先把这 3 个东西串成一条最实用的链
你第一次看 BPF_CORE_READ,最适合在脑子里把这 3 件事连起来:
text
vmlinux.h 提供结构体类型声明
->
源码里用 BPF_CORE_READ/BPF_CORE_READ_STR_INTO 这类方式读字段
->
加载到目标内核时,CO-RE 根据该内核 BTF 做字段定位/重定位
所以:
vmlinux.h解决"源码里怎么自然地写结构体字段名"BPF_CORE_READ解决"源码里怎么表达我要读这条字段路径"CO-RE解决"到了目标内核上怎么尽量稳地找到它"
如果不走 CO-RE,问题会出在哪里,你可以先想一个很朴素的风险:
- 你知道
task_struct里有某字段 - 你在 A 内核上试过,偏移没问题
- 到 B 内核上,这个结构前面多了点字段,或者布局调整了
- 你原来的固定访问方式就可能不稳
所以 CO-RE 的价值,不是"让你永远不用关心内核差异",而是:尽量把"字段位置变化"这类适配工作,从手写偏移,变成依赖 BTF 的自动重定位
第一次看 BPF_CORE_READ,语义上把它翻译成中文就够了
比如这句:
c
ppid = BPF_CORE_READ(task, real_parent, tgid);
你第一次完全可以把它先翻译成:
从 task 这个 task_struct 出发,沿着 real_parent 走到父进程对象,再读取它的 tgid 字段
也就是:
text
task
-> real_parent
-> tgid
它的重点不是宏长什么样,而是:它在表达一条字段访问路径,这对第一次看源码特别重要,因为你真正该关注的是"它到底想从哪个结构里取什么",而不是先陷进宏实现细节里。
第一次看这类代码,建议只盯 3 个问题:
- 起点对象是谁:比如
task、mm、file、dentry - 访问路径是什么:比如
real_parent -> tgid、mm -> exe_file - 最终想拿到的字段是什么:比如
pid、tgid、comm、指针、时间戳相关字段
只要这 3 件事先回答清楚,BPF_CORE_READ 看起来就不会那么像"黑魔法"。你可以把它和 bpf_probe_read_kernel 的心智模型区分开
第一次可以先粗一点记:
bpf_probe_read_kernel(...):更像"我自己明确知道要读一块内核内存"BPF_CORE_READ(...):更像"我按类型和字段路径去读,让 CO-RE 帮我处理适配"
不是说前者就不能用、后者就一定替代一切,而是:当你看到 BPF_CORE_READ 时,说明这份程序在尽量按'类型字段语义'而不是按'固定布局假设'来写
vmlinux.h 第一次到底看什么,不看什么
第一次看到 #include "vmlinux.h",很容易被里面海量结构体吓到。其实第一次不该那样看。第一次更好的看法是:看它有没有把你当前要读的内核类型声明出来,看源码里到底用了哪些类型:task_struct、file、dentry、sock 等,不要试图第一次就把 vmlinux.h 整体读完
也就是说,vmlinux.h 第一次更像"字典",不是"教材正文"。你只在需要查某个类型字段路径时,回去定位那一小段就够了。
把 CO-RE 压成一句最容易记的话,第一次最值得记住的不是"Compile Once, Run Everywhere" 这句口号本身,而是它背后的真实含义:尽量让同一份 eBPF 源码,在相近但不完全相同的内核上,仍然能按字段语义稳定工作,注意这里更稳的表述是"尽量"和"相近内核",而不是绝对意义上的"任何内核都无脑通用"。
看到 #include "vmlinux.h",说明:这份 eBPF 程序准备按内核类型信息来写,不想只靠手工猜结构
看到 BPF_CORE_READ(...),说明:这里正在按字段路径读取内核结构,并把适配工作尽量交给 CO-RE
看到 bpf_core_read_str / BPF_CORE_READ_STR_INTO(...),说明:这里不是只读标量字段了,而是想更稳地把字符串或字符数组按类型路径读出来
一个很短的源码阅读顺序建议,在 bootstrap.bpf.c 或别的 CO-RE 程序里看到类似代码时,第一次最稳的顺序是:
- 先找
SEC("..."),确认挂载点 - 再看函数里拿到的起点对象是谁
- 再看
BPF_CORE_READ(...)这一串路径到底在读什么 - 最后再考虑这些字段为什么要这样组织成事件
这样你不会一上来就被 CO-RE 宏本身拖住,而是先把"程序想观察什么"这条主线抓住。
bootstrap.bpf.c 里有哪些典型 BPF_CORE_READ 路径
这一节最适合配着 bootstrap.bpf.c 一起看。第一次你不用把所有宏都抠透,只要先把"它到底在读什么"看明白。
按照 libbpf-bootstrap 示例里的 bootstrap.bpf.c,最典型的有下面两类:
- 读父进程 PID:
BPF_CORE_READ(task, real_parent, tgid)源码里你会看到类似这一句:
c
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
第一次可以直接把它翻译成:从当前 task_struct 出发,先走到 real_parent,再读取父进程的 tgid 作为 ppid
也就是这条路径:
text
task_struct
-> real_parent
-> tgid
这句特别值得你记住,因为它很能代表 BPF_CORE_READ 的真实用途:
- 不是"读一块裸内存"
- 而是"按字段语义走一条结构路径"
为什么这里用 tgid 而不是别的字段?
pid更像线程级 IDtgid更像进程级 ID- 父进程这个语义,在展示时通常更关心进程维度
所以 bootstrap 这里拿 ppid 时,读的是父任务的 tgid,这和表格里展示的 PPID 列是对得上的。
- 读退出码:
BPF_CORE_READ(task, exit_code)
在退出事件路径里,你会看到类似这样:
c
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
第一次先不要急着被位运算吓住,先抓大意:
task还是当前任务对象- 这次读取的是
task_struct里的exit_code - 读出来后再做一层格式处理,变成最终展示给用户看的退出码
也就是:
text
task_struct
-> exit_code
这里最该形成的感觉是:
BPF_CORE_READ 负责"把字段稳地读出来",至于这个字段拿到后怎么解释、怎么格式化,是后续逻辑的事`
换句话说:
CO-RE解决的是"字段怎么稳地找到">> 8 & 0xff解决的是"字段怎么按业务含义解释"
这两个层面不要混在一起。为什么 bootstrap.bpf.c 里 ppid 和 exit_code 都很适合拿来学 CO-RE
因为它们刚好代表了两种典型情况:
real_parent -> tgid:是"多级路径读取"exit_code:是"单字段读取 + 后续解释"
只要你把这两个例子看懂,后面再看到:
BPF_CORE_READ(task, mm, exe_file)BPF_CORE_READ(file, f_inode, i_ino)BPF_CORE_READ(sock, __sk_common.skc_dport)
你就不会先慌,因为本质上都还是同一件事:确定起点对象 -> 沿字段路径取值 -> 必要时再做格式转换
不是所有字段都必须走 BPF_CORE_READ
比如 bootstrap.bpf.c 里还有这种写法:
c
bpf_get_current_comm(&e->comm, sizeof(e->comm));
以及:
c
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);
这说明一个很重要的事实:
CO-RE 很重要,但它不是"所有数据访问都统一写成 BPF_CORE_READ"`
你第一次更稳的理解应该是:
- 如果是在按内核结构字段路径取值,
BPF_CORE_READ很常见 - 如果是 helper 已经直接提供了能力,比如取 comm,就直接用 helper
- 如果是 tracepoint 上下文里的动态字符串,就按对应上下文方式去读
也就是说,第一次读源码时,最好不要强行找"有没有把所有读取都改成 CO-RE"。更重要的是分清:
当前这份数据,来自内核结构体字段、helper,还是 tracepoint 上下文本身,你可以按下面这个小模板去拆:
-
起点对象是谁
比如
task -
字段路径是什么
比如
real_parent -> tgid、exit_code -
读出来后是直接用,还是还要加工
比如
ppid直接用,exit_code还要位运算整理 -
最终这个字段是进
struct event的哪一列比如
PPID、EXIT CODE
只要这样拆,BPF_CORE_READ 在你眼里就会从"宏"变成"字段路径表达式"。
第一次看 open/load/attach,怎么和实际现象对应起来
你可以先这样分层记:
open把对象和元信息先读进来,还没真正让程序进内核执行load程序和 map 真正进入内核,verifier 开始检查attach程序已经在内核里了,现在开始和具体 hook 连起来
为什么这三步一定要分开,因为它们对应的是不同层的失败:
- 编译过了但 load 失败,常常是 verifier 或对象问题
- load 过了但 attach 失败,常常是 hook 或 attach 参数问题
- attach 过了但没输出,常常要去看触发条件和观察路径
把它们和终端现象对起来,第一次可以这样记:
open过了:说明用户态已经接到这份对象了load过了:说明程序和 map 已经真正进内核了attach过了:说明程序已经和具体 hook 连上了
这时如果还没输出,优先怀疑:事件有没有触发、输出路径是不是对、用户态有没有在读
第一次改 libbpf 示例,改什么最稳
还是老原则:只改一个小点。最适合第一次做的通常是:
- 给事件结构体加一个字段
- 在
.bpf.c里填进去 - 在用户态打印出来
或者加一个简单 PID 过滤,为什么这种改动最有教学价值,因为它能逼你真正走通这条链:内核态字段 -> 结构体 -> 用户态读取 -> 用户态显示
一个最小前后对照
改造前:
text
pid=4211 comm=ls
pid=4214 comm=date
改造后:
text
pid=4211 comm=ls ts=1042234788211
pid=4214 comm=date ts=1042235119087
真正重要的不是数值,而是:
- 你新增的字段真的跑通了整条链
- 你知道改动影响了哪一层
这里再补一个更稳的实践顺序
第一次改 libbpf 示例,建议固定按下面这个顺序做:
- 先只加字段,不改 hook
- 先只改事件结构,不改用户态整体逻辑
- 改完后重新编译对象和 skeleton
- 再确认
bpftool prog/link和输出现象都还正常
这样做的好处是:
- 变量最少
- 一旦失败,问题范围更容易缩小
- 你更容易把"改了哪里"和"现象哪里变了"对应起来
学 libbpf 时最容易踩哪几类坑
最常见的坑主要有 5 类:
- 以为编译成功就等于能跑
- 不理解 section 命名
- 不重视
BTF / CO-RE / vmlinux.h - 只会跑 demo,不会做最小改动
- 不理解 link 和对象生命周期
最后总结
如果这一篇你只记住一句话,我希望是:
libbpf 这条路线真正要学的,不是把示例跑起来,而是看清现代 eBPF 工具为什么会由 object、program、map、link、skeleton、BTF、CO-RE 这些部件组成,所以你在这一篇里真正要收获的是:
- 看懂
minimal为什么能活 - 看懂
bootstrap为什么更像真实工具 - 分清 object/program/map/link/skeleton
- 分清
open/load/attach - 理解
BTF / CO-RE / vmlinux.h的关系 - 做一次最小改动
做到这一步,你对 eBPF 的理解就会从"会跑 demo",进入"知道为什么这样组织"。
博文部分内容参考
© 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 😃
- libbpf-bootstrap - GitHub:https://github.com/libbpf/libbpf-bootstrap
- libbpf documentation: https://libbpf.readthedocs.io/en/latest/
- eBPF Docs: https://docs.ebpf.io/
© 2018-至今 liruilonger@gmail.com, 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)