Rust 123 🦀 Linux eBPF Program Development using Rust (aya framework)
Extended Berkeley Packet Filter (eBPF) allows custom programs to run inside the Linux kernel without changing kernel source code or loading modules. For years, writing eBPF meant writing C and relying on libbpf and clang. The aya framework changes this: it is a pure-Rust library that lets you write both the kernel-side eBPF program and the user-space loader in Rust, without depending on libbpf, BCC, or a runtime LLVM dependency . The trade-off is that the kernel-side code must obey the strict constraints of the eBPF virtual machine—no standard library, no heap, 512 bytes of stack—while the user-space side remains ordinary Rust.
Key point: Aya splits your project into two crates: an ebpf crate compiled for the bpfel-unknown-none target (kernel-side, #![no_std]) and a user-space crate that loads and attaches the eBPF program. The two sides share a third #![no_std] crate for event types, ensuring the structs passed through ring buffers or perf buffers are identical on both sides .
Why Aya exists (and why Rust for eBPF)
The single-language problem. Traditional eBPF development requires C for the kernel program and Go, Rust, or C for the loader. Reviewers context-switch between languages, error types, and idioms. Aya eliminates this: the eBPF program is Rust, the loader is Rust, and the shared types crate is Rust. The same clippy lints apply, and errors propagate from the eBPF program’s return code all the way to the user-space Result .
The runtime dependency problem. libbpf requires clang and llvm at build time, and BCC requires them at runtime. Aya compiles Rust to eBPF bytecode using bpf-linker and rustc, with no C toolchain in the build path. The resulting object file is loaded directly by the user-space program .
The CO-RE problem. Compile-Once, Run-Everywhere (CO-RE) allows a single eBPF binary to work across different kernel versions by resolving struct field offsets at load time using BTF (BPF Type Format). Aya supports CO-RE, but it requires discipline: kernel struct reads must go through BTF-aware helpers like bpf_probe_read_kernel, not hard-coded offsets. Production users learned this the hard way when binaries built against 5.15 failed on 6.x kernels .
The verifier problem. The eBPF verifier is the kernel’s safety gate. It rejects programs that access memory unsafely, have unbounded loops, or exceed stack limits. Aya’s typed API catches some errors at compile time, but the verifier remains the final authority. Common verifier failures include unbounded loops, pointer arithmetic the verifier cannot track, and stack frames exceeding 512 bytes. The fix is usually a #[unroll] attribute or moving large state into a per-CPU array map .
The no_std boundary. The kernel-side crate must be #![no_std]. It cannot use String, Vec, std::fmt, or panic!. Stack space is limited to 512 bytes (256 with tail calls), and there is no heap. Every event struct must be #[repr(C)] with fixed-size fields—no pointers, no dynamic allocation .
a. Setting up the development environment
Aya requires both stable and nightly Rust toolchains, the bpf-linker for compiling to the BPF target, and cargo-generate for scaffolding. The bpftool package is needed to generate kernel bindings .
# Install both toolchains
rustup install stable
rustup toolchain install nightly --component rust-src
# Install the eBPF linker
cargo install bpf-linker
# Install project scaffolding
cargo install cargo-generate
The project structure generated by cargo generate https://github.com/aya-rs/aya-template contains three crates: the eBPF program (myapp-ebpf), the user-space loader (myapp), and a shared types crate (myapp-common). The eBPF crate is compiled for bpfel-unknown-none, and build.rs invokes bpf-linker to produce the object file the loader consumes .
b. Writing the eBPF program (kernel side)
The eBPF program is a function annotated with a program-type macro (#[xdp], #[socket_filter], #[tracepoint], etc.). It receives a context type specific to the program type and returns a value the kernel interprets—XDP_PASS or XDP_DROP for XDP, a verdict for socket filters, or 0 for tracepoints .
#![no_std]
#![no_main]
use aya_ebpf::{
bindings::xdp_action,
macros::xdp,
programs::XdpContext,
};
#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
match unsafe { try_xdp_firewall(ctx) } {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
unsafe fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, u32> {
// Packet parsing goes here.
Ok(xdp_action::XDP_PASS)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
The #[panic_handler] is required for compilation but must never be reached. The eBPF VM does not support unwinding, and a panic in kernel code is fatal. The loop {} satisfies the ! return type .
For packet parsing, bounds checks are mandatory. The verifier rejects any packet access that does not prove the offset is within data..data_end. The Aya book provides a ptr_at helper that checks bounds before every access :
use core::mem;
use network_types::eth::{EthHdr, EtherType};
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();
if start + offset + len > end {
return Err(());
}
Ok((start + offset) as *const T)
}
c. Ring buffers, maps, and the user-space loader
eBPF programs communicate with user space through maps. Aya supports array maps, hash maps, perf event arrays, and ring buffers. Ring buffers are preferred for event streaming: they are a single MPSC ring shared across all CPUs, and Aya exposes a polled file descriptor that integrates with tokio .
use aya_ebpf::{
macros::map,
maps::RingBuf,
};
#[map]
static EVENTS: RingBuf = RingBuf::with_byte_size(4096, 0);
#[derive(Copy, Clone)]
#[repr(C)]
pub struct Event {
pub pid: u32,
pub comm: [u8; 16],
}
The user-space loader opens the compiled object file, loads the program, attaches it to the target (an interface for XDP, a socket for socket filters, a tracepoint for tracing), and polls the ring buffer. Aya’s Bpf::load and BpfLoader handle the ELF parsing and relocation .
use aya::Bpf;
use aya::programs::Xdp;
let mut bpf = Bpf::load(include_bytes!("../target/bpfel-unknown-none/release/myapp"))?;
let program: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
program.load()?;
program.attach(&interface, XdpFlags::default())?;
let mut ring = RingBuf::try_from(bpf.take_map("EVENTS").unwrap())?;
let mut poll = tokio::io::unix::AsyncFd::new(ring)?;
loop {
let mut guard = poll.readable_mut().await?;
while let Some(item) = guard.get_inner_mut().next() {
let event = unsafe { ptr::read_unaligned(item.as_ptr() as *const Event) };
println!("pid: {}, comm: {}", event.pid, String::from_utf8_lossy(&event.comm));
}
guard.clear_ready();
}
Complete Example Session
# ============================================
# PART 1: INSTALL TOOLCHAINS AND DEPENDENCIES
# ============================================
rustup install stable
rustup toolchain install nightly --component rust-src
cargo install bpf-linker
cargo install cargo-generate
# ============================================
# PART 2: GENERATE PROJECT FROM TEMPLATE
# ============================================
cargo generate https://github.com/aya-rs/aya-template
# Project name: xdp-demo
# Program type: xdp
# ============================================
# PART 3: PROJECT STRUCTURE
# ============================================
xdp-demo/
├── Cargo.toml
├── build.rs
├── src/
│ └── main.rs # User-space loader
├── xdp-demo-ebpf/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # Kernel-side eBPF program
└── xdp-demo-common/
└── src/
└── lib.rs # Shared event types
# ============================================
# PART 4: KERNEL-SIDE XDP PROGRAM
# ============================================
# xdp-demo-ebpf/src/main.rs
#![no_std]
#![no_main]
use aya_ebpf::{
bindings::xdp_action,
macros::xdp,
programs::XdpContext,
};
#[xdp]
pub fn xdp_demo(ctx: XdpContext) -> u32 {
match unsafe { try_xdp_demo(ctx) } {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
unsafe fn try_xdp_demo(_ctx: XdpContext) -> Result<u32, u32> {
Ok(xdp_action::XDP_PASS)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
# ============================================
# PART 5: SHARED EVENT TYPE
# ============================================
# xdp-demo-common/src/lib.rs
#![no_std]
#[derive(Copy, Clone)]
#[repr(C)]
pub struct PacketEvent {
pub src_ip: u32,
pub dst_ip: u32,
pub action: u32,
}
# ============================================
# PART 6: USER-SPACE LOADER
# ============================================
# src/main.rs
use aya::Bpf;
use aya::programs::Xdp;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut bpf = Bpf::load(include_bytes!(
"../target/bpfel-unknown-none/release/xdp-demo"
))?;
let program: &mut Xdp = bpf.program_mut("xdp_demo").unwrap().try_into()?;
program.load()?;
program.attach("eth0", XdpFlags::default())?;
println!("XDP program attached to eth0");
Ok(())
}
# ============================================
# PART 7: BUILD THE eBPF PROGRAM
# ============================================
cargo xtask build-ebpf
# Compiles xdp-demo-ebpf for bpfel-unknown-none
# Output: target/bpfel-unknown-none/release/xdp-demo
# ============================================
# PART 8: RUN THE LOADER
# ============================================
cargo xtask run
# Loads and attaches the XDP program to eth0
# ============================================
# PART 9: VERIFIER LOG ON FAILURE
# ============================================
# If the program is rejected:
RUST_LOG=debug cargo xtask run
# Aya prints the verifier log, naming the offending instruction.
The nine parts trace the Aya workflow: environment setup, project scaffolding, kernel-side program, shared types, user-space loader, build, run, and verifier debugging. Each piece is a separate crate or compilation target, and the boundaries between them enforce the no_std discipline the eBPF VM requires.
Quick Reference
Aya Crates
| Crate | Purpose |
|---|---|
aya | User-space loader |
aya-ebpf | Kernel-side eBPF API |
aya-log / aya-log-ebpf | Logging from eBPF to user space |
network-types | Typed Ethernet/IP/TCP headers |
Program Type Macros
| Macro | Context Type | Use |
|---|---|---|
#[xdp] | XdpContext | Packet filtering at driver level |
#[socket_filter] | SocketFilterContext | Socket-level filtering |
#[tracepoint] | TracePointContext | Kernel tracepoint events |
#[kprobe] | ProbeContext | Function entry/exit tracing |
eBPF Constraints
| Constraint | Value |
|---|---|
| Stack size | 512 bytes (256 with tail calls) |
| Heap | None |
| Standard library | Disabled (#![no_std]) |
| Panics | Not allowed |
| Main function | None (#![no_main]) |
Best Practices
✅ Do This:
#![no_std]
#![no_main]
- ✅ Both attributes are mandatory for eBPF crates
if start + offset + len > end { return Err(()); }
- ✅ Bounds-check every packet access before reading
#[repr(C)]
pub struct Event { pub pid: u32 }
- ✅ Use fixed-size
#[repr(C)]types for shared events
❌ Don’t Do This:
let s = String::from("hello");
- ❌ No heap, no
Stringin kernel-side code
panic!("error");
- ❌ Panics are unreachable and undefined in eBPF
let offset = 14; // Ethernet header size
- ❌ Hard-coded offsets break across kernel versions; use BTF-aware reads
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Verifier rejects loop | Loop bound not provable | Use #[unroll] or a const-bounded counter |
| Stack overflow | Local variable exceeds 512 bytes | Move state to a per-CPU array map |
| Program fails on new kernel | Hard-coded struct offsets | Use BTF and bpf_probe_read_kernel |
core::fmt recursion | Using Debug or Display | Use aya-log or plain integers |
| Ring buffer wedges | Reserve/commit unbalanced | Ensure commit happens on every path |
Real-World Examples
1. XDP Drop by IP
if src_ip == BLOCKED_IP { return Ok(xdp_action::XDP_DROP); }
2. Ring Buffer Event
EVENTS.output(&event, 0);
3. Attach XDP
program.attach("eth0", XdpFlags::default())?;
4. Tracepoint Context
let pid = ctx.pid();
5. kprobe Entry
#[kprobe]
pub fn probe_execve(ctx: ProbeContext) -> u32 { 0 }
6. Shared Type
#[derive(Copy, Clone)]
#[repr(C)]
pub struct Event { pub pid: u32, pub comm: [u8; 16] }
7. Poll Ring Buffer
while let Some(item) = ring.next() { /* process */ }
8. Verifier Log
RUST_LOG=debug cargo xtask run
9. CO-RE Read
bpf_probe_read_kernel(&mut val, size, ptr)
10. Aya Log
info!(&ctx, "packet from {}", src_ip);
Visual
Aya Project Architecture
┌──────────────────────────────────────────────┐
│ User Space (normal Rust) │
│ │
│ Bpf::load(object_file) │
│ program.attach(interface) │
│ ring_buffer.poll() │
└──────────────────┬───────────────────────────┘
│ bpf() syscall
▼
┌──────────────────────────────────────────────┐
│ Kernel Space (no_std Rust) │
│ │
│ #[xdp] fn xdp_firewall(ctx) -> u32 │
│ #[map] static EVENTS: RingBuf │
│ │
│ Verifier checks: bounds, loops, stack │
└──────────────────────────────────────────────┘
The two halves are separate crates compiled for different targets. The shared types crate is no_std and links against neither.
Verifier Rejection Loop
┌──────────────────────────────────────────────┐
│ eBPF program compiled │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader calls bpf(BPF_PROG_LOAD) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Verifier inspects bytecode │
│ ├─ Bounds checks on packet access │
│ ├─ Loop termination │
│ ├─ Stack frame size │
│ └─ Helper function usage │
└──────────┬───────────────────────┬───────────┘
│ PASS │ FAIL
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Program runs │ │ Log printed │
│ Attached to hook │ │ Fix and retry │
└──────────────────────┘ └──────────────────────┘
The verifier is the final authority. Aya’s typed API catches some errors, but the verifier catches the rest.
Summary
| Item | Value |
|---|---|
| Framework | aya (pure Rust, no libbpf) |
| Kernel-side crate | #![no_std], bpfel-unknown-none |
| User-space crate | Normal Rust |
| Shared types | #![no_std], #[repr(C)] |
| Build tool | bpf-linker |
| Event streaming | Ring buffer (preferred) or perf buffer |
| Verifier failures | Log with RUST_LOG=debug |
| Stack limit | 512 bytes |
Key takeaways:
- Aya is pure Rust. No
libbpf, noclangat runtime, no C toolchain in the build path. The eBPF program, loader, and shared types are all Rust crates . - The
no_stdboundary is strict. Kernel-side code cannot allocate, cannot panic, and cannot useDebugorDisplay. Event structs must be#[repr(C)]with fixed-size fields . - The verifier is the authority. Aya’s types catch schema mismatches, but the kernel verifier rejects unsafe memory access, unbounded loops, and oversized stack frames .
- Ring buffers are preferred over perf event arrays. They are a single shared ring across CPUs, and Aya exposes a polled file descriptor that integrates with
tokio. - CO-RE requires discipline. Hard-coded struct offsets break across kernel versions. Use
bpf_probe_read_kernelwith BTF-aware field access .
Remember: Aya does not make eBPF safe—it makes eBPF development Rust-shaped. The verifier still rejects unsafe memory access, the stack is still 512 bytes, and the kernel side is still no_std. What Aya provides is a single language across the boundary, a build system that does not require clang, and an API that catches some errors before the verifier does. The verifier is still the gate.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!