Rust’s memory safety guarantees can help you avoid common C-based eBPF pitfalls, but writing eBPF programs in Rust with Aya isn’t just about avoiding segfaults; it’s about building robust, performant network and observability tools that were previously difficult or impossible to craft reliably.

Let’s see Aya in action. Imagine we want to count how many times a specific syscall, say sys_enter_execve, is called.

First, we need a Rust project. We’ll use cargo:

cargo new aya_execve_counter
cd aya_execve_counter

Now, we add Aya and its dependencies to Cargo.toml:

[package]
name = "aya_execve_counter"
version = "0.1.0"
edition = "2021"

[dependencies]
aya = { version = "0.13.0", features = ["thread_local"] }
aya-log = { version = "0.2.0" }
memmap2 = "0.9.0" # For mapping memory-mapped files
clap = { version = "4.0.0", features = ["derive"] }

[[bin]]
name = "aya_execve_counter"
path = "src/main.rs"

[lib]
name = "aya_execve_counter_ebpf"
path = "src/ebpf/mod.rs"

Next, we define our eBPF program in src/ebpf/mod.rs. This is where the magic happens – Aya’s procedural macros will compile this Rust code into eBPF bytecode.

// src/ebpf/mod.rs
#![no_std]
#![feature(allocator_api)]

use aya_bpf::{
    bindings::errno::EINVAL,
    helpers::{bpf_get_current_pid_tgid, bpf_printk},
    macros::{map, program},
    maps::HashMap,
};

// Define a BPF map to store our counts.
// The key will be the PID (lower 32 bits of tgid) and the value will be the count.
#[map(name = "EXECVE_COUNTS")]
static mut EXECVE_COUNTS: HashMap<u32, u64> = HashMap::new();

// The eBPF program itself. This will be attached to the kprobe for sys_enter_execve.
#[program(name = "sys_enter_execve")]
fn sys_enter_execve(ctx: aya_bpf::ctxes::KprobeContext) -> u32 {
    // Get the current process ID (PID) and thread group ID (TGID).
    // bpf_get_current_pid_tgid returns a u64 where the lower 32 bits are PID and
    // the upper 32 bits are TGID. We are interested in the PID for our map.
    let pid_tgid = bpf_get_current_pid_tgid();
    let pid = pid_tgid as u32; // Extract the PID

    // Look up the current count for this PID in our map.
    // If the PID is not found, `get_or_insert` will insert it with a value of 0.
    // Then, we increment the count.
    let mut count = unsafe { EXECVE_COUNTS.get_or_insert(pid, &0).clone() };
    count += 1;

    // Update the map with the new count.
    unsafe {
        if EXECVE_COUNTS.insert(pid, &count).is_err() {
            // If insertion fails, print an error message. This is unlikely in practice
            // for a simple counter, but good to handle.
            bpf_printk("Failed to insert into EXECVE_COUNTS map for PID %u\n", pid);
        }
    }

    // Print a debug message (optional, for verification).
    // bpf_printk("execve called by PID %u, count: %u\n", pid, count);

    // Return 0 to indicate success.
    0
}

// This is a dummy function needed for Aya's build process to handle the ebpf library.
// It ensures the ebpf code compiles correctly.
#[allow(dead_code)]
fn dummy_fn() {
    // This function is never called at runtime.
    // It's used by Aya to generate necessary boilerplate code.
}

Now, in src/main.rs, we’ll write the userspace application that loads and interacts with our eBPF program.

// src/main.rs
use aya::programs::{Kprobe, Xdp, XdpFlags};
use aya::transport::tcp::AsyncTcpStream;
use aya::{include_bytes, Bpf};
use aya_log::error;
use clap::Parser;
use memmap2::MmapOptions;
use std::fs::File;
use std::path::PathBuf;
use std::process::exit;
use std::sync::Arc;
use tokio::signal;

#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
    /// TCP port to listen on
    #[clap(short, long, value_parser)]
    port: u16,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();

    // Load the eBPF program from the compiled Rust code.
    // Aya automatically handles compiling the `src/ebpf/mod.rs` file into eBPF bytecode.
    let mut bpf = Bpf::load(include_bytes!(
        "../target/bpfel-unknown-none/release/aya_execve_counter_ebpf"
    ))?;

    // Attach the kprobe to the `sys_enter_execve` syscall.
    // This means our eBPF program will run every time a process calls execve.
    let kprobe: Kprobe = bpf.program_mut("sys_enter_execve")?.try_into()?;
    kprobe.load()?;
    kprobe.attach("sys_enter_execve", 0)?; // Attach to the entry point of the syscall

    println!("BPF program loaded and attached. Press Ctrl+C to stop.");

    // In a real-world scenario, you'd likely want to read from the BPF map
    // periodically to display the counts. For this example, we'll just keep
    // the program running.
    //
    // To read the map, you would do something like this:
    // let mut map = bpf.map("EXECVE_COUNTS").unwrap();
    // loop {
    //     for (pid, count) in map.iter() {
    //         println!("PID: {}, Execve count: {}", pid, count);
    //     }
    //     tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
    // }

    // Keep the userspace application running until interrupted.
    signal::ctrl_c().await?;

    println!("Detaching BPF program...");
    // The program will be detached automatically when `bpf` goes out of scope.

    Ok(())
}

To build and run this:

  1. Build the eBPF part:

    cargo build --release -p aya_execve_counter_ebpf
    

    This compiles the Rust code in src/ebpf/mod.rs into BPF bytecode. Aya’s build process, configured in Cargo.toml, handles this.

  2. Build the userspace application:

    cargo build --release
    
  3. Run with root privileges:

    sudo ./target/release/aya_execve_counter
    

Now, whenever a process calls execve (e.g., when you run a new command in another terminal), the eBPF program will increment a counter associated with that process’s PID in the EXECVE_COUNTS map. To see the counts, you’d typically add a loop in src/main.rs to read from the map.

The mental model here is that Aya acts as a bridge. It allows you to write your eBPF logic in Rust, providing strong typing and memory safety. During the build process, Aya’s macros compile this Rust code into eBPF bytecode. The userspace application, also written in Rust, uses the Aya library to load this bytecode into the kernel, attach it to specific kernel hooks (like kprobes or tracepoints), and then interact with BPF maps created by the eBPF program to collect data.

The core of the eBPF program is the #[program] macro, which tells Aya to compile the annotated Rust function into an eBPF program. Inside, you use aya_bpf’s helper functions (like bpf_get_current_pid_tgid and bpf_printk) and map definitions (#[map]). The HashMap is a kernel-side data structure managed by the eBPF runtime, accessible from your Rust eBPF code.

What most people miss is how Aya manages the compilation and loading pipeline. You don’t explicitly compile the eBPF code to .o files and then load them; Aya’s build script and the include_bytes! macro handle the entire cross-compilation and embedding of the eBPF bytecode directly into your userspace binary. This simplifies the development workflow significantly, treating eBPF code much like any other Rust library dependency.

The next step is to explore how to efficiently read data from BPF maps in userspace and how to use more complex eBPF features like XDP for high-speed packet processing.

Want structured learning?

Take the full Ebpf course →