Skip to content

Escribir código BPF en Rust: una guía práctica

Rust aporta seguridad de memoria y rendimiento al desarrollo de BPF. Aquí tienes un recorrido práctico sobre cómo escribir código BPF en Rust desde cero.

Red Sift
Published: December 19, 2019·Updated: July 15, 2025·13 min read

Si te dedicas a la programación de sistemas, probablemente hayas oído hablar mucho de BPF últimamente. Es una nueva y candente tecnología de Linux que permite ejecutar programas proporcionados por el usuario dentro del kernel. La utilizan Netflix, Facebook, Google, Cloudflare y muchas otras empresas para implementar cosas como balanceo de carga ultrarrápido, mitigación de DDoS y monitorización del rendimiento.

En los últimos meses he estado trabajando con Red Sift en RedBPF, un conjunto de herramientas BPF para Rust. Red Sift utiliza RedBPF para impulsar el agente de monitorización de seguridad InGRAINd. Peter escribió recientemente una entrada de blog sobre RedBPF e InGRAINd, y organizó un taller en RustFest Barcelona. Desde entonces hemos seguido mejorando RedBPF, corrigiendo errores, mejorando y añadiendo nuevas APIs, incorporando soporte para kernels de Google Kubernetes Engine y mucho más. También hemos completado el cambio de licencia del proyecto a Apache2/MIT, el esquema de licencias utilizado por muchos de los crates más destacados del ecosistema Rust, lo que esperamos que facilite aún más la adopción de RedBPF.

En esta entrada voy a profundizar en qué es RedBPF, cuáles son sus componentes principales y cómo es el proceso completo de escribir un programa BPF.

No hace falta ser un experto en BPF para leer esta entrada, ya que en la siguiente sección voy a dar una visión general rápida y muy de alto nivel de los conceptos principales que necesitas conocer para seguir el resto del contenido. Si quieres profundizar más, el libro de Brendan Gregg BPF Performance Tools acaba de salir y es bastante excelente. Y si no eres muy de libros, al final también incluiré otros enlaces útiles.

El curso intensivo más rápido sobre BPF

BPF es una máquina virtual que permite ejecutar programas definidos por el usuario en el kernel cuando ocurren ciertos eventos en un sistema Linux. Por ejemplo, si quieres monitorizar actividad sospechosa de archivos, registrar la latencia de respuesta de la red o incluso rastrear aplicaciones de espacio de usuario, puedes escribir pequeños programas BPF, solicitar que se adjunten en el lugar adecuado del kernel e implementar la instrumentación necesaria.

La máquina virtual BPF utiliza su propio conjunto de instrucciones. Puedes escribir el bytecode directamente, pero lo habitual es usar bpftrace o escribir código en C y compilarlo con BPF Compiler Collection (BCC).

bpftrace es una herramienta increíble que te permite escribir programas BPF utilizando un lenguaje de alto nivel ad hoc. Es excelente para scripts cortos e instrumentación manual, mientras que BCC es más adecuado para herramientas más complejas, o cuando se integra con otras aplicaciones y sistemas.

BCC aprovecha el soporte de LLVM para el target de BPF. Te permite escribir código C que luego se compila con clang a bytecode BPF que puede ser ejecutado por la máquina virtual BPF en el kernel. Existen algunas restricciones sobre el tipo de código C que puedes escribir —sobre todo, no puedes usar bucles—, pero por lo demás, escribir un programa BPF en C no se siente muy diferente de programar para cualquier otra plataforma embebida (algo peculiar).

Dado que los programas BPF se ejecutan en el kernel de Linux, el bytecode BPF no puede simplemente ejecutarse tal cual, sino que necesita cargarse en el kernel. Por eso, la mayoría de las aplicaciones que usan BPF se dividen en dos partes: el código BPF que se ejecuta en el kernel y un proceso en espacio de usuario encargado de cargar el código en el kernel e interactuar con él.

De forma esquemática, el proceso de desarrollo de un programa BPF puede resumirse en los siguientes pasos:

  1. Escribir el código BPF en C
  2. Compilar el código para la máquina virtual BPF
  3. Escribir un componente en espacio de usuario que cargue el resultado del paso 2 en la máquina virtual BPF
  4. Usar la API de BPF para intercambiar datos entre el componente de espacio de usuario y el código BPF

RedBPF incluye APIs y herramientas para implementar todos los pasos anteriores excepto el paso 1). Con RedBPF, el paso 1 pasa a ser:

  1. Escribir el código BPF en Rust

He pasado por alto muchos detalles y he simplificado en exceso algunas cosas, pero si no sabías nada sobre BPF, ahora deberías entender lo suficiente para seguir el resto. A continuación voy a mostrar exactamente cómo se puede usar RedBPF para implementar los pasos anteriores.

Entonces, ¿qué es RedBPF?

RedBPF es una colección de crates de Rust. Incluye:

  • redbpf-macros y redbpf-probes: proporcionan la API de BPF del espacio del kernel (paso 1)
  • redbpf: proporciona la API de BPF del espacio de usuario. En particular, ofrece la API para cargar el bytecode BPF (paso 3)
  • cargo-bpf: un plugin de cargo que simplifica la creación, compilación y depuración de programas BPF (paso 2)

Un rastreador HTTP sencillo (lado del kernel)

Ahora voy a mostrar un programa BPF muy sencillo escrito en Rust usando RedBPF, que puede ejecutarse en el kernel. Rastrea todas las solicitudes HTTP entrantes en una interfaz de red determinada utilizando las APIs de eXpress Data Path (XDP). Los programas XDP se enganchan directamente en el controlador de la NIC (pero pueden recurrir a ejecutarse a un nivel superior), proporcionando un acceso rápido y de bajo overhead a los datos entrantes antes de que entren en el resto de la pila de red.

Code
#![no_std] #![no_main] use redbpf_macros::{map, program, xdp}; use redbpf_probes::bindings::*; use redbpf_probes::xdp::{PerfMap, Transport, XdpAction, XdpContext, MapData}; use bpf_examples::trace_http::RequestInfo; program!(0xFFFFFFFE, "GPL"); #[map("requests")] static mut requests: PerfMap<RequestInfo> = PerfMap::with_max_entries(1024); #[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { let (ip, transport, data) = match (ctx.ip(), ctx.transport(), ctx.data()) { (Some(ip), Some(t @ Transport::TCP(_)), Some(data)) => (unsafe { *ip }, t, data), _ => return XdpAction::Pass, }; let buff: [u8; 6] = match data.read() { Some(b) => b, None => return XdpAction::Pass, }; if &buff[..4] != b"GET " && &buff[..4] != b"HEAD" && &buff[..4] != b"PUT " && &buff[..4] != b"POST" && &buff[..6] != b"DELETE" { return XdpAction::Pass; } let info = RequestInfo { saddr: ip.saddr, daddr: ip.daddr, sport: transport.source(), dport: transport.dest(), }; unsafe { requests.insert( &ctx, MapData::with_payload(info, data.offset() as u32, data.len() as u32), ) }; XdpAction::Pass }

Archivo: bpf_examples/src/trace_http/main.rsbpf_examples/src/trace_http/main.rs

Lo primero que hay que notar es que el programa es un binario #![no_std] #![no_main]:

Code
#![no_std] #![no_main] ...

La máquina virtual BPF no admite muchas de las funciones que requiere la stdlib, y los programas BPF se ejecutan en respuesta a eventos designados, por lo que no tienen un punto de entrada main convencional. Los binarios producidos no se ejecutan directamente, sino que se cargan en el kernel usando la API proporcionada por redbpf, como veremos más adelante.

Lo siguiente que hay que notar es la llamada al macro program!():

Code
program!(0xFFFFFFFE, "GPL");

Los programas BPF necesitan especificar con qué versión del kernel son compatibles y bajo qué licencia se distribuyen. 0xFFFFFFFE es un valor especial que significa cualquier versión del kernel. La licencia debe declararse porque la máquina virtual habilitará o no ciertas APIs en función de ella (si el programa no es GPL, no podrá usar funcionalidad GPL del kernel). Además de permitirte especificar la versión y la licencia, program!() también genera parte del código repetitivo global necesario para que el programa compile y se cargue correctamente.

trace_http() es la función que analiza los datos de red en busca de solicitudes HTTP:

Code
#[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { ... }

Como puedes ver, está anotada con el macro de atributo #[xdp], que forma parte del crate redbpf-macros. #[xdp] hace varias cosas, pero como veremos más adelante, se utiliza principalmente para indicar al cargador de bytecode de redbpf que la función es un programa XDP.

La función recibe un XdpContext y devuelve un XdpAction. XdpContext proporciona una abstracción de más alto nivel sobre el puntero subyacente xdp_md que la máquina virtual BPF proporciona a los programas XDP. #[xdp] mapea de forma transparente entre ambos tipos. El valor de retorno —XdpAction— puede usarse para indicar qué debe hacerse con los datos que se están inspeccionando actualmente: si deben pasarse a lo largo de la pila de red, descartarse, redirigirse a otra interfaz, etc.

La lógica de análisis real es bastante sencilla: si el protocolo de transporte es TCP y el payload parece una solicitud HTTP, la solicitud se envía al espacio de usuario donde puede analizarse. La API de BPF proporciona varias estructuras de datos —llamadas maps— que pueden utilizarse para almacenar y agregar datos entre invocaciones del programa, y también para intercambiar datos con el espacio de usuario. El map utilizado por nuestro programa —un PerfMap— permite a los programas BPF almacenar datos en memoria compartida mapeada con mmap() accesible desde el espacio de usuario.

Code
#[map("requests")] static mut requests: PerfMap<RequestInfo> = PerfMap::with_max_entries(1024); #[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { ... let info = RequestInfo { saddr: ip.saddr, daddr: ip.daddr, sport: transport.source(), dport: transport.dest(), }; unsafe { requests.insert( &ctx, MapData::with_payload(info, data.offset() as u32, data.len() as u32), ) }; XdpAction::Pass }

La variable global requests es nuestro PerfMap, que como puedes ver está anotado con el atributo #[map]. El atributo se usa para nombrar el map y hacer que se coloque en una sección ELF especial llamada maps/<name> (en nuestro caso maps/requests) del binario resultante, para que el cargador del espacio de usuario pueda encontrarlo e inicializarlo.

Para cada solicitud HTTP creamos una estructura RequestInfo que contiene la dirección de origen, la dirección de destino, el puerto de origen y el puerto de destino de la solicitud. Luego la insertamos en el PerfMap envuelta en un valor MapData. MapData::with_payload() se utiliza para indicar al driver que queremos que data.len() bytes del paquete actual se inserten en el map inmediatamente después de los datos de RequestInfo. data.offset() se usa para indicar al espacio de usuario el offset en el que comienzan los datos HTTP (después de las cabeceras Ethernet, IP y TCP).

Compilación y depuración del rastreador HTTP

Si quieres compilar el código mostrado en la sección anterior y cargarlo como se muestra en la siguiente sección, puedes clonar el repositorio que incluye el código anterior desde http://github.com/alessandrod/bpf_examples.

Para compilar el código, entra (cd) en la carpeta clonada y ejecuta:

Code
$ cargo install cargo-bpf $ cargo bpf build

Si la compilación se completa correctamente, colocará el programa BPF compilado en target/release/bpf-programs/trace_http/trace_http.elf. Para asegurarte de que el programa se carga y funciona correctamente, puedes usar cargo bpf load (debe ejecutarse como root):

Code
# cargo bpf load -i en0 target/release/bpf-programs/trace_http/trace_http.elf Loaded: trace_http, XDP

Sustituye en0 por la interfaz de red que quieras rastrear. Luego, si ejecutas un servidor HTTP en esa interfaz y envías una solicitud HTTP, deberías ver algo como esto:

Code
Loaded: trace_http, XDP -- Event: requests -- |3cf2510a ac1f0bed c3fd401f 42000000| <.Q.......@.B... 00000000 |51000000 0293e251 265202ad 2f8f62b2| Q......Q&R../.b. 00000010 |08004500 00850000 40002f06 056b3cf2| ..E.....@./..k<. 00000020 |510aac1f 0bedfdc3 1f40aaf5 7507bbd8| Q........@..u... 00000030 |9a7a8018 0816fa89 00000101 080a82b1| .z.............. 00000040 |1819b4d3 62134745 54202f66 6f6f2048| ....b.GET /foo H 00000050 |5454502f 31000000 00000000| TTP/1....... 00000060 0000006c

Para cada solicitud, cargo bpf mostrará -- Event: [MAP NAME] -- seguido de un volcado hexadecimal de los datos enviados por el código del kernel.

Escribir un cargador personalizado en espacio de usuario

cargo bpf load resulta bastante útil durante el desarrollo. Sin embargo, una vez que el código BPF funciona correctamente, probablemente quieras hacer algo más útil con los datos que recopila. Para nuestro ejemplo sencillo, supongamos que por cada solicitud queremos generar una línea con el formato:

Code
1.2.3.4 - GET /foo HTTP/1

Donde el lado izquierdo es la dirección IP del cliente, y el lado derecho es la línea de solicitud HTTP. Aquí tienes un programa sencillo que usa redbpf::load::Loader para hacer eso:

Code
use std::env; use std::path::PathBuf; use std::io; use std::net::IpAddr; use futures::stream::StreamExt; use tokio; use tokio::runtime::Runtime; use tokio::signal; use redbpf::load::Loader; use redbpf::XdpFlags; use bpf_examples::trace_http::{MapData, RequestInfo}; #[tokio::main] async fn main() -> Result<(), io::Error> { let args: Vec<String> = env::args().collect(); if args.len() != 3 { eprintln!("usage: bpf_example_program [NETWORK_INTERFACE] [FILENAME]"); return Err(io::Error::new(io::ErrorKind::Other, "invalid arguments")); } let interface = args[1].clone(); let file = args[2].clone(); let mut loader = Loader::new() .xdp(Some(interface), XdpFlags::default()) .load_file(&file.into()) .await .expect("error loading file"); tokio::spawn(async move { while let Some((_, events)) = loader.events.next().await { for event in events { let event = unsafe { &*(event.as_ptr() as *const MapData<RequestInfo>) }; let info = &event.data; let payload = String::from_utf8_lossy(event.payload()); let req_line = payload.split("\r\n").next().unwrap(); let ip = IpAddr::from(info.saddr.to_ne_bytes()); println!("{} - {}", ip, req_line); } } }); signal::ctrl_c().await }

Archivo: bpf_example_loader/src/main.rsbpf_example_loader/src/main.rs

Lo primero destacable es que el programa es async y usa async-await. Para cargar el código BPF, utiliza la API redbpf::load::Loader:

Code
let mut loader = Loader::new() .xdp(Some(interface), XdpFlags::default()) .load_file(&file.into()) .await

Loader es una API de alto nivel que utiliza la API de más bajo nivel Module y expone los eventos de los maps como un flujo unificado de Vec<Box<[u8]>>. La API Loader está disponible al compilar redbpf con la característica de cargo load habilitada.

Los eventos se procesan luego con:

Code
tokio::spawn(async move { while let Some((_, events)) = loader.events.next().await { for event in events { let event = unsafe { &*(event.as_ptr() as *const MapData<RequestInfo>) }; let info = &event.data; let payload = String::from_utf8_lossy(event.payload()); let req_line = payload.split("\r\n").next().unwrap(); let ip = IpAddr::from(info.saddr.to_ne_bytes()); println!("{} - {}", ip, req_line); } } });

Se lanza una nueva tarea para procesar el flujo loader.events. Cada elemento del flujo es un Vec de slices de bytes (los eventos se recuperan en lotes, de ahí el Vec). Cada slice de bytes es la representación en bytes de un MapData<RequestInfo> que insertamos en el map desde el espacio del kernel. Cada slice se convierte de nuevo a MapData<RequestInfo> para poder extraer el RequestInfo y el payload(). El resto del código extrae luego la línea de solicitud HTTP, convierte la dirección IP a un formato más fácil de manejar e imprime el registro de la solicitud.

Finalmente, la última línea es:

Code
signal::ctrl_c().await

Los programas XDP necesitan descargarse, algo que Loader hace en su implementación de Drop. Por eso interceptamos CTRL-C (SIGINT) y dejamos que el proceso termine de forma limpia.

He subido el paquete del cargador en https://github.com/alessandrod/bpf_example_loader. Si lo clonas junto a bpf_examples, lo compilas y luego lo ejecutas como root con:

Code
# cargo run -- en0 ../bpf_examples/target/release/bpf-programs/trace_http/trace_http.elf

Sustituye en0 por tu interfaz de red, realiza algunas solicitudes HTTP y deberías obtener algo como esto:

Code
Loaded: trace_http, XDP 60.242.81.10 - GET /where-has-the-time-gone? HTTP/1 60.242.81.10 - GET /you-will-be-missed-but-you-are HTTP/1 60.242.81.10 - GET /off-to-do-great-things HTTP/1

Notas finales y enlaces

Esta entrada terminó siendo más densa y larga de lo que esperaba, ¡así que gracias por seguir hasta aquí! Iba a mostrar un segundo tipo de programa —un kprobe—, pero supongo que ahora quieres volver a tu vida. No pasa nada, cubriré los kprobes en otra entrada. Mientras tanto, si quieres contactarme, no dudes en enviarme un correo electrónico o escribirme en twitter @alessandrod.

Si conseguí despertar tu interés en probar RedBPF, te recomiendo encarecidamente que te familiarices con cargo bpf, ya que simplifica enormemente las cosas. Ten en cuenta que RedBPF está evolucionando a un ritmo bastante rápido, así que espera algunas asperezas y, como siempre, los informes de errores y los parches son más que bienvenidos.

Y ahora esos enlaces que prometí al principio:

Red Sift
Red Sift