4 min read

Understanding Rust's bpfel-unknown-none Target: A Deep Dive into eBPF Compilation

When compiling Rust code for eBPF (extended Berkeley Packet Filter), you encounter one of Rust's most unusual compilation targets: bpfel-unknown-none. This target produces bytecode that runs inside the Linux kernel's eBPF virtual machine, enabling powerful kernel-level instrumentation, networking, and security tools.

This guide explains what makes this target unique, why it exists, and how to use it effectively.

πŸ”—
Related: For a real-world eBPF implementation, see Loom Technical Deep Dive: eBPF, K8s, and Enterprise Observability

What is bpfel-unknown-none?

The target triple breaks down as:

  • bpf - BPF bytecode format (runs in Linux kernel's eBPF VM)
  • el - Little-endian byte order (eb = big-endian)
  • unknown - No specific operating system (kernel bytecode, not userspace)
  • none - No standard library - bare metal environment

There are two variants:

  • bpfel-unknown-none - Little-endian (most common, used on x86_64)
  • bpfeb-unknown-none - Big-endian (used on some embedded systems)

The Target Specification

You can inspect the full target specification with:

rustc +nightly -Z unstable-options --print target-spec-json --target bpfel-unknown-none
{
  "arch": "bpf",
  "atomic-cas": false,
  "data-layout": "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128",
  "dynamic-linking": true,
  "linker-flavor": "bpf",
  "llvm-target": "bpfel",
  "max-atomic-width": 64,
  "min-atomic-width": 64,
  "no-builtins": true,
  "obj-is-bitcode": true,
  "panic-strategy": "abort",
  "singlethread": true,
  "target-pointer-width": 64,
  "metadata": {
    "description": "BPF (little endian)",
    "tier": 3,
    "std": false
  }
}

Key Properties Explained

obj-is-bitcode: true - This is the most unusual property. Unlike normal targets that output object files, this target outputs LLVM bitcode (.bc files). The actual eBPF bytecode generation happens during linking via bpf-linker. This approach allows link-time optimizations to eliminate unsupported Rust constructs before code generation.

atomic-cas: false - eBPF doesn't support compare-and-swap atomics. Only simple 64-bit atomic loads and stores are available.

panic-strategy: abort - There's no unwinding in kernel space. Panics must abort immediately.

singlethread: true - eBPF programs run in a single-threaded context (per-CPU).

Why eBPF is Different

The eBPF virtual machine has severe constraints that make it unlike any other Rust target:

1. Formal Verification

Before your eBPF program runs, the Linux kernel's verifier statically proves that your program:

  • Will terminate (no infinite loops)
  • Won't access invalid memory
  • Won't crash the kernel
  • Follows the eBPF calling convention

This is actual formal verification, not just type checking.

2. Resource Limits

  • Stack size: 512 bytes (256 with tail calls)
  • Heap: None (use BPF maps instead)
  • Instructions: ~1 million (kernel version dependent)
  • Nested calls: 8 levels
  • Registers: 11 (r0-r10)

3. No Standard Library

eBPF programs use #![no_std] and #![no_main]. You only have access to core - no heap allocation, no I/O, no threads.

4. Register-Based Calling Convention

  • Arguments: r1 through r5 (max 5 arguments)
  • Return value: r0
  • Frame pointer: r10 (read-only)
  • Kernel helpers invoked via call [helper_id]

The Compilation Pipeline

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Rust Source   β”‚
β”‚   #![no_std]    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ rustc --target bpfel-unknown-none
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LLVM Bitcode   β”‚  ← Note: NOT object code!
β”‚     (.bc)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ bpf-linker (LTO + codegen)
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   eBPF Object   β”‚
β”‚     (.o)        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ loader (aya, libbpf, etc.)
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Kernel eBPF VM β”‚
β”‚  (after verify) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why Bitcode First?

The bpf-linker defers code generation until after link-time optimization. This is crucial because:

  1. Dead code elimination - Unsupported Rust constructs can be optimized away before code generation fails
  2. Cross-crate inlining - Better optimization across crate boundaries
  3. Constant propagation - More code can be evaluated at compile time

Build Configuration

.cargo/config.toml

[build]
target = "bpfel-unknown-none"

[unstable]
build-std = ["core"]

[target.bpfel-unknown-none]
linker = "bpf-linker"
rustflags = [
    "-C", "debuginfo=2",
    "-C", "link-arg=--btf",
    "-C", "panic=abort",
]

Cargo.toml

[package]
name = "my-ebpf-program"
version = "0.1.0"
edition = "2021"

[dependencies]
aya-ebpf = "0.1"
aya-log-ebpf = "0.1"

[profile.release]
lto = true
panic = "abort"
opt-level = 3

[profile.dev]
opt-level = 3          # eBPF needs optimization even in dev
debug = false
panic = "abort"

Build Command

cargo +nightly build \
    --target=bpfel-unknown-none \
    -Z build-std=core \
    --release

A Minimal eBPF Program

#![no_std]
#![no_main]

use aya_ebpf::{macros::tracepoint, programs::TracePointContext};
use aya_log_ebpf::info;

#[tracepoint]
pub fn sys_enter_execve(ctx: TracePointContext) -> u32 {
    match try_execve(ctx) {
        Ok(()) => 0,
        Err(_) => 1,
    }
}

fn try_execve(ctx: TracePointContext) -> Result<(), i64> {
    info!(&ctx, "execve called!");
    Ok(())
}

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

Common Pitfalls

1. Stack Overflow

With only 512 bytes of stack, large local variables cause immediate failure:

// BAD: 1024 bytes on stack
let buffer: [u8; 1024] = [0; 1024];

// GOOD: Use a BPF map
#[map]
static BUFFER: PerCpuArray<[u8; 1024]> = PerCpuArray::with_max_entries(1, 0);

2. Unbounded Loops

The verifier rejects loops it can't prove terminate:

// BAD: Verifier can't prove termination
for item in collection.iter() { ... }

// GOOD: Bounded loop
for i in 0..MAX_ITEMS {
    if i >= collection.len() { break; }
    ...
}

3. Function Pointers

eBPF doesn't support indirect calls:

// BAD: Function pointer
let f: fn() = some_function;
f();

// GOOD: Direct call
some_function();

Nix Integration

Building eBPF with Nix requires network access for dependencies (cargo2nix doesn't support the BPF target):

{ stdenv, fenix, bpf-linker }:

let
  toolchain = fenix.complete.withComponents [
    "cargo" "rustc" "rust-src" "llvm-tools"
  ];
in
stdenv.mkDerivation {
  pname = "my-ebpf";
  nativeBuildInputs = [ toolchain bpf-linker ];

  buildPhase = ''
    export HOME=$(mktemp -d)
    cargo build --release
  '';
}

Note: You may need --option sandbox false for the cargo build to fetch dependencies.

BTF (BPF Type Format)

BTF embeds type information in the eBPF object file, enabling:

  • CO-RE (Compile Once, Run Everywhere) portability
  • Better debugging with bpftool
  • Kernel structure relocation

Enable with: -C link-arg=--btf

Further Reading

Conclusion

The bpfel-unknown-none target represents one of Rust's most constrained compilation environments. Understanding its limitations - 512 bytes of stack, no heap, formal verification, bitcode-first compilation - is essential for writing successful eBPF programs.

The key insight is that this isn't just "Rust for a weird CPU" - it's Rust for a formally verified, kernel-resident virtual machine with unique constraints that exist nowhere else in computing.