Artykuł pochodzi z wydania: Styczeń 2024
Diagnozowanie problemów w systemach operacyjnych zmusza ich twórców do opracowywania odpowiednich mechanizmów, które są w stanie temu podołać. Nie jest to zadanie proste, bo chodzi tu o zachowanie wydajności.
Dobre narzędzie diagnostyczne można poznać po tym, że możemy je wykorzystać bez obaw o pogorszenie sytuacji w trakcie występowania problemu. Oznacza to, że narzędzie musi być niezwykle wydajne, wręcz niezauważalne dla wydajności systemu.
Nie jest to nowe zagadnienie – w systemie Solaris opracowano takie rozwiązanie już w roku 2005 i do dzisiaj jest ono uznawane za synonim profesjonalnego narzędzia do optymalizacji systemu i jego programów. Niemal wszyscy administratorzy chcieli mieć takie narzędzie w swoim systemie, dlatego w 2009 r. powstał port DTrace na FreeBSD, a w 2011 r. na Linuksa, w którym nie jest to rozwiązanie natywne. Trwają nawet prace nad portem dla systemu Windows. Mimo to w 2018 r. powstało narzędzie bpftrace, określane mianem DTrace 2.0 dla systemu Linux. W tym artykule postaramy się je przybliżyć.
> HISTORIA BPF
Główny problem z narzędziami sięgającymi po dane do wewnątrz systemu operacyjnego polega na kopiowaniu tych danych pomiędzy przestrzeniami jądra i użytkownika. Z takim właśnie problemem zetknęli się deweloperzy systemu BSD, tworząc ogólnie znane narzędzie tcpdump, które pozwala stosować filtry do pakietów sieciowych. Są dwie możliwości rozwiązania tej kwestii. Pierwszy to kopiowanie wszystkich pakietów z jądra do przestrzeni użytkownika, a następnie filtrowanie ich. Drugi – właściwy – to wykonywanie filtrowania po stronie jądra. Tylko jak przekazywać złożone filtry od użytkownika do przestrzeni jądra i w bezpieczny sposób je wykonywać?
W 1993 r. na zimowej konferencji USENIX deweloperzy systemu BSD zaprezentowali efekt dwuletniej pracy nad rozwiązaniem tego problemu w prezentacji zatytułowanej „The BSD Packet Filter: A New Architecture for User-level Packet Capture”. Dziś już wiemy, że rozwiązanie to wyprzedziło swoje czasy, a wszystko to miało miejsce w okresie, gdy BSD jeszcze nie miał przedrostka „Free-” w nazwie, a BPF był akronimem od BSD Packet Filter, a nie Berkeley Packet Filter.
> MASZYNA WIRTUALNA
Obecnie wirtualizacja odmieniana jest przez wszystkie przypadki, jednak nie wszyscy znają maszynę filtrująca (ang. filter machine) BSD. Intencją było stworzenie czegoś na podobieństwo sandboksa z natywną prędkością wykonywania. Wymyślono więc utworzenie w jądrze systemu tzw. pseudo-machine składającej się z procesora i pamięci. Zaprojektowano CPU w architekturze RISC z dwoma rejestrami (A i X) 32-bitowymi i pamięć mieszczącą 16 słów 32-bitowych. Wirtualna architektura obsługiwała 22 instrukcje procesora, a jej program przypominał pojedynczą funkcję boolowską. Wartość 0 oznaczała brak dopasowania, wartości dodatnie przekazywały, ile bajtów należy skopiować do map przechowujących dane. Programy z przestrzeni użytkownika mogły odczytywać te dane bez konieczności ich kopiowania. Program BPF ma dostęp do systemowej struktury skb (ang. socket buffer), która przechowywała aktualnie przetwarzany pakiet w postaci zwykłej tablicy bajtów. Dzięki temu program BPF nie musiał niczego kopiować, jedyne, co robił, to odczytywał określone bajty z danych już znajdujących się w jądrze. Ponadto instrukcje skoku nie mogły go wykonywać wstecz, co zapobiegało pętlom, a największy skok do przodu mógł wynosić 265 bajtów. Ponadto program BPF nie mógł wykonywać innych programów BPF czy wywoływać funkcji zdefiniowanych wcześniej. Miał tylko ładować bajty (ldb), półsłowa (ldh) i słowa (ld) i porównywać je (jeq) z danymi z pakietu ([index]). Pseudomaszyna miała rozkazy o stałej długości (RISC) w przedstawionym formacie:
opcode:16 jt:8 jf:8 k:32
Pierwsze 16 bitów określało rozkaz. Jeżeli był nim skok, to pole jt określało offset skoku dla warunku prawdziwego, a jf dla fałszywego. Pole k było ogólnego przeznaczenia i mogło przechowywać 32 bity. To do niego były ładowane dane z pakietu. Teraz przyjrzyjmy się prawdziwemu przykładowi kodu bitowego BPF:
# tcpdump -d -i lo icmp
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 5
(002) ldb [23]
(003) jeq #0x1 jt 4 jf 5
(004) ret #262144
(005) ret #0
# tcpdump -XXnn -i lo icmp
IP 127.0.0.1 > 127.0.0.1: ICMP echo request
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500
0x0010: 0054 15fe 4000 4001 26a9 7f00 0001 7f00
Filtr wybierający wyłącznie pakiet ICMP musi sprawdzić w nim dwie wartości. Pierwsza informuje, czy mamy do czynienia z pakietem IP – wartość 0x0800 w bajcie 13 i 14 ramki ethernet (licząc od zera w tablicy bajtów to półsłowo pod indeksem [12]). Jeżeli warunek jest prawdziwy, to skocz do rozkazu numer 2 i sprawdź wartość pod indeksem [23]. Tam mieści się pole protocol nagłówka IPv4, dla ICMP ma wartość 0x01. Jeżeli warunek jest prawdziwy, to skocz do rozkazu numer 4 (zwraca wartość true). Jeżeli którykolwiek z warunków nie zostanie spełniony, program skoczy do rozkazu numer 5, który go zakończy wartością false.
Oczywiście tak wygenerowany kod nie był jeszcze natywnym kodem procesora, lecz tzw. BPF bytecode, który później był tłumaczony na rozkazy prawdziwego procesora przez interpreter. Natomiast przed załadowaniem filtra do przestrzeni jądra bytecode był sprawdzany przez walidator, aby zadane instrukcje nie dokonały jakichś szkód w jądrze systemu. Takie rozwiązanie było niezależne od architektury, na której działało, i pozwalało rozszerzać dostępne filtry bez jakiejkolwiek zmiany w mechanizmie jądra.
[…]
Grzegorz Kuczyński
Autor zawodowo zajmuje się informatyką. Jest członkiem społeczności open source, prowadzi blog na temat systemu GNU/Linux.