土法炼钢兴趣小组的算法知识备份

【eBPF 内核实现深度拆解】CO-RE 重定位引擎:libbpf 的运行时指令修补

文章导航

分类入口
kernelebpf
标签入口
#ebpf#co-re#relocation#btf#libbpf#preserve_access_index#clang#bpf_core_read#linux-kernel

目录

你在 Ubuntu 22.04(kernel 5.15)上编译的 BPF 程序访问 struct task_struct->comm,编译时硬编码的偏移量是 2328。把这个 .o 文件放到 CentOS 9 的 5.14 内核上运行——comm 的偏移量是 2312。直接加载,读取到的是错误的字段内容,运气好时触发 verifier 的越界访问拒绝,运气差时静默读到其他字段的值。

这不是一个理论问题——Red Hat、Ubuntu、Debian 各自维护的内核 tree 有不同的 backport 策略,同一个内核大版本的不同构建之间 struct task_struct 的布局就可能不同。加上内核自身的演进(如 sched/headers: Move signal_struct field 之类的补丁持续重构内核数据结构),硬编码字段偏移量的 BPF 程序在跨内核版本时必定出错。

CO-RE(Compile Once – Run Everywhere)解决的就是这个问题:不在编译时决定字段在哪,而在加载时通过读取目标内核的 BTF 动态计算正确的位置。本文追踪 CO-RE 的完整链路——从 clang 的 __builtin_preserve_access_index() 生成重定位记录,到 struct bpf_core_reloBTF.ext 中的编码,再到 libbpf 的 bpf_core_apply_relo() 在加载时执行的实际指令修补。源码基于 kernel 6.6 的 tools/lib/bpf/relo_core.c

一、问题:编译期偏移量的脆弱性

1.1 无 CO-RE 的 BPF 程序

不使用 CO-RE 时,BPF 程序访问内核结构体字段的方式是在编译时依赖头文件中的类型定义,编译器计算出字段的绝对偏移量嵌入 BPF 指令:

// 无 CO-RE:编译时决定偏移量
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
// 编译为:
//   r1 = *(u64 *)(r0 + 1008)    ← 1008 是 task->tgid 在编译内核头文件中的偏移
u32 pid = task->tgid;

当程序在偏移量为 1024 的内核上运行时,这条指令读取到的是完全错误的字段。更糟的是,内核内部的许多结构体布局甚至在同一主线版本的不同 distro kernel 之间都有差异——因为发行版从不同的子版本 backport 补丁。

1.2 CO-RE 的解决方案

CO-RE 将”访问哪个字段”(what)与”字段在哪个偏移”(where)分离:

  1. 编译时:clang 记录”这个 BPF 指令想访问 struct task_struct 的第 3 个字段(tgid)“,而不是硬编码偏移量。这些记录写入 .BTF.ext 的 CO-RE 重定位节中。
  2. 加载时:libbpf 读取 .BTF.ext 的重定位记录,从目标内核的 BTF(/sys/kernel/btf/vmlinux)中查找 struct task_struct.tgid 的实际偏移量,然后才把这实际偏移量写入 BPF 指令。
// 有 CO-RE:加载时决定偏移量
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
// 编译为(初始偏移=0,有重定位记录):
//   r1 = *(u64 *)(r0 + 0)       ← 0 是占位符,加载时 libbpf 会修补
u32 pid = BPF_CORE_READ(task, tgid);
// libbpf 加载时 → 读取目标内核 BTF → tgid 实际偏移 = 1024 → 修补指令为 (r0 + 1024)

二、编译器端:__builtin_preserve_access_index

2.1 工作原理

__builtin_preserve_access_index() 是 clang 的 BPF 后端专用的编译器内置函数。它接受一个指针运算表达式,将这个表达式的访问路径记录为 CO-RE 重定位信息,而不在编译期计算实际偏移。

// clang BPF 后端处理:
// __builtin_preserve_access_index(expr)
// 1. 正常编译 expr → BPF 指令序列(偏移量为本地 BTF 的值或 0)
// 2. 额外生成一个 CO-RE 重定位记录,写入 .BTF.ext 的 core_relo 子节
// 3. 记录内容:指令偏移、访问路径(type_id:idx:idx...)、重定位种类

对于 task->tgid 这个访问,clang 生成的重定位记录编码为:

2.2 生成的重定位结构体

CO-RE 重定位记录在 ELF 文件中编码为 struct bpf_core_relo

/* include/uapi/linux/btf.h (kernel 6.6) */
struct bpf_core_relo {
    __u32   insn_off;       /* 指令在 BPF 程序中的字节偏移 */
    __u32   type_id;        /* 访问的起始 BTF 类型 ID */
    __u32   access_str_off; /* 访问路径字符串在 BTF 字符串表中的偏移 */
    __u32   kind;           /* 重定位种类(enum bpf_core_relo_kind) */
};

access_str_off 指向一个编码访问路径的字符串,格式为:

"<local_spec_id>:<idx0>:<idx1>:..."

其中 <local_spec_id> 是通过 __attribute__((btf_type_tag(...)))preserve_access_index 额外绑定的本地 location specifier ID(clang 内部编号),<idx0> 是起始类型中的成员索引,<idx1> 是下一层类型中的成员索引,依此类推。

2.3 重定位种类枚举

/* include/uapi/linux/btf.h - enum bpf_core_relo_kind */
enum bpf_core_relo_kind {
    BPF_CORE_FIELD_BYTE_OFFSET = 0,   /* 修补字段的字节偏移 */
    BPF_CORE_FIELD_BYTE_SIZE   = 1,   /* 修补字段的字节大小 */
    BPF_CORE_FIELD_EXISTS      = 2,   /* 检查字段是否存在(返回 0 或 1) */
    BPF_CORE_FIELD_SIGNED      = 3,   /* 检查字段是否是有符号类型 */
    BPF_CORE_FIELD_LSHIFT_U64  = 4,   /* 位域操作的左移位数 */
    BPF_CORE_FIELD_RSHIFT_U64  = 5,   /* 位域操作的右移位数 */
    BPF_CORE_TYPE_ID_LOCAL     = 6,   /* 将本地 type_id 写入指令 */
    BPF_CORE_TYPE_ID_TARGET    = 7,   /* 将目标 type_id 写入指令 */
    BPF_CORE_TYPE_EXISTS       = 8,   /* 检查类型是否存在(返回 0 或 1) */
    BPF_CORE_TYPE_SIZE         = 9,   /* 修补类型的大小 */
    BPF_CORE_ENUMVAL_EXISTS    = 10,  /* 检查枚举值是否存在 */
    BPF_CORE_ENUMVAL_VALUE     = 11,  /* 修补枚举值的数值 */
    BPF_CORE_TYPE_MATCHES      = 12,  /* 检查两个类型是否匹配(返回 0 或 1) */
};

每个 kind 决定了 libbpf 修补 BPF 指令的哪个字段以及修补成什么值:

Kind 修补的指令字段 修补的来源
FIELD_BYTE_OFFSET imm(指令的立即数) 目标 BTF 中的 btf_member.offset / 8
FIELD_BYTE_SIZE imm 目标 BTF 中的 btf_type.size(经过成员类型解析)
FIELD_EXISTS imm(设为 0 或 1) 目标 BTF 中对应的 member name_off 是否存在且类型兼容
FIELD_SIGNED imm(设为 0 或 1) 目标 BTF 中 BTF_INT_ENCODING & BTF_INT_SIGNED
TYPE_ID_LOCAL/TARGET imm 对应的 type_id
TYPE_EXISTS imm(设为 0 或 1) 目标 BTF 中是否存在名称和 kind 匹配的类型
TYPE_SIZE imm 目标 BTF 中的 btf_type.size
ENUMVAL_EXISTS imm(设为 0 或 1) 目标 BTF 中是否存在同名同种枚举值
ENUMVAL_VALUE imm 目标 BTF 中枚举值的实际数值

三、BPF_CORE_READ 宏展开

3.1 宏定义链路

BPF_CORE_READ(src, field) 展开为多层嵌套的编译器内置函数调用。以下为示意性简化——完整展开涉及 ___core_read 变参递归,以 tools/lib/bpf/bpf_core_read.h 为准:

// BPF_CORE_READ(task, tgid) 示意展开:
__builtin_preserve_access_index(((typeof(task))0)->tgid)

BPF_CORE_READ 的完整实现使用变参数宏 BPF_CORE_READ_STR_INTO() 处理多级字段访问:

/* tools/lib/bpf/bpf_core_read.h (kernel 6.6) — 简化逻辑 */
#define BPF_CORE_READ(src, a, ...)                                       \
    ({                                                                   \
        typeof(src) _v = (src);                                          \
        __attribute__((preserve_access_index)) *_v;                         \
        _v->a ## __VA_ARGS__;                                            \
    })

/* 更高效的底层宏:处理指针链 */
#define BPF_CORE_READ_INTO(dst, src, a, ...)                             \
    ({                                                                   \
        ___core_read(bpf_rdonly_cast(dst, __u64), src, a, ##__VA_ARGS__); \
    })

3.2 bpftool 生成的 vmlinux.h 中的类型注解

使用 CO-RE 的程序通常不直接 #include <linux/sched.h>(因为内核头文件与 BPF 编译环境不兼容),而是使用 bpftool 生成的 vmlinux.h

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

生成的 vmlinux.h 对所有结构体/联合体类型添加了 __attribute__((preserve_access_index))

/* vmlinux.h 片段(由 bpftool gen 自动生成) */
typedef struct task_struct {
    ...
    pid_t tgid __attribute__((preserve_access_index));
    char comm[16] __attribute__((preserve_access_index));
    ...
} __attribute__((preserve_access_index)) task_struct;

有了这个属性加持,在 BPF 程序中直接写 task->tgid(不使用 BPF_CORE_READ 宏)也能触发 CO-RE 重定位——clang 将 preserve_access_index 视为对类型所有成员访问的统一标记。但 BPF_CORE_READ 宏仍然是推荐做法,因为它在代码层面显式区分了 CO-RE 保护的访问和普通访问。

3.3 不支持 CO-RE 的宏:直接访问

bpf_core_read.h 还提供了 BPF_CORE_READ_INTO() 用于将 CO-RE 读取结果直接写入目标变量,以及 bpf_core_read() 底层的直接调用:

/* 底层调用 */
long bpf_core_read(void *dst, int sz, const void *unsafe_ptr);

/* 宏封装 */
#define BPF_CORE_READ_INTO(dst, src, field) ...
#define BPF_CORE_READ_STR_INTO(dst, src, field) ...  // 字符串读取(自动 NULL 终止)
#define BPF_CORE_READ_BITFIELD(src, field) ...         // 位域读取

bpf_core_read() 底层实现为 bpf_probe_read_kernel()(对于 kernel-space CO-RE 访问),它是 BPF helper 的封装,并不直接和 CO-RE 机制耦合——CO-RE 只修补偏移量字段,实际的读取仍然走正常的 BPF 内存加载路径。

四、libbpf 端:bpf_core_apply_relo() 的指令修补

4.1 入口与调用时机

bpf_core_apply_relo() 定义在 tools/lib/bpf/relo_core.c,在 bpf_object__load() 流程中、每个程序调用 BPF_PROG_LOAD 之前被调用。libbpf 在用户态持有 BPF 指令的内存副本,先根据目标内核 BTF 修补 CO-RE 占位符,再将已修补的字节码一次性提交给内核 verifier:

bpf_object__load(obj)
  → bpf_object__create_maps(obj)
  → bpf_object__relocate(obj)           // 非 CO-RE 重定位(map fd 等)
  → bpf_object__relocate_core(obj)      // 对每个 prog 应用 CO-RE 重定位
for each prog: for each relo:
          bpf_core_apply_relo(prog, relo)
            → 读取目标内核 BTF(/sys/kernel/btf/vmlinux)
            → 解析访问路径,计算偏移/大小/存在性
            → 修补 prog->insns[relo->insn_off / 8] 的 imm 或 off
  → bpf_object__load_progs(obj)         // 提交已修补的字节码
      → bpf(BPF_PROG_LOAD, ...)

4.2 修补过程详解

BPF_CORE_FIELD_BYTE_OFFSET 为例,bpf_core_apply_relo() 的内部逻辑:

/* tools/lib/bpf/relo_core.c - bpf_core_apply_relo() 简化流程 */
int bpf_core_apply_relo(struct bpf_program *prog,
                        const struct bpf_core_relo *relo,
                        int relo_idx,
                        const struct btf *local_btf,
                        const struct btf *target_btf)
{
    struct bpf_insn *insn = &prog->insns[relo->insn_off / sizeof(struct bpf_insn)];
    const char *access_str = btf__str_by_offset(local_btf, relo->access_str_off);
    struct bpf_core_spec specs[CORE_SPEC_MAX];  // 解析的 CO-RE 规格
    int err;

    /* 步骤 1:解析访问路径 */
    err = bpf_core_spec_parse(local_btf, relo->type_id, access_str, specs);

    /* 步骤 2:在目标 BTF 中查找对应的字段/类型 */
    /*    local_spec → target_spec 的映射:遍历目标 BTF 中同名同结构类型的同一成员 */
    struct bpf_core_spec target_spec;
    err = bpf_core_calc_relo(relo->kind, specs, local_btf, target_btf, &target_spec);

    /* 步骤 3:根据计算结果修补指令 */
    switch (relo->kind) {
    case BPF_CORE_FIELD_BYTE_OFFSET:
        /* 修补指令的 imm 字段:填写字段的字节偏移 */
        insn->imm = bpf_core_field_byte_off(target_spec);
        break;
    case BPF_CORE_FIELD_BYTE_SIZE:
        /* 修补指令的 imm 字段:填写字段的字节大小 */
        insn->imm = bpf_core_field_byte_size(target_spec);
        break;
    case BPF_CORE_FIELD_EXISTS:
        /* 修补指令的 imm 字段:字段存在则 0,不存在则从 verifier 错误中返回 */
        insn->imm = target_spec.field_exists ? 0 : 1;
        break;
    /* ... 其他种类类似 ... */
    }

    return 0;
}

4.3 具体示例:修补 task->comm 的偏移

考虑这样一段 BPF 代码:

struct task_struct *task = (void *)bpf_get_current_task();
char comm[16];
BPF_CORE_READ_INTO(&comm, task, comm);

bpf_core_read() 调用展开后的 BPF 指令序列(简化)大致长这样:

; r0 = bpf_get_current_task()
  0: (85) call bpf_get_current_task#35
; r1 = r0 + 0    <-- 0 是 task_struct->comm 的偏移占位符
  1: (07) r1 = r0
; 对应的 CO-RE 重定位:
;   insn_off=8 (指令 1 的偏移)
;   type_id=<BTF_KIND_STRUCT("task_struct")>
;   access_str="0:?@task_struct?comm"
;   kind=BPF_CORE_FIELD_BYTE_OFFSET
; r2 = 16         <-- sizeof(comm)
  2: (b7) r2 = 16
; bpf_probe_read_kernel(r9, r2, r1)
  3: (bf) r3 = r1
  4: (85) call bpf_probe_read_kernel#113

libbpf 加载此程序时的修补过程:

  1. BTF.ext 中找到指令 1 的 CO-RE 重定位记录;
  2. 解析 access_str:起始 type_id 是 task_struct,访问路径是 comm
  3. 在目标内核的 BTF 中查找 struct task_struct 的成员 comm,提取 btf_member->offset / 8
  4. 发现在当前目标内核中 comm 的字节偏移是 2312(而非编译时的 2328);
  5. 将指令 1 的 src_reg 保持不变(仍为 r0),修改 off 字段为 2312(某些 insn 变体修改 offset 而非 imm)。

修补后的 BPF 指令:

; r1 = r0 + 2312    <-- 使用目标内核的实际偏移
  1: (07) r1 = r0
     off = 2312

4.4 结构等价匹配:local_spec → target_spec

这是 CO-RE 最精巧的部分。bpf_core_calc_relo() 需要将编译环境中的类型引用(local BTF 中的 type_id)映射到运行环境中的等价类型(target BTF 中的 type_id)。匹配规则如下:

  1. 按名称匹配:在 target BTF 中查找与 local BTF 中相同名称的 STRUCT/UNION/ENUM/ENUM64/FWD/TYPEDEF
  2. 按嵌套名称匹配:对于 typedef 包裹的类型链(typedef struct task_struct task_t),递归解析 typedef 直到找到基础类型,然后按名称匹配;
  3. 成员兼容性检查:匹配到结构体后,在 target 中找到与 local 中同名的成员——成员的自身类型也必须兼容(名称匹配或 nested typedef 解析匹配);
  4. 失败处理:如果 target BTF 中找不到 matching type 或 member,根据重定位种类返回不同结果——FIELD_EXISTS 返回 false,FIELD_BYTE_OFFSET 返回错误导致加载失败。

五、CO-RE 的边界与失效模式

5.1 CO-RE 能处理的情况

场景 CO-RE 行为
字段插入到结构体前面 自动修正所有后续字段的偏移量
字段从结构体中间删除 自动修正所有后续字段的偏移量;访问被删除字段时加载失败
字段类型改变(布局不变) 不影响偏移重定位,但可能影响读写语义
字段重命名 匹配失败(FIELD_EXISTS 返回 0)
类型重命名 匹配失败
位域定义改变 通过 FIELD_LSHIFT_U64 / FIELD_RSHIFT_U64 处理
枚举值改变 通过 ENUMVAL_VALUE 处理

5.2 CO-RE 失效的情况

  1. 类型重命名:如果 struct task_struct 在内核 evolve 某一版本被改为 struct task,CO-RE 的所有基于名称的类型匹配全部失效。这类重大改变在主线内核中极其罕见,但在企业内核的 backport 分支中可能出现。

  2. 字段语义改变:如果 task->pid 的偏移量保持不变(比如因为前面删了一个相同大小的字段),但 pid 的语义从 pid_t 变成了 struct pid *——CO-RE 只修补了偏移,不知道字段类型变了。这种静默语义改变会导致逻辑错误而非崩溃。

  3. 访问路径中充满重定义 typedef:深层次引用的内核类型链(struct task_structstruct signal_structstruct mm_struct → …)中如果任何一环的类型名被重定义,整条访问路径断裂。

  4. 动态嵌入结构体字段:某些内核实现中字段的 type_id 指向一个 CONSTVOLATILE 包裹的类型,而 local 和 target 的包裹层数不一致——CO-RE 的类型匹配需要 recursive 解析 type modifier chains,但过深的嵌套可能导致匹配超时。

5.3 确定匹配模式:strict vs relaxed

libbpf 支持两种 CO-RE 匹配模式:

六、Mermaid:CO-RE 从编译到修补的完整序列

sequenceDiagram
    participant Dev as 开发者
    participant Clang as Clang BPF 后端
    participant ELF as BPF ELF .o
    participant Libbpf as libbpf
    participant TargetBTF as 目标内核 BTF
    participant Kernel as 内核

    Dev->>Clang: BPF_CORE_READ(task, tgid)
    Clang->>Clang: __builtin_preserve_access_index()
    Clang->>Clang: 正常编译 & 生成 bpf_core_relo 记录
    Clang->>ELF: insn(off=0) + BTF.ext(core_relo)

    Dev->>Libbpf: bpf_object__open(prog.o)
    Libbpf->>ELF: 解析 BTF.ext → core_relo 条目列表

    Dev->>Libbpf: bpf_object__load(obj)
    Note over Libbpf: create_maps → relocate → relocate_core
    loop 每个 CO-RE 重定位
        Libbpf->>Libbpf: bpf_core_apply_relo(prog, relo)
        Libbpf->>TargetBTF: 加载 /sys/kernel/btf/vmlinux
        TargetBTF-->>Libbpf: 查找 struct task_struct.tgid 的偏移
        Libbpf->>Libbpf: 计算实际偏移=1024,修补 insn
    end
    Libbpf->>Kernel: BPF_PROG_LOAD(已修补,偏移=1024)
    Note over Kernel: verifier & JIT
    Kernel-->>Libbpf: prog fd

    Libbpf->>Kernel: attach

七、小结

CO-RE 是一个精巧的两阶段架构:编译器端只记录”想访问什么”,加载器端根据目标内核的实际类型布局计算”现在它在哪”。这个架构的每一点正确性都依赖 BTF 提供的完整类型信息——第 11 篇讲 BTF 的格式编码,本篇讲它的下游消费。两篇合在一起,才能理解为什么”编译一次到处运行”是可以工作的,以及在什么条件下它会失效。

理解 CO-RE 的失效边界和修补语义,对于写生产级跨内核版本 BPF 程序不是可选项——是你的程序从”在自己机器上运行”到”在别人机器上运行”必须跨越的技术鸿沟。


参考

  1. Linux 内核源码 tools/lib/bpf/relo_core.c(kernel 6.6):bpf_core_apply_relo() 完整实现
  2. Linux 内核源码 tools/lib/bpf/bpf_core_read.hBPF_CORE_READ 等宏定义
  3. Linux 内核源码 include/uapi/linux/btf.hstruct bpf_core_reloenum bpf_core_relo_kind
  4. Linux 内核源码 tools/lib/bpf/libbpf.cbpf_object__relocate_core() 调用入口
  5. Andrii Nakryiko, “BPF CO-RE Reference Guide” (nakryiko.com)
  6. Clang/LLVM BPF 后端源码(llvm/lib/Target/BPF/):__builtin_preserve_access_index 实现

上一篇BTF 格式规范与内核类型系统(第 11 篇)

下一篇BPF 编译工具链(第 13 篇)

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-12 · kernel / ebpf

【eBPF 内核实现深度拆解】从验证器到 JIT,从 BTF 到调度器

eBPF 内核虚拟机内部实现系统讲解:BPF 指令集与寄存器机器、验证器的抽象解释与状态裁剪、JIT 编译器后端、Map 各类型的并发与内存模型、helper 函数注册与类型检查、BTF 格式规范与 CO-RE 重定位引擎、libbpf 加载器工程、fentry/fexit 蹦床机制、sched_ext 调度器内核接口。面向想读懂 eBPF 内核源码、写生产级 BPF 程序的系统工程师。

2026-06-12 · kernel / ebpf

【eBPF 内核实现深度拆解】BTF 格式规范与内核类型系统

从 BTF 的二进制编码格式(btf_header + type entries + string table)出发,讲清 BTF 如何编码基本类型、结构体、联合体、函数原型与 typedef——BTF.ext 节的 func_info/line_info 记录,以及内核 pahole 的 BTF 生成与去重算法 btf_dedup。

2026-06-12 · kernel / ebpf

【eBPF 内核实现深度拆解】实战:构建微型 eBPF 可观测 Agent

把 01--17 的知识串成一条实践线——从 libbpf skeleton 写第一个 BPF 程序、加载到内核、用 ring buffer 回传事件、用 CO-RE 实现跨内核版本兼容、map pinning 实现热升级、配上半自动化的 verifier 错误排障流程——构建一个麻雀虽小五脏俱全的 eBPF 可观测 Agent。


By .