Tour 2: "tcp-client"

Überblick

Als erstes "richtiges" Programm wollen wir einen Client für das Daytime-Protokoll nach RFC 867 schreiben. Dieses Protokoll ist furchtbar simpel: es wird eine TCP-Verbindung zu Port 13 aufgebaut, der Server sendet das aktuelle Datum nebst Uhrzeit an den Client und schließt danach die Verbindung wieder.

Um das Programm auszuprobieren kommen verschiedene Optionen in Betracht:

In unserem Programm müssen wir die folgenden Aufgaben lösen:

  1. Den auf der Kommandozeile übergebenen Hostnamen in eine IP-Adresse auflösen
  2. Einen TCP-Socket erzeugen
  3. Den Socket mit Port 13 des Ziel-Hosts verbinden
  4. Die gesendeten Daten empfangen und ausgeben
  5. Die Verbindung schließen

Die wichtigsten Stellen werden im Folgenden besprochen, auf der Seite Beispiel-Programme sind die vollständigen Programme zu finden.

Den Hostnamen auflösen

Hostnamen sind eine tolle Sache, denn sie ersparen einem sich IP-Adressen direkt merken zu müssen. Außerdem bieten sie Dienstanbietern eine einfache Form der Lastverteilung. Der bereits genannte Hostname time.nist.gov beispielsweise wird mit jedem Aufruf in eine andere IP-Adresse aufgelöst, sodass nicht alle Anfragen beim selben Server rauskommen, sondern auf einen ganzen Pool von Servern verteilt werden.

Programmtechnisch geschieht das Auflösen eines Hostnamens durch einen Aufruf der Funktion gethostbyname(). Das sieht beispielsweise so aus:

struct hostent *host = gethostbyname(name);
struct in_addr addr = *(struct in_addr*)host->h_addr;

Ein erfolgreicher Aufruf von gethostbyname() liefert einen Zeiger auf eine Struktur vom Typ hostent. Diese hat folgenden Aufbau:

struct hostent {
        char  *h_name;        /* official name of host */
        char **h_aliases;     /* alias list */
        int    h_addrtype;    /* host address type */
        int    h_length;      /* length of address */
        char **h_addr_list;   /* list of addresses */
};
#define h_addr h_addr_list[0] /* for backward compatibility */

Die Elemente sind in der Befehlsreferenz zu gethostbyname detailliert erklärt. Für uns ist zunächst das Element h_addr interessant. Es ist ein Zeiger auf eine IP-Adresse in ihrer binären Form, also eigentlich eine vorzeichenlose 32-Bit-Zahl. Für die weitere Verwendung in einem Socket-Programm eignet sich aber die Struktur in_addr meist besser, die binärkompatibel ist:

typedef uint32_t in_addr_t;

struct in_addr {
        in_addr_t s_addr;
};

Doch was passiert, wenn der Aufruf nicht erfolgreich ist, z.B. weil ein nicht-existierender Hostname benutzt wurde? In diesem Fall liefert gethostbyname() einen Nullzeiger zurück. Aber anders als die anderen Socket-Funktionen wird nicht der Mechanismus der globalen Fehlervariable errno genutzt, sondern eine eigene Veriable mit Namen h_errno. Das hat historische Gründe, weil der Resolver (die Systemkomponente, die für die Namensauflösung zuständig ist) nicht Teil der C-Bibliothek ist und somit ggf. ganz andere Fehlercodes benutzt. Folgerichtig gibt es auch eine andere Funktion, um die Fehlercodes in Zeichenketten zu übersetzen: hstrerror().

fprintf(stderr, "Couldn't resolve hostname: %s\n", hstrerror(h_errno));

Unser Programm gibt zur Kontrolle die IP-Adresse auf der Konsole aus, in der sogenannten dotted notation, also beispielsweise 192.168.2.1. Diese Umwandlung wird von der Funktion inet_ntoa() besorgt, die als Parameter die bereits erwähnte Struktur in_addr erwartet:

printf("Connecting to %s:%u...", inet_ntoa(addr), DAYTIME_PORT);

Sowohl gethostbyname() als auch hstrerror() und inet_ntoa() geben übrigens Zeiger auf statische Variablen zurück, d.h. sie sind nicht reentrant. Der Aufrufer muss dafür sorgen, dass die Ergebnisse an einen dauerhaften Ort kopiert werden, wenn sie nach einem erneuten Aufruf der jeweiligen Funktion noch zur Verfügung stehen sollen. Das ist insbesondere wichtig, wenn z.B. zwei Aufrufe von inet_ntoa() für eine einzige Ausgabe via printf() benutzt werden sollen -- das geht nämlich nicht! (Die Funktion wird erst zwei mal aufgerufen bevor am Ende printf() aufgerufen wird, was dann wiederum zweimal das Ergebnis der letzten Adressumwandlung ausgibt.)

Einen TCP-Socket erzeugen

Einen Socket haben wir bereits im letzten Beispiel erzeugt. Hier soll noch einmal auf ein paar Details eingegangen werden.

int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
{
        fprintf(stderr, "Couldn't create socket: %s\n", strerror(errno));
}

Dieser Befehl erzeugt einen neuen, nicht verbundenen Socket der Adress-Familie INET (Internet). Es gibt auch einen Satz Konstanten mit dem Präfix PF für Protokoll-Familie. Die Kurzfassung: ist egal, sind identisch. Neben der INET-Familie gibt es noch weitere, am interessantesten ist noch AF_LOCAL (synonym zu AF_UNIX) für lokale Sockets die nur zwischen Prozessen auf dem gleichen System kommunizieren können. In diesem Tutorial werden wir immer AF_INET benutzen.

Der Parameter SOCK_STREAM teilt dem System mit, dass wir einen Bytestrom übertragen wollen, in Abgrenzung zu SOCK_DGRAM für die Übertragung einzelner Datagramme. Streng genommen gibt das noch nicht vor, welches Protokoll tatsächlich benutzt werden soll. Dafür ist der letzte Parameter gut, für den wir 0 übergeben. In der Praxis ist es aber immer so, dass SOCK_STREAM zu einem TCP-Socket und SOCK_DGRAM zu einem UDP-Socket führt. Ich habe jedenfalls in 20 Jahren nichts anderes gesehen.

Wie bei den meisten UNIX-Systemaufrufen gibt auch socket() im Fehlerfall -1 zurück. Den genauen Grund erfährt man über die Variable errno, die mittels strerror() in einen lesbaren Text übersetzt wird. Außer erschöpften Systemressourcen gibt es keinen Grund, warum dieser socket()-Aufruf fehlschlagen sollte.

Einen TCP-Socket verbinden

Um einen TCP-Socket zu verbinden steht die Funktion connect() zur Verfügung. (Achtung bei Programmen die das Qt-Framework benutzen -- hier gibt es ebenfalls eine connect()-Methode die etwas völlig anderes macht; in diesem Fall am Besten immer ::connect() schreiben.)

struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(DAYTIME_PORT);
saddr.sin_addr = addr;
if (connect(sock, (struct sockaddr*) &saddr, sizeof(saddr)) == -1)
{
        fprintf(stderr, "Couldn't connect to host: %s\n", strerror(errno));
}

Hier sehen wir eine neue Struktur, sockaddr_in. Anders als in_addr handelt es sich nicht um eine Internet-Adresse, sondern um eine Socket-Adresse. Um genau zu sein um eine Socket-Adresse für Sockets der Familie AF_INET. Zunächst einmal einen Blick auf den Aufbau (zusammengesetzt aus diversen System-Headerdateien meiner Linux-Installation):

struct sockaddr_in {
        unsigned short sin_family;
        uint16_t       sin_port;
        struct in_addr sin_addr;
        unsigned char  sin_zero[8];
};

Es gibt noch eine weitere Struktur mit Namen sockaddr. Wir sehen auch, dass connect() eigentlich einen Zeiger auf eine solche Struktur erwartet, weshalb auch ein Typcast notwendig ist. Was hat es damit auf sich?

Die Struktur sockaddr ist unspezifisch und groß genug um alle möglichen Socket-Adressen aufzunehmen. Allen Socket-Adressen gemein ist, dass ihr erstes Element vom Typ unsigned short ist und die genaue Adress-Familie aufnimmt. Das Betriebssystem untersucht nun bei einem Aufruf von connect() (bzw. anderer Socket-Funktionen) das erste Element und ist in der Lage zu entscheiden, ob die übergebene Struktur auf einen passenden spezifischen Typ gecastet werden kann.

Neben der Adress-Familie wird noch die Port-Nummer in Network Byte Order benötigt, sowie die IP-Adresse des Ziel-Hosts. Zum Thema Network Byte Order sei auf die Seite Hintergrundwissen verwiesen, für den Moment reicht es zu wissen, dass Port-Nummern mittels htons() umgewandelt werden müssen, damit sie in einer Struktur vom Typ sockaddr_in benutzt werden können. Die Umkehrfunktion heißt ntohs().

Nun ist alles für den connect()-Aufruf beisammen. Dieser baut die Verbindung auf und kehrt entweder mit 0 zurück (erfolgreich) oder mit -1 (fehlgeschlagen). Auch hier gibt es genaue Details via errno und strerror(). Ein connect()-Aufruf kann ein bisschen dauern. Genauer gesagt klappt es ziemlich schnell, wenn der Ziel-Host reagiert, läuft aber erst nach einiger Zeit in einen Timeout, wenn der Ziel-Host nicht antwortet. Dieser Timeout ist systemabhängig und kann manchmal global konfiguriert werden.

Wenn der Aufruf erfolgreich war, dann ist der Socket verbunden und kann für Aufrufe von send() und recv() benutzt werden.

Daten auf einem Socket empfangen

Um Daten auf einem verbundenen Socket zu empfangen steht die Funktion recv() zur Verfügung. Ein typischer Aufruf von recv() sieht so aus:

int bytes = recv(sock, buffer, sizeof(buffer), 0);
if (bytes > 0)
{
        /* data received */
}
else if (bytes == 0)
{
        /* socket closed */
}
else if (bytes == -1)
{
        /* error occurred */
        fprintf(stderr, "Error reading from socket: %s\n", strerror(errno));
}

Der erste Parameter ist der verbundene Socket, der zweite Parameter gibt den Puffer an, in den die empfangenen Daten geschrieben werden sollen. Der dritte Parameter gibt das Maximum an, das in den Puffer geschrieben werden darf. Die tatsächliche Anzahl gelesener Bytes wird zurückgegeben. Für den vierten Parameter geben wir immer 0 an, wer mehr erfahren will findet Details in der Befehlsreferenz.

Man kann für die Rückgabewerte drei verschiedene Werte(-bereiche) unterscheiden:

Hier ist zu berücksichtigen, dass allein die Tatsache, dass in eine TCP-Verbindung eine bestimmte Menge an Daten reingeschrieben wurde, noch kein Garant dafür ist, dass ein recv()-Aufruf die gleiche Menge Daten raus liest. Die Datagramme können jederzeit unterwegs fragmentiert werden, oder auf der Senderseite nur portionsweise rausgeschickt werden (Stichwort Nagle-Algorithmus). Aus diesem Grund sollte man sich immer darauf einstellen, dass mehrere recv()-Aufrufe notwendig sind um alle Daten zu bekommen (z.B. durch Verwendung einer Schleife).

Einen TCP-Socket schließen

Einen Socket zu schließen ist einfach:

close(sock);

Hier sieht man wieder die Verwandtschaft zu Dateideskriptoren, die unter UNIX ebenfalls mit close() geschlossen werden.

Besonderheiten unter Windows

Neben den Aufrufen von WSAStartup() und WSASCleanup() sowie der Windows-spezifischen Datentypen gibt es noch zwei Besonderheiten, die hier vorgestellt werden sollen:

  1. Fehlerbehandlung
  2. closesocket()

Während unter Linux die Fehlercodes in den globalen Fehlervariablen errno (Systemaufrufe/Bibliotheksfunktionen) bzw. h_errno (Resolver-Funktionen) zu finden sind und mit strerror() resp. hstrerror() in Texte umgewandelt werden, stehen unter Windows die Funktionen WSAGetLastError() und FormatMessage() zur Verfügung. Deren Verwendung wird im Beispiel-Programm deutlich. Wichtig an dieser Stelle ist noch, dass die Funktion WSAStartup() ihren Fehlercode direkt als Rückgabewert liefert, ein Aufruf von WSAGetLastError() nach einem fehlgeschlagenen WSAStartup() ist nicht zulässig.

Der zweite Unterschied ist lapidar: statt der Funktion close() muss die Funktion closesocket() benutzt werden, Bedeutung und Funktionsweise sind identisch.

Ein paar Ausgaben des fertigen Programms

RFC 867 schreibt den Aufbau des Datum-Strings nicht vor, dort heißt es lediglich It is recommended that it be limited to the ASCII printing characters, space, carriage return, and line feed. The daytime should be just one line. Dementsprechend sehen die Ausgaben auch sehr unterschiedlich aus, weshalb ich mich dazu entschlossen habe, alle drei zu zeigen.

Linux-Version, verbunden mit öffentlichem Server time.nist.gov:

$ ./daytime time.nist.gov
Connecting to 132.163.97.6:13... OK.
Received 51 bytes: <
58545 19-03-03 09:15:54 58 0 0  92.7 UTC(NIST) * 
>

Wir sehen hier, anders als bei den anderen Daytime-Servern, einen Zeilenumbruch vor und einen nach dem Datum-String. Außerdem ist der Aufbau etwas sonderbar und hier erklärt: NIST Internet time service.

Linux-Version, verbunden mit dem eingebauten Daytime-Service von inetd unter Ubuntu:

$ ./daytime localhost
Connecting to 127.0.0.1:13... OK.
Received 26 bytes: <Sun Mar  3 10:16:26 2019
>

Windows-Version, verbunden mit dem eingebauten Daytime-Service von Windows 10:

C:\>daytime.exe localhost
Connecting to 127.0.0.1:13... OK.
Received 20 bytes: <10:22:45 03.03.2019
>

Wer übrigens auf die Idee kommen sollte, diese Ausgabe zu parsen um damit einen automatischen Zeitabgleich durchzuführen, der sei auf den Time-Service (RFC 868) verwiesen. Dieser liefert ein maschinenlesbares Ergebnis in Form eines 32-Bit-Werts, der die Anzahl Sekunden angibt, die seit Mitternacht des 1. Januars 1900 vergangen sind. Man war sich durchaus bewusst, dass das nur bis zum Jahr 2036 reicht, aber aus Sicht der 1970er-Jahre war das wohl mehr als genug. Siehe dazu auch das Jahr-2038-Problem und dort den Abschnitt Verwandtes Jahr-2036-Problem.


Zurück