Einführung in die Netzwerkprogrammierung

aus PUG, der Penguin User Group
Wechseln zu: Navigation, Suche

Allgemeines

In diesem Artikel möchte ich eine kleine Einführung geben, wie man sowohl TCP- als auch UDP-Client-/Server-Programme erstellt. Dazu stelle ich die nötigen System-Calls vor und zeige am Ende des Artikels mehrere kleine Beispiele.

Um diese Einführung so kurz wie möglich zu halten, habe ich im folgenden auf einiges verzichtet:

  • OSI-Modell: Eine ausführliche Erklärung des OSI-Schichtenmodells ist bei Wikipedia zu finden.
  • Protokolle: Beschreibungen von TCP und UDP finden sich ebenfalls bei Wikipedia:
  • Beschreibung der System-Calls: Die genaue Beschreibung ist in den Manpages Abschnitt 2 und 3 zu finden: man 2 <system-call>
  • fork()/Threads: Teilweise ist es nötig einen Sub-Prozess zu erzeugen. Hier benutze ich ein einfaches fork(). Andere Methoden werden hier nicht näher beschrieben.


IPv6

Der Artikel wurde überarbeitet und behandelt nun IPv6. Lediglich die IPv4-Variante der struct sockaddr_in ist der Vollständigkeit halber noch aufgeführt. Alle anderen Datenstrukturen wurden folgendermaßen ersetzt:

IPv4 IPv6 Erklärung
AF_INET AF_INET6 Address-Family
PF_INET PF_INET6 Protocol-Family
sockaddr_in sockaddr_in6 Socket-Address

Die Code-Beispiele am Ende des Artikels sind IPv6- und IPv4-fähig.

fork()

switch(fork()) {
  case -1: 
    //fork fehlgeschlagen
    perror("fork error");
    exit(errno);
  case 0:
    //Code des Sub-Prozesses	 
  default: 
    //Code des Super-Prozesses
 }

struct sockaddr_in6

Zunächst ist es wichtig die struct sockaddr_in6 kennen zu lernen. Mit ihr werden die Daten "Adress-Familie" (AF_INET6), Port-Nummer und IP-Adresse definiert und die Funktionsaufrufe bind, connect, accept bekommen Argumente in dieser Form übergeben. Diese struct ist in <netinet/in.h> folgendermaßen definiert:

struct sockaddr_in6
 {
   __SOCKADDR_COMMON (sin6_);
   in_port_t sin6_port;        /* Transport layer port # */
   uint32_t sin6_flowinfo;     /* IPv6 flow information */
   struct in6_addr sin6_addr;  /* IPv6 address */
   uint32_t sin6_scope_id;     /* IPv6 scope-id */
 };

Diese Struktur wird folgendermaßen benutzt:

#include <netinet/in.h>

struct sockaddr_in6 server_address;

server_address.sin6_family=AF_INET6;
server_address.sin6_flowinfo = 0;
server_address.sin6_port=htons(port);
server_address.sin6_addr=in6addr_any;


struct sockaddr_in

Der Vollständigkeit halber ist hier noch die Definition von struct sockaddr_in für IPv4. Die "Adress-Familie" ist AF_INET.

struct in_addr {
  inn_addr_t s_addr;         /* 32-bit IPv4 address */
                             /* network byte ordered */
};

struct sockaddr_in {
  sa_familiy_t sin_familiy;  /* AF_INET */
  in_port_t    sin_port;     /* 16-bit TCP or UDP port number */
                             /* network byte ordered */
  struct       sin_addr;     /* 32-bit IPv4 address */
                             /* network byte ordered */
  char         sin_zero[8];
};

Diese Struktur wird folgendermaßen benutzt:

#include <netinet/in.h>

struct sockaddr_in server_address;

server_address.sin_family=AF_INET;
server_address.sin_port=htons(port);
server_address.sin_addr.s_addr=htonl(INADDR_ANY);
memset(&server_address.sin_zero, 0, sizeof(server_address.sin_zero));


socket

Der Anfang ist überall gleich, egal ob wir einen Client oder einen Server programmieren wollen und egal ob er TCP oder UDP sprechen soll. Erste Amtshandlung ist immer das öffnen eines Sockets.

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

Als Protokoll-Familie benutzen wir PF_INET6 (IPv6 Protokollfamilie). Für den Protokolltyp beschränken wir uns hier auf zwei Typen:

  • TCP: SOCK_STREAM
  • UDP: SOCK_DGRAM

Das Protokoll selbst ist damit vorgegeben, so daß wir den dritten Parameter auf 0 setzen können.

Beispiel für TCP:

#include <sys/socket.h>

int fd;  /* file descriptor */

if ((fd=socket(PF_INET6, SOCK_STREAM, 0)) < 0) {
  perror("socket error");
  exit(errno);
}


TCP- und UDP-Client

Als TCP-Client müssen wir ein connect ausführen. Wir können danach mit read und write Daten empfangen und senden. Ein UDP-Client muß kein connect ausführen. In diesem Fall arbeitet er mit recvfrom und sendto. Die Angabe von connect ermöglicht einem UDP-Client jedoch mit read und write zu arbeiten.

connect

Als Client öffnen wir also jetzt auf dem oben definierten Socket eine Verbindung zu dem in struct sockaddr_in angegebenen Server.

#include <sys/socket.h>
int connect (int filedescriptor, const struct sockaddr *server_address, socklen_t address_length);

Beispiel:

#include <sys/socket.h>
.
.
.
if (connect(fd, (struct sockaddr*)&server_address, sizeof(server_address)) < 0) {
  perror("connect error");
  exit(errno);
}

TCP-Server

listen

Als TCP-Server hingegen würden wir auf dem angegebenen Port auf einen Verbindungswunsch "lauschen".

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

Die Funktion des Parameters backlog ist als "Maximum actual number of queued connections" angegeben, ist auf jedem UNIX-System unterschiedlich und die Definition füllt mehrere Seiten. Der Einfachheit halber setzten wir sie so hoch wie möglich.

Beispiel:

#include <sys/socket.h>
.
.
.
listen (fd, 15);

accept

Nun wollen wir eine eingehende Verbindung "Akzeptieren":

#include <sys/socket.h>
int accept (int filedescriptor, struct sockaddr *client_address, socklen_t *address_length);

Beispiel:

#include <sys/socket.h>

int fdclient;  /* file descriptor for client connection */
int rlen;
struct sockaddr_in6 remote_address;			
.
.
.
rlen=sizeof(remote_address);
if ((fdclient=accept(fd, (struct sockaddr*)&remote_address, &rlen)) < 0) {
  perror("accept error");
  exit(errno);
}

read

Die Funktionen read und write sollten allgemein bekannt sein. Mit ihnen können wir nun Daten senden und empfangen:

#include <unistd.h>
ssize_t read(int filedescriptor, const void *buffer, size_t buffer_length);

write

#include <unistd.h>
ssize_t write(int filedescriptor, const void *buffer, size_t buffer_length);


UDP-Server

Ein UDP-Server macht kein listen und accept, da keine direkte "Verbindung" aufgebaut wird. Er holt seine Daten einfach per recvfrom aus einer Art Queue.

recvfrom und recv

Um UDP-Daten zu lesen benutzt man recvfrom. Dieses wird genau so benutzt wie ein read, nur das dabei zusätzliche Flags angegeben werden können. Auf eine Erklärung der Flags wird hier verzichtet. Als weiteren Parameter bekommt man in struct sockaddr *from die Adresse des Senders mitgeteilt.

#include <sys/socket.h>
ssize_t recvfrom(int filedescriptor, void *buffer, size_t buffer_length, int flags, struct sockaddr *from, socklen_t *from_length);

Ein recv ist ein recvfrom mit vorherigem connect. Daher fällt die Socketadresse hier weg.

#include <sys/socket.h>
ssize_t recv(int filedescriptor, void *buffer, size_t buffer_length, int flags);

sendto und send

Um UDP-Daten zu lesen benutzt man sendto. Dieses wird genau so benutzt wie ein send, nur das dabei zusätzliche Flags und die Zieladresse angegeben werden müssen.

#include <sys/socket.h>
ssize_t sendto(int filedescriptor, const void *buffer, size_t buffer_length, int flags, const struct sockaddr *to, socklen_t to_length);

Ein vorher ausgeführtes connect macht die Angabe der Zieladresse überflüssig, daher kann in diesem Fall ein send benutzt werden.

#include <sys/socket.h>
ssize_t send(int filedescriptor, const void *buffer, size_t buffer_length, int flags);


close

Natürlich dürfen wir nicht vergessen, den File-Deskriptor am Ende der Verbindung wieder zu schließen:

#include <unistd.h>
int close (int <file descriptor>);


Hilfsfunktionen

Konvertierung zwischen host- und network byte order

Da auf den verschiedenen Architekturen und Betriebssystemen die byte order unterschiedlich ist, benutzt man für das Internet die einheitliche network byte order. Um IP-Adressen und Ports von der lokalen host byte order in die network byte order umzuwandeln gibt es Konvertierungsfunktionen:

#include <netinet/in.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
  • hton bedeutet host-to-network
  • ntoh beteutet network-to-host
  • beide Funktionen gibt es für short int (z.B. Ports) und long int (z.B. IP-Adressen).

IPv6-Adresse in lesbarer Form

Sowohl IPv4 als auch IPv6-Adressen lassen sich in lesbarer Form am besten mit der Funktion network-to-printable ausgeben:

#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

getaddrinfo

Um die IP-Adresse und Adress-Familie zu einem hostname zu bekommen - und umgekehrt - benutzt man die Funktionen getaddrinfo:

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);

void freeaddrinfo(struct addrinfo *res);

const char *gai_strerror(int errcode);

Beispiel:

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int fd, ret;
struct addrinfo hints;
struct addrinfo *result, *rp;
void *in_addr;

/* Suchoptionen angeben */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;     /* Suche nach IPv4 (AF_INET), IPv6 (AF_INET6) oder beidem (AF_UNSPEC) */
hints.ai_socktype = SOCK_STREAM; /* TCP Socket (SOCK_STREAM) oder UDP Socket (SOCK_DGRAM) */
hints.ai_flags = 0;
hints.ai_protocol = 0;           /* Beliebiges Protokoll */

/* Hole Zeiger auf mögliche IP-Adressen */ 
ret = getaddrinfo(Adresse, Port, &hints, &result);
if (ret != 0) {
	fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
	exit(ret);
}

/* Alle gefundenen Adressen ausprobieren */
for (rp = result; rp != NULL; rp = rp->ai_next) {
	fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
	if (fd == -1) 
		continue;
 	if (connect(fd, rp->ai_addr, rp->ai_addrlen) != -1)
       break;                  /* Erfolg */
   close(fd);
}
if (rp == NULL) {               /* Keine Adresse gefunden */
	fprintf(stderr, "Could not connect\n");
	exit(-1);
}
 
/* IP-Familie der gefundenen Adresse ermitteln */
switch (rp->ai_family)
{
	case AF_INET:
	{
		/* IPv4 gefunden */
		struct sockaddr_in *s4 = (struct sockaddr_in *)rp->ai_addr;
		in_addr = &s4->sin_addr;
		break;
	}
	case AF_INET6:
	{
		/* IPv6 gefunden */
		struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)rp->ai_addr;
		in_addr = &s6->sin6_addr;
		break;
	}
}
...
freeaddrinfo(result);           /* Datenstruktur freigeben */

Beispiele

IPv6

Einige Bemerkungen zu den Beispielen:

  • Clients: Der TCP-Client in den Beispielen unterscheidet sich vom UDP-Client dabei nur in der entsprechenden Option im socket-Aufruf. Die Clients nehmen lediglich eine Textfolge von stdin entgegen und senden sie an den Server. Danach warten sie auf eine Antwort vom Server.
  • Server: Was der Server mit den Daten macht, die er vom Client gesendet bekommt, hängt natürlich vom Anwendungsfall ab. Um in unseren Beispielen überhaupt irgendetwas zu machen wandeln wir hier die Textfolge vom Client einfach in Groß-/Kleinbuchstaben um und senden sie zurück.

IPv4

Die (alten) Beispiele unter Benutzung von IPv4: