Выравнивание данных в C++: разбор инцидента с SIGBUS на ARM
Сервис падал с SIGBUS только на ARM-узлах, а на x86 работал годами. Причина — чтение double по невыровненному адресу через reinterpret_cast. Разбираем, почему так вышло и как ловить это до продакшена.

Сервис десериализации бинарных пакетов работал на x86-серверах два года без единого падения. После переезда части парка на ARM-узлы (AWS Graviton) 3–5% запросов стали завершаться крешем с сигналом SIGBUS. Код не менялся, флаги компиляции не менялись, входные данные те же самые. Разбор занял полдня и упёрся в одну строку с reinterpret_cast, которая читала double из буфера по адресу, кратному четырём, но не восьми.
Ниже — что именно произошло на уровне процессора, почему x86 это прощал, а ARM нет, и как поймать такие места до того, как они доедут до прода. Примеры собраны на GCC 13 и Clang 17, стандарт C++17.
Что показал core dump
Первое, что дал бэктрейс, — падение внутри функции разбора заголовка пакета:
Program terminated with signal SIGBUS, Bus error.
#0 parse_price (buffer=0x7f...) at codec.cpp:88
88 double price = *reinterpret_cast<const double*>(ptr);SIGBUS — не то же самое, что SIGSEGV. Segmentation fault означает обращение к памяти, которой у процесса нет. Bus error на большинстве систем означает, что память доступна, но обращение к ней нарушает требования выравнивания: процессор получил инструкцию «прочитай 8 байт как double по адресу, не кратному 8», и отказался её выполнять.
Адрес ptr в момент падения был кратен 4, но не 8. Это и есть корень.
Почему буфер оказался невыровненным
Формат пакета выглядел так: 1 байт тег, 4 байта длина, дальше поля вплотную, без паддинга — экономили на размере. Указатель ptr двигался по буферу побайтово, и к моменту чтения double он стоял на смещении, не кратном 8. Код читал значение в лоб:
// codec.cpp, версия до фикса — UB при невыровненном ptr
double price = *reinterpret_cast<const double*>(ptr);
ptr += sizeof(double);Разыменование указателя на тип T по адресу, не выровненному под alignof(T), — это неопределённое поведение в C++. Не «медленно», не «зависит от платформы» на уровне спецификации, а именно UB. То, что x86 годами выдавал правильный результат, — везение, а не гарантия.
Почему x86 прощал, а ARM нет
x86 и x86-64 аппаратно поддерживают невыровненные обычные загрузки: инструкция mov прочитает 8 байт с любого адреса. Есть штраф по производительности, когда доступ пересекает границу кэш-линии, но исключение не возникает — программа работает.
AArch64 в этом строже. Для многих скалярных загрузок ядро настраивается так, что невыровненный доступ приводит к аппаратному исключению, которое ОС транслирует в SIGBUS. Поведение зависит от типа инструкции и настроек ядра, но на практике на Graviton под Linux именно этот сценарий и выстрелил.
Код, который «работает на x86», и код, который «корректен по стандарту», — это два разных множества. Их пересечение большое, но не полное, и разница вылезает ровно при смене архитектуры.
Ещё один множитель — SIMD
Отдельная ловушка: SSE-инструкции вроде movaps требуют выравнивания на 16 байт даже на x86 и бросают SIGSEGV при нарушении. Так что «x86 всё прощает» — упрощение. Автовекторизатор компилятора может сгенерировать выровненную загрузку там, где вы этого не ждали, и тогда падение приедет и на Intel.
Как читать невыровненные данные правильно
Единственный корректный способ прочитать значение из потенциально невыровненного буфера — скопировать байты в правильно выровненный объект. Стандартный инструмент — std::memcpy:
#include <cstring>
#include <cstdint>
// Корректно на любой архитектуре: memcpy не требует выравнивания источника
double read_double(const std::uint8_t* ptr) {
double value;
std::memcpy(&value, ptr, sizeof(double));
return value;
}Может показаться, что memcpy на каждое поле — это дорого. На деле современный компилятор с оптимизацией распознаёт копирование фиксированного размера в локальную переменную и на x86 генерирует ту же самую одну инструкцию загрузки, что и reinterpret_cast. На ARM он сгенерирует безопасную последовательность. То есть корректность здесь бесплатна на x86 и дёшева на ARM.
Что не помогает
reinterpret_castс последующим разыменованием — исходный баг, UB.- Приведение через
unionс записью в один член и чтением другого — в C++ это тоже формально UB (type punning через union разрешён в C, но не в C++), хотя многие компиляторы его поддерживают как расширение. std::bit_cast(C++20) решает проблему type punning, но не проблему выравнивания: он работает с объектами, а не с сырыми указателями, поэтому вам всё равно сначала нужен корректно прочитанный источник.
Как поймать это до прода
Инцидент не должен был доехать до продакшена. Инструменты, которые ловят невыровненный доступ на этапе тестов:
- UBSan с проверкой alignment. Соберите тесты с флагом
-fsanitize=alignment(входит в-fsanitize=undefined) на GCC и Clang. Санитайзер сообщит о каждом разыменовании по невыровненному адресу с точным местом в коде — даже на x86, где падения нет. - Прогон тестов на ARM в CI. Если целевой парк смешанный, тесты должны идти хотя бы на одном ARM-раннере. Разница архитектур ловится только так.
- Запрет reinterpret_cast в парсерах через ревью. Договорённость команды: любое чтение из бинарного буфера — только через
memcpyили обёртку над ним.
Пример вывода UBSan на исходном коде:
codec.cpp:88:22: runtime error: load of misaligned address 0x...
for type 'const double', which requires 8 byte alignmentОдна такая строка в логе CI закрыла бы вопрос за минуту вместо полудня разбора core dump.
Что ревьюить в чужом коде
Конкретные формулировки замечаний, которые стоит писать в ревью парсеров и сериализаторов:
- «Здесь
reinterpret_castк указателю на тип шире байта — источник может быть невыровнен, замените наmemcpyв локальную переменную». - «Структура читается из буфера целиком через каст — она не trivially copyable либо буфер невыровнен, разберите по полям».
- «
packed-структура: взятие адреса поля даёт указатель, который нельзя разыменовывать напрямую, компилятор предупреждает-Waddress-of-packed-member».
Последний пункт про __attribute__((packed)) — частый источник тех же граней: поле внутри упакованной структуры может лежать невыровненно, и передача его адреса в функцию, ожидающую выровненный указатель, воспроизводит ровно тот же SIGBUS.
Граничные случаи
Где подход с memcpy не закрывает всё:
- Многобайтовые поля и порядок байтов.
memcpyкопирует байты как есть. Если формат big-endian, а машина little-endian, значение получится неверным, но без падения — это ещё коварнее. Нужна явная перестановка байтов. - Структуры, а не отдельные поля. Копировать в
doubleпросто; копировать в структуру можно только если она trivially copyable, иначе UB другого рода. - NaN и денормализованные double. Если байты в буфере не были записаны как валидный IEEE-754, вы получите NaN или мусор —
memcpyэто пропустит молча. Валидация значения — отдельный слой.
Что делать дальше
Минимальный план после такого инцидента:
- Прогоните весь код разбора бинарных форматов под
-fsanitize=undefinedи почините все находки, а не только упавшую строку. - Добавьте ARM-раннер в CI, если целевые узлы смешанные — иначе следующий такой баг снова доедет до прода.
- Заведите в code style правило про
memcpyдля чтения из буферов и включите-Waddress-of-packed-memberкак ошибку.
Дальше стоит посмотреть в сторону типизированной обёртки для парсинга (небольшой класс-курсор над буфером с методами read<T>() на базе memcpy), чтобы reinterpret_cast физически негде было написать. Инвалидация endianness и валидация значений — отдельная тема, здесь разбирали только выравнивание.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: cppreference — reinterpret_cast и требования выравнивания, Clang UndefinedBehaviorSanitizer — список проверок
Частые вопросы
У меня весь парк на x86, мне вообще это нужно?
memcpy на каждое поле не убьёт производительность?
А если я использую std::bit_cast из C++20?
У меня другая версия компилятора, флаги санитайзера те же?
Собрал с UBSan, но он ничего не нашёл, хотя на ARM падает. Почему?
Проблема только с double или со всеми типами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.