std.pack — Binäre Serialisierung
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^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^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^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^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
