本文地址:https://www.ebpf.top/post/maintaining-ebpf-prog

写一个能在本机加载的 eBPF 程序并不难;难的是让它在多发行版、多架构、多内核版本上长期可用。2026 年 6 月,开发者 kxxt 以开源工具 tracexec 的 eBPF 后端为样本,系统整理了维护负担与工程对策。下文按问题域、案例边界、负担分类、对策与结论展开;版本号、发行版名单与组合数均对照原文。

1. 背景:为何不能按用户态软件估算维护成本

用户态二进制可以把依赖钉死到某套运行时或容器镜像。eBPF 程序必须进入目标主机内核,因而至少受三类约束。

  • 稳定与不稳定 API 并存。 helper 面向较稳定的能力面,kfunc 则可能随内核演进变化。一次无意选用较新的 helper,就会抬高最低支持内核版本(MSKV)。

  • 能力按架构分批落地。 同一特性在 x86_64、arm64、riscv64 上的引入版本常不一致,MSKV 因而可能随 CPU 架构变化。

  • 发行版 Kconfig 不同构。 即便主线版本号相同,关闭某项配置也会让加载或挂载失败;失败信息往往只给出 EINVAL / ENOTSUPP,根因要回到内核源码或二次观测。

用户态软件被遗弃后,许多场景仍能继续跑;内核态代码(含 eBPF)会随升级缓慢失效,只是通常比树外内核模块更慢。比较基准应是其他内核代码,而不是普通用户态应用。eBPF 维护负担仍重于用户态,但通常轻于树外模块,并仍受验证器约束。

2. 以 tracexec 工具维护为起点

tracexec 是用 Rust 编写的 exec 系统调用追踪器,同时提供 ptrace(2) 与 eBPF 两条后端。eBPF 后端于 2024 年 9 月随 v0.6.0 发布并标为实验性;约两年后才被作者标为稳定。用户态加载栈为 libbpf-rs,内核侧程序为 C。文中经验主要来自该栈,但作者认为换语言后多数结论仍适用。

直接触发点之一,是项目进入 Arch Linux 与 NixOS 官方仓库后,维护预期上升。中心问题可概括为:在无法指定用户内核的前提下,如何把「本机可跑」变成可陈述的 MSKV、可探测的特性开关,以及可回归的 CI 矩阵。

tracexec 大量使用 bpf_loop(Linux 5.17 引入),因而将 MSKV 锚定在 5.17。后文所有兼容策略都在这一边界上展开。

3. 兼容性负担:按机制展开

3.1 Helper / kfunc 与 MSKV

写程序时就要清点所用 helper 与 kfunc,并在 docs.ebpf.io 核对引入版本,避免无意抬高 MSKV。helper 抬高 MSKV 的路径直观;kfunc 额外引入跨发行版与跨版本漂移风险。维护者需要把「用了哪些内核入口」写成显式清单,而不是事后从加载失败倒推。

3.2 可睡眠 fentry 与 CONFIG_FUNCTION_ERROR_INJECTION

追踪类程序常需读取用户态内存。默认不可睡眠时,缺页路径可能返回 EFAULT(参见 mozillazg 的案例说明)。在 libbpf-rs 里打开 BPF_F_SLEEPABLE,并把 bpf_probe_read_user{,_str} 换成 bpf_copy_from_user{,_str} 后,程序在 Arch / CachyOS 上可工作,却可能在 Fedora 43 上加载失败。

根因是可睡眠路径历史上依赖 CONFIG_FUNCTION_ERROR_INJECTION:程序并未注入错误,却要吃这个配置。邮件列表讨论指出,该检查实质把错误注入列表当白名单使用。作者用 kconfigwtf 汇总了发行版现状(撰文时):

  • AlmaLinux / CentOS / OracleLinux / RockyLinux 8:amd64 启用,其他架构禁用
  • 同系 9:全架构启用;同系 10:全架构禁用
  • Alpine、Debian、Fedora:禁用
  • Ubuntu:部分架构的部分内核包启用
  • Android:仅 android12-5.10、android13-5.10、android13-5.15 启用
  • Proxmox、Void、Arch、OpenSUSE、Guix、AmazonLinux al2023、AzureLinux 3.0:启用

Linux 7.1 的提交 16d9c5660692(bpf: Always allow sleepable programs on syscalls)允许系统调用上的可睡眠程序。但存量发行版仍会停留在旧内核多年,钉死内核版本的用户更久,因而不可睡眠回退路径仍需保留。

3.3 架构差异:原子操作与 BPF JIT

许多 eBPF 能力按架构分批实现,MSKV 因此可能随 CPU 变化。

原子操作(文中指 BPF_FETCH 与 BPF_ADD;其他原子指令可能另有引入版本):

BPF JIT。 关闭 JIT 时,部分程序无法通过验证。例如非 JIT 程序不允许回调,加载日志会出现:

1
callbacks are not allowed in non-JITed programs

提交 81c22041d9f1 为 x86_64 与 arm64 默认启用 JIT(随 v5.6)。撰文时 RISC-V 默认仍偏解释器。此类错误在新兴或过时架构上更常见。

3.4 带直接调用的动态 Ftrace

fentry 有时加载成功、挂载失败,errno 为 ENOTSUPP(os error 524),验证器日志为空。在 tracexec#173 中用 bpftrace 定位到 register_fentry():当目标地址落在 ftrace 管理范围、但 tr->fops 为空时直接返回 -ENOTSUPP。根因是缺少 CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS。

发行版差异见 kconfigwtf 同名页。多数发行版在最新内核、多数架构上已启用,但引入时间差很大:

3.5 可睡眠程序与 BPF_F_NO_PREALLOC Map

用 BPF map 跨钩子保存状态时,容量上界不明则常设很大;BPF_F_NO_PREALLOC 可避免为巨大容量预分配。但 Linux 6.1 之前,可睡眠程序只能使用预分配 map。该限制由提交 02cc5aa29e8c 解除。若在旧内核上无条件打开该标志,加载会失败。

3.6 系统调用包装符号与 kprobe 边角

符号形如 __x64_sys_execve / __arm64_sys_execve / __riscv_sys_execve,由架构相关系统调用包装器生成,受 CONFIG_ARCH_HAS_SYSCALL_WRAPPER 控制。

因此在 riscv64 上追踪 Linux 6.6 之前的 execve,需要挂到 __se_sys_execve,而不是 __riscv_sys_execve。

tracexec 同时支持 fentry/fexit 与 kprobe/kretprobe。在 Linux 6.12 riscv64 上测试 kprobe 路径时,加载成功但挂载失败:无法为 __riscv_sys_execve+0x0 创建 kprobe perf event(-EINVAL)。根因与 ftrace 直接调用类似,落在 check_ftrace_location():当探测地址恰为 ftrace 可打补丁入口、且未启用 CONFIG_KPROBES_ON_FTRACE 时返回 -EINVAL。RISC-V 系统调用包装器可以从 ftrace 可打补丁入口开始;撰文时 RISC-V 仍不支持该配置。

3.7 为何不直接改用系统调用跟踪点

系统调用跟踪点相对 kprobe/fprobe 更稳定,上述不少挂载坑可以绕开,但原文给出三条不采用的理由。

  • 性能。 跟踪点通常比 fentry/fexit 更慢,开销更大。

  • 可睡眠支持滞后。 撰文时稳定内核发行版上,跟踪点仍不可睡眠;主线虽已合入 raw 跟踪点可睡眠(439ebd5b5708)与经典跟踪点可睡眠(57918341dd19),但尚未进入发行版内核。可睡眠 fentry/fexit 则早在 Linux 5.10(1e6c62a88215)引入。

  • 覆盖面。 跟踪点只覆盖原生系统调用接口;若要处理替代 ABI,仍需 fprobe/kprobe。

3.8 替代系统调用接口

仅追踪 __x64_sys_execve 对多数软件够用,但安全向程序必须考虑替代 ABI。x86_64 上主要有两类:

  • 遗留 32 位应用:compat ia32,符号如 __ia32_compat_sys_execve
  • 较少使用的 x32:符号如 __x32_compat_sys_execve(内核侧曾多次讨论移除,见 LWN)

攻击者可以利用「只挂了原生接口」留下的缺口躲避检测。安全场景应覆盖替代接口;更稳妥时,尽可能改用 LSM 钩子。

3.9 内核回归:LTS、主线与隐蔽的本地存储失败

eBPF 的负担之路路径包括:kfunc 不稳定、追踪符号消失,以及验证器/子系统回归。作者至少记录了三次影响 tracexec 的回归:

  • LTS。 区间 [6.6.64, 6.6.70) 因回移植安全修复导致验证变慢,原先可加载的程序可能以 E2BIG 失败。作者于 2025-01-04(周六)报告,促成 revert。该安全修复后来在 2026-05-17(周日)再次进入 6.6.140,此前已回移植更多验证器性能改进以抵消开销(见 stable 覆盖说明)。

  • 主线。 Linux 6.19.5 再次出现 E2BIG。作者于 2026-03-06(周五)报告。其特例是用手写 BPF、经 bpf_loop、find_next_bit、generic___ffs 遍历 fd 位图;改用 v6.11 引入的 bpf_iter_bits 后可再加载。Eduard 亦给出应对建议。

  • BPF 本地存储(隐蔽)。 不表现为加载失败,而是 bpf_task_storage_get 在 riscv64、Linux ≥ 6.19 上分配永远失败。回归由本地存储迁到 kmalloc_nolock 引起,后确认波及所有缺少 HAVE_CMPXCHG_DOUBLE 的架构;撰文时仅 x86、arm64、s390、loongarch 选择了该选项。作者报告后收到修复补丁,但补丁链接已 404,且撰文时修复尚未进入主线。

3.10 LLVM 版本差异

除内核外,LLVM 版本也会改变验证结果。作者遇到过仅在 clang 20/21 编译时出现的 E2BIG。对 LLVM 二分后,坏提交为 627746581b8f(对 C 初始化列表中未显式初始化的成员与结构体填充做零初始化)。补丁本身看似无害,却把一个指向 BPF map 元素的指针溢出到栈上;验证器必须在又长又复杂的函数体里沿栈追踪该指针,最终放弃。作者通过调整代码结构,使验证器不必跨整个函数追踪该溢出变量。

3.11 测试、覆盖率与 CI 组合爆炸

测试 eBPF 通常需要 root 或一组 CAP_*。Rust/Cargo 没有现成机制把「部分测试以 root 跑」与普通测试分开配置。

用户态覆盖率工具成熟(如 cargo-llvm-cov);eBPF 侧可选方案很少,且撰文时均呈停更迹象:

  • Cilium coverbee:最近发布约 2023-03-06 的 v0.3.2,Go 实现,难以直接嵌入 libbpf-rs 测试
  • Elastic bpfcov:约 2022 年后少有更新;含树外 LLVM pass 与基于源码的覆盖率 CLI

对付回归,最好的办法是自建 CI。内核验证器改动常以 Cilium 程序为基准(见 Paul Chaignon 的说明),其他项目享受不到同等待遇。难点包括:多内核、多架构虚拟机;无 KVM 时极慢(GitHub Actions 的 linux-arm64 runner 无 KVM);以及组合爆炸。若覆盖 LTS + 最新稳定 + 主线共 8 个内核目标(原文举例:5.10、5.15、6.1、6.6、6.12、6.18、7.1、7.2-rc),再乘最近三个 LLVM(撰文时 20、21、22)与三种架构(x86_64、arm64、riscv64),组合数为 8 × 3 × 3 = 72。

4. 工程对策:从双路径到 UKCI

4.1 实现包装器:fentry 与 kprobe 并存

有时 fentry 挂载失败,有时 kprobe 挂载失败,但两者很少同时失败。因此在 eBPF 侧保留公共逻辑(如 trace_exec_common),再为两类程序写薄包装,并按架构宏选择系统调用符号前缀:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifdef TRACEXEC_TARGET_X86_64
#define SYSCALL_PREFIX "x64"
#elif TRACEXEC_TARGET_AARCH64
#define SYSCALL_PREFIX "arm64"
#elif TRACEXEC_TARGET_RISCV64
#define SYSCALL_PREFIX "riscv"
#endif

SEC("kprobe/__" SYSCALL_PREFIX "_sys_execve")
int BPF_KSYSCALL(sys_execve_kprobe, u8 *base_filename,
                 u8 const *const *argv, u8 const *const *envp) {
  trace_exec_common(false, false, base_filename, argv, envp);
  return 0;
}

SEC("fentry/__" SYSCALL_PREFIX "_sys_execve")
int BPF_PROG(sys_execve_fentry, struct pt_regs *regs) {
  trace_exec_common(false, false,
                    (u8 *)PT_REGS_PARM1_CORE(regs),
                    (u8 const *const *)PT_REGS_PARM2_CORE(regs),
                    (u8 const *const *)PT_REGS_PARM3_CORE(regs));
  return 0;
}

4.2 条件加载:Kconfig、环境变量与版本启发式

用户态按优先级决策:环境变量覆盖(如 TRACEXEC_USE_KPROBE / TRACEXEC_USE_FENTRY)→ 读取内核配置是否含 CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS → 按架构回退到版本启发式(x86_64 在 MSKV ≥ 5.17 时可视为具备;arm64 ≥ 6.4;riscv64 ≥ 6.8)。

另对 riscv64 检测「已有系统调用包装器 + 启用 DYNAMIC_FTRACE + 缺少 KPROBES_ON_FTRACE」,以避免选择必然失败的 kprobe 路径。

4.3 内核侧条件代码:版本门控、bpf_ksym_exists 与 polyfill

libbpf 可在加载期按内核版本解析条件代码。例如仅在 ≥ 6.2 调用 RCU kfunc:

1
2
3
4
5
6
7
8
extern int LINUX_KERNEL_VERSION __kconfig;
extern void bpf_rcu_read_lock(void) __ksym;

int __always_inline rcu_read_lock() {
  if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(6, 2, 0))
    bpf_rcu_read_lock();
  return 0;
}

更稳妥的写法是把 kfunc 标为 __weak,用 bpf_ksym_exists 探测,避免写死版本。同一机制也用于 polyfill:内核在 6.11 增加 bits 迭代器;旧内核保留手写遍历。改用新迭代器曾帮助越过 6.19.4 附近的验证器性能回归,同时不抬高 MSKV。

4.4 用户态条件代码:map 标志与运行时改挂符号

对可睡眠 + BPF_F_NO_PREALLOC,仅在内核 ≥ 6.1 时于用户态设置 map 标志。对缺少系统调用包装器的内核(典型:riscv64 且版本低于 6.6),关闭相关程序的 auto-attach,加载后再手动挂到 __se_sys_execve / __se_sys_execveat(及对应 kretprobe)。原因是 SEC 宏只接受编译期常量,符号名必须在运行时改写。

4.5 内核态与用户态协同:可睡眠开关

程序内无法可靠判断当前是否处于可睡眠上下文;可睡眠路径应使用 bpf_copy_from_user*,不可睡眠路径应使用 bpf_probe_read_user*,否则验证器会拒绝。tracexec 用 rodata 配置结构把 sleepable 布尔量从用户态传入,并在读用户字符串时额外用 bpf_ksym_exists(bpf_copy_from_user_str) 做 kfunc 级回退。用户态探测则检查 CONFIG_FUNCTION_ERROR_INJECTION(可用环境变量 TRACEXEC_NO_SLEEP 强制关闭),再同步设置 BPF_F_SLEEPABLE 与 rodata.tracexec_config.sleepable。

4.6 替代 ABI 的条件编译与局限

目前 tracexec 覆盖最常见的替代接口:x86_64 上的 32 位系统调用。通过 SYSCALL_COMPAT_PREFIX 与 32 位宽度的 COMPAT_PT_REGS_PARM*_CORE 宏读取参数。局限是:它假定 ia32 模拟始终存在;若发行版关闭 CONFIG_IA32_EMULATION,加载会失败。撰文时主流发行版默认仍启用(见 kconfigwtf),但预期随着 32 位支持萎缩,这将变成真实问题。

4.7 在 Rust 项目中以 root 跑测试

把需 root 的测试标为 #[ignore = "root"],与普通测试分开;再用 runner 提权执行 ignored 用例,例如:

1
2
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E" \
  cargo test --workspace -- --ignored

4.8 bpfcov-rs:面向 libbpf-rs 的覆盖率

作者 fork Elastic 的 bpfcov,将其从过时 LLVM 12 与旧覆盖率格式移植到现代 LLVM 21/22,并实现供 libbpf-rs 使用的 Rust crate,开源为 bpfcov-rs(crates.io 名 bpfcov)。由此可在 CodeCov 上跟踪 eBPF 源码覆盖率(见 tracexec 上的 blob 页)。

4.9 用 Nix 做 UKCI

为在多组合上持续测试,作者基于 kernel-module-flake 用 Nix 搭建 UKCI(User-space Kernel-space CI)。折中策略:日常测「内核 × 架构」并固定最新稳定 LLVM;全量「内核 × LLVM × 架构」每周一次;eBPF 相关 PR 可打 full-ci 标签跑全量。

流程概要:构建 flake,产出各组合的内核、initrd 与测试二进制 → ukci 并行启动 qemu 与测试脚本 → 测试脚本 SSH 进虚拟机执行 → 在 GitHub Actions UI 汇总结果。

架构侧:x86_64 用 KVM(Actions x86_64 runner 支持);arm64 在 linux-arm64 runner 上跑、无 KVM;riscv64 从 x86_64 交叉编译,经 qemu-system-riscv64 运行(无原生 riscv64 runner;撰文时 nixpkgs 在 riscv64 上几乎无原生二进制缓存)。

内核矩阵由 workflow 每周更新,包括:MSKV;LTS(6.1 / 6.6 / 6.12 / 6.18);最新稳定(撰文时 7.1);若存在则含最新 RC。作者还计划接入 bpf-next,缩短「回归合入 → 被发现」的时间窗。

5. 总结

原文的可迁移结论可以收束为四条:

  1. 先陈述边界。 把 helper/kfunc、架构能力与关键 Kconfig 写成 MSKV 与特性矩阵,而不是依赖本机发行版。
  2. 默认准备回退。 可睡眠 / 不可睡眠、fentry / kprobe、新 kfunc / polyfill、包装符号 / __se_sys_* 应成对出现,并由用户态探测驱动。
  3. 安全面单独记账。 原生系统调用接口不等于完整可见性;替代 ABI 与 LSM 选择要在威胁模型里写明。
  4. 用 CI 定义「支持」。 没有跨内核矩阵,就无法区分偶发失败与系统性回归;组合爆炸时用分层频率(日常子集 + 周期全量)换可承受成本。

写作目的不是劝退维护 eBPF,而是说明所列问题大多已有可操作解法。比较基准应是其他内核态代码。

延伸阅读

若还想顺着读,可看这几篇:

  1. eBPF 质检员 DepSurf:提前识别 83% 跨内核依赖错配
  2. 最后一公里:十年磨一剑,BPF 编译器的双引擎时代
  3. 一文全面了解 LSM BPF (含实战,强烈建议收藏)