Skip to content
Torna al blog
Tutorial

Endianness: perché gli stessi byte danno due numeri diversi

Gli stessi byte 12 34 56 78 si leggono 0x12345678 o 0x78563412 a seconda del lettore. Endianness in JavaScript, Python, Go, PNG e GZIP, misurata online.

13 min di lettura

Endianness: perché gli stessi byte danno due numeri diversi

Quattro byte in memoria: 12 34 56 78. Leggili con tre API JavaScript e ne ottieni due numeri diversi. Si chiama endianness, e tre righe di tabella bastano a mostrarla.

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

Non c’è niente di rotto e nessuna delle tre solleva eccezioni. Ognuna applica una regola diversa su quale estremità di un numero multi-byte venga per prima.

La domanda utile non riguarda la tua macchina, ma la convenzione con cui quei byte sono stati scritti. Un PNG sul tuo disco è big-endian; un file GZIP lì accanto è little-endian. La tua CPU non ha voce in capitolo né nell’uno né nell’altro caso.

Le misure che seguono vengono tutte da Node v26.7.0 e Python 3.14.6 su macOS darwin arm64, dove os.endianness() restituisce LE e sys.byteorder restituisce little.

1. Cosa significano davvero big-endian e little-endian

Prendi il valore a 32 bit 0x12345678. Sono quattro byte: 12 è il più significativo, 78 il meno significativo. L’endianness decide quale dei due finisce all’indirizzo più basso.

DisposizioneIndirizzo 0Indirizzo 1Indirizzo 2Indirizzo 3
Big-endian12345678
Little-endian78563412

Big-endian mette per prima l’estremità grande, lo stesso ordine con cui scriveresti il numero su carta. Little-endian mette per prima l’estremità piccola. Nessuna delle due tocca i bit dentro un byte: 0x12 resta 0x12 in entrambe le disposizioni. Si spostano solo byte interi.

Se a darti problemi è il passaggio da esadecimale a binario, il convertitore di basi mostra ogni byte in binario accanto alla sua forma esadecimale, mentre la guida alla conversione tra binario, esadecimale e ottale si occupa della notazione in sé.

1.1 Perché ne esistono due

La divisione è storica, non di principio. Big-endian si legge come le persone scrivono i numeri, ed è diventato la convenzione dei protocolli di rete abbastanza presto da restarci. Little-endian ha vinto sul fronte CPU perché lo usa x86 e ARM lo adotta di default. L’argomento dell’efficienza che trovi ripetuto in quasi ogni articolo sul tema lo misura la sezione 8: vale circa il 2%.

2. L’endianness è una proprietà del formato, non della piattaforma

L’ordine dei byte lo fissa chi ha scritto quei byte, non la macchina che li legge, ed è il passaggio che le spiegazioni brevi saltano quasi sempre.

Per dimostrarlo bastano un portatile e due file: sulla stessa macchina arm64, dentro lo stesso processo, vanno decodificati in modi opposti.

2.1 PNG è big-endian

L’RFC 2083 impone gli interi multi-byte in network byte order, quindi ogni lunghezza, larghezza e altezza dentro un PNG è big-endian. La disposizione dell’header è fissa: otto byte di firma, poi la lunghezza del chunk su quattro byte, poi il tipo di chunk su quattro caratteri, poi larghezza e altezza.

const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');

png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — firma PNG
png.readUInt32BE(8);                // 13   — lunghezza del chunk IHDR
png.readUInt32BE(16);               // 1200 — larghezza dell'immagine
png.readUInt32BE(20);               // 630  — altezza dell'immagine

png.readUInt32LE(16);               // gli stessi quattro byte, letti nel verso sbagliato
CampoByteBig-endianLittle-endian
Lunghezza IHDR00 00 00 0d13218103808
Larghezza immagine00 00 04 b012002953052160

2.2 GZIP è little-endian

L’RFC 1952 §2.3.1 lo dice a chiare lettere: byte meno significativo per primo. Gli ultimi quattro byte di uno stream gzip sono ISIZE, la dimensione non compressa. Comprimi 300 byte di A e verifica:

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 — corretto
gz.readUInt32BE(gz.length - 4);    // gli stessi quattro byte, letti nel verso sbagliato
CampoByteLittle-endianBig-endian
ISIZE del trailer2c 01 00 00300738263040

Le due letture girano sulla stessa macchina, nello stesso processo, con la stessa primitiva a quattro byte. Se la tua regola pratica è “la mia macchina è little-endian quindi leggo little-endian”, uno dei due file ti si decodifica in spazzatura. A decidere è il formato.

3. JavaScript: due API, due default opposti

Qui browser e Node si fanno disorientanti: i due modi di leggere un ArrayBuffer hanno default opposti.

3.1 Endianness di DataView: decide il terzo argomento

I metodi di DataView accettano un flag opzionale littleEndian come ultimo argomento. Se lo ometti ottieni big-endian. setUint32(0, x) e setUint32(0, x, false) sono la stessa chiamata.

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, il default
dv.getUint32(0, true).toString(16); // '78563412'  — littleEndian: true

In scrittura funziona allo stesso modo, al contrario:

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 segue la piattaforma, e non puoi cambiarlo

Uint32Array, Int16Array, Float64Array e tutti gli altri usano quello che usa la CPU. Non esiste un argomento da passare né un flag del costruttore per cambiarlo. Su questa macchina arm64 significa little-endian, cioè l’esatto opposto del default di DataView.

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

Quindi un singolo ArrayBuffer letto con new DataView(buf).getUint32(0) e con new Uint32Array(buf)[0] produce 0x12345678 e 0x78563412. Sono corretti entrambi, perché rispondono a due domande diverse.

Le viste a un byte sono immuni, perché l’ordine dei byte esiste solo per unità più larghe di un byte. Uint8Array e Int8Array non hanno mai bisogno di un flag. Allarga di un passo e il problema torna:

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

3.3 Buffer di Node: l’ordine sta nel nome del metodo

Buffer salta del tutto i default e mette l’ordine nel nome del metodo, ed è per questo che il codice Node è di solito il più facile da controllare.

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' — modifica b sul posto

swap32() inverte ogni gruppo di quattro byte e restituisce lo stesso buffer invece di una copia. Comodo quando hai un intero array di interi con l’endianness sbagliata; pericoloso se ti eri dimenticato che il buffer era condiviso.

4. Python struct: cinque prefissi, e quanto costa davvero @

4.1 < > ! = @: i cinque prefissi di ordine dei byte di struct pack

0x12345678 impacchettato come intero senza segno a 32 bit, una riga per prefisso:

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'  ordine di rete
struct.pack('=I', 0x12345678).hex(' ')  # '78 56 34 12'  ordine nativo, dimensioni standard
struct.pack('@I', 0x12345678).hex(' ')  # '78 56 34 12'  ordine nativo, allineamento nativo

! e > producono byte identici perché il network byte order è big-endian. Lo spacchettamento fa da specchio, e int.from_bytes dà la stessa coppia:

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 @ e = differiscono nel padding, non nell’ordine dei byte

Entrambi seguono la piattaforma, quindi su questa macchina scrivono tutti e due in little-endian. La differenza sta nell’allineamento, e cambia la dimensione della tua struct:

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

Un char seguito da un int sono cinque byte di dati. Con @, il default quando non scrivi nessun prefisso, Python inserisce tre byte di padding perché l’int cominci su un confine di quattro byte. Con = o con qualsiasi prefisso esplicito di ordine dei byte, il padding sparisce.

È questo il meccanismo dietro un bug che sembra impossibile: qualcuno aggiunge < per risolvere un problema di ordine dei byte e la lunghezza del record gli cambia sotto i piedi. Non è stato l’ordine dei byte a causarlo. Abbandonare @ ha spento in silenzio anche l’allineamento nativo.

5. Network byte order, e come lo scrivono gli altri linguaggi

Il network byte order è big-endian. Gli header TCP, UDP e IP portano i loro campi multi-byte in quel modo, e la scelta risale a quando l’hardware big-endian era abbastanza diffuso da costringere qualcuno a schierarsi. Il C espone la conversione con htons, htonl, ntohs e ntohl: da host a rete e ritorno, per short e per long. Su un host little-endian scambiano i byte; su un host big-endian non fanno niente, ed è per questo che il codice che li omette funziona benissimo finché non incontra una macchina diversa.

Go prende la strada opposta e si rifiuta di avere un default:

import "encoding/binary"

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

binary.BigEndian e binary.LittleEndian sono valori che nomini nel punto di chiamata. Non esiste un percorso dipendente dalla piattaforma né un flag opzionale da dimenticare, così rivedere l’ordine dei byte in Go si riduce a leggere l’identificatore.

La stessa disciplina vale per i dati in virgola fissa. Un campione Q15 o Q31, appena esce dal tuo codice, è un normale intero a 16 o 32 bit, quindi si porta dietro la questione dell’ordine dei byte insieme a tutto il resto; il convertitore di formato Q mostra l’intero dietro la frazione, ed è quell’intero a essere ordinato.

6. Anche i numeri in virgola mobile hanno un ordine dei byte

Un float non ha niente di speciale. IEEE 754 definisce il pattern di bit, e poi gli stessi quattro o otto byte vengono depositati nell’ordine che il formato pretende.

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'

Il convertitore IEEE 754 risponde alla prima metà della domanda: digita 3.14159 con FP32 selezionato e ottieni 0x40490FD0. Questo articolo risponde alla seconda metà, cioè in che ordine quei quattro byte arrivano nel file. Lo strumento ti dà il valore, l’endianness la disposizione in memoria.

La riga del double 0.1 è anche il motivo per cui 0.1 + 0.2 si comporta male. Quei byte 99 che si ripetono sono un’espansione binaria che non finisce mai, e la guida alla precisione della virgola mobile la smonta pezzo per pezzo.

6.1 float 1.0 è 3f 80 00 00, oppure 00 00 80 3f

1.0 in FP32 è una spia utile perché il suo pattern di byte è molto sbilanciato. Big-endian scrive 3f 80 00 00; little-endian scrive 00 00 80 3f. Fai il dump di un formato binario che non conosci, trova un campo che sai dover valere 1.0, e i due zeri finali ti dicono da che parte ti trovi. Vale anche per il double, dove lo stesso valore ha sei byte a zero raggruppati da un lato.

7. Ordine dei byte nelle codifiche di testo: il BOM è solo una dichiarazione

UTF-16 e UTF-32 sono fatti di unità multi-byte, quindi hanno esattamente questo problema. La loro risposta è lasciare che sia il file ad annunciare il proprio ordine con un byte order mark:

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

fffe in testa significa little-endian; feff significa big-endian. Questo fa del BOM il caso più diffuso di formato che dichiara il proprio ordine dei byte invece di darlo per scontato. La storia completa, compreso perché UTF-8 non ha bisogno di BOM e quanto ti costa quel marcatore quando compare senza invito, sta nella guida alle codifiche UTF-8, UTF-16 e Unicode.

8. Little-endian è più veloce? Cosa dicono 40 milioni di iterazioni

L’affermazione che little-endian sia più efficiente compare nei riassunti dei motori di ricerca e nella maggior parte degli articoli introduttivi sul tema. È verificabile. Quaranta milioni di iterazioni di una singola lettura a 32 bit su questa macchina arm64:

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

Quei due rapporti misurano cose diverse, e riportare solo il secondo sarebbe lo stesso errore che fanno quegli articoli.

La coppia DataView è la misura onesta del costo dell’ordine dei byte. Entrambe le chiamate vengono compilate sullo stesso percorso inline in V8; quella big-endian si porta dietro un’istruzione ARM REV in più per scambiare i byte. È questo l’1.021×, circa il 2%. Poco, ma non zero: non arrotondarlo a “gratis”.

La coppia Buffer misura tutt’altro. V8 ha un percorso veloce dedicato per readUInt32LE che a readUInt32BE non tocca, quindi il 3.354× è una differenza di implementazione dentro un runtime, non il prezzo di scambiare byte su una CPU. Citarlo come prova che big-endian sia lento sarebbe sbagliato. Cambia runtime e quel numero cambia con lui.

Sull’hardware moderno, insomma, la conversione di ordine dei byte costa troppo poco per entrare in una discussione di progettazione di un formato. L’argomento storico dell’efficienza viene da un’epoca in cui le istruzioni dedicate allo scambio non esistevano. Scegli l’ordine che il tuo protocollo, o chi ti sta intorno, già usa.

9. Come distinguere un bug di endianness da un bug qualunque

“Come faccio a sapere se la mia macchina è big-endian o little-endian” si risolve con os.endianness() in Node e sys.byteorder in Python, una riga a testa, e i motori di ricerca stampano già la risposta sopra i risultati. Nel debug reale, però, è quasi sempre la domanda sbagliata: quando analizzi un file o un pacchetto decide il formato, e la tua CPU non c’entra. Quello che serve saper fare è riconoscere il sintomo.

9.1 Due sintomi: il numero assurdo e il numero sbagliato di 256 volte

Quello rumoroso è facile. Leggi nel verso sbagliato la larghezza del PNG della sezione 2 e ottieni 2953052160 per un’immagine da 1200 pixel. Qualsiasi campo che dovrebbe essere un conteggio modesto e torna nell’ordine dei miliardi è un intero a 32 bit rovesciato finché non si dimostra il contrario.

Quello silenzioso costa molto di più. I byte 00 00 01 00 si leggono 256 in big-endian e 65536 in little-endian, e sembrano entrambi dimensioni di buffer plausibili. Non scatta nessuna eccezione e nessuna asserzione, ma il valore è sbagliato di un fattore 256. Bug come questo sopravvivono alla code review perché il numero a schermo sembra ragionevole. Sono della stessa famiglia del BOM UTF-8 che rompe il parsing di un JSON: un dettaglio invisibile a livello di byte con una superficie d’errore del tutto fuorviante.

Due regole pratiche. I valori piccoli con tre byte a zero in testa sono quelli che si ribaltano senza far rumore, perché entrambe le letture restano nell’intervallo. E se rovesciare i byte a mano produce un numero che ha senso, hai la risposta senza toccare un debugger.

9.2 L’ordine in cui controllare le cose

  1. Controlla per prima cosa la specifica del formato. L’RFC 2083 dice che PNG è big-endian; l’RFC 1952 §2.3.1 dice che GZIP è little-endian. Quello che fa la tua macchina è irrilevante per entrambi.
  2. Poi controlla il default di chi legge. DataView.getUint32(0) è big-endian, Uint32Array usa l’ordine della piattaforma, struct.pack('@I', ...) usa l’ordine della piattaforma, binary.BigEndian.Uint32 è quello che dice di essere. La maggior parte dei bug di ordine dei byte nasce da un terzo argomento o da un prefisso dimenticato, non da un equivoco concettuale.
  3. Sospetta della piattaforma per ultima. Conta quando scrivi un file con Uint32Array o con @ e lo spedisci a un’architettura diversa, e conta quando confronti un dump di memoria con una specifica. Non conta quasi mai quando stai leggendo un formato ben definito che ti ha già detto quale ordine usa.

Domande frequenti

Meglio big-endian o little-endian?

Né big-endian né little-endian è meglio. Quello corretto per un formato è quello che il formato specifica, e raramente puoi scegliere. Sul fronte prestazioni, su questa macchina una lettura DataView big-endian ha misurato 1.021× rispetto a quella little-endian, circa il 2%, troppo poco per guidare qualsiasi decisione di progettazione.

Come faccio a sapere se la mia macchina è big-endian o little-endian?

os.endianness() in Node qui restituisce LE, e sys.byteorder in Python restituisce little. Sono entrambi one-liner. La domanda però conta meno di quanto sembri: quando analizzi un file o un pacchetto, l’ordine dei byte lo detta il formato, indipendentemente da cosa fa la tua CPU.

DataView e Uint32Array danno numeri diversi dallo stesso buffer. È un bug?

No, è il comportamento documentato di DataView. DataView.getUint32(0) usa big-endian per default, mentre Uint32Array segue sempre la piattaforma, che su x86 e su Apple Silicon è little-endian. Stessi byte, due convenzioni. Passa true come terzo argomento e DataView si allinea.

Perché la mia struct ha cambiato dimensione quando ho aggiunto <?

Perché hai abbandonato @, il default, che aggiunge padding per l’allineamento nativo. struct.calcsize('@ci') è 8, mentre struct.calcsize('=ci') e struct.calcsize('<ci') sono entrambi 5. I tre byte di padding prima dell’int se ne sono andati insieme all’allineamento nativo.

Il network byte order è big-endian o little-endian?

Big-endian. È la convenzione che usano gli header TCP/IP, ed è il motivo per cui in C esistono htons e htonl. In Python i prefissi ! e > producono byte identici: struct.pack('!I', 0x12345678) e struct.pack('>I', 0x12345678) danno entrambi 12 34 56 78.

L’endianness riguarda anche UTF-8?

No. UTF-8 è un flusso di byte, e ogni code point viene scritto come una sequenza ordinata di byte singoli, quindi non resta nessuna unità multi-byte da riordinare. UTF-16 e UTF-32 quel problema ce l’hanno, ed è esattamente per questo che si portano dietro un BOM, come visto nella sezione 7.

Gli array a un byte hanno bisogno di gestire l’ordine dei byte?

No. L’ordine dei byte esiste solo per unità più larghe di un byte, quindi Uint8Array, Int8Array e gli oggetti bytes di Python sono immuni. Allarga di un passo e il problema torna subito: new Uint16Array(two)[0] = 0x00ff finisce in memoria come ff 00 su questa macchina.

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

Articoli correlati

Vedi tutti gli articoli