BPF für die Kleinen, Teil eins: Erweiterter BPF

Am Anfang gab es eine Technologie und sie hieß BPF. Wir sahen sie an vorheriges, alttestamentlicher Artikel dieser Reihe. Im Jahr 2013 wurde durch die Bemühungen von Alexei Starovoitov und Daniel Borkman eine verbesserte, für moderne 64-Bit-Maschinen optimierte Version entwickelt und in den Linux-Kernel integriert. Diese neue Technologie wurde kurzzeitig „Internal BPF“ genannt, dann in „Extended BPF“ umbenannt und jetzt, nach einigen Jahren, nennen es alle einfach BPF.

Grob gesagt ermöglicht BPF die Ausführung beliebigen, vom Benutzer bereitgestellten Codes im Linux-Kernel-Bereich, und die neue Architektur erwies sich als so erfolgreich, dass wir ein Dutzend weiterer Artikel benötigen, um alle ihre Anwendungen zu beschreiben. (Das Einzige, was den Entwicklern nicht gut gelungen ist, war die Erstellung eines anständigen Logos, wie Sie im folgenden Leistungscode sehen können.)

Dieser Artikel beschreibt die Struktur der virtuellen BPF-Maschine, Kernel-Schnittstellen für die Arbeit mit BPF, Entwicklungstools sowie einen kurzen, sehr kurzen Überblick über die vorhandenen Funktionen, d. h. alles, was wir in Zukunft für eine tiefergehende Untersuchung der praktischen Anwendungen von BPF benötigen werden.
BPF für die Kleinen, Teil eins: Erweiterter BPF

Zusammenfassung des Artikels

Einführung in die BPF-Architektur. Zunächst betrachten wir die BPF-Architektur aus der Vogelperspektive und skizzieren die Hauptkomponenten.

Register und Befehlssystem der virtuellen BPF-Maschine. Nachdem wir bereits eine Vorstellung von der Architektur als Ganzes haben, beschreiben wir die Struktur der virtuellen BPF-Maschine.

Lebenszyklus von BPF-Objekten, bpffs-Dateisystem. In diesem Abschnitt werfen wir einen genaueren Blick auf den Lebenszyklus von BPF-Objekten – Programmen und Karten.

Verwalten von Objekten mit dem BPF-Systemaufruf. Nachdem wir das System bereits einigermaßen verstanden haben, werden wir uns schließlich damit befassen, wie man mithilfe eines speziellen Systemaufrufs − Objekte aus dem Benutzerbereich erstellt und manipuliert bpf(2).

Пишем программы BPF с помощью libbpf. Natürlich können Sie Programme auch über einen Systemaufruf schreiben. Aber es ist schwierig. Für ein realistischeres Szenario entwickelten Nuklearprogrammierer eine Bibliothek libbpf. Wir erstellen ein grundlegendes BPF-Anwendungsgerüst, das wir in den folgenden Beispielen verwenden.

Kernel-Helfer. Hier erfahren wir, wie BPF-Programme auf Kernel-Hilfsfunktionen zugreifen können – ein Tool, das zusammen mit Maps die Fähigkeiten des neuen BPF im Vergleich zum klassischen grundlegend erweitert.

Zugriff auf Karten aus BPF-Programmen. An diesem Punkt werden wir genug wissen, um genau zu verstehen, wie wir Programme erstellen können, die Karten verwenden. Und werfen wir noch einen kurzen Blick auf den großen und mächtigen Verifizierer.

Entwicklungswerkzeuge. Hilfeabschnitt zum Zusammenstellen der erforderlichen Dienstprogramme und des Kernels für Experimente.

Fazit. Am Ende des Artikels finden diejenigen, die bis hierher gelesen haben, motivierende Worte und eine kurze Beschreibung dessen, was in den folgenden Artikeln passieren wird. Wir werden auch eine Reihe von Links zum Selbststudium für diejenigen auflisten, die nicht die Lust oder Fähigkeit haben, auf die Fortsetzung zu warten.

Einführung in die BPF-Architektur

Bevor wir beginnen, die BPF-Architektur zu betrachten, beziehen wir uns noch einmal (oh) auf klassischer BPF, das als Reaktion auf das Aufkommen von RISC-Maschinen entwickelt wurde und das Problem der effizienten Paketfilterung löste. Die Architektur erwies sich als so erfolgreich, dass sie, nachdem sie in den schneidigen Neunzigern unter UNIX in Berkeley geboren wurde, auf die meisten vorhandenen Betriebssysteme portiert wurde, bis in die verrückten Zwanziger überlebte und immer noch neue Anwendungen findet.

Das neue BPF wurde als Reaktion auf die Allgegenwärtigkeit von 64-Bit-Maschinen, Cloud-Diensten und den gestiegenen Bedarf an Tools zum Erstellen von SDN entwickelt (Software-dverfeinert nNetzwerken). Von Kernel-Netzwerkingenieuren als verbesserter Ersatz für das klassische BPF entwickelt, fand das neue BPF buchstäblich sechs Monate später Anwendung bei der schwierigen Aufgabe, Linux-Systeme zu verfolgen, und jetzt, sechs Jahre nach seinem Erscheinen, brauchen wir nur einen ganzen nächsten Artikel Listen Sie die verschiedenen Arten von Programmen auf.

Lustige Bilder

Im Kern ist BPF eine virtuelle Sandbox-Maschine, die es Ihnen ermöglicht, „beliebigen“ Code im Kernel-Bereich auszuführen, ohne die Sicherheit zu beeinträchtigen. BPF-Programme werden im Userspace erstellt, in den Kernel geladen und mit einer Ereignisquelle verbunden. Ein Ereignis könnte beispielsweise die Zustellung eines Pakets an eine Netzwerkschnittstelle, der Start einer Kernelfunktion usw. sein. Im Falle eines Pakets hat das BPF-Programm Zugriff auf die Daten und Metadaten des Pakets (zum Lesen und möglicherweise Schreiben, abhängig vom Programmtyp); im Falle der Ausführung einer Kernelfunktion auf die Argumente von die Funktion, einschließlich Zeiger auf den Kernel-Speicher usw.

Schauen wir uns diesen Prozess genauer an. Lassen Sie uns zunächst über den ersten Unterschied zum klassischen BPF sprechen, dessen Programme in Assembler geschrieben wurden. In der neuen Version wurde die Architektur erweitert, sodass Programme in Hochsprachen geschrieben werden können, vorrangig natürlich in C. Hierzu wurde ein Backend für llvm entwickelt, das es ermöglicht, Bytecode für die BPF-Architektur zu generieren.

BPF für die Kleinen, Teil eins: Erweiterter BPF

Die BPF-Architektur wurde teilweise so konzipiert, dass sie effizient auf modernen Maschinen läuft. Damit dies in der Praxis funktioniert, wird der BPF-Bytecode nach dem Laden in den Kernel mithilfe einer Komponente namens JIT-Compiler (JUst In Time). Als nächstes wurde, wie Sie sich erinnern, im klassischen BPF das Programm in den Kernel geladen und atomar an die Ereignisquelle angehängt – im Kontext eines einzelnen Systemaufrufs. In der neuen Architektur geschieht dies in zwei Schritten: Zunächst wird der Code über einen Systemaufruf in den Kernel geladen bpf(2)und später stellt das Programm über andere Mechanismen, die je nach Programmtyp variieren, eine Verbindung zur Ereignisquelle her.

Hier könnte der Leser eine Frage haben: War das möglich? Wie wird die Ausführungssicherheit eines solchen Codes gewährleistet? Die Ausführungssicherheit wird uns durch die Phase des Ladens von BPF-Programmen garantiert, die als „Verifier“ bezeichnet wird (im Englischen heißt diese Phase „Verifier“, und ich werde weiterhin das englische Wort verwenden):

BPF für die Kleinen, Teil eins: Erweiterter BPF

Verifier ist ein statischer Analysator, der sicherstellt, dass ein Programm den normalen Betrieb des Kernels nicht stört. Dies bedeutet übrigens nicht, dass das Programm den Betrieb des Systems nicht beeinträchtigen kann. BPF-Programme können je nach Typ Abschnitte des Kernelspeichers lesen und neu schreiben, Werte von Funktionen zurückgeben, trimmen, anhängen und neu schreiben und sogar Netzwerkpakete weiterleiten. Der Verifier garantiert, dass die Ausführung eines BPF-Programms nicht zum Absturz des Kernels führt und dass ein Programm, das gemäß den Regeln Schreibzugriff hat, beispielsweise auf die Daten eines ausgehenden Pakets, den Kernelspeicher außerhalb des Pakets nicht überschreiben kann. Wir werden den Verifier im entsprechenden Abschnitt etwas detaillierter betrachten, nachdem wir uns mit allen anderen Komponenten von BPF vertraut gemacht haben.

Was haben wir also bisher gelernt? Der Benutzer schreibt ein Programm in C und lädt es über einen Systemaufruf in den Kernel bpf(2), wo es von einem Prüfer überprüft und in nativen Bytecode übersetzt wird. Dann verbindet derselbe oder ein anderer Benutzer das Programm mit der Ereignisquelle und beginnt mit der Ausführung. Die Trennung von Kofferraum und Verbindung ist aus mehreren Gründen notwendig. Erstens ist die Ausführung eines Verifizierers relativ teuer und durch das mehrmalige Herunterladen desselben Programms verschwenden wir Computerzeit. Zweitens hängt die genaue Art und Weise, wie ein Programm verbunden ist, von seinem Typ ab, und eine vor einem Jahr entwickelte „universelle“ Schnittstelle ist möglicherweise nicht für neue Programmtypen geeignet. (Obwohl die Architektur jetzt ausgereifter wird, besteht die Idee, diese Schnittstelle auf der Ebene zu vereinheitlichen libbpf.)

Dem aufmerksamen Leser wird vielleicht auffallen, dass wir mit den Bildern noch nicht fertig sind. Tatsächlich erklärt all das nicht, warum BPF das Bild im Vergleich zum klassischen BPF grundlegend verändert. Zwei Neuerungen, die den Anwendungsbereich deutlich erweitern, sind die Möglichkeit zur Nutzung von Shared Memory und Kernel-Hilfsfunktionen. In BPF wird Shared Memory mithilfe sogenannter Maps implementiert – gemeinsam genutzte Datenstrukturen mit einer bestimmten API. Sie haben diesen Namen wahrscheinlich erhalten, weil der erste Kartentyp, der auftauchte, eine Hash-Tabelle war. Dann erschienen Arrays, lokale (pro CPU) Hash-Tabellen und lokale Arrays, Suchbäume, Karten mit Zeigern auf BPF-Programme und vieles mehr. Was uns jetzt interessiert, ist, dass BPF-Programme jetzt die Möglichkeit haben, den Status zwischen Aufrufen beizubehalten und ihn mit anderen Programmen und mit dem Benutzerbereich zu teilen.

Der Zugriff auf Maps erfolgt über Benutzerprozesse über einen Systemaufruf bpf(2)und von BPF-Programmen, die im Kernel ausgeführt werden, mithilfe von Hilfsfunktionen. Darüber hinaus gibt es Hilfsprogramme nicht nur für die Arbeit mit Karten, sondern auch für den Zugriff auf andere Kernelfunktionen. BPF-Programme können beispielsweise Hilfsfunktionen verwenden, um Pakete an andere Schnittstellen weiterzuleiten, Perf-Ereignisse zu generieren, auf Kernelstrukturen zuzugreifen und so weiter.

BPF für die Kleinen, Teil eins: Erweiterter BPF

Zusammenfassend bietet BPF die Möglichkeit, beliebigen, d. h. verifizierergetesteten Benutzercode in den Kernelraum zu laden. Dieser Code kann den Status zwischen Aufrufen speichern und Daten mit dem Benutzerbereich austauschen und hat außerdem Zugriff auf Kernel-Subsysteme, die dieser Programmtyp ermöglicht.

Dies ähnelt bereits den von Kernelmodulen bereitgestellten Funktionen, gegenüber denen BPF einige Vorteile hat (natürlich können Sie nur ähnliche Anwendungen vergleichen, zum Beispiel die Systemverfolgung – Sie können mit BPF keinen beliebigen Treiber schreiben). Sie können eine niedrigere Einstiegsschwelle feststellen (einige Dienstprogramme, die BPF verwenden, erfordern vom Benutzer keine Kernel-Programmierkenntnisse oder Programmierkenntnisse im Allgemeinen), Laufzeitsicherheit (heben Sie Ihre Hand in den Kommentaren für diejenigen, die das System beim Schreiben nicht kaputt gemacht haben). oder Testen von Modulen), Atomizität – beim Neuladen von Modulen kommt es zu Ausfallzeiten, und das BPF-Subsystem stellt sicher, dass keine Ereignisse verpasst werden (fairerweise muss man sagen, dass dies nicht für alle Arten von BPF-Programmen gilt).

Das Vorhandensein solcher Funktionen macht BPF zu einem universellen Werkzeug zur Erweiterung des Kernels, was sich in der Praxis bestätigt: Immer mehr neue Programmtypen werden zu BPF hinzugefügt, immer mehr große Unternehmen nutzen BPF rund um die Uhr auf Kampfservern, immer mehr Startups bauen ihr Geschäft auf Lösungen auf, die auf BPF basieren. BPF wird überall eingesetzt: beim Schutz vor DDoS-Angriffen, beim Erstellen von SDN (z. B. bei der Implementierung von Netzwerken für Kubernetes), als Hauptsystem-Tracing-Tool und Statistiksammler, in Intrusion-Detection-Systemen und Sandbox-Systemen usw.

Lassen Sie uns den Übersichtsteil des Artikels hier beenden und uns die virtuelle Maschine und das BPF-Ökosystem genauer ansehen.

Exkurs: Versorgungsunternehmen

Um die Beispiele in den folgenden Abschnitten ausführen zu können, benötigen Sie möglicherweise mindestens eine Reihe von Dienstprogrammen llvm/clang mit BPF-Unterstützung und bpftool. In der Sektion Entwicklungswerkzeuge Sie können die Anweisungen zum Zusammenstellen der Dienstprogramme sowie Ihres Kernels lesen. Dieser Abschnitt wird weiter unten platziert, um die Harmonie unserer Präsentation nicht zu stören.

BPF-Register und Befehlssystem für virtuelle Maschinen

Die Architektur und das Befehlssystem von BPF wurden unter Berücksichtigung der Tatsache entwickelt, dass Programme in der Sprache C geschrieben und nach dem Laden in den Kernel in nativen Code übersetzt werden. Daher wurden die Anzahl der Register und der Befehlssatz im Hinblick auf den Schnittpunkt im mathematischen Sinne der Fähigkeiten moderner Maschinen ausgewählt. Darüber hinaus wurden den Programmen verschiedene Einschränkungen auferlegt, beispielsweise war es bis vor kurzem nicht möglich, Schleifen und Unterprogramme zu schreiben, und die Anzahl der Anweisungen war auf 4096 begrenzt (privilegierte Programme können jetzt bis zu einer Million Anweisungen laden).

BPF verfügt über elf vom Benutzer zugängliche 64-Bit-Register r0-r10 und einen Programmzähler. Registrieren r10 enthält einen Frame-Zeiger und ist schreibgeschützt. Programme haben zur Laufzeit Zugriff auf einen 512-Byte-Stack und eine unbegrenzte Menge an gemeinsam genutztem Speicher in Form von Karten.

BPF-Programme dürfen einen bestimmten Satz programmartiger Kernel-Helfer und neuerdings auch reguläre Funktionen ausführen. Jede aufgerufene Funktion kann bis zu fünf Argumente annehmen, die in Registern übergeben werden r1-r5, und der Rückgabewert wird an übergeben r0. Es ist gewährleistet, dass nach der Rückkehr aus der Funktion der Inhalt der Register erhalten bleibt r6-r9 wird sich nicht ändern.

Für eine effiziente Programmübersetzung registrieren r0-r11 für alle unterstützten Architekturen werden unter Berücksichtigung der ABI-Merkmale der aktuellen Architektur eindeutig realen Registern zugeordnet. Zum Beispiel, z x86_64 registriert r1-r5, die zur Übergabe von Funktionsparametern verwendet werden, werden angezeigt rdi, rsi, rdx, rcx, r8, die zur Übergabe von Parametern an Funktionen verwendet werden x86_64. Der Code auf der linken Seite lässt sich beispielsweise wie folgt in den Code auf der rechten Seite übersetzen:

1:  (b7) r1 = 1                    mov    $0x1,%rdi
2:  (b7) r2 = 2                    mov    $0x2,%rsi
3:  (b7) r3 = 3                    mov    $0x3,%rdx
4:  (b7) r4 = 4                    mov    $0x4,%rcx
5:  (b7) r5 = 5                    mov    $0x5,%r8
6:  (85) call pc+1                 callq  0x0000000000001ee8

Das Register r0 Wird auch verwendet, um das Ergebnis der Programmausführung und im Register zurückzugeben r1 Dem Programm wird ein Zeiger auf den Kontext übergeben – je nach Programmtyp kann dies beispielsweise eine Struktur sein struct xdp_md (für XDP) oder Struktur struct __sk_buff (für verschiedene Netzwerkprogramme) oder Struktur struct pt_regs (für verschiedene Arten von Nachverfolgungsprogrammen) usw.

Wir hatten also eine Reihe von Registern, Kernel-Helfern, einen Stapel, einen Kontextzeiger und einen gemeinsamen Speicher in Form von Karten. Nicht, dass das alles auf der Reise unbedingt nötig wäre, aber...

Lassen Sie uns mit der Beschreibung fortfahren und über das Befehlssystem für die Arbeit mit diesen Objekten sprechen. Alle (fast alle) BPF-Anweisungen haben eine feste 64-Bit-Größe. Wenn Sie sich eine Anweisung auf einer 64-Bit-Big-Endian-Maschine ansehen, werden Sie Folgendes sehen:

BPF für die Kleinen, Teil eins: Erweiterter BPF

Hier Code - Dies ist die Kodierung der Anweisung, Dst/Src sind die Kodierungen des Empfängers bzw. der Quelle, Off - 16-Bit-Einrückung mit Vorzeichen und Imm ist eine 32-Bit-Ganzzahl mit Vorzeichen, die in einigen Anweisungen verwendet wird (ähnlich der cBPF-Konstante K). Codierung Code hat einen von zwei Typen:

BPF für die Kleinen, Teil eins: Erweiterter BPF

Die Befehlsklassen 0, 1, 2, 3 definieren Befehle für die Arbeit mit dem Speicher. Sie werden angerufen, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, jeweils. Klassen 4, 7 (BPF_ALU, BPF_ALU64) stellen einen Satz von ALU-Anweisungen dar. Klassen 5, 6 (BPF_JMP, BPF_JMP32) enthalten Sprunganweisungen.

Der weitere Plan für das Studium des BPF-Instruktionssystems ist wie folgt: Anstatt alle Anweisungen und ihre Parameter akribisch aufzulisten, werden wir uns in diesem Abschnitt einige Beispiele ansehen und anhand dieser deutlich machen, wie die Anweisungen tatsächlich funktionieren und wie sie funktionieren Zerlegen Sie manuell jede Binärdatei für BPF. Um das Material später im Artikel zu festigen, werden wir auch in den Abschnitten zu Verifier, JIT-Compiler, Übersetzung des klassischen BPF sowie beim Studium von Karten, Aufruffunktionen usw. auf individuelle Anleitungen stoßen.

Wenn wir über einzelne Anweisungen sprechen, beziehen wir uns auf die Kerndateien bpf.h и bpf_common.h, die die numerischen Codes von BPF-Anweisungen definieren. Wenn Sie Architektur selbst studieren und/oder Binärdateien analysieren, können Sie Semantik in den folgenden Quellen finden, sortiert nach Komplexität: Inoffizielle eBPF-Spezifikation, BPF- und XDP-Referenzhandbuch, Befehlssatz, Documentation/networking/filter.txt und natürlich im Linux-Quellcode – Verifizierer, JIT, BPF-Interpreter.

Beispiel: BPF im Kopf zerlegen

Schauen wir uns ein Beispiel an, in dem wir ein Programm kompilieren readelf-example.c und schauen Sie sich die resultierende Binärdatei an. Wir werden den Originalinhalt enthüllen readelf-example.c Unten, nachdem wir seine Logik aus Binärcodes wiederhergestellt haben:

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

Erste Spalte in der Ausgabe readelf ist ein Einzug 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 00000000

Befehlscodes sind gleich b7, 15, b7 и 95. Denken Sie daran, dass die niedrigstwertigen drei Bits die Befehlsklasse darstellen. In unserem Fall ist das vierte Bit aller Befehle leer, daher sind die Befehlsklassen 7, 5, 7 bzw. 5. Klasse 7 ist BPF_ALU64, und 5 ist BPF_JMP. Für beide Klassen ist das Anweisungsformat dasselbe (siehe oben) und wir können unser Programm wie folgt umschreiben (gleichzeitig werden wir die restlichen Spalten in menschlicher Form umschreiben):

Op S  Class   Dst Src Off  Imm
b  0  ALU64   0   0   0    1
1  0  JMP     0   1   1    0
b  0  ALU64   0   0   0    2
9  0  JMP     0   0   0    0

Betrieb b Klasse ALU64 - Das BPF_MOV. Es weist dem Zielregister einen Wert zu. Wenn das Bit gesetzt ist s (Quelle), dann wird der Wert aus dem Quellregister entnommen, und wenn er, wie in unserem Fall, nicht gesetzt ist, wird der Wert aus dem Feld entnommen Imm. In der ersten und dritten Anleitung führen wir also die Operation durch r0 = Imm. Darüber hinaus ist JMP Klasse 1-Betrieb BPF_JEQ (springen, wenn gleich). In unserem Fall seit dem Bit S Null ist, vergleicht es den Wert des Quellregisters mit dem Feld Imm. Stimmen die Werte überein, erfolgt der Übergang zu PC + OffWo PC, wie üblich, enthält die Adresse der nächsten Anweisung. Schließlich ist JMP Class 9 Operation BPF_EXIT. Diese Anweisung beendet das Programm und kehrt zum Kernel zurück r0. Fügen wir unserer Tabelle eine neue Spalte hinzu:

Op    S  Class   Dst Src Off  Imm    Disassm
MOV   0  ALU64   0   0   0    1      r0 = 1
JEQ   0  JMP     0   1   1    0      if (r1 == 0) goto pc+1
MOV   0  ALU64   0   0   0    2      r0 = 2
EXIT  0  JMP     0   0   0    0      exit

Wir können dies in einer bequemeren Form umschreiben:

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

Wenn wir uns daran erinnern, was im Register steht r1 Dem Programm wird vom Kernel und im Register ein Zeiger auf den Kontext übergeben r0 Der Wert wird an den Kernel zurückgegeben. Dann können wir sehen, dass wir 1 zurückgeben, wenn der Zeiger auf den Kontext Null ist, andernfalls - 2. Überprüfen wir, ob wir Recht haben, indem wir uns die Quelle ansehen:

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

Ja, es ist ein bedeutungsloses Programm, aber es lässt sich in nur vier einfache Anweisungen übersetzen.

Ausnahmebeispiel: 16-Byte-Anweisung

Wir haben bereits erwähnt, dass einige Anweisungen mehr als 64 Bit beanspruchen. Dies gilt beispielsweise für Anleitungen lddw (Code = 0x18 = BPF_LD | BPF_DW | BPF_IMM) – ein Doppelwort aus den Feldern in das Register laden Imm. Die Sache ist die Imm hat eine Größe von 32 und ein Doppelwort besteht aus 64 Bits, sodass das Laden eines 64-Bit-Direktwerts in ein Register in einem 64-Bit-Befehl nicht funktioniert. Dazu werden zwei benachbarte Anweisungen verwendet, um den zweiten Teil des 64-Bit-Werts 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 of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

In einem Binärprogramm gibt es nur zwei Anweisungen:

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

Wir werden uns mit Anweisungen wiedersehen lddw, wenn wir über Umzüge und die Arbeit mit Karten sprechen.

Beispiel: Demontage des BPF mit Standardwerkzeugen

Wir haben also gelernt, BPF-Binärcodes zu lesen und sind bereit, bei Bedarf jede Anweisung zu analysieren. Es ist jedoch erwähnenswert, dass es in der Praxis bequemer und schneller ist, Programme mit Standardtools zu zerlegen, zum Beispiel:

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 <foo>:
 0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
 2: 95 00 00 00 00 00 00 00 exit

Lebenszyklus von BPF-Objekten, bpffs-Dateisystem

(Einige der in diesem Unterabschnitt beschriebenen Details habe ich zunächst von erfahren Post Alexei Starovoitov in BPF-Blog.)

BPF-Objekte – Programme und Karten – werden mithilfe von Befehlen aus dem Benutzerbereich erstellt BPF_PROG_LOAD и BPF_MAP_CREATE Systemaufruf bpf(2)Wie das genau geschieht, besprechen wir im nächsten Abschnitt. Dadurch werden Kernel-Datenstrukturen erstellt und für jeden von ihnen refcount (Referenzanzahl) wird auf eins gesetzt und ein Dateideskriptor, der auf das Objekt zeigt, wird an den Benutzer zurückgegeben. Nachdem der Griff geschlossen ist refcount Das Objekt wird um eins reduziert, und wenn es Null erreicht, wird das Objekt zerstört.

Wenn das Programm Karten verwendet, dann refcount diese Karten werden nach dem Laden des Programms um eins erhöht, d.h. Ihre Dateideskriptoren können weiterhin vom Benutzerprozess geschlossen werden refcount wird nicht Null:

BPF für die Kleinen, Teil eins: Erweiterter BPF

Nachdem wir ein Programm erfolgreich geladen haben, hängen wir es normalerweise an eine Art Ereignisgenerator an. Wir können es beispielsweise auf einer Netzwerkschnittstelle platzieren, um eingehende Pakete zu verarbeiten, oder es mit einigen verbinden tracepoint im Kern. Zu diesem Zeitpunkt erhöht sich auch der Referenzzähler um eins und wir können den Dateideskriptor im Ladeprogramm schließen.

Was passiert, wenn wir jetzt den Bootloader herunterfahren? Dies hängt von der Art des Ereignisgenerators (Hook) ab. Nach Abschluss des Ladevorgangs sind alle Netzwerk-Hooks vorhanden. Dies sind die sogenannten globalen Hooks. Und zum Beispiel werden Trace-Programme freigegeben, nachdem der Prozess, der sie erstellt hat, beendet wird (und werden daher lokal genannt, von „lokal für den Prozess“). Technisch gesehen haben lokale Hooks immer einen entsprechenden Dateideskriptor im Benutzerbereich und werden daher geschlossen, wenn der Prozess geschlossen wird, globale Hooks jedoch nicht. In der folgenden Abbildung versuche ich anhand roter Kreuze zu zeigen, wie sich die Beendigung des Loader-Programms auf die Lebensdauer von Objekten bei lokalen und globalen Hooks auswirkt.

BPF für die Kleinen, Teil eins: Erweiterter BPF

Warum gibt es einen Unterschied zwischen lokalen und globalen Hooks? Das Ausführen einiger Arten von Netzwerkprogrammen ist ohne Userspace sinnvoll. Stellen Sie sich zum Beispiel den DDoS-Schutz vor: Der Bootloader schreibt die Regeln und verbindet das BPF-Programm mit der Netzwerkschnittstelle, woraufhin der Bootloader loslegen und sich selbst töten kann. Stellen Sie sich andererseits ein Debugging-Trace-Programm vor, das Sie in zehn Minuten auf den Knien geschrieben haben – wenn es fertig ist, möchten Sie, dass kein Müll mehr im System verbleibt, und lokale Hooks sorgen dafür.

Stellen Sie sich andererseits vor, Sie möchten eine Verbindung zu einem Tracepoint im Kernel herstellen und über viele Jahre hinweg Statistiken sammeln. In diesem Fall möchten Sie den Benutzerteil abschließen und von Zeit zu Zeit zu den Statistiken zurückkehren. Das BPF-Dateisystem bietet diese Möglichkeit. Es handelt sich um ein reines In-Memory-Pseudodateisystem, das die Erstellung von Dateien ermöglicht, die auf BPF-Objekte verweisen und dadurch wachsen refcount Objekte. Danach kann der Loader beendet werden und die von ihm erstellten Objekte bleiben am Leben.

BPF für die Kleinen, Teil eins: Erweiterter BPF

Das Erstellen von Dateien in bpffs, die auf BPF-Objekte verweisen, wird als „Anheften“ bezeichnet (wie im folgenden Satz: „Der Prozess kann ein BPF-Programm oder eine BPF-Zuordnung anheften“). Das Erstellen von Dateiobjekten für BPF-Objekte macht nicht nur Sinn, um die Lebensdauer lokaler Objekte zu verlängern, sondern auch für die Benutzerfreundlichkeit globaler Objekte. Um auf das Beispiel mit dem globalen DDoS-Schutzprogramm zurückzukommen, möchten wir uns die Statistiken ansehen von Zeit zu Zeit.

Das BPF-Dateisystem wird normalerweise eingehängt /sys/fs/bpf, kann aber auch lokal gemountet werden, zum Beispiel so:

$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Dateisystemnamen werden mit dem Befehl erstellt BPF_OBJ_PIN BPF-Systemaufruf. Nehmen wir zur Veranschaulichung ein Programm, kompilieren es, laden es hoch und pinnen es an bpffs. Unser Programm macht nichts Nützliches, wir präsentieren nur den Code, 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";

Lassen Sie uns dieses Programm kompilieren und eine lokale Kopie des Dateisystems erstellen bpffs:

$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Jetzt laden wir unser Programm mit dem Dienstprogramm herunter bpftool und schauen Sie sich die zugehörigen Systemaufrufe an bpf(2) (einige irrelevante Zeilen 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) = 0

Hier haben wir das Programm mit geladen BPF_PROG_LOAD, hat einen Dateideskriptor vom Kernel erhalten 3 und den Befehl verwenden BPF_OBJ_PIN hat diesen Dateideskriptor als Datei angeheftet "bpf-mountpoint/test". Danach das Bootloader-Programm bpftool funktionierte nicht mehr, aber unser Programm blieb im Kernel, obwohl wir es an keine Netzwerkschnittstelle angeschlossen 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 4096B

Wir können das Dateiobjekt normal löschen unlink(2) 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 directory

Objekte aktualisieren

Was das Löschen von Objekten angeht, muss klargestellt werden, dass nach dem Trennen des Programms vom Hook (Ereignisgenerator) kein einziges neues Ereignis seinen Start auslöst, jedoch alle aktuellen Instanzen des Programms in der normalen Reihenfolge abgeschlossen werden .

Bei einigen Arten von BPF-Programmen können Sie das Programm im laufenden Betrieb ersetzen, d. h. sorgen für Sequenzatomarität replace = detach old program, attach new program. In diesem Fall beenden alle aktiven Instanzen der alten Version des Programms ihre Arbeit und neue Event-Handler werden aus dem neuen Programm erstellt. „Atomarität“ bedeutet hier, dass kein einziges Ereignis verpasst wird.

Anhängen von Programmen an Ereignisquellen

In diesem Artikel werden wir die Verbindung von Programmen mit Ereignisquellen nicht gesondert beschreiben, da es sinnvoll ist, dies im Kontext eines bestimmten Programmtyps zu untersuchen. Cm. Beispiel Unten zeigen wir, wie Programme wie XDP verbunden sind.

Bearbeiten von Objekten mit dem bpf-Systemaufruf

BPF-Programme

Alle BPF-Objekte werden mithilfe eines Systemaufrufs aus dem Benutzerbereich erstellt und verwaltet bpf, mit folgendem Prototyp:

#include <linux/bpf.h>

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

Hier ist das Team cmd ist einer der Werte des Typs enum bpf_cmd, attr — ein Zeiger auf Parameter für ein bestimmtes Programm und size — Objektgröße entsprechend dem Zeiger, d. h. normalerweise das sizeof(*attr). Im Kernel 5.8 der Systemaufruf bpf unterstützt 34 verschiedene Befehle und Bestimmung union bpf_attr belegt 200 Zeilen. Davon sollten wir uns aber nicht einschüchtern lassen, denn wir werden uns im Laufe mehrerer Artikel mit den Befehlen und Parametern vertraut machen.

Beginnen wir mit dem Team BPF_PROG_LOAD, das BPF-Programme erstellt – nimmt eine Reihe von BPF-Anweisungen und lädt sie in den Kernel. Beim Laden wird der Verifizierer gestartet und anschließend der JIT-Compiler und nach erfolgreicher Ausführung der Programmdateideskriptor an den Benutzer zurückgegeben. Was als nächstes mit ihm passiert, haben wir im vorherigen Abschnitt gesehen über den Lebenszyklus von BPF-Objekten.

Wir werden jetzt ein benutzerdefiniertes Programm schreiben, das ein einfaches BPF-Programm lädt, aber zuerst müssen wir entscheiden, welche Art von Programm wir laden möchten – wir müssen es auswählen тип Schreiben Sie im Rahmen dieses Typs ein Programm, das den Verifizierertest besteht. Um den Prozess jedoch nicht zu verkomplizieren, finden Sie hier eine vorgefertigte Lösung: Wir nehmen ein Programm wie BPF_PROG_TYPE_XDP, wodurch der Wert zurückgegeben wird XDP_PASS (Alle Pakete überspringen). Im BPF-Assembler sieht es ganz einfach aus:

r0 = 2
exit

Nachdem wir uns entschieden haben dass Wir werden hochladen, wir können Ihnen sagen, wie wir es machen 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 einem Programm beginnen mit der Definition eines Arrays insns - unser BPF-Programm im Maschinencode. In diesem Fall wird jede Anweisung des BPF-Programms in die Struktur gepackt bpf_insn. Erstes Element insns entspricht den Anweisungen r0 = 2, zweite - exit.

Rückzug. Der Kernel definiert praktischere Makros zum Schreiben von Maschinencodes und zur Verwendung der Kernel-Header-Datei tools/include/linux/filter.h wir könnten schreiben

struct bpf_insn insns[] = {
    BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
    BPF_EXIT_INSN()
};

Da das Schreiben von BPF-Programmen in nativem Code jedoch nur zum Schreiben von Tests im Kernel und von Artikeln über BPF erforderlich ist, erschwert das Fehlen dieser Makros das Leben des Entwicklers nicht wirklich.

Nachdem wir das BPF-Programm definiert haben, laden wir es in den Kernel. Unser minimalistischer Parametersatz attr Enthält den Programmtyp, den Satz und die Anzahl der Anweisungen, die erforderliche Lizenz und den Namen "woo", mit dem wir nach dem Download unser Programm auf dem System finden. Das Programm wird, wie versprochen, per Systemaufruf in das System geladen bpf.

Am Ende des Programms landen wir in einer Endlosschleife, die die Nutzlast simuliert. Ohne sie wird das Programm vom Kernel beendet, wenn der Dateideskriptor, den der Systemaufruf an uns zurückgegeben hat, geschlossen wird bpf, und wir werden es nicht im System sehen.

Nun, wir sind bereit zum Testen. Lassen Sie uns das Programm zusammenstellen und ausführen straceUm 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_V
ERSION(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 Handle 3 zurück und wir gingen mit in eine Endlosschleife pause(). Versuchen wir, unser Programm im System zu finden. Dazu gehen wir zu einem anderen 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 auf dem System ein geladenes Programm vorhanden ist woo dessen globale ID 390 ist und derzeit in Bearbeitung ist simple-prog Es gibt einen offenen Dateideskriptor, der auf das Programm verweist (und wenn simple-prog Dann werde ich die Arbeit zu Ende bringen woo wird verschwinden). Wie erwartet, das Programm woo benötigt 16 Bytes – zwei Anweisungen – an Binärcodes in der BPF-Architektur, aber in seiner nativen Form (x86_64) sind es bereits 40 Bytes. Schauen wir uns unser Programm in seiner ursprünglichen Form an:

# bpftool prog dump xlated id 390
   0: (b7) r0 = 2
   1: (95) exit

keine Überraschungen. Schauen wir uns nun den vom JIT-Compiler generierten Code an:

# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
   0:   nopl   0x0(%rax,%rax,1)
   5:   push   %rbp
   6:   mov    %rsp,%rbp
   9:   sub    $0x0,%rsp
  10:   push   %rbx
  11:   push   %r13
  13:   push   %r14
  15:   push   %r15
  17:   pushq  $0x0
  19:   mov    $0x2,%eax
  1e:   pop    %rbx
  1f:   pop    %r15
  21:   pop    %r14
  23:   pop    %r13
  25:   pop    %rbx
  26:   leaveq
  27:   retq

nicht sehr effektiv für exit(2), aber fairerweise muss man sagen, dass unser Programm zu einfach ist und für nicht triviale Programme natürlich der vom JIT-Compiler hinzugefügte Prolog und Epilog benötigt werden.

Landkarten

BPF-Programme können strukturierte Speicherbereiche nutzen, die sowohl für andere BPF-Programme als auch für Programme im Benutzerbereich zugänglich sind. Diese Objekte werden Maps genannt und in diesem Abschnitt zeigen wir, wie man sie mithilfe eines Systemaufrufs manipuliert bpf.

Nehmen wir gleich an, dass die Fähigkeiten von Karten nicht nur auf den Zugriff auf den gemeinsam genutzten Speicher beschränkt sind. Es gibt spezielle Karten, die beispielsweise Zeiger auf BPF-Programme oder Zeiger auf Netzwerkschnittstellen, Karten für die Arbeit mit Perf-Ereignissen usw. enthalten. Wir werden hier nicht darauf eingehen, um den Leser nicht zu verwirren. Abgesehen davon ignorieren wir Synchronisationsprobleme, da diese für unsere Beispiele nicht wichtig sind. Eine vollständige Liste der verfügbaren Kartentypen finden Sie in <linux/bpf.h>, und in diesem Abschnitt nehmen wir als Beispiel den historisch ersten Typ, die Hash-Tabelle BPF_MAP_TYPE_HASH.

Wenn Sie beispielsweise in C++ eine Hash-Tabelle erstellen, würden Sie sagen unordered_map<int,long> woo, was auf Russisch „Ich brauche einen Tisch“ bedeutet woo unbegrenzte Größe, deren Schlüssel vom Typ sind int, und die Werte sind der Typ long" Um eine BPF-Hash-Tabelle zu erstellen, müssen wir fast das Gleiche tun, außer dass wir die maximale Größe der Tabelle angeben müssen und statt der Schlüssel- und Wertetypen ihre Größe in Bytes angeben müssen . Um Karten zu erstellen, verwenden Sie den Befehl BPF_MAP_CREATE Systemaufruf bpf. Schauen wir uns ein mehr oder weniger minimales Programm an, das eine Karte erstellt. Nach dem vorherigen Programm, das BPF-Programme lädt, sollte Ihnen dieses einfach erscheinen:

$ cat simple-map.c
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

int main(void)
{
    union bpf_attr attr = {
        .map_type = BPF_MAP_TYPE_HASH,
        .key_size = sizeof(int),
        .value_size = sizeof(int),
        .max_entries = 4,
    };
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

Hier definieren wir eine Reihe von Parametern attr, in dem wir sagen: „Ich brauche eine Hash-Tabelle mit Schlüsseln und Größenwerten sizeof(int), in die ich maximal vier Elemente einfügen kann.“ Beim Erstellen von BPF-Karten können Sie andere Parameter angeben, zum Beispiel auf die gleiche Weise wie im Beispiel mit dem Programm, wir haben den Namen des Objekts als angegeben "woo".

Lassen Sie uns das Programm kompilieren und ausführen:

$ 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 ist der Systemaufruf bpf(2) hat uns die Deskriptorkartennummer zurückgegeben 3 und dann wartet das Programm wie erwartet auf weitere Anweisungen im Systemaufruf pause(2).

Lassen Sie uns nun unser Programm in den Hintergrund schicken oder ein anderes Terminal öffnen und unser Objekt mit dem Dienstprogramm betrachten bpftool (Wir können unsere Karte anhand ihres Namens von anderen unterscheiden):

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

Die Nummer 114 ist die globale ID unseres Objekts. Jedes Programm auf dem System kann diese ID verwenden, um mit dem Befehl eine vorhandene Karte zu öffnen BPF_MAP_GET_FD_BY_ID Systemaufruf bpf.

Jetzt können wir mit unserer Hash-Tabelle spielen. Schauen wir uns den Inhalt an:

$ sudo bpftool map dump id 114
Found 0 elements

Leer. Geben wir ihm einen Wert hash[1] = 1:

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

Schauen wir uns noch einmal die Tabelle an:

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

Hurra! Es ist uns gelungen, ein Element hinzuzufügen. Beachten Sie, dass wir dazu auf Byte-Ebene arbeiten müssen, da bptftool weiß nicht, um welchen Typ es sich bei den Werten in der Hash-Tabelle handelt. (Dieses Wissen kann mithilfe von BTF auf sie übertragen werden, aber dazu jetzt mehr.)

Wie genau liest und fügt bpftool Elemente ein? Werfen wir einen Blick unter die Haube:

$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

Zuerst haben wir die Karte mit dem Befehl über ihre globale ID geöffnet BPF_MAP_GET_FD_BY_ID и bpf(2) hat uns Deskriptor 3 zurückgegeben. Weitere Verwendung des Befehls BPF_MAP_GET_NEXT_KEY Den ersten Schlüssel in der Tabelle haben wir durch Vorbeigehen gefunden NULL als Zeiger auf den „vorherigen“ Schlüssel. Wenn wir den Schlüssel haben, können wir es tun BPF_MAP_LOOKUP_ELEMdie einen Wert auf einen Zeiger zurückgibt value. Im nächsten Schritt versuchen wir, das nächste Element zu finden, indem wir einen Zeiger auf den aktuellen Schlüssel übergeben. Unsere Tabelle enthält jedoch nur ein Element und den Befehl BPF_MAP_GET_NEXT_KEY kehrt zurück ENOENT.

Okay, ändern wir den Wert von Schlüssel 1. Nehmen wir an, unsere Geschäftslogik erfordert eine Registrierung hash[1] = 2:

$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0

Wie erwartet ist es ganz einfach: der Befehl BPF_MAP_GET_FD_BY_ID öffnet unsere Karte nach ID und dem Befehl BPF_MAP_UPDATE_ELEM überschreibt das Element.

Nachdem wir also eine Hash-Tabelle aus einem Programm erstellt haben, können wir deren Inhalt aus einem anderen Programm lesen und schreiben. Beachten Sie, dass, wenn wir dies über die Befehlszeile tun könnten, dies auch jedes andere Programm auf dem System tun könnte. Zusätzlich zu den oben beschriebenen Befehlen zum Arbeiten mit Karten aus dem Benutzerbereich: Im folgenden:

  • BPF_MAP_LOOKUP_ELEM: Wert nach Schlüssel finden
  • BPF_MAP_UPDATE_ELEM: Wert aktualisieren/erstellen
  • BPF_MAP_DELETE_ELEM: Schlüssel abziehen
  • BPF_MAP_GET_NEXT_KEY: Den nächsten (oder ersten) Schlüssel finden
  • BPF_MAP_GET_NEXT_ID: ermöglicht es Ihnen, alle vorhandenen Karten durchzugehen, so funktioniert es bpftool map
  • BPF_MAP_GET_FD_BY_ID: Öffnen Sie eine vorhandene Karte anhand ihrer globalen ID
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: Den Wert eines Objekts atomar aktualisieren und den alten zurückgeben
  • BPF_MAP_FREEZE: Machen Sie die Karte vom Userspace aus unveränderlich (dieser Vorgang kann nicht rückgängig gemacht werden)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: Massenoperationen. Zum Beispiel, BPF_MAP_LOOKUP_AND_DELETE_BATCH - Dies ist die einzige zuverlässige Möglichkeit, alle Werte aus der Karte auszulesen und zurückzusetzen

Nicht alle dieser Befehle funktionieren für alle Kartentypen, aber im Allgemeinen sieht die Arbeit mit anderen Kartentypen aus dem Benutzerbereich genauso aus wie die Arbeit mit Hash-Tabellen.

Der Ordnung halber beenden wir unsere Hash-Tabellen-Experimente. Erinnern Sie sich, 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 0

So weit, ist es 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
Found 4 elements

Versuchen wir, noch eines hinzuzufügen:

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

Wie erwartet ist uns das nicht gelungen. 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 (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

Alles bestens: wie erwartet das Team BPF_MAP_UPDATE_ELEM versucht, einen neuen, fünften Schlüssel zu erstellen, stürzt jedoch ab E2BIG.

So können wir BPF-Programme erstellen und laden sowie Karten aus dem Benutzerbereich erstellen und verwalten. Nun ist es logisch, einen Blick darauf zu werfen, wie wir Karten aus den BPF-Programmen selbst verwenden können. Wir könnten darüber in der Sprache schwer lesbarer Programme in Maschinenmakrocodes sprechen, aber tatsächlich ist es an der Zeit zu zeigen, wie BPF-Programme tatsächlich geschrieben und verwaltet werden – mit libbpf.

(Für Leser, die mit dem Fehlen eines Low-Level-Beispiels unzufrieden sind: Wir werden Programme im Detail analysieren, die Karten und Hilfsfunktionen verwenden, die mit erstellt wurden libbpf und sagen Ihnen, was auf der Unterrichtsebene passiert. Für unzufriedene Leser sehr stark, wir fügten hinzu Beispiel an der entsprechenden Stelle im Artikel.)

Schreiben von BPF-Programmen mit libbpf

Das Schreiben von BPF-Programmen mithilfe von Maschinencodes kann nur beim ersten Mal interessant sein, und dann stellt sich die Sättigung ein. In diesem Moment müssen Sie Ihre Aufmerksamkeit darauf richten llvm, das über ein Backend zum Generieren von Code für die BPF-Architektur sowie eine Bibliothek verfügt libbpf, mit dem Sie die Benutzerseite von BPF-Anwendungen schreiben und den Code von BPF-Programmen laden können, die mit generiert wurden llvm/clang.

Wie wir in diesem und den folgenden Artikeln sehen werden, libbpf erledigt ziemlich viel Arbeit ohne es (oder ähnliche Tools - iproute2, libbcc, libbpf-gousw.) ist es unmöglich zu leben. Eines der Killerfeatures des Projekts libbpf ist BPF CO-RE (Compile Once, Run Everywhere) – ein Projekt, mit dem Sie BPF-Programme schreiben können, die von einem Kernel auf einen anderen portierbar sind und auf verschiedenen APIs ausgeführt werden können (z. B. wenn sich die Kernelstruktur von einer Version ändert). zur Version). Um mit CO-RE arbeiten zu können, muss Ihr Kernel mit BTF-Unterstützung kompiliert sein (wie das geht, beschreiben wir im Abschnitt Entwicklungswerkzeuge. Sie können ganz einfach anhand der folgenden Datei überprüfen, ob Ihr Kernel mit BTF erstellt wurde oder nicht:

$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinux

Diese Datei speichert Informationen über alle im Kernel verwendeten Datentypen und wird in allen unseren Beispielen verwendet libbpf. Wir werden im nächsten Artikel ausführlich über CO-RE sprechen, aber in diesem – erstellen Sie sich einfach einen Kernel mit CONFIG_DEBUG_INFO_BTF.

Bibliothek libbpf wohnt direkt im Verzeichnis tools/lib/bpf Kernel und seine Entwicklung erfolgt über die Mailingliste [email protected]. Für die Anforderungen von Anwendungen außerhalb des Kernels wird jedoch ein separates Repository verwaltet https://github.com/libbpf/libbpf bei dem die Kernel-Bibliothek mehr oder weniger unverändert für den Lesezugriff gespiegelt wird.

In diesem Abschnitt schauen wir uns an, wie Sie ein Projekt erstellen können, das verwendet libbpfLassen Sie uns mehrere (mehr oder weniger bedeutungslose) Testprogramme schreiben und im Detail analysieren, wie das alles funktioniert. Dadurch können wir in den folgenden Abschnitten einfacher erklären, wie BPF-Programme mit Maps, Kernel-Helfern, BTF usw. interagieren.

Normalerweise verwenden Projekte libbpf Fügen Sie ein GitHub-Repository als Git-Submodul hinzu. Wir machen dasselbe:

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.

Gehe zu libbpf sehr einfach:

$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pc

Unser nächster Plan in diesem Abschnitt ist wie folgt: Wir werden ein BPF-Programm wie schreiben BPF_PROG_TYPE_XDP, das Gleiche wie im vorherigen Beispiel, aber in C kompilieren wir es mit clang, und schreiben Sie ein Hilfsprogramm, das es in den Kernel lädt. In den folgenden Abschnitten werden wir die Möglichkeiten sowohl des BPF-Programms als auch des Assistentenprogramms erweitern.

Beispiel: Erstellen einer vollwertigen Anwendung mit libbpf

Zunächst verwenden wir die Datei /sys/kernel/btf/vmlinux, das oben erwähnt wurde, und erstellen Sie sein Äquivalent in Form einer Header-Datei:

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

In dieser Datei werden alle in unserem Kernel verfügbaren Datenstrukturen gespeichert. So ist beispielsweise der IPv4-Header im Kernel 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 schreiben wir unser BPF-Programm in C:

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

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

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

Obwohl sich unser Programm als sehr einfach herausstellte, müssen wir dennoch auf viele Details achten. Erstens ist die erste Header-Datei, die wir einbinden vmlinux.h, mit dem wir gerade generiert haben bpftool btf dump - Jetzt müssen wir das Kernel-Headers-Paket nicht mehr installieren, um herauszufinden, wie die Kernelstrukturen aussehen. Die folgende Header-Datei kommt aus der Bibliothek zu uns libbpf. Jetzt brauchen wir es nur noch, um das Makro zu definieren SEC, wodurch das Zeichen an den entsprechenden Abschnitt der ELF-Objektdatei gesendet wird. Unser Programm finden Sie in der Rubrik xdp/simple, wobei wir vor dem Schrägstrich den Programmtyp BPF definieren – das ist die Konvention, die in verwendet wird libbpf, basierend auf dem Abschnittsnamen, wird es beim Start durch den richtigen Typ ersetzt bpf(2). Das BPF-Programm selbst ist C - sehr einfach und besteht aus einer Zeile return XDP_PASS. Zum Schluss noch ein eigener Abschnitt "license" enthält den Namen der Lizenz.

Wir können unser Programm mit llvm/clang, Version >= 10.0.0 oder besser noch höher, kompilieren (siehe Abschnitt Entwicklungswerkzeuge):

$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o

Zu den interessanten Features: Wir geben die Zielarchitektur an -target bpf und der Pfad zu den Headern libbpf, das wir kürzlich installiert haben. Vergessen Sie es auch nicht -O2Ohne diese Option könnten Sie in Zukunft Überraschungen erleben. Schauen wir uns unseren Code an. Haben wir es geschafft, das gewünschte Programm zu schreiben?

$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       r0 = 2
       1:       exit

Ja, es hat funktioniert! Jetzt haben wir eine Binärdatei mit dem Programm und möchten eine Anwendung erstellen, die es in den Kernel lädt. Zu diesem Zweck steht die Bibliothek zur Verfügung libbpf bietet uns zwei Optionen: Verwenden Sie eine API auf niedrigerer Ebene oder eine API auf höherer Ebene. Wir gehen den zweiten Weg, da wir lernen wollen, wie man BPF-Programme mit minimalem Aufwand für deren späteres Studium schreibt, lädt und verbindet.

Zuerst müssen wir mit demselben Dienstprogramm das „Skelett“ unseres Programms aus seiner Binärdatei generieren bpftool – das Schweizer Messer der BPF-Welt (was wörtlich genommen werden kann, da Daniel Borkman, einer der Gründer und Betreuer von BPF, Schweizer ist):

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

Im Ordner xdp-simple.skel.h enthält den Binärcode unseres Programms und Funktionen zum Verwalten – Laden, Anhängen, Löschen unseres Objekts. In unserem einfachen Fall sieht das wie ein Overkill aus, aber es funktioniert auch, wenn die Objektdatei viele BPF-Programme und Maps enthält und um dieses riesige ELF zu laden, müssen wir nur das Grundgerüst generieren und eine oder zwei Funktionen aus der benutzerdefinierten Anwendung aufrufen, die wir verwenden schreiben. Lasst uns jetzt weitermachen.

Streng genommen ist unser Loader-Programm 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 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 einer Low-Level-API erkennen: die Struktur struct bpf_program *simple и struct bpf_link *simple. Die erste Struktur beschreibt speziell unser Programm, das im Abschnitt geschrieben ist xdp/simpleund der zweite beschreibt, wie das Programm eine Verbindung zur Ereignisquelle herstellt.

Funktion xdp_simple_bpf__open_and_load, öffnet ein ELF-Objekt, analysiert es, erstellt alle Strukturen und Unterstrukturen (neben dem Programm enthält ELF auch andere Abschnitte – Daten, schreibgeschützte Daten, Debugging-Informationen, Lizenz usw.) und lädt es dann mithilfe eines Systems in den Kernel Anruf bpf, was 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) = 4

Schauen wir uns nun unser Programm an bpftool. Finden wir ihren Ausweis:

# 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 dump (wir verwenden eine verkürzte Form des Befehls bpftool prog dump xlated):

# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
   0: (b7) r0 = 2
   1: (95) exit

Etwas Neues! Das Programm druckte Teile unserer C-Quelldatei. Dies wurde von der Bibliothek durchgeführt libbpf, der den Debug-Abschnitt in der Binärdatei gefunden, ihn in ein BTF-Objekt kompiliert und mit in den Kernel geladen hat BPF_BTF_LOAD, und gab dann beim Laden des Programms mit dem Befehl den resultierenden Dateideskriptor an BPG_PROG_LOAD.

Kernel-Helfer

BPF-Programme können „externe“ Funktionen ausführen – Kernel-Helfer. Diese Hilfsfunktionen ermöglichen es BPF-Programmen, auf Kernelstrukturen zuzugreifen, Karten zu verwalten und auch mit der „realen Welt“ zu kommunizieren – Perf-Ereignisse zu erstellen, Hardware zu steuern (z. B. Pakete umzuleiten) usw.

Beispiel: bpf_get_smp_processor_id

Betrachten wir im Rahmen des Paradigmas „Lernen durch Beispiel“ eine der Hilfsfunktionen: bpf_get_smp_processor_id(), sicher im Ordner kernel/bpf/helpers.c. Es gibt die Nummer des Prozessors zurück, auf dem das BPF-Programm läuft, das es aufgerufen hat. Aber uns interessiert nicht so sehr seine Semantik als vielmehr die Tatsache, dass seine Implementierung eine Zeile hat:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Die Definitionen der BPF-Hilfsfunktionen ähneln den Linux-Systemaufrufdefinitionen. Hier wird beispielsweise eine Funktion definiert, die keine Argumente hat. (Eine Funktion, die beispielsweise drei Argumente benötigt, wird mithilfe des Makros definiert BPF_CALL_3. Die maximale Anzahl von Argumenten beträgt fünf. Dies ist jedoch nur der erste Teil der Definition. Der zweite Teil besteht darin, die Typstruktur zu definieren struct bpf_func_proto, die eine Beschreibung der Hilfsfunktion enthält, die der Verifizierer versteht:

const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
    .func     = bpf_get_smp_processor_id,
    .gpl_only = false,
    .ret_type = RET_INTEGER,
};

Registrieren von Hilfsfunktionen

Damit BPF-Programme eines bestimmten Typs diese Funktion nutzen können, müssen sie diese beispielsweise für den Typ registrieren BPF_PROG_TYPE_XDP Eine Funktion ist im Kernel definiert xdp_func_proto, der anhand der Hilfsfunktions-ID ermittelt, ob XDP diese Funktion unterstützt oder nicht. Unsere Funktion ist unterstützt die:

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 BPF-Programmtypen werden in der Datei „definiert“. include/linux/bpf_types.h mithilfe eines Makros BPF_PROG_TYPE. Wird in Anführungszeichen gesetzt, da es sich um eine logische Definition handelt und in C-Sprachbegriffen die Definition einer ganzen Reihe konkreter Strukturen an anderen Stellen vorkommt. Insbesondere in der Akte kernel/bpf/verifier.c Alle Definitionen aus der Datei bpf_types.h werden verwendet, um eine Reihe 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 <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

Das heißt, für jeden BPF-Programmtyp wird ein Zeiger auf eine Datenstruktur dieses Typs definiert struct bpf_verifier_ops, die mit dem Wert initialisiert wird _name ## _verifier_ops, d.h., xdp_verifier_ops für xdp. Struktur xdp_verifier_ops entschlossen im Ordner net/core/filter.c следующим обрахом:

const struct bpf_verifier_ops xdp_verifier_ops = {
    .get_func_proto     = xdp_func_proto,
    .is_valid_access    = xdp_is_valid_access,
    .convert_ctx_access = xdp_convert_ctx_access,
    .gen_prologue       = bpf_noop_prologue,
};

Hier sehen wir unsere bekannte Funktion xdp_func_proto, der den Verifizierer jedes Mal ausführt, wenn er auf eine Herausforderung stößt einige Funktionen innerhalb eines BPF-Programms finden Sie unter verifier.c.

Schauen wir uns an, wie ein hypothetisches BPF-Programm die Funktion nutzt 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";

Symbol bpf_get_smp_processor_id entschlossen в <bpf/bpf_helper_defs.h> Bibliothek libbpf als

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

also, bpf_get_smp_processor_id ist ein Funktionszeiger, dessen Wert 8 ist, wobei 8 der Wert ist BPF_FUNC_get_smp_processor_id типа enum bpf_fun_id, die für uns in der Datei definiert ist vmlinux.h (Datei bpf_helper_defs.h im Kernel wird von einem Skript generiert, daher sind die „magischen“ Zahlen in Ordnung). Diese Funktion akzeptiert keine Argumente und gibt einen Wert vom Typ zurück __u32. Wenn wir es in unserem Programm ausführen, clang generiert eine Anweisung BPF_CALL „die richtige Art“ 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:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       bf 01 00 00 00 00 00 00 r1 = r0
       2:       67 01 00 00 20 00 00 00 r1 <<= 32
       3:       77 01 00 00 20 00 00 00 r1 >>= 32
       4:       b7 00 00 00 02 00 00 00 r0 = 2
       5:       15 01 01 00 00 00 00 00 if r1 == 0 goto +1 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

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

In der ersten Zeile sehen wir Anweisungen call, Parameter IMM was gleich 8 ist, und SRC_REG - null. Gemäß der vom Prüfer verwendeten ABI-Vereinbarung handelt es sich hierbei um einen Aufruf der Hilfsfunktion Nummer acht. Sobald es gestartet ist, ist die Logik einfach. Rückgabewert vom Register r0 kopiert nach r1 und in den Zeilen 2,3 wird es in Typ umgewandelt u32 — Die oberen 32 Bits werden gelöscht. In den Zeilen 4,5,6,7 geben wir 2 (XDP_PASS) oder 1 (XDP_DROP), abhängig davon, ob die Hilfsfunktion aus Zeile 0 einen Null- oder Nicht-Null-Wert zurückgegeben hat.

Testen wir uns selbst: Laden Sie das Programm und schauen Sie sich 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
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

Ok, der Verifizierer hat den richtigen Kernel-Helfer gefunden.

Beispiel: Argumente übergeben und schließlich das Programm ausführen!

Für alle Run-Level-Hilfsfunktionen gibt es einen Prototyp

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

Parameter an Hilfsfunktionen werden in Registern übergeben r1-r5, und der Wert wird im Register zurückgegeben r0. Es gibt keine Funktionen, die mehr als fünf Argumente annehmen, und es ist nicht zu erwarten, dass diese in Zukunft unterstützt werden.

Werfen wir einen Blick auf den neuen Kernel-Helfer und wie BPF Parameter übergibt. Lasst uns umschreiben xdp-simple.bpf.c wie folgt (der Rest der Zeilen hat sich nicht geändert):

SEC("xdp/simple")
int simple(void *ctx)
{
    bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
    return XDP_PASS;
}

Unser Programm gibt die Nummer der CPU aus, auf der es läuft. Kompilieren wir es und schauen wir uns den Code an:

$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       r1 = 10
       1:       *(u16 *)(r10 - 8) = r1
       2:       r1 = 8441246879787806319 ll
       4:       *(u64 *)(r10 - 16) = r1
       5:       r1 = 2334956330918245746 ll
       7:       *(u64 *)(r10 - 24) = r1
       8:       call 8
       9:       r1 = r10
      10:       r1 += -24
      11:       r2 = 18
      12:       r3 = r0
      13:       call 6
      14:       r0 = 2
      15:       exit

In den Zeilen 0-7 schreiben wir den String running on CPU%un, und dann führen wir in Zeile 8 das Bekannte aus bpf_get_smp_processor_id. In den Zeilen 9-12 bereiten wir die Hilfsargumente vor bpf_printk - registriert r1, r2, r3. Warum sind es drei und nicht zwei? Weil bpf_printkDies ist ein Makro-Wrapper um den wahren Helfer bpf_trace_printk, das die Größe der Formatzeichenfolge übergeben muss.

Fügen wir nun ein paar Zeilen hinzu xdp-simple.cdamit sich unser Programm mit der Schnittstelle verbindet lo und es ging richtig los!

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    __u32 flags = XDP_FLAGS_SKB_MODE;
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    bpf_set_link_xdp_fd(1, -1, flags);
    bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);

cleanup:
    xdp_simple_bpf__destroy(obj);
}

Hier verwenden wir die Funktion bpf_set_link_xdp_fd, das BPF-Programme vom XDP-Typ mit Netzwerkschnittstellen verbindet. Wir haben die Schnittstellennummer fest codiert lo, was immer 1 ist. Wir führen die Funktion zweimal aus, um zunächst das alte Programm zu trennen, falls es angehängt war. Beachten Sie, dass wir jetzt keine Herausforderung mehr brauchen pause oder eine Endlosschleife: Unser Ladeprogramm wird beendet, aber das BPF-Programm wird nicht beendet, da es mit der Ereignisquelle verbunden ist. Nach erfolgreichem Download und erfolgreicher Verbindung wird das Programm für jedes eingehende Netzwerkpaket gestartet lo.

Laden wir das Programm herunter und schauen uns die Benutzeroberfläche an lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 669

Das von uns heruntergeladene Programm hat die ID 669 und wir sehen dieselbe ID auf der Schnittstelle lo. Wir schicken ein paar Pakete dorthin 127.0.0.1 (Anfrage + Antwort):

$ ping -c1 localhost

Schauen wir uns nun den Inhalt der virtuellen Debug-Datei an /sys/kernel/debug/tracing/trace_pipe, in welchem bpf_printk schreibt seine Nachrichten:

# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0

Es wurden zwei Pakete entdeckt lo und auf CPU0 verarbeitet - unser erstes vollwertiges bedeutungsloses BPF-Programm hat funktioniert!

Es ist erwähnenswert, dass bpf_printk Nicht umsonst schreibt es in die Debug-Datei: Dies ist nicht der erfolgreichste Helfer für den Einsatz in der Produktion, aber unser Ziel war es, etwas Einfaches zu zeigen.

Zugriff auf Karten aus BPF-Programmen

Beispiel: Verwendung einer Karte aus dem BPF-Programm

In den vorherigen Abschnitten haben wir gelernt, wie man Karten aus dem Benutzerbereich erstellt und verwendet. Schauen wir uns nun den Kernel-Teil an. Beginnen wir wie üblich mit einem Beispiel. Schreiben wir unser Programm neu xdp-simple.bpf.c следующим обрахом:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 8);
    __type(key, u32);
    __type(value, u64);
} woo SEC(".maps");

SEC("xdp/simple")
int simple(void *ctx)
{
    u32 key = bpf_get_smp_processor_id();
    u32 *val;

    val = bpf_map_lookup_elem(&woo, &key);
    if (!val)
        return XDP_ABORTED;

    *val += 1;

    return XDP_PASS;
}

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

Zu Beginn des Programms haben wir eine Kartendefinition hinzugefügt woo: Dies ist ein 8-Elemente-Array, das Werte wie speichert u64 (In C würden wir ein solches Array definieren als u64 woo[8]). In einem Programm "xdp/simple" Wir bekommen die aktuelle Prozessornummer in eine Variable key und dann die Hilfsfunktion verwenden bpf_map_lookup_element Wir erhalten einen Zeiger auf den entsprechenden Eintrag im Array, den wir um eins erhöhen. Ins Russische übersetzt: Wir berechnen Statistiken darüber, welche CPU eingehende Pakete verarbeitet hat. Versuchen wir, das Programm auszuführen:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple

Schauen wir mal nach, ob sie angeschlossen ist lo und einige Pakete verschicken:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 108

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done

Schauen wir uns nun 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 abgearbeitet. Für uns ist das nicht wichtig, Hauptsache das Programm funktioniert und wir verstehen, wie man aus BPF-Programmen auf Karten zugreifen kann – mit хелперов bpf_mp_*.

Mystischer Index

Wir können also vom BPF-Programm aus mit Aufrufen wie auf die Karte zugreifen

val = bpf_map_lookup_elem(&woo, &key);

wo die Hilfsfunktion aussieht

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

aber wir übergeben einen Zeiger &woo zu einer unbenannten Struktur struct { ... }...

Wenn wir uns den Programmassembler ansehen, sehen wir, dass der Wert &woo ist eigentlich nicht definiert (Zeile 4):

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

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
...

und ist in Umzügen enthalten:

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

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

Wenn wir uns aber das bereits geladene Programm ansehen, sehen wir einen Hinweis auf die richtige Karte (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]
...

Daraus können wir schließen, dass zum Zeitpunkt des Starts unseres Loader-Programms der Link zu &woo wurde durch etwas mit einer Bibliothek ersetzt libbpf. Zuerst 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) = 5

Wir sehen das libbpf eine Karte erstellt woo und dann unser Programm heruntergeladen simple. Schauen wir uns genauer an, wie wir das Programm laden:

  • Anruf xdp_simple_bpf__open_and_load aus Datei xdp-simple.skel.h
  • was verursacht xdp_simple_bpf__load aus Datei xdp-simple.skel.h
  • was verursacht bpf_object__load_skeleton aus Datei libbpf/src/libbpf.c
  • was verursacht bpf_object__load_xattr von libbpf/src/libbpf.c

Die letzte Funktion wird unter anderem aufgerufen bpf_object__create_maps, das vorhandene Karten erstellt oder öffnet und sie in Dateideskriptoren umwandelt. (Hier sehen wir BPF_MAP_CREATE in der Ausgabe strace.) Als nächstes wird die Funktion aufgerufen bpf_object__relocate und sie ist es, die uns interessiert, da wir uns an das erinnern, was wir gesehen haben woo in der Umzugstabelle. Beim Erkunden finden wir uns schließlich in der Funktion wieder bpf_program__relocate, welche und beschäftigt sich mit Kartenumzügen:

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

Also befolgen wir unsere Anweisungen

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

und ersetzen Sie das Quellregister darin durch BPF_PSEUDO_MAP_FD, und das erste IMM zum Dateideskriptor unserer Karte und, wenn es beispielsweise gleich ist, 0xdeadbeef, dann erhalten wir als Ergebnis die Anweisung

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

Auf diese Weise werden Karteninformationen an ein bestimmtes geladenes BPF-Programm übertragen. In diesem Fall kann die Karte mit erstellt werden BPF_MAP_CREATE, und per ID mit geöffnet BPF_MAP_GET_FD_BY_ID.

Insgesamt, bei Verwendung libbpf Der Algorithmus ist wie folgt:

  • Bei der Kompilierung werden in der Umzugstabelle Datensätze für Verknüpfungen zu Karten erstellt
  • libbpf öffnet das ELF-Objektbuch, findet alle verwendeten Karten und erstellt Dateideskriptoren für sie
  • Dateideskriptoren werden als Teil der Anweisung in den Kernel geladen LD64

Wie Sie sich vorstellen können, kommt noch mehr und wir müssen uns mit dem Kern befassen. Zum Glück haben wir eine Ahnung – wir haben die Bedeutung aufgeschrieben BPF_PSEUDO_MAP_FD in das Quellenregister und wir können es begraben, was uns zum Allerheiligsten führen wird - kernel/bpf/verifier.c, wobei eine Funktion mit einem eindeutigen Namen einen Dateideskriptor durch die Adresse einer Typstruktur ersetzt struct bpf_map:

static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
    ...

    f = fdget(insn[0].imm);
    map = __bpf_map_get(f);
    if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
        addr = (unsigned long)map;
    }
    insn[0].imm = (u32)addr;
    insn[1].imm = addr >> 32;

(Der vollständige Code ist zu finden Link). So können wir unseren Algorithmus erweitern:

  • Beim Laden des Programms prüft der Prüfer die korrekte Verwendung der Karte und schreibt die Adresse der entsprechenden Struktur struct bpf_map

Beim Herunterladen der ELF-Binärdatei mit libbpf Es ist noch viel mehr los, aber wir werden das in anderen Artikeln besprechen.

Laden von Programmen und Karten ohne libbpf

Wie versprochen ist hier ein Beispiel für Leser, die wissen möchten, wie man ohne Hilfe ein Programm erstellt und lädt, das Karten verwendet libbpf. Dies kann nützlich sein, wenn Sie in einer Umgebung arbeiten, für die Sie keine Abhängigkeiten erstellen, nicht jedes Bit speichern oder ein Programm schreiben können ply, das im laufenden Betrieb BPF-Binärcode generiert.

Um es einfacher zu machen, der Logik zu folgen, werden wir unser Beispiel für diese Zwecke umschreiben xdp-simple. Den vollständigen und leicht erweiterten Code des in diesem Beispiel besprochenen Programms finden Sie hier Grund.

Die Logik unserer Anwendung ist wie folgt:

  • Erstellen Sie eine Typzuordnung BPF_MAP_TYPE_ARRAY mit dem Befehl BPF_MAP_CREATE,
  • Erstellen Sie ein Programm, das diese Karte verwendet.
  • Verbinden Sie das Programm mit der Schnittstelle lo,

was sich in menschlich als übersetzen lässt

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 Karte auf die gleiche Weise wie im ersten Beispiel zum Systemaufruf bpf - „Kernel, bitte erstelle mir eine neue Karte in Form eines Arrays aus 8 Elementen wie.“ __u64 und gib mir den 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 ist auch einfach zu laden:

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 Teil prog_load ist die Definition unseres BPF-Programms als ein Array von Strukturen struct bpf_insn insns[]. Da wir aber ein Programm verwenden, das wir in C haben, können wir ein wenig schummeln:

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

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
       7:       b7 01 00 00 00 00 00 00 r1 = 0
       8:       15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2>
       9:       61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
      10:       07 01 00 00 01 00 00 00 r1 += 1
      11:       63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
      12:       b7 01 00 00 02 00 00 00 r1 = 2

0000000000000068 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

Insgesamt müssen wir 14 Anweisungen in Form von Strukturen wie schreiben struct bpf_insn (Beratung: Nehmen Sie den Dump von oben, lesen Sie den Abschnitt mit den Anweisungen noch einmal und öffnen Sie ihn linux/bpf.h и linux/bpf_common.h und versuche es herauszufinden struct bpf_insn insns[] auf sich allein):

struct bpf_insn insns[] = {
    /* 85 00 00 00 08 00 00 00 call 8 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 8,
    },

    /* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
    {
        .code = BPF_MEM | BPF_STX,
        .off = -4,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_10,
    },

    /* bf a2 00 00 00 00 00 00 r2 = r10 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_10,
        .dst_reg = BPF_REG_2,
    },

    /* 07 02 00 00 fc ff ff ff r2 += -4 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_2,
        .imm = -4,
    },

    /* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
    {
        .code = BPF_LD | BPF_DW | BPF_IMM,
        .src_reg = BPF_PSEUDO_MAP_FD,
        .dst_reg = BPF_REG_1,
        .imm = map_fd,
    },
    { }, /* placeholder */

    /* 85 00 00 00 01 00 00 00 call 1 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 1,
    },

    /* b7 01 00 00 00 00 00 00 r1 = 0 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 0,
    },

    /* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

    /* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
    {
        .code = BPF_MEM | BPF_LDX,
        .off = 0,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_1,
    },

    /* 07 01 00 00 01 00 00 00 r1 += 1 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 1,
    },

    /* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
    {
        .code = BPF_MEM | BPF_STX,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* b7 01 00 00 02 00 00 00 r1 = 2 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 2,
    },

    /* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* 95 00 00 00 00 00 00 00 exit */
    {
        .code = BPF_JMP | BPF_EXIT
    },
};

Eine Übung für diejenigen, die dies nicht selbst geschrieben haben – finden map_fd.

Es gibt noch einen weiteren unbekannten Teil in unserem Programm – xdp_attach. Leider können Programme wie XDP nicht über einen Systemaufruf verbunden werden bpf. Die Leute, die BPF und normal people)-Schnittstelle zur Interaktion mit dem Kernel: Netlink-Sockets, siehe auch RFC3549. Der einfachste Weg zur Umsetzung xdp_attach kopiert Code von libbpf, nämlich aus der Akte netlink.c, was wir getan haben, indem wir es ein wenig gekürzt haben:

Willkommen in der Welt der Netlink-Sockets

Öffnen Sie einen Netlink-Socket-Typ NETLINK_ROUTE:

int netlink_open(__u32 *nl_pid)
{
    struct sockaddr_nl sa;
    socklen_t addrlen;
    int one = 1, ret;
    int sock;

    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;

    sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
    if (sock < 0)
        err(1, "socket");

    if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
        warnx("netlink error reporting not supported");

    if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
        err(1, "bind");

    addrlen = sizeof(sa);
    if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
        err(1, "getsockname");

    *nl_pid = sa.nl_pid;
    return sock;
}

Wir lesen aus dieser Steckdose:

static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
    bool multipart = true;
    struct nlmsgerr *errm;
    struct nlmsghdr *nh;
    char buf[4096];
    int len, ret;

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->nlmsg_pid != nl_pid)
                errx(1, "wrong pid");
            if (nh->nlmsg_seq != seq)
                errx(1, "INVSEQ");
            if (nh->nlmsg_flags & NLM_F_MULTI)
                multipart = true;
            switch (nh->nlmsg_type) {
                case NLMSG_ERROR:
                    errm = (struct nlmsgerr *)NLMSG_DATA(nh);
                    if (!errm->error)
                        continue;
                    ret = errm->error;
                    // libbpf_nla_dump_errormsg(nh); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

Zum Schluss ist hier unsere Funktion, die einen Socket öffnet und eine spezielle Nachricht mit einem Dateideskriptor an ihn sendet:

static int xdp_attach(int ifindex, int prog_fd)
{
    int sock, seq = 0, ret;
    struct nlattr *nla, *nla_xdp;
    struct {
        struct nlmsghdr  nh;
        struct ifinfomsg ifinfo;
        char             attrbuf[64];
    } req;
    __u32 nl_pid = 0;

    sock = netlink_open(&nl_pid);
    if (sock < 0)
        return sock;

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

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* add XDP fd */
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FD;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
    nla->nla_len += nla_xdp->nla_len;

    /* if user passed in any flags, add those too */
    __u32 flags = XDP_FLAGS_SKB_MODE;
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FLAGS;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
    nla->nla_len += nla_xdp->nla_len;

    req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);

    if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
        err(1, "send");
    ret = bpf_netlink_recv(sock, nl_pid, seq);

cleanup:
    close(sock);
    return ret;
}

Also, alles ist bereit zum Testen:

$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++

Mal sehen, ob unser Programm eine Verbindung hergestellt hat lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 160

Lasst uns Pings senden und uns die Karte ansehen:

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00  value: 90 01 00 00 00 00 00 00
key: 01 00 00 00  value: 00 00 00 00 00 00 00 00
key: 02 00 00 00  value: 00 00 00 00 00 00 00 00
key: 03 00 00 00  value: 00 00 00 00 00 00 00 00
key: 04 00 00 00  value: 00 00 00 00 00 00 00 00
key: 05 00 00 00  value: 00 00 00 00 00 00 00 00
key: 06 00 00 00  value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00  value: 00 00 00 00 00 00 00 00
Found 8 elements

Hurra, alles funktioniert. Beachten Sie übrigens, dass unsere Karte wieder in Byte-Form angezeigt wird. Dies liegt daran, dass im Gegensatz dazu libbpf Wir haben keine Typinformationen (BTF) geladen. Aber wir werden das nächste Mal mehr darüber reden.

Entwicklungswerkzeuge

In diesem Abschnitt sehen wir uns das minimale BPF-Entwickler-Toolkit an.

Im Allgemeinen benötigen Sie nichts Besonderes, um BPF-Programme zu entwickeln – BPF läuft auf jedem anständigen Distributionskernel und Programme werden damit erstellt clang, das aus der Packung geliefert werden kann. Da sich BPF jedoch in der Entwicklung befindet, ändern sich der Kernel und die Tools ständig. Wenn Sie BPF-Programme nicht mit altmodischen Methoden aus dem Jahr 2019 schreiben möchten, müssen Sie kompilieren

  • llvm/clang
  • pahole
  • sein Kern
  • bpftool

(Als Referenz: Dieser Abschnitt und alle Beispiele im Artikel wurden unter Debian 10 ausgeführt.)

llvm/clang

BPF ist mit LLVM kompatibel und obwohl Programme für BPF seit kurzem mit gcc kompiliert werden können, werden alle aktuellen Entwicklungen für LLVM durchgeführt. Daher werden wir zunächst die aktuelle Version erstellen clang von Git:

$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86" 
                      -DLLVM_ENABLE_PROJECTS="clang" 
                      -DBUILD_SHARED_LIBS=OFF 
                      -DCMAKE_BUILD_TYPE=Release 
                      -DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$

Jetzt können wir prüfen, ob alles richtig geklappt hat:

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

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

(Montageanleitungen clang von mir übernommen aus bpf_devel_QA.)

Wir werden die Programme, die wir gerade erstellt haben, nicht installieren, sondern nur hinzufügen PATHzum Beispiel:

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

(Dies kann hinzugefügt werden .bashrc oder in eine separate Datei. Persönlich füge ich solche Dinge hinzu ~/bin/activate-llvm.sh und wenn es nötig ist, mache ich es . activate-llvm.sh.)

Pahole und BTF

Dienstprogramm pahole Wird beim Erstellen 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, außer auf die Tatsache, dass sie praktisch ist und wir sie nutzen möchten. Wenn Sie also Ihren Kernel erstellen möchten, erstellen Sie zuerst pahole (ohne pahole Sie können den Kernel mit dieser Option nicht erstellen CONFIG_DEBUG_INFO_BTF:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole

Kernel zum Experimentieren mit BPF

Wenn ich die Möglichkeiten von BPF erkunde, möchte ich meinen eigenen Kern zusammenstellen. Dies ist im Allgemeinen nicht notwendig, da Sie BPF-Programme auf dem Distributionskernel kompilieren und laden können. Wenn Sie jedoch über einen eigenen Kernel verfügen, können Sie die neuesten BPF-Funktionen nutzen, die bestenfalls in Monaten in Ihrer Distribution verfügbar sein werden oder, wie im Fall einiger Debugging-Tools, in absehbarer Zeit überhaupt nicht gepackt werden. Außerdem macht es der eigene Kern wichtig, mit dem Code zu experimentieren.

Um einen Kernel zu erstellen, benötigen Sie erstens den Kernel selbst und zweitens eine Kernel-Konfigurationsdatei. Um mit BPF zu experimentieren, können wir das Übliche verwenden Vanille Kernel oder einen der Entwicklungskernel. Historisch gesehen findet die BPF-Entwicklung innerhalb der Linux-Netzwerk-Community statt und daher werden alle Änderungen früher oder später von David Miller, dem Linux-Netzwerk-Betreuer, übernommen. Abhängig von ihrer Art – Änderungen oder neue Funktionen – fallen Netzwerkänderungen in einen von zwei Kernen – net oder net-next. Änderungen für BPF werden auf die gleiche Weise verteilt bpf и bpf-next, die dann in net bzw. net-next zusammengefasst werden. Weitere Einzelheiten finden Sie unter bpf_devel_QA и netdev-FAQ. Wählen Sie also einen Kernel basierend auf Ihrem Geschmack und den Stabilitätsanforderungen des Systems, auf dem Sie testen (*-next Kernel sind die instabilsten der aufgelisteten Kernel).

Es würde den Rahmen dieses Artikels sprengen, darüber zu sprechen, wie man Kernel-Konfigurationsdateien verwaltet. Es wird davon ausgegangen, dass Sie entweder bereits wissen, wie das geht, oder bereit zu lernen auf sich allein. Die folgenden Anweisungen sollten jedoch mehr oder weniger ausreichen, um ein funktionierendes BPF-fähiges System zu erhalten.

Laden Sie einen der oben genannten Kernel herunter:

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

Erstellen Sie eine minimal funktionierende Kernelkonfiguration:

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

Aktivieren Sie BPF-Optionen in der Datei .config Ihrer Wahl (höchstwahrscheinlich). CONFIG_BPF wird bereits aktiviert sein, da systemd es verwendet). Hier ist eine Liste der Optionen des für diesen Artikel verwendeten Kernels:

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y

Dann können wir die Module und den Kernel einfach zusammenbauen und installieren (übrigens können Sie den Kernel mit dem neu zusammengestellten zusammenbauen). clangbeim Hinzufügen CC=clang):

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

und starten Sie mit dem neuen Kernel neu (ich verwende dafür kexec aus dem Paket kexec-tools):

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

bpftool

Das in diesem Artikel am häufigsten verwendete Dienstprogramm ist das Dienstprogramm bpftool, wird als Teil des Linux-Kernels bereitgestellt. Es wird von BPF-Entwicklern für BPF-Entwickler geschrieben und gepflegt und kann zum Verwalten aller Arten von BPF-Objekten verwendet werden – zum Laden von Programmen, zum Erstellen und Bearbeiten von Karten, zum Erkunden des Lebens des BPF-Ökosystems usw. Dokumentation in Form von Quellcodes für Manpages ist verfügbar im Kern oder, bereits zusammengestellt, Netzwerk.

Zum Zeitpunkt des Schreibens bpftool ist nur für RHEL, Fedora und Ubuntu vorgefertigt (siehe z. B. dieser Thread, das die unvollendete Geschichte der Verpackung erzählt bpftool in Debian). Aber wenn Sie Ihren Kernel bereits erstellt haben, dann erstellen Sie ihn bpftool So einfach wie Kuchen:

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

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

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

$

(Hier ${linux} - Dies ist Ihr Kernel-Verzeichnis.) Nach der Ausführung dieser Befehle bpftool werden in einem Verzeichnis gesammelt ${linux}/tools/bpf/bpftool und es kann dem Pfad hinzugefügt werden (zunächst dem Benutzer). root) oder kopieren Sie einfach nach /usr/local/sbin.

sammeln bpftool Verwenden Sie am besten Letzteres clang, wie oben beschrieben, zusammenbauen und prüfen, ob es richtig zusammengebaut ist – zum Beispiel mit dem Befehl

$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...

Dadurch wird angezeigt, welche BPF-Funktionen in Ihrem Kernel aktiviert sind.

Der vorherige Befehl kann übrigens ausgeführt werden als

# bpftool f p k

Dies erfolgt analog zu den Dienstprogrammen aus dem Paket iproute2, wo wir zum Beispiel sagen können ip a s eth0 statt ip addr show dev eth0.

Abschluss

Mit BPF können Sie einen Floh beschlagen, um die Funktionalität des Kerns effektiv zu messen und im Handumdrehen zu ändern. Das System erwies sich als sehr erfolgreich, ganz in der besten Tradition von UNIX: Ein einfacher Mechanismus, der es ermöglichte, den Kernel (neu) zu programmieren, ermöglichte einer großen Anzahl von Menschen und Organisationen das Experimentieren. Und obwohl die Experimente sowie die Entwicklung der BPF-Infrastruktur selbst noch lange nicht abgeschlossen sind, verfügt das System bereits über eine stabile ABI, die es Ihnen ermöglicht, zuverlässige und vor allem effektive Geschäftslogik aufzubauen.

Ich möchte anmerken, dass die Technologie meiner Meinung nach einerseits deshalb so beliebt geworden ist, weil sie es kann играть (Die Architektur einer Maschine lässt sich mehr oder weniger an einem Abend verstehen) und andererseits Probleme zu lösen, die vor ihrem Erscheinen nicht (schön) gelöst werden konnten. Diese beiden Komponenten zusammen zwingen die Menschen zum Experimentieren und Träumen, was zur Entstehung immer innovativerer Lösungen führt.

Dieser Artikel ist zwar nicht besonders kurz, stellt aber lediglich eine Einführung in die Welt von BPF dar und beschreibt keine „erweiterten“ Funktionen und wichtige Teile der Architektur. Der Plan für die Zukunft sieht etwa so aus: Der nächste Artikel wird eine Übersicht über BPF-Programmtypen geben (es werden 5.8 Programmtypen im 30-Kernel unterstützt), dann schauen wir uns schließlich an, wie man echte BPF-Anwendungen mithilfe von Kernel-Tracing-Programmen schreibt als Beispiel, dann ist es Zeit für einen ausführlicheren Kurs über die BPF-Architektur, gefolgt von Beispielen für BPF-Netzwerk- und Sicherheitsanwendungen.

Frühere Artikel dieser Reihe

  1. BPF für die Kleinen, Teil Null: klassisches BPF

Links

  1. BPF- und XDP-Referenzhandbuch – Dokumentation zu BPF von Cilium, genauer gesagt von Daniel Borkman, einem der Schöpfer und Betreuer von BPF. Dies ist eine der ersten seriösen Beschreibungen, die sich von den anderen dadurch unterscheidet, dass Daniel genau weiß, worüber er schreibt und es keine Fehler gibt. In diesem Dokument wird insbesondere beschrieben, wie mit dem bekannten Dienstprogramm mit BPF-Programmen der Typen XDP und TC gearbeitet wird ip aus dem Paket iproute2.

  2. Documentation/networking/filter.txt – Originaldatei mit Dokumentation für klassisches und dann erweitertes BPF. Eine gute Lektüre, wenn Sie sich mit Assemblersprache und technischen Architekturdetails befassen möchten.

  3. Blog über BPF auf Facebook. Es wird selten, aber treffend aktualisiert, wie Alexei Starovoitov (Autor von eBPF) und Andrii Nakryiko – (Betreuer) dort schreiben libbpf).

  4. Geheimnisse von bpftool. Ein unterhaltsamer Twitter-Thread von Quentin Monnet mit Beispielen und Geheimnissen der Verwendung von bpftool.

  5. Tauchen Sie ein in BPF: eine Liste mit Lesematerial. Eine riesige (und immer noch gepflegte) Liste mit Links zur BPF-Dokumentation von Quentin Monnet.

Source: habr.com

Kommentar hinzufügen