BPF_PROG_LOAD 系统调用深度剖析——从用户态到内核的指令流转之旅

BPF_PROG_LOAD 系统调用深度剖析——从用户态到内核的指令流转之旅

作者:ybb | 日期:2026年10月8日 | 分类:Linux 内核 / eBPF

1. 概述:BPF 加载全景

Berkeley Packet Filter(BPF)已经从当年那个仅用于网络包过滤的简易虚拟机,演变为当今 Linux 内核中最强大的可编程基础设施。无论是网络(XDP)、可追踪性(kprobes/tracepoints)、安全(seccomp/Landlock),还是资源调度(sched-ext),BPF 都在以一种前所未有的方式改变着我们与内核交互的模式。

然而,这一切的基础是一个看似简单的操作——将 BPF 程序加载到内核中。这个操作通过 bpf() 系统调用、使用 BPF_PROG_LOAD 命令来完成。从用户态的 libbpf 调用开始,BPF 字节码经历了验证、修复、JIT 编译、文件描述符分配等一系列复杂的内核处理步骤,最终变成一个可以附加到内核钩子的可执行实体。

本文将完整追踪这一流程,深入到内核源码层面,揭示从 bpf(BPF_PROG_LOAD, ...) 发出那一刻起,到返回文件描述符为止所发生的一切。与本系列上一篇讨论 eBPF Verifier 的文章互补,本文关注的是加载链路的全貌。

2. BPF 加载接口的演进史

理解 BPF_PROG_LOAD 需要先回顾 BPF 加载接口的历史变迁。

2.1 经典 BPF(cBPF)时代

在 Linux 2.1.75(1997年)引入 BPF 时,加载 BPF 程序的方式非常简单:对网络套接字使用 setsockopt(SO_ATTACH_FILTER, ...)。此时的 BPF 指令集极为精简——仅有 32 位操作、两个寄存器(A 和 X),以及有限的跳转指令。程序在套接字层面即时解释执行,没有验证器、没有 JIT。

2.2 eBPF 的诞生

2014 年,Alexei Starovoitov 引入了扩展 BPF(eBPF)。关键变化包括:64 位寄存器(10 个通用寄存器 R0-R9 + 栈指针 R10)、更丰富的指令集、以及引入了验证器(Verifier)来保证程序安全性。

起初,eBPF 程序的加载仍然通过 setsockopt 完成,这带来了严重的设计问题:

  • 套接字语义与 BPF 程序类型紧耦合,无法复用 BPF 程序到其他场景
  • 缺乏独立的 BPF 对象管理
  • 难以支持 Map 等共享状态结构

2.3 bpf() 系统调用

Linux 3.18(2014 年 12 月)引入了专用的 bpf() 系统调用,最初仅支持 BPF_MAP_CREATE 和 BPF_PROG_LOAD 两个命令。这标志着 BPF 开始作为独立于套接字的内核抽象存在。

随后,系统调用不断扩展,新增了数十个 bpf_cmd:

// include/uapi/linux/bpf.h 中的部分 BPF 命令
enum bpf_cmd {
    BPF_MAP_CREATE,           // 创建 BPF Map
    BPF_MAP_LOOKUP_ELEM,      // 查找 Map 元素
    BPF_MAP_UPDATE_ELEM,      // 更新 Map 元素
    BPF_MAP_DELETE_ELEM,      // 删除 Map 元素
    BPF_MAP_GET_NEXT_KEY,     // 获取下一个键
    BPF_PROG_LOAD,            // 加载 BPF 程序
    BPF_OBJ_PIN,              // 固定 BPF 对象到伪文件系统
    BPF_OBJ_GET,              // 通过路径获取 BPF 对象
    BPF_PROG_ATTACH,          // 附加 BPF 程序
    BPF_PROG_DETACH,          // 分离 BPF 程序
    BPF_PROG_TEST_RUN,        // 测试运行(bpftool prog run)
    BPF_PROG_GET_NEXT_ID,     // 获取下一个程序 ID
    BPF_MAP_GET_NEXT_ID,      // 获取下一个 Map ID
    BPF_PROG_GET_FD_BY_ID,    // 通过 ID 获取 FD
    BPF_MAP_GET_FD_BY_ID,     // 通过 ID 获取 FD
    BPF_OBJ_GET_INFO_BY_FD,   // 通过 FD 获取详细信息
    BPF_PROG_QUERY,           // 查询已附加的程序
    BPF_RAW_TRACEPOINT_OPEN,  // 打开裸 tracepoint
    BPF_BTF_LOAD,             // 加载 BTF 信息
    BPF_BTF_GET_FD_BY_ID,     // 通过 ID 获取 BTF FD
    BPF_TASK_FD_QUERY,        // 查询任务 FD
    BPF_MAP_LOOKUP_AND_DELETE_ELEM, // 原子查找并删除
    BPF_MAP_FREEZE,           // 冻结 Map
    BPF_BTF_GET_NEXT_ID,      // 获取下一个 BTF ID
    BPF_MAP_LOOKUP_BATCH,     // 批量查找
    BPF_MAP_LOOKUP_AND_DELETE_BATCH,
    BPF_MAP_UPDATE_BATCH,     // 批量更新
    BPF_MAP_DELETE_BATCH,     // 批量删除
    BPF_LINK_CREATE,          // 创建 BPF Link(替代 PROG_ATTACH)
    BPF_LINK_UPDATE,          // 更新 BPF Link
    BPF_LINK_GET_FD_BY_ID,
    BPF_LINK_GET_NEXT_ID,
    BPF_ENABLE_STATS,         // 启用统计
    BPF_ITER_CREATE,          // 创建迭代器
    BPF_LINK_DETACH,
    BPF_PROG_BIND_MAP,        // 绑定程序到 Map
    BPF_TOKEN_CREATE,         // 创建 BPF Token
};

3. bpf() 系统调用入口

bpf() 系统调用的函数签名如下:

// include/linux/syscalls.h
SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr *, attr, unsigned int, size)

三个参数的含义:

  • cmd:指定要执行的命令,如 BPF_PROG_LOAD、BPF_MAP_CREATE 等
  • attr:指向 union bpf_attr 的指针,这是一个巨大的联合体,不同命令使用不同字段
  • size:attr 结构的大小,用于向前/向后兼容

size 参数的存在是 Linux 内核 ABI 兼容性的精妙体现。当内核新增字段到 bpf_attr 时,旧用户态程序传入的 size 较小,内核忽略新字段;新用户态程序传入更大的 size,内核填充新增字段。这使得 libbpf 可以跨内核版本工作。

3.1 调用链路概览

用户态到内核态的完整调用路径:

用户态: bpf() / bpf_prog_load()
  ↓ glibc wrapper
   syscall instruction (x86) / svc #0 (ARM64)
  ↓
   SYSCALL_DEFINE3(bpf, ...)        [kernel/bpf/syscall.c]
  ↓
   switch (cmd) {
     case BPF_PROG_LOAD:
       bpf_prog_load(attr, size);
     case BPF_MAP_CREATE:
       bpf_map_create(attr, ...);
     ...
   }

4. libbpf:现代 BPF 加载的标准库

直接调用 bpf() 系统调用需要填充巨大的 union bpf_attr,实际上非常不便。libbpf(位于内核源码 tools/lib/bpf/)是官方维护的用户态库,封装了所有 BPF 操作,是现代 BPF 应用的事实标准。

4.1 核心 API

libbpf 主要为 BPF 程序加载提供了两层接口:

高层接口(Skeleton):通过 bpftool gen skeleton 从 BPF 目标文件(.bpf.o)生成 C 头文件,提供类型安全的 API:

struct my_prog *skel = my_prog__open();
my_prog__load(skel);     // 自动加载程序和 Map
my_prog__attach(skel);   // 自动附加到钩子
my_prog__destroy(skel);  // 清理资源

底层接口(直接加载):通过 bpf_prog_load() 系列函数手动控制加载过程:

// 最简单的 BPF 程序加载流程
struct bpf_object *obj = bpf_object__open_file("my_prog.bpf.o", NULL);
bpf_object__load(obj);    // 内部调用 BPF_PROG_LOAD 对所有程序
struct bpf_program *prog = bpf_object__find_program_by_name(obj, "xdp_main");
int prog_fd = bpf_program__fd(prog);  // 获取文件描述符
// 然后附加...

4.2 自动加载 vs 手动控制

libbpf 的 bpf_object__open_mem() 和 bpf_object__open_file() 做了大量准备工作:

  • ELF 解析:从 ELF 文件中提取 BPF 程序、Map 定义、BTF 数据、重定位信息
  • Probe-Based 适配:根据目标内核版本,选择兼容的函数集
  • License 检查:提取 __license 符号并与 GPL 兼容许可证进行匹配
  • BTF 提取:加载自定义 BTF(BPF Type Format)用于 CO-RE(Compile Once, Run Everywhere)

5. 内核入口:SYSCALL_DEFINE3(bpf)

深入内核源码,bpf() 系统调用的实现在 kernel/bpf/syscall.c 中。入口处理逻辑相当简洁——一个巨大的 switch 语句:

// kernel/bpf/syscall.c
SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr *, attr, unsigned int, size)
{
    bpf_enable_stats();
    
    switch (cmd) {
    case BPF_MAP_CREATE:
        err = bpf_map_create(attr); break;
    case BPF_PROG_LOAD:
        err = bpf_prog_load(attr, size); break;
    case BPF_LINK_CREATE:
        err = bpf_link_create(attr, size); break;
    case BPF_BTF_LOAD:
        err = bpf_btf_load(attr, size); break;
    case BPF_PROG_ATTACH:
        err = bpf_prog_attach(attr); break;
    default:
        err = -EINVAL;
    }
    
    return err;
}

每个命令的处理前都会进行能力检查(capability check)。对于 BPF_PROG_LOAD,默认要求 CAP_BPF 能力(Linux 5.8+),在此之前要求 CAP_SYS_ADMIN。随着 BPF Token 的引入,权限模型变得更加灵活。

6. BPF_PROG_LOAD 核心流程详解

bpf_prog_load() 是整个 BPF 加载流程中最复杂的函数,它执行以下关键步骤:

6.1 许可证验证

BPF 程序在内核中运行的是 GPL 许可证的代码。内核会检查 BPF 程序声明的许可证是否与 GPL 兼容:

// kernel/bpf/syscall.c
static int bpf_prog_load(union bpf_attr *attr, unsigned int size)
{
    enum bpf_prog_type type = attr->prog_type;
    struct bpf_prog *prog;
    
    // 1. 检查 BPF token 权限
    // 2. 验证程序类型的特权要求
    // 3. 许可证检查
    if (!bpf_prog_is_gpl_compatible(license)) {
        // 仅 GPL 兼容许可证的程序才能访问 GPL-only 辅助函数
    }
}

许可证检查不在早期拒绝非 GPL 程序,而是标记一个标志。在后续验证器阶段,如果程序调用了 GPL-only 辅助函数但声明的许可证不兼容,才会被拒绝。这种设计允许非 GPL 程序(如 BSD 许可证)使用纯 MIT 兼容的辅助函数子集。

6.2 程序类型分发

根据 prog_type,加载流程会调用类型特定的预处理回调。每种 BPF 程序类型都有独特的限制和行为:

程序类型特权要求典型用途
BPF_PROG_TYPE_SOCKET_FILTER无特权(需调整 RLIMIT_MEMLOCK)包过滤
BPF_PROG_TYPE_KPROBECAP_BPF内核函数追踪
BPF_PROG_TYPE_XDPCAP_BPF + NET_ADMIN高性能网络
BPF_PROG_TYPE_SCHED_CLSCAP_BPF + NET_ADMIN流量控制
BPF_PROG_TYPE_TRACEPOINTCAP_BPF静态追踪点
BPF_PROG_TYPE_LSMCAP_BPF + CAP_SYS_ADMIN安全钩子
BPF_PROG_TYPE_STRUCT_OPSCAP_BPF + CAP_SYS_ADMIN内核操作替换
BPF_PROG_TYPE_TRACINGCAP_BPF + CAP_PERFMON高级追踪

每种类型通过 struct bpf_prog_ops 注册特定的操作集:

// include/linux/bpf.h
struct bpf_prog_ops {
    (*gen_prologue)(struct bpf_prog *prog, ...);
    (*gen_ld_abs)(struct bpf_prog *prog, ...);
    (*calc_tag)(struct bpf_prog *prog, ...);
    (*test_run)(struct bpf_prog *prog, ...);
    (*translate)(struct bpf_insn *dst, ...);
};

6.3 指令复制与初始验证

内核首先将用户态的 BPF 指令复制到内核空间,进行基本的 sanity 检查:

// kernel/bpf/syscall.c
insns = u64_to_user_ptr(attr->insns);        // 指令数组指针
insn_cnt = attr->insn_cnt;                    // 指令数量

// 检查指令数量上限(100 万条)
if (insn_cnt >= BPF_MAXINSNS || insn_cnt == 0)
    return -EINVAL;

// 从用户态复制指令到内核的 bpf_insn 数组
prog = bpf_prog_alloc(bpf_prog_size(insn_cnt), GFP_USER);
copy_from_user(prog->insns, insns, bpf_prog_insn_size(prog));

struct bpf_insn 是内核中表示单条 BPF 指令的结构:

// include/uapi/linux/bpf.h
struct bpf_insn {
    __u8  code;       // 操作码(opcode)
    __u8  dst_reg:4,  // 目标寄存器
          src_reg:4;  // 源寄存器
    __s16 off;        // 有符号偏移
    __s32 imm;        // 有符号立即数
};

每条指令 8 字节,操作码包含大类(BPF_LD/BPF_ST/BPF_ALU/BPF_JMP/BPF_JMP32/BPF_LDX 等)与子类(BPF_K 表示立即数操作,BPF_X 表示寄存器操作)。

6.4 验证器入口(Verifier)

指令复制完毕后,控制权转移到验证器。这是加载流程中最耗时的步骤。与本系列上一篇讨论验证器的文章不同,我们这里关注验证结果如何被加载流程消费:

// kernel/bpf/syscall.c - bpf_prog_load 中调用验证器的部分
prog->aux->env = bpf_check(&prog, attr);
if (IS_ERR(prog->aux->env))
    return PTR_ERR(prog->aux->env);

验证器返回 struct bpf_verifier_env,其中包含:

  • 日志字符串(用于 verifier_log 输出)
  • 状态栈的精炼信息(用于 JIT 编译时的寄存器分配)
  • Map 引用信息(哪些 Map 被 BPF 程序访问)

6.5 日志与调试

BPF 加载支持详细的验证器日志。用户态传入 log_buf 和 log_size 参数,验证器填充日志以帮助调试:

// 用户态调用示例
char log_buf[65536] = {};
union bpf_attr attr = {
    .prog_type = BPF_PROG_TYPE_XDP,
    .insns = (__u64)(unsigned long)insns,
    .insn_cnt = insn_cnt,
    .license = (__u64)"GPL",
    .log_buf = (__u64)(unsigned long)log_buf,
    .log_size = sizeof(log_buf),
    .log_level = 2,  // 0=关闭, 1=错误, 2=详细信息
};

int fd = bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
if (fd < 0) {
    // 失败时,log_buf 包含验证器的拒绝信息
    fprintf(stderr, "Verifier rejected:\n%s\n", log_buf);
}

log_level 有三个层次:0 无日志、1 仅错误、2 完整状态追踪。

7. Map 创建与指令修复

eBPF 程序不能直接引用 Map 对象,而是通过伪加载指令(pseudo-load)引用 Map 的文件描述符。验证之后,内核需要将这些伪引用替换为实际的 Map 地址——这就是 Map Fixup 阶段。

7.1 伪加载指令

在 BPF 代码中引用 Map 的标准方式:

// eBPF 伪代码:引用文件描述符为 fd 的 Map
BPF_LD_MAP_FD(R1, fd);
   
// 底层 BPF 指令(128 位立即数加载)
BPF_LD_IMM64(BPF_REG_1, (__s64)fd)    ; 8 字节立即数 + BPF_PSEUDO_MAP_FD 标记

内核使用 BPF_PSEUDO_MAP_FD 和 BPF_PSEUDO_MAP_VALUE 特殊常量作为立即数标记。

7.2 Fixup 流程

验证器分析过程中记录了所有 Map 引用(存储在 prog->aux->used_map_cnt 和 prog->aux->used_maps 中)。加载阶段,内核遍历所有指令,将伪加载中的 Map FD 替换为 BPF Map 的实际内核地址:

// kernel/bpf/verifier.c - fixup_bpf_calls()
static int fixup_bpf_calls(struct bpf_verifier_env *env)
{
    for (i = 0; i < prog->len; i++) {
        struct bpf_insn *insn = &prog->insns[i];
        
        if (insn->code == (BPF_LD | BPF_IMM | BPF_DW)) {
            if (insn->imm == BPF_PSEUDO_MAP_FD ||
                insn->imm == BPF_PSEUDO_MAP_VALUE) {
                // 替换为 Map 地址
                insn->imm = addr_of_map;
            }
        }
        
        // 其他 fixup:函数内联、尾调用优化、常量折叠等
    }
}

最终,原始指令序列中被标记为伪加载的指令被直接 patch 为 Map 结构指针。这使得 BPF 程序运行时可以直接通过寄存器调用 Map 操作,无需走系统调用。

7.3 辅助函数修复

BPF 程序调用的辅助函数(如 bpf_map_lookup_elem、bpf_trace_printk)也需要修复。这些函数编号存储在 CALL 类指令的 imm 字段中,内核将它们替换为实际的函数指针:

// kernel/bpf/verifier.c — 常见的 BPF 辅助函数映射
static const struct bpf_func_proto * const
bpf_funcIDA[__BPF_FUNC_MAX_ID] = {
    [BPF_FUNC_map_lookup_elem]  = &bpf_map_lookup_elem_proto,
    [BPF_FUNC_map_update_elem]  = &bpf_map_update_elem_proto,
    [BPF_FUNC_probe_read]       = &bpf_probe_read_proto,
    [BPF_FUNC_ktime_get_ns]     = &bpf_ktime_get_ns_proto,
    [BPF_FUNC_trace_printk]     = &bpf_trace_printk_proto,
    [BPF_FUNC_get_smp_processor_id] = &bpf_get_smp_processor_id_proto,
    // ... 200+ 个辅助函数
};

8. JIT 编译与优化

验证和修复之后,BPF 指令仍然存储在 BPF 虚拟机指令集中。为获得接近原生的性能,内核会将其编译为目标架构的机器代码,即 JIT 编译。

8.1 BPF JIT 架构

Linux 内核为每个支持的架构维护独立的 BPF JIT 后端:

arch/x86/net/bpf_jit_comp.c     # x86/x86_64 — 最成熟、功能最全
arch/arm64/net/bpf_jit_comp.c   # ARM64 — 服务器主流架构
arch/s390/net/bpf_jit_comp.c    # IBM Z — 大型机架构
arch/powerpc/net/bpf_jit_comp.c # PowerPC — Power 系列
arch/sparc/net/bpf_jit_comp.c   # SPARC — 小众但完整
arch/mips/net/bpf_jit_comp.c    # MIPS — 嵌入式
arch/riscv/net/bpf_jit_comp.c   # RISC-V — 新兴架构
arch/loongarch/net/bpf_jit_comp.c # 龙芯 LoongArch

JIT 编译默认启用,可通过 sysctl net.core.bpf_jit_enable 控制:

cat /proc/sys/net/core/bpf_jit_enable     # 查看 JIT 状态
echo 1 | sudo tee /proc/sys/net/core/bpf_jit_enable   # 启用 JIT
echo 0 | sudo tee /proc/sys/net/core/bpf_jit_enable   # 禁用(解释执行)
echo 2 | sudo tee /proc/sys/net/core/bpf_jit_enable   # 带日志的 JIT

8.2 x86 JIT 编译流水线

x86 的 BPF JIT 是最成熟的实现,其编译流水线包括:

  1. 栈帧建立:分配栈空间(BPF 栈帧固定 512 字节)
  2. 寄存器映射:
    • R0(返回值)→ RAX
    • R1-R5(参数/临时)→ RDI, RSI, RDX, RCX, R8(调用者保存)
    • R6-R9(被调用者保存)→ RBX, R12, R13, R14
    • R10(栈指针)→ RBP(固定映射到 BPF 栈底)
  3. 指令翻译:逐条将 BPF 指令序列翻译为 x86 指令
  4. 辅助函数调用:插入对 BPF 辅助函数的 call 指令
  5. 守卫代码优化:对于边界检查等,JIT 可省略运行时检查——这正是 BPF JIT 性能极高的关键
  6. 尾声代码:返回值写入 R0,恢复栈帧

由于验证器已证明程序不会越界、不会死循环,JIT 编译器可以省略大量安全检查代码。这是 BPF 程序比普通内核模块快的重要原因之一。

8.3 JIT 编译时机

JIT 编译在 bpf_prog_select_runtime() 中触发。内核通过 bpf_jit_kallsyms 参数控制是否将 JIT 编译后的函数符号注册到 kallsyms,以便 perf top 分析:

// 启用 JIT kallsyms
echo 1 | sudo tee /proc/sys/net/core/bpf_jit_kallsyms

默认情况下 BPF JIT 按需触发——在 BPF 程序首次被运行时才 JIT。对于高吞吐量场景(如 XDP),可用 BPF_F_TEST_XDP_LIVE_FRAMES 在加载时触发即时编译。

9. 程序附加(Attach)机制

BPF 程序加载成功后获得了文件描述符,但只是在内存中等待执行。要让程序真正运行,需要将其附加到具体事件源。

9.1 Attach 方式演进

方式一:BPF_PROG_ATTACH(旧式系统调用)

union bpf_attr attr = {
    .target_fd = cgroup_fd,
    .attach_bpf_fd = prog_fd,
    .attach_type = BPF_CGROUP_INET_INGRESS,
    .attach_flags = BPF_F_ALLOW_MULTI,
};
bpf(BPF_PROG_ATTACH, &attr, sizeof(attr));

方式二:BPF_LINK(现代方式)

BPF Link 将抽象为可持久化的 Link 对象:

union bpf_attr link_attr = {
    .link_create = {
        .prog_fd = prog_fd,
        .target_fd_ifindex = ifindex,  // 或 target_fd
        .attach_type = BPF_XDP,
    },
};
int link_fd = bpf(BPF_LINK_CREATE, &link_attr, sizeof(link_attr));

BPF Link 的优势:独立生命周期、可追溯性、原子替换(BPF_LINK_UPDATE 可无中断替换附加程序)、权限分离。

方式三:Netlink 协议(XDP 专用)

XDP 通过 Netlink 附加,网络套接字权限检查更精细,libbpf 的 xdp_attach() 走这条路。

9.2 各类型程序的附加路径

程序类型附加方式触发条件
XDPNetlink / BPF_LINK网卡接收数据包
TCnetlink / BPF_LINK包调度时
Kprobeperf_event / BPF_LINK函数入口命中
Tracepointperf_event / BPF_LINK命中静态追踪点
CgroupBPF_PROG_ATTACH进程进入 cgroup
LSMBPF_PROG_ATTACHLSM 钩子调用
IterBPF_ITER_CREATE迭代内核数据结构

10. BTF 与 CO-RE:可移植性的基石

BPF Type Format(BTF)和 CO-RE 解决了 BPF 程序跨内核版本移植的核心问题。

10.1 BTF 加载

加载 BPF 程序前,内核需先加载其关联的 BTF 数据:

union bpf_attr btf_attr = {
    .btf = (__u64)(unsigned long)btf_data,
    .btf_size = btf_size,
    .btf_log_buf = (__u64)(unsigned long)log_buf,
    .btf_log_size = sizeof(log_buf),
};
int btf_fd = bpf(BPF_BTF_LOAD, &btf_attr, sizeof(btf_attr));

BTF 是 C 类型的二进制编码,含结构体、函数签名、全局变量等完整类型信息。每个 BTF 数据的 ID 0 为特殊的 void 类型。

10.2 CO-RE 重定位

编译时描述的内核结构体布局,可能因内核版本不同而不同。CO-RE 通过记录重定位信息,在加载时根据目标内核实际 BTF 自动修正偏移:

struct bpf_relocation {
    insn_idx;        // 需重定位的指令索引
    type_id;         // 结构体类型 BTF ID
    offset_name;     // 字段名(如 "skb->data")
    relocation_kind; // FIELD_BYTE_OFFSET / FIELD_EXISTS 等
};

libbpf 在 bpf_object__relocate() 中完成:查询目标内核 BTF → 匹配类型和字段偏移 → 写回 BPF 指令。同一个目标文件可在不同内核运行,无需重新编译。

10.3 kfunc 调用验证

BPF 调用内核函数(kfunc)时,通过 BTF 传递函数签名。内核验证调用约定与目标 ABI 一致。这解决了传统 BPF 辅助函数固定编号的局限,使 BPF 可以安全调用任意导出内核函数。

11. BPF Token 与权限模型

Linux 6.9 引入 BPF Token,解决非特权容器内 BPF 加载问题。传统 BPF 需 CAP_BPF,通常不会赋予容器。BPF Token 允许特权进程将部分 BPF 权限委托给容器。

11.1 BPF Token 工作原理

  1. 特权进程在用户命名空间中创建 BPF Token,指定允许的命令集
  2. Token 文件描述符通过 Unix domain socket 传递给非特权容器
  3. 容器通过 Token 创建受限的 BPF 程序,绕过 CAP_BPF 要求,但受限于 Token 能力集

11.2 Token 的能力委托

  • 程序类型限制:仅允许特定 prog_type
  • 命令限制:仅允许特定 bpf_cmd 子集
  • 可加载指令数上限:防止资源滥用
  • BTF 类型范围:限制 kfunc 调用能力

BPF Token 是让容器编排器(如 Kubernetes)在安全范围内赋予工作负载 BPF 能力的关键一步。

12. 调试与故障诊断

12.1 常见错误码

错误码含义常见原因
EPERM无权限缺少 CAP_BPF;非特权环境未启用 unprivileged BPF
EINVAL无效参数prog_type 不支持;insn_cnt 超限
EACCES验证器拒绝程序违反安全规则;未初始化寄存器
ENOMEM内存不足指令数过大;Map 数过多
ENOENTMap 不存在伪加载引用了不存在的 Map FD
EFAULT地址错误用户态 attr 指针无效

12.2 常用调试工具

# 查看已加载的 BPF 程序
sudo bpftool prog show

# 查看程序详情和 JIT 后的机器码
sudo bpftool prog dump xlated id 123   # 翻译后 BPF 指令
sudo bpftool prog dump jited id 123    # JIT 后 x86 代码

# 加载并附带 Map
sudo bpftool prog load my_prog.bpf.o /sys/fs/bpf/my_prog

# 查看 Map
sudo bpftool map dump id 456

# 查看 BTF
sudo bpftool btf dump prog id 123

12.3 JIT 调试隔离

怀疑 JIT 引入 bug 时可临时关闭 JIT 来隔离问题:

echo 0 | sudo tee /proc/sys/net/core/bpf_jit_enable   # 使用解释器
# 测试...
echo 1 | sudo tee /proc/sys/net/core/bpf_jit_enable   # 恢复

13. 完整实战示例

13.1 BPF C 代码(xdp_drop.bpf.c)

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

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

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1);
} pkt_count SEC(".maps");

SEC("xdp")
int xdp_drop_fn(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    
    // 验证器要求边界检查
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_DROP;
    
    struct ethhdr *eth = data;
    
    // 更新包计数
    __u32 key = 0;
    __u64 *count = bpf_map_lookup_elem(&pkt_count, &key);
    if (count)
        __sync_fetch_and_add(count, 1);
    
    // 简单策略:丢弃 ARP 包
    if (eth->h_proto == __bpf_constant_htons(0x0806))
        return XDP_DROP;
    
    return XDP_PASS;
}

13.2 编译与加载流程

# 编译 BPF 程序
clang -O2 -g -target bpf -c xdp_drop.bpf.c -o xdp_drop.bpf.o

# 生成 Skeleton(含 open/load/attach/destroy 全部 API)
bpftool gen skeleton xdp_drop.bpf.o > xdp_drop.skel.h

# 直接加载(bpftool 一步完成)
sudo bpftool prog load xdp_drop.bpf.o /sys/fs/bpf/xdp_drop

# 附加到网卡
sudo bpftool net attach xdp id 123 dev eth0

13.3 使用 libbpf Skeleton API

#include "xdp_drop.skel.h"

int main(int argc, char **argv)
{
    struct xdp_drop *skel = xdp_drop__open();
    if (!skel) return 1;
    
    // 内部对所有 prog + map 按序调用 BPF_PROG_LOAD / BPF_MAP_CREATE
    int err = xdp_drop__load(skel);
    if (err) goto cleanup;
    
    // 自动附加到 SEC() 中声明的钩子
    err = xdp_drop__attach(skel);
    if (err) goto cleanup;
    
    printf("Program attached. Press Enter to exit.\n");
    getchar();
    
cleanup:
    xdp_drop__destroy(skel);
    return err != 0;
}

14. 总结

BPF_PROG_LOAD 看似一个简单的加载接口,实则涉及许可证合规、指令集验证、控制流安全证明、内存访问检查、Map 关联修复、JIT 代码生成、权限多层校验等一系列精密机制。整个加载流程可分为五个阶段:

  1. 预处理:许可证校验、权限检查、Capability 验证
  2. 验证:验证器静态分析——保证安全性(更多讨论见系列文章《eBPF Verifier 深度剖析》)
  3. 修复:Map Fixup、辅助函数替换、CO-RE 重定位
  4. JIT 编译:BPF 字节码 → 目标架构原生代码
  5. 资源绑定:FD 分配、Map 注册、(可选)附加到钩子

理解 BPF 加载全貌是掌握 eBPF 生态的关键。无论是开发网络数据平面(XDP)、追踪工具(BCC/bpftrace)、安全策略(LSM BPF)、还是资源调度器(sched-ext),所有这些都建立在相同的加载链路之上。

随着 BPF Token 引入非特权场景、BTF 增强跨内核兼容性、BPF Link 统一附加模型,BPF 加载接口仍在快速演进。我们看到的不仅是技术细节演进,更是 Linux 内核将越来越多可编程能力安全高效交付给用户态的持续努力。

延伸阅读:
• 《eBPF Verifier 深度剖析——安全沙箱的验证之路》(ybb.press)
• BPF 内核文档 kernel.org/doc/html/latest/bpf/
• Brendan Gregg《BPF Performance Tools》
• Andrii Nakryiko《BPF CO-RE 指南》(nakryiko.com)
• Cilium《BPF 和 XDP 参考指南》
点赞(0) 打赏

评论列表 共有 0 条评论

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

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ .skip-link { position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } .skip-link:focus { top: 0; outline: 3px solid #0056b3; }