Writing your first XDP program: from boilerplate to packet drop
April 8, 2026 · kernel notes · ebpf · xdp · networking
XDP (eXpress Data Path) is the Linux kernel’s mechanism for running BPF programs on packets the moment they arrive from the NIC, before the kernel network stack does anything with them. It’s the fastest place in Linux to make a per-packet decision.
People reach for XDP when they need DDoS mitigation, packet filtering, or load balancing at line rate. They reach for XDP wrong when they think it can replace netfilter, or when they need TCP-aware logic. XDP sees individual packets. It does not see flows.
Here’s a minimal program.
The simplest possible XDP program
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int xdp_pass(struct xdp_md *ctx) {
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
This program does nothing. It sees every packet, returns XDP_PASS, and lets the packet continue up the stack. It compiles, loads, and attaches. If it doesn’t, your environment has a problem before you write real logic.
Adding a counter
The next obvious thing is counting packets. BPF maps are how you communicate between the BPF program (which runs in kernel context) and userspace.
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
} pkt_count SEC(".maps");
SEC("xdp")
int xdp_count(struct xdp_md *ctx) {
__u32 key = 0;
__u64 *count = bpf_map_lookup_elem(&pkt_count, &key);
if (count) {
__sync_fetch_and_add(count, 1);
}
return XDP_PASS;
}
Two things to notice. First, this is a per-CPU array — each CPU gets its own counter, and userspace sums them. This avoids contention. Second, bpf_map_lookup_elem returns a pointer that may be NULL — the verifier will reject your program if you don’t check.
Adding conditional drop
Now drop packets from a specific source IP. This requires parsing Ethernet and IP headers, with bounds checking that satisfies the BPF verifier.
#include <linux/if_ether.h>
#include <linux/ip.h>
SEC("xdp")
int xdp_drop_ip(struct xdp_md *ctx) {
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end) return XDP_PASS;
// Drop traffic from 192.0.2.1
if (ip->saddr == bpf_htonl(0xC0000201)) {
return XDP_DROP;
}
return XDP_PASS;
}
Every pointer dereference must be preceded by a bounds check against data_end. The verifier walks every code path and rejects anything where it cannot prove safety. This is annoying when you start; it becomes second nature after a week.
What XDP can’t do
The kernel network stack hasn’t run yet when XDP fires. That means:
- No TCP state. You can’t write XDP that reasons about connections.
- No socket lookup at this layer. Use sockmap or tc-bpf for that.
- No conntrack visibility. Connection tracking is netfilter machinery.
- No fragmentation reassembly. You see fragments, not whole packets.
For per-packet filtering at line rate, XDP is excellent. For anything that requires session state, you want tc-bpf, kprobes, or kernel-level tracing. Choose your attach point based on what information you actually need.
Modes: native, generic, offloaded
XDP has three attach modes. Native runs the program in the NIC driver. Generic runs it in the kernel after a small detour through skb allocation — slower, but works on any interface. Offloaded runs the program on the NIC’s own processor, requires hardware support, and is rare outside specific Mellanox/NVIDIA setups.
In a Kind cluster or local Docker, you’ll always be in generic mode. Performance there proves nothing — it proves the program logic works. Production performance claims require native mode on real hardware.
The first time you see XDP_DROP eliminate a packet without involving the kernel stack at all, the appeal becomes obvious. The second time you wrestle the verifier into accepting a loop, the cost becomes obvious. Both are real.