1. Einleitung

Im Block E des Praktikums Kommunikationsnetze geht es um die Socket-Schnittstelle. Kommunizieren zwei Prozesse miteinander, die sich auf verschiedenen Rechnern befinden, müssen zum Datentransport über das Netzwerk Protokolle verwendet werden. Dabei realisiert die Socket-Schnittstelle (kurz Sockets (deutsch Steckdose, Anschluss)) den Zugriff auf diese Protokolle und bietet somit eine Möglichkeit zur Interprozesskommunikation an. Prinzipiell kann die Kommunikation zwischen Prozessen im selben Rechner ebenfalls durch die Socket-Schnittstelle realisiert werden. Jeder UNIX-Rechner bietet hierzu ein sogenanntes „Loop-Back“-Interface an, über das auf ein rechnerinternes Netzwerk zugegriffen werden kann.

Die Socket-Schnittstelle ist so aufgebaut, dass aus Sichtweise eines Programmierers zwischen einem Socket und einer Datei (eng. File) nahezu kein Unterschied besteht, zumindest was die grundlegenden Funktionen betrifft. Ähnlich wie bei einer Datei wird ein Socket durch einen Socket-Deskriptor (bei Dateien File-Deskriptoren) angesprochen. Wie bei einer Datei kann man Sockets öffnen und schließen, von ihnen lesen oder schreiben.

In den ersten beiden Terminen werden Sie sich mit der Erstellung eines Sockets vertraut machen und über den Socket einen zuverlässigen, verbindungsorientierten Dienst zur Datenübertragung im Internet verwenden, den sogenannten Stream-Socket. Ein Stream-Socket garantiert, dass Daten verlustfrei übertragen und in der Reihenfolge, in der sie gesendet werden, beim Empfänger bereitgestellt werden. Dabei erstellen Sie sowohl einen Client, der eine Kommunikation initiiert und eine Nachricht sendet, als auch einen Server, der die Nachricht empfängt und sie am Bildschirm anzeigt.

Der Vollständigkeit halber soll erwähnt werden, dass es neben dem Stream-Socket auch einen Datagram- und einen Raw-Socket gibt. Mittels des Datagram-Sockets können zwei Prozesse über eine unzuverlässige Verbindung (es gibt Datenverluste und die Daten brauchen nicht in der Reihenfolge empfangen werden in der sie gesendet wurden) miteinander kommunizieren. Ein Raw-Socket ermöglicht die Implementation eigener Protokolle.

Die von Ihnen zu erstellende kleine Applikation ist nicht in der Lage, die volle Leistungsfähigkeit von Sockets zu zeigen. Deshalb werden Sie eine verbreitete Applikation zur Leistungsanalyse von Netzwerken, das Programm „iperf“, kennenlernen. Iperf ist ein sogenannter Lastgenerator, der beliebige Daten erzeugt und diese mittels der Socket-Schnittstelle über ein Netzwerk zu einer Datensenke sendet.

Iperf besteht aus zwei Teilen: Einem Client, der als Lastgenerator verwendet wird, und einem Server, der als Datensenke dient. Der Client verwendet wahlweise einen unzuverlässigen Datagram- oder zuverlässigen Stream-Socket, um mit dem Server zu kommunizieren.

Beim Client wird eine Senderate in Bit/s eingestellt, mit der Daten zum Server gesendet werden. Der Server misst den Datendurchsatz und die Datenverlustrate der vom Client erzeugten Daten.

Unter Verwendung von iperf soll später eine praktische Hausaufgabe angefertigt werden: Innerhalb eines Rechners sollen ein iperf-Client und ein -Server installiert werden und sowohl der Datendurchsatz, als auch die Datenverlustrate zwischen Client und Server in einem simulierten Netzwerk gemessen werden. Das simulierte Netzwerk ermöglicht es, die Verzögerungszeit und Kapazität des Netzwerkes künstlich einzustellen. Im simulierten Netzwerk soll der Datendurchsatz und die Datenverlustrate einer zuverlässigen und einer unzuverlässigen Übertragung mit unterschiedlichen Senderaten des Lastgenerators gemessen werden.

2. Interprozesskommunikation

In Zeiten, in denen Rechnersysteme isoliert und nicht vernetzt betrieben wurden, beschränkte sich die Interprozesskommunikation auf Mechanismen des gemeinsamen Zugriffs auf Variablen in einem von mehreren Prozessen gemeinsamen genutzten Speicherbereich. Um Kollisionen beim gleichzeitigen Schreiben von Variablen zu verhindern, wurden beispielsweise Semaphoren eingesetzt. Eine Semaphore funktioniert ähnlich wie ein Staffelstab: Nur der Prozess, der den Staffelstab besitzt, darf auf den gemeinsamen Datenbereich zugreifen.

Eine andere Möglichkeit besteht darin FIFO (engl. First-In-First-Out)-Speicher oder Pipes (deutsch Rohre) zu verwenden: ein Prozess schreibt Daten in einen FIFO (oder eine Pipe), die der Empfängerprozess liest. Auf FIFOs- und Pipes wird mittels File-Deskriptoren zugegriffen und FIFOs oder Pipes können deshalb als Dateien verstanden werden. Pro Übertragungsrichtung muss ein FIFO oder eine Pipe verwendet werden. Hierdurch wird das gleichzeitige Schreiben von Daten verhindert. Allerdings muss aufgepasst werden, dass der FIFO oder die Pipe nicht überläuft, was zu Datenverlusten führt. Da die Datenübertragung über systeminterne Bussysteme als nahezu fehlerfrei angesehen werden kann, existiert kein Fehlerschutzmechanismus oder nur rudimentäre Mechanismen wie etwa die Berechnung der binären Quersumme über die zu übertragenen Daten.

Mit der Einführung vernetzter Rechnersysteme bestand das Bedürfnis mit Prozessen in unterschiedlichen Rechnersystemen zu kommunizieren. Allerdings führte dies zu Problemen: Im Gegensatz zur Interprozesskommunikation in lokalen Rechnersystemen, kann die Datenkommunikation nicht mehr als fehlerfrei angesehen werden. Im Gegenteil, Datenfehler sind nicht mehr die Ausnahme, sondern die Regel. Dies wiederum führt dazu, dass komplexe Protokolle zur Rechnerkommunikation entwickelt wurden. Die Interprozesskommunikation und eine Schnittstelle, die Funktionen der Interprozesskommunikation nutzbar macht, sollte deshalb folgende Randbedingungen erfüllen:

3. Sockets

Sockets realisieren eine Schnittstelle zur Interprozesskommunikation, durch welche Prozesse des gleichen oder auch unterschiedlicher Rechner miteinander kommunizieren können. Durch das Nutzen von Sockets wird das Finden des Kommunikationspartners, sowie die generell unterliegende Netzwerktopologie und Rechnerarchitektur komplett abstrahiert. Ein Socket wird im UNIX-Betriebssystem ähnlich wie eine Datei oder ein Prozess eindeutig durch einen File-Deskriptor identifiziert. File-Deskriptoren (kurz: FD) wiederum sind einfache Integer-Zahlen, die offenen Dateien zugeordnet sind. Abb. 1-1 verdeutlicht die Funktion der File-Deskriptoren. Es lässt sich deutlich erkennen, dass jeder File-Deskriptor vom Typ Integer über eine Tabelle einer Datei zugeordnet ist. Nicht nur Dateien sind File-Deskriptoren zugeordnet, sondern auch Geräte und Prozesse. Der Deskriptor ist dabei nur solange gültig, solange das referenzierte Objekt (Datei, Gerät oder Prozess) existiert.


Abbildung 1-1: Filedeskriptor

Da unter Unix alle Ein- und Ausgabeeinheiten Geräte sind, denen deshalb ein FD zugeordnet ist, können Prozesse mittels der Socket-Schnittstelle auch über ein Netzwerk hinweg auf Eingabe- und Ausgabeeinheiten zugreifen.


3.1 Internet - Sockets

Folgend konzentrieren wir uns auf Sockets zur Kommunikation über das Internet: Es gibt zwei für diese Veranstaltung relevanten Varianten:

Sie unterscheiden sich bezüglich des Dienstes (zuverlässig oder nicht zuverlässig), den sie anbieten.

Im ersten Fall wird eine fehlerfreie Übertragung sichergestellt, d. h. die Reihenfolge der gesendeten Daten wird eingehalten, auch im Fall von Datenverlusten. Die Socketschnittstelle lässt jedoch offen, wie bei einem Stream-Socket Verluste kompensiert werden bzw. die korrekte Reihenfolge der Daten gewährleistet wird. Eine einfache Möglichkeit ist beispielsweise, verlorengegangene Daten erneut zu übertragen. Es könnten jedoch auch Fehlerkorrekturverfahren Anwendung finden. Im zweiten Fall hingegen, dem Datagram-Socket, können Daten verloren gehen.

Werden Daten beim Empfänger korrekt empfangen, dann gehen wir davon aus, dass die Inhalte fehlerfrei sind. Dies bedeutet, dass Daten nicht innerhalb eines Rechners verfälscht werden.

Eine weitere Variante, die Sie kennen sollten, die aber nicht in dieser Veranstaltung benötigt wird, ist der „Raw-Socket“. Mit diesem Typ können Sie neue Internet-Protokolle erstellen und den Zugriff auf diese Protokolle mittels der Socket-Schnittstelle ermöglichen.


3.2 Initiierung eines Sockets

Stellen Sie sich nun vor, sie möchten, dass ein Prozess X im Rechner A und ein Prozess Y im Rechner B miteinander kommunizieren. Die beiden Prozesse befinden sich in der Regel in unterschiedlichen Rechnern. Allerdings können sie sich auch in einem Rechner befinden.

Für den Aufbau dieser Kommunikation benötigt man als Erstes einen File-Deskriptor, der in diesem Fall auch Socket-Deskriptor genannt wird. Dieser wird mit Hilfe der Funktion socket() erzeugt. Durch die socket()-Funktion wird angezeigt, dass eine Kommunikation über ein Netzwerk benötigt wird und beispielsweise nicht auf eine Datei auf der lokalen Festplatte zugegriffen werden soll.

3.2.1 Protokollfamilie

Dies reicht jedoch noch nicht aus. Zusätzlich wird die Angabe benötigt zu welchem Prozess in welchem Rechner eine Kommunikation hergestellt werden soll. Also muss zusätzlich angegeben werden, dass mit diesem gerade erzeugten Socket eine Kommunikation mit Prozess Y im Rechner B hergestellt werden soll. Um einen Socket mit diesen beiden Größen zu assoziieren, werden entsprechende Funktionen bereitgestellt, auf die folgend näher eingegangen wird.

Zu diesem Zeitpunkt wurde noch nicht festgelegt, wie ein Rechner und ein Prozess adressiert werden bzw. wie diese Adressen ermittelt werden. Konzentrieren wir uns zunächst auf die Rechneradresse.

Eine Rechneradresse kann eine Telefonnummer im ISDN oder Mobilfunknetz oder beispielsweise eine Internetadresse sein. Alle diese Adressen besitzen einen unterschiedlichen Aufbau. Die Socket-Schnittstelle muss in der Lage sein, sie zu interpretieren. Wie kann so etwas realisiert werden?

Zu diesem Zweck ordnet die Socket-Schnittstelle einzelne Netzwerktypen (z. B. Internet oder ISDN) zu Protokollfamilien zu, deren Adressformat identisch ist. Beispielsweise gehören zur ISDN-Protokollfamilie Protokolle zum Faxen von Nachrichten oder zur Datenübertragung über das Fernschreibernetz. Beide Protokolle haben ein identisches Adressformat. Beim Aufbau eines Sockets wird die Protokollfamilie als Parameter der Funktion socket() übergeben.

Die Adresse selbst wird in der Regel in Textform angegeben und ebenfalls als Parameter übergeben. Hierdurch wird vermieden, dass eine Telefonnummer als Internetadresse interpretiert wird oder umgekehrt.

3.2.2 Portnummern

Nachdem nun geklärt ist, wie ein Rechner adressiert wird, bleibt die Frage offen, wie ein Prozess adressiert wird?

Man könnte sich vorstellen, dass zur Prozessadressierung die Prozessnummer verwendet wird, die jeder Prozess bei seinem Start erhält. Dies ist allerdings sehr ungünstig, da Prozessnummern in der Regel dynamisch vergeben werden. Beispielsweise hat ein Prozess, der Dateien über das Netzwerk sendet, mal die Prozessnummer 4711 und ein andermal die Prozessnummer 1147.

Ein Prozess, der mit einem Prozess in einem entfernten Rechner kommunizieren möchte, benötigt in diesem Fall eine Liste aller möglichen Prozesse. Dies ist sehr umständlich. Aus diesem Grund führt die Socket-Schnittstelle den Begriff des Ports ein. Ein Port kann als Synonym für einen Dienst verstanden werden. Jedem Dienst wird ein Port zugeordnet. Beispielsweise ist dem WEB-Dienst die Port-Nummer 80 zugeordnet. Wenn also ein Prozess mit einem anderen Prozess kommunizieren möchte, der einen WEB-Dienst anbietet, wird dem Socket-Aufruf die Port-Nummer 80 als Parameter übergeben. Im Zielrechner sorgt ein Hintergrundprozess dafür, dass die Zuordnung von der Port-Nummer zur Prozessnummer durchgeführt wird. Eine Übersicht der Port-Nummer ist in jedem Unix-System der Datei /etc/services zu entnehmen. Zusätzlich gibt die folgende Tabelle eine Auswahl an besonders häufig verwendeten Ports.


Tabelle 1-1: Wichtige Portnummern

3.2.3 Stream- vs. Datagram-Sockets

Bei einer zuverlässigen Übertragung mittels Stream-Sockets werden andere Voraussetzungen für den Client und Server gelten als bei einer unzuverlässigen Übertragung mittels Datagram-Sockets. Beispielsweise müssen bei der zuverlässigen Übertragung immer die beiden kommunizierenden Prozesse miteinander verbunden werden. Hierzu verwendet der Client den connect()-Befehl. Der Server quittiert den Verbindungswunsch mit einem accept()-Befehl.

3.2.4 Client vs. Server

Des Weiteren können sich die Anforderungen vom Client und Server unterscheiden. Server können in der Regel mehr als nur einen Prozess bedienen. Z. B. kann ein Web-Server in der Regel mit einer Vielzahl von Client-Prozessen kommunizieren. Zu diesem Zweck bietet die Socket-Schnittstelle die listen()-Funktion an. Immer, wenn ein Client eine neue Kommunikation initiieren möchte, wird durch die listen()-Funktion ein neuer Socket initiiert. Später folgende Daten werden direkt an diesen neuen Socket geleitet.


3.3 Datenübertragung

Nachdem ein Socket initiiert — man sagt auch „geöffnet“ — wurde, können Daten über den Socket versendet bzw. empfangen werden. Hierzu werden die Funktionen send() und recv() verwendet.


3.4 Beenden der Kommunikation

Wenn alle Daten übertragen wurden, wird der Socket mit der Funktion close() beendet. Im Falle eines Fehlers wird die reset()-Funktion verwendet.


3.5 Socket-Adress-Struktur

Da die Socket-Schnittstelle für verschiedene Netzwerk-Protokolle (Internet, XNS, …) konzipiert wurde, sind alle Funktionen, die Netzwerkadressen auswerten, so allgemein gehalten, dass sie zuerst die Protokoll-Familie auswerten und zusätzlich noch die Größe der Adress-Struktur als Parameter erwarten.

Die allgemeine Adress-Struktur (siehe Listing 1) der Socket-Schnittstelle besteht daher auch nur aus einer (16-Bit, unsigned short int) Kennung der Protokoll-Familie sa_family, und einer festgelegten Anzahl von Adress-Bytes sa_data (hier 14 Bytes), die protokoll- spezifisch die Adresse repräsentieren.

struct sockaddr {
   u_short sa_family;
   char sa_data[14];
}
Listing 1: Socket-Adresse

Im Folgenden muss beachtet werden, dass im Gegensatz zu einer Telefonnummer (-adresse), die aus 14 Zeichen (14 Bytes) besteht, eine Internet-Adresse (IP-Adresse) – des Internets der Version 4 – aus 32 Bit besteht. Die 32 Bit werden zur besseren Lesbarkeit in Gruppen von 4 Byte geteilt. Jedes Byte wird dezimal dargestellt und die einzelnen Bytes durch einen Punkt voneinander getrennt. So hat beispielsweise ein Rechner im Netz der TU-Berlin die Internet-Adresse 130.149.49.1. Deshalb werden die 14 Bytes der Komponente char sa_data aus der Struktur sockaddr weiter aufgeteilt, was letztendlich zu der Struktur sockaddr_in (siehe Listing 2) führt:

struct sockaddr_in {
   short int sin_family;
   unsigned short int sin_port;
   struct in_addr sin_addr;
   unsigned char sin_pad[8];
};
Listing 2: IP-Adresse

Wie man erkennen kann, besteht eine Socket-Adresse für das Internet aus dem sin_port, der eigentlichen Internetadresse sin_addr und aus Füllbytes (sin_pad). Die sockaddr_in-Struktur bestand in früheren Implementationen aus verschiedenen Elementen, die eine IP-Adresse auf unterschiedliche Weise (Byte-Reihenfolge) darstellten.

struct in_addr {
   unsigned long;
   int s_addr;
};
Listing 3: Internet-Adresse

Heute besteht diese Struktur (siehe Listing 3) nur noch aus einem Element s_addr, sodass eigentlich die Struktur unnötig ist, aber aus Kompatibilitätsgründen weiter verwendet wird (was sich auch in naher Zukunft nicht ändern wird). So kann man auf die als long int dargestellte IP-Adresse nur über den Ausdruck sin_addr.s_addr zugreifen.


3.6 Byte-Order

Da ein Netzwerk (normalerweise) heterogene Rechner, also Rechner unterschiedlicher Architekturen und mit unterschiedlicher Datenrepräsentation (Byte-Folge innerhalb eines Datenwortes) miteinander verbinden kann bzw. soll, muss eine einheitliche Datenrepräsentation der zu übertragenen Daten definiert werden. Im OSI-Referenzmodell übernimmt die Schicht 6 (Präsentations-Schicht) unter anderem diese Datenrepräsentation der Anwendungsdaten.

Während das Phänomen, dass bei einigen Computersystemen Bytes nicht aus 8 Bits sondern aus 9, 11 oder 12 Bits bestehen, langsam verschwindet und daher vernachlässigt werden kann, bleibt ein anderes Problem weiterhin bestehen: Verschiedene Computersysteme speichern bei Datenworten, die aus mehreren Bytes bestehen, die einzelnen Bytes in unterschiedlicher Reihenfolge. So kann ein 16-Bit Datenwort (short int) auf zwei verschiedene Arten gespeichert werden, entweder mit dem niederwertigen Byte an der Startadresse („Little-Endian“-Anordnung) oder mit dem höherwertigen Byte an der Startadresse („Big-Endian“-Anordnung).

Die Anordnung eines 32-Bit-Datenwortes entspricht dabei – durch Aufteilung in zwei 16-Bit-Worte – der Anordnung zweier Bytes in einem 16-Bit-Wort. So verwenden Intel (80x86), DEC VAX, … die „Little-Endian“-Anordnung, wogegen Motorola (68xxx), Sun Sparc und IBM-Großrechner (IBM 370, …) die „Big-Endian“-Anordnung verwenden.

Beim Internet-Protokoll wird die „Big-Endian“-Anordnung verwendet. Dem Programmierer von Applikationen werden eine Reihe von Funktionen angeboten, die 16-Bit-Worte bzw. 32-Bit-Worte von der Netz-Repräsentation in die Repräsentation des lokalen Rechners (und umgekehrt) umformen.

Unabhängig davon, ob der lokale Rechner eventuell die „Big-Endian“-Anordnung schon verwendet, sollte man diese Funktionen immer benutzen, da eine Portierung des entwickelten Programms auf andere Rechner sonst (fast) unmöglich ist.

Auf Rechnern, die schon die „Big-Endian“-Anordnung verwenden, sind diese Funktionen Leer-Funktionen, d. h. sie liefern als Ergebnis die Daten unverändert zurück. Die IP-Port-Nummer und IP-Adresse müssen in der sockaddr_in-Struktur in Netz-Repräsentation gespeichert werden. Wichtige Funktionen zur Umwandlung der Adress-Darstellung sind in Listing 4 dargestellt.

Listing 4: Funktionen zur Umwandlung von Datenrepräsentationen

3.7 I/O-Multiplexing unter Unix

Wie in den vorhergehenden Abschnitten deutlich geworden ist, müssen verschiedene Verbindungen gleichzeitig aufgebaut und I/O-Events verarbeitet werden. Hinzu kommen die Standard-Filedeskriptoren für die Ein- und Ausgabe zum Lesen bzw. Ausgeben von Textdaten.

Um nun nicht dauerhaft einzelne Deskriptoren abfragen (= pollen) zu müssen, bietet Unix den Systemaufruf select() an. Dieser erhält eine Liste aller relevanten Deskriptoren und legt den Prozess schlafen, solange kein Ereignis eingetreten ist (blockierender Systemaufruf).

Tritt ein Ereignis auf, z. B. der Empfang eines Datenpaketes, so weckt der Scheduler des Linux-Kernels den Prozess wieder auf und teilt ihm mit, auf welchem Deskriptor (oder auf welchen Deskriptoren) ein Ereignis stattgefunden hat.


3.8 Stream-Sockets

Wird beim Internet eine Kommunikation mit einem Stream-Socket initiiert, dann werden die zu sendenden Daten mittels des Transmission Control Protocol (TCP) übertragen. Ohne näher auf den Aufbau des Protokolls einzugehen, handelt es sich beim TCP um ein verbindungsorientiertes und zuverlässiges Protokoll. Bevor Daten übertragen werden können, muss zwischen Sender und Empfänger eine Verbindung aufgebaut werden. Die Verbindung wird durch einen 3-Wege-Handshake initiiert. Wenn die Verbindung nicht mehr benötigt wird, dann wird sie abgebaut. Während der Datenübertragungsphase erwartet das Protokoll von den Applikationen, dass die zu übertragenen Daten in Form von Datenblöcken übergeben werden. Die Datenblöcke werden segmentiert und in Paketen verpackt über das Netzwerk übertragen.

Im Netzwerk kann es passieren, dass Pakete nicht, mehrfach oder in vertauschter Reihenfolge beim Empfänger ankommen. TCP ist in der Lage solche Fehler zu korrigieren, ohne dass die Applikation sich darum kümmern muss. Da die Korrektur der Fehler Zeit benötigt, übernimmt TCP keine Garantie zu welchem Zeitpunkt Daten beim Empfänger eintreffen. Die Daten werden also nach dem Best-Effort-Prinzip übertragen.

TCP wird für Bulk-Transfer-Anwendungen verwendet, bei der große Datenmengen übertragen werden müssen (z. B. das File Transfer Protokoll (FTP)) oder nicht-interaktive Multimediaapplikationen (z. B. Video- oder Audio-Streaming (z. B. YouTube)). Dabei darf nicht verwechselt werden, dass im Gegensatz zu den nicht-interaktiven Multimediaapplikationen, interaktive Multimediaapplikationen (z. B. Skype) den Datagram-Socket verwenden und keinen Stream-Socket.

Aber auch Applikationen, die vergleichsweise geringe Datenmengen produzieren, verwenden TCP. Z. B. das Hypertext Transfer Protocol (HTTP) zur Kommunikation mit Web-Servern oder Terminal-Emulationen basieren auf TCP.

4. Aufgabe

Zur Vorbereitung auf den Termin lesen Sie bitte das Skript zum Block E und machen Sie sich mit den C-Funktionen, die benötigt werden, vertraut (in welcher C-Bibliothek sind sie zu finden, welche Parameter benötigen sie und welche Information liefert ihr Rückgabewert). Es ist außerdem empfehlenswert sich mit der kurzen Übung intensiver zu beschäftigen. Einen Shortcut zu der Funktionsübersicht finden sie auch in der Navigation rechts.

Socket-Funktionen

Frischen Sie ihre C-Kenntnisse auf. Ein gute Einführung finden Sie hier:

C Tutorial
C++ Tutorial

Empfehlenswert sind auch die Folien der Vorlesung der Unit 2 (Communication Services and Protocols).

Wer tiefer in die Socket-Programmierung einsteigen möchte, dem sei das Buch „TCP/IP Sockets in C: Practical Guide for Programmers“ von Michael J. Donahoo und Kenneth L. Calvert empfohlen. Auch das Buch „Linux-UNIX-Programmierung“ von Jürgen Wolf (Kapitel 11) ist empfehlenswert. „Beej′s Guide to Network Programming“ und Perkins „NS3 Lab 1 – TCP/IP Network Programming in C“ sind ebenfalls empfehlenswerte Referenzen.

TCP/IP Sockets in C: Practical Guide for Programmers
Beej's Guide to Network Programming
NS3 Lab 1 – TCP/IP Network Programming in C
Linux-UNIX-Programmierung

Auf alle Fälle ist es empfehlenswert, einige Programmbeispiele zu studieren, die in einer Vielzahl im Internet zu finden sind.

Programmbeispiel

Ihre Aufgabe ist es, einen Stream Socket in der Programmiersprache C zu implementieren. Der Client soll Text, der über die Tastatur eingegeben wird, zu einem Server senden, der den Text auf dem Bildschirm ausgibt. Verwenden Sie dazu bitte die zur Verfügung gestellten Dateien stream_client.c bzw. stream_server.c und Makefile. Bevor Sie mit der Implementierung beginnen, müssen Sie die folgenden Aspekte berücksichtigen:


4.1 Programmgerüst

In der Regel ist es kein Problem, die Abfolge von Funktionen zu programmieren. Es gestaltet sich als viel schwieriger, den Funktionen die korrekten Parameter in der korrekten Darstellung zu übergeben. Werden hierbei Fehler gemacht, passiert gar nichts ohne, dass der Grund zunächst erkennbar ist. Die Korrektur der Fehler benötigt in solchen Fällen sehr viel Zeit. Um Ihnen dies zu vereinfachen, existiert bereits ein fertiges Programm. Sie können nun in diesem Programm einzelne Programmteile durch Ihren eigenen Quelltext ersetzen. Hierzu ist in jeder Funktion in den oben genannten Dateien ein Schalter vorhanden. Wenn der Schalter auf 1 gesetzt wird, verwenden Sie ihren eigenen Quellcode (siehe folgenden Beispielcode), wenn er ungleich eins ist, wird der vorbereitete Code ausgeführt.

#if 1

void someFunctionHeader( void someParameters )
{
	//TODO irgendwas
}

#endif
Listing 5: Aufbau einer Funktion im Programmgerüst

Dadurch werden Sie in die Lage versetzt, selektive Änderungen durchzuführen und deren Auswirkungen zu untersuchen. Wie Sie am Beispielcode erkennen, ist der Schalter mittels einer Präprozessor-Anweisung realisiert. Der Code zwischen den #if- und #endif-Anweisungen wird nur ausgeführt, wenn der Wert der #if-Anweisung 1 ist. Wenn der Wert 0 ist, dann wird eine Funktion in der vorhandenen Bibliothek des Programmgerüsts verwendet.

Zu Beginn steht in allen #if-Anweisungen der Wert 0. Das Programm ist somit von Anfang an kompilierbar und lauffähig und kann durch Änderung der entsprechenden Quelltextzeilen zu 1 sukzessive durch eigene Implementationen ersetzt werden. Hierdurch soll Ihnen die Möglichkeit gegeben werden, eigene Funktionen und vorgegebene Funktionen in beliebiger Konstellation zu laden, was die Fehlersuche stark vereinfachen sollte. Die genauen Funktionsbeschreibungen und Aufgabenstellungen sind in der gegebenen Quelltext-Datei aufgeführt, dennoch soll hier ein kurzer Überblick, verbunden mit dem empfohlenen Arbeitsablauf gegeben werden.

4.1.1 Dateien und Verzeichnisse

Alle zur Verfügung gestellten Dateien für diesen Termin befinden sich als Kopie im ISIS und in Ihrem Home-Verzeichnis im Ordner „StreamSocket“. In diesem Ordner befinden sich der Ordner lib und die Dateien Makefile, stream_client.c und stream_ server.c, sowie die Dateien stream_client.h und stream_server.h.

Makefile: Das Makefile erleichtert Ihnen das Übersetzen des Quellcodes zu einem ausführbaren Programm, indem es alle benötigten Anweisungen ausführt. Sie geben lediglich den Befehl

make

ein. Vor jedem neuen make-Befehl empfehlen wir make clean aufzurufen.

lib: Dieser Ordner enthält Bibliotheken mit vordefinierten Funktionen für gängige Betriebssysteme, die zur Verwendung des Clients/ Servers benötigt werden. Unterstützt werden Linux und MacOS X.

stream_client.c / stream_server.c: Diese Dateien enthalten den Quellcode des Clients und des Servers. Jede der beiden Dateien lässt sich im Wesentlichen in drei Abschnitte gliedern:


4.2 Empfohlene Vorgehensweise

Folgende Vorgehensweise hat sich in der Vergangenheit bewährt:

4.2.1 Vertrautmachen mit der Umgebung

Bevor die Programme stream_client und stream_server ausgeführt werden können, müssen Sie zunächst übersetzt und mit einem Linker gebunden werden. Dies geschieht automatisch durch Eingabe des Befehls make. Hierzu starten Sie zunächst das Programm Terminal. Hierauf öffnet sich ein Fenster mit einer Shell. In der Shell wechseln Sie in das Verzeichnis StreamSocket in ihren Home-Bereich und geben am Prompt den Befehl make ein.

Daraufhin werden die beiden Programme stream_client und stream_server erzeugt. Starten Sie zunächst das Programm stream_server. Der Server wird durch Eingabe des Befehls:

./stream_server <ip_adresse> <port>

gestartet. Die fest eingestellte ip_adresse für den Client ist das LoopBack-Interface (Adresse 127.0.0.1) und der Port 3850. Für den Server wird der Wert INADDR_ANY der Adresse zugewiesen. Optional kann der im Programm fest eingestellte port und die ip_adresse durch Angabe der Argumente verändert werden. Dies ist beispielsweise dann notwendig, wenn der Client und der Server auf verschiedenen Rechnern laufen.

Öffnen Sie nun ein neues Fenster im Programm Terminal. Wechseln Sie in das Verzeichnis StreamSocket und starten durch Eingabe des Befehls

./stream_client <ip_adresse > <port>

den Client.

Wenn Sie alle Anweisungen korrekt durchgeführt haben, können Sie wahlweise am Client oder am Server einen Text eingeben, der dann am jeweils anderen Fenster ausgegeben wird.

4.2.2 Implementation des TCP Clients

In diesem Abschnitt wird beschrieben, welche Stellen im Quelltext des Clients modifiziert werden müssen und welche Schritte zum Erstellen des Programms notwendig sind.

Starten Sie einen Texteditor und öffnen dann die Datei stream_client.c. Diese befindet sich in Ihrem Home-Bereich im Verzeichnis StreamSocket. Vollziehen Sie die gegebene Struktur des Quelltextes nach. Die folgenden Funktionen müssen von Ihnen ergänzt werden:

Sie haben die Möglichkeit, alle Änderungen in der Datei stream_client.c auf einmal oder jede der aufgeführten Funktionen einzeln zu modifizieren.

Empfehlenswert ist eine schrittweise Implementation. Achten Sie darauf, dass bei der modifizierten Funktion der Wert der #if-Anweisung von 0 auf 1 geändert werden muss. Ansonsten hat Ihre Modifikation keine Wirkung.

Nach jedem Schritt speichern Sie die Datei und führen das make-Kommando aus. Wenn das make-Kommando nicht erfolgreich ausgeführt wurde, müssen Sie den Quelltext korrigieren, bis das Kommando fehlerfrei abgeschlossen wurde. Anschließend testen Sie durch Ausführen des Programms und überprüfen, ob ihre Änderung das gewünschte Verhalten zeigt.

4.2.3 Implementation des TCP Servers

Die Schritte, die zum Erzeugen des Servers notwendig sind, entsprechen im Wesentlichen denen des Clients. Unterschiedlich ist lediglich der Name der Quell- und der Ausführbaren-Datei (stream_server.c bzw. stream_server).

Da der Server das Pendant des Client ist, sind in ihm vielen Funktionen identisch mit denen des Clients. Deshalb werden nur die zusätzlichen Funktionen beschrieben: