Endianness: Warum dieselben Bytes zwei Zahlen ergeben
Vier Bytes liegen im Speicher: 12 34 56 78. Lesen Sie sie mit drei verschiedenen JavaScript-APIs, und zurück kommen zwei verschiedene Zahlen. Das ist Endianness, und das ganze Problem passt in eine Tabelle:
| Leseaufruf | Ergebnis |
|---|---|
new DataView(buf).getUint32(0) | 0x12345678 |
new DataView(buf).getUint32(0, true) | 0x78563412 |
new Uint32Array(buf)[0] | 0x78563412 |
Nichts ist kaputt, und nichts wirft eine Exception. Jeder Aufruf wendet eine andere Regel darauf an, welches Ende einer Zahl aus mehreren Bytes zuerst kommt.
Am meisten Zeit spart es, die Frage anders zu stellen. Nicht „Was für eine Maschine habe ich?“, sondern „Nach wessen Konvention wurden diese Bytes geschrieben?“ Ein PNG auf Ihrer Festplatte ist big-endian. Eine GZIP-Datei daneben ist little-endian. Ihre CPU hat in beiden Fällen kein Mitspracherecht.
Alles Folgende wurde mit Node v26.7.0 und Python 3.14.6 unter macOS darwin arm64 gemessen, wo os.endianness() den Wert LE liefert und sys.byteorder den Wert little.
1. Was big-endian und little-endian tatsächlich bedeuten
Nehmen Sie den 32-Bit-Wert 0x12345678. Er besteht aus vier Bytes: 12 ist das höchstwertige, 78 das niedrigstwertige. Die Bytereihenfolge entscheidet, welches davon an der niedrigsten Adresse landet.
| Anordnung | Adresse 0 | Adresse 1 | Adresse 2 | Adresse 3 |
|---|---|---|---|---|
| Big-endian | 12 | 34 | 56 | 78 |
| Little-endian | 78 | 56 | 34 | 12 |
Big-endian speichert das große Ende zuerst, also in derselben Reihenfolge, in der Sie die Zahl auf Papier schreiben würden. Little-endian speichert das kleine Ende zuerst. An den Bits innerhalb eines Bytes rührt keine der beiden Varianten: 0x12 bleibt in beiden Anordnungen 0x12. Es bewegen sich nur ganze Bytes.
Falls der Schritt von Hex zu Binär die wacklige Stelle ist: Der Zahlensystem-Konverter stellt jedes Byte binär neben seine Hex-Form, und der Leitfaden zur Umrechnung zwischen Binär, Hex und Oktal behandelt die Notation selbst.
1.1 Warum es zwei davon gibt
Die Spaltung ist historisch gewachsen und nicht durchdacht. Big-endian liest sich so, wie Menschen Zahlen schreiben, und wurde früh genug zur Konvention für Netzwerkprotokolle, um dort hängen zu bleiben. Auf der CPU-Seite hat little-endian gewonnen, weil x86 es verwendet und ARM standardmäßig darauf setzt. Das Effizienzargument, das nahezu jeder Artikel zum Thema wiederholt, misst Abschnitt 8 nach; dort schlägt es mit rund 2 % zu Buche.
2. Endianness ist eine Eigenschaft des Formats, nicht der Plattform
Kurze Erklärungen überspringen diesen Punkt fast immer. Über die Bytereihenfolge entscheidet, wer die Bytes geschrieben hat, nicht die Maschine, die sie liest.
Für den Beweis genügen ein Laptop und zwei Dateien. Auf derselben arm64-Maschine, im selben Prozess, verlangen diese beiden Dateien entgegengesetzte Dekodierung.
2.1 PNG ist big-endian
RFC 2083 verlangt Ganzzahlen aus mehreren Bytes in Netzwerk-Bytereihenfolge, also ist jede Länge, jede Breite und jede Höhe in einem PNG big-endian. Der Aufbau des Headers liegt fest: acht Signaturbytes, dann eine Chunk-Länge aus vier Bytes, dann der vier Zeichen lange Chunk-Typ, dann Breite und Höhe.
const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');
png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG signature
png.readUInt32BE(8); // 13 — IHDR chunk length
png.readUInt32BE(16); // 1200 — image width
png.readUInt32BE(20); // 630 — image height
png.readUInt32LE(16); // the same four bytes, read the wrong way
| Feld | Bytes | Big-endian | Little-endian |
|---|---|---|---|
| IHDR-Länge | 00 00 00 0d | 13 | 218.103.808 |
| Bildbreite | 00 00 04 b0 | 1200 | 2.953.052.160 |
2.2 GZIP ist little-endian
RFC 1952 §2.3.1 schreibt es aus: niedrigstwertiges Byte zuerst. Die letzten vier Bytes eines Gzip-Streams sind ISIZE, die unkomprimierte Größe. Komprimieren Sie 300 Bytes A und sehen Sie nach:
python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');
gz.subarray(-4).toString('hex'); // '2c010000'
gz.readUInt32LE(gz.length - 4); // 300 — correct
gz.readUInt32BE(gz.length - 4); // the same four bytes, read the wrong way
| Feld | Bytes | Little-endian | Big-endian |
|---|---|---|---|
| Trailer ISIZE | 2c 01 00 00 | 300 | 738.263.040 |
Dieselbe Maschine, derselbe Prozess, und beide Male dasselbe Vier-Byte-Leseprimitiv. Wenn Ihre Faustregel lautet „Meine Maschine ist little-endian, also lese ich little-endian“, zerfällt eine der beiden Dateien beim Dekodieren zu Müll. Es entscheidet immer das Format.
3. JavaScript: zwei APIs, zwei entgegengesetzte Voreinstellungen
Hier werden Browser und Node verwirrend. Die beiden Wege, auf einen ArrayBuffer zu blicken, widersprechen einander in ihren Voreinstellungen.
3.1 DataView-Endianness: das dritte Argument entscheidet
Die Methoden von DataView nehmen als letztes Argument ein optionales littleEndian-Flag. Lassen Sie es weg, bekommen Sie big-endian. setUint32(0, x) und setUint32(0, x, false) sind derselbe Aufruf.
const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);
dv.getUint32(0).toString(16); // '12345678' — big-endian, the default
dv.getUint32(0, true).toString(16); // '78563412' — littleEndian: true
Beim Schreiben gilt dasselbe in umgekehrter Richtung:
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');
dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'
dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'
3.2 TypedArray folgt der Plattform, und Sie können nichts daran ändern
Uint32Array, Int16Array, Float64Array und der ganze Rest verwenden das, was die CPU verwendet. Dafür gibt es weder ein Argument noch ein Konstruktor-Flag. Auf dieser arm64-Maschine heißt das little-endian, also genau das Gegenteil der Voreinstellung von DataView.
new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'
Ein und derselbe ArrayBuffer liefert also über new DataView(buf).getUint32(0) gelesen 0x12345678 und über new Uint32Array(buf)[0] gelesen 0x78563412. Beides ist korrekt; die beiden Aufrufe beantworten verschiedene Fragen.
Sichten auf einzelne Bytes sind immun, denn eine Bytereihenfolge existiert nur für Einheiten, die breiter als ein Byte sind. Uint8Array und Int8Array brauchen nie ein Flag. Verbreitern Sie um einen einzigen Schritt, und das Thema ist zurück:
const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'
3.3 Node Buffer: die Reihenfolge steht im Methodennamen
Buffer verzichtet vollständig auf Voreinstellungen und schreibt die Reihenfolge in den Methodennamen. Deshalb lässt sich Node-Code in dieser Hinsicht meist am leichtesten prüfen.
const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);
b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'
b.swap32().toString('hex'); // '78563412' — mutates b in place
swap32() dreht jede Vierergruppe von Bytes um und gibt denselben Buffer zurück statt einer Kopie. Das ist praktisch, wenn Sie ein ganzes Array von Ganzzahlen in der falschen Reihenfolge vor sich haben, und gefährlich, wenn Sie vergessen haben, dass sich mehrere Stellen diesen Buffer teilen.
4. Python struct: fünf Präfixe, und was @ wirklich kostet
4.1 < > ! = @: die fünf Präfixe für die Bytereihenfolge von struct pack
0x12345678 als vorzeichenlose 32-Bit-Ganzzahl gepackt, eine Zeile je Präfix:
import struct
struct.pack('<I', 0x12345678).hex(' ') # '78 56 34 12' little-endian
struct.pack('>I', 0x12345678).hex(' ') # '12 34 56 78' big-endian
struct.pack('!I', 0x12345678).hex(' ') # '12 34 56 78' network order
struct.pack('=I', 0x12345678).hex(' ') # '78 56 34 12' native order, standard sizes
struct.pack('@I', 0x12345678).hex(' ') # '78 56 34 12' native order, native alignment
! und > erzeugen identische Bytes, weil die Netzwerk-Bytereihenfolge big-endian ist. Beim Entpacken spiegelt sich das, und int.from_bytes liefert dasselbe Paar:
hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0]) # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0]) # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big')) # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little')) # '0x78563412'
4.2 @ und = unterscheiden sich im Padding, nicht in der Bytereihenfolge
Beide folgen der Plattform, schreiben auf dieser Maschine also beide little-endian. Der Unterschied liegt im Alignment, und er verändert die Größe Ihres Structs:
struct.calcsize('@ci') # 8
struct.calcsize('=ci') # 5
struct.calcsize('<ci') # 5
Ein char gefolgt von einem int sind fünf Bytes Daten. Unter @, dem Standard, wenn Sie gar kein Präfix schreiben, fügt Python drei Padding-Bytes ein, damit das int an einer Vier-Byte-Grenze beginnt. Unter = oder einem beliebigen expliziten Präfix für die Bytereihenfolge verschwindet dieses Padding.
Das ist der Mechanismus hinter einem Bug, der auf den ersten Blick keinen Sinn ergibt: Jemand ergänzt <, um ein Problem mit der Bytereihenfolge zu beheben, und plötzlich verschiebt sich die Satzlänge unter seinen Händen. Mit der Bytereihenfolge hat das nichts zu tun. Der Wechsel weg von @ hat stillschweigend auch das native Alignment abgeschaltet.
5. Netzwerk-Bytereihenfolge, und wie andere Sprachen sie schreiben
Die Netzwerk-Bytereihenfolge ist big-endian. TCP-, UDP- und IP-Header führen ihre Felder aus mehreren Bytes alle so, und das geht auf eine Zeit zurück, in der big-endian-Hardware verbreitet genug war, dass sich jemand entscheiden musste. C stellt die Umwandlung über htons, htonl, ntohs und ntohl bereit: Host zu Netzwerk und zurück, für Shorts und Longs. Auf einem little-endian-Host tauschen sie die Bytes, auf einem big-endian-Host tun sie nichts. Deshalb läuft Code, der sie weglässt, tadellos, bis er auf eine andere Maschine trifft.
Go geht den umgekehrten Weg und weigert sich, überhaupt eine Voreinstellung zu haben:
import "encoding/binary"
v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)
binary.BigEndian und binary.LittleEndian sind Werte, die Sie an der Aufrufstelle benennen. Es gibt keinen plattformabhängigen Pfad und kein optionales Flag, das jemand vergessen könnte. Um die Bytereihenfolge in Go zu prüfen, lesen Sie den Bezeichner.
Dieselbe Disziplin gilt für Festkommadaten. Ein Q15- oder Q31-Sample ist, sobald es Ihren Code verlässt, eine schlichte 16- oder 32-Bit-Ganzzahl und erbt damit die Frage nach der Bytereihenfolge wie alles andere auch. Der Q-Format-Konverter zeigt die Ganzzahl hinter dem Bruch, und für genau diese Ganzzahl gilt dann die Bytereihenfolge.
6. Auch Gleitkommazahlen haben eine Bytereihenfolge
Ein Float ist nichts Besonderes. IEEE 754 legt das Bitmuster fest, und danach landen dieselben vier oder acht Bytes in der Reihenfolge auf dem Datenträger, die das Format verlangt.
struct.pack('>f', 1.0).hex(' ') # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ') # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ') # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ') # '9a 99 99 99 99 99 b9 3f'
Der IEEE-754-Konverter beantwortet die erste Hälfte der Frage: Tippen Sie 3.14159 mit ausgewähltem FP32 ein, und Sie erhalten 0x40490FD0. Dieser Artikel beantwortet die zweite Hälfte, nämlich in welcher Reihenfolge diese vier Bytes in der Datei ankommen.
Die Zeile mit double 0.1 ist zugleich der Grund, warum 0.1 + 0.2 sich danebenbenimmt. Diese sich wiederholenden 99-Bytes sind eine Binärentwicklung, die nie abbricht; der Leitfaden zur Gleitkomma-Genauigkeit nimmt sie auseinander.
6.1 float 1.0 ist 3f 80 00 00, oder eben 00 00 80 3f
1.0 in FP32 ist ein brauchbarer Kanarienvogel, weil sein Bytemuster so einseitig ausfällt. Big-endian schreibt 3f 80 00 00, little-endian schreibt 00 00 80 3f. Dumpen Sie ein unbekanntes Binärformat, suchen Sie ein Feld, von dem Sie wissen, dass es 1.0 sein muss, und die beiden Nullen am Ende sagen Ihnen, an welchem Ende Sie stehen. Für double funktioniert es genauso, dort liegen sechs Nullbytes gebündelt auf einer Seite.
7. Bytereihenfolge in Textkodierungen: die BOM ist nur eine Ansage
UTF-16 und UTF-32 bestehen aus Einheiten von mehreren Bytes und laufen deshalb genau in das Problem, das dieser Artikel beschreibt. Ihre Antwort darauf: Die Datei kündigt ihre eigene Reihenfolge mit einer Byte Order Mark an.
Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex'); // '4100'
fffe am Anfang bedeutet little-endian, feff bedeutet big-endian. Damit ist die BOM der häufigste Fall, in dem ein Format seine Bytereihenfolge deklariert, statt eine anzunehmen. Die ganze Geschichte, samt der Frage, warum UTF-8 keine BOM braucht und was die Marke kostet, wenn sie ungebeten auftaucht, steht im Leitfaden zu UTF-8, UTF-16 und Unicode-Kodierung.
8. Ist little-endian schneller? Was 40 Millionen Iterationen sagen
Die Behauptung, little-endian sei effizienter, taucht in den Zusammenfassungen von Suchmaschinen auf und in den meisten einführenden Artikeln zum Thema. Sie lässt sich testen. Vierzig Millionen Iterationen eines einzelnen 32-Bit-Lesevorgangs auf dieser arm64-Maschine:
| Pfad | ns/Op |
|---|---|
DataView.getUint32(4, true) (little-endian) | 4.4267 |
DataView.getUint32(4) (big-endian) | 4.5200 |
Buffer.readUInt32LE(4) | 1.5173 |
Buffer.readUInt32BE(4) | 5.0893 |
| Verhältnis | Faktor |
|---|---|
| DataView big-endian / little-endian | 1.021× |
| Buffer big-endian / little-endian | 3.354× |
Diese beiden Verhältnisse messen verschiedene Dinge, und nur das zweite zu berichten wäre genau der Fehler, den die Artikel machen.
Das DataView-Paar ist die ehrliche Messung dessen, was die Bytereihenfolge kostet. Beide Aufrufe kompilieren in V8 zum selben Inline-Pfad; der big-endian-Aufruf trägt eine zusätzliche ARM-Instruktion REV, um die Bytes zu tauschen. Das sind die 1.021×, also rund 2 %. Wenig, aber eben nicht null. Runden Sie es nicht auf „kostenlos“ ab.
Das Buffer-Paar misst etwas ganz anderes. V8 hat für readUInt32LE einen eigenen Fast Path, den readUInt32BE nicht bekommt. Die 3.354× sind damit ein Implementierungsunterschied in einer einzigen Laufzeitumgebung und nicht der Preis dafür, auf einer CPU Bytes zu tauschen. Sie als Beleg dafür anzuführen, dass big-endian langsam sei, wäre falsch. Wechseln Sie die Laufzeitumgebung, und die Zahl wechselt mit.
Auf moderner Hardware ist die Umwandlung der Bytereihenfolge damit zu billig, um in einer Diskussion über Formatdesign eine Rolle zu spielen. Das historische Effizienzargument stammt aus einer Zeit vor den dedizierten Tausch-Instruktionen. Nehmen Sie die Reihenfolge, die Ihr Protokoll oder Ihre Umgebung ohnehin schon verwendet.
9. Wie Sie einen Endianness-Bug von einem gewöhnlichen Bug unterscheiden
„Wie prüfe ich, ob meine Maschine big-endian oder little-endian ist“ lautet in Node os.endianness() und in Python sys.byteorder, je eine Zeile, und Suchmaschinen drucken die Antwort ohnehin schon über den Ergebnissen ab. Beim echten Debuggen ist das meistens die falsche Frage: Wenn Sie eine Datei oder ein Paket parsen, entscheidet das Format, und Ihre CPU ist daran gar nicht beteiligt. Nützlicher ist es, das Symptom zu erkennen.
9.1 Zwei Symptome: die absurde Zahl und die Zahl mit Faktor 256
Das laute Symptom ist leicht zu erkennen: Lesen Sie die PNG-Breite aus Abschnitt 2 falsch herum, und Sie bekommen 2.953.052.160 für ein 1200 Pixel breites Bild. Jedes Feld, das eine bescheidene Anzahl tragen sollte und in Milliardenhöhe zurückkommt, ist bis zum Beweis des Gegenteils eine umgedrehte 32-Bit-Ganzzahl.
Das leise Symptom ist das teure. Die Bytes 00 00 01 00 ergeben big-endian gelesen 256 und little-endian gelesen 65.536. Beides sieht nach einer plausiblen Puffergröße aus. Nichts wirft eine Exception, keine Assertion schlägt an, und der Wert ist um den Faktor 256 falsch. Solche Bugs überstehen jedes Code-Review, weil die Zahl auf dem Bildschirm vernünftig aussieht. Damit gehören sie in dieselbe Familie wie eine UTF-8-BOM, die ein JSON-Parsing zerlegt: ein unsichtbares Detail auf Byte-Ebene mit einer völlig irreführenden Fehleroberfläche, nachzulesen im Leitfaden zur UTF-8-BOM und zu JSON-Parsing-Fehlern.
Zwei Faustregeln lohnen sich. Kleine Werte mit drei führenden Nullbytes sind die, die leise umkippen, weil beide Lesarten im gültigen Bereich bleiben. Und wenn das Umdrehen der Bytes von Hand eine Zahl ergibt, die Sinn ergibt, haben Sie Ihre Antwort, ohne einen Debugger anzufassen.
9.2 Die Reihenfolge, in der Sie prüfen sollten
- Prüfen Sie zuerst die Formatspezifikation. RFC 2083 sagt, PNG ist big-endian; RFC 1952 §2.3.1 sagt, GZIP ist little-endian. Was Ihre Maschine tut, ist für beide belanglos.
- Prüfen Sie als Zweites die Voreinstellung Ihres Lesers.
DataView.getUint32(0)ist big-endian,Uint32Arrayfolgt der Plattform,struct.pack('@I', ...)folgt der Plattform, undbinary.BigEndian.Uint32ist genau das, was dort steht. Die meisten Fehler bei der Bytereihenfolge gehen auf ein fehlendes drittes Argument oder ein fehlendes Präfix zurück und nicht auf ein tiefes Missverständnis. - Verdächtigen Sie die Plattform zuletzt. Sie zählt, wenn Sie eine Datei mit
Uint32Arrayoder@schreiben und an eine andere Architektur ausliefern, und sie zählt, wenn Sie einen Speicherauszug gegen eine Spezifikation halten. Fast nie zählt sie, wenn Sie ein wohldefiniertes Format lesen, das Ihnen seine Reihenfolge längst mitgeteilt hat.
Häufig gestellte Fragen
Ist big-endian oder little-endian besser?
Weder big-endian noch little-endian ist besser. Richtig ist jeweils das, was das Format vorschreibt, und die Wahl haben Sie selten. Zur Performance: Ein big-endian-Lesevorgang mit DataView lag auf dieser Maschine bei 1.021× des little-endian-Pendants, also bei rund 2 %, und das ist viel zu wenig, um irgendeine Designentscheidung zu tragen.
Wie finde ich heraus, ob meine Maschine big-endian oder little-endian ist?
In Node liefert os.endianness() hier LE, in Python liefert sys.byteorder den Wert little. Beides sind Einzeiler. Die Frage wiegt allerdings weniger schwer, als sie aussieht: Wenn Sie eine Datei oder ein Paket parsen, diktiert das Format die Bytereihenfolge, und Ihre CPU hat kein Mitspracherecht.
DataView und Uint32Array liefern aus demselben Buffer verschiedene Zahlen. Ist das ein Bug?
Nein, das ist dokumentiertes DataView-Verhalten. DataView.getUint32(0) steht standardmäßig auf big-endian, während Uint32Array immer der Plattform folgt, und die ist auf x86 und Apple Silicon little-endian. Dieselben Bytes, zwei Konventionen. Übergeben Sie true als drittes Argument, dann liefern beide dasselbe.
Warum hat mein Struct seine Größe geändert, als ich < ergänzt habe?
Weil Sie damit @ verlassen haben, den Standard, der für natives Alignment auffüllt. struct.calcsize('@ci') ergibt 8, struct.calcsize('=ci') und struct.calcsize('<ci') ergeben beide 5. Die drei Padding-Bytes vor dem int sind zusammen mit dem nativen Alignment verschwunden.
Ist die Netzwerk-Bytereihenfolge big-endian oder little-endian?
Big-endian. Das ist die Konvention der TCP/IP-Header, und deshalb gibt es in C htons und htonl. In Python erzeugen die Präfixe ! und > identische Bytes: struct.pack('!I', 0x12345678) und struct.pack('>I', 0x12345678) liefern beide 12 34 56 78.
Wirkt sich Endianness auf UTF-8 aus?
Nein. UTF-8 ist ein Byte-Strom, und jeder Codepoint wird als geordnete Folge einzelner Bytes geschrieben, es bleibt also keine Einheit aus mehreren Bytes übrig, die man umsortieren könnte. UTF-16 und UTF-32 haben dieses Problem sehr wohl, und genau deshalb tragen sie eine BOM, wie in Abschnitt 7 beschrieben.
Brauchen Arrays aus einzelnen Bytes eine Behandlung der Bytereihenfolge?
Nein. Eine Bytereihenfolge existiert nur für Einheiten, die breiter als ein Byte sind; Uint8Array, Int8Array und Pythons bytes-Objekte sind damit immun. Verbreitern Sie um einen einzigen Schritt, und sie ist sofort zurück: new Uint16Array(two)[0] = 0x00ff landet auf dieser Maschine als ff 00 im Speicher.