XDP: the earliest hook
Drop and count packets at XDP — the eXpress Data Path, in the driver before an sk_buff is allocated. The fastest eBPF hook, raw packet pointers, and the bounds-checking discipline the verifier demands.
Chapter 31 put your code on the packet path at the qdisc, working on a
fully-formed sk_buff. XDP — the eXpress Data Path — goes lower and
earlier: it runs in the network driver, on the raw received frame,
before the kernel allocates an sk_buff at all. That makes it the
fastest place to touch a packet and the canonical home of high-rate
filtering and load balancing — and it changes how you read the packet,
because there’s no metadata yet, just bytes between two pointers.
The code is in examples/32-xdp-drop/. ./demo.sh there builds, deploys,
and runs it; its README.md covers what it does and how to drive it.
Earliest and fastest — and rawest
Look at the figure top-to-bottom and the whole book’s network surface
lines up: sockets and IP at the top (Chapters 27–30), tc/clsact below them
(Chapter 31), and XDP at the very bottom, right above the driver. A
packet hits XDP before the kernel has spent anything building an
sk_buff, which is exactly why XDP can drop millions of packets per
second — the dropped ones cost almost nothing.
XDP returns a verdict, like tc, but the set is XDP’s own:
XDP_PASS— continue up the stack (ansk_buffgets built).XDP_DROP— discard now, in the driver. The cheapest drop in Linux.XDP_TX— bounce the (possibly rewritten) packet back out the same NIC.XDP_REDIRECT— send it to another NIC or a userspace socket (AF_XDP).XDP_ABORTED— error path; behaves like drop and fires a tracepoint.
The cost of being first is that there is no sk_buff and no helper that
copies for you. You get two pointers — data and data_end — and
everything between them is the raw frame. Before you read any byte, you
must prove to the verifier that the byte is inside that window.
How the code works
The bounds-checked read
Every XDP program has some version of this helper, and it is the heart of the chapter:
#[inline(always)]
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
if start + offset + mem::size_of::<T>() > end {
return Err(()); // would read past the frame — refuse
}
Ok((start + offset) as *const T)
}
ctx.data() and ctx.data_end() are the packet window. The check
start + offset + size_of::<T>() > end is not optional politeness — it is
the exact condition the verifier insists on. The verifier traces the
comparison and only then lets you dereference the pointer; omit it and the
program is rejected at load time, not at runtime. This is the discipline
XDP trades for its speed: you do the bounds proof by hand, every access.
Contrast Chapter 31: TcContext::load copied a T out of the sk_buff
and did the bounds check internally. Here there is nothing to copy out of —
you point directly into driver memory, so you carry the proof yourself.
The program
#[xdp]
pub fn xdp_filter(ctx: XdpContext) -> u32 {
try_filter(&ctx).unwrap_or(xdp_action::XDP_PASS) // on any miss, pass
}
fn try_filter(ctx: &XdpContext) -> Result<u32, ()> {
let eth: *const EthHdr = unsafe { ptr_at(ctx, 0)? };
if unsafe { (*eth).ether_type } != EtherType::Ipv4 {
return Ok(xdp_action::XDP_PASS);
}
let ip: *const Ipv4Hdr = unsafe { ptr_at(ctx, EthHdr::LEN)? };
let proto = unsafe { (*ip).proto };
bump(&PKTS, proto as u32, 1);
if proto == IpProto::Icmp {
bump(&DROPS, proto as u32, 1);
return Ok(xdp_action::XDP_DROP); // ICMP dies here, in the driver
}
Ok(xdp_action::XDP_PASS)
}
- Each header is obtained through
ptr_at, so each access is proven in bounds before the dereference. We read the Ethernetether_type; if it isn’t IPv4 weXDP_PASS(leave ARP/IPv6/… alone). Then we read the IPv4 header and its protocol byte. - We count every IPv4 packet by protocol in a
HashMap(same in-kernel aggregation as Chapter 31 — per-packet ring buffers are out of the question at XDP rates), and we drop ICMP withXDP_DROP, bumping a drop counter. Everything else passes. - Dropping ICMP makes the verdict vividly visible: a
pingto the target gets no replies while the program is attached, because the echo requests are discarded in the driver before the stack ever sees them — then resume the instant you detach.
The maps and bump are identical in spirit to the previous chapter:
#[map] static PKTS: HashMap<u32, u64> = HashMap::with_max_entries(16, 0);
#[map] static DROPS: HashMap<u32, u64> = HashMap::with_max_entries(16, 0);
Attaching XDP (and the VM caveat)
let iface = std::env::var("IFACE").unwrap_or_else(|_| "eth0".into());
let prog: &mut Xdp = ebpf.program_mut("xdp_filter").unwrap().try_into()?;
prog.load()?;
prog.attach(&iface, XdpFlags::default())
.or_else(|_| prog.attach(&iface, XdpFlags::SKB_MODE))?;
XdpFlags::default() asks for native (driver) XDP — the fast path,
where the program runs inside the driver’s receive routine. Not every
driver supports it. The lab runs on virtio-net, which does support
native XDP on recent kernels, but to keep the example robust we fall back
to SKB_MODE (generic XDP): the kernel runs the program a little later,
after a minimal sk_buff exists. Generic mode is slower and somewhat
defeats XDP’s point, but it works everywhere and is fine for learning and
for functional tests. On bare metal with a supported NIC, native mode is
what you’d run.
Draining is the timer-based delta read from Chapter 31, producing
ebpf_xdp_packets_total{proto} and ebpf_xdp_dropped_total{proto}.
Build, deploy, observe
cd examples/32-xdp-drop && ./demo.sh
The demo attaches the filter to the target’s interface, then pings the
target from the peer. Before attach, replies come back; with the program
attached, the pings go unanswered (ICMP dropped at XDP) while a parallel
TCP check keeps working — and ebpf_xdp_packets_total climbs while
ebpf_xdp_dropped_total{proto="icmp"} tracks the dropped echo requests.
Stop the program and ping recovers immediately.
In Grafana (127.0.0.1:3000 → Explore), graph rate(ebpf_xdp_packets_total[1m]) — total packets at the XDP hook, against rate(ebpf_xdp_dropped_total[1m]) for how many were dropped.
Cross-check
On the target ([vm]$ — ssh fedora@$(scripts/lab/vm-ip.sh ebpf-target)),
first find the interface (usually enp1s0):
[vm]$ ip -br link # the lab NIC is the UP one carrying the guest's IP
With the program attached, check it from inside the target (substitute your
interface where you see enp1s0):
[vm]$ ip link show enp1s0 # look for "xdp" (native) or "xdpgeneric" (SKB mode)
3: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... xdpgeneric/id:42 ...
[vm]$ sudo bpftool net show # names the XDP program bound to each interface
xdp:
enp1s0(2) generic id 42
Then watch the verdict from the peer. <target-ip> is the target’s lab
IP — get it on the host with scripts/lab/vm-ip.sh ebpf-target (something
like 192.168.122.50):
[peer]$ ping -c3 192.168.122.50 # times out while attached; replies again once you stop it
ip link show reporting xdp/xdpgeneric confirms the attach and tells
you which mode you got; bpftool net show names the program. The ping going
from replies → timeouts → replies is the verdict, demonstrated end to end.
What you learned
- XDP is the earliest, fastest hook — in the driver, before the
sk_buff— with verdictsXDP_PASS/XDP_DROP/XDP_TX/XDP_REDIRECT/XDP_ABORTED. - The
ptr_atbounds-check idiom: with rawdata/data_endpointers you prove every access is in-window before dereferencing, or the verifier rejects the program — the trade-off for running rawest. - The
#[xdp]program type,XdpContext, and attaching withXdpFlags::default()(native) falling back toSKB_MODE(generic), including why a VM may need the fallback.
This closes the Networking part — from timing connects to dropping packets in the driver. Next we move to Part 5, Security & LSM, where eBPF programs answer a different question: not “what happened?” but “should this be allowed?”
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.