Skip to content
Terug naar blog
Tutorials

Endianness: waarom dezelfde bytes twee getallen opleveren

Dezelfde bytes 12 34 56 78 lezen als 0x12345678 of als 0x78563412, afhankelijk van de lezer. Endianness in JavaScript, Python, Go, PNG en GZIP, online gemeten.

13 min leestijd

Endianness: waarom dezelfde bytes twee getallen opleveren

Vier bytes staan in het geheugen: 12 34 56 78. Lees ze met drie verschillende JavaScript-API’s en er komen twee verschillende getallen uit. Dat is endianness, en de tabel hieronder is bijna het hele verhaal.

LezingResultaat
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

Er gaat niets kapot en er komt nergens een fout uit. Elke aanroep hanteert een andere regel over welk uiteinde van een meerbytegetal het eerst komt.

Je bent sneller klaar als je de vraag anders stelt: niet “wat is mijn machine?” maar “onder wiens conventie zijn deze bytes geschreven?” Een PNG op je schijf is big-endian. Een GZIP-bestand ernaast is little-endian. Je CPU heeft in beide gevallen niets te zeggen.

Alles hieronder is gemeten op Node v26.7.0 en Python 3.14.6 onder macOS darwin arm64, waar os.endianness() LE teruggeeft en sys.byteorder little.

1. Wat big-endian en little-endian precies betekenen

Neem de 32-bits waarde 0x12345678. Dat zijn vier bytes: 12 is de meest significante, 78 de minst significante. De bytevolgorde bepaalt welke van die vier op het laagste adres terechtkomt.

IndelingAdres 0Adres 1Adres 2Adres 3
Big-endian12345678
Little-endian78563412

Big-endian zet het grote uiteinde eerst, dezelfde volgorde waarin je het getal op papier zou schrijven. Little-endian zet het kleine uiteinde eerst. Geen van beide raakt de bits binnen een byte aan: 0x12 blijft 0x12 in allebei de indelingen. Alleen hele bytes verschuiven.

Zit de stap van hex naar binair nog niet vast, dan zet de talstelselconverter elke byte binair naast zijn hex-vorm; de gids over binair, hex en octaal gaat over de notatie zelf.

1.1 Waarom er twee van zijn

De splitsing is historisch, niet principieel. Big-endian leest zoals mensen getallen schrijven, en het werd vroeg genoeg de conventie voor netwerkprotocollen om te blijven hangen. Little-endian won aan de CPU-kant, omdat x86 het gebruikt en ARM er standaard op staat. Het efficiëntieargument dat er standaard bij verteld wordt, staat gemeten in sectie 8: het komt uit op ongeveer 2%.

2. Bytevolgorde hoort bij het formaat, niet bij het platform

De bytevolgorde ligt vast bij wie de bytes geschreven heeft, niet bij de machine die ze leest.

Het bewijs kost één laptop en twee bestanden: op dezelfde arm64-machine, binnen hetzelfde proces, moeten die twee tegengesteld gedecodeerd worden.

2.1 PNG is big-endian

RFC 2083 eist meerbyte-integers in netwerkbytevolgorde, dus elke lengte, breedte en hoogte in een PNG is big-endian. De headerindeling ligt vast: acht signatuurbytes, dan een chunklengte van vier bytes, dan het chunktype van vier tekens, dan breedte en hoogte.

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
VeldBytesBig-endianLittle-endian
IHDR-lengte00 00 00 0d13218.103.808
Afbeeldingsbreedte00 00 04 b012002.953.052.160

2.2 GZIP is little-endian

RFC 1952 §2.3.1 zegt het met zoveel woorden: minst significante byte eerst. De laatste vier bytes van een gzip-stream zijn ISIZE, de ongecomprimeerde grootte. Comprimeer 300 bytes A en kijk zelf:

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
VeldBytesLittle-endianBig-endian
ISIZE in de trailer2c 01 00 00300738.263.040

Dezelfde machine, hetzelfde proces, dezelfde leesbewerking van vier bytes. Is je werkregel “mijn machine is little-endian, dus ik lees little-endian”, dan decodeert een van deze twee bestanden tot onzin. Het formaat beslist, niet de hardware waarop je het openmaakt.

3. JavaScript: twee API’s, twee tegengestelde standaarden

In de browser en in Node wordt het pas echt verwarrend: de twee manieren om naar een ArrayBuffer te kijken zijn het standaard met elkaar oneens.

3.1 DataView-endianness: het derde argument beslist

De methodes van DataView nemen een optionele littleEndian-vlag als laatste argument. Laat je die weg, dan krijg je big-endian. setUint32(0, x) en setUint32(0, x, false) zijn dezelfde aanroep.

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

Schrijven werkt precies andersom:

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 volgt het platform, en dat kun je niet veranderen

Uint32Array, Int16Array, Float64Array en de rest gebruiken wat de CPU gebruikt. Daar valt niets aan te draaien: de constructor heeft er geen argument of vlag voor. Op deze arm64-machine betekent dat little-endian, precies het tegenovergestelde van de standaard van DataView.

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

Eén en dezelfde ArrayBuffer, gelezen via new DataView(buf).getUint32(0) en via new Uint32Array(buf)[0], levert dus 0x12345678 en 0x78563412 op. Allebei kloppen ze, want ze beantwoorden verschillende vragen.

Views van één byte zijn immuun, want bytevolgorde bestaat alleen voor eenheden breder dan een byte. Uint8Array en Int8Array hebben nooit een vlag nodig. Maak het één stap breder en het probleem is terug:

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Node Buffer: zet de volgorde in de methodenaam

Buffer slaat standaardwaarden helemaal over en zet de volgorde in de methodenaam. Daarom is Node-code meestal het makkelijkst na te kijken.

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() keert elke groep van vier bytes om en geeft dezelfde buffer terug in plaats van een kopie. Handig als je een hele array integers met de verkeerde bytevolgorde hebt; gevaarlijk als je vergeten was dat die buffer gedeeld werd.

4. Python struct: vijf prefixen, en wat @ echt kost

4.1 < > ! = @: de vijf struct pack-prefixen voor de bytevolgorde

0x12345678 inpakken als 32-bits integer zonder teken, één regel per prefix:

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

! en > leveren identieke bytes op, want de netwerkbytevolgorde is big-endian. Uitpakken werkt gespiegeld, en int.from_bytes geeft hetzelfde 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 @ en = verschillen in opvulling, niet in bytevolgorde

Allebei volgen ze het platform, dus op deze machine schrijven ze allebei little-endian. Het verschil zit in de uitlijning, en dat verandert de grootte van je struct:

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

Een char gevolgd door een int is vijf bytes aan data. Onder @, de standaard als je helemaal geen prefix schrijft, voegt Python drie opvulbytes toe zodat de int op een grens van vier bytes begint. Onder = of onder een expliciete prefix voor de bytevolgorde verdwijnt die opvulling.

Dat is het mechanisme achter een bug die onmogelijk lijkt: iemand zet er een < bij om een probleem met de bytevolgorde op te lossen, en de recordlengte verandert onder zijn handen. De bytevolgorde heeft er niets mee te maken. Wegschakelen van @ zette stilletjes ook de uitlijning van het platform uit.

5. Netwerkbytevolgorde, en hoe andere talen het opschrijven

De netwerkbytevolgorde is big-endian. TCP-, UDP- en IP-headers dragen hun meerbytevelden allemaal zo, en dat gaat terug op de tijd dat big-endian hardware gangbaar genoeg was dat iemand een kant moest kiezen. C biedt de conversie via htons, htonl, ntohs en ntohl: host naar netwerk en terug, voor shorts en longs. Op een little-endian host wisselen ze om; op een big-endian host doen ze niets, en daarom werkt code die ze weglaat prima tot ze een andere machine tegenkomt.

Go doet het omgekeerd en hanteert helemaal geen standaard:

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

binary.BigEndian en binary.LittleEndian zijn waarden die je op de aanroepplek benoemt. Er is geen platformafhankelijk pad en geen optionele vlag om te vergeten, waardoor het nakijken van de bytevolgorde in Go neerkomt op het lezen van de identifier.

Dezelfde discipline geldt voor vastekommadata. Een Q15- of Q31-sample is gewoon een 16- of 32-bits integer zodra het je code verlaat, dus het erft de vraag naar de bytevolgorde net als al het andere. De Q-formaat-converter laat de integer achter de breuk zien, en die integer is wat er geordend wordt.

6. Drijvende-kommagetallen hebben ook een bytevolgorde

Een float is niets bijzonders. IEEE 754 legt het bitpatroon vast, en daarna worden diezelfde vier of acht bytes neergelegd in de volgorde die het formaat eist.

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'

De IEEE 754-converter beantwoordt de eerste helft van de vraag: typ 3.14159 met FP32 geselecteerd en je krijgt 0x40490FD0. Dit artikel beantwoordt de tweede helft, namelijk in welke volgorde die vier bytes in het bestand belanden.

De regel met double 0.1 is ook de reden dat 0.1 + 0.2 zich misdraagt. Die zich herhalende 99-bytes zijn een binaire expansie die nooit eindigt, en de gids over drijvende-kommaprecisie haalt die uit elkaar.

6.1 float 1.0 is 3f 80 00 00, of 00 00 80 3f

1.0 in FP32 is een handige kanarie, juist omdat het bytepatroon zo scheef is. Big-endian schrijft 3f 80 00 00; little-endian schrijft 00 00 80 3f. Dump een onbekend binair formaat, zoek een veld waarvan je weet dat het 1.0 hoort te zijn, en de twee nullen aan het eind vertellen je aan welk uiteinde je zit. Voor een double werkt het net zo: daar staan bij dezelfde waarde zes nulbytes bij elkaar aan één kant.

7. Bytevolgorde in tekstcoderingen: de BOM is alleen een verklaring

UTF-16 en UTF-32 bestaan uit eenheden van meerdere bytes, dus ze lopen precies tegen het probleem aan dat dit artikel beschrijft. Hun antwoord: het bestand kondigt zijn eigen volgorde aan met een byte order mark.

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

fffe vooraan betekent little-endian; feff betekent big-endian. Daarmee is de BOM het bekendste geval van een formaat dat zijn eigen bytevolgorde verklaart in plaats van er een aan te nemen. Het hele verhaal, inclusief waarom UTF-8 geen BOM nodig heeft en wat dat teken je kost als het ongevraagd opduikt, staat in de gids over UTF-8, UTF-16 en Unicode-codering.

8. Is little-endian sneller? Wat 40 miljoen iteraties zeggen

De bewering dat little-endian efficiënter is, duikt op in samenvattingen van zoekmachines en in de meeste inleidende artikelen over het onderwerp. Ze is te testen. Veertig miljoen iteraties van één 32-bits leesactie op deze arm64-machine:

Padns/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
VerhoudingFactor
DataView big-endian / little-endian1.021×
Buffer big-endian / little-endian3.354×

Die twee verhoudingen meten verschillende dingen. Wie alleen de tweede noemt, maakt dezelfde fout als de artikelen hierboven.

Het paar met DataView is de eerlijke meting van wat de bytevolgorde kost. Beide aanroepen compileren naar hetzelfde inline pad in V8; de big-endian variant draagt één extra ARM-instructie REV om de bytes om te wisselen. Dat is de 1.021×, ruwweg 2%. Klein, maar niet nul: rond het niet af naar “gratis”.

Het paar met Buffer meet iets heel anders. V8 heeft een apart snel pad voor readUInt32LE dat readUInt32BE niet krijgt, dus de 3.354× is een implementatieverschil binnen één runtime, niet de prijs van bytes omwisselen op een CPU. Dat aanhalen als bewijs dat big-endian traag is, zou fout zijn. Verander de runtime en het getal verandert mee.

De conclusie die de cijfers wél dragen: op moderne hardware is het omzetten van de bytevolgorde te goedkoop om nog thuis te horen in een discussie over formaatontwerp. Het historische efficiëntieargument stamt uit een tijd voordat er speciale instructies voor het omwisselen bestonden. Kies de volgorde die je protocol of de systemen om je heen al gebruiken.

9. Zo onderscheid je een bytevolgorde-bug van een gewone bug

“Hoe controleer ik of mijn machine big-endian of little-endian is” is os.endianness() in Node en sys.byteorder in Python, één regel elk, en zoekmachines drukken dat antwoord al boven de resultaten af. Bij echt debugwerk is het meestal de verkeerde vraag: lees je een bestand of een pakket in, dan beslist het formaat en doet je CPU niet mee.

Wat wel helpt, is het symptoom herkennen.

9.1 Twee symptomen: het absurde getal en het getal dat 256× ernaast zit

Het luide symptoom is makkelijk. Lees de PNG-breedte uit sectie 2 verkeerd en je krijgt 2.953.052.160 voor een afbeelding van 1200 pixels. Elk veld dat een bescheiden aantal hoort te zijn en in de miljarden terugkomt, is een omgekeerde 32-bits integer tot het tegendeel bewezen is.

Het stille symptoom is duurder. De bytes 00 00 01 00 lezen als 256 big-endian en als 65.536 little-endian. Allebei zien ze eruit als plausibele buffergroottes. Er komt geen fout uit, er slaat geen assertie aan, en de waarde zit er een factor 256 naast. Zulke bugs overleven code review, omdat het getal op het scherm redelijk oogt. Het is hetzelfde soort probleem als een UTF-8-BOM die het inlezen van JSON laat mislukken: een onzichtbaar detail op byteniveau met een compleet misleidend foutbeeld, uitgewerkt in de gids over de UTF-8-BOM en JSON-parsefouten.

Twee vuistregels. Kleine waarden met drie voorloopnulbytes zijn degene die stilletjes omklappen, want beide lezingen blijven binnen bereik. En als de bytes met de hand omdraaien een getal oplevert dat wél klopt, heb je je antwoord zonder een debugger aan te raken.

9.2 De volgorde waarin je dingen nakijkt

  1. Kijk eerst in de specificatie van het formaat. RFC 2083 zegt dat PNG big-endian is; RFC 1952 §2.3.1 zegt dat GZIP little-endian is. Wat jouw machine doet, is voor allebei irrelevant.
  2. Kijk als tweede naar de standaard van je lezer. DataView.getUint32(0) is big-endian, Uint32Array volgt het platform, struct.pack('@I', ...) volgt het platform, en binary.BigEndian.Uint32 is precies wat er staat. De meeste bugs met de bytevolgorde zijn een ontbrekend derde argument of een ontbrekende prefix, geen diep misverstand.
  3. Verdenk het platform als laatste. Het doet ertoe als je een bestand schrijft met Uint32Array of @ en het naar een andere architectuur stuurt, en het doet ertoe als je een geheugendump naast een specificatie legt. Het doet vrijwel nooit ter zake als je een goed gedefinieerd formaat leest dat je al verteld heeft welke volgorde het gebruikt.

Veelgestelde vragen

Is big-endian of little-endian beter?

Big-endian noch little-endian is beter. De juiste volgorde is die het formaat voorschrijft, en je mag zelden zelf kiezen. Qua prestaties: een big-endian lezing met DataView mat 1.021× de little-endian variant op deze machine, ruwweg 2%, en dat is veel te klein om er welke ontwerpbeslissing dan ook op te baseren.

Hoe weet ik of mijn machine big-endian of little-endian is?

os.endianness() in Node geeft hier LE terug, en sys.byteorder in Python geeft little. Allebei one-liners. Alleen doet die vraag er minder toe dan ze lijkt: lees je een bestand of een pakket in, dan dicteert het formaat de bytevolgorde en heeft je CPU niets in te brengen.

DataView en Uint32Array geven verschillende getallen uit dezelfde buffer. Is dat een bug?

Nee, dit is gedocumenteerd DataView-gedrag. DataView.getUint32(0) gaat standaard uit van big-endian, terwijl Uint32Array altijd het platform volgt, en dat is little-endian op x86 en op Apple Silicon. Dezelfde bytes, twee conventies. Geef true mee als derde argument en DataView is het ermee eens.

Waarom veranderde de grootte van mijn struct toen ik < toevoegde?

Omdat je wegschakelde van @, de standaard, die opvult voor de uitlijning van het platform. struct.calcsize('@ci') is 8, terwijl struct.calcsize('=ci') en struct.calcsize('<ci') allebei 5 zijn. De drie opvulbytes die vóór de int stonden, vertrokken samen met die uitlijning.

Is de netwerkbytevolgorde big-endian of little-endian?

Big-endian. Dat is de conventie die TCP/IP-headers gebruiken, en daarom bestaan htons en htonl in C. In Python leveren de prefixen ! en > identieke bytes op: struct.pack('!I', 0x12345678) en struct.pack('>I', 0x12345678) geven allebei 12 34 56 78.

Heeft endianness invloed op UTF-8?

Nee. UTF-8 is een bytestroom, en elk code point wordt geschreven als een geordende reeks losse bytes, dus er blijft geen eenheid van meerdere bytes over om te herschikken. UTF-16 en UTF-32 hebben dat probleem wel, en precies daarom dragen ze een BOM, zoals beschreven in sectie 7.

Hebben arrays van één byte enige behandeling van de bytevolgorde nodig?

Nee. Bytevolgorde bestaat alleen voor eenheden breder dan één byte, dus Uint8Array, Int8Array en Python-bytes-objecten zijn immuun. Maak het één stap breder en het komt meteen terug: new Uint16Array(two)[0] = 0x00ff belandt op deze machine in het geheugen als ff 00.

Tags: endianness byte-order binary-data file-formats cross-platform

Gerelateerde artikelen

Alle artikelen bekijken