Биты, байты и endianness
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Биты, байты и endianness
Открываем машинный блок. Всё, что хранит и гоняет компьютер, это байты, и Rust даёт к ним честный доступ без неопределённого поведения. Биты, маски, сдвиги, порядок байтов: фундамент, на котором дальше стоят эмулятор, сетевой протокол и блокчейн.
Идея
Семнадцать уроков мы поднимались вверх: от владения к трейтам, от модулей к архитектуре. Теперь разворачиваемся и идём вниз, к машине. Этот блок из шести уроков отвечает на вопросы, которые высокоуровневый код позволяет откладывать годами: как число лежит в памяти, что физически происходит при переполнении, во что компилируется match. Откладывать дальше нельзя: впереди эмулятор процессора, бинарный сетевой протокол и блокчейн, а там байты это не деталь реализации, а сам материал.
Базу ты уже собрал в уроке про биты и кодирование: системы счисления, побитовые операторы, маски. Но там был JavaScript, где любое число превращается в 64-битный float, побитовые операторы тайком конвертируют его в 32 бита и обратно, а взять адрес байта нельзя в принципе. Rust другой: u8, i32, u64 это ровно столько бит, сколько написано, и язык даёт разобрать любое число на байты стандартным методом. Поэтому сегодня мы не повторяем теорию, а переводим её на язык, где она наконец видна руками.
Главная мысль урока укладывается в одно предложение. В памяти нет чисел, строк и структур, есть только байты, а тип это договорённость о том, как эти байты читать:
let bytes: [u8; 4] = [0x00, 0x00, 0x80, 0x3f];
let as_u32 = u32::from_le_bytes(bytes);
let as_f32 = f32::from_le_bytes(bytes);
println!("{as_u32}"); // 1065353216
println!("{as_f32}"); // 1.0
Одни и те же четыре байта. Прочитанные как u32, они дают миллиард с хвостом, прочитанные как f32, ровно единицу. Ни одно прочтение не правильнее другого: байты не знают, чем они были. Когда эта мысль уляжется, с целого пласта тем сойдёт мистика. Сериализация это договорённость, как раскладывать значения в байты. Хеш-функция ест байты, и поэтому от перестановки двух байтов местами хеш блока меняется до неузнаваемости. Дизассемблер читает те же байты как инструкции. Всё это один и тот же навык: видеть за типом его байтовое представление.
Биты, байты и запись чисел
Договоримся о словаре. Бит это 0 или 1, байт это восемь бит, и именно байт минимальная адресуемая единица: у каждого байта в памяти есть адрес, а к отдельному биту можно добраться только операциями над содержащим его байтом. u8 это байт как он есть, u32 это четыре байта, u64 восемь.
Писать значения можно в любой системе счисления, число от этого не меняется:
let a = 0b1010_1100u8; // двоичная запись
let b = 0xACu8; // шестнадцатеричная
let c = 172u8; // десятичная
assert_eq!(a, b);
assert_eq!(b, c);
Подчёркивание внутри литерала это просто разделитель для глаз, компилятор его игнорирует. Шестнадцатеричная запись доминирует в низкоуровневом коде не из снобизма: одна hex-цифра кодирует ровно четыре бита, поэтому байт всегда занимает две цифры и перевод в биты делается в уме посимвольно. 0xAC это 1010 от A и 1100 от C, без столбиков и калькулятора.
Посмотреть на представление числа можно через форматирование:
let x = 172u8;
println!("{x:08b}"); // 10101100
println!("{x:#010b}"); // 0b10101100
println!("{x:#04x}"); // 0xac
Спецификатор 08b означает двоичную запись с дополнением нулями до восьми знаков, решётка добавляет префикс. Эти строчки формата будут мелькать весь блок: дамп регистров эмулятора, отладка протокола, разглядывание float изнутри.
У каждого целого типа есть набор методов, которые отвечают на вопросы о битах напрямую:
let x = 0b1010_1100u8;
assert_eq!(x.count_ones(), 4); // сколько единичных бит
assert_eq!(x.leading_zeros(), 0); // нулей слева
assert_eq!(x.trailing_zeros(), 2); // нулей справа
assert_eq!(x.reverse_bits(), 0b0011_0101);
assert_eq!(x.rotate_left(4), 0b1100_1010);
count_ones известен в литературе как popcount и встречается чаще, чем кажется: расстояние Хэмминга между ключами, плотность битовой карты, вес хеша. На современных процессорах это одна инструкция, и count_ones компилируется ровно в неё.
Побитовые операции и маски
Сами операторы тебе знакомы по уроку про биты, здесь они выглядят почти так же:
let a = 0b1100u8;
let b = 0b1010u8;
assert_eq!(a & b, 0b1000); // И: единица там, где у обоих
assert_eq!(a | b, 0b1110); // ИЛИ: единица там, где хотя бы у одного
assert_eq!(a ^ b, 0b0110); // исключающее ИЛИ: там, где биты различаются
assert_eq!(!a, 0b1111_0011); // НЕ: инверсия всех восьми бит
Два отличия от JavaScript стоят упоминания. Отрицание пишется как !, отдельной тильды в Rust нет, и для bool тот же ! означает логическое НЕ; перепутать не выйдет, типы разные. И главное: операции работают в честной ширине типа. !a для u8 инвертирует ровно восемь бит, никакой скрытой конвертации в 32 бита, как делают побитовые операторы JS.
Рабочая лошадка низкоуровневого кода это маска: число, в котором единицы стоят на интересующих позициях. Четыре канонических приёма, из которых собирается почти всё:
let flags = 0b0000_0101u8;
let n = 3; // позиция бита, нумерация с нуля справа
let set = flags | (1 << n); // установить бит n
let cleared = flags & !(1 << n); // сбросить бит n
let toggled = flags ^ (1 << n); // переключить бит n
let is_set = flags & (1 << n) != 0; // проверить бит n
Каждый приём выводится, а не зубрится. ИЛИ с единицей даёт единицу, ИЛИ с нулём ничего не меняет, поэтому | (1 << n) трогает только бит n. И с нулём даёт ноль, И с единицей сохраняет, поэтому & !(1 << n) гасит ровно один бит. Исключающее ИЛИ с единицей переворачивает. Проверка сравнивает вырезанный бит с нулём.
Пятый приём достаёт из числа не бит, а целое поле. Так декодируют форматы, где в одно слово упаковано несколько значений:
// цвет RGB565: пять бит красного, шесть зелёного, пять синего в одном u16
let pixel: u16 = 0b10110_101100_01011;
let r = (pixel >> 11) & 0b11111; // сдвинуть поле к краю, вырезать маской
let g = (pixel >> 5) & 0b111111;
let b = pixel & 0b11111;
Формула всегда одна: сдвиг вправо подгоняет поле к младшему краю, И с маской отрезает лишнее слева. Обратная операция, сборка поля, это сдвиг влево и ИЛИ. Запомни этот паттерн как следует: в уроке про кодирование битиков ты соберёшь на нём bit reader, а в эмуляторе из него состоит декодер инструкций, где опкод, регистр и непосредственное значение лежат в одном 16-битном слове.
Сдвиги и их острые края
Сдвиг влево << приписывает нули справа, каждый шаг удваивает число. Сдвиг вправо >> интереснее: его поведение зависит от знака типа.
let unsigned = 0b1000_0000u8;
assert_eq!(unsigned >> 2, 0b0010_0000); // слева въезжают нули
let signed = -128i8; // те же биты 1000_0000
assert_eq!(signed >> 2, -32); // слева въезжают единицы, знак сохранён
Для беззнаковых типов работает логический сдвиг, слева появляются нули. Для знаковых арифметический: слева копируется старший бит, и отрицательное число после сдвига остаётся отрицательным. Почему старший бит ведёт себя как знак, разберём в следующем уроке, пока достаточно правила: семантика >> выбирается типом, а не оператором. Хочешь другую, преобразуй тип.
Второй острый край: сдвиг на ширину типа и больше. x << 40 для u32 это не ноль, как подсказывает интуиция, а ошибка переполнения: с константой такой сдвиг даже не скомпилируется, а если величина сдвига вычисляется в рантайме, debug-сборка паникует. Причина в железе: инструкции сдвига x86 сами обрезают величину сдвига, и Rust отказывается молча узаконивать этот сюрприз. Для случаев, когда величина сдвига приходит из данных, есть явные методы checked_shl и wrapping_shl; их семейство во всей красе ждёт в следующем уроке.
Endianness
Теперь вопрос, который не возникает, пока число живёт в регистре, и становится главным, как только оно ложится в память. u32 занимает четыре байта. У каждого байта свой адрес. С какого конца класть?
Два ответа делят мир пополам. Endianness, порядок байтов: little endian кладёт первым младший байт, big endian старший. Число 0x12345678 в памяти выглядит так:
адрес: a a+1 a+2 a+3
little endian: 78 56 34 12
big endian: 12 34 56 78
Big endian читается как пишется, слева направо, и потому кажется естественным. Little endian удобнее машине: младший байт лежит по младшему адресу, поэтому первые байты u64 и его усечённой копии u8 совпадают, и читать число меньшей ширины можно с того же адреса без смещений. Спор о том, какой порядок правильный, старше тебя и не имеет ответа, отсюда и название из Свифта: тупоконечники против остроконечников.
Расклад в современном мире такой. Твой процессор почти наверняка little endian: x86 всегда, ARM в том режиме, в котором работают macOS, Linux и Android. Сеть исторически big endian: заголовки IP и TCP хранят порты и адреса старшим байтом вперёд, поэтому big endian зовут network byte order. Форматы файлов и протоколы выбирают кто во что горазд, и именно на стыках, когда байты покидают твою машину или приходят извне, порядок становится источником классических багов.
Теперь сильная сторона Rust. В C число превращают в байты кастом указателя, и код молча читает их в порядке той машины, на которой запущен: на ноутбуке разработчика работает, на big endian роутере разваливается. В Rust преобразование явное, и порядок байтов записан в имени метода:
let x: u32 = 0x12345678;
assert_eq!(x.to_le_bytes(), [0x78, 0x56, 0x34, 0x12]);
assert_eq!(x.to_be_bytes(), [0x12, 0x34, 0x56, 0x78]);
let from_network = u32::from_be_bytes([0x12, 0x34, 0x56, 0x78]);
assert_eq!(from_network, 0x12345678);
to_le_bytes и to_be_bytes разбирают число в массив [u8; N], from_le_bytes и from_be_bytes собирают обратно. Есть и пара to_ne_bytes с from_ne_bytes, родной порядок текущей машины: ne значит native endian. Для долговременного хранения и сетевых форматов они почти всегда ошибка, файл, записанный в native порядке, прочитается мусором на машине с другим порядком. Их ниша это память внутри одного процесса.
Случайно прочитать число не тем порядком в Rust не получится: метода без суффикса просто нет, выбор приходится проговорить. Это тот же принцип, что и в прошлом уроке: неоднозначность не компилируется. А пригодится выбор очень скоро: формат инструкций эмулятора, varint в протоколе, сериализация блока в блокчейне, везде первым делом договариваются о порядке байтов.
Трюки с битами
Маски и сдвиги складываются в фольклор трюков. Часть из них ты видел в уроке про биты, теперь посмотрим, как они выглядят в Rust и где проходит граница между идиомой и выпендрёжем.
Проверка на степень двойки:
fn is_power_of_two(x: u32) -> bool {
x != 0 && x & (x - 1) == 0
}
У степени двойки ровно один единичный бит. Вычитание единицы превращает этот бит в ноль, а все нули справа в единицы: 1000 минус один это 0111. Исходное число и результат не пересекаются ни в одном бите, И даёт ноль. У любого другого числа старшие биты переживают вычитание, и И не обнуляется. В std трюк уже упакован: x.is_power_of_two(), и в своём коде зови именно метод, имя объясняет намерение лучше формулы.
Выравнивание вверх, постоянный гость аллокаторов и бинарных форматов: округлить размер до кратного степени двойки.
fn align_up(x: usize, align: usize) -> usize {
debug_assert!(align.is_power_of_two());
(x + align - 1) & !(align - 1)
}
assert_eq!(align_up(13, 8), 16);
assert_eq!(align_up(16, 8), 16);
Прибавили почти align, чтобы перевалить за следующую границу, и сбросили младшие биты маской: !(align - 1) для align 8 это все единицы кроме трёх младших. Работает только для степеней двойки, потому и debug_assert. Этот трюк всплывёт в уроке про раскладку памяти, где компилятор тем же способом считает padding в структурах.
Обмен значений через исключающее ИЛИ, легенда собеседований:
let mut a = 0b1100u8;
let mut b = 0b1010u8;
a ^= b;
b ^= a; // b стал старым a
a ^= b; // a стал старым b
Работает, потому что x ^ y ^ y == x: двойное применение исключающего ИЛИ с одним и тем же значением самоуничтожается. Это же свойство, к слову, лежит в основе потоковых шифров, там оно появится всерьёз. А вот сам обмен через XOR в реальном коде не пиши никогда: std::mem::swap яснее, быстрее на современных процессорах и не ломается, когда оба операнда указывают на одно место. Трюк здесь ради свойства, не ради применения.
И самый практичный пункт: битовые флаги, набор булевых признаков в одном числе. Восемь флагов в u8 вместо восьми полей bool:
const FLAG_CARRY: u8 = 1 << 0;
const FLAG_ZERO: u8 = 1 << 1;
const FLAG_NEGATIVE: u8 = 1 << 2;
let mut status = 0u8;
status |= FLAG_ZERO; // установить
status &= !FLAG_CARRY; // сбросить
let zero = status & FLAG_ZERO != 0; // проверить
Не случайный пример: это регистр статуса процессора, и в блоке про эмулятор ты будешь выставлять эти флаги после каждой операции АЛУ. Там, где флагов много и они часть публичного API, в экосистеме берут крейт bitflags, он генерирует типобезопасную обёртку над той же механикой. Но внутри горячего кода и в форматах данных голые маски остаются нормой, поэтому читать их нужно бегло.
Граница уместности проходит просто. Трюк оправдан, когда он в горячем цикле и за ним стоит измерение, или когда он сам и есть предметная область: декодер, аллокатор, криптопримитив. В обычной бизнес-логике выигрыш нулевой, а читателю придётся разворачивать формулу в голове. Правило из урока про итераторы действует и тут: пиши ясно, измеряй, и только потом оптимизируй.
Практика
Битовые операции не выучиваются глазами, только пальцами. Две задачи прямо в браузере: первая гоняет четыре канонических приёма с масками на регистре флагов, вторая просит написать popcount и реверс байтов руками, без встроенных методов, чтобы внутренности count_ones и swap_bytes перестали быть магией.
ДЗ
Дальше
Байты разобраны, но самый интересный из них остался недообъяснённым: старший бит знакового числа, который при арифметическом сдвиге ведёт себя как знак. В следующем уроке разберём дополнительный код: почему минус один это все единицы, что физически происходит при переполнении и зачем у каждой целочисленной операции в Rust четыре версии.