Порядок байтов: почему одни байты дают два разных числа
В памяти лежат четыре байта: 12 34 56 78. Прочитайте их тремя разными API JavaScript — и вернутся два разных числа.
| Чтение | Результат |
|---|---|
new DataView(buf).getUint32(0) | 0x12345678 |
new DataView(buf).getUint32(0, true) | 0x78563412 |
new Uint32Array(buf)[0] | 0x78563412 |
Это и есть порядок байтов (endianness). Ничего не сломано, ни один вызов не выбрасывает исключение: просто каждый применяет свое правило о том, какой конец многобайтового числа идет первым.
Вопрос тут не про вашу машину, а про то, под чью конвенцию эти байты записывали. PNG на диске — big-endian. Лежащий рядом файл GZIP — little-endian. Голоса процессора не спрашивают ни в том, ни в другом случае.
Всё, что ниже, замерено на Node v26.7.0 и Python 3.14.6 под macOS darwin arm64, где os.endianness() возвращает LE, а sys.byteorder — little.
1. Что на самом деле значат big-endian и little-endian
Возьмем 32-битное значение 0x12345678. Это четыре байта: 12 — старший, 78 — младший. Порядок байтов решает, какой из них ляжет по младшему адресу.
| Раскладка | Адрес 0 | Адрес 1 | Адрес 2 | Адрес 3 |
|---|---|---|---|---|
| Big-endian | 12 | 34 | 56 | 78 |
| Little-endian | 78 | 56 | 34 | 12 |
Big-endian кладет первым большой конец — в том же порядке, в каком число записывают на бумаге. Little-endian кладет первым маленький. Ни одна из раскладок не трогает биты внутри байта: 0x12 остается 0x12 в обеих. Переезжают только целые байты.
Если спотыкаетесь как раз на переходе от шестнадцатеричной записи к двоичной, конвертер систем счисления показывает каждый байт в двоичном виде рядом с шестнадцатеричным, а руководство по двоичной, шестнадцатеричной и восьмеричной системам разбирает саму нотацию.
1.1 Почему их два
Раскол скорее исторический, чем принципиальный. Big-endian читается так же, как люди пишут числа, и достаточно рано стал конвенцией сетевых протоколов, чтобы там и закрепиться. Little-endian победил на стороне процессоров: его использует x86, и ARM по умолчанию тоже. Аргумент об эффективности, который повторяется чуть ли не в каждой статье на эту тему, замерен в разделе 8: примерно 2%.
2. Порядок байтов — свойство формата, а не платформы
Порядок байтов задает тот, кто эти байты записал, а не машина, которая их читает. Короткие объяснения обычно этот момент пропускают.
Для доказательства хватит одного ноутбука и двух файлов. На одной и той же машине arm64, внутри одного процесса, эти два файла требуют противоположного декодирования.
2.1 PNG — big-endian
RFC 2083 требует, чтобы многобайтовые целые шли в сетевом порядке байтов, поэтому любая длина, ширина и высота в PNG — big-endian. Раскладка заголовка фиксирована: восемь байтов сигнатуры, затем четырехбайтовая длина чанка, затем четырехсимвольный тип чанка, затем ширина и высота.
const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');
png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — сигнатура PNG
png.readUInt32BE(8); // 13 — длина чанка IHDR
png.readUInt32BE(16); // 1200 — ширина изображения
png.readUInt32BE(20); // 630 — высота изображения
png.readUInt32LE(16); // те же четыре байта, прочитанные не с того конца
| Поле | Байты | Big-endian | Little-endian |
|---|---|---|---|
| Длина IHDR | 00 00 00 0d | 13 | 218 103 808 |
| Ширина изображения | 00 00 04 b0 | 1200 | 2 953 052 160 |
2.2 GZIP — little-endian
RFC 1952 §2.3.1 говорит об этом прямым текстом: младший байт первым. Последние четыре байта потока gzip — это ISIZE, размер до сжатия. Сожмем 300 байт 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 — верно
gz.readUInt32BE(gz.length - 4); // те же четыре байта, прочитанные не с того конца
| Поле | Байты | Little-endian | Big-endian |
|---|---|---|---|
| Трейлер ISIZE | 2c 01 00 00 | 300 | 738 263 040 |
Та же машина, тот же процесс, тот же примитив чтения четырех байт. Если рабочее правило звучит как «моя машина little-endian, значит и читаю я little-endian», один из этих двух файлов раскодируется в мусор.
3. JavaScript: два API, два противоположных умолчания
Два способа посмотреть на один и тот же ArrayBuffer по умолчанию противоречат друг другу, и в большинстве текстов о порядке байтов об этом не пишут.
3.1 Порядок байтов в DataView: решает третий аргумент
Методы DataView принимают необязательный флаг littleEndian последним аргументом. Не передали — получите big-endian. setUint32(0, x) и setUint32(0, x, false) — это один и тот же вызов.
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, по умолчанию
dv.getUint32(0, true).toString(16); // '78563412' — littleEndian: true
Запись ведет себя так же, только в обратную сторону:
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 следует за платформой, и это не изменить
Uint32Array, Int16Array, Float64Array и остальные используют то же, что и процессор. Ни аргумента, ни флага в конструкторе — переопределить это нечем. На этой машине arm64 это значит little-endian — ровно противоположное умолчанию DataView.
new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'
Так что один и тот же ArrayBuffer, прочитанный через new DataView(buf).getUint32(0) и через new Uint32Array(buf)[0], дает 0x12345678 и 0x78563412. Оба значения верны; они просто отвечают на разные вопросы.
Однобайтовые представления к этому невосприимчивы: порядок байтов существует только для единиц шире байта. Uint8Array и Int8Array во флаге не нуждаются никогда. Расширьте на один шаг — и он возвращается:
const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'
3.3 Node Buffer: порядок указан прямо в имени метода
Buffer обходится без умолчаний вовсе и выносит порядок в имя метода — поэтому код на Node обычно проще всего проверять глазами.
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' — меняет b на месте
swap32() переворачивает каждую группу из четырех байт и возвращает тот же буфер, а не копию. Удобно, когда на руках целый массив целых с неправильным порядком; опасно, если забыли, что буфер общий.
4. Python struct: пять префиксов и во что обходится @
4.1 < > ! = @: пять префиксов порядка байтов в struct
Упаковка 0x12345678 как беззнакового 32-битного целого, по строке на префикс:
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' сетевой порядок
struct.pack('=I', 0x12345678).hex(' ') # '78 56 34 12' порядок платформы, стандартные размеры
struct.pack('@I', 0x12345678).hex(' ') # '78 56 34 12' порядок платформы, родное выравнивание
! и > дают одинаковые байты, потому что сетевой порядок байтов — это big-endian. Распаковка зеркальна, и int.from_bytes дает ту же пару:
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 @ и = различаются выравниванием, а не порядком байтов
Оба следуют за платформой, так что на этой машине оба пишут little-endian. Разница — в выравнивании, и она меняет размер структуры:
struct.calcsize('@ci') # 8
struct.calcsize('=ci') # 5
struct.calcsize('<ci') # 5
char, за которым идет int, — это пять байт данных. Под @, то есть по умолчанию, когда префикс не написан вообще, Python вставляет три байта заполнения, чтобы int начинался на границе четырех байт. Под = или под любым явным префиксом порядка байтов заполнение исчезает.
Именно этот механизм стоит за багом, который читается как невозможный: человек добавляет <, чтобы починить порядок байтов, и длина записи меняется у него под руками. Порядок байтов тут ни при чем. Уход от @ заодно молча выключил и родное выравнивание.
5. Сетевой порядок байтов и как его называют в других языках
Сетевой порядок байтов — big-endian. Заголовки TCP, UDP и IP несут свои многобайтовые поля именно так, и тянется это с тех времен, когда big-endian-железа было достаточно много, чтобы кому-то пришлось выбрать сторону. В C преобразование доступно через htons, htonl, ntohs и ntohl: из хостового порядка в сетевой и обратно, для коротких и длинных. На little-endian-хосте они переставляют байты, на big-endian-хосте это пустышки — поэтому код, где их забыли, прекрасно работает, пока не встретит другую машину.
Go идет противоположным путем — умолчания у него нет вообще:
import "encoding/binary"
v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)
binary.BigEndian и binary.LittleEndian — это значения, которые называют прямо в месте вызова. Нет ни зависимого от платформы пути, ни необязательного флага, который можно забыть, так что проверка порядка байтов в Go сводится к чтению идентификатора.
То же самое относится к данным с фиксированной точкой. Отсчет в формате Q15 или Q31 — это обычное 16- или 32-битное целое, как только он покидает ваш код, так что вопрос порядка байтов достается ему вместе со всем остальным. Конвертер формата Q показывает целое, стоящее за дробью, и упорядочивают именно это целое.
6. У чисел с плавающей точкой тоже есть порядок байтов
Число с плавающей точкой ничем не выделяется. IEEE 754 задает битовый шаблон, а дальше те же четыре или восемь байт укладываются в том порядке, которого требует формат.
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'
Конвертер IEEE 754 отвечает на первую половину вопроса: введите 3.14159 с выбранным FP32 — и получите 0x40490FD0. Вторая половина — в каком порядке эти четыре байта попадут в файл, и как раз про нее эта статья.
Строка с double 0.1 заодно объясняет, почему 0.1 + 0.2 ведет себя странно. Эти повторяющиеся байты 99 — двоичное разложение, которое никогда не заканчивается; руководство по точности чисел с плавающей точкой разбирает его по частям.
6.1 float 1.0 — это 3f 80 00 00 или 00 00 80 3f
1.0 в FP32 — удобная канарейка, потому что байтовый рисунок у нее сильно перекошен. Big-endian пишет 3f 80 00 00, little-endian — 00 00 80 3f. Сделайте дамп незнакомого двоичного формата, найдите поле, которое точно должно быть 1.0, и два нулевых байта на хвосте скажут, с какого вы конца. Для double работает так же: у того же значения шесть нулевых байт сбиваются в кучу с одной стороны.
7. Порядок байтов в текстовых кодировках: BOM — это просто объявление
UTF-16 и UTF-32 состоят из многобайтовых единиц, поэтому попадают ровно в ту проблему, которую описывает эта статья. Их ответ — дать файлу объявить собственный порядок с помощью метки порядка байтов (BOM):
Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF и есть BOM
Buffer.from('A', 'utf16le').toString('hex'); // '4100'
fffe в начале означает little-endian, feff — big-endian. BOM — самый частый случай, когда формат объявляет свой порядок байтов, а не подразумевает его. Почему UTF-8 не нужен BOM и чем метка оборачивается, когда появляется незваной, разобрано в руководстве по UTF-8, UTF-16 и кодировкам Unicode.
8. Правда ли little-endian быстрее? Что говорят 40 миллионов итераций
Утверждение, что little-endian эффективнее, встречается и в сводках поисковиков, и в большинстве вводных статей на эту тему. Его можно проверить. Сорок миллионов итераций одного 32-битного чтения на этой машине arm64:
| Путь | нс/операцию |
|---|---|
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 |
| Отношение | Множитель |
|---|---|
| DataView big-endian / little-endian | 1.021× |
| Buffer big-endian / little-endian | 3.354× |
Эти два отношения измеряют разные вещи, и привести только второе означало бы повторить ошибку тех самых статей.
Пара DataView — честное измерение стоимости порядка байтов. Оба вызова компилируются в V8 в один и тот же встроенный путь; у варианта big-endian добавляется одна инструкция ARM REV, переставляющая байты. Это и есть 1.021×, примерно 2%. Мало, но не ноль — не округляйте до «бесплатно».
Пара Buffer измеряет совсем другое. В V8 есть выделенный быстрый путь для readUInt32LE, которого readUInt32BE не досталось, так что 3.354× — это разница реализаций внутри одной среды выполнения, а не цена перестановки байтов на процессоре. Приводить это как доказательство медленности big-endian было бы неверно. Смените среду выполнения — и число сменится вместе с ней.
На современном железе преобразование порядка байтов слишком дешево, чтобы ему было место в обсуждении дизайна формата. Исторический аргумент об эффективности пришел из эпохи до появления выделенных инструкций перестановки. Берите тот порядок, который уже используют ваш протокол или ваши соседи.
9. Как отличить ошибку порядка байтов от обычной
Проверить собственную машину — это одна строка: os.endianness() в Node, sys.byteorder в Python. Поисковики печатают ответ прямо над результатами, да и в реальной отладке это чаще всего неверный вопрос: когда разбирают файл или пакет, решает формат, а процессор в этом не участвует. Полезный навык — узнавать симптом.
9.1 Два симптома: абсурдное число и число, промахнувшееся в 256 раз
Громкий распознается легко. Прочитайте ширину PNG из раздела 2 не с того конца — и для изображения шириной 1200 пикселей получится 2 953 052 160. Любое поле, которое должно быть скромным счетчиком, а возвращается в миллиардах, стоит считать перевернутым 32-битным целым, пока не доказано обратное.
Тихий симптом — дорогой. Байты 00 00 01 00 читаются как 256 в big-endian и как 65 536 в little-endian. Оба значения выглядят правдоподобными размерами буфера. Ничего не выбрасывается, ни одна проверка не срабатывает, а значение неверно в 256 раз. Такие баги переживают код-ревью, потому что число на экране выглядит разумно. Это тот же класс, что и UTF-8 BOM, ломающий разбор JSON: невидимая деталь на уровне байтов с обманчивой картиной ошибки. Этому случаю посвящено отдельное руководство по UTF-8 BOM и ошибкам разбора JSON.
Две эвристики стоит держать в голове. Тихо переворачиваются небольшие значения с тремя ведущими нулевыми байтами, потому что оба прочтения остаются в допустимом диапазоне. И если перестановка байтов вручную дает число, в котором есть смысл, ответ уже получен без всякого отладчика.
9.2 В каком порядке проверять
- Сначала — спецификацию формата. RFC 2083 говорит, что PNG big-endian; RFC 1952 §2.3.1 говорит, что GZIP little-endian. Что там делает ваша машина, к обоим не относится.
- Дальше — умолчание вашего читателя.
DataView.getUint32(0)— big-endian,Uint32Array— порядок платформы,struct.pack('@I', ...)— порядок платформы,binary.BigEndian.Uint32— то, что написано. Большинство багов порядка байтов — это забытый третий аргумент или забытый префикс, а не глубокое непонимание. - Платформу подозревайте последней. Она важна, когда файл пишут через
Uint32Arrayили@и отправляют на другую архитектуру, и когда дамп памяти сверяют со спецификацией. И почти никогда не важна, когда читают хорошо описанный формат, который уже сам сказал, какой порядок использует.
Часто задаваемые вопросы
Что лучше — big-endian или little-endian?
Ни big-endian, ни little-endian не лучше. Правильным для формата будет тот порядок, который этот формат задает, а выбирать приходится редко. По производительности: на этой машине чтение big-endian через DataView дало 1.021× от little-endian, примерно 2% — слишком мало, чтобы на этом строить хоть какое-то проектное решение.
Как узнать, какой порядок байтов у моей машины?
os.endianness() в Node возвращает здесь LE, а sys.byteorder в Python — little. Обе однострочные. Правда, вопрос значит меньше, чем кажется: когда разбирают файл или пакет, порядок байтов диктует формат, а у процессора права голоса нет.
DataView и Uint32Array дают разные числа из одного буфера. Это баг?
Нет, это документированное поведение DataView. DataView.getUint32(0) по умолчанию big-endian, а Uint32Array всегда следует за платформой — и на x86, и на Apple Silicon это little-endian. Те же байты, две конвенции. Передайте true третьим аргументом, и DataView согласится.
Почему структура поменяла размер, когда я добавил <?
Потому что вы ушли от @ — умолчания, которое добавляет заполнение под родное выравнивание. struct.calcsize('@ci') равен 8, а struct.calcsize('=ci') и struct.calcsize('<ci') — оба 5. Три байта заполнения перед int ушли вместе с родным выравниванием.
Сетевой порядок байтов — это big-endian или little-endian?
Big-endian. Такова конвенция заголовков TCP/IP, и именно поэтому в C существуют htons и htonl. В Python префиксы ! и > дают одинаковые байты: struct.pack('!I', 0x12345678) и struct.pack('>I', 0x12345678) оба выдают 12 34 56 78.
Влияет ли порядок байтов на UTF-8?
Нет. UTF-8 — это поток байтов, и каждая кодовая точка записывается упорядоченной последовательностью отдельных байтов, так что переставлять нечего: многобайтовой единицы просто не остается. У UTF-16 и UTF-32 такая проблема есть — ровно поэтому они и носят BOM, как разобрано в разделе 7.
Нужно ли обрабатывать порядок байтов для однобайтовых массивов?
Нет. Порядок байтов существует только для единиц шире одного байта, так что Uint8Array, Int8Array и объекты bytes в Python к нему невосприимчивы. Расширьте на один шаг — и он тут же возвращается: new Uint16Array(two)[0] = 0x00ff ложится в память как ff 00 на этой машине.