跨发行版 eBPF 程序:兼容性负担与工程对策
本文地址: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;其他原子指令可能另有引入版本):
- x86_64:提交
5ca419f2864a,随 5.12 - Arm64:提交
1902472b4fa9,随 5.18 - RISC-V:提交
dd642ccb45ec,随 5.19
BPF JIT。 关闭 JIT 时,部分程序无法通过验证。例如非 JIT 程序不允许回调,加载日志会出现:
|
|
提交 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 同名页。多数发行版在最新内核、多数架构上已启用,但引入时间差很大:
- x86_64:提交
562955fe6a55,随 Linux 5.5 - arm64:提交
2aa6ac03516d,随 Linux 6.4 - riscv64:提交
196c79f19a92,随 Linux 6.8
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 控制。
- arm64:提交
4378a7d4be30,随 v4.19 - riscv64:提交
08d0ce30e0e4,随 v6.6
因此在 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),再为两类程序写薄包装,并按架构宏选择系统调用符号前缀:
|
|
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:
|
|
更稳妥的写法是把 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 用例,例如:
|
|
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. 总结
原文的可迁移结论可以收束为四条:
- 先陈述边界。 把 helper/kfunc、架构能力与关键 Kconfig 写成 MSKV 与特性矩阵,而不是依赖本机发行版。
- 默认准备回退。 可睡眠 / 不可睡眠、
fentry/kprobe、新 kfunc / polyfill、包装符号 /__se_sys_*应成对出现,并由用户态探测驱动。 - 安全面单独记账。 原生系统调用接口不等于完整可见性;替代 ABI 与 LSM 选择要在威胁模型里写明。
- 用 CI 定义「支持」。 没有跨内核矩阵,就无法区分偶发失败与系统性回归;组合爆炸时用分层频率(日常子集 + 周期全量)换可承受成本。
写作目的不是劝退维护 eBPF,而是说明所列问题大多已有可操作解法。比较基准应是其他内核态代码。
延伸阅读
若还想顺着读,可看这几篇:
- 原文作者:DavidDi
- 原文链接:https://www.ebpf.top/post/maintaining-ebpf-prog/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 最后更新时间:2026-10-19 19:24:00 +0800 CST