All'inizio c'era una tecnologia chiamata BPF. L'abbiamo già esaminata nell'articolo di questa serie, che risale all'Antico Testamento. Nel 2013, grazie agli sforzi di Alexei Starovoitov e Daniel Borkman, è stata sviluppata e integrata nel kernel Linux una versione migliorata, ottimizzata per le moderne macchine a 64 bit. Questa nuova tecnologia è stata brevemente chiamata Internal BPF, poi rinominata in Extended BPF, e ora, dopo alcuni anni, è comunemente conosciuta semplicemente come BPF.
In sintesi, BPF consente di eseguire codice fornito dall'utente nello spazio kernel di Linux, e la nuova architettura si è rivelata così efficace che avremo bisogno di almeno una decina di articoli per descrivere tutte le sue applicazioni. (L'unica cosa su cui i sviluppatori non sono riusciti, come puoi vedere sul kpdv qui sotto, è la creazione di un logo decente.)
In questo articolo descriveremo la struttura della macchina virtuale BPF, le interfacce del kernel per lavorare con BPF, gli strumenti di sviluppo e una breve, brevissima, panoramica delle capacità esistenti, ovvero tutto ciò di cui avremo bisogno in seguito per un approfondimento delle applicazioni pratiche di BPF.
Sommario dell'articolo
Inizieremo guardando l'architettura BPF dall'alto, identificando i suoi componenti principali.
Avendo già una visione d'insieme dell'architettura, descriveremo la struttura della macchina virtuale BPF.
In questa sezione daremo uno sguardo più attento al ciclo di vita degli oggetti BPF: programmi e mappe.
Avendo già una certa familiarità con il sistema, finalmente vedremo come creare e gestire oggetti dallo spazio utente utilizzando una system call speciale — bpf(2).
Certo, è possibile scrivere programmi utilizzando la system call. Ma è complesso. Per uno scenario più pratico, i programmatori del kernel hanno sviluppato una libreria libbpf. Creeremo la struttura base più semplice di un'applicazione BPF che utilizzeremo nei prossimi esempi.
Qui scopriremo come i programmi BPF possono accedere alle funzioni di supporto del kernel, uno strumento che, insieme alle mappe, amplia significativamente le capacità del nuovo BPF rispetto al classico.
A questo punto, sapremo abbastanza per capire come si possano creare programmi che utilizzano le mappe. Daremo anche un breve sguardo al grande e potente verifier.
Sezione di riferimento su come compilare gli strumenti e il kernel necessari per gli esperimenti.
Alla fine dell'articolo, coloro che arriveranno fin qui troveranno parole motivazionali e una breve descrizione di ciò che verrà negli articoli successivi. Elencheremo anche un certo numero di link per l'apprendimento autonomo per chi non ha voglia o possibilità di attendere il seguito.
Introduzione all'architettura BPF
Prima di iniziare a esaminare l'architettura BPF, faremo un ultimo riferimento a , progettato come risposta all'emergere delle macchine RISC e risolveva il problema della filtrazione efficace dei pacchetti. L'architettura si è rivelata così riuscita che, nata nei turbolenti anni '90 nel Berkeley UNIX, è stata portata sulla maggior parte dei sistemi operativi esistenti, ha superato i folli 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). Sviluppato da ingegneri di rete del kernel come un sostituto avanzato del BPF classico, il nuovo BPF ha trovato applicazione in un'impresa impegnativa come il tracciamento dei sistemi Linux dopo soli sei mesi dalla sua introduzione e ora, a sei anni dalla sua apparizione, ci vorrà un intero articolo successivo solo per elencare i vari tipi di programmi.
ВеСёЛыЕ КаРтИнКи
In sostanza, BPF è una macchina virtuale sandbox che consente di eseguire codice "arbitrario" nello spazio del kernel senza compromettere la sicurezza. I programmi BPF sono creati nello spazio utente, caricati nel kernel e collegati a una sorgente 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, ecc. Nel caso di un pacchetto, i dati e i metadati del pacchetto saranno accessibili al programma BPF (in lettura e, forse, in scrittura, a seconda del tipo di programma), mentre nel caso di chiamate a funzioni del kernel, saranno disponibili gli argomenti della funzione, includendo puntatori alla memoria del kernel, ecc.
Analizziamo questo processo più nel dettaglio. Iniziamo a descrivere la prima differenza rispetto al classico BPF, per il quale i programmi erano scritti in assembly. Nella nuova versione, l'architettura è stata ampliata in modo tale che ora i programmi possano essere scritti in linguaggi di alto livello, soprattutto in C. A tal fine, è stato sviluppato un backend per llvm, che consente di generare codice byte per l'architettura BPF.

L'architettura BPF è stata sviluppata, in particolare, per essere eseguita in modo efficiente sulle macchine moderne. Affinché ciò funzionasse nella pratica, il codice byte BPF, dopo essere stato caricato nel kernel, viene tradotto in codice nativo tramite un componente chiamato JIT compiler (Just In Time). Inoltre, se ricordi bene, nel classico BPF, il programma veniva caricato nel kernel e collegato a una sorgente di 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, vari a seconda del tipo di programma, il programma si collega (attaches) alla sorgente di eventi.
Qui il lettore potrebbe avere una domanda: ma era possibile fare tutto ciò? Come viene garantita la sicurezza nell'esecuzione di questo codice? La sicurezza dell'esecuzione è garantita dal passo di caricamento dei programmi BPF chiamato verificatore (in inglese si chiama verifier e continuerò a usare il termine inglese):

Verifier è un analizzatore statico che garantisce che un programma non interrompa il normale funzionamento del kernel. Questo, va notato, non significa che il programma non possa interferire con il funzionamento del sistema: i programmi BPF, a seconda del tipo, possono leggere e riscrivere porzioni di memoria del kernel, restituire valori delle funzioni, tranciare, completare, riscrivere e persino inoltrare pacchetti di rete. Verifier garantisce che l'esecuzione di un programma BPF non causerà il crash del kernel e che un programma, che per regole ha accesso in scrittura, come ad esempio i dati di un pacchetto in uscita, non potrà riscrivere la memoria del kernel al di fuori del pacchetto. Vedremo il verifier in modo più dettagliato nella sezione corrispondente, dopo aver esaminato tutti gli altri componenti di BPF.
Quindi, cosa abbiamo appreso finora? L'utente scrive un programma in linguaggio C, lo carica nel kernel tramite una chiamata di sistema bpf(2), dove viene sottoposto a controllo dal verifier e tradotto in bytecode nativo. Successivamente, lo stesso utente o un altro collega collega il programma a una sorgente di eventi e questo inizia ad essere eseguito. La separazione del caricamento e del collegamento è necessaria per diverse ragioni. In primo luogo, l'esecuzione del verifier è relativamente costosa e, caricando lo stesso programma più volte, sprechiamo tempo di calcolo. In secondo luogo, il modo in cui il programma viene collegato dipende dal suo tipo e un'interfaccia "universale" progettata un anno fa potrebbe non essere adatta per i nuovi tipi di programmi. (Tuttavia, ora che l'architettura sta diventando più matura, c'è l'idea di uniformare questa interfaccia a livello) libbpf.)
Il lettore attento potrebbe notare che non abbiamo ancora finito con le immagini. È vero, quanto detto sopra non spiega come BPF cambi sostanzialmente la situazione rispetto al BPF classico. Due novità che ampliano notevolmente i confini di applicabilità sono la possibilità di utilizzare la memoria condivisa e le funzioni di supporto del kernel (kernel helpers). In BPF, la memoria condivisa è implementata tramite quelle che vengono chiamate maps — strutture di dati condivisi con un API definita. Questo nome è stato probabilmente scelto perché il primo tipo di map ad apparire era una tabella hash. Successivamente sono stati introdotti array, tabelle hash locali (per-CPU), array locali, alberi di ricerca, mappe contenenti puntatori a programmi BPF e molto altro. Ci interessa ora il fatto che i programmi BPF hanno la possibilità 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 chiamate di sistema bpf(2), e dai programmi BPF che operano nel kernel tramite funzioni di supporto. Inoltre, gli helpers non esistono solo per lavorare con le maps, ma anche per accedere ad altre funzionalità del kernel. Ad esempio, i programmi BPF possono utilizzare funzioni di supporto per reindirizzare pacchetti su altre interfacce, generare eventi nella sottosistema perf, accedere a strutture del kernel e così via.

In sintesi, BPF offre la possibilità di caricare codice utente arbitrario, cioè verificato dal verifier, nello spazio del kernel. Questo codice può mantenere stato tra le chiamate e scambiare dati con lo spazio utente, e ha inoltre accesso alle sottosistemi del kernel autorizzati per questo tipo di programmi.
Questo già somiglia alle funzionalità fornite dai moduli del kernel, rispetto alle quali BPF ha alcuni vantaggi (naturalmente, si possono confrontare solo applicazioni simili, ad esempio, il tracciamento del sistema - non si può scrivere un driver arbitrario in BPF). Si possono notare una soglia di ingresso più bassa (alcuni strumenti che utilizzano BPF non richiedono all'utente di avere competenze nella programmazione del kernel, e in generale, competenze di programmazione), la sicurezza del tempo di esecuzione (alzate la mano nei commenti se non avete mai rotto un sistema mentre scrivevate o testavate moduli), l'atomicità - al riavvio dei moduli c'è un tempo 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 presenza di queste funzionalità rende BPF uno strumento versatile per l'estensione del kernel, come dimostrato nella pratica: tipi sempre nuovi di programmi vengono aggiunti a BPF, sempre più grandi aziende utilizzano BPF su server di produzione 24×7, sempre più startup costruiscono il loro 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 la raccolta delle statistiche, nei sistemi di rilevamento delle intrusioni e nei sistemi sandbox, ecc.
Concludiamo qui la parte introduttiva dell'articolo e diamo un'occhiata più da vicino alla macchina virtuale e all'ecosistema BPF.
Divagazione: strumenti
Per poter eseguire gli esempi delle sezioni successive, potrebbe essere necessario un certo numero di strumenti, almeno llvm/clang con supporto BPF e bpftool. Nella sezione è possibile leggere le istruzioni per la compilazione degli strumenti e del proprio kernel. Questa sezione è stata posizionata più in basso per non interrompere la fluidità della nostra esposizione.
Registri e sistema di comandi della macchina virtuale BPF
L'architettura e il sistema di comandi BPF sono stati sviluppati tenendo conto che i programmi sarebbero stati scritti in linguaggio C e, dopo il caricamento nel kernel, sarebbero stati tradotti in codice nativo. Pertanto, il numero di registri e il numero di comandi sono stati scelti considerando l'intersezione, in senso matematico, delle capacità delle macchine moderne. Inoltre, i programmi avevano vari vincoli, ad esempio, fino a poco tempo fa non era possibile scrivere cicli e subroutine, 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 a 64 bit accessibili all'utente r0—r10 e il contatore di istruzioni (program counter). Il registro r10 contiene un puntatore allo stack (frame pointer) ed è accessibile solo in lettura. Durante l'esecuzione, i programmi possono accedere a uno stack di 512 byte e a un numero illimitato di memoria condivisa sotto forma di maps.
Ai programmi BPF è permesso chiamare un set di funzioni ausiliarie (kernel helpers) a seconda del tipo di programma e, da poco tempo, anche funzioni normali. Ogni funzione chiamata può accettare fino a cinque argomenti, passati nei registri r1—r5, mentre il valore restituito è passato a r0. È garantito che dopo il ritorno dalla funzione il contenuto dei registri r6—r9 non cambierà.
Per una traduzione efficace dei programmi, i registri r0—r11 per tutte le architetture supportate sono mappati chiaramente sui registri reali, tenendo conto delle peculiarità dell'ABI dell'architettura corrente. Ad esempio, per x86_64 i registri r1—r5, utilizzati per il passaggio dei parametri delle funzioni, sono mappati su rdi, rsi, rdx, rcx, r8, che sono usati per il passaggio dei parametri alle funzioni in 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, e nel registro r1 viene passato un puntatore al contesto — a seconda del tipo di programma questo può essere, ad esempio, una struttura (per XDP) o una struttura (per vari programmi di rete) o una struttura (per vari tipi di programmi di tracing) e così via.
Quindi, avevamo un insieme di registri, helper del kernel, stack, puntatore al contesto e memoria condivisa sotto forma di mappe. Non è che tutto questo fosse categoricamente necessario nel viaggio, ma…
Continuiamo 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 Codice — è la codifica dell'istruzione, Dst/Src — sono rispettivamente le codifiche del destinatario e della fonte, Off — un offset di 16 bit segnato, e Imm — è un intero segnato a 32 bit usato in alcune istruzioni (simile alla costante K di cBPF). La codifica Codice 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) includono istruzioni di salto.
Il piano successivo per lo studio del sistema di istruzioni BPF è il seguente: invece di elencare meticolosamente tutte le istruzioni e i loro parametri, esamineremo un paio di esempi in questa sezione e da essi sarà chiaro come sono strutturate realmente le istruzioni e come disassemblare manualmente qualsiasi file binario per BPF. Per consolidare il materiale, più avanti nell'articolo ci imbatteremo nuovamente in istruzioni individuali nelle sezioni riguardanti Verifier, compilatore JIT, traduzione del BPF classico, così come durante lo studio delle mappe, chiamando funzioni, ecc.
Quando parleremo di istruzioni individuali, faremo riferimento ai file del kernel e , nei quali sono definiti i codici numerici delle istruzioni BPF. Durante lo studio autonomo dell'architettura e/o l'analisi dei binari, puoi trovare la semantica nelle seguenti fonti, ordinate per difficoltà: , , e, naturalmente, nei codici sorgente di Linux — verifier, JIT, interprete BPF.
Esempio: disassemblare BPF nella mente
Esaminiamo un esempio in cui compileremo il programma readelf-example.c e daremo un'occhiata al binario risultante. Riveleremo il contenuto originale readelf-example.c di seguito, 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 è un rientro e il nostro programma, quindi, consiste di quattro istruzioni:
Codice Dst 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 meno significativi rappresentano la classe dell'istruzione. Nel nostro caso, il quarto bit è vuoto per tutte le istruzioni, quindi le classi delle istruzioni sono, rispettivamente, 7, 5, 7, 5. La classe 7 è BPF_ALU64, mentre 5 è BPF_JMP. Per entrambe le classi, il formato dell'istruzione è lo stesso (vedi sopra) e possiamo riscrivere il nostro programma in questo modo (nel contempo riscriviamo le altre colonne in modo comprensibile):
Op S Classe Dst 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 è . Essa assegna un valore al registro di destinazione. Se il bit s (source) è impostato, il valore viene prelevato dal registro sorgente, mentre se, come nel nostro caso, non è impostato, il valore viene prelevato dal campo Imm. Quindi, nelle prime e terze istruzioni, eseguiamo l'operazione r0 = Imm. Di seguito, l'operazione di classe 1 JMP è (salto se uguale). Nel nostro caso, poiché il bit S è pari a zero, essa confronta il valore del registro sorgente con il campo Imm. Se i valori coincidono, il salto avviene a PC + Off, dove PC, come al solito, 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 Classe 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, mentre nel registro r0 viene restituito un valore al kernel, possiamo vedere che se il puntatore al contesto è uguale a zero, restituiamo 1, altrimenti 2. Verifichiamo se abbiamo ragione, guardando il codice sorgente:
$ cat readelf-example.c
int foo(void *ctx)
{
return ctx ? 2 : 1;
}Sì, è un programma privo di senso, ma viene tradotto in sole quattro istruzioni semplici.
Eccezione d'esempio: istruzione di 16 byte
In precedenza abbiamo menzionato che alcune istruzioni occupano più di 64 bit. Questo si applica, ad esempio, all'istruzione lddw (Codice = 0x18 = | | ) — caricare un doppio word nel registro dai campi Imm. Il punto è che Imm ha una dimensione di 32, e una parola doppia è di 64 bit, quindi non è possibile caricare in un registro un valore immediato a 64 bit in un'unica istruzione a 64 bit. A tal fine, vengono utilizzate 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
Esportazione 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 ancora l'istruzione lddw, quando parleremo di rilocazioni e dell'uso delle mappe.
Esempio: disassemblare BPF utilizzando strumenti standard
Quindi, abbiamo imparato a leggere i codici binari BPF e siamo pronti a decifrare qualsiasi istruzione, se necessario. Tuttavia, vale la pena dire che nella pratica è più comodo e veloce disassemblare programmi utilizzando strumenti standard, ad esempio:
$ llvm-objdump -d x64.o
Disassemblaggio della sezione .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 dalla Alexei Starovoitov nel .)
Gli oggetti BPF - programmi e mappe - vengono creati dallo spazio utente utilizzando i comandi BPF_PROG_LOAD e BPF_MAP_CREATE della chiamata di sistema bpf(2), parleremo di come avviene esattamente questo nella prossima sezione. Durante questo processo, vengono create le strutture dati del kernel e per ciascuna di esse refcount (contatore riferimenti) viene impostato su uno, e viene restituito all'utente un descrittore di file che punta all'oggetto. Dopo la chiusura del descrittore refcount il contatore di riferimenti dell'oggetto viene decrementato di uno, e quando raggiunge zero, l'oggetto viene distrutto.
Se il programma utilizza mappe, allora refcount il numero di questi mappe aumenta di uno dopo il caricamento del programma, cioè i loro descrittori di file possono essere chiusi dal processo utente e in tal modo refcount non diventa zero:

Dopo un caricamento riuscito del programma, in genere lo colleghiamo a qualche generatore di eventi. Ad esempio, possiamo associarlo a un'interfaccia di rete per elaborare pacchetti in arrivo o collegarlo a qualche tracepoint nel kernel. A questo punto, anche il contatore di riferimenti aumenterà di uno e potremo chiudere il descrittore di file nel programma di caricamento.
Cosa succede se ora chiudiamo il lavoro del caricatore? Dipende dal tipo di generatore di eventi (hook). Tutti gli hook di rete esisteranno dopo la chiusura del caricatore, questi sono noti come hook globali. Ad esempio, i programmi di tracciamento verranno rilasciati dopo la chiusura del processo che li ha creati (e perciò sono chiamati locali, da "local to the process"). Tecnacamente, gli hook locali possiedono sempre un descrittore di file corrispondente nello spazio utente e quindi vengono chiusi con la chiusura del processo, mentre gli hook globali no. Nella seguente illustrazione, cerco di mostrare con croci rosse come la terminazione del programma di caricamento influisca sulla vita degli oggetti in caso di hook locali e globali.

Perché esiste una distinzione tra hook locali e globali? L'esecuzione di alcuni tipi di programmi di rete ha senso anche senza userspace, ad esempio, immaginate una protezione contro il DDoS: il caricatore imposta delle regole e collega un programma BPF all'interfaccia di rete, dopodiché il caricatore può terminare tranquillamente. D'altra parte, immaginate un programma di debug di tracciamento che avete scritto in dieci minuti: dopo la sua conclusione, vorreste che non ci fosse spazzatura nel sistema, e gli hook locali garantiscono proprio questo.
D'altro canto, immaginate di voler collegarvi a un tracepoint nel kernel e raccogliere statistiche per molti anni. In questo caso, vorreste concludere la parte utente e tornare a controllare le statistiche di tanto in tanto. Una tale possibilità è offerta dal filesystem bpf. È un filesystem pseudo che esiste solo in memoria, il quale consente di creare file che fanno riferimento agli oggetti BPF e, in questo modo, aumentano refcount la vita degli oggetti. Dopodiché, il caricatore può terminare il lavoro mentre gli oggetti creati rimangono attivi.

La creazione di file in bpffs, riferendosi agli oggetti BPF, è chiamata "pinning" ("pin", come nella frase seguente: "il processo può pin un programma o una mappa BPF"). La creazione di file oggetto per oggetti BPF ha senso non solo per prolungare la vita degli oggetti locali, ma anche per facilitare l'uso degli oggetti globali: tornando all'esempio del programma globale per la protezione da DDoS, vogliamo avere la possibilità di tornare di tanto in tanto e controllare le statistiche.
Il file system BPF viene solitamente montato in /sys/fs/bpf, ma può essere montato anche 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 syscall BPF. Per illustrare, prendiamo un programma, compiliamolo, carichiamolo e fissiamolo in bpffs. Il nostro programma non fa nulla di utile, riportiamo il suo codice solo per permettervi di 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 carichiamo il nostro programma utilizzando l'utilità bpftool e guardiamo le relative syscall bpf(2) (alcune righe non pertinenti 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 fissato questo descrittore di file come file "bpf-mountpoint\/test". Dopo di che il programma di caricamento bpftool ha terminato l'esecuzione, ma il nostro programma è rimasto nel kernel, anche se non lo 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 usando il comune unlink(2) e dopo di ciò, 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 degli oggetti
Parlando della rimozione degli oggetti, è importante specificare che dopo aver scollegato il programma dal hook (generatore di eventi), nessun nuovo evento ne causerà l'esecuzione, tuttavia, tutte le istanze attuali del programma verranno terminate normalmente.
Alcuni tipi di programmi BPF consentono di sostituire il programma al volo, ovvero forniscono l'atomicità della sequenza replace = detach old program, attach new program. In questo caso, tutte le istanze attive della vecchia versione del programma termineranno la loro esecuzione e nuovi gestori di eventi saranno creati dalla nuova programma, e l'"atomicità" significa qui che nessun evento sarà perso.
Collegamento dei programmi alle fonti di eventi
In questo articolo non descriveremo separatamente il collegamento dei programmi alle fonti di eventi, poiché è più sensato studiarlo 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, avente il seguente prototipo:
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);Qui il comando cmd è uno dei valori del tipo , attr è un puntatore ai parametri per un programma specifico e size è la dimensione dell'oggetto puntato, ovvero di solito è sizeof(*attr). Nel kernel 5.8, la chiamata di sistema bpf supporta 34 comandi diversi, e union bpf_attr richiede 200 righe. Ma non dovremmo lasciarci spaventare, poiché ci familiarizzeremo con comandi e parametri nel corso di diversi articoli.
Iniziamo con il comando BPF_PROG_LOAD, che crea programmi BPF: prende un insieme di istruzioni BPF e lo carica nel kernel. Al momento del caricamento si avvia il verifier, poi il JIT compiler e, dopo un esito positivo, all'utente viene restituito il descrittore di file del programma. Abbiamo visto cosa succede dopo nel capitolo precedente .
Ora scriveremo un programma utente che caricherà un semplice programma BPF, ma prima dobbiamo decidere quale programma vogliamo caricare: dobbiamo 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: prenderemo un programma di tipo BPF_PROG_TYPE_XDP, che restituirà il valore XDP_PASS (saltare tutti i pacchetti). In assembly BPF appare molto semplice:
r0 = 2
exitDopo aver deciso cosa cosa caricheremo, possiamo spiegare 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. In questo modo, ogni istruzione del programma BPF viene imballata in una struttura . Il primo elemento insns corrisponde all'istruzione r0 = 2, il secondo — exit.
Nota a margine. Nel kernel sono definiti macro più comodi 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é la scrittura di programmi BPF in codici macchina è necessaria solo per scrivere test nel kernel e articoli su BPF, l'assenza di questi macro non complica realmente la vita dello sviluppatore.
Dopo aver definito il programma BPF, passiamo al suo caricamento nel kernel. Il nostro insieme minimalista di parametri attr include il tipo di programma, un insieme e un numero di istruzioni, una licenza obbligatoria e anche il nome "woo", che utilizziamo 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 verrebbe distrutto dal kernel alla chiusura del descrittore di file che ci ha restituito la chiamata di sistema bpf, e non lo vedremo nel sistema.
Bene, siamo pronti per il test. Compiliamo e avviamo il programma in strace, per verificare che tutto funzioni come dovrebbe:
$ 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 a posto, bpf(2) ci ha restituito il descrittore 3 e siamo entrati in un ciclo infinito con pause(). Proviamo a trovare il nostro programma nel sistema. A tale scopo, andremo in un altro terminale e useremo lo strumento 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)Possiamo vedere che nel sistema è presente un programma caricato woo il cui ID globale è 390, e che attualmente nel processo simple-prog è presente un descrittore di file aperto che punta al programma (e se simple-prog finisce l'esecuzione, allora woo scomparirà). Come previsto, il programma woo occupa 16 byte — due istruzioni — codici binari nell'architettura BPF, ma in forma nativa (x86_64) sono 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 a onor del vero, il nostro programma è troppo semplice, e per programmi non banali 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 sia a programmi dallo spazio utente. Questi oggetti sono chiamati mappe e in questa sezione mostreremo come gestirli tramite una chiamata di sistema. bpf.
Diciamo subito che le possibilità delle mappe non si limitano solo all'accesso alla memoria condivisa. Ci sono mappe a scopo speciale, che contengono, ad esempio, puntatori a programmi BPF o puntatori a interfacce di rete, mappe per lavorare con eventi di perf e così via. Non ne parleremo qui per non confondere il lettore. Inoltre, ignoriamo i problemi di sincronizzazione, poiché non sono importanti per i nostri esempi. L'elenco completo dei tipi di mappe disponibili può essere trovato in , in questa sezione prenderemo come esempio il primo tipo storico, la 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, i cui chiavi sono di tipo int, e i 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 chiave e valore dobbiamo specificare le loro dimensioni in byte. Per creare mappe si utilizza il comando BPF_MAP_CREATE della chiamata di sistema bpf. Diamo un'occhiata a un programma più o meno minimo che crea una mappa. Dopo il programma precedente che carica 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 dimensioni sizeof(int), in cui posso inserire al massimo quattro elementi». Nella creazione delle mappe BPF possono essere specificati anche altri parametri, ad esempio, così come nell'esempio con il 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 si trova una 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 inviamo il nostro programma in background o apriamo un altro terminale e diamo un'occhiata al nostro oggetto usando 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ò usare questo ID per aprire una mappa già esistente con il comando BPF_MAP_GET_FD_BY_ID della chiamata di sistema bpf.
Ora possiamo giocare con la nostra tabella hash. Diamo un'occhiata al suo contenuto:
$ sudo bpftool map dump id 114
Trovati 0 elementiVuota. Mettiamo un valore dentro hash[1] = 1:
$ sudo bpftool map update id 114 chiave 1 0 0 0 valore 1 0 0 0Rivediamo la tabella ancora una volta:
$ 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é bptftool non sa quale tipo abbiano i valori nella tabella hash. (Può ricevere queste informazioni usando BTF, ma non è il momento di parlarne.)
Come fa bpftool a leggere e aggiungere elementi? 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, chiave=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, chiave=0x55856ab65280, valore=0x55856ab652a0}, 120) = 0
chiave: 01 00 00 00 valore: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, chiave=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENTInizialmente abbiamo aperto la mappa usando il suo ID globale con il comando BPF_MAP_GET_FD_BY_ID e bpf(2) ci ha restituito il descrittore 3. Poi, usando il comando BPF_MAP_GET_NEXT_KEY abbiamo trovato la prima chiave nella tabella, passando NULL come puntatore alla «chiave precedente». Avendo una chiave possiamo fare BPF_MAP_LOOKUP_ELEM, che restituisce il valore nel puntatore value. Il passo successivo è cercare di trovare il prossimo elemento, passando il puntatore alla chiave attuale, ma la nostra tabella contiene solo un elemento e il comando BPF_MAP_GET_NEXT_KEY false, se l'elemento da sostituire è ENOENT.
Bene, cambiamo il valore con la chiave 1, supponiamo che la nostra logica aziendale richieda di scrivere hash[1] = 2:
$ sudo strace -e bpf bpftool map update id 114 chiave 1 0 0 0 valore 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, chiave=0x55dcd72be260, valore=0x55dcd72be280, flags=BPF_ANY}, 120) = 0Come previsto, è molto semplice: il comando BPF_MAP_GET_FD_BY_ID apre la nostra mappa per ID, e il comando BPF_MAP_UPDATE_ELEM sovrascrive l'elemento.
In conclusione, dopo aver creato una tabella hash da un programma possiamo leggere e scrivere il suo contenuto da un altro. Nota che se siamo riusciti a farlo da riga di comando, anche qualsiasi altro programma nel sistema può farlo. Oltre ai comandi sopra descritti, per lavorare con le mappe dallo spazio degli utenti sono disponibili :
BPF_MAP_LOOKUP_ELEM: trova il valore per una chiaveBPF_MAP_UPDATE_ELEM: aggiorna/crea valorizzazioneBPF_MAP_DELETE_ELEM: elimina la chiaveBPF_MAP_GET_NEXT_KEY: trova la chiave successiva (o la prima)BPF_MAP_GET_NEXT_ID: consente di scorrere tutte le mappe esistenti, ed è così che funzionabpftool mapBPF_MAP_GET_FD_BY_ID: aprire una mappa esistente tramite il suo ID globaleBPF_MAP_LOOKUP_AND_DELETE_ELEM: aggiornare atomici il valore dell'oggetto e restituire il valore 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 degli utenti appare esattamente come lavorare con le tabelle hash.
Per completezza, completiamo i nostri esperimenti con la tabella hash. Ricorda che abbiamo creato una tabella che può contenere fino a quattro chiavi? Aggiungiamo alcuni 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 0Tutto bene finora:
$ 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 aggiungere un altro:
$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: aggiornamento fallito: lista argomenti troppo lungaCome previsto, non ci siamo riusciti. Esaminiamo l'errore più nel dettaglio:
$ 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 (lista argomenti troppo lunga)
Error: aggiornamento fallito: lista argomenti troppo lunga
+++ uscita 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, così come creare e gestire le mappe dallo spazio utente. È ora logico guardare a come possiamo usare le mappe all'interno dei programmi BPF. Potremmo parlare di questo, in un linguaggio di programmi difficili da leggere in codici macchina macro, ma ora è il momento di mostrare come si scrivono e si mantengono i programmi BPF veramente — con libbpf.
(Per i lettori insoddisfatti della mancanza di un esempio a basso livello: esamineremo in dettaglio i programmi che utilizzano le mappe e le funzioni di supporto, create con libbpf e spiegheremo cosa succede a livello di istruzioni. Per i lettori che non sono soddisfatti molto, abbiamo aggiunto nel posto appropriato dell'articolo.)
Scrittura di programmi BPF con libbpf.
Scrivere programmi BPF con codici macchina può essere interessante solo all'inizio, dopodiché subentra la saturazione. In quel momento è necessario indirizzare la propria attenzione su llvm, che ha un backend per la generazione di codice per l'architettura BPF, e anche sulla libreria libbpf, che consente di scrivere la parte utente delle applicazioni BPF e caricare codice dei programmi BPF, generato con llvm/clang.
In realtà, come vedremo in questo e nei prossimi articoli, libbpf fa davvero molto lavoro e senza di essa (o strumenti simili — iproute2, libbcc, libbpf-go, ecc.) non si può vivere. Una delle funzionalità killer del progetto libbpf è BPF CO-RE (Compile Once, Run Everywhere) — un progetto che permette di scrivere programmi BPF portabili da un kernel all'altro, con la possibilità di essere eseguiti su diverse API (ad esempio, quando la struttura del kernel cambia da versione a versione). Per poter lavorare con CO-RE, il tuo kernel deve essere compilato con supporto BTF (come farlo lo spieghiamo nella sezione . Controllare se il tuo kernel è compilato con BTF o meno è molto semplice — basta verificare l'esistenza del seguente file:
$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M 29 Jul 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 sfruttano libbpf. Parleremo in dettaglio di CO-RE nel prossimo articolo, mentre in questo — costruisci semplicemente un kernel con CONFIG_DEBUG_INFO_BTF.
La libreria libbpf che si trova direttamente nella directory tools/lib/bpf del kernel e il suo sviluppo avviene attraverso la mailing list bpf@vger.kernel.org. Tuttavia, per le esigenze delle applicazioni che operano al di fuori del kernel, è supportato un repository separato in cui la libreria del kernel è mirrorata per un accesso in sola lettura più o meno così com'è.
In questa sezione vedremo come creare un progetto che utilizza libbpf, scriveremo alcuni (più o meno inutili) programmi di test e analizzeremo in dettaglio come funziona tutto questo. Questo ci permetterà di spiegare più facilmente nelle sezioni successive come i programmi BPF interagiscono con le mappe, gli helper del kernel, il BTF, ecc.
Di solito, i progetti che utilizzano libbpf aggiungono un repository di GitHub come submodulo git, facciamolo anche noi:
$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Inizializzato repository Git vuoto in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Clonazione in '/tmp/libbpf-example/libbpf'...
remote: Enumerando oggetti: 200, fatto.
remote: Conteggio oggetti: 100% (200/200), fatto.
remote: Compressione oggetti: 100% (103/103), fatto.
remote: Totale 3354 (delta 101), riutilizzati 118 (delta 79), pacchetti riutilizzati 3154
Ricezione oggetti: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, fatto.
Risoluzione dei delta: 100% (2176/2176), fatto.Si compila 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 per questa sezione è il seguente: scriveremo un programma BPF di tipo BPF_PROG_TYPE_XDP, lo stesso visto nel precedente esempio, ma in C, lo compileremo usando clang, e scriveremo un programma helper che lo caricherà nel kernel. Nelle sezioni successive espanderemo le funzionalità sia del programma BPF che del programma helper.
Esempio: creiamo un'applicazione completa con libbpf
Per iniziare, utilizziamo il file /sys/kernel/btf/vmlinux, di cui si parlava sopra, e creiamo il suo equivalente in forma di file header:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.hIn questo file saranno memorizzate tutte le strutture dati presenti nel nostro kernel, per esempio, così viene definito l'header 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 scriveremo 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. In primo luogo, 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 strutturati i kernel. Il prossimo file di intestazione proviene dalla libreria libbpf. Ora ci serve solo per definire la macro SEC, che invia il simbolo alla sezione corrispondente del file oggetto ELF. Il nostro programma si trova nella sezione xdp/simple, dove prima della barra definisce il tipo di programma BPF—questo è un accordo utilizzato in libbpf, in base al nome della sezione, inserirà il tipo corretto all'esecuzione bpf(2). Il programma BPF in 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, ancor meglio se superiore (vedi 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 installato recentemente. Inoltre, non dimenticate -O2, senza questa opzione potrebbero esserci 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 :
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 tal fine, la libreria libbpf ci offre due opzioni—utilizzare un'API di basso livello o un'API di alto livello. Seguiremo la seconda strada, poiché vogliamo imparare a scrivere, caricare e connettere i programmi BPF con il minimo sforzo per il loro successivo studio.
Per cominciare, dobbiamo generare lo "scheletro" del nostro programma dal suo binario utilizzando la stessa utilità bpftool — il coltellino svizzero del mondo BPF (cosa che si può intendere anche letteralmente, poiché Daniel Borkman è uno dei creatori e maintainer di BPF ed è 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 la gestione — caricamento, attacco, rimozione del nostro oggetto. Nel nostro caso semplice, sembra eccessivo, ma funziona anche quando il file oggetto contiene numerosi programmi BPF e mappe e per caricare questo gigantesco ELF ci basta generare lo scheletro e chiamare una o due funzioni dall'applicazione utente, alla quale ora ci sposteremo.
Infatti, 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 un'API a basso livello: la struttura struct bpf_program *simple e struct bpf_link *simple. La prima struttura descrive specificamente il nostro programma, scritto nella sezione xdp/simple, mentre la seconda descrive come il programma si collega alla sorgente degli eventi.
La funzione xdp_simple_bpf__open_and_load, apre l'oggetto ELF, lo analizza, crea tutte le strutture e sottostrutture (tranne il programma nell'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) = 4Ora diamo un'occhiata al nostro programma utilizzando bpftool. Troveremo 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 lo dumpiamo (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 pezzi 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 attraverso BPF_BTF_LOAD, e poi ha указato 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" — creare eventi di performance, gestire l'hardware (ad esempio, reindirizzare i pacchetti) e così via.
Esempio: bpf_get_smp_processor_id
Nel contesto del paradigma "apprendere con esempi", consideriamo una delle funzioni di supporto, bpf_get_smp_processor_id(), nel file kernel/bpf/helpers.c. Essa restituisce il numero del processore su cui viene eseguito il programma BPF che la richiama. Ma ciò che ci interessa non è 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 di supporto BPF sono simili alle definizioni delle chiamate di sistema di Linux. Qui, per esempio, viene definita una funzione senza argomenti. (Una funzione che accetta, diciamo, tre argomenti, viene definita utilizzando il macro BPF_CALL_3. Il numero massimo di argomenti è cinque.) Tuttavia, questa è solo la prima parte della definizione. La seconda parte consiste nella definizione di una struttura di tipo struct bpf_func_proto, che contiene una descrizione della funzione di supporto comprensibile al 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 di supporto
Affinché i programmi BPF di un determinato tipo possano utilizzare questa funzione, devono registrarla, per esempio, per il tipo BPF_PROG_TYPE_XDP nel kernel è definita una funzione xdp_func_proto, che in base all'ID della funzione di supporto determina se XDP supporta questa funzione o meno. 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;
...
}
}I nuovi tipi di programmi BPF vengono "definiti" nel file usando il macro BPF_PROG_TYPE. Definiti è tra virgolette, poiché si tratta di una definizione logica, mentre in termini di linguaggio C la definizione di un insieme di strutture specifiche avviene altrove. 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 del tipo struct bpf_verifier_ops, che viene inizializzato con il valore _name ## _verifier_ops, cioè, 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 funzione familiare xdp_func_proto, che verrà eseguita dal verifier ogni volta che incontra una chiamata a qualche funzione all'interno di un programma BPF, vedi .
Vediamo come un programma BPF ipotetico utilizza la funzione bpf_get_smp_processor_id. Per 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> una libreria libbpf come
static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;cioè, 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 (il file bpf_helper_defs.h in kernel è generato da uno script, quindi i numeri "magici" sono ok). Questa funzione non accetta argomenti e restituisce un valore di tipo __u32. Quando la eseguiamo nel nostro programma, clang genera un'istruzione BPF_CALL "di tipo corretto". Compiliamo il programma e guardiamo la 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 exitGià nella prima riga vediamo l'istruzione call, parametro IMM il quale è uguale a 8, e SRC_REG è zero. Secondo il convenzione ABI utilizzata dal verifier, questa è la chiamata alla funzione helper numero otto. Dopo la sua esecuzione, la logica è semplice. Il valore restituito dal registro r0 viene copiato in r1 e alle righe 2,3 viene convertito al tipo u32 — i 32 bit superiori vengono azzerati. Alle righe 4,5,6,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 noi stessi: carichiamo il programma e guardiamo 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) exitOttimo, il verifier ha trovato il kernel-helper corretto.
Esempio: passiamo gli argomenti e, infine, avviamo il programma!
Tutte le funzioni helper a livello di esecuzione hanno un prototipo
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)I parametri vengono passati alle funzioni helper tramite registri r1—r5, e il valore viene restituito in registro r0. Non ci sono funzioni che accettano più di cinque argomenti e non è previsto di aggiungerne supporto in futuro.
Vediamo ora il nuovo kernel helper e come BPF passa i parametri. Riscriviamolo xdp-simple.bpf.c nel seguente modo (le altre righe non sono cambiate):
SEC("xdp/simple")
int simple(void *ctx)
{
bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
return XDP_PASS;
}Il nostro programma stampa il numero della CPU su cui è in esecuzione. Compiliamolo e guardiamo il 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 memorizzando nello stack la stringa running on CPU%un, e poi alla riga 8 avviamo il nostro amico bpf_get_smp_processor_id. Nelle righe 9-12 prepariamo gli argomenti dell'helper bpf_printk — registri r1, r2, r3. Perché sono tre e non due? Perché bpf_printk — attorno al vero helper bpf_trace_printk, al quale è necessario passare la dimensione della stringa di formato.
Aggiungiamo ora un paio di righe a xdp-simple.c, affinché il nostro programma si colleghi all'interfaccia lo e venga eseguito 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, "failed to open and/or load BPF objectn");
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 utilizziamo la funzione bpf_set_link_xdp_fd, che collega i programmi BPF di tipo XDP alle interfacce di rete. Abbiamo hardcodato il numero dell'interfaccia lo, che è sempre uguale a 1. Lanciamo la funzione due volte, per scollegare prima il vecchio programma, se era connesso. Notare che ora non abbiamo bisogno di una chiamata pause o di un ciclo infinito: il nostro programma di caricamento si fermerà, ma il programma BPF non sarà distrutto, poiché è connesso alla sorgente degli eventi. Dopo un caricamento e una connessione riusciti, il programma verrà eseguito per ogni pacchetto di rete che arriva a lo.
Carichiamo il programma e guardiamo l'interfaccia lo:
$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp nome 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 notati su lo e elaborati su CPU0 — il nostro primo completo programma BPF senza senso ha funzionato!
Vale la pena notare che bpf_printk non scrive a caso nel file di debug: non è il miglior helper da utilizzare in produzione, ma il nostro obiettivo era mostrare qualcosa di semplice.
Accesso a maps da programmi BPF
Esempio: utilizziamo una mappa da un programma BPF
Nei capitoli precedenti abbiamo imparato a creare e utilizzare le mappe dallo spazio utente, ora guardiamo la parte kernel. Iniziamo, come al solito, con un esempio. Riscriviamo 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 della mappa woo: è un array di 8 elementi, che memorizza valori di tipo u64 (in C avremmo definito un array così come u64 woo[8]). Nel programma "xdp/simple" prendiamo il numero dell'attuale CPU nella variabile key e poi, usando la funzione helper bpf_map_lookup_element otteniamo un puntatore alla corrispondente voce nell'array, che incrementiamo di uno. In altre parole: stiamo contando le statistiche su quale CPU ha elaborato i pacchetti in arrivo. 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-simpleControlliamo che si sia collegato a lo e inviamo un po' di pacchetti:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue stato UNKNOWN mode DEFAULT gruppo 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 diamo un'occhiata al 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. Questo non ci importa, l'importante è che il programma funziona e abbiamo capito come accedere alle mappe dai programmi BPF — tramite .
Puntatore mistico
Quindi, possiamo accedere da un programma BPF alla mappa tramite chiamate del tipo
val = bpf_map_lookup_elem(&woo, &key);dove la funzione helper appare come
void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)ma noi passiamo un puntatore &woo a una struttura anonima struct { ... }…
Se diamo un'occhiata all'assembly 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
Disassembly of section 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
...e si trova nelle rilocazioni:
$ llvm-readelf -r xdp-simple.bpf.o | head -4
Sezione di rilocazione '.relxdp/simple' all'offset 0xe18 contiene 1 voci:
Offset Info Tipo Valore Simbolo Nome Simbolo
0000000000000020 0000002700000001 R_BPF_64_64 0000000000000000 wooMa se osserviamo il 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'esecuzione del nostro programma di caricamento il riferimento a &woo è stato sostituito da qualcosa dalla biblioteca 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) = 5Vediamo che libbpf ha creato la 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_xattrdalibbpf/src/libbpf.c
L'ultima funzione, tra le altre cose, chiamerà bpf_object__create_maps, che crea o apre le mappe esistenti, trasformandole in descrittori di file. (Questo è il punto in cui vediamo BPF_MAP_CREATE nell'output strace.) Poi viene chiamata la funzione bpf_object__relocate ed è proprio questa che ci interessa, poiché ricordiamo che abbiamo visto woo nella tabella delle relocazioni. Esplorandola, alla fine arriviamo alla funzione bpf_program__relocate, che si :
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 il registro sorgente con BPF_PSEUDO_MAP_FD, e il primo IMM con il descrittore di file della nostra mappa e, se è per esempio 0xdeadbeef, allora alla fine otterremo l'istruzione
18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 llÈ così che le informazioni sulle mappe vengono passate a un programma BPF caricato specifico. Qui la mappa può essere sia creata tramite BPF_MAP_CREATE, sia aperta tramite ID utilizzando BPF_MAP_GET_FD_BY_ID.
In sintesi, utilizzando libbpf l'algoritmo è il seguente:
- durante la compilazione vengono create voci nella tabella delle relocazioni per i riferimenti alle mappe
libbpfapre l'oggetto ELF, trova tutte le mappe utilizzate e crea descrittori di file per esse- i descrittori di file vengono caricati nel kernel come parte dell'istruzione
LD64
Come potete capire, questo non è tutto, e dobbiamo dare un'occhiata al kernel. Fortunatamente, abbiamo una pista — abbiamo scritto un valore BPF_PSEUDO_MAP_FD nel registro sorgente e possiamo cercarlo, il che ci porterà nel sacro dei santi — kernel/bpf/verifier.c, dove una funzione con un nome caratteristico sostituisce il descrittore di file con l'indirizzo della 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 è disponibile ). 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 rispettiva struttura
struct bpf_map
Durante il caricamento di un binario ELF tramite libbpf ci sono molti altri eventi, ma ne discuteremo in articoli successivi.
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 raccogliere le dipendenze o stai risparmiando ogni bit, o scrivi un programma del tipo , che genera codice binario BPF al volo.
Per facilitare la comprensione della logica, per questi scopi scriveremo di nuovo il nostro esempio xdp-simple. Il codice completo e un po' ampliato del programma discusso in questo esempio può essere trovato 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 utilizzi questa mappa,
- collegare il programma all'interfaccia
lo,
che si traduce in termini semplici 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 sulla chiamata di sistema bpf — "nucleo, per favore, fammi una nuova mappa come un array di 8 elementi di tipo __u64 e restituisci un file descriptor":
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 in modo semplice:
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 difficile prog_load — è la definizione del nostro programma BPF sotto forma di 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 totale, dobbiamo scrivere 14 istruzioni sotto forma di strutture di tipo struct bpf_insn (consiglio: prendi il dump qui sopra, rileggi la sezione sulle istruzioni, apri e e prova a determinare struct bpf_insn insns[] da solo):
struct bpf_insn insns[] = {
\/\* 85 00 00 00 08 00 00 00 call 8 *\/\n {
.code = BPF_JMP | BPF_CALL,
.imm = 8,
},\n
\/\* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 *\/\n {
.code = BPF_MEM | BPF_STX,
.off = -4,
.src_reg = BPF_REG_0,
.dst_reg = BPF_REG_10,
},\n
\/\* bf a2 00 00 00 00 00 00 r2 = r10 *\/\n {
.code = BPF_ALU64 | BPF_MOV | BPF_X,
.src_reg = BPF_REG_10,
.dst_reg = BPF_REG_2,
},\n
\/\* 07 02 00 00 fc ff ff ff r2 += -4 *\/\n {
.code = BPF_ALU64 | BPF_ADD | BPF_K,
.dst_reg = BPF_REG_2,
.imm = -4,
},\n
\/\* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll *\/\n {
.code = BPF_LD | BPF_DW | BPF_IMM,
.src_reg = BPF_PSEUDO_MAP_FD,
.dst_reg = BPF_REG_1,
.imm = map_fd,
},\n { }, \/\* placeholder *\/\n
\/\* 85 00 00 00 01 00 00 00 call 1 *\/\n {
.code = BPF_JMP | BPF_CALL,
.imm = 1,
},\n
\/\* b7 01 00 00 00 00 00 00 r1 = 0 *\/\n {
.code = BPF_ALU64 | BPF_MOV | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 0,
},\n
\/\* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 *\/\n {
.code = BPF_JMP | BPF_JEQ | BPF_K,
.off = 4,
.src_reg = BPF_REG_0,
.imm = 0,
},\n
\/\* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) *\/\n {
.code = BPF_MEM | BPF_LDX,
.off = 0,
.src_reg = BPF_REG_0,
.dst_reg = BPF_REG_1,
},\n
\/\* 07 01 00 00 01 00 00 00 r1 += 1 *\/\n {
.code = BPF_ALU64 | BPF_ADD | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 1,
},\n
\/\* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 *\/\n {
.code = BPF_MEM | BPF_STX,
.src_reg = BPF_REG_1,
.dst_reg = BPF_REG_0,
},\n
\/\* b7 01 00 00 02 00 00 00 r1 = 2 *\/\n {
.code = BPF_ALU64 | BPF_MOV | BPF_K,
.dst_reg = BPF_REG_1,
.imm = 2,
},\n
\/\* : bf 10 00 00 00 00 00 00 r0 = r1 *\/\n {
.code = BPF_ALU64 | BPF_MOV | BPF_X,
.src_reg = BPF_REG_1,
.dst_reg = BPF_REG_0,
},\n
\/\* 95 00 00 00 00 00 00 00 exit *\/\n {
.code = BPF_JMP | BPF_EXIT
},\n};Esercizio per chi non l'ha scritto da solo — trova map_fd.
Nella nostra programma rimane ancora una parte non rivelata — xdp_attach. Sfortunatamente, i programmi di tipo XDP non possono essere collegati tramite una chiamata di sistema bpf. Le persone che hanno creato BPF e XDP provenivano dalla comunità di rete di Linux, il che significa che utilizzavano l'interfaccia a loro più familiare (ma non per persone normali) per interagire con il kernel: , vedi anche . Il modo più semplice per implementare xdp_attach è copiare il codice da libbpf, ovvero, dal file , e lo 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("la segnalazione degli errori netlink non è supportata");
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 del genere:
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, "pid errato");
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;
/* aggiungo fd XDP */
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;
/* se l'utente ha passato dei flag, aggiungo anche quelli */
__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, "invio");
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
+++ uscito con 0 +++Controlliamo se il nostro programma si è connesso a lo:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue stato UNKNOWN mode DEFAULT gruppo default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 160Invieremo alcuni ping e controlleremo la 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
Trovati 8 elementiEvviva, tutto funziona. Nota, a proposito, che la nostra mappa viene di nuovo visualizzata come byte. Questo accade perché, a differenza di libbpf non abbiamo caricato le informazioni sui tipi (BTF). Ma parleremo di questo più nel dettaglio la prossima volta.
Strumenti di sviluppo
In questa sezione daremo un'occhiata al set minimo di strumenti per gli sviluppatori BPF.
In generale, per sviluppare programmi BPF non serve nulla di speciale: BPF funziona su qualsiasi distribuzione del kernel di qualità, e i programmi vengono compilati utilizzando clang, che può essere installato dal pacchetto. Tuttavia, a causa del fatto che il BPF è in fase di sviluppo, il kernel e gli strumenti cambiano continuamente; se non vuoi scrivere programmi BPF con metodi obsoleti del 2019, dovrai compilare
llvm/clangpahole- il tuo kernel
bpftool
(Per riferimento: questa sezione e tutti gli esempi nell'articolo sono stati eseguiti su Debian 10.)
llvm/clang
Il BPF è compatibile con LLVM e, sebbene da poco sia possibile compilare programmi per BPF anche utilizzando gcc, tutto lo sviluppo corrente avviene per LLVM. Pertanto, per prima cosa 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 verificare se tutto è stato compilato correttamente:
$ ./bin/llc --version
LLVM (http://llvm.org/):
versione LLVM 11.0.0git
Build ottimizzata.
Target predefinito: x86_64-unknown-linux-gnu
Host CPU: znver1
Target registrati:
bpf - BPF (host endian)
bpfeb - BPF (big endian)
bpfel - BPF (little endian)
x86 - X86 a 32 bit: Pentium-Pro e successivi
x86-64 - X86 a 64 bit: EM64T e AMD64(Le istruzioni per la compilazione clang provengono da .)
Non installeremo i programmi appena compilati, ma semplicemente li aggiungeremo a PATH, per esempio:
export PATH="`pwd`/bin:$PATH"(Puoi aggiungere questo in .bashrc o in un file separato. Personalmente, aggiungo tali cose in ~/bin/activate-llvm.sh e quando necessario eseguo . activate-llvm.sh.)
Pahole e BTF
Utilità pahole viene utilizzato durante la compilazione del kernel per creare informazioni di debug in formato BTF. Non approfondiremo in dettaglio la tecnologia BTF in questo articolo, se non per il fatto che è conveniente e vogliamo usarla. Pertanto, se intendi compilare il tuo kernel, prima compila 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 compilare il proprio kernel. Questo, in generale, non è obbligatorio, poiché potrete anche compilare e caricare programmi BPF su un kernel distribuito; tuttavia, avere un proprio kernel consente di sfruttare le più recenti funzionalità del BPF che nel migliore dei casi arriveranno al vostro distributore dopo mesi o, come nel caso di alcuni strumenti di debug, potrebbero non essere confezionate nel prossimo futuro. Inoltre, avere il proprio kernel permette di sentirsi importanti e di sperimentare con il codice.
Per costruire un kernel avete bisogno, innanzitutto, del kernel stesso e, in secondo luogo, di un file di configurazione del kernel. Per gli esperimenti con BPF possiamo utilizzare un normale o uno dei kernel per sviluppatori. Storicamente, lo sviluppo del BPF avviene nell'ambito della comunità di rete 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 vanno in uno dei due kernel — o . Le modifiche per il BPF vengono distribuite in modo simile tra e , che poi vengono integrate in net e net-next, rispettivamente. Maggiori dettagli si trovano in e . Quindi scegliete un 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 menzionati).
Questa articolo non include la spiegazione su come gestire i file di configurazione del kernel — si presume che abbiate già le competenze necessarie oppure da soli. Tuttavia, le seguenti istruzioni dovrebbero essere più o meno sufficienti per avere un sistema funzionante con supporto per il 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 a vostra scelta (probabilmente, la stessa CONFIG_BPF sarà già abilitata, poiché è utilizzata 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=yPoi possiamo facilmente compilare e installare i moduli e il kernel (per inciso, è possibile compilare il kernel utilizzando appena compilato clang, aggiungendo CC=clang):
$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make installe riavviare con il nuovo kernel (io utilizzo per questo kexec dal pacchetto kexec-tools):
v=5.8.0-rc6+ # se stai ricompilando l'attuale kernel, puoi fare v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -ebpftool
La utility più comunemente utilizzata nell'articolo sarà la utility bpftool, fornita insieme al kernel di Linux. È scritta e mantenuta dagli sviluppatori BPF per sviluppatori BPF e con essa si possono gestire tutti i tipi di oggetti BPF: caricare programmi, creare e modificare mappe, esplorare l'ecosistema BPF, ecc. La documentazione sotto forma di sorgenti per i man pages si può trovare o, già compilata, .
Al momento della scrittura di questo articolo bpftool viene fornita pronta solo per RHEL, Fedora e Ubuntu (vedi, ad esempio, , in cui si racconta una storia incompleta su come impacchettare bpftool in Debian). Ma se hai già compilato il tuo kernel, allora compilare bpftool è facilissimo:
$ cd ${linux}/tools/bpf/bpftool
# ... inserisci i percorsi all'ultimo clang, come descritto sopra
$ make -s
Rilevamento automatico delle funzionalità di sistema:
... libbfd: [ on ]
... disassembler-four-args: [ on ]
... zlib: [ on ]
... libcap: [ on ]
... clang-bpf-co-re: [ on ]
Rilevamento automatico delle funzionalità di sistema:
... libelf: [ on ]
... zlib: [ on ]
... bpf: [ on ]
$(qui ${linux} è la tua directory con il kernel.) Dopo aver eseguito questi comandi bpftool sarà compilato nella directory ${linux}/tools/bpf/bpftool e può essere aggiunto al percorso (soprattutto per l'utente root) oppure semplicemente copiato in /usr/local/sbin.
Compilare bpftool è meglio farlo usando l'ultimo clang, compilato come descritto sopra, e per controllare se è stato compilato correttamente — usando, ad esempio, il comando
$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...che mostrerà quali funzionalità BPF sono attivate nel tuo kernel.
A proposito, il comando precedente può essere eseguito come
# bpftool f p kQuesto è stato fatto per analogia con le utility del pacchetto iproute2, dove possiamo, ad esempio, dire ip a s eth0 anziché ip addr show dev eth0.
Conclusione
BPF consente di misurare e modificare efficacemente la funzionalità del kernel in volo. Il sistema si è rivelato molto riuscito, nelle 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 e lo sviluppo stesso dell'infrastruttura BPF non siano ancora finiti, il sistema ha già un ABI stabile che consente di costruire logiche di business affidabili ed efficienti.
Vale la pena notare che, secondo me, la tecnologia è diventata così popolare perché, da un lato, è possibile giocare (l'architettura della macchina può essere compresa più o meno in una sola sera), e dall'altro — risolvere problemi che non erano risolvibili (in modo elegante) prima della sua apparizione. Questi due componenti insieme spingono le persone a sperimentare e sognare, dando origine a sempre nuove soluzioni innovative.
Questo articolo, sebbene non sia particolarmente breve, è solo un'introduzione al mondo BPF e non descrive le funzionalità "avanzate" e le parti importanti dell'architettura. Il piano successivo è più o meno il seguente: il prossimo articolo sarà una panoramica sui tipi di programmi BPF (nella versione 5.8 sono supportati 30 tipi di programmi), quindi finalmente vedremo come scrivere vere applicazioni su BPF prendendo come esempio programmi per il tracciamento del kernel, poi arriverà il momento per un corso più approfondito sull'architettura BPF e infine — per esempi di applicazioni di rete e di sicurezza BPF.
Articoli precedenti di questo ciclo
Link
— documentazione su BPF da cilium, e più precisamente da Daniel Borkman, uno dei creatori e manutentori di BPF. Questa è una delle prime descrizioni serie, che si distingue dalle altre perché Daniel sa esattamente di cosa sta parlando e non ci sono errori evidenti. In particolare, in questo documento si discute di come lavorare con programmi BPF dei tipi XDP e TC utilizzando l'utilità nota
ipdal pacchettoiproute2.— file originale con documentazione su BPF classico e poi sull'extended BPF. È utile leggerlo se desiderate esplorare l'assemblatore e i dettagli tecnici dell'architettura.
. Aggiornato raramente, ma con precisione, poiché scrivono Alexei Starovoitov (autore di eBPF) e Andrii Nakryiko — (manutentore
libbpf).. Intrigante thread su Twitter di Quentin Monnet con esempi e segreti sull'uso di bpftool.
. Un gigantesco (e ancora mantenuto) elenco di link alla documentazione di BPF da Quentin Monnet.
Fonte: habr.com
