Netzwerkprogrammierung mit POSIX
In einigen Bereichen legt der ISO-C-Standard grundlegende Konzepte fest, die von POSIX später präzisiert und erweitert werden. Das zeigt sich etwa bei der Dateiverarbeitung: ISO C beschreibt Dateien als abstrakte Ströme, während POSIX dieses Modell auf konkrete Betriebssystemressourcen ausdehnt und einen einheitlichen Zugriff auf Dateien, Geräte und andere Objekte ermöglicht. Ähnlich verhält es sich bei Zeit- und Datumsfunktionen, bei denen POSIX zusätzliche Schnittstellen bereitstellt, um mit Zeitquellen, Auflösungen und systemnahen Zeitmechanismen zu arbeiten.
Im Bereich der Netzwerkkommunikation existiert eine solche Vorarbeit im C-Standard überhaupt nicht. ISO C enthält keinerlei Konzepte für Netzwerke, Verbindungen oder entfernte Kommunikationspartner. Mit der C-Standardbibliothek allein lassen sich daher keine Programme schreiben, die über ein Netzwerk kommunizieren. Jede Form von Netzwerkfunktionalität erfordert Erweiterungen jenseits von ISO C, sei es durch POSIX oder durch betriebssystemspezifische Bibliotheken.
POSIX schließt diese Lücke, indem es ein vollständiges und einheitliches Modell für Netzwerkkommunikation definiert. Der Standard beschreibt dazu mehrere zusammenhängende Funktionsbereiche: die Abbildung von Kommunikationsendpunkten, die Adressierung und Namensauflösung, den Verbindungsaufbau und -abbau, den Datenaustausch über verbindungsorientierte und verbindungslose Modelle, die Konfiguration und Steuerung von Kommunikationsparametern sowie Mechanismen zur gleichzeitigen Überwachung mehrerer Kommunikationskanäle.
In diesem Abschnitt betrachten wir einen zentralen Teil dieser Schnittstellen: den Aufbau von Netzwerkverbindungen und die Programmierung einfacher Server und Clients. Grundlage dafür sind Sockets.
Sockets
Ein Socket ist ein vom Betriebssystem bereitgestellter Kommunikationsendpunkt. Er repräsentiert eine Verbindung oder einen potenziellen Kommunikationskanal und wird im Programm wie eine besondere Art von Datei behandelt. Über Sockets können Programme Daten senden und empfangen, unabhängig davon, ob die Gegenstelle sich auf demselben Rechner oder auf einem entfernten System befindet. Die konkrete Art der Kommunikation, etwa verbindungsorientiert oder verbindungslos, wird beim Erzeugen des Sockets festgelegt.
Die wichtigsten Funktionen für die socketbasierte Netzwerkprogrammierung sind:
-
int socket(int domain, int type, int protocol);Erstellt einen neuen Kommunikationsendpunkt. -
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);Verknüpft einen Socket mit einer lokalen Adresse. -
int listen(int sockfd, int backlog);/int accept(int sockfd, ...);Ermöglicht Servern, eingehende Verbindungen anzunehmen. -
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);Baut auf Client-Seite eine Verbindung zu einem Server auf. -
ssize_t send(int sockfd, const void *buf, size_t len, int flags);/ssize_t recv(int sockfd, void *buf, size_t len, int flags);Dient dem Senden und Empfangen von Daten über eine bestehende Verbindung.
Auf dieser Basis können wir im nächsten Schritt ein erstes vollständiges Netzwerkprogramm entwickeln.
Echo-Server ansprechen
Wir wollen ein kleines Netzwerkprogramm schreiben, das einen Echo-Server anspricht. Ein Echo-Server verhält sich sehr einfach: Er nimmt eine Zeichenfolge entgegen und sendet exakt dieselbe Zeichenfolge wieder zurück. Dadurch eignet er sich gut, um den grundlegenden Ablauf einer Socket-Verbindung zu zeigen, ohne sich mit komplexen Protokollen beschäftigen zu müssen.
Das folgende Programm erledigt dabei folgende Aufgaben:
- Auflösen eines Hostnamens und Ports
- Aufbau einer TCP-Verbindung
- Senden einer Nachricht
- Empfangen der Antwort
- Freigeben aller Ressourcen
So kann der TCP-Client für einen Echo-Server aussehen:
#define _POSIX_C_SOURCE 200112L
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
int main() {
int rc = 1;
int fd = -1;
struct addrinfo *ai = nullptr;
struct addrinfo hints = {
.ai_family = AF_INET, // bewusst: IPv4
.ai_socktype = SOCK_STREAM
};
int gai = getaddrinfo("tcpbin.com", "4242", &hints, &ai);
if (gai != 0) {
printf("getaddrinfo: %s\n", gai_strerror(gai));
goto cleanup;
}
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0) {
perror("socket");
goto cleanup;
}
if (connect(fd, ai->ai_addr, ai->ai_addrlen) < 0) {
perror("connect");
goto cleanup;
}
// Nachricht senden
const char *msg = "Hello, Echo!\n";
ssize_t sent = write(fd, msg, strlen(msg));
if (sent < 0) {
perror("write");
goto cleanup;
}
// Echo-Antwort empfangen
char buf[256];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n < 0) {
perror("read");
goto cleanup;
}
buf[n] = '\0';
printf("Echo: %s", buf);
rc = 0;
cleanup:
if (fd >= 0) close(fd);
if (ai) freeaddrinfo(ai);
return rc;
}
Das Programm beginnt mit der Festlegung der gewünschten POSIX-Schnittstellen _POSIX_C_SOURCE 200112L.
Anschließend importieren wir Header, die für die socketbasierte Netzwerkprogrammierung notwendig sind:
-
<netdb.h>: Deklariert Funktionen zur Namensauflösung. Dazu gehörengetaddrinfo(),freeaddrinfo(), der Strukturtypstruct addrinfosowiegai_strerror()zur Fehlerausgabe. -
<unistd.h>: Stellt POSIX-Systemaufrufe bereit, unter anderemread(),write()undclose(). Diese arbeiten auf Dateideskriptoren, zu denen auch Sockets zählen. -
<sys/socket.h>: Enthält die grundlegenden Socket-Funktionen und -Typen, etwasocket(),connect()sowie die nötigen Konstanten für Protokolle und Socket-Typen.
Schritt 1: Namesauflösung
In vielen modernen Programmiersprachen kann eine Netzwerkverbindung direkt mit einem Hostnamen und einem Port aufgebaut werden. Die Namensauflösung und die Auswahl passender Protokollparameter sind dort meist in der Laufzeitbibliothek verborgen. In C ist das anders. Bevor eine Verbindung aufgebaut werden kann, muss der Hostname explizit in konkrete Netzwerkadressen übersetzt werden.
Das Ergebnis dieser Namensauflösung ist eine oder mehrere Strukturen vom Typ struct addrinfo. Erst mit diesen Informationen -- insbesondere Adressfamilie (z. B. IPv4), Socket-Typ (z. B. TCP), Protokoll und Zieladresse -- ist es möglich, einen passenden Socket zu erzeugen und eine Verbindung herzustellen. Mit getaddrinfo() wird der Hostname zusammen mit dem Port in eine oder mehrere konkrete Netzwerkadressen übersetzt. Das Ergebnis ist eine verkettete Liste, von der wir in diesem einfachen Beispiel den ersten Eintrag verwenden.
Schritt 2: Socket erzeugen
Aus den gelieferten Informationen wird anschließend der Socket erzeugt.
Dabei werden exakt die Parameter verwendet, die zum Zielsystem passen.
Das vermeidet Annahmen und erhöht die Portabilität. Mit connect() wird die TCP-Verbindung zum Server aufgebaut. Erst ab diesem Zeitpunkt existiert eine echte Netzwerkverbindung.
Schritt 3: Daten senden und empfangen
Nach dem Verbindungsaufbau sendet der Client eine kurze Zeichenfolge an den Server. Der Echo-Server antwortet mit genau denselben Daten.
Die Antwort wird gelesen, als Zeichenkette terminiert und anschließend ausgegeben.
Schritt 4: Aufräumen und Fehlerbehandlung
Unabhängig davon, ob das Programm erfolgreich war oder unterwegs ein Fehler aufgetreten ist, müssen alle Ressourcen sauber freigegeben werden. Dazu gehört insbesondere:
- das Schließen des Socket-Dateideskriptors
- das Freigeben der von
getaddrinfo()reservierten Adressinformationen
Das Programm besitzt dafür eine zentrale Clean-up-Phase am Ende. Diese Phase kann sowohl beim normalen Programmende als auch aus jedem Fehlerfall heraus angesprungen werden.

HTTP-Webserver
Nachdem wir einen einfachen Client implementiert haben, der eine Verbindung zu einem Server aufbaut, betrachten wir nun die Gegenseite. In diesem Beispiel wird ein kleiner HTTP-Server umgesetzt, der auf eingehende Verbindungen wartet und Anfragen entgegennimmt.
Das folgende Programm implementiert einen einfachen Server, der auf eingehende HTTP-Verbindungen wartet, eine Anfrage entgegennimmt und darauf reagiert. Dabei wird ausgewertet, was angefordert wurde, zum Beispiel ob der Client die Pfade › /‹ oder ›/hello‹ abruft. Abhängig von diesem Pfad wird eine passende Funktion aufgerufen, die festlegt, welche Antwort der Server zurückschickt.
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
constexpr int HTTP_PORT = 8080;
constexpr int MAX_HEADER_BYTES = 8192;
constexpr int MAX_BODY_BYTES = 4096;
constexpr int MAX_RESPONSE_BODY_BYTES = 4096;
static const char DEFAULT_CONTENT_TYPE[] = "text/plain";
typedef struct {
char path[256];
char body[MAX_BODY_BYTES];
size_t body_size;
} Request;
typedef struct {
int status_code;
const char* content_type;
char body[MAX_RESPONSE_BODY_BYTES];
size_t body_len;
} Response;
typedef void (*Handler)(const Request*, Response*);
typedef struct {
const char *path;
Handler handler;
} Route;
// ---------- I/O ----------
// Sendet alle Bytes oder gibt -1 bei Fehler zurück.
static int send_all_bytes(int fd, const void* data, size_t len) {
const uint8_t* p = data;
size_t sent = 0;
while (sent < len) {
ssize_t n = send(fd, p + sent, len - sent, 0);
if (n < 0) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) return -1;
sent += (size_t)n;
}
return 0;
}
// Sucht im Puffer nach "\r\n\r\n" und gibt einen Zeiger auf den
// Body-Start oder nullptr.
static const char* find_http_header_end(const char* buf, size_t len) {
if (len < 4) return nullptr;
for (size_t i = 0; i + 3 < len; ++i) {
if (buf[i] == '\r' && buf[i + 1] == '\n' &&
buf[i + 2] == '\r' && buf[i + 3] == '\n') {
return buf + i + 4;
}
}
return nullptr;
}
// ---------- Request ----------
// Parst die Request-Line (1. Zeile) und schreibt den Pfad nach
// req->path; true bei Erfolg.
static bool parse_http_request_line(const char* header, Request* req) {
char method[16];
char version[16];
return sscanf(header, "%15s %255s %15s",
method, req->path, version) == 3;
}
// Liest bis Header-Ende, parst den Pfad und kopiert die direkt
// mitgelesenen Body-Bytes (ohne Content-Length).
static bool read_http_request_header(int fd, Request* req) {
char buf[MAX_HEADER_BYTES + 1];
size_t used = 0;
memset(req, 0, sizeof(*req));
while (used < MAX_HEADER_BYTES) {
ssize_t n = recv(fd, buf + used, MAX_HEADER_BYTES - used, 0);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
if (n == 0) return false;
used += (size_t)n;
buf[used] = '\0';
const char* body = find_http_header_end(buf, used);
if (!body) continue;
if (!parse_http_request_line(buf, req)) return false;
size_t body_bytes = used - (size_t)(body - buf);
if (body_bytes > MAX_BODY_BYTES) body_bytes = MAX_BODY_BYTES;
memcpy(req->body, body, body_bytes);
req->body_size = body_bytes;
return true;
}
return false;
}
// ---------- Response ----------
// Baut HTTP-Header und sendet Header + Body; Content-Type fällt
// auf DEFAULT_CONTENT_TYPE zurück.
static void send_http_response(int fd, const Response* res) {
const char* ct = res->content_type
? res->content_type
: DEFAULT_CONTENT_TYPE;
char header[512];
int len = snprintf(
header, sizeof(header),
"HTTP/1.1 %d\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n\r\n",
res->status_code, ct, res->body_len
);
if (len > 0) {
send_all_bytes(fd, header, (size_t)len);
send_all_bytes(fd, res->body, res->body_len);
}
}
// ---------- Handler ----------
// "/" -> HTML Willkommensseite.
static void home_handler([[maybe_unused]] const Request* req,
Response* res) {
res->status_code = 200;
res->content_type = "text/html";
res->body_len = (size_t)snprintf(
res->body, sizeof(res->body),
"<h1>Willkommen</h1>\n"
);
}
// "/json" -> JSON Antwort.
static void hello_handler([[maybe_unused]] const Request* req,
Response* res) {
res->content_type = "application/json";
res->body_len = (size_t)snprintf(
res->body, sizeof(res->body),
"{\"message\":\"Hallo Welt\"}\n"
);
}
// Fallback fuer unbekannte Pfade.
static void not_found_handler([[maybe_unused]] const Request* req,
Response* res) {
res->status_code = 404;
res->content_type = "text/plain";
res->body_len = (size_t)snprintf(
res->body, sizeof(res->body),
"404 - Not Found\n"
);
}
// ---------- Routing ----------
static const Route routes[] = {
{ "/", home_handler },
{ "/hello", hello_handler },
};
static constexpr size_t routes_count = sizeof routes / sizeof routes[0];
static inline bool str_equal(const char *a, const char *b) {
return strcmp(a, b) == 0;
}
// Liest Request, sucht Route, ruft Handler, sendet Response.
static void handle_http_connection(int fd) {
Request req;
Response res = {0};
if (!read_http_request_header(fd, &req)) {
res.status_code = 400;
res.content_type = "text/plain";
res.body_len = sizeof "Bad Request\n" - 1;
memcpy(res.body, "Bad Request\n", res.body_len);
send_http_response(fd, &res);
return;
}
res.status_code = 200;
Handler handler = not_found_handler;
for (size_t i = 0; i < routes_count; i++) {
if (str_equal(routes[i].path, req.path)) {
handler = routes[i].handler;
break;
}
}
handler(&req, &res);
send_http_response(fd, &res);
}
// ---------- main ----------
int main() {
int server_fd = -1;
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
goto cleanup;
}
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
goto cleanup;
}
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = INADDR_ANY,
.sin_port = htons(HTTP_PORT),
};
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
goto cleanup;
}
if (listen(server_fd, 16) < 0) {
perror("listen");
goto cleanup;
}
printf("Server läuft auf Port %d\n", HTTP_PORT);
for (;;) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) {
if (errno == EINTR) continue;
perror("accept");
break;
}
handle_http_connection(client_fd);
close(client_fd);
}
cleanup:
if (server_fd >= 0) close(server_fd);
return EXIT_FAILURE;
}
Wenn das Programm startet, wird in main() der Server eingerichtet. Dort werden Socket, Portbindung und Zustand vorbereitet. In der Endlosschleife ruft accept() für jede eingehende Verbindung einen neuen Client Socket ab. Dieser Socket wird anschließend an die Funktion handle_http_connection(int fd) übergeben. Sie ist der zentrale Einstiegspunkt für die Verarbeitung einer einzelnen Verbindung.
handle_http_connection() arbeitet eine Verbindung vollständig ab. Zuerst wird versucht, die Anfrage zu lesen. Dafür wird ein Objekt vom Typ Request angelegt und an read_http_request_header(int fd, Request* req) übergeben. Diese Funktion liest vom Socket, sucht das Ende des HTTP Headers und extrahiert aus der ersten Zeile den angeforderten Pfad, der im Feld req.path abgelegt wird. Schlägt dieser Schritt fehl, wird sofort eine einfache Antwort mit Statuscode 400 erzeugt und zurückgesendet.
Ist die Anfrage gültig, beginnt die eigentliche Entscheidung. In handle_http_connection() wird anhand von req.path eine passende Route gesucht. Die Routen sind als Array von Route Strukturen definiert, die jeweils einen Pfad und einen zugehörigen Funktionszeiger enthalten. Der Funktionszeiger hat den Typ Handler, also eine Funktion mit der Signatur
void handler(const Request*, Response*).
Wird ein passender Eintrag gefunden, wird der zugehörige Handler aufgerufen. Andernfalls wird not_found_handler() verwendet. Vor dem Aufruf wird eine Response Struktur angelegt, die der Handler befüllt. Der Handler wertet den Request aus und schreibt Statuscode, Content Type und Antwortdaten in die Response. Die Netzwerkkommunikation spielt an dieser Stelle keine Rolle mehr.
Nachdem der Handler zurückkehrt, wird die Antwort über send_http_response(int fd, const Response* res) an den Client gesendet. Diese Funktion erzeugt aus der Response-Struktur einen HTTP-Header und überträgt Header und Body vollständig über den Socket. Danach endet handle_http_connection(), der Client-Socket wird geschlossen, und der Server kehrt in main() zum nächsten accept()-Aufruf zurück.
Zusammengefasst ergibt sich folgender Ablauf: main() nimmt Verbindungen an, handle_http_connection() steuert die Verarbeitung, read_http_request_header() liest die Anfrage, ein Handler erzeugt die Antwort, und send_http_response() überträgt sie. Die einzelnen Schritte sind voneinander getrennt, bleiben überschaubar und lassen sich unabhängig erweitern oder austauschen.
Verwendung:
Wir können das Programm kompilieren und starten:
$ cc -o webserver webserver.c
./webserver & echo $!
Beim Starten wird die Prozess-ID ausgegeben, damit wir den Server später wieder von außen starten können.
Der Webserver wird im Hintergrund gestartet. Wir können über einen beliebigen Webbrowser auf Pfade zugreifen und das Ergebnis beobachten. Auch mit Kommandozeilen-Werkzeugen wie CURL können wir einen HTTP-Zugriff initiieren:
$ curl -i http://127.0.0.1:8080/
HTTP/1.1 200
Content-Type: text/html
Content-Length: 20
Connection: close
<h1>Willkommen</h1>
$ curl -i http://127.0.0.1:8080/hello
HTTP/1.1 200
Content-Type: application/json
Content-Length: 25
Connection: close
{"message":"Hallo Welt"}
$ curl -i http://127.0.0.1:8080/does-not-exist
HTTP/1.1 404
Content-Type: text/plain
Content-Length: 16
Connection: close
404 - Not Found
Einordnung des Beispiels: Dieser Server ist stark vereinfacht. Er verarbeitet genau eine Anfrage pro Verbindung, ohne Parallelität, ohne Keep-Alive, ohne vollständiges HTTP-Parsing und ohne Absicherung. Viele Teile realer Protokollstandards fehlen bewusst. Ein vollständiger Webserver muss deutlich mehr leisten: Nebenläufigkeit, Zeitlimits, Sicherheit, vollständige RFC-Umsetzung und robuste Fehlerbehandlung. Das ist kein triviales Unterfangen.
Deshalb werden solche Systeme typischerweise nicht selbst programmiert. Stattdessen nutzt man etablierte Lösungen wie Apache HTTP Server (https://httpd.apache.org/) oder Nginx (https://nginx.org/) sowie Webframeworks.
Auch wenn ein vollwertiger Webserver deutlich mehr Aufwand erfordert, eignet sich dieses Beispiel gut als Übung: Mit überschaubarem Aufwand können Leser Multithreading und eine Signalbehandlung ergänzen, etwa um den Server per Ctrl+C sauber zu beenden.