| |

Rust 123 🦀 Linux eBPF Program Development using Rust (aya framework)

⏱️ Estimated read: 11 min | |

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

CratePurpose
ayaUser-space loader
aya-ebpfKernel-side eBPF API
aya-log / aya-log-ebpfLogging from eBPF to user space
network-typesTyped Ethernet/IP/TCP headers

Program Type Macros

MacroContext TypeUse
#[xdp]XdpContextPacket filtering at driver level
#[socket_filter]SocketFilterContextSocket-level filtering
#[tracepoint]TracePointContextKernel tracepoint events
#[kprobe]ProbeContextFunction entry/exit tracing

eBPF Constraints

ConstraintValue
Stack size512 bytes (256 with tail calls)
HeapNone
Standard libraryDisabled (#![no_std])
PanicsNot allowed
Main functionNone (#![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 String in 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

PitfallWhy It HappensFix
Verifier rejects loopLoop bound not provableUse #[unroll] or a const-bounded counter
Stack overflowLocal variable exceeds 512 bytesMove state to a per-CPU array map
Program fails on new kernelHard-coded struct offsetsUse BTF and bpf_probe_read_kernel
core::fmt recursionUsing Debug or DisplayUse aya-log or plain integers
Ring buffer wedgesReserve/commit unbalancedEnsure 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

ItemValue
Frameworkaya (pure Rust, no libbpf)
Kernel-side crate#![no_std], bpfel-unknown-none
User-space crateNormal Rust
Shared types#![no_std], #[repr(C)]
Build toolbpf-linker
Event streamingRing buffer (preferred) or perf buffer
Verifier failuresLog with RUST_LOG=debug
Stack limit512 bytes

Key takeaways:

  • Aya is pure Rust. No libbpf, no clang at runtime, no C toolchain in the build path. The eBPF program, loader, and shared types are all Rust crates .
  • The no_std boundary is strict. Kernel-side code cannot allocate, cannot panic, and cannot use Debug or Display. 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_kernel with 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.

Was this helpful?


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!