Notes

Why Rust Is the Right Language for eBPF

eBPF has meant writing C and fighting the verifier. Rust and the aya framework change that: type safety, no libbpf, one cargo build for kernel and userspace.

Why I wrote this

Most eBPF content assumes C and BCC/libbpf. Rust is a viable alternative that nobody is writing about from a practitioner perspective; this post fills that gap with concrete experience from building a 10-subcommand observability tool.

build log 7 min read

I spent the last few weeks building Tuxscope, a Linux observability toolkit with 10 eBPF-powered subcommands, from syscall tracing to container-level cgroup visibility. The entire thing is Rust, kernel side and userspace, built with the aya framework.

The experience convinced me that Rust is not just a viable alternative to C for eBPF; it is a better default for new projects. This post explains why, with specifics from the build.

The traditional eBPF toolchain

Most eBPF tutorials start with C. You write a kernel program in restricted C, compile it with clang to BPF bytecode, and load it from userspace using either BCC (Python bindings) or libbpf (C library). The ecosystem works and has years of production mileage behind it.

But the developer experience has sharp edges:

  • Two languages, two build systems. The kernel program is C compiled with clang. The userspace loader is C or Python. You coordinate them manually.
  • Header dependencies. BCC compiles eBPF programs at runtime and needs kernel headers installed on the target machine. libbpf’s CO-RE approach avoids this but requires BTF-enabled kernels and vmlinux header generation.
  • No type safety across the boundary. The event structs shared between kernel and userspace are duplicated manually, one definition in the BPF C code, another in the loader. Get the layout wrong and you get silent corruption.
  • Verifier errors are cryptic. The eBPF verifier rejects unsafe programs, but its error messages assume you understand BPF instruction semantics.

None of these are dealbreakers. People build serious infrastructure on BCC and libbpf every day. But they add friction that compounds across a project with multiple eBPF programs.

What aya changes

Aya is a Rust eBPF framework. It compiles eBPF programs from Rust (not C) and loads them from Rust userspace. The key properties:

Single language, single build. Both the kernel program and the userspace binary are Rust. A build.rs script compiles the eBPF crate to the bpfel-unknown-none target and embeds the resulting ELF into the userspace binary. One cargo build produces everything.

No libbpf, no BCC, no kernel headers. Aya talks directly to the kernel’s bpf() syscall. There is no C dependency to link against. The tradeoff is that you lose some of libbpf’s conveniences (like automatic map pinning), but for most observability tools you do not need them.

Shared types with compile-time guarantees. Tuxscope uses a tuxscope-common crate that defines event structs as #[repr(C)] types shared between the eBPF and userspace crates. If the kernel program writes a SyscallEvent and the userspace reads a SyscallEvent, the layout is guaranteed to match: the compiler enforces it.

Rust’s type system catches eBPF mistakes early. Map types like PerfEventArray<HelloEvent> and HashMap<u64, u64> are generic over their key and value types. If you try to write the wrong type to a map, you get a compile error, not a verifier rejection or silent data corruption at runtime.

The workspace layout

Aya projects follow a three-crate workspace pattern:

tuxscope/
├── tuxscope-common/     # Shared event types (#![no_std])
├── tuxscope-ebpf/       # Kernel-side eBPF programs (#![no_std] #![no_main])
├── tuxscope/            # Userspace CLI binary
└── xtask/               # Build automation

The tuxscope-common crate is #![no_std] so it compiles for both the BPF target and the host. It defines every event struct once. The eBPF crate imports it for writing events; the userspace crate imports it (with a user feature flag that enables aya::Pod and serde::Serialize) for reading them.

This is the pattern that made Tuxscope’s cumulative design work. Each of the 10 labs adds one event struct to common, one eBPF program to the kernel crate, and one probe handler to the userspace crate. Nothing from previous labs changes. The final binary contains all 10 probes, activated by subcommand.

What actually went well

Kprobes and tracepoints are straightforward. Attaching to a tracepoint is one attribute and one line of setup:

// inside the try_ helper (returns Result<(), i64>)
let id: i64 = unsafe { ctx.read_at(8)? };

Kprobes are similarly clean. Function arguments are accessed by index:

// inside the try_ helper (returns Result<(), i64>)
let bytes: u64 = ctx.arg(2).ok_or(1i64)?;

RingBuf just works. Labs 2-10 use RingBuf instead of PerfEventArray. On the kernel side, writing an event is one call:

EVENTS.output(&event, 0).map_err(|_| 1i64)?;

On the userspace side, reading is a poll loop. Not as elegant as the async PerfEventArray reader, but simple and reliable.

HashMap for stateful eBPF. Lab 7 (disk I/O profiling) stores request issue timestamps in a HashMap<u64, u64> and computes per-request latency on completion. The eBPF HashMap API in aya is clean:

DISKIO_START.insert(&sector, &timestamp, 0)?;
// ... later, on completion:
let start = unsafe { DISKIO_START.get(&sector) };

This is the same pattern you would use in C, but with Rust’s type system ensuring the key and value types match.

What was painful

Nightly Rust is required. The BPF target (bpfel-unknown-none) and -Z build-std=core are unstable features. This means pinning a nightly toolchain date and hoping it does not break upstream. In practice, aya pins well and breakage is rare, but it is a real dependency management concern for long-lived projects.

The verifier is still the verifier. Rust does not make the eBPF verifier smarter. Bounded loops, stack size limits, and memory access restrictions all still apply. The Rust compiler catches type errors, but it cannot tell you that your loop will exceed the instruction limit or that your stack frame is too large. When the verifier rejects a program, the error messages are the same cryptic BPF instruction dumps you would get from C.

No_std is constraining. The eBPF crate is #![no_std] and #![no_main]; no allocator, no standard library, no printing. This is inherent to eBPF (kernel programs run in a restricted VM), but it means you cannot use most Rust idioms. No String, no Vec, no format!. You work with fixed-size byte arrays and raw pointers.

Kretprobe return values need care. For Lab 4 (network monitoring), inet_csk_accept returns a struct sock *. Reading fields from kernel structs requires knowing field offsets, which vary by kernel version. Aya does not generate bindings from BTF automatically the way libbpf’s CO-RE does. For production use, you would want aya-gen or manual BTF integration.

When to use aya vs libbpf

Use aya when:

  • Your project is greenfield and your team knows Rust
  • You want a single binary with no runtime dependencies
  • Type safety across the kernel/userspace boundary matters to you
  • You are building an observability or security tool (not a networking datapath. XDP in aya works but the libbpf ecosystem has more XDP examples)

Stick with libbpf/BCC when:

  • You need CO-RE portability across many kernel versions without recompilation
  • Your team’s primary language is C or Python
  • You are extending existing BCC/libbpf-based infrastructure
  • You need the broadest possible community support and examples

Both are production-grade. The choice is about team ergonomics, not capability.

The companion tutorial series

Tuxscope is the code companion to the Tuxscope tutorial series on this site, 10 tutorials that each teach a Linux kernel subsystem by observing it with eBPF. The emphasis is on kernel concepts (syscalls, VFS, TCP/IP, scheduling, cgroups), not on Rust or eBPF syntax. But if you want to understand how the code works, the source is structured to be read as a tutorial itself.

LabSubcommandKernel concepteBPF technique
1helloeBPF pipelinePerfEventArray, tracepoint
2syscallSyscall interfaceRingBuf, tracepoint
3fileioVFS layerKprobe, function args
4netTCP/IP stackKretprobe, kernel memory reads
5procProcess lifecycleMultiple tracepoints
6memoryVirtual memoryPage fault tracing
7diskioBlock layerHashMap (stateful eBPF)
8schedCPU schedulerHigh-frequency tracing
9securityCapabilitiesKprobe on security hooks
10containerCgroupsCgroup-aware tracing

Start with Lab 1: Hello eBPF if you want to follow along from scratch.