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

Game-Packet-Design: Header, Serialisierung, Byte-Reihenfolge

Gutes Game-Packet-Design ist der erste Baustein, der entscheidet, ob ein Spielserver skaliert und ob Client und Server tatsächlich dieselbe Sprache sprechen. Wenn du Daten über TCP sendest, sendest du in Wahrheit einen Byte-Strom; den Begriff einer „Nachricht" gibt es von Haus aus nicht. Du musst also rohe Bytes in ein strukturiertes Protokoll verwandeln, das beide Seiten auf dieselbe Weise lesen und schreiben können. In diesem Artikel zeige ich, wie man von Grund auf ein robustes Binärprotokoll mit dem Trio Header, Serialisierung und Byte-Reihenfolge aufbaut.

TCP ist ein Strom, keine Nachricht

Der erste und häufigste Fehler ist die Annahme „ein send() gleich ein recv()". TCP garantiert das nicht. Die Daten, die du mit einem einzigen send() abschickst, können auf der anderen Seite in zwei getrennten Stücken ankommen; oder zwei kurz hintereinander gesendete Pakete können verschmolzen in einem einzigen recv()-Aufruf landen (das nennt man „TCP Coalescing"). Mit anderen Worten: Die Daten wieder entlang der Nachrichtengrenzen aufzuteilen, ist allein deine Aufgabe.

Es gibt zwei klassische Wege, das zu lösen:

  • Längen-präfixiert: Du schreibst am Anfang jedes Pakets, wie viele Bytes der Body umfasst. Der gängigste und zuverlässigste Ansatz.
  • Trennzeichen-basiert: Du trennst Nachrichten mit einem speziellen Byte (zum Beispiel \n). Bei Binärdaten ist das riskant, weil das Trennzeichen auch innerhalb der Nutzlast vorkommen kann; bei Textprotokollen wird es bevorzugt.

Spiele verwenden fast immer ein längen-präfixiertes Binärprotokoll, weil es schnell, kompakt und eindeutig ist.

Header-Design: die Identität jedes Pakets

Der Header ist ein Block fester Größe, der vor dem Body steht und dir sagt, wie das Paket zu lesen ist. Ein minimaler, aber brauchbarer Header enthält in der Regel:

  • length — die Byte-Länge des Bodys (oder des gesamten Pakets). Um den Strom in Nachrichten aufzuteilen.
  • opcode / type — was dieses Paket ist (z. B. login, move, chat). Um es an den richtigen Handler weiterzuleiten.
  • Optional: eine sequence-Nummer, Flags (komprimiert/verschlüsselt?), die Protokoll-Version.

Ein typischer Header lässt sich in C++ so ausdrücken:

#include <cstdint>

#pragma pack(push, 1)
struct PacketHeader {
    uint16_t length;   // Body-Länge (Bytes)
    uint16_t opcode;   // Pakettyp
};
#pragma pack(pop)

#pragma pack(push, 1) ist hier entscheidend: Der Compiler fügt aus Performance-Gründen Padding (Ausrichtungslücken) zwischen den Struct-Feldern ein. Dieses Padding variiert je nach Compiler und Plattform, sodass beide Seiten ein anderes Byte-Layout sehen können, wenn du die Struct direkt über die Leitung sendest. Das Padding abzuschalten — oder, robuster, die Felder einzeln zu serialisieren — beseitigt dieses Problem.

Byte-Reihenfolge: Little-Endian oder Big-Endian?

Wenn du eine mehrbytige Zahl (etwa einen 4-Byte-uint32_t) in den Speicher schreibst, hängt die Reihenfolge der Bytes von der Architektur ab. x86/x64 und ARM (meist) sind Little-Endian; die Konvention in Netzwerkprotokollen ist Big-Endian („network byte order"). Wenn beide Seiten eine andere Reihenfolge annehmen, wird die Zahl 1 am anderen Ende als 16777216 gelesen.

Die Lösung: Lege eine einzige Byte-Reihenfolge auf der Leitung fest und wende sie an beiden Enden konsistent an. Auf POSIX-Systemen sind die Konvertierungsfunktionen ab Werk dabei:

#include <arpa/inet.h>

uint16_t net = htons(host_value);   // host -> network (Big-Endian)
uint16_t host = ntohs(net);         // network -> host
uint32_t net32 = htonl(host_value); // 32-Bit-Versionen

Diese Funktionen gelten nur für 16 und 32 Bit; für 64-Bit-Werte musst du in zwei 32-Bit-Hälften aufteilen oder einen eigenen Helfer schreiben. Solange Client und Server stets dieselbe Endianness annehmen (zum Beispiel beide Little-Endian-PCs), ist technisch keine Konvertierung nötig; aber das Protokoll ausdrücklich auf eine Endianness festzulegen, erspart dir später Kopfschmerzen, wenn du eine andere Plattform hinzufügst.

Serialisierung: Sende die Struct nicht direkt

Eine verlockende, aber gefährliche Abkürzung ist: send(sock, &packet, sizeof(packet), 0). Das sendet das Speicher-Layout der Struct unverändert und bricht plattformübergreifend wegen Padding, Endianness, sogar Pointer-/Struct-Ausrichtung. Schreibe die Felder stattdessen explizit in einen Puffer:

void writeUint16(std::vector<uint8_t>& buf, uint16_t v) {
    uint16_t net = htons(v);
    buf.push_back(net & 0xFF);
    buf.push_back((net >> 8) & 0xFF);
}

uint16_t readUint16(const uint8_t* data) {
    uint16_t net = data[0] | (data[1] << 8);
    return ntohs(net);
}

Beim Serialisieren von Strings vergiss das Längen-Präfix nicht: zuerst eine uint16_t-Länge, dann so viele Bytes. So weiß die lesende Seite, wo es endet. Statt ein eigenes Format zu schreiben, kannst du auch eine ausgereifte Bibliothek verwenden — Protocol Buffers, FlatBuffers oder Cap'n Proto bieten schemabasierte, versionierbare Serialisierung und senken die Wartungslast, besonders wenn das Protokoll wächst.

Die Leseschleife: fragmentierte Daten wieder zusammensetzen

Halte pro Verbindung einen Empfangspuffer. Hänge die aus recv() kommenden Bytes daran an und prüfe dann in einer Schleife „gibt es genug Daten?":

// grobe Logik (Fehlerbehandlung gekürzt)
buffer.append(recv_data);
while (buffer.size() >= sizeof(PacketHeader)) {
    uint16_t len = readUint16(buffer.data()); // Body-Länge
    size_t total = sizeof(PacketHeader) + len;
    if (buffer.size() < total) break;         // Paket noch nicht vollständig
    handlePacket(buffer.data(), total);
    buffer.erase_front(total);                // verarbeiteten Teil verwerfen
}

Diese Schleife behandelt sowohl den fragmentierten Fall (ein Paket über mehrere recvs verteilt) als auch den verschmolzenen Fall (mehrere Pakete in einem recv) korrekt. Für Produktionsqualität sind zwei weitere Dinge unerlässlich: Setze eine Obergrenze für das length-Feld (z. B. 64 KB), damit ein bösartiger Client keine riesige Länge senden und deinen Speicher erschöpfen kann; und validiere jedes eingehende Feld, bevor du es an den Handler übergibst.

Häufige Fragen

Sollte ich UDP statt TCP verwenden?

Das kommt darauf an. TCP liefert geordnete, zuverlässige Zustellung; es ist ideal für verlustempfindliche Daten wie Login, Inventar und Chat. Für schnelle Action-Spiele (FPS, Fighting) wird UDP wegen der geringen Latenz bevorzugt, aber die Zuverlässigkeit (Ordnung, erneute Übertragung) musst du selbst hinzufügen. Viele Spiele nutzen beides zusammen.

Sollte ich den Header verschlüsseln?

Meist reicht es, den Body zu verschlüsseln, aber Basisfelder wie length sollten lesbar bleiben, damit du den Strom aufteilen kannst. Ein gängiger Ansatz ist, den gesamten Verkehr nach dem Handshake in eine TLS-ähnliche Schicht zu hüllen; in dem Fall läuft dein Paketprotokoll innerhalb des verschlüsselten Tunnels und die Byte-Reihenfolge-/Serialisierungslogik bleibt unverändert.

Wie entwickle ich das Protokoll später weiter?

Setze ein version-Feld in den Header und füge neue Opcodes hinzu, ohne die alten zu brechen. Schemabasierte Serialisierung (wie Protobuf) macht das Hinzufügen und Entfernen von Feldern abwärtskompatibel; bei handgeschriebenen Protokollen musst du diese Disziplin selbst wahren.

Brauchst du ein robustes Spielprotokoll? Von Metin2-artigen Servern bis zu maßgeschneiderten C++-Netzwerkschichten kann ich bei Packet-Design und Networking helfen — nimm Kontakt auf.

Devamı için