Netzwerk-Kommunikation


Sockets

Mithilfe sog. Sockets (Endpunkt einer Netzwerkkommunikation) kann über verschiedene Netzwerkprotokolle mit Prozessen auf anderen Rechnern im Netzwerk oder auch lokal kommuniziert werden. Dies ist auch eine relativ einfache Möglichkeit, Embedded-Boards mit Peripheriegeräten oder Standard-PCs zu koppeln. Die Übertragungsbandbreite der Verbindung ist mit 10-1000 Mbit/s wesentlich höher als bei I²C, CAN oder Bluetooth und durch die Ethernet-Topologie auch sehr flexibel. Weiterhin ist die Kommunikation über Sockets auch über WLAN möglich. Einzige Voraussetzung hierfür ist das Vorhandensein eines passenden Gerätetreibers und die Sichtbarkeit des Netzwerkgerätes in der ifconfig-Ausgabe.


TCP/IP-Referenzmodell

Beim TCP/IP-Referenzmodell handelt es sich um ein Schichtenmodell mit fünf Ebenen, welches für die Kommunikation mittels der Internet-Protokoll-Familie TCP/IP entworfen wurde. Später wurde dieses Modell zum ISO/OSI-Modell mit sieben Schichten ausgebaut, wobei bestimmte Schichten oder Gruppen den im TCP/IP-Modell vorhandenen Ebenen entsprechen. Dieses Modell ist die Grundlage für die heutige Kommunikation im Internet und an dieser Stelle hilfreich, um die für eine Socket-Kommunikation relevanten Ebenen zu verdeutlichen.


TCP/IP Model


In der Anwendungsschicht erfolgt die Datenübertragung über bekannte Protokolle wie FTP und HTTP. Weiterhin lassen sich in dieser Schicht über Telnet oder SSH Konsolenverbindungen zur Fernsteuerung entfernter Rechner herstellen und über SMTP E-Mails übermitteln. Diese Schicht und ihre Protokolle werden üblicherweise von der Anwendungs-Software bereitgestellt und sind nicht im Betriebssystem enthalten.

In der Transportschicht wird die Verbindung von einem zum anderen Ende aufgebaut und verwaltet. Hierzu sind zwei Kommunikationsendpunkte (Sockets) notwendig. Ein Endpunkt wird durch eine Adresse und eine Port-Nummer beschrieben und kommuniziert üblicherweise über TCP (Transmission Control Protocol) oder UDP (User Datagram Protocol).

Die Internetschicht ist für die Übertragung und Weiterleitung (Routing) von Paketen höherer Schichten zuständig. Mittels IP-Adresse und Subnetzmaske sind Teilnehmer in Subnetz-Gruppen als logische Einheiten gegliedert. Über das IP-Protokoll erfolgt auf dieser Schicht die Adressierung einzelner Rechner, um Übertragungswege auszumachen und Pakete zuzustellen. Eine zuverlässige Übertragung kann auf dieser Ebene allerdings nicht garantiert werden.

Für die Netzwerkschicht ist im TCP/IP-Referenzmodell kein dediziertes Protokoll vorgesehen. Vielmehr können verschiedene Techniken der Punkt-zu-Punkt-Verbindung zum Einsatz kommen, wie bspw. Ethernet (CSMA/CD, Carrier Sense Multiple Access and Collision Detection). Übersetzt: Mehrfachzugriff mit Trägerprüfung und Kollisionserkennung, FDDI (Fiber Distributed Data Interface) oder Wireless LAN.


TCP und UDP

Für den Aufbau einer Socket-Verbindung muss der Anwender zunächst eines der beiden Protokolle TCP oder UDP auswählen, welche wesentliche Unterschiede aufweisen. Während TCP flussorientiert arbeitet, handelt es sich bei UDP um ein paketorientiertes Protokoll, bzw. um eine Übertragung einzelner Nachrichten oder Datagramme.


TCP/UDP


TCP stellt einen virtuellen Kanal zwischen beiden Endpunkten her und zählt entsprechend zu den verbindungsorientierten Protokollen. Auf diesem Kanal ist eine bidirektionale Datenübertragung möglich, wobei Datenverluste erkannt und behoben werden. Das TCP-Protokoll ist somit ein zuverlässiges Verfahren der Datenübertragung, bei welchem Fehler in der Datenübertragung sicher erkannt werden. Aufgrund dieser Eigenschaften wird TCP als Grundlage für die meisten Protokolle auf Anwendungsschicht verwendet.

Bei UDP handelt es sich um ein verbindungsloses Protokoll, bei welchem einzelne Nachrichten, sog. Datagramme, übertragen werden. UDP gilt als nicht zuverlässiges Übertragungsverfahren, da es keine Garantie für eine erfolgreiche Übertragung gibt. Ebenso wenig wird garantiert, dass mehrere Pakete in der gleichen Reihenfolge und genau einmal ankommen. UDP verfügt allerdings über Prüfmechanismen, um sicherzustellen, dass ein ankommendes Paket auch die korrekten Daten enthält. Ein Datenaustausch via UDP ist aufgrund des einfacheren Protokolls und des Verzichtes auf einen Verbindungsaufbau im Vergleich zu TCP effizienter. Das unmittelbare Senden von Datagrammen bedeutet zudem eine geringere Latenz gegenüber einer gepufferten, flussorientierten Übertragung.

Die Auswahl des Protokolls auf Anwendungsschicht ist grundsätzlich abhängig von der Aufgabe bzw. von den Anforderungen hinsichtlich der zu übertragenden Datenmengen, der maximalen Übertragungszeiten und der geforderten Sicherheit. Würde bei einer TCP-Verbindung ein Paket verloren gehen, so würde dieses erneut angefordert. Bis das angeforderte Paket einträfe, könnten bereits neue Daten vorliegen, womit die alten Daten bereits nicht mehr relevant wären. Diese Varianzen in der Übertragungsdauer disqualifizieren TCP als Protokoll für einen schnellen Prozessdatenaustausch. Bei der Verwendung eines Standard-Linux-Betriebssystems und den entsprechenden Treibern ist UDP nicht echtzeitfähig im Sinne garantierter Übertragungszeiten. Es ist jedoch in jedem Fall schneller als TCP.


Berkeley Sockets

Die als Berkeley Sockets bekannte Schnittstelle wurde 1982 im Unix-Release 4.1cBSD (Berkeley Software Distribution oder auch Berkeley Unix) als Programmierschnittstelle in C implementiert. Mit dem Aufkommen von Netzwerkverbindungen war es das Ziel, ähnlich der IO-Routinen für den Dateizugriff auch eine Bibliothek für den Datenaustausch über das Netzwerk zu realisieren. Dies ist auch der Grund, weswegen die Socket-Schnittstelle bekannte Namen für die Zugriffsfunktionen wie open(), read(), write() oder close() verwendet. Die Berkeley Socket Library bündelt die Funktionen dieser Schnittstelle und ist in jeder Linux-Distribution integriert. Für die Verwendung müssen lediglich die entsprechenden Header-Dateien inkludiert werden. Die folgende Abildung zeigt die grundlegende Vorgehensweise zur Erzeugung einer verbindungsorientierten bzw. einer verbindungslosen Kommunikation:


Socket-Diagramm


Adressstrukturen

Die Adressstrukturen unterschiedlichen Typs werden der Berkeley Socket Library über Parameter übergeben. Dies kann teilweise etwas unübersichtlich sein, deshalb soll an dieser Stelle ein Überblick über verwendete Strukturen gegeben werden. Alle Funktionen der Bibliothek erwarten folgende, generische Adressstruktur:

#include <sys/socket.h>
	
struct sockaddr {
  u_short sa_family;		// Adressfamilie
  char sa_data[14];		// Protokollspezifische Adresse
};

sa_family enthält die Adressfamilie und beschreibt die Art der Adressstruktur. Die eigentlichen Adressen werden in von sa_data gespeichert. In sys/socket.h sind für die Adressfamilien Konstanten der Form AF_xxxx definiert. Der Umgang mit dieser Struktur wäre relativ unhandlich, um darin bspw. die für eine Adressfamilie AF_INET notwendigen Daten wie IP und Port-Nummer zu verwalten. Für jede Adressfamilie existiert deshalb ein angepasster Datentyp. Im Falle von AF_INET ist dies die Struktur sockaddr_in:

#include <netinet/in.h>

struct sockaddr_in {
  sa_family_t sin_family;	// Adressfamilie
  in_port_t sin_port;		// Port-Nummer
  struct in_addr sin_addr;	// Internet-Adresse als 32-bit-Ganzzahl
  char sin_zero[8];		// nicht verwendet
};

Zu beachten ist dabei, dass sowohl die IP-Adresse als auch die Port-Nummer in Network Byte Order angegeben werden. Die jeweilige spezifische Struktur kann in die generische Struktur gecastet werden. Entsprechend sind beim Aufruf der Socket-Funktionen in der Regel folgende Umwandlungen notwendig:

struct sockaddr_in addr;
<function>(sock, (struct sockaddr* )&addr,...)

Verwendung der Berkeley Socket API

Im folgenden Beispiel sollen Daten von einer Sender-Anwendung (udp_basic_sender) über eine verbindungslose UDP-Kommunikation an ein Empfängerprogramm (udp_basic_receiver) übertragen werden (siehe Embedded Linux Toolbox in Downloads). Es kommt dafür ausschließlich die in der Programmiersprache C erstellte Berkeley Socket Library zum Einsatz. Zunächst wird die Datei udp_basic_sender/sender.c näher betrachtet:

#include <sys/types.h>		// Primitive system data types
#include <stdio.h>		// Input/Output
#include <stdlib.h>		// General utilities
#include <string.h>		// String handling
#include <sys/socket.h>		// Basic socket functions
#include <netdb.h>		// Translating protocol and host names

int main(int argc, char *argv[]) {
	
  struct addrinfo cfg,*srv;
  int fd, buflen;
  char buf[100];

  if (argc != 3) { // Test for correct number of arguments
    printf("Usage: %s <Send IP> <Send Port>\n", argv[0]);
    exit(1);
  }

  printf("Sending UDP-packets to %s:%s..\n", argv[1], argv[2]);
  // make sure that defaults are 0/NULL
  memset( &cfg, 0, sizeof(struct addrinfo) );
  cfg.ai_family = PF_INET;
  cfg.ai_socktype = SOCK_DGRAM;
  cfg.ai_protocol = IPPROTO_UDP;

  if (getaddrinfo ( argv[1], argv[2], &cfg, &srv) != 0) {
    printf("Error resolving address\n");
    exit(1);
  }

  fd = socket(srv->ai_addr->sa_family,srv->ai_socktype,srv->ai_protocol);

  while(1) {
    printf("Enter your string now, ENTER to send\n");
    // read characters and send them
    scanf("%s", buf);
    buflen = strlen(buf) + 1;
    if ( sendto( fd, &buf, buflen, 0, srv -> ai_addr, srv -> ai_addrlen) != buflen)
      printf("Error sending string\n");
  }
  return 0;
}

				
			

Neben den Header-Dateien für Typdefinitionen und Standardfunktionen müssen zusätzlich sys/socket.h für Socket-Funktionen und netdb.h für Umwandlungsfunktionen eingebunden werden. Als Argumente werden der Anwendung die Zieladresse (oder der Host-Name) und der Zielport übergeben. Familie, Typ und Protokoll des Sockets werden vom Anwender in der Struktur cfg vom Typ addrinfo festgelegt. Die Funktion getaddrinfo() löst Host-Namen und Service auf und liefert als Ergebnis mit srv einen Zeiger auf eine Struktur vom Typ addrinfo, welche IP-Adresse und Port-Nummer enthält. Nachdem nun alle benötigten Informationen vorliegen, kann der Socket mit socket() erstellt werden.

Da die Kommunikation verbindungslos erfolgt, kann nun direkt mit sendto() unter Angabe von Zieladresse und -port gesendet werden. Hierbei ist zu beachten, dass bestimmte Ports für Systemdienste reserviert sind und nicht verwendet werden dürfen. Die Nummern 0-1023 sind den sog. Well-Known-Ports vorbehalten. Die Nummern 1024 bis 49151 zählen zu den Registered Ports und sollten für eigene Anwendungen ebenfalls nicht verwendet werden. So bleibt letztlich der Bereich von 49152 bis 65535 für die Nutzung in eigenen Programmen.

Die Anzahl der zu übertragenden Bytes ergibt sich aus der Länge der vorgegebenen Zeichenkette. Das Endezeichen wird ebenfalls übertragen. Im Erfolgsfall sollte die Länge der gesendeten Bytes wiederum buflen betragen. Auf der Gegenseite empfängt die Anwendung udp_basic_receiver eintreffende Daten. Ob ein Zuhörer existiert, spielt beim Versenden jedoch keine Rolle. Im Folgenden ist der Quelltext aus udp_basic_receiver/receiver.c abgedruckt:

#include <sys/types.h>		// Primitive system data types
#include <stdio.h>		// Input/Output
#include <stdlib.h> 		// General utilities
#include <string.h> 		// String handling
#include <sys/socket.h>		// Basic socket functions
#include <netdb.h>		// Translating protocol and host names
#include <arpa/inet.h>		// Internet address manipulation

int main(int argc, char *argv[]) {

  struct addrinfo cfg,*srv;
  struct sockaddr_in client_addr;
  socklen_t addrlen = sizeof(client_addr);
  int fd, recvlen;
  char buf[100];
  char host[20];
  char service[6];

  if (argc != 2) { // Test for correct number of arguments
    printf("Usage: %s <Receive Port>\n", argv[0]);
    exit(1);
  }

  printf("Receiving UDP-packets at port %s..\n", argv[1]);
  // make sure defaults are 0/NULL
  memset( &cfg, 0, sizeof(struct addrinfo) );
  cfg.ai_flags = AI_PASSIVE;
  cfg.ai_family = PF_INET;
  cfg.ai_socktype = SOCK_DGRAM;
  cfg.ai_protocol = IPPROTO_UDP;
  if (getaddrinfo ( NULL, argv[1], &cfg, &srv) != 0) {
    printf("Error resolving address or service\n");
    exit(1);
  }
  fd = socket(srv->ai_addr->sa_family,srv->ai_socktype,srv->ai_protocol);
  if (bind ( fd, srv->ai_addr, srv->ai_addrlen) != 0) {
    printf("Error resolving address or service\n");
    exit(1);
  }
  while(1) {
    recvlen = recvfrom(fd, &buf, 100, 0, (struct sockaddr *) &client_addr, (socklen_t *) &addrlen );
    printf("Received string: %s from %s:%d\n", buf, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port) );
  }

  return 0;
}

				
			

Die Prozedur zur Erzeugung eines Sockets für den Datenempfang ist fast identisch zu vorigem Beispiel. Der kleine Unterschied ist, dass lediglich der Service, aber kein Host-Name mit getaddrinfo() aufgelöst werden muss. Nachdem der Socket mit bind() einer Port-Nummer zugeordnet wurde, kann auf diesem Port empfangen werden. Eine Angabe der maximalen Puffergröße von 100 Zeichen stellt sicher, dass recvfrom() nicht über den Puffer hinaus schreibt. Die Absenderinformationen werden in client_addr festgehalten.

Da es sich hier um die Protokollfamilie PF_INET handelt, und damit zwangsläufig um die Adressfamilie AF_INET, kann statt des abstrakten Datentyps sockaddr direkt die spezialisierte Version sockaddr_in verwendet werden, um Adressinformationen aufzunehmen. Über die Funktion inet_ntoa() wird die als 32-bit-Adresse vorliegende IP in die bekannte, punktierte Darstellung konvertiert und als String zurückgeliefert. Die Funktion ntohs() wandelt die Port-Nummer als 16-bit-Ganzzahl, welche im Network Byte Format vorliegt, bei Bedarf in die auf dem Zielsystem abweichende Darstellung um. Übrigens handelt es sich bei der von der Empfänger-Anwendung angezeigten Nummer um die Port-Nummer, unter welcher die Nachricht abgesendet wurde. Diese hat mit dem Argument des Senders (Zielport) nichts zu tun.


Eine kurze Einführung in die Netzwerkprogrammierung unter Linux soll an dieser Stelle genügen. Im Embedded-Linux-Buch wird vertiefend auf die Themen Netzwerk-Debugging mit NetCat und Network Byte Order eingegangen. Weiterhin wird die Definition eigener Protokolle mithilfe der C++ Klasse Practical Sockets erklärt.


Zurück