Skip to content

Scrivere codice BPF in Rust: una guida pratica

Rust porta memory safety e prestazioni allo sviluppo BPF. Ecco una panoramica pratica su come scrivere codice BPF in Rust partendo da zero.

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

Se ti occupi di system programming probabilmente hai sentito parlare spesso di BPF ultimamente. È una nuova tecnologia Linux molto in voga che permette di eseguire programmi forniti dall'utente all'interno del kernel. Viene utilizzata da Netflix, Facebook, Google, Cloudflare e molte altre aziende per implementare funzionalità come il load balancing ultra veloce, la mitigazione DDoS e il monitoraggio delle prestazioni.

Negli ultimi mesi ho lavorato con Red Sift su RedBPF, un toolkit BPF per Rust. Red Sift utilizza RedBPF per alimentare l'agente di monitoraggio della sicurezza InGRAINd. Peter ha recentemente scritto un post su RedBPF e InGRAINd, e ha condotto un workshop al RustFest di Barcellona. Da allora abbiamo continuato a migliorare RedBPF, correggendo bug, migliorando e aggiungendo nuove API, aggiungendo il supporto per i kernel di Google Kubernetes Engine e altro ancora. Abbiamo anche completato la modifica della licenza del progetto ad Apache2/MIT – lo schema di licenza utilizzato da molti dei crate più importanti nell'ecosistema Rust – il che si spera renderà ancora più semplice l'adozione di RedBPF.

In questo post entrerò nei dettagli su cosa sia RedBPF, quali siano i suoi componenti principali, e come si presenti l'intero processo di scrittura di un programma BPF.

Non è realmente necessario essere un esperto di BPF per leggere questo post, poiché nella prossima sezione fornirò una panoramica rapida e di altissimo livello sui concetti principali che devi conoscere per seguire il resto. Se invece vuoi approfondire, il libro di Brendan Gregg BPF Performance Tools è appena uscito ed è davvero eccellente. E se non sei un tipo da libri, fornirò anche altri link utili alla fine.

Il corso accelerato più rapido su BPF

BPF è una macchina virtuale che permette di eseguire programmi definiti dall'utente nel kernel quando si verificano determinati eventi su un sistema Linux. Ad esempio, se vuoi monitorare attività sospette sui file, registrare la latenza delle risposte di rete o persino tracciare applicazioni user space – puoi scrivere piccoli programmi BPF, richiedere che vengano collegati nel punto giusto del kernel e implementare la strumentazione necessaria.

La VM BPF utilizza un proprio set di istruzioni. Puoi scrivere direttamente il bytecode, ma solitamente si usa bpftrace oppure si scrive codice C e si compila con la BPF Compiler Collection (BCC).

bpftrace è uno strumento incredibile che ti permette di scrivere programmi BPF utilizzando un linguaggio ad hoc di alto livello. È eccellente per script brevi e strumentazione manuale, mentre BCC è più adatto per strumenti più complessi, o quando si integra con altre applicazioni e sistemi.

BCC sfrutta il supporto target BPF di LLVM. Ti permette di scrivere codice C che viene poi compilato con clang in bytecode BPF eseguibile dalla VM BPF nel kernel. Ci sono alcune restrizioni sul tipo di codice C che puoi scrivere – la più importante è che non puoi usare i loop – ma per il resto scrivere un programma BPF in C non è molto diverso dal targettare un'altra piattaforma embedded (leggermente bizzarra).

Poiché i programmi BPF vengono eseguiti nel kernel Linux, il bytecode BPF non può essere semplicemente eseguito così com'è, ma deve essere caricato nel kernel. La maggior parte delle applicazioni che usano BPF sono quindi divise in due parti: il codice BPF che viene eseguito nel kernel, e un processo user space incaricato di caricare il codice nel kernel e interagire con esso.

Schematicamente, il processo di sviluppo di un programma BPF può essere riassunto con i seguenti passaggi:

  1. Scrivere il codice BPF in C
  2. Compilare il codice per la VM BPF
  3. Scrivere un componente user space che carica l'output del passaggio 2 nella VM BPF
  4. Utilizzare l'API BPF per scambiare dati tra il componente user space e il codice BPF

RedBPF include API e strumenti per implementare tutti i passaggi sopra descritti, eccetto il passaggio 1). Con RedBPF, il passaggio 1 diventa:

  1. Scrivere il codice BPF in Rust

Ho tralasciato molti dettagli e semplificato eccessivamente alcune cose, ma se non sapevi nulla di BPF ora dovresti aver capito a sufficienza per seguire il resto. A seguire mostrerò esattamente come RedBPF può essere utilizzato per implementare i passaggi sopra descritti.

Quindi cos'è RedBPF?

RedBPF è una collezione di crate Rust. Include:

  • redbpf-macros e redbpf-probes: forniscono l'API BPF kernel space (passaggio 1)
  • redbpf: fornisce l'API BPF user space. In particolare, fornisce l'API per caricare il bytecode BPF (passaggio 3)
  • cargo-bpf: un plugin cargo che semplifica la creazione, la compilazione e il debug dei programmi BPF (passaggio 2)

Un semplice tracer HTTP (lato kernel)

Ora mostrerò un programma BPF molto semplice scritto in Rust utilizzando RedBPF, che può essere eseguito nel kernel. Traccia tutte le richieste HTTP in ingresso su una determinata interfaccia di rete utilizzando le API eXpress Data Path (XDP). I programmi XDP si collegano direttamente al driver della NIC (ma possono ricadere sull'esecuzione a un livello più alto), fornendo un accesso rapido e a basso overhead ai dati in ingresso prima che entrino nel resto dello stack di rete.

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 }

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

La prima cosa da notare è che il programma è un binario #![no_std] #![no_main]:

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

La VM BPF non supporta molte delle funzionalità richieste dalla stdlib e i programmi BPF vengono eseguiti in risposta a eventi designati, quindi non hanno un punto di ingresso main convenzionale. I binari prodotti non vengono eseguiti, ma vengono caricati nel kernel utilizzando l'API fornita da redbpf come vedremo più avanti.

La cosa successiva da notare è la chiamata alla macro program!():

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

I programmi BPF devono specificare con quale versione del kernel sono compatibili e sotto quale licenza sono distribuiti. 0xFFFFFFFE è un valore speciale che significa qualsiasi versione del kernel. La licenza deve essere dichiarata perché la VM renderà disponibili o meno alcune API in base a essa (se il programma non è GPL, non potrà utilizzare funzionalità GPL nel kernel). Oltre a permetterti di specificare versione e licenza, program!() genera anche del boilerplate globale necessario perché il programma venga compilato e caricato correttamente.

trace_http() è la funzione che analizza i dati di rete cercando richieste HTTP:

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

Come puoi vedere è annotata con la macro attributo #[xdp], che fa parte del crate redbpf-macros. #[xdp] fa diverse cose, ma come vedremo più avanti, viene utilizzata principalmente per segnalare al loader del bytecode in redbpf che la funzione è un programma XDP.

La funzione prende un XdpContext e restituisce un XdpAction. XdpContext fornisce un'abstraction di livello più alto sul puntatore xdp_md sottostante fornito dalla VM BPF ai programmi XDP. #[xdp] effettua la mappatura tra i due tipi in modo trasparente. Il valore di ritorno – XdpAction – può essere utilizzato per indicare cosa deve essere fatto con i dati attualmente ispezionati: se devono essere passati oltre nello stack di rete, scartati, reindirizzati a un'altra interfaccia, ecc.

La logica di parsing effettiva è piuttosto semplice: se il protocollo di trasporto è TCP e il payload sembra una richiesta HTTP, la richiesta viene inviata allo user space dove può essere analizzata. L'API BPF fornisce diverse strutture dati – chiamate map – che possono essere utilizzate per memorizzare e aggregare dati tra le invocazioni del programma e possono anche essere utilizzate per scambiare dati con lo user space. La map utilizzata dal nostro programma – una PerfMap – permette ai programmi BPF di memorizzare dati in memoria condivisa mappata con mmap() accessibile dallo user space.

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 variabile globale requests è la nostra PerfMap, che come puoi vedere è annotata con l'attributo #[map]. L'attributo viene utilizzato per assegnare un nome alla map e per far sì che venga collocata in una speciale sezione ELF chiamata maps/<name> (nel nostro caso maps/requests) del binario risultante, così che il loader user space possa trovarla e inizializzarla.

Per ogni richiesta HTTP creiamo una struct RequestInfo che contiene indirizzo di origine, indirizzo di destinazione, porta di origine e porta di destinazione della richiesta. Successivamente la inseriamo nella PerfMap avvolta in un valore MapData. MapData::with_payload() viene utilizzato per indicare al driver che vogliamo che data.len() byte del pacchetto corrente vengano inseriti nella map immediatamente dopo i dati RequestInfo. data.offset() viene utilizzato per indicare allo user space l'offset a cui iniziano i dati HTTP (dopo gli header Ethernet, IP e TCP).

Compilazione e debug del tracer HTTP

Se vuoi compilare il codice mostrato nella sezione precedente, e caricarlo come mostrato nella sezione seguente, puoi clonare il repository che include il codice sopra da http://github.com/alessandrod/bpf_examples.

Per compilare il codice, esegui cd nella cartella clonata ed esegui:

Code
$ cargo install cargo-bpf $ cargo bpf build

Se la compilazione riesce, verrà posizionato il programma BPF compilato in target/release/bpf-programs/trace_http/trace_http.elf. Per assicurarti che il programma si carichi e funzioni correttamente, puoi usare cargo bpf load (deve essere eseguito come root):

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

Sostituisci en0 con l'interfaccia di rete che vuoi tracciare. Poi se esegui un server HTTP su quell'interfaccia, e invii una richiesta HTTP, dovresti vedere qualcosa come:

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

Per ogni richiesta, cargo bpf emetterà -- Event: [MAP NAME] -- seguito da un dump esadecimale dei dati inviati dal codice del kernel.

Scrivere un loader user space personalizzato

cargo bpf load è piuttosto comodo durante lo sviluppo. Una volta che il codice BPF funziona però, probabilmente vorrai fare qualcosa di più utile con i dati che raccoglie. Per il nostro semplice esempio, supponiamo che per ogni richiesta vogliamo produrre una riga nel formato:

Code
1.2.3.4 - GET /foo HTTP/1

Dove il lato sinistro è l'indirizzo IP del client, e il lato destro è la riga della richiesta HTTP. Ecco un semplice programma che usa redbpf::load::Loader per farlo:

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 }

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

La prima cosa notevole è che il programma è asincrono e usa async-await. Per caricare il codice BPF, utilizza l'API redbpf::load::Loader:

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

Loader è un'API di alto livello che usa l'API Module di livello più basso ed esporta gli eventi dalle map come un unico stream di Vec<Box<[u8]>>. L'API Loader è disponibile quando si compila redbpf con la feature cargo load abilitata.

Gli eventi vengono poi elaborati 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); } } });

Viene generato un nuovo task per elaborare lo stream loader.events. Ogni elemento dello stream è un Vec di slice di byte (gli eventi vengono recuperati in batch, quindi un Vec). Ogni slice di byte è la rappresentazione in byte di un MapData<RequestInfo> che abbiamo inserito nella map dal kernel space. Ogni slice viene riconvertito in MapData<RequestInfo> così che RequestInfo e payload() possano essere estratti. Il codice rimanente estrae poi la riga della richiesta HTTP, converte l'indirizzo IP in qualcosa più facile da gestire, e stampa il log della richiesta.

Infine, l'ultima riga è:

Code
signal::ctrl_c().await

I programmi XDP devono essere scaricati, cosa che Loader fa nella sua implementazione di Drop. Intercettiamo quindi CTRL-C (SIGINT) e lasciamo che il processo termini in modo corretto.

Ho caricato il package del loader su https://github.com/alessandrod/bpf_example_loader. Se lo cloni accanto a bpf_examples, lo compili e poi lo esegui come root con:

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

Sostituisci en0 con la tua interfaccia di rete, esegui alcune richieste HTTP e dovresti ottenere qualcosa come:

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

Questo post è finito per essere più denso e lungo di quanto mi aspettassi, quindi grazie per essere rimasto con me! Avevo intenzione di mostrare un secondo tipo di programma – un kprobe – ma immagino che ora tu abbia una vita a cui tornare. Va bene, tratterò i kprobe in un altro post. Nel frattempo, se vuoi contattarmi, sentiti libero di scrivermi un'email o di scrivermi su twitter @alessandrod.

Se sono riuscito a farti appassionare a RedBPF, ti consiglio vivamente di familiarizzare con cargo bpf poiché semplifica notevolmente le cose. Ti chiedo di tenere presente che RedBPF si evolve a un ritmo piuttosto rapido, quindi aspettati alcune imperfezioni e, come sempre, segnalazioni di bug e patch sono più che benvenute!

E ora quei link che avevo promesso all'inizio:

Red Sift
Red Sift