Am Anfang war die Technologie, die BPF genannt wurde. Wir haben sie in , dem alttestamentlichen Artikel dieser Reihe, betrachtet. Im Jahr 2013 wurde durch die Bemühungen von Alexei Starovoitov und Daniel Borkman eine verbesserte Version entwickelt und in den Linux-Kern integriert, die für moderne 64-Bit-Maschinen optimiert ist. Diese neue Technologie trug für kurze Zeit den Namen Internal BPF, wurde dann in Extended BPF umbenannt, und mittlerweile wird sie einfach als BPF bezeichnet.
Groß gesagt ermöglicht BPF die Ausführung beliebigen vom Benutzer bereitgestellten Codes im Linux-Kernel, und die neue Architektur war so erfolgreich, dass wir noch etwa ein Dutzend Artikel benötigen werden, um alle ihre Anwendungen zu beschreiben. (Das Einzige, was die Entwickler nicht geschafft haben, wie Sie auf dem Bild unten sehen können, ist die Erstellung eines ansprechenden Logos.)
In diesem Artikel beschreiben wir den Aufbau der BPF-virtuellen Maschine, die Kernel-Schnittstellen zur Arbeit mit BPF, die Entwicklungstools sowie eine kurze, sehr kurze Übersicht über die bestehenden Möglichkeiten, d. h. alles, was wir für ein tieferes Verständnis der praktischen Anwendungen von BPF benötigen.
Zusammenfassung des Artikels
Zunächst werfen wir einen Blick auf die BPF-Architektur aus der Vogelperspektive und kennzeichnen die wichtigsten Komponenten.
Nachdem wir bereits eine Vorstellung von der Architektur im Allgemeinen haben, werden wir den Aufbau der BPF-virtuellen Maschine beschreiben.
In diesem Abschnitt werden wir genauer auf den Lebenszyklus von BPF-Objekten – Programmen und Maps – eingehen.
Nachdem wir bereits einige Kenntnisse über das System haben, werden wir schließlich betrachten, wie man Objekte aus dem Benutzerspace mithilfe eines speziellen Systemaufrufs erstellt und verwaltet – bpf(2).
Es ist natürlich möglich, Programme mit dem Systemaufruf zu schreiben. Aber es ist kompliziert. Für realistischere Szenarien wurde von Kernel-Programmierern die Bibliothek libbpfentwickelt. Wir werden das einfachste Skelett einer BPF-Anwendung erstellen, das wir in den folgenden Beispielen verwenden werden.
Hier erfahren wir, wie BPF-Programme auf Hilfsfunktionen des Kernels zugreifen können – ein Werkzeug, das zusammen mit Maps die Möglichkeiten des neuen BPF im Vergleich zum klassischen erheblich erweitert.
Bis zu diesem Punkt werden wir genügend wissen, um zu verstehen, wie genau man Programme erstellen kann, die Maps verwenden. Und wir werfen sogar einen kurzen Blick auf den großartigen und mächtigen Verifier.
Ein Abschnitt mit Hinweisen dazu, wie die erforderlichen Tools und den Kernel für Experimente zusammengestellt werden.
Am Ende des Artikels finden diejenigen, die bis hierhin lesen, motivierende Worte und eine kurze Beschreibung dessen, was in den nächsten Artikeln behandelt wird. Wir werden auch einige Links für das Selbststudium auflisten, für diejenigen, die nicht warten möchten oder können.
Einführung in die BPF-Architektur
Bevor wir mit der Betrachtung der BPF-Architektur beginnen, verweisen wir ein letztes Mal (oder vielleicht doch nicht) auf , das als Antwort auf das Aufkommen von RISC-Maschinen entwickelt wurde und das Problem der effizienten Paketfilterung löste. Die Architektur war so gelungen, dass sie, geboren in den chaotischen Neunzigern in Berkeley UNIX, auf die meisten existierenden Betriebssysteme portiert wurde, bis in die verrückten Zwanziger überdauerte und bis heute neue Anwendungen findet.
Das neue BPF wurde als Antwort auf die weitverbreitete Nutzung von 64-Bit-Maschinen, Cloud-Diensten und den erhöhten Bedarf an SDN-Entwicklungstools (Software-defined networking) entwickelt. Als verbesserte Version des klassischen BPF von Netzwerkingenieuren des Kernels entworfen, fand das neue BPF schon nach einem halben Jahr Anwendung bei der schwierigen Aufgabe der Tracing von Linux-Systemen, und jetzt, sechs Jahre nach seiner Einführung, benötigen wir einen ganzen, folgenden Artikel, nur um die verschiedenen Programtypen aufzulisten.
VeSeLeE nEWs
Im Grunde genommen ist BPF eine Sandbox-virtuelle Maschine, die es ermöglicht, "beliebigen" Code im Kernelraum auszuführen, ohne die Sicherheit zu gefährden. BPF-Programme werden im Benutzermodus erstellt, in den Kernel geladen und mit einer Ereignisquelle verbunden. Ein Ereignis kann beispielsweise die Lieferung eines Pakets an ein Netzwerkinterface oder das Auslösen einer Kernel-Funktion sein. Im Falle eines Pakets hat das BPF-Programm Zugriff auf die Daten und Metadaten des Pakets (lesen und möglicherweise schreiben, je nach Programmart), im Falle einer Kernel-Funktionsausführung auf die Funktionsargumente, einschließlich Zeiger auf den Kernel-Speicher, usw.
Lassen Sie uns diesen Prozess genauer betrachten. Zunächst erläutern wir den ersten Unterschied zum klassischen BPF, dessen Programme in Assembler geschrieben wurden. In der neuen Version wurde die Architektur so erweitert, dass Programme in Hochsprachen, insbesondere natürlich in C, geschrieben werden können. Dazu wurde ein Backend für llvm entwickelt, das es ermöglicht, Bytecode für die BPF-Architektur zu generieren.

Die BPF-Architektur wurde unter anderem entwickelt, um effizient auf modernen Maschinen ausgeführt zu werden. Damit dies in der Praxis funktioniert, wird der BPF-Bytecode nach dem Laden in den Kernel mithilfe eines als JIT-Compiler bezeichneten Moduls in nativen Code übersetzt (Just In Time). Wenn Sie sich erinnern, wurde im klassischen BPF das Programm atomar in den Kernel geladen und mit einer Ereignisquelle verbunden – im Rahmen eines einzigen Systemaufrufs. In der neuen Architektur erfolgt dies in zwei Schritten – zunächst wird der Code mithilfe eines Systemaufrufs in den Kernel geladen bpf(2), und später wird das Programm mithilfe anderer Mechanismen, die je nach Programmart unterschiedlich sind, an die Ereignisquelle angehängt (attaches).
Hier könnte der Leser sich fragen: War das wirklich möglich? Wie wird die Sicherheit der Ausführung solchen Codes gewährleistet? Die Sicherheit wird uns durch den Schritt beim Laden der BPF-Programme gewährleistet, der Verifikator (auf Englisch: verifier, und ich werde das englische Wort weiterhin verwenden):

Verifier — ein statischer Analyse-Tool, das garantiert, dass ein Programm den normalen Betrieb des Kernels nicht stört. Das bedeutet jedoch nicht, dass das Programm nicht in die Systemoperation eingreifen kann — BPF-Programme können je nach Typ Bereiche des Kernelspeichers lesen und überschreiben, Rückgabewerte von Funktionen verändern, modifizieren, ergänzen, umschreiben und sogar Netzwerkpakete umleiten. Der Verifier stellt sicher, dass das Kernel bei der Ausführung des BPF-Programms nicht abstürzt und dass Programme, die laut den Regeln schreibenden Zugriff haben, wie beispielsweise die Daten eines ausgehenden Pakets, nicht den Kernel-Speicher außerhalb des Pakets überschreiben können. Etwas detaillierter werden wir den Verifier im entsprechenden Abschnitt betrachten, nachdem wir uns mit allen anderen BPF-Komponenten vertraut gemacht haben.
Also, was haben wir bis zu diesem Punkt gelernt? Der Benutzer schreibt ein Programm in C, lädt es über einen Systemaufruf in den Kernel bpf(2), wo es durch den Verifier überprüft wird und in nativen Bytecode übersetzt wird. Dann verbindet derselbe oder ein anderer Benutzer das Programm mit einer Ereignisquelle und es beginnt mit der Ausführung. Die Trennung von Laden und Verbinden ist aus mehreren Gründen wichtig. Erstens ist der Start des Verifiers relativ ressourcenintensiv, und wenn wir dasselbe Programm mehrmals laden, verschwenden wir Rechenzeit. Zweitens hängt es von dem jeweiligen Programmtyp ab, wie genau das Programm verbunden wird. Ein "universelles" Interface, das vor einem Jahr entwickelt wurde, könnte für neue Programmtypen ungeeignet sein. (Obwohl es jetzt, da die Architektur reifer wird, die Idee gibt, dieses Interface auf einem einheitlichen Niveau zu standardisieren. libbpf.)
Aufmerksame Leser werden bemerken, dass wir noch nicht mit den Bildern fertig sind. Tatsächlich erklärt alles, was oben gesagt wurde, nicht, wie BPF die Situation im Vergleich zu klassischem BPF grundlegend verändert. Zwei Innovationen, die den Anwendungsbereich erheblich erweitern, sind die Möglichkeit, gemeinsamen Speicher zu nutzen, und die Kernel-Helferfunktionen. In BPF wird der gemeinsame Speicher mittels sogenannter Maps realisiert – gemeinsam genutzte Datenstrukturen mit einer bestimmten API. Sie wurden wahrscheinlich so genannt, weil der erste Map-Typ eine Hash-Tabelle war. Danach kamen Arrays, lokale (per-CPU) Hash-Tabellen und lokale Arrays, Suchbäume, Maps, die Zeiger auf BPF-Programme enthalten, und vieles mehr. Uns interessiert hier vor allem die Tatsache, dass BPF-Programme die Möglichkeit erhielten, ihren Zustand zwischen Aufrufen zu speichern und ihn mit anderen Programmen und mit dem Benutzerraum zu teilen.
Der Zugriff auf Maps erfolgt aus Benutzerprozessen über einen Systemaufruf bpf(2), und aus BPF-Programmen, die im Kernel laufen – mittels Helferfunktionen. Darüber hinaus existieren Helfer nicht nur für die Arbeit mit Maps, sondern auch für den Zugriff auf andere Möglichkeiten des Kernels. Zum Beispiel können BPF-Programme Helferfunktionen verwenden, um Pakete auf andere Schnittstellen umzuleiten, Ereignisse im Perf-Subsystem zu generieren, auf Kernelstrukturen zuzugreifen usw.

Zusammenfassend lässt sich sagen, dass BPF die Möglichkeit bietet, beliebigen, d.h. den Verifier überstandene Benutzer-Code in den Kernelraum zu laden. Dieser Code kann seinen Zustand zwischen Aufrufen speichern und Daten mit dem Benutzerraum austauschen, und hat auch Zugang zu den für diesen Programmtyp zugelassenen Kernel-Subsystemen.
Das ähnelt bereits den Möglichkeiten, die von den Kernmodulen bereitgestellt werden, im Vergleich zu denen BPF einige Vorteile hat (natürlich kann man nur ähnliche Anwendungen vergleichen, zum Beispiel die Systemverfolgung – man kann auf BPF keinen beliebigen Treiber schreiben). Man kann eine niedrigere Einstiegsschwelle feststellen (einige Dienstprogramme, die BPF verwenden, setzen keine Programmierkenntnisse des Kernels beim Benutzer voraus, geschweige denn allgemeine Programmierkenntnisse), die Sicherheit zur Laufzeit (hebt die Hand in den Kommentaren, wer beim Schreiben oder Testen von Modulen sein System nicht beschädigt hat), die Atomarität – beim Neuladen von Modulen gibt es Ausfallzeiten, und das BPF-Subsystem garantiert, dass kein Ereignis verpasst wird (fairerweise gilt das nicht für alle BPF-Programmartypen).
Die Verfügbarkeit solcher Möglichkeiten macht BPF zu einem vielseitigen Werkzeug zur Erweiterung des Kernels, was sich in der Praxis bestätigt: Immer mehr neue Programmtypen werden BPF hinzugefügt, immer mehr große Unternehmen setzen BPF auf Produktionsservern 24×7 ein, und immer mehr Startups bauen ihr Geschäft auf Lösungen auf, die auf BPF basieren. BPF wird überall eingesetzt: zum Schutz vor DDoS-Angriffen, zur Schaffung von SDN (z.B. Implementierungen von Netzwerken für Kubernetes), als Hauptwerkzeug zur Systemverfolgung und zur Sammlung von Statistiken, in Intrusion Detection Systemen und in Sandboxes usw.
Lassen Sie uns damit den Überblicksteil des Artikels abschließen und einen genaueren Blick auf die virtuelle Maschine und das BPF-Ökosystem werfen.
Ausweichmanöver: Dienstprogramme
Um die Beispiele aus den folgenden Abschnitten auszuführen, benötigen Sie möglicherweise einige Dienstprogramme, mindestens llvm/clang mit BPF-Unterstützung und bpftool. Im Abschnitt können Sie Anweisungen zum Erstellen von Dienstprogrammen sowie Ihres eigenen Kernels lesen. Dieser Abschnitt wurde weiter unten platziert, um die Klarheit unserer Darstellung nicht zu stören.
Register und BPF-VM-Befehlsystem
Die Architektur und das BPF-Befehlsystem wurden mit der Überlegung entwickelt, dass Programme in der Sprache C geschrieben werden und nach dem Laden in den Kernel in nativen Code übersetzt werden. Daher wurde die Anzahl der Register und die Vielzahl der Befehle unter Berücksichtigung der Überlappung, im mathematischen Sinne, der Möglichkeiten moderner Maschinen ausgewählt. Darüber hinaus wurden verschiedene Einschränkungen für Programme eingeführt, beispielsweise war es bis vor kurzem nicht möglich, Schleifen und Unterprogramme zu schreiben, und die Anzahl der Anweisungen war auf 4096 begrenzt (jetzt können privilegierte Programme bis zu eine Million Anweisungen laden).
BPF verfügt über elf für Benutzer zugängliche 64-Bit-Register r0—r10 und den Befehlszähler (program counter). Das Register r10 enthält den Zeiger auf den Stack (frame pointer) und ist nur lesbar. Programmen steht während der Ausführung ein 512 Byte großer Stack und eine unbegrenzte Menge an gemeinsam genutztem Speicher in Form von Maps zur Verfügung.
BPF-Programmen ist es erlaubt, je nach Programtyp eine bestimmte Menge von Hilfsfunktionen (kernel helpers) auszuführen und seit kurzem auch reguläre Funktionen. Jede aufgerufene Funktion kann bis zu fünf Argumente entgegennehmen, die in den Registern r1—r5, übergeben werden, während der Rückgabewert in r0übergeben wird. Es wird garantiert, dass der Inhalt der Register r6—r9 nach der Rückkehr aus der Funktion unverändert bleibt.
Für eine effektive Übersetzung von Programmen werden die Register r0—r11 für alle unterstützten Architekturen eindeutig auf die realen Register unter Berücksichtigung der Besonderheiten des ABI der aktuellen Architektur abgebildet. Zum Beispiel werden für x86_64 Register r1—r5, die für die Übergabe von Funktionsparametern verwendet werden, auf rdi, rsi, rdx, rcx, r8, die für die Übergabe von Parametern in Funktionen auf x86_64. Zum Beispiel wird der Code links in den Code rechts wie folgt übersetzt:
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 0x0000000000001ee8Das Register r0 wird auch zur Rückgabe des Ergebnisses der Programmausführung verwendet, und im Register r1 wird der Zeiger auf den Kontext übergeben — je nach Programtyp kann dies beispielsweise die Struktur (für XDP) oder die Struktur (für verschiedene Netzwerkprogramme) oder die Struktur (für verschiedene Arten von Tracing-Programmen) usw. sein.
Wir hatten also ein Set von Registern, Kernel-Helpern, einem Stack, einem Zeiger auf einen Kontext und gemeinsam genutztem Speicher in Form von Maps. Es war nicht unbedingt erforderlich, all dies auf der Reise dabei zu haben, aber…
Lassen Sie uns die Beschreibung fortsetzen und über das Befehlssystem zur Arbeit mit diesen Objekten sprechen. Alle () BPF-Instruktionen haben eine feste Größe von 64 Bit. Wenn Sie sich eine Instruktion auf einer 64-Bit-Big-Endian-Maschine ansehen, werden Sie sehen
![]()
Hier Code — das ist die Kodierung der Instruktion, Dst/Src — das sind die Kodierungen des Ziel- und des Quelloperanden, entsprechend, Off — ein 16-Bit vorzeichenbehafteter Offset, und Imm — das ist eine 32-Bit vorzeichenbehaftete Ganzzahl, die in einigen Befehlen verwendet wird (analog zur Konstante K aus cBPF). Die Kodierung Code hat eine der zwei Formen:

Die Klassen 0, 1, 2, 3 definieren Befehle zur Arbeit mit Speicher. Sie , BPF_LD, BPF_LDX, BPF_ST, BPF_STX, entsprechend. Die Klassen 4, 7 (BPF_ALU, BPF_ALU64) stellen eine Menge von ALU-Instruktionen dar. Die Klassen 5, 6 (BPF_JMP, BPF_JMP32) umfassen Sprunginstruktionen.
Der weitere Plan zum Studium des BPF-Befehlssystems ist folgender: Statt alle Instruktionen und deren Parameter detektivisch aufzulisten, werden wir in diesem Abschnitt einige Beispiele durchgehen, aus denen deutlich wird, wie die Instruktionen tatsächlich aufgebaut sind und wie man jede Binärdatei für BPF manuell disassemblieren kann. Zur Festigung des Gelernten werden wir im weiteren Verlauf des Artikels auch individuellen Instruktionen in den Abschnitten über den Verifier, JIT-Compiler, die Übersetzung des klassischen BPF sowie beim Studium von Maps, dem Aufruf von Funktionen usw. begegnen.
Wenn wir von einzelnen Instruktionen sprechen, werden wir auf die Kernel-Dateien und , in denen die numerischen Codes der BPF-Instruktionen definiert sind, verweisen. Bei selbstständigem Studium der Architektur und/oder bei der Analyse von Binärdateien finden Sie die Semantiken in den folgenden, nach Schwierigkeit geordneten Quellen: , , und natürlich im Quellcode von Linux — Verifier, JIT, BPF-Interpreter.
Beispiel: BPF im Kopf disassemblieren
Betrachten wir ein Beispiel, in dem wir das Programm readelf-example.c kompilieren und das resultierende Binary anschauen. Wir werden den ursprünglichen Inhalt readelf-example.c nach unten offenlegen, nachdem wir seine Logik aus den Binärcodes wiederhergestellt haben:
$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hexdump des Abschnitts '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................Die erste Spalte in der Ausgabe readelf — das ist der Abstand, und unser Programm besteht somit aus vier Befehlen:
Code Dst Src Off Imm
b7 0 0 0000 01000000
15 0 1 0100 00000000
b7 0 0 0000 02000000
95 0 0 0000 00000000Die Opcode-Codes sind gleich b7, 15, b7 und 95. Erinnern wir uns daran, dass die drei niedrigsten Bits die Instruktionsklasse darstellen. In unserem Fall ist das vierte Bit bei allen Anweisungen leer, daher sind die Klassen der Anweisungen gleich, nämlich 7, 5, 7, 5. Klasse 7 ist BPF_ALU64, und 5 ist BPF_JMP. Für beide Klassen ist das Format der Anweisung identisch (siehe oben), und wir können unser Programm so umschreiben (nebenbei alle übrigen Spalten lesbar umschreiben):
Op S Klasse 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 0Operation b Klasse ALU64 sind . Es weist einen Wert dem Zielregister zu. Wenn das Bit s gesetzt ist (source), wird der Wert aus dem Quellregister genommen, und wenn es, wie in unserem Fall, nicht gesetzt ist, wird der Wert aus dem Feld Immgenommen. Somit führen wir in der ersten und dritten Anweisung die Operation aus r0 = Imm. Die Operation 1 der Klasse JMP ist (Sprung, wenn gleich). In unserem Fall, da das Bit S null ist, vergleicht sie den Wert des Quellregisters mit dem Feld Imm. Wenn die Werte übereinstimmen, erfolgt der Sprung zu PC + OffPC, wie üblich, enthält die Adresse der nächsten Anweisung. Schließlich ist die Operation 9 der Klasse JMP . Diese Anweisung beendet das Programm und gibt an den Kernel zurück r0. Lassen Sie uns eine neue Spalte zu unserer Tabelle hinzufügen:
Op S Klasse Dst Src Off Imm Disassm
MOV 0 ALU64 0 0 0 1 r0 = 1
JEQ 0 JMP 0 1 1 0 wenn (r1 == 0) gehe zu pc+1
MOV 0 ALU64 0 0 0 2 r0 = 2
EXIT 0 JMP 0 0 0 0 exitWir können dies in eine bequemere Form umschreiben:
r0 = 1
wenn (r1 == 0) gehe zu END
r0 = 2
END:
exitWenn wir uns daran erinnern, dass im Register r1 der Pointer auf den Kontext vom Kernel übergeben wird und im Register r0 der Wert an den Kernel zurückgegeben wird, können wir sehen, dass wir 1 zurückgeben, wenn der Kontext-Pointer null ist, und andernfalls 2. Lassen Sie uns überprüfen, ob wir recht haben, indem wir uns den Quellcode anschauen:
$ cat readelf-example.c
int foo(void *ctx)
{
return ctx ? 2 : 1;
}Ja, es ist ein sinnloses Programm, aber es wird nur in vier einfache Anweisungen übersetzt.
Beispiel-Ausschluss: 16-Byte-Anweisung
Wir haben bereits erwähnt, dass einige Anweisungen mehr als 64 Bit benötigen. Dies betrifft beispielsweise die Anweisung lddw (Code = 0x18 = | | ) — lädt ein doppeltword in das Register aus den Feldern Imm. Das Problem ist, dass Imm 32 Bit groß sind und ein doppeltword 64 Bit, sodass man ein 64-Bit- unmittelbares Wert nicht in einer 64-Bit-Anweisung in das Register laden kann. Dazu werden zwei benachbarte Anweisungen verwendet, um den zweiten Teil des 64-Bit-Wertes im Feld zu speichern Imm. Beispiel:
$ 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 des Abschnitts '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000 ........Im Binärogramm gibt es nur zwei Anweisungen:
Binary Disassm
18000000 ddccbbaa 00000000 44332211 r0 = Imm[0]|Imm[1]
95000000 00000000 exitWir werden auch noch die Anweisung lddwsehen, wenn wir über Relokationen und die Arbeit mit Maps sprechen.
Beispiel: Wir disassemblieren BPF mit Standardmitteln
Wir haben also gelernt, die BPF-Binärcodes zu lesen und sind bereit, jede Anweisung zu analysieren, falls nötig. Es ist jedoch erwähnenswert, dass es in der Praxis bequemer und schneller ist, Programme mit Hilfe von Standardmitteln zu disassemblieren, zum Beispiel:
$ llvm-objdump -d x64.o
Disassemblierung des Abschnitts .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 exitDer Lebenszyklus von BPF-Objekten, das bpffs-Dateisystem
(Einige der Details, die in diesem Abschnitt beschrieben werden, habe ich zum ersten Mal aus von Alexei Starovoitov im .)
BPF-Objekte — Programme und Maps — werden aus dem Benutzerspeicherraum mit den Befehlen BPF_PROG_LOAD und BPF_MAP_CREATE des Systemaufrufs bpf(2), wir werden im nächsten Abschnitt darüber sprechen, wie genau das geschieht. Dabei werden Datenstrukturen im Kernel erstellt und für jede von ihnen wird der Refcount (Referenzzähler) auf eins gesetzt, und dem Benutzer wird ein Dateideskriptor zurückgegeben, der auf das Objekt zeigt. Nach dem Schließen des Deskriptors wird der Refcount wird der Zähler des Objekts um eins verringert, und wenn er null erreicht, wird das Objekt zerstört.
Wenn das Programm Maps verwendet, dann wird der Refcount wird die Anzahl dieser Maps um eins erhöht, nachdem das Programm geladen wurde, d.h. ihre Dateideskriptoren können aus dem Benutzerprozess geschlossen werden, ohne dass sie wird der Refcount null werden:

Nach dem erfolgreichen Laden des Programms verbinden wir es normalerweise mit einem Ereignisgenerator. Zum Beispiel können wir es an eine Netzwerkschnittstelle anschließen, um eingehende Pakete zu verarbeiten, oder es an irgendeinen anschließen. tracepoint im Kernel. In diesem Moment wird auch der Referenzzähler um eins erhöht und wir können den Dateideskriptor im Bootloader schließen.
Was passiert, wenn wir jetzt den Bootloader beenden? Das hängt von der Art des Ereignisgenerators (Hook) ab. Alle Netzwerkhooks bleiben nach der Beendigung des Bootloaders bestehen, das sind die sogenannten globalen Hooks. Programme zur Nachverfolgung hingegen werden nach dem Ende des Prozesses, der sie erstellt hat, freigegeben (und werden daher als lokal bezeichnet, von „local to the process“). Technisch gesehen haben lokale Hooks immer einen entsprechenden Dateideskriptor im Benutzerspeicher und werden daher mit dem Schließen des Prozesses geschlossen, globale jedoch nicht. Im nächsten Bild versuche ich mit roten Kreuzen darzustellen, wie das Beenden des Bootloaders die Lebensdauer der Objekte im Falle lokaler und globaler Hooks beeinflusst.

Warum gibt es eine Unterscheidung zwischen lokalen und globalen Hooks? Der Start bestimmter Arten von Netzwerkprogrammen ergibt auch ohne Userspace Sinn. Stellen Sie sich zum Beispiel DDoS-Schutz vor – der Bootloader legt Regeln fest und verbindet ein BPF-Programm mit der Netzwerkschnittstelle, danach kann der Bootloader beendet werden. Andererseits stellen Sie sich ein Debugging-Trace-Programm vor, das Sie in zehn Minuten schnell geschrieben haben – nachdem Sie es beendet haben, möchten Sie sicherstellen, dass im System kein Müll bleibt, und lokale Hooks garantieren dies.
Andererseits, stellen Sie sich vor, Sie möchten sich mit einem Tracepoint im Kernel verbinden und über viele Jahre Statistiken sammeln. In diesem Fall möchten Sie wahrscheinlich den Benutzerteil beenden und von Zeit zu Zeit zu den Statistiken zurückkehren. Diese Möglichkeit bietet das BPF-Dateisystem. Es ist ein Pseudodateisystem, das nur im Speicher existiert und es ermöglicht, Dateien zu erstellen, die auf BPF-Objekte verweisen und damit die wird der Refcount Objekte erhöhen. Danach kann der Bootloader beendet werden, und die von ihm erstellten Objekte bleiben erhalten.

Das Erstellen von Dateien im bpffs, die auf BPF-Objekte verweisen, wird als "Anheften" ("pin", wie in der folgenden Aussage: "Der Prozess kann ein BPF-Programm oder eine Map anheften") bezeichnet. Das Erstellen von Dateiobjekten für BPF-Objekte ist nicht nur sinnvoll, um die Lebensdauer lokaler Objekte zu verlängern, sondern auch um die Nutzung globaler Objekte zu erleichtern — zurückkommend auf das Beispiel mit dem globalen Programm zum Schutz vor DDoS, möchten wir gelegentlich die Möglichkeit haben, die Statistiken einzusehen.
Das BPF-Dateisystem wird normalerweise in /sys/fs/bpfgemountet, kann aber auch lokal gemountet werden, zum Beispiel so:
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpointDie Namen im Dateisystem werden mit dem Befehl BPF_OBJ_PIN des Systemaufrufs BPF erstellt. Um das zu verdeutlichen, lassen Sie uns ein Programm nehmen, es kompilieren, laden und es im bpffsanheften. Unser Programm macht nichts Nützliches, wir führen nur den Code an, damit Sie das Beispiel reproduzieren können:
$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
return 0;
}
char _license[] __attribute__((section("license"), used)) = "GPL";Kompilieren wir dieses Programm und erstellen eine lokale Kopie des Dateisystems. bpffs:
$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpointLaden wir nun unser Programm mit dem Werkzeug bpftool und sehen wir uns die begleitenden Systemaufrufe an bpf(2) (einige irrelevante Zeilen wurden aus der strace-Ausgabe entfernt):
$ 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) = 0Hier haben wir das Programm mit Hilfe von BPF_PROG_LOADgeladen, einen Dateideskriptor vom Kernel erhalten 3 und mit dem Befehl BPF_OBJ_PIN haben wir diesen Dateideskriptor als Datei angeheftet "bpf-mountpoint\/test". Danach hat das Ladescriptprogramm bpftool seine Arbeit beendet, aber unser Programm blieb im Kernel, obwohl wir es an kein Netzwerkinterface angeheftet haben:
$ 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 4096BWir können das Dateiobjekt mit einem gewöhnlichen unlink(2) entfernen und danach wird das entsprechende Programm gelöscht:
$ sudo rm .\/bpf-mountpoint\/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directoryLöschen von Objekten
Wenn wir über das Entfernen von Objekten sprechen, ist es wichtig zu klären, dass, nachdem wir das Programm vom Hook (Ereignisgenerator) getrennt haben, kein neues Ereignis mehr dessen Ausführung auslösen wird. Alle aktuellen Instanzen des Programms werden jedoch ordnungsgemäß beendet.
Einige Arten von BPF-Programmen erlauben es, Programme zur Laufzeit zu ersetzen, d.h. sie bieten die Atomarität der Sequenz. replace = alte Programm abtrennen, neues Programm anbringen. In diesem Fall werden alle aktiven Instanzen der alten Version des Programms beendet, und neue Ereignis-Handler werden aus dem neuen Programm erstellt. "Atomarität" bedeutet hier, dass kein Ereignis verpasst wird.
Anschließen von Programmen an Ereignisquellen
In diesem Artikel werden wir das Anschließen von Programmen an Ereignisquellen nicht separat beschreiben, da dies im Kontext eines bestimmten Programtyps sinnvoll ist. Siehe unten, wo wir zeigen, wie Programme vom Typ XDP angeschlossen werden.
Objektverwaltung über den Systemaufruf bpf
BPF-Programme
Alle BPF-Objekte werden aus dem Benutzermodus unter Verwendung des Systemaufrufs bpf, der folgendes Prototyp hat:
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);Hier ist der Befehl cmd — eines der Werte vom Typ , attr — ein Zeiger auf Parameter für ein bestimmtes Programm und Größe — die Größe des Objekts über den Zeiger, d.h. normalerweise ist das sizeof(*attr). Im Kernel 5.8 unterstützt der Systemaufruf bpf 34 verschiedene Befehle, und union bpf_attr belegt 200 Zeilen. Das sollte uns jedoch nicht abschrecken, da wir die Befehle und Parameter im Verlauf mehrerer Artikel kennenlernen werden.
Wir beginnen mit dem Befehl BPF_PROG_LOAD, der BPF-Programme erstellt - eine Reihe von BPF-Anweisungen übernimmt und diese in den Kernel lädt. Zum Zeitpunkt des Ladevorgangs wird der Verifier aktiviert, dann der JIT-Compiler, und nach erfolgreichem Abschluss wird dem Benutzer ein Dateideskriptor des Programms zurückgegeben. Wir haben gesehen, was als Nächstes mit ihm passiert ist im vorherigen Abschnitt .
Jetzt werden wir ein Benutzerprogramm schreiben, das ein einfaches BPF-Programm lädt, aber zuerst müssen wir entscheiden, welches Programm wir laden möchten - wir müssen und innerhalb dieses Typs ein Programm schreiben, das die Überprüfung im Verifier bestanden hat. Um den Prozess jedoch nicht zu verkomplizieren, hier ist eine fertige Lösung: Wir nehmen ein Programm vom Typ BPF_PROG_TYPE_XDP, die den Wert zurückgeben wird XDP_PASS (alle Pakete überspringen). Auf BPF-Assembler sieht das ganz einfach aus:
r0 = 2
exitNachdem wir uns entschieden haben, was wir laden werden, können wir erklären, wie wir das tun werden:
#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();
}Interessante Ereignisse in dem Programm beginnen mit der Definition des Arrays insns — unser BPF-Programm in Maschinenbefehlen. Dabei wird jeder BPF-Befehl in einer Struktur verpackt. Das erste Element insns entspricht dem Befehl r0 = 2, das zweite — exit.
Ein Rückblick. Im Kernel sind bequemere Makros für die Erstellung von Maschinenbefehlen definiert, und unter Verwendung der Kernel-Header-Datei tools/include/linux/filter.h könnten wir schreiben
struct bpf_insn insns[] = {
BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
BPF_EXIT_INSN()
};Aber da das Schreiben von BPF-Programmen in Maschinenbefehlen nur für das Schreiben von Tests im Kernel und für Artikel über BPF erforderlich ist, macht das Fehlen dieser Makros das Leben des Entwicklers in Wirklichkeit nicht komplizierter.
Nachdem das BPF-Programm definiert wurde, gehen wir zur Laden in den Kernel über. Unser minimalistisches Set an Parametern attr umfasst den Programtyp, die Anzahl und einen Satz von Befehlen, die obligatorische Lizenz sowie den Namen "woo", den wir verwenden, um unser Programm nach dem Laden im System zu finden. Das Programm wird, wie versprochen, mithilfe eines Systemaufrufs ins System geladen. bpf.
Am Ende des Programms gelangen wir in eine Endlosschleife, die die Nutzlast simuliert. Ohne sie wird das Programm vom Kernel beim Schließen des Dateihandles, das uns der Systemaufruf zurückgegeben hat, zerstört, bpfund wir werden es nicht im System sehen.
Nun, wir sind bereit für Tests. Lassen Sie uns das Programm unter stracekompilieren und starten, um zu überprüfen, ob alles ordnungsgemäß funktioniert:
$ clang -g -O2 simple-prog.c -o simple-prog
$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(Alles in Ordnung, bpf(2) gab uns den Deskriptor 3 zurück und wir sind in eine Endlosschleife mit pause(). Lassen Sie uns versuchen, unser Programm im System zu finden. Dazu wechseln wir in ein anderes Terminal und verwenden das Dienstprogramm 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)Wir sehen, dass im System ein geladenes Programm woo , dessen globaler ID 390 beträgt, und dass der Prozess simple-prog ein offenes Dateihandle hat, das auf das Programm verweist (und wenn simple-prog beendet die Arbeit, dann woo verschwinden). Wie erwartet, das Programm woo belegt 16 Byte – zwei Anweisungen – binäre Codes in der BPF-Architektur, aber in nativer Form (x86_64) sind es bereits 40 Byte. Lassen Sie uns unser Programm in der Originalform ansehen:
# bpftool prog dump xlated id 390
0: (b7) r0 = 2
1: (95) exitohne Überraschungen. Schauen wir uns nun den Code an, der vom JIT-Compiler erstellt wurde:
# 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: retqnicht gerade effizient für exit(2), aber der Fairness halber, unser Programm ist zu einfach, und bei nicht-trivialen Programmen sind Prolog und Epilog, die vom JIT-Compiler hinzugefügt werden, natürlich erforderlich.
Maps
BPF-Programme können strukturierte Speicherbereiche nutzen, die sowohl von anderen BPF-Programmen als auch von Programmen aus dem Benutzerspeicher zugänglich sind. Diese Objekte werden as maps bezeichnet und in diesem Abschnitt zeigen wir, wie man sie mit einem Systemaufruf verwaltet. bpf.
Lassen Sie uns gleich sagen, dass die Möglichkeiten von maps nicht nur auf den Zugriff auf den gemeinsamen Speicher beschränkt sind. Es gibt spezialisierte Maps, die beispielsweise Zeiger auf BPF-Programme oder Zeiger auf Netzwerkinterfaces enthalten, Maps für die Arbeit mit perf events usw. Hier werden wir sie nicht behandeln, um den Leser nicht zu verwirren. Darüber hinaus ignorieren wir Synchronisationsprobleme, da dies für unsere Beispiele nicht wichtig ist. Eine vollständige Liste der verfügbaren Map-Typen finden Sie in , und in diesem Abschnitt nehmen wir als Beispiel den historisch ersten Typ, die Hash-Tabelle. BPF_MAP_TYPE_HASH.
Wenn Sie eine Hash-Tabelle erstellen, sagen wir, in C++, würden Sie sagen unordered_map woo, was auf Deutsch bedeutet „ich benötige eine Tabelle woo unbegrenzter Größe, deren Schlüssel vom Typ intund die Werte vom Typ long“ sind. Um eine BPF-Hash-Tabelle zu erstellen, müssen wir ungefähr dasselbe tun, mit der Einschränkung, dass wir die maximale Tabellengröße angeben müssen, und anstelle der Typen von Schlüsseln und Werten müssen wir deren Größen in Byte angeben. Zur Erstellung von Maps wird der Befehl BPF_MAP_CREATE des Systemaufrufs bpfverwendet. Lassen Sie uns ein mehr oder weniger minimales Programm ansehen, das eine Map erstellt. Nach dem vorherigen Programm, das BPF-Programme lädt, sollte Ihnen dieses einfach erscheinen:
$ cat simple-map.c
#define _GNU_SOURCE
#include
#include
#include
#include
int main(void)
{
union bpf_attr attr = {
.map_type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 4,
};
strncpy(attr.map_name, "woo", sizeof(attr.map_name));
syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
for ( ;; )
pause();
}Hier definieren wir eine Gruppe von Parametern attr, in der wir sagen: „Ich brauche eine Hash-Tabelle mit Schlüsseln und Werten der Größe sizeof(int), in die ich maximal vier Elemente einfügen kann.“ Beim Erstellen von BPF-Karten können weitere Parameter definiert werden, beispielsweise, wie auch im Beispiel des Programms, haben wir den Objektname als "woo".
Komplizieren und starten wir das Programm:
$ 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(Hier hat der Systemaufruf bpf(2) uns den Deskriptor der Karte Nummer 3 zurückgegeben und das Programm wartet, wie erwartet, auf weitere Anweisungen im Systemaufruf pause(2).
Jetzt schicken wir unser Programm in den Hintergrund oder öffnen ein weiteres Terminal und schauen uns unser Objekt mit dem Tool an bpftool (wir können unsere Karten anhand ihres Namens unterscheiden):
$ sudo bpftool map
...
114: hash name woo flags 0x0
key 4B value 4B max_entries 4 memlock 4096B
...Die Zahl 114 ist die globale ID unseres Objekts. Jedes Programm im System kann diese ID verwenden, um eine bereits vorhandene Karte mit dem Befehl BPF_MAP_GET_FD_BY_ID des Systemaufrufs bpf.
Jetzt können wir mit unserer Hash-Tabelle experimentieren. Lassen Sie uns ihren Inhalt ansehen:
$ sudo bpftool map dump id 114
Gefunden 0 ElementeLeer. Lassen Sie uns einen Wert hinzufügen hash[1] = 1:
$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0Lassen Sie uns die Tabelle noch einmal ansehen:
$ sudo bpftool map dump id 114
key: 01 00 00 00 value: 01 00 00 00
Gefunden 1 ElementHurra! Wir haben es geschafft, ein Element hinzuzufügen. Beachten Sie, dass wir hierfür auf Byte-Ebene arbeiten müssen, da bptftool nicht weiß, welchen Typ die Werte in der Hash-Tabelle haben. (Das Wissen kann mit BTF übergeben werden, aber nicht jetzt.)
Wie genau liest und fügt bpftool Elemente hinzu? Lassen Sie uns einen Blick unter die Haube werfen:
$ 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 ENOENTZuerst haben wir die Karte anhand ihrer globalen ID mit dem Befehl BPF_MAP_GET_FD_BY_ID und bpf(2) öffneten uns den Deskriptor 3. Dann fanden wir mit dem Befehl BPF_MAP_GET_NEXT_KEY den ersten Schlüssel in der Tabelle, indem wir NULL als Verweis auf den "vorherigen" Schlüssel übergaben. Wenn ein Schlüssel vorhanden ist, können wir BPF_MAP_LOOKUP_ELEM, der den Wert an den Zeiger zurückgibt value. Der nächste Schritt besteht darin, das nächste Element zu finden, indem wir einen Zeiger auf den aktuellen Schlüssel übergeben, aber unsere Tabelle enthält nur ein Element und einen Befehl BPF_MAP_GET_NEXT_KEY gibt zurück ENOENT.
Gut, lassen Sie uns den Wert für den Schlüssel 1 ändern, sagen wir, unsere Geschäftslogik erfordert, dass wir das schreiben 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) = 0Wie erwartet ist das sehr einfach: der Befehl BPF_MAP_GET_FD_BY_ID öffnet unsere Map nach ID, und der Befehl BPF_MAP_UPDATE_ELEM überschreibt das Element.
Zusammenfassend lässt sich sagen, dass wir nach der Erstellung der Hash-Tabelle aus einem Programm den Inhalt von einem anderen Programm lesen und schreiben können. Beachten Sie, dass, wenn wir dies von der Kommandozeile aus tun konnten, es auch jedes andere Programm im System tun kann. Neben den oben beschriebenen Befehlen stehen zum Arbeiten mit Maps aus dem Userspace auch :
BPF_MAP_LOOKUP_ELEM: finde den Wert nach SchlüsselBPF_MAP_UPDATE_ELEM: Wert aktualisieren/erstellenBPF_MAP_DELETE_ELEM: Schlüssel löschenBPF_MAP_GET_NEXT_KEY: nächsten (oder ersten) Schlüssel findenBPF_MAP_GET_NEXT_ID: ermöglicht das Durchlaufen aller vorhandenen Maps, so funktioniertbpftool mapBPF_MAP_GET_FD_BY_ID: vorhandene Map nach ihrer globalen ID öffnenBPF_MAP_LOOKUP_AND_DELETE_ELEM: atomar den Wert des Objekts aktualisieren und den alten zurückgebenBPF_MAP_FREEZE: macht die Map unveränderlich aus dem Userspace (diese Operation kann nicht zurückgenommen werden)BPF_MAP_LOOKUP_BATCH,BPF_MAP_LOOKUP_AND_DELETE_BATCH,BPF_MAP_UPDATE_BATCH,BPF_MAP_DELETE_BATCH: Batch-Operationen. Zum Beispiel,BPF_MAP_LOOKUP_AND_DELETE_BATCH— dies ist die einzige zuverlässige Möglichkeit, alle Werte aus der Map zu lesen und zurückzusetzen
Nicht alle dieser Befehle funktionieren für alle Arten von Maps, aber im Allgemeinen sieht die Arbeit mit anderen Typen von Maps aus dem Userspace genauso aus wie die Arbeit mit Hash-Tabellen.
Zur Ordnung wollen wir unsere Experimente mit der Hash-Tabelle abschließen. Denken Sie daran, dass wir eine Tabelle erstellt haben, die bis zu vier Schlüssel enthalten kann? Fügen wir noch ein paar Elemente hinzu:
$ 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 0Bis jetzt läuft alles gut:
$ 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
Gefunden 4 ElementeVersuchen wir, noch einen hinzuzufügen:
$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Fehler: Update fehlgeschlagen: Argumentenliste zu langWie erwartet haben wir es nicht geschafft. Schauen wir uns den Fehler genauer an:
$ 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 (Argumentliste zu lang)
Fehler: Update fehlgeschlagen: Argumentliste zu lang
+++ beendet mit 255 +++Alles in Ordnung: wie erwartet, der Befehl BPF_MAP_UPDATE_ELEM versucht, einen neuen, fünften Schlüssel zu erstellen, stürzt aber mit E2BIG.
Wir können also BPF-Programme erstellen und laden sowie Karten im Benutzerspeicher erstellen und verwalten. Jetzt ist es sinnvoll, zu betrachten, wie wir Karten aus den BPF-Programmen selbst verwenden können. Wir könnten darüber in der Sprache schwer lesbarer Maschinen-Codes und -Makros sprechen, aber tatsächlich ist es an der Zeit zu zeigen, wie BPF-Programme wirklich geschrieben und gewartet werden — mit Hilfe von libbpf.
(Für Leser, die mit dem Mangel an niedrigstufigen Beispielen unzufrieden sind: Wir werden Programme im Detail untersuchen, die Karten und Hilfsfunktionen verwenden, die mit Hilfe von libbpf erstellt wurden, und erklären, was auf der Anleitungsebene passiert. Für Leser, die mit sehr unzufrieden, haben wir an der entsprechenden Stelle des Artikels hinzugefügt.)
BPF-Programme mit libbpf schreiben
BPF-Programme mit Maschinen-Codes zu schreiben, kann anfangs interessant sein, aber dann tritt eine Übersättigung auf. In diesem Moment sollte man seinen Blick auf llvmrichten, das einen Backend zur Codegenerierung für die BPF-Architektur enthält, sowie auf die Bibliothek libbpf, die es ermöglicht, den benutzerspezifischen Teil von BPF-Anwendungen zu schreiben und BPF-Programme, die mit Hilfe von llvm/clang.
generiert wurden, zu laden. Tatsächlich leistet libbpf eine Menge Arbeit, und ohne sie (oder ähnliche Werkzeuge — iproute2, libbcc, libbpf-go, usw.) wäre das Leben unmöglich. Eines der Killer-Features des Projekts libbpf ist BPF CO-RE (Compile Once, Run Everywhere) — ein Projekt, das es ermöglicht, BPF-Programme zu schreiben, die von einem Kern auf einen anderen portierbar sind, mit der Möglichkeit, auf verschiedenen APIs ausgeführt zu werden (zum Beispiel, wenn sich die Kernelstruktur von Version zu Version ändert). Um mit CO-RE arbeiten zu können, muss Ihr Kernel mit BTF-Unterstützung kompiliert sein (wie dies zu tun ist, erläutern wir im Abschnitt . Um zu überprüfen, ob Ihr Kernel mit BTF oder nicht kompiliert wurde, ist es ganz einfach — an der Existenz der folgenden Datei:
$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M 29. Jul 15:30 /sys/kernel/btf/vmlinuxDiese Datei enthält Informationen über alle in den Kern verwendeten Datentypen und wird in all unseren Beispielen verwendet, die libbpf. Wir werden im nächsten Artikel ausführlich über CO-RE sprechen, in diesem Artikel jedoch – bauen Sie einfach einen Kern mit CONFIG_DEBUG_INFO_BTF.
Bibliothek libbpf direkt im Verzeichnis tools/lib/bpf des Kerns und ihre Entwicklung erfolgt über die Mailingliste bpf@vger.kernel.org. Für Anwendungen, die außerhalb des Kerns leben, wird jedoch ein separates Repository unterstützt, in dem die Kernel-Bibliothek für den schreibgeschützten Zugriff mehr oder weniger so gespiegelt wird, wie sie ist.
In diesem Abschnitt werden wir sehen, wie ein Projekt erstellt werden kann, das libbpf, wir werden einige (mehr oder weniger sinnlose) Testprogramme schreiben und detailliert erklären, wie das alles funktioniert. Dadurch wird es uns in den folgenden Abschnitten leichter fallen, zu erklären, wie die BPF-Programme mit Maps, Kernel-Helpern, BTF usw. interagieren.
In der Regel fügen Projekte, die libbpf , das GitHub-Repository als Git-Submodul hinzu, das werden wir auch tun:
$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Leeres Git-Repository in /tmp/libbpf-example/.git/ initialisiert
$ git submodule add https://github.com/libbpf/libbpf.git
Klonen in '/tmp/libbpf-example/libbpf'...
remote: Objekte werden aufgelistet: 200, abgeschlossen.
remote: Objekte zählen: 100% (200/200), abgeschlossen.
remote: Objekte komprimieren: 100% (103/103), abgeschlossen.
remote: Insgesamt 3354 (Delta 101), wiederverwendet 118 (Delta 79), pack-wiederverwendet 3154
Objekte empfangen: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, abgeschlossen.
Delta auflösen: 100% (2176/2176), abgeschlossen.Wird libbpf sehr einfach kompiliert:
$ 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.pcUnser weiterer Plan in diesem Abschnitt ist Folgendes: Wir werden ein BPF-Programm vom Typ BPF_PROG_TYPE_XDP, das gleiche wie im vorherigen Beispiel, aber in C, kompilieren es mit Hilfe von clang, und schreiben ein Hilfsprogramm, das es in den Kern lädt. In den folgenden Abschnitten werden wir sowohl die Funktionen des BPF-Programms als auch des Hilfsprogramms erweitern.
Beispiel: Erstellen einer vollständigen Anwendung mit libbpf
Zunächst verwenden wir die oben erwähnte Datei /sys/kernel/btf/vmlinux, und erstellen ihr Äquivalent in Form einer Header-Datei:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.hIn dieser Datei werden alle Datenstrukturen gespeichert, die in unserem Kernel vorhanden sind, zum Beispiel wird der IPv4-Header im Kernel folgendermaßen definiert:
$ 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;
};Jetzt werden wir unser BPF-Programm in C schreiben:
$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include
SEC("xdp/simple")
int simple(void *ctx)
{
return XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";Obwohl unser Programm sehr einfach ist, sollten wir auf viele Details achten. Zunächst ist die erste Header-Datei, die wir einfügen, vmlinux.h, die wir gerade mit Hilfe von bpftool btf dump — jetzt müssen wir das Paket kernel-headers nicht installieren, um zu erfahren, wie die Strukturen des Kernels aussehen. Die nächste Header-Datei stammt aus der Bibliothek libbpf. Im Moment benötigen wir sie nur, damit das Makro SEC, das das Symbol in den entsprechenden Abschnitt der ELF-Objektdatei sendet, definiert wird. Unser Programm befindet sich im Abschnitt xdp/simple, wo wir vor dem Slash den Typ des BPF-Programms definieren — dies ist eine Vereinbarung, die in libbpfverwendet wird, basierend auf dem Namen des Abschnitts wird der richtige Typ beim Start eingesetzt bpf(2). Das BPF-Programm selbst ist in C — sehr einfach und besteht aus einer Zeile return XDP_PASS. Schließlich enthält der separate Abschnitt "license" den Namen der Lizenz.
Wir können unser Programm mit llvm/clang, Version >= 10.0.0, besser noch mehr, kompilieren (siehe Abschnitt ):
$ 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.oVon interessanten Merkmalen: Wir geben die Zielarchitektur an -target bpf und den Pfad zu den Header-Dateien libbpf, die wir kürzlich installiert haben. Außerdem, vergiss nicht -O2, ohne diese Option könnten Überraschungen auf dich warten. Lassen Sie uns unseren Code ansehen, haben wir das Programm geschrieben, das wir wollten?
$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o
xdp-simple.bpf.o: file format elf64-bpf
Disassembly of section xdp/simple:
0000000000000000 :
0: r0 = 2
1: exitJa, es hat funktioniert! Nun haben wir eine Binärdatei mit dem Programm, und wir möchten eine Anwendung erstellen, die sie in den Kernel lädt. Dazu benötigt man die Bibliothek libbpf bietet uns zwei Optionen – ein niedrigeres API oder ein höheres API zu verwenden. Wir werden den zweiten Weg wählen, da wir lernen möchten, wie man BPF-Programme mit minimalem Aufwand schreibt, lädt und anschließt, um sie anschließend zu studieren.
Zunächst müssen wir das "Skelett" unseres Programms aus seinem Binärformat mit demselben Tool generieren bpftool – dem Schweizer Taschenmesser der BPF-Welt (was auch wörtlich verstanden werden kann, da Daniel Borkman – einer der Schöpfer und Wartungsbeauftragten von BPF – Schweizer ist):
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.hIn der Datei xdp-simple.skel.h enthält den Binärcode unseres Programms und Funktionen zur Verwaltung – Laden, Anschließen, Löschen unseres Objekts. In unserem einfachen Fall scheint das übertrieben zu sein, aber es funktioniert auch, wenn die Objektdatei viele BPF-Programme und Maps enthält, und um diese riesige ELF zu laden, müssen wir nur das Skelett generieren und ein oder zwei Funktionen aus der Benutzeranwendung aufrufen, zu deren Schreiben wir jetzt übergehen werden.
Unsere Ladeanwendung ist eigentlich trivial:
#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);
}Hier struct xdp_simple_bpf wird in der Datei definiert xdp-simple.skel.h und beschreibt unsere Objektdatei:
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;
};Hier können wir Spuren des niedrigen APIs erkennen: die Struktur struct bpf_program *simple und struct bpf_link *simple. Die erste Struktur beschreibt konkret unser Programm, das im Abschnitt xdp/simplegespeichert ist, und die zweite beschreibt, wie das Programm an die Ereignisquelle angeschlossen wird.
Funktion xdp_simple_bpf__open_and_load, öffnet das ELF-Objekt, parst es, erstellt alle Strukturen und Unterstrukturen (neben dem Programm sind im ELF auch andere Abschnitte – data, readonly data, Debug-Informationen, Lizenz usw. enthalten), und lädt es dann über den Systemaufruf bpf, den wir überprüfen können, indem wir das Programm kompilieren und ausführen:
$ 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) = 4Sehen wir uns jetzt unser Programm mit Hilfe von bpftoolan. Finden wir ihre 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)und dumpen wir es (wir verwenden eine verkürzte Befehlsform) bpftool prog dump xlated):
# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
0: (b7) r0 = 2
1: (95) exitEtwas Neues! Das Programm hat Teile unserer Quelldatei in der Programmiersprache C ausgegeben. Dies wurde durch die Bibliothek erreicht libbpf, die den Debugging-Bereich im Binärformat gefunden, ihn in ein BTF-Objekt kompiliert, ihn mit Hilfe von BPF_BTF_LOAD, und dann den erhaltenen Dateideskriptor beim Laden des Programms mit dem Befehl BPG_PROG_LOAD.
Kernel-Hilfsprogramme
BPF-Programme können „externe“ Funktionen — Kernel-Hilfsprogramme — aufrufen. Diese Hilfsfunktionen ermöglichen es BPF-Programmen, auf Kernel-Datenstrukturen zuzugreifen, Maps zu verwalten und mit der „realen Welt“ zu kommunizieren — z. B. Perf-Events zu erstellen, Hardware zu steuern (z. B. Pakete umzuleiten) usw.
Beispiel: bpf_get_smp_processor_id
Im Rahmen des Paradigmas „Lernen durch Beispiele“ betrachten wir eine der Hilfsfunktionen, bpf_get_smp_processor_id(), in der Datei kernel/bpf/helpers.c. Sie gibt die Nummer des Prozessors zurück, auf dem das aufrufende BPF-Programm ausgeführt wird. Uns interessiert dabei weniger ihre Semantik als vielmehr, dass ihre Implementierung aus einer Zeile besteht:
BPF_CALL_0(bpf_get_smp_processor_id)
{
return smp_processor_id();
}Definitionen von BPF-Hilfsfunktionen ähneln den Definitionen von Systemaufrufen in Linux. Hier wird z. B. eine Funktion definiert, die keine Argumente hat. (Eine Funktion, die drei Argumente akzeptiert, wird mit dem Makro BPF_CALL_3definiert. Die maximale Anzahl von Argumenten beträgt fünf.) Dies ist jedoch nur der erste Teil der Definition. Der zweite Teil besteht darin, eine Struktur des Typs struct bpf_func_protozu definieren, die die Beschreibung der Hilfsfunktion enthält, die für den Verifier verständlich ist:
const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
.func = bpf_get_smp_processor_id,
.gpl_only = false,
.ret_type = RET_INTEGER,
};Registrierung von Hilfsfunktionen
Damit BPF-Programme eines bestimmten Typs diese Funktion verwenden können, müssen sie sie registrieren, z. B. für den Typ BPF_PROG_TYPE_XDP Im Kernel wird die Funktion xdp_func_proto, die anhand der ID der Hilfsfunktion bestimmt, ob XDP diese Funktion unterstützt oder nicht. Unsere Funktion wird :
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;
...
}
}Neue Typen von BPF-Programmen werden in der Datei mit Hilfe des Makros BPF_PROG_TYPE. Sie werden in Anführungszeichen gesetzt, da es sich um eine logische Definition handelt, während die Definition einer ganzen Reihe konkreter Strukturen in der Programmiersprache C an anderen Stellen erfolgt. Insbesondere in der Datei kernel/bpf/verifier.c werden alle Definitionen aus der Datei bpf_types.h verwendet, um ein Array von Strukturen zu erstellen bpf_verifier_ops[]:
static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type)
[_id] = & _name ## _verifier_ops,
#include
#undef BPF_PROG_TYPE
};Das heißt, für jeden Typ von BPF-Programmen wird ein Zeiger auf die Datenstruktur vom Typ struct bpf_verifier_ops, der mit dem Wert _name ## _verifier_ops, d.h., xdp_verifier_ops für xdp. Die Struktur xdp_verifier_ops in der Datei net/core/filter.c folgendermaßen ausführen:
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,
};Hier sehen wir unsere bekannte Funktion xdp_func_proto, die jedes Mal aufgerufen wird, wenn der Verifier auf einen Aufruf von einer Funktion innerhalb des BPF-Programms trifft, siehe .
Schauen wir uns an, wie ein hypothetisches BPF-Programm die Funktion bpf_get_smp_processor_id. Dazu schreiben wir das Programm aus unserem vorherigen Abschnitt wie folgt um:
#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";Das Zeichen bpf_get_smp_processor_id in <bpf/bpf_helper_defs.h> Bibliothek libbpf als
static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;das heißt, bpf_get_smp_processor_id — ist ein Zeiger auf eine Funktion, deren Wert gleich 8 ist, wobei 8 — das Wert BPF_FUNC_get_smp_processor_id des Typs enum bpf_fun_id, der für uns in der Datei definiert wird vmlinux.h (die Datei bpf_helper_defs.h im Kernel wird durch ein Skript generiert, daher sind „magische“ Zahlen in Ordnung). Diese Funktion nimmt keine Argumente entgegen und gibt einen Wert vom Typ __u32. Wenn wir sie in unserem Programm ausführen, clang generiert sie die Anweisung BPF_CALL „richtigen Typs“. Lassen Sie uns das Programm kompilieren und uns den Abschnitt ansehen 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: dateiformat elf64-bpf
Disassembly des Abschnitts xdp/simple:
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: bf 01 00 00 00 00 00 00 r1 = r0
2: 67 01 00 00 20 00 00 00 r1 <>= 32
4: b7 00 00 00 02 00 00 00 r0 = 2
5: 15 01 01 00 00 00 00 00 if r1 == 0 goto +1
6: b7 00 00 00 01 00 00 00 r0 = 1
0000000000000038 :
7: 95 00 00 00 00 00 00 00 exitIn der ersten Zeile sehen wir die Anweisung call, der Parameter IMM deren Wert 8 ist, und SRC_REG — null. Gemäß der ABI-Vereinbarung, die vom Verifier verwendet wird, ist dies der Aufruf der Hilfsfunktion mit der Nummer acht. Nach ihrem Start ist die Logik einfach. Der zurückgegebene Wert aus dem Register r0 wird kopiert in r1 und in den Zeilen 2,3 in den Typ u32 — die oberen 32 Bits werden auf null gesetzt. In den Zeilen 4,5,6,7 geben wir 2 (XDP_PASS) oder 1 (XDP_DROP) zurück, je nachdem, ob die Hilfsfunktion in Zeile 0 einen null- oder einen nicht-null-Wert zurückgegeben hat.
Lassen Sie uns überprüfen: Wir laden das Programm und sehen uns die Ausgabe an bpftool prog dump xlated:
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914
$ sudo bpftool p | grep simple
523: xdp name simple tag 44c38a10c657e1b0 gpl
pids xdp-simple(10915)
$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
0: (85) call bpf_get_smp_processor_id#114128
1: (bf) r1 = r0
2: (67) r1 <>= 32
4: (b7) r0 = 2
; }
5: (15) if r1 == 0x0 goto pc+1
6: (b7) r0 = 1
7: (95) exitGut, der Verifier hat den richtigen Kernel-Helfer gefunden.
Beispiel: Wir übergeben Argumente und starten schließlich das Programm!
Alle Hilfsfunktionen auf der Ausführungsebene haben das Prototyp
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)Die Parameter werden in Registern an die Hilfsfunktionen übergeben r1—r5, und der Wert wird im Register r0zurückgegeben. Es gibt keine Funktionen, die mehr als fünf Argumente annehmen – und eine Unterstützung dafür wird in Zukunft nicht vorgesehen.
Lassen Sie uns den neuen Kernel-Helfer und die Art und Weise, wie BPF Parameter übergibt, ansehen. Lassen Sie uns xdp-simple.bpf.c wie folgt umschreiben (die anderen Zeilen sind unverändert):
SEC("xdp/simple")
int simple(void *ctx)
{
bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
return XDP_PASS;
}Unser Programm druckt die Nummer der CPU, auf der es läuft. Lassen Sie uns es kompilieren und den Code ansehen:
$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o
0000000000000000 :
0: r1 = 10
1: *(u16 *)(r10 - 8) = r1
2: r1 = 8441246879787806319 ll
4: *(u64 *)(r10 - 16) = r1
5: r1 = 2334956330918245746 ll
7: *(u64 *)(r10 - 24) = r1
8: call 8
9: r1 = r10
10: r1 += -24
11: r2 = 18
12: r3 = r0
13: call 6
14: r0 = 2
15: exitIn den Zeilen 0-7 speichern wir den String running on CPU%un, und dann in Zeile 8 rufen wir unseren bekannten bpf_get_smp_processor_idauf. In den Zeilen 9-12 bereiten wir die Argumente des Helfers vor bpf_printk — Register r1, r2, r3. Warum sind es drei und nicht zwei? Weil bpf_printk — um den echten Helfer bpf_trace_printk, der die Größe des Format-Strings benötigt, zu kapseln.
Lassen Sie uns jetzt ein paar Zeilen zu xdp-simple.c, damit unser Programm sich mit dem Interface verbindet lo und startete wirklich!
$ cat xdp-simple.c
#include
#include
#include
#include "xdp-simple.skel.h"
int main(int argc, char **argv)
{
__u32 flags = XDP_FLAGS_SKB_MODE;
struct xdp_simple_bpf *obj;
obj = xdp_simple_bpf__open_and_load();
if (!obj)
err(1, "Fehler beim Öffnen und/oder Laden des BPF-Objektsn");
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);
}Hier verwenden wir die Funktion bpf_set_link_xdp_fd, die BPF-Programme vom Typ XDP mit Netzwerkinterfaces verbindet. Wir haben die Schnittstellennummer hartcodiert lo, die immer 1 ist. Wir rufen die Funktion zweimal auf, um zuerst das alte Programm zu trennen, falls es angeschlossen war. Beachten Sie, dass wir jetzt keinen Aufruf mehr benötigen pause oder eine Endlosschleife: Unser Loader-Programm wird beendet, aber das BPF-Programm wird nicht zerstört, da es an die Ereignisquelle angeschlossen ist. Nach dem erfolgreichen Laden und Anschließen wird das Programm für jedes eintreffende Netzwerkpaket ausgeführt, das an lo.
Laden wir das Programm und sehen uns das Interface an lo:
$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp name simple tag 4fca62e77ccb43d6 gpl
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 669Das Programm, das wir geladen haben, hat die ID 669 und die gleiche ID sehen wir am Interface lo. Lassen Sie uns ein paar Pakete an 127.0.0.1 (Anfrage + Antwort):
$ ping -c1 localhostund nun sehen wir uns den Inhalt der Debug-Virtuellen Datei an /sys/kernel/debug/tracing/trace_pipe, in die bpf_printk seine Nachrichten schreibt:
# 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 CPU0Zwei Pakete wurden an lo registriert und auf CPU0 verarbeitet – unser erstes voll funktionsfähiges sinnloses BPF-Programm hat funktioniert!
Es ist erwähnenswert, dass bpf_printk nicht umsonst in die Debug-Datei schreibt: es ist kein besonders guter Helfer für die Verwendung in der Produktion, aber unser Ziel war es, etwas Einfaches zu zeigen.
Zugriff auf Maps aus BPF-Programmen
Beispiel: Verwendung einer Map aus einem BPF-Programm
In den vorherigen Abschnitten haben wir gelernt, Maps aus dem Benutzerspeicher zu erstellen und zu verwenden, und jetzt schauen wir uns den Kernelteil an. Fangen wir, wie gewohnt, mit einem Beispiel an. Lassen Sie uns unser Programm umschreiben xdp-simple.bpf.c folgendermaßen ausführen:
#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";Am Anfang des Programms haben wir die Definition einer Map hinzugefügt woo: dies ist ein Array von 8 Elementen, in dem Werte des Typs u64 (in C würden wir ein solches Array als u64 woo[8]) definieren. Im Programm "xdp/simple" holen wir uns die Nummer des aktuellen Prozessors in die Variable key und verwenden dann die Hilfsfunktion bpf_map_lookup_element Wir erhalten einen Verweis auf den entsprechenden Eintrag im Array, den wir um eins erhöhen. Auf Russisch gesagt: Wir zählen die Statistiken darüber, auf welchem CPU die eingehenden Pakete verarbeitet wurden. Lassen Sie uns das Programm starten:
$ 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-simplePrüfen wir, ob sie verbunden ist mit lo und senden wir ein paar Pakete:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 108
$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; doneJetzt schauen wir uns den Inhalt des Arrays an:
$ 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 }
]Fast alle Prozesse wurden auf CPU7 verarbeitet. Das ist für uns nicht wichtig; Hauptsache, das Programm funktioniert und wir haben verstanden, wie man auf Maps aus BPF-Programmen zugreift — mit Hilfe von .
Mystischer Verweis
Also können wir aus dem BPF-Programm auf die Map zugreifen, indem wir Aufrufe der Form verwenden
val = bpf_map_lookup_elem(&woo, &key);wo die Hilfsfunktion so aussieht
void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)aber wir übergeben den Verweis &woo auf eine anonyme Struktur struct { ... }…
Wenn wir uns die Assembler des Programms ansehen, werden wir feststellen, dass der Wert &woo tatsächlich nicht definiert ist (Zeile 4):
llvm-objdump -D --section xdp/simple xdp-simple.bpf.o
xdp-simple.bpf.o: dateiformat elf64-bpf
Zerlegung des Abschnitts xdp/simple:
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
2: bf a2 00 00 00 00 00 00 r2 = r10
3: 07 02 00 00 fc ff ff ff r2 += -4
4: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
6: 85 00 00 00 01 00 00 00 call 1
...und ist in den Relokationen enthalten:
$ llvm-readelf -r xdp-simple.bpf.o | head -4
Relokationsabschnitt '.relxdp/simple' bei Offset 0xe18 enthält 1 Einträge:
Offset Info Typ Wert des Symbols Name des Symbols
0000000000000020 0000002700000001 R_BPF_64_64 0000000000000000 wooAber wenn wir uns das bereits geladene Programm ansehen, sehen wir einen Verweis auf die richtige Map (Zeile 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]
...Somit können wir schließen, dass beim Starten unseres Ladeprogramms der Verweis auf &woo etwas von der Bibliothek ersetzt wurde. libbpf. Zunächst schauen wir uns die Ausgabe an 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) = 5Wir sehen, dass libbpf eine Map erstellt wurde woo und dann unser Programm geladen wurde simple. Lassen Sie uns genauer betrachten, wie wir das Programm laden:
- wir rufen auf
xdp_simple_bpf__open_and_loadaus der Dateixdp-simple.skel.h - die aufruft
xdp_simple_bpf__loadaus der Dateixdp-simple.skel.h - die aufruft
bpf_object__load_skeletonaus der Dateilibbpf/src/libbpf.c - die aufruft
bpf_object__load_xattrauslibbpf/src/libbpf.c
Die letzte Funktion wird unter anderem bpf_object__create_maps, die neue Maps erstellt oder bestehende öffnet und diese in Dateideskriptoren umwandelt. (Hier sehen wir BPF_MAP_CREATE in der Ausgabe strace.) Danach wird die Funktion bpf_object__relocate aufgerufen, und genau die interessiert uns, da wir uns erinnern, dass wir woo in der Relocation-Tabelle gesehen haben. Wenn wir sie untersuchen, gelangen wir schließlich zur Funktion bpf_program__relocate, die sich :
case RELO_LD64:
insn[0].src_reg = BPF_PSEUDO_MAP_FD;
insn[0].imm = obj->maps[relo->map_idx].fd;
break;Also nehmen wir unseren Befehl
18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 llund ersetzen das Quellregister durch BPF_PSEUDO_MAP_FD, und das erste IMM durch den Dateideskriptor unserer Map, und wenn der zum Beispiel 0xdeadbeef, erhalten wir den Befehl
18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 llSo wird die Information über die Maps an das spezifisch geladene BPF-Programm weitergegeben. Die Map kann entweder mit BPF_MAP_CREATEerstellt oder über die ID mit BPF_MAP_GET_FD_BY_ID.
geöffnet werden. Zusammenfassend lässt sich sagen, dass der Ablauf wie folgt aussieht: libbpf während der Kompilierung werden Einträge in der Relocation-Tabelle für die Referenzen auf die Maps erstellt
- öffnet den ELF-Objekt-Handler, findet alle verwendeten Maps und erstellt für sie Dateideskriptoren
libbpfdie Dateideskriptoren werden im Kernel als Teil des Befehls- LD64
Wie Sie verstehen, ist das noch nicht alles, und wir müssen einen Blick in den Kernel werfen. Glücklicherweise haben wir einen Anhaltspunkt – wir haben einen Wert
in das Quellregister geschrieben und können ihn durchsuchen, was uns zu dem heiligsten Ort führt – BPF_PSEUDO_MAP_FD , wo eine Funktion mit einem charakteristischen Namen den Dateideskriptor durch die Adresse einer Struktur vom Typ kernel/bpf/verifier.cstruct 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;:
(den vollständigen Code finden Sie). Damit können wir unseren Algorithmus ergänzen: ). So können wir unseren Algorithmus erweitern:
- Während des Programmstarts überprüft der Verifier die korrekte Verwendung der Map und schreibt die Adresse der entsprechenden Struktur.
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;
Beim Laden des ELF-Binärformats mit libbpf ereignen sich noch viele weitere Ereignisse, aber wir werden das in den Rahmen anderer Artikel diskutieren.
Laden von Programmen und Maps ohne libbpf.
Wie versprochen, hier ist ein Beispiel für Leser, die wissen möchten, wie man ein Programm erstellt und lädt, das Maps ohne Hilfe verwendet. libbpfDas kann nützlich sein, wenn Sie in einer Umgebung arbeiten, für die Sie keine Abhängigkeiten erstellen können, oder jeden Bit sparen oder ein Programm vom Typ , das BPF-Binärcode zur Laufzeit generiert.
Um es einfacher zu machen, der Logik zu folgen, werden wir unser Beispiel xdp-simpleneu schreiben. Den vollen und etwas erweiterten Code des Programms, das in diesem Beispiel betrachtet wird, finden Sie in diesem .
Die Logik unserer Anwendung ist folgende:
- erstelle eine Map vom Typ
BPF_MAP_TYPE_ARRAYmit dem BefehlBPF_MAP_CREATE, - erstelle ein Programm, das diese Map verwendet,
- verbinde das Programm mit der Schnittstelle,
lo,
was übersetzt auf Menschensprache heißt
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);
}Hier map_create erstellt eine Map genau so, wie wir es im ersten Beispiel zum Systemaufruf gemacht haben — "Kern, mach mir bitte eine neue Map in Form eines Arrays mit 8 Elementen vom Typ bpf __u64 und gib mir einen Dateideskriptor zurück:" 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)); }
Das Programm wird ebenfalls einfach geladen: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)); }
Der schwierige Teilprog_load ist die Definition unseres BPF-Programms in Form eines Arrays von Strukturen struct bpf_insn insns[] . Da wir jedoch ein Programm verwenden, das wir in C haben, können wir ein wenig tricksen:. Aber da wir das Programm verwenden, das wir in C haben, können wir ein wenig tricksen:
$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o
0000000000000000 :
0: 85 00 00 00 08 00 00 00 call 8
1: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
2: bf a2 00 00 00 00 00 00 r2 = r10
3: 07 02 00 00 fc ff ff ff r2 += -4
4: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
6: 85 00 00 00 01 00 00 00 call 1
7: b7 01 00 00 00 00 00 00 r1 = 0
8: 15 00 04 00 00 00 00 00 if r0 == 0 goto +4
9: 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
10: 07 01 00 00 01 00 00 00 r1 += 1
11: 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
12: b7 01 00 00 02 00 00 00 r1 = 2
0000000000000068 :
13: bf 10 00 00 00 00 00 00 r0 = r1
14: 95 00 00 00 00 00 00 00 exitInsgesamt müssen wir 14 Anweisungen in Form von Strukturen vom Typ struct bpf_insn (Tipp: Nehmen Sie das Dump oben, lesen Sie den Abschnitt über die Anweisungen erneut, öffnen Sie und und versuchen Sie zu bestimmen . Da wir jedoch ein Programm verwenden, das wir in C haben, können wir ein wenig tricksen: selbst):
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,
},
{ }, /* Platzhalter */
/* 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 */
{
.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,
},
/* : 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
},
};Übung für diejenigen, die dies nicht selbst geschrieben haben – finden Sie map_fd.
In unserem Programm gibt es noch einen unentdeckten Teil – xdp_attach. Leider können Programme vom Typ XDP nicht über einen Systemaufruf angeschlossen werden. bpf. Die Menschen, die BPF und XDP entwickelt haben, stammen aus der Linux-Netzwerk-Community, was bedeutet, dass sie die für sie am vertrautesten (aber nicht für normale) Schnittstelle zur Interaktion mit dem Kernel verwendet haben: normalen Menschen) Schnittstelle zur Interaktion mit dem Kernel: , siehe auch . Der einfachste Weg zur Umsetzung xdp_attach besteht darin, den Code aus libbpf, genauer gesagt, aus der Datei , was wir getan haben, indem wir ihn etwas verkürzt haben:
Willkommen in der Welt der netlink-Sockets
Öffnen eines netlink-Sockets des Typs 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-Fehlerberichterstattung nicht unterstützt");
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;
}Lesen aus einem solchen 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 nlmsg_pid != nl_pid)
errx(1, "falsche 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); zu viel Code zum Kopieren...
goto done;
case NLMSG_DONE:
return 0;
default:
break;
}
}
}
ret = 0;
done:
return ret;
}Schließlich ist hier unsere Funktion, die den Socket öffnet und eine spezielle Nachricht sendet, die einen Dateideskriptor enthält:
static int xdp_attach(int ifindex, int prog_fd)
{
int sock, seq = 0, ret;
struct nlattr *nla, *nla_xdp;
struct {
struct nlmsghdr nh;
struct ifinfomsg ifinfo;
char attrbuf[64];
} req;
__u32 nl_pid = 0;
sock = netlink_open(&nl_pid);
if (sock nla_type = NLA_F_NESTED | IFLA_XDP;
nla->nla_len = NLA_HDRLEN;
/* fügte XDP fd hinzu */
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;
/* falls der Benutzer einige Flags übergeben hat, fügen Sie diese ebenfalls hinzu */
__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;
}Also, alles ist bereit für die Tests:
$ 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
+++ beendet mit 0 +++Schauen wir mal, ob unser Programm verbunden ist mit lo:
$ ip l show dev lo
1: lo: mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 160Lass uns ein paar Pings schicken und die Map betrachten:
$ 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
Gefunden 8 ElementeHurra, alles funktioniert. Beachten Sie übrigens, dass unsere Map wieder in Form von Bytes angezeigt wird. Das geschieht, weil wir im Gegensatz zu libbpf wir keine Typinformationen (BTF) geladen haben. Aber mehr dazu werden wir beim nächsten Mal sprechen.
Entwicklungstools
In diesem Abschnitt werden wir uns das minimale Toolset für BPF-Entwickler ansehen.
Im Allgemeinen benötigt man für die Entwicklung von BPF-Programmen nichts Besonderes – BPF funktioniert auf jedem anständigen Distribution-Kernel, und die Programme werden mit Hilfe von clang, der aus dem Paket installiert werden kann. Da sich BPF jedoch in der Entwicklung befindet, ändern sich das Kernel und die Tools ständig. Wenn Sie nicht möchten, dass Sie BPF-Programme mit veralteten Methoden aus den 2010er Jahren schreiben, müssen Sie es selbst kompilieren.
llvm/clangpahole- Ihr Kernel
bpftool
(Zur Info: Dieser Abschnitt und alle Beispiele im Artikel wurden auf Debian 10 ausgeführt.)
llvm/clang
BPF arbeitet gut mit LLVM zusammen, und obwohl BPF-Programme seit einiger Zeit auch mit gcc kompiliert werden können, erfolgt die gesamte aktuelle Entwicklung für LLVM. Daher werden wir zuerst die aktuelle Version clang aus git kompilieren:
$ 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
... viele Zeit später
$Jetzt können wir überprüfen, ob alles korrekt kompiliert wurde:
$ ./bin/llc --version
LLVM (http://llvm.org/):
LLVM-Version 11.0.0git
Optimierter Build.
Standardziel: x86_64-unknown-linux-gnu
Host-CPU: znver1
Registrierte Ziele:
bpf - BPF (Host-Endianness)
bpfeb - BPF (Big Endian)
bpfel - BPF (Little Endian)
x86 - 32-Bit X86: Pentium-Pro und höher
x86-64 - 64-Bit X86: EM64T und AMD64(Die Anleitung zum Kompilieren clang stammt von mir aus .)
Wir werden die gerade kompilierten Programme nicht installieren, sondern sie einfach in PATH, zum Beispiel:
export PATH="`pwd`/bin:$PATH"(Das kann zu .bashrc oder in eine separate Datei hinzugefügt werden. Persönlich füge ich solche Dinge zu ~/bin/activate-llvm.sh und mache dann die . activate-llvm.sh.)
Pahole und BTF
Das Tool pahole wird beim Kompilieren des Kernels verwendet, um Debugging-Informationen im BTF-Format zu erstellen. Wir werden in diesem Artikel nicht näher auf die Details der BTF-Technologie eingehen, abgesehen von der Tatsache, dass sie praktisch ist und wir sie verwenden möchten. Wenn Sie also Ihren eigenen Kernel kompilieren möchten, sollten Sie zuerst pahole (sonst pahole können Sie den Kernel nicht mit der Option kompilieren 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/paholeKerne für Experimente mit BPF
Bei der Untersuchung der Möglichkeiten von BPF möchte man seinen eigenen Kern zusammenstellen. Das ist zwar nicht zwingend erforderlich, da Sie BPF-Programme auch mit einem Standardkernel bauen und laden können, jedoch ermöglicht ein eigener Kernel die Nutzung der neuesten BPF-Funktionen, die in Ihrem Distributionskernel bestenfalls erst nach Monaten oder, im Fall einiger Debugging-Tools, möglicherweise gar nicht in naher Zukunft verpackt werden. Außerdem ermöglicht ein eigener Kernel, sich wichtig zu fühlen und mit dem Code zu experimentieren.
Um einen Kernel zu erstellen, benötigen Sie zunächst den Kernel selbst und zweitens die Konfigurationsdatei des Kernels. Für Experimente mit BPF können wir einen gewöhnlichen Kernel oder einen der Entwicklerkerne verwenden. Historisch wird die Entwicklung von BPF im Rahmen der Linux-Community vorangetrieben, weshalb alle Änderungen früher oder später durch David Miller — den Maintainer des Netzwerkbereichs von Linux — gehen. Je nach Art der Änderungen — ob Bugfixes oder neue Funktionen — landen Netzwerkänderungen in einem der beiden Kerne – oder . Änderungen für BPF werden auf die gleiche Weise zwischen und , die dann entsprechend in net und net-next integriert werden. Weitere Informationen finden Sie in der und . Wählen Sie also den Kernel basierend auf Ihren Vorlieben und den Anforderungen an die Stabilität des Systems, auf dem Sie testen (*-next Kerne sind die instabilsten der aufgeführten).
Der Rahmen dieses Artikels umfasst nicht, wie man mit Konfigurationsdateien des Kernels umgeht — es wird davon ausgegangen, dass Sie dies entweder bereits können oder selbst zu lernen. Die folgenden Anweisungen sollten jedoch mehr oder weniger ausreichend sein, damit Sie ein funktionierendes System mit BPF-Unterstützung erhalten.
Laden Sie einen der oben genannten Kerne herunter:
$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-nextKompilieren Sie eine minimale funktionierende Kernel-Konfiguration:
$ cp /boot/config-`uname -r` .config
$ make localmodconfigAktivieren Sie die BPF-Optionen in der Datei .config nach Ihrem Ermessen (höchstwahrscheinlich ist bereits die CONFIG_BPF aktiviert, da sie von systemd verwendet wird). Hier ist die Liste der Optionen aus dem Kernel, der für diesen Artikel verwendet wurde:
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 ist nicht gesetzt
# CONFIG_BPFILTER ist nicht gesetzt
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=yNun können wir die Module und den Kernel ganz einfach kompilieren und installieren (übrigens kann der Kernel mit dem gerade kompilierten clang, hinzufügen CC=clang):
$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make installund mit dem neuen Kernel neu starten (ich benutze dafür kexec aus dem Paket kexec-tools):
v=5.8.0-rc6+ # wenn Sie den aktuellen Kernel neu kompilieren, können Sie v=`uname -r` verwenden
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -ebpftool
Das am häufigsten verwendete Dienstprogramm in diesem Artikel wird das Programm sein bpftool, das im Linux-Kernel enthalten ist. Es wurde von den BPF-Entwicklern für die BPF-Entwickler geschrieben und ermöglicht die Verwaltung aller Arten von BPF-Objekten – Programme laden, Maps erstellen und ändern, die Lebensweise des BPF-Ökosystems erforschen usw. Die Dokumentation in Form von Quellcodes für die Man-Seiten finden Sie oder bereits kompiliert .
Zum Zeitpunkt des Schreibens des Artikels bpftool wird sie nur für RHEL, Fedora und Ubuntu in fertiger Form angeboten (siehe z.B. , in dem die unvollendete Geschichte der Paketierung beschrieben wird bpftool in Debian). Aber wenn Sie Ihren eigenen Kernel bereits kompiliert haben, ist es ganz einfach, bpftool es zu kompilieren:
$ cd ${linux}/tools/bpf/bpftool
# ... geben Sie die Pfade zum letzten clang ein, wie oben erläutert
$ make -s
Automatische Erkennung von Systemfunktionen:
... libbfd: [ an ]
... disassembler-four-args: [ an ]
... zlib: [ an ]
... libcap: [ an ]
... clang-bpf-co-re: [ an ]
Automatische Erkennung von Systemfunktionen:
... libelf: [ an ]
... zlib: [ an ]
... bpf: [ an ]
$(hier ${linux} — das ist Ihr Verzeichnis mit dem Kernel.) Nach der Ausführung dieser Befehle bpftool wird es im Verzeichnis ${linux}/tools/bpf/bpftool kompiliert und kann in den Pfad eingetragen werden (vor allem für den Benutzer root) oder einfach nach kopiert werden /usr/local/sbin.
Am besten sollte man mit dem letzten bpftool , wie oben beschrieben, kompilieren und überprüfen, ob es korrekt kompiliert wurde – zum Beispiel mit dem Befehl clang$ sudo bpftool feature probe kernel Scanning system configuration... bpf() syscall für nicht privilegierte Benutzer ist aktiviert JIT-Compiler ist aktiviert JIT-Compiler-Härtung ist deaktiviert JIT-Compiler kallsyms-Exporte sind für root aktiviert ...
der zeigt, welche BPF-Funktionen in Ihrem Kernel aktiviert sind.Übrigens kann der vorherige Befehl wie
ausgeführt werden
# bpftool f p kDies wurde analog zu den Tools aus dem Paket gemacht iproute2, wo wir zum Beispiel sagen können ip a s eth0 anstatt ip addr show dev eth0.
Fazit
BPF ermöglicht es, den Kernel effektiv zu messen und dessen Funktionalität während des laufenden Betriebs zu ändern. Das System ist sehr gelungen, ganz im besten Sinne von UNIX: Ein einfacher Mechanismus, der das (Neu)programmieren des Kernels erlaubt, hat es einer großen Anzahl von Menschen und Organisationen ermöglicht, zu experimentieren. Und obwohl die Experimente, ebenso wie die Entwicklung der BPF-Infrastruktur, noch längst nicht abgeschlossen sind, hat das System bereits eine stabile ABI, die es ermöglicht, zuverlässige und vor allem effiziente Geschäftslogik aufzubauen.
Ich möchte anmerken, dass meiner Meinung nach die Technologie so populär geworden ist, weil man einerseits damit spielen (die Architektur der Maschine kann man mehr oder weniger an einem Abend verstehen), und andererseits — Probleme lösen kann, die (schön) vorher nicht lösbar waren. Diese beiden Komponenten zusammen bringen die Menschen dazu, zu experimentieren und zu träumen, was zu immer neuen innovativen Lösungen führt.
Dieser Artikel, obwohl er nicht besonders kurz ist, ist nur eine Einführung in die Welt von BPF und beschreibt nicht die „fortgeschrittenen“ Möglichkeiten und wichtigen Teile der Architektur. Der weitere Plan ist ungefähr wie folgt: Der nächste Artikel wird einen Überblick über die BPF-Programmart bieten (in Kernel 5.8 werden 30 Programmart unterstützt), dann werden wir endlich darauf schauen, wie man echte Anwendungen auf BPF am Beispiel von Kernel-Tracing-Programmen schreibt, danach kommt die Zeit für einen vertiefenden Kurs zur BPF-Architektur und schließlich für Beispiele von Netzwerk- und Sicherheitsanwendungen von BPF.
Die vorhergehenden Artikel dieser Reihe
Links
— die BPF-Dokumentation von Cilium, genauer gesagt von Daniel Borkman, einem der Schöpfer und Maintainer von BPF. Dies ist eine der ersten umfassenden Beschreibungen, die sich hervorragend von anderen abhebt, da Daniel genau weiß, wovon er spricht, und es keine Fehler gibt. Insbesondere beschreibt dieses Dokument, wie man mit BPF-Programmen der Typen XDP und TC unter Verwendung des bekannten Tools
ipaus dem Paketiproute2.— die Originaldatei mit der Dokumentation zu klassischem und dann erweitertem BPF. Es ist nützlich zu lesen, wenn Sie sich mit Assemblersprache und technischen Details der Architektur auseinandersetzen möchten.
. Aktualisiert selten, aber präzise, da Alexei Starovoitov (Autor von eBPF) und Andrii Nakryiko dort schreiben — (Maintainer
libbpf).. Ein interessanter Twitter-Thread von Quentin Monnet mit Beispielen und Geheimnissen zur Verwendung von bpftool.
. Eine riesige (und immer noch unterstützte) Liste von Links zur BPF-Dokumentation von Quentin Monnet.
Quelle: habr.com
