aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Game-Server

C++ Socket-Programmierung: Einen einfachen TCP-Server bauen

Ob du einen Spielserver, einen Chat-Dienst oder eine einfache API schreibst — die Grundlage ist immer dieselbe: zwei Maschinen, die über das Netzwerk miteinander sprechen. Auf der C++-Seite ist die unterste Schicht, die diese Arbeit erledigt, die c++ socket-API — die Berkeley-Sockets, die seit den 1980er-Jahren Standard sind und bis heute unter dem Linux-Kernel laufen. In diesem Artikel bauen wir von Grund auf einen einfachen TCP-Server, ausschließlich mit POSIX-Systemaufrufen und ohne externe Bibliothek, der eingehende Clients abhört, ihre Daten liest und eine Antwort zurücksendet.

Was ist ein Socket und warum ist TCP wichtig?

Ein Socket ist ein Kommunikationsendpunkt, den das Betriebssystem bereitstellt. Aus Sicht deines Programms verhält er sich wie ein File Descriptor: Du liest von ihm und schreibst in ihn, und der Kernel kümmert sich um die gesamte Netzwerkkomplexität dazwischen. Es gibt zwei Haupt-Transportprotokolle:

  • TCP (SOCK_STREAM): verbindungsorientiert, geordnet und zuverlässig. Die Bytes, die du sendest, kommen vollständig und in der richtigen Reihenfolge an. Wird überall dort verwendet, wo alles ankommen muss — Chat, HTTP, Spiel-Login.
  • UDP (SOCK_DGRAM): verbindungslos, schnell, aber ohne Garantien. Pakete können verloren gehen oder die Reihenfolge ändern. Bevorzugt, wo Latenz wichtiger ist als Zuverlässigkeit, etwa bei Echtzeit-Spielerpositionen.

Wir konzentrieren uns hier auf TCP, weil es das Muster, das man einen „einfachen Server" nennt, am besten veranschaulicht. Der Ablauf ist immer derselbe: socket()bind()listen()accept()recv()/send()close().

Die benötigten Header

Socket-Programmierung unter Linux erfordert eine Handvoll POSIX-Header. Unter Windows würde man Winsock verwenden (winsock2.h und WSAStartup); hier nehmen wir Linux/macOS an.

#include <iostream>
#include <cstring>      // memset, strlen
#include <unistd.h>     // close, read, write
#include <sys/socket.h> // socket, bind, listen, accept
#include <netinet/in.h> // sockaddr_in, htons
#include <arpa/inet.h>  // inet_ntop

Den Listening-Socket erstellen

Der erste Schritt besteht darin, mit dem Aufruf socket() einen Endpunkt zu erzeugen. AF_INET wählt IPv4 und SOCK_STREAM wählt TCP. Der Aufruf gibt bei einem Fehler -1 zurück; das Prüfen des Rückgabewerts jedes Systemaufrufs ist in der Socket-Programmierung unerlässlich.

int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
    perror("socket");
    return 1;
}

// Verhindert den Fehler "Address already in use" beim Neustart des Servers
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

Die Option SO_REUSEADDR ist klein, aber wichtig: Wenn du den Server stoppst und sofort wieder startest, kann der Port noch im Zustand TIME_WAIT sein, und bind() schlägt fehl. Diese Option beseitigt dieses Hindernis.

Binden, lauschen und annehmen

Jetzt binden wir den Socket an eine IP und einen Port. Die Adresse füllen wir mit einer sockaddr_in-Struktur. Der kritischste Punkt hier ist die Byte-Reihenfolge: Die Portnummer wird im Netzwerk in Big-Endian erwartet, daher konvertieren wir sie mit htons() (host-to-network short). INADDR_ANY bedeutet „auf allen Netzwerkschnittstellen dieser Maschine lauschen".

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;   // alle Schnittstellen
addr.sin_port = htons(8080);         // Port 8080 in Netzwerk-Byte-Reihenfolge

if (bind(server_fd, (sockaddr*)&addr, sizeof(addr)) == -1) {
    perror("bind");
    return 1;
}

if (listen(server_fd, SOMAXCONN) == -1) {  // Warteschlange
    perror("listen");
    return 1;
}
std::cout << "Server lauscht auf Port 8080...\n";

Das zweite Argument von listen() ist die Größe der Warteschlange für ausstehende Verbindungen; SOMAXCONN verwendet den größten sinnvollen Wert, den das System zulässt. Nun können wir eingehende Clients einzeln mit accept() begrüßen. Dieser Aufruf blockiert, bis eine neue Verbindung eintrifft, und gibt für jede Verbindung einen eigenen Socket-Deskriptor zurück.

Lesen, schreiben und die Schleife aufbauen

Sobald die Verbindung hergestellt ist, sprechen wir mit dem Client über recv() und send() (gleichwertig funktionieren auch read()/write()). Der häufigste Fehler hier ist zu vergessen, dass recv() nicht so viele Bytes zurückgibt, wie du angefordert hast, sondern so viele, wie gerade verfügbar sind. Der Rückgabewert ist die Anzahl der gelesenen Bytes; 0 bedeutet, dass die Gegenseite die Verbindung geschlossen hat, und -1 bedeutet einen Fehler.

while (true) {
    sockaddr_in client{};
    socklen_t len = sizeof(client);
    int client_fd = accept(server_fd, (sockaddr*)&client, &len);
    if (client_fd == -1) { perror("accept"); continue; }

    char ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
    std::cout << "Neue Verbindung: " << ip << "\n";

    char buffer[1024];
    ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        std::cout << "Empfangen: " << buffer;
        const char* reply = "Hallo, Nachricht erhalten!\n";
        send(client_fd, reply, strlen(reply), 0);
    }
    close(client_fd);   // fertig mit diesem Client
}
close(server_fd);

Diese Schleife bedient einen Client nach dem anderen. Nach dem Kompilieren des Programms (g++ -std=c++17 server.cpp -o server) und dem Ausführen kannst du es von einem anderen Terminal aus testen, indem du dich mit nc localhost 8080 oder telnet localhost 8080 verbindest und eine Nachricht eingibst.

Worauf in der Produktion zu achten ist

Der obige Server ist als Tutorial perfekt, reicht aber für sich genommen in der realen Welt nicht aus. Einige Punkte, die du im Hinterkopf behalten solltest:

  • Nebenläufigkeit: Eine einzelne Schleife bedient immer nur einen Client. Für viele Clients musst du jeder Verbindung einen Thread geben oder — als skalierbarere Lösung — eine auf epoll basierende Ereignisschleife aufbauen.
  • Teilweise Lese-/Schreibvorgänge: Wie recv() sendet auch send() möglicherweise nicht alle Bytes auf einmal. Du musst es in einer Schleife erneut aufrufen, bis der gesamte Puffer gesendet ist.
  • Signale: Das Schreiben in einen geschlossenen Socket kann den Prozess mit SIGPIPE beenden; das Flag MSG_NOSIGNAL beim send()-Aufruf zu verwenden ist eine gute Angewohnheit.
  • Nachrichtengrenzen: TCP ist ein Byte-Strom ohne Konzept von „Nachrichten". In deinem eigenen Protokoll musst du die Nachrichtenlänge als Präfix senden oder ein Trennzeichen verwenden.

Häufige Fragen

Sollte ich TCP oder UDP verwenden?

Verwende TCP, wenn Daten vollständig und in der richtigen Reihenfolge ankommen müssen: Login/Authentifizierung, Chat, Inventaroperationen. Wenn der Verlust einiger Pakete akzeptabel ist, aber geringe Latenz entscheidend ist (Spielerpositionen, Bewegungsaktualisierungen), passt UDP besser. Viele Spiele verwenden beide zusammen.

Funktioniert derselbe Code unter Windows?

Die Logik ist dieselbe, aber Windows benötigt die Winsock-Bibliothek: Man ruft am Anfang WSAStartup() auf, der Socket-Typ ist SOCKET statt int, und man verwendet closesocket() statt close(). Eine plattformübergreifende Schicht zu schreiben, um diese Unterschiede zu abstrahieren, ist ein gängiger Ansatz.

Warum gibt recv() weniger Daten zurück als erwartet?

Weil TCP ein Strom ist, keine Pakete. Der Kernel gibt dir das, was sich gerade in seinem Puffer befindet. Um eine vollständige Nachricht zu lesen, musst du recv() in einer Schleife erneut aufrufen, bis du die erwartete Anzahl an Bytes erreicht hast.

Brauchst du Hilfe bei Low-Level-Netzwerkprogrammierung? Wenn du Unterstützung bei hochperformanten Spiel- und Chat-Servern in C++, maßgeschneiderten Protokollen und epoll-basierten Architekturen möchtest, nimm Kontakt mit mir auf — lass uns dein Projekt gemeinsam auf ein solides Fundament stellen.

Bu kategorideki tüm yazılar →

Devamı için