Subsections


3 Kod sieciowy w jądrach badanych systemów operacyjnych

Niniejszy rozdział stanowi opis organizacji kodu sieciowego w jądrach badanych systemów.

Najpierw zostanie przedstawiony podział kodu na pliki i katalogi, z uwzględnieniem tych części źródeł jądra, które są związane z komunikacją sieciową. Obejmują one:

Pozostałe części jądra będą w tym opisie rozważane tylko pobieżnie, choć zawierają elementy, bez których kod sieciowy nie mógłby funkcjonować. Najważniejsze z nich to: Wszystkie ścieżki do plików będą podane względem głównego katalogu z kodem źródłowym jądra. W Linuksie tym katalogiem jest zazwyczaj /usr/src/linux/, a w systemach BSD /usr/src/sys/, wskazywany też przez dowiązanie symboliczne /sys.

W dalszej części zostaną przedstawione najważniejsze struktury danych, z których korzysta kod sieciowy. Opis struktur zależnych od protokołów będzie ograniczony do rodziny protokołów TCP/IP. Kolejność omawiania struktur będzie odpowiadała ich umiejscowieniu w stosie protokołów sieciowych -- struktury z wyższych warstw zostaną omówione wcześniej. Wyjątkiem są struktury opisujące przesyłane komunikaty i bufory sieciowe, używane w wielu miejscach kodu sieciowego.

Znajomość opisanych struktur będzie pomocna przy śledzeniu drogi danych przesyłanych siecią od jednego procesu do drugiego. Założymy przy tym, że oba procesy działają na komputerach wyposażonych w ten sam system operacyjny (Linux lub FreeBSD) i utworzyły między sobą połączenie TCP/IP. Pierwszy proces wywołuje funkcję write() w celu przesłania danych, drugi odbiera je przy pomocy funkcji read(). Pominiemy szczegóły związane z implementacją mechanizmów wywołań systemowych i niskopoziomowej obsługi przerwań sprzętowych. Drogę wysyłanego pakietu zaczniemy śledzić od funkcji jądra implementującej wywołanie write(), a odbieranego pakietu -- od procedury obsługi przerwania karty sieciowej. Założymy, że komputery, na których działają komunikujące się procesy, są połączone siecią fizyczną typu Ethernet. Dla jasności opisu pominiemy wiele mniej istotnych szczegółów wynikających z rozmaitości zagadnień, jakimi musi się zajmować oprogramowanie sieciowe.

Analiza zostanie przeprowadzona osobno dla Linuksa i osobno dla systemów BSD (na przykładzie FreeBSD). Pozwoli ona lepiej zorientować się w zadaniach, jakie wykonują poszczególne warstwy kodu sieciowego.

Należy pamiętać, że nie będzie to pełen obraz działania kodu sieciowego. Czynności nie związane z przesyłaniem danych, takie jak tworzenie gniazd, nawiązywanie połączeń czy obsługa sytuacji specjalnych, stanowią istotne elementy oprogramowania sieciowego. Nierzadko wymagają podobnej ilości kodu, co przesyłanie danych, a kod ten występuje we wszystkich warstwach. Szczególnym stopniem skomplikowania odznacza się protokół TCP, który wyróżnia 11 stanów, w jakich może znajdować się połączenie i tylko jeden z nich jest związany z przesyłaniem danych (zob. [Postel 81]).

1 Linux

1 Organizacja kodu źródłowego

W tym punkcie wyszczególniono katalogi, które gromadzą definicje i kod związane z podsystemem sieciowym. Tłustym drukiem wyróżniono najistotniejsze z nich.

2 Najważniejsze struktury danych

Kod sieciowy Linuksa, ze względu na swoje rozmiary i złożoność, zawiera dziesiątki struktur danych o różnej wielkości i znaczeniu. Nie sposób opisać je wszystkie w jednej pracy. Warto zatem skupić uwagę tylko na niektórych z nich. W tabeli 3.1 wyszczególniono najistotniejsze struktury wraz z miejscami ich deklaracji (ścieżki podano względem katalogu /usr/src/linux/include/). Tam, gdzie można wyróżnić jeden plik gromadzący podstawowe operacje na danej strukturze, istotny dla zrozumienia organizacji kodu sieciowego, wymieniono jego nazwę (ścieżki względem /usr/src/linux/net/). Podano również warstwy kodu sieciowego przypisane poszczególnym strukturom. Na rysunkach B.1, B.2B.3 zamieszczonych w dodatku B (strony [*]-[*]) zobrazowano graficznie omawiane struktury oraz istotne powiązania między nimi.


Tabela 3.1: Najważniejsze struktury danych w kodzie sieciowym Linuksa
\begin{tabular}{\vert l\vert l\vert l\vert l\vert}
\hline
{\bf Struktura} & {\bf...
... &
{\tt core{/}dev.c} & interfejsu $\mapsto$\ protokołów
\\ \hline
\end{tabular}


Role przedstawionych struktur są następujące (zob. też [Studenci 97], [Beck 99]).

3 Studium przypadku -- wędrówka danych

W tym punkcie opisano przepływ sterowania w kodzie sieciowym Linuksa podczas transmisji danych. Przy czytaniu opisu mogą być pomocne schematyczne ilustracje przepływu sterowania zamieszczone na rys. 3.1 (s. [*], ścieżka nadawcza) i 3.3 (s. [*], ścieżka odbiorcza). Nazwy nowo wprowadzanych funkcji leżących na ścieżce przepływu wyróżniono tłustym drukiem.

Proces wysyłający dane wywołuje funkcję systemową write(). Sterowanie trafia -- poprzez kod obsługi wywołań systemowych -- do funkcji sys_write() (fs/read_write.c), umiejscowionej logicznie w warstwie VFS. Na początku sprawdza się kilka warunków, choć nie wszystkie mają sens w przypadku deskryptora związanego z gniazdem. Poprzez otrzymany deskryptor funkcja dociera m. in. do struktur filefile_operations. W drugiej z nich znajduje się wskaźnik do operacji write() właściwej dla danego pliku (gniazda). W tym przypadku jest nią sock_write() (net/socket.c).

Funkcja ta należy już do warstwy gniazd. Znajduje ona strukturę socket związaną z gniazdem, wypełnia pola struktury msghdr opisującej przesyłany komunikat (dane ciągle pozostają w przestrzeni użytkownika), po czym wywołuje funkcję sock_sendmsg() (net/socket.c). Stanowi ona element wspólny dla wszystkich funkcji wysyłających dane (write(), writev(), send(), sendto()sendmsg()). Po zbadaniu uprawnień do wysyłania danych specjalnych i skopiowaniu tych danych do osobnej struktury (w omawianym przypadku brak jest takich danych) wołana jest funkcja wskazywana przez pole sendmsg() struktury proto_ops.

Tą funkcją jest inet_sendmsg() (net/ipv4/af_inet.c). Ona także nie ma zbyt dużo pracy. Ze struktury socket, która nie będzie już potrzebna, wydobywa się wskaźnik do struktury sock i po sprawdzeniu kilku warunków przekazuje się sterowanie do funkcji wskazywanej przez pole sendmsg() struktury proto. To wszystko, co w tym przypadku wykonuje się w warstwie gniazd INET.

Funkcja tcp_v4_sendmsg() (net/ipv4/tcp_ipv4.c) należy już do warstwy protokołu transportowego. Po sprawdzeniu poprawności struktury msghdr12, opisującej przesyłaną wiadomość, wywoływana jest funkcja tcp_do_sendmsg() (net/ipv4/tcp.c). Jest to duża funkcja (ok. 250 wierszy kodu) i odpowiada za wiele zadań, z których tylko najważniejsze będą tu opisane. Na początku zakłada się blokadę na gniazdo (zdejmuje się ją dopiero przy wyjściu z funkcji). Potem w pętli wysyłane są dane z kolejnych elementów wektora iovec, na który wskazuje element msg_iov struktury msghdr. W rozpatrywanym przypadku jest tylko jeden taki element. Operacja wysyłania jest dość złożona. Dane mogą zostać dołączone do innych danych, których wysłanie zlecono wcześniej, ale ich ilość była zbyt mała, by mogły zostać wysłane od razu. W szczególności mogą zostać połączone razem dane z kolejnych elementów wektora iovec. Może też zajść potrzeba podzielenia danych na wiele mniejszych kawałków, aby uniknąć późniejszej fragmentacji zbyt dużych pakietów IP. Należy rozważyć różne przypadki związane z wielkością bieżącego okna TCP. Może również dojść do sytuacji, w której połączenie zostanie zamknięte lub zerwane przed wysłaniem wszystkich danych. Po sprawdzeniu i obsłużeniu różnych warunków specjalnych alokuje się pamięć przeznaczoną na bufor sk_buff. Zajmuje się tym funkcja sock_wmalloc() (net/core/sock.c), stanowiąca nakładkę na funkcję biblioteczną alloc_skb() (net/core/skbuff.c). Po obsłużeniu ewentualnego niepowodzenia przy alokowaniu pamięci, rezerwuje się pamięć na nagłówki warstw niższych. Informacja o wielkości pamięci, jaką trzeba zarezerwować, jest przechowywana w nieoczekiwanym miejscu -- jest to pole max_header struktury proto. Następnie opracowuje się niektóre informacje o nagłówku TCP, ale sam nagłówek nie jest jeszcze tworzony. Wreszcie kopiuje się dane z pamięci użytkownika, z jednoczesnym sprawdzeniem dostępności wymaganego obszaru pamięci i obliczeniem sumy kontrolnej danych. Dalej pakietem zajmuje się funkcja tcp_send_skb() (net/ipv4/tcp_output.c). W każdym przypadku wstawia ona bufor z danymi do kolejki write_queue struktury sock, gdyż TCP, jako protokół niezawodny, musi przechowywać wysyłane dane do czasu potwierdzenia ich przez odbiorcę. Poza tym zwiększa się numery sekwencyjne wysyłanych danych, aktualizuje zegary TCP i statystykę wysyłanych pakietów. Pakiet może w tym momencie zostać przekazany dalej, ale tylko pod pewnymi warunkami, wynikającymi ze specyfikacji TCP. W szczególności kolejka write_queue nie może zawierać żadnego innego pakietu oczekującego na wysłanie, choć mogą znajdować się w niej pakiety nie potwierdzone. Aby przekazać bufor dalej, tworzy się jego kopię za pomocą funkcji skb_clone() (net/core/skbuff.c). Otrzymana kopia jest oznaczana jako nie należąca do gniazda. Kopiowane są tylko struktury kontrolne bufora, dane -- nie13. Kopia jest przekazywana do funkcji tcp_transmit_skb() (net/ipv4/tcp_output.c), która tworzy nagłówek TCP (m. in. na podstawie informacji przygotowanych przez tcp_do_sendmsg()) i oblicza sumę kontrolną właściwą dla używanej wersji protokołu IP (4 lub 6). Tutaj też usuwa się opóźnione potwierdzenia (tj. pakiety ACK, które należy wysłać w przyszłości), gdyż bieżący pakiet oprócz danych będzie niósł także potwierdzenie. Wreszcie wywołuje się funkcję wskazywaną przez pole queue_xmit() struktury tcp_func, przechodząc do warstwy protokołu sieciowego.

W rozważanym przypadku tą funkcją jest ip_queue_xmit() (net/ipv4/ip_output.c). Jej pierwszym zadaniem jest znalezienie trasy dla wysyłanego pakietu. Może to wymagać wysłania dodatkowego pakietu z zapytaniem o trasę i oczekiwania na odpowiedź. Potem tworzy się nagłówek IP. Jego elementem mogą być opcje IP, składane w osobnej funkcji. Jeśli jądro ma wkompilowany kod zapory ogniowej (ang. firewall), to pakiet może zostać odrzucony, o ile narusza on zasady przesyłania pakietów ustalone przez administratora. W przypadku retransmisji pakietów warstwy transportowej może zajść sytuacja, gdy od czasu pierwotnej transmisji zmieniło się urządzenie wyjściowe dla pakietu. W takiej sytuacji mogła ulec zwiększeniu długość nagłówka sprzętowego sieci odbiorczej, czego efektem będzie brak miejsca na ten nagłówek w buforze skbuff. To kolejny z problemów, którymi zajmuje się funkcja ip_queue_xmit(). Następnym zadaniem jest sprawdzenie, czy zachodzi konieczność fragmentacji pakietu. Jeśli tak, to czynność ta jest zlecana funkcji ip_fragment() (net/ipv4/ip_output.c). W zdecydowanej większości przypadków pakiety TCP wysyłane z lokalnej maszyny nie muszą być fragmentowane (patrz opis działania warstwy transportowej), zatem załóżmy, że i teraz nie zachodzi taka potrzeba. Pozostaje już tylko obliczyć sumę kontrolną nagłówka IP, co wykonuje funkcja asemblerowa ip_fast_csum() z pliku include/asm/checksum.h. Gotowy pakiet przekazuje się do funkcji wyjściowej IP, w tym przypadku ip_output() (net/ipv4/ip_output.c)14. Zazwyczaj nie robi ona praktycznie nic, poza przekazaniem sterowania do funkcji ip_finish_output() (include/net/ip.h). W obecnej implementacji jest to funkcja typu inline. Jej typowym działaniem jest dopisanie przed pakietem nagłówka sprzętowego i wywołanie funkcji dev_queue_xmit() (net/core/dev.c). Pomijamy tu szczegóły związane z zarządzaniem pamięcią podręczną nagłówków sprzętowych, możliwością wyboru różnych ścieżek wyjściowych i ponownym wyborem trasy.

Funkcja dev_queue_xmit() stanowi sprzęg pomiędzy warstwą protokołów a kodem interfejsu sieciowego zależnym od sprzętu. Trudno jest ją precyzyjnie umiejscowić w strukturze warstwowej kodu sieciowego, ale można uznać, że należy do warstwy interfejsu. Zadaniem tej funkcji jest buforowanie wychodzących pakietów w kolejkach, przy czym z każdym urządzeniem sprzętowym jest związana jedna kolejka. Dla urządzeń programowych nie korzysta się z kolejek, lecz wywołuje od razu funkcję hard_start_xmit(). Struktura wewnętrzna kolejek (include/net/pkt_sched.h) jest dość złożona i została pominięta w niniejszym opisie, gdyż stopień komplikacji wynika z potrzeby dostosowania się do wymagań zaawansowanych ruterów i systemów równoważących obciążenie sieciowe. Dla typowych końcówek sieciowych dev_queue_xmit() dopisuje pakiet do kolejki wyjściowej urządzenia sieciowego i -- o ile to urządzenie nie jest zajęte transmisją innego pakietu -- wywołuje funkcję wskazywaną przez pole hard_start_xmit() struktury device. Teraz jest wykonywany kod z warstwy interfejsu ściśle zależny od urządzenia wyjściowego. Niech tym urządzeniem będzie karta NE2000. W funkcji ei_start_xmit() (drivers/net/8390.c) bada się wiele możliwości wystąpienia błędów, kopiuje się pakiet do buforów wyjściowych karty sieciowej i inicjuje się fizyczną transmisję pakietu. Przy tych operacjach jest potrzebna duża ostrożność, gdyż asynchronicznie mogą się pojawiać przerwania od karty sieciowej (np. po odczytaniu pakietu z sieci). Wreszcie jądro powraca z wywołania systemowego. Gdy fizyczna transmisja zostanie zakończona, na komputerach nadawczym i odbiorczym zostanie wygenerowane przerwanie od karty sieciowej. Przy założeniu, że urządzeniem odbiorczym jest również karta NE2000, na obu komputerach sterowanie trafi do funkcji ei_interrupt() (drivers/net/8390.c). Wykonuje się w niej sporo zadań administracyjnych: trzeba sprawdzić, jakiego zdarzenia dotyczyło przerwanie, potwierdzić przyjęcie przerwania, zbadać możliwości wystąpienia błędów, zaktualizować zmienne statystyczne itp. Na komputerze nadawczym wywoływana jest funkcja ei_tx_intr() (drivers/net/8390.c). Jeśli karta sieciowa ma 2 lub więcej buforów nadawczych, to w tym momencie może zostać zainicjowana transmisja następnego pakietu (załadowanego wcześniej do karty). Trzeba też obsłużyć ewentualne błędy transmisji (np. kolizje pakietów w sieci). Dalszym przetwarzaniem zajmie się kod tzw. dolnej połowy przerwania sieciowego, już po wyjściu z obsługi przerwania sprzętowego. Aby zaznaczyć konieczność wykonania dolnej połowy, woła się funkcję mark_bh() (include/asm/softirq.h).

Więcej czynności trzeba wykonać na komputerze odbiorczym w funkcji ei_receive() (drivers/net/8390.h). Alokuje się w niej bufor skbuff i woła funkcję ne_block_input() (drivers/net/ne.c), która komunikuje się ze sprzętem w celu odczytania odebranego pakietu. W funkcji ei_receive() trzeba też zbadać rozmaite możliwości wystąpienia błędów. Możliwe jest odczytanie do dziesięciu pakietów podczas jednego przerwania sprzętowego, co powinno zwiększyć przepustowość w przypadku szybkich sieci. Nie ma to większego znaczenia dla kart NE2000, gdyż pozwalają one na transmisję z maksymalną szybkością 10Mb/s. Przy pomocy funkcji eth_type_trans() (net/ethernet/eth.c) ustala się pole protocol struktury sk_buff, opisujące kod protokołu warstwy sieciowej, po czym przesuwa się wskaźnik data za nagłówek sprzętowy. Nagłówek ten będzie nadal dostępny poprzez wskaźnik mac struktury sk_buff. Bufor z odczytanym pakietem jest przekazywany wyższym warstwom kodu sieciowego w funkcji netif_rx() (net/core/dev.c). Dodaje ona pakiet na koniec globalnej kolejki backlog i zaznacza konieczność wykonania dolnej połowy przerwania. W razie przeciążenia systemu, którego objawem jest przekroczenie maksymalnej długości kolejki backlog, pakiet jest odrzucany.

Po zakończeniu obsługi przerwania sprzętowego wykonywany jest kod dolnej połowy przerwania. Został on wyodrębniony z kodu obsługi przerwania sprzętowego, aby mógł działać przy odblokowanych przerwaniach. Poprawia to czas odpowiedzi systemu na zdarzenia zewnętrzne, ale też wymaga dużej staranności przy pisaniu takiego kodu. W czasie jego działania mogą się pojawiać nowe przerwania sprzętowe, operujące na wspólnych strukturach danych (np. na wspomnianej kolejce backlog). Ponieważ sprzętowe przerwania muszą zostać obsłużone możliwie jak najszybciej, nie mogą czekać na dostęp do wspólnych zasobów. Dolna połowa przerwania sieciowego jest zaimplementowana w funkcji net_bh() (net/core/dev.c) i wywoływana przez kod powrotu z przerwania sprzętowego za każdym razem, gdy zostanie zgłoszona potrzeba jej wywołania. Jak wynika z tego opisu, tę funkcję wykonuje zarówno nadawca, jak i odbiorca pakietu. Jej zadanie jest dwojakie. Po pierwsze, podejmuje się w niej próbę wysłania pakietów, które mogą oczekiwać w kolejkach wyjściowych urządzeń. Zadanie to jest zlecane funkcji qdisc_run_queues() (net/sched/sch_generic.c), która przegląda listę urządzeń mających niepuste kolejki, aby wywołać funkcje hard_start_xmit() przypisane tym urządzeniom. Taka próba podejmowana jest dwukrotnie: po rozpoczęciu i przed zakończeniem działania net_bh(). Drugim zadaniem net_bh() jest opróżnienie kolejki backlog. Dla każdego znajdującego się w niej bufora sk_buff wskaźniki nhh są ustawiane na początek danych pakietu, gdzie znajduje się nagłówek warstwy sieciowej (nagłówek sprzętowy został pominięty w funkcji eth_type_trans()). Następnie w tablicy haszującej ptype_base wyszukuje się element typu packet_type, zawierający wskaźnik do funkcji odbiorczej odpowiedniego protokołu warstwy sieciowej, zgodnie z polem protocol bufora sk_buff. Dalsze przetwarzanie pakietu ma miejsce w warstwie sieciowej, ale warto jeszcze wspomnieć o zabezpieczeniu systemu przed zagłodzeniem przez funkcję net_bh(): jeśli od rozpoczęcia jej działania upłynęły 2 takty zegara15, a kolejka backlog jest ciągle niepusta (co oznacza, że asynchronicznie napływa duży strumień pakietów), to funkcja kończy działanie, aby mógł zostać wykonany kod innych dolnych połów.

Funkcją odbiorczą warstwy protokołu sieciowego jest -- dla protokołu IP -- ip_rcv() (net/ipv4/ip_input.c). Jej pierwsze zadanie to aktualizacja statystyk i analiza poprawności nagłówka IP. Następnie sprawdza się, czy zachodzi konieczność defragmentacji pakietu i w razie potrzeby wywołuje się funkcję ip_defrag() (net/ipv4/ip_fragment.c). Jeśli jądro zawiera kod implementujący zaporę ogniową, to pakiet może zostać odrzucony. Kolejnym zadaniem jest analiza drogi, jaką pakiet dotarł do systemu i jaką powinien podążać dalej. Podczas tej analizy można m. in. wykryć niektóre ataki sieciowe i zachować w pamięci podręcznej informacje przyspieszające wybór tras dla pakietów wychodzących. Tym zadaniem zajmuje się funkcja ip_route_input() (net/ipv4/route.c). Nieprawidłowe pakiety, np. mające adres źródłowy przypisany lokalnej maszynie, są odrzucane. Jeśli otrzymany pakiet zawiera opcje IP, to w funkcji ip_options_compile() (net/ipv4/ip_options.c) dokonuje się ich analizy. Dalszy los pakietu zależy od decyzji podjętych przez kod wyboru trasy, ale w rozważanym przypadku (pakiet przeznaczony dla maszyny lokalnej) bufor zostanie przekazany funkcji ip_local_deliver() (net/ipv4/ip_input.c). Jeśli pakiet, który uległ fragmentacji, nie został wcześniej sklejony16, to trzeba to zrobić teraz. Jeśli włączono ukrywanie adresów (ang. masquerade), to pakiety z fikcyjnymi adresami muszą zostać obsłużone w sposób specjalny, ale nie dotyczy to rozważanego przypadku. Wskaźnik h jest przesuwany poza nagłówek IP i wskazuje teraz na nagłówek protokołu warstwy transportowej. Kolejną czynnością jest dostarczenie pakietu do wszystkich gniazd surowych, które oczekują na pakiety IP o odpowiednich właściwościach. W rozważanym przypadku proces odbiorczy nie korzysta z gniazda surowego. Ostatnim zadaniem funkcji ip_local_deliver() jest odnalezienie funkcji odbiorczej z warstwy protokołu transportowego. W tablicy haszującej inet_protos wyszukuje się element typu inet_protocol odpowiedni dla rodzaju przetwarzanego pakietu. Jego pole handler() wskazuje na funkcję odbiorczą.

Sterowanie trafia teraz do funkcji tcp_v4_rcv() (net/ipv4/tcp_ipv4.c). W tej funkcji odrzuca się z pakietu nagłówek IP, aktualizuje się statystyki, sprawdza się kilka warunków błędów. Jednym z możliwych błędów jest niepoprawna wartość sumy kontrolnej segmentu TCP. Warto zaznaczyć, że liczenie sumy kontrolnej może (ale nie musi) być wykonane na wcześniejszym etapie przetwarzania, tj. podczas kopiowania pakietu z karty sieciowej do bufora sk_buff. Jacobson [Jacobson 92b] pokazuje, że uzyskana w ten sposób oszczędność czasu zależy od rodzaju architektury sprzętowej, ale w skrajnym przypadku kopiowanie z liczeniem sumy zajmuje tyle samo czasu, co zwykłe kopiowanie (zob. rozdział 4). W specjalny sposób obsługuje się sytuację, gdy komputer pracuje jako niewidoczny pośrednik (ang. transparent proxy), ale szczegóły zostaną pominięte w niniejszym opisie. Przy użyciu funkcji __tcp_v4_lookup() (net/ipv4/tcp_ipv4.c) odnajduje się strukturę sock opisującą gniazdo, dla którego przeznaczony jest pakiet. W celu przyspieszenia tej operacji stosuje się zarówno tablicę haszującą, jak i pamięć podręczną ostatnio używanych połączeń TCP. Następnie zapamiętuje się informacje z nagłówka TCP dotyczące numerów sekwencyjnych pakietu. Jeżeli gniazdo jest zablokowane przez jakiś proces, to bufor z pakietem zostaje wstawiony do kolejki związanej z gniazdem w celu późniejszego przetworzenia. W przeciwnym razie pakiet jest przekazywany do funkcji tcp_v4_do_rcv() (net/ipv4/tcp_ipv4.c), gdzie ustala się gniazdo jako właściciela pakietu i wywołuje się funkcję tcp_rcv_established() (net/ipv4/tcp_input.c). Należy zaznaczyć, że opisywany scenariusz dotyczy pakietu przeznaczonego dla gniazda będącego w stanie ESTABLISHED (zob. [Postel 81]). Implementacja maszyny stanowej TCP wymaga dużej ilości dodatkowego kodu. Pozostałe stany gniazda są w większości rozpatrywane w funkcji tcp_rcv_state_process() (net/ipv4/tcp_input.c), która z kolei korzysta z wielu funkcji pomocniczych. W rozpatrywanym przypadku gniazdo jest w stanie ESTABLISHED. Pierwszą czynnością tcp_rcv_established() jest sprawdzenie, czy nie doszło do zawinięcia (ang. wrap) numerów sekwencyjnych TCP. Służy do tego algorytm PAWS przedstawiony w [Jacobson 92a]. Dalsze przetwarzanie jest rozbite na dwie alternatywne ścieżki: szybką i wolną. Pierwsza z nich jest wzorowana na algorytmie Vana Jacobsona (zob. rozdział 4) i wykorzystuje technikę przewidywania nagłówka (ang. header prediction). Jeśli otrzymany pakiet ma oczekiwane numery sekwencyjne i flagi TCP, to można go przetworzyć w sposób uproszczony, oszczędzając czas. W przeciwnym razie trzeba zastosować zwykły algorytm, opisany w [Postel 81]. Większość segmentów z danymi, które nie zawierają danych pilnych i przychodzą we właściwej kolejności, można przetworzyć w szybkiej ścieżce. Pozwala to zwiększyć przepustowość TCP np. w szybkich sieciach lokalnych. W obu ścieżkach dokonuje się aktualizacji statystyk protokołu TCP, dotyczących przedziałów czasowych, ilości przesyłanych danych itp. Informacje te są potrzebne do sterowania przepływem danych, co obejmuje m. in. zarządzanie wielkością okna i ustalanie stałych czasowych dla zegarów TCP. W obu ścieżkach trzeba też przeanalizować potwierdzenie (ACK) otrzymane od nadawcy i zaznaczyć konieczność wysłania po pewnym czasie własnego potwierdzenia17. W ścieżce wolnej wołana jest funkcja tcp_data() (net/ipv4/tcp_input.c), a w niej tcp_data_queue() (net/ipv4/tcp_input.c). Tutaj rozważa się różne przypadki nadejścia danych poza kolejnością i duplikacji segmentów TCP. Do obsłużenia takich danych używa się -- oprócz kolejki odbiorczej gniazda -- kolejki przechowującej dane otrzymane poza kolejnością. Jeśli jest ona niepusta, to dla następnego odebranego segmentu trzeba będzie zastosować wolną ścieżkę przetwarzania. Aby zaznaczyć taką konieczność, manipuluje się wartością pola pred_flags umieszczonego w strukturze tcp_opt. Jeśli otrzymano dane w kolejności i bez duplikacji, to ich przetwarzanie sprowadza się do obcięcia nagłówka TCP i wstawienia bufora sk_buff na koniec kolejki odbiorczej gniazda. Wreszcie wołana jest funkcja wskazywana przez pole data_ready() struktury sock. Takie same operacje jak w ścieżce wolnej dla typowego przypadku, wykonuje się w ścieżce szybkiej, tyle że zostały one zakodowane bezpośrednio w funkcji tcp_rcv_established().

Sterowanie trafia teraz do funkcji sock_def_readable() (net/core/sock.c), umiejscowionej logicznie w warstwie gniazd INET. Funkcja nie jest związana tylko z protokołami internetowymi, jednak operuje na strukturze sock, dlatego leży w warstwie gniazd INET, a nie w warstwie gniazd, operującej na strukturze socket. Budzone są procesy, które czekały na pojawienie się w gnieździe nowych danych. Procesy, które zażądały asynchronicznego powiadamiania o przyjściu danych, otrzymują sygnał SIGIO.

Opisane przetwarzanie danych po stronie odbiorcy przebiegało poza kontekstem procesu. Stanowiło ono reakcję na przerwanie otrzymane od karty sieciowej. Część kodu została wykonana w procedurze obsługi przerwania, reszta -- w dolnej połowie przerwania. Otrzymane dane nadal są przechowywane w buforze sk_buff, należącym do jądra. Kopiowanie danych do przestrzeni użytkownika odbywa się w kontekście procesu, po wywołaniu przezeń odpowiedniej funkcji systemowej, np. read(). Droga, jaką podąża teraz sterowanie, jest analogiczna do tej, którą widzieliśmy w przypadku pisania do gniazda.

W warstwie VFS znajduje się funkcja sys_read() (fs/read_write.c), która niczym szczególnym nie różni się od sys_write().

W warstwie gniazd sterowanie trafia do funkcji sock_read() (net/socket.c). Ona z kolei jest dokładnym odpowiednikiem funkcji sock_write(). Podobnie funkcja sock_recvmsg() (net/socket.c) różni się od sock_sendmsg() tylko sposobem obsługi danych specjalnych.

Teraz jest wołana funkcja inet_recvmsg() (net/ipv4/af_inet.c), umiejscowiona w warstwie gniazd INET. Podobnie jak inet_sendmsg(), wykonuje ona niewiele czynności.

Wreszcie w warstwie protokołu transportowego sterowanie trafia do funkcji tcp_recvmsg() (net/ipv4/tcp.c). Jest to właściwa funkcja przesyłająca dane do przestrzeni użytkownika i -- podobnie jak tcp_do_sendmsg() -- odpowiada za wiele zadań. Najpierw sprawdza się w niej kilka warunków błędów i zakłada blokadę na gniazdo. Odbiór danych wysokopriorytetowych zlecany jest funkcji tpc_recv_urg() (net/ipv4/tcp.c). Następnie przesyła się dane w pętli z buforów sk_buff umieszczonych w kolejce odbiorczej gniazda do przestrzeni użytkownika. Jeśli w buforach jest zbyt mało danych, aby zaspokoić żądanie odczytu, to proces jest usypiany w kolejce oczekiwania związanej z gniazdem. Jak widzieliśmy, funkcja sock_def_readable() obudzi proces po przyjściu nowych danych. W międzyczasie proces może otrzymać sygnały, a stan połączenia TCP może ulec zmianie. Ponadto do funkcji tcp_recvmsg() mogły zostać przekazane opcje zmieniające jej zachowanie (nie dotyczy to rozważanego przypadku). W środku strumienia danych TCP mogą pojawić się dane wysokopriorytetowe. Po odczytaniu danych może zajść potrzeba wysłania potwierdzenia do nadawcy i przeliczenia rozmiaru okna TCP. To wszystko sprawia, że kod tcp_recvmsg() musi uwzględniać wiele różnych przypadków. Właściwe kopiowanie danych sprowadza się do wywołania funkcji memcpy_toiovec() (net/core/iovec.c), w której proces może zostać ponownie uśpiony w oczekiwaniu na sprowadzenie strony pamięci z urządzenia wymiany. Zarówno przy wyjściu z tcp_recvmsg(), jak i podczas oczekiwania na nadejście danych zdejmuje się blokadę z gniazda, natomiast w czasie wykonywania memcpy_toiovec() gniazdo jest zablokowane (oznacza to, że inne procesy nie mogą korzystać z tego gniazda, choć ciągle mogą napływać do niego nowe dane). Analogiczna sytuacja miała miejsce podczas kopiowania danych z przestrzeni użytkownika do buforów jądra w funkcji tcp_do_sendmsg().

Po skopiowaniu danych do przestrzeni użytkownika operacja przesyłania danych od jednego procesu do drugiego kończy się.

2 FreeBSD

1 Organizacja kodu źródłowego

W tym punkcie wyszczególniono katalogi, które gromadzą definicje i kod związane z podsystemem sieciowym. Tłustym drukiem wyróżniono najistotniejsze z nich.

Dalsze katalogi gromadzące kod sieciowy zawierają głównie implementacje rodzin protokołów i sieciowych systemów plików nie omawianych w niniejszej pracy:

2 Najważniejsze struktury danych

Podobnie jak w przypadku Linuksa, do analizy wybrano tylko niektóre spośród wielu struktur danych używanych w kodzie sieciowym FreeBSD. Wyselekcjonowano struktury, które pełnią kluczową rolę w implementacji rodziny protokołów TCP/IP. W tabeli 3.2 pokazano te struktury w takim układzie, jak w tabeli 3.1. Wszystkie ścieżki zostały podane względem katalogu /usr/src/sys/. Akronim VFS został zaczerpnięty z terminologii linuksowej i oznacza zbiór funkcji działających zarówno na plikach, jak i na gniazdach. Funkcje te implementują wywołania systemowe służące do transferu danych. Na rysunkach B.4, B.5B.6 zamieszczonych w dodatku B (strony [*]-[*]) zobrazowano graficznie omawiane struktury oraz istotne powiązania między nimi.


Tabela 3.2: Najważniejsze struktury danych w kodzie sieciowym FreeBSD
\begin{tabular}{\vert l\vert l\vert l\vert l\vert}
\hline
{\bf Struktura} & {\bf...
...\\
{\tt if\_data} & {\tt net{/}if.h} & --- & interfejsu
\\ \hline
\end{tabular}


Poniższy wykaz opisuje znaczenie przedstawionych struktur (zob. też [Stevens 98b]).

3 Studium przypadku -- wędrówka danych

W tym punkcie opisano przepływ sterowania w kodzie sieciowym FreeBSD podczas transmisji danych. Przy czytaniu opisu mogą być pomocne schematyczne ilustracje przepływu sterowania zamieszczone na rys. 3.2 (s. [*], ścieżka nadawcza) i 3.4 (s. [*], ścieżka odbiorcza). Nazwy nowo wprowadzanych funkcji leżących na ścieżce przepływu wyróżniono tłustym drukiem.

Proces wysyłający dane wywołuje funkcję systemową write(). Sterowanie trafia -- poprzez kod obsługi wywołań systemowych -- do funkcji write() (kern/sys_generic.c), umiejscowionej logicznie w warstwie VFS. Po znalezieniu struktury file w funkcji sprawdza się, czy plik jest otwarty do zapisu, po czym woła się funkcję dofilewrite() (kern/sys_generic.c). Teraz są tworzone i wypełnianie struktury uioiovec, następnie sterowanie trafia do funkcji fo_write() (sys/file.h, kod funkcji jest rozwijany w miejscu wywołania). W tej funkcji zwiększa się licznik odwołań do pliku i woła funkcję właściwą dla danego pliku (gniazda) -- tę, na którą wskazuje pole fo_write() struktury fileops przypisanej plikowi.

W tym przypadku jest nią soo_write() (kern/sys_socket.c). Funkcja ta należy do warstwy gniazd. Znajduje się w niej struktury socket, protoswpr_usrreqs związane z gniazdem i wywołuje się funkcję wskazywaną przez pole pru_sosend() struktury pr_usrreqs. Choć takie rozwiązanie pozwala zaimplementować funkcję wysyłającą specyficzną dla protokołu warstwy transportowej, to w przypadku protokołu TCP sterowanie trafia do ogólnej funkcji sosend() (kern/uipc_socket.c), leżącej w warstwie gniazd. Jest to złożona funkcja, której mogą używać protokoły różnych typów (zawodne, niezawodne; strumieniowe, pakietowe, strumieniowe z podziałem na rekordy). Wysyłane dane mogą zawierać dane pilne i informacje kontrolne. Proces może zażądać różnych trybów działania, np. operacji blokującej lub nieblokującej. Tu zostanie opisana procedura blokującego zapisu danych zwykłych do gniazda TCP z domyślnym zestawem opcji. Na początku na gniazdo zakłada się blokadę. Potem w pętli wysyła się dane do momentu, gdy zostaną wysłane wszystkie. Przy wyjściu zdejmuje się blokadę. Obszerne części kodu pętli zapisującej działają na poziomie przerwania sieciowego splnet, co oznacza, że kod obsługi przerwania sieciowego (np. procedury wejściowe IP) nie może wywłaszczyć tych części kodu pętli. Pętla rozpoczyna się od sprawdzenia wielu warunków, które mogłyby spowodować powstanie błędów (np. partner komunikacyjny mógł zamknąć połączenie). Jeśli ilość dostępnej pamięci w buforze nadawczym jest mniejsza od wartości określonej dla gniazda, to proces jest usypiany. W przeciwnym razie alokuje się bufor mbuf przeznaczony na wysyłane dane. Może on zawierać klaster, jeśli porcja danych jest dostatecznie duża. W przypadku protokołu TCP zawsze jest to tylko jeden bufor, nigdy łańcuch. Jeśli dane nie mieszczą się w buforze, to zostaną wysłane w kilku obrotach pętli. Możliwa jest też sytuacja, gdy przestrzeń w buforze nadawczym gniazda zostanie wyczerpana w trakcie wysyłania i nie wszystkie dane będą wysłane. Funkcja biblioteczna uiomove kopiuje dane z przestrzeni użytkownika do bufora mbuf. Jeśli obszar danych użytkownika nie jest dostępny dla wysyłającego procesu, to dopiero teraz zostanie przekazany błąd. Nie ma to jednak wpływu na szybkość działania poprawnych programów. Po ustaleniu kilku pól w nagłówku mbuf wywołuje się funkcję wskazywaną przez pole pru_send() struktury pr_usrreqs.

Sterowanie trafia do funkcji specyficznej dla TCP, a więc leżącej w warstwie protokołu. Jest nią tcp_usr_send() (netinet/tcp_usrreq.c). Funkcja ta znajduje wskaźniki do bloków kontrolnych protokołu (inpcbtcpcb). Następnie sprawdza możliwości wystąpienia błędu (zerwanie połączenia, próba wysłania danych kontrolnych). W specjalny sposób obsługuje się dane pilne20 oraz przypadki, gdy gniazdo nie jest jeszcze połączone z innym gniazdem lub gdy należy zamknąć połączenie. W typowym przypadku w tcp_usr_send() wykonuje się znacznie mniej czynności niż w linuksowej funkcji tcp_v4_sendmsg() -- czynności związane z alokowaniem pamięci, kontrolą wielkości buforów gniazd i kopiowaniem danych zostały zakodowane we FreeBSD w kodzie sieciowym warstwy gniazd. Wadą jest brak liczenia sumy kontrolnej podczas kopiowania danych. Typowe działanie funkcji tcp_usr_send() sprowadza się do dołączenia wysyłanego bufora do kolejki nadawczej gniazda, zaznaczenia, czy na wysłanie czekają kolejne dane i wywołania funkcji tcp_output() (netinet/tcp_output.c). To jedna z największych funkcji w kodzie sieciowym BSD (prawie 800 wierszy kodu). Wysyła się w niej w pętli po jednym segmencie TCP, opróżniając kolejkę nadawczą gniazda. Na początku pętli określa się rozmiar danych do wysłania oraz flagi segmentów TCP. Trzeba zbadać szereg warunków określonych przez specyfikację TCP i jej rozszerzenia. Są to m. in. warunki nałożone na rozmiary okien, rozmiar wysyłanych danych, stan zegarów TCP, stan połączenia. Przy okazji wywołuje się różne procedury pomocnicze, np. ustalające czas retransmisji. Może się zdarzyć, że tcp_output() nie wyśle żadnych danych. W kodzie wysyłania danych najpierw konstruuje się opcje, które zostaną dołączone do nagłówka TCP, później aktualizuje się niektóre statystyki. Następnie alokuje się bufor mbuf przeznaczony na nagłówki warstw łącza danych, sieciowej i transportowej, po czym dołącza się do tego bufora kopię bufora zawierającego wysyłane dane. Jeśli dane znajdują się w klastrze, nie są fizycznie kopiowane. Jeżeli dane i nagłówki mieszczą się w jednym buforze mbuf, wówczas nie tworzy się łańcucha buforów, lecz kopiuje się dane do bufora przeznaczonego na nagłówki. Oprócz tego aktualizuje się niektóre pola w nagłówku bufora. Każda próba alokacji bufora mbuf może zakończyć się błędem -- w takiej sytuacji funkcja tcp_output() również zakończy się błędem. Kolejnym krokiem jest skopiowanie szablonów nagłówków IP i TCP i zaktualizowanie odpowiednich pól w kopiach. Potem następuje przeliczenie rozmiaru okna odbiorczego, obliczenie sumy kontrolnej segmentu TCP, uruchomienie zegara retransmisji, ustawienie kilku pól w nagłówku IP i przekazanie łańcucha mbuf funkcji wyjściowej IP. Pod koniec funkcji tcp_output() wykonuje się kilka operacji porządkowych i -- jeśli zachodzi potrzeba wysłania dalszych danych -- przekazuje się sterowanie na początek funkcji.

Funkcją wyjściową IP jest ip_output() (netinet/ip_output.c). Jest to rozbudowana, uniwersalna funkcja, tak jak tcp_output(). Dalej zostaną opisane najważniejsze części jej kodu, wykonywane w rozpatrywanym scenariuszu. Najpierw do nagłówka IP mogą zostać dodane opcje IP. Następnie dokonuje się wyboru trasy, którą będzie podążał pakiet. Wykorzystuje się przy tym pamięć podręczną tras. Pakiet może zostać odrzucony, jeśli adres docelowy jest nieosiągalny. Podobnie kod zapory ogniowej i protokołu IPsec może zadecydować o odrzuceniu pakietu. Kod zapory ogniowej może również zmienić adres docelowy pakietu, a kod IPsec przekazać pakiet do połączenia tunelującego. W wyniku wyboru trasy zostaje określony adres rutera (lub komputera docelowego, jeśli jest on przyłączony do sieci lokalnej) oraz wskaźnik do struktury ifnet opisującej interfejs sieciowy, przez który należy wysłać pakiet. Dalsza część funkcji ip_output() dotyczy przede wszystkim fragmentowania pakietów. Może się zdarzyć, że kolejny pakiet w połączeniu TCP zostanie wysłany przez inny interfejs sieciowy niż poprzednie pakiety i segment TCP będzie musiał zostać wysłany we fragmentach z powodu zmniejszenia wartości MTU. Załóżmy, że taka sytuacja nie ma miejsca. Teraz ustawia się kilka pól w nagłówku IP, oblicza się sumę kontrolną nagłówka, używając asemblerowej funkcji in_cksum_hdr() (i386/include/in_cksum.h) lub mniej zoptymalizowanej in_cksum() (netinet/in_cksum.c), przekazuje się pakiet funkcji wyjściowej interfejsu, po czym zwalnia się pamięć przechowującą pakiet.

Funkcja wyjściowa interfejsu jest wskazywana przez pole if_output() struktury ifnet. Zakładamy, że urządzeniem wyjściowym jest karta NE2000 dołączona do szyny ISA komputera z rodziny Intel 386. Sterowanie trafia do funkcji ether_output() (net/if_ethersubr.c). Najpierw sprawdza się rozmaite warunki wystąpienia błędów związane ze stanem interfejsu. Następnie woła się funkcję arpresolve() (netinet/if_ether.c), aby skojarzyć adres internetowy komputera docelowego lub rutera z jego adresem ethernetowym. Jeśli takie skojarzenie nie było dotąd przechowywane w pamięci podręcznej ARP, to operacja wysyłania pakietu jest wstrzymywana do czasu otrzymania odpowiedzi na zapytanie ARP, a wykonywanie funkcji ether_output() kończy się od razu. Załóżmy, że znaleziono odpowiednie skojarzenie. Kolejną czynnością jest zarezerwowanie w łańcuchu mbuf miejsca na nagłówek warstwy łącza danych (sprzętowy). W wyjątkowych przypadkach może zajść potrzeba wydłużenia łańcucha. Dalej funkcja ether_output() tworzy nagłówek sprzętowy. Następnie bada się kilka warunków, które w rozpatrywanym przypadku nie mają zastosowania. Potem dołącza się pakiet do kolejki wyjściowej interfejsu. Jeżeli kolejka jest przepełniona, to pakiet zostaje odrzucony i funkcja kończy się błędem. Ostatnie czynności funkcji ether_output() to aktualizacja statystyk i wywołanie funkcji wskazywanej przez pole if_start() struktury ifnet, o ile interfejs nie był zajęty transmisją innego pakietu. Sterowanie trafia do funkcji ed_start() (dev/ed/if_ed.c). Tutaj kopiuje się w pętli treść pakietów do buforów transmisyjnych karty tak długo, jak długo kolejka wyjściowa jest niepusta, a karta posiada co najmniej jeden wolny bufor. W szczególnym przypadku żaden bufor może nie zostać zapisany do karty. Kopiowanie jest realizowane albo przez funkcję bcopy() (jeśli karta jest wyposażona w pamięć dzieloną), albo przez funkcję ed_pio_write_mbufs() (dev/ed/if_ed.c), która komunikuje się z kartą sieciową poprzez porty I/O. Funkcja ed_start() jest również odpowiedzialna za kilka mniej istotnych czynności. Aby zainicjować fizyczną transmisję pakietu, ed_start() woła funkcję ed_xmit() (dev/ed/if_ed.c). Po zakończeniu jej wykonywania jądro może powrócić z wywołania systemowego. Gdy fizyczna transmisja zostanie zakończona, na komputerach nadawczym i odbiorczym zostanie wygenerowane przerwanie od karty sieciowej. Przy założeniu, że urządzeniem odbiorczym jest również karta NE2000, na obu komputerach sterowanie trafi do funkcji edintr() (dev/ed/if_ed.c), która łączy w sobie funkcjonalność linuksowych funkcji ei_interrupt()ei_tx_intr(): komunikuje się ze sprzętem, aktualizuje statystyki, bada warunki wystąpienia błędów. W funkcji tej można obsłużyć wiele następujących po sobie przerwań karty bez potrzeby zgłaszania ich kontrolerowi przerwań. Na komputerze nadawczym woła się funkcję ed_xmit(), o ile do karty sieciowej załadowano uprzednio następny pakiet przeznaczony do wysłania. Inaczej niż w kodzie Linuksa, w funkcji edintr() nie wykorzystuje się przerwań programowych, aby podjąć próbę wysłania pakietów oczekujących w kolejce wyjściowej urządzenia, lecz woła się funkcję ed_start() bezpośrednio. W efekcie nie trzeba przeglądać kolejek wszystkich urządzeń, ale wydłużeniu ulega czas obsługi przerwania sprzętowego: kopiowanie pakietu do karty sieciowej jest operacją względnie czasochłonną. Na komputerze nadawczym edintr() jest ostatnią funkcją wywoływaną w procesie wysyłania pakietu.

Na komputerze odbiorczym sterowanie trafia do funkcji ed_rint() (dev/ed/if_ed.c), która komunikuje się ze sprzętem, a następnie do ed_get_packet() (dev/ed/if_ed.c). Tutaj alokuje się bufor mbuf i zleca się funkcji ed_ring_copy() (dev/ed/if_ed.c) skopiowanie do niego pakietu. Dalej, po wykonaniu kilku mniej istotnych zadań, usuwa się z pakietu nagłówek warstwy łącza danych i woła się funkcję ether_input() (net/if_ethersubr.c). W tej funkcji ustawia się znaczniki bufora mbuf, aktualizuje się statystyki i bada się rodzaj odebranego pakietu. Wykonywanie tego badania wydłuża nieznacznie czas obsługi przerwania sprzętowego, ale można dzięki temu umieścić pakiet w kolejce wejściowej właściwego protokołu: dla protokołu IP jest to kolejka ipintrq. Za pomocą funkcji setsoftnet() (i386/isa/ipl_funcs.c) zaznacza się konieczność wykonania przerwania programowego21przypisanego sieci (softnet), a zmienną globalną netisr ustawia się tak, aby zaznaczyć konieczność zbadania w procedurze obsługi tego przerwania stanu kolejki wejściowej IP. Na tym kończy się procedura obsługi przerwania sprzętowego.

Teraz sterowanie -- poprzez mechanizm przerwań programowych -- trafia do asemblerowej funkcji swi_net() (i386/isa/ipl.s), w której bada się zmienną netisr i wywołuje się funkcje wejściowe odpowiednich protokołów. Porównując implementację programu obsługi przerwania softnet i dolnej połowy NET_BH łatwo zauważyć, że mechanizm zastosowany we FreeBSD cechuje się mniejszą złożonością. Jak wspomniano powyżej, jest to okupione dłuższym czasem obsługi przerwania sprzętowego.

W rozważanym przypadku został odebrany pakiet IP. Sterowanie trafia do funkcji ipintr() (netinet/ip_input.c), w której dla każdego pakietu z kolejki wejściowej IP woła się funkcję ip_input() (netinet/ip_input.c). Zostaną tu omówione najważniejsze zadania tej funkcji. Po pierwsze, należy przeprowadzić weryfikację poprawności odebranego pakietu i zbadać użytą wersję protokołu IP. Weryfikacja poprawności obejmuje, oprócz różnorakich testów, sprawdzenie sumy kontrolnej nagłówka IP. Po drugie, przekształca się format dwubajtowych pól w nagłówku IP z sieciowej do lokalnej kolejności bajtów. Dalej mogą zostać dokonane różne operacje na pakiecie narzucone przez kod ściany ogniowej, translacji adresów i inne. Następnie w funkcji ip_dooptions() (netinet/ip_input.c) przetwarza się opcje IP. Dalej następuje szereg testów mających określić, czy pakiet jest przeznaczony dla lokalnej maszyny. Jeśli nie jest, to może zostać przekazany funkcji ip_forward() (netinet/ip_input.c) lub ip_mforward() (netinet/ip_mroute.c) albo odrzucony. W rozpatrywanym przypadku pakiet jest przeznaczony dla maszyny lokalnej. Jeśli stanowi fragment większego pakietu, to wyszukuje się kolejkę fragmentów związanych z tym pakietem, dołącza się do niej otrzymany fragment i zleca się funkcji ip_reass() (netinet/ip_input.c) zadanie sklejenia pakietu. Jeśli nie odebrano jeszcze wszystkich fragmentów, to nie można skleić pakietu i funkcja ip_input() kończy działanie. Załóżmy, że pakiet nie był fragmentem lub udało się go skleić. Pozostaje już tylko przekazać pakiet funkcji odbiorczej protokołu warstwy transportowej: tablica inetsw określa indeks w tablicy ip_protox, pod którym można znaleźć strukturę ipprotosw22 opisującą ten protokół. Pole pr_input() struktury ipprotosw zawiera wskaźnik do funkcji odbiorczej protokołu. Należy jeszcze nadmienić, że w skład wymienionych czynności funkcji ip_input() wchodzi aktualizacja rozmaitych zmiennych statystycznych.

Funkcją odbiorczą protokołu TCP jest tcp_input() (netinet/tcp_input.c). Podobnie jak tcp_output(), jest to złożona funkcja (ponad 1900 wierszy kodu) i podobnie jak inne takie funkcje, zawiera dużo instrukcji aktualizujących zmienne statystyczne i dużo instrukcji goto. Pierwszym jej zadaniem jest odrzucenie opcji z nagłówka IP i zapewnienie, że nagłówki IP i TCP będą umieszczone razem w jednym buforze mbuf. Dalej następuje weryfikacja poprawności pakietu, w tym badanie sumy kontrolnej. Potem przekształca się format niektórych pól w nagłówku TCP z sieciowej do lokalnej kolejności bajtów. Przy użyciu funkcji in_pcblookup_hash() (netinet/in_pcb.c) wyszukuje się w tablicy haszującej blok kontrolny protokołu. Funkcja ta może zakończyć się niepowodzeniem, jeśli otrzymano segment TCP przeznaczony dla nieistniejącego połączenia. W rozpatrywanym przypadku połączenie istnieje. Dalej następuje obsługa przychodzących połączeń (nie dotyczy rozpatrywanego przypadku) i wyzerowanie dwóch zegarów TCP. Przetwarzanie opcji TCP jest zlecane funkcji tcp_dooptions() (netinet/tcp_input.c). Dalszy kod implementuje technikę przewidywania nagłówka, ale jest on bardziej złożony od kodu linuksowego, gdyż zajmuje się zarówno pakietami danych, jak i pakietami niosącymi potwierdzenia. Jak podaje Stevens [Stevens 98b], badanie skuteczności przewidywania nagłówka w systemie BSD pokazało, że pozytywne rezultaty osiąga się dla 97%-100% pakietów w sieciach LAN i dla 83%-99% pakietów w sieciach WAN. Jeśli pakiet zawiera dane, a nagłówek jest zgodny z przewidywaniami oraz kolejka otrzymanych segmentów TCP jest pusta, a także kolejka odbiorcza gniazda nie jest przepełniona, to wystarczy usunąć z bufora mbuf nagłówki IP i TCP, dołączyć bufor do kolejki odbiorczej gniazda, zaznaczyć konieczność wysłania potwierdzenia z opóźnieniem i przekazać sterowanie do warstwy gniazd. Podobne czynności wykonuje się w ścieżce wolnej, ale są one poprzedzone przeliczeniem rozmiaru okna odbiorczego TCP, zbadaniem stanu maszyny stanowej TCP, zbadaniem flag zawartych w segmencie TCP, sprawdzeniem rozmaitych możliwości wystąpienia błędu i wykonaniem różnych czynności zalecanych przez rozszerzenia protokołu TCP. Poza tym bada się numery sekwencyjne otrzymanego segmentu i wykonuje się dalsze kroki zawarte w protokole TCP (zob. [Postel 81]), np. obsługę danych pilnych. Jeśli segment przybył poza kolejnością, to zostanie wstawiony do kolejki segmentów związanej z blokiem kontrolnym protokołu, po czym zostanie wywołana funkcja tcp_reass() (netinet/tcp_input.c), która również może przekazać sterowanie do warstwy gniazd, o ile będzie możliwe złożenie otrzymanych danych we właściwej kolejności.

W warstwie gniazd jest wywoływana funkcja sowakeup() (kern/uipc_socket2.c), w której budzi się procesy czekające na pojawienie się danych do odczytu (uśpione np. w funkcji systemowej read() lub select()) i wysyła się sygnał SIGIO do tych procesów, które zażądały asynchronicznego powiadamiania o nadejściu danych. Podobnie jak to ma miejsce w jądrze Linuksa, dalsze przetwarzanie danych odbywa się w kontekście procesu.

Proces użytkownika poprzez mechanizm wywołań systemowych uruchamia funkcję read() (kern/sys_generic.c), w której woła się funkcję dofileread() (kern/sys_generic.c). Działanie tych funkcji jest analogiczne do działania opisanych wcześniej innych funkcji z warstwy VFS: write()dofilewrite(). Dalej wykonywany jest kod funkcji fo_read() (sys/file.h), analogicznej do fo_write().

Następnie sterowanie trafia do funkcji soo_read() (kern/sys_socket.c), w której -- analogicznie do soo_write() -- wywołuje się funkcję wskazywaną przez pole pru_soreceive() struktury pr_usrreqs. Jest nią funkcja soreceive() (kern/uipc_socket.c), leżąca w warstwie gniazd. Podobnie jak sosend(), jest to złożona funkcja i mogą jej używać protokoły o różnej charakterystyce. Jak pokazano wcześniej, podczas obsługi przerwania programowego kod z warstwy protokołów umieścił otrzymane dane w kolejce odbiorczej gniazda. W typowym przypadku w funkcji soreceive() nie trzeba odwoływać się do kodu z warstwy protokołów, aby zaspokoić żądanie procesu. Kod Linuksa jest zorganizowany odmiennie: w kontekście procesu wywołuje się funkcję tcp_recvmsg(), umiejscowioną w warstwie protokołu transportowego. W rozważanym przypadku nadejścia danych zwykłych do połączonego gniazda TCP działanie soreceive() przedstawia się następująco. Najpierw na gniazdo zakłada się blokadę, a poziom działania ustawia się na poziom przerwania sieciowego splnet. Potem wykonuje się testy w celu sprawdzenia, czy należy uśpić proces w oczekiwaniu na dane. W razie potrzeby proces jest usypiany. Po pominięciu gałęzi służących do odczytywania adresu i informacji kontrolnej, kopiuje się dane do przestrzeni użytkownika przy pomocy funkcji uiomove(). Jeśli przestrzeń użytkownika nie jest dostępna dla procesu, to funkcja soreceive() przekazuje błąd. Na czas kopiowania przywraca się poprzedni poziom działania jądra, aby można było obsługiwać asynchroniczne przerwania sieciowe. Jeśli skopiowano wszystkie dane z łańcucha mbuf znajdującego się na początku kolejki odbiorczej gniazda, to łańcuch ten usuwa się z kolejki, a jego pamięć zwalnia się. Następnie podejmuje się decyzję, czy należy kopiować dalsze dane. Jeśli odpowiedź jest pozytywna, to proces ponownie może zostać uśpiony do czasu pojawienia się danych w kolejce odbiorczej. Jeżeli gniazdo ma ustawioną flagę MSG_WAITALL nakazującą odczytać wszystkie żądane dane, to nie zdejmuje się blokady z gniazda podczas oczekiwania na nadejście danych. Aby przetworzyć dalsze dane, przekazuje się sterowanie instrukcją goto na początek funkcji. Gdy żądanie procesu zostanie uznane za zaspokojone, zdejmuje się blokadę z gniazda, a poziom działania jądra przywraca do poprzedniej wartości.

Po skopiowaniu danych do przestrzeni użytkownika operacja przesyłania danych od jednego procesu do drugiego kończy się.

3 Porównanie obu systemów

Na rysunkach 3.1, 3.2, 3.33.4 dokonano schematycznego podsumowania studiów przypadku opisanych w dwóch poprzednich sekcjach. W porównaniu z przedstawionymi wcześniej modelami struktury warstwowej, na rysunkach pojawił się dodatkowy podział warstwy interfejsu na dwie podwarstwy. Celem było odróżnienie funkcji wspólnych dla wszystkich urządzeń sieciowych od funkcji implementowanych przez sterownik karty NE2000. Na rysunkach nie uwzględniono przepływu sterowania, jaki może mieć miejsce w wyniku zajścia asynchronicznych zdarzeń związanych z zegarami TCP lub odebrania innych pakietów sieciowych, np. odpowiedzi na zapytanie ARP.

Jak widać obie implementacje są zbudowane w podobny sposób. Istnienie lub brak podziału warstwy protokołu na warstwy protokołów sieciowego i transportowego jest sprawą czysto umowną.

Dla typowego przypadku ścieżki nadawczej w obu implementacjach dane są kopiowane 2 razy: z przestrzeni użytkownika do buforów sieciowych i z buforów sieciowych do pamięci karty sieciowej. Na ścieżce odbiorczej mają miejsce 2 kopiowania w odwrotnym kierunku.

Do najistotniejszych różnic w architekturze kodu i struktur danych Linuksa i FreeBSD można zaliczyć:

Rysunek 3.1: Przepływ sterowania w kodzie sieciowym Linuksa -- ścieżka nadawcza
\includegraphics[]{rys1linux.eps}

Rysunek 3.2: Przepływ sterowania w kodzie sieciowym FreeBSD -- ścieżka nadawcza
\includegraphics[]{rys1freebsd.eps}

Rysunek 3.3: Przepływ sterowania w kodzie sieciowym Linuksa -- ścieżka odbiorcza
\includegraphics[]{rys2linux.eps}

Rysunek 3.4: Przepływ sterowania w kodzie sieciowym FreeBSD -- ścieżka odbiorcza
\includegraphics[]{rys2freebsd.eps}



Footnotes

... IP9
Ta wersja jest przedmiotem analizy niniejszej pracy.
...poll()10
Zastąpiła ona występującą w poprzednich wersjach operację select().
... protokołów11
W poprzednich wersjach Linuksa struktura socket zawierała wskaźnik void *data, prowadzący do danych prywatnych protokołu. Obecnie wskaźnik ten jest zdefiniowany jako struct sock *sk.
...msghdr12
Dla wiadomości przesyłanych funkcją write() to sprawdzanie nie jest potrzebne, lecz mimo to jest wykonywane.
... nie13
Kopiowanie bufora wraz z danymi można osiągnąć przy użyciu funkcji skb_copy().
...net/ipv4/ip_output.c)14
Inna funkcja byłaby użyta przy nadawaniu do wielu odbiorców (ang. multicasting).
... zegara15
Chodzi o zegar wyzwalający przerwanie zegarowe, a nie o zegar związany z taktowaniem procesora.
... sklejony16
Miejsce defragmentacji pakietów IP zależy od ustawień systemowych.
... potwierdzenia17
W oprogramowaniu TCP ważną rolę pełnią zegary, które asynchronicznie inicjują wykonywanie wielu czynności, np. wysyłanie opóźnionych potwierdzeń.
... transportowej18
W teoretycznym modelu warstwowym kodu Net/3 przedstawionym w rozdziale 2 nie występuje podział na warstwy sieciową i transportową.
...inetsw19
Elementy tej tablicy są typu struct ipprotosw (zdefiniowanego w netinet/ipprotosw.h), który różni się od struct protosw prototypem jednej funkcji. Oryginalny kod 4.4BSD nie definiuje struktury ipprotosw. W systemie FreeBSD funkcji tcp_input() przekazuje się dodatkowy parametr, ale obecnie nie jest on wykorzystywany.
... pilne20
Autorzy jądra BSD przyznają w komentarzu, że świadomie łamią specyfikację protokołu TCP w zakresie semantyki wskaźnika danych pilnych.
... programowego21
Mechanizm przerwań programowych jest zbliżony do mechanizmu dolnych połów w Linuksie.
...ipprotosw22
Struktura zbliżona do struct protosw; zob. opis struktury protosw wcześniej w tym rozdziale.