16 байт вместо 32: управляем layout'ом в C++

24df5f8141801121de3db475c6543b04.jpg

Привет, Хабр!

Если вы пишете код для систем с ограниченными ресурсами, или просто хотите держать в голове не только логическую, но и физическую модель своей программы — вам необходимо понимать, как именно компилятор размещает данные в памяти.

В этой статье рассмотрим, как:

  • выравнивание и порядок полей влияют на размер struct

  • использовать bitfield, alignas, offsetof, [[no_unique_address]]

  • добиться нужного layout без паддингов

  • сверить теорию с практикой с помощью clang -fdump-record-layouts

  • и уплотнить структуру до 16 байт без компромиссов

Что хотим получить

Нужно описать структуру, содержащую:

  • 3 поля int32_t

  • 5 логических флагов (bool или аналоги)

  • 2 enum‑поля: одно с размером uint8_t, второе — uint16_t

Цель: чтобы sizeof(struct) был не больше 16 байт, при этом структура оставалась строго типизированной и легко читаемой.

Проблема: паддинги и выравнивание

Стандарт C++ требует, чтобы каждый элемент структуры был выровнен в памяти согласно своим требованиям alignof(T). Это значит, что компилятор может вставлять неявные байты между полями.

Простейший пример:

struct BadLayout {
    int32_t x;
    bool flag1;
    int32_t y;
};

Размер этой структуры не 9 байт, как можно было бы наивно ожидать, а 12, из‑за выравнивания int32_t на границу 4 байт. Между flag1 и y — 3 байта паддинга.

В реальных структурах это приводит к значительному росту sizeof, особенно если bool, char, enum : uint8_t перемежаются с int, double и другими крупными типами.

Исходный пример

enum class Mode : uint8_t { A, B, C };
enum class Type : uint16_t { T1, T2, T3 };

struct Naive {
    int32_t x;
    int32_t y;
    int32_t z;

    bool flag1;
    bool flag2;
    bool flag3;
    bool flag4;
    bool flag5;

    Mode mode;
    Type type;
};

На большинстве платформ x86_64 размер этой структуры будет:

static_assert(sizeof(Naive) == 32);

И это при том, что чисто логически в ней нет ничего, что нельзя было бы уложить в 16 байт. Потери — исключительно на выравнивании и порядке полей.

Порядок полей: оптимизация без изменения семантики

Первое, что можно сделать без изменений типов — это переставить поля так, чтобы сначала шли самые выровненные.

struct Sorted {
    int32_t x;
    int32_t y;
    int32_t z;

    Type type;
    Mode mode;

    bool flag1;
    bool flag2;
    bool flag3;
    bool flag4;
    bool flag5;
};

Проверим:

static_assert(sizeof(Sorted) == 24);

Уже лучше, но не идеально. bool'ы по‑прежнему занимают лишние байты и вставляют паддинги после mode.

Используем bitfield

Переводим флаги в битовое поле:

struct Flags {
    uint8_t flag1 : 1;
    uint8_t flag2 : 1;
    uint8_t flag3 : 1;
    uint8_t flag4 : 1;
    uint8_t flag5 : 1;
};

Размер:

static_assert(sizeof(Flags) == 1);

Если вы используете uint8_t как базовый тип — размер строго ограничен одним байтом (или максимум двумя, если компилятор решит выровнять).

Теперь соберём всё в один layout.

Финальный вариант

#include 
#include 

enum class Mode : uint8_t { A, B, C };
enum class Type : uint16_t { T1, T2, T3 };

struct Flags {
    uint8_t flag1 : 1;
    uint8_t flag2 : 1;
    uint8_t flag3 : 1;
    uint8_t flag4 : 1;
    uint8_t flag5 : 1;
};

struct alignas(4) Compact {
    int32_t x;
    int32_t y;
    int32_t z;

    Type type;
    Mode mode;
    Flags flags;
};

static_assert(sizeof(Compact) == 16);

Этот layout на 64-битной платформе занимает ровно 16 байт.

Контроль через offsetof

Проверим смещения:

#include 
#include 

int main() {
    std::cout << "Offset x: " << offsetof(Compact, x) << '\n';
    std::cout << "Offset y: " << offsetof(Compact, y) << '\n';
    std::cout << "Offset z: " << offsetof(Compact, z) << '\n';
    std::cout << "Offset type: " << offsetof(Compact, type) << '\n';
    std::cout << "Offset mode: " << offsetof(Compact, mode) << '\n';
    std::cout << "Offset flags: " << offsetof(Compact, flags) << '\n';
}

Вывод:

Offset x: 0
Offset y: 4
Offset z: 8
Offset type: 12
Offset mode: 14
Offset flags: 15

Подтверждает, что паддингов нет.

Проверка layout«а через Clang

clang++ -Xclang -fdump-record-layouts compact.cpp

Фрагмент вывода:

0 | int x
4 | int y
8 | int z
12 | Type type
14 | Mode mode
15 | Flags flags

Выравнивание alignas(4) гарантирует, что struct остаётся кратной 4 байтам, а не выравнивается до 8.

Альтернатива: struct + union для сериализации

Если нужно иметь представление как массив байт, можно добавить union:

union PackedCompact {
    Compact value;
    uint8_t raw[16];
};

static_assert(sizeof(PackedCompact) == 16);

Теперь raw можно напрямую отправлять по сети или записывать в файл. Главное — убедиться, что endianness и layout фиксированы (например, с static_assert(offsetof(...)) на нужных платформах).

Сериализация структуры в бинарный протокол

Когда структура уплотнена до фиксированного размера и все поля находятся в предсказуемом порядке, напрашивается следующий шаг — использовать её как часть бинарного протокола. Это может быть:

  • внутриигровой сетевой протокол;

  • межпроцессное взаимодействие через shared memory;

  • запись бинарного файла (например, заголовок или индекс);

  • передача по SPI, UART и другим физическим интерфейсам.

Для сериализации важно, чтобы:

  1. Поля структуры не содержали паддингов.

  2. Порядок полей был зафиксирован.

  3. Endianness был задан явно (если работаете на разнородных архитектурах).

Пример структуры с сериализацией:

#pragma pack(push, 1)
struct NetworkStruct {
    uint32_t x;
    uint32_t y;
    uint32_t z;

    uint16_t type;
    uint8_t mode;
    uint8_t flags; // 5 бит флагов + 3 бита можно зарезервировать
};
#pragma pack(pop)

static_assert(sizeof(NetworkStruct) == 14, "Unexpected size");

Здесь #pragma pack(1) отключает выравнивание. Это — решение, приближённое к системному коду. Подходит для передачи в сеть или запись в файл.

Сериализация и десериализация в байтовый буфер

Передача по сети:

uint8_t buffer[14];
NetworkStruct data = {100, 200, 300, 1, 2, 0b00011101};
memcpy(buffer, &data, sizeof(NetworkStruct));
send(socket, buffer, sizeof(buffer), 0);

Обратная операция:

NetworkStruct result;
memcpy(&result, buffer, sizeof(NetworkStruct));

memcpy безопасен только если вы уверены, что:

  • архитектура та же;

  • выравнивание не нарушено;

  • структура не содержит внутренних указателей или ссылок;

  • порядок байтов (endianness) не отличается.

Проблема кросс-платформенного layout«а

Если код компилируется под ARM и x86_64, нужно явно контролировать:

  1. Выравнивание — через #pragma pack, alignas, attribute((packed)) (GCC/Clang) или __declspec(align()) (MSVC).

  2. Endian‑порядок — little‑endian на x86, но возможен big‑endian на некоторых embedded ARM.

Решение: явная сериализация по байтам

void serialize(const NetworkStruct& src, uint8_t* dst) {
    dst[0] = src.x & 0xFF;
    dst[1] = (src.x >> 8) & 0xFF;
    dst[2] = (src.x >> 16) & 0xFF;
    dst[3] = (src.x >> 24) & 0xFF;
    // повторить для y, z, type, mode, flags
}

Есть альтернативы:

  • std::bit_cast + htonl/ntohl, начиная с C++20

  • сторонние библиотеки (e.g. FlatBuffers, Cap«n Proto, Protocol Buffers)

  • constexpr‑сериализаторы

Как гарантировать layout во время компиляции

Используем static_assert с offsetof, alignof, sizeof.

Пример:

static_assert(offsetof(NetworkStruct, x) == 0);
static_assert(offsetof(NetworkStruct, y) == 4);
static_assert(offsetof(NetworkStruct, z) == 8);
static_assert(offsetof(NetworkStruct, type) == 12);
static_assert(sizeof(NetworkStruct) == 14);

Можно сделать даже constexpr‑функцию, которая валидирует layout:

template
constexpr bool validate_layout() {
    return sizeof(T) == 14 &&
           offsetof(T, x) == 0 &&
           offsetof(T, y) == 4 &&
           offsetof(T, z) == 8 &&
           offsetof(T, type) == 12;
}
static_assert(validate_layout(), "Layout mismatch");

Если вам близка тема управления памятью и вы хотите глубже разобраться в том, как именно C++ хранит и перемещает данные, — приходите 15 апреля на открытый урок «Плоские контейнеры и С++: что, зачем, почему и как?».

Разберёмся, в чём реальное преимущество flat‑структур, где они оправданы, а где — избыточны, как они соотносятся с cache‑friendly подходами и зачем всё это знать разработчику, который пишет код не «в учебник», а в прод. Записывайтесь на странице курса «C++ Developer».

Habrahabr.ru прочитано 13911 раз