sizeof структуры в C и C++ почти всегда больше суммы размеров её полей. Компилятор вставляет padding, чтобы каждое поле начиналось с адреса, кратного своему выравниванию, а вся структура занимала число байт, кратное выравниванию самого строгого поля. Массив внутри структуры не добавляет собственного выравнивания: int[4] выравнивается как один int, char[16] - как один char.
Для структуры с полями char tag и int values[4] на типовой x86-64 ABI поля занимают 17 байт, а между tag и началом массива остаётся 3 байта padding. Итоговый sizeof зависит от ABI и компилятора, поэтому проверяйте его на своём тулчейне командами из последнего раздела. В массиве из миллиона таких структур лишние байты складываются в мегабайты памяти, которые никто не планировал выделять.
Ниже пошаговый расчёт sizeof для структур с массивами фиксированной длины, разбор атрибутов packed и aligned, последствия невыровненного доступа на x86-64, ARM64 и ARMv7, а также команды и static_assert для проверки layout. Числа приведены для типовых ABI: System V AMD64 (Linux x86-64) и AAPCS64 (ARM64), где char выравнивается по 1 байту, uint32_t по 4, uint64_t и указатели по 8. Значения для конкретного тулчейна проверяйте командами из последнего раздела: раскладка зависит от компилятора, его версии и флагов.
Почему sizeof структуры с массивом больше, чем сумма полей
Padding - это неиспользуемые байты, которые компилятор оставляет между полями и в конце структуры. В исходном коде их не видно, но они попадают в дамп памяти, в размер аллокации и в сетевой трафик, если структуру отправляют целиком.
Правила размещения задаёт ABI платформы, а не стандарт языка. Для Linux x86-64 это System V AMD64 ABI, для ARM64 - AAPCS64. Оба документа требуют, чтобы адрес поля был кратен его выравниванию, а размер структуры - кратен выравниванию самого строгого поля. Windows x86-64 (MSVC) использует похожие правила для встроенных типов, но отличается в деталях для вложенных структур и отдельных типов.
Как выравнивание поля задаёт позицию следующего поля
Компилятор идёт по полям сверху вниз и держит текущее смещение. Перед каждым полем он поднимает смещение до ближайшего числа, кратного выравниванию поля.
struct Sample {
char tag; /* размер 1, выравнивание 1 */
int values[4]; /* элементы по 4 байта, выравнивание 4 */
};
tag получает смещение 0. Следующее поле требует кратности 4, поэтому компилятор пропускает байты 1, 2 и 3, а values[0] ставит на смещение 4.
| Что | Размер | Выравнивание | Смещение |
|---|---|---|---|
| tag | 1 | 1 | 0 |
| padding | 3 | - | 1-3 |
| values[0] | 4 | 4 | 4 |
| values[1] | 4 | 4 | 8 |
| values[2] | 4 | 4 | 12 |
| values[3] | 4 | 4 | 16 |
| итого sizeof | 20 | 4 | - |
Длинный char-буфер в середине структуры не защищает от padding и сам в нём не нуждается: массив наследует выравнивание элемента, а не становится «более строгим» полем. Именно поэтому смещение массива всегда кратно размеру одного элемента.
Содержимое байтов padding не определено, и компилятор не обязан их обнулять. Сравнивать структуры через memcmp или хешировать их целиком нельзя: два логически одинаковых объекта дадут разные байты. То же правило действует при отправке структуры по сети и при записи её в лог.
Приёмы перестановки полей и выравнивание по границе кэш-линии, которые убирают лишние байты в массивах структур, разобраны в гайде Выравнивание данных в памяти и padding в массивах: как раскладка структур влияет на память и скорость.
Почему размер структуры кратен выравниванию самого строгого поля
Второе правило - trailing padding. Компилятор округляет размер структуры вверх до кратного максимальному выравниванию среди её полей.
struct Pair {
int a; /* 4 байта, выравнивание 4 */
char b; /* 1 байт, выравнивание 1 */
};
Поля занимают 5 байт, а sizeof(Pair) равен 8: три байта в хвосте. Причина в массивах структур. Элемент такого массива должен начинаться с адреса, кратного 4, иначе int во втором элементе окажется невыровненным. Шаг массива равен sizeof, значит sizeof обязан делиться на 4.
Масштаб потерь считайте умножением: 3 байта хвоста на количество элементов. Миллион структур Pair занимает 8 МБ вместо 5 МБ полезных данных. На счётчиках и метриках, где экземпляров десятки миллионов, хвостовой padding превращается в десятки мегабайт.
Пошаговый расчёт sizeof для структур с массивами фиксированной длины
Пять шагов, которые дают точный ответ для любой структуры без packed-атрибутов:
- Выпишите поля, их размеры и выравнивания. Для массива берите размер и выравнивание одного элемента: uint32_t payload[4] - шаг 4 байта, выравнивание 4.
- Поставьте первое поле на смещение 0.
- Для каждого следующего поля поднимите текущее смещение до ближайшего кратного его выравниванию и зафиксируйте offset. Пропущенные байты и есть padding.
- Прибавьте размер последнего поля: получится полезный конец структуры.
- Округлите результат вверх до максимального выравнивания среди полей. Полученное число и есть sizeof.
Проверить расчёт можно компилятором: gcc -Wpadded предупредит о каждом вставленном padding, а clang -Xclang -fdump-record-layouts покажет готовую таблицу смещений.
Пример 1: структура сетевого заголовка с массивом payload
struct Packet {
uint8_t type; /* offset 0 */
uint32_t payload[4]; /* offset 4, 8, 12, 16 */
uint16_t crc; /* offset 20 */
};
| Что | Размер | Выравнивание | Смещение |
|---|---|---|---|
| type | 1 | 1 | 0 |
| padding | 3 | - | 1-3 |
| payload[0] | 4 | 4 | 4 |
| payload[1] | 4 | 4 | 8 |
| payload[2] | 4 | 4 | 12 |
| payload[3] | 4 | 4 | 16 |
| crc | 2 | 2 | 20 |
| padding | 2 | - | 22-23 |
| итого sizeof | 24 | 4 | - |
Полезных байт 19, служебных 5 - почти 21% размера структуры. Перестановка полей даёт 20 байт: сначала массив, затем crc, затем type, хвост 1 байт. Экономия 4 байта на каждой структуре; в кольцевом буфере на 100 000 пакетов это 400 КБ.
struct PacketBetter {
uint32_t payload[4]; /* offset 0, 4, 8, 12 */
uint16_t crc; /* offset 16 */
uint8_t type; /* offset 18 */
/* padding: 1 байт в хвосте, sizeof = 20 */
};
Зафиксировать результат можно уже на этапе компиляции:
_Static_assert(sizeof(struct Packet) == 24, "Packet layout changed");
Пример 2: структура конфига с char-массивом и uint64_t
struct Config {
char name[32]; /* offset 0, выравнивание 1 */
uint8_t flags; /* offset 32 */
uint64_t id; /* offset 40: байты 33-39 - padding */
};
| Что | Размер | Выравнивание | Смещение |
|---|---|---|---|
| name[0..31] | 32 | 1 | 0-31 |
| flags | 1 | 1 | 32 |
| padding | 7 | - | 33-39 |
| id | 8 | 8 | 40 |
| итого sizeof | 48 | 8 | - |
Сумма полей 41, sizeof 48, потери 7 байт (17%). Перестановка id на первое место размер не меняет: id занимает 0-7, name 8-39, flags встаёт на 40, и результат всё равно округляется до 48. Выигрыш появляется там, где внутренний padding больше хвостового округления. Пример: структура с uint8_t unit, uint64_t value и uint16_t code занимает 24 байта, а те же поля в порядке value, code, unit - 16 байт. На 10 миллионах экземпляров разница достигает 80 МБ.
Проверка смещения в рантайме:
#include <stddef.h>
#include <stdio.h>
int main(void) {
printf("id offset = %zu\n", offsetof(struct Config, id));
printf("sizeof = %zu\n", sizeof(struct Config));
return 0;
}
Вывод: 40 и 48. Те же числа должны совпадать на всех платформах, где структура участвует в обмене данными.
Проверка результата через offsetof и static_assert
Расчёт в голове нужен один раз. Чтобы layout не поехал после смены компилятора, флагов или платформы, зафиксируйте его в коде:
#include <stddef.h> _Static_assert(offsetof(struct Packet, crc) == 20, "crc offset changed"); _Static_assert(sizeof(struct Packet) == 24, "Packet size changed"); _Static_assert(offsetof(struct Config, id) == 40, "id offset changed"); _Static_assert(sizeof(struct Config) == 48, "Config size changed");
В C++ используйте static_assert с теми же аргументами, а в C11 доступен _Static_assert из stddef.h. Макрос offsetof работает только для standard-layout типов: виртуальные функции, разные уровни доступа к членам и нестандартные базовые классы делают его неприменимым. Проверить принадлежность к standard-layout можно через std::is_standard_layout.
Такие assert срабатывают на этапе компиляции и ломают сборку, а не прод. Для структуры, которая уходит в бинарный протокол или в разделяемую память, это ровно то поведение, которое нужно.
Атрибуты packed и aligned: когда упаковка памяти оправдана
Раскладкой управляют три инструмента: __attribute__((packed)) убирает padding, __attribute__((aligned(N))) добавляет выравнивание, #pragma pack делает упаковку через директиву препроцессора.
packed для бинарных протоколов и сериализации
struct __attribute__((packed)) WireHeader {
uint8_t type; /* offset 0 */
uint32_t seq; /* offset 1 */
uint16_t len; /* offset 5 */
}; /* sizeof = 7 */
Размер совпадает с форматом на проводе: 7 байт без единого служебного байта. Цена - невыровненные поля seq и len, поэтому читайте их через memcpy, а не через приведение указателя на буфер:
uint32_t seq; memcpy(&seq, buf + 1, 4); /* безопасно и переносимо */
Для packed-поля компилятор генерирует доступ по байтам или несколько инструкций вместо одной. Копирование в локальную переменную позволяет собрать значение одной выровненной загрузкой. Упаковывайте только структуры, описывающие внешний формат: заголовки протоколов, записи в файле, элементы сетевого кадра.
Порядок байт при этом остаётся порядком байт вашей платформы. При обмене между разными архитектурами значения приводите через htonl и ntohl, а поле длины храните в фиксированной ширине, а не в size_t.
aligned для кэш-линий и разделяемой памяти
struct __attribute__((aligned(64))) PerCpuCounter {
uint64_t hits;
uint64_t misses;
}; /* sizeof = 64 */
Поля занимают 16 байт, но структура растянута до 64: счётчики разных ядер не делят одну кэш-линию. Размер линии проверяется командой:
getconf LEVEL1_DCACHE_LINESIZE
Значение зависит от конкретного процессора, и обобщать его на все x86 нельзя: 64 байта - распространённый, но не универсальный размер кэш-линии. Подставляйте в aligned(N) результат getconf на целевой машине, а не жёстко зашитое число. В C++11 вместо атрибута используйте стандартный alignas(64).
#pragma pack и переносимость между компиляторами
#pragma pack(push, 1)
struct WireHeaderLegacy {
uint8_t type;
uint32_t seq;
uint16_t len;
};
#pragma pack(pop)
Директива понятна MSVC, GCC и Clang, поэтому её часто применяют в библиотеках, собираемых разными тулчейнами. Различия остаются: вложенные структуры, битовые поля и смешанные атрибуты обрабатываются по-разному, а на MSVC настройку упаковки можно перекрыть ключом /Zp.
Выберите один механизм на проект и не смешивайте его с другим в одном заголовке. Упакованные структуры нарушают требования ABI, поэтому передавать их между модулями, собранными разными компиляторами, рискованно. static_assert на sizeof и offsetof в общем заголовке снимает половину сюрпризов.
Последствия невыровненного доступа на x86-64 и ARM
Результат чтения uint32_t из невыровненного адреса зависит от архитектуры: на x86-64 это замедление, на ARM64 замедление или отказ для отдельных операций, на ARMv7 часто SIGBUS.
x86-64: когда невыровненный доступ просто медленный
Процессоры x86-64 аппаратно обрабатывают невыровненные загрузки и сохранения. Штраф появляется, когда обращение пересекает границу 64-байтовой кэш-линии: вместо одной загрузки ядро выполняет две. Второй эффект - компилятор может разбить обращение на несколько инструкций, если не знает, что адрес выровнен.
perf stat -e cycles,instructions ./bench
Такой замер показывает разницу между вариантом с packed и вариантом с обычным выравниванием на вашем стенде. Атомарные операции к невыровненным адресам применять нельзя: std::atomic требует выравнивания по размеру типа, поэтому компилятор вправе заменить одну инструкцию на вызов рантайма или на последовательность с блокировкой. Для поля внутри packed-структуры атомарность обычно выносят в отдельную выровненную переменную.
ARM64 и ARMv7: где невыровненный доступ приводит к SIGBUS
На ARM64 обычные LDR и STR допускают невыровненные адреса: Linux работает с выключенной проверкой выравнивания (бит A в SCTLR). Ограничения начинаются там, где задействованы специальные инструкции и атомарность:
- эксклюзивный доступ LDXR и STXR, на котором построены атомарные примитивы, требует адреса, кратного размеру операнда;
- инструкции LSE-расширения и часть SIMD-загрузок рассчитаны на выровненные адреса;
- для атомарного доступа к packed-полю компилятор генерирует вызов функции рантайма вместо одной инструкции.
На ARMv7 ситуация строже, и код, который годами работал на x86-64, падает при первом запуске на устройстве. Одиночные LDR и STR выдерживают невыровненные адреса, а LDRD и STRD, блочные LDM и STM и работа с 64-битными значениями требуют выравнивания. С включённым битом SCTLR.A отказывает любой невыровненный доступ. Снаружи это выглядит как SIGBUS внутри глубоко встроенной функции или в memcpy из библиотеки.
Проверяйте на целевой архитектуре: сборка под ARMv7 через qemu-user ловит такие падения без физического железа.
qemu-arm -L /usr/arm-linux-gnueabihf ./test_packed qemu-aarch64 -L /usr/aarch64-linux-gnu ./test_packed
Как поймать проблему до продакшена: UBSan и тесты на ARM
Clang и GCC умеют вставлять проверки выравнивания:
clang -fsanitize=alignment -fsanitize=undefined -g test.c -o test && ./test
UBSan останавливается ровно на месте невыровненного доступа и печатает адрес, тип и функцию. Флаг -fsanitize=undefined включает проверку alignment в составе набора, отдельный -fsanitize=alignment полезен, когда остальные проверки отключены. Прогон тестов с этой опцией находит проблему до того, как бинарник попадёт на ARM-стенд. Второй обязательный шаг - те же тесты на ARM, потому что x86-64 прощает то, на чём падает ARMv7.
Практика: где padding в структурах бьёт по продакшену
Массив структур: как padding умножается на миллионы элементов
struct Reading {
uint8_t flag; /* offset 0 */
uint64_t value; /* offset 8, байты 1-7 - padding */
}; /* sizeof = 16 */
Полезных данных 9 байт из 16, потери 44%. Массив из 10 миллионов элементов занимает 160 МБ вместо 90 МБ. Формула для быстрой оценки: перерасход = (sizeof(структура) - сумма размеров полей) * количество элементов. В packed-варианте размер равен 9 байтам, то есть 90 МБ, но каждое чтение value становится невыровненным. Если структура лежит в больших массивах и обходится целиком, выигрывает упаковка; если к полям идёт частый доступ, выигрывают выравнивание и перестановка полей. Дополнительные приёмы есть в материале о выравнивании данных в памяти и padding в массивах.
Как раскладка структуры влияет на скорость обхода больших массивов, разбираем в статье Кэш-локальность массивов: как порядок обхода влияет на производительность.
Shared memory и mmap: почему layout должен совпадать
Если два процесса читают одну структуру из mmap-области, раскладка должна совпадать байт в байт. Флаг -fpack-struct, разный порядок полей или разные версии заголовка дают чтение мусора без единой ошибки компиляции. Что делать:
- храните определения общих структур в одном заголовке и берите типы фиксированной ширины: uint32_t, uint64_t вместо long;
- добавьте в заголовок static_assert на sizeof и offsetof каждого поля;
- заложите поле версии в начало структуры и проверяйте его при подключении к области;
- инициализируйте структуру memset перед публикацией: padding-байты содержат остатки предыдущих данных и утекают наружу при отправке структуры целиком.
False sharing: когда padding спасает, а не мешает
Два атомарных счётчика в одной 64-байтовой линии заставляют ядра по очереди инвалидировать кэш друг друга, и производительность падает в разы.
struct alignas(64) PaddedCounters {
alignas(64) std::atomic<uint64_t> a; /* offset 0 */
alignas(64) std::atomic<uint64_t> b; /* offset 64 */
}; /* sizeof = 128: каждый счётчик на своей линии */
Здесь выравнивание экономит такты, а не память. Искать такие места помогает perf c2c:
perf c2c record -a -- sleep 10 perf c2c report
Чек-лист: проверяем layout структуры на своём стеке
Команды для GCC и Clang: -Wpadded, pahole, objdump
gcc -Wpadded -c packet.c clang -Xclang -fdump-record-layouts -fsyntax-only packet.cpp gcc -g -c packet.c && pahole -C Packet packet.o objdump -d ./packet | less
Первая команда печатает предупреждения о вставленном padding. Вторая выводит таблицу смещений из компилятора. pahole читает DWARF-информацию и показывает раскладку с дырами, для этого объектный файл или бинарник собирается с -g. Через objdump смотрите, во что превратился доступ к полю: одна загрузка или несколько инструкций по байтам.
Пример вывода pahole для структуры Reading:
struct Reading {
uint8_t flag; /* 0 1 */
/* XXX 7 bytes hole, try to pack */
uint64_t value; /* 8 8 */
};
Встраиваем проверки в CI: static_assert и UBSan
CFLAGS += -Wall -Wextra -Werror=padding
UBSAN = -fsanitize=alignment -fsanitize=undefined -fno-sanitize-recover=all
build-ubsan:
$(CC) $(CFLAGS) $(UBSAN) -g test_layout.c -o test_layout
test-layout: build-ubsan
./test_layout
-Werror=padding превращает предупреждение о новых дырах в падение сборки, а -fno-sanitize-recover=all останавливает тест на первом невыровненном доступе. Шаг с UBSan гоняйте и на x86-64, и на ARM: в CI это отдельная job с ARM-раннером или с qemu-user.
Чек-лист из семи пунктов:
- Соберите проект с -Wpadded и прочитайте предупреждения: они перечисляют каждую дыру между полями.
- Проверьте layout ключевых структур через pahole -C StructName ./binary после сборки с -g.
- Зафиксируйте sizeof и offsetof всех полей структуры через static_assert в заголовке.
- Для packed-структур читайте поля через memcpy, не через приведение указателя.
- Прогоните тесты с -fsanitize=alignment и -fno-sanitize-recover=all.
- Запустите те же тесты на ARM64 и ARMv7 под qemu-user, если нет железа.
- Для shared memory и mmap заведите общий заголовок с полем версии, типы фиксированной ширины и memset перед публикацией.
Раскладка структуры - часть ABI вашей платформы: она меняется вместе с компилятором, флагами и целевой архитектурой. Фиксируйте её assert-проверками и прогоняйте команды из чек-листа, чтобы лишние байты не превращались в гигабайты на проде.