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 -