All'inizio c'era la tecnologia chiamata BPF. Ne abbiamo parlato nel , articolo veterotestamentario di questo ciclo. Nel 2013, grazie agli sforzi di Alexei Starovoitov e Daniel Borkman, è stata sviluppata e integrata nel kernel di Linux una versione migliorata, ottimizzata per le moderne macchine a 64 bit. Questa nuova tecnologia ha portato per un breve periodo il nome di Internal BPF, poi è stata rinominata in Extended BPF e ora, dopo diversi anni, tutti la chiamano semplicemente BPF.
In sostanza, BPF consente di eseguire codice arbitrario fornito dall'utente nello spazio del kernel di Linux e la nuova architettura è stata così efficace che avremo bisogno di una decina di articoli per descrivere tutte le sue applicazioni. (L'unica cosa con cui gli sviluppatori non sono riusciti, come puoi vedere nell'immagine qua sotto, è stata la creazione di un logo decente.)
In questo articolo si descrive la struttura della macchina virtuale BPF, le interfacce del kernel per lavorare con BPF, gli strumenti di sviluppo e una breve panoramica delle funzionalità esistenti, cioè tutto ciò di cui avremo bisogno per un approfondimento delle applicazioni pratiche di BPF.
Sintesi dell'articolo
Iniziamo osservando l'architettura BPF dall'alto e evidenziando i principali componenti.
Dopo aver acquisito una visione generale dell'architettura, descriveremo la struttura della macchina virtuale BPF.
In questa sezione esamineremo più da vicino il ciclo di vita degli oggetti BPF — programmi e mappe.
Avendo già una certa comprensione del sistema, infine esamineremo come creare e gestire oggetti dallo spazio utente tramite una chiamata di sistema speciale — bpf(2).
Scrivere programmi utilizzando le chiamate di sistema è possibile, ovviamente. Ma è difficile. Per scenari più realistici, i programmatori del kernel hanno sviluppato la libreria libbpf. Creeremo uno scheletro semplice di un'applicazione BPF che utilizzeremo nei prossimi esempi.
Qui scopriremo come i programmi BPF possono accedere a funzioni di supporto del kernel, un utile strumento che, insieme alle mappe, espande notevolmente le capacità del nuovo BPF rispetto al classico.
A questo punto, sapremo abbastanza per capire come creare programmi che utilizzano le mappe. Daremo anche un'occhiata al potente verifier.
Una sezione di riferimento su come raccogliere le utilità e il kernel necessari per gli esperimenti.
Alla fine dell'articolo, coloro che arriveranno fino in fondo troveranno parole motivanti e una breve descrizione di ciò che ci sarà nei prossimi articoli. Elencheremo anche un certo numero di link per lo studio individuale per chi non ha voglia o possibilità di aspettare il seguito.
Introduzione all'architettura BPF
Prima di iniziare a esaminare l'architettura BPF, ci riferiremo ancora una volta a , sviluppato come risposta all'emergere delle macchine RISC e per affrontare il problema della filtraggio efficiente dei pacchetti. L'architettura si è rivelata così efficace che, nata nei turbolenti anni '90 con Berkeley UNIX, è stata portata sulla maggior parte dei sistemi operativi esistenti, è sopravvissuta fino ai frenetici anni '20 e continua a trovare nuove applicazioni.
Il nuovo BPF è stato sviluppato come risposta alla diffusione delle macchine a 64 bit, dei servizi cloud e delle crescenti esigenze di strumenti per la creazione di SDN (Software-defined networking). Creato da ingegneri di rete come una sostituzione avanzata del BPF classico, il nuovo BPF ha trovato applicazioni in tempi record nel difficile compito di tracciamento dei sistemi Linux; ora, a sei anni dalla sua apparizione, ci vorrà un intero, prossimo articolo solo per elencare i diversi tipi di programmi.
VeSòLêE KaRtiNki
Alla base, BPF è una macchina virtuale sandbox che consente di eseguire codice "arbitrario" nello spazio del kernel senza compromettere la sicurezza. I programmi BPF vengono creati nello spazio utente, caricati nel kernel e collegati a una fonte di eventi. Un evento può essere, ad esempio, la consegna di un pacchetto su un'interfaccia di rete, l'esecuzione di una funzione del kernel, e così via. Nel caso di un pacchetto, il programma BPF avrà accesso ai dati e ai metadati del pacchetto (in lettura e, forse, in scrittura, a seconda del tipo di programma); nel caso dell'esecuzione di una funzione del kernel, avrà accesso agli argomenti della funzione, compresi i puntatori alla memoria del kernel, e così via.
Esaminiamo questo processo più nel dettaglio. Iniziamo a parlare del primo aspetto che differenzia il nuovo BPF da quello classico, le cui applicazioni erano scritte in linguaggio assembly. Nella nuova versione, l'architettura è stata ampliata in modo da consentire la scrittura di programmi in linguaggi di alto livello, principalmente in C. A tal fine, è stato sviluppato un backend per llvm, in grado di generare bytecode per l'architettura BPF.

L'architettura BPF è stata progettata, in particolare, per funzionare in modo efficace sulle macchine moderne. Per farlo funzionare nella pratica, il bytecode BPF, dopo essere stato caricato nel kernel, viene tradotto in codice nativo tramite un componente chiamato JIT compiler (Just In Time). Inoltre, se ricordi, nel classico BPF il programma veniva caricato nel kernel e agganciato alla sorgente eventi in modo atomico — nel contesto di una singola chiamata di sistema. Nella nuova architettura, questo avviene in due fasi: prima il codice viene caricato nel kernel tramite una chiamata di sistema bpf(2), e poi, successivamente, tramite altri meccanismi, variabili in base al tipo di programma, il programma si aggancia (attaches) alla sorgente eventi.
Qui il lettore potrebbe porsi la domanda: ma si poteva fare? In che modo viene garantita la sicurezza di esecuzione di questo codice? La sicurezza di esecuzione è garantita dalla fase di caricamento dei programmi BPF chiamata verificador (in inglese questo passaggio è chiamato verifier e lo utilizzerò in seguito):

Verifier è un analizzatore statico che garantisce che un programma non interrompa il normale funzionamento del kernel. Questo, per la cronaca, non significa che il programma non possa intervenire nel funzionamento del sistema: i programmi BPF, a seconda del tipo, possono leggere e sovrascrivere aree di memoria del kernel, modificare i valori di ritorno delle funzioni, tranciare, completare, riscrivere e persino inoltrare pacchetti di rete. Il Verifier assicura che il kernel non crasherà a causa dell'esecuzione di un programma BPF e che un programma autorizzato a scrivere, come i dati di un pacchetto in uscita, non possa sovrascrivere la memoria del kernel al di fuori del pacchetto. Esploreremo il verifier in maggiore dettaglio nella sezione corrispondente, dopo aver introdotto tutti gli altri componenti del BPF.
Quindi, cosa abbiamo appreso fino a questo momento? L'utente scrive un programma in linguaggio C e lo carica nel kernel attraverso una chiamata di sistema. bpf(2), dove viene controllato dal verifier e tradotto in bytecode nativo. Successivamente, lo stesso o un altro utente collega il programma alla sorgente eventi e questo inizia a essere eseguito. La separazione tra caricamento e connessione è necessaria per diversi motivi. Innanzitutto, avviare il verifier è relativamente costoso e, caricando più volte lo stesso programma, sprechiamo tempo di elaborazione. In secondo luogo, il modo in cui il programma si collega dipende dal suo tipo e un'interfaccia 'universale', sviluppata un anno fa, potrebbe non essere adatta per nuovi tipi di programmi. (Sebbene ora, con l'architettura che diventa più matura, ci sia l'idea di unificare questa interfaccia a livello libbpf.)
Il lettore attento potrà notare che non abbiamo ancora finito con le immagini. È vero, quanto detto sopra non spiega come il BPF cambi in maniera sostanziale il contesto rispetto al BPF classico. Due innovazioni che espandono notevolmente i confini delle applicazioni sono la possibilità di utilizzare la memoria condivisa e le funzioni di supporto del kernel (kernel helpers). Nel BPF, la memoria condivisa è implementata attraverso quelle che vengono chiamate maps, strutture dati condivise dotate di un API specifico. Questo nome è stato probabilmente scelto perché il primo tipo di map ad essere introdotto è stata una tabella hash. Successivamente, sono apparse anche matrici, tabelle hash locali (per CPU) e matrici locali, alberi di ricerca, mappe contenenti puntatori a programmi BPF e molto altro. È interessante notare che i programmi BPF hanno acquisito la capacità di mantenere uno stato tra le chiamate e di condividerlo con altri programmi e con lo spazio utente.
L'accesso alle maps avviene dai processi utente tramite una chiamata di sistema bpf(2), e dai programmi BPF in esecuzione nel kernel tramite funzioni helper. Inoltre, gli helper non esistono solo per operare con le mappe, ma anche per accedere ad altre funzionalità del kernel. Ad esempio, i programmi BPF possono utilizzare funzioni helper per reindirizzare i pacchetti ad altre interfacce, generare eventi nella sottosistema perf, accedere alle strutture del kernel, e così via.

In sintesi, BPF consente di caricare codice dell'utente arbitrario, cioè approvato dal verifier, nello spazio del kernel. Questo codice può mantenere lo stato tra le chiamate e scambiare dati con lo spazio utente, oltre ad avere accesso ai sottosistemi del kernel autorizzati per questo tipo di programmi.
Questo somiglia già alle funzionalità fornite dai moduli del kernel, rispetto ai quali BPF ha alcuni vantaggi (certo, è possibile fare confronti solo tra applicazioni simili, come il tracciamento del sistema — non è possibile scrivere un driver arbitrario per BPF). Si può notare una soglia di ingresso più bassa (alcuni strumenti che utilizzano BPF non richiedono all'utente di avere competenze nella programmazione di kernel, e in generale, capacità di programmazione), la sicurezza a tempo di esecuzione (alzate la mano nei commenti se non avete mai compromesso il sistema mentre scrivevate o testavate i moduli), l'atomicità — durante il riavvio dei moduli ci sono tempi di inattività, mentre il sottosistema BPF garantisce che nessun evento venga perso (per essere equi, questo non è vero per tutti i tipi di programmi BPF).
La disponibilità di tali funzionalità rende BPF uno strumento universale per l'estensione del kernel, come dimostrato dalla pratica: sempre più tipi di programmi vengono aggiunti a BPF, un numero crescente di grandi aziende utilizza BPF sui server in produzione 24×7 e sempre più startup costruiscono il proprio business su soluzioni basate su BPF. BPF è utilizzato ovunque: nella protezione contro attacchi DDoS, nella creazione di SDN (ad esempio, implementazioni di reti per Kubernetes), come strumento principale per il tracciamento dei sistemi e raccolta statistiche, nei sistemi di rilevamento delle intrusioni e in sandbox e così via.
Concludiamo qui la parte introduttiva dell'articolo e analizziamo più dettagliatamente la macchina virtuale e l'ecosistema BPF.
Interludio: utilità
Per eseguire gli esempi delle sezioni successive, potresti aver bisogno di alcune utilità, almeno llvm/clang con supporto per bpf e bpftool. Nella sezione puoi leggere le istruzioni per la compilazione delle utilità e del tuo kernel. Questa sezione è stata posizionata qui per non interrompere il fluire del nostro discorso.
Registri e set di istruzioni della macchina virtuale BPF
L'architettura e il sistema di comandi BPF sono stati progettati tenendo conto che i programmi sarebbero stati scritti in linguaggio C e convertiti in codice nativo dopo il caricamento nel kernel. Pertanto, il numero di registri e il set di comandi sono stati scelti considerando la sovrapposizione, in senso matematico, delle capacità delle macchine moderne. Inoltre, i programmi erano soggetti a vari limiti; ad esempio, fino a tempi recenti non era possibile scrivere cicli e sottoprogrammi, e il numero di istruzioni era limitato a 4096 (ora, i programmi privilegiati possono caricare fino a un milione di istruzioni).
In BPF ci sono undici registri da 64 bit disponibili per l'utente r0—r10 e il contatore dei comandi (program counter). Il registro r10 contiene il puntatore allo stack (frame pointer) ed è disponibile solo in lettura. Durante l'esecuzione, i programmi hanno accesso a uno stack di 512 byte e a un numero illimitato di memoria condivisa sotto forma di maps.
I programmi BPF possono eseguire un insieme di funzioni ausiliarie (kernel helpers) specifico in base al tipo di programma e, da poco, anche funzioni normali. Ogni funzione chiamata può accettare fino a cinque argomenti passati nei registri. r1—r5, e il valore restituito viene passato a r0. Si garantisce che, dopo il ritorno dalla funzione, il contenuto dei registri r6—r9 non venga modificato.
Per una traduzione efficace dei programmi, i registri r0—r11 sono mappati in modo univoco sui registri reali tenendo conto delle peculiarità dell'ABI dell'architettura corrente. Ad esempio, per x86_64 i registri r1—r5, utilizzati per passare i parametri alle funzioni, sono mappati su rdi, rsi, rdx, rcx, r8, che sono utilizzati per passare i parametri nelle funzioni di x86_64. Ad esempio, il codice a sinistra viene tradotto nel codice a destra in questo modo:
1: (b7) r1 = 1 mov $0x1,%rdi
2: (b7) r2 = 2 mov $0x2,%rsi
3: (b7) r3 = 3 mov $0x3,%rdx
4: (b7) r4 = 4 mov $0x4,%rcx
5: (b7) r5 = 5 mov $0x5,%r8
6: (85) call pc+1 callq 0x0000000000001ee8Il registro r0 è anche utilizzato per restituire il risultato dell'esecuzione del programma, mentre nel registro r1 alla funzione viene passato un puntatore al contesto — a seconda del tipo di funzione, questo potrebbe essere, ad esempio, la struttura (per XDP) oppure la struttura (per diverse funzioni di rete) oppure la struttura (per diversi tipi di programmi di tracciamento) e così via.
Quindi, avevamo un insieme di registri, kernel helpers, stack, un puntatore al contesto e memoria condivisa sotto forma di maps. Non è che tutto ciò fosse categoricamente necessario durante il viaggio, ma…
Continuiamo con la descrizione e parliamo del sistema di istruzioni per lavorare con questi oggetti. Tutte () le istruzioni BPF hanno una dimensione fissa di 64 bit. Se guardi un'istruzione su una macchina Big Endian a 64 bit, vedrai
![]()
Qui Code — questa è la codifica dell'istruzione, Dst/Src — queste sono le codifiche del destinatario e della sorgente, rispettivamente, Off — uno spostamento firmato a 16 bit, e Imm — questo è un intero firmato a 32 bit, usato in alcune istruzioni (equivalente alla costante K di cBPF). La codifica Code ha uno dei due tipi:

Le classi di istruzioni 0, 1, 2, 3 definiscono comandi per lavorare con la memoria. Esse , BPF_LD, BPF_LDX, BPF_ST, BPF_STX, rispettivamente. Le classi 4, 7 (BPF_ALU, BPF_ALU64) costituiscono un insieme di istruzioni ALU. Le classi 5, 6 (BPF_JMP, BPF_JMP32) contengono istruzioni di transizione.
Il piano per approfondire il sistema di comandi BPF è il seguente: invece di elencare meticolosamente tutte le istruzioni e i loro parametri, analizzeremo un paio di esempi in questa sezione, che chiariranno come sono effettivamente strutturate le istruzioni e come disassemblare manualmente qualsiasi file binario per BPF. Per consolidare il materiale, incontreremo ulteriormente singole istruzioni nelle sezioni relative al Verificatore, al compilatore JIT, alla traslazione del BPF classico, così come nello studio delle maps, nella chiamata delle funzioni, ecc.
Quando parleremo delle singole istruzioni, ci riferiremo ai file del kernel e , dove sono definite le codifiche numeriche delle istruzioni BPF. Durante l'autoapprendimento dell'architettura e/o l'analisi dei binari, puoi trovare la semantica nelle seguenti fonti, ordinate per complessità: , , e, naturalmente, nei codici sorgente di Linux - verificatore, JIT, interprete BPF.
Esempio: disassemblare BPF a mente
Analizziamo un esempio in cui compileremo un programma readelf-example.c e vedremo il binario risultante. Scopriremo il contenuto originale readelf-example.c qui sotto, dopo aver ripristinato la sua logica dai codici binari:
$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Dump esadecimale della sezione '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................La prima colonna nell'output readelf è l'indentazione e quindi il nostro programma è composto da quattro istruzioni:
Codice Dest Src Off Imm
b7 0 0 0000 01000000
15 0 1 0100 00000000
b7 0 0 0000 02000000
95 0 0 0000 00000000I codici delle istruzioni sono uguali b7, 15, b7 e 95. Ricordiamo che i tre bit inferiori rappresentano la classe di istruzione. In questo caso, il quarto bit è vuoto per tutte le istruzioni, quindi le classi di istruzione sono uguali: rispettivamente 7, 5, 7, 5. La classe 7 è BPF_ALU64, e 5 è BPF_JMP. Per entrambe le classi il formato dell'istruzione è lo stesso (vedi sopra) e possiamo riscrivere il nostro programma così (peraltro, riscriveremo le altre colonne in forma leggibile):
Op S Classe Dest Src Off Imm
b 0 ALU64 0 0 0 1
1 0 JMP 0 1 1 0
b 0 ALU64 0 0 0 2
9 0 JMP 0 0 0 0Operazione b classe ALU64 — rappresenta . Essa assegna un valore al registro destinatario. Se il bit è impostato s (source), quindi il valore viene preso dal registro sorgente, e se, come nel nostro caso, non è impostato, il valore viene preso dal campo Imm. In questo modo, nella prima e nella terza istruzione eseguiamo l'operazione r0 = Imm. In seguito, l'operazione di classe 1 JMP è (salto se uguale). Nel nostro caso, poiché il bit S è uguale a zero, confronta il valore del registro sorgente con il campo Imm. Se i valori coincidono, il salto avviene a PC + Off, dove PC, come di consueto, contiene l'indirizzo della prossima istruzione. Infine, l'operazione di classe 9 JMP è . Questa istruzione termina l'esecuzione del programma, restituendo al kernel r0. Aggiungiamo una nuova colonna alla nostra tabella:
Op S Class Dst Src Off Imm Disassm
MOV 0 ALU64 0 0 0 1 r0 = 1
JEQ 0 JMP 0 1 1 0 if (r1 == 0) goto pc+1
MOV 0 ALU64 0 0 0 2 r0 = 2
EXIT 0 JMP 0 0 0 0 exitPossiamo riscriverlo in una forma più conveniente:
r0 = 1
if (r1 == 0) goto END
r0 = 2
END:
exitSe ricordiamo che nel registro r1 viene passato un puntatore al contesto dal kernel, e nel registro r0 ritorna un valore al kernel, possiamo vedere che se il puntatore al contesto è zero, restituiamo 1, altrimenti 2. Verifichiamo se abbiamo ragione, osservando il sorgente:
$ cat readelf-example.c
int foo(void *ctx)
{
return ctx ? 2 : 1;
}Sì, questo è un programma privo di senso, ma può essere tradotto in quattro semplici istruzioni.
Esempio di eccezione: istruzione a 16 byte
In precedenza abbiamo menzionato che alcune istruzioni occupano più di 64 bit. Questo si applica, ad esempio, all'istruzione lddw (Codice = 0x18 = | | ) — carica nel registro una parola doppia dai campi Imm. Infatti, Imm ha una dimensione di 32, mentre una parola doppia è di 64 bit, quindi non è possibile caricare un valore immediato a 64 bit in un'unica istruzione a 64 bit. A tale scopo si utilizzano due istruzioni adiacenti per memorizzare la seconda parte del valore a 64 bit nel campo Imm. Esempio:
$ cat x64.c
long foo(void *ctx)
{
return 0x11223344aabbccdd;
}
$ clang -target bpf -c x64.c -o x64.o -O2
$ llvm-readelf -x .text x64.o
Dump esadecimale della sezione '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000 ........Nel programma binario ci sono solo due istruzioni:
Binary Disassm
18000000 ddccbbaa 00000000 44332211 r0 = Imm[0]|Imm[1]
95000000 00000000 exitIncontreremo di nuovo l'istruzione lddw, quando parleremo di rilocazioni e dell'uso delle maps.
Esempio: disassemblare BPF con strumenti standard
Ora che abbiamo imparato a leggere i codici binari BPF, siamo pronti a analizzare qualsiasi istruzione, se necessario. Tuttavia, è importante dire che nella pratica è più comodo e veloce disassemblare i programmi utilizzando strumenti standard, ad esempio:
$ llvm-objdump -d x64.o
Disassembly of section .text:
0000000000000000 :
0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
2: 95 00 00 00 00 00 00 00 exitCiclo di vita degli oggetti BPF, filesystem bpffs
(Alcuni dettagli descritti in questa sezione li ho appresi per la prima volta da Alexei Starovoitov in .)
Gli oggetti BPF — programmi e mappe — vengono creati dallo spazio utente tramite i comandi BPF_PROG_LOAD e BPF_MAP_CREATE chiamata di sistema bpf(2), parleremo di come avviene esattamente questo nel prossimo capitolo. Durante questo processo vengono create strutture dati del kernel e per ciascuna di esse refcount (contatore di riferimenti) è impostato a uno, e all'utente viene restituito un descrittore di file che punta all'oggetto. Dopo la chiusura del descrittore refcount il contatore dell'oggetto diminuisce di uno, e una volta raggiunto zero l'oggetto viene distrutto.
Se il programma utilizza mappe, allora refcount il numero di queste mappe aumenta di uno dopo il caricamento del programma, cioè i loro descrittori di file possono essere chiusi dal processo utente e, allo stesso tempo, refcount non diventerà zero:

Dopo un caricamento riuscito del programma, di solito lo colleghiamo a qualche generatore di eventi. Ad esempio, possiamo collegarlo a un'interfaccia di rete per gestire i pacchetti in arrivo oppure integrarlo con un tracepoint nel kernel. A questo punto, anche il contatore dei riferimenti aumenterà di uno e potremo chiudere il descrittore di file nel programma di caricamento.
Cosa succederà se ora chiudiamo il caricatore? Dipende dal tipo di generatore di eventi (hook). Tutti i hook di rete esisteranno dopo la chiusura del caricatore, questi sono chiamati hook globali. Ad esempio, i programmi di tracing verranno liberati dopo il completamento del processo che li ha creati (e quindi sono chiamati locali, da "local to the process"). Tecnicamente, gli hook locali hanno sempre un corrispondente file descriptor nello spazio utente e quindi vengono chiusi con la chiusura del processo, mentre i globali non lo sono. Nella prossima illustrazione cerco di mostrare con delle crocette rosse come la chiusura del programma caricatore influisce sul ciclo di vita degli oggetti nel caso di hook locali e globali.

Perché esiste la distinzione tra hook locali e globali? L'esecuzione di alcuni tipi di programmi di rete ha senso anche senza userspace; ad esempio, immagina una protezione DDoS — il bootloader configura le regole e collega il programma BPF all'interfaccia di rete, dopo di che il bootloader può andare e terminare. D'altra parte, immagina di aver scritto un programma di debug per il tracing in dieci minuti — una volta completato, vorresti che nel sistema non rimanessero spazzatura, e gli hook locali lo garantiscono.
D'altra parte, immagina di voler collegarti a un tracepoint nel kernel e raccogliere statistiche per anni. In questo caso, vorresti completare la parte utente e tornare alle statistiche di tanto in tanto. Questa possibilità è offerta dal filesystem bpf. Si tratta di un filesystem pseudo, che esiste solo in memoria, che consente di creare file che si riferiscono a oggetti BPF e, in questo modo, aumentano refcount il numero di oggetti. Dopo di ciò, il bootloader può completare il lavoro e gli oggetti creati rimarranno attivi.

La creazione di file nel bpffs, che si riferiscono a oggetti BPF, è chiamata "pinning" ("pin", come nella frase seguente: "il processo può pin un programma o una mappa BPF"). Creare oggetti file per oggetti BPF ha senso non solo per estendere la vita degli oggetti locali, ma anche per facilitare l'uso di oggetti globali. Tornando all'esempio con il programma globale di protezione DDoS, vogliamo avere la possibilità di venire di tanto in tanto a controllare le statistiche.
Il file system BPF è solitamente montato in /sys/fs/bpf, ma può anche essere montato localmente, ad esempio in questo modo:
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpointI nomi nel file system vengono creati utilizzando il comando BPF_OBJ_PIN della chiamata di sistema BPF. A titolo esemplificativo, prendiamo un programma, compiliamolo, carichiamolo e pinniamolo in bpffs. Il nostro programma non fa nulla di utile, forniamo solo il suo codice in modo che tu possa riprodurre l'esempio:
$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
return 0;
}
char _license[] __attribute__((section("license"), used)) = "GPL";Compiliamo questo programma e creiamo una copia locale del file system bpffs:
$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpointOra caricheremo il nostro programma utilizzando l'utility bpftool e daremo un'occhiata alle relative chiamate di sistema bpf(2) (alcune righe irrilevanti sono state rimosse dall'output di strace):
$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0Qui abbiamo caricato il programma utilizzando BPF_PROG_LOAD, abbiamo ricevuto dal kernel un descrittore di file 3 e con il comando BPF_OBJ_PIN abbiamo pinnato questo descrittore di file come un file "bpf-mountpoint/test". Dopo di ciò l'applicazione di caricamento bpftool ha terminato il lavoro, ma il nostro programma è rimasto nel kernel, anche se non l'abbiamo collegato a nessuna interfaccia di rete:
$ sudo bpftool prog | tail -3
783: xdp name test tag 5c8ba0cf164cb46c gpl
loaded_at 2020-05-05T13:27:08+0000 uid 0
xlated 24B jited 41B memlock 4096BPossiamo rimuovere l'oggetto file tramite unlink(2) e dopo questo il programma corrispondente sarà rimosso:
$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directoryRimozione di oggetti
Parlando della rimozione degli oggetti, è importante precisare che una volta scollegato il programma dall'hook (generatore di eventi), nessun nuovo evento attiverà il suo avvio; tuttavia, tutte le istanze attuali del programma verranno terminate correttamente.
Alcuni tipi di programmi BPF permettono di sostituire il programma al volo, ovvero offrono l'atomicità della sequenza. replace = scollega il vecchio programma, attacca il nuovo programma. In questo caso, tutte le istanze attive della vecchia versione del programma termineranno la loro esecuzione, e i nuovi gestori di eventi saranno creati dalla nuova versione del programma, dove l'«atomicità» significa qui che nessun evento verrà trascurato.
Collegamento dei programmi alle fonti di eventi
In questo articolo non descriveremo separatamente il collegamento dei programmi alle fonti di eventi, poiché ha senso esaminarlo nel contesto di un tipo specifico di programma. Vedi qui sotto, dove mostriamo come si collegano i programmi di tipo XDP.
Gestione degli oggetti tramite la chiamata di sistema bpf
Programmi BPF
Tutti gli oggetti BPF vengono creati e gestiti dallo spazio utente tramite la chiamata di sistema bpf, che ha il seguente prototipo:
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);Qui il team cmd è uno dei valori del tipo , attr — puntatore ai parametri per un programma specifico e dimensione — dimensione dell'oggetto puntato, cioè di solito è sizeof(*attr). Nel kernel 5.8, la chiamata di sistema bpf supporta 34 comandi diversi, e union bpf_attr è lunga 200 righe. Ma non dobbiamo farci spaventare, poiché ci familiarizzeremo con comandi e parametri attraverso diversi articoli.
Iniziamo con il comando BPF_PROG_LOAD, che crea programmi BPF — prende un insieme di istruzioni BPF e lo carica nel kernel. Durante il caricamento, si attiva il verifier, quindi il compilatore JIT e, dopo un'esecuzione riuscita, viene restituito all'utente un descrittore di file del programma. Abbiamo visto cosa succede dopo nel precedente paragrafo .
Ora scriveremo un programma utente che caricherà un semplice programma BPF, ma prima dobbiamo decidere che tipo di programma vogliamo caricare — ci toccherà scegliere e all'interno di questo tipo scrivere un programma che superi il controllo del verifier. Tuttavia, per non complicare il processo, ecco una soluzione pronta: utilizzeremo un programma di tipo BPF_PROG_TYPE_XDP, che restituirà un valore XDP_PASS (salta tutti i pacchetti). In assembly BPF sembra molto semplice:
r0 = 2
exitDopo aver definito cosa cosa caricheremo, possiamo raccontare come faremo:
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>
static inline __u64 ptr_to_u64(const void *ptr)
{
return (__u64) (unsigned long) ptr;
}
int main(void)
{
struct bpf_insn insns[] = {
{
.code = BPF_ALU64 | BPF_MOV | BPF_K,
.dst_reg = BPF_REG_0,
.imm = XDP_PASS
},
{
.code = BPF_JMP | BPF_EXIT
},
};
union bpf_attr attr = {
.prog_type = BPF_PROG_TYPE_XDP,
.insns = ptr_to_u64(insns),
.insn_cnt = sizeof(insns)/sizeof(insns[0]),
.license = ptr_to_u64("GPL"),
};
strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
for ( ;; )
pause();
}Eventi interessanti nel programma iniziano con la definizione dell'array insns — il nostro programma BPF in codici macchina. Ogni istruzione del programma BPF viene impacchettata in una struttura . Il primo elemento insns corrisponde all'istruzione r0 = 2, il secondo — exit.
Un passo indietro. Nel kernel sono definiti macro più utili per scrivere codici macchina, e, utilizzando il file di intestazione del kernel tools/include/linux/filter.h potremmo scrivere
struct bpf_insn insns[] = {
BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
BPF_EXIT_INSN()
};Ma poiché scrivere programmi BPF in codici macchina è necessario solo per scrivere test nel kernel e articoli su BPF, l'assenza di queste macro in realtà non complica la vita dello sviluppatore.
Dopo aver definito il programma BPF, passiamo al suo caricamento nel kernel. Il nostro set minimalista di parametri attr include il tipo di programma, il set e il numero di istruzioni, la licenza obbligatoria, e il nome "woo", che usiamo per trovare il nostro programma nel sistema dopo il caricamento. Il programma, come promesso, viene caricato nel sistema tramite una chiamata di sistema. bpf.
Alla fine del programma ci troviamo in un ciclo infinito che simula un carico utile. Senza di esso, il programma verrà terminato dal kernel quando si chiude il descrittore di file restituito dalla chiamata di sistema. bpf, e non lo vedremo nel sistema.
Bene, siamo pronti per il test. Compiliamo e avviamo il programma sotto strace, per verificare che tutto funzioni come previsto:
$ clang -g -O2 simple-prog.c -o simple-prog
$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(Tutto va bene, bpf(2) ci ha restituito il descrittore 3 e ci siamo avventurati in un ciclo infinito con pause(). Proviamo a trovare il nostro programma nel sistema. Per fare questo, andremo in un altro terminale e utilizzeremo l'utility bpftool:
# bpftool prog | grep -A3 woo
390: xdp name woo tag 3b185187f1855c4c gpl
loaded_at 2020-08-31T24:66:44+0000 uid 0
xlated 16B jited 40B memlock 4096B
pids simple-prog(10381)Vediamo che nel sistema è presente un programma caricato woo il cui ID globale è 390, e attualmente è in esecuzione nel processo simple-prog c'è un descrittore di file aperto che punta a un programma (e se simple-prog termina, allora woo scomparirà). Come previsto, il programma woo occupa 16 byte — due istruzioni — codici binari nell'architettura BPF, ma in forma nativa (x86_64) è già 40 byte. Diamo un'occhiata al nostro programma nella sua forma originale:
# bpftool prog dump xlated id 390
0: (b7) r0 = 2
1: (95) exitsenza sorprese. Ora diamo un'occhiata al codice generato dal compilatore JIT:
# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
0: nopl 0x0(%rax,%rax,1)
5: push %rbp
6: mov %rsp,%rbp
9: sub $0x0,%rsp
10: push %rbx
11: push %r13
13: push %r14
15: push %r15
17: pushq $0x0
19: mov $0x2,%eax
1e: pop %rbx
1f: pop %r15
21: pop %r14
23: pop %r13
25: pop %rbx
26: leaveq
27: retqnon è molto efficiente per exit(2), ma per essere giusti, il nostro programma è troppo semplice, e per programmi non triviali, il prologo e l'epilogo, aggiunti dal compilatore JIT, sono ovviamente necessari.
Maps
I programmi BPF possono utilizzare aree di memoria strutturate, accessibili sia ad altri programmi BPF che a programmi nello spazio utente. Questi oggetti sono chiamati maps e in questa sezione mostreremo come gestirli tramite chiamate di sistema. bpf.
Dobbiamo dire subito che le possibilità delle mappe non si limitano solo all'accesso alla memoria condivisa. Esistono mappe specializzate, contenenti, ad esempio, puntatori a programmi BPF o puntatori a interfacce di rete, mappe per lavorare con eventi di perf e così via. Qui non ne parleremo per non confondere il lettore. Inoltre, ignoriamo i problemi di sincronizzazione, poiché non sono importanti per i nostri esempi. Un elenco completo dei tipi di mappe disponibili può essere trovato in , e in questa sezione prenderemo come esempio il primo tipo storico, una tabella hash. BPF_MAP_TYPE_HASH.
Se stai creando una tabella hash, diciamo, in C++, dirai unordered_map woo, che in italiano significa «ho bisogno di una tabella woo di dimensioni illimitate, con chiavi di tipo -int, e valori di tipo long». Per creare una tabella hash BPF dobbiamo fare più o meno la stessa cosa, tenendo presente che dovremo specificare la dimensione massima della tabella, e invece dei tipi di chiavi e valori dobbiamo indicarne le dimensioni in byte. Per creare mappe si utilizza il comando BPF_MAP_CREATE chiamata di sistema bpf. Vediamo un programma minimo che crea una mappa. Dopo il programma precedente che caricava i programmi BPF, questo dovrebbe sembrarti semplice:
$ cat simple-map.c
#define _GNU_SOURCE
#include
#include
#include
#include
int main(void)
{
union bpf_attr attr = {
.map_type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 4,
};
strncpy(attr.map_name, "woo", sizeof(attr.map_name));
syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
for ( ;; )
pause();
}Qui definiamo un insieme di parametri attr, in cui diciamo «ho bisogno di una tabella hash con chiavi e valori di dimensione sizeof(int), in cui posso inserire al massimo quattro elementi». Durante la creazione delle mappe BPF, è possibile specificare altri parametri, ad esempio, come nel caso del programma, abbiamo specificato il nome dell'oggetto come "woo".
Compiliamo e avviamo il programma:
$ clang -g -O2 simple-map.c -o simple-map
$ sudo strace ./simple-map
execve("./simple-map", ["./simple-map"], 0x7ffd40a27070 /* 14 vars */) = 0
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=4, max_entries=4, map_name="woo", ...}, 72) = 3
pause(Qui la chiamata di sistema bpf(2) ci ha restituito il descrittore della mappa numero 3 e poi il programma, come previsto, attende ulteriori istruzioni nella chiamata di sistema pause(2).
Ora invieremo il nostro programma in background o apriremo un altro terminale per esaminare il nostro oggetto utilizzando l'utilità bpftool (possiamo distinguere la nostra mappa dalle altre dal suo nome):
$ sudo bpftool map
...
114: hash nome woo flags 0x0
chiave 4B valore 4B max_entries 4 memlock 4096B
...Il numero 114 è l'ID globale del nostro oggetto. Qualsiasi programma nel sistema può utilizzare questo ID per aprire una mappa già esistente con il comando BPF_MAP_GET_FD_BY_ID chiamata di sistema bpf.
Adesso possiamo giocare con la nostra tabella hash. Diamo un'occhiata al suo contenuto:
$ sudo bpftool map dump id 114
Trovati 0 elementiVuota. Mettiamoci un valore hash[1] = 1:
$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0Rivediamo la tabella:
$ sudo bpftool map dump id 114
chiave: 01 00 00 00 valore: 01 00 00 00
Trovato 1 elementoEvviva! Siamo riusciti ad aggiungere un elemento. Notate che per questo dobbiamo lavorare a livello di byte, poiché bpftool non conosce il tipo di valori nella tabella hash. (Può essere fornita questa informazione, utilizzando BTF, ma non è il momento di parlarne.)
Come legge e aggiunge elementi bpftool? Diamo un'occhiata sotto il cofano:
$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
chiave: 01 00 00 00 valore: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENTInnanzitutto, abbiamo aperto la mappa utilizzando il suo ID globale tramite il comando BPF_MAP_GET_FD_BY_ID e bpf(2) ha restituito il descrittore 3. Poi, utilizzando il comando BPF_MAP_GET_NEXT_KEY abbiamo trovato la prima chiave nella tabella, passando NULL come puntatore alla «chiave precedente». Se abbiamo una chiave, possiamo fare BPF_MAP_LOOKUP_ELEM, che restituisce il valore nel puntatore value. La fase successiva è tentare di trovare il prossimo elemento, passando il puntatore alla chiave corrente, ma la nostra tabella contiene solo un elemento e il comando BPF_MAP_GET_NEXT_KEY restituisce ENOENT.
Bene, cambiamo il valore per la chiave 1, diciamo che la nostra logica di business richiede di scrivere hash[1] = 2:
$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0Come previsto, è molto semplice: il comando BPF_MAP_GET_FD_BY_ID apre la nostra mappa tramite l'ID, e il comando BPF_MAP_UPDATE_ELEM riscrive l'elemento.
In conclusione, dopo aver creato una tabella hash da un programma, possiamo leggere e scrivere il suo contenuto da un altro. Notate che se siamo riusciti a farlo dalla riga di comando, anche qualsiasi altro programma nel sistema può farlo. Oltre ai comandi sopra descritti, per lavorare con le mappe dallo spazio utente sono disponibili :
BPF_MAP_LOOKUP_ELEM: trovare un valore per chiaveBPF_MAP_UPDATE_ELEM: aggiornare/creare un valoreBPF_MAP_DELETE_ELEM: eliminare la chiaveBPF_MAP_GET_NEXT_KEY: trovare la chiave successiva (o la prima)BPF_MAP_GET_NEXT_ID: consente di scorrere tutte le mappe esistenti, funziona cosìbpftool mapBPF_MAP_GET_FD_BY_ID: aprire una mappa esistente tramite il suo ID globaleBPF_MAP_LOOKUP_AND_DELETE_ELEM: aggiornare atomarmente il valore dell'oggetto e restituire quello precedenteBPF_MAP_FREEZE: rendere la mappa immutabile dallo userspace (questa operazione non può essere annullata)BPF_MAP_LOOKUP_BATCH,BPF_MAP_LOOKUP_AND_DELETE_BATCH,BPF_MAP_UPDATE_BATCH,BPF_MAP_DELETE_BATCH: operazioni di massa. Ad esempio,BPF_MAP_LOOKUP_AND_DELETE_BATCH— è l'unico modo affidabile per leggere e azzerare tutti i valori da una mappa
Non tutti questi comandi funzionano per tutti i tipi di mappe, ma in generale lavorare con altri tipi di mappe dallo spazio utente sembra esattamente come lavorare con le tabelle hash.
Per ordine, completiamo i nostri esperimenti con la tabella hash. Ricordate che abbiamo creato una tabella che può contenere fino a quattro chiavi? Aggiungiamo altri elementi:
$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0Finora tutto bene:
$ sudo bpftool map dump id 114
key: 01 00 00 00 value: 01 00 00 00
key: 02 00 00 00 value: 01 00 00 00
key: 04 00 00 00 value: 01 00 00 00
key: 03 00 00 00 value: 01 00 00 00
Trovati 4 elementiProviamo ad aggiungerne un altro:
$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Errore: aggiornamento fallito: la lista degli argomenti è troppo lungaCome previsto, non ci siamo riusciti. Diamo un'occhiata all'errore più da vicino:
$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (la lista degli argomenti è troppo lunga)
Errore: aggiornamento fallito: la lista degli argomenti è troppo lunga
+++ uscito con 255 +++Tutto a posto: come previsto, il comando BPF_MAP_UPDATE_ELEM cerca di creare una nuova, quinta chiave, ma fallisce con E2BIG.
Quindi, sappiamo come creare e caricare programmi BPF, nonché come creare e gestire le mappe dallo spazio utente. Ora sarebbe logico vedere come possiamo usare le mappe all'interno dei programmi BPF stessi. Potremmo parlare di questo usando un linguaggio di programmazione difficile da leggere con macro codice macchina, ma in realtà è giunto il momento di mostrare come vengono scritti e mantenuti i programmi BPF realmente — con libbpf.
(Per i lettori insoddisfatti dalla mancanza di un esempio a basso livello: analizzeremo nel dettaglio i programmi che utilizzano mappe e le funzioni di supporto create con libbpf e spiegheremo cosa succede a livello di istruzioni. Per i lettori insoddisfatti moltissimo, abbiamo aggiunto nel posto giusto dell'articolo.)
Scriviamo programmi BPF utilizzando libbpf.
Scrivere programmi BPF utilizzando codici macchina può essere interessante solo per un breve periodo, dopodiché si verifica una certa saturazione. A questo punto, è necessario rivolgere l'attenzione a llvm, che offre un backend per la generazione di codice per l'architettura BPF, e alla libreria libbpf, che consente di scrivere la parte utente delle applicazioni BPF e caricare codice BPF generato con llvm/clang.
In realtà, come vedremo in questo e nei successivi articoli, libbpf fa abbastanza lavoro anche senza di essa (o strumenti simili — iproute2, libbcc, libbpf-go, ecc.) è impossibile vivere. Una delle killer feature del progetto libbpf è BPF CO-RE (Compile Once, Run Everywhere) — un progetto che consente di scrivere programmi BPF portabili da un kernel all'altro, con la possibilità di eseguirli su diverse API (ad esempio, quando la struttura del kernel cambia da una versione all'altra). Per poter lavorare con CO-RE, il vostro kernel deve essere compilato con il supporto BTF (come faremo a spiegare nella sezione . Verificare se il vostro kernel è stato compilato con BTF è molto semplice — basta controllare la presenza del seguente file:
$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinuxQuesto file contiene informazioni su tutti i tipi di dati utilizzati nel kernel ed è utilizzato in tutti i nostri esempi che utilizzano libbpf. Parleremo in dettaglio di CO-RE nell'articolo successivo, mentre in questo — costruite semplicemente il vostro kernel con CONFIG_DEBUG_INFO_BTF.
Libreria libbpf è situato direttamente nella directory tools/lib/bpf del kernel e il suo sviluppo è gestito tramite la mailing list bpf@vger.kernel.org. Tuttavia, per le esigenze delle applicazioni che operano al di fuori del kernel, è supportato un repository separato nel quale la libreria del kernel è mirrorata per l'accesso in lettura più o meno così com'è.
In questa sezione vedremo come creare un progetto che utilizza libbpf, scriveremo alcuni (piuttosto insignificanti) programmi di test e analizzeremo in dettaglio come funziona tutto questo. Ciò ci consentirà di spiegare più facilmente, nelle sezioni successive, come i programmi BPF interagiscono con maps, kernel helpers, BTF, ecc.
Di solito, i progetti che utilizzano libbpf aggiungono il repository di GitHub come git submodule, facciamolo anche noi:
$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.Si costruisce libbpf molto facilmente:
$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pcIl nostro piano successivo in questa sezione è il seguente: scriveremo un programma BPF di tipo BPF_PROG_TYPE_XDP, lo stesso dell'esempio precedente, ma in C, lo compileremo tramite clang, e scriveremo un programma di supporto che lo caricherà nel kernel. Nelle prossime sezioni amplieremo le funzionalità sia del programma BPF che del programma di supporto.
Esempio: creiamo un'applicazione completa utilizzando libbpf
Iniziamo utilizzando il file /sys/kernel/btf/vmlinux, menzionato in precedenza, e creeremo il suo equivalente in forma di file di intestazione:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.hIn questo file verranno memorizzate tutte le strutture dati presenti nel nostro kernel, ad esempio, in questo modo viene definita l'intestazione IPv4 nel kernel:
$ grep -A 12 'struct iphdr {' vmlinux.h
struct iphdr {
__u8 ihl: 4;
__u8 version: 4;
__u8 tos;
__be16 tot_len;
__be16 id;
__be16 frag_off;
__u8 ttl;
__u8 protocol;
__sum16 check;
__be32 saddr;
__be32 daddr;
};Ora scriviamo il nostro programma BPF in linguaggio C:
$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include
SEC("xdp/simple")
int simple(void *ctx)
{
return XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";Anche se il nostro programma è molto semplice, dobbiamo comunque prestare attenzione a molti dettagli. Innanzitutto, il primo file di intestazione che includiamo è vmlinux.h, che abbiamo appena generato utilizzando bpftool btf dump — ora non abbiamo bisogno di installare il pacchetto kernel-headers per sapere come sono fatte le strutture del kernel. Il successivo file di intestazione proviene dalla libreria libbpf. Ora è necessario solo per definire la macro SEC, che invia il simbolo nella sezione appropriata del file oggetto ELF. Il nostro programma è contenuto nella sezione xdp/simple, dove prima dello slash definiamo il tipo di programma BPF — questa è una convenzione usata in libbpf, in base al nome della sezione, verrà inserito il tipo corretto all'esecuzione bpf(2). Il programma BPF su C è molto semplice e consiste in una sola riga return XDP_PASS. Infine, una sezione separata "license" contiene il nome della licenza.
Possiamo compilare il nostro programma utilizzando llvm/clang, versione >= 10.0.0, o meglio — più recente (vedere la sezione ):
$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...
$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.oTra le caratteristiche interessanti: specifichiamo l'architettura di destinazione -target bpf e il percorso delle intestazioni libbpf, che abbiamo recentemente installato. Inoltre, non dimenticate di -O2, senza questa opzione potreste avere sorprese in seguito. Diamo un'occhiata al nostro codice, siamo riusciti a scrivere il programma che volevamo?
$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o
xdp-simple.bpf.o: file format elf64-bpf
Disassembly of section xdp/simple:
0000000000000000 <simple>:
0: r0 = 2
1: exitSì, ci siamo riusciti! Ora abbiamo un file binario con il programma, e vogliamo creare un'applicazione che lo carichi nel kernel. A questo scopo, la libreria libbpf ci offre due opzioni: utilizzare un'API a basso livello o un'API ad alto livello. Opteremo per la seconda, poiché vogliamo imparare a scrivere, caricare e collegare programmi BPF con il minimo sforzo per il loro successivo studio.
Per iniziare, dobbiamo generare lo "scheletro" del nostro programma dal suo binario usando lo stesso strumento bpftool — il coltellino svizzero del mondo BPF (che si può intendere anche letteralmente, dato che Daniel Borkman — uno dei creatori e manutentori del BPF — è svizzero):
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.hNel file xdp-simple.skel.h contiene il codice binario del nostro programma e le funzioni per gestirlo — caricare, collegare e rimuovere il nostro oggetto. Nel nostro semplice caso sembra eccessivo, ma funziona anche nel caso in cui il file oggetto contenga molteplici programmi BPF e mappe; per caricare questo gigantesco ELF ci basta generare lo scheletro e chiamare una o due funzioni dall'applicazione utente, alla quale ci avvicineremo ora.
In effetti, il nostro programma di caricamento è banale:
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"
int main(int argc, char **argv)
{
struct xdp_simple_bpf *obj;
obj = xdp_simple_bpf__open_and_load();
if (!obj)
err(1, "failed to open and/or load BPF objectn");
pause();
xdp_simple_bpf__destroy(obj);
}Qui struct xdp_simple_bpf è definito nel file xdp-simple.skel.h e descrive il nostro file oggetto:
struct xdp_simple_bpf {
struct bpf_object_skeleton *skeleton;
struct bpf_object *obj;
struct {
struct bpf_program *simple;
} progs;
struct {
struct bpf_link *simple;
} links;
};Possiamo notare qui tracce di API a basso livello: la struttura struct bpf_program *simple e struct bpf_link *simple. La prima struttura descrive specificamente il nostro programma, registrato nella sezione xdp/simple, mentre la seconda descrive come il programma si connette alla fonte di eventi.
Funzione xdp_simple_bpf__open_and_load, apre il file ELF, lo analizza, crea tutte le strutture e le sotto-strutture (oltre al programma, nel ELF ci sono anche altre sezioni — data, readonly data, informazioni di debug, licenza, ecc.), e poi lo carica nel kernel tramite una chiamata di sistema bpf, che possiamo verificare compilando e avviando il programma:
$ clang -O2 -I ./libbpf/src/root/usr/include/ xdp-simple.c -o xdp-simple ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_BTF_LOAD, 0x7ffdb8fd9670, 120) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0xdfd580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 8, 0), prog_flags=0, prog_name="simple", prog_ifindex=0, expected_attach_type=0x25 /* BPF_??? */, ...}, 120) = 4Adesso diamo un'occhiata al nostro programma con bpftool. Troviamo il suo ID:
# bpftool p | grep -A4 simple
463: xdp name simple tag 3b185187f1855c4c gpl
loaded_at 2020-08-01T01:59:49+0000 uid 0
xlated 16B jited 40B memlock 4096B
btf_id 185
pids xdp-simple(16498)e facciamo un dump (utilizziamo la forma abbreviata del comando bpftool prog dump xlated):
# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
0: (b7) r0 = 2
1: (95) exitQualcosa di nuovo! Il programma ha stampato parti del nostro file sorgente in linguaggio C. Questo è stato fatto dalla libreria libbpf, che ha trovato la sezione di debug nel binario, l'ha compilata in un oggetto BTF, l'ha caricata nel kernel utilizzando BPF_BTF_LOAD, e poi ha passato il file descriptor ottenuto durante il caricamento del programma con il comando BPG_PROG_LOAD.
Kernel Helpers
I programmi BPF possono eseguire funzioni "esterne" — kernel helpers. Queste funzioni di supporto consentono ai programmi BPF di accedere alle strutture del kernel, gestire le mappe e comunicare con il "mondo reale" - generare eventi di perf, gestire l'hardware (ad esempio, reindirizzare pacchetti) e così via.
Esempio: bpf_get_smp_processor_id
Nell'ambito del paradigma "impariamo con gli esempi", consideriamo una delle funzioni di supporto, bpf_get_smp_processor_id(), nel file kernel/bpf/helpers.c. Restituisce il numero del processore su cui viene eseguito il programma BPF che l'ha chiamata. Ma non ci interessa tanto la sua semantica, quanto il fatto che la sua implementazione richiede una sola riga:
BPF_CALL_0(bpf_get_smp_processor_id)
{
return smp_processor_id();
}Le definizioni delle funzioni ausiliarie BPF sono simili a quelle delle chiamate di sistema Linux. Qui, ad esempio, viene definita una funzione che non ha argomenti. (Una funzione che accetta, ad esempio, tre argomenti è definita tramite una macro. BPF_CALL_3. Il numero massimo di argomenti è cinque.) Tuttavia, questa è solo la prima parte della definizione. La seconda parte riguarda la definizione della struttura di tipo struct bpf_func_proto, che contiene una descrizione della funzione ausiliaria comprensibile dal verifier:
const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
.func = bpf_get_smp_processor_id,
.gpl_only = false,
.ret_type = RET_INTEGER,
};Registrazione delle funzioni ausiliarie
Affinché i programmi BPF di un certo tipo possano utilizzare questa funzione, devono registrarla, ad esempio, per il tipo BPF_PROG_TYPE_XDP nel kernel viene definita la funzione xdp_func_proto, che, in base all'ID della funzione ausiliaria, determina se XDP supporta o meno questa funzione. La nostra funzione :
static const struct bpf_func_proto *
xdp_func_proto(enum bpf_func_id func_id, const struct bpf_prog *prog)
{
switch (func_id) {
...
case BPF_FUNC_get_smp_processor_id:
return &bpf_get_smp_processor_id_proto;
...
}
}Nuovi tipi di programmi BPF vengono "definiti" nel file utilizzando la macro BPF_PROG_TYPE. Viene definito tra virgolette, poiché si tratta di una definizione logica, mentre nei termini del linguaggio C la definizione di un intero insieme di strutture specifiche avviene in altre sedi. In particolare, nel file kernel/bpf/verifier.c tutte le definizioni del file bpf_types.h vengono utilizzate per creare un array di strutture bpf_verifier_ops[]:
static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type)
[_id] = &_name ## _verifier_ops,
#include
#undef BPF_PROG_TYPE
};Cioè, per ogni tipo di programma BPF viene definito un puntatore alla struttura dati di tipo struct bpf_verifier_ops, che viene inizializzato con il valore _name ## _verifier_ops, ossia, xdp_verifier_ops per xdp. La struttura xdp_verifier_ops nel file net/core/filter.c nel seguente modo:
const struct bpf_verifier_ops xdp_verifier_ops = {
.get_func_proto = xdp_func_proto,
.is_valid_access = xdp_is_valid_access,
.convert_ctx_access = xdp_convert_ctx_access,
.gen_prologue = bpf_noop_prologue,
};Qui vediamo la nostra amata funzione xdp_func_proto, che verrà eseguita dal verifier ogni volta che incontra una chiamata a qualche funzione all'interno del programma BPF, vedi. .
Diamo un'occhiata a come un programma BPF ipotetico utilizza la funzione bpf_get_smp_processor_id. Per fare questo, riscriveremo il programma della nostra sezione precedente come segue:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
SEC("xdp/simple")
int simple(void *ctx)
{
if (bpf_get_smp_processor_id() != 0)
return XDP_DROP;
return XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";Simbolo bpf_get_smp_processor_id in <bpf/bpf_helper_defs.h> della libreria libbpf ZFS archivia i dati su disco.
static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;ovvero, bpf_get_smp_processor_id — è un puntatore a una funzione, il cui valore è 8, dove 8 è il valore BPF_FUNC_get_smp_processor_id di tipo enum bpf_fun_id, che è definito per noi nel file vmlinux.h (file bpf_helper_defs.h nel kernel è generato da uno script, quindi i numeri «magici» sono accettabili). Questa funzione non prende argomenti e restituisce un valore di tipo __u32. Quando lo avviamo nel nostro programma, clang genera un'istruzione BPF_CALL «di forma corretta». Compiliamo il programma e diamo un'occhiata alla sezione xdp/simple:
$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ llvm-objdump -D --section=xdp/simple xdp-simple.bpf.o
xdp-simple.bpf.o: file format elf64-bpf
Disassembly of section xdp/simple:
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: bf 01 00 00 00 00 00 00 r1 = r0
2: 67 01 00 00 20 00 00 00 r1 <>= 32
4: b7 00 00 00 02 00 00 00 r0 = 2
5: 15 01 01 00 00 00 00 00 if r1 == 0 goto +1
6: b7 00 00 00 01 00 00 00 r0 = 1
0000000000000038 :
7: 95 00 00 00 00 00 00 00 exitNella prima riga vediamo l'istruzione call, parametro IMM che è uguale a 8, e SRC_REG — zero. Secondo la convenzione ABI utilizzata dal verifier, questa è la chiamata alla funzione helper numero otto. Dopo che è stata eseguita, la logica è semplice. Il valore restituito dal registro r0 viene copiato in r1 e alle righe 2 e 3 viene convertito in u32 — i 32 bit superiori vengono azzerati. Nelle righe 4, 5, 6 e 7 restituiamo 2 (XDP_PASS) o 1 (XDP_DROP) a seconda che la funzione helper alla riga 0 abbia restituito un valore zero o non zero.
Controlliamo: carichiamo il programma e osserviamo l'output bpftool prog dump xlated:
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914
$ sudo bpftool p | grep simple
523: xdp name simple tag 44c38a10c657e1b0 gpl
pids xdp-simple(10915)
$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
0: (85) call bpf_get_smp_processor_id#114128
1: (bf) r1 = r0
2: (67) r1 <>= 32
4: (b7) r0 = 2
; }
5: (15) if r1 == 0x0 goto pc+1
6: (b7) r0 = 1
7: (95) exitBene, il verifier ha trovato il kernel-helper corretto.
Esempio: passiamo gli argomenti e infine eseguiamo il programma!
Tutte le funzioni helper in fase di esecuzione hanno il prototipo
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)I parametri vengono passati alle funzioni helper nei registri r1—r5, mentre il valore viene restituito nel registro r0Non ci sono funzioni che accettano più di cinque argomenti e non si prevede di aggiungere supporto in futuro.
Esaminiamo il nuovo kernel helper e come BPF passa i parametri. Riscriviamo xdp-simple.bpf.c nel seguente modo (le altre righe non sono state modificate):
SEC("xdp/simple")
int simple(void *ctx)
{
bpf_printk("in esecuzione su CPU%un", bpf_get_smp_processor_id());
return XDP_PASS;
}Il nostro programma stampa il numero della CPU su cui è in esecuzione. Compiliamolo e diamo un'occhiata al codice:
$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o
0000000000000000 :
0: r1 = 10
1: *(u16 *)(r10 - 8) = r1
2: r1 = 8441246879787806319 ll
4: *(u64 *)(r10 - 16) = r1
5: r1 = 2334956330918245746 ll
7: *(u64 *)(r10 - 24) = r1
8: call 8
9: r1 = r10
10: r1 += -24
11: r2 = 18
12: r3 = r0
13: call 6
14: r0 = 2
15: exitNelle righe 0-7 stiamo scrivendo nello stack la stringa in esecuzione su CPU%un, e poi alla riga 8 avviamo il familiare bpf_get_smp_processor_id. Nelle righe 9-12 prepariamo gli argomenti del helper bpf_printk — registri r1, r2, r3. Perché sono tre e non due? Perché bpf_printk — attorno al vero helper bpf_trace_printk, che richiede di passare la dimensione della stringa di formato.
Aggiungiamo ora un paio di righe a xdp-simple.c, per far sì che il nostro programma si colleghi all'interfaccia lo e venga avviato realmente!
$ cat xdp-simple.c
#include
#include
#include
#include "xdp-simple.skel.h"
int main(int argc, char **argv)
{
__u32 flags = XDP_FLAGS_SKB_MODE;
struct xdp_simple_bpf *obj;
obj = xdp_simple_bpf__open_and_load();
if (!obj)
err(1, "errore nell'apertura e/o caricamento dell'oggetto BPFn");
bpf_set_link_xdp_fd(1, -1, flags);
bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);
cleanup:
xdp_simple_bpf__destroy(obj);
}Qui usiamo la funzione bpf_set_link_xdp_fd, che collega i programmi BPF di tipo XDP alle interfacce di rete. Abbiamo hardcoded il numero dell'interfaccia lo, che è sempre uguale a 1. Eseguiamo la funzione due volte, per scollegare prima il vecchio programma, se era connesso. Nota che ora non abbiamo bisogno della chiamata pause o di un ciclo infinito: il nostro programma di caricamento terminerà, ma il programma BPF non verrà distrutto, poiché è collegato a una fonte di eventi. Dopo il caricamento e il collegamento riuscito, il programma verrà eseguito per ciascun pacchetto di rete che arriva su lo.
Carichiamo il programma e osserviamo l'interfaccia lo:
$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp name simple tag 4fca62e77ccb43d6 gpl
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 669Il programma che abbiamo caricato ha ID 669 e lo stesso ID lo vediamo sull'interfaccia. lo. Invieremo un paio di pacchetti a 127.0.0.1 (richiesta + risposta):
$ ping -c1 localhoste ora guardiamo il contenuto del file virtuale di debug /sys/kernel/debug/tracing/trace_pipe, in cui bpf_printk scrive i suoi messaggi:
# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0Due pacchetti sono stati rilevati su lo e lavorati su CPU0 — la nostra prima reale e irrilevante programma BPF ha funzionato!
Vale la pena notare che bpf_printk non scrive a caso nel file di debug: non è il miglior helper per l'uso in produzione, ma il nostro obiettivo era mostrare qualcosa di semplice.
Accesso a maps dai programmi BPF
Esempio: usiamo un mappa dal programma BPF
Nei capitoli precedenti abbiamo imparato a creare e usare mappe dallo spazio utente, ora diamo un'occhiata alla parte kernel. Iniziamo, come di consueto, con un esempio. Riscriveremo il nostro programma xdp-simple.bpf.c nel seguente modo:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 8);
__type(key, u32);
__type(value, u64);
} woo SEC(".maps");
SEC("xdp/simple")
int simple(void *ctx)
{
u32 key = bpf_get_smp_processor_id();
u32 *val;
val = bpf_map_lookup_elem(&woo, &key);
if (!val)
return XDP_ABORTED;
*val += 1;
return XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";All'inizio del programma abbiamo aggiunto la definizione di una mappa woo: è un array di 8 elementi, in cui vengono memorizzati valori di tipo u64 (in C definiremmo questo array come u64 woo[8]). Nel programma "xdp/simple" otteniamo il numero del processore corrente nella variabile key e poi tramite la funzione di supporto bpf_map_lookup_element ottiene un puntatore all'entry corrispondente dell'array, che aumentiamo di uno. In altre parole: stiamo contando le statistiche su quale CPU ha elaborato i pacchetti in entrata. Proviamo a eseguire il programma:
$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simpleVerifichiamo che sia stata collegata a lo e inviamo un po' di pacchetti:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 108
$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; doneOra guardiamo il contenuto dell'array:
$ sudo bpftool map dump name woo
[
{ "key": 0, "value": 0 },
{ "key": 1, "value": 400 },
{ "key": 2, "value": 0 },
{ "key": 3, "value": 0 },
{ "key": 4, "value": 0 },
{ "key": 5, "value": 0 },
{ "key": 6, "value": 0 },
{ "key": 7, "value": 46400 }
]Quasi tutti i processi sono stati elaborati su CPU7. Non è importante, l'importante è che il programma funzioni e abbiamo capito come accedere alle mappe dai programmi BPF — tramite .
Puntatore mistico
Quindi, possiamo accedere alla mappa dalla programmazione BPF tramite chiamate di questo tipo
val = bpf_map_lookup_elem(&woo, &key);dove la funzione di supporto appare come
void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)ma stiamo passando un puntatore &woo a una struttura anonima struct { ... }…
Se guardiamo l'assemblatore del programma, vedremo che il valore &woo in realtà non è definito (riga 4):
llvm-objdump -D --section xdp/simple xdp-simple.bpf.o
xdp-simple.bpf.o: file format elf64-bpf
Disassemblaggio della sezione xdp/simple:
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
2: bf a2 00 00 00 00 00 00 r2 = r10
3: 07 02 00 00 fc ff ff ff r2 += -4
4: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
6: 85 00 00 00 01 00 00 00 call 1
...ed è contenuto nelle rilocalizzazioni:
$ llvm-readelf -r xdp-simple.bpf.o | head -4
La sezione di rilocalizzazione '.relxdp/simple' all'offset 0xe18 contiene 1 voce:
Offset Info Tipo Valore Simbolo Nome Simbolo
0000000000000020 0000002700000001 R_BPF_64_64 0000000000000000 wooMa se guardiamo al programma già caricato, vedremo un puntatore sulla mappa corretta (riga 4):
$ sudo bpftool prog dump x name simple
int simple(void *ctx):
0: (85) call bpf_get_smp_processor_id#114128
1: (63) *(u32 *)(r10 -4) = r0
2: (bf) r2 = r10
3: (07) r2 += -4
4: (18) r1 = map[id:64]
...Pertanto, possiamo concludere che al momento dell'avvio del nostro programma di caricamento il collegamento a &woo è stato sostituito con qualcosa dalla libreria libbpf. Iniziamo a guardare l'output strace:
$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=8, max_entries=8, map_name="woo", ...}, 120) = 4
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="simple", ...}, 120) = 5Possiamo vedere che libbpf ha creato una mappa woo e poi ha caricato il nostro programma simple. Diamo un'occhiata più da vicino a come carichiamo il programma:
- chiamiamo
xdp_simple_bpf__open_and_loaddal filexdp-simple.skel.h - che chiama
xdp_simple_bpf__loaddal filexdp-simple.skel.h - che chiama
bpf_object__load_skeletondal filelibbpf/src/libbpf.c - che chiama
bpf_object__load_xattrdilibbpf/src/libbpf.c
L'ultima funzione, tra le altre cose, chiamerà bpf_object__create_maps, che crea o apre mappe esistenti, trasformandole in descrittori di file. (È qui che vediamo BPF_MAP_CREATE nell'output strace.) Poi viene chiamata la funzione bpf_object__relocate ed è proprio su di essa che ci interessa, dato che ricordiamo di aver visto woo nella tabella delle relocalizzazioni. Esplorandola, alla fine arriviamo alla funzione bpf_program__relocate, che si occupa :
case RELO_LD64:
insn[0].src_reg = BPF_PSEUDO_MAP_FD;
insn[0].imm = obj->maps[relo->map_idx].fd;
break;Quindi prendiamo la nostra istruzione
18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 lle sostituiamo in essa il registro sorgente con BPF_PSEUDO_MAP_FD, e il primo IMM sul file descriptor della nostra mappa e, se ad esempio è uguale a 0xdeadbeef, allora avremo l'istruzione
18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 llÈ proprio così che le informazioni sulle mappe vengono passate a un programma BPF specifico caricato. La mappa può essere creata tramite BPF_MAP_CREATE, oppure aperta per ID tramite BPF_MAP_GET_FD_BY_ID.
In totale, usando libbpf l'algoritmo è il seguente:
- durante la compilazione, per i riferimenti alle mappe vengono create voci nella tabella di rilocazione
libbpfapre l'oggetto ELF, trova tutte le mappe utilizzate e crea per esse file descriptor- i file descriptor sono caricati nel kernel come parte dell'istruzione
LD64
Come potete immaginare, questo non è tutto, e dovremo dare un'occhiata al kernel. Fortunatamente, abbiamo un indizio: abbiamo scritto il valore BPF_PSEUDO_MAP_FD nel registro sorgente e possiamo scavare, il che ci porterà nel santo dei santi — kernel/bpf/verifier.c, dove una funzione dal nome caratteristico sostituisce il file descriptor con l'indirizzo di una struttura di tipo struct bpf_map:
static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
...
f = fdget(insn[0].imm);
map = __bpf_map_get(f);
if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
addr = (unsigned long)map;
}
insn[0].imm = (u32)addr;
insn[1].imm = addr >> 32;(il codice completo può essere trovato ). Quindi possiamo completare il nostro algoritmo:
- durante il caricamento del programma, il verifier controlla la correttezza dell'uso della mappa e scrive l'indirizzo della struttura corrispondente
struct bpf_map
Durante il caricamento del binario ELF tramite libbpf ci sono molti altri eventi, ma ne parleremo in articoli futuri.
Carichiamo programmi e mappe senza libbpf
Come promesso, ecco un esempio per i lettori che vogliono sapere come creare e caricare un programma che utilizza mappe, senza aiuto libbpf. Questo può essere utile quando lavori in un ambiente in cui non puoi compilare le dipendenze, o stai risparmiando ogni byte, o scrivendo un programma del tipo , che genera codice binario BPF al volo.
Per seguire più facilmente la logica, riscriveremo il nostro esempio di xdp-simple. Il codice completo e leggermente esteso del programma considerato in questo esempio è disponibile in questo .
La logica della nostra applicazione è la seguente:
- creare una mappa di tipo
BPF_MAP_TYPE_ARRAYutilizzando il comandoBPF_MAP_CREATE, - creare un programma che utilizza questa mappa,
- collegare il programma all'interfaccia
lo,
che si traduce in umano come
int main(void)
{
int map_fd, prog_fd;
map_fd = map_create();
if (map_fd < 0)
err(1, "bpf: BPF_MAP_CREATE");
prog_fd = prog_load(map_fd);
if (prog_fd < 0)
err(1, "bpf: BPF_PROG_LOAD");
xdp_attach(1, prog_fd);
}Qui map_create crea una mappa esattamente come abbiamo fatto nel primo esempio con la chiamata di sistema bpf — "nucleo, per favore, fammi una nuova mappa sotto forma di array con 8 elementi di tipo __u64 e restituiscimi un descrittore di file":
static int map_create()
{
union bpf_attr attr;
memset(&attr, 0, sizeof(attr));
attr.map_type = BPF_MAP_TYPE_ARRAY,
attr.key_size = sizeof(__u32),
attr.value_size = sizeof(__u64),
attr.max_entries = 8,
strncpy(attr.map_name, "woo", sizeof(attr.map_name));
return syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
}Il programma viene caricato semplicemente:
static int prog_load(int map_fd)
{
union bpf_attr attr;
struct bpf_insn insns[] = {
...
};
memset(&attr, 0, sizeof(attr));
attr.prog_type = BPF_PROG_TYPE_XDP;
attr.insns = ptr_to_u64(insns);
attr.insn_cnt = sizeof(insns)/sizeof(insns[0]);
attr.license = ptr_to_u64("GPL");
strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
return syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
}La parte complessa prog_load — è la definizione del nostro programma BPF come array di strutture struct bpf_insn insns[]. Ma poiché stiamo utilizzando un programma che abbiamo in C, possiamo barare un po':
$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
2: bf a2 00 00 00 00 00 00 r2 = r10
3: 07 02 00 00 fc ff ff ff r2 += -4
4: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
6: 85 00 00 00 01 00 00 00 call 1
7: b7 01 00 00 00 00 00 00 r1 = 0
8: 15 00 04 00 00 00 00 00 if r0 == 0 goto +4
9: 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
10: 07 01 00 00 01 00 00 00 r1 += 1
11: 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
12: b7 01 00 00 02 00 00 00 r1 = 2
0000000000000068 :
13: bf 10 00 00 00 00 00 00 r0 = r1
14: 95 00 00 00 00 00 00 00 exitIn conclusione, dobbiamo scrivere 14 istruzioni come strutture di tipo struct bpf_insn (consiglio: prendi il dump qui sopra, rileggi la sezione sulle istruzioni, apri e e cerca di definire struct bpf_insn insns[] da solo):
struct bpf_insn insns[] = {
/* 85 00 00 00 08 00 00 00 call 8 */
{
.code = BPF_JMP | BPF_CALL,
.imm = 8,
},
/* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
{
.code = BPF_MEM | BPF_STX,
.off = -4,
.src_reg = BPF_REG_0,
.dst_reg = BPF_REG_10,
},
/* bf a2 00 00 00 00 00 00 r2 = r10 */
{
.code = BPF_ALU64 | BPF_MOV | BPF_X,
.src_reg = BPF_REG_10,
.dst_reg = BPF_REG_2,
},
/* 07 02 00 00 fc ff ff ff r2 += -4 */
{
.code = BPF_ALU64 | BPF_ADD | BPF_K,
.dst_reg = BPF_REG_2,
.imm = -4,
},
/* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
{
.code = BPF_LD | BPF_DW | BPF_IMM,
.src_reg = BPF_PSEUDO_MAP_FD,
.dst_reg = BPF_REG_1,
.imm = map_fd,
},
{ }, /* placeholder */
/* 85 00 00 00 01 00 00 00 call 1 */
{
.code = BPF_JMP | BPF_CALL,
.imm = 1,
},
/* b7 01 00 00 00 00 00 00 r1 = 0 */
{
.code = BPF_ALU64 | BPF_MOV | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 0,
},
/* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
{
.code = BPF_JMP | BPF_JEQ | BPF_K,
.off = 4,
.src_reg = BPF_REG_0,
.imm = 0,
},
/* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
{
.code = BPF_MEM | BPF_LDX,
.off = 0,
.src_reg = BPF_REG_0,
.dst_reg = BPF_REG_1,
},
/* 07 01 00 00 01 00 00 00 r1 += 1 */
{
.code = BPF_ALU64 | BPF_ADD | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 1,
},
/* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
{
.code = BPF_MEM | BPF_STX,
.src_reg = BPF_REG_1,
.dst_reg = BPF_REG_0,
},
/* b7 01 00 00 02 00 00 00 r1 = 2 */
{
.code = BPF_ALU64 | BPF_MOV | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 2,
},
/* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
{
.code = BPF_ALU64 | BPF_MOV | BPF_X,
.src_reg = BPF_REG_1,
.dst_reg = BPF_REG_0,
},
/* 95 00 00 00 00 00 00 00 exit */
{
.code = BPF_JMP | BPF_EXIT
},
};Esercizio per chi non l'ha scritto da solo — trova map_fd.
Nella nostra programmazione rimane ancora una parte non rivelata — xdp_attach. Sfortunatamente, programmi come XDP non possono essere collegati tramite una chiamata di sistema bpf. Le persone che hanno creato BPF e XDP provenivano dalla comunità di rete Linux, quindi hanno usato l'interfaccia che per loro era più familiare (ma non per persone normali) : , vedi anche . Il modo più semplice per implementare xdp_attach è copiare il codice da libbpf, in particolare, dal file , cosa che abbiamo fatto, accorciandolo un po':
Benvenuto nel mondo dei socket netlink
Apriamo un socket netlink di tipo NETLINK_ROUTE:
int netlink_open(__u32 *nl_pid)
{
struct sockaddr_nl sa;
socklen_t addrlen;
int one = 1, ret;
int sock;
memset(&sa, 0, sizeof(sa));
sa.nl_family = AF_NETLINK;
sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (sock < 0)
err(1, "socket");
if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
warnx("netlink error reporting not supported");
if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
err(1, "bind");
addrlen = sizeof(sa);
if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
err(1, "getsockname");
*nl_pid = sa.nl_pid;
return sock;
}Leggiamo da un socket di questo tipo:
static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
bool multipart = true;
struct nlmsgerr *errm;
struct nlmsghdr *nh;
char buf[4096];
int len, ret;
while (multipart) {
multipart = false;
len = recv(sock, buf, sizeof(buf), 0);
if (len nlmsg_pid != nl_pid)
errx(1, "wrong pid");
if (nh->nlmsg_seq != seq)
errx(1, "INVSEQ");
if (nh->nlmsg_flags & NLM_F_MULTI)
multipart = true;
switch (nh->nlmsg_type) {
case NLMSG_ERROR:
errm = (struct nlmsgerr *)NLMSG_DATA(nh);
if (!errm->error)
continue;
ret = errm->error;
// libbpf_nla_dump_errormsg(nh); troppo codice da copiare...
goto done;
case NLMSG_DONE:
return 0;
default:
break;
}
}
}
ret = 0;
done:
return ret;
}Infine, ecco la nostra funzione che apre un socket e invia un messaggio speciale contenente un descrittore di file:
static int xdp_attach(int ifindex, int prog_fd)
{
int sock, seq = 0, ret;
struct nlattr *nla, *nla_xdp;
struct {
struct nlmsghdr nh;
struct ifinfomsg ifinfo;
char attrbuf[64];
} req;
__u32 nl_pid = 0;
sock = netlink_open(&nl_pid);
if (sock nla_type = NLA_F_NESTED | IFLA_XDP;
nla->nla_len = NLA_HDRLEN;
/* add XDP fd */
nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
nla_xdp->nla_type = IFLA_XDP_FD;
nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
nla->nla_len += nla_xdp->nla_len;
/* if user passed in any flags, add those too */
__u32 flags = XDP_FLAGS_SKB_MODE;
nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
nla_xdp->nla_type = IFLA_XDP_FLAGS;
nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
nla->nla_len += nla_xdp->nla_len;
req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);
if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
err(1, "send");
ret = bpf_netlink_recv(sock, nl_pid, seq);
cleanup:
close(sock);
return ret;
}Quindi, tutto è pronto per il collaudo:
$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++Controlliamo se il nostro programma è connesso a lo:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 160Invieremo dei ping e daremo un'occhiata alla mappa:
$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00 value: 90 01 00 00 00 00 00 00
key: 01 00 00 00 value: 00 00 00 00 00 00 00 00
key: 02 00 00 00 value: 00 00 00 00 00 00 00 00
key: 03 00 00 00 value: 00 00 00 00 00 00 00 00
key: 04 00 00 00 value: 00 00 00 00 00 00 00 00
key: 05 00 00 00 value: 00 00 00 00 00 00 00 00
key: 06 00 00 00 value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00 value: 00 00 00 00 00 00 00 00
Found 8 elementsEvviva, tutto funziona. Notate che la nostra mappa appare ancora come byte. Questo accade perché, a differenza di libbpf non abbiamo caricato informazioni sui tipi (BTF). Ma ne parleremo più in dettaglio la prossima volta.
Strumenti di sviluppo
In questa sezione daremo un'occhiata al set minimo di strumenti per sviluppatori BPF.
In generale, non è necessario nulla di speciale per sviluppare programmi BPF: BPF funziona su qualsiasi kernel di distribuzione decente e i programmi vengono compilati utilizzando clang, che può essere installato dal pacchetto. Tuttavia, poiché il BPF è in fase di sviluppo, il kernel e gli strumenti cambiano costantemente; se non vuoi scrivere programmi BPF con metodi obsoleti del 2019, dovrai compilare
llvm/clangpahole- il tuo kernel
bpftool
(A scopo informativo: questa sezione e tutti gli esempi nell'articolo sono stati eseguiti su Debian 10.)
llvm/clang
BPF è compatibile con LLVM e, sebbene recentemente sia possibile compilare programmi per BPF anche con gcc, tutta la attuale sviluppo avviene per LLVM. Pertanto, prima di tutto compileremo la versione attuale clang da git:
$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86"
-DLLVM_ENABLE_PROJECTS="clang"
-DBUILD_SHARED_LIBS=OFF
-DCMAKE_BUILD_TYPE=Release
-DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... molto tempo dopo
$Ora possiamo controllare se tutto è stato compilato correttamente:
$ ./bin/llc --version
LLVM (http://llvm.org/):
Versione LLVM 11.0.0git
Compilazione ottimizzata.
Target predefinito: x86_64-unknown-linux-gnu
CPU host: znver1
Target registrati:
bpf - BPF (endian host)
bpfeb - BPF (big endian)
bpfel - BPF (little endian)
x86 - X86 a 32 bit: Pentium-Pro e superiori
x86-64 - X86 a 64 bit: EM64T e AMD64(Le istruzioni per la compilazione clang sono state tratte da .)
Non installeremo i programmi appena compilati, ma li aggiungeremo semplicemente a PATH, ad esempio:
export PATH="`pwd`/bin:$PATH"(Questo può essere aggiunto a .bashrc o a un file separato. Personalmente, aggiungo queste cose a ~/bin/activate-llvm.sh e quando necessario faccio . activate-llvm.sh.)
Pahole e BTF
Utility pahole viene utilizzato durante la compilazione del kernel per generare informazioni di debug in formato BTF. Non ci soffermeremo sui dettagli della tecnologia BTF in questo articolo, se non per il fatto che è comoda e vogliamo usarla. Pertanto, se intendi compilare il tuo kernel, inizia a compilarlo pahole (senza pahole non potrai compilare il kernel con l'opzione CONFIG_DEBUG_INFO_BTF:
$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/paholeKernel per esperimenti con BPF
Quando si esplorano le possibilità del BPF, si desidera creare il proprio kernel. Questo, in realtà, non è obbligatorio, poiché è possibile compilare e caricare programmi BPF anche con un kernel distribuito; tuttavia, avere il proprio kernel consente di utilizzare le funzionalità più recenti del BPF, che nel tuo distribuito potrebbero apparire al massimo dopo mesi o, nel caso di alcuni strumenti di debug, potrebbero non essere imballate affatto in un futuro prevedibile. Inoltre, avere il proprio kernel consente di sperimentare con il codice.
Per costruire un kernel hai bisogno, innanzitutto, del kernel stesso e, in secondo luogo, del file di configurazione del kernel. Per sperimentare con il BPF possiamo utilizzare un normale kernel o uno dei kernel di sviluppo. Storicamente, lo sviluppo del BPF avviene nell'ambito della comunità di rete di Linux, e quindi tutte le modifiche, prima o poi, passano attraverso David Miller, il mantenitore della parte di rete di Linux. A seconda della loro natura — correzioni o nuove funzionalità — le modifiche di rete vengono distribuite tra due kernel: o . Le modifiche per il BPF vengono distribuite allo stesso modo tra e , che poi si ritrovano in net e net-next, rispettivamente. Maggiori dettagli su e . Quindi scegliete il kernel in base ai vostri gusti e alle vostre necessità in termini di stabilità del sistema su cui state testando (*-next i kernel sono i più instabili tra quelli elencati).
Questo articolo non tratta di come gestire i file di configurazione del kernel — si presume che sappiate già farlo oppure da soli. Tuttavia, le istruzioni successive dovrebbero essere, più o meno, sufficienti per avere un sistema funzionante con supporto BPF.
Scaricare uno dei kernel menzionati sopra:
$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-nextCompilare una configurazione minima funzionante del kernel:
$ cp /boot/config-`uname -r` .config
$ make localmodconfigAbilitare le opzioni BPF nel file .config di vostra scelta (probabilmente, già CONFIG_BPF sarà attivato, poiché usato da systemd). Ecco un elenco delle opzioni dal kernel utilizzato per questo articolo:
CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF non impostato
# CONFIG_BPFILTER non impostato
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=ySuccessivamente, possiamo facilmente compilare e installare i moduli e il kernel (tra l'altro, si può compilare il kernel usando solo clang, aggiungendo CC=clang):
$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make installe riavviarmi con il nuovo kernel (uso per questo kexec del pacchetto kexec-tools):
v=5.8.0-rc6+ # se ricompili il kernel corrente, puoi usare v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -ebpftool
L'utilità più comunemente usata nell'articolo sarà l'utilità bpftool, fornita con il kernel Linux. È scritta e mantenuta dagli sviluppatori BPF per gli sviluppatori BPF e permette di gestire tutti i tipi di oggetti BPF: caricare programmi, creare e modificare mappe, esplorare la vita dell'ecosistema BPF, ecc. La documentazione sotto forma di sorgenti delle man pages può essere trovata o, già compilata, .
Al momento della scrittura dell'articolo bpftool è fornito già pronto solo per RHEL, Fedora e Ubuntu (vedi, ad esempio, , che racconta la storia incompleta del pacchettizzazione bpftool in Debian). Ma se hai già compilato il tuo kernel, è più semplice di così: bpftool $ cd ${linux}/tools/bpf/bpftool # ... specifica i percorsi all'ultima clang, come spiegato sopra $ make -sRilevamento automatico delle funzionalità del sistema: ... libbfd: [ acceso ] ... disassembler-four-args: [ acceso ] ... zlib: [ acceso ] ... libcap: [ acceso ] ... clang-bpf-co-re: [ acceso ]Rilevamento automatico delle funzionalità del sistema: ... libelf: [ acceso ] ... zlib: [ acceso ] ... bpf: [ acceso ]$
$ cd ${linux}/tools/bpf/bpftool
# ... specifica i percorsi all'ultimo clang, come descritto sopra
$ make -s
Rilevamento automatico delle funzionalità di sistema:
... libbfd: [ su ]
... disassembler-four-args: [ su ]
... zlib: [ su ]
... libcap: [ su ]
... clang-bpf-co-re: [ su ]
Rilevamento automatico delle funzionalità di sistema:
... libelf: [ su ]
... zlib: [ su ]
... bpf: [ su ]
$(qui ${linux} è la tua directory con il kernel.) Dopo aver eseguito questi comandi bpftool sarà compilato nella directory ${linux}/tools/bpf/bpftool e potrà essere aggiunto al percorso (soprattutto per l'utente root) o semplicemente copiato in /usr/local/sbin.
Compilare bpftool è meglio farlo con l'ultima versione clang, compilata come descritto sopra, e per verificare se è stata compilata correttamente — utilizzando, ad esempio, il comando
$ sudo bpftool feature probe kernel
Scansione della configurazione del sistema...
bpf() syscall per utenti non privilegiati è abilitata
Il compilatore JIT è abilitato
Il rafforzamento del compilatore JIT è disabilitato
Le esportazioni di kallsyms del compilatore JIT sono abilitate per root
...che mostrerà quali funzioni BPF sono abilitate nel tuo kernel.
A proposito, il comando precedente può essere eseguito come
# bpftool f p kQuesto è fatto per analogia con gli strumenti del pacchetto iproute2, dove possiamo, per esempio, dire ip a s eth0 invece di ip addr show dev eth0.
Conclusione
BPF consente di misurare e modificare dinamicamente in modo efficiente la funzionalità del kernel. Il sistema si è rivelato molto efficace, seguendo le migliori tradizioni UNIX: un meccanismo semplice che permette di (ri)programmare il kernel ha consentito a un gran numero di persone e organizzazioni di sperimentare. E, sebbene gli esperimenti, così come lo sviluppo dell'infrastruttura BPF, non siano affatto conclusi, il sistema ha già un ABI stabile che consente di costruire una logica aziendale affidabile ed efficace.
Vorrei sottolineare che, secondo me, questa tecnologia è diventata così popolare perché, da un lato, permette di giocare (l'architettura della macchina può essere compresa abbastanza bene in una sola sera), e dall'altro lato — affrontare problemi che prima della sua introduzione non potevano essere risolti (in modo elegante). Questi due aspetti insieme spingono le persone a sperimentare e sognare, portando così alla nascita di sempre nuove soluzioni innovative.
Questo articolo, sebbene non sia particolarmente breve, è solo un'introduzione al mondo di BPF e non tratta delle funzionalità "avanzate" e delle parti importanti dell'architettura. Il piano per il seguito è approssimativamente il seguente: il prossimo articolo fornirà una panoramica dei tipi di programmi BPF (nella versione del kernel 5.8 sono supportati 30 tipi di programmi), poi finalmente esploreremo come scrivere vere applicazioni BPF con esempi di programmi per la tracciabilità del kernel, dopodiché sarà il momento di un corso più approfondito sull'architettura BPF, e infine, esempi di applicazioni BPF per la rete e la sicurezza.
Articoli precedenti di questo ciclo
Collegamenti
— documentazione su BPF da cilium, in particolare di Daniel Borkman, uno dei creatori e dei manutentori di BPF. È una delle prime descrizioni serie che si distingue dalle altre poiché Daniel sa esattamente di cosa sta parlando e non ci sono errori. In particolare, questo documento spiega come lavorare con i programmi BPF dei tipi XDP e TC utilizzando l'utility nota.
ipdel pacchettoiproute2.— file originale della documentazione riguardante il BPF classico e successivamente l'extended BPF. È utile leggerlo se si desidera esplorare l'assembly e i dettagli tecnici dell'architettura.
. Aggiornato raramente, ma con precisione, grazie agli interventi di Alexei Starovoitov (autore di eBPF) e Andrii Nakryiko — (maintainer)
libbpf).. Un thread interessante su Twitter di Quentin Monnet con esempi e segreti dell'uso di bpftool.
. Un gigantesco (e ancora supportato) elenco di link alla documentazione BPF di Quentin Monnet.
Fonte: habr.com
