Gniazda BSD

Copyright © 2003 Jacek Piotr Nowicki  ( biuro@jpn.hmcloud.pl )
pobierz PDF

Z każdym dniem Internet staje się coraz popularniejszy, a to pociąga za sobą fakt, że programy sieciowe nabierają większego znaczenia. Kilka lat temu programowanie aplikacji sieciowych było prawdziwym wyzwaniem. Programista musiał znać wiele szczegółów dotyczących sieci, aby napisać prostą aplikację działającą w Internecie. Dzisiaj sytuacja wygląda zupełnie inaczej. Istnieją wygodne narzędzia do programowania sieciowego, których możliwości zależą od systemu operacyjnego i języka programowania. Jednym z nich jest interfejs gniazdowy, zwany też gniazdami BSD (ang. Berkeley Software Distribution), wywodzący się z systemu operacyjnego BSD utworzonego na Uniwersytecie w Berkeley. Interfejs gniazd pozwala programiście skoncentrować się na tworzeniu aplikacji, ukrywając trudności związane z programowaniem sieciowym na niskim poziomie.

Celem niniejszej pracy jest przedstawienie teoretycznych i praktycznych podstaw zagadnień dotyczących programowania sieciowego, wykorzystującego interfejs gniazd BSD. Praca składa się z pięciu rozdziałów i jest podzielona na dwie części :

Wszystkie przykładowe programy napisałem w języku ANSI C i przetestowałem na komputerze z zainstalowanym systemem LINUX Red Hat 7.3 (Valhalla). Programy te umieściłem na dyskietce dołączonej do pracy.

Krótki rys historyczny

4 października 1957 roku Związek Radziecki umieścił na orbicie okołoziemskiej pierwszego sztucznego satelitę o nazwie “Sputnik”, bijąc tym samym Amerykanów w dziedzinie podboju kosmosu. W roku 1962 wybuchł kryzys na Kubie. Te wydarzenia spowodowały, że Stany Zjednoczone zaczęły zabezpieczać się przed ewentualną wojną atomową. Rząd amerykański nie ograniczając wydatków na rozwój nowoczesnych technologii powołał do życia agencję ARPA (Advanced Research Project Agency – Agencję do Zaawansowanych Projektów Technicznych), której celem było stworzenie zdecentralizowanego systemu komunikacji (tak jakby bez punktu centralnego), który działałby nawet po częściowym zniszczeniu w ataku nuklearnym części podłączonych do niego urządzeń. Owocem pracy agencji było powstanie w 1969 roku sieci ARPAnet - pierwszej rozległej sieci stworzonej na potrzeby ARPA. Łączyła centra badań i uczelnie, umożliwiając im pracę nad technologiami sieciowymi. Po pewnym czasie ARPA została przekształcona w agencję DARPA (ang. Defense Advanced Research Projects Agency – Agencja do spraw Wojskowych Zaawansowanych Projektów Technicznych).

Równocześnie z rozwojem ARPAnetu, od roku 1969 rozpoczął się rozwój systemu UNIX. Na Uniwersytecie w Berkeley (ang. University of California, Berkeley, w skrócie UCB) utworzono własną odmianę systemu UNIX znaną jako BSD. Rozwijany przez studentów i naukowców system stawał się coraz bardziej funkcjonalny. W miarę upływu czasu pojawiały się jego kolejne wersje, których możliwości i udogodnienia wzrastały w zaskakującym tempie. W efekcie tych prac powstał system 3BSD będący następcą wersji 1BSD i 2BSD. Ten niesamowity rozwój zwrócił uwagę agencji DARPA, która postanowiła dofinansowywać dalsze badania związane z tym systemem. W 1977 roku UCB i DARPA nawiązały współpracę, której celem było stworzenie systemu operacyjnego zdolnego pracować na licznych platformach sprzętowych używanych przez agencję. Specjalna grupa – Grupa Badań nad Systemami Operacyjnymi (ang. CSRG - Computer System Research Group) powołana przez UCB miała na celu dostosowanie systemu BSD do potrzeb agencji. W 1980 roku światło dzienne ujrzał system 4BSD, a następnie 4.1BSD ulepszony pod kątem wydajności. Jednak ARPA na tym nie poprzestała i przedłużyła kontrakt zlecając UCB włączenie do systemu obsługę sieci ARPAnet i poprawienie komunikacji międzyprocesowej. Dalekowzroczni programiści “poszli dalej” i włączyli do systemu obsługę innych protokołów sieciowych. Efektem ich pracy było powstanie w 1983 roku systemu 4.2BSD, wyposażonego w solidne oprogramowanie sieciowe, w którego skład wchodził między innymi interfejs gniazd BSD. W roku 1990 wprowadzono kilka zmian do interfejsu gniazdowego w związku z ukazaniem się systemu 4.3BSD Reno, w którym oprogramowanie protokołów OSI weszło do jądra systemu.

Punktem wyjścia dla wielu wersji systemu UNIX był kod oprogramowania sieciowego jakieś wersji systemu BSD, zawierającego oprogramowanie interfejsu gniazdowego.

Wprowadzenie do gniazd

Rozdział ten poświęcony jest kilku kluczowym pojęciom związanym z tematem gniazd BSD. Większość z nich jest doskonale znana programistom pracującym w środowisku UNIX'a.

Komputer w sieci

System komputerowy ma charakter wspólnoty symbiotycznej – sprzęt i oprogramowanie zależą ściśle od siebie nawzajem i nie mogą działać osobno. Sprzęt składa się z urządzeń peryferyjnych, procesorów, pamięci, dysków i innych urządzeń elektronicznych, które razem stanowią całość komputera. Jednak komputer bez oprogramowania jest bezużyteczny. Oprogramowanie sterujące, niezbędne do działania komputera, nazywamy systemem operacyjnym. Jest to niskopoziomowe oprogramowanie obsługujące sprzęt i zapewniające określony zestaw usług programom użytkowym.

Popularnym systemem operacyjnym, który może pracować w sieci jest system UNIX lub jego nowoczesna implementacja LINUX – wielozoadaniowy i wielodostępowy system operacyjny klasy UNIX, dystrybuowany na podstawie licencji GNU (ang. GNU General Public License) opracowanej przez Free Software Foundation. Pomyślany początkowo jako narzędzie dla hobbystów i profesjonalnych programistów, LINUX staje się w coraz większym stopniu systemem operacyjnym dla “przeciętnego użytkownika”. Będąc zarazem systemem wydajnym, szybkim i darmowym, zdobywa domowe i profesjonalne środowiska komputerowe.

Ściśle związany z UNIX'em (LINUX'em) jest język C, będący językiem ogólnego stosowania. Charakteryzuje się prostotą wyrażeń, nowoczesnym sterowaniem, nowoczesnymi strukturami danych oraz bogatym zestawem operatorów. Język ten opracował i zrealizował Dennis Ritchie dla systemu operacyjnego UNIX działającego na mikrokomputerze DEC PDP-11.

Dysponując komputerem z zainstalowanym systemem operacyjnym (na przykład systemem LINUX), kompilatorem języka C oraz dowolnym edytorem tekstu (na przykład emacs) możemy napisać program. Program jest to plik wykonywalny, utworzony zazwyczaj przy użyciu programu łączącego i znajdujący się w pamięci dyskowej. Program staje się procesem wtedy, kiedy jest wykonywany przez system operacyjny. Często się mówi, że program jest obiektem statycznym, czyli formalnym opisem tego, co ma być wykonane, a proces jest obiektem dynamicznym, czyli ciągiem sekwencyjnie wykonywanych przez system operacyjny instrukcji programu. W jądrze UNIX'a istnieje pewna liczba (zazwyczaj ograniczona) tak zwanych funkcji systemowych (ang. shell call), za pomocą których aktywny proces może uzyskać różne usługi ze strony jądra systemu. Większość funkcji systemowych zwraca wartość całkowitą dodatnią w przypadku pomyślnego wykonania. Jeżeli podczas wykonywania funkcji wystąpi błąd, to funkcja zazwyczaj zwraca wartość -1 , a zmienna globalna errno otrzyma wartość całkowitą dodatnią, wskazującą na rodzaj błędu. Wszystkim dodatnim wartościom zmiennej errno odpowiadają stałe, których nazwy składają się z wielkich liter, zaczynają się od litery E i są zdefiniowane w pliku nagłówkowym . Żaden błąd nie ma wartości 0.

Sieć komputerowa

Sieć komputerowa jest systemem komunikacyjnym łączącym systemy końcowe, zwane stacjami sieciowymi (ang. host computer) . Komputery są połączone jakimś medium fizycznym, na przykład kablem komunikacyjnych. Niezależnie od sposobu połączenia komputerów ze sobą, celem sieci jest zapewnienie komunikacji między poszczególnymi stacjami – komputerami do niej podłączonymi.

Najpopularniejszą, siecią komputerową jest Internet – największa sieć, łącząca sieci komputerowe na całym świecie (“sieć sieci”). Składa się z setek tysięcy kilometrów światłowodów, łącz satelitarnych oraz całej masy mniej lub bardziej skomplikowanym urządzeń. Użytkownicy Internetu, których liczbę szacuje się w milionach, mogą za jego pośrednictwem mieć dostęp do “oceanu” informacji, komunikować się między sobą, wymieniać dane itp.

Większość programów użytkowych, które są przeznaczone do działania w sieci komputerowej, można podzielić na dwie grupy. Pierwsza z nich to aplikacje zwane serwerami, które dostarczają usług w Internecie, drugi zbiór składa się z programów zwanych klientami, które z tych korzystają. Termin serwer powoduje czasem nieporozumienia. Formalnie oznacza on program, który czeka biernie na wykonanie pewnej usługi, a nie komputer, który ją wykonuje. Jeśli jednak jakiś komputer jest wyznaczony do wykonywania jednego lub więcej serwerów, to sam jest czasami (niepoprawnie) nazywany serwerem. Producenci sprzętu komputerowego pogłębiają jeszcze te nieporozumienia, gdyż nazywają serwerami komputery o dostatecznie szybkim procesorze, odpowiednio pojemnej pamięci i stosownie wyrafinowanym systemie operacyjnym.

Komunikacja w sieci oparta jest na protokołach. Protokoły są to formalne reguły postępowania. Na przykład w stosunkach międzynarodowych, protokoły minimalizują problemy wynikające z różnic kulturowych przy współpracy różnych narodów. Zgadzając się na wspólny zestaw ogólnie przyjętych reguł, które są niezależne od jakichkolwiek zwyczajów narodowych, protokoły dyplomatyczne minimalizują nieporozumienia; każdy wie jak się zachować i jak interpretować zachowania innych. Podobnie przy łączności komputerów konieczne jest zdefiniowanie reguł, które rządzą komunikacją w sieci. Ponieważ zdefiniowanie takich zasad jest niesłychanie trudne, Międzynarodowa Organizacja ds. Standardów (ang. International Standards Organization [ISO]) opracowała model architektury służący do opisu zasad komunikacji w sieci. Model odniesienia łączenia systemów otwartych (ang. Open System Interconnect [OSI] Reference Model) gwarantuje uzyskanie wspólnego mianownika dla zagadnień komunikacyjnych. Wszystko co zostało zdefiniowane za pomocą tego modelu jest ogólnie rozumiane i szeroko stosowane przez osoby zajmujące się komunikacją w sieci. Model OSI składa się z siedmiu warstw na których znajduje się jeden lub więcej protokołów, które opisują sposoby realizacji zadań przeznaczonych dla danej warstwy. Zbiór protokołów obowiązujących na różnych warstwach i mogących stanowić podstawę dla użytecznej sieci nazywamy rodziną protokołów (ang. protocol family).

Rodzinę TCP/IP (ang. Transmission Control Protocol / Internet Protocol) będącą rodziną protokołów odpowiedzialną za komunikację w Internecie również możemy przedstawić za pomocą modelu OSI, ale za pomocą czterech warstw : warstwa kanałowa; warstwa sieciowa; warstwa transportowa; warstwa zastosowań. W modelu czterowarstwowym rodzinę protokołów wyznaczają dwie warstwy : transportowa (protokół TCP i protokół UDP) oraz sieciowa (protokół IP). Natomiast w jedną warstwę kanałową są połączone protokoły i cechy charakterystyczne sieci na poziomie jej topologii oraz użytego sprzętu (Ethernet albo Token ring). Przestrzenią programów użytkowych jest zaś warstwa zastosowań.

Do najważniejszych protokołów z rodziny TCP/IP należą :

Aby komputery podłączone do Internetu mogły się ze sobą komunikować, musi istnieć pewien sposób ich identyfikacji. Innymi słowy, każda stacja podłączona do sieci musi mieć przypisany numer identyfikacyjny, który jest niepowtarzalny. Typowy adres stacji składa się z identyfikatora sieci oraz identyfikatora stacji w tej sieci. W Internecie komputery są identyfikowane za pomocą 32-bitowej liczby całkowitej, znanej jako adres IP. Adresy te są wyznaczane przez upoważniony do tego węzeł centralny – Sieciowe Centrum Informacyjne czyli NIC (ang. Network Information Center), zarządzane przez firmę SRI International. Aby adresy IP były łatwo czytelne, zostały podzielone na 8-bitowe liczby zwane oktetami (format ten często jest nazywany kropkową notacją czwórkową). Ponieważ łatwiej zapamiętać nazwy niż liczby, usługa nazewnictwa domen (ang. Domain Name Service – DNS) przyporządkowuje unikatowym adresom liczbowym IP nazwy. Na przykład komputer o nazwie quark.physics.grouch.edu ma adres IP 0x954C0C04, zapisywany jako 146.76.12.4. Nie wszystkie adresy sieci i komputerów są dostępne dla użytkowników. Na przykład adresy, których pierwszy bajt jest większy od 223 są zarezerwowane. Nas będzie najbardziej interesował adres 127.0.0.1 będący adresem sieci zwrotnej (ang. loopback adress). Sieć zwrotna składa się z jednego komputera o nazwie localhost i adresie 127.0.0.1. Oznacza to, że samotny komputer (nie podłączony do żadnej sieci) z zainstalowanym systemem UNIX działa w sieci jednoelementowej, którą stanowi ten pojedyczy komputer. W przykładach opisanych w dalszej części pracy użyłem właśnie sieci zwrotnej. Adres takiego komputera można znaleźć w pliku hostów sieciowych /etc/hosts.

Komputer w sieci może działać jako stacja jednosieciowa lub wielosieciowa, czyli stacja połączona z co najmniej dwiema sieciami. Każda sieć, z którą porozumiewa się komputer, posiada przypisany jej interfejs sprzętowy, przez który następuje dostęp do sprzętu sieciowego. Komputer może mieć odmienne nazwy w każdej z sieci, a z pewnością będzie miał odrębne adresy. Wypływa z tego wniosek, że każdy adres w Internecie określa w jednoznaczny sposób daną stację, ale nie każda stacja musi mieć dokładnie jeden adres.

Znajomość adresu IP danego komputera, czyli umiejętność zlokalizowania go w sieci nie wystarcza do nawiązania komunikacji, ponieważ na komputerze o danym adresie IP może działać wiele aplikacji pełniących rolę serwera. W celu rozróżnienia tych procesów używa się 16-bitowych numerów portów. Zarówno dla protokołów TCP jak i UDP zdefiniowano grupę portów ogólnie znanych (ang. well known ports), przeznaczonych do wykonywania ogólnie znanych usług. Na przykład, w każdej implementacji rodziny protokołów TCP/IP, która pozwala korzystać z protokołu FTP (ang. File Transfer Protocol), serwerowi FTP przypisano ogólnie znany port o numerze 21. Protokół TFTP, czyli prymitywny protokół przesyłania plików (ang. Trivial File Transfer Protocol), ma w protokole UDP przyporządkowany ogólnie znany port 69. Klienci łącząc się z serwerem używają portów efemerycznych (ang. ephemeral port), czyli krótkotrwałych. Numery tych portów są zazwyczaj automatycznie wyznaczane klientom przez protokoły TCP i UDP, które gwarantują, że nie ma portu przypisanego do dwóch procesów jednocześnie i że numer takiego portu jest zawsze większy od numerów dobrze znanych portów. Dokument RFC 1700 zawiera wykaz numerów portów wyznaczonych przez organizację IANA (ang. Internet Assigned Numbers Authority). Każdy numer portu należy do jednego z trzech zakresów :

W systemie LINUX, informacja o tym, jakie usługi odpowiadają jakim portom przechowywana jest w pliku /etc/services. Do najbardziej znanych usług należą :

Model komunikacji klient-serwer

Standardowym modelem komunikacji w sieci jest model “klient-serwer”. Polega on na tym, że jeden proces zwany serwerem dostarcza usługi pod określonym adresem IP i numerem portu. Drugi proces zwany klientem znając adres IP i numer portu serwera, może skorzystać z jego usługi. Przykładowy scenariusz mógłby wyglądać tak :

Definicja gniazda

Kombinacja adresu IP i numeru portu nosi nazwę gniazda (ang. socket). Można powiedzieć, że gniazdo jest programową abstrakcją używaną do reprezentowania końcówki połączenia między dwoma komputerami. Dla każdego połączenia istnieją gniazda na obydwu uczestniczących w nim komputerach (aplikacjach działających na tych komputerach). Wyobraźmy sobie rozciągający się pomiędzy komputerami kabel, którego końce są wpięte do tych gniazd. Formalnie gniazdo powinniśmy zdefiniować jako zbiór trzyelementowy :

Wartością adres-lokalny i adres-obcy są identyfikatory stacji lokalnej oraz stacji odległej. Natomiast elementy proces-lokalny i proces-obcy określają konkretne procesy działające na obu komputerach. Te trzyelementowe zbiory stanowią punkty końcowe komunikacji w sieci zwane półasocjacjami.

Jeżeli rozpocznie się komunikacja między powyższymi komputerami, zostanie utworzony pięcioelementowy zbiór wystarczający do pełnego określenia obu komunikujących się ze sobą procesów.

Ten zbiór nazywamy asocjacją.

Interfejs gniazd

Interfejs wykorzystywany przez program użytkowy przy interakcji z oprogramowaniem protokołów warstwy transportowej nazywa się interfejsem programu użytkowego (ang. Application Program Interface – API). Interfejs API określa zestaw operacji, które program użytkowy może wykonywać w ramach interakcji z oprogramowaniem protokołów. Większość systemów oprogramowania definiuje interfejs API, podając zestaw funkcji, które program może wywoływać, oraz argumentów, których każda z nich się spodziewa. Zwykle API zawiera oddzielną funkcję dla każdej operacji podstawowej. Interfejs API może na przykład zawierać funkcję, która jest wykorzystywana do ustanawiania komunikacji, oraz inną funkcję, która jest używana do wysyłania danych.

Standardy protokołów komunikacyjnych nie określają zwykle interfejsu, którego programy mają używać przy interakcji z nimi. Protokoły określają ogólne operacje, które powinny być udostępnione, oraz pozwalają systemowi operacyjnemu na zdefiniowanie konkretnego interfejsu API, którego programy będą używać do wykonywania tych operacji. Chociaż standardy protokołów pozwalają projektantom systemów operacyjnych na wybranie interfejsu API, to wielu z nich wybrało interfejs gniazd BSD, który stał się standardem interfejsów API.

Atrybuty gniazd

Znając definicje gniazd można zrobić krok naprzód i zająć się podstawowymi parametrami, które mają wpływ na nowo utworzone gniazdo. Gniazda muszą mieć swoją domenę, czyli określone środowisko, w którym będą działać. Powinny mieć typ, czyli sposób komunikowania się w sieci. Zazwyczaj do pełnego zdefiniowania gniazda wystarczają powyższe atrybuty. Czasem zdarzają się sytuację, gdy programista musi ręcznie przypisać gniazdu protokół komunikacyjny. Gniazda mają również swój adres, który jest kojarzony z ich nazwą.

Rodzaj transmisji

Rodzaj transmisji informuje nas w jaki sposób gniazda będą traktować transmitowane dane. Gniazdo może przyjąć jedną z poniższych transmisji :

Domena gniazd

Domena gniazd określa środowisko sieciowe, z którego będą korzystać gniazda oraz sposób zapisu adresów gniazd identyfikujących jeden z końców połączenia komunikacyjnego. Gniazda mogą występować w następujących środowiskach :

Protokół komunikacyjny

Czasem zdarza się, że mechanizm transportowy potrafi korzystać z różnych protokołów komunikacyjnych, aby określić żądany typ gniazda. Wtedy programista może wskazać najwłaściwszy protokół dla danego gniazda. Zaleca się wyrażenie zgody, aby system operacyjny wybrał domyślny protokół komunikacyjny.

Adresowanie gniazd

W tym i następnym rozdziale zajmę się interfejsem gniazd BSD, będącym zestawem funkcji, które mogą być wykorzystane przez program użytkowy do interakcji z oprogramowaniem protokołów warstwy transportowej. Najpierw omówię problematykę adresowania gniazd, co jest chyba najbardziej kłopotliwym aspektem pisania oprogramowania sieciowego.

Gniazda mogą być używane z dowolnymi protokołami, zatem format adresu zależy od tego, który z protokołów będzie użyty. Interfejs gniazd definiuje ogólną postać reprezentacji adresów, a następnie wymaga, aby każda rodzina protokołów określała sposób korzystania z niej przez adresy protokołowe.

Ogólna gniazdowa struktura adresowa

W 1982 roku została zdefiniowana w pliku nagłówkowym ogólna gniazdowa struktura adresowa sockaddr.

struct sockaddr
{
sa_family_t sa_family; // rodzina adresu //
char sa_data[14] // właściwy adres //
}

Obecnie sa_family_t to krótka liczba całkowita (ang. short integer), której długość w systemie LINUX wynosi dwa bajty. Cała struktura ma 16 bajtów długości. Element sa_data[14] struktury reprezentuje 14 bajtów, zawierających dane o adresie.

Ogólna struktura adresu gniazda nie jest specjalnie użyteczna dla programisty. Niemniej dostarcza schematu, do którego muszą się dostosować wszystkie inne struktury adresów.

Adresowanie gniazd w dziedzinie Internetu

Dla każdej rodziny protokołów zdefiniowano właściwą jej gniazdową strukturę adresową. Nazwy tych struktur zaczynają się od członu sockaddr_, po którym występuje drugi człon nazwy, identyfikujący daną rodzinę protokołów. Na przykład rodzina protokołów TCP/IP jako definicji adresu używa struktury sockaddr_in, zdefiniowanej w pliku nagłówkowym .

struct in_addr
{
unsigned long int s_addr; // 32 bitowy adres IP
}

struct sockaddr_in
{
sa_family_t sin_family; // Tutaj należy wpisać domenę AF_INET //
unsigned short int sin_port // 16-bitowy numer portu TCP lub UDP //
struct in_addr sin_addr // 32-bitowy adres IP //
char sin_zero[8] // nie używane (wartości zerowe)
}

Strukturę powinno się wypełniać w następujący sposób :

Adresowanie gniazd w dziedzinie Unix'a

Ten format adresu jest używany w odniesieniu do gniazd, które znajdują się lokalnie w hoście użytkownika (na przykład w komputerze klasy PC z zainstalowanym systemem LINUX). Adres lokalny określony jest za pomocą gniazdowej struktury adresowej dla dziedziny UNIX'a, która nosi nazwę sockaddr_un.

#include
struct sockaddr_un
{
sa_family_t sun_family // Tutaj należy wpisać domenę AF_UNIX //
char sun_path[]; // tutaj wpisujemy ścieżkę pliku //
}

Strukturę powinno się wypełniać w następujący sposób :

Podstawowe funkcje gniazd

W tym rozdziale opiszę podstawowe funkcje wchodzące w skład interfejsu gniazd BSD.

Tworzenie nowego gniazda

Nowe gniazdo można utworzyć za pomocą funkcji systemowej socket().

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

Pierwszy argument domain określa rodzinę protokołów i jest jedną ze stałych wymienionych w tabeli 4.1. Parametr type definiuje typ gniazda i jest jedną ze stałych przedstawionych w tabeli 4.2. Nie wszystkie pary argumentów domain i type są poprawne. Pary poprawne i wybrany dla danej pary protokół przedstawia tabela 4.3. Słowo “tak” w klatce oznacza, że dana para jest poprawna, ale nie istnieje dla niej poprawny akronim.

Dziedzina gniazda Opis
AF_UNIX lub AF_LOCAL Protokoły dziedziny UNIX'a
AF_INET Protokoły rodziny TCP/IP
Tabela 4.1. Rodziny protokołów dla funkcji socket().
Typ gniazda Opis
SOCK_STREAM Gniazdo strumieniowe
SOCK_DGRAM Gniazdo datagramowe
Tabela 4.2. Typy gniazd dla funkcji socket() Tabela 4.2. Typy gniazd dla funkcji socket()
  AF_UNIX (AF_LOCAL) AF_INET
SOCK_STREAM tak TCP
SOCK_DGRAM tak UDP
Tabela 4.3. Pary argumentów domain i type dla funkcji socket().

Mogłoby się wydawać, że po zdefiniowaniu rodziny protokołów i typu gniazda nie ma potrzeby definiowania niczego więcej. Jednak mimo iż dla danej rodziny protokołów i typu gniazda jest tylko jeden protokół, są sytuacje, gdy tych protokołów jest więcej. Parametr protocol pozwala w takiej sytuacji wybrać jeden z dostępnych protokołów. W praktyce programista powinien ustawić argument protocol na wartość 0, co spowoduje, że jądro systemu wybierze domyślny protokół.

Zestawienie parametrów funkcji socket() i ich wpływ na sposób komunikacji przedstawia tabela 4.4.

Domena Typ gniazda Opis
AF_UNIX SOCK_STREAM Dostarcza gniazdo strumieniowe do komunikacji w obrębie lokalnego hosta. Usługa jest połączeniowa i bezpieczna.
AF_UNIX SOCK_DGRAM Dostarcza gniazdo datagramowe do komunikacji w obrębie lokalnego hosta. Usługa jest bezpołączeniowa i zazwyczaj bezpieczna.
AF_INET SOCK_STREAM Stosowane dla potrzeb przesyłania danych przez Internet między dwoma połączonymi gniazdami. Używa się tutaj protokołu TCP, który jest niezawodny.
AF_INET SOCK_DGRAM Służy do przesyłania danych przez Internet między dwoma niepołączeniowymi gniazdami. Używa się tutaj protokołu UDP, który nie gwarantuje bezpieczeństwa przesyłania danych.
Tabela 4.4. Zestawiene parametrów funkcji socket() związanych z komunikacją lokalną i internetową

Jeżeli powiedzie się wykonanie funkcji socket() to przekaże ona małą nieujemną liczbę całkowitą, która jest deskryptorem gniazda (ang. socket descriptor) i służy jako element identyfikujący to gniazdo we wszystkich następnych wywołaniach funkcji systemowych (np. dla funkcji connect(), którą opiszę później). W przypadku błędu funkcja zwróci wartość -1 i ustawi zmienną errno na wartość całkowitą dodatnią wskazującą na rodzaj błędu. Wybrane stałe opisujące rodzaj błędu przedstawia tabela 4.5.

Errno Opis
EINVAL Błędne dane
EPROTONOSUPPORT Wartość typu i protokołu nie są dozwolone w podanej dziedzinie gniazda
EMFILE Przepełniona tablica deskryptorów procesu
ENFILE Przepełniona tablica plików
EACCES Brak uprawnień do utworzenia gniazda o podanych parametrach.
ENOSR Brak zasobów systemowych
ENOBUFS Brak pamięci na bufory gniazda
Tabela 4.5. Wybrane stałe opisujące rodzaj błędu dla funkcji socket().

Związanie gniazda z adresem

Gniazdo utworzone za pomocą funkcji socket() jest anonimowe. Aby związać to gniazdo z danym adresem protokołowym należy zastosować wywołanie systemowe bind().

#include <sys/types.h>
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *adress, socklen_t adress_len)

Funkcja bind() przypisuje adres określony parametrem adress do nienazwanego gniazda o deskryptorze socket. Długość struktury adresowej adress jest przekazywana w argumencie adress_len. Dla gniazd internetowych adres składa się z 32-bitowego adresu IP oraz 16-bitowego numeru portu TCP lub UDP. Natomiast dla gniazd lokalnych adresem jest po prostu nazwa pliku. Wywołując funkcję bind() dla gniazda internetowego możemy określić : numer portu, adres IP, albo obie te wielkości, albo też żadnej nie podawać.

Adres IP Numer portu Wynik funkcji bind()
Brak Brak Jądro systemu wybiera adres IP oraz numer portu.
Brak Podany Jądro wybiera adres IP, proces określa numer portu.
Podany Brak Proces określa adres IP, jądro wybiera numer portu.
Podany Podany Proces określa adres IP oraz numer portu.
Tabela 4.6. Sposób działania funkcji bind() w zależności od określenia adresu IP i numeru portu.

Jeżeli wywołując funkcję bind() nie określimy numeru portu, to jądro systemu wybierze port efemeryczny dla danego gniazda, jeśli będzie wywołana funkcja connect() (dla gniazda klienta) lub listen() (dla gniazda serwera). W przypadku nieokreślenia adresu IP, jądro systemu wybierze adres źródłowy IP wtedy, kiedy gniazdo będzie połączone. Jednak procesy pełniące rolę serwerów nie powinny wyrażać zgody, aby jądro systemu dokonywało za nich wyboru, ponieważ są one zazwyczaj rozpoznawane na podstawie ich ogólnie znanych numerów portów.

Errno Opis
EBADF Błędny deskryptor
ENOTSOCK Deskryptor nie odnosi się do gniazda
EINVAL Deskryptor odnosi się do już nazwanego gniazda
EADDRNOTAVAIL Adres jest niedostępny
EADDRINUSE Adres ma już przypisane gniazdo
EACCESS Nie można utworzyć nazwy w systemie plików ze względu na brak zezwolenia (dotyczy gniazd AF_UNIX)
ENOTDIR Niefortunnie wybrana nazwa pliku (dotyczy gniazd AF_UNIX)
ENAMETOOLONG Niefortunnie wybrana nazwa pliku (dotyczy gniazd AF_UNIX)
Tabela 4.7. Wybrane stałe opisujące rodzaj błędu dla funkcji bind().

W przypadku pomyślnego wykonania funkcja zwraca 0. W przypadku błędu, zwraca -1 i ustawia errno na wartość całkowitą dodatnią wskazującą na rodzaj błędu.

Proces może dowiązać konkretny adres IP do swojego gniazda jeżeli ten adres należy do interfejsu danej stacji. W wypadku procesu klienta dowiązany konkretny adres IP staje się adresem źródłowym IP, którego używa się dla datagramów IP wysyłanych z tego gniazda. W przypadku procesu serwera takie dowiązanie powoduje, że gniazdo może przyjmować tylko te nadchodzące od klientów połączenia, które są przeznaczone dla tego adresu IP. Aby umożliwić działanie serwerów na komputerach wielosieciowych, interfejs gniazd zawiera specjalną stałą symboliczną, INADDR_ANY, która pozwala, aby serwer korzystał z danego portu na każdym z adresów IP danego komputera.

Pobierz przykład - związanie gniazda z adresem internetowym za pomocą funkcji bind()

Tworzenie kolejki na gnieździe

Wywołując funkcję listen(), przekształcamy gniazdo połączeniowe serwera utworzone za pomocą funkcji socket() w gniazdo nasłuchujące, w którym przychodzące od klientów połączenia będą akceptowane przez jądro systemu. Te następujące po sobie wywołania trzech funkcji : socket(), bind() i listen() to trzy czynności, które trzeba wykonać dla każdego serwera działającego zgodnie z protokołem TCP.

#include <sys/types.h>
#include <sys/socket.h>
int listen(int socket, int backlog)

Pierwszym argumentem funkcji jest deskryptor gniazda serwera, natomiast używając parametru backlog możemy ograniczyć maksymalną liczbę oczekujących na obsłużenie klientów, którzy mogą być przetrzymywani w kolejce. Próby połączenia przekraczające tę liczbę będą odrzucane przez jądro systemu. Dawniej w przykładowych program używano zawsze liczby pięć jako argumentu backlog, ponieważ była to największa jego wartość dopuszczana w systemie 4.2BSD. Było to dobre rozwiązanie w latach osiemdziesiątych, kiedy serwery obsługiwały tylko kilkaset połączeń dziennie. Dzisiaj kiedy obciążone serwery mogą obsługiwać nawet kilka milionów połączeń dziennie, tak mała wartość parametru backlog okazuje się zupełnie nieodpowiednia.

Funkcja listen() zwraca 0 w przypadku pomyślnego wykonania. W przypadku błędu funkcja zwraca wartość -1, a jego przyczynę będzie można ustalić poprzez zbadanie zmiennej errno.

Errno Opis
EBADF Argument socket nie jest poprawnym deskryptorem pliku.
ENOTSOCK Argument socket nie jest deskryptorem gniazda.
EOPNOTSUPP Gniazdo o deskryptorze socket nie zezwala na wykonanie tej operacji.
Tabela 4.8. Wybrane stałe opisujące rodzaj błędu dla funkcji listen().

Akceptowanie połączeń

Serwer używający gniazda połączeniowego wywołuje funkcję accept(), aby z wierzchu kolejki utworzonej przez funkcję listen() pobrała następne oczekujące połączenie. Jeżeli kolejka jest pusta, to proces serwera zostanie uśpiony.

#include <sys/types.h>
#include <sys/socket.h>
int accept(int socket, struct sockaddr *adress, socklen_t adress_len)

Pierwszym argumentem jest jak zwykle deskryptor gniazda serwera utworzonego przez funkcję socket(). Adres wywołującego klienta zostanie umieszczony w strukturze sockaddr, na którą wskazuje argument adress. Długość tej struktury znajduje się w parametrze adress_len. Jeżeli adres klienta będzie dłuższy niż ta wartość, zostanie on obcięty. Jeżeli podczas wykonywania funkcji accept() nie wystąpi błąd, to wartość przekazana przez funkcję będzie deskryptorem nowego gniazda, automatycznie utworzonego przez jądro systemu i mającego ten sam typ co gniazdo utworzone przez funkcję socket(). Omawiając funkcję accept() mamy do czynienia z dwoma gniazdami. Pierwszy argument funkcji jest deskryptorem pierwszego gniazda, zwanego gniazdem nasłuchującym (ang. listening socket), utworzonego przez funkcje socket() i następnie użytego w wywołaniach systemowych bind() oraz listen(). Natomiast wartością przekazaną przez funkcję accept() jest deskryptor gniazda zwanego gniazdem połączonym (ang. connected socket).

To rozróżnienie gniazd jest bardzo ważne. Serwer zazwyczaj tworzy tylko jedno gniazdo nasłuchujące, które istnieje przez cały czas jego działania. Wywołanie accept() powoduje utworzenie gniazda połączonego dla każdego połączenia z klientem, które zostało zaakceptowane. Celem takiego gniazda jest tylko komunikacja z danym klientem. Kiedy serwer zakończy obsługę tego klienta, wtedy połączone gniazdo zostanie zamknięte.

Żądanie nawiązania połączenia

Klient korzystający z gniazda połączeniowego używa funkcji connect() do ustanowienia połączenia z serwerem.

#include <sys/types.h>
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *adress, socklen_t adress_len)

Argument socket oznacza deskryptor gniazda, który przekazuje funkcja socket(). Drugi i trzeci argument to wskaźnik do gniazdowej struktury adresowej i jej rozmiar. W gniazdowej strukturze adresowej musi znajdować się adres serwera. Jeżeli komunikacja odbywa się w środowisku Internetu to musimy podać adres IP oraz numer portu serwera, jeżeli komunikacja odbywa się wewnątrz jednego systemu operacyjnego to podajemy nazwę pliku związaną z gniazdem serwera. Przed wywołaniem funkcji connect() klient nie musi wywoływać funkcji bind(), ponieważ w razie potrzeby jądro systemu samo wybierze dla niego port efemeryczny i źródłowy adres IP. Powrót z funkcji nastąpi tylko wtedy, kiedy będzie ustanowione połączenie z serwerem albo pojawi się błąd. Jeżeli połączenie klienta z serwerem nie może być od razu zrealizowane, funkcja connect() zablokuje się na jakiś nieokreślony czas. Po upłynięciu limitu czasu połączenie zostanie zaniechane i funkcja zwróci błąd.

Wartością zwracaną przez funkcję connect() jest zero w przypadku pomyślnego wykonania oraz -1 w przeciwnym przypadku. W razie błędu zmienna errno przyjmie dodatnią wartość całkowitą.

Errno Opis
EBADF W parametrze socket określono niepoprawny deskryptor.
EALREADY Gniazdo nawiązało już połączenie.
ECONNREFUSED Serwer odrzucił próbę połączenia.
EINTR Wywołanie connect zostało przerwane przez sygnał.
Tabela 4.9. Wybrane stałe opisujące rodzaj błędu dla funkcji connect().

Funkcje systemowe read() i write()

Działanie tych funkcji na deskryptorze gniazda jest analogicznie do ich działania na deskryptorach plików. Funkcje te służą do wymiany danych między dwoma gniazdami już połączonymi (transmisja połączeniowa).

#include <unistd.h>
int read(int sockfd, char *buf, int nbytes)
int write(int sockfd, char *buf, int nbytes)

Parametr sockfd określa deskryptor gniazda, utworzony przez funkcję socket() lub accept(). Argument buf jest wskaźnikiem do bufora, zawierającego dane do wysłania przez funkcje write(), lub pod którym zostaną umieszczone dane do odebrania przez funkcję read(). Parametr nbytes definiuje liczbę bajtów do wysłania przez funkcję write() lub maksymalną liczbę bajtów, którą można jednorazowo odebrać, wywołując funkcję read().

Funkcja zwraca liczbę przeczytanych lub zapisanych bajtów w przypadku sukcesu lub -1 w razie błędu. Liczba odebranych/wysłanych bajtów może być mniejsza niż wartość nbytes.

Funkcje systemowe sendto() i recvfrom()

Funkcje sendto() i recvfrom() są używane do komunikacji procesów klienta i serwera przy użyciu gniazd bezpołączeniowych. Klient nie ustanawia połączenia z serwerem. W zamian klient po prostu wysyła datagram do serwera, korzystając z funkcji sendto(), która wymaga podanie adresu docelowego (adresu serwera) jako argumentu wywołania. Podobnie też serwer nie akceptuje połączenia z klientem. W zamian serwer po prostu wywołuje funkcję recvfrom(), która czeka, aż nadejdą dane od jakiegoś klienta. Funkcja recvfrom() przekazuje adres protokołowy klienta razem z datagramem, więc serwer może wysłać odpowiedź do właściwego klienta. W sensie konstrukcyjnym, powyższe funkcje są podobne do funkcji read() i write(), ale mają trzy dodatkowe argumenty.

#include <sys/types.h>
#include <sys/socket.h>
int sendto(int sockfd,char *buff, int nbytes, int flags, struct sockaddr *to, int addrlen);
int recvfrom(int sockfd,char *buff, int nbytes, int flags, struct sockaddr *from, int *addrlen);

Pierwsze trzy argumenty sockfd, buff oraz nbytes są takie same jak pierwsze trzy argumenty funkcji read() oraz write() i oznaczają odpowiednio : deskryptor, wskaźnik do bufora, do którego się pobiera lub z którego się odsyła dane, oraz liczbę pobranych lub odesłanych bajtów. Argument flags określający sygnalizatory jest przeważnie równy 0. Argument to funkcji send() wskazuje na gniazdową strukturę adresową, zawierającą adres protokołowy (adres IP oraz numer portu dla gniazd internetowych lub nazwę pliku dla gniazd lokalnych), pod który mają być wysłane dane. Rozmiar tej struktury jest określony przez argument addrlen. Funkcja recvfrom() umieszcza adres protokołowy nadawcy datagramu w gniazdowej strukturze adresowej wskazywanej przez argument from. Liczba bajtów zapamiętanych w tej strukturze będzie również przekazana do programu, który wywołał funkcję recvfrom(), jako liczba całkowita wskazywana przez argument addrlen. Zauważmy, że ostatni argument przekazywany do funkcji sendto() jest liczbą całkowitą, podczas gdy ostatni argument funkcji recvfrom() jest wskaźnikiem do wartości całkowitej. Dzieje się tak, ponieważ ostatni argument funkcji recvfrom() służy również do przekazywania dodatkowego wyniku tej funkcji. Obie funkcje jako swoją wartość przekazują rozmiar danych lub odesłanych.

Zamykanie gniazda

Możemy zamknąć połączenie od strony klienta lub serwera, używając funkcji close().

#include <unistd.h>
int close(int socket)

Funkcja zawiera tylko jeden parametr będący deskryptorem zamykanego gniazda. Jeżeli zamykane gniazdo jest związane z protokołem zapewniającym niezawodne doręczenie danych (na przykład protokół TCP), to system operacyjny musi zadbać o to, aby wysłać wszystkie dane, które pozostają wewnątrz jądra, a muszą być przesłane. Zazwyczaj następuje natychmiastowy powrót z funkcji systemowej close() do systemu, ale jądro próbuje jeszcze wysłać wszystkie dane znajdujące się w kolejce. Funkcja zwróci wartość zero, jeżeli gniazdo zostanie zamknięte z powodzeniem.

Większe możliwości daje funkcja shutdown(), która pozwala zlikwidować połączenie całkowicie lub częściowo.

#include <sys/socket.h>
int shutdown(int socket, int how)

Argument socket oznacza deskryptor gniazda, a parametr how określa sposób likwidacji połączenia. Dopuszczalne są następujące wartości argumentu how :

Przykład 1

W poprzednich podrozdziałach omówiłem interfejs programowy gniazd, uwzględniając poszczególne funkcje, ich parametry i spełniane przez nie operacje na gniazdach. Teraz będę dalej omawiał ten interfejs, analizując przykładowe programy klienta i serwera komunikujące się przy pomocy gniazd. Dla zmniejszenia rozmiaru przykładowych programów i uwypuklenia odwołań do funkcji interfejsu gniazdowego, zdecydowałem się na implementację bardzo prostej usługi :

Wszystkie przedstawione poniżej programy będą wykonywać te same zadanie wykorzystując różne typy gniazd (strumieniowe, datagramowe) i działając w różnych środowiskach (system LINUX, Internet).

Pierwszy przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd połączeniowych. Środowiskiem gniazd jest system LINUX.

Pobierz przykład - serwer
Pobierz przykład - klient

Opis powyższego programu : od strony serwera.

Opis powyższego programu (od strony klienta)

Przykład 2

Drugi przykład jest analogiczny do pierwszego, z tą różnicą, że środowiskiem gniazd jest teraz Internet.

W tym przykładzie serwer i klient pracują w sieci. Ale nie należy zakładać, że gniazda sieciowe bywają użyteczne tylko w sieci złożonej z wielu komputerów z zainstalowanym systemem UNIX (LINUX). Należy pamiętać, że nawet pojedynczy komputer połączonym z Internetem (za pomocą modemu) może korzystać z gniazd, aby porozumiewać z innymi komputerami. A co więcej można używać sieciowych programów na izolowanych komputerze, ponieważ większość maszyn z zainstalowanym system UNIX (LINUX) skonfigurowana jest do korzystania z sieci zwrotnej (ang. loopback network), która zawiera tylko samą siebie. Sieć zwrotna składa się z jednego komputera, o nazwie localhost i adresie IP 127.0.0.1. Ponieważ komputer na którym pisałem i testowałem ten program jest komputerem izolowanym (nie podłączonym do żadnej sieci), wykorzystałem w tym przykładzie adres sieci zwrotnej.

Pobierz przykład - serwer
Pobierz przykład - klient

Opis powyższego programu : od strony serwera.

Opis powyższego programu : od strony klienta

Przykład 3

Trzeci przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd datagramowych. Środowiskiem jest system LINUX

Pobierz przykład - serwer
Pobierz przykład - klient

Opis powyższego programu : od strony serwera.

Opis powyższego programu : od strony klienta

Przykład 4

Ostatni przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd datagramowych. Teraz środowiskiem jest Internet.

Pobierz przykład - serwer
Pobierz przykład - klient

Opis powyższego programu : od strony serwera.

Opis powyższego programu : od strony klienta.

Przykład 5 - serwer współbieżny

W poprzednich rozdziałach zajmowałem się implementacją systemów opartych na architekturze “klient-serwer”, w których serwer był iteracyjny, czyli mógł aktualnie obsługiwać tylko jednego klienta. Teraz omówię serwery współbieżne, czyli takie, które w danej chwili potrafią obsłużyć jednocześnie wielu klientów. Najpierw należy zdefiniować funkcję fork(). Wywołanie tej funkcji jest jedynym sposobem utworzenia nowego procesu. Funkcja ta tworzy kopię tego procesu, który wywołał funkcję fork(). Proces wywołujący tę funkcję nazywa się procesem macierzystym (ang. parent process), a nowy proces utworzony przez funkcję fork() nazywany jest procesem potomnym (ang. child process). Specyfika tej funkcji polega na tym, że związane są z nią dwa powroty. Najpierw powraca ona do procesu macierzystego, który ją wywołał i przekazuje mu identyfikator nowo utworzonego procesu potomnego. Następnie powraca do procesu potomnego i przekazuje mu wartość zero. Dlatego na podstawie wartości przekazanych przez funkcję fork() można odróżnić proces macierzysty od potomnego. W praktyce proces macierzysty wywołuje funkcję fork(), aby on mógł się zając jedną operacją, podczas gdy proces potomny (jego kopia) mógłby wykonywać inne zadanie. Ważny jest tutaj fakt, że wszystkie otwarte deskryptory (między innymi deskryptory gniazd), które były otwarte w procesie macierzystym przed wywołaniem funkcji fork(), będą po powrocie z niej dostępne również dla procesu potomnego. Właśnie ze względu na powyższą własność tej funkcji, zdecydowałem się wykorzystać ją do implementacji serwera współbieżnego.

Pobierz przykład - serwer
Pobierz przykład - klient

Bibliografia


Coppyright © 2018 Jacek Piotr Nowicki