BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Na początku istniała technologia i nazywała się BPF. Przyjrzeliśmy się jej Poprzedni, artykuł ze Starego Testamentu z tej serii. W 2013 roku, dzięki staraniom Aleksieja Starovoitova i Daniela Borkmana, opracowano jego ulepszoną wersję, zoptymalizowaną dla nowoczesnych maszyn 64-bitowych, która została włączona do jądra Linuksa. Ta nowa technologia została przez krótki czas nazwana Internal BPF, następnie Extended BPF, a teraz, po kilku latach, wszyscy nazywają ją po prostu BPF.

Z grubsza rzecz ujmując, BPF umożliwia uruchamianie dowolnego kodu dostarczonego przez użytkownika w przestrzeni jądra Linuksa, a nowa architektura okazała się na tyle udana, że ​​będziemy potrzebowali jeszcze kilkunastu artykułów, aby opisać wszystkie jej zastosowania. (Jedyną rzeczą, której programiści nie zrobili dobrze, jak widać w kodzie wydajności poniżej, było stworzenie przyzwoitego logo.)

W artykule opisano strukturę wirtualnej maszyny BPF, interfejsy jądra do pracy z BPF, narzędzia deweloperskie, a także krótki, bardzo zwięzły przegląd istniejących możliwości, tj. wszystko, czego będziemy potrzebować w przyszłości do głębszego zbadania praktycznych zastosowań BPF.
BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Podsumowanie artykułu

Wprowadzenie do architektury BPF. Najpierw przyjrzymy się architekturze BPF z lotu ptaka i zarysujemy główne komponenty.

Rejestry i system poleceń wirtualnej maszyny BPF. Mając już pojęcie o architekturze jako całości, opiszemy strukturę 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 manipulować obiektami z przestrzeni użytkownika za pomocą specjalnego wywołania systemowego − bpf(2).

Пишем программы BPF с помощью libbpf. Oczywiście możesz pisać programy za pomocą wywołania systemowego. Ale to jest trudne. Aby uzyskać bardziej realistyczny scenariusz, programiści nuklearni opracowali bibliotekę libbpf. Stworzymy podstawowy szkielet aplikacji BPF, który wykorzystamy w kolejnych przykładach.

Pomocnicy jądra. Tutaj dowiemy się w jaki sposób programy BPF mogą uzyskać dostęp do funkcji pomocniczych jądra - narzędzia, które wraz z mapami zasadniczo rozszerza możliwości nowego BPF w porównaniu do klasycznego.

Dostęp do map z programów BPF. W tym momencie będziemy już wiedzieć wystarczająco dużo, aby dokładnie zrozumieć, w jaki sposób możemy tworzyć programy korzystające z map. Rzućmy nawet okiem na wielki i potężny weryfikator.

Narzędzia programistyczne. Sekcja pomocy dotycząca montażu wymaganych narzędzi i jądra do eksperymentów.

Wnioski. Na końcu artykułu ci, którzy doczytali aż do tego momentu, znajdą motywujące słowa i krótki opis tego, co będzie się działo w kolejnych artykułach. Dla tych, którzy nie mają ochoty lub możliwości czekania na ciąg dalszy, podamy także szereg linków do samodzielnej nauki.

Wprowadzenie do architektury BPF

Zanim zaczniemy rozważać architekturę BPF, odniesiemy się po raz ostatni (och). klasyczny BPF, który powstał w odpowiedzi na pojawienie się maszyn RISC i rozwiązał problem wydajnego filtrowania pakietów. Architektura okazała się na tyle udana, że ​​narodziła się w szalonych latach dziewięćdziesiątych w Berkeley UNIX, została przeniesiona na większość istniejących systemów operacyjnych, przetrwała do szalonych lat dwudziestych i wciąż znajduje nowe zastosowania.

Nowy BPF powstał jako odpowiedź na wszechobecność maszyn 64-bitowych, usług chmurowych oraz zwiększone zapotrzebowanie na narzędzia do tworzenia SDN (Sczęsto-dzdefiniowany ne-praca). Opracowany przez inżynierów sieci jądra jako ulepszony zamiennik klasycznego BPF, nowy BPF dosłownie sześć miesięcy później 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, aby wymień różne typy programów.

Śmieszne obrazki

W swej istocie BPF jest maszyną wirtualną typu sandbox, która umożliwia uruchamianie „dowolnego” kodu w przestrzeni jądra bez narażania bezpieczeństwa. Programy BPF są tworzone 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 do interfejsu sieciowego, uruchomienie jakiejś funkcji jądra itp. W przypadku pakietu program BPF będzie miał dostęp do danych i metadanych pakietu (do odczytu i ewentualnie zapisu, w zależności od typu programu); w przypadku uruchomienia funkcji jądra argumenty funkcję, w tym wskaźniki do pamięci jądra itp.

Przyjrzyjmy się bliżej temu procesowi. Na początek porozmawiajmy o pierwszej różnicy w stosunku do klasycznego BPF, dla którego programy zostały napisane w asemblerze. W nowej wersji architektura została rozbudowana tak, aby można było pisać programy w językach wysokiego poziomu, przede wszystkim oczywiście w C. W tym celu opracowano backend dla llvm, który umożliwia generowanie kodu bajtowego dla architektury BPF.

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Architektura BPF została zaprojektowana częściowo tak, aby działała wydajnie na nowoczesnych maszynach. Aby to zadziałało w praktyce, kod bajtowy BPF po załadowaniu do jądra jest tłumaczony na kod natywny przy użyciu komponentu zwanego kompilatorem JIT (JUst In Tja ja). Następnie, jeśli pamiętasz, w klasycznym BPF program był ładowany do jądra i atomowo dołączany do źródła zdarzenia - w kontekście pojedynczego wywołania systemowego. W nowej architekturze dzieje się to dwuetapowo – najpierw kod jest ładowany do jądra za pomocą wywołania systemowego bpf(2)a później, za pomocą innych mechanizmów, które różnią się w zależności od typu programu, program łączy się ze źródłem zdarzenia.

Tutaj czytelnik może zadać sobie pytanie: czy było to możliwe? W jaki sposób gwarantuje się bezpieczeństwo wykonania takiego kodu? Bezpieczeństwo wykonania gwarantuje nam etap ładowania programów BPF zwany weryfikatorem (po angielsku ten etap nazywa się weryfikatorem i w dalszym ciągu będę używał angielskiego słowa):

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Weryfikator to analizator statyczny, który dba o to, aby program nie zakłócał normalnego działania jądra. Nie oznacza to zresztą, że program nie może ingerować w pracę systemu - programy BPF w zależności od typu potrafią czytać i przepisywać sekcje pamięci jądra, zwracać wartości funkcji, przycinać, dołączać, przepisywać a nawet przesyłać dalej pakiety sieciowe. Verifier gwarantuje, że uruchomienie programu BPF nie spowoduje awarii jądra i że program, który zgodnie z regułami ma prawo do zapisu np. danych pakietu wychodzącego, nie będzie w stanie nadpisać pamięci jądra poza pakietem. Weryfikatorowi przyjrzymy się bardziej szczegółowo w odpowiedniej sekcji, po zapoznaniu się ze wszystkimi pozostałymi komponentami BPF.

Czego więc nauczyliśmy się do tej pory? Użytkownik pisze program w C, ładuje go do jądra za pomocą wywołania systemowego bpf(2), gdzie jest sprawdzany przez weryfikator i tłumaczony na natywny kod bajtowy. Następnie ten sam lub inny użytkownik podłącza program do źródła zdarzenia i zaczyna on działać. Oddzielenie rozruchu i połączenia jest konieczne z kilku powodów. Po pierwsze, uruchomienie weryfikatora jest stosunkowo drogie, a pobierając kilka razy ten sam program, marnujemy czas komputera. Po drugie, sposób podłączenia programu zależy od jego typu, a jeden „uniwersalny” interfejs opracowany rok temu może nie nadawać się do nowych typów programów. (Chociaż teraz, gdy architektura jest już bardziej dojrzała, pojawił się pomysł ujednolicenia tego interfejsu na poziomie libbpf.)

Uważny czytelnik może zauważyć, że nie skończyliśmy jeszcze zdjęć. Rzeczywiście, wszystko powyższe nie wyjaśnia, dlaczego BPF zasadniczo zmienia obraz w porównaniu z klasycznym BPF. Dwie innowacje, które znacznie poszerzają zakres zastosowań, to możliwość wykorzystania pamięci współdzielonej i funkcji pomocniczych jądra. W BPF pamięć współdzielona realizowana jest za pomocą tzw. map – współdzielonych struktur danych z określonym API. Prawdopodobnie otrzymali tę nazwę, ponieważ pierwszym typem mapy, który się pojawił, była tablica mieszająca. Następnie pojawiły się tablice, lokalne (na procesor) tablice mieszające i tablice lokalne, drzewa wyszukiwania, mapy zawierające wskaźniki do programów BPF i wiele więcej. Interesujące dla nas jest teraz to, że programy BPF mają teraz możliwość utrzymywania stanu pomiędzy wywołaniami i udostępniania go innym programom oraz przestrzeni użytkownika.

Dostęp do Map jest możliwy z procesów użytkownika za pomocą wywołania systemowego bpf(2)oraz z programów BPF działających w jądrze przy użyciu funkcji pomocniczych. Co więcej, pomocnicy 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ą używać funkcji pomocniczych do przekazywania pakietów do innych interfejsów, generowania zdarzeń wydajności, uzyskiwania dostępu do struktur jądra i tak dalej.

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Podsumowując, BPF zapewnia możliwość załadowania dowolnego, tj. przetestowanego przez weryfikatora, kodu użytkownika do przestrzeni jądra. Kod ten może zapisywać stan pomiędzy wywołaniami i wymieniać dane z przestrzenią użytkownika, a także ma dostęp do podsystemów jądra dozwolonych przez tego typu programy.

To już przypomina możliwości, jakie dają moduły jądra, w porównaniu z którymi BPF ma pewne zalety (oczywiście można porównywać tylko podobne aplikacje, np. śledzenie systemu - z BPF nie da się napisać dowolnego sterownika). Można zwrócić uwagę na niższy próg wejścia (niektóre narzędzia korzystające z BPF nie wymagają od użytkownika umiejętności programowania w jądrze, czy w ogóle umiejętności programowania), bezpieczeństwo wykonywania (ręka w górę w komentarzach dla tych, którzy nie zepsuli systemu przy pisaniu lub testowanie modułów), atomowość - przy ponownym ładowaniu modułów występują przestoje, a podsystem BPF dba o to, aby żadne zdarzenia nie zostały pominięte (szczerze mówiąc, nie dotyczy to wszystkich typów programów BPF).

Obecność takich możliwości czyni BPF uniwersalnym narzędziem do rozbudowy jądra, co potwierdza praktyka: do BPF dodawanych jest coraz więcej nowych typów programów, coraz więcej dużych firm korzysta z BPF na serwerach bojowych 24×7, coraz więcej startupy budują swój biznes w oparciu o rozwiązania bazujące na BPF. BPF znajduje zastosowanie wszędzie: w ochronie przed atakami DDoS, tworzeniu SDN (na przykład wdrażając sieci dla kubernetes), jako główne narzędzie do śledzenia systemu i zbierania statystyk, w systemach wykrywania włamań i systemach sandbox itp.

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

Dygresja: narzędzia

Aby móc uruchomić przykłady z poniższych sekcji, możesz potrzebować przynajmniej kilku narzędzi llvm/clang przy wsparciu bpf i bpftool. W sekcji Narzędzia programistyczne Możesz przeczytać instrukcje dotyczące montażu narzędzi, a także swojego jądra. Ta sekcja została umieszczona poniżej, aby nie zakłócać harmonii naszej prezentacji.

Rejestry maszyn wirtualnych BPF i system instrukcji

Architekturę i system poleceń BPF opracowano z uwzględnieniem faktu, że programy będą pisane w języku C, a po załadowaniu do jądra tłumaczone na kod natywny. Dlatego liczbę rejestrów i zestaw poleceń dobrano z myślą o przecięciu, w sensie matematycznym, możliwości współczesnych maszyn. Dodatkowo na programy nałożono różne ograniczenia, np. do niedawna nie można było pisać pętli i podprogramów, a ilość instrukcji ograniczano do 4096 (obecnie programy uprzywilejowane mogą załadować nawet milion instrukcji).

BPF posiada jedenaście dostępnych dla użytkownika rejestrów 64-bitowych r0-r10 i licznik programów. Rejestr r10 zawiera wskaźnik ramki i jest tylko do odczytu. Programy mają w czasie wykonywania dostęp do 512-bajtowego stosu i nieograniczonej ilości pamięci współdzielonej w postaci map.

Programy BPF mogą uruchamiać określony zestaw programów pomocniczych jądra, a ostatnio także zwykłe funkcje. Każda wywoływana funkcja może przyjąć do pięciu argumentów przekazywanych w rejestrach r1-r5, a wartość zwracana jest przekazywana do r0. Gwarantuje się, że po powrocie z funkcji zawartość rejestrów r6-r9 Nie zmieni się.

W celu wydajnego tłumaczenia programów rejestry r0-r11 dla wszystkich obsługiwanych architektur są jednoznacznie mapowane na rzeczywiste rejestry, biorąc pod uwagę cechy ABI bieżącej architektury. Na przykład dla x86_64 rejestruje r1-r5, używane do przekazywania parametrów funkcji, są wyświetlane na rdi, rsi, rdx, rcx, r8, które służą do przekazywania parametrów do funkcji on x86_64. Na przykład kod po lewej stronie przekłada się na kod po prawej stronie w następujący 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

Zarejestrować r0 używany również do zwracania wyniku wykonania programu oraz w rejestrze r1 do programu przekazywany jest wskaźnik do kontekstu - w zależności od typu programu może to być np. struktura struct xdp_md (dla XDP) lub struktura struct __sk_buff (dla różnych programów sieciowych) lub struktury struct pt_regs (dla różnych typów programów śledzących) itp.

Mieliśmy więc zestaw rejestrów, pomocników jądra, stos, wskaźnik kontekstu i pamięć współdzieloną w postaci map. Nie żeby to wszystko było absolutnie konieczne w podróży, ale...

Kontynuujmy opis i porozmawiajmy o systemie poleceń do pracy z tymi obiektami. Wszystko (Prawie wszystko) Instrukcje BPF mają stały rozmiar 64-bitowy. Jeśli spojrzysz na jedną instrukcję na 64-bitowej maszynie Big Endian, zobaczysz

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Tutaj Code - to jest kodowanie instrukcji, Dst/Src są kodowaniem odpowiednio odbiornika i źródła, Off - 16-bitowe wcięcie ze znakiem, oraz Imm jest 32-bitową liczbą całkowitą ze znakiem używaną w niektórych instrukcjach (podobnie jak stała K w cBPF). Kodowanie Code ma jeden z dwóch typów:

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Klasy instrukcji 0, 1, 2, 3 definiują polecenia do pracy z pamięcią. Oni są nazywane, BPF_LD, BPF_LDX, BPF_ST, BPF_STXodpowiednio. Klasy 4, 7 (BPF_ALU, BPF_ALU64) stanowią zbiór instrukcji ALU. Klasy 5, 6 (BPF_JMP, BPF_JMP32) zawierają instrukcje skoku.

Dalszy plan studiowania systemu instrukcji BPF jest następujący: zamiast skrupulatnie wymieniać wszystkie instrukcje i ich parametry, przyjrzymy się kilku przykładom w tej sekcji i stanie się z nich jasne, jak instrukcje faktycznie działają i jak ręcznie zdemontuj dowolny plik binarny dla BPF. Dla utrwalenia materiału w dalszej części artykułu, z indywidualnymi instrukcjami spotkamy się także w działach dotyczących Verifiera, kompilatora JIT, tłumaczenia klasycznego BPF, a także przy studiowaniu map, wywoływaniu funkcji itp.

Kiedy mówimy o poszczególnych instrukcjach, będziemy odnosić się do plików podstawowych bpf.h и bpf_common.h, które definiują kody numeryczne instrukcji BPF. Studiując samodzielnie architekturę i/lub analizując pliki binarne, możesz znaleźć semantykę w następujących źródłach, posortowaną według złożoności: Nieoficjalna specyfikacja eBPF, Przewodnik referencyjny BPF i XDP, zestaw instrukcji, Dokumentacja/sieć/filter.txt i oczywiście w kodzie źródłowym Linuksa - weryfikator, JIT, interpreter BPF.

Przykład: demontaż BPF w głowie

Spójrzmy na przykład, w którym kompilujemy program readelf-example.c i spójrz na wynikowy plik binarny. Ujawnimy oryginalną treść readelf-example.c poniżej, po przywróceniu jego logiki z kodów binarnych:

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

Pierwsza kolumna na wyjściu readelf jest wcięciem i dlatego nasz program składa się z czterech poleceń:

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

Kody poleceń są równe b7, 15, b7 и 95. Przypomnijmy, że trzy najmniej znaczące bity to klasa instrukcji. W naszym przypadku czwarty bit wszystkich instrukcji jest pusty, więc klasy instrukcji to odpowiednio 7, 5, 7, 5. Klasa 7 to BPF_ALU64, a 5 to BPF_JMP. Dla obu klas format instrukcji jest taki sam (patrz wyżej) i możemy przepisać nasz program w ten sposób (jednocześnie przepiszemy pozostałe kolumny w postaci ludzkiej):

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

operacja b klasa ALU64 - jest BPF_MOV. Przypisuje wartość do rejestru docelowego. Jeśli bit jest ustawiony s (źródło), to wartość pobierana jest z rejestru źródłowego, a jeśli tak jak w naszym przypadku nie jest ustawiona, to wartość pobierana jest z pola Imm. Zatem w pierwszej i trzeciej instrukcji wykonujemy operację r0 = Imm. Ponadto działanie JMP klasy 1 jest BPF_JEQ (skok, jeśli jest równy). W naszym przypadku od bitu S wynosi zero, porównuje wartość rejestru źródłowego z polem Imm. Jeśli wartości się pokrywają, następuje przejście do PC + OffGdzie PCjak zwykle zawiera adres kolejnej instrukcji. Wreszcie, operacja JMP klasy 9 jest BPF_EXIT. Ta instrukcja kończy działanie programu i powraca do jądra r0. Dodajmy nową kolumnę do naszej tabeli:

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

Możemy to zapisać w wygodniejszej formie:

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

Jeśli pamiętamy co jest w rejestrze r1 program otrzymuje wskaźnik do kontekstu z jądra i do rejestru r0 wartość jest zwracana do jądra, wtedy widzimy, że jeśli wskaźnik do kontekstu wynosi zero, to zwracamy 1, a w przeciwnym razie - 2. Sprawdźmy, czy mamy rację, patrząc na źródło:

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

Tak, to program bez znaczenia, ale przekłada się to na zaledwie cztery proste instrukcje.

Przykład wyjątku: instrukcja 16-bajtowa

Wspomnieliśmy wcześniej, że niektóre instrukcje zajmują więcej niż 64 bity. Dotyczy to na przykład instrukcji lddw (Kod = 0x18 = BPF_LD | 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 ma 64 bity, więc ładowanie 64-bitowej wartości bezpośredniej do rejestru w jednej instrukcji 64-bitowej nie będzie działać. Aby to zrobić, używane są dwie sąsiadujące ze sobą 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

Spotkamy się ponownie z instrukcjami lddw, kiedy mówimy o relokacjach i pracy z mapami.

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

Nauczyliśmy się więc czytać kody binarne BPF i jesteśmy gotowi, aby w razie potrzeby przeanalizować każdą instrukcję. Warto jednak powiedzieć, że w praktyce wygodniej i szybciej jest deasemblować programy przy użyciu standardowych narzędzi, na przykład:

$ 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

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

(Po raz pierwszy dowiedziałem się o niektórych szczegółach opisanych w tym podrozdziale z post Aleksiej Starowoitow w Blog BPF.)

Obiekty BPF – programy i mapy – tworzone są z przestrzeni użytkownika za pomocą poleceń BPF_PROG_LOAD и BPF_MAP_CREATE wywołanie systemowe bpf(2), omówimy dokładnie, jak to się dzieje w następnej sekcji. Tworzy to struktury danych jądra i dla każdej z nich refcount (liczba odwołań) jest ustawiona na jeden, a deskryptor pliku wskazujący obiekt jest zwracany użytkownikowi. Po zamknięciu uchwytu refcount obiekt zmniejsza się o jeden, a gdy osiągnie zero, obiekt ulega zniszczeniu.

Jeśli program korzysta z map, to refcount mapy te są zwiększane o jeden po załadowaniu programu, tj. ich deskryptory plików można zamknąć z poziomu procesu użytkownika i nadal refcount nie stanie się zerem:

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Po pomyślnym załadowaniu programu zazwyczaj dołączamy go do jakiegoś generatora zdarzeń. Możemy na przykład umieścić go na interfejsie sieciowym, aby przetwarzał przychodzące pakiety lub łączyć się z niektórymi tracepoint w rdzeniu. W tym momencie licznik referencji również wzrośnie o jeden i będziemy mogli zamknąć deskryptor pliku w programie ładującym.

Co się stanie, jeśli teraz zamkniemy bootloader? Zależy to od rodzaju generatora zdarzeń (haka). Wszystkie zaczepy sieciowe będą istnieć po zakończeniu modułu ładującego, są to tak zwane zaczepy globalne. I na przykład programy śledzące zostaną wydane po zakończeniu procesu, który je utworzył (i dlatego nazywane są lokalnymi, od „lokalnego do procesu”). Technicznie rzecz biorąc, lokalne zaczepy zawsze mają odpowiedni deskryptor pliku w przestrzeni użytkownika i dlatego zamykają się po zamknięciu procesu, ale globalne nie. Na poniższym rysunku za pomocą czerwonych krzyżyków próbuję pokazać jak zakończenie programu ładującego wpływa na czas życia obiektów w przypadku hooków lokalnych i globalnych.

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Dlaczego istnieje rozróżnienie między hakami lokalnymi i globalnymi? Uruchamianie niektórych typów programów sieciowych ma sens bez przestrzeni użytkownika, wyobraźmy sobie na przykład ochronę DDoS - program ładujący zapisuje reguły i łączy program BPF z interfejsem sieciowym, po czym program ładujący może się zabić. Z drugiej strony wyobraź sobie program śledzący debugowanie, który napisałeś na kolanach w dziesięć minut - po jego zakończeniu chciałbyś, aby w systemie nie pozostały żadne śmieci, a lokalne hooki o to zadbają.

Z drugiej strony wyobraź sobie, że chcesz połączyć się z punktem śledzenia w jądrze i zbierać statystyki przez wiele lat. W takiej sytuacji warto od czasu do czasu uzupełnić część dotyczącą użytkownika i powrócić do statystyk. System plików bpf zapewnia taką możliwość. Jest to system pseudoplików przechowywany wyłącznie w pamięci, który umożliwia tworzenie plików odwołujących się do obiektów BPF, zwiększając w ten sposób refcount obiekty. Następnie moduł ładujący może wyjść, a utworzone przez niego obiekty pozostaną żywe.

BPF dla najmłodszych, część pierwsza: rozszerzony BPF

Tworzenie plików w bpffs, które odwołują się do obiektów BPF, nazywane jest „przypinaniem” (jak w następującym zdaniu: „proces może przypiąć program lub mapę BPF”). Tworzenie obiektów plikowych dla obiektów BPF ma sens nie tylko ze względu na przedłużenie życia obiektów lokalnych, ale także ze względu na użyteczność obiektów globalnych - wracając do przykładu z globalnym programem ochrony DDoS, chcemy móc przyjechać i popatrzeć na statystyki od czasu do czasu.

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

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

Nazwy systemów plików tworzone są za pomocą polecenia BPF_OBJ_PIN Wywołanie systemowe BPF. Aby to zilustrować, weźmy program, skompilujmy go, prześlijmy i przypnijmy do niego bpffs. Nasz program nie robi nic pożytecznego, prezentujemy jedynie kod, abyś mógł 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 utwó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 pobierzmy nasz program za pomocą narzędzia bpftool i spójrz na towarzyszące wywołania systemowe bpf(2) (niektóre nieistotne linie usunięte z wyjścia strace):

$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0

Tutaj załadowaliśmy program za pomocą BPF_PROG_LOAD, otrzymał deskryptor pliku z jądra 3 i za pomocą polecenia BPF_OBJ_PIN przypiął ten deskryptor pliku jako plik "bpf-mountpoint/test". Następnie program bootloader bpftool zakończył pracę, ale nasz program pozostał w jądrze, choć nie podłączaliś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 normalnie usunąć obiekt pliku unlink(2) a następnie odpowiedni program zostanie usunięty:

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

Usuwanie obiektów

Skoro już mowa o usuwaniu obiektów, warto wyjaśnić, że po odłączeniu programu od hooka (generatora zdarzeń) żadne nowe zdarzenie nie spowoduje jego uruchomienia, natomiast wszystkie bieżące instancje programu zostaną zakończone w normalnej kolejności .

Niektóre typy programów BPF pozwalają na wymianę programu w locie, tj. zapewnić atomowość sekwencji replace = detach old program, attach new program. W takim przypadku wszystkie aktywne instancje starej wersji programu zakończą swoją pracę, a z nowego programu zostaną utworzone nowe procedury obsługi zdarzeń, a „atomowość” oznacza tutaj, że żadne zdarzenie nie zostanie pominięte.

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

W tym artykule nie będziemy osobno opisywać programów łączących ze źródłami zdarzeń, ponieważ warto przestudiować to w kontekście konkretnego rodzaju programu. Cm. przykład poniżej, w którym pokazujemy, jak podłączone są programy takie jak XDP.

Manipulowanie 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, posiadający następujący prototyp:

#include <linux/bpf.h>

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

Oto zespół cmd jest jedną z wartości typu enum bpf_cmd, attr — wskaźnik do parametrów konkretnego programu i size — wielkość obiektu według wskaźnika, tj. zwykle to sizeof(*attr). W jądrze 5.8 wywołanie systemowe bpf obsługuje 34 różne polecenia i określenie union bpf_attr zajmuje 200 linii. Ale nie powinniśmy się tego bać, ponieważ z poleceniami i parametrami będziemy zapoznawać się w kilku artykułach.

Zacznijmy od zespołu BPF_PROG_LOAD, który tworzy programy BPF - pobiera zestaw instrukcji BPF i ładuje go do jądra. W momencie załadowania uruchamiany jest weryfikator, następnie kompilator JIT i po pomyślnym wykonaniu zwracany jest użytkownikowi deskryptor pliku programu. W poprzedniej sekcji widzieliśmy, co się z nim dalej dzieje o cyklu życia obiektów BPF.

Napiszemy teraz niestandardowy program, który załaduje prosty program BPF, ale najpierw musimy zdecydować, jaki rodzaj programu chcemy załadować - będziemy musieli wybrać Typ i w ramach tego typu napisać program, który przejdzie test weryfikatora. Aby jednak nie komplikować procesu, oto gotowe rozwiązanie: weźmiemy taki program BPF_PROG_TYPE_XDP, która zwróci wartość XDP_PASS (pomiń wszystkie pakiety). W asemblerze BPF wygląda to bardzo prosto:

r0 = 2
exit

Po tym jak zdecydowaliśmy się że prześlemy, 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();
}

Ciekawe zdarzenia w programie zaczynają się od definicji tablicy insns - nasz program BPF w kodzie maszynowym. W tym przypadku każda instrukcja programu BPF jest spakowana w strukturę bpf_insn. Pierwszy element insns jest zgodny z instrukcją r0 = 2, druga - exit.

Wycofać się. Jądro definiuje wygodniejsze makra do pisania kodów maszynowych i korzystania z pliku nagłówkowego 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 natywnym jest konieczne tylko do pisania testów w jądrze i artykułów o BPF, brak tych makr tak naprawdę nie komplikuje życia programisty.

Po zdefiniowaniu programu BPF przystępujemy do ładowania go do jądra. Nasz minimalistyczny zestaw parametrów attr zawiera typ programu, zestaw i liczbę instrukcji, wymaganą licencję i nazwę "woo", za pomocą którego po pobraniu odnajdujemy nasz program w systemie. Program zgodnie z obietnicą ładowany jest do systemu za pomocą wywołania systemowego bpf.

Pod koniec programu znajdujemy się w nieskończonej pętli, która symuluje ładunek. Bez tego program zostanie zabity przez jądro, gdy deskryptor pliku, który zwrócił nam wywołanie systemowe, zostanie zamknięty bpf, i nie zobaczymy tego w systemie.

Cóż, jesteśmy gotowi do testów. Złóżmy i uruchommy program w ramach straceaby sprawdzić, czy wszystko działa tak, jak powinno:

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

Wszystko w porządku, bpf(2) zwrócił nam uchwyt 3 i weszliśmy w nieskończoną pętlę pause(). Spróbujmy znaleźć nasz program w systemie. Aby to zrobić, przejdziemy do innego terminala i skorzystamy z 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)

Widzimy, że w systemie jest załadowany program woo którego globalny identyfikator to 390 i jest obecnie w toku simple-prog istnieje otwarty deskryptor pliku wskazujący na program (i if simple-prog w takim razie dokończę robotę woo zniknie). Zgodnie z oczekiwaniami, program woo zajmuje 16 bajtów – dwie instrukcje – kodów binarnych w architekturze BPF, ale w swojej natywnej postaci (x86_64) jest to już 40 bajtów. Spójrzmy na nasz program w jego oryginalnej formie:

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

bez niespodzianek. Przyjrzyjmy się teraz kodowi wygenerowanemu 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 skuteczny dla exit(2), ale szczerze mówiąc, nasz program jest zbyt prosty, a dla nietrywialnych programów potrzebny jest oczywiście prolog i epilog dodany przez kompilator JIT.

Mapy

Programy BPF mogą wykorzystywać obszary pamięci strukturalnej, które są dostępne zarówno dla innych programów BPF, jak i dla programów w przestrzeni użytkownika. Obiekty te nazywane są mapami i w tej sekcji pokażemy, jak manipulować nimi za pomocą wywołania systemowego bpf.

Powiedzmy od razu, że możliwości map nie ograniczają się jedynie do dostępu do pamięci współdzielonej. Istnieją mapy specjalnego przeznaczenia zawierające np. wskaźniki do programów BPF lub wskaźniki do interfejsów sieciowych, mapy do pracy ze zdarzeniami perf itp. Nie będziemy o nich tutaj mówić, aby nie wprowadzać czytelnika w błąd. Poza tym ignorujemy kwestie synchronizacji, ponieważ w naszych przykładach nie jest to istotne. Pełną listę dostępnych typów map można znaleźć w <linux/bpf.h>, a w tej sekcji weźmiemy za przykład pierwszy w historii typ, tablicę mieszającą BPF_MAP_TYPE_HASH.

Jeśli utworzysz tabelę mieszającą, powiedzmy, w C++, powiesz unordered_map<int,long> woo, co po rosyjsku oznacza „Potrzebuję stołu woo nieograniczony rozmiar, którego klucze są typu int, a wartości to typ long" Aby utworzyć tablicę mieszającą BPF, musimy zrobić prawie to samo, z tą różnicą, że musimy określić maksymalny rozmiar tabeli, a zamiast określać typy kluczy i wartości, musimy określić ich rozmiary w bajtach . Aby utworzyć mapy użyj polecenia BPF_MAP_CREATE wywołanie systemowe bpf. Przyjrzyjmy się mniej więcej minimalnemu programowi tworzącemu mapę. Po poprzednim programie ładującym programy BPF, ten powinien wydawać Ci się prosty:

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

Tutaj definiujemy zestaw parametrów attr, w którym mówimy: „Potrzebuję tabeli mieszającej z kluczami i wartościami rozmiaru sizeof(int), w którym mogę umieścić maksymalnie cztery elementy.” Tworząc mapy BPF można określić inne parametry, przykładowo analogicznie jak w przykładzie z programem podaliś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(

Oto wywołanie systemowe bpf(2) zwrócił nam numer mapy deskryptorów 3 po czym program zgodnie z oczekiwaniami oczekuje na dalsze instrukcje w wywołaniu systemowym pause(2).

Teraz wyślijmy nasz program w tło lub otwórzmy inny terminal i spójrzmy na nasz obiekt za pomocą narzędzia bpftool (naszą mapę od innych możemy odróżnić po nazwie):

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

Liczba 114 to globalny identyfikator naszego obiektu. Dowolny program w systemie może użyć tego identyfikatora do otwarcia istniejącej mapy za pomocą polecenia BPF_MAP_GET_FD_BY_ID wywołanie systemowe bpf.

Teraz możemy pobawić się naszą tablicą mieszającą. Przyjrzyjmy się jego zawartości:

$ sudo bpftool map dump id 114
Found 0 elements

Pusty. Nadajmy temu wartość hash[1] = 1:

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

Spójrzmy jeszcze raz na tabelę:

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

Brawo! Udało nam się dodać jeden element. Zauważ, że aby to zrobić, musimy pracować na poziomie bajtu, ponieważ bptftool nie wie jakiego typu są wartości w tablicy mieszającej. (Tę wiedzę można jej przekazać za pomocą BTF, ale o tym teraz.)

Jak dokładnie bpftool czyta 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ę według jej globalnego identyfikatora za pomocą polecenia BPF_MAP_GET_FD_BY_ID и bpf(2) zwrócił nam deskryptor 3. Dalsze korzystanie z polecenia BPF_MAP_GET_NEXT_KEY mijając, znaleźliśmy pierwszy klucz w stole NULL jako wskaźnik do „poprzedniego” klucza. Jeśli mamy klucz, możemy to zrobić BPF_MAP_LOOKUP_ELEMktóra zwraca wartość do wskaźnika value. Następnym krokiem jest znalezienie następnego elementu poprzez przekazanie wskaźnika do bieżącego klucza, ale nasza tabela zawiera tylko jeden element i polecenie BPF_MAP_GET_NEXT_KEY zwroty ENOENT.

OK, zmieńmy wartość o klucz 1, powiedzmy, że nasza logika biznesowa wymaga rejestracji 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

Zgodnie z oczekiwaniami, jest to bardzo proste: polecenie BPF_MAP_GET_FD_BY_ID otwiera naszą mapę według identyfikatora i polecenia BPF_MAP_UPDATE_ELEM nadpisuje element.

Zatem po utworzeniu tablicy skrótów w jednym programie możemy czytać i zapisywać jej zawartość w innym. Zauważ, że jeśli mogliśmy to zrobić z wiersza poleceń, może to zrobić każdy inny program w systemie. Oprócz poleceń opisanych powyżej, do pracy z mapami z przestrzeni użytkownika, następujące:

  • BPF_MAP_LOOKUP_ELEM: znajdź wartość według klucza
  • BPF_MAP_UPDATE_ELEM: aktualizacja/utwórz wartość
  • BPF_MAP_DELETE_ELEM: usuń klucz
  • BPF_MAP_GET_NEXT_KEY: znajdź następny (lub pierwszy) klucz
  • BPF_MAP_GET_NEXT_ID: umożliwia przeglądanie wszystkich istniejących map, tak to działa bpftool map
  • BPF_MAP_GET_FD_BY_ID: otwórz istniejącą mapę według jej globalnego identyfikatora
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: atomowo zaktualizuj wartość obiektu i zwróć starą
  • BPF_MAP_FREEZE: uczyń 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 zresetowanie wszystkich wartości z mapy

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

Dla porządku zakończmy nasze eksperymenty z tablicą mieszającą. Pamiętasz, że stworzyliśmy tabelę, która może zawierać aż cztery klucze? 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

Jak na razie dobrze:

$ 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

Spróbujmy dodać jeszcze jedno:

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

Tak jak się spodziewaliśmy, nie udało nam się. Przyjrzyjmy się bliżej błędowi:

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

Wszystko jest w porządku: zgodnie z oczekiwaniami, zespół BPF_MAP_UPDATE_ELEM próbuje utworzyć nowy, piąty klucz, ale ulega awarii E2BIG.

Możemy więc tworzyć i ładować programy BPF, a także tworzyć i zarządzać mapami z przestrzeni użytkownika. Teraz logiczne jest, aby przyjrzeć się, jak możemy wykorzystać mapy z samych programów BPF. Moglibyśmy o tym mówić w języku trudnych do odczytania programów w kodach makr maszynowych, ale tak naprawdę nadszedł czas, aby pokazać, jak faktycznie pisze się i utrzymuje programy BPF - przy użyciu libbpf.

(Dla czytelników niezadowolonych z braku niskopoziomowego przykładu: szczegółowo przeanalizujemy programy wykorzystujące mapy i funkcje pomocnicze utworzone przy użyciu libbpf i powiedzieć, co dzieje się na poziomie instrukcji. Dla czytelników niezadowolonych bardzo, Dodaliśmy przykład w odpowiednim miejscu artykułu.)

Pisanie programów w BPF przy użyciu libbpf

Pisanie programów BPF przy użyciu kodów maszynowych może być interesujące tylko za pierwszym razem, a potem pojawia się uczucie sytości. W tym momencie musisz zwrócić swoją uwagę llvm, który posiada backend do generowania kodu dla architektury BPF, a także bibliotekę libbpf, który pozwala na pisanie strony użytkownika aplikacji BPF i ładowanie kodu programów BPF wygenerowanych przy użyciu llvm/clang.

W rzeczywistości, jak zobaczymy w tym i kolejnych artykułach, libbpf wykonuje bez niego sporo pracy (lub podobnych narzędzi - iproute2, libbcc, libbpf-goitp.) nie da się żyć. Jedna z zabójczych cech projektu libbpf to BPF CO-RE (Compile Once, Run Everywhere) - projekt pozwalający na pisanie programów BPF, które są przenośne z jednego jądra na drugie, z możliwością uruchamiania na różnych API (na przykład przy zmianie struktury jądra z wersji do wersji). Aby móc pracować z CO-RE, Twoje jądro musi być skompilowane z obsługą BTF (opisujemy jak to zrobić w dziale Narzędzia programistyczne. Możesz sprawdzić, czy twoje jądro jest zbudowane z BTF, czy nie, w bardzo prosty sposób - poprzez obecność 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 użycia libbpf. Porozmawiamy szczegółowo o CO-RE w następnym artykule, ale w tym - po prostu zbuduj sobie jądro CONFIG_DEBUG_INFO_BTF.

biblioteka libbpf mieszka bezpośrednio w katalogu tools/lib/bpf jądro i jego rozwój odbywa się poprzez listę mailingową [email protected]. Jednak na potrzeby aplikacji żyjących poza jądrem utrzymywane jest osobne repozytorium https://github.com/libbpf/libbpf w którym biblioteka jądra jest dublowana w celu umożliwienia dostępu do odczytu mniej więcej tak, jak jest.

W tej sekcji przyjrzymy się, jak stworzyć projekt wykorzystujący libbpf, napiszmy kilka (mniej lub bardziej bezsensownych) programów testowych i szczegółowo przeanalizujmy, jak to wszystko działa. Pozwoli nam to łatwiej wyjaśnić w kolejnych sekcjach dokładnie, w jaki sposób programy BPF współdziałają z mapami, pomocnikami jądra, BTF itp.

Zwykle projekty używają libbpf dodaj repozytorium GitHub jako podmoduł git, zrobimy to samo:

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

Zamierzam libbpf bardzo proste:

$ 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 następny plan w tej sekcji jest następujący: napiszemy program BPF podobny do BPF_PROG_TYPE_XDP, tak samo jak w poprzednim przykładzie, ale w C kompilujemy go za pomocą clangi napisz program pomocniczy, który załaduje go do jądra. W kolejnych rozdziałach będziemy poszerzać możliwości zarówno programu BPF, jak i programu asystenta.

Przykład: tworzenie pełnoprawnej aplikacji przy użyciu libbpf

Na początek używamy pliku /sys/kernel/btf/vmlinux, o którym mowa powyżej, i utwórz jego odpowiednik w postaci pliku nagłówkowego:

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

W tym pliku będą przechowywane wszystkie struktury danych dostępne w naszym jądrze, przykładowo tak jest zdefiniowany w jądrze 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 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";

Choć nasz program okazał się bardzo prosty, wciąż musimy 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. Z biblioteki przychodzi do nas następujący plik nagłówkowy libbpf. Teraz potrzebujemy go jedynie do zdefiniowania makra SEC, który wysyła znak do odpowiedniej sekcji pliku obiektowego ELF. Nasz program znajduje się w dziale xdp/simple, gdzie przed ukośnikiem definiujemy typ programu BPF – taka jest konwencja stosowana w libbpf, w oparciu o nazwę sekcji, przy uruchomieniu zastąpi prawidłowy typ bpf(2). Sam program BPF taki jest C - bardzo prosty i składa się z jednej linii return XDP_PASS. Wreszcie osobna sekcja "license" zawiera nazwę licencji.

Możemy skompilować nasz program przy użyciu llvm/clang w wersji >= 10.0.0 lub jeszcze lepszej, wyższej (patrz sekcja Narzędzia programistyczne):

$ 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

Wśród ciekawych funkcji: wskazujemy docelową architekturę -target bpf i ścieżka do nagłówków libbpf, który niedawno zainstalowaliśmy. Nie zapomnij także o -O2, bez tej opcji w przyszłości mogą Cię spotkać niespodzianki. Przyjrzyjmy się naszemu kodowi, czy udało nam się napisać taki program, jaki 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 <simple>:
       0:       r0 = 2
       1:       exit

Tak, zadziałało! Mamy teraz plik binarny z programem i chcemy stworzyć aplikację, która załaduje go do jądra. W tym celu biblioteka libbpf oferuje nam dwie możliwości - skorzystaj z API niższego poziomu lub API wyższego poziomu. Pójdziemy drugą drogą, ponieważ chcemy nauczyć się pisać, ładować i łączyć programy BPF przy minimalnym wysiłku w celu ich późniejszego studiowania.

Najpierw musimy wygenerować „szkielet” naszego programu z jego pliku binarnego, używając tego samego narzędzia bpftool — szwajcarski nóż świata BPF (co można brać dosłownie, gdyż Daniel Borkman, jeden z twórców i opiekunów BPF, jest 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 zarządzania - ładowania, dołączania, usuwania naszego obiektu. W naszym prostym przypadku wygląda to na przesadę, ale działa również w przypadku, gdy plik obiektowy zawiera wiele programów i map BPF i aby załadować ten gigantyczny ELF, wystarczy wygenerować szkielet i wywołać jedną lub dwie funkcje z niestandardowej aplikacji, którą piszą. Przejdźmy teraz dalej.

Ściśle mówiąc, nasz program ładujący 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 zdefiniowane 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;
};

Tutaj możemy zobaczyć ślady API niskiego poziomu: struktura struct bpf_program *simple и struct bpf_link *simple. Pierwsza struktura szczegółowo opisuje nasz program, napisany w tej sekcji xdp/simple, a drugi opisuje sposób, w jaki program łączy się ze źródłem zdarzenia.

Funkcja xdp_simple_bpf__open_and_load, otwiera obiekt ELF, analizuje go, tworzy wszystkie struktury i podstruktury (oprócz programu, ELF zawiera także inne sekcje - dane, dane tylko do odczytu, informacje o debugowaniu, licencję itp.), a następnie ładuje go do jądra za pomocą systemu dzwonić 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

Przyjrzyjmy się teraz naszemu programowi używającemu bpftool. Znajdźmy jej identyfikator:

# 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 dump (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 C. Zrobiła to biblioteka libbpf, który znalazł sekcję debugowania w pliku binarnym, skompilował ją do obiektu BTF, załadował do jądra za pomocą BPF_BTF_LOAD, a następnie określił wynikowy deskryptor pliku podczas ładowania programu za pomocą polecenia BPG_PROG_LOAD.

Pomocnicy jądra

Programy BPF mogą uruchamiać funkcje „zewnętrzne” – pomocników jądra. Te funkcje pomocnicze umożliwiają programom BPF dostęp do struktur jądra, zarządzanie mapami, a także komunikację z „prawdziwym światem” - tworzenie zdarzeń wydajności, kontrolowanie sprzętu (na przykład przekierowywanie pakietów) itp.

Przykład: bpf_get_smp_processor_id

W ramach paradygmatu „uczenia się przez przykład” rozważmy jedną z funkcji pomocniczych, bpf_get_smp_processor_id(), pewien w pliku kernel/bpf/helpers.c. Zwraca numer procesora, na którym działa program BPF, który go wywołał. Ale nie jesteśmy tak zainteresowani jego semantyką, jak tym, że jego realizacja przebiega w jednym kierunku:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Definicje funkcji pomocniczych BPF są podobne do definicji wywołań systemowych w systemie Linux. Tutaj na przykład zdefiniowano funkcję, która nie ma argumentów. (Funkcja, która przyjmuje, powiedzmy, trzy argumenty, jest definiowana za pomocą makra BPF_CALL_3. Maksymalna liczba argumentów wynosi pięć.) Jest to jednak tylko pierwsza część definicji. Druga część polega na zdefiniowaniu struktury typów struct bpf_func_proto, który zawiera opis funkcji pomocniczej zrozumiałej 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,
};

Rejestrowanie funkcji pomocniczych

Aby programy BPF danego typu mogły skorzystać z tej funkcji muszą ją zarejestrować np. dla typu BPF_PROG_TYPE_XDP funkcja jest zdefiniowana w jądrze xdp_func_proto, który na podstawie identyfikatora funkcji pomocniczej określa, czy XDP obsługuje tę funkcję, czy nie. Naszą funkcją jest obsługuje:

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. Zdefiniowane w cudzysłowie, ponieważ jest to definicja logiczna, a w terminologii języka C definicja całego zestawu konstrukcji betonowych występuje w innych miejscach. W szczególności w pliku kernel/bpf/verifier.c wszystkie definicje z pliku bpf_types.h służą do tworzenia szeregu 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 <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

Oznacza to, że dla każdego typu programu BPF zdefiniowany jest wskaźnik do struktury danych tego typu struct bpf_verifier_ops, który jest inicjowany wartością _name ## _verifier_ops, tj., xdp_verifier_ops dla xdp. Struktura xdp_verifier_ops określone przez 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óry uruchomi weryfikator za każdym razem, gdy napotka wyzwanie Niektóre funkcje wewnątrz programu BPF, patrz verifier.c.

Przyjrzyjmy się, jak hipotetyczny program BPF wykorzystuje tę funkcję bpf_get_smp_processor_id. W tym celu przepisujemy program z 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 określone przez в <bpf/bpf_helper_defs.h> Biblioteka libbpf jak

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

to jest, bpf_get_smp_processor_id jest wskaźnikiem funkcji, którego wartość wynosi 8, gdzie 8 jest wartością BPF_FUNC_get_smp_processor_id typ enum bpf_fun_id, który jest dla nas zdefiniowany w pliku vmlinux.h (plik bpf_helper_defs.h w jądrze jest generowane przez skrypt, więc „magiczne” liczby są w porządku). Funkcja ta nie przyjmuje żadnych argumentów i zwraca wartość typu __u32. Kiedy uruchomimy to w naszym programie, clang generuje instrukcję BPF_CALL „właściwy rodzaj” Skompilujmy program i spójrzmy na 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:       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

W pierwszej linii widzimy instrukcje call, parametr IMM co jest równe 8, i SRC_REG - zero. Zgodnie z umową ABI stosowaną przez weryfikatora jest to wywołanie funkcji pomocniczej numer osiem. Po uruchomieniu logika jest prosta. Zwróć wartość z rejestru r0 skopiowane do r1 a w liniach 2,3 jest konwertowany na typ u32 — górne 32 bity zostaną wyczyszczone. W liniach 4,5,6,7 zwracamy 2 (XDP_PASS) lub 1 (XDP_DROP) w zależności od tego, czy funkcja pomocnicza z linii 0 zwróciła wartość zerową czy niezerową.

Przetestujmy się: załaduj program i spójrz na 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
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

OK, weryfikator znalazł poprawnego pomocnika jądra.

Przykład: przekazanie argumentów i ostateczne uruchomienie programu!

Wszystkie funkcje pomocnicze na poziomie uruchamiania mają prototyp

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

Parametry funkcji pomocniczych przekazywane są w rejestrach r1-r5, a wartość jest zwracana w rejestrze r0. Nie ma funkcji, które przyjmują więcej niż pięć argumentów i nie oczekuje się, że w przyszłości zostanie dodana obsługa ich.

Przyjrzyjmy się nowemu pomocnikowi jądra i sposobowi przekazywania parametrów przez BPF. Przepiszmy xdp-simple.bpf.c w następujący sposób (pozostałe wiersze 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 wypisuje numer procesora, na którym jest uruchomiony. Skompilujmy to i spójrzmy na kod:

$ 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

W liniach 0-7 zapisujemy ciąg znaków running on CPU%un, a następnie w linii 8 uruchamiamy znajomy bpf_get_smp_processor_id. W liniach 9-12 przygotowujemy argumenty pomocnicze bpf_printk - rejestruje r1, r2, r3. Dlaczego jest ich trzech, a nie dwóch? Ponieważ bpf_printkto jest opakowanie makr wokół prawdziwego pomocnika bpf_trace_printk, który musi przekazać rozmiar ciągu formatującego.

Dodajmy teraz kilka linii do xdp-simple.caby nasz program połączył się z interfejsem lo i naprawdę się zaczęło!

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

Tutaj używamy funkcji bpf_set_link_xdp_fd, który łączy programy BPF typu XDP z interfejsami sieciowymi. Zakodowaliśmy na stałe numer interfejsu lo, czyli zawsze 1. Uruchamiamy funkcję dwukrotnie, aby najpierw odłączyć stary program, jeśli był dołączony. Zauważ, że teraz nie potrzebujemy wyzwań pause lub nieskończona pętla: nasz program ładujący zakończy działanie, ale program BPF nie zostanie zabity, ponieważ jest podłączony do źródła zdarzenia. Po pomyślnym pobraniu i połączeniu program zostanie uruchomiony dla każdego przychodzącego pakietu sieciowego lo.

Pobierzmy program i spójrzmy na interfejs 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

Program, który pobraliśmy ma ID 669 i ten sam ID widzimy na interfejsie lo. Wyślemy kilka paczek do 127.0.0.1 (prośba + odpowiedź):

$ ping -c1 localhost

a teraz spójrzmy na zawartość wirtualnego pliku debugowania /sys/kernel/debug/tracing/trace_pipe, w którym bpf_printk pisze swoje wiadomości:

# 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

Zauważono dwie paczki lo i przetwarzane na CPU0 - nasz pierwszy pełnoprawny, pozbawiony sensu program BPF zadziałał!

Warto to zauważyć bpf_printk Nie bez powodu zapisuje plik debugowania: nie jest to najskuteczniejszy pomocnik do wykorzystania w produkcji, ale naszym celem było pokazanie czegoś prostego.

Dostęp do map z programów BPF

Przykład: użycie mapy z programu BPF

W poprzednich sekcjach dowiedzieliśmy się, jak tworzyć i używać map z przestrzeni użytkownika, a teraz spójrzmy na część 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 8-elementowa tablica przechowująca wartości takie jak u64 (w C zdefiniowalibyśmy taką tablicę jak u64 woo[8]). W programie "xdp/simple" pobieramy bieżący numer procesora do zmiennej key a następnie użyj funkcji pomocniczej bpf_map_lookup_element otrzymujemy wskaźnik do odpowiedniego wpisu w tablicy, który zwiększamy o jeden. W tłumaczeniu na rosyjski: obliczamy statystyki, które procesory przetworzyły przychodzące pakiety. Spróbujmy 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 jest podłączona lo i wyślij kilka pakietów:

$ 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

Przyjrzyjmy się teraz 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 przetwarzane na CPU7. Nie jest to dla nas ważne, najważniejsze, że program działa i rozumiemy, jak uzyskać dostęp do map z programów BPF - za pomocą хелперов bpf_mp_*.

Indeks mistyczny

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

val = bpf_map_lookup_elem(&woo, &key);

gdzie wygląda funkcja pomocnicza

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

ale przekazujemy wskaźnik &woo do nienazwanej struktury struct { ... }...

Jeśli spojrzymy na asembler programu, zobaczymy, że wartość &woo nie jest faktycznie zdefiniowany (linia 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
...

i zawiera się w relokacjach:

$ 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

Jeśli jednak spojrzymy na już załadowany program, zobaczymy wskaźnik do właściwej mapy (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]
...

Możemy zatem stwierdzić, że w momencie uruchomienia naszego programu ładującego link do &woo został zastąpiony czymś z biblioteką libbpf. Najpierw przyjrzymy się wynikom 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 to libbpf stworzył mapę woo a następnie pobrałem nasz program simple. Przyjrzyjmy się bliżej sposobowi ładowania programu:

  • dzwonić xdp_simple_bpf__open_and_load z pliku xdp-simple.skel.h
  • co powoduje xdp_simple_bpf__load z pliku xdp-simple.skel.h
  • co powoduje bpf_object__load_skeleton z pliku libbpf/src/libbpf.c
  • co powoduje bpf_object__load_xattr z libbpf/src/libbpf.c

Ostatnią funkcją będzie między innymi wywołanie bpf_object__create_maps, który tworzy lub otwiera istniejące mapy, zamieniając je w deskryptory plików. (Tutaj widzimy BPF_MAP_CREATE na wyjściu strace.) Następnie wywoływana jest funkcja bpf_object__relocate i to ona nas interesuje, bo pamiętamy to, co widzieliśmy woo w tabeli relokacji. Badając to, w końcu znajdziemy się w tej funkcji bpf_program__relocate, który i zajmuje się przenoszeniem map:

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

Zatem stosujemy się do naszych wskazówek

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

i zamień w nim rejestr źródłowy na BPF_PSEUDO_MAP_FD, a pierwszy IMM do deskryptora pliku naszej mapy i jeśli jest równy np. 0xdeadbeef, w rezultacie otrzymamy instrukcję

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

W ten sposób informacje mapowe są przesyłane do konkretnego załadowanego programu BPF. W takim przypadku mapę można utworzyć za pomocą BPF_MAP_CREATEi otwierane według identyfikatora za pomocą BPF_MAP_GET_FD_BY_ID.

Razem, podczas używania libbpf algorytm jest następujący:

  • podczas kompilacji w tabeli relokacji tworzone są rekordy dla linków do map
  • libbpf otwiera księgę obiektów ELF, wyszukuje wszystkie używane mapy i tworzy dla nich deskryptory plików
  • deskryptory plików są ładowane do jądra jako część instrukcji LD64

Jak możesz sobie wyobrazić, jeszcze wiele przed nami i będziemy musieli zajrzeć do rdzenia. Na szczęście mamy wskazówkę – spisaliśmy znaczenie BPF_PSEUDO_MAP_FD do księgi źródłowej i możemy ją zakopać, co doprowadzi nas do miejsca najświętszego wszystkich świętych - kernel/bpf/verifier.c, gdzie funkcja o charakterystycznej nazwie zastępuje deskryptor pliku adresem 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źć по ссылке). Możemy więc rozszerzyć nasz algorytm:

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

Podczas pobierania pliku binarnego ELF za pomocą libbpf Dzieje się dużo więcej, ale o tym porozmawiamy w innych artykułach.

Ładowanie programów i map bez libbpf

Zgodnie z obietnicą, oto przykład dla czytelników, którzy chcą wiedzieć, jak samodzielnie utworzyć i załadować program korzystający z map libbpf. Może to być przydatne, gdy pracujesz w środowisku, dla którego nie możesz tworzyć zależności, zapisywać każdego bitu lub pisać programu takiego jak ply, który na bieżąco generuje kod binarny BPF.

Aby ułatwić śledzenie logiki, przepiszemy nasz przykład w tych celach xdp-simple. Kompletny i nieco rozszerzony kod programu omawianego w tym przykładzie można znaleźć w tym miejscu istota.

Logika naszej aplikacji jest następująca:

  • utwórz mapę typów BPF_MAP_TYPE_ARRAY za pomocą polecenia BPF_MAP_CREATE,
  • utwórz program korzystający z tej mapy,
  • podłącz program do interfejsu lo,

co przekłada się na człowieka 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ę w taki sam sposób, jak zrobiliśmy to w pierwszym przykładzie dotyczącym wywołania systemowego bpf - „jądro, proszę o wykonanie nowej mapy w postaci tablicy 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 jest również łatwy do załadowania:

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 jako tablicy struktur struct bpf_insn insns[]. Ale ponieważ używamy programu, który mamy w C, możemy trochę oszukać:

$ 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

W sumie musimy napisać 14 instrukcji w postaci struktur typu struct bpf_insn (rada: weź zrzut z góry, przeczytaj ponownie sekcję z instrukcjami, otwórz linux/bpf.h и linux/bpf_common.h i spróbuj ustalić struct bpf_insn insns[] na własną rękę):

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

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

W naszym programie pozostała jeszcze jedna nieujawniona część - xdp_attach. Niestety programów takich jak XDP nie można połączyć za pomocą wywołania systemowego bpf. Ludzie, którzy stworzyli BPF i XDP, pochodzili z internetowej społeczności Linuksa, co oznacza, że ​​używali tego, który im najbardziej znany (ale nie normalne People) interfejs do interakcji z jądrem: gniazda sieciowe, Zobacz też RFC3549. Najprostszy sposób wdrożenia xdp_attach kopiuje kod z libbpf, czyli z pliku netlink.c, co właśnie zrobiliśmy, trochę go skracając:

Witamy w świecie gniazd Netlink

Otwórz typ gniazda netlink NETLINK_ROUTE:

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

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

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

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

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

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

    *nl_pid = sa.nl_pid;
    return sock;
}

Czytamy z tego 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 < 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;
}

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

Wszystko jest więc 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 +++

Zobaczmy, czy nasz program się połączył 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

Wyślijmy pingi i spójrzmy na 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
Found 8 elements

Hurra, wszystko działa. Nawiasem mówiąc, nasza mapa jest ponownie wyświetlana w postaci bajtów. Dzieje się tak dlatego, że w odróżnieniu od libbpf nie załadowaliśmy informacji o typie (BTF). Ale o tym porozmawiamy więcej następnym razem.

Narzędzia programistyczne

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

Ogólnie rzecz biorąc, do tworzenia programów BPF nie potrzeba niczego specjalnego - BPF działa na dowolnym jądrze przyzwoitej dystrybucji, a programy są budowane przy użyciu clang, które można dostarczyć z opakowania. Jednak ze względu na to, że BPF jest w fazie rozwoju, jądro i narzędzia ciągle się zmieniają, jeśli nie chcesz pisać programów BPF przestarzałymi metodami z 2019 roku, będziesz musiał skompilować

  • llvm/clang
  • pahole
  • jego rdzeń
  • bpftool

(Dla porównania, ta sekcja i wszystkie przykłady w artykule zostały uruchomione w Debianie 10.)

llvm/brzęk

BPF jest przyjazny dla LLVM i chociaż ostatnio programy dla BPF można kompilować przy użyciu gcc, cały bieżący rozwój jest wykonywany dla LLVM. Dlatego w pierwszej kolejności zbudujemy obecną wersję clang z gita:

$ 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
... много времени спустя
$

Teraz możemy sprawdzić czy wszystko zostało poprawnie połączone:

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

(Instrukcje składania clang zabrane przeze mnie bpf_devel_QA.)

Nie będziemy instalować programów, które właśnie zbudowaliśmy, a zamiast tego po prostu je dodamy PATHna 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, to robię . activate-llvm.sh.)

Pahole i BTF

Użyteczność pahole używany podczas budowania jądra do tworzenia informacji debugowania w formacie BTF. Nie będziemy w tym artykule szczegółowo omawiać szczegółów technologii BTF, poza tym, że jest ona wygodna i chcemy z niej korzystać. Jeśli więc zamierzasz zbudować jądro, najpierw zbuduj pahole (bez pahole za pomocą tej opcji nie będziesz mógł zbudować jądra 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 eksperymentowania z BPF

Badając możliwości BPF, chcę złożyć własny rdzeń. To w sumie nie jest konieczne, gdyż będziesz mógł kompilować i ładować programy BPF na jądro dystrybucyjne, jednak posiadanie własnego jądra pozwala na korzystanie z najnowszych funkcjonalności BPF, które pojawią się w Twojej dystrybucji najwyżej za kilka miesięcy lub, jak w przypadku niektórych narzędzi do debugowania, w dającej się przewidzieć przyszłości w ogóle nie zostaną spakowane. Ponadto jego własny rdzeń sprawia, że ​​eksperymentowanie z kodem wydaje się ważne.

Do zbudowania jądra potrzebne jest po pierwsze samo jądro, a po drugie plik konfiguracyjny jądra. Aby poeksperymentować z BPF, możemy użyć zwykłego wanilia jądro lub jedno z jąder deweloperskich. Historycznie rzecz biorąc, rozwój BPF odbywa się w społeczności sieciowej Linuksa i dlatego wszystkie zmiany prędzej czy później przechodzą przez Davida Millera, opiekuna sieci Linuksa. W zależności od ich charakteru – edycji lub nowych funkcji – zmiany sieciowe dzielą się na jeden z dwóch rdzeni – net lub net-next. Zmiany dla BPF są rozdzielane w ten sam sposób pomiędzy bpf и bpf-next, które są następnie łączone odpowiednio w klasach net i net-next. Więcej szczegółów znajdziesz w bpf_devel_QA и Netdev — często zadawane pytania. Wybierz więc jądro w oparciu o swój gust i potrzeby stabilności systemu, na którym testujesz (*-next jądra są najbardziej niestabilne z wymienionych).

Omówienie sposobu zarządzania plikami konfiguracyjnymi jądra wykracza poza zakres tego artykułu - zakłada się, że albo już wiesz, jak to zrobić, albo gotowy do nauki na własną rękę. Jednakże poniższe instrukcje powinny w zupełności wystarczyć, aby otrzymać działający system obsługujący BPF.

Pobierz jedno z powyższych jąder:

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

Zbuduj minimalną działającą konfigurację jądra:

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

Włącz opcje BPF w pliku .config według własnego wyboru (najprawdopodobniej CONFIG_BPF będzie już włączony, ponieważ system go używa). Oto lista opcji jądra wykorzystanych 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

Wtedy możemy już łatwo złożyć i zainstalować moduły oraz jądro (swoją drogą można złożyć jądro za pomocą nowo zmontowanego clangpoprzez dodanie CC=clang):

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

i uruchom ponownie komputer z nowym jądrem (używam do tego kexec z pakietu 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

Najczęściej używanym narzędziem w tym artykule będzie narzędzie bpftool, dostarczany jako część jądra Linuksa. Jest napisany i utrzymywany przez programistów BPF dla programistów BPF i może być używany do zarządzania wszystkimi typami obiektów BPF - ładowania programów, tworzenia i edytowania map, odkrywania życia ekosystemu BPF itp. Można znaleźć dokumentację w postaci kodów źródłowych stron podręcznika w rdzeniu lub już skompilowany, sieć.

W momencie pisania tego tekstu bpftool jest gotowy tylko dla RHEL, Fedory i Ubuntu (zobacz na przykład ten wątek, która opowiada niedokończoną historię opakowań bpftool w Debianie). Ale jeśli już zbudowałeś jądro, zbuduj bpftool bułka z masłem:

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

$

(Tutaj ${linux} - to jest katalog twojego jądra.) Po wykonaniu tych poleceń bpftool zostaną zebrane w katalogu ${linux}/tools/bpf/bpftool i można go dodać do ścieżki (przede wszystkim do pliku user root) lub po prostu skopiuj do /usr/local/sbin.

zbierać bpftool najlepiej używać tego drugiego clang, zmontuj jak opisano powyżej i sprawdź czy jest poprawnie zmontowany - za pomocą np. polecenia

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

który pokaże, które funkcje BPF są włączone w twoim jądrze.

Nawiasem mówiąc, poprzednie polecenie można uruchomić jako

# bpftool f p k

Odbywa się to analogicznie do narzędzi z pakietu iproute2, gdzie możemy na przykład powiedzieć ip a s eth0 zamiast ip addr show dev eth0.

wniosek

BPF pozwala na podbicie pchły, aby skutecznie zmierzyć i na bieżąco zmieniać funkcjonalność rdzenia. System okazał się bardzo udany, nawiązując do najlepszych tradycji UNIX-a: prosty mechanizm pozwalający na (prze)programowanie jądra pozwolił na eksperymenty ogromnej liczbie osób i organizacji. I choć eksperymenty, a także rozwój samej infrastruktury BPF są jeszcze dalekie od zakończenia, system posiada już stabilne ABI, które pozwala budować niezawodną, ​​a co najważniejsze efektywną logikę biznesową.

Chciałbym zauważyć, że moim zdaniem technologia ta stała się tak popularna, ponieważ z jednej strony może grać (architekturę maszyny można zrozumieć mniej więcej w jeden wieczór), a z drugiej strony rozwiązywać problemy, których nie dało się (pięknie) rozwiązać przed jej pojawieniem się. Te dwa elementy razem zmuszają ludzi do eksperymentowania i marzeń, co prowadzi do powstawania coraz bardziej innowacyjnych rozwiązań.

Artykuł ten, choć nie jest szczególnie krótki, stanowi jedynie wprowadzenie do świata BPF i nie opisuje „zaawansowanych” funkcji 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 obsługiwanych jest 30 typów programów), następnie w końcu przyjrzymy się, jak pisać prawdziwe aplikacje BPF przy użyciu programów śledzących jądro jako przykład, czas na bardziej szczegółowy kurs na temat architektury BPF, a następnie przykłady sieci BPF i aplikacji zabezpieczających.

Poprzednie artykuły z tej serii

  1. BPF dla najmłodszych, część zero: klasyczny BPF

Spinki do mankietów

  1. Przewodnik referencyjny BPF i XDP — dokumentacja dotycząca BPF z rzęski, a dokładniej od Daniela Borkmana, jednego z twórców i opiekunów BPF. To jeden z pierwszych poważnych opisów, który różni się od pozostałych tym, że Daniel dokładnie wie o czym pisze i nie ma w nim błędów. W szczególności w tym dokumencie opisano sposób pracy z programami BPF typu XDP i TC przy użyciu dobrze znanego narzędzia ip z pakietu iproute2.

  2. Dokumentacja/sieć/filter.txt — oryginalny plik z dokumentacją dla wersji klasycznej i później rozszerzonej BPF. Dobra lektura, jeśli chcesz zagłębić się w język asemblera i techniczne szczegóły architektoniczne.

  3. Blog o BPF na Facebooku. Jest aktualizowany rzadko, ale trafnie, jak piszą tam Aleksiej Starovoitov (autor eBPF) i Andrii Nakryiko - (opiekun) libbpf).

  4. Sekrety bpftoola. Zabawny wątek na Twitterze Quentina Monneta z przykładami i sekretami korzystania z bpftool.

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

Źródło: www.habr.com

Dodaj komentarz