SIGBUS раз в три дня: шесть недель и одна упакованная структура
Сервис падал на двух узлах из девяти, раз в двое-четверо суток, только на aarch64 и только под нагрузкой. Я три недели проверял неверную гипотезу, а санитайзер нашёл причину за сорок миллисекунд работы.
Что было известно вначале
Девять узлов: семь x86-64 и два aarch64. Падает только на двух последних. Сигнал - SIGBUS, а не привычный SIGSEGV, и это первое, что стоило прочитать внимательно, а не пролистать. Интервал между падениями от 44 до 91 часа, корреляции со временем суток и с нагрузкой на первый взгляд нет.
Из dmesg на узле:
svc[2841]: unhandled signal 7 code 0x1 at 0x0000ffff8c1d20b5
CPU: 2 PID: 2841 Comm: svc Not tainted 6.6.32 #1 aarch64
Сигнал 7 - это SIGBUS, код 0x1 - BUS_ADRALN, то есть ошибка выравнивания. Адрес …b5 не кратен даже двум. В этот момент можно было закончить, но я туда не посмотрел и ушёл не в ту сторону.
Неверная гипотеза, три недели
Я знал второй способ получить SIGBUS: обращение к отображённому файлу за его фактическим концом. Сервис как раз отображает индексный файл через mmap, а внешний процесс его периодически перестраивает - то есть теоретически мог усечь под нами. Гипотеза ложилась на всё: редкость, зависимость от узла (на aarch64 стояли узлы с индексом), отсутствие видимой закономерности.
Я добавил fstat после отображения, проверку длины перед каждым доступом, перехват SIGBUS с переотображением. Падения продолжились с той же частотой. Это был хороший урок про то, что гипотеза, объясняющая все наблюдения, не обязана быть верной - особенно если я не проверил ни одного её следствия отдельно.
Что показала корка
$ gdb ./svc core.2841
(gdb) bt
#0 stats_bump (s=0xffff8c1d2080, k=3) at stats.c:71
#1 queue_push_slow (q=0xaaaad41f3a40) at queue.c:218
(gdb) p/x $pc
$1 = 0x4a3c1c
(gdb) x/i $pc
=> 0x4a3c1c <stats_bump+28>: ldaddal x1, x1, [x0]
(gdb) p/x $x0
$2 = 0xffff8c1d20b5
Инструкция ldaddal - атомарное сложение с семантикой acquire-release. Адрес в x0 оканчивается на 0xb5: он не выровнен ни на 8, ни на 2.
Почему это падает только на aarch64
На x86-64 невыровненные обращения, включая атомарные с префиксом lock, работают - медленнее, но работают. На aarch64 правила другие и их стоит знать дословно: обычные загрузки и записи при SCTLR_EL1.A = 0 невыровненный адрес допускают, а вот эксклюзивные и атомарные инструкции (ldxr/stxr, семейство ldadd, cas) требуют естественного выравнивания всегда. Нарушение даёт alignment fault, который ядро отдаёт процессу как SIGBUS с кодом BUS_ADRALN.
Значит, тот же самый код на x86-64 работал годами и был неправильным всё это время - просто архитектура его прощала.
Откуда взялся невыровненный адрес
Счётчики лежали в структуре, которая когда-то использовалась для сериализации, и поэтому была упакована:
struct __attribute__((packed)) shm_head {
uint16_t version; /* 0 */
uint8_t flags; /* 2 */
char node[34]; /* 3 */
uint64_t counters[8]; /* 37 <- не кратно 8 */
};
packed убирает выравнивание полей, и counters оказывается по смещению 37. Дальше всё зависит от адреса самой структуры: в сегменте разделяемой памяти он кратен странице, то есть counters гарантированно сидит на нечётном адресе. На x86-64 это никого не беспокоило.
А почему падало раз в три дня и только на двух узлах - потому что stats_bump с атомарным сложением вызывается не на каждое событие, а только из медленной ветки queue_push_slow, когда очередь переполняется. На семи x86-64-узлах она вызывалась регулярно и успешно, на двух aarch64 - ровно в момент переполнения, то есть раз в двое-четверо суток под пиком.
Чем это находится за сорок миллисекунд
$ cc -O1 -g -fsanitize=undefined,alignment -o svc.ubsan *.c
$ ./svc.ubsan --self-test
stats.c:71: runtime error: member access within misaligned address
0xaaab0f3c20b5 for type 'uint64_t', which requires 8 byte alignment
Сорок миллисекунд работы самотеста. Шесть недель моего времени против одного флага компилятора, который я не включал, потому что «санитайзеры для новых проектов».
Отдельно обидно, что компилятор и так предупреждал: -Waddress-of-packed-member ругается на взятие указателя на поле упакованной структуры. В сборочных флагах проекта это предупреждение было подавлено вместе с группой других, «чтобы не шумело».
Что сделал
- Счётчики вынесены из упакованной структуры в отдельную, выровненную по 64 байта - попутно разъехались по разным строкам кеша, и конкуренция между потоками стала меньше.
- Упакованная структура осталась там, где она нужна по делу: в сериализации. Чтение полей из неё - только через
memcpyв локальную переменную, никаких указателей на поля. - Предупреждение
-Waddress-of-packed-memberвключено обратно и поднято до ошибки. Нашлось ещё четыре места того же класса, ни одно пока не падало. - Сборка с
-fsanitize=undefined,alignmentдобавлена в проверку перед слиянием ветки.
Чему научился
Читать сигнал целиком: SIGBUS с кодом BUS_ADRALN говорит ровно про выравнивание, и адрес из сообщения ядра проверяется в одну строку. Проверять следствия гипотезы по отдельности, а не радоваться тому, что она «объясняет всё». И не держать packed на структурах, по которым кто-то ходит атомарно: разница между «работает» и «не падает» здесь ровно в архитектуре, на которой запустили.
Список того, чем я теперь пользуюсь на старте разбора, собран на странице инструментов.
Комментарии (1)
armdev - 12 июня 2025, 15:08
packed плюс атомарный доступ на arm - больная тема. ldaddal на невыровненном адресе ловил ровно такой же SIGBUS, причём не на всех ядрах.
Войти, чтобы комментировать.
Регистрация закрыта.