02.08.2026 129 материалов

Внутренняя кухня памяти в Rust: выравнивание, компоновка и магия атрибута repr

Глубокое погружение в то, как компилятор Rust организует ваши данные в памяти, зачем нужно выравнивание и как атрибут repr берёт управление компоновкой в свои руки.

Внутренняя кухня памяти в Rust: выравнивание, компоновка и магия атрибута repr

Тип в Rust — это не просто метка для переменной, а инструкция для компилятора о том, как именно следует интерпретировать биты в памяти и где их безопасно хранить.

Привет, друзья! Сегодня мы откроем крышку «чёрного ящика» и посмотрим, как Rust на самом деле раскладывает наши данные по памяти. Эта тема может показаться абстрактной, но понимание её — ключ к написанию по-настоящему безопасного и производительного кода, особенно когда дело касается взаимодействия с другими языками или работой с «сырыми» указателями.

Что такое «ответственность типа»?

Давайте начнём с самого фундамента. Каждое значение в Rust имеет тип. И главная работа этого типа — сказать вам, как интерпретировать кусок битов, лежащих в памяти.

Набор битов 0b10111101 сам по себе ничего не значит. Но если сказать компилятору: «Это число без знака u8», — он превратит его в 189. Если же указать тип i8 (число со знаком), те же биты станут -67. Тип буквально задаёт «линзу», через которую мы смотрим на память.

Когда вы создаёте свой собственный тип (например, структуру), компилятор уже решает, как разместить её части в памяти. И здесь на сцену выходит понятие выравнивания.

Выравнивание: почему данные не могут лежать где попало

Теоретически, данные можно было бы хранить в памяти где угодно. Но на практике этому мешает «железо». Компьютерные указатели адресуют не отдельные биты, а байты (8 бит). Поэтому любое значение, к которому мы обращаемся, должно начинаться на границе байта. Это минимальное, «побайтовое» выравнивание, которое обязательно для всех типов.

Однако для многих типов этого мало. Современные процессоры работают с данными не по одному байту, а блоками. Для 64-битного CPU «родной» размер блока — 8 байт. Процессору куда удобнее и быстрее читать 8-байтовое число, если оно начинается по адресу, кратному 8 (т.е. выровнено по 8 байтам).

Представьте, что вы хотите прочитать число i64, которое начинается не в начале 8-байтового блока, а посередине. В этом случае процессору придётся сделать два обращения к памяти: прочитать конец одного блока и начало следующего, а затем склеить результаты. Это очень неэффективно и замедляет работу программы.

Такой доступ называется «невыровненным» (misaligned access). Он чреват не только потерей производительности, но и сложными ошибками при работе с многопоточностью.

Как компилятор справляется с этим хаосом?

Разумеется, программист не должен вручную высчитывать, куда какие байты падающих данных вставить. Компилятор Rust делает это за нас, основываясь на правиле: выравнивание типа обычно совпадает с его размером.

  • u8 выровнен по 1 байту (размер 1 байт).
  • u16 — по 2 байтам.
  • u32 — по 4 байтам.
  • u64 — по 8 байтам.

Для составных типов (структур) выравнивание определяется самым «жадным» полем. Если в структуре есть u64, то вся структура в целом должна быть выровнена по 8 байтам, даже если остальные поля мельче.

Компоновка (Layout) и атрибут repr

Под компоновкой понимается итоговый план, по которому компилятор раскладывает поля типа в памяти, включая добавление заполнения (padding) для соблюдения выравнивания.

По умолчанию компилятор Rust не обещает нам конкретной компоновки. Он свободен менять порядок полей или добавлять заполнение так, как считает нужным для оптимизации. Это хорошо для производительности, но плохо для двух вещей:

  1. Предсказуемости при работе с «сырой» памятью.
  2. Совместимости (interop) с другими языками.

Для решения этих проблем существует атрибут #[repr(...)]. Он говорит компилятору: «Пожалуйста, расположи данные вот так, а не иначе».

#[repr(C)]: ради совместимости с внешним миром

Это самый популярный repr. Буква C означает, что компоновка будет совместима с тем, как C-компилятор разместил бы аналогичную структуру.

Зачем это нужно? Когда ваш Rust-код вызывает функции, написанные на C/C++ (через FFI — Foreign Function Interface), или работает с библиотеками, написанными на этих языках, обе стороны должны точно понимать, как выглядят передаваемые данные в памяти. repr(C) даёт эту предсказуемость. Компоновка C стабильна и документирована, что делает её надёжной основой для unsafe-контекста.

#[repr(transparent)]: прозрачность для newtype-паттерна

Этот атрибут гарантирует, что упаковочный тип (новый тип на основе кортежа, например, struct Wrapper(MyType)) будет иметь ровно такую же компоновку в памяти, как и его внутреннее поле. Это критически важно, когда вы хотите безопасно работать с памятью представления Wrapper как с памятью MyType, и наоборот.

Практический пример: как выглядит заполнение

Давайте разберём классический пример с repr(C), чтобы увидеть, как компилятор добавляет заполнение.

Предположим, у нас есть структура:

#[repr(C)]
struct Foo {
    tiny: bool,      // 1 байт, выровнен по 1 байту
    normal: u32,     // 4 байта, выровнен по 4 байтам
    small: u8,       // 1 байт, выровнен по 1 байту
    long: u64,       // 8 байт, выровнен по 8 байтам
    short: u16,      // 2 байта, выровнен по 2 байтам
}
  1. Кладём tiny (1 байт). Занимает смещение 0.
  2. Кладём normal (4 байта). Но normal должен начинаться по адресу, кратному 4. После tiny мы на смещении 1, а нужно 4. Компилятор добавляет 3 байта заполнения после tiny. Теперь normal лежит по смещению 4 и занимает 4 байта. Итого занято 8 байт (0-7).
  3. Кладём small (1 байт). Занимает смещение 8.
  4. Кладём long (8 байт). long требует выравнивания по 8 байтам. Сейчас после small мы на смещении 9. Компилятор добавляет 7 байт заполнения после small. Теперь long начинается по смещению 16 (что кратно 8) и занимает до 23 смещения.
  5. Кладём short (2 байта). Занимает смещение 24.
  6. Важно: вся структура должна быть выровнена по самому строгому полю — long (8 байт). Текущий размер 26 байт (0-25). Компилятор добавляет 6 байт заполнения в конец, чтобы общий размер стал 32 байта (кратно 8).

Итог: из 16 «полезных» байт (1+4+1+8+2) мы получили 32 байта в памяти. Это цена, которую мы платим за абсолютную производительность и предсказуемость, обеспечиваемые жёстким выравниванием.

Зачем вам это знать?

  1. Производительность. Невыровненный доступ — реальный убийца скорости в низкоуровневом коде.
  2. Интероп. Если вы пишете библиотеку на Rust, которую будут вызывать из C, или наоборот, repr(C) — ваш лучший друг.
  3. Работа с unsafe. Понимание компоновки необходимо, когда вы работаете с указателями и транмутируете типы.
  4. Экономия памяти. Зная правила выравнивания, можно осознанно упорядочивать поля в структуре, чтобы минимизировать заполнение и сэкономить память (это называется «упорядочивание полей для уменьшения padding»).

Rust по умолчанию заботится о вас, пряча эту сложность. Но когда вы спускаетесь на уровень ниже, эти знания становятся фундаментом для написания по-настоящему системного кода. В следующей части мы, возможно, углубимся в более сложные аспекты компоновки. Оставайтесь!