BPF dla najmniejszych, część pierwsza: extended BPF

Na początku była technologia, nazywana BPF. Spojrzeliśmy na nią w poprzednim, starożytnej części tego cyklu. W 2013 roku dzięki wysiłkom Aleksieja Starowoitowa (Alexei Starovoitov) i Daniela Borkmana (Daniel Borkman) opracowano jej ulepszoną wersję, dostosowaną do nowoczesnych maszyn 64-bitowych, i włączono ją do jądra Linux. Ta nowa technologia przez krótki czas nosiła nazwę Internal BPF, następnie została przemianowana na Extended BPF, a teraz, po kilku latach, wszyscy nazywają ją po prostu BPF.

Mówiąc w prost, BPF pozwala na uruchamianie dowolnego kodu dostarczonego przez użytkownika w przestrzeni jądra Linux, a nowa architektura okazała się na tyle udana, że potrzeba nam jeszcze co najmniej dziesięciu artykułów, aby opisać wszystkie jej zastosowania. (Jedynym, z czym nie poradzili sobie twórcy, jak widać na poniższym kpędzie, jest stworzenie przyzwoitego logo.)

W tym artykule opisujemy budowę maszyny wirtualnej BPF, interfejsy jądra do pracy z BPF, narzędzia deweloperskie oraz krótkie, bardzo krótkie, omówienie istniejących możliwości, tj. wszystko to, co będzie nam potrzebne do dalszego zgłębiania praktycznych zastosowań BPF.
BPF dla najmniejszych, część pierwsza: extended BPF

Streszczenie artykułu

Wprowadzenie do architektury BPF. Na początku spojrzymy na architekturę BPF z lotu ptaka i określimy główne komponenty.

Rejestry i system komend maszyny wirtualnej BPF. Już mając ogólne pojęcie o architekturze, opiszemy budowę maszyny wirtualnej BPF.

Cykl życia obiektów BPF, system plików bpffs. W tej sekcji przyjrzymy się bliżej cyklowi życia obiektów BPF — programów i map.

Zarządzanie obiektami za pomocą wywołania systemowego bpf. Mając już pewne pojęcie o systemie, w końcu przyjrzymy się, jak tworzyć i zarządzać obiektami z przestrzeni użytkownika za pomocą specjalnego wywołania systemowego — bpf(2).

Tworzenie programów BPF za pomocą libbpf. Pisanie programów przy pomocy wywołania systemowego jest oczywiście możliwe. Ale trudne. Aby stworzyć bardziej realistyczny scenariusz, programiści jądra opracowali bibliotekę libbpf. Stworzymy najprostszy szkielet aplikacji BPF, który będziemy wykorzystywać w kolejnych przykładach.

Kernel Helpers. Tutaj dowiemy się, jak programy BPF mogą korzystać z funkcji pomocniczych jądra — narzędzia, które, obok map, zasadniczo rozszerza możliwości nowego BPF w porównaniu z klasycznym.

Dostęp do map z programów BPF. Na tym etapie będziemy wiedzieć wystarczająco dużo, aby zrozumieć, jak tworzyć programy wykorzystujące mapy. Zajrzymy nawet jednym okiem do wielkiego i potężnego weryfikatora.

Narzędzia deweloperskie. Dział referencyjny dotyczący tego, jak zbudować wymagane narzędzia i jądro do eksperymentów.

Podsumowanie. Na końcu artykułu ci, którzy dotrwają do końca, znajdą słowa motywujące i krótkie opisy tego, co będzie w następnych artykułach. Wyszczególnimy także kilka linków do samodzielnego studiowania dla tych, którzy nie mają ochoty lub możliwości czekać na kontynuację.

Wprowadzenie do architektury BPF

Zanim zaczniemy omawiać architekturę BPF, po raz ostatni (czy na pewno?) odwołamy się do klasycznego BPF, który został opracowany jako odpowiedź na pojawienie się maszyn RISC i rozwiązywał problem efektywnego filtrowania pakietów. Architektura okazała się na tyle udana, że narodziwszy się w burzliwych latach dziewięćdziesiątych w Berkeley UNIX, została przeniesiona na większość istniejących systemów operacyjnych, przetrwała szalone dwudzieste lata i wciąż znajduje nowe zastosowania.

Nowy BPF został opracowany jako odpowiedź na powszechne rozprzestrzenienie się maszyn 64-bitowych, usług w chmurze oraz rosnące potrzeby na narzędzia do tworzenia SDN (Software-defined networking). Opracowany przez inżynierów sieciowych w jądrze jako udoskonalona wersja klasycznego BPF, nowy BPF dosłownie po sześciu miesiącach znalazł zastosowanie w trudnym zadaniu śledzenia systemów Linux, a teraz, sześć lat po jego pojawieniu się, będziemy potrzebować całego następnego artykułu tylko po to, aby wymienić różne typy programów.

VeSióŁyE KaRtInKi

BPF to w istocie maszyna wirtualna pozwalająca na uruchamianie "dowolnego" kodu w przestrzeni jądra bez uszczerbku dla bezpieczeństwa. Programy BPF tworzane są w przestrzeni użytkownika, ładowane do jądra i podłączane do jakiegoś źródła zdarzeń. Zdarzeniem może być na przykład dostarczenie pakietu na interfejsie sieciowym, wywołanie funkcji jądra itp. W przypadku pakietu programowi BPF będą dostępne dane i metadane pakietu (do odczytu i, być może, do zapisu, w zależności od rodzaju programu), a w przypadku wywołania funkcji jądra – argumenty funkcji, w tym wskaźniki do pamięci jądra itp.

Przyjrzyjmy się temu procesowi bliżej. Na początek opowiemy o pierwszej różnicy w stosunku do klasycznego BPF, dla którego programy pisano w assemblerze. W nowej wersji architektura została wzbogacona o możliwości pisania programów w językach wysokiego poziomu, przede wszystkim oczywiście w C. W tym celu opracowano backend dla llvm, umożliwiający generowanie kodu bajtowego dla architektury BPF.

BPF dla najmniejszych, część pierwsza: extended BPF

Architektura BPF była projektowana, aby efektywnie działać na nowoczesnych maszynach. Aby to działało w praktyce, kod bajtowy BPF po załadowaniu do jądra jest tłumaczony na kod natywny przy pomocy komponentu nazwanego kompilatorem JIT (Just In Time). Jak pamiętacie, w klasycznym BPF program był ładowany do jądra i atomowo podłączany do źródła zdarzeń — w kontekście jednego wywołania systemowego. W nowej architekturze dzieje się to w dwóch etapach — najpierw kod ładowany jest do jądra przy pomocy wywołania systemowego bpf(2), a następnie, w późniejszym czasie, przy pomocy innych mechanizmów, różnych w zależności od rodzaju programu, program jest podłączany (attaches) do źródła zdarzeń.

Czytelnicy mogą zadać sobie pytanie: czy to naprawdę było możliwe? W jaki sposób gwarantowana jest bezpieczeństwo wykonywania takiego kodu? Bezpieczeństwo wykonywania zapewniane jest przez etap ładowania programów BPF zwany weryfikatorem (po angielsku nazywa się ten etap verifier i będę dalej używać angielskiego słowa):

BPF dla najmniejszych, część pierwsza: extended BPF

Verifier to statyczny analizator, który gwarantuje, że program nie zakłóci normalnego działania jądra. Nie oznacza to jednak, że program nie może ingerować w działanie systemu — programy BPF, w zależności od typu, mogą czytać i modyfikować obszary pamięci jądra, zwracać wartości funkcji, modyfikować, uzupełniać, przepisywać, a nawet przesyłać pakiety sieciowe. Verifier gwarantuje, że uruchomienie programu BPF nie spowoduje awarii jądra oraz że program, któremu zgodnie z zasadami przysługuje dostęp do zapisu, na przykład dane wychodzącego pakietu, nie będzie mogło przepisać pamięci jądra poza pakietem. W nieco większym szczególe przyjrzymy się verifierowi w odpowiedniej sekcji, po zapoznaniu się z pozostałymi komponentami BPF.

A więc, co do tej pory dowiedzieliśmy się? Użytkownik pisze program w języku C, ładuje go do jądra za pomocą wywołania systemowego bpf(2), gdzie przechodzi on weryfikację w verifierze i jest tłumaczony na natywny kod bajtowy. Następnie ten sam lub inny użytkownik podłącza program do źródła zdarzeń i ten zaczyna się uruchamiać. Oddzielenie ładowania i podłączania jest konieczne z kilku powodów. Po pierwsze, uruchomienie verifera jest stosunkowo kosztowne, a ładowanie tego samego programu wiele razy marnuje czas komputerowy. Po drugie, sposób, w jaki program jest podłączany, zależy od jego typu, a jeden „uniwersalny” interfejs, zaprojektowany rok temu, może nie pasować do nowych typów programów. (Chociaż teraz, gdy architektura staje się coraz bardziej dojrzała, pojawił się pomysł unifikacji tego interfejsu na poziomie libbpf.)

Uważny czytelnik może zauważyć, że wciąż nie skończyliśmy z obrazkami. I rzeczywiście, wszystko, co powiedziano powyżej, nie wyjaśnia, w jaki sposób BPF zasadniczo zmienia sytuację w porównaniu z klasycznym BPF. Dwie innowacje, które znacznie poszerzają zakres zastosowań, to możliwość wykorzystania pamięci współdzielonej oraz funkcje pomocnicze jądra (kernel helpers). W BPF pamięć współdzielona jest realizowana za pomocą tzw. maps — współdzielonych struktur danych z określonym API. Taką nazwę otrzymały najprawdopodobniej dlatego, że pierwszym typem mapy, który się pojawił, była tabela haszowa. Następnie pojawiły się tablice, lokalne (per-CPU) tabele haszowe i lokalne tablice, drzewa wyszukiwania, mapy zawierające wskaźniki do programów BPF i wiele innych. Obecnie interesuje nas fakt, że programy BPF zyskały możliwość przechowywania stanu pomiędzy wywołaniami i dzielenia się nim z innymi programami oraz z przestrzenią użytkownika.

Dostęp do maps odbywa się z procesów użytkowników za pomocą wywołania systemowego bpf(2), a z programów BPF działających w jądrze — za pomocą funkcji pomocniczych. Co więcej, helpery istnieją nie tylko do pracy z mapami, ale także do uzyskiwania dostępu do innych możliwości jądra. Na przykład programy BPF mogą korzystać z funkcji pomocniczych do przekierowywania pakietów na inne interfejsy, generowania zdarzeń w subsystemie perf, uzyskiwania dostępu do struktur jądra itd.

BPF dla najmniejszych, część pierwsza: extended BPF

Podsumowując, BPF umożliwia ładowanie dowolnego, tj. przeszedłszy weryfikację w verifierze, kodu użytkownika do przestrzeni jądra. Kod ten może przechowywać stan pomiędzy wywołaniami i wymieniać dane z przestrzenią użytkownika, a także ma dostęp do dozwolonych dla tego typu programów subsystemów jądra.

To już zaczyna przypominać możliwości oferowane przez moduły jądra, w porównaniu do których BPF ma pewne zalety (oczywiście, można porównywać tylko podobne aplikacje, jak na przykład śledzenie systemu — nie można napisać dowolnego sterownika na BPF). Można zauważyć niższy próg wejścia (niektóre narzędzia wykorzystujące BPF nie wymagają od użytkownika umiejętności programowania jądra, a nawet ogólnych umiejętności programistycznych), bezpieczeństwo czasu wykonania (podnieście rękę w komentarzach, kto nie uszkodził systemu podczas pisania lub testowania modułów), atomowość — przy przeładowywaniu modułów występuje czas przestoju, a podsystem BPF gwarantuje, że żadne zdarzenie nie zostanie pominięte (dla sprawiedliwości, nie jest to prawdą dla wszystkich typów programów BPF).

Obecność takich możliwości sprawia, że BPF jest uniwersalnym narzędziem do rozszerzania jądra, co zostało potwierdzone w praktyce: wciąż dodawane są nowe typy programów do BPF, coraz więcej dużych firm korzysta z BPF na serwerach produkcyjnych 24×7, coraz więcej startupów buduje swoje biznesy na rozwiązaniach opartych na BPF. BPF jest stosowane wszędzie: w ochronie przed atakami DDoS, w tworzeniu SDN (na przykład w realizacji sieci dla kubernetes), jako podstawowe narzędzie do śledzenia systemów i zbierania statystyk, w systemach wykrywania włamań i w systemach piaskownicowych itp.

Zakończmy w tym miejscu część przeglądową artykułu i przyjrzyjmy się maszynie wirtualnej oraz ekosystemowi BPF bardziej szczegółowo.

Dygresja: narzędzia

Aby móc uruchamiać przykłady z kolejnych rozdziałów, może być konieczne zainstalowanie pewnej liczby narzędzi, przynajmniej llvm/clang z obsługą bpf i bpftool. W sekcji Narzędzia deweloperskie można przeczytać instrukcje dotyczące kompilacji narzędzi oraz swojego jądra. Ten rozdział został umieszczony poniżej, aby nie zakłócać spójności naszego wywodu.

Rejestry i system instrukcji maszyny wirtualnej BPF

Architektura i system komend BPF były projektowane z myślą o tym, że programy będą pisane w języku C i po załadowaniu do jądra będą tłumaczone na kod natywny. Dlatego liczba rejestrów i mnogość komend były wybierane z uwzględnieniem przecięcia, w sensie matematycznym, możliwości nowoczesnych maszyn. Co więcej, na programy nakładano różne ograniczenia, na przykład, do niedawna nie było możliwości pisania pętli i podprogramów, a liczba instrukcji była ograniczona do 4096 (obecnie programy uprzywilejowane mogą ładować do miliona instrukcji).

W BPF dostępnych jest jedenaście 64-bitowych rejestrów użytkownika. r0r10 i licznik instrukcji (program counter). Rejestr r10 zawiera wskaźnik na stos (frame pointer) i jest dostępny tylko do odczytu. Programy w czasie wykonania mają dostęp do stosu o rozmiarze 512 bajtów i nieograniczonej ilości pamięci współdzielonej w postaci maps.

Programom BPF zezwala się na uruchamianie zestawu funkcji pomocniczych (kernel helpers), w zależności od typu programu, oraz, od niedawna, również zwykłych funkcji. Każda wywoływana funkcja może przyjmować do pięciu argumentów, przekazywanych w rejestrach r1r5, a wartość zwracana jest przekazywana do r0. Gwarantuje się, że po powrocie z funkcji zawartość rejestrów r6r9 nie zmieni się.

Aby zapewnić efektywne tłumaczenie programów, rejestry r0r11 dla wszystkich wspieranych architektur są jednoznacznie mapowane na rzeczywiste rejestry, uwzględniając szczegóły ABI bieżącej architektury. Na przykład, dla x86_64 rejestry r1r5, używane do przekazywania argumentów funkcji, są mapowane na rdi, rsi, rdx, rcx, r8, które są używane do przekazywania argumentów do funkcji w x86_64. Na przykład, kod po lewej stronie jest tłumaczony na kod po prawej stronie w ten sposób:

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

Rejestr r0 jest również używany do zwracania wyniku wykonania programu, a w rejestrze r1 programowi przekazywany jest wskaźnik na kontekst – w zależności od typu programu może to być na przykład struktura struct xdp_md (dla XDP) lub struktura struct __sk_buff (dla różnych programów sieciowych) lub struktura struct pt_regs (dla różnych typów programów tracingowych) itd.

Mieliśmy zatem zestaw rejestrów, pomocników jądra, stos, wskaźnik na kontekst oraz współdzieloną pamięć w postaci map. Nie było to absolutnie konieczne w podróży, ale…

Przejdźmy do opisu i omówmy system instrukcji do pracy z tymi obiektami. Wszystkie (prawie wszystkie) instrukcje BPF mają stały rozmiar 64 bitów. Jeśli spojrzysz na jedną instrukcję na maszynie 64-bitowej Big Endian, zobaczysz,

BPF dla najmniejszych, część pierwsza: extended BPF

Tutaj Kod — to jest kodowanie instrukcji, Dst/Src — to są kodowania dla miejsca docelowego i źródła, odpowiednio, Wyłącz — 16-bitowy znakowy odstęp, a Imm — to 32-bitowa liczba całkowita ze znakiem używana w niektórych instrukcjach (coś jak stała K z cBPF). Kodowanie Kod może mieć jeden z dwóch typów:

BPF dla najmniejszych, część pierwsza: extended BPF

Klasy instrukcji 0, 1, 2, 3 definiują polecenia do pracy z pamięcią. Nazywają się BPF_LD, BPF_LDX, BPF_ST, BPF_STX, , odpowiednio. Klasy 4, 7 (BPF_ALUBPF_ALU64, ) stanowią zestaw instrukcji ALU. Klasy 5, 6 (BPF_JMPBPF_JMP32, ) zawierają instrukcje skoku.Następny plan poznawania systemu instrukcji BPF jest taki: zamiast drobiazgowego wymieniania wszystkich instrukcji i ich parametrów, omówimy kilka przykładów w tym rozdziale, a z nich stanie się jasne, jak naprawdę działają instrukcje i jak ręcznie zdezassemblować każdy plik binarny dla BPF. Aby utrwalić materiał, w dalszej części artykułu spotkamy się jeszcze z indywidualnymi instrukcjami w rozdziałach o Weryfikatorze, kompilatorze JIT, tłumaczeniu klasycznego BPF, a także przy studiowaniu map, wywoływaniu funkcji itp.

Kiedy będziemy mówić o indywidualnych instrukcjach, będziemy odnosić się do plików jądra

bpf.h bpf_common.h i , w których zdefiniowane są numeryczne kody instrukcji BPF. Podczas samodzielnego poznawania architektury i/lub analizowania plików binarnych, semantykę możesz znaleźć w następujących, uporządkowanych według trudności, źródłach:Nieoficjalna specyfikacja eBPF Podręcznik odniesienia BPF i XDP, zestaw instrukcji, i oczywiście w kodach źródłowych Linuksa — weryfikator, JIT, interpreter BPF., Dokumentacja/networking/filter.txt Przykład: dezassemblujemy BPF w myślach

Rozważmy przykład, w którym skompilujemy program

readelf-example.c i spojrzymy na uzyskany plik binarny. Odkryjemy oryginalną zawartość poniżej, po tym jak odtworzymy jego logikę z binarnych kodów: i spojrzymy na uzyskany plik binarny. Odkryjemy oryginalną zawartość $ 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 ................

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Zrzut szesnastkowy sekcji '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

Pierwsza kolumna w wyjściu readelf — to wcięcie i nasz program, składa się z czterech poleceń:

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

Kody poleceń są równe b7, 15, b7 i 95. Przypomnijmy, że trzy najmłodsze bity to klasa instrukcji. W naszym przypadku czwarty bit we wszystkich instrukcjach jest pusty, więc klasy instrukcji są równe, odpowiednio, 7, 5, 7, 5. Klasa 7 to ) stanowią zestaw instrukcji ALU. Klasy 5, 6 (, a 5 to BPF_JMP32. Dla obu klas format instrukcji jest taki sam (patrz wyżej) i możemy przepisać nasz program tak (przy okazji przepisując pozostałe kolumny w bardziej czytelnej formie):

Op S  Klasa   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

Operacja b klasy ALU64 — to BPF_MOV. Przypisuje wartość rejestrowi docelowemu. Jeśli bit s (źródło), to wartość jest pobierana z rejestru źródłowego, a jeśli, jak w naszym przypadku, nie jest ustawiony, to wartość jest pobierana z pola Imm. Tak więc w pierwszej i trzeciej instrukcji wykonujemy operację r0 = Imm. Następnie, operacja 1 klasy JMP to BPF_JEQ (skok, jeśli równe). W naszym przypadku, ponieważ bit S jest równy zeru, porównuje wartość rejestru źródłowego z polem Imm. Jeśli wartości są równe, następuje skok na PC + Off, gdzie PC, jak zwykle, zawiera adres następnej instrukcji. Na koniec, operacja 9 klasy JMP to BPF_EXIT. Ta instrukcja kończy działanie programu, zwracając do jądra r0. Dodajmy nową kolumnę do naszej tabeli:

Op    S  Klasa   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

Możemy przepisać to w bardziej wygodnej formie:

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

Jeśli przypomnimy sobie, że w rejestrze r1 program przekazuje wskaźnik do kontekstu z jądra, a w rejestrze r0 zwracane jest do jądra, to możemy dostrzec, że jeśli wskaźnik do kontekstu jest równy zeru, to zwracamy 1, a w przeciwnym razie 2. Sprawdźmy, czy mamy rację, patrząc na kod źródłowy:

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

Tak, to sensowny program, ale transluje się w zaledwie cztery proste instrukcje.

Przykład wyjątku: 16-bajtowa instrukcja

Wcześniej wspomnieliśmy, że niektóre instrukcje zajmują więcej niż 64 bity. Dotyczy to na przykład instrukcji lddw (Kod = 0x18 = BPF_LDX | BPF_DW | BPF_IMM) — załaduj do rejestru podwójne słowo z pól Imm. Chodzi o to, że Imm ma rozmiar 32, a podwójne słowo — 64 bity, dlatego załadowanie 64-bitowej wartości bezpośredniej do rejestru w jednej 64-bitowej instrukcji nie jest możliwe. W tym celu używane są dwie sąsiednie instrukcje do przechowywania drugiej części 64-bitowej wartości w polu Imm. Przykład:

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

W programie binarnym są tylko dwie instrukcje:

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

Jeszcze spotkamy się z instrukcją lddw, gdy porozmawiamy o relokacjach i pracy z maps.

Przykład: disasembly przy użyciu standardowych narzędzi

Nauczyliśmy się czytać binarne kody BPF i jesteśmy gotowi rozebrać każdą instrukcję, jeśli zajdzie taka potrzeba. Należy jednak zauważyć, że w praktyce wygodniej i szybciej jest rozbierać programy przy użyciu standardowych narzędzi, na przykład:

$ llvm-objdump -d x64.o

Disassembly of section .text:

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

Cykl życia obiektów BPF, system plików bpffs

(Niektóre szczegóły opisane w tym podrozdziale po raz pierwszy poznałem z postu Alexeja Starowoitowa na BPF Blog.)

Obiekty BPF – programy i mapy – są tworzone z przestrzeni użytkownika za pomocą poleceń BPF_PROG_LOAD i BPF_MAP_CREATE wywołania systemowego bpf(2), porozmawiamy o tym, jak dokładnie dzieje się to w następnym rozdziale. Tworzone są struktury danych jądra i dla każdej z nich refcount (licznik referencji) ustawia się na wartość jeden, a użytkownik otrzymuje deskryptor pliku wskazujący na obiekt. Po zamknięciu deskryptora refcount obiekt jest zmniejszany o jeden, a gdy osiągnie zero, obiekt jest niszczony.

Jeśli program używa maps, to refcount tych mapsów zwiększa się o jeden po załadowaniu programu, to znaczy, że ich deskryptory plików można zamknąć z procesu użytkownika i w tym samym czasie refcount nie staną się zerem:

BPF dla najmniejszych, część pierwsza: extended BPF

Po pomyślnym załadowaniu programu zwykle dołączamy go do jakiegoś generatora zdarzeń. Na przykład możemy umieścić go na interfejsie sieciowym do przetwarzania przychodzących pakietów lub połączyć go z jakimś tracepoint w jądrze. W tym momencie licznik referencji również zwiększy się o jeden i będziemy mogli zamknąć deskryptor pliku w programie ładującym.

Co się stanie, jeśli teraz zakończymy działanie ładowarki? Zależy to od typu generatora zdarzeń (hook). Wszystkie sieciowe hooki będą istnieć po zakończeniu ładowarki. To są tzw. hooki globalne. Natomiast na przykład programy śledzące zostaną zwolnione po zakończeniu procesu, który je stworzył (dlatego są nazywane lokalnymi, od „local to the process”). Technicznie rzecz biorąc, lokalne hooki zawsze mają odpowiadający deskryptor pliku w przestrzeni użytkownika i dlatego zamykają się podczas zamykania procesu, podczas gdy globalne — nie. Na następnym rysunku staram się za pomocą czerwonych krzyżyków pokazać, jak zakończenie programu ładowarki wpływa na czas życia obiektów w przypadku lokalnych i globalnych hooków.

BPF dla najmniejszych, część pierwsza: extended BPF

Dlaczego istnieje podział na lokalne i globalne hooki? Uruchomienie niektórych typów programów sieciowych ma sens i bez przestrzeni użytkownika, na przykład wyobraź sobie ochronę przed DDoS — ładowarka zapisuje reguły i podłącza program BPF do interfejsu sieciowego, po czym ładowarka może zakończyć swoje działanie. Z drugiej strony, wyobraź sobie program debugujący śledzący, który napisałeś w dziesięć minut — po jego zakończeniu chciałbyś, aby w systemie nie pozostawał żaden bałagan, a lokalne hooki to gwarantują.

Z drugiej strony, wyobraź sobie, że chcesz podłączyć się do tracepoint w jądrze i zbierać statystyki przez wiele lat. W tym przypadku chciałbyś zakończyć część użytkową i wracać do statystyk od czasu do czasu. Taką możliwość zapewnia system plików bpf. To pseudo-system plików, istniejący tylko w pamięci, który pozwala tworzyć pliki odnoszące się do obiektów BPF, a tym samym zwiększające refcount obiektów. Po tym ładowarka może zakończyć pracę, a utworzone przez nią obiekty pozostaną żywe.

BPF dla najmniejszych, część pierwsza: extended BPF

Tworzenie plików w bpffs, odnoszących się do obiektów BPF, nazywane jest „przytwierdzaniem” („pin”, jak w następnej frazie: „process can pin a BPF program or map”). Tworzenie obiektów plikowych dla obiektów BPF ma sens nie tylko w celu przedłużenia życia obiektów lokalnych, ale także dla wygody korzystania z obiektów globalnych — wracając do przykładu z globalnym programem ochrony przed DDoS, chcemy mieć możliwość od czasu do czasu przeglądać statystyki.

System plików BPF zwykle montowany jest w /sys/fs/bpf, ale można go również zamontować lokalnie, na przykład tak:

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

Nazwy w systemie plików tworzone są za pomocą polecenia BPF_OBJ_PIN wywołania systemowego BPF. Jako ilustrację weźmy jakikolwiek program, skompilujmy go, załadujmy i przytwierdźmy do bpffs. Nasz program nie robi nic użytecznego, podajemy jego kod tylko po to, abyście mogli odtworzyć przykład:

$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
        return 0;
}

char _license[] __attribute__((section("license"), used)) = "GPL";

Skompilujmy ten program i stwórzmy lokalną kopię systemu plików bpffs:

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

Teraz załadujmy nasz program za pomocą narzędzia bpftool i przyjrzyjmy się powiązanym wywołaniom systemowym bpf(2) (z wyjścia strace usunięto niektóre nieistotne linie):

$ 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

Tutaj załadowaliśmy program za pomocą BPF_PROG_LOAD, otrzymaliśmy od jądra deskryptor pliku 3 i za pomocą polecenia BPF_OBJ_PIN przytwierdziliśmy ten deskryptor pliku w formie pliku "bpf-mountpoint\/test". Po tym program ładujący bpftool zakończył działanie, ale nasz program pozostał w jądrze, chociaż nie przytwierdzaliśmy go do żadnego interfejsu sieciowego:

$ 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

Możemy usunąć obiekt plikowy tradycyjnym unlink(2) i po tym odpowiedni program zostanie usunięty:

$ sudo rm .\/bpf-mountpoint\/test
$ sudo bpftool prog show id 783
Błąd: pobranie według id (783): Brak takiego pliku lub katalogu

Usuwanie obiektów

Mówiąc o usuwaniu obiektów, należy wyjaśnić, że po odłączeniu programu od haka (generatora zdarzeń), żadne nowe zdarzenia nie spowodują jego uruchomienia, jednak wszystkie bieżące instancje programu zostaną zakończone w normalny sposób.

Niektóre rodzaje programów BPF pozwalają na podmianę programu w trakcie działania, tzn. zapewniają atomowość sekwencji replace = odłącz stary program, dołącz nowy program. Przy tym wszystkie aktywne instancje starej wersji programu zakończą swoją pracę, a nowe obsługujące zdarzenia będą tworzone już z nowego programu, a "atomowość" oznacza tutaj, że żadne zdarzenie nie zostanie pominięte.

Podłączanie programów do źródeł zdarzeń

W tym artykule nie będziemy osobno opisywać podłączania programów do źródeł zdarzeń, ponieważ ma to sens badać w kontekście konkretnego typu programu. Zob. przykład poniżej, w którym pokazujemy, jak podłączają się programy typu XDP.

Zarządzanie obiektami za pomocą wywołania systemowego bpf

Programy BPF

Wszystkie obiekty BPF są tworzone i zarządzane z przestrzeni użytkownika za pomocą wywołania systemowego bpf, które ma następujący prototyp:

#include <linux/bpf.h>

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

Tutaj polecenie cmd to jedna z wartości typu enum bpf_cmd, attr — wskaźnik do parametrów dla konkretnego programu oraz rozmiar — rozmiar obiektu wskazywanego, tj. zazwyczaj jest to sizeof(*attr). W jądrze 5.8 wywołanie systemowe bpf obsługuje 34 różne polecenia, a definicja union bpf_attr zajmuje 200 linii. Ale nie powinno nas to przerażać, ponieważ zapoznamy się z poleceniami i parametrami w ciągu kilku artykułów.

Zaczniemy od polecenia BPF_PROG_LOAD, które tworzy programy BPF — bierze zestaw instrukcji BPF i ładuje je do jądra. W momencie ładowania uruchamiany jest verifier, a następnie kompilator JIT i, po pomyślnym zakończeniu, zwracany jest deskryptor pliku programu. Widzieliśmy, co dzieje się dalej w poprzedniej sekcji o cyklu życia obiektów BPF..

Teraz napiszemy program użytkownika, który załadowuje prosty program BPF, ale najpierw musimy zdecydować, jaki dokładnie program chcemy załadować — będziemy musieli wybrać typ i w ramach tego typu napisać program, który przejdzie weryfikację w verifierze. Jednak, aby nie komplikować procesu, oto gotowe rozwiązanie: weźmiemy program typu BPF_PROG_TYPE_XDP, który zwróci wartość XDP_PASS (przepuścić wszystkie pakiety). W asemblerze BPF wygląda to bardzo prosto:

r0 = 2
exit

Po tym, jak zdecydowaliśmy, co co zamierzamy załadować, możemy powiedzieć, jak to zrobimy:

#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();
}

Interesujące zdarzenia w programie zaczynają się od zdefiniowania tablicy insns — naszego programu BPF w kodzie maszynowym. Przy tym każda instrukcja programu BPF jest pakowana w strukturę bpf_insn.Pierwszy element insns odpowiada instrukcji r0 = 2, drugi — exit.

Dygresja. W jądrze zdefiniowano bardziej wygodne makra do pisania kodów maszynowych i, korzystając z nagłówka jądra tools/include/linux/filter.h , moglibyśmy napisać

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

Ale ponieważ pisanie programów BPF w kodzie maszynowym jest potrzebne tylko do pisania testów w jądrze i artykułów na temat BPF, brak tych makr tak naprawdę nie utrudnia życia programiście.

Po zdefiniowaniu programu BPF przechodzimy do jego ładowania w jądro. Nasz minimalistyczny zestaw parametrów attr obejmuje typ programu, zestaw i liczbę instrukcji, obowiązkową licencję oraz nazwę "woo", której używamy, aby odnaleźć nasz program w systemie po załadowaniu. Program, jak obiecano, jest ładowany do systemu za pomocą wywołania systemowego bpf.

Na końcu programu wpadamy w nieskończoną pętlę, która imituje ładunek użyteczny. Bez niej program zostanie zniszczony przez jądro po zamknięciu deskryptora pliku, który zwróciło nam wywołanie systemowe bpf, i nie zobaczymy go w systemie.

Cóż, jesteśmy gotowi do testowania. Skompilujmy i uruchommy program pod strace, aby sprawdzić, czy wszystko działa jak należy:

$ clang -g -O2 simple-prog.c -o simple-prog

$ sudo strace .\/simple-prog
execve(".\/simple-prog", [".\/simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

Wszystko w porządku, bpf(2) zwrócił nam deskryptor 3 i weszliśmy w nieskończoną pętlę z pause(). Spróbujmy znaleźć nasz program w systemie. W tym celu przejdźmy do innego terminala i użyjmy narzędzia 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)

Widzę, że w systemie znajduje się załadowany program woo którego globalny ID wynosi 390, i że w danym momencie w procesie simple-prog istnieje otwarty deskryptor pliku, który wskazuje na program (a jeśli simple-prog zakończy działanie, to woo zniknie). Jak się spodziewano, program woo zajmuje 16 bajtów — dwie instrukcje — kodów binarnych w architekturze BPF, ale w postaci natywnej (x86_64) — to już 40 bajtów. Przyjrzyjmy się naszemu programowi w oryginalnej formie:

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

bez niespodzianek. Teraz zobaczmy kod, stworzony przez kompilator JIT:

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

niezbyt efektywnie dla exit(2), ale sprawiedliwości czyniąc, nasz program jest zbyt prosty, a dla nietrywialnych programów prolog i epilog, dodane przez kompilator JIT, są oczywiście potrzebne.

Maps

Programy BPF mogą korzystać ze strukturalnych obszarów pamięci, dostępnych zarówno dla innych programów BPF, jak i dla programów z przestrzeni użytkownika. Obiekty te nazywane są mapami, a w tej sekcji pokażemy, jak nimi zarządzać przy pomocy wywołania systemowego. bpf.

Od razu powiedzmy, że możliwości map nie ograniczają się tylko do dostępu do wspólnej pamięci. Istnieją mapy specjalnego przeznaczenia, które zawierają na przykład wskaźniki do programów BPF lub wskaźniki do interfejsów sieciowych, mapy do pracy z perf events itd. O nich nie będziemy tutaj mówić, aby nie mylić czytelnika. Ponadto ignorujemy problemy synchronizacji, ponieważ nie są one istotne dla naszych przykładów. Pełną listę dostępnych typów map można znaleźć w <linux/bpf.h>, a w tej sekcji weźmiemy na przykład historycznie pierwszy typ, tabelę haszową. BPF_MAP_TYPE_HASH.

Jeśli tworzysz tabelę haszową, powiedzmy w C++, mówisz unordered_map woo, co po polsku oznacza „potrzebuję tabeli woo o nieograniczonej wielkości, w której klucze mają typ int, a wartości mają typ long”. Aby stworzyć tabelę haszową BPF, musimy zrobić mniej więcej to samo, z zastrzeżeniem, że musimy określić maksymalny rozmiar tabeli, a zamiast typów kluczy i wartości musimy podać ich rozmiary w bajtach. Do tworzenia map używamy polecenia BPF_MAP_CREATE wywołania systemowego bpf. Zobaczmy bardziej lub mniej minimalny program, który tworzy mapę. Po poprzednim programie ładującym programy BPF, ten powinien wydawać się prosty:

$ cat simple-map.c
#define _GNU_SOURCE
#include 
#include 
#include 
#include 

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

    for ( ;; )
        pause();
}

Tutaj definiujemy zestaw parametrów, attr, w którym mówimy „potrzebuję tabeli haszowej z kluczami i wartościami rozmiaru sizeof(int), do której mogę włożyć maksymalnie cztery elementy”. Przy tworzeniu map BPF można określać również inne parametry; na przykład, tak jak w przykładzie z programem, określiliśmy nazwę obiektu jako "woo".

Skompilujmy i uruchommy program:

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

Tutaj jest wywołanie systemowe bpf(2) zwróciło nam deskryptor mapy numer 3 a program, jak się spodziewano, czeka na dalsze instrukcje w wywołaniu systemowym pause(2).

Teraz wyślemy nasz program w tle lub otworzymy inny terminal i przyjrzymy się naszemu obiektowi za pomocą narzędzia bpftool (możemy odróżnić naszą mapę od innych po jej nazwie):

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

Liczba 114 to globalny ID naszego obiektu. Każdy program w systemie może używać tego ID, aby otworzyć już istniejącą mapę za pomocą polecenia BPF_MAP_GET_FD_BY_ID wywołania systemowego bpf.

Teraz możemy się pobawić naszą tablicą haszującą. Sprawdźmy jej zawartość:

$ sudo bpftool map dump id 114
Znaleziono 0 elementów

Pusto. Wrzućmy do niej wartość hash[1] = 1:

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

Spójrzmy na tabelę jeszcze raz:

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

Hurra! Udało nam się dodać jeden element. Zauważ, że musimy pracować na poziomie bajtów, ponieważ bptftool nie wie, jaki typ mają wartości w tablicy haszującej. (Można mu to wiedzieć przekazując BTF, ale nie teraz.)

Jak dokładnie bpftool odczytuje i dodaje elementy? Zajrzyjmy pod maskę:

$ 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

Najpierw otworzyliśmy mapę po jej globalnym ID za pomocą polecenia BPF_MAP_GET_FD_BY_ID i bpf(2) zwróciło nam deskryptor 3. Następnie, za pomocą polecenia BPF_MAP_GET_NEXT_KEY znaleźliśmy pierwszy klucz w tabeli, przekazując NULL jako wskaźnik do «poprzedniego» klucza. Mając klucz, możemy wykonać BPF_MAP_LOOKUP_ELEM, który zwraca wartość do wskaźnika value. Następny krok — próbujemy znaleźć następny element, przekazując wskaźnik do aktualnego klucza, ale nasza tabela zawiera tylko jeden element i polecenie BPF_MAP_GET_NEXT_KEY zwraca ENOENT.

Dobrze, zmieńmy wartość dla klucza 1, powiedzmy, nasza logika biznesowa wymaga zapisania 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

Jak się spodziewano, to bardzo proste: polecenie BPF_MAP_GET_FD_BY_ID otwiera naszą mapę po ID, a polecenie BPF_MAP_UPDATE_ELEM nadpisuje element.

W rezultacie, po utworzeniu tablicy haszowej w jednym programie możemy odczytywać i zapisywać jej zawartość z drugiego. Zauważ, że jeśli udało nam się to zrobić z wiersza poleceń, to każda inna aplikacja w systemie też może. Oprócz komend opisanych powyżej do pracy z mapami w przestrzeni użytkownika dostępne są następujące:

  • BPF_MAP_LOOKUP_ELEM: znaleźć wartość według klucza
  • BPF_MAP_UPDATE_ELEM: zaktualizować/utworzyć wartość
  • BPF_MAP_DELETE_ELEM: usunąć klucz
  • BPF_MAP_GET_NEXT_KEY: znaleźć następny (lub pierwszy) klucz
  • BPF_MAP_GET_NEXT_ID: umożliwia przechodzenie przez wszystkie istniejące mapy, więc działa bpftool map
  • BPF_MAP_GET_FD_BY_ID: otworzyć istniejącą mapę według jej globalnego ID
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: atomowo zaktualizować wartość obiektu i zwrócić starą
  • BPF_MAP_FREEZE: uczynić mapę niezmienną z przestrzeni użytkownika (tej operacji nie można cofnąć)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: operacje masowe. Na przykład, BPF_MAP_LOOKUP_AND_DELETE_BATCH — to jedyny niezawodny sposób na odczytanie i wyzerowanie wszystkich wartości z mapy

Nie wszystkie z tych komend działają dla wszystkich typów map, ale ogólnie praca z innymi typami map w przestrzeni użytkownika wygląda tak samo jak praca z tablicami haszowymi.

Dla porządku zakończmy nasze eksperymenty z tablicą haszową. Pamiętaj, że stworzyliśmy tabelę, w której może znajdować się do czterech kluczy? Dodajmy jeszcze kilka elementów:

$ 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

Na razie wszystko w porządku:

$ 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
Znaleziono 4 elementy

Spróbujemy dodać jeszcze jeden:

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Błąd: aktualizacja niepowodzenie: zbyt długa lista argumentów

Jak się spodziewano, nie udało nam się. Spójrzmy na błąd bliżej:

$ 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 (zbyt długa lista argumentów)
Błąd: aktualizacja niepowodzenie: zbyt długa lista argumentów
+++ zakończono z 255 +++

Wszystko w porządku: jak się spodziewano, komenda BPF_MAP_UPDATE_ELEM próbuje utworzyć nowy, piąty, klucz, ale kończy się błędem E2BIG.

Zatem potrafimy tworzyć i ładować programy BPF, a także tworzyć i zarządzać mapami z przestrzeni użytkownika. Teraz logicznie jest przyjrzeć się, jak możemy używać map z samych programów BPF. Moglibyśmy o tym opowiedzieć w języku trudnych do odczytania programów w kodach maszynowych, ale w rzeczywistości nadszedł czas, aby pokazać, jak naprawdę pisane i utrzymywane są programy BPF — za pomocą libbpf.

(Dla czytelników, niezadowolonych z braku niskopoziomowego przykładu: dokładnie przeanalizujemy programy, które wykorzystują mapy i funkcje pomocnicze stworzone przy pomocy libbpf i opowiemy, co dzieje się na poziomie instrukcji. Dla czytelników, niezadowolonych bardzo mocno, dodaliśmy przykład w odpowiednim miejscu artykułu.)

Tworzenie programów BPF za pomocą libbpf

Pisanie programów BPF przy użyciu kodów maszynowych może być interesujące tylko przez chwilę, a potem następuje przesyt. W tym momencie należy zwrócić wzrok na llvm, który ma backend do generowania kodu dla architektury BPF, a także na bibliotekę libbpf, która umożliwia pisanie części użytkowej aplikacji BPF i ładowanie kodu programów BPF, wygenerowanego przy pomocy llvm/clang.

W rzeczywistości, jak zobaczymy w tym i kolejnych artykułach, libbpf wykonuje całkiem sporo pracy i bez niej (lub podobnych narzędzi — iproute2, libbcc, libbpf-go, itd.) życie byłoby niemożliwe. Jedną z killer-funkcji projektu libbpf jest BPF CO-RE (Compile Once, Run Everywhere) — projekt, który pozwala pisać programy BPF przenośne z jądra do jądra, z możliwością uruchamiania na różnych API (na przykład, gdy struktura jądra zmienia się z wersji na wersję). Aby móc pracować z CO-RE, Twoje jądro musi być skompilowane z obsługą BTF (jak to zrobić opisujemy w sekcji Narzędzia deweloperskie. Sprawdzenie, czy Twoje jądro zostało skompilowane z BTF czy nie, jest bardzo proste — na podstawie istnienia następującego pliku:

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

Ten plik przechowuje informacje o wszystkich typach danych używanych w jądrze i jest używany we wszystkich naszych przykładach wykorzystujących libbpf. Będziemy dokładnie omawiać CO-RE w następnym artykule, a w tym — po prostu zbuduj sobie jądro z CONFIG_DEBUG_INFO_BTF.

Biblioteka libbpf żyje bezpośrednio w katalogu tools/lib/bpf jądra, a jego rozwój odbywa się za pośrednictwem listy dyskusyjnej bpf@vger.kernel.org. Jednak dla aplikacji działających poza jądem, obsługiwane jest oddzielne repozytorium https://github.com/libbpf/libbpf , w którym biblioteka jądra jest lustrzana do odczytu mniej więcej tak, jak jest.

W tej sekcji przyjrzymy się, jak stworzyć projekt korzystający z libbpf, napiszemy kilka (w miarę bezsensownych) programów testowych i szczegółowo omówimy, jak to wszystko działa. Pomoże nam to w następnych sekcjach łatwiej wyjaśnić, w jaki sposób programy BPF współdziałają z mapami, pomocnikami jądra, BTF itd.

Zwykle projekty korzystające z libbpf dodają repozytorium z GitHuba jako podmoduł git, zróbmy to samo:

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Zainicjowano pustą bazę repozytoriów Git w /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Klonowanie do '/tmp/libbpf-example/libbpf'...
remote: Enumerowanie obiektów: 200, zakończono.
remote: Liczenie obiektów: 100% (200/200), zakończono.
remote: Kompresowanie obiektów: 100% (103/103), zakończono.
remote: Łącznie 3354 (delta 101), ponownie wykorzystano 118 (delta 79), spakowane 3154
Otrzymano obiekty: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, zakończono.
Rozwiązywanie deltas: 100% (2176/2176), zakończono.

Kompilacja libbpf jest bardzo prosta:

$ 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

Nasz dalszy plan w tej sekcji jest następujący: napiszemy program BPF typu BPF_PROG_TYPE_XDP, ten sam, co w poprzednim przykładzie, ale w C, skompilujemy go za pomocą clang, i napiszemy program pomocniczy, który załaduje go do jądra. W następnych sekcjach rozszerzymy możliwości zarówno programu BPF, jak i programu pomocniczego.

Przykład: tworzymy pełnoprawną aplikację za pomocą libbpf

Na początek użyjemy pliku /sys/kernel/btf/vmlinux, o którym wspomniano powyżej, i stworzymy jego odpowiednik w formie pliku nagłówkowego:

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

W tym pliku będą przechowywane wszystkie struktury danych, które istnieją w naszym jądrze, na przykład tak w jądrze definiowany jest nagłówek IPv4:

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

Teraz napiszemy nasz program BPF w języku C:

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include 

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

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

Choć nasz program okazał się bardzo prosty, powinniśmy zwrócić uwagę na wiele szczegółów. Po pierwsze, pierwszym plikiem nagłówkowym, który dołączamy, jest vmlinux.h, który właśnie wygenerowaliśmy za pomocą bpftool btf dump — teraz nie musimy instalować pakietu kernel-headers, aby dowiedzieć się, jak wyglądają struktury jądra. Następny plik nagłówkowy pochodzi z biblioteki libbpf. Teraz potrzebujemy go tylko po to, aby zdefiniował makro SEC, które wysyła symbol do odpowiedniej sekcji pliku obiektowego ELF. Nasz program znajduje się w sekcji xdp/simple, gdzie przed ukośnikiem określamy typ programu BPF — to konwencja stosowana w libbpf, na podstawie nazwy sekcji zostanie wstawiony odpowiedni typ podczas uruchamiania bpf(2). Sam program BPF na C — jest bardzo prosty i składa się z jednej linijki return XDP_PASS. Wreszcie, osobna sekcja "license" zawiera nazwę licencji.

Możemy skompilować nasz program za pomocą llvm/clang w wersji >= 10.0.0, a lepiej — więcej (zob. sekcja Narzędzia deweloperskie):

$ 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

Z interesujących cech: określamy docelową architekturę -target bpf i ścieżkę do nagłówków libbpf, które niedawno zainstalowaliśmy. Ponadto, nie zapominaj o -O2, bez tej opcji mogą cię czekać niespodzianki w przyszłości. Sprawdźmy nasz kod, czy udało nam się napisać program, który chcieliśmy?

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

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

Disassembly of section xdp/simple:

0000000000000000 :
       0:       r0 = 2
       1:       exit

Tak, udało się! Teraz mamy plik binarny z programem, i chcemy stworzyć aplikację, która załadowuje go do jądra. W tym celu biblioteka libbpf oferuje nam dwie opcje — użyć API niskiego poziomu lub API wysokiego poziomu. Wybierzemy tę drugą drogę, ponieważ chcemy nauczyć się pisać, ładować i podłączać programy BPF z minimalnym wysiłkiem do ich dalszej nauki.

Na początek musimy wygenerować "szkielet" naszego programu z jego binarki za pomocą tej samej utility bpftool — szwajcarskiego noża świata BPF (co można rozumieć dosłownie, ponieważ Daniel Borkman jest jednym z twórców i konserwatorów BPF — Szwajcarem):

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

W pliku xdp-simple.skel.h zawiera kod binarny naszego programu oraz funkcje do zarządzania — ładowania, podłączania, usuwania naszego obiektu. W naszym prostym przypadku wydaje się to przesadą, ale działa także wtedy, gdy plik obiektowy zawiera wiele programów BPF i map, a do załadowania tego olbrzymiego ELF wystarczy tylko wygenerować szkielet i wywołać jedną lub dwie funkcje z aplikacji użytkownika, do pisania której teraz przejdziemy.

Właściwie to nasz program-loader jest trywialny:

#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);
}

Tutaj struct xdp_simple_bpf jest zdefiniowany w pliku xdp-simple.skel.h i opisuje nasz plik obiektowy:

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

Możemy zauważyć tutaj ślady niskopoziomowego API: struktura struct bpf_program *simple i struct bpf_link *simple. Pierwsza struktura opisuje konkretnie nasz program, zapisany w sekcji xdp/simple, a druga — opisuje, w jaki sposób program jest podłączany do źródła wydarzeń.

veth_xdp_flush_bq() xdp_simple_bpf__open_and_load, otwiera obiekt ELF, analizuje go, tworzy wszystkie struktury i podstruktury (oprócz programu w ELF znajdują się inne sekcje — dane, dane tylko do odczytu, informacje debugowania, licencja itp.), a następnie ładował do jądra za pomocą wywołania systemowego bpf, co możemy sprawdzić, kompilując i uruchamiając program:

$ 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

Zobaczmy teraz nasz program za pomocą bpftool. Znajdziemy jego ID:

# bpftool p | grep -A4 simple
463: xdp  name simple  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-01T01:59:49+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        btf_id 185
        pids xdp-simple(16498)

i zrzucimy (używamy skróconej formy polecenia bpftool prog dump xlated):

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

Coś nowego! Program wydrukował fragmenty naszego pliku źródłowego w języku C. Zostało to zrealizowane przez bibliotekę libbpf, która znalazła sekcję debugowania w binarze, skompilowała ją do obiektu BTF, załadowała do jądra za pomocą BPF_BTF_LOAD, a następnie wskazała uzyskany deskryptor pliku podczas ładowania programu komendą BPG_PROG_LOAD.

Kernel Helpers

Programy BPF mogą uruchamiać "zewnętrzne" funkcje — helpery jądra. Te funkcje pomocnicze pozwalają programom BPF uzyskiwać dostęp do struktur jądra, zarządzać mapami oraz komunikować się z "rzeczywistym światem" — tworzyć zdarzenia perf, zarządzać sprzętem (na przykład, przekierowywać pakiety) itd.

Przykład: bpf_get_smp_processor_id

W ramach paradygmy "uczymy się na przykładach", rozważmy jedną z funkcji pomocniczych, bpf_get_smp_processor_id(), określoną w pliku kernel/bpf/helpers.c. Zwraca numer procesora, na którym uruchamiana jest wywołująca ją program BPF. Jednak interesuje nas nie tylko jej semantyka, ale również to, że jej implementacja zajmuje jedną linijkę:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Definicje funkcji pomocniczych BPF są podobne do definicji wywołań systemowych w Linuksie. Tutaj, na przykład, definiowana jest funkcja, która nie ma argumentów. (Funkcja, przyjmująca, powiedzmy, trzy argumenty, jest definiowana za pomocą makra BPF_CALL_3. Maksymalna liczba argumentów wynosi pięć.) To jednak tylko pierwsza część definicji. Druga część polega na zdefiniowaniu struktury typu struct bpf_func_proto, która zawiera opis funkcji pomocniczej zrozumiały dla weryfikatora:

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

Rejestracja funkcji pomocniczych

Aby programy BPF określonego typu mogły używać tej funkcji, muszą ją zarejestrować, na przykład dla typu BPF_PROG_TYPE_XDP w jądrze definiowana jest funkcja xdp_func_proto, która na podstawie ID funkcji pomocniczej określa, czy XDP obsługuje tę funkcję, czy nie. Nasza funkcja wspiera:

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

Nowe typy programów BPF "są definiowane" w pliku include/linux/bpf_types.h za pomocą makra BPF_PROG_TYPE. "Definiowane" ujęte w cudzysłowy, ponieważ jest to definicja logiczna, a w terminologii języka C definicja pełnego zestawu konkretnych struktur odbywa się w innych miejscach. Szczególnie, w pliku kernel/bpf/verifier.c wszystkie definicje z pliku bpf_types.h są używane do stworzenia tablicy struktur bpf_verifier_ops[]:

static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type) 
    [_id] = & _name ## _verifier_ops,
#include 
#undef BPF_PROG_TYPE
};

Oznacza to, że dla każdego typu programów BPF określany jest wskaźnik na strukturę danych typu struct bpf_verifier_ops, który jest inicjowany wartością _name ## _verifier_ops, tj. xdp_verifier_ops do xdp. Struktura xdp_verifier_ops jest definiowana w pliku net/core/filter.c w następujący sposób:

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

Tutaj widzimy naszą znaną funkcję xdp_func_proto, która będzie uruchamiana przez verifier za każdym razem, gdy napotka wywołanie jakiejś funkcji wewnątrz programu BPF, patrz. verifier.c.

Przyjrzyjmy się, jak hipotetyczny program BPF używa funkcji bpf_get_smp_processor_id. Aby to zrobić, przepiszemy program z naszej poprzedniej sekcji w następujący sposób:

#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 jest definiowana do <bpf/bpf_helper_defs.h> biblioteki libbpf jak

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

to znaczy, bpf_get_smp_processor_id to wskaźnik na funkcję, której wartość wynosi 8, gdzie 8 to wartość BPF_FUNC_get_smp_processor_id typ enum bpf_fun_id, która jest określana dla nas w pliku vmlinux.h (plik bpf_helper_defs.h jest generowany w jądrze przez skrypt, więc «magicze» liczby są w porządku). Ta funkcja nie przyjmuje argumentów i zwraca wartość typu __u32. Gdy uruchamiamy ją w naszym programie, clang generuje instrukcję BPF_CALL «w odpowiednim formacie». Skonstruujmy program i zobaczmy sekcję 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:       format pliku elf64-bpf

Dekompozycja sekcji 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

Już w pierwszej linijce widzimy instrukcję call, parametr IMM którego wartość wynosi 8, a SRC_REG wynosi zero. Zgodnie z umową ABI używaną przez verifier, jest to wywołanie funkcji pomocniczej o numerze osiem. Po jej uruchomieniu logika jest prosta. Zwracana wartość z rejestru r0 jest kopiowana do r1 i w linijkach 2,3 jest rzutowana na typ u32 — górne 32 bity są zerowane. W linijkach 4,5,6,7 zwracamy 2 (XDP_PASS) lub 1 (XDP_DROP) w zależności od tego, czy funkcja pomocnicza z linijki 0 zwróciła wartość zero czy niezerową.

Sprawdźmy to: załadujmy program i zobaczmy jego wynik bpftool prog dump xlated:

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

$ sudo bpftool p | grep simple
523: xdp  name simple  tag 44c38a10c657e1b0  gpl
        pids xdp-simple(10915)

$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
   0: (85) call bpf_get_smp_processor_id#114128
   1: (bf) r1 = r0
   2: (67) r1 <>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

Dobrze, verifier znalazł właściwy kernel-helper.

Przykład: przekazujemy argumenty i w końcu uruchamiamy program!

Wszystkie funkcje pomocnicze na poziomie wykonania mają prototyp

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

Parametry funkcjom pomocniczym przekazywane są w rejestrach r1r5, a wartość zwracana jest w rejestrze r0. Funkcji przyjmujących więcej niż pięć argumentów - nie ma i nie przewiduje się ich wsparcia w przyszłości.

Spójrzmy na nowy kernel helper i to, jak BPF przekazuje parametry. Przepiszmy xdp-simple.bpf.c w następujący sposób (pozostałe linie nie uległy zmianie):

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

Nasz program drukuje numer CPU, na którym jest uruchomiony. Skompilujmy go i sprawdźmy kod:

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

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

W liniach 0-7 zapisujemy na stosie łańcuch running on CPU%un, a następnie w linii 8 uruchamiamy nam znany bpf_get_smp_processor_id. W liniach 9-12 przygotowujemy argumenty pomocnika bpf_printk — rejestry r1, r2, r3. Dlaczego jest ich trzy, a nie dwa? Ponieważ bpf_printk — to jest makro-opakowanie wokół prawdziwego pomocnika bpf_trace_printk, który wymaga przekazania rozmiaru łańcucha formatującego.

Dodajmy teraz kilka linii do xdp-simple.c, aby nasz program podłączał się do interfejsu lo i naprawdę uruchamiał się!

$ cat xdp-simple.c
#include 
#include 
#include 
#include "xdp-simple.skel.h"

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

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

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

cleanup:
    xdp_simple_bpf__destroy(obj);
}

Tutaj używamy funkcji bpf_set_link_xdp_fd, która podłącza programy BPF typu XDP do interfejsów sieciowych. Zhardcodowaliśmy numer interfejsu lo, który zawsze wynosi 1. Uruchamiamy funkcję dwa razy, aby najpierw odłączyć starą aplikację, jeśli była podłączona. Zauważ, że teraz nie potrzebujemy wywołania pauza lub nieskończona pętla: nasz program ładujący zakończy działanie, ale program BPF nie zostanie zniszczony, ponieważ jest podłączony do źródła zdarzeń. Po pomyślnym załadowaniu i podłączeniu, program będzie się uruchamiać dla każdego pakietu sieciowego, który przychodzi na lo.

Załadujmy program i zobaczmy interfejs lo:

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

Program, który załadowaliśmy ma ID 669, a to samo ID widzimy na interfejsie lo. Wyślemy kilka pakietów do 127.0.0.1 (request + reply):

$ ping -c1 localhost

a teraz zobaczmy na zawartość pliku wirtualnego do debugowania /sys/kernel/debug/tracing/trace_pipe, do którego bpf_printk zapisuje swoje komunikaty:

# 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

Dwa pakiety zostały zauważone na lo i przetworzone na CPU0 — nasz pierwszy w pełni funkcjonalny bezsensowny program BPF zakończył działanie!

Warto zauważyć, że bpf_printk nie bez powodu pisze do pliku debugowania: to nie jest najlepszy pomocnik do używania w produkcji, ale naszym celem było pokazanie czegoś prostego.

Dostęp do map z programów BPF

Przykład: użyjemy mapy z programu BPF

W poprzednich rozdziałach nauczyliśmy się tworzyć i używać mapy z przestrzeni użytkownika, a teraz przyjrzymy się części jądra. Zacznijmy, jak zwykle, od przykładu. Przepiszmy nasz program xdp-simple.bpf.c w następujący sposób:

#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";

Na początku programu dodaliśmy definicję mapy woo: jest to tablica z 8 elementów, w której przechowywane są wartości typu u64 (w C zdefiniowalibyśmy tę tablicę jako u64 woo[8]). W programie "xdp/simple" otrzymujemy numer aktualnego procesora w zmiennej key a następnie za pomocą funkcji pomocniczej bpf_map_lookup_element zdobywamy wskaźnik na odpowiedni wpis w tablicy, który zwiększamy o jeden. W przekładzie na polski: zliczamy statystyki tego, na którym CPU były przetwarzane przychodzące pakiety. Spróbujemy uruchomić program:

$ 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

Sprawdźmy, czy podłączył się do lo i wyślijmy kilka pakietów:

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

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

Teraz przyjrzyjmy się zawartości tablicy:

$ 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 }
]

Prawie wszystkie procesy były obsługiwane na CPU7. To dla nas nieistotne, najważniejsze, że program działa i rozumiemy, jak uzyskać dostęp do map z programów BPF — za pomocą pomocników bpf_mp_*.

Mistyczny wskaźnik

Możemy więc uzyskać dostęp z programu BPF do mapy za pomocą wywołań typu

val = bpf_map_lookup_elem(&woo, &key);

gdzie funkcja pomocnicza wygląda jak

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

ale przekazujemy wskaźnik &woo na bezimienną strukturę struct { ... }

Jeśli przyjrzymy się assemblerowi programu, zobaczymy, że wartość &woo w rzeczywistości nie jest zdefiniowana (linia 4):

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

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

Rozmontowanie sekcji xdp/simple:

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

i jest zawarta w relokacjach:

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

Sekcja relokacji '.relxdp/simple' w offsetcie 0xe18 zawiera 1 wpisy:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

Ale jeśli spojrzymy na już załadowany program, zobaczymy wskaźnik na poprawną mapę (linia 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]
...

Z tego wynika, że w momencie uruchomienia naszego programu ładującego odniesienie do &woo zostało zamienione przez bibliotekę libbpf. Na początek przyjrzymy się wyjściu 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

Widzimy, że libbpf stworzyła mapę woo a następnie załadowała nasz program simple. Przyjrzyjmy się dokładniej, jak ładujemy program:

  • wywołujemy xdp_simple_bpf__open_and_load z pliku xdp-simple.skel.h
  • który wywołuje xdp_simple_bpf__load z pliku xdp-simple.skel.h
  • który wywołuje bpf_object__load_skeleton z pliku libbpf/src/libbpf.c
  • który wywołuje bpf_object__load_xattr z libbpf/src/libbpf.c

Ostatnia funkcja, oprócz wszystkiego, wywoła bpf_object__create_maps, która tworzy lub otwiera istniejące maps, przekształcając je w deskryptory plików. (To jest miejsce, gdzie widzimy BPF_MAP_CREATE w wyniku strace.) Dalej wywoływana jest funkcja bpf_object__relocate , która nas interesuje, ponieważ pamiętamy, że widzieliśmy woo w tabeli relokacji. Badając ją, ostatecznie trafiamy do funkcji bpf_program__relocate, która zajmuje się relokacjami map:

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

Więc bierzemy naszą instrukcję

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

i zastępujemy w niej rejestr źródłowy na BPF_PSEUDO_MAP_FD, a pierwszy IMM na deskryptor pliku naszej mapy i, jeśli jest równy, na przykład, 0xdeadbeef, to w rezultacie otrzymamy instrukcję

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

Tak właśnie informacje o mapach są przekazywane do konkretnego załadowanego programu BPF. Przy czym mapa może być zarówno stworzona za pomocą BPF_MAP_CREATE, jak i otwarta po ID za pomocą BPF_MAP_GET_FD_BY_ID.

W rezultacie, przy użyciu libbpf algorytm jest następujący:

  • podczas kompilacji dla odniesień do map tworzony jest wpis w tabeli relokacji
  • libbpf otwiera obiekt ELF, znajduje wszystkie używane mapy i tworzy dla nich deskryptory plików
  • deskryptory plików są ładowane do jądra jako część instrukcji LD64

Jak widzicie, to jeszcze nie wszystko, i będziemy musieli zajrzeć do jądra. Na szczęście mamy trop — wpisaliśmy wartość BPF_PSEUDO_MAP_FD w rejestr źródłowy i możemy go przeszukać, co zaprowadzi nas w święte miejsce — kernel/bpf/verifier.c, gdzie funkcja o charakterystycznej nazwie zamienia deskryptor pliku na adres struktury typu 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;

(pełny kod można znaleźć pod tym linkiem). Zatem możemy uzupełnić nasz algorytm:

  • podczas ładowania programu weryfikator sprawdza poprawność użycia mapy i zapisuje adres odpowiedniej struktury struct bpf_map

Podczas ładowania binarnego ELF przy pomocy libbpf dzieje się jeszcze wiele wydarzeń, ale omówimy to w ramach innych artykułów.

Ładowanie programów i map bez libbpf

Jak obiecano, oto przykład dla czytelników, którzy chcą wiedzieć, jak stworzyć i załadować program, który używa map, bez pomocy libbpfMoże to być użyteczne, gdy pracujesz w środowisku, w którym nie możesz zebrać zależności, oszczędzasz każdy bajt lub piszesz program typu ply, który generuje kod binarny BPF w locie.

Aby łatwiej śledzić logikę, dla tych celów przepiszemy nasz przykład xdp-simple. Pełny i nieco rozszerzony kod programu, omawianego w tym przykładzie, możesz znaleźć w tym gist.

Logika naszej aplikacji jest następująca:

  • utworzyć mapę typu BPF_MAP_TYPE_ARRAY za pomocą polecenia BPF_MAP_CREATE,
  • utworzyć program, który wykorzystuje tę mapę,
  • podłączyć program do interfejsu, lo,

co tłumaczy się na ludzkie jako

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

Tutaj map_create tworzy mapę dokładnie tak, jak robiliśmy to w pierwszym przykładzie dotyczącemu wywołania systemowego bpf — „jądro, proszę, stwórz mi nową mapę w postaci tablicy z 8 elementów typu __u64 i zwróć mi deskryptor pliku”:

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

Program również ładowany jest prosto:

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

Trudna część prog_load — to definicja naszego programu BPF w postaci tablicy struktur struct bpf_insn insns[]. Ale ponieważ używamy programu, który mamy w C, możemy nieco oszukać:

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

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

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

Podsumowując, musimy napisać 14 instrukcji w postaci struktur typu struct bpf_insn (wskazówka: Weź zrzut górny, przeczytaj sekcję o instrukcjach, otwórz linux/bpf.h i linux/bpf_common.h i spróbuj określić struct bpf_insn insns[] samodzielnie):

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  */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

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

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

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

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

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

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

Ćwiczenie dla tych, którzy nie napisali tego sami – znajdźcie map_fd.

W naszym programie pozostała jeszcze jedna nieujawniona część – xdp_attach. Niestety, programy typu XDP nie mogą być podłączone za pomocą systemowego wywołania bpf. Ludzie, którzy tworzyli BPF i XDP, pochodzili ze społeczności sieciowej Linuksa, co oznacza, że używali najbardziej znajomego dla nich (ale nie dla normalnych ludzi) interfejsu interakcji z jądrem: gniazd netlink, zobacz także RFC3549. Najprostszym sposobem implementacji xdp_attach jest skopiowanie kodu z libbpf, a dokładnie, z pliku netlink.c, co właśnie zrobiliśmy, nieco go skracając:

Witaj w świecie gniazd netlink

Otwieramy gniazdo netlink typu 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;
}

Czytamy z takiego gniazda:

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

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len nlmsg_pid != nl_pid)
                errx(1, "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;
}

W końcu, oto nasza funkcja, która otwiera gniazdo i wysyła do niego specjalną wiadomość zawierającą deskryptor pliku:

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

    sock = netlink_open(&nl_pid);
    if (sock nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* 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;
}

Więc wszystko jest gotowe do testów:

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

Sprawdźmy, czy nasza aplikacja połączyła się z lo:

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

Wyślemy ping i sprawdzimy mapę:

$ 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
Znaleziono 8 elementów

Hurra, wszystko działa. Zauważcie, że nasza mapa znów jest wyświetlana w postaci bajtów. Dzieje się tak, ponieważ, w przeciwieństwie do libbpf nie załadowaliśmy informacji o typach (BTF). Ale o tym porozmawiamy następnym razem.

Narzędzia deweloperskie

W tej sekcji przyjrzymy się minimalnemu zestawowi narzędzi dla programisty BPF.

Generalnie, do tworzenia programów BPF nie potrzebujesz niczego specjalnego — BPF działa na każdej dobrej dystrybucji jądra, a programy są kompilowane przy użyciu clang, który można zainstalować z pakietu. Jednakże, ze względu na to, że BPF jest w trakcie rozwoju, jądro i narzędzia ulegają nieustannym zmianom. Jeśli nie chcesz pisać programów BPF przestarzałymi metodami z 2019 roku, będziesz musiał zbudować

  • llvm/clang
  • pahole
  • swoje jądro
  • bpftool

(Dla informacji: ta sekcja i wszystkie przykłady w artykule były uruchamiane na Debianie 10.)

llvm/clang

BPF współpracuje z LLVM i, chociaż od niedawna programy dla BPF można kompilować również za pomocą gcc, obecny rozwój odbywa się w ramach LLVM. Dlatego jako pierwsze zbierzemy aktualną wersję clang z 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
... wiele czasu później
$

Teraz możemy sprawdzić, czy wszystko się poprawnie zbudowało:

$ ./bin/llc --version
LLVM (http://llvm.org/):
  Wersja LLVM 11.0.0git
  Zoptymalizowana kompilacja.
  Domyślny cel: x86_64-unknown-linux-gnu
  CPU hosta: znver1

  Zarejestrowane cele:
    bpf    - BPF (endianness hosta)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bitowy X86: Pentium-Pro i nowsze
    x86-64 - 64-bitowy X86: EM64T i AMD64

(Instrukcje dotyczące budowy clang zostały przeze mnie wzięte z bpf_devel_QA.)

Nie będziemy instalować dopiero co zbudowanych programów, zamiast tego po prostu dodamy je do ŚCIEŻKA, na przykład:

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

(Można to dodać do .bashrc lub do osobnego pliku. Osobiście dodaję takie rzeczy do ~/bin/activate-llvm.sh i kiedy trzeba, robię . activate-llvm.sh.)

Pahole i BTF

Narzędzie pahole są używane podczas budowy jądra do tworzenia informacji debugujących w formacie BTF. Nie będziemy na tym artykule szczegółowo omawiać technologii BTF, poza tym, że jest to wygodne i chcemy to używać. Dlatego jeśli planujesz zbudować swoje jądro, najpierw zbierz pahole (bez pahole nie będziesz mógł zbudować jądra z opcją 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

Jądra do eksperymentów z BPF

Podczas badania możliwości BPF warto zbudować swoje jądro. Generalnie nie jest to konieczne, ponieważ możesz kompilować i ładować programy BPF na jądrze dystrybucyjnym, jednak posiadanie własnego jądra pozwala na korzystanie z najnowszych funkcji BPF, które w najlepszym przypadku trafią do twojej dystrybucji za kilka miesięcy, lub, w przypadku niektórych narzędzi do debugowania, w ogóle nie będą spakowane w przewidywalnej przyszłości. Ponadto własne jądro pozwala poczuć się ważnym i eksperymentować z kodem.

Aby zbudować jądro, potrzebujesz, po pierwsze, samego jądra, a po drugie, pliku konfiguracyjnego jądra. Do eksperymentów z BPF możesz użyć zwykłego waniliowego jądra lub jednego z jąder deweloperskich. Historycznie rozwój BPF odbywa się w ramach społeczności sieciowej Linux, dlatego wszystkie zmiany prędzej czy później trafiają do Davida Millera — maintenancera części sieciowej Linux. W zależności od charakteru zmian — poprawek lub nowych funkcji — zmiany sieciowe trafiają do jednego z dwóch jąder — net lub net-next. Zmiany dla BPF w ten sam sposób są dystrybuowane między bpf i bpf-next, które następnie są wstrzeliwane do net i net-next, odpowiednio. Szczegóły znajdziesz w bpf_devel_QA i netdev-FAQ. Więc wybierz jądro w zależności od swoich upodobań i potrzeb dotyczących stabilności systemu, na którym testujesz (*-next jądra są najbardziej niestabilne z wymienionych).

Zakres tego artykułu nie obejmuje opisu zarządzania plikami konfiguracyjnymi jądra — zakłada się, że albo już wiesz, jak to robić, albo jesteś gotów nauczyć się tego samodzielnie. Niemniej jednak, poniższe instrukcje powinny być wystarczające, abyś uzyskał działający system z obsługą BPF.

Pobierz jedno z powyżej wymienionych jąder:

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

Skonfiguruj minimalny działający plik konfiguracyjny jądra:

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

Włącz opcje BPF w pliku .config do własnego wyboru (najprawdopodobniej sama CONFIG_BPF już będzie włączona, ponieważ korzysta z niej systemd). Oto lista opcji z jądra, które użyto w tym artykule:

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

Następnie możemy łatwo zbudować i zainstalować moduły oraz jądro (przy okazji, można zbudować jądro za pomocą właśnie skonstruowanego clang, dodając CC=clang):

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

i zrestartować się z nowym jądrem (ja używam do tego kexec z pakietu kexec-tools):

v=5.8.0-rc6+ # jeśli rekonstruujesz obecne jądro, można użyć v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

bpftool

Najczęściej używaną narzędziem w artykule będzie narzędzie bpftool, dostarczane w składzie jądra Linux. Jest napisane i wspierane przez programistów BPF dla programistów BPF i można dzięki niemu zarządzać wszystkimi typami obiektów BPF — ładować programy, tworzyć i zmieniać mapy, badać żywotność ekosystemu BPF itd. Dokumentację w postaci kodu źródłowego do man pages można znaleźć w jądrze lub już skompilowaną, w sieci.

W momencie pisania artykułu bpftool dostarczana jest gotowa tylko dla RHEL, Fedora i Ubuntu (patrz, na przykład, ten wątek, w którym opowiedziano o nieukończonej historii pakowania bpftool w Debianie). Ale jeśli już skompilowałeś swoje jądro, to zebranie go bpftool jest proste:

$ cd ${linux}/tools/bpf/bpftool
# ... wpisz ścieżki do ostatniego clanga, jak to opisano powyżej
$ make -s

Auto-odkrywanie cech systemu:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-odkrywanie cech systemu:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(tutaj ${linux} — to jest twój katalog z jądrem.) Po wykonaniu tych komend bpftool zostanie zbudowane w katalogu ${linux}/tools/bpf/bpftool i można je dodać do ścieżki (najpierw dla użytkownika root) lub po prostu skopiować do /usr/local/sbin.

Bardzo dobrze jest zbierać przy pomocy najnowszego bpftool , skonstruowanego, jak opisano powyżej, a aby sprawdzić, czy zostało poprawnie zbudowane — przy pomocy, na przykład, komendy clang$ sudo bpftool feature probe kernel Skanowanie konfiguracji systemu... bpf() syscall for unprivileged users is enabled JIT compiler is enabled JIT compiler hardening is disabled JIT compiler kallsyms exports are enabled for root ...

co pokaże, jakie cechy BPF są włączone w twoim jądrze.

Przy okazji, poprzednią komendę można uruchomić jako

Zrobione to zostało na podobieństwo narzędzi z pakietu

# bpftool f p k

Zostało to zrobione na wzór narzędzi z pakietu iproute2, gdzie możemy na przykład powiedzieć ip a s eth0 zamiast ip addr show dev eth0.

Podsumowanie

BPF pozwala efektywnie modyfikować funkcjonalność jądra w czasie, a system okazał się bardzo udany, w najlepszych tradycjach UNIX-a: prosty mechanizm, który umożliwia (prze)programowanie jądra, pozwolił ogromnej liczbie ludzi i organizacji eksperymentować. I chociaż eksperymenty, podobnie jak rozwój samej infrastruktury BPF, wciąż są dalekie od zakończenia, system już ma stabilne ABI, co pozwala budować niezawodną, a przede wszystkim efektywną logikę biznesową.

Chcę zauważyć, że moim zdaniem technologia stała się tak popularna, ponieważ z jednej strony można w nią bawić się (architekturę maszyny można zrozumieć mniej więcej w jeden wieczór), a z drugiej — rozwiązywać zadania, które wcześniej były niemożliwe (ładnie) do rozwiązania. Te dwa elementy razem skłaniają ludzi do eksperymentowania i marzenia, co prowadzi do pojawiania się coraz to nowych innowacyjnych rozwiązań.

Ten artykuł, chociaż nie krótki, jest tylko wprowadzeniem do świata BPF i nie opisuje «zaawansowanych» możliwości i ważnych części architektury. Plan na przyszłość jest mniej więcej taki: następny artykuł będzie przeglądem typów programów BPF (w jądrze 5.8 wspieranych jest 30 typów programów), następnie w końcu przyjrzymy się, jak pisać prawdziwe aplikacje na BPF na przykładzie programów do śledzenia jądra, potem nadejdzie czas na pogłębiony kurs architektury BPF, a następnie — na przykłady aplikacji sieciowych i bezpieczeństwa BPF.

Poprzednie artykuły z tej serii

  1. BPF dla najmniejszych, część zerowa: klasyczny BPF

Linki

  1. BPF i XDP Przewodnik Referencyjny — dokumentacja BPF od cilium, a dokładniej od Daniela Borkmana, jednego z twórców i maintainerów BPF. To jedno z pierwszych poważnych opisów, które wyróżnia się tym, że Daniel dokładnie wie, o czym pisze i nie można zauważyć tam żadnych błędów. W szczególności w tym dokumencie opisano, jak pracować z programami BPF typów XDP i TC przy użyciu znanego narzędzia ip z pakietu iproute2.

  2. Dokumentacja/networking/filter.txt — oryginalny plik z dokumentacją klasycznego, a potem rozszerzonego BPF. Warto przeczytać, jeśli chcesz przyjrzeć się assemblerowi i szczegółom technicznym architektury.

  3. Blog o BPF od Facebooka. Rzadko jest aktualizowany, ale dokładnie, ponieważ piszą tam Alexei Starovoitov (autor eBPF) i Andrii Nakryiko — (maintainer libbpf).

  4. Sekrety bpftool. Ciekawe wątki na Twitterze od Quentin Monnet z przykładami i sekretami używania bpftool.

  5. Zanurz się w BPF: lista materiałów do przeczytania. Gigantyczna (i wciąż aktualizowana) lista linków do dokumentacji BPF od Quentina Monneta.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster