std.pack — Binäre Serialisierung

Zurück zur Unit-Übersicht

Schreibt und liest Werte in einem rohen Byte-Puffer: Ganzzahlen fester Breite (little-endian), VarInts, Bool, Strings mit Längenpräfix, Null-Marker und Array-Header. Kein Format-Header, keine Typkennung im Datenstrom — Schreiber und Leser müssen sich über die Reihenfolge einig sein.

Einsatzbereiche: eigene Binärformate, IPC, Netzwerkprotokolle, kompakte Ablage strukturierter Daten.

Autor: Andreas Röne
Copyright: 2024–2025 Andreas Röne
Quelle: std/pack.lyx

<WRAP info> Seit lyxc 1.0.20F stimmt VarIntSize wieder mit WriteVarInt überein (#1501), und der Überlauf ab 2&#94;62 ist behoben. Rundlauf nachgemessen:

0  ->  1 Byte, VarIntSize=1, zurueck=0            ✓
        300  ->  2 Byte, VarIntSize=2, zurueck=300          ✓
         -1  ->  1 Byte, VarIntSize=1, zurueck=-1           ✓
 4611686018427387904 -> 10 Byte, VarIntSize=10, zurueck identisch  ✓

Die vorausberechnete Puffergröße trägt damit wieder, auch für 2&#94;62. </WRAP>

Import

import std.pack;


Grundregeln

Drei Dinge, die man vor dem ersten Aufruf wissen muss:

Der Rückgabewert ist die Bytezahl, nicht die neue Position. PackInt64 liefert 8, PackInt8 liefert 1. Die Schreibposition führt der Aufrufer selbst weiter: pos := pos + PackInt64(buf, pos, wert);. (Frühere Fassungen dieser Seite behaupteten das Gegenteil.)

Der Puffer gehört dem Aufrufer. Keine Funktion allokiert oder prüft Grenzen. Wie viel Platz nötig ist, rechnet man vorher aus — für Strings hilft StringPackSize, für VarInts VarIntSize.

Es gibt keine Selbstbeschreibung. Im Puffer steht nur der nackte Wert. Wer einen Int32 geschrieben hat, muss beim Lesen UnpackInt32 an derselben Position aufrufen — ein Versatz von einem Byte liefert stillschweigend Unsinn.


Funktionen

Ganzzahlen fester Breite

Alle little-endian, alle vorzeichenbehaftet interpretiert.

Signatur Bytes Beschreibung
PackInt64(buf, pos, val: int64): int64 8 Schreibt 64-Bit-Wert
UnpackInt64(buf, pos: int64): int64 8 Liest 64-Bit-Wert
PackInt32(buf, pos, val: int64): int64 4 Schreibt untere 32 Bit
UnpackInt32(buf, pos: int64): int64 4 Liest 32 Bit, vorzeichenrichtig
PackInt16(buf, pos, val: int64): int64 2 Schreibt untere 16 Bit
UnpackInt16(buf, pos: int64): int64 2 Liest 16 Bit, vorzeichenrichtig
PackInt8(buf, pos, val: int64): int64 1 Schreibt unteres Byte
UnpackInt8(buf, pos: int64): int64 1 Liest 1 Byte, vorzeichenrichtig
PackBool(buf, pos, val: bool): int64 1 Schreibt 1 oder 0
UnpackBool(buf, pos: int64): bool 1 Jeder Wert ungleich 0 gilt als true

Die Pack-Funktionen schneiden den Wert auf ihre Breite zu, ohne einen Überlauf zu melden: PackInt8(buf, 0, 300) schreibt 44.

VarInt

Kompakte Kodierung mit 7 Nutzbits je Byte; das höchste Bit zeigt an, dass ein weiteres Byte folgt. Kleine Zahlen brauchen 1 Byte.

Seit dem Fix von #1463 wird ZickZack-kodiert: das Vorzeichen wandert ins unterste Bit, damit kleine negative Beträge kurz bleiben (−1 belegt 1 Byte statt zehn). Der Betragsbereich je Bytezahl halbiert sich dadurch — 63 passt noch in ein Byte, 64 braucht bereits zwei. Das Format hat sich damit geändert: Dateien, die eine ältere Fassung geschrieben hat, lassen sich nicht mehr lesen.

Signatur Beschreibung
WriteVarInt(buf, pos, val: int64): int64 Schreibt val als VarInt (ZickZack-kodiert), liefert die Bytezahl. Negative Werte sind zulässig; Beträge ab 2&#94;62 laufen über (#1501)
ReadVarInt(buf, pos: int64): int64 Liest ein VarInt; bricht spätestens nach 10 Byte ab
VarIntSize(val: int64): int64 Bytezahl, die val belegen würde — stimmt nicht mit WriteVarInt überein (#1501)

VarIntSize ist beim Lesen unentbehrlich: ReadVarInt liefert nur den Wert, nicht die verbrauchte Bytezahl — die muss man sich über VarIntSize(gelesenerWert) zurückrechnen.

Strings

Signatur Beschreibung
PackString(buf, pos, s: int64): int64 Schreibt Längen-VarInt und danach die Zeichen (ohne Nullbyte); liefert die Gesamtbytezahl
UnpackString(buf, pos: int64): int64 Liefert einen Zeiger auf das erste Zeichen hinter dem Längenpräfix. Nicht nullterminiert
StringPackSize(s: int64): int64 Platzbedarf: Längenpräfix plus Zeichen

Null-Marker und Arrays

Signatur Beschreibung
PackNull(buf, pos: int64): int64 Schreibt das Byte 255 als „kein Wert„
IsNull(buf, pos: int64): bool Ob an pos das Byte 255 steht
PackArrayStart(buf, pos, count: int64): int64 Schreibt die Elementzahl als VarInt
UnpackArrayStart(buf, pos: int64): int64 Liest die Elementzahl

PackNull und IsNull sind eine reine Verabredung: 255 ist ein gültiges Datenbyte, ein Null-Marker ist also nur dort erkennbar, wo das Format an dieser Stelle einen erwartet.

Fließkomma

Signatur Bytes Beschreibung
PackFloat64(buf, pos, val: f64): int64 8 IEEE-754-Bitmuster, little-endian
UnpackFloat64(buf, pos: int64): f64 8 Liest es zurück
PackFloat32(buf, pos, val: f64): int64 4 Rechnet auf 32-Bit-IEEE-754 um (Exponent-Bias 127, 23 Mantissenbits)
UnpackFloat32(buf, pos: int64): f64 4 Liest es zurück

Der Rundlauf ist geprüft: 3.5, 1/3, −0.75 und 42.0 kommen unverändert zurück, PackFloat32 verarbeitet 3.5 und 0.25 korrekt. Bei Float32 ist der übliche Genauigkeitsverlust einzurechnen — 1/3 lässt sich in 32 Bit nicht exakt darstellen.


Beispiele

Ganzzahlen schreiben und lesen

import std.io;
import std.pack;
import std.alloc;

fn main(): int64 {
    var buf: int64 := alloc(256);
    var pos: int64 := 0;

    // Schreiben: jede Pack-Funktion liefert die Anzahl geschriebener Bytes,
    // die Position fuehrt der Aufrufer selbst weiter
    pos := pos + PackInt64(buf, pos, -1234567890123);
    pos := pos + PackInt32(buf, pos, -1000);
    pos := pos + PackInt16(buf, pos, -300);
    pos := pos + PackInt8(buf, pos, -5);
    pos := pos + PackBool(buf, pos, true);
    pos := pos + PackFloat64(buf, pos, fDiv(7 as f64, 2 as f64));
    PrintLn("geschrieben: ", IntToStr(pos), " Bytes");

    // Lesen: dieselbe Reihenfolge, Positionen erneut fortschreiben
    var p: int64 := 0;
    PrintLn("Int64   = ", IntToStr(UnpackInt64(buf, p)));   p := p + 8;
    PrintLn("Int32   = ", IntToStr(UnpackInt32(buf, p)));   p := p + 4;
    PrintLn("Int16   = ", IntToStr(UnpackInt16(buf, p)));   p := p + 2;
    PrintLn("Int8    = ", IntToStr(UnpackInt8(buf, p)));    p := p + 1;
    PrintLn("Bool    = ", IntToStr(UnpackBool(buf, p) as int64)); p := p + 1;
    PrintLn("Float64 = ", FloatToStr(UnpackFloat64(buf, p), 4));
    return 0;
}

Ausgabe:

geschrieben: 24 Bytes
Int64   = -1234567890123
Int32   = -1000
Int16   = -300
Int8    = -5
Bool    = 1
Float64 = 3.500000

Negative Werte kommen bei allen festen Breiten korrekt zurück — die Vorzeichenauslegung beim Entpacken wurde unter #1270 nachgezogen, die Fließkommadarstellung unter #1462.

Datensatz mit Strings und Array-Header

import std.io;
import std.pack;
import std.alloc;

fn main(): int64 {
    var buf: int64 := alloc(256);

    // Datensatz: Anzahl, dann je Eintrag ein String und eine Zahl
    var pos: int64 := 0;
    pos := pos + PackArrayStart(buf, pos, 3);
    pos := pos + PackString(buf, pos, "Muster" as int64);
    pos := pos + PackInt32(buf, pos, 42);
    pos := pos + PackString(buf, pos, "Beispiel" as int64);
    pos := pos + PackInt32(buf, pos, -7);
    pos := pos + PackString(buf, pos, "Test" as int64);
    pos := pos + PackInt32(buf, pos, 1000);
    PrintLn("Datensatz: ", IntToStr(pos), " Bytes");

    // Lesen
    var p: int64 := 0;
    var anzahl: int64 := UnpackArrayStart(buf, p);
    p := p + VarIntSize(anzahl);
    PrintLn("Eintraege: ", IntToStr(anzahl));

    var i: int64 := 0;
    while (i < anzahl) {
        var slen: int64 := ReadVarInt(buf, p);
        var sptr: int64 := UnpackString(buf, p);
        // Der Zeiger ist NICHT nullterminiert — Zeichen einzeln ausgeben
        Print("  '");
        var j: int64 := 0;
        while (j < slen) { PrintChar(peek8(sptr + j)); j := j + 1; }
        p := p + VarIntSize(slen) + slen;
        var zahl: int64 := UnpackInt32(buf, p);
        p := p + 4;
        PrintLn("' -> ", IntToStr(zahl));
        i := i + 1;
    }
    return 0;
}

Ausgabe:

Datensatz: 34 Bytes
Eintraege: 3
  'Muster' -> 42
  'Beispiel' -> -7
  'Test' -> 1000

Der Positionsvorschub beim Lesen eines Strings ist VarIntSize(slen) + slen — man braucht die Länge also zweimal: einmal zum Vorrücken, einmal zum Ausgeben. UnpackString allein reicht nicht.


Fallstricke

Geprüft mit lyxc 1.0.21A.

VarIntSize passt nicht zu WriteVarInt (#1501)

Seit der Umstellung auf ZickZack rechnet WriteVarInt mit dem verdoppelten Betrag, VarIntSize aber weiterhin mit dem Originalwert. Für einen Teil des Wertebereichs meldet es deshalb ein Byte zu wenig:

Wert WriteVarInt schreibt VarIntSize meldet
63 1 1
64 2 1
127 2 1
128 2 2
8192 3 2
−1 1 1
−65 2 1
−1000 2 1

Das trifft genau den dokumentierten Zweck der Funktion: Wer damit die Puffergröße vorausberechnet, reserviert zu knapp, und WriteVarInt schreibt darüber hinaus. Beim Lesen läuft die Positionsfortschreibung p := p + VarIntSize(wert) aus dem Takt.

Bis zum Fix die Bytezahl vom Schreiben zurückgeben lassen statt sie zu schätzen:

var geschrieben: int64 := WriteVarInt(buf, pos, wert);
pos := pos + geschrieben;                       // verlaesslich

Beim Lesen hilft das nicht — dort bleibt nur, Längen mit fester Breite (PackInt32) abzulegen, solange #1501 offen ist.

Beträge ab 2&#94;62 laufen über (#1501)

4611686018427387903  ->  9 Byte, Rundlauf korrekt
4611686018427387904  ->  1 Byte, Rundlauf falsch
9223372036854775807  ->  1 Byte, gelesen wird 63
-9223372036854775808 ->  1 Byte, gelesen wird -64

Die ZickZack-Abbildung verdoppelt den Wert und läuft dabei für große Beträge über. Vor dem Fix von #1463 liefen wenigstens die nicht-negativen Werte bis zum int64-Maximum korrekt durch; dieser Bereich ist derzeit verloren. Für sehr große Zahlen PackInt64 mit fester Breite verwenden.

UnpackString liefert keinen nullterminierten String

Der Zeiger zeigt in den Puffer, direkt hinter das Längenpräfix. Ein Nullbyte steht dort nicht:

var b: int64 := alloc(64);
var i: int64 := 0;
while (i < 64) { poke8(b + i, 88); i := i + 1; }   // Puffer mit 'X' fuellen
PackString(b, 0, "Hallo" as int64);
var p: int64 := UnpackString(b, 0);
PrintLn("'", p as pchar, "'");
PrintLn("StrLen: ", IntToStr(StrLen(p as pchar)));

'HalloXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
StrLen: 63

Die Länge muss immer separat über ReadVarInt(buf, pos) geholt werden. Wer den Wert als pchar weiterreichen will, kopiert ihn in einen eigenen Puffer und terminiert dort selbst.

Kein Grenzschutz, keine Typprüfung

Keine Funktion prüft, ob der Puffer groß genug ist — PackInt64 schreibt 8 Byte, wohin man zeigt. Ebenso wenig prüft UnpackInt32, ob dort je ein Int32 stand. Ein um ein Byte verschobener Lesevorgang liefert eine plausible Zahl statt eines Fehlers.

Praktische Absicherung: Puffergröße vorab aus VarIntSize/StringPackSize und den festen Breiten summieren, und Schreib- wie Lesecode in einer Funktion nebeneinander halten, damit die Reihenfolge nicht auseinanderläuft.

Pack-Funktionen schneiden still zu

PackInt8(buf, pos, 300) schreibt 44, PackInt16(buf, pos, 70000) schreibt 4464 — jeweils ohne Meldung. Wer Werte fremder Herkunft packt, prüft den Bereich vorher selbst.


Verwandte Units

  • std.base64 — Binärdaten in Text umwandeln
  • std.json — textbasierte, selbstbeschreibende Serialisierung
  • std.net.asn1 — ASN.1/BER mit Typkennung im Datenstrom
  • std.alloc — Puffer für die Pack-Funktionen