Catch Performance in eBPF with Rust: XDP Programs

This is the second in a five-part series. Read Part 1 here.
In this series we learned what eBPF is, the tools to work with it, why eBPF performance is important and how to track it with continuous benchmarking. In this entry of the series, we will discuss how to create a basic eBPF XDP program in Rust using Aya. All of the source code for the project is open source and is available on GitHub.
eBPF XDP programs allow for very efficient, custom packet handling. The eBPF XDP program is run before it reaches the kernel’s network stack. There are four different actions an eBPF XDP program can perform:
XDP_PASS
: Pass the packet to the normal network stack for processing. The packet contents can be modified.XDP_DROP
: Drop the packet and do not process it. This is the fastest action.XDP_TX
: Forward the packet to the same network interface it arrived on. The packet contents can be modified.XDP_ABORTED
: Error when processing, so drop the packet and do not process it. This indicates an error in the eBPF program.
In our basic example, if everything goes well, we will only perform the first action, XDP_PASS
, as we will be focusing more on the scaffolding and interprocess communication than the packet handling logic. The initial version of our eBPF XDP application will simply log the IPv4 source address for each packet that is received.
Let’s first look at the kernel side of things:
- #[xdp(name =
fun_xdp
)] - pub fn fun_xdp(ctx: XdpContext) -> u32 {
- match try_fun_xdp(&ctx) {
- Ok(ret) => ret,
- Err(_) => xdp_action::XDP_ABORTED,
Going line by line:
- An Aya macro that tells us we are creating an eBPF XDP program named
fun_xdp
. - The function definition for our eBPF XDP program. This takes in the context as its only argument. The context tells us all the information that the kernel provides to us and returns an unsigned 32-bit integer.
- There is a helper function called
try_fun_xdp
that we will discuss next. Based on what it returns, if it isOk
then everything is good and we return the value given. Otherwise, if we get anErr
, we abort.
Now let’s look at that try_fun_xdp
function:
-
-
- fn try_fun_xdp(ctx: &XdpContext) -> Result<u32, ()> {
- let eth_hdr: *const EthHdr = unsafe { ptr_at(ctx, 0)? };
- unsafe {
- let EtherType::Ipv4 = (*eth_hdr).ether_type else {
- return Ok(xdp_action::XDP_PASS);
- };
- }
- let ipv4_hdr: *const Ipv4Hdr = unsafe { ptr_at(ctx, EthHdr::LEN)? };
- let source_addr = unsafe { (*ipv4_hdr).src_addr };
- info!(ctx, “IPv4 Source Address: {}”, source_addr);
- Ok(xdp_action::XDP_PASS)
- }
- }
- }
Going line by line:
- The
try_fun_xdp
function takes in a reference to the context and returns aResult
containing either anOk
unsigned 32-bit integer value or an emptyErr
. - From the context, we get the ethernet header. Notice the
unsafe
ptr_at
helper function here. We will look at that next. - The following operations are also considered
unsafe
by the Rust compiler, so we have to explicitly opt in. - We only care about IPv4 for our basic example, so for anything else we just go ahead and pass the packet along.
- –
- –
- –
- Extract the IPv4 header. Again using that
unsafe
ptr_at
helper function. - Get the source address from the IPv4 header.
- Log the IPv4 source address
- –
- Return pass!
- –
- –
Finally let’s look at that
ptr_at
helper function:- #[inline(always)]
- unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
- let start = ctx.data();
- let end = ctx.data_end();
- let len = core::mem::size_of::<T>();
- if start + offset + len > end {
- return Err(());
- }
- Ok((start + offset) as _)
- }
Going line by line:
- Because this operation is going to be performed a lot and within the critical path, we use a macro to ask the compiler to always inline our helper function.
- This is an unsafe function that reads a generic type,
T
, from the memory given by the context at a specific byteoffset
. For a successful read, theResult
is anOk
pointer to aT
. Otherwise, an emptyErr
is returned. - The address of the start of the memory given by context.
- The address of the end of the memory given by the context.
- The size in bytes of the generic type
T
. - If the sum of the start address, byte offset and length of
T
in bytes is greater than the end address, then return an emptyErr
, as we have exceeded the bounds of the context. If we didn’t perform this check, the eBPF verifier would get upset and likely fail our build. - –
- –
- –
- –
- Read
T
from the memory given by the context at a specific byteoffset
- –
There is also one last function:
- #[panic_handler]
- fn panic(_info: &core::panic::PanicInfo) -> ! {
- unsafe { core::hint::unreachable_unchecked() }
- }
Going line by line:
- Define a custom Rust panic handler.
- This function takes in the Rust panic info, which it never uses. This function should never return.
- Give a hint to the Rust compiler that this code path should be unreachable. That is, we are never expecting to panic. This is required to keep the eBPF verifier happy.
- –
Now over to the userspace side of things. Let’s look at our
main
function:- #[tokio::main]
- async fn main() -> Result<(), anyhow::Error> {
- let opt = Opt::parse();
- env_logger::init();
- let mut bpf = Bpf::load(include_bytes_aligned!(“../path/to/ebpf-bin”))?;
- BpfLogger::init(&mut bpf)?;
- let program: &mut Xdp = bpf.program_mut(
fun_xdp
).unwrap().try_into()?; - program.load()?;
- program.attach(&opt.iface, XdpFlags::default())?;
- info!(“Waiting for Ctrl-C…”);
- signal::ctrl_c().await?;
- info!(“Exiting…”);
- Ok(())
- }
- #[derive(clap::Parser)]
- struct Opt {
- #[clap(long)]
- iface: String,
- }
Going line by line:
- This macro creates an asynchronous runtime for our program using
tokio
. - An asynchronous
main
function. In a Rust binary,main
is the de facto entry point. TheResult
of this function is an emptyOk
or a catch-allErr
using theanyhow
crate. - Parse the command line arguments passed to our binary.
- Initialize logging for userspace.
- Load our compiled eBPF bytecode. Aya makes recompiling our eBPF source code into bytecode easy, so it automatically happens before our userspace code is compiled.
- Initialize logging from our eBPF program.
- From our eBPF bytecode, get our
fun_xdp
eBPF XDP program. - Load the
fun_xdp
eBPF XDP program into the kernel using the default flags. - Attach our
fun_xdp
eBPF XDP program to a network interface that was set by theiface
command line argument to our binary. - –
- Log how to exit our program.
- Wait for the user to enter Ctrl + C.
- Log that our program is exiting.
- Return an empty
Ok
as ourResult
. - –
- –
- This macro uses
clap
to parse the command line arguments defined in theOpt
struct. - The command line argument struct named
Opt
. - Another macro that tells
clap
that this field should be parsed as a long argument name, i.e.,--iface
. - The name of argument is
iface
, and its value is a string.
With that, we have created a very basic eBPF program. Again, all of the source code for the project is open source and is available on GitHub.
-