Wprowadzenie do gniazd BSD

Wstęp

Począwszy od wersji 4.1 systemu UNIX BSD, wprowadzono do niego obsługę protokołów TCP/IP wraz z interfejsem dostępu dla programów o nazwie gniazda (ang. sockets). Interfejs ten jest także dostępny w systemach Linux, a jego zadaniem jest pośredniczenie pomiędzy programami użytkowników, a implementacją stosu TCP/IP.

Do dyspozycji programistów oddano zestaw funkcji bibliotecznych, które służą do obsługi komunikacji pomiędzy procesami działającymi na różnych węzłach w sieci (możliwa jest także komunikacji pomiędzy procesami działającymi lokalnie), które składają się na interfejs sieci. Funkcje te ukrywają przed programistą warstwę transportową oraz wymagają utworzenia specjalnego punktu końcowego kanału komunikacji, które nazywany jest gniazdem. W procesie systemowym gniazdo jest traktowane jak plik specjalny, po jego utworzeniu programista otrzymuje deskryptor, co pozwala na realizację funkcji odczytu i zapisu, w sposób analogiczny jak na pliku (np. read() i write()).

Model oraz trybu komunikacji

Nawiązanie połączenia i rozpoczęcie komunikacji wymaga aby jeden z uczestniczących procesów oczekiwał na zgłoszenia; wówczas inny proces może nawiązać z nim komunikację, w dowolnym, obsługi przez siebie momencie.

Proces, który oczekuje na połączenie nazywany jest serwerem (ang. server). Serwer może działać według dwóch schematów:

  1. po odebraniu połączenia od innego procesu następuje przetwarzanie przyjętego zgłoszenia oraz ewentualna odpowiedź, po tym serwer przechodzi w stan dalszego oczekiwania na nowe zgłoszenia, schemat taki nazywany jest iteracyjnym,
  2. odbieranie połączenia od klientów obsługiwane są przez serwer jednocześnie, np. poprzez wykorzystanie procesów potomnych lub wątków, główny proces serwera w tym czasie może dalej nasłuchiwać nowych połączeń; model ten nazywany jest współbieżnym.

Proces, który nawiązuje połączenie z serwerem nazywany jest klientem. Proces ten musi posiadać informacje o adresie IP serwera oraz porcie TCP lub UDP, na którym serwer nasłuchuje. Proces serwera może uzyskać informacje o kliencie, dopiero po nawiązaniu przez tego połączenia.

Interfejs gniazd obsługuje dwa tryby komunikacji: połączeniowy, za pomocą protokołu transportowego TCP (RFC 793), oraz bezpołączeniowy, z wykorzystaniem protokołu transportowego UDP (RFC 768).

Interfejs gniazd

Korzystanie z mechanizmu komunikacji TCP/IP wymaga utworzenia obiektu zwanego gniazdem, które traktowane jest przez system jako plik specjalny. Gniazdo tworzone jest przy pomocy funkcji bibliotecznej socket(), która w wyniku zwraca deskryptor utworzonego gniazda. Oba komunikujące się procesy muszą utworzyć gniazda, jedno gniazdo może służyć zarówno do odbioru jak i do wysyłania danych. Przez użyciem gniazda należy określić adres oraz port, a także wybrać tryb komunikacji.

Informacja

Na jednym komputerze można utworzyć dwa gniazda z tym samym numerem portu, jednak muszą mieć różne tryby komunikacji.

Główne funkcje interfejsu gniazd

Aby wykorzystać omawiane poniżej funkcje, należy skorzystać z następujących plików nagłówkowych: <sys/types.h>, <sys/socket.h>, a dodatkowo aby korzystać z pozostałych funkcji, należy użyć: <netinet/in.h>, <arpa/inet.h>, <netdb.h>.

int socket(int domain, int type, int protocol)

Funkcja tworzy nowe gniazdo do komunikacji sieciowej, w wyniku zwraca deskryptor gniazda lub wartość -1 w przypadku błędu, strona man: socket(7)

  • int domain określa domenę komunikacyjną i służy do wyboru rodziny protokołów: AF_UNIX i AF_LOCAL dla komunikacji wewnątrzsystemowej, AF_INET oraz AF_INET6 dla komunikacji zdalnej protokołem IPv4 oraz IPv6.
  • int type określa tryb komunikacji: SOCK_STREAM dla gniazd TCP, SOCK_DGRAM dla UDP oraz SOCK_RAW dla gniazd surowych.
  • int protocol określa protokół, najcześciej parametr ten przyjmuje wartość 0
int bind(int sockfd, struct sockaddr *addr, socklen_t addrlen)

Funckja pozwala na związanie wcześniej utworzonego gniazda z adresem lokalnej maszyny i jest używana głównie przez serwery i klientów UDP. Poprawnie wykonana funkcja zwraca 0, błąd oznaczany jest wartością -1.

  • int sockfd to deskryptor gniazda utworzony przez funckję socket()
  • struct sockaddr * addr struktura adresu opisana poniżej
  • socklen_t addrlen rozmiar drugiego parametru
int listen(int sockfd, int backlog)

Funkcja pozwala na przygotowanie gniazda do obierania zgłoszeń i umozliwia określenie rozmiaru kolejki żądań, oczekujących na wykonanie funkcji accept(). Wynikiem poprawnie wykonanej funkcji jest wartość 0, błąd oznaczany jest przez wartość -1.

  • int sockfd to deskryptor gniazda utworzonego przez funckję socket()
  • int backlog to rozmiar kolejki dla nadchodzących żądań
int accept(int sockfd, struct sockaddr *addr, socklen_t * addrlen)

Funkcja oczekuje na zgłoszenie lub pobiera takowe z kolejki dla gniazd strumieniowych. W wyniku otrzymujemy nowy deskryptor gniazda, w przypadku błędu -1

  • int sockfd to deskryptor gniazda utworzonego przez funckję socket()
  • struct sockaddr *addr wskaźnik na strukturę, która zostanie wypełniona danymi połączenia, adresem i numerem portu klienta
  • socklen_t *addrlen wskaźnik na rozmiar struktury adresowej
int connect(int sockfd, const struct sockaddr *srvaddr, socklen_t addrlen)

Funkcja użwana przez klientów, wiąże stworzone gniazdo z adresem serwera. Jeśli gniazdo zostało uwtworzone w trybie połączeniowym, funkcja nawiązuje połączenie z serwerem, jeśli w trybie bezpołączeniowym, funkcja jedynie przypisuje gniazdu adres i numer portu zdalnego serwera.

  • int sockfd to deskryptor gniazda utworzonego przez funckję socket()
  • const struct sockaddr *srvaddr adres zdalnej maszyny
  • socklen_t addrlen rozmiar struktury drugiego patrametru
int close(int fd)

Funkcja zamyka gniazdo i usuwa jego deskryptor, jako parametr podajemy deskryptor zamyknaego gniazda.

Do odbierania i wysyłania danych należy używać “tradycyjnych” funkcji read() oraz write(). W przypadku komunikacji bezpołączeniowej należy używać funkcji recv() oraz send(). Istnieją także ich odpowiedniki, w których jawnie należy podać adres adresata lub wysyłającego: sendto() i recvfrom().

Poniżej przedstawiono struktury używane przez funkcję interfejsu gniazd.

struct sockaddr_in {
  u_short sin_family;       // AF_INET
  u_short sin_port;         // numer portu
  struct in_addr sin_addr;  //adres węzła
  char sin_zero[8]
}

struct in_addr {
  u_long s_addr;           // 32 bitowy adres
}

Informacja

W domenie protokołów internetowych, zamiast sockaddr używamy sockaddr_in, wykonując rzutowanie (struct sockaddr*)

Poniższy rysunek przedstawia kolejność wywołań kolejnych funkcji interfejsu gniazd dla serwera i klienta TCP:

../_images/socket_control_flow.png

Rys.1. Przepływ sterowania dla serwera i klienta TCP.

../_images/udp_flow.png

Rys.2. Przepływ sterowania dla serwera i klienta UDP.

W przypadku klienta i serwera UDP należy wywołać metodę bind.

Funkcje pomocnicze

Dla protokołów TCP/IP przyjęto standard określający sieciowy porządek bajtów [Wiki20131], zgodnie z którym kolejność bajtów reprezentujących liczbę liczony jest od najmniej znaczącego bajtu (czyli od “prawej” strony). Niektóre parametry funkcji interfejsu gniazd należy podawać zgodnie z tym właśnie porządkiem, np. numer portu w strukturze sockaddr_in. Dostępne są zatem funkcje, które umożliwiają konwersję z porządku systemu operacyjnego do porządku sieciowego i odwrotnie. Istnieją dwa zestawy tych funkcji, odpowiednio dla 16 oraz 32 bitowych liczb.

uint32_t htonl(uint32_t hostlong)

Konwersja z reprezentacji lokalnek do sieciowej dla liczb 16 bitowych

uint16_t htons(uint16_t hostshort)

Konwersja z reprezentacji lokalnek do sieciowej dla liczb 32 bitowych

uint32_t ntohl(uint32_t netlong)

Konwersja z reprezentacji sieciowej do lokalnej dla liczb 32 bitowych

uint16_t ntohs(uint16_t netshort)

Konwersja z reprezentacji lokalnej do sieciowej dla liczb 16 bitowych

inet_addr(const char *cp)

Zamiana adresu IP zapisanego w formacie dziesiętnym (XXX.YYY.ZZZ.VVV) na jego reprezentację sieciową

Pozostałe funkcje katalogowe i odwzorowujące:

struct hostent *gethostbyaddr(const char *addr, int len, int type)

Zamiana adresu IP na nazwę domenową

struct hostent *gethostbyname(const char *name)

Zamiana nazwy domenowej na adres IP

int getpeername(int sockfd, struct sockaddr *name, socklen_t *namelen)

Zwraca adres zdalnego komputera, z którym połączone jest gniazdo.

struct protent *getprotobyname(const char *name)

Pobiera informacje o protokole z /etc/protocols na podstawie nazwy i zwracastrukturę protent, zawierającą między innymi numer protokołu (getprotobyname(3)).

struct servent *getservbyname(const char *name)

Pobiera informacje o usłudze z /etc/services na podstawie nazwy i zwraca strukturę servent, zawierającą numer między innymi numer portu (getservbyname(3))

Informacja

Dokładny opis wszystkich funkcji można znaleźć w manualu !!

Interfejs gniazd w systemach Windows

Interfejs gniazd dostępny jest także dla systemów Windows - nosi on nazwę Windows Sockets lub WinSock. Interfejs ten udostępnia dwie grupy poleceń: pierwsza obejmuje funkcje niemal całkowicie zgodne z interfejsem BSD, druga grupa natomiast jest specyficzna sla systemów Windows i charakteryzuje się przedrostkiem WSA_. Definicje funkcji ujęte są w pliku nagłówkowym winsock2.h lub w starszych systemach w winsock.h. Podstawowe różnice to:

  • przed rozpoczęciem pracy należy zainicjować bibliotekę WinSock funkcją WSAStartup(), funkcja ta przyjmuje dwa argumenty wywołania: minimalną wymaganą wersję biblioteki oraz strukturę WSADATA, której postać jest następująca:
typedef struct WSADATA {
  WCRD wVersion;                    //oczekiwany nr wersji
  WCRD wHighVersion;                //najwyższy akceptowany wersji
  char szDescription[WSADESCRIPTION_LEN+1] //opis implementacji
  char szSystemStatus[WSASYS_STATUS_LEN+1] //informacje konfiguracyjne
  unsigned short iMaxSockets;
  unsigned short iMaxUdpDg;
  char FAR* lpVendorInfo;
} WSADATA, *LPWSADATA
  • zakończenie pracy z biblioteką wymaga wywołania bezargumentowej funkcji WSACleanup()
  • część funkcji w wyniku przekazuje wartości innych typów, np. socket() i accept() przekazują w wyniku wartości typu SOCKET
  • zamknięcie gniazda realizowane jest przez funkcję closesocket()
  • odczyt i zapis z gniazda realizowany jest funkcjami recv()/recvfrom() oraz send()/sendto()

Więcej: [WinSock]

Przykłady

Klient usługi daytime

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

#define BUFSIZE 10000

char *server = "127.0.0.1";	/* adres IP pętli zwrotnej */
char *protocol = "tcp";
short service_port = 13;	/* port usługi daytime */

char bufor[BUFSIZE];

int main ()
{
	struct sockaddr_in sck_addr;

	int sck, odp;

	printf ("Usługa %d na %s z serwera %s :\n", service_port, protocol, server);

	memset (&sck_addr, 0, sizeof sck_addr);
	sck_addr.sin_family = AF_INET;
	inet_aton (server, &sck_addr.sin_addr);
	sck_addr.sin_port = htons (service_port);

	if ((sck = socket (PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
		perror ("Nie można utworzyć gniazdka");
		exit (EXIT_FAILURE);
	}

	if (connect (sck, (struct sockaddr*) &sck_addr, sizeof sck_addr) < 0) {
		perror ("Brak połączenia");
		exit (EXIT_FAILURE);
	}

	while ((odp = read (sck, bufor, BUFSIZE)) > 0)
		write (1, bufor, odp);
	close (sck);

	exit (EXIT_SUCCESS);
}

Przykład użycia funkcji katalogowych

// deklaracje
struct sockaddr_in  sck_adr;
struct hostent   *host_ptr;
struct protoent  *protocol_ptr;
struct servent         *service_ptr;

host_ptr = gethostbyname (ADRES_IP);
protocol_ptr = getprotobyname("tcp");
service_ptr = getservbyname("daytime", "tcp");

// wypełnienie struktury
memset(&sck_addr, 0, sizeof sck_addr);
memcpy ((char*) &sck_addr.sin_addr, (char*) host_ptr -> h_addr, host_ptr -> h_length);
      sck_addr.sin_port = service_ptr -> s_port;

// tworzymy gniazdo
sock = socket (PF_INET, SOCK_STREAM, protocol_ptr->p_proto)

Klient UDP - szkielet

...
struct sockaddr_in myaddr, serv_addr;

// struktura adresu własnego
memset(&myaddr, 0, sizeof(struct sockaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = INADDR_ANY; // jakikolwiek adres
myaddr.sin_port = 0; // -> jakikolwiek port wybrany przez system

// struktura adresu serwera
memset(&serv_addr, 0, sizeof(struct sockaddr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("1.2.3.4");
serv_addr.sin_port = htons(atoi("23000"));

sock = socket (PF_INET, SOCK_DGRAM, IPPROTO_UDP);

socklen_t serv_addr_size = sizeof(struct sockaddr);
bind(sock, (struct sockaddr*)&myaddr, sizeof(struct sockaddr));
sendto(sock, buf, strlen(buf+1), 0, (struct sockaddr*)&serv_addr, &serv_addr_size);
recvfrom(sock, buf, strlen(buf+1), 0, (struct sockaddr*)&serv_addr, &serv_addr_size);

close(sock);
...

Serwer UDP - szkielet

...
struct sockaddr_in myaddr, serv_addr;
int nFoo = 1

// struktura adresu serwera
memset(&myaddr, 0, sizeof(struct sockaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = INADDR_ANY;
myaddr.sin_port = htons(atoi("PORT SERVERA"));

sock = socket (PF_INET, SOCK_DGRAM, IPPROTO_UDP);
setsockopt(nSocket, SOL_SOCKET, SO_REUSEADDR, (char*)&nFoo, sizeof(nFoo));

bind(sock, (struct sockaddr*)&myaddr, sizeof(struct sockaddr));
while(1) {
    ...
    recvfrom(
    sendto
    ...
}

close(sock);
...

Serwer daytime - TCP

#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <time.h>

#define SERVER_PORT 1234
#define QUEUE_SIZE 5

int main(int argc, char* argv[])
{
   int nSocket, nClientSocket;
   int nBind, nListen;
   int nFoo = 1;
   socklen_t nTmp;
   struct sockaddr_in stAddr, stClientAddr;


   /* address structure */
   memset(&stAddr, 0, sizeof(struct sockaddr));
   stAddr.sin_family = AF_INET;
   stAddr.sin_addr.s_addr = htonl(INADDR_ANY);
   stAddr.sin_port = htons(SERVER_PORT);

   /* create a socket */
   nSocket = socket(AF_INET, SOCK_STREAM, 0);
   if (nSocket < 0)
   {
       fprintf(stderr, "%s: Can't create a socket.\n", argv[0]);
       exit(1);
   }
   setsockopt(nSocket, SOL_SOCKET, SO_REUSEADDR, (char*)&nFoo, sizeof(nFoo));

   /* bind a name to a socket */
   nBind = bind(nSocket, (struct sockaddr*)&stAddr, sizeof(struct sockaddr));
   if (nBind < 0)
   {
       fprintf(stderr, "%s: Can't bind a name to a socket.\n", argv[0]);
       exit(1);
   }
   /* specify queue size */
   nListen = listen(nSocket, QUEUE_SIZE);
   if (nListen < 0)
   {
       fprintf(stderr, "%s: Can't set queue size.\n", argv[0]);
   }

   while(1)
   {
       /* block for connection request */
       nTmp = sizeof(struct sockaddr);
       nClientSocket = accept(nSocket, (struct sockaddr*)&stClientAddr, &nTmp);
       if (nClientSocket < 0)
       {
           fprintf(stderr, "%s: Can't create a connection's socket.\n", argv[0]);
           exit(1);
       }

           printf("%s: [connection from %s]\n",
                  argv[0], inet_ntoa((struct in_addr)stClientAddr.sin_addr));
           time_t now;
           struct tm *local;
           time (&now);
           local = localtime(&now);
           char buffer[50];
           int n;
           n = sprintf(buffer, "%s\n", asctime(local));
           write(nClientSocket, buffer, n);
           close(nClientSocket);
       }

   close(nSocket);
   return(0);
}

Wątki

Kompilacja z biblioteką pthread z przełącznikiem -lpthread.

#include <pthread.h>

...

sock = socket(...);
bind(sock, ... );
listen(..);

while(is_running) {

  tmp_sck = accept(...)
  pthread_t id;

  pthread_create(&id, NULL, client_loop, sck);

}

...
void* client_loop(void *arg) {
    int rcvd;
    char buffer[1204];
    int sck = *((int*) arg);

    while(rcvd = recv(sck, buffer, 1024, 0) {
      send(sck, buffer, rcvd, 0);
    close(sck);

    pthread_exit(NULL);
}

Blokowanie w celu dostępu równoczesnego za pomocą zamka (pthread_mutex_lock(3p)).

pthread_mutex_lock(&sock_mutex)
.. operacje na współdzielonej strukturze np. client_threds
pthread_mutex_unlock(&sock_mutex)

Przykład klienta z WinSock

#include <winsock2.h>
#include <stdio.h>
#include <stdlib.h>


...

WORD WRequiredVersion;
WSADATA WData;
SOCKET SSocket;



WRequiredVersion = MAKEWORD(2, 0);

WSAStartup(WRequiredVersion, &WData)

SSocket = socket(..);
connect(..);
read(..);

closesocket(SSocket);

WSACleanup();

Zadania

  1. Używając programu telnet lub netcat (komenda nc) przetestuj serwery usług echo oraz daytime uruchomionych na wskazanym przez prowadzącego adresie oraz porcie.
  2. Uruchom program nc w trybie nasłuchiwania na dowolnym porcie. Poproś koleżankę lub kolegę o podłączenie się do ciebie. Wykonaj to także dla protokołu UDP.
  3. Skompiluj klienta (z Klient usługi daytime) i zmień tak, aby połączyć się do usługi daytime (src/daytime_tcp.c).
  4. Przerób klienta aby używał funkcji katalogowych, skorzystać z pomocy z Przykład użycia funkcji katalogowych.
  5. Przerób klienta daytime aby używał protokołu UDP, skorzystaj z pomocy z Klient UDP - szkielet.
  6. Napisz iteracyjny serwer usługi echo lub daytime, sprawdź jak zachowa się przy 2 i więcej połączeniach jednoczesnych.
  7. Przerób serwer tak aby obsługiwał równoczesne połączenia za pomocą wątków, zobacz przykład Wątki oraz przykładowy program src/thread_example.c, race condition src/race_example.c, src/mutex_example.c.
  8. Napisz prostego klienta z pomocą biblioteki WinSock, zobacz przykład winsock.
  9. Napisz klienta w bibliotece Qt.

Literatura

[Wiki20131]http://en.wikipedia.org/wiki/Endianness#Endianness_in_networking
[Tanen]A.S. Tannenbaum: Computer Networks, Prentice Hall PTR, 2003.
[Steve]
    1. Stevens: UNIX programowanie usług sieciowych, WNT, 2002.
[WinSock]http://www.sockets.com/winsock2.htm