Массивы в структурах C/C++: выравнивание, padding и упаковка памяти на практике | AdminWiki

Массивы в структурах C/C++: выравнивание, padding и упаковка памяти на практике

28 сентября 2026 13 мин. чтения
Содержание статьи

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.

ЧтоРазмерВыравниваниеСмещение
tag110
padding3-1-3
values[0]444
values[1]448
values[2]4412
values[3]4416
итого sizeof204-

Длинный 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-атрибутов:

  1. Выпишите поля, их размеры и выравнивания. Для массива берите размер и выравнивание одного элемента: uint32_t payload[4] - шаг 4 байта, выравнивание 4.
  2. Поставьте первое поле на смещение 0.
  3. Для каждого следующего поля поднимите текущее смещение до ближайшего кратного его выравниванию и зафиксируйте offset. Пропущенные байты и есть padding.
  4. Прибавьте размер последнего поля: получится полезный конец структуры.
  5. Округлите результат вверх до максимального выравнивания среди полей. Полученное число и есть 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 */
};
ЧтоРазмерВыравниваниеСмещение
type110
padding3-1-3
payload[0]444
payload[1]448
payload[2]4412
payload[3]4416
crc2220
padding2-22-23
итого sizeof244-

Полезных байт 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]3210-31
flags1132
padding7-33-39
id8840
итого sizeof488-

Сумма полей 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.

Чек-лист из семи пунктов:

  1. Соберите проект с -Wpadded и прочитайте предупреждения: они перечисляют каждую дыру между полями.
  2. Проверьте layout ключевых структур через pahole -C StructName ./binary после сборки с -g.
  3. Зафиксируйте sizeof и offsetof всех полей структуры через static_assert в заголовке.
  4. Для packed-структур читайте поля через memcpy, не через приведение указателя.
  5. Прогоните тесты с -fsanitize=alignment и -fno-sanitize-recover=all.
  6. Запустите те же тесты на ARM64 и ARMv7 под qemu-user, если нет железа.
  7. Для shared memory и mmap заведите общий заголовок с полем версии, типы фиксированной ширины и memset перед публикацией.

Раскладка структуры - часть ABI вашей платформы: она меняется вместе с компилятором, флагами и целевой архитектурой. Фиксируйте её assert-проверками и прогоняйте команды из чек-листа, чтобы лишние байты не превращались в гигабайты на проде.

Поделиться: