Kolejność bajtów: dlaczego te same bajty dają dwie liczby
W pamięci leżą cztery bajty: 12 34 56 78. Trzy różne API JavaScriptu zwrócą z nich dwie różne liczby. Na tym polega kolejność bajtów, czyli endianness.
| Odczyt | Wynik |
|---|---|
new DataView(buf).getUint32(0) | 0x12345678 |
new DataView(buf).getUint32(0, true) | 0x78563412 |
new Uint32Array(buf)[0] | 0x78563412 |
Wszystkie trzy wywołania są poprawne i żadne nie zgłasza wyjątku. Różnią się regułą tego, który koniec liczby wielobajtowej trafia do pamięci jako pierwszy.
Najwięcej czasu oszczędza zmiana pytania. Zamiast „jakiej architektury jest moja maszyna?” trzeba zapytać „według czyjej konwencji zapisano te bajty?”. PNG na dysku jest big-endian, a leżący obok plik GZIP jest little-endian. Procesor nie ma w żadnym z tych przypadków nic do powiedzenia.
Wszystko poniżej zmierzono na Node v26.7.0 i Pythonie 3.14.6 pod macOS darwin arm64, gdzie os.endianness() zwraca LE, a sys.byteorder zwraca little.
1. Co znaczą big-endian i little-endian
Weźmy 32-bitową wartość 0x12345678. To cztery bajty: 12 jest najbardziej znaczący, 78 najmniej. Kolejność bajtów rozstrzyga, który z nich trafi pod najniższy adres.
| Układ | Adres 0 | Adres 1 | Adres 2 | Adres 3 |
|---|---|---|---|---|
| Big-endian | 12 | 34 | 56 | 78 |
| Little-endian | 78 | 56 | 34 | 12 |
Big-endian zapisuje najpierw duży koniec, czyli w tej samej kolejności, w jakiej liczbę zapisuje się na kartce. Little-endian zapisuje najpierw mały koniec. Żaden z układów nie rusza bitów wewnątrz bajtu: 0x12 pozostaje 0x12 w obu. Przestawiają się wyłącznie całe bajty.
Jeśli problemem jest samo przejście z zapisu szesnastkowego na binarny, konwerter liczbowy pokazuje każdy bajt binarnie obok jego postaci szesnastkowej, a przewodnik po konwersji binarnej, szesnastkowej i ósemkowej omawia samą notację.
1.1 Dlaczego są dwie
Podział wynika z historii, a nie z zasad. Big-endian czyta się tak, jak ludzie zapisują liczby, i na tyle wcześnie stał się konwencją protokołów sieciowych, że po prostu został. Little-endian wygrał po stronie procesorów, bo używa go x86, a ARM domyślnie robi to samo. Argument o wydajności, który powtarza niemal każdy artykuł na ten temat, mierzy sekcja 8. Wychodzi z tego mniej więcej 2%.
2. Kolejność bajtów jest cechą formatu, a nie platformy
Kolejność bajtów ustala ten, kto je zapisał, a nie maszyna, która je czyta. Krótkie wyjaśnienia zwykle to pomijają.
Dowód wymaga jednego laptopa i dwóch plików. Na tej samej maszynie arm64, w tym samym procesie, te dwa pliki potrzebują przeciwnego dekodowania.
2.1 PNG jest big-endian
RFC 2083 wymaga, by liczby wielobajtowe zapisywać w sieciowej kolejności bajtów, więc każda długość, szerokość i wysokość w pliku PNG jest big-endian. Układ nagłówka jest sztywny: osiem bajtów sygnatury, potem czterobajtowa długość chunku, potem czteroznakowy typ chunku, a na końcu szerokość i wysokość.
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
| Pole | Bajty | Big-endian | Little-endian |
|---|---|---|---|
| Długość IHDR | 00 00 00 0d | 13 | 218 103 808 |
| Szerokość obrazu | 00 00 04 b0 | 1200 | 2 953 052 160 |
2.2 GZIP jest little-endian
RFC 1952 §2.3.1 mówi to wprost: najmniej znaczący bajt pierwszy. Ostatnie cztery bajty strumienia gzip to ISIZE, czyli rozmiar sprzed kompresji. Sprawdźmy to na 300 bajtach A:
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
| Pole | Bajty | Little-endian | Big-endian |
|---|---|---|---|
| ISIZE w stopce | 2c 01 00 00 | 300 | 738 263 040 |
Jeden proces na jednej maszynie, dwa razy ten sam czterobajtowy odczyt. Jeśli obowiązującą regułą jest „moja maszyna jest little-endian, więc czytam little-endian”, jeden z tych dwóch plików rozkoduje się na śmieci. Za każdym razem rozstrzyga format.
3. JavaScript: dwa API, dwa przeciwne ustawienia domyślne
Dwa sposoby patrzenia na ten sam ArrayBuffer domyślnie nie zgadzają się ze sobą, a większość opracowań o kolejności bajtów w ogóle o tym nie wspomina.
3.1 Kolejność bajtów w DataView: rozstrzyga trzeci argument
Metody DataView przyjmują opcjonalną flagę littleEndian jako ostatni argument. Bez niej wynikiem jest big-endian. setUint32(0, x) i setUint32(0, x, false) to dokładnie to samo wywołanie.
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
Zapis działa tak samo, tylko w drugą stronę:
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 idzie za platformą i nie da się tego zmienić
Uint32Array, Int16Array, Float64Array i cała reszta używają tego, czego używa procesor. Nie ma tu argumentu ani flagi konstruktora. Na tej maszynie arm64 oznacza to little-endian, czyli dokładne przeciwieństwo domyślnego ustawienia DataView.
new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'
Ten sam ArrayBuffer odczytany przez new DataView(buf).getUint32(0) i przez new Uint32Array(buf)[0] daje więc 0x12345678 oraz 0x78563412. Oba wyniki są poprawne, bo odpowiadają na różne pytania.
Widoki jednobajtowe są na to odporne, bo kolejność bajtów istnieje wyłącznie dla jednostek szerszych niż bajt. Uint8Array i Int8Array nigdy nie potrzebują flagi. Jeden krok szerzej i problem wraca:
const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'
3.3 Node Buffer: kolejność zapisana w nazwie metody
Buffer w ogóle rezygnuje z ustawień domyślnych i umieszcza kolejność w nazwie metody, dlatego kod w Node zwykle najłatwiej się audytuje.
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() odwraca każdą grupę czterech bajtów i zwraca ten sam bufor, a nie kopię. To wygodne, gdy trzeba przestawić całą tablicę liczb o złej kolejności bajtów, i niebezpieczne, gdy umknie fakt, że bufor był współdzielony.
4. Python struct: pięć prefiksów i koszt @
4.1 < > ! = @: pięć prefiksów kolejności bajtów w struct pack
Pakowanie 0x12345678 jako 32-bitowej liczby bez znaku, po jednej linii na prefiks:
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
! i > dają identyczne bajty, bo sieciowa kolejność bajtów to big-endian. Rozpakowanie jest lustrzane, a int.from_bytes zwraca tę samą parę:
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 @ i = różnią się wypełnieniem, a nie kolejnością bajtów
Oba idą za platformą, więc na tej maszynie oba zapisują little-endian. Różnica dotyczy wyrównania i zmienia rozmiar struktury:
struct.calcsize('@ci') # 8
struct.calcsize('=ci') # 5
struct.calcsize('<ci') # 5
char, po którym następuje int, to pięć bajtów danych. Pod @, czyli w trybie domyślnym przy całkowitym braku prefiksu, Python wstawia trzy bajty wypełnienia, żeby int zaczynał się na granicy czterech bajtów. Pod = albo przy dowolnym jawnym prefiksie kolejności bajtów wypełnienie znika.
Stąd bierze się błąd, który brzmi jak niemożliwy: ktoś dodaje <, żeby naprawić problem z kolejnością bajtów, i nagle zmienia mu się pod ręką długość rekordu. Kolejność bajtów nie ma z tym nic wspólnego. Odejście od @ po cichu wyłączyło również natywne wyrównanie.
5. Sieciowa kolejność bajtów i jak nazywają ją inne języki
Sieciowa kolejność bajtów to big-endian. Nagłówki TCP, UDP i IP niosą swoje pola wielobajtowe właśnie w ten sposób, a sięga to czasów, gdy sprzęt big-endian był na tyle rozpowszechniony, że ktoś musiał wybrać stronę. W C konwersję udostępniają htons, htonl, ntohs i ntohl: z hosta do sieci i z powrotem, dla wartości short i long. Na hoście little-endian przestawiają bajty, na hoście big-endian nie robią nic. Właśnie dlatego kod, który je pomija, działa bez zarzutu aż do spotkania z inną maszyną.
Go idzie w przeciwną stronę i w ogóle nie ma ustawienia domyślnego:
import "encoding/binary"
v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)
binary.BigEndian i binary.LittleEndian to wartości, które trzeba wskazać z nazwy w miejscu wywołania. Nie ma tu ścieżki zależnej od platformy ani opcjonalnej flagi, o której da się zapomnieć, więc przegląd kolejności bajtów w Go sprowadza się do przeczytania identyfikatora.
Ta sama dyscyplina obowiązuje przy danych stałoprzecinkowych. Próbka Q15 albo Q31 jest po wyjściu z kodu zwykłą liczbą 16- lub 32-bitową, więc dziedziczy pytanie o kolejność bajtów razem z całą resztą; konwerter formatu Q pokazuje liczbę całkowitą kryjącą się za ułamkiem, a to właśnie ona podlega uporządkowaniu.
6. Liczby zmiennoprzecinkowe też mają kolejność bajtów
Liczba zmiennoprzecinkowa nie jest niczym wyjątkowym. IEEE 754 definiuje układ bitów, a potem te same cztery albo osiem bajtów układa się w kolejności, jakiej wymaga 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'
Konwerter IEEE 754 odpowiada na pierwszą połowę pytania: po wpisaniu 3.14159 przy wybranym FP32 wynikiem jest 0x40490FD0. Ten artykuł odpowiada na drugą połowę, czyli w jakiej kolejności te cztery bajty trafiają do pliku. Narzędzie podaje wartość, kolejność bajtów podaje ułożenie.
Wiersz z double 0.1 jest zarazem powodem, dla którego 0.1 + 0.2 zachowuje się dziwnie. Te powtarzające się bajty 99 to rozwinięcie binarne, które nigdy się nie kończy — rozkłada je na części przewodnik po precyzji liczb zmiennoprzecinkowych.
6.1 float 1.0 to 3f 80 00 00 albo 00 00 80 3f
1.0 w FP32 to wygodny test, bo jego układ bajtów jest wyjątkowo niesymetryczny. Big-endian zapisuje 3f 80 00 00, little-endian zapisuje 00 00 80 3f. W zrzucie nieznanego formatu binarnego trzeba znaleźć pole, o którym wiadomo, że powinno mieć wartość 1.0; dwa zera na końcu podpowiedzą, po której stronie się jest. Działa to również dla typu double, gdzie ta sama wartość ma sześć zerowych bajtów skupionych po jednej stronie.
7. Kolejność bajtów w kodowaniach tekstu: BOM to tylko deklaracja
UTF-16 i UTF-32 składają się z jednostek wielobajtowych, więc trafiają dokładnie na problem opisywany w tym artykule. Odpowiadają na niego tak, że pozwalają plikowi samemu ogłosić swoją kolejność za pomocą znacznika kolejności bajtów:
Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex'); // '4100'
fffe na początku oznacza little-endian, feff oznacza big-endian. BOM jest przez to najczęstszym przypadkiem formatu, który deklaruje swoją kolejność bajtów, zamiast ją zakładać. Całą historię, łącznie z tym, dlaczego UTF-8 nie potrzebuje BOM-u i ile ten znacznik kosztuje, gdy pojawi się nieproszony, opisuje przewodnik po kodowaniu UTF-8, UTF-16 i Unicode.
8. Czy little-endian jest szybszy? Co mówi 40 milionów iteracji
Twierdzenie, że little-endian jest wydajniejszy, pojawia się w podsumowaniach wyszukiwarek i w wielu wprowadzających artykułach na ten temat. Da się je sprawdzić. Czterdzieści milionów iteracji pojedynczego 32-bitowego odczytu na tej maszynie arm64:
| Ścieżka | 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 |
| Stosunek | Współczynnik |
|---|---|
| DataView big-endian / little-endian | 1.021× |
| Buffer big-endian / little-endian | 3.354× |
Te dwa stosunki mierzą różne rzeczy, a podanie samego drugiego byłoby dokładnie tym błędem, który popełniają wspomniane artykuły.
Para DataView to uczciwy pomiar kosztu kolejności bajtów. Oba wywołania kompilują się w V8 do tej samej ścieżki inline; to big-endianowe niesie jedną dodatkową instrukcję ARM REV, która przestawia bajty. Stąd 1.021×, czyli mniej więcej 2%. Mało, ale nie zero — nie należy zaokrąglać tego do „za darmo”.
Para Buffer mierzy coś zupełnie innego. V8 ma dedykowaną szybką ścieżkę dla readUInt32LE, której readUInt32BE nie dostaje, więc 3.354× to różnica w implementacji jednego środowiska uruchomieniowego, a nie cena przestawiania bajtów przez procesor. Cytowanie tego jako dowodu na to, że big-endian jest wolny, byłoby błędem. Zmiana środowiska zmienia i tę liczbę.
Na współczesnym sprzęcie konwersja kolejności bajtów jest zbyt tania, by w ogóle pojawiać się w dyskusji o projekcie formatu. Historyczny argument o wydajności pochodzi z czasów sprzed dedykowanych instrukcji przestawiania bajtów. Lepiej wybrać tę kolejność, której już używa protokół albo sąsiedzi.
9. Jak odróżnić błąd kolejności bajtów od zwykłego błędu
Pytanie „jak sprawdzić, czy moja maszyna jest big-endian, czy little-endian” ma odpowiedź na jedną linię: os.endianness() w Node i sys.byteorder w Pythonie, a wyszukiwarki i tak drukują ją nad wynikami. W większości realnych sesji debugowania jest to zresztą pytanie postawione źle: przy parsowaniu pliku albo pakietu rozstrzyga format, a procesor nie bierze w tym udziału. Bardziej przydaje się umiejętność rozpoznania objawu.
9.1 Dwa objawy: liczba absurdalna i liczba 256×
Ten głośny jest łatwy. Odczyt szerokości PNG z sekcji 2 w złą stronę daje dla obrazu o szerokości 1200 pikseli wynik 2 953 052 160. Każde pole, które powinno być skromną liczbą, a wraca w miliardach, jest odwróconą 32-bitową liczbą całkowitą, dopóki nie okaże się inaczej.
Ten cichy jest kosztowny. Bajty 00 00 01 00 czytają się jako 256 w big-endian i 65 536 w little-endian. Oba wyglądają na wiarygodne rozmiary bufora. Nic nie rzuca wyjątkiem, żadna asercja się nie odpala, a wartość jest błędna 256-krotnie. Takie błędy przechodzą przez przegląd kodu, bo liczba na ekranie wygląda rozsądnie. To stawia je w jednej rodzinie z BOM-em UTF-8 psującym parsowanie JSON: niewidoczny szczegół na poziomie bajtów, którego objaw kompletnie myli tropy. Opisuje go osobny przewodnik po BOM UTF-8 i błędach parsowania JSON.
Wynikają z tego dwie heurystyki. Małe wartości z trzema zerowymi bajtami na początku to te, które przewracają się po cichu, bo oba odczyty mieszczą się w zakresie. A jeśli ręczne odwrócenie bajtów daje liczbę, która ma sens, odpowiedź jest gotowa bez uruchamiania debuggera.
9.2 W jakiej kolejności sprawdzać
- Najpierw specyfikacja formatu. RFC 2083 mówi, że PNG jest big-endian, RFC 1952 §2.3.1 mówi, że GZIP jest little-endian. To, co robi maszyna, nie ma dla żadnego z nich znaczenia.
- Potem domyślne ustawienie czytnika.
DataView.getUint32(0)to big-endian,Uint32Arrayto kolejność platformy,struct.pack('@I', ...)to kolejność platformy, abinary.BigEndian.Uint32to dokładnie to, co ma w nazwie. Większość błędów kolejności bajtów to brakujący trzeci argument albo brakujący prefiks, a nie głębokie nieporozumienie. - Platforma jako ostatnia. Ma znaczenie wtedy, gdy plik powstaje przez
Uint32Arrayalbo@i trafia na inną architekturę, oraz wtedy, gdy zrzut pamięci porównuje się ze specyfikacją. Prawie nigdy nie ma znaczenia przy czytaniu dobrze zdefiniowanego formatu, który już powiedział, jakiej kolejności używa.
Najczęściej zadawane pytania
Czy lepszy jest big-endian, czy little-endian?
Ani big-endian, ani little-endian nie jest lepszy. Poprawna dla danego formatu jest ta kolejność, którą sam format wskazuje, a wyboru rzadko się w ogóle dostaje. Jeśli chodzi o wydajność, odczyt DataView w big-endian zmierzono na tej maszynie na 1.021× odczytu little-endian, czyli mniej więcej 2%, zdecydowanie za mało, by opierać na tym jakąkolwiek decyzję projektową.
Jak sprawdzić, czy moja maszyna jest big-endian, czy little-endian?
os.endianness() w Node zwraca tutaj LE, a sys.byteorder w Pythonie zwraca little. Oba to jednolinijkowce. Pytanie ma jednak mniejszą wagę, niż się wydaje: przy parsowaniu pliku albo pakietu kolejność bajtów dyktuje format, a procesor nie ma nic do powiedzenia.
DataView i Uint32Array zwracają różne liczby z tego samego bufora. Czy to błąd?
Nie, to udokumentowane zachowanie DataView. DataView.getUint32(0) domyślnie działa w big-endian, a Uint32Array zawsze idzie za platformą, czyli little-endian na x86 i Apple Silicon. Te same bajty, dwie konwencje. Wystarczy podać true jako trzeci argument, a DataView zacznie się zgadzać.
Dlaczego po dodaniu < zmienił się rozmiar mojej struktury?
Ponieważ jawny prefiks wyłącza domyślne @, które dokłada wypełnienie pod natywne wyrównanie. struct.calcsize('@ci') daje 8, a struct.calcsize('=ci') i struct.calcsize('<ci') dają po 5. Trzy bajty wypełnienia przed int odeszły razem z natywnym wyrównaniem.
Czy sieciowa kolejność bajtów to big-endian, czy little-endian?
Big-endian. Taką konwencję stosują nagłówki TCP/IP i dlatego w C istnieją htons oraz htonl. W Pythonie prefiksy ! i > dają identyczne bajty: struct.pack('!I', 0x12345678) i struct.pack('>I', 0x12345678) zwracają 12 34 56 78.
Czy kolejność bajtów ma znaczenie dla UTF-8?
Nie. UTF-8 to strumień bajtów, a każdy code point zapisuje się jako uporządkowany ciąg pojedynczych bajtów, więc nie zostaje żadna jednostka wielobajtowa do przestawienia. UTF-16 i UTF-32 ten problem mają i właśnie dlatego niosą BOM, o czym mowa w sekcji 7.
Czy tablice jednobajtowe wymagają obsługi kolejności bajtów?
Nie. Kolejność bajtów istnieje tylko dla jednostek szerszych niż jeden bajt, więc Uint8Array, Int8Array i pythonowe obiekty bytes są na nią odporne. Wystarczy poszerzyć o jeden krok, a wraca od razu: new Uint16Array(two)[0] = 0x00ff ląduje w pamięci tej maszyny jako ff 00.