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.
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.
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):
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.
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:
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
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:
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.
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:
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:
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:
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:
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:
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.
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.
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:
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):
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:
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
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:
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:
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:
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:
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".
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:
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:
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
$ 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:
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:
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:
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):
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?
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:
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:
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:
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:
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 è поддерживает:
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[]:
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_opsdeterminato da in archivio net/core/filter.c следующим обрахом:
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:
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:
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:
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_printk - questo è 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!
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 следующим обрахом:
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:
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
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
$ 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):
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:
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;
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:
(è 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":
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':
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):
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:
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
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:
(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.
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.
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.
Blog su BPF da Facebook. Viene aggiornato raramente, ma in modo appropriato, come scrivono Alexei Starovoitov (autore di eBPF) e Andrii Nakryiko - (manutentore) libbpf).
I segreti di bpftool. Un divertente thread Twitter di Quentin Monnet con esempi e segreti sull'utilizzo di bpftool.