BPF-eBPF 开发路线二:libbpf、CO-RE 与 libbpf-bootstrap认知

写在前面


  • 如果说 BCC 更偏"先建立观察直觉",那 libbpf 更偏"把 eBPF 的主干工程能力真正学扎实"
  • 重点会放在:object、program、map、link、skeleton、BTF/CO-RE、事件回传、生命周期和第一次最小改动
  • 你真正要学的不是"把示例跑起来",而是"为什么现代 eBPF 工具会长成这样"

一个人知道自己为什么而活,就可以忍受任何一种生活。------尼采


先说清楚:libbpf 这条路线到底在解决什么问题

libbpf 这条路线最重要的价值,不是"API 更底层",而是它把现代 eBPF 工具的主干工程组织方式直接摆在你面前,你会在这条路线里第一次真正碰到这些东西:

  • object
  • program
  • map
  • link
  • skeleton
  • BTF
  • CO-RE
  • 用户态生命周期管理

所以这条路线不只是"又一种写法",而是你开始真正理解现代 eBPF 工程结构的地方。

为什么说 libbpf 是主干路线

因为很多现代 eBPF 工具,不管最后是不是 Go 控制面,本质上都绕不开 libbpf 这套对象组织思路。

它的优势主要在:

  • 更贴近内核原生生态
  • CO-RE 支持成熟
  • object/program/map/link 生命周期划分清楚
  • skeleton 让 open/load/attach 这条链更规整

如果你想真正理解:一个对象是怎么被加载的,一个程序是怎么 attach 的,一个 link 为什么存在、为什么退出后消失,一个事件结构体怎么从内核态走到用户态,那 libbpf 基本绕不过去。

第一次学 libbpf 从哪里开始

最稳的入口还是 libbpf-bootstrap。因为它给你的不只是 demo,而是一套非常适合作为教学样本的最小工程骨架。

更稳的顺序是:

  1. 先看 examples/c/minimal
  2. 再看 examples/c/bootstrap
  3. 再去看 kprobe / uprobe / fentry / usdt
  4. 最后再补 libbpf 文档细节

为什么这个顺序更稳,因为它先给你最小骨架,再给你真实点的工具骨架。这样你不会一上来就被多个 hook,多个 map,多种 attach 方式,复杂用户态参数搞的头晕。

关于 minimal 和 bootstrap 这最开始的认知部分简单讲解过,这里在过一遍

libbpf-bootstrap 最值钱的教学点是什么

不是"它有几个示例",而是它把现代 eBPF 工具最核心的骨架都摆出来了。你会在里面碰到:

  • .bpf.c 内核态程序
  • 用户态加载器
  • skeleton
  • ring buffer
  • vmlinux.h
  • CO-RE

它不是单纯告诉你:这里挂一个 probe,而是在告诉你,一个现代 eBPF 工具通常由哪几部分组成,它们怎么协作

第一次看 libbpf 相关术语,最该先分清哪些词

第一次一定要先把这 5 个词分开:

  1. object 可以先理解成:一整份 BPF 对象的集合视图,里面通常会包含:多个 program,多个 map,相关元信息
  2. program 可以先理解成:真正会被挂到某个 hook 上执行的那段 eBPF 程序
  3. map 可以先理解成:程序运行时的内核态数据存储
  4. link 可以先理解成:某个 program 和某个具体 hook 之间已经建立好的连接
  5. skeleton 可以先理解成:围绕这份 BPF 对象自动生成的一层用户态辅助外壳,它最大的教学价值在于:把 open/load/attach/destroy 这套流程包装得更统一,让初学者更容易把注意力放在对象关系,而不是一开始就陷进细碎底层 API

minimal 到底在教你什么

minimal 不是在教业务逻辑,而是在教你:一个最小 eBPF 工具到底靠哪些部件活起来,别只看"能不能跑",而要看这 4 件事:

  1. 用户态程序有没有把对象 open/load/attach 完
  2. trace_pipe 里有没有输出
  3. bpftool prog showbpftool link show 里有没有新对象
  4. 停掉程序后这些现象会不会消失

minimal 最常见的感觉是:

  • 程序启动后不立即退出
  • trace_pipe 里有周期性 bpf_printk() 输出
  • bpftool 能看到对应 program 和 link

很多人第一次会跑 bpftool,但还不会把它和示例现象对应起来。更稳的方式是只先做下面 3 组对照:

  1. bpftool prog show程序本体到底有没有真的进内核,第一次重点看有没有新的 progtype 是不是你预期的类型,名字能不能大概和示例程序对应起来
  2. bpftool link show程序和 hook 到底有没有真的连上,第一次重点看有没有新的 link,它是不是关联到刚才那个 prog
  3. bpftool map showminimal 里它不是第一观察重点,因为 minimal 的教学重点不是复杂 map 协作。但到了 bootstrap,它就会明显重要起来。

所以第一次minimal先盯 prog/linkbootstrap再认真盯 map

第一次跑 minimaltrace_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 个点:

  1. SEC("...") 在哪里,这是你第一次定位"它挂在哪"的最快入口。你第一次不用先背 section 语法细节,只要先回答:这段程序是打算挂到哪类 hook 上
  2. 函数参数是什么,这是你第一次理解"上下文从哪里来"的入口。你只要先知道:这不是普通用户态函数,每次触发时,内核会把当前事件对应的上下文传给它
  3. 函数体里真正做了什么,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 程序本体其实可以非常短,复杂的往往不在这里,而在外围组织

  1. 输出为什么走 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.cminimal.cminimal.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_bpf
  • minimal_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.cminimal.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 个点:

  1. 哪些程序在处理 EXEC,先找和 exec 相关的 handler。谁负责在进程刚开始执行时收起点信息
  2. 哪些程序在处理 EXIT,再找和 exit 相关的 handler。完整信息不是一次拿齐的,而是分阶段拿
  3. map 定义在哪里,第一次看 bootstrap.bpf.c,map 是一定要定位出来的。
  4. struct event 在哪里,内核态不是随便打印一段字符串,它是在认真组装一份结构化事件,用户态之后会按这份结构去解释
  5. ring buffer 发送发生在哪里,完整事件到底是在哪一刻被真正送回用户态的
  6. 哪些字段是起点信息,哪些字段是终点信息,这一步会帮助你把 EXEC、map、EXIT 三段真正串起来。

bootstrap.c 第一次到底该盯哪里

第一次建议你只盯住下面 6 个点:

  1. 参数解析:先看它支持哪些过滤、开关或模式。用户态控制面不是摆设,它决定了工具怎么运行、怎么收窄范围
  2. 对象在哪里 open/load/attach:这是你把前面 libbpf 抽象步骤和真实代码对上的入口。
  3. ring buffer 在哪里初始化,这一步回答的是:用户态是从哪里开始准备接收结构化事件的
  4. 回调函数在哪里,第一次你只要先找到:收到一条事件后,到底是哪段函数负责把它解释和打印出来
  5. 打印格式在哪里定义,这一步会帮你明白:终端里的表格不是天然存在的,是用户态拿到事件后自己格式化出来的
  6. 退出时资源怎么收,这一步很关键,因为它和 link、ring buffer、对象生命周期直接相关。

一个最短的事件链阅读方法

第一次看 bootstrap,最稳的读法不是按文件顺序,而是按下面这条线:

  1. 进程 EXEC
  2. eBPF 程序收起点信息
  3. map 暂存
  4. 进程 EXIT
  5. eBPF 程序补终点信息
  6. 组装 struct event
  7. ring buffer 送给用户态
  8. 用户态回调打印成一行

你只要把这 8 步顺下来,bootstrap 看起来就不会是一堆零散函数名。

EXEC 先收起点,map 暂存中间状态,EXIT 再补终点,最后由 ring buffer 把完整事件交给用户态回调打印出来

bootstrapminimal 的最大差别压成一句话:minimal 主要让你看到"程序挂上去了",bootstrap 才开始认真教你"事件怎么结构化回传"

第一次读 bootstrap,你最好先把这 3 个角色分清:

  • map:负责跨事件保存中间状态
  • struct event:负责定义"回传给用户态的一条完整记录长什么样"
  • ring buffer + 回调:负责把结构化记录送回用户态并打印

map 在这里扮演什么角色

bootstrap 这种工具里,EXECEXIT 不是同一时刻发生的,所以你没法在一次触发里把所有字段拿齐。

于是就需要一张中间状态表,大意可以理解成:

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 件事

  1. 它是怎么把 data 转成 struct event *
  2. 它是怎么判断这是 EXEC 还是 EXIT
  3. 它最后是按什么格式把字段打印成一行的

大意通常像这样:

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;
}

你第一次不用太在意格式串细节,但一定要抓住一个关键转变:这里已经不是"内核直接打印给你看",而是"内核送结构,用户态决定怎么显示",为什么 EXECEXIT 要拆开处理,因为它们本来就是不同时间点发生的事件。EXEC 那一刻你能拿到的是起点信息:

  • pid
  • ppid
  • comm
  • filename
  • 开始时间

但那时你还拿不到:

  • 退出码
  • 运行了多久

EXIT 那一刻正好补上终点信息。

map 在中间扮演什么角色,你可以把它理解成"中间接力区"。流程大致是:

  1. EXEC 先把起点信息存进 map
  2. 进程继续运行
  3. EXIT 发生时再把起点信息取出来
  4. 补齐退出码和耗时
  5. 组装完整事件
  6. 通过 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 个问题:

  1. 起点对象是谁:比如 taskmmfiledentry
  2. 访问路径是什么:比如 real_parent -> tgidmm -> exe_file
  3. 最终想拿到的字段是什么:比如 pidtgidcomm、指针、时间戳相关字段

只要这 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_structfiledentrysock 等,不要试图第一次就把 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 程序里看到类似代码时,第一次最稳的顺序是:

  1. 先找 SEC("..."),确认挂载点
  2. 再看函数里拿到的起点对象是谁
  3. 再看 BPF_CORE_READ(...) 这一串路径到底在读什么
  4. 最后再考虑这些字段为什么要这样组织成事件

这样你不会一上来就被 CO-RE 宏本身拖住,而是先把"程序想观察什么"这条主线抓住。

bootstrap.bpf.c 里有哪些典型 BPF_CORE_READ 路径

这一节最适合配着 bootstrap.bpf.c 一起看。第一次你不用把所有宏都抠透,只要先把"它到底在读什么"看明白。

按照 libbpf-bootstrap 示例里的 bootstrap.bpf.c,最典型的有下面两类:

  1. 读父进程 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 更像线程级 ID
  • tgid 更像进程级 ID
  • 父进程这个语义,在展示时通常更关心进程维度

所以 bootstrap 这里拿 ppid 时,读的是父任务的 tgid,这和表格里展示的 PPID 列是对得上的。

  1. 读退出码: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.cppidexit_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 上下文本身,你可以按下面这个小模板去拆:

  1. 起点对象是谁

    比如 task

  2. 字段路径是什么

    比如 real_parent -> tgidexit_code

  3. 读出来后是直接用,还是还要加工

    比如 ppid 直接用,exit_code 还要位运算整理

  4. 最终这个字段是进 struct event 的哪一列

    比如 PPIDEXIT CODE

只要这样拆,BPF_CORE_READ 在你眼里就会从"宏"变成"字段路径表达式"。

第一次看 open/load/attach,怎么和实际现象对应起来

你可以先这样分层记:

  1. open 把对象和元信息先读进来,还没真正让程序进内核执行
  2. load 程序和 map 真正进入内核,verifier 开始检查
  3. 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 示例,建议固定按下面这个顺序做:

  1. 先只加字段,不改 hook
  2. 先只改事件结构,不改用户态整体逻辑
  3. 改完后重新编译对象和 skeleton
  4. 再确认 bpftool prog/link 和输出现象都还正常

这样做的好处是:

  • 变量最少
  • 一旦失败,问题范围更容易缩小
  • 你更容易把"改了哪里"和"现象哪里变了"对应起来

学 libbpf 时最容易踩哪几类坑

最常见的坑主要有 5 类:

  1. 以为编译成功就等于能跑
  2. 不理解 section 命名
  3. 不重视 BTF / CO-RE / vmlinux.h
  4. 只会跑 demo,不会做最小改动
  5. 不理解 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",进入"知道为什么这样组织"。

博文部分内容参考

© 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 😃



© 2018-至今 liruilonger@gmail.com, 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)

相关推荐
ZengLiangYi2 小时前
React Query + REST API 最佳实践
javascript·后端·react.js
ZengLiangYi2 小时前
Fastify 加 Electron:把 Web 服务嵌进桌面应用
前端·javascript·后端
胡萝卜术2 小时前
从零搭建生成式AI项目:OpenAI + Node.js 环境配置与密钥安全实践
前端·javascript·面试
柒和远方2 小时前
每日一学V012: 从 Python 到 Node.js:一个 AI Native 开发者的 JavaScript 调用 LLM 实战
javascript·node.js·api
STDD2 小时前
Farming Simulator 25(模拟农场 25) Linux 专服搭建完全指南
linux·运维·javascript
超人气王3 小时前
新手学前端 JavaScript 类型判断:一篇彻底搞懂 typeof、instanceof 和 Object.prototype.toString
前端·javascript
丷丩3 小时前
MapLibre GL JS第35课:显示带地形高程(三维地形)的卫星影像
javascript·gis·map·mapbox·maplibre gl js
三乐2284 小时前
node不认识类型?多半是没用上这几段代码
javascript
前端毕业班4 小时前
uni-app 小程序样式隔离实践指南和原理分析
前端·javascript·vue.js