В этой статье мы поговорим о понятиях big-endian и little-endian в computer science.
Эта запись также доступна в канале Telegram “DEV: Рубиновые тона”, а обсудить же эту тему можно в нашем чате Telegram.
Дом, который построил Свифт
Частенько в руководствах и документации можно встретить термины big-endian и little-endian — да хотя бы в статье про кодировки UTF8 и ASCII. Но что эти понятия вообще значат? На самом деле, всё довольно просто: это буквально война тупоконечников и остроконечников (я серьёзно).
Когда-то давно Джонатан Свифт написал роман “Путешествия Гулливера” — помните такой? Там была история, что, дескать, изначально варёные яйца лилипуты разбивали с тупого конца, но потом один из императоров умудрился себе порезать руку за завтраком, очищая яичко, после чего был издан указ: разбивать яйца только с острого конца, иначе будут применяться санкции.
Это привело к кровавым бунтам, тысячи человек пошли на казнь, так как, следуя заветам предков, всё равно разбивали яйца с тупого конца. По этой теме писались книги (хотя тупоконечную литературу запрещали), устраивались диспуты, и так далее, хотя в главном трактате некоего пророка было написано просто “разбивайте так, как вам больше нравится”. Скажем прямо, в реальной жизни подобный идиотизм тоже встречается частенько. Это, конечно, едкая сатира, но она напрямую связана с нашим вопросом.
В оригинале “тупоконечники” — это “big endians”, а их противники — “little endians”. Отсюда и пошли эти термины; впервые в далёком 80-м году их употребил Дэнни Коэн, один из участников проекта ARPANet. Это была во многом шутка, но в итоге словечки прижились.
Порядок следования байтов
Предположим, у нас есть шестнадцатеричное число 0x12345678, и оно размещается в памяти, начиная с адреса 0x100 (про организацию памяти можно глянуть стрим). Каждая двойка шестнадцатеричных чисел (12, 34 и так далее) занимает по 8 бит, потому что каждое число hex занимает 4 бита: 0x0 = 0b0000, 0xf = 0b1111. Следовательно, для хранения этого числа потребуется 4 байта или 32 бита (это называется “слово”, “word”; хотя вообще размер слова зависит от архитектуры). Если принять, что каждый адрес в памяти указывает на 8 бит информации, то наше число займёт адреса с 0x100 по 0x103. Но вопрос в том, как эти пары там разместить: справа налево или слева направо?
Можно сделать так:
100 101 102 103
12 34 56 78
Но можно сделать и так:
100 101 102 103
78 56 34 12
То есть сначала может идти “самая значимая” пара (это 0x12), а может, наоборот, наименее значимая (0x78). Казалось бы, выбор очевиден, но можно вспомнить хотя бы о том, что некоторые народы читают справа налево, а кто-то вообще пишет столбиком. Так что, на самом деле, всё не так однозначно™.
Поэтому подход, когда наиболее значимый байт идёт на первой позиции, называется “big endian” или BE (большинству из нас это куда привычнее), а “little endian” или LE — это когда сначала идёт наименее значимый байт. В разных машинах может задействоваться один из двух подходов, к примеру, многие устройства (но не все) от Oracle используют режим big endian. Впрочем, есть чипы вида bi-endian, которые умеют работать в обоих режимах.
Порядок байтов в оперативной памяти прежде всего зависит от архитектуры процессора. Например, процессоры семейства x86 используют little-endian, а некоторые другие архитектуры могут поддерживать оба варианта. Поэтому современные компьютеры с Windows, а также большинство смартфонов с Android и iOS работают в режиме little-endian.
Во многих сетевых протоколах, например IPv4 и TCP, для многобайтовых числовых полей принят порядок big-endian. Его часто называют network byte order. Однако это не универсальное правило для всех протоколов.
Пример работы с big-endian и little-endian на Go
Посмотрим, как порядок байтов влияет на интерпретацию числа. Для этого воспользуемся стандартным пакетом Go encoding/binary, который позволяет читать и записывать целые числа в разных порядках.
package main
import (
"encoding/binary"
"fmt"
)
func main() {
data := []byte{0x12, 0x34, 0x56, 0x78}
big := binary.BigEndian.Uint32(data)
little := binary.LittleEndian.Uint32(data)
fmt.Printf("Исходные байты: % X\n", data)
fmt.Printf("Big-endian: 0x%08X\n", big)
fmt.Printf("Little-endian: 0x%08X\n", little)
}
Результат выполнения:
Исходные байты: 12 34 56 78
Big-endian: 0x12345678
Little-endian: 0x78563412
Обратим внимание: содержимое массива data не изменилось. Мы лишь указали Go, в каком порядке следует интерпретировать байты при преобразовании их в 32-битное целое число. В первом случае 0x12 считается старшим байтом, а 0x78 — младшим. Во втором случае всё наоборот.
При этом результат не зависит от того, на каком процессоре выполняется программа. Пакет encoding/binary явно задаёт порядок байтов, поэтому мы получим одинаковые значения как на архитектуре little-endian, так и на big-endian.
Есть ли принципиальная разница?
Сами по себе оба порядка байтов равноправны: ни один не делает числа более правильными. Однако при обмене бинарными данными важно, чтобы отправитель и получатель одинаково интерпретировали их порядок. Иначе одно и то же содержимое памяти может быть прочитано как совершенно разные числа.
С порядком байтов можно столкнуться и при работе с Ethereum. Например, при ABI-кодировании целые числа представляются в big-endian, но правила размещения переменных в storage устроены сложнее: несколько небольших значений могут упаковываться в один 256-битный слот.
Интересно, что сказал бы по этому поводу Свифт…