Chapter 5: XDP - eBPF at Line Rate
“XDP does not see packets. It sees bytes that used to be packets, before the kernel spent any time on them - and that is exactly its power.”
The XDP hook is where eBPF earns its reputation. Attached at the very front
of the receive path - in the driver’s poll context, before an skb is
allocated, before GRO, before netfilter, before anything that costs money -
an XDP program sees the raw DMA’d frame and must decide its fate in the
per-packet budget of Chapter 1. Drop, pass, transmit back out the same
interface, or redirect to another interface, another CPU, or an AF_XDP
socket. This chapter is the practical XDP reference: the program, the
actions, the packet-parsing discipline, and the Aya attach path.
5.1 Where XDP Runs and What It Sees
Recall the path of Chapter 1: the NIC DMAs a frame into the ring; the
driver’s NAPI poll loop wakes; normally the driver builds an skb and hands
it to the stack. XDP runs in that poll loop, on the raw frame, before
the skb exists. The program receives a context with two pointers - data
and data_end - delimiting the frame in DMA memory, plus the receive
queue’s index. Everything the program needs (MAC addresses, IP headers,
ports) it must parse itself from those bytes, with the bounds discipline of
Chapter 2: every read checked against data_end.
Three properties follow:
- No allocation. The frame is in memory the NIC filled; the program neither allocates nor frees. This is why an XDP drop can cost ~100 ns while the same drop in the stack costs thousands.
- No stack, no protocol state. There is no
skb, no routing decision, no conntrack entry, no socket. XDP is pre-protocol. If your decision needs connection state, either build it in maps yourself or use a later hook. - The driver must support it. XDP attaches to a driver’s native path
(
nativemode). Generic XDP exists as a fallback that runs in the stack, and offloaded XDP runs in the NIC’s firmware; for performance you want native, andethtool -Ltells you whether your NIC and driver support it.
5.2 The Actions: A Decision, Not a Filter
An XDP program returns one of five actions, and the action is the whole output of the program:
| Action | Value | Meaning |
|---|---|---|
XDP_ABORTED | 0 | error: trace and drop |
XDP_DROP | 1 | discard in the driver; nothing more happens |
XDP_TX | 3 | transmit back out the same interface |
XDP_PASS | 2 | hand the frame to the normal stack path |
XDP_REDIRECT | 4 | send to another interface, CPU, or AF_XDP socket |
The framing that matters: XDP is a decision point, not a packet filter.
DROP is the DDoS use case (drop bad traffic at the NIC, before it costs
anything). REDIRECT is the load-balancing use case (steer a packet to a
backend interface without the stack touching it) and the AF_XDP use case
(hand the frame to a userspace poller, zero-copy). PASS is the honest
fallback: “I looked, I do not want to deal with this packet - let the stack
have it.”
Source: code/ch05_xdp/kernel/src/main.rs
#![allow(unused)]
fn main() {
// code/ch05_xdp/kernel/src/main.rs
// The canonical XDP program shape: parse once, look up once, act.
// Fail-open everywhere - an unparseable or unknown packet passes (Ch.5.2).
#![no_std]
#![no_main]
mod parse;
use aya_bpf::{
macros::{map, xdp},
maps::HashMap,
programs::XdpContext,
};
use aya_bpf::bindings::xdp_action::*;
const MAX_BACKENDS: u32 = 64;
// Backend table: key = dst port, value = where to send it.
#[repr(C)]
pub struct Backend {
pub ifindex: u32, // redirect target interface
pub cpu: u32, // redirect target CPU (reserved)
}
#[map]
static BACKENDS: HashMap<u16, Backend> = HashMap::with_max_entries(MAX_BACKENDS, 0);
#[xdp]
pub fn backend_lb(ctx: XdpContext) -> u32 {
// Turn (data, data_end) into a slice once; all parsing is slice reads.
let frame = unsafe { parse::frame(ctx.data(), ctx.data_end()) };
// Parse once (eth + ip + tcp). None means "not ours": pass it on.
let Some(t) = parse::tcp_tuple(frame) else {
return XDP_PASS;
};
// One map lookup by dst port. Hash lookups can fail (Ch.4): no entry
// means "not a service we know" - pass it on.
match BACKENDS.get(&t.dport) {
Some(be) => aya_bpf::helpers::bpf_redirect(be.ifindex, 0),
None => XDP_PASS,
}
}
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
}
Read the program as a conversation with the verifier. Every parse step
begins with a bounds check; every lookup handles the NULL case; every
branch that cannot decide returns XDP_PASS rather than dropping a packet
that might be legitimate. This fail-open discipline - pass when unsure -
is what separates a network program from a network accident.
5.3 The Bounds Discipline: data, data_end, and the Verifier
The single most common verifier rejection in XDP programming is the one
shown in Chapter 2: reading past data_end. The rule is absolute: every
packet access must be provably within [data, data_end]. In practice
that means the program structure is fixed:
check: start + offset + size <= data_end -> then read
Aya’s XdpContext exposes data() and data_end(), and the pattern above
-
compute the needed extent, compare, then dereference - is the pattern the verifier accepts. Two subtleties that bite:
-
Helper calls can invalidate packet pointers. After a call that may reallocate or grow the frame (e.g.
bpf_xdp_adjust_head), the olddatapointer is no longer usable and must be recomputed. Aya’s types enforce this: the context’s data pointers are only valid until such a call, and the compiler (like the verifier) complains if you hold them across one. -
Do not parse twice what you can parse once. Each bounds check costs a comparison; a frame with three headers wants three checks, not nine. Parse the headers in order, carrying the offset forward - the pattern the capstone uses.
5.4 AF_XDP: The Zero-Copy Path to Userspace
XDP’s most powerful redirect target is AF_XDP: a socket type that gives
userspace zero-copy access to the frames XDP redirects to it. The
architecture is the rings of Chapter 1 done twice: the NIC’s RX ring, and
a set of UMEM rings (fill, rx, tx, completion) shared between kernel and
userspace. An XDP program does XDP_REDIRECT into an AF_XDP socket’s rx
ring; userspace reads frames directly from the UMEM without a copy, and
recycles buffers through the fill ring.
AF_XDP is the escape hatch that keeps XDP honest: when your packet processing outgrows what the verifier’s instruction budget allows, or when you need userspace libraries for parsing (TLS, HTTP/2, your own L7), you do not leave the kernel - you redirect into userspace and keep the zero-copy path. This is exactly the architecture of modern high-performance proxies and of Cilium’s service mesh when L7 processing must happen (Chapter 12).
5.5 Attaching XDP from Aya
Source: code/ch05_xdp/userspace/src/main.rs
// code/ch05_xdp/userspace/src/main.rs
// Userspace side: load, attach, fill the backend table.
use aya::{maps::HashMap, programs::Xdp, Ebpf};
use std::error::Error;
const KERNEL_ELF: &[u8] =
include_bytes!("../../kernel/target/bpfel-unknown-none/release/kernel");
const DEFAULT_IFACE: &str = "eth0";
const SERVICE_PORT: u16 = 8080;
const BACKEND_IFINDEX: u32 = 2;
#[repr(C)]
pub struct Backend {
pub ifindex: u32,
pub cpu: u32,
}
fn main() -> Result<(), Box<dyn Error>> {
let iface = std::env::args().nth(1).unwrap_or_else(|| DEFAULT_IFACE.to_string());
let mut ebpf = Ebpf::load(KERNEL_ELF)?;
let program: &mut Xdp = ebpf.program_mut("backend_lb")?.try_into()?;
program.load()?;
program.attach(&iface, aya::programs::XdpFlags::default())?;
println!("XDP attached to {iface}");
let mut backends: HashMap<_, u16, Backend> = ebpf.map_mut("BACKENDS")?.try_into()?;
backends.insert(
SERVICE_PORT,
Backend { ifindex: BACKEND_IFINDEX, cpu: 0 },
0,
)?;
println!("backend table: port {SERVICE_PORT} -> ifindex {BACKEND_IFINDEX}");
Ok(())
}
Note what is not in this program: no packet code. The control plane fills maps; the data plane makes decisions. Keeping the two planes separate is the Chapter 13 methodology in miniature - and it is why the capstone can reconfigure a running data path by updating a map, not by reloading a program.
5.6 XDP in Production: DDoS, LB, and the Security Story
XDP’s production track record is the strongest argument for learning it:
Facebook’s Katran (an XDP load balancer) and Cloudflare’s DDoS mitigation
both drop or redirect tens of millions of packets per second on commodity
hardware - numbers the stack cannot touch because every packet the stack
sees has already paid for an skb. Katran in particular is the production
ancestor of the capstone’s XDP load balancer: hash the flow tuple, look up
a backend in a map, XDP_REDIRECT to the backend’s interface. The DDoS
story is even simpler: a HASH map of bad addresses, XDP_DROP on match,
fail-open otherwise. Both are the same program shape you just wrote -
parse, look up, act - at a scale that only the front-of-the-path hook can
reach.
Hands-On Lab
# 1. Build and attach the XDP load balancer to a veth pair.
cd code/ch05_xdp/kernel && cargo build --release --target bpfel-unknown-none
cd ../userspace && sudo cargo run --release -- veth0
# 2. Prove it runs before the stack: the JIT output is a few dozen
# instructions, and the drop never reaches tcpdump.
sudo bpftool prog dump jited name backend_lb
sudo tcpdump -i veth0 -nn -c 5 # packets to the service are gone
# 3. Measure the budget (Chapter 13): pktgen at 1 Mpps, then read the
# per-program counters.
sudo bpftool prog show name backend_lb # runtime + the 'drop' counter
The verification loop is: traffic in, map counter up, tcpdump silent, JIT small. All four together are what “XDP at line rate” means.
Summary
- XDP runs in the driver’s poll loop, on the raw DMA’d frame, before
skballocation - no allocation, no stack, no protocol state. - The output is one of five actions:
ABORTED,DROP,PASS,TX,REDIRECT. It is a decision point, not a filter. - Bounds discipline is the whole game: every read checked against
data_end, helper calls invalidate packet pointers, parse-once. REDIRECTto AF_XDP gives userspace zero-copy access - the escape hatch for L7 processing beyond the verifier’s budget.- Attach from Aya is
XdpFlags::default()on an interface; control plane (maps) and data plane (program) stay separate. - Production XDP (Katran, Cloudflare) is the same shape you wrote, at tens of Mpps - the proof that the front-of-path hook is worth it.
Next: Chapter 6 crosses to the other side of the path - the traffic
control hooks that run after the stack has allocated the skb.