eBPF CO-RE 与 BTF Portable Tracing 实战:跨内核版本一次编译到处运行

摘要

传统的 eBPF 开发依赖目标机器的内核头文件,导致每次内核升级都需要重新编译。eBPF CO-RE (Compile Once – Run Everywhere) 技术结合内核自带的 BTF (BPF Type Format) 元数据,实现了 eBPF 程序的"编写一次、到处运行"——只需在构建时编译一次,即可在任意支持 BTF 的内核版本上零配置加载执行。本篇文章将深入剖析 BTF 的底层数据布局、CO-RE 的重定位机制、libbpf 的骨架加载流程,并最终构建一个完整的、跨内核版本可运行的进程 tracer。

1. 为什么需要 CO-RE

1.1 传统 eBPF 开发之痛

在 CO-RE 出现之前,编写一个访问内核结构的 eBPF 程序至少需要:

  • 目标机器安装完整内核头文件包
  • 使用 bpf_probe_read() 手动读取每一个字段
  • 为每个内核版本字段偏移量的变化编写条件编译
  • 每次目标内核升级后重新交叉编译

在云原生环境下,Kubernetes 集群可能运行着数十个不同的内核版本(4.18 / 5.4 / 5.15 / 6.1 / 6.6 ...),为每个版本维护独立的 eBPF 二进制文件是不可承受的运维负担。

1.2 三个核心问题的解法

CO-RE 解决的三个根本性障碍:

  1. 内核结构定义来源:不需要内核头文件,直接从目标内核的 BTF 信息获取
  2. 内核结构在不同版本中的差异:libbpf 的字段重定位机制自动处理偏移量、存在性、类型变更
  3. 内核头文件不可用:依赖 Linux 5.2+ 内核普遍开启的 CONFIG_DEBUG_INFO_BTF=y

2. BPF Type Format (BTF) 底层解析

2.1 BTF 是什么

BTF 是一种紧凑的类型元数据格式,定义了整型、结构体、联合、枚举、函数签名等类型信息。Linux 内核编译时通过 pahole 工具从 DWARF 调试信息转换为 BTF,存储在 /sys/kernel/btf/vmlinux 中。

BTF 的精妙之处在于它只保存类型定义而不保存调试信息、代码位置、变量地址等冗余数据,整个 BTF 段通常只有几 MB:

# ls -lh /sys/kernel/btf/vmlinux

-rw-r--r-- 1 root root 5.5M Oct 10 14:22 /sys/kernel/btf/vmlinux

bpftool btf dump file /sys/kernel/btf/vmlinux format raw | wc -l

82341

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

2.2 BTF 数据布局

BTF 数据由两部分组成:BTF 类型表和字符串表。类型表是一个紧凑的字节序列,每个类型条目通过 struct btf_type 描述,包含以下关键字段:

struct btf_type {

__u32 name_off; // 类型名称在字符串表中的偏移

__u32 info; // 类型种类(vlen/Kind/kind_flag)+ vlen 的组合位字段

union {

__u32 size; // 对 STRUCT/UNION/ENUM 表示结构体大小

__u32 type; // 对 PTR/TYPEDEF/CONST/VOLATILE 表示指向的类型 ID

}

};

通过 bpftool 可以浏览完整的类型图:

# 查看所有任务的 task_struct BTF 信息

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A 50 "struct task_struct"

查看特定类型 ID 的详细信息

$ bpftool btf dump id 12345 format raw

2.3 vmlinux.h — 一切的基石

CO-RE 开发的第一步是从目标环境的 vmlinux BTF C 头文件:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

这个文件包含了内核中所有公共和内部类型定义,包括我们需要的 task_struct、mm_struct、file_operations、tcp_sock 等全部内部结构。eBPF 程序可以直接 #include "vmlinux.h",因此不再需要内核头文件——libbpf 会通过 BTF 重定位机制来"知道"该结构体在目标内核中的实际大小和字段偏移量。

2.4 BPF Type Tags 与 BTF_KIND_TYPE_TAG

BTF 还支持 Type Tag(从 Linux 5.16 开始),使得程序能够区分同一结构体类型在不同上下文中的语义差异:

// 标记需要特定访问权限的字段

struct task_struct {

struct thread_info __ptrauth *thread_info;

...

};

CO-RE 扩展还引入了 preserve_access_index 编译属性,使 BPF 后端保留重定位编码信息,让 libbpf 在加载时执行精确的重定位操作。

3. CO-RE 重定位机制深度解析

3.1 重定位的基本原理

CO-RE 的核心是一种编译时记录重定位信息、加载时自动修补的机制。它包含以下步骤:

  1. 编译阶段:Clang 在 eBPF C 代码中,对每个访问内核结构字段的表达式(如 task->mm),生成一条 BPF 重定位记录,记录"需要跳转到 struct task_struct 类型中 mm 字段偏移量"的语义
  2. 编码为重定位记录:每条重定位记录编码在 ELF 的 .BTF.ext 段中,包含"源类型 ID"和"源字段名"
  3. 加载阶段重定位:libbpf 在加载 ELF 对象时读取目标内核的 BTF,解析各类型的字段偏移量,修补 BPF 指令中的立即数偏移量

3.2 重定位的三种操作

CO-RE 在加载时对 BPF 指令执行三类修补操作:

类型重定位 (Type Relocation):处理字节大小差异,确保 BPF 程序不会读取超出目标内核结构范围的字节:

// 访问 task_struct.comm 字段 (TASK_COMM_LEN=16)

bpf_probe_read_kernel(&task.comm, TASK_COMM_LEN, &task->comm);

// → 如果目标内核 TASK_COMM_LEN 变成 32,auto-zero 高 16 字节

字段重定位 (Field Relocation):处理字段偏移量的变化:

// task->mm (偏移量从 v5.10 的 1096 变为 v5.15 的 1032)

// CO-RE 自动修补为正确的偏移量

存在性重定位 (Existence Relocation):处理字段在内核中被移除或重命名的情况,支持可选记录字段提供默认值:

// BPF_CORE_READ_INTO — 宏展开后若字段不存在则设置默认值

BPF_CORE_READ_INTO(&pid, task, tgid);

3.3 BPF_CORE_READ 宏家族解析

CO-RE 提供了一套按访问模式分层的宏家族,每个宏都会生成正确的重定位记录:

// 1. 直接取值 (返回值或NULL/0)

struct mm_struct *mm = BPF_CORE_READ(task, mm);

// 2. 带错误码的读取

BPF_CORE_READ_RET_INTO(&mm_ptr, task, mm);

// 3. 内联读取到变量

BPF_CORE_READ_INTO(&pid, task, tgid);

// 4. 读取用户空间指针

BPF_CORE_READ_USER_INTO(&pid, task, tgid);

// 5. 全局/静态字段绝对地址读取 (用于常量偏移量)

unsigned long addr = BPF_CORE_READ(task, mm, total_vm);

// 6. 字段的位字段读取

unsigned int flags = BPF_CORE_READ_BITFIELD(task, flags);

这些宏通过 Clang 编译时生成的 __builtin_preserve_access_index 属性携带重定位信息。

3.4 Type Compatibility 机制

CO-RE 通过 BTF 的 Type Index 匹配实现了结构体之间的"type compatibility"验证:

// 允许结构体 B 作为结构体 A 的参考——只要字段名和类型在 B 中都在 A 中存在

struct my_task_struct {

int pid;

unsigned long rss;

};

// CO-RE 编译到 my_task_struct 的 BPF 代码可以通过重定位来读取 target task_struct

// 只要这些字段名在两个类型中都存在且字节宽度兼容

这让我们可以只声明我们关心的子集字段,而无需关心目标 ev_struct 的完整性偏移量。

4. 实战:构建一个可移植进程追踪器

我们将构建一个完整的 CO-RE 追踪器,它能够在任意 BTF-enabled 内核上记录进程的 fork、exec、exit 事件,包括进程名、父进程信息、PID、运行时间等,编译一次就可以在内部数千台机器上运行。

4.1 项目结构

trace-process/

├── Makefile # 使用 clang + libbpf 编译

├── vmlinux.h # 从 /sys/kernel/btf/vmlinux 生成

├── process_tracer.bpf.c # BPF 内核程序

├── process_tracer.h # 用户空间/BPF 共享头文件

└── process_tracer.c # 用户空间加载器

4.2 BPF 核心程序 (process_tracer.bpf.c)

#include "vmlinux.h"

#include <bpf/bpf_helpers.h>

#include <bpf/bpf_tracing.h>

#include <bpf/bpf_core_read.h>

#include "process_tracer.h"

/* BPF Maps: 用于内核到用户空间的数据传递 */

struct {

__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);

__uint(key_size, sizeof(u32));

__uint(value_size, sizeof(u32));

} events SEC(".maps");

/* 可选: 用于过滤跟踪特定进程组 ID */

struct {

__uint(type, BPF_MAP_TYPE_HASH);

__uint(max_entries, 10240);

__type(key, u32);

__type(value, u32);

} filter_pids SEC(".maps");

char LICENSE[] SEC("license") = "GPL";

/**

  • 通用的事件采集例程 - 利用 CO-RE 从 task_struct 安全读取字段

*

  • 通过 BPF_CORE_READ_INTO 宏自动执行重定位:
  • 1. 如果在编译时 vmlinux.h 中的 task_struct 有 start_time 字段
  • 2. 目标内核的 BTF 中 task_struct 也有该字段
  • 3. libbpf 自动修补偏移量和字节数

*

  • 如果目标内核中某个字段不存在,宏将优雅地设置默认值为 0

*/

static __always_inline int

__attribute__((always_inline))

collect_task_info(struct event *e, struct task_struct *task)

{

/* CO-RE: 即使 RHS 中的字段偏移量在不同内核版本中不一样,重定位会自动修补 */

BPF_CORE_READ_INTO(&e->pid, task, tgid);

BPF_CORE_READ_INTO(&e->ppid, task, real_parent, tgid);

/* 使用 BPF_CORE_READ_STR_INTO 读取字符串字段 */

BPF_CORE_READ_STR_INTO(&e->comm, task, comm);

/* 读取内存和调度信息 */

BPF_CORE_READ_INTO(&e->start_time_boot, task, start_boottime);

/* 通过 BPF_PROBE_READ_USER 替代直接指针解引用 — 防止 RCU 失效 */

const struct cred *cred = BPF_CORE_READ(task, cred);

if (cred)

BPF_CORE_READ_INTO(&e->uid, cred, uid.val);

return 0;

}

/**

  • Tracepoint: 进程 fork
  • 跟踪 sched_process_fork — 也捕获 vfork/clone

*/

SEC("tp/sched/sched_process_fork")

int tracepoint__sched_process_fork(struct trace_event_raw_sched_process_fork *ctx)

{

struct event e = {};

struct task_struct *parent = (struct task_struct *)bpf_get_current_task();

struct task_struct *child;

if (!parent)

return 0;

child = (struct task_struct *)ctx->child_pid;

e.type = EVENT_FORK;

collect_task_info(&e, child);

/* perf buffer 提交到用户空间 — 单次上下文切换 */

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,

&e, sizeof(e));

return 0;

}

/**

  • Tracepoint: 进程 exec
  • 跟踪 sched_process_exec

*/

SEC("tp/sched/sched_process_exec")

int tracepoint__sched_process_exec(struct trace_event_raw_sched_process_exec *ctx)

{

struct event e = {};

struct task_struct *task = (struct task_struct *)bpf_get_current_task();

e.type = EVENT_EXEC;

e.exec_ret = ctx->ret;

collect_task_info(&e, task);

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,

&e, sizeof(e));

return 0;

}

/**

  • Tracepoint: 进程退出
  • 跟踪 sched_process_exit

*/

SEC("tp/sched/sched_process_exit")

int tracepoint__sched_process_exit(struct trace_event_raw_sched_process_exit *ctx)

{

struct event e = {};

struct task_struct *task = (struct task_struct *)bpf_get_current_task();

e.type = EVENT_EXIT;

/* 注意: exit_code 在 sched_process_exit 中的数据 */

e.exit_code = BPF_CORE_READ(task, exit_code) >> 8;

collect_task_info(&e, task);

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,

&e, sizeof(e));

return 0;

}

/**

  • Tracepoint: 高精度进程创建
  • 跟踪 task_newtask — 捕获所有 fork/clone/vfork 事件(包括线程)

*/

SEC("tp/task/task_newtask")

int tracepoint__task_newtask(struct trace_event_raw_task_newtask *ctx)

{

struct event e = {};

struct task_struct *task = (struct task_struct *)bpf_get_current_task();

struct task_struct *new_task = (struct task_struct *)ctx->pid;

u32 pid = bpf_get_current_pid_tgid() >> 32;

/* PID 过滤 — 可选 */

if (bpf_map_lookup_elem(&filter_pids, &pid))

return 0;

e.type = EVENT_NEWTASK;

collect_task_info(&e, new_task);

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,

&e, sizeof(e));

return 0;

}

4.3 共享头文件 (process_tracer.h)

#pragma once

#define TASK_COMM_LEN 16

#define MAX_FILENAME_LEN 256

/* 截 EVENT_TYPE 枚举 */

enum event_type {

EVENT_FORK = 1,

EVENT_EXEC = 2,

EVENT_EXIT = 3,

EVENT_NEWTASK = 4,

};

/* 内核-用户空间共享的事件结构 — 两边必须一致 */

struct event {

u32 type; // 事件类型 (fork/exec/exit)

u32 pid; // TGID (进程级 PID)

u32 ppid; // 父进程 PID

u32 uid; // Effective UID

u64 timestamp; // ktime 或 bpf_ktime_get_ns()

u64 start_time_boot; // 启动时间(用于 delta 计算)

s32 exec_ret; // exec 的返回码或 0

u32 exit_code; // 退出码

char comm[TASK_COMM_LEN]; // 进程名

char parent_comm[TASK_COMM_LEN]; // 父进程名

/* 填充字段以达到 8 字节对齐要求 — perf buffer 提交需要 8 字节对齐 */

u32 _pad;

};

4.4 用户空间加载器 (process_tracer.c)

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <string.h>

#include <signal.h>

#include <errno.h>

#include <linux/perf_event.h>

#include <linux/hw_breakpoint.h>

#include <sys/resource.h>

#include <bpf/libbpf.h>

#include "process_tracer.skel.h" /* 由 bpftool gen skeleton 自动生成 */

#include "process_tracer.h"

static volatile sig_atomic_t exiting = 0;

static void sig_handler(int sig)

{

exiting = 1;

}

/**

  • Libbpf 的 trampoline 级别调试回调

*

  • CO-RE 加载期间的错误信息会通过这里输出,提示你为什么被拒绝加载
  • 常见错误:
  • - "field 'xxx' not found in target kernel" → 字段在当前内核不存在
  • - "failed to find BTF info" → 目标内核未启用 BTF,需升级内核

*/

static int libbpf_print_fn(enum libbpf_print_level level,

const char *format, va_list args)

{

if (level == LIBBPF_DEBUG)

return 0;

return vfprintf(stderr, format, args);

}

static void handle_event(void *ctx, int cpu, void *data, __u32 data_sz)

{

struct event *e = data;

u64 ts = e->timestamp ?: bpf_ktime_get_ns();

switch (e->type) {

case EVENT_FORK:

printf("%-12llu FORK pid=%-8u ppid=%-8u uid=%-6u comm=%-16s parent=%-16s\n",

ts, e->pid, e->ppid, e->uid, e->comm, e->parent_comm);

break;

case EVENT_EXEC:

printf("%-12llu EXEC pid=%-8u ret=%-3d uid=%-6u comm=%-16s\n",

ts, e->pid, e->exec_ret, e->uid, e->comm);

break;

case EVENT_EXIT:

printf("%-12llu EXIT pid=%-8u code=%-3u uid=%-6u comm=%-16s\n",

ts, e->pid, e->exit_code, e->uid, e->comm);

break;

default:

break;

}

}

static void handle_lost_events(void *ctx, int cpu, __u64 lost_cnt)

{

fprintf(stderr, "Lost %llu events on CPU %d (buffer overrun)\n",

lost_cnt, cpu);

}

int main(int argc, char **argv)

{

struct process_tracer_bpf *skel;

struct perf_buffer *pb = NULL;

int err;

/* 设置 libbpf 调试输出 — 查看 CO-RE 重定位细节 */

libbpf_set_print(libbpf_print_fn);

/* 优雅关闭信号 */

signal(SIGINT, sig_handler);

signal(SIGTERM, sig_handler);

/* 打开 BPF 对象 — 触发 CO-RE 重定位机制 */

skel = process_tracer_bpf__open();

if (!skel) {

fprintf(stderr, "Failed to open BPF skeleton\n");

err = -ENOMEM;

goto cleanup;

}

/* --------

  • CO-RE 重定位在这里自动发生
  • libbpf 读取 /sys/kernel/btf/vmlinux 中的实际偏移量
  • 修补 BPF 指令中的立即数值

*

  • 如果目标内核中某些字段不存在,你会在控制台中看到:
  • "libbpf: CO-RE: field 'xxx' not found in target kernel"
  • 并且相关指令将被改写为 MOV_IMM(0) 或跳过
  • -------- */

err = process_tracer_bpf__load(skel);

if (err) {

fprintf(stderr, "Failed to load and verify BPF skeleton: %d\n", err);

goto cleanup;

}

/* 附加 tp 程序到 syscalls */

err = process_tracer_bpf__attach(skel);

if (err) {

fprintf(stderr, "Failed to attach BPF skeleton: %d\n", err);

goto cleanup;

}

/* 设置 perf buffer */

pb = perf_buffer__new(bpf_map__fd(skel->maps.events), 128,

handle_event, handle_lost_events, NULL, NULL);

if (!pb) {

err = -errno;

fprintf(stderr, "Failed to create perf buffer: %s\n", strerror(errno));

goto cleanup;

}

printf("Successfully started! Press Ctrl+C to exit.\n");

while (!exiting) {

err = perf_buffer__poll(pb, 100 /* timeout_ms */);

if (err < 0 && err != -EINTR) {

fprintf(stderr, "Error polling perf buffer: %s\n", strerror(-err));

goto cleanup;

}

}

cleanup:

perf_buffer__free(pb);

process_tracer_bpf__destroy(skel);

return err != 0;

}

4.5 Makefile (使用 libbpf 骨架工作流)

# Makefile for CO-RE eBPF process tracer

ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' | sed 's/aarch64/arm64/')

CLANG ?= clang

LLVM_STRIP ?= llvm-strip

BPFTOOL ?= bpftool

CC ?= gcc

libbpf 安装路径 — 通过 apt install libbpf-dev 或编译安装

LIBBPF_SRC := $(abspath ../libbpf/src)

LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a)

BPF_CFLAGS := -target bpf -D__TARGET_ARCH_$(ARCH) -I$(OUTPUT) \

-I../libbpf/include/uapi -g -O2 -Wall

CONFIGURE: 用户空间的 C 编译

APP_CFLAGS := -g -O2 -Wall

APP_LDFLAGS := $(LIBBPF_OBJ) -lelf -lz

OUTPUT := .output

APPS := process_tracer

.PHONY: all clean

all: $(APPS)

Step 1: 编译 BPF 内核代码

$(OUTPUT)/%.bpf.o: %.bpf.c $(wildcard %.h) vmlinux.h

$(CLANG) $(BPF_CFLAGS) -c $< -o $@

$(LLVM_STRIP) -g $@ # 移除 DWARF 以减小体积但保留 BTF

Step 2: 生成 libbpf 骨架头文件

$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o

$(BPFTOOL) gen skeleton $< > $@

Step 3: 编译用户空间加载器

$(OUTPUT)/%.o: %.c $(OUTPUT)/%.skel.h

$(CC) $(APP_CFLAGS) -c $< -o $@

Step 4: 链接生成可执行文件

$(OUTPUT)/%: $(OUTPUT)/%.o libbpf

$(CC) $^ $(APP_LDFLAGS) -o $@

libbpf 构建

$(LIBBPF_OBJ):

$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \

OBJDIR=$(dir $@) libbpf.a

.PHONY: vmlinux

vmlinux: $(OUTPUT)/vmlinux.h

$(OUTPUT)/vmlinux.h:

$(BPFTOOL) btf dump file /sys/kernel/btf/vmlinux format c > $@

clean:

rm -rf $(OUTPUT) $(APPS)

5. 进阶技巧:处理真正的内核差异

5.1 BPF_KPROBE 与 CO-RE 联合使用动态函数探测

Tracepoint 是稳定的 ABI,但 kprobe 允许跟踪任意函数。CO-RE + kprobe 的组合让我们在五的内核版本上探测相同的函数:

SEC("kprobe/tcp_sendmsg")

int BPF_KPROBE(trace_tcp_sendmsg, struct sock *sk, struct msghdr *msg, size_t size)

{

// 直接获得函数签名的参数,无需解析寄存器

// 自动适应不同 ABI 的参数传递约定

u16 family = BPF_CORE_READ(sk, __sk_common.skc_family);

if (family == AF_INET) {

// IPv4 处理

BPF_CORE_READ_INTO(&e->daddr, sk, __sk_common.skc_daddr);

BPF_CORE_READ_INTO(&e->saddr, sk, __sk_common.skc_rcv_saddr);

} else if (family == AF_INET6) {

// IPv6 处理

BPF_CORE_READ_INTO(&e->daddr_v6, sk, __sk_common.skc_v6_daddr.in6_u.u6_addr32);

}

return 0;

}

BPF_KPROBE 宏自动提供当前内核中函数签名的形参,并通过 PT_REGS_PARAM 宏从寄存器中正确提取。

5.2 处理内核字段重命名与移除

假设目标内核中 task_struct 的以下字段在某些版本中不存在或重命名:

/* 在 Linux 5.14 之前,UID 通过 taskcred_* 宏访问,之后变为 task_struct->cred

  • 且 cred 结构体本身在 5.8 前后有字段布局变化 */

#if __has_include("vmlinux.h")

/* CO-RE 推荐方式:使用条件读取 */

struct event {

u32 pid;

u64 start_time;

};

/* 在 newer 内核中 */

BPF_CORE_READ_INTO(&event.start_time, task, start_boottime);

/* 退化方案:使用 bpf_core_field_exists 检查字段 */

if (bpf_core_field_exists(task_struct, start_boottime)) {

BPF_CORE_READ_INTO(&e->start_time, task, start_boottime);

} else {

// 退化到旧的 start_time (使用 INVALID_HZ 但功能相同)

BPF_CORE_READ_INTO(&e->start_time, task, start_time);

}

#endif

/* 使用 bpf_core_enum_value_exists 处理枚举变更 */

int val = bpf_core_enum_value(task_states, __TASK_STOPPED);

5.3 BPF Ring Buffer vs Perf Buffer 的选择

CO-RE 中两种主要的内核→用户空间数据传输方式对比:

特征Perf BufferRing Buffer (Linux 5.8+)
最大项数N CPU × PAGE_SIZE固定字节(通常 256KB–8MB)
并发语义按 CPU 非重叠环形缓冲区全局单一环形缓冲区
丢失通知有有(但语义更清晰)
延迟略高(perf_event_open 开销)更低(纯 MPSC 队列)
API 复杂度perf_buffer__pollring_buffer__poll
丢失事件上报全局丢失计数全局丢失计数 + 实时通知

对于大多数追踪场景,我们推荐 Ring Buffer,因为它具有以下优势:

  • 全局一致性的事件顺序(跨 CPU)
  • 无需按 CPU 轮询
  • 更高的吞吐量和更低的延迟
  • 与 BPF timers 配合更好的超时语义

5.4 文件描述符 CO-RE 追踪 (kprobe + fentry)

通过跟踪 do_sys_openat2 来捕捉文件打开事件:

// CO-RE 处理 open/openat 不同版本差异

SEC("fentry/do_sys_openat2")

int BPF_PROG(trace_do_sys_openat2, int dfd, struct filename *name,

struct open_flags *how, umode_t mode)

{

// BPF_PROG 不依赖 struct pt_regs — 用 fentry 直接获取函数参数

const char *fname = BPF_CORE_READ(name, name);

// bpf_probe_read_str 安全读取用户空间指针

char buf[256];

long ret = bpf_probe_read_user_str(buf, sizeof(buf), fname);

if (ret > 0) {

// 过滤 uid

u32 uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;

if (uid != 0) { // 只追踪 root 操作

struct event e = {};

e.type = EVENT_OPEN;

e.uid = uid;

bpf_probe_read_kernel_str(e.open.path, sizeof(e.open.path), buf);

bpf_ringbuf_submit(&e, 0);

}

}

return 0;

}

这里的 fentry 替代 kprobe 是因为 fentry 完全消除了函数入口的额外 tracepoint 开销,并且对热路径性能损耗极小。

6. 内核验证器交互与优化

6.1 CO-RE 重定位与验证器的协同

BPF 验证器在加载时执行静态分析,确保程序的安全性。CO-RE 重定位在验证之前完成,这意味着:

  1. 验证器看到的是已经修补完偏移量的 BPF 指令
  2. 验证器会检查修补后的访问是否违反安全边界
  3. 如果某些字段因差异导致越界访问,验证器会拒绝加载(此时 libbpf 会 output 清晰的错误信息)

6.2 BPF 循环与 CO-RE 的协同

传统 BPF 禁止循环(防止无限执行),但 Linux 5.3+ 引入了有界循环支持。结合 CO-RE 可以编写复杂的数据处理逻辑:

// 遍历进程的每个 VMA 查找匹配的文件

struct vm_area_struct *vma = BPF_CORE_READ(task, mm, mmap);

struct vm_area_struct *cur = vma;

struct file *vm_file = NULL;

for (int i = 0; i < 64; i++) {

if (!cur)

break;

BPF_CORE_READ_INTO(&vm_file, cur, vm_file);

if (vm_file) {

// 读取文件路径,匹配目标文件路径

struct path f_path = BPF_CORE_READ(vm_file, f_path);

...

}

BPF_CORE_READ_INTO(&cur, cur, vm_next);

}

6.3 BPF 尾调用与 MAP 转移式状态机

CO-RE 处理重定位时,尾调用段定位表的偏移量是固定的(编译时确定),因此尾调用在不同内核版本中保持稳定。这让我们可以构建事件过滤状态机:

// BPF 尾调用跳转表

struct {

__uint(type, BPF_MAP_TYPE_PROG_ARRAY);

__uint(max_entries, 8);

__type(key, u32);

__type(value, u32);

} progs SEC(".maps");

// 在 tp 中通过 tail call 进入下一个处理阶段

SEC("tp/sched/sched_process_exec")

int handle_exec(struct trace_event_raw_sched_process_exec *ctx)

{

u32 key = EXEC_FILTER_STAGE;

bpf_tail_call(ctx, &progs, key);

return 0;

}

7. 性能调优与生产部署

7.1 关闭不必要的 libbpf 调试输出

生产环境中重定位信息不应该出现在日志中,通过修改静态链接或运行时设置抑制:

// 生产模式静默输出

libbpf_set_print(NULL); // 完全静默

// 或只保留错误级别

static int silent_print(enum libbpf_print_level level,

const char *format, va_list args)

{

if (level == LIBBPF_WARN)

return vfprintf(stderr, format, args);

return 0;

}

7.2 CO-RE BPF 程序大小优化

通过 BPF skeleton 直接嵌入二进制对象,避免磁盘 I/O 和维护多个文件:

// 编译时内联 BPF 二进制

$ bpftool gen object process_tracer.bpf.o process_tracer.bpf.o

或直接 GCC 链接: gcc ... process_tracer.bpf.o -lelf -lz

// 在骨架流程中直接引用

skel = process_tracer_bpf__open_and_load(); // 一次性完成

7.3 LSM 安全集成

CO-RE 可以与 Linux Security Modules 集成,实现安全策略强制执行:

// LSM hook 使用 BPF_PROG 宏 — 自动处理返回值和函数签名

SEC("lsm/file_receive")

int BPF_PROG(file_receive, struct file *file)

{

// 通过 CO-RE 读取文件的 inode UID

u32 uid = BPF_CORE_READ(file, f_inode, i_uid.val);

// 安全策略检查逻辑...

return 0;

}

7.4 Systemd 服务集成

作为系统级追踪器,通过 systemd 管理:

[Unit]

Description=eBPF Process Tracer (CO-RE)

After=network.target

ConditionKernelCommandLine=|btf

[Service]

Type=simple

ExecStart=/usr/local/bin/process_tracer

Restart=on-failure

RestartSec=5

MemoryMax=32M

CPUQuota=5%

PrivateTmp=true

ProcSubset=pid

ProtectHome=true

ProtectSystem=strict

NoNewPrivileges=true

AmbientCapabilities=CAP_BPF CAP_PERFMON CAP_NET_ADMIN CAP_SYS_ADMIN

[Install]

WantedBy=multi-user.target

8. 跨内核测试策略

8.1 如何在不接触物理机器的情况下测试跨版本兼容性

编写 __s32 ver 的条件宏:

// 在 BPF 程序中通过 bpf_core_kernel_version 判断

#if __has_builtin(__builtin_preserve_type_info)

if (bpf_core_kernel_version < KERNEL_VERSION(5, 15, 0)) {

// 使用旧字段名

BPF_CORE_READ_INTO(&pid, task, tgid);

} else {

// 使用新字段名

BPF_CORE_READ_INTO(&pid, task, thread_pid);

}

#endif

更优雅的方式 — 使用 libbpf 升级宏:

// vmlinux.h 可以按内核版本生成不同的 BPFTOOL_TAG 进行预定义

// 让我们可以使用 bpf_core_field_exists、 bpf_core_field_size 等宏

if (bpf_core_field_exists(task_struct, thread_pid)) {

// 5.15+ 内核,用 thread_pid 替代 tgid

BPF_CORE_READ_INTO(&pid, task, thread_pid);

} else if (bpf_core_field_exists(task_struct, tgid)) {

BPF_CORE_READ_INTO(&pod, task, tgid);

}

8.2 实际跨版本测试 — QEMU + 自定义 vm 方案

# 创建包含不同内核版本的 virtiofs/9pfs 共享目录,通过 CI 测试:

  • linux-5.4 (Alpine 3.15/16)
  • linux-5.10 (Ubuntu 22.04)
  • linux-5.15 (Debian 12)
  • linux-6.1 (Ubuntu 24.04)
  • linux-6.6 (Fedora 39)

复用同一份 eBPF 二进制在所有 VM 上加载测试

$ ./process_tracer && echo "PASS on $(uname -r)"

8.3 Deployer 模式:一次构建、多次部署

CO-RE 二进制具有全局唯一性,因此在 CI/CD 流程中:

  1. 在 build 容器(任意内核版本,只要 > 5.2 且支持 BTF)中编译
  2. 生成 .bpf.o 和 .skel.h,嵌入最终二进制
  3. 分发同一份二进制文件到所有异构机器
  4. 每台机器上的 libbpf 加载时独立完成重定位和验证

9. 完整可运行代码清单与示例输出

9.1 运行输出示例

$ sudo ./process_tracer

Successfully started! Press Ctrl+C to exit.

1234567890123 FORK pid=4521 ppid=4510 uid=1000 comm=bash parent=sshd

1234567890124 EXEC pid=4521 ret=0 uid=1000 comm=bash

1234567890456 FORK pid=4522 ppid=4521 uid=1000 comm=nginx parent=bash

1234567890789 EXEC pid=4522 ret=0 uid=1000 comm=nginx

1234567891234 EXIT pid=4521 code=0 uid=1000 comm=bash

1234567891567 FORK pid=4523 ppid=1 uid=0 comm=kworker/u4:2 parent=systemd

...

[Press Ctrl+C]

$ # 退出时总结

$ sudo ./process_tracer --stats

Processed: 147830 events

Lost events: 12 (0.008%) — triggered by short-lived processes during ringbuffer saturation

Runtime: 142.3s

Avg rate: 1041.7 events/sec

9.2 使用 BPF 尾调用构建分级过滤器

一个优雅的方案是:主事件路由 BPF 程序使用尾调用进入分级过滤器,每个过滤器 BPF 程序只针对一种事件类型,实现热点分离和代码模块化:

// progs map:

// KEY 0 → 处理 FORK 事件

// KEY 1 → 处理 EXEC 事件

// KEY 2 → 处理 EXIT 事件

// KEY 3 → 处理所有日志事件

SEC("tp/sched/sched_process_fork")

int handle_fork(struct trace_event_raw_sched_process_fork *ctx)

{

u32 key = 0;

bpf_tail_call(ctx, &progs, key);

return 0;

}

SEC("tp/sched/sched_process_exec")

int handle_exec(struct trace_event_raw_sched_process_exec *ctx)

{

u32 key = 1;

bpf_tail_call(ctx, &progs, key);

return 0;

}

10. 总结与未来方向

CO-RE 和 BTF 是近年来 eBPF 生态中最关键的基础设施改进之一。它们带来的核心价值包括:

  • 一次构建到处运行:消除内核头文件和交叉编译依赖
  • 类型安全保证:加载时验证目标内核的重定位合法性
  • 抽象层简化:隐藏内核内部结构变更的复杂度
  • 性能无损:编译时编码重定位,运行时零成本的补丁修补

CO-RE 目前已经成为了业界事实标准,几乎所有主流 eBPF 项目都已迁移:

  • BCC:提供向 libbpf/CO-RE 迁移的编译时推荐
  • bpftrace:通过 --info 输出提示用户升级到 CO-RE
  • Falco:已实现完整的 CO-RE 支持,内核模块不再是必须
  • Pixie:通过 CO-RE 在 GKE/EKS 等云 Kubernetes 上无缝部署
  • Tetragon:Cilium 的 eBPF 安全观测引擎,100% 依赖 CO-RE

Kernel 的未来发展方向也在进一步优化 CO-RE 的体验:

  • BPF Type Format 的扩展:支持更多类型种类(如位字段、异常追踪、多维数组)
  • 强化的重定位缓存:对频繁加载相同 eBPF 程序时加速重定位
  • 用户空间 CO-RE (USCORE):目标应用进程与 UPROBE 追踪的结构体访问也可享受类似机制
  • BPF CO-RE uprobe 追踪的跨进程 ABI 兼容性保证

推荐资源

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部