Skip to content
Retour au blog
Tutoriels

Endianness : pourquoi les mêmes octets donnent deux nombres

Les mêmes quatre octets 12 34 56 78 se lisent 0x12345678 ou 0x78563412 selon le lecteur. L'endianness en JavaScript, Python, Go, PNG et GZIP, mesurée en ligne.

13 min de lecture

Endianness : pourquoi les mêmes octets donnent deux nombres

Quatre octets occupent la mémoire : 12 34 56 78. Trois API JavaScript les lisent, et il en sort deux nombres différents.

LectureRésultat
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

Rien n’est cassé et aucun de ces trois appels ne lève d’exception. Chacun suit une convention différente quant à l’extrémité du nombre multi-octet qui vient en premier.

L’endianness ne répond pas à la question « quelle est ma machine ? », mais à « sous quelle convention ces octets ont-ils été écrits ? ». Un PNG posé sur votre disque est big-endian. Un fichier GZIP juste à côté est little-endian. Votre processeur n’a voix au chapitre ni dans un cas ni dans l’autre.

Tout ce qui suit a été mesuré sur Node v26.7.0 et Python 3.14.6 sous macOS darwin arm64, où os.endianness() renvoie LE et sys.byteorder renvoie little.

1. Ce que big-endian et little-endian veulent dire

Prenez la valeur 32 bits 0x12345678. Ce sont quatre octets : 12 est le plus significatif, 78 le moins. L’endianness décide lequel d’entre eux atterrit à l’adresse la plus basse.

DispositionAdresse 0Adresse 1Adresse 2Adresse 3
Big-endian12345678
Little-endian78563412

Le big-endian (gros-boutiste) range d’abord le gros bout, dans l’ordre même où vous écririez le nombre sur papier. Le little-endian (petit-boutiste) range d’abord le petit bout. Ni l’un ni l’autre ne touche aux bits à l’intérieur d’un octet : 0x12 reste 0x12 dans les deux dispositions. Seuls des octets entiers se déplacent.

Si c’est le passage de l’hexadécimal au binaire qui vous gêne, le convertisseur de base affiche chaque octet en binaire à côté de sa forme hexadécimale, et le guide de conversion binaire, hexadécimal et octal traite de la notation elle-même.

1.1 Pourquoi il y en a deux

La séparation est historique plutôt que fondée sur un principe. Le big-endian se lit comme les humains écrivent les nombres, et il est devenu la convention des protocoles réseau assez tôt pour ne plus en bouger. Le little-endian a gagné du côté processeur parce que x86 l’utilise et qu’ARM l’adopte par défaut. Quant à l’argument d’efficacité que répètent presque tous les articles sur le sujet, la section 8 le chiffre : environ 2 %.

2. L’endianness est une propriété du format, pas de la plateforme

C’est celui qui a écrit les octets qui fixe leur ordre, pas la machine qui les lit. Les explications courtes passent presque toujours ce point sous silence.

Deux fichiers sur un portable suffisent à le montrer. Sur la même machine arm64, dans le même processus, ils exigent des décodages opposés.

2.1 PNG est big-endian

La RFC 2083 impose que les entiers multi-octets soient en ordre d’octets réseau, donc chaque longueur, largeur et hauteur d’un PNG est en big-endian. La disposition de l’en-tête est figée : huit octets de signature, puis une longueur de chunk sur quatre octets, puis le type de chunk sur quatre caractères, puis la largeur et la hauteur.

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
ChampOctetsBig-endianLittle-endian
Longueur IHDR00 00 00 0d13218 103 808
Largeur de l’image00 00 04 b012002 953 052 160

2.2 GZIP est little-endian

La RFC 1952 §2.3.1 le dit noir sur blanc : octet de poids faible en premier. Les quatre derniers octets d’un flux gzip forment ISIZE, la taille non compressée. Compressez 300 octets de A et vérifiez :

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
ChampOctetsLittle-endianBig-endian
ISIZE du trailer2c 01 00 00300738 263 040

Même machine, même processus, même primitive de lecture sur quatre octets. Si votre règle de travail est « ma machine est little-endian, donc je lis en little-endian », l’un de ces deux fichiers se décode en charabia. C’est le format qui décide, à chaque fois.

3. JavaScript : deux API, deux valeurs par défaut opposées

Les deux façons de voir un ArrayBuffer se contredisent par défaut, et peu d’articles sur l’ordre des octets le signalent.

3.1 L’endianness de DataView : c’est le troisième argument qui décide

Les méthodes de DataView acceptent un drapeau littleEndian optionnel en dernier argument. Omettez-le et vous obtenez du big-endian. setUint32(0, x) et setUint32(0, x, false) sont le même appel.

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

L’écriture obéit à la même règle, en sens inverse :

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 suit la plateforme, et vous n’y pouvez rien

Uint32Array, Int16Array, Float64Array et les autres reprennent l’ordre du processeur. Aucun argument ni drapeau de constructeur ne permet d’en changer. Sur cette machine arm64, cela veut dire little-endian, soit l’inverse de la valeur par défaut de DataView.

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

Ainsi, un même ArrayBuffer lu via new DataView(buf).getUint32(0) puis via new Uint32Array(buf)[0] donne 0x12345678 et 0x78563412. Les deux sont corrects : ils répondent à des questions différentes.

Les vues sur un seul octet sont immunisées, parce que l’ordre des octets n’existe que pour des unités plus larges qu’un octet. Uint8Array et Int8Array n’ont jamais besoin de drapeau. Élargissez d’un cran et il revient :

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

3.3 Buffer de Node : l’ordre est inscrit dans le nom de la méthode

Buffer n’a aucune valeur par défaut à retenir, et c’est pour cela que le code Node est en général le plus facile à auditer sur ce point.

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() inverse chaque groupe de quatre octets et renvoie le même buffer plutôt qu’une copie. Pratique quand vous avez tout un tableau d’entiers dans le mauvais ordre ; dangereux si vous aviez oublié que le buffer était partagé.

4. struct en Python : cinq préfixes, et ce que @ coûte

4.1 < > ! = @ : les cinq préfixes d’ordre des octets de struct

Empaqueter 0x12345678 comme entier 32 bits non signé, une ligne par préfixe :

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

! et > produisent des octets identiques parce que l’ordre d’octets réseau est le big-endian. Le dépaquetage en est le miroir exact, et int.from_bytes donne la même paire :

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 @ et = diffèrent par le remplissage, pas par l’ordre des octets

Les deux suivent la plateforme : sur cette machine, ils écrivent donc en little-endian. La différence porte sur l’alignement, et elle change la taille de votre struct :

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

Un char suivi d’un int, cela fait cinq octets de données. Sous @, la valeur par défaut quand vous n’écrivez aucun préfixe, Python insère trois octets de remplissage pour que l’int démarre sur une frontière de quatre octets. Sous = ou n’importe quel préfixe d’ordre explicite, le remplissage disparaît.

C’est le mécanisme derrière un bug qui semble impossible : quelqu’un ajoute < pour corriger un problème d’ordre des octets et la longueur de l’enregistrement change sous ses pieds. L’ordre des octets n’y est pour rien. En s’écartant de @, on a aussi coupé silencieusement l’alignement natif.

5. L’ordre d’octets réseau, et comment les autres langages l’écrivent

L’ordre d’octets réseau, c’est le big-endian. Les en-têtes TCP, UDP et IP transportent tous leurs champs multi-octets de cette façon, un héritage de l’époque où le matériel big-endian était assez répandu pour qu’il faille bien trancher. Le C expose la conversion via htons, htonl, ntohs et ntohl : hôte vers réseau et retour, pour les shorts et les longs. Sur un hôte little-endian, ils permutent ; sur un hôte big-endian, ce sont des no-ops, et c’est pour cela que le code qui les omet fonctionne très bien jusqu’au jour où il rencontre une autre machine.

Go prend le parti inverse et refuse purement et simplement d’avoir une valeur par défaut :

import "encoding/binary"

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

binary.BigEndian et binary.LittleEndian sont des valeurs que vous nommez au point d’appel. Il n’y a pas de chemin dépendant de la plateforme, pas de drapeau optionnel à oublier : auditer l’ordre des octets en Go revient à lire un identifiant.

La même discipline s’applique aux données en virgule fixe. Une fois sorti de votre code, un échantillon Q15 ou Q31 n’est qu’un entier de 16 ou 32 bits : il hérite du problème d’ordre des octets comme tout le reste. Le convertisseur de format Q montre l’entier derrière la fraction, et c’est cet entier que l’on ordonne.

6. Les nombres à virgule flottante ont eux aussi un ordre d’octets

Un float n’a rien de spécial : IEEE 754 définit le motif de bits, puis les mêmes quatre ou huit octets se posent dans l’ordre qu’exige le format.

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'

Le convertisseur IEEE 754 répond à la première moitié de la question : tapez 3.14159 avec FP32 sélectionné et vous obtenez 0x40490FD0. Cet article répond à la seconde moitié, à savoir dans quel ordre ces quatre octets arrivent dans le fichier.

La ligne double 0.1 est aussi la raison pour laquelle 0.1 + 0.2 se comporte mal. Ces octets 99 qui se répètent sont un développement binaire qui ne se termine jamais, ce que le guide sur la précision des nombres à virgule flottante décortique.

6.1 Le float 1.0 vaut 3f 80 00 00, ou 00 00 80 3f

1.0 en FP32 fait un bon canari parce que son motif d’octets est très déséquilibré. Le big-endian écrit 3f 80 00 00 ; le little-endian écrit 00 00 80 3f. Faites un vidage d’un format binaire inconnu, trouvez un champ dont vous savez qu’il devrait valoir 1.0, et les deux zéros de fin vous disent de quel côté vous êtes. Cela marche aussi pour le double, où la même valeur présente six octets nuls groupés d’un seul côté.

7. L’ordre des octets dans les encodages de texte : le BOM n’est qu’une déclaration

UTF-16 et UTF-32 sont faits d’unités multi-octets, ils se heurtent donc exactement au problème décrit dans cet article. Leur réponse, c’est de laisser le fichier annoncer son ordre lui-même, avec une marque d’ordre des octets, le BOM :

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

fffe en tête signifie little-endian ; feff signifie big-endian. Cela fait du BOM le cas le plus répandu d’un format qui déclare son propre ordre des octets au lieu d’en présumer un. L’histoire complète, y compris pourquoi UTF-8 n’a besoin d’aucun BOM et ce que la marque vous coûte quand elle débarque sans invitation, est dans le guide UTF-8, UTF-16 et encodage Unicode.

8. Le little-endian est-il plus rapide ? Ce que disent 40 millions d’itérations

L’affirmation selon laquelle le little-endian serait plus efficace apparaît dans les résumés des moteurs de recherche et dans la plupart des explications d’introduction sur le sujet, et elle se teste. Quarante millions d’itérations d’une seule lecture 32 bits sur cette machine arm64 :

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

Ces deux rapports ne mesurent pas la même chose, et ne citer que le second reproduirait l’erreur de ces articles.

La paire DataView est la mesure honnête du coût de l’ordre des octets. Les deux appels compilent vers le même chemin inline dans V8 ; celui en big-endian porte une instruction ARM REV supplémentaire pour permuter les octets. D’où le 1.021×, soit environ 2 %. C’est peu, mais ce n’est pas nul : ne l’arrondissez pas à « gratuit ».

La paire Buffer mesure tout autre chose. V8 dispose d’un chemin rapide dédié pour readUInt32LE dont readUInt32BE ne bénéficie pas, donc le 3.354× est une différence d’implémentation dans un seul runtime, pas le prix de la permutation d’octets sur un processeur. Le citer comme preuve que le big-endian est lent serait faux. Changez de runtime et le chiffre change avec lui.

Sur du matériel moderne, la conversion d’ordre des octets coûte bien trop peu pour avoir sa place dans une discussion de conception de format. L’argument historique de l’efficacité vient d’une époque antérieure aux instructions de permutation dédiées. Choisissez l’ordre que votre protocole ou vos voisins utilisent déjà.

9. Comment distinguer un bug d’endianness d’un bug ordinaire

« Comment savoir si ma machine est big-endian ou little-endian » se règle avec os.endianness() en Node et sys.byteorder en Python, une ligne chacun, et les moteurs de recherche impriment déjà la réponse au-dessus des résultats. C’est aussi la mauvaise question dans la plupart des débogages réels : quand vous analysez un fichier ou un paquet, la réponse est écrite dans la spécification du format, pas dans votre processeur. Ce qui sert au quotidien, c’est de savoir reconnaître le symptôme.

9.1 Deux symptômes : le nombre absurde et le nombre 256×

Le bruyant est facile. Lisez la largeur du PNG de la section 2 dans le mauvais sens et vous obtenez 2 953 052 160 pour une image de 1200 pixels. Tout champ censé contenir un compte modeste et qui revient en milliards est un entier 32 bits inversé jusqu’à preuve du contraire.

Le discret est celui qui coûte cher. Les octets 00 00 01 00 se lisent 256 en big-endian et 65 536 en little-endian. Les deux ressemblent à des tailles de tampon plausibles. Rien ne lève d’exception, aucune assertion ne se déclenche, et la valeur est fausse d’un facteur 256. Des bugs pareils survivent à la revue de code parce que le nombre affiché a l’air raisonnable. Ils sont de la même famille qu’un BOM UTF-8 qui casse une analyse JSON : un détail invisible au niveau de l’octet, et un message d’erreur qui pointe ailleurs. Voir le guide de dépannage du BOM UTF-8 et des erreurs d’analyse JSON.

Retenez deux heuristiques. Les petites valeurs avec trois octets nuls en tête sont celles qui basculent en silence, parce que les deux lectures restent dans les bornes. Et si inverser les octets à la main produit un nombre qui a du sens, vous avez votre réponse sans toucher au débogueur.

9.2 L’ordre dans lequel vérifier les choses

  1. Vérifiez d’abord la spécification du format. La RFC 2083 dit que PNG est big-endian ; la RFC 1952 §2.3.1 dit que GZIP est little-endian. Ce que fait votre machine n’a aucune importance dans les deux cas.
  2. Vérifiez ensuite la valeur par défaut de votre lecteur. DataView.getUint32(0) est big-endian ; Uint32Array et struct.pack('@I', ...) suivent l’ordre de la plateforme ; binary.BigEndian.Uint32 fait ce qu’il annonce. La plupart des bugs d’ordre des octets se réduisent à un troisième argument oublié ou à un préfixe absent, pas à un malentendu profond.
  3. Ne suspectez la plateforme qu’en dernier. Elle compte quand vous écrivez un fichier avec Uint32Array ou @ et que vous l’expédiez vers une autre architecture, ou quand vous comparez un vidage mémoire à une spécification. Elle ne compte presque jamais quand vous lisez un format bien défini qui vous a déjà dit quel ordre il utilise.

Questions fréquentes

Le big-endian ou le little-endian, lequel est le meilleur ?

Ni le big-endian ni le little-endian n’est meilleur. Celui que la spécification du format impose est le bon pour ce format, et vous avez rarement le choix. Côté performance, une lecture DataView en big-endian s’est mesurée à 1.021× celle en little-endian sur cette machine, soit environ 2 %, bien trop peu pour orienter la moindre décision de conception.

Comment savoir si ma machine est big-endian ou little-endian ?

os.endianness() en Node renvoie LE ici, et sys.byteorder en Python renvoie little. Les deux tiennent sur une ligne. La question compte pourtant moins qu’il n’y paraît : quand vous analysez un fichier ou un paquet, c’est le format qui dicte l’ordre des octets et votre processeur n’a pas son mot à dire.

DataView et Uint32Array donnent des nombres différents à partir du même buffer. Est-ce un bug ?

Non, c’est un comportement documenté de DataView. DataView.getUint32(0) prend le big-endian par défaut, tandis que Uint32Array suit toujours la plateforme, qui est little-endian sur x86 comme sur Apple Silicon. Mêmes octets, deux conventions. Passez true en troisième argument et DataView sera d’accord.

Pourquoi la taille de mon struct a-t-elle changé quand j’ai ajouté < ?

Parce que vous vous êtes écarté de @, la valeur par défaut, qui ajoute du remplissage pour l’alignement natif. struct.calcsize('@ci') vaut 8, tandis que struct.calcsize('=ci') et struct.calcsize('<ci') valent tous deux 5. Les trois octets de remplissage devant l’int sont partis en même temps que l’alignement natif.

L’ordre d’octets réseau est-il big-endian ou little-endian ?

Big-endian. C’est la convention qu’utilisent les en-têtes TCP/IP, et c’est pour cela que htons et htonl existent en C. En Python, les préfixes ! et > produisent des octets identiques : struct.pack('!I', 0x12345678) et struct.pack('>I', 0x12345678) donnent tous deux 12 34 56 78.

L’endianness affecte-t-elle UTF-8 ?

Non. UTF-8 est un flux d’octets, et chaque point de code s’écrit comme une suite ordonnée d’octets individuels, il ne reste donc aucune unité multi-octet à réordonner. UTF-16 et UTF-32 ont bien ce problème, ce qui est précisément la raison d’être de leur BOM (voir la section 7).

Les tableaux d’un seul octet demandent-ils une gestion de l’ordre des octets ?

Non. L’ordre des octets n’existe que pour des unités plus larges qu’un octet, donc Uint8Array, Int8Array et les objets bytes de Python sont immunisés. Élargissez d’un cran et il revient aussitôt : new Uint16Array(two)[0] = 0x00ff atterrit en mémoire sous la forme ff 00 sur cette machine.

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

Articles connexes

Voir tous les articles