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

Привет, Хабр!
Если вы пишете код для систем с ограниченными ресурсами, или просто хотите держать в голове не только логическую, но и физическую модель своей программы — вам необходимо понимать, как именно компилятор размещает данные в памяти.
В этой статье рассмотрим, как:
выравнивание и порядок полей влияют на размер
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 и другим физическим интерфейсам.
Для сериализации важно, чтобы:
Поля структуры не содержали паддингов.
Порядок полей был зафиксирован.
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, нужно явно контролировать:
Выравнивание — через
#pragma pack
,alignas
,attribute((packed))
(GCC/Clang) или__declspec(align())
(MSVC).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 прочитано 13962 раза