V_Socket_Feldmann

Werbung
Institut für Systemarchitektur
Lehrstuhl Rechnernetze
(Komplex-)Praktikum Rechnernetze
Komplex I - Basistechnologie
Praktikumsversuch Socket-Programmierung
- Entwicklung eines Programms zur Socket-Kommunikation in C –
Praktikumsdokumentation
-1-
1
Einleitung ...................................................................................................................... - 3 -
2
Grundlagen ................................................................................................................... - 3 -
3
4
5
6
2.1
Das Socket-Konzept ............................................................................................. - 3 -
2.2
Konzeptioneller Ablauf ........................................................................................ - 4 -
2.3
Client-/Server-Modell .......................................................................................... - 5 -
2.4
Kommunikationsabläufe ..................................................................................... - 5 -
2.4.1
Verbindungsorientierter Kommunikationsablauf ........................................... - 5 -
2.4.2
Verbindungsloser Kommunikationsablauf ..................................................... - 7 -
2.5
Konvertierungen................................................................................................... - 7 -
2.6
Socketprogrammierung unter Windows ............................................................ - 8 -
2.6.1
Header und Initialisierung .............................................................................. - 8 -
2.6.2
Datenstrukturen .............................................................................................. - 9 -
Beispiel Socket-Kommunikation ................................................................................. - 9 3.1
Client-Prozess ..................................................................................................... - 10 -
3.2
Server-Prozess .................................................................................................... - 11 -
3.3
Testen des Beispiels ............................................................................................ - 13 -
Der Versuch ................................................................................................................ - 14 4.1
Versuchsbeschreibung ....................................................................................... - 14 -
4.2
Sicherung der Versuchsergebnisse ................................................................... - 15 -
Anhang - Datenstrukturen und Systemaufrufe ....................................................... - 16 5.1
Datenstrukturen ................................................................................................. - 16 -
5.2
Systemaufrufe ..................................................................................................... - 17 -
Literatur ...................................................................................................................... - 22 -
-2-
1 Einleitung
Bei der Programmierung von Anwendungen, die eine Kommunikation über ein Netzwerk
durchführen, liegt heutzutage ein Schwerpunkt auf dem Aspekt der Abstraktion. Zentrales
Ziel dabei ist, den Anwendungsentwickler von der Verantwortung für die Vorgänge auf
unterer Ebene der Netzwerkübertragung zu befreien. Dieser Gedanke wurde konsequent bis
hin zu komponentenbasierten Systemen wie den Enterprise Java Beans realisiert, bei denen
die reine Anwendungslogik im Vordergrund steht. Dabei stellt sich natürlich die Frage, wieso
es unter dem Gesichtspunkt dieser Entwicklung relevant sein kann, sich mit der
Programmierung einer Anwendung auf Basis der klassischen Socket-Kommunikation zu
befassen. Zum einen hat dieses einen didaktischen Grund. Die Socket-Programmierung bietet
ein sehr gutes Verständnis des Client-/Servermodells und den damit verbundenen Vorgängen
auf der Ebene der Netzwerk- und Transportschicht. Zum anderen hat es einen praktischen
Grund. Die Socket-Programmierung ermöglicht erst eine breite Palette von Anwendungen
und Optimierungsmöglichkeiten, die im Rahmen der vollständigen Kontrolle über die
Kommunikation ermöglicht werden. Beispiele dafür stellt die Entwicklung von Firewalls,
Netzwerkanalysesoftware oder eines performanten Webservers dar.
2 Grundlagen
2.1
Das Socket-Konzept
Bei dem Socket-Konzept handelt es sich prinzipiell um eine Abstraktion von der tatsächlichen
Netzwerkübertragung. Allerdings ist diese Abstraktion auf einem wesentlich niedrigeren
Niveau, als bei den im vorherigen Abschnitt erwähnten, moderneren Technologien. So bieten
Sockets eine große Kontrolle über die Details der Kommunikation, bis hin zur Möglichkeit,
beispielsweise IP-Packete selbst zusammen zu setzen (so genannte Raw-Sockets). Ein Socket
ist dabei vergleichbar mit einem File-Deskriptor, auf den mit normalen Lese- und
Schreibaufrufen zugegriffen werden kann. Anstatt aber mit Dateien zu interagieren verbirgt
sich hinter einem Socket ein Kanal zu einem anderen Socket. Das zugrunde liegende Konzept
ermöglicht, dass zwei miteinander verbundene Sockets sich in unterschiedlichen Prozessen
und gar auf unterschiedlichen Rechnern befinden können. Die in einen Socket geschriebenen
Daten können dadurch aus dem angeschlossenen Socket gelesen werden und dienen somit der
Interprozess- oder Netzwerkkommunikation.
Die vorliegende Dokumentation bietet eine Einführung in die Socket-Progammierung. Dabei
wird zunächst auf das zugrunde liegende Kommunikationsmodell eingegangen und die
notwendigen Schritte für die Anwendungsentwicklung erläutert bzw. anhand eines Beispiels
praktisch dargestellt. Nachdem das notwendige Grundverständnis geschaffen wurde, wird
detailliert auf den Praktikumsversuch eingegangen. An manchen Stellen wird zunächst
konzeptionell auf bestimmte Funktionen und Datenstrukturen eingegangen. Diese werden alle
in ihrer Semantik im Anhang „Datenstrukturen und Systemaufrufe“ erläutert.
Es sei an dieser Stelle darauf hingewiesen, dass der Begriff „Socket“ teils unterschiedlich
definiert wird. In der Folge ist darunter die für die Programmiersprache C entwickelte und mit
dem
Unix-Betriebssystem
BSD
Anfang
der
achtziger
Jahre
eingeführte
Programmierschnittstelle zur Kommunikation zweier Rechner in einem TCP/IP-Netz zu
verstehen.
-3-
Die konzeptionelle Anordnung eines Sockets innerhalb eines Kommunikationsvorgans zweier
Instanzen ist in der Abbildung 1 dargestellt:
ApplicatioProtocoll
Application
Application
socket
socket
TCP/UDP
TCP/UDP
IP
IP
TCP/UDP
IP
Physical-Protocol
Abbildung 1: Socket als Schnittstelle
Wie hier zu sehen ist, stellt ein Socket also nichts anderes als eine Schnittstelle zwischen
Applikation und dem gewählten Transportprotokoll (hier TCP oder UDP) dar, über die Daten
ausgetauscht werden können.
Die Vorteile einer Anwendung, die auf Basis dieses Konzeptes entwickelt werden, sind vor
allem eine große Flexibilität und hohe Performance, wobei diesen Vorteilen ein hoher
Aufwand bei der Entwicklung gegenübersteht.
2.2
Konzeptioneller Ablauf
Der Ablauf bei der Kommunikation lässt sich am einfachsten durch das open-close-readwrite-Paradigma beschreiben. Dieses besagt, dass ein Socket zunächst „geöffnet“ werden
muss und daraufhin in ihn geschrieben bzw. aus ihm gelesen werden kann. Nach Abschluss
dieser Kommunikation wird der Socket wieder geschlossen. Der Begriff des Öffnens
beschreibt dabei allerdings einen Vorgang, der abhängig vom verwendeten Transportprotokoll
ist. Im Falle eines UDP-Sockets bedeutet das Öffnen lediglich, dass mithilfe eines
Systemaufrufs ein Socket angelegt wird und dieser in der Folge für die Kommunikation
verwendet werden kann. Da es sich bei UDP um ein verbindungsloses Protokoll handelt,
muss bei jedem Sendevorgang die Adresse des Zielsystems angegeben werden.
Im Falle eines Sockets, der auf TCP aufsetzt gestaltet sich der Vorgang hingegen etwas
aufwendiger. Bevor Daten über einen solchen Socket versendet werden können, muss dieser
vom nicht verbundenen Zustand in den Zustand „verbunden“ gebracht werden. Erst wenn
eine Verbindung zwischen den Kommunikationspartnern besteht, ist der Socket für Lese- und
Schreibzugriffe verfügbar. Für den Verbindungsaufbau werden aller relevanten Daten (IPAdresse, Portnummer) in eine so genannte sockaddr_in-Struktur gefüllt und diese dann einer
Funktion namens connect() übergeben. Bei den einzelnen Sendeschritten ist daraufhin die
-4-
Angabe der Empfängeradresse nicht erforderlich, da das Betriebssystem die Zuordnung dieser
Daten zum verbundenen Socket intern speichert.
2.3
Client-/Server-Modell
Einer Netzwerkanwendung auf Basis von Sockets liegt das klassische Client-/Server-Modell
zugrunde. Es existiert somit eine klare Rollenverteilung zwischen einem Dienstanbieter, der
auf eingehende Anfragen reagiert und einem Dienstnutzer, der eine Kommunikation initiiert.
Dabei erfolgt die Vergabe der Rollen nicht für ganze Rechner bzw. Netzteilnehmer, sondern
für Prozesse auf diesen Rechnern. Das bedeutet natürlich, dass nicht nur ein Host in einem
Netzwerk über eine IP-Adresse adressierbar sein muss, sondern ein einzelner Prozess auf
diesem Host. Diese Aufgabe erfüllen die beiden wichtigsten Transportprotokolle TCP und
UDP mithilfe des Port-Konzepts. Die Protokollheader halten ein 16-Bit-Feld bereit, in das die
Portnummer des anzusprechenden Prozesses eingetragen wird. Ein Prozess wird damit
eindeutig durch die Angabe Host-Adresse:Port-Adresse identifiziert. Bei der Zuordnung der
Port-Adresse zum Prozess muss zwischen Client- und Server-Prozess unterschieden werden.
Der Server bindet meist mithilfe eines Systemaufrufs explizit eine Portnummer an sich selbst,
bzw. genauer gesagt an den Socket, auf dem er auf eingehende Anfragen wartet, während
einem Client-Prozess häufig eine solche Nummer vom Betriebssystem zugewiesen wird.
Ein Prozess kann prinzipiell sowohl die Rolle eines Servers, als auch eines Clients
übernehmen. Denkbar ist zum Beispiel ein Szenario, bei dem ein Client im Rahmen einer
Flugbuchung bei einem entsprechenden Server eine Anfrage stellt und dieser Server
wiederum einen weiteren Server kontaktieren muss, um die Anfrage beantworten zu können.
Die Benennung eines Servers und eines Clients bezieht sich daher immer auf eine
Kommunikationsbeziehung und kann keine globale Gültigkeit haben.
In der Praxis kommuniziert ein Server häufig gleichzeitig mit mehreren Clients, wie
beispielsweise im Falle eines hochfrequentierten Webservers, der in kürzester Zeit mehrere
hunderte oder gar tausende Seitenanfragen erfüllen muss. Diese Möglichkeit wird durch
parallele Prozesse oder Threads realisiert, worauf im Rahmen dieser Dokumentation
allerdings nicht eingegangen werden soll.
2.4
Kommunikationsabläufe
2.4.1 Verbindungsorientierter Kommunikationsablauf
Für die Kommunikation über einen Socket müssen auf Client- und auf Serverseite
unterschiedliche Vorarbeiten geleistet werden. Der Ablauf einer einfachen
verbindungsorientierten Kommunikation ist in der Abbildung 2 dargestellt. Die aufgeführten
Systemaufrufe werden im Anhang „Datenstrukturen und Systemaufrufe“ detailliert diskutiert.
-5-
1
socket()
socket()
1
bind()
2
listen()
3
accept()
4
5
connect()
6
recv()
send()
7
7
8
7
recv()
send()
close()
close()
Client
8
Server
Abbildung 2: verbindungsorientierte Kommunikation
1. Sowohl Client, als auch Server fordern einen Socket an, der daraufhin als Schnittstelle
zur Transportschicht verwendet werden kann. Bereits an dieser Stelle wird das zu
verwendende Transportprotokoll, hier TCP, angegeben.
2. Der Server bindet eine lokale Adresse (eine Portnummer) an den erstellten Socket,
über die der Prozess in der Folge verfügbar ist.
3. Daraufhin wird durch den Aufruf von listen auf eingehende Verbindungen gelauscht.
4. Die Möglichkeit eingehende Verbindungen anzunehmen wird durch den Aufruf von
accept erreicht.
5. Der Client initiiert eine Verbindung auf dem unter 1. angelegten Socket. Dabei werden
alle notwendigen Daten, wie IP-Adresse und Portnummer spezifiziert. Bei dem
Verbindungsvorgang handelt es sich um einen 3-Wege-Handshake.
6. Auf Server-Seite wird eine eingehende Verbindung zunächst in eine Warteschlange
eingehängt. Der vorherige Aufruf von accept bewirkt, dass diese Warteschlange
abgearbeitet wird. Für jede eingegangene Anfrage wird dazu ein neuer, verbundener
Socket erstellt. Damit ist zwischen den beiden Endpunkten ein Kommunikationskanal
hergestellt.
7. Auf diesem Kanal können nun mit Hilfe entsprechender Systemaufrufe Daten
geschrieben bzw. gelesen werden.
8. Nachdem alle Daten übertragen wurden können die Sockets geschlossen werden.
Das im 6. Schritt genannte automatische Anlegen eines neuen Sockets für jede eingegangene
Verbindung mag ein wenig verwirren, könnte man doch den ursprünglich erstellten Socket für
die eigentliche Kommunikation verwenden. Allerdings wird durch den dargestellten Ablauf
eine sehr gute Aufgabenverteilung bewirkt. So kann der im 1. Schritt erzeugte Socket vom
Serverprozess dazu verwendet werden, auf Verbindungen zu warten und nach dem Herstellen
einer Verbindung ein weiterer Prozess den neuen Socket für die Arbeit mit dem Client
verwendet, wodurch die Grundlage für parallele Anfragen geschaffen ist.
-6-
2.4.2 Verbindungsloser Kommunikationsablauf
Die einfachere der beiden hier diskutierten Interaktionsformen ist die verbindungslose
Kommunikation mittels UDP. Ein typischer Ablauf ist in der Abbildung 3 dargestellt.
1
socket()
socket()
1
bind()
2
4
recvfrom()
sendto()
4
3
5
sendto()
recvfrom()
6
close()
Client
Server
Abbildung 3: verbindungslose Kommunikation
1. Client und Server fordern jeweils mit dem Systemaufruf einen Socket an. Dabei wird
als Transportprotokoll UDP angeben.
2. Der Server bindet eine Portnummer an den erstellten Socket.
3. Durch den Aufruf von recvfrom blockiert der Server-Prozess und wartet auf
eingehende Daten auf dem erstellten Socket.
4. Der Client versendet unter Angabe der Adressinformationen (die zuvor in eine
sockaddr_in-Struktur gefüllt wurden) mit Hilfe von sendto Daten an den Server, die
durch recvfrom von diesem entgegengenommen werden. Implizit wird dabei ebenfalls
in eine sockaddr_in-Struktur, die recvfrom als Parameter übergeben wurde, die
Absenderadresse bzw. der Absenderport eingetragen, so dass dem Client geantwortet
werden kann.
5. Der Server antwortet dem Client.
6. Der Socket auf Client-Seite wird nach abgeschlossener Kommunikation geschlossen.
2.5
Konvertierungen
Zur Gewährleistung von Heterogenität und für die Umwandlung unterschiedlicher
Adressierungsarten spielen zahlreiche Konvertierungsfunktionen eine wichtige Rolle in der
Socketprogrammierung. Eine der ursprünglichen Herausforderungen bestand darin,
Programme möglichst leicht auf unterschiedlichen Rechner-Architekturen portieren zu
können. Dabei tritt vor allem das aus anderen Bereichen bekannte Problem der verschiedenen
Darstellung gleicher Variablentypen auf Big-Endian-Architekturen (z.B. Sparc) und LittleEndian-Architekturen (z.B. Intel x86) auf. Zur Umgehung der Uneinheitlichkeit wurde ein
neues Format eingeführt, die so genannte Network-Byte-Order, wobei es sich hier tatsächlich
um das Big-Endian-Format handelt. Zur Konvertierung zwischen der nativen Darstellungsart
von int Werten und short int Werten in das Netzwerkformat bzw. zurück existieren die vier
Funktionen htonl(), htons(), ntohl(), ntohs(). Zwar ist eine Umwandlung in Fällen, bei denen
-7-
ein Programm nur auf einer Architektur ausgeführt wird, nicht notwendig, dennoch zeugt es
von einem guten Stil. Beispielsweise bei der Zuweisung der Adressinformationen an die
sockaddr_in-Struktur sollten diese Funktionen verwendet werden:
struct sockaddr_in serverAddress;
serverAddress.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddress.sin_port = htons(2305);
. . .
Eine weitere wichtige Kategorie von Konvertierungen bezieht sich auf die Umwandlung von
Hostnamen in IP-Adressen. Dazu wird die Funktion gethostbyname() eingesetzt, die zum
übergebenen Argument eine hostent-Struktur zurückgibt, die wiederum eine Liste von IPAdressen (in Network-Byte-Order) des angefragten Hostnamens enthält. Wie genau diese
Umwandlung stattfindet ist sehr stark Implementierungsabhängig. Prinzipiell kann die
Funktion dazu einen DNS-Server, eine lokale Datenbasis und ähnliches verwenden. Weitere
Funktionen aus diesem Bereich sind gethostbyaddr() für die umgekehrte Umwandlung und
getservbyname() für die Anfrage nach einem Server, der einen bestimmten Service (z.B. FTP)
anbietet. Im Rahmen dieser Dokumentation soll auf die beiden Funktionen allerdings nicht
näher eingegangen werden.
2.6
Socketprogrammierung unter Windows
In Folge der Tatsache, dass das beschriebene Socket-Konzept ursprünglich aus der Unix-Welt
stammt und insbesondere keinen Standard darstellt, sind einige Besonderheiten unter
Windows zu berücksichtigen.
2.6.1 Header und Initialisierung
Zur Verwendung der einzelnen Socket-Funktionen und spezieller Windows-Funktionen
werden neben den sonstigen Headern die beiden Headerdateien
#include <windows.h>
#include <winsock2.h>
eingebunden und die Bibliothek lib/libwsock32.a hinzugelinkt.
Zudem ist es notwendig, durch den Aufruf der Funktion
int WSAStartup (WORD wVersionRequested, LPWSADATA lpWSAData);
die Socket-Funktionen zu initialisieren. Der erste Parameter gibt die zu verwendende
Winsock-Version an, der zweite Parameter stellt eine Datenstruktur dar, in die zur Laufzeit
Informationen über die Socket-Version gespeichert werden.
Ein typischer Aufruf sieht somit folgendermaßen aus:
-8-
int startWinsock(void){
WSADATA wsa;
return WSAStartup(MAKEWORD(2,0),&wsa);
}
int main(int argc,char *argv[]){
long wsa = startWinsock();
. . .
}
Nach dieser Vorarbeit können die Socket-Funktionen überwiegend analog zu denen unter
Unix bzw. Linux verwendet werden. Für die Funktion close() bietet Windows allerdings eine
eigene Implementierung: closesocket(), deren Semantik aber zu close() vollkommen identisch
ist.
2.6.2 Datenstrukturen
Im Bereich der Datentypen und Datenstrukturen können prinzipiell die identischen
Bezeichner verwendet werden, wie im Fall von Unix-/Linux-Systemen, wobei Windows
darüber hinaus eigene Bezeichner anbietet. Die beiden am häufigsten verwendeten sind zum
einen der Datentyp SOCKET für einen socket und die Datenstruktur SOCKADDR bzw.
SOCKADDR_IN.
SOCKET sock;
SOCKADDR_IN serverAddress;
sock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
serverAddress.sin_family = AF_INET;
serverAddress.sin_addr.s_addr = inet_addr(serverIP);
serverAddress.sin_port = htons(2305);
connect(sock, (SOCKADDR *)&serverAddress, sizeof(serverAddress));
3 Beispiel Socket-Kommunikation
Die bis hierhin gemachten Ausführungen sollen nun anhand eines kleinen Beispiels praktisch
demonstriert werden. Dabei kommen einige der im Anhang „Datenstrukturen und
Systemaufrufe“ aufgeführten Systemaufrufe zum Einsatz. Das Beispiel kann ohne
Ergänzungen auf einem Linux-/Unix-System getestet werden. Für den Test unter Windows
sind die im Abschnitt „Socketprogrammierung unter Windows“ genannten Hinweise zu
beachten.
Bei dem Beispiel handelt es sich um einen einfachen Echo-Server. Ein Server-Prozess liest
dabei fortlaufend Daten aus einem Socket aus und sendet diese an den Client zurück, der sie
wiederum auf der Kommandozeile ausgibt. Als Transportprotokoll kommt TCP zum Einsatz.
Der Client wird unter Angabe einer IP-Adresse und des Textes, der an den Server übertragen
werden soll aufgerufen. Auf Fehlerbehandlung wurde aus Gründen der Übersichtlichkeit
vollständig verzichtet. In einem realen System sollten selbstverständlich Dinge wie die
Prüfung der Rückgabewerte oder der Anzahl der Kommandozeilenparameter durchgeführt
werden. Bei den Rückgabewerten der Systemaufrufe gilt ein negativer Rückgabewert als
Indikation für einen Fehler.
-9-
3.1
Client-Prozess
Der Client (client.c) hat folgende Struktur:
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main(int argc,char *argv[]){
int sock;
struct sockaddr_in serverAddress;
char *serverIP;
char *echoData;
char buffer[32];
int echoDataLength;
int bytesReceived, sumBytesReceived;
serverIP = argv[1];
echoData = argv[2];
echoDataLength = strlen(echoData);
sock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
serverAddress.sin_family = AF_INET;
serverAddress.sin_addr.s_addr = inet_addr(serverIP);
serverAddress.sin_port = htons(2305);
connect(sock, (struct sockaddr *)&serverAddress, sizeof(serverAddress));
send(sock,echoData,echoDataLength,0);
printf("Echo: ");
while(sumBytesReceived < echoDataLength){
bytesReceived = recv(sock,buffer,31,0);
sumBytesReceived += bytesReceived;
buffer[bytesReceived] = '\0';
printf(buffer);
}
printf("\n");
close(sock);
}
- 10 -
3.2
Server-Prozess
Der dazugehörige Server (server.c) hat folgenden Aufbau:
- 11 -
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
void workForClient(int clientSock);
int main(int argc, char *argv[]){
int serverSock;
int clientSock;
struct sockaddr_in serverAddress;
struct sockaddr_in clientAddress;
int clientAdrLength;
serverAddress.sin_family = AF_INET;
serverAddress.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddress.sin_port = htons(2305);
serverSock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
bind(serverSock,(struct sockaddr *)&serverAddress, sizeof(serverAddress));
listen(serverSock, 5);
for(;;){
clientAdrLength = sizeof(clientAddress);
clientSock = accept(serverSock,(struct sockaddr *)&clientAddress,
&clientAdrLength);
printf("Client-IP: %s\n",inet_ntoa(clientAddress.sin_addr));
workForClient(clientSock);
}
}
void workForClient(int clientSock){
char buffer[32];
int dataSize;
dataSize = recv(clientSock,buffer,32,0);
buffer[31] = '\0';
while(dataSize > 0){
send(clientSock,buffer,dataSize,0);
dataSize = recv(clientSock,buffer,32,0);
}
close(clientSock);
}
Auf den Quellcode für Client und Server soll an dieser Stelle nicht näher eingegangen
werden, da es sich dabei um die programmtechnische Umsetzung des Ablaufs aus dem
Abschnitt „Verbindungsorientierter Kommunikationsablauf“ handelt und die verwendeten
Systemaufrufe im Anhang detailliert beschrieben werden.
- 12 -
3.3
Testen des Beispiels
Unter einem Linux-/Unix-System können die Dateien client.c bzw. server.c durch die Aufrufe
$ gcc –o client client.c
$ gcc –o server server.c
übersetzt werden.
Daraufhin kann zunächst der Server gestartet werden:
$ ./server
Dieser lauscht nun auf Port 2305 auf eingehende Verbindungsanfragen. Danach kann der
Client durch folgenden Aufruf gestartet werden:
$ ./client 127.0.0.1 Hallo
Echo: Hallo
$
Wie hier zu sehen ist, wird der übergebene String „Hallo“ erfolgreich an den lokal laufenden
Server versendet und wieder empfangen.
Die Umstellung des Quellcodes auf eine verbindungslose Kommunikation sei an dieser Stelle
dem interessierten Studenten überlassen.
- 13 -
4 Der Versuch
Bei dem zugrunde liegenden Szenario handelt es sich um ein verteiltes Fluchbuchungssystem,
bestehend aus einem oder mehreren Reisebüros (Clients) und einer Zentrale (Server). Dabei
können die Clients beim Server Flüge buchen, anzeigen und stornieren.
Ausgangspunkt für die Entwicklung des Systems bilden die Programmrahmen, die von den
Seiten des Lehrstuhls Rechnernetze herunter geladen werden können. Verfügbar sind dabei
drei verschiedene Versionen, davon eine für Linux-/Unix-Systeme und zwei für Windows.
4.1
Versuchsbeschreibung
Nach dem Download der Sourcen von den Seiten des Lehrstuhls und dem Entpacken eines
der drei Archive liegen folgende gemeinsame Dateien vor:
Datei
Aufgabe
client.c
Sorgt für die Interaktion mit dem Nutzer. Füllt dabei die gemachten
Angaben in eine Struktur, die später als Anfrage an den Server
verwendet wird.
Realisiert die Netzwerkfunktionalität, wie die Initialisierung des
Sockets oder dem Versand bzw. Empfang von Daten.
clientSy.c
clientSy.h
data.h
error.c
server.c
Deklarationen für clientSy.c
Beinhaltet verschiedene Strukturen zur Kapselung von Daten.
Stellt Daten für die Fehlerbehandlung bereit.
Stellt das eigentliche Server-Programm dar, das die Logik des
Systems beinhaltet und für die Ausgabe der einzelnen Anfragen auf
der Konsole sorgt.
serverSy.c
Implementiert die Socket-Funktionalität auf Server-Seite.
serverSy.h
Deklarationen für serverSy.c
Daneben existiert für die Linux-/Unix-Version ein Makefile, das der Kompilation der
einzelnen Komponenten von der Kommandozeile aus dient (auf der Kommandozeile im
entpackten Verzeichnis das Kommando make eingeben). Im Falle der beiden WindowsVersionen sind noch zahlreiche weitere Dateien, die von den Entwicklungsumgebungen
erzeugt wurden, vorhanden.
Folgenden beiden Aufgaben sind durchzuführen:
I.
II.
Die Dateien clientSy.c und serverSy.c sind an den durch Kommentare
gekennzeichneten Stellen zu ergänzen, so dass eine verbindungslose
Kommunikation auf Basis von UDP realisiert wird.
Die Dateien clientSy.c und serverSy.c sind an den durch Kommentare
gekennzeichneten Stellen zu ergänzen, so dass eine verbindungsorientierte
Kommunikation auf Basis von TCP realisiert wird.
Bei der Implementierung können zum Testen des erfolgreichen Ausführens der Systemaufrufe
die bereitgestellte Funktion void test(int retval, char *text) verwendet werden. Diese gibt den
übergebenen Text aus, falls der erste Parameter negativ sein sollte. Somit kann sie zur
Fehlermeldung genutzt werden:
- 14 -
test(s=socket(PF_INET, SOCK_STREAM, 0),”Error: socket()”);
Nach der Implementierung und Übersetzung der beiden Programmversionen, sind sie
ausführlich zu testen. Dabei können sowohl lokale, als auch entfernte Tests durchgeführt
werden. Bei den entfernten Tests muss beim Start als Parameter die Internetadresse oder der
Hostname des entfernten Servers angegeben werden.
4.2
Sicherung der Versuchsergebnisse
Nachdem die richtige Funktionsweise der Versuchsergebnisse ausreichend getestet wurde,
sind folgende Dinge zu tun:
1. Anlegen des Ordners „LösungenSocket“ im Unterverzeichnis „Lösungen“ des eigenen
Verzeichnisses auf dem Praktikumsrechner
2. Speicherung der veränderten Dateien (clientSy.c, serverSy.c) für die UDP-Version im
Unterverzeichnis SocketUDP.
3. Speicherung der veränderten Dateien (clientSy.c, serverSy.c) für die TCP-Version im
Unterverzeichnis SocketTCP
4. Mail mit dem Betreff „Versuch-Socket“ an [email protected] unter Angabe des
Namens, Vornamens, der Matrikelnummer und des Logins verschicken
5. Kopie der Versuchsergebnisse bis zur Ausgabe der Scheine sichern
- 15 -
5 Anhang - Datenstrukturen und Systemaufrufe
In diesem Abschnitt werden die wichtigsten Datenstrukturen und Systemaufrufe mit ihren
Parametern und Rückgabewerten für die klassische Socket-Kommunikation vorgestellt. Er
dient dabei vorrangig als Referenz. Die praktische Anwendung anhand eines vollständigen
Beispiels vieler der hier genannten Funktionen erfolgte bereits im Abschnitt „Beispiel SocketKommunikation“.
Wie bei Systemaufrufen aus der Unix-Welt üblich, bedeutet jeweils ein negativer
Rückgabewert eines Aufrufs, dass selbiger gescheitert ist. In den Beispielen zu den
Systemaufrufen wird die Prüfung auf gültige Rückgabe ausgespart. In einem realen
Programm sollte hier allerdings nach dem Schema
if (socket < 0)
{
perror("socket() failed");
return 1;
}
die Rückgabe auf Korrektheit geprüft werden.
5.1
Datenstrukturen
struct hostent (#include <netdb.h>)
struct hostent {
char *h_name;
char **h_aliases;
int h_addrtype;
int h_length;
char **h_addr_list;
#define h_addr h_addr_list[0]
};
/*
/*
/*
/*
/*
/*
Official name of host. */
Alias list. */
Host address type. */
Length of address. */
List of addresses from name server. */
Address, for backward compatibility. */
Diese Struktur dient als Rückgabe der Funktion gethostbyname(). Besonders wichtig dabei ist
der Eintrag h_addr_list bzw. h_addr, in denen die IP-Adressen nach der Namenauflösung
abgelegt werden.
struct sockaddr_in (#include <netinet/in.h>)
struct sockaddr_in {
unsigned short sin_family;
unsigned short sin_port;
struct in_addr sin_addr;
. . .
};
/* Internet protocol (AF_INET) */
/* Port number. */
/* Internet address. */
Diese Struktur kommt immer dann zum Einsatz, wenn Adressinformationen angegeben bzw.
verwendet werden müssen. Bis auf die Angabe der Internet-Adresse sind die Felder
selbsterklärend. Die IP-Adresse wird in der Struktur in_addr abgelegt:
struct in_addr {
unsigned long s_addr; /* IP address (32 bits) */
};
- 16 -
An manchen Stellen wird anstatt der Struktur sockaddr_in die zugehörige, generische Struktur
sockaddr verwendet (z.B. bei accept()). Bei dieser Struktur handelt es sich um eine
protokollunabhängige Struktur, im Gegensatz zu sockaddr_in, die speziell für die IP-Familie
geschaffen wurde. Immer dann, wenn also von einem Systemaufruf als Parameter eine
sockaddr-Struktur gefordert wird, ist ein cast der Struktur sockaddr_in erforderlich.
socklen_t, ssize_t, size_t etc.
Bei diesen Datentypen handelt es sich um nichts weiter, als um Ummantelung für reguläre
Datentypen, meist um int-Werte (socklen_t, size_t: unsigned; ssize_t: signed). Die in dieser
Dokumentation aufgeführten Beispiele sollten genügen, um mit diesen Datentypen umgehen
zu können.
5.2
Systemaufrufe
socket()
int socket(int domain, int type, int protocol);
Parameter
Der erste Parameter spezifiziert dabei die Protokoll-Familie des Netzwerkprotokolls. Die am
häufigsten gemachte Angabe ist hierbei PF_INET (bzw. gleichbedeutend AF_INET), was den
IPv4-Protokollen entspricht. Mit dem zunehmenden Einsatz von IPv6 gewinnt an dessen
Stelle die Angabe PF_INET6 an Bedeutung.
Als zweiten Parameter bekommt der Systemaufruf den Typ des Sockets übergeben.
Besonders relevant sind die Typen SOCK_STREAM für eine Übertragung der Daten in
einem Strom und SOCK_DGRAM für die Übertragung in Datagrammen.
Der dritte Parameter schließlich gibt das zu verwendende Transportprotokolls an. Wird hier
eine 0 übergeben, so wird automatisch das Standard-Protokoll für den entsprechenden Typ
verwendet – TCP für SOCK_STREAM und UDP für SOCK_DGRAM.
Rückgabe
Als Rückgabe liefert der Aufruf von socket einen Socketdeskriptor, der als Endpunkt einer
Kommunikation verwendet werden kann. Ein solcher Deskriptor ist tatsächlich nichts
anderes, als ein Integer-Wert, der als Identifier innerhalb des Betriebssystems fungiert.
Beispiel
int socket = socket(PF_INET, SOCK_STREAM, 0);
bind()
int bind(int socket, struct sockaddr *addresse, socklen_t addrlen);
Parameter
Mithilfe von bind wird der als erster Parameter angegebene Socket an eine Adresse gebunden.
Diese Adresse wird in Form eines Zeigers auf die bereits beschriebene sockaddr-Struktur
übergeben. Ebenso notwendig ist als dritter Parameter die Angabe der Länge der übergeben
Adressstruktur, was aufgrund der unterschiedlichen Möglichkeiten bei der verwendeten
- 17 -
Adress-Struktur notwendig ist.
Rückgabe
Im Falle eines Fehlers wird -1 zurückgegeben, ansonsten 0.
Beispiel
struct sockaddr_in adresse;
adresse.sin_family = AF_INET;
adresse.sin_addr.s_addr = htonl(INADDR_ANY);
adresse.sin_port = htons(80);
bind(socket, (struct sockaddr*) &addresse, sizeof(addresse));
listen()
int listen(int socket, int backlog);
Parameter
Dieser Systemaufruf überführt einen Socket, der als erstes Argument angegeben wird vom
Zustand „closed“ in den Zustand „listen“, wodurch Verbindungsanforderungen an diesen
Socket akzeptiert werden können. Der zweite Parameter spezifiziert die Größe der
Warteschlange. In diese Warteschlange werden alle eingehenden Verbindungen eingereiht,
um in der Folge abgearbeitet werden zu können. Die Funktion muss vor dem Aufruf von
accept() aufgerufen werden.
Rückgabe
Im Falle eines Fehlers wird -1 zurückgegeben, ansonsten 0.
Beispiel
listen(socket, 3);
accept()
int accept(int socket, struct sockaddr *addr, socklen_t *addrlen);
Parameter
Als erster Parameter wird der Socket spezifiziert, für den ein Server eingehende
Verbindungen akzeptieren soll. Genauer gesagt wird damit der Socket angegeben, aus dessen
zugeordneter Warteschlange eingehende Verbindungsanfragen heraus genommen werden und
für die eine Verbindung etabliert wird. Beim zweiten Parameter handelt es sich um einen
Ergebnisparameter. In die übergebene Struktur werden die Adressinformationen des mit dem
Socket verbundenen Client eingetragen. Als dritter Parameter wird ein Zeiger auf die Länge
der sockaddr-Struktur übergeben.
Rückgabe
Bei erfolgreichem Aufruf wird der Deskriptor des verbundenen Sockets zurückgeliefert, auf
dem dann geschrieben bzw. von dem gelesen werden kann.
- 18 -
Beispiel
struct sockaddr_in client_adr;
socklen_t client_size;
int con_socket;
client_size = sizeof(client_adr);
con_socket = accept(socket, (struct sockaddr*) &client_adr, &client_size);
connect()
int connect(int socket, struct sockaddr *serv_addr, socklen_t addrlen);
Parameter
Der erste Parameter gibt den Socket an, für den eine Verbindung zu der mit dem zweiten
Parameter angegeben Adresse hergestellt werden soll. Auch hier wird analog zu bind() die
Länge der sockaddr-Struktur übergeben.
Rückgabe
Im Falle eines Fehlers wird -1 zurückgegeben, ansonsten 0.
Beispiel
struct sockaddr_in adresse;
adresse.sin_family = AF_INET;
adresse.sin_addr.s_addr = inet_addr("127.0.0.1");
adresse.sin_port = htons(80) ;
connect(socket, (struct sockaddr*) &adresse, sizeof(adresse));
close()
int close(int socket);
Parameter
Der übergebene Socket wird geschlossen. Bei der Terminierung eines Prozesses werden alle
Socket- bzw. File-Deskriptoren frei gegeben, auch ohne den Aufruf von close().
Rückgabe
Im Falle eines Fehlers wird -1 zurückgegeben, ansonsten 0.
Beispiel
close(socket);
send()
ssize_t send(int socket, const void *buf, size_t len, int flags);
Parameter
Der erste Parameter gibt den Socket an, über den an einen anderen Socket die Nachricht, auf
dessen Speicheradresse der zweite Parameter zeigt, versendet wird. Wichtig hierbei ist, dass
- 19 -
der übergebene Socket verbunden sein muss. Der dritte Parameter gibt an, wie viele Zeichen
von diesem Speicherbereich versendet werden sollen. Mit Hilfe des vierten Parameters ist es
möglich, die Eigenschaften des Sendevorgangs an unterschiedliche Anforderungen
anzupassen.
Rückgabe
Es wird die Anzahl der versendet Bytes zurückgegeben.
Beispiel
char buffer[] = "Bosheit ist kein Lebenszweck! r\n";
bytes = send(socket, buffer, strlen(buffer), 0);
if (bytes == -1){
. . .
}
recv()
ssize_t recv(int socket, void *buf, size_t len, int flags);
Parameter
Der erste Parameter gibt den Socket an, über den Daten empfangen werden. Dieser Socket
muss verbunden sein. Die eintreffenden Daten werden dabei in den Speicherbereich
geschrieben, auf den der zweite Parameter zeigt. Maximal werden len Bytes gelesen. Auch
hier können durch den vierten Parameter analog zu send() Kommunikationseigenschaften
eingestellt werden.
Rückgabe
Es wird die Anzahl der empfangenen Bytes zurückgegeben. Sind zum Zeitpunkt keine Daten
aus dem Socket lesbar, so blockiert der Aufruf standardmäßig.
Beispiel
char buffer[1024];
int bytes = recv(socket, buffer, sizeof(buffer) - 1, 0);
if (bytes == -1){
. . .
}
buf[bytes] = '\0';
Analog zu recv() und send() existieren noch die Funktionen recvmsg() und sendmsg(), auf die
hier nicht eingegangen werden soll.
sendto()
ssize_t sendto(int socket, const void *buf, size_t len, int flags,
const struct sockaddr *to, socklen_t tolen);
Parameter
Die ersten vier Parameter sind identisch zu denen bei send(). Der fünfte Parameter gibt in
Form einer sockaddr-Struktur die Informationen für die Adressierung des entfernten Sockets
an. Der Socket, von dem aus hierbei Daten versendet werden, muss somit nicht verbunden
- 20 -
sein. Als sechster Parameter muss noch die Länge dieser Struktur übergeben werden.
Rückgabe
Es wird die Anzahl der versenden Zeichen zurückgegeben.
Beispiel
struct sockaddr_in adresse;
char buffer[] = "Sapere aude!\r\n";
adresse.sin_addr.s_addr = inet_addr("127.0.0.1");
adresse.sin_family = AF_INET;
adresse.sin_port = htons(4242);
sendto(socket,text,strlen(buffer),0,(struct sockaddr*) & adresse,
sizeof(addr));
recvfrom()
ssize_t recvfrom(int socket, void *buf, size_t len, int flags,
struct sockaddr *from, socklen_t *fromlen);
Parameter
Die ersten vier Parameter sind identisch zu denen von recv(). Die letzten beiden Parameter
resultieren aus der Tatsache, dass ein Socket, über den per recvfrom() Daten versendet werden
nicht verbunden sein muss. In die übergebene sockaddr-Struktur werden daher sobald Daten
eintreffen die Adressinformationen des sendenden Sockets gespeichert. Der letzte Parameter
gibt die Länge der sockaddr-Struktur an.
Rückgabe
Die Anzahl der empfangenen Bytes wird zurückgegeben.
Beispiel
char buffer[1024];
struct sockaddr_in adresse;
int bytes = recvfrom(socket,buffer,sizeof(buffer) - 1,0,(struct sockaddr*)
&adresse, sizeof(adresse));
buffer [bytes] = '\0';
htons(), ntohs(), htonl(), ntohl()
uint32_t
uint16_t
uint32_t
uint16_t
htonl(uint32_t
htons(uint16_t
ntohl(uint32_t
ntohs(uint16_t
hostlong);
hostshort);
netlong);
netshort);
Parameter
Der übergebene unsigned int- bzw. unsigned short-Wert wird von Host-Byte-Order in
Network-Byte-Order umgewandelt bzw. umgekehrt.
Rückgabe
Der konvertierte Parameter.
- 21 -
inet_addr()
in_addr_t inet_addr(const char *cp);
Parameter
Der Parameter stellt einen Zeiger auf die String-Repräsentation einer Internet-Host-Adresse in
Punkt-Notation (z.B. 127.0.0.1) dar.
Rückgabe
Zurückgegeben wird die Binär-Repräsentation des Strings.
gethostbyname()
struct hostent *gethostbyname(const char *name);
Parameter
Also Parameter erhält der Systemaufruf beispielsweise eine URL der Form ‚www.tudresden.de’.
Rückgabe
Geliefert wird eine hostent-Struktur, in dessen h_addr-Feld die zum Parameter gehörige IPAdresse steht.
Beispiel
struct sockaddr_in adresse;
struct hostent *host;
host = gethostbyname("www.tu-dresden.de");
adresse.sin_addr.s_addr = *(unsigned long*) host->h_addr;
6 Literatur
[1]
Umfassendes Buch zur Unix-Socketprogrammierung:
W. R. Stevens: UNIX Network Programming, The Sockets Networking; Addison
Wesley, ISBN 0131411551, 2003
[2]
Seite mit einem „Open-Book“ zur Systemprogrammierung in C:
http://www.pronix.de/
[3]
Seite zur Socket-Programmierung unter Windows:
http://www.c-worker.ch/
- 22 -
Herunterladen