BPF per i più piccoli, prima parte: BPF esteso

All'inizio esisteva una tecnologia e si chiamava BPF. L'abbiamo guardata precedente, Articolo dell'Antico Testamento di questa serie. Nel 2013, grazie agli sforzi di Alexei Starovoitov e Daniel Borkman, una versione migliorata, ottimizzata per le moderne macchine a 64 bit, è stata sviluppata e inclusa nel kernel Linux. Questa nuova tecnologia è stata brevemente chiamata Internal BPF, poi ribattezzata Extended BPF e ora, dopo diversi anni, tutti la chiamano semplicemente BPF.

In parole povere, BPF consente di eseguire codice arbitrario fornito dall'utente nello spazio del kernel Linux e la nuova architettura si è rivelata così efficace che avremo bisogno di una dozzina di articoli in più per descrivere tutte le sue applicazioni. (L'unica cosa che gli sviluppatori non hanno fatto bene, come puoi vedere nel codice delle prestazioni qui sotto, è stato creare un logo decente.)

Questo articolo descrive la struttura della macchina virtuale BPF, le interfacce del kernel per lavorare con BPF, gli strumenti di sviluppo, nonché una breve, molto breve panoramica delle funzionalità esistenti, ad es. tutto ciò di cui avremo bisogno in futuro per uno studio più approfondito delle applicazioni pratiche della BPF.
BPF per i più piccoli, prima parte: BPF esteso

Riepilogo dell'articolo

Introduzione all'architettura BPF. Innanzitutto, daremo una panoramica dell'architettura BPF e delineeremo i componenti principali.

Registri e sistema di comando della macchina virtuale BPF. Avendo già un'idea dell'architettura nel suo complesso, descriveremo la struttura della macchina virtuale BPF.

Ciclo di vita degli oggetti BPF, file system bpffs. In questa sezione daremo uno sguardo più da vicino al ciclo di vita degli oggetti BPF: programmi e mappe.

Gestire gli oggetti utilizzando la chiamata di sistema bpf. Con una certa comprensione del sistema già in atto, vedremo finalmente come creare e manipolare oggetti dallo spazio utente utilizzando una speciale chiamata di sistema − bpf(2).

Пишем программы BPF с помощью libbpf. Naturalmente è possibile scrivere programmi utilizzando una chiamata di sistema. Ma è difficile. Per uno scenario più realistico, i programmatori nucleari hanno sviluppato una libreria libbpf. Creeremo uno scheletro di applicazione BPF di base che utilizzeremo negli esempi successivi.

Aiutanti del kernel. Qui impareremo come i programmi BPF possono accedere alle funzioni di supporto del kernel, uno strumento che, insieme alle mappe, espande sostanzialmente le capacità del nuovo BPF rispetto a quello classico.

Accesso alle mappe dai programmi BPF. A questo punto ne sapremo abbastanza per capire esattamente come creare programmi che utilizzino le mappe. E diamo anche una rapida occhiata al grande e potente verificatore.

Strumenti di sviluppo. Sezione di aiuto su come assemblare le utilità e il kernel richiesti per gli esperimenti.

Conclusione. Alla fine dell’articolo, chi ha letto fin qui troverà parole motivanti e una breve descrizione di ciò che accadrà negli articoli successivi. Elencheremo anche una serie di link per lo studio autonomo per chi non ha voglia o capacità di aspettare il seguito.

Introduzione all'architettura BPF

Prima di iniziare a considerare l'architettura BPF, faremo riferimento un'ultima volta (oh) a BPF classico, che è stato sviluppato come risposta all'avvento delle macchine RISC e ha risolto il problema del filtraggio efficiente dei pacchetti. L'architettura si è rivelata un tale successo che, essendo nata negli anni Novanta nello UNIX di Berkeley, è stata trasferita sulla maggior parte dei sistemi operativi esistenti, è sopravvissuta fino ai folli anni Venti e trova ancora nuove applicazioni.

Il nuovo BPF è stato sviluppato come risposta all'ubiquità delle macchine a 64 bit, dei servizi cloud e alla crescente necessità di strumenti per la creazione di SDN (Ssoftware-ddefinito nrete). Sviluppato dagli ingegneri di rete del kernel come sostituto migliorato del classico BPF, il nuovo BPF letteralmente sei mesi dopo ha trovato applicazioni nel difficile compito di tracciare i sistemi Linux, e ora, sei anni dopo la sua comparsa, avremo bisogno di un intero articolo successivo solo per elencare i diversi tipi di programmi.

Foto divertenti

Fondamentalmente, 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 ad alcune origini eventi. Un evento potrebbe essere, ad esempio, la consegna di un pacchetto a un'interfaccia di rete, il lancio di alcune funzioni del kernel, ecc. Nel caso di un pacchetto, il programma BPF avrà accesso ai dati e ai metadati del pacchetto (in lettura ed eventualmente in scrittura, a seconda del tipo di programma); nel caso di esecuzione di una funzione del kernel, gli argomenti di la funzione, inclusi i puntatori alla memoria del kernel, ecc.

Diamo uno sguardo più da vicino a questo processo. Per cominciare parliamo della prima differenza rispetto al classico BPF, programmi per i quali erano scritti in assembler. Nella nuova versione, l'architettura è stata ampliata in modo che i programmi possano essere scritti in linguaggi di alto livello, principalmente, ovviamente, in C. Per questo è stato sviluppato un backend per llvm, che consente di generare bytecode per l'architettura BPF.

BPF per i più piccoli, prima parte: BPF esteso

L'architettura BPF è stata progettata, in parte, per funzionare in modo efficiente su macchine moderne. Per far funzionare tutto questo in pratica, il bytecode BPF, una volta caricato nel kernel, viene tradotto in codice nativo utilizzando un componente chiamato compilatore JIT (JUst In Ttempo). Successivamente, se ricordi, nel classico BPF il programma veniva caricato nel kernel e collegato atomicamente all'origine dell'evento, nel contesto di un'unica chiamata di sistema. Nella nuova architettura, ciò avviene in due fasi: in primo luogo, il codice viene caricato nel kernel utilizzando una chiamata di sistema bpf(2)e poi, successivamente, attraverso altri meccanismi che variano a seconda del tipo di programma, il programma si aggancia alla sorgente dell'evento.

Qui il lettore potrebbe avere una domanda: era possibile? Come viene garantita la sicurezza di esecuzione di tale codice? La sicurezza di esecuzione ci è garantita dalla fase di caricamento dei programmi BPF chiamata verifier (in inglese questa fase si chiama verifier e continuerò ad usare la parola inglese):

BPF per i più piccoli, prima parte: BPF esteso

Verifier è un analizzatore statico che garantisce che un programma non interrompa il normale funzionamento del kernel. Ciò, tra l'altro, non significa che il programma non possa interferire con il funzionamento del sistema: i programmi BPF, a seconda del tipo, possono leggere e riscrivere sezioni della memoria del kernel, restituire valori di funzioni, tagliare, aggiungere, riscrivere e persino inoltrare pacchetti di rete. Verifier garantisce che l'esecuzione di un programma BPF non manderà in crash il kernel e che un programma che, secondo le regole, ha accesso in scrittura, ad esempio, ai dati di un pacchetto in uscita, non sarà in grado di sovrascrivere la memoria del kernel all'esterno del pacchetto. Esamineremo il verificatore un po' più in dettaglio nella sezione corrispondente, dopo aver familiarizzato con tutti gli altri componenti di BPF.

Quindi cosa abbiamo imparato finora? L'utente scrive un programma in C, lo carica nel kernel utilizzando una chiamata di sistema bpf(2), dove viene controllato da un verificatore e tradotto in bytecode nativo. Quindi lo stesso o un altro utente collega il programma all'origine dell'evento e inizia l'esecuzione. La separazione di avvio e connessione è necessaria per diversi motivi. Innanzitutto, eseguire un verificatore è relativamente costoso e scaricando più volte lo stesso programma perdiamo tempo al computer. In secondo luogo, il modo esatto in cui un programma è connesso dipende dal suo tipo e un'interfaccia "universale" sviluppata un anno fa potrebbe non essere adatta a nuovi tipi di programmi. (Anche se ora che l'architettura sta diventando più matura, c'è l'idea di unificare questa interfaccia a livello libbpf.)

Il lettore attento noterà che non abbiamo ancora finito con le immagini. In effetti, tutto quanto sopra non spiega perché la BPF cambi radicalmente il quadro rispetto alla BPF classica. Due innovazioni che ampliano significativamente l'ambito di applicabilità sono la capacità di utilizzare la memoria condivisa e le funzioni di supporto del kernel. In BPF, la memoria condivisa viene implementata utilizzando le cosiddette mappe: strutture di dati condivise con un'API specifica. Probabilmente hanno preso questo nome perché il primo tipo di mappa ad apparire era una tabella hash. Poi sono comparsi gli array, tabelle hash locali (per CPU) e array locali, alberi di ricerca, mappe contenenti puntatori ai programmi BPF e molto altro. Ciò che è interessante per noi ora è che i programmi BPF ora hanno la capacità di persistere lo stato tra le chiamate e condividerlo con altri programmi e con lo spazio utente.

È possibile accedere alle mappe dai processi utente utilizzando una chiamata di sistema bpf(2)e dai programmi BPF in esecuzione nel kernel utilizzando funzioni di supporto. Inoltre, esistono degli helper non solo per lavorare con le mappe, ma anche per accedere ad altre funzionalità del kernel. Ad esempio, i programmi BPF possono utilizzare funzioni di supporto per inoltrare pacchetti ad altre interfacce, generare eventi perf, accedere alle strutture del kernel e così via.

BPF per i più piccoli, prima parte: BPF esteso

In sintesi, BPF offre la possibilità di caricare codice utente arbitrario, ovvero testato dal verificatore, nello spazio del kernel. Questo codice può salvare lo stato tra le chiamate e scambiare dati con lo spazio utente e ha anche accesso ai sottosistemi del kernel consentiti da questo tipo di programma.

Questo è già simile alle funzionalità fornite dai moduli del kernel, rispetto ai quali BPF presenta alcuni vantaggi (ovviamente, puoi solo confrontare applicazioni simili, ad esempio il tracciamento del sistema: non puoi scrivere un driver arbitrario con BPF). Si può notare una soglia di ingresso più bassa (alcune utility che utilizzano BPF non richiedono all'utente competenze di programmazione del kernel, o competenze di programmazione in generale), sicurezza di runtime (alzi la mano nei commenti per chi non ha rotto il sistema durante la scrittura o test di moduli), atomicità: si verificano tempi di inattività durante il ricaricamento dei moduli e il sottosistema BPF garantisce che nessun evento venga perso (per essere onesti, questo non è vero per tutti i tipi di programmi BPF).

La presenza di tali funzionalità rende BPF uno strumento universale per espandere il kernel, il che è confermato nella pratica: a BPF vengono aggiunti sempre più nuovi tipi di programmi, sempre più grandi aziende utilizzano BPF su server di combattimento 24 ore su 7, XNUMX giorni su XNUMX, sempre più le startup costruiscono il proprio business su soluzioni su cui si basano BPF. BPF viene utilizzato ovunque: nella protezione dagli attacchi DDoS, nella creazione di SDN (ad esempio, nell'implementazione di reti per Kubernetes), come principale strumento di tracciamento del sistema e raccoglitore di statistiche, nei sistemi di rilevamento delle intrusioni e nei sistemi sandbox, ecc.

Concludiamo qui la parte panoramica dell'articolo e osserviamo la macchina virtuale e l'ecosistema BPF in modo più dettagliato.

Digressione: utilità

Per poter eseguire gli esempi nelle sezioni seguenti, potresti aver bisogno di almeno un certo numero di utilità llvm/clang con supporto bpf e bpftool. Nella sezione Strumenti di sviluppo Puoi leggere le istruzioni per assemblare le utilità, così come il tuo kernel. Questa sezione è posta sotto per non disturbare l'armonia della nostra presentazione.

Registri e sistema di istruzioni della macchina virtuale BPF

L'architettura e il sistema di comando di BPF sono stati sviluppati tenendo conto del fatto che i programmi verranno scritti in linguaggio C e, dopo essere stati caricati nel kernel, tradotti in codice nativo. Pertanto, il numero di registri e l'insieme dei comandi sono stati scelti tenendo conto dell'intersezione, in senso matematico, delle capacità delle macchine moderne. Inoltre, ai programmi venivano imposte varie restrizioni, ad esempio, fino a poco tempo fa non era possibile scrivere loop e subroutine e il numero di istruzioni era limitato a 4096 (ora i programmi privilegiati possono caricare fino a un milione di istruzioni).

BPF ha undici registri a 64 bit accessibili all'utente r0-r10 e un contatore del programma. Registrati r10 contiene un puntatore al frame ed è di sola lettura. I programmi hanno accesso a uno stack di 512 byte in fase di esecuzione e a una quantità illimitata di memoria condivisa sotto forma di mappe.

I programmi BPF possono eseguire un insieme specifico di helper del kernel di tipo programma e, più recentemente, funzioni regolari. Ciascuna funzione chiamata può richiedere fino a cinque argomenti, passati nei registri r1-r5e il valore restituito viene passato a r0. È garantito che al ritorno dalla funzione, il contenuto dei registri r6-r9 non cambierà.

Per una traduzione efficiente del programma, registri r0-r11 per tutte le architetture supportate sono mappati in modo univoco sui registri reali, tenendo conto delle caratteristiche ABI dell'architettura attuale. Ad esempio, per x86_64 registri r1-r5, utilizzati per passare i parametri della funzione, vengono visualizzati su rdi, rsi, rdx, rcx, r8, che vengono utilizzati per passare parametri alle funzioni x86_64. Ad esempio, il codice a sinistra si traduce 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  0x0000000000001ee8

Il registro r0 utilizzato anche per restituire il risultato dell'esecuzione del programma e nel registro r1 al programma viene passato un puntatore al contesto - a seconda del tipo di programma, questo potrebbe essere, ad esempio, una struttura struct xdp_md (per XDP) o struttura struct __sk_buff (per diversi programmi di rete) o struttura struct pt_regs (per diversi tipi di programmi di tracciamento), ecc.

Quindi, avevamo una serie di registri, helper del kernel, uno stack, un puntatore di contesto e memoria condivisa sotto forma di mappe. Non che tutto questo sia assolutamente necessario durante il viaggio, ma...

Continuiamo la descrizione e parliamo del sistema di comando per lavorare con questi oggetti. Tutto (quasi tutto) Le istruzioni BPF hanno una dimensione fissa di 64 bit. Se guardi un'istruzione su una macchina Big Endian a 64 bit, vedrai

BPF per i più piccoli, prima parte: BPF esteso

Qui Code - questa è la codifica dell'istruzione, Dst/Src sono le codifiche del ricevitore e della sorgente, rispettivamente, Off - Rientro con segno a 16 bit e Imm è un intero con segno a 32 bit utilizzato in alcune istruzioni (simile alla costante K di cBPF). Codifica Code ha uno di due tipi:

BPF per i più piccoli, prima parte: BPF esteso

Le classi di istruzioni 0, 1, 2, 3 definiscono i comandi per lavorare con la memoria. Essi sono chiamati, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, rispettivamente. Classi 4, 7 (BPF_ALU, BPF_ALU64) costituiscono un insieme di istruzioni ALU. Classi 5, 6 (BPF_JMP, BPF_JMP32) contengono istruzioni di salto.

L'ulteriore piano per studiare il sistema di istruzioni BPF è il seguente: invece di elencare meticolosamente tutte le istruzioni e i loro parametri, in questa sezione esamineremo un paio di esempi e da essi risulterà chiaro come funzionano effettivamente le istruzioni e come disassemblare manualmente qualsiasi file binario per BPF. Per consolidare il materiale più avanti nell'articolo, incontreremo anche istruzioni individuali nelle sezioni relative a Verifier, compilatore JIT, traduzione del classico BPF, nonché durante lo studio delle mappe, la chiamata di funzioni, ecc.

Quando parliamo di istruzioni individuali, faremo riferimento ai file principali bpf.h и bpf_common.h, che definiscono i codici numerici delle istruzioni BPF. Quando studi l'architettura da solo e/o analizzi i binari, puoi trovare la semantica nelle seguenti fonti, ordinate in ordine di complessità: Specifiche eBPF non ufficiali, Guida di riferimento BPF e XDP, set di istruzioni, Documentazione/rete/filtro.txt e, naturalmente, nel codice sorgente Linux: verificatore, JIT, interprete BPF.

Esempio: smontare BPF nella tua testa

Consideriamo un esempio in cui compiliamo un programma readelf-example.c e guarda il file 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
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

Prima colonna in output readelf è un rientro e il nostro programma è quindi composto da quattro comandi:

Code Dst Src Off  Imm
b7   0   0   0000 01000000
15   0   1   0100 00000000
b7   0   0   0000 02000000
95   0   0   0000 00000000

I codici di comando sono uguali b7, 15, b7 и 95. Ricordiamo che i tre bit meno significativi rappresentano la classe dell'istruzione. Nel nostro caso, il quarto bit di tutte le istruzioni è vuoto, quindi le classi di istruzioni sono rispettivamente 7, 5, 7, 5. La classe 7 è BPF_ALU64, e 5 è BPF_JMP. Per entrambe le classi il formato delle istruzioni è lo stesso (vedi sopra) e possiamo riscrivere il nostro programma in questo modo (allo stesso tempo riscriveremo le rimanenti colonne in forma umana):

Op S  Class   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    0

Operazione b classe ALU64 - E ' BPF_MOV. Assegna un valore al registro di destinazione. Se il bit è impostato s (sorgente), allora il valore viene preso dal registro sorgente e se, come nel nostro caso, non è impostato, allora il valore viene preso dal campo Imm. Quindi nella prima e nella terza istruzione eseguiamo l'operazione r0 = Imm. Inoltre, il funzionamento di JMP classe 1 lo è BPF_JEQ (salta se uguale). Nel nostro caso, dal momento che il bit S è zero, confronta il valore del registro sorgente con il campo Imm. Se i valori coincidono, avviene la transizione PC + OffDove PC, come al solito, contiene l'indirizzo dell'istruzione successiva. Infine, l'operazione JMP Classe 9 lo è BPF_EXIT. Questa istruzione termina il programma, ritornando 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      exit

Possiamo riscriverlo in una forma più conveniente:

     r0 = 1
     if (r1 == 0) goto END
     r0 = 2
END:
     exit

Se ricordiamo cosa c'è nel registro r1 al programma viene passato un puntatore al contesto dal kernel e nel registro r0 il valore viene restituito al kernel, quindi possiamo vedere che se il puntatore al contesto è zero, restituiamo 1, altrimenti - 2. Controlliamo che abbiamo ragione guardando il sorgente:

$ cat readelf-example.c
int foo(void *ctx)
{
        return ctx ? 2 : 1;
}

Sì, è un programma senza significato, ma si traduce in sole quattro semplici istruzioni.

Esempio di eccezione: istruzione da 16 byte

Abbiamo accennato in precedenza che alcune istruzioni occupano più di 64 bit. Ciò vale ad esempio per le istruzioni lddw (Codice = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — carica una doppia parola dai campi nel registro Imm. Punto è che Imm ha una dimensione di 32 e una doppia parola è di 64 bit, quindi caricare un valore immediato a 64 bit in un registro in un'istruzione a 64 bit non funzionerà. A tale scopo vengono utilizzate due istruzioni adiacenti per memorizzare nel campo la seconda parte del valore a 64 bit 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
Hex dump of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

Ci sono solo due istruzioni in un programma binario:

Binary                                 Disassm
18000000 ddccbbaa 00000000 44332211    r0 = Imm[0]|Imm[1]
95000000 00000000                      exit

Ci incontreremo di nuovo con le istruzioni lddw, quando parliamo di spostamenti e di lavoro con le mappe.

Esempio: smontaggio del BPF utilizzando strumenti standard

Quindi, abbiamo imparato a leggere i codici binari BPF e siamo pronti ad analizzare qualsiasi istruzione se necessario. Tuttavia, vale la pena dire che in pratica è più comodo e veloce disassemblare i programmi utilizzando strumenti standard, ad esempio:

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 <foo>:
 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 exit

Ciclo di vita degli oggetti BPF, file system bpffs

(Ho appreso per la prima volta alcuni dei dettagli descritti in questa sottosezione da inviare Aleksej Starovoitov dentro Blog BPF.)

Gli oggetti BPF - programmi e mappe - vengono creati dallo spazio utente utilizzando i comandi BPF_PROG_LOAD и BPF_MAP_CREATE chiamata di sistema bpf(2), parleremo esattamente di come ciò accade nella prossima sezione. Questo crea strutture dati del kernel e per ciascuna di esse refcount (conteggio riferimenti) è impostato su uno e all'utente viene restituito un descrittore di file che punta all'oggetto. Dopo che la maniglia è stata chiusa refcount l'oggetto viene ridotto di uno e quando raggiunge lo zero l'oggetto viene distrutto.

Se il programma utilizza le mappe, allora refcount queste mappe vengono aumentate di uno dopo aver caricato il programma, cioè i loro descrittori di file possono essere chiusi dal processo utente e comunque refcount non diventerà zero:

BPF per i più piccoli, prima parte: BPF esteso

Dopo aver caricato con successo un programma, di solito lo colleghiamo a una sorta di generatore di eventi. Ad esempio, possiamo metterlo su un'interfaccia di rete per elaborare i pacchetti in arrivo o collegarlo ad alcuni tracepoint nel nucleo. A questo punto anche il contatore dei riferimenti aumenterà di uno e potremo chiudere il descrittore di file nel programma di caricamento.

Cosa succede se ora spegniamo il bootloader? Dipende dal tipo di generatore di eventi (hook). Tutti gli hook di rete esisteranno dopo il completamento del caricatore, questi sono i cosiddetti hook globali. E, ad esempio, i programmi traccia verranno rilasciati dopo che il processo che li ha creati termina (e quindi sono chiamati locali, da “locale al processo”). Tecnicamente, gli hook locali hanno sempre un descrittore di file corrispondente nello spazio utente e quindi si chiudono quando il processo viene chiuso, ma gli hook globali no. Nella figura seguente, utilizzando le croci rosse, provo a mostrare come la terminazione del programma di caricamento influisce sulla durata degli oggetti nel caso di hook locali e globali.

BPF per i più piccoli, prima parte: BPF esteso

Perché esiste una distinzione tra hook locali e globali? L'esecuzione di alcuni tipi di programmi di rete ha senso senza uno spazio utente, ad esempio immagina la protezione DDoS: il bootloader scrive le regole e collega il programma BPF all'interfaccia di rete, dopodiché il bootloader può uccidersi. D'altra parte, immagina un programma di traccia di debug che hai scritto in ginocchio in dieci minuti: una volta terminato, vorresti che non rimanesse spazzatura nel sistema e gli hook locali lo garantiranno.

D'altra parte, immagina di volerti connettere a un tracepoint nel kernel e raccogliere statistiche per molti anni. In questo caso, ti consigliamo di completare la parte utente e tornare di tanto in tanto alle statistiche. Il file system bpf offre questa opportunità. È uno pseudo-file system solo in memoria che consente la creazione di file che fanno riferimento a oggetti BPF e quindi aumentano refcount oggetti. Successivamente, il caricatore può uscire e gli oggetti creati rimarranno vivi.

BPF per i più piccoli, prima parte: BPF esteso

La creazione di file in bpff che fanno riferimento a oggetti BPF è chiamata "pinning" (come nella frase seguente: "il processo può bloccare un programma o una mappa BPF"). La creazione di oggetti file per oggetti BPF ha senso non solo per estendere la vita degli oggetti locali, ma anche per l'usabilità degli oggetti globali: tornando all'esempio con il programma di protezione DDoS globale, vogliamo poter venire a vedere le statistiche di volta in volta.

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-mountpoint

I nomi dei file system vengono creati utilizzando il comando BPF_OBJ_PIN Chiamata di sistema BPF. Per illustrare, prendiamo un programma, compiliamolo, carichiamolo e aggiungiamolo bpffs. Il nostro programma non fa nulla di utile, presentiamo solo il 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-mountpoint

Ora scarichiamo il nostro programma utilizzando l'utility bpftool e guarda le chiamate di sistema che le accompagnano bpf(2) (alcune righe irrilevanti rimosse dall'output 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) = 0

Qui abbiamo caricato il programma utilizzando BPF_PROG_LOAD, ha ricevuto un descrittore di file dal kernel 3 e utilizzando il comando BPF_OBJ_PIN ha bloccato questo descrittore di file come file "bpf-mountpoint/test". Successivamente viene avviato il programma bootloader bpftool ha finito di funzionare, ma il nostro programma è rimasto nel kernel, anche se non lo abbiamo collegato ad alcuna 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 4096B

Possiamo eliminare normalmente l'oggetto file unlink(2) e successivamente il programma corrispondente verrà eliminato:

$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory

Eliminazione di oggetti

Parlando di eliminazione di oggetti, è necessario chiarire che dopo aver disconnesso il programma dall'hook (generatore di eventi), nessun nuovo evento ne attiverà l'avvio, tuttavia, tutte le istanze attuali del programma verranno completate nell'ordine normale .

Alcuni tipi di programmi BPF consentono di sostituire il programma al volo, ad es. fornire l'atomicità della sequenza replace = detach old program, attach new program. In questo caso, tutte le istanze attive della vecchia versione del programma finiranno il loro lavoro e dal nuovo programma verranno creati nuovi gestori di eventi e "atomicità" qui significa che non verrà perso un singolo evento.

Allegare programmi alle origini evento

In questo articolo non descriveremo separatamente la connessione dei programmi alle origini degli eventi, poiché ha senso studiarla nel contesto di un tipo specifico di programma. Cm. esempio di seguito, in cui mostriamo come sono collegati programmi come XDP.

Manipolazione di oggetti utilizzando la chiamata di sistema bpf

Programmi BPF

Tutti gli oggetti BPF vengono creati e gestiti dallo spazio utente utilizzando una chiamata di sistema bpf, avente il seguente prototipo:

#include <linux/bpf.h>

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

Ecco la squadra cmd è uno dei valori di type enum bpf_cmd, attr — un puntatore ai parametri per un programma specifico e size — dimensione dell'oggetto in base al puntatore, ad es. di solito questo sizeof(*attr). Nel kernel 5.8 la chiamata di sistema bpf supporta 34 comandi diversi e определение union bpf_attr occupa 200 righe. Ma non dobbiamo lasciarci intimidire, poiché nel corso di diversi articoli familiarizzeremo con i comandi e i parametri.

Cominciamo dalla squadra BPF_PROG_LOAD, che crea programmi BPF: prende una serie di istruzioni BPF e le carica nel kernel. Al momento del caricamento, viene avviato il verificatore, quindi il compilatore JIT e, dopo l'esecuzione riuscita, viene restituito all'utente il descrittore del file di programma. Abbiamo visto cosa gli succede nella sezione precedente sul ciclo di vita degli oggetti BPF.

Ora scriveremo un programma personalizzato che caricherà un semplice programma BPF, ma prima dobbiamo decidere che tipo di programma vogliamo caricare: dovremo selezionare тип e nell'ambito di questo tipo, scrivi un programma che supererà il test di verifica. Tuttavia, per non complicare il processo, ecco una soluzione già pronta: prenderemo un programma come BPF_PROG_TYPE_XDP, che restituirà il valore XDP_PASS (salta tutti i pacchetti). Nell'assemblatore BPF sembra molto semplice:

r0 = 2
exit

Dopo che abbiamo deciso che caricheremo, possiamo dirti come lo 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();
}

Gli eventi interessanti in un programma iniziano con la definizione di un array insns - il nostro programma BPF in codice macchina. In questo caso, ogni istruzione del programma BPF viene impacchettata nella struttura bpf_insn. Primo elemento insns è conforme alle istruzioni r0 = 2, il secondo - exit.

Ritiro. Il kernel definisce macro più convenienti per scrivere codici macchina e utilizzare 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 codice nativo è necessario solo per scrivere test nel kernel e articoli su BPF, l'assenza di queste macro non complica realmente la vita dello sviluppatore.

Dopo aver definito il programma BPF passiamo a caricarlo nel kernel. Il nostro set minimalista di parametri attr include il tipo di programma, il set e il numero di istruzioni, la licenza richiesta e il nome "woo", che utilizziamo per trovare il nostro programma sul sistema dopo il download. Il programma, come promesso, viene caricato nel sistema tramite una chiamata di sistema bpf.

Alla fine del programma ci ritroviamo in un loop infinito che simula il carico utile. Senza di esso, il programma verrà ucciso dal kernel quando il descrittore di file restituito dalla chiamata di sistema verrà chiuso bpfe non lo vedremo nel sistema.

Bene, siamo pronti per il test. Assembliamo ed eseguiamo il programma sotto straceper 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_V
ERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

Va tutto bene, bpf(2) ci ha restituito l'handle 3 e siamo entrati in un ciclo infinito con pause(). Proviamo a trovare il nostro programma nel sistema. Per fare ciò andremo su un altro terminale e utilizzeremo l'utilità 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 c'è un programma caricato sul sistema woo il cui ID globale è 390 ed è attualmente in corso simple-prog c'è un descrittore di file aperto che punta al programma (e se simple-prog finirà il lavoro, allora woo scomparirà). Come previsto, il programma woo occupa 16 byte - due istruzioni - di codici binari nell'architettura BPF, ma nella sua 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) exit

niente 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:   retq

non molto efficace per exit(2), ma in tutta onestà, il nostro programma è troppo semplice e per i programmi non banali sono ovviamente necessari il prologo e l'epilogo aggiunti dal compilatore JIT.

Maps

I programmi BPF possono utilizzare aree di memoria strutturate accessibili sia ad altri programmi BPF che ai programmi nello spazio utente. Questi oggetti sono chiamati mappe e in questa sezione mostreremo come manipolarli utilizzando una chiamata di sistema bpf.

Diciamo subito che le capacità delle mappe non si limitano solo all'accesso alla memoria condivisa. Esistono mappe per scopi speciali contenenti, ad esempio, puntatori a programmi BPF o puntatori a interfacce di rete, mappe per lavorare con eventi perf, ecc. Non ne parleremo qui, per non confondere il lettore. A parte questo, ignoriamo i problemi di sincronizzazione, poiché questo non è importante per i nostri esempi. Un elenco completo dei tipi di mappe disponibili è disponibile in <linux/bpf.h>, e in questa sezione prenderemo come esempio la storicamente prima tipologia, la tabella hash BPF_MAP_TYPE_HASH.

Se crei una tabella hash, ad esempio, in C++, diresti unordered_map<int,long> woo, che in russo significa “Ho bisogno di un tavolo woo dimensione illimitata, le cui chiavi sono di tipo inte i valori sono il tipo long" Per creare una tabella hash BPF, dobbiamo fare più o meno la stessa cosa, tranne che dobbiamo specificare la dimensione massima della tabella e invece di specificare i tipi di chiavi e valori, dobbiamo specificare le loro dimensioni in byte . Per creare mappe utilizzare il comando BPF_MAP_CREATE chiamata di sistema bpf. Consideriamo un programma più o meno minimale 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 <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

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 una serie di parametri attr, in cui diciamo "Ho bisogno di una tabella hash con chiavi e valori di dimensione sizeof(int), in cui posso inserire un massimo di quattro elementi." Quando si creano mappe BPF, è possibile specificare altri parametri, ad esempio, proprio come nell'esempio con il programma, abbiamo specificato il nome dell'oggetto come "woo".

Compiliamo ed eseguiamo 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(

Ecco la chiamata di sistema bpf(2) ci ha restituito il numero della mappa descrittiva 3 e poi il programma, come previsto, attende ulteriori istruzioni nella chiamata di sistema pause(2).

Ora mandiamo il nostro programma in background o apriamo un altro terminale e guardiamo il nostro oggetto utilizzando l'utility bpftool (possiamo distinguere la nostra mappa dalle altre con il suo nome):

$ sudo bpftool map
...
114: hash  name woo  flags 0x0
        key 4B  value 4B  max_entries 4  memlock 4096B
...

Il numero 114 è l'ID globale del nostro oggetto. Qualsiasi programma sul sistema può utilizzare questo ID per aprire una mappa esistente utilizzando il comando BPF_MAP_GET_FD_BY_ID chiamata di sistema bpf.

Ora possiamo giocare con la nostra tabella hash. Diamo un'occhiata al suo contenuto:

$ sudo bpftool map dump id 114
Found 0 elements

Vuoto. Diamogli un valore hash[1] = 1:

$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0

Consideriamo nuovamente la tabella:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
Found 1 element

Evviva! Siamo riusciti ad aggiungere un elemento. Nota che dobbiamo lavorare a livello di byte per farlo, poiché bptftool non sa di che tipo sono i valori nella tabella hash. (Questa conoscenza può essere trasferita a lei utilizzando BTF, ma ne parleremo ora.)

Come fa esattamente 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, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

Per prima cosa abbiamo aperto la mappa tramite il suo ID globale utilizzando il comando BPF_MAP_GET_FD_BY_ID и bpf(2) ci ha restituito il descrittore 3. Utilizzando ulteriormente il comando BPF_MAP_GET_NEXT_KEY abbiamo trovato la prima chiave nella tabella passando NULL come puntatore alla chiave "precedente". Se abbiamo la chiave possiamo farlo BPF_MAP_LOOKUP_ELEMche restituisce un valore a un puntatore value. Il passo successivo è provare a trovare l'elemento successivo passando un puntatore alla chiave corrente, ma la nostra tabella contiene solo un elemento e il comando BPF_MAP_GET_NEXT_KEY ritorna ENOENT.

Ok, cambiamo il valore con la chiave 1, diciamo che la nostra logica aziendale richiede la registrazione 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) = 0

Come previsto, è molto semplice: il comando BPF_MAP_GET_FD_BY_ID apre la nostra mappa tramite ID e il comando BPF_MAP_UPDATE_ELEM sovrascrive l'elemento.

Quindi, dopo aver creato una tabella hash da un programma, possiamo leggere e scrivere il suo contenuto da un altro. Tieni presente che se potessimo farlo dalla riga di comando, qualsiasi altro programma sul sistema può farlo. Oltre ai comandi sopra descritti, per lavorare con le mappe dallo spazio utente, Il seguente:

  • BPF_MAP_LOOKUP_ELEM: trova il valore per chiave
  • BPF_MAP_UPDATE_ELEM: aggiorna/crea valore
  • BPF_MAP_DELETE_ELEM: rimuovere la chiave
  • BPF_MAP_GET_NEXT_KEY: trova la chiave successiva (o la prima).
  • BPF_MAP_GET_NEXT_ID: ti permette di sfogliare tutte le mappe esistenti, ecco come funziona bpftool map
  • BPF_MAP_GET_FD_BY_ID: apre una mappa esistente tramite il suo ID globale
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: aggiorna atomicamente il valore di un oggetto e restituisce quello vecchio
  • BPF_MAP_FREEZE: rende 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. Per esempio, BPF_MAP_LOOKUP_AND_DELETE_BATCH - questo è l'unico modo affidabile per leggere e reimpostare tutti i valori dalla mappa

Non tutti questi comandi funzionano per tutti i tipi di mappa, ma in generale lavorare con altri tipi di mappe dallo spazio utente è esattamente uguale a lavorare con le tabelle hash.

Per motivi di ordine, terminiamo i nostri esperimenti con la tabella hash. Ricordi che abbiamo creato una tabella che può contenere fino a quattro chiavi? Aggiungiamo qualche altro elemento:

$ 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 0

Fin qui 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
Found 4 elements

Proviamo ad aggiungerne un altro:

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long

Come previsto, non ci siamo riusciti. Esaminiamo l'errore più in 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 (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

Va tutto bene: come previsto, la squadra BPF_MAP_UPDATE_ELEM tenta di creare una nuova, quinta chiave, ma si blocca E2BIG.

Quindi, possiamo creare e caricare programmi BPF, nonché creare e gestire mappe dallo spazio utente. Ora è logico vedere come possiamo utilizzare le mappe degli stessi programmi BPF. Potremmo parlarne nel linguaggio dei programmi di difficile lettura nei codici macro macchina, ma in realtà è giunto il momento di mostrare come i programmi BPF vengono effettivamente scritti e mantenuti - utilizzando libbpf.

(Per i lettori che non sono soddisfatti della mancanza di un esempio di basso livello: analizzeremo in dettaglio i programmi che utilizzano mappe e funzioni di supporto create utilizzando libbpf e dirti cosa succede a livello di istruzione. Per i lettori insoddisfatti molto, abbiamo aggiunto esempio nel posto apposito dell'articolo.)

Scrivere programmi BPF usando libbpf

Scrivere programmi BPF utilizzando i codici macchina può essere interessante solo la prima volta, poi subentra la sazietà. In questo momento devi rivolgere la tua attenzione llvm, che ha un backend per generare codice per l'architettura BPF, oltre a una libreria libbpf, che consente di scrivere il lato utente delle applicazioni BPF e caricare il codice dei programmi BPF generati utilizzando llvm/clang.

Infatti, come vedremo in questo e nei successivi articoli, libbpf fa parecchio lavoro senza di esso (o strumenti simili - iproute2, libbcc, libbpf-go, ecc.) è impossibile vivere. Una delle caratteristiche killer 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 essere eseguiti su API diverse (ad esempio, quando la struttura del kernel cambia dalla versione alla versione). Per poter lavorare con CO-RE, il tuo kernel deve essere compilato con il supporto BTF (descriviamo come farlo nella sezione Strumenti di sviluppo. Puoi verificare se il tuo kernel è compilato con BTF o meno in modo molto semplice - dalla 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/vmlinux

Questo file memorizza le informazioni su tutti i tipi di dati utilizzati nel kernel e viene utilizzato in tutti i nostri esempi utilizzando libbpf. Parleremo in dettaglio di CO-RE nel prossimo articolo, ma in questo ti basterà costruirti un kernel con CONFIG_DEBUG_INFO_BTF.

Biblioteca libbpf vive proprio nella directory tools/lib/bpf kernel e il suo sviluppo avviene tramite la mailing list [email protected]. Tuttavia, viene mantenuto un repository separato per le esigenze delle applicazioni che vivono all'esterno del kernel https://github.com/libbpf/libbpf in cui viene eseguito il mirroring della libreria del kernel per l'accesso in lettura più o meno così com'è.

In questa sezione vedremo come creare un progetto che utilizzi libbpf, scriviamo diversi programmi di test (più o meno privi di significato) e analizziamo in dettaglio come funziona il tutto. Questo ci permetterà di spiegare più facilmente nelle sezioni seguenti esattamente come i programmi BPF interagiscono con mappe, helper del kernel, BTF, ecc.

In genere i progetti utilizzano libbpf aggiungi un repository GitHub come sottomodulo git, faremo lo stesso:

$ 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.

Andando a libbpf molto semplice:

$ 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.pc

Il nostro prossimo piano in questa sezione è il seguente: scriveremo un programma BPF come BPF_PROG_TYPE_XDP, lo stesso dell'esempio precedente, ma in C lo compiliamo utilizzando clange scrivere un programma di supporto che lo caricherà nel kernel. Nelle sezioni seguenti espanderemo le capacità sia del programma BPF che del programma Assistant.

Esempio: creazione di un'applicazione completa utilizzando libbpf

Per cominciare, utilizziamo il file /sys/kernel/btf/vmlinux, menzionato sopra, e crea il suo equivalente sotto forma di file di intestazione:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

Questo file memorizzerà tutte le strutture dati disponibili nel nostro kernel, ad esempio, ecco come 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 scriveremo il nostro programma BPF in C:

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
        return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Anche se il nostro programma si è rivelato 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 scoprire come sono le strutture del kernel. Il seguente file di intestazione ci arriva dalla libreria libbpf. Ora ci serve solo per definire la macro SEC, che invia il carattere alla sezione appropriata del file oggetto ELF. Il nostro programma è contenuto nella sezione xdp/simple, dove prima della barra definiamo il tipo di programma BPF - questa è la convenzione utilizzata in libbpf, in base al nome della sezione sostituirà il tipo corretto all'avvio bpf(2). Lo stesso programma BPF lo è C - molto semplice e composto da una riga return XDP_PASS. Infine, una sezione a parte "license" contiene il nome della licenza.

Possiamo compilare il nostro programma utilizzando llvm/clang, versione >= 10.0.0, o meglio ancora, superiore (vedi sezione Strumenti di sviluppo):

$ 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.o

Tra le caratteristiche interessanti: indichiamo l'architettura di destinazione -target bpf e il percorso delle intestazioni libbpf, che abbiamo installato di recente. Inoltre, non dimenticare -O2, senza questa opzione potresti avere sorprese in futuro. 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:       exit

Sì, ha funzionato! Ora abbiamo un file binario con il programma e vogliamo creare un'applicazione che lo caricherà nel kernel. A questo scopo la biblioteca libbpf ci offre due opzioni: utilizzare un'API di livello inferiore o un'API di livello superiore. Seguiremo la seconda strada, poiché vogliamo imparare a scrivere, caricare e connettere i programmi BPF con il minimo sforzo per il loro studio successivo.

Per prima cosa dobbiamo generare lo “scheletro” del nostro programma dal suo binario utilizzando la stessa utility bpftool — il coltellino svizzero del mondo BPF (che può essere preso alla lettera, visto che Daniel Borkman, uno degli ideatori e manutentori di BPF, è svizzero):

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h

In archivio xdp-simple.skel.h contiene il codice binario del nostro programma e le funzioni per gestire: caricare, allegare, eliminare il nostro oggetto. Nel nostro caso semplice questo sembra eccessivo, ma funziona anche nel caso in cui il file oggetto contenga molti programmi e mappe BPF e per caricare questo ELF gigante dobbiamo solo generare lo scheletro e chiamare una o due funzioni dall'applicazione personalizzata che abbiamo stanno scrivendo Andiamo avanti adesso.

A rigor di termini, 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 vedere tracce di un'API di basso livello qui: la struttura struct bpf_program *simple и struct bpf_link *simple. La prima struttura descrive nello specifico il nostro programma, scritto nella sezione xdp/simplee il secondo descrive come il programma si connette all'origine dell'evento.

Funzione xdp_simple_bpf__open_and_load, apre un oggetto ELF, lo analizza, crea tutte le strutture e sottostrutture (oltre al programma, ELF contiene anche altre sezioni: dati, dati di sola lettura, informazioni di debug, licenza, ecc.), quindi lo carica nel kernel utilizzando un sistema chiamata bpf, che possiamo verificare compilando ed eseguendo 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) = 4

Diamo ora un'occhiata al nostro programma utilizzando 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 dump (usiamo una 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) exit

Qualcosa di nuovo! Il programma ha stampato parti del nostro file sorgente C. Ciò è 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, quindi specifica il descrittore di file risultante durante il caricamento del programma con il comando BPG_PROG_LOAD.

Aiutanti del kernel

I programmi BPF possono eseguire funzioni "esterne" - helper del kernel. Queste funzioni di supporto consentono ai programmi BPF di accedere alle strutture del kernel, gestire le mappe e anche comunicare con il "mondo reale": creare eventi perf, controllare l'hardware (ad esempio, reindirizzare i pacchetti), ecc.

Esempio: bpf_get_smp_processor_id

Nell’ambito del paradigma “imparare attraverso l’esempio”, consideriamo una delle funzioni di aiuto, bpf_get_smp_processor_id(), un po ' in archivio kernel/bpf/helpers.c. Restituisce il numero del processore su cui è in esecuzione il programma BPF che lo ha chiamato. Ma non siamo tanto interessati alla sua semantica quanto al fatto che la sua implementazione richieda una sola riga:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Le definizioni delle funzioni helper BPF sono simili alle definizioni delle chiamate di sistema Linux. Qui, ad esempio, viene definita una funzione che non ha argomenti. (Una funzione che accetta, ad esempio, tre argomenti viene definita utilizzando la macro BPF_CALL_3. Il numero massimo di argomenti è cinque.) Tuttavia, questa è solo la prima parte della definizione. La seconda parte è definire la struttura del tipo struct bpf_func_proto, che contiene una descrizione della funzione helper che il verificatore comprende:

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 particolare tipo possano utilizzare questa funzione, devono registrarla, ad esempio per il tipo BPF_PROG_TYPE_XDP una funzione è definita nel kernel xdp_func_proto, che determina dall'ID della funzione helper 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;
    ...
    }
}

Nel file vengono "definiti" i nuovi tipi di programma BPF include/linux/bpf_types.h utilizzando una macro BPF_PROG_TYPE. Definito tra virgolette perché è una definizione logica, e in termini di linguaggio C la definizione di un intero insieme di strutture concrete si trova in altri posti. In particolare, nel fascicolo kernel/bpf/verifier.c tutte le definizioni dal file bpf_types.h vengono utilizzati per creare una serie 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 <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

Cioè, per ogni tipo di programma BPF, viene definito un puntatore a una struttura dati del tipo struct bpf_verifier_ops, che viene inizializzato con il valore _name ## _verifier_ops, cioè., xdp_verifier_ops per xdp. Struttura xdp_verifier_ops determinato da in archivio net/core/filter.c следующим обрахом:

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 eseguirà il verificatore ogni volta che incontra una sfida qualche tipo funzioni all'interno di un programma BPF, vedere verifier.c.

Diamo un'occhiata a come un ipotetico programma BPF utilizza la funzione bpf_get_smp_processor_id. Per fare ciò, riscriviamo 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 determinato da в <bpf/bpf_helper_defs.h> Biblioteca libbpf come

static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;

quello è bpf_get_smp_processor_id è un puntatore a funzione il cui valore è 8, dove 8 è il valore BPF_FUNC_get_smp_processor_id типа 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” vanno bene). Questa funzione non accetta argomenti e restituisce un valore di tipo __u32. Quando lo eseguiamo nel nostro programma, clang genera un'istruzione BPF_CALL "quello giusto" 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 <simple>:
       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
       3:       77 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 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

0000000000000038 <LBB0_2>:
       7:       95 00 00 00 00 00 00 00 exit

Nella prima riga vediamo le istruzioni call, parametro IMM che è uguale a 8, e SRC_REG - zero. Secondo l'accordo ABI utilizzato dal verificatore, questa è una chiamata alla funzione di supporto numero otto. Una volta avviato, la logica è semplice. Restituisce il valore dal registro r0 copiato in r1 e alle righe 2,3 viene convertito in type u32 — i 32 bit superiori vengono cancellati. Alle righe 4,5,6,7 restituiamo 2 (XDP_PASS) o 1 (XDP_DROP) a seconda che la funzione helper della riga 0 abbia restituito un valore zero o diverso da zero.

Mettiamoci alla prova: 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
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

Ok, il verificatore ha trovato l'helper del kernel corretto.

Esempio: passaggio di argomenti e infine esecuzione del programma!

Tutte le funzioni di supporto a livello di esecuzione hanno un prototipo

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

I parametri alle funzioni di supporto vengono passati nei registri r1-r5e il valore viene restituito nel registro r0. Non esistono funzioni che accettano più di cinque argomenti e non è previsto che venga aggiunto il supporto in futuro.

Diamo un'occhiata al nuovo helper del kernel e al modo in cui BPF passa i parametri. Riscriviamo xdp-simple.bpf.c come segue (il resto delle righe non è cambiato):

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 <simple>:
       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:       exit

Nelle righe 0-7 scriviamo la stringa running on CPU%un, e poi sulla riga 8 eseguiamo quello familiare bpf_get_smp_processor_id. Nelle righe 9-12 prepariamo gli argomenti di supporto bpf_printk - registri r1, r2, r3. Perché sono tre e non due? Perché bpf_printkquesto è un wrapper macro intorno al vero aiutante bpf_trace_printk, che deve passare la dimensione della stringa di formato.

Aggiungiamo ora un paio di righe a xdp-simple.cin modo che il nostro programma si connetta all'interfaccia lo e ha iniziato davvero!

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#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 usiamo la funzione bpf_set_link_xdp_fd, che collega i programmi BPF di tipo XDP alle interfacce di rete. Abbiamo codificato il numero di interfaccia lo, che è sempre 1. Eseguiamo la funzione due volte per staccare prima il vecchio programma, se era allegato. Nota che ora non abbiamo bisogno di una sfida pause o un ciclo infinito: il nostro programma di caricamento uscirà, ma il programma BPF non verrà ucciso poiché è connesso all'origine dell'evento. Dopo il download e la connessione con successo, il programma verrà avviato per ogni pacchetto di rete in arrivo lo.

Scarichiamo il programma e diamo un'occhiata all'interfaccia lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> 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 669

Il programma che abbiamo scaricato ha l'ID 669 e vediamo lo stesso ID sull'interfaccia lo. Invieremo un paio di pacchi a 127.0.0.1 (richiesta + risposta):

$ ping -c1 localhost

e ora diamo un'occhiata al contenuto del file virtuale di debug /sys/kernel/debug/tracing/trace_pipe, in quale 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 CPU0

Sono stati avvistati due pacchi lo ed elaborato su CPU0: il nostro primo programma BPF completamente privo di significato ha funzionato!

Vale la pena notare che bpf_printk Non per niente scrive nel file di debug: non è l'helper di maggior successo da utilizzare in produzione, ma il nostro obiettivo era mostrare qualcosa di semplice.

Accesso alle mappe dai programmi BPF

Esempio: utilizzando una mappa dal programma BPF

Nelle sezioni precedenti abbiamo imparato come creare e utilizzare le mappe dallo spazio utente, e ora diamo un'occhiata alla parte kernel. Cominciamo, come al solito, con un esempio. Riscriviamo il nostro programma xdp-simple.bpf.c следующим обрахом:

#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 una definizione di mappa woo: Questo è un array di 8 elementi che memorizza valori come u64 (in C definiremmo un array come u64 woo[8]). In un programma "xdp/simple" otteniamo il numero corrente del processore in una variabile key e quindi utilizzando la funzione di supporto bpf_map_lookup_element otteniamo un puntatore alla voce corrispondente nell'array, che aumentiamo di uno. Tradotto in russo: calcoliamo 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-simple

Controlliamo che sia collegata lo e invia alcuni pacchetti:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> 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; done

Ora 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 è importante per noi, la cosa principale è che il programma funzioni e sappiamo come accedere alle mappe dai programmi BPF - utilizzando хелперов bpf_mp_*.

Indice mistico

Quindi, possiamo accedere alla mappa dal programma BPF utilizzando chiamate come

val = bpf_map_lookup_elem(&woo, &key);

dove appare la funzione helper

void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)

ma stiamo passando un puntatore &woo ad una struttura senza nome struct { ... }...

Se guardiamo l'assemblatore del programma, vediamo che il valore &woo non è effettivamente 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 <simple>:
       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 rilocazioni:

$ llvm-readelf -r xdp-simple.bpf.o | head -4

Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

Ma se guardiamo il programma già caricato, vediamo un puntatore alla 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 del lancio del nostro programma di caricamento, il collegamento a &woo è stato sostituito da qualcosa con una biblioteca libbpf. Per prima cosa esamineremo 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) = 5

Lo vediamo libbpf creato una mappa woo e poi scaricato il nostro programma simple. Diamo uno sguardo più da vicino a come carichiamo il programma:

  • chiamata xdp_simple_bpf__open_and_load dal file xdp-simple.skel.h
  • quali cause xdp_simple_bpf__load dal file xdp-simple.skel.h
  • quali cause bpf_object__load_skeleton dal file libbpf/src/libbpf.c
  • quali cause bpf_object__load_xattr di libbpf/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 è dove vediamo BPF_MAP_CREATE nell'uscita strace.) Successivamente viene richiamata la funzione bpf_object__relocate ed è lei che ci interessa, poiché ricordiamo ciò che abbiamo visto woo nella tabella di trasferimento. Esplorandola, alla fine ci ritroviamo nella funzione bpf_program__relocate, quale e si occupa dei trasferimenti delle mappe:

case RELO_LD64:
    insn[0].src_reg = BPF_PSEUDO_MAP_FD;
    insn[0].imm = obj->maps[relo->map_idx].fd;
    break;

Quindi prendiamo le nostre istruzioni

18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll

e sostituire il registro sorgente in esso contenuto con BPF_PSEUDO_MAP_FD, e il primo IMM al descrittore di file della nostra mappa e, se è uguale, ad esempio, 0xdeadbeef, quindi di conseguenza riceveremo le istruzioni

18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll

Questo è il modo in cui le informazioni sulla mappa vengono trasferite a uno specifico programma BPF caricato. In questo caso, la mappa può essere creata utilizzando BPF_MAP_CREATEe aperto dall'ID utilizzando BPF_MAP_GET_FD_BY_ID.

Totale, durante l'utilizzo libbpf L'algoritmo è il seguente:

  • durante la compilazione vengono creati dei record nella tabella di rilocazione per i collegamenti alle mappe
  • libbpf apre il libro degli oggetti ELF, trova tutte le mappe utilizzate e crea per esse descrittori di file
  • i descrittori di file vengono caricati nel kernel come parte dell'istruzione LD64

Come puoi immaginare, c'è altro in arrivo e dovremo esaminare il nocciolo della questione. Fortunatamente abbiamo un indizio: abbiamo scritto il significato BPF_PSEUDO_MAP_FD nel registro delle fonti e possiamo seppellirlo, il che ci condurrà al Santo di tutti i santi - kernel/bpf/verifier.c, dove una funzione con un nome distintivo sostituisce un descrittore di file 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;

(è possibile trovare il codice completo collegamento). Quindi possiamo espandere il nostro algoritmo:

  • durante il caricamento del programma il verificatore verifica il corretto utilizzo della mappa e scrive l'indirizzo della struttura corrispondente struct bpf_map

Quando si scarica il binario ELF utilizzando libbpf C'è molto altro da fare, ma ne parleremo in altri articoli.

Caricamento di programmi e mappe senza libbpf

Come promesso, ecco un esempio per i lettori che vogliono sapere come creare e caricare un programma che utilizzi le mappe, senza aiuti libbpf. Questo può essere utile quando lavori in un ambiente per il quale non puoi creare dipendenze, o salvare ogni bit, o scrivere un programma come ply, che genera al volo il codice binario BPF.

Per rendere più semplice seguire la logica, riscriveremo il nostro esempio per questi scopi xdp-simple. Il codice completo e leggermente ampliato del programma discusso in questo esempio lo trovate qui nocciolo.

La logica della nostra applicazione è la seguente:

  • creare una mappa dei tipi BPF_MAP_TYPE_ARRAY utilizzando il comando BPF_MAP_CREATE,
  • creare un programma che utilizzi 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 nello stesso modo in cui abbiamo fatto nel primo esempio sulla chiamata di sistema bpf - “kernel, per favore creami una nuova mappa sotto forma di un array di 8 elementi come __u64 e restituiscimi il descrittore del 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 è anche facile da caricare:

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 come un insieme di strutture struct bpf_insn insns[]. Ma poiché stiamo utilizzando un programma che abbiamo in C, possiamo imbrogliare un po':

$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

0000000000000000 <simple>:
       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 <LBB0_2>
       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 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

In totale, dobbiamo scrivere 14 istruzioni sotto forma di strutture come struct bpf_insn (consiglio: prendi la discarica dall'alto, rileggi la sezione istruzioni, apri linux/bpf.h и linux/bpf_common.h e provare a determinare struct bpf_insn insns[] da soli):

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
    },
};

Un esercizio per coloro che non l'hanno scritto da soli: trova map_fd.

C'è ancora una parte non rivelata nel nostro programma - 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à Linux online, il che significa che hanno utilizzato quello a loro più familiare (ma non per farlo). normale people) interfaccia per interagire con il kernel: prese di rete, Guarda anche RFC3549. Il modo più semplice per implementare xdp_attach sta copiando il codice da libbpf, vale a dire, dal file netlink.c, che è quello che abbiamo fatto, accorciandolo un po':

Benvenuti nel mondo delle prese NetLink

Apri un tipo di socket netlink 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 questo socket:

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 < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->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); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

Infine, ecco la nostra funzione che apre un socket e gli 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 < 0)
        return sock;

    memset(&req, 0, sizeof(req));
    req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
    req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    req.nh.nlmsg_type = RTM_SETLINK;
    req.nh.nlmsg_pid = 0;
    req.nh.nlmsg_seq = ++seq;
    req.ifinfo.ifi_family = AF_UNSPEC;
    req.ifinfo.ifi_index = ifindex;

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->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 test:

$ 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 +++

Vediamo se il nostro programma si è connesso a lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> 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 160

Inviamo i ping e guardiamo 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
Found 8 elements

Evviva, funziona tutto. Tieni presente, a proposito, che la nostra mappa viene nuovamente visualizzata sotto forma di byte. Ciò è dovuto al fatto che, a differenza libbpf non abbiamo caricato le informazioni sul tipo (BTF). Ma di questo parleremo meglio la prossima volta.

Strumenti di sviluppo

In questa sezione esamineremo il toolkit minimo 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 creati utilizzando clang, che può essere fornito dalla confezione. Tuttavia, poiché BPF è in fase di sviluppo, il kernel e gli strumenti cambiano costantemente, se non vuoi scrivere programmi BPF utilizzando metodi antiquati del 2019, dovrai compilare

  • llvm/clang
  • pahole
  • il suo nucleo
  • bpftool

(Per riferimento, questa sezione e tutti gli esempi nell'articolo sono stati eseguiti su Debian 10.)

lvm/clang

BPF è compatibile con LLVM e, sebbene recentemente i programmi per BPF possano essere compilati utilizzando gcc, tutto lo sviluppo attuale viene effettuato per LLVM. Pertanto, prima di tutto, costruiremo 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
... много времени спустя
$

Ora possiamo verificare se tutto è andato a buon fine:

$ ./bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 11.0.0git
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: znver1

  Registered Targets:
    bpf    - BPF (host endian)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bit X86: Pentium-Pro and above
    x86-64 - 64-bit X86: EM64T and AMD64

(Istruzioni di montaggio clang preso da me da bpf_devel_QA.)

Non installeremo i programmi che abbiamo appena creato, ma li aggiungeremo semplicemente PATH, ad esempio:

export PATH="`pwd`/bin:$PATH"

(Questo può essere aggiunto .bashrc o in un file separato. Personalmente aggiungo cose del genere ~/bin/activate-llvm.sh e quando necessario lo faccio . activate-llvm.sh.)

Pahole e BTF

Utilità pahole utilizzato durante la compilazione del kernel per creare informazioni di debug in formato BTF. In questo articolo non entreremo nei dettagli della tecnologia BTF, a parte il fatto che è conveniente e vogliamo usarla. Quindi se hai intenzione di compilare il tuo kernel, compila prima pahole (senza pahole non sarai in grado di 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/pahole

Kernel per sperimentare BPF

Quando esploro le possibilità di BPF, voglio mettere insieme il mio nucleo. Questo, in generale, non è necessario, poiché sarai in grado di compilare e caricare programmi BPF sul kernel della distribuzione, tuttavia, avere il tuo kernel ti consente di utilizzare le ultime funzionalità BPF, che appariranno nella tua distribuzione nel migliore dei casi entro pochi mesi o, come nel caso di alcuni strumenti di debug, non verranno affatto pacchettizzati nel prossimo futuro. Inoltre, il suo stesso nucleo rende importante sperimentare il codice.

Per costruire un kernel è necessario, in primo luogo, il kernel stesso e, in secondo luogo, un file di configurazione del kernel. Per sperimentare con BPF possiamo usare il solito vaniglia kernel o uno dei kernel di sviluppo. Storicamente, lo sviluppo di BPF avviene all'interno della comunità di rete Linux e quindi tutti i cambiamenti prima o poi passano attraverso David Miller, il manutentore della rete Linux. A seconda della loro natura (modifiche o nuove funzionalità), le modifiche alla rete ricadono in uno dei due core: net o net-next. Le modifiche per BPF sono distribuite nello stesso modo tra bpf и bpf-next, che vengono poi raggruppati rispettivamente in net e net-next. Per maggiori dettagli, vedere bpf_devel_QA и netdev-FAQ. Quindi scegli un kernel in base ai tuoi gusti e alle esigenze di stabilità del sistema su cui stai testando (*-next kernel sono i più instabili tra quelli elencati).

Va oltre lo scopo di questo articolo parlare di come gestire i file di configurazione del kernel: si presuppone che tu sappia già come farlo, oppure Pronto ad imparare da soli. Tuttavia, le seguenti istruzioni dovrebbero essere più o meno sufficienti per fornirti un sistema funzionante e abilitato per BPF.

Scarica uno dei kernel sopra indicati:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next

Crea una configurazione minima del kernel funzionante:

$ cp /boot/config-`uname -r` .config
$ make localmodconfig

Abilita le opzioni BPF nel file .config di tua scelta (molto probabilmente CONFIG_BPF sarà già abilitato poiché systemd lo utilizza). Ecco un elenco di opzioni del 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 is not set
# CONFIG_BPFILTER is not set
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=y

Quindi possiamo facilmente assemblare e installare i moduli e il kernel (a proposito, puoi assemblare il kernel utilizzando il file appena assemblato clangaggiungendo CC=clang):

$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install

e riavviare con il nuovo kernel (io uso per questo kexec dal pacchetto kexec-tools):

v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

bpftool

L'utilità più comunemente utilizzata nell'articolo sarà l'utilità bpftool, fornito come parte del kernel Linux. È scritto e gestito dagli sviluppatori BPF per gli sviluppatori BPF e può essere utilizzato per gestire tutti i tipi di oggetti BPF: caricare programmi, creare e modificare mappe, esplorare la vita dell'ecosistema BPF, ecc. È possibile trovare la documentazione sotto forma di codici sorgente per le pagine man nel nucleo oppure, già compilato, Rete.

Al momento della scrittura bpftool viene fornito già pronto solo per RHEL, Fedora e Ubuntu (vedi, ad esempio, questo filo, che racconta la storia incompiuta del packaging bpftool in Debian). Ma se hai già creato il tuo kernel, allora costruisci bpftool facile come una torta:

$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s

Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-detecting system features:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(Qui ${linux} - questa è la directory del kernel.) Dopo aver eseguito questi comandi bpftool verranno raccolti in una directory ${linux}/tools/bpf/bpftool e può essere aggiunto al percorso (innanzitutto all'utente root) o semplicemente copia in /usr/local/sbin.

Raccogliere bpftool è meglio usare quest'ultimo clang, assemblato come descritto sopra, e controlla se è assemblato correttamente, utilizzando, 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 abilitate nel tuo kernel.

A proposito, il comando precedente può essere eseguito come

# bpftool f p k

Questo viene fatto per analogia con le utilità del pacchetto iproute2, dove possiamo, ad esempio, dire ip a s eth0 invece di ip addr show dev eth0.

conclusione

BPF ti consente di calzare una pulce per misurare in modo efficace e modificare al volo la funzionalità del nucleo. Il sistema si è rivelato un grande successo, nella migliore tradizione di UNIX: un semplice meccanismo che permette di (ri)programmare il kernel ha permesso a un numero enorme di persone e organizzazioni di sperimentare. E, sebbene gli esperimenti, così come lo sviluppo dell'infrastruttura BPF stessa, siano lungi dall'essere finiti, il sistema dispone già di un'ABI stabile che consente di costruire una logica aziendale affidabile e, soprattutto, efficace.

Vorrei sottolineare che, a mio avviso, la tecnologia è diventata così popolare perché, da un lato, può essere utilizzata играть (l'architettura di una macchina si capisce più o meno in una sera), e dall'altro risolvere problemi che non potevano essere risolti (bellamente) prima della sua comparsa. Queste due componenti insieme costringono le persone a sperimentare e sognare, il che porta alla nascita di soluzioni sempre più innovative.

Questo articolo, anche se non particolarmente breve, è solo un'introduzione al mondo di BPF e non descrive funzionalità “avanzate” e parti importanti dell'architettura. Il piano per il futuro è più o meno questo: il prossimo articolo sarà una panoramica dei tipi di programmi BPF (ci sono 5.8 tipi di programmi supportati nel kernel 30), poi vedremo finalmente come scrivere applicazioni BPF reali utilizzando programmi di tracciamento del kernel ad esempio, è il momento di seguire un corso più approfondito sull'architettura BPF, seguito da esempi di applicazioni di rete e sicurezza BPF.

Articoli precedenti di questa serie

  1. BPF per i più piccoli, parte zero: BPF classico

Collegamenti

  1. Guida di riferimento BPF e XDP — documentazione su BPF da cilium, o più precisamente da Daniel Borkman, uno dei creatori e manutentori di BPF. Questa è una delle prime descrizioni serie, che differisce dalle altre in quanto Daniel sa esattamente di cosa sta scrivendo e non ci sono errori. In particolare, questo documento descrive come lavorare con i programmi BPF dei tipi XDP e TC utilizzando la nota utilità ip dal pacchetto iproute2.

  2. Documentazione/rete/filtro.txt — file originale con documentazione per BPF classico e poi esteso. Una buona lettura se vuoi approfondire il linguaggio assembly e i dettagli tecnici dell'architettura.

  3. Blog su BPF da Facebook. Viene aggiornato raramente, ma in modo appropriato, come scrivono Alexei Starovoitov (autore di eBPF) e Andrii Nakryiko - (manutentore) libbpf).

  4. I segreti di bpftool. Un divertente thread Twitter di Quentin Monnet con esempi e segreti sull'utilizzo di bpftool.

  5. Immergiti in BPF: un elenco di materiale da leggere. Un elenco enorme (e tuttora mantenuto) di collegamenti alla documentazione BPF di Quentin Monnet.

Fonte: habr.com

Aggiungi un commento