eBPF 开发语言之争:C、Rust 与 Zig 全链路对比

前面几篇讲 OOM 追踪时,所有 eBPF 内核态程序都是用 C 写的。这很自然——C 是 eBPF 的"母语",verifier、CO-RE、libbpf 整个工具链都是围绕 C 设计的。但如果你关注过 eBPF 生态,会发现一个明显的趋势:越来越多的人开始用 C 以外的语言写 eBPF。Rust 的 Aya 框架已经被 Solana 验证器、Kubernetes Gateway API 用在生产环境;而 Zig 也在尝试用 comptime、显式分配和一流的 C 互操作带来新的开发体验。

这篇文章换一个视角:不再聚焦某个具体场景,而是做一次全链路对比——同样一个 eBPF 程序,用 C、Rust、Zig 分别怎么写?内核态怎么编译?用户态加载器怎么实现?三者真正的技术分水岭在哪里?

时效性说明:本文基于 2026 年 6 月的工具链现状。Zig 的 BPF CO-RE 支持仍是 Post-1.0 目标,Aya 的编译器级 CO-RE 重定位也在进行中——这些都在快速演进,部分结论可能随版本变化。

eBPF 全链路开发模型

无论用什么语言,一个完整的 eBPF 工具都由两部分组成:跑在内核态的 BPF 程序,和跑在用户态的控制程序。理解这个模型,是对比三种语言的前提。

mermaid
flowchart LR
    subgraph 用户态["用户态 User Space"]
        LD["Loader<br/>打开 .o、加载、attach"]
        POLL["控制循环<br/>读 maps / ringbuf"]
    end
    subgraph 内核态["内核态 Kernel"]
        HOOK["Hook 点<br/>kprobe / tracepoint / xdp"]
        BPF["BPF 程序<br/>verifier 校验后运行"]
        MAP["Maps / RingBuf<br/>内核 ↔ 用户数据通道"]
    end
    LD -- "bpf 系统调用" --> BPF
    BPF -- "挂载到" --> HOOK
    HOOK -- "事件触发执行" --> BPF
    BPF -- "读写" --> MAP
    MAP -- "用户态读取" --> POLL
    classDef user fill:#3b82f6,color:#fff
    classDef kern fill:#f59e0b,color:#fff
    class LD,POLL user
    class HOOK,BPF,MAP kern

两段代码、两个目标、一个 ELF 文件把它们粘起来:

  • 内核态程序编译成一个 BPF ELF 对象(.o),里面包含字节码、map 定义、BTF 信息。它必须通过 verifier 的校验才能运行。
  • 用户态加载器负责把这个 .o 加载进内核、处理 CO-RE 重定位、attach 到具体的 hook 点,然后读 map / ringbuf 把数据取出来。

三种语言的差异,本质就是这两段代码分别用什么写、用什么工具链粘合

C + libbpf:不可动摇的基准线

C 是 eBPF 的事实标准。内核文档、Cilium、bcc、Tetragon、所有主流发行版的 BPF 工具——清一色 C + libbpf。后来者都以它为参照系。

典型开发范式是 libbpf-bootstrap:用 Clang 编译内核态程序,通过 BPF_CORE_READ 宏访问内核结构体(这就是 CO-RE),再用 bpftool gen skeleton 生成类型安全的加载器骨架。

内核态程序(C + libbpf):

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, u64);
} counter SEC(".maps");

SEC("kprobe/do_sys_openat2")
int count_open(struct pt_regs *ctx)
{
    u32 key = 0;
    u64 *val = bpf_map_lookup_elem(&counter, &key);
    if (val)
        __sync_fetch_and_add(val, 1);
    return 0;
}

用户态加载器(libbpf skeleton):

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include "probe.skel.h"

int main(void)
{
    struct probe_bpf *skel = probe_bpf__open_and_load();
    probe_bpf__attach(skel);

    while (1) {
        u32 key = 0;
        u64 val = 0;
        bpf_map_lookup_elem(bpf_map__fd(skel->maps.counter), &key, &val);
        printf("open count: %llu\n", val);
        sleep(1);
    }
    return 0;
}

vmlinux.hbpftool btf dump 从目标内核生成的所有内核结构体定义,配合 BPF_CORE_READ,一个程序能在不同内核版本上运行而不需重新编译——这就是 CO-RE(Compile Once, Run Everywhere)。C 路径的 CO-RE 是编译器内建的、最完整的,这也是它至今不可动摇的根本原因。

Rust + Aya:内存安全的最强挑战者

Aya 是 Rust 生态的 eBPF 框架,aya-rs/aya 截至 2026 年 6 月已有 4.6k stars,最新版本 v0.14.0。它最大的卖点是纯 Rust、无 C 工具链依赖:内核态用 aya-ebpf#![no_std])编译成 BPF 字节码,用户态用 aya 加载,全程不需要 Clang、libbpf、kernel headers。

内核态程序(Aya,#![no_std]):

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#![no_std]
#![no_main]

use aya_ebpf::{
    helpers::bpf_get_current_pid_tgid,
    macros::{kprobe, map},
    maps::Array,
    programs::ProbeContext,
};

#[map]
static COUNTER: Array<u64> = Array::pinned(1, 0);

#[kprobe]
pub fn count_open(ctx: ProbeContext) -> u32 {
    let _pid = bpf_get_current_pid_tgid() as u32;
    if let Some(val) = COUNTER.get(0) {
        let _ = COUNTER.set(0, val + 1, 0);
    }
    0
}

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

用户态加载器(Aya):

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use aya::{
    include_bytes_aligned,
    maps::Array,
    programs::KProbe,
    Ebpf,
};

fn main() -> anyhow::Result<()> {
    let mut ebpf = Ebpf::load(include_bytes_aligned!(
        concat!(env!("OUT_DIR"), "/probe")
    ))?;

    let prog: &mut KProbe = ebpf.program_mut("count_open")?.try_into()?;
    prog.load()?;
    prog.attach("do_sys_openat2", 0)?;

    let counter: Array<_, u64> = Array::try_from(ebpf.map("COUNTER").unwrap())?;
    loop {
        println!("open count: {}", counter.get(&0, 0)?);
        std::thread::sleep(std::thread::Duration::from_secs(1));
    }
}

Aya 真正的生产实力不在代码本身,而在它的用户名单:Solana 验证器的 XDP 快速包重传(agave-xdp)、Kubernetes SIGs 的 L4 负载均衡器 blixt、Red Hat 的 bpfman、Deepfence 的 ebpfguard。这是目前非 C 阵营里唯一被大规模生产验证的方案。

Aya 还带来了 C 路径没有的东西:类型安全的 map 访问、tokio 原生异步(AsyncFd 直接对接 ringbuf)、所有权系统在编译期挡掉一类错误。

Zig + zbpf:潜力与局限并存的探索者

Zig 是三者中最年轻的尝试。Zig 工具链本身支持 BPF 目标——zig cc -target bpfel 能编译出 BPF ELF 对象,Zig 源码 src/target.zigbpfel/bpfeb 明确列为 LLVM 支持的目标架构。但官方平台支持页把 BPF 列为 “Additional Platforms”,不在 Tier 评级体系内,意味着可用但不保证正确性。

bash
1
2
3
4
5
# 把 C 代码编译成 BPF 对象(zig cc 当 Clang 用)
zig cc -target bpfel -O2 -c probe.bpf.c -o probe.o

# 或直接编译 Zig 源码到 BPF(freestanding,无 std)
zig build-exe -target bpfel-freestanding probe.zig

社区里最成熟的是 tw4452852/zbpf(275 stars)。它不走 @cImport(libbpf) 的老路,而是实现了一套纯 Zig 的用户态加载器:自带 ELF 解析、BTF 解析、CO-RE 重定位、skeleton 代码生成,零外部依赖(不需要 libelf、libbpf、libz)。

内核态程序(Zig + zbpf,示意):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 参考 zbpf samples 的 API 风格,具体接口随版本变化
const bpf = @import("zbpf");

pub var counter = bpf.ArrayMap(u64).init(1);

// zbpf 用 comptime 保证类型安全:访问不存在的字段会在编译期报错
export fn count_open() void {
    const val = counter.get(0) orelse 0;
    counter.update(0, val + 1);
}

用户态加载器(Zig + zbpf):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const std = @import("std");
const zbpf = @import("zbpf");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // 纯 Zig loader:解析 ELF、加载 BTF、做 CO-RE 重定位、过 verifier
    var obj = try zbpf.BpfObject.open(allocator, @embedFile("probe.o"));
    defer obj.close();
    try obj.load();

    const prog = obj.findProgram("count_open") orelse return error.NotFound;
    try prog.attachKprobe("do_sys_openat2");

    const map = obj.findMap("counter") orelse return error.NotFound;
    while (true) {
        std.debug.print("open count: {d}\n", .{map.get(u64, 0) orelse 0});
        std.time.sleep(std.time.ns_per_s);
    }
}

Zig 的吸引力在于语言特性:comptime 能在编译期做类型检查、显式分配器让内存来源一目了然、@cImport 让 C 互操作几乎无成本。构建一条命令 zig build zbpf -Dbpf=probe.zig -Dmain=main.zig 就把内核态和用户态打成一个二进制,交叉编译也是零配置。

但 Zig 写 eBPF 有一个绕不开的硬伤。

CO-RE:三种语言的技术分水岭

这是整篇文章最关键的部分。CO-RE 不是"有没有"的二选一,而是"在哪一层实现"的三档,它决定了你的程序能不能跨内核版本运行。

CO-RE 的本质是:BPF 程序访问内核结构体字段时不硬编码偏移量,而是编码成 BTF 重定位记录,加载时由用户态 loader 根据目标内核的真实布局重写指令。要做到这点,编译器必须发射一种特殊的 LLVM intrinsic(llvm.preserve.struct.access.index 等)。

语言CO-RE 实现层级机制成熟度
C (libbpf)编译器内建Clang 发射 BTF 重定位 intrinsics,BPF_CORE_READ生产级
Rust (Aya)用户态重定位aya-obj 解析 ELF 做重定位;Rust 编译器侧的 BTF relo 尚未完成可用
Zig (zbpf)用户态 workaroundzbpf 在 ELF 层实现全部 16 种重定位;Zig 编译器不发射 intrinsics实验性

C 的优势是原生:Clang 直接认识 CO-RE,BPF_CORE_READ 是一等公民,跨内核运行是编译器保证的。

Aya 处于过渡期:根据 2026 年 FOSDEM 演讲,Rust 已经能为所有类型产生正确的 BTF,但编译器侧的 BTF 重定位 intrinsics 尚未完成。目前 Aya 在用户态(aya-obj)做重定位来补偿,所以能用,但还不是编译器原生保证。这条路线官方正在推进。

Zig 卡在最难的一步:Codeberg issue #35324 明确指出,Zig 编译器不会发射 CO-RE 所需的 intrinsics,所以"无法现实地用于编写 eBPF 程序"。zmitchell 正在 Air.zig 里实现这些 intrinsic,但 Andrew Kelley 把它标记为 Post-1.0 里程碑——也就是 1.0 之后才会考虑,目前没有时间表。zbpf 的 CO-RE 是在 ELF 层手动实现的 workaround,能用但不是编译器保证的等价物,访问复杂嵌套结构体时仍可能受限。

这一层差异,是 Zig 暂时无法和 C、Aya 平起平坐的根本原因。

全链路对比

维度C (libbpf)Rust (Aya)Zig (zbpf)
内核态语言CRust(#![no_std])Zig(freestanding)
用户态语言C / GoRustZig
CO-RE / BTF编译器内建,完整BTF 完整,编译器 relo 进行中ELF 层 workaround,编译器无内建
C 工具链依赖需要 Clang不需要编译 BPF 时仍需 Clang 后端
内存安全无保证所有权系统编译期保证显式分配,运行时部分检查
异步运行时手动 epolltokio / async-std 原生无原生 async
生态规模最大(Cilium、bcc、Tetragon)大(4.6k stars,7+ 生产用户)小(275 stars,1 主维护者)
生产案例行业标准Solana、K8s、Red Hat、Deepfence无公开生产案例
Skeleton 生成bpftool gen skeletonaya-tool / xtaskzbpf gen-skeleton
内核版本兼容≥ 4.18(CO-RE)≥ 5.8(ringbuf)取决于特性
编译速度慢(Clang + kernel headers)快(纯 Rust)快(Zig 工具链)
学习曲线中(C + libbpf 复杂)中(Rust + Aya 概念)中(Zig 简单但生态薄)

成熟度判定与现实选型

诚实地说:

  • C + libbpf:生产就绪,行业标准。CO-RE 最完整、生态最厚、文档最多。新项目没有特殊理由,它仍是默认选择。代价是要忍受 C 的内存不安全和较重的工具链。
  • Rust + Aya:生产可用,是今天唯一能替代 C 写生产 eBPF 的非 C 方案。如果你的团队已经用 Rust,或者需要内存安全 + 异步,Aya 是明确的选择。唯一要注意的是编译器级 CO-RE 还在路上,复杂场景需要测试。
  • Zig + zbpf:实验性。技术探索很有想象力(纯 Zig loader、零依赖、comptime 类型安全),但编译器级 CO-RE 缺失意味着跨内核运行没有保证,生态也太小(单一维护者)。适合学习、做技术预研、给社区贡献,但不建议上生产。

一个务实的判断:如果你今天要选一个非 C 语言写 eBPF,答案只有一个——Rust + Aya。Zig 的故事很性感,但它的关键拼图(CO-RE)还在 Post-1.0 的 roadmap 上,至少短期内不是生产选项。不过 zbpf 的纯 Zig loader 实现值得每个 eBPF 爱好者读一遍——它用一门语言重新实现了 libbpf 的核心,本身就是对 eBPF 工具链最好的深度学习。

小结

这篇文章从语言生态的视角梳理了 eBPF 的三种开发路径:

  • C + libbpf 仍是基准线,CO-RE 编译器内建是它的护城河
  • Rust + Aya 是唯一生产可用的非 C 方案,内存安全 + 异步是真实价值
  • Zig + zbpf 是有潜力的探索者,但卡在编译器级 CO-RE,目前只适合实验

三种语言的真正分水岭不是语法,而是 CO-RE 在哪一层实现——编译器内建、用户态重定位、还是 ELF 层 workaround。理解了这一点,就能看懂每个项目的成熟度天花板。

eBPF 的语言之争才刚开始。后面会继续往这个方向探索——可能会动手用 zbpf 真正写一个工具,也可能去拆解 Aya 的 CO-RE 重定位实现。这个系列还会继续往下走。