Ядро, которое не хотело выключаться
В сентябре 2026 года poweroff то не срабатывал, то зависал, то падал. Последние две ошибки: CPU, проснувшийся после останова, и куча, ушедшая первой.
В сентябре 2026 года nos умела почти всё, кроме одного: выключаться. Команда poweroff в шелле иногда не делала ровным счётом ничего. Если на диске была подготовлена область для disk log, выключение зависало. А если ядро всё же успевало размонтировать файловые системы, то потом оно часто падало, пока работали статические деструкторы: на arm64 каждый раз, на x86-64 в одном-двух запусках из трёх.
Ошибка была не одна, а целая цепочка. Две последние исправили 13 сентября. На двух архитектурах они выглядели по-разному, но устроены были одинаково: что-то ещё работало или всё ещё висело в каком-то списке, а остановка уже освобождала память у него под ногами.
Кнопку никто не нажимал
Ошибки так долго прятались, потому что путь выключения никто не запускал. Оба smoke-теста ждут маркеров загрузки, завершаются и на выходе убивают QEMU (smoke-test.sh), и сеанс QEMU во время разработки обычно заканчивается так же. Ядро, которое падало по дороге вниз, снаружи ничем не отличалось от ядра, которое не падало.
Всё изменилось 9 сентября, когда у nos появилась корневая файловая система ext2 с поддержкой записи (7e0b5db). Драйвер, как и Linux, сбрасывает в суперблоке бит valid, когда монтирует её на запись, и снова ставит его при чистом размонтировании. Если загрузка закончилась как-то иначе, следующее монтирование сообщит, что файловую систему не размонтировали чисто. С этого момента у poweroff появилась настоящая работа.
Запрос, которого никто не услышал
Первым был самый тихий сбой: poweroff просто ничего не делал. Команды poweroff и reboot в шелле только поднимают флаг. Проверяет его idle-задача BSP, то есть загрузочного процессора. Idle-задача получает процессор, когда он больше никому не нужен, и смотрит на флаг в промежутках между остановами. Такой задачей «на крайний случай» она стала совсем недавно: с 5 сентября планировщик выбирает idle-задачу, только если на её CPU больше нечего запускать (021cbbf).
Но тогда Sleep() уступал процессор, а не блокировал задачу. На CPU, где жили шелл, DHCP-клиент и задача опроса USB, всегда было что запустить. Очередь до idle-задачи так и не доходила, и запрос лежал непрочитанным. На QEMU с двумя CPU так получалось каждый раз. Тот же коммит с ext2 от 9 сентября исправил и это: запрос теперь снимает с idle-задачи FlagIdleBit, и дальше она получает свою очередь наравне с остальными (cmd.cpp, Планировщик):
static void WakeShutdownWatch()
{
auto& cpus = CpuTable::GetInstance();
Task* idle = cpus.GetCpu(cpus.GetBspIndex()).GetIdleTask();
if (idle != nullptr)
idle->ClearIdle();
}
Пришлось поменять и порядок остановки. При размонтировании ext2 пишет суперблок, а блочный запрос завершается через soft IRQ BLK_IO, отложенную половину обработки прерываний. Поэтому размонтирование переехало раньше SoftIrq::Stop(). Через два дня та же беда обнаружилась у disk log, который копирует лог ядра на сырой раздел диска (0d70452). С подготовленной областью под QEMU не завершилось ни одно выключение из трёх. Вывод в последовательный порт обрывался после того, как вторичные процессоры (AP) выходили из работы: ядро так и не дождалось записи в disk log. Теперь disk log останавливается после размонтирования и до soft IRQ.
Вот порядок на x86 в его нынешнем виде, по kernel/main.cpp:
BpStartup, once the request is seen
network services, USB, the shells, DHCP stopped
ModuleTable::UnloadAll a module's exit still has files and block I/O
kernel_vfs_unmount_all ext2 writes its superblock, through BLK_IO
rust_disklog_stop the disk log's writer finishes
SoftIrq::Stop the soft IRQ tasks exit
PrepareHalt
ExitAllExceptSelf "Stopping cpu's" ... "Cpu's stopped"
CpuTable::Reset exited tasks reaped, run queues emptied
switch to the static stack
FinalizeOnStaticStack
task->Put the idle task and its stack freed
rust_fini, __cxa_finalize Rust teardown, then the static destructors
"Finalized", then power off or reset
Всё, что выше PrepareHalt, работает на живой машине. Ниже ядро разбирает само себя, и падало оно именно там.
arm64: останов, который не удержал процессор
ExitAllExceptSelf останавливает остальные процессоры по одному. Он помечает CPU как выходящий и шлёт ему IPI, пока тот не выставит StateExited. Обработчик IPI, Cpu::IPI, выставлял StateExited, как только видел запрос, доделывал работу, поставленную ему в очередь, и заканчивался так:
InterruptDisable();
Hlt();
return;
Но одной инструкции останова мало, чтобы процессор остался на месте. На arm64 Hlt() — это WFI, а WFI может завершиться в любой момент: ожидающее прерывание будит процессор, даже когда прерывания замаскированы. Под HVF (Hypervisor.framework от Apple, через который QEMU работает на Apple Silicon) все три AP гостя с четырьмя CPU выходили из WFI за считаные миллисекунды. Каждый возвращался из IPI в ту задачу, которую прервал. А CPU 0 к этому времени уже увидел StateExited и освобождал задачи, их стеки и под конец кучу. Один из AP записал в страницу, которую только что убрали из адресного пространства, и получил data abort посреди статических деструкторов CPU 0.
На отчёте мы и свернули не туда. Паника пишет в последовательный порт PL011, не захватывая блокировку консоли. Поэтому отчёт AP выходил вперемешку с трассировкой CPU 0 из деструкторов, символ за символом. Если читать его как есть, падение выглядело как ошибка в самом TaskTable::~TaskTable, и какое-то время ошибку так и называли. Тест SSH-сервера, написанный за день до исправления, допускает панику при выключении и в комментарии винит TaskTable::~TaskTable (sshd-test.py).
Одиночный Hlt() появился давно, в 2018 году, в коммите с названием «kernel: fix crash on shutdown» (28ba855). Он заменил собой бесконечный цикл ожидания. На x86 останов с выключенными прерываниями длится до первого NMI (немаскируемого прерывания). Порт на arm64 выполняет тот же код, а WFI таких обещаний не даёт.
Исправление (7908bf6) даёт выходящему процессору одно место, где он заканчивает работу: Cpu::Park(). Вот как функция выглядит сегодня, вместе с флагом Parked из исправления, о котором речь пойдёт ниже (cpu.cpp):
void Cpu::Park()
{
InterruptDisable();
Parked.Set(1);
{
Stdlib::AutoLock lock(Lock);
State |= StateExited;
}
for (;;)
{
Hlt();
}
}
Теперь StateExited публикуется последним, потому что CPU, который попросил остановку, начинает освобождать память, как только это увидит. Останов стоит в цикле, так что завершившийся WFI просто приводит к следующему WFI. А Park() объявлена как noreturn, и у обработчика IPI нет пути назад в прерванную задачу. До исправления на arm64 под HVF падали все три выключения из трёх. С ним и со следующим исправлением чистыми прошли 15 из 15.
x86-64: куча ушла первой
На x86 ядро падало реже и в другом месте: в __cxa_finalize, на Watchdog::UnregisterSpinLock+0xAA. 11 сентября коммит про disk log списал это на «the known race with task memory freed during the halt», то есть на известную гонку с памятью задач, которую освобождают во время остановки. Деструктор, на котором всё падало, менялся от запуска к запуску: попадались Cpu, TaskQueue и Serial.
__cxa_finalize вызывает деструкторы синглтонов ядра в порядке, обратном порядку создания. На x86 куча, AllocatorImpl, создаётся в Main2 после CpuTable, Serial, Dmesg и watchdog, а значит, уничтожается раньше всех них. Каждый её класс размеров — это Pool, и ~Pool возвращал все страницы, оставшиеся в его списках (Страничная память). Но Free() возвращал страницу, как только освобождался её последний блок. Значит, страница, которая к этому моменту ещё лежала в списке, хранила блок, который никто не освободил. Циклы убирали из адресного пространства живые объекты, и только их.
Утёкшие к остановке объекты были бы безвредны, если бы их никто не трогал. Но их трогал watchdog. Каждый наблюдаемый спинлок при создании регистрируется в watchdog, в одной из 512 корзин. Корзина — это двусвязный список, и выбирают её по хешу адреса спинлока. Спинлок утёкшего объекта так и остаётся в своей корзине. Когда статический объект, который уничтожается после кучи, снимает с учёта свой спинлок, удаление из списка пишет в соседние элементы. Если сосед лежит на странице, которую ~Pool только что убрал из адресного пространства, эта запись падает.
Адрес сбоя показал ровно это. В пойманном случае утёкшими объектами были задачи soft IRQ, softirq/N, выделенные с тегом 'SIrq' и забытые в списках завершившихся задач своих CPU. Cpu::~Cpu падал с cr2 (регистром, где лежит адрес сбоя), равным адресу задачи плюс 0x68. Это поле Blink в Lock.RawLock.WatchdogListEntry этой задачи. Какой статический объект упадёт, зависело от того, в какую корзину попал утёкший спинлок.
Исправление (a51290e) удаляет оба цикла и оставляет на их месте комментарий, который начинается словами «The pages stay mapped» (pool.cpp):
- while (!FreePageList.IsEmpty())
- {
- PgAlloc->Free(FreePageList.RemoveHead());
- }
-
- while (!PageList.IsEmpty())
- {
- PgAlloc->Free(PageList.RemoveHead());
- }
~Pool по-прежнему печатает каждый утёкший блок с его тегом, так что утечка остаётся заметной. Просто страница с таким блоком теперь остаётся на месте. Вместе с исправлением для arm64 выключения на x86 под TCG (программной эмуляцией процессора в QEMU) перестали падать: было два падения из трёх, стало 15 чистых выключений из 15.
Потом сами утечки
Сохранённые страницы сделали утечки безвредными, но не убрали их. Ещё два коммита в тот же день закрыли и их.
Задачи soft IRQ утекали на каждой архитектуре по своей причине (c686108). На x86 их останавливает SoftIrq::Stop() за мгновение до остановки системы. Последнюю ссылку на завершившуюся задачу отпускает idle-цикл её CPU в TaskQueue::ReapExited(), а после начала остановки ни один CPU в idle больше не попадает. Теперь CpuTable::Reset() сам разбирает списки завершившихся задач всех CPU, причём до того, как возьмёт блокировку таблицы. Освобождая задачу, ядро освобождает и её стек, а TLB shootdown для этого на x86 читает набор работающих CPU под той же блокировкой (почему это важно). На arm64 остановка вообще не вызывала SoftIrq::Stop(). Теперь вызывает.
Задача опроса USB утекала третьим способом (9e236b2). Stop() дожидался её завершения, но так и не отпускал ссылку, с которой её создал Start(). После этих исправлений ~Pool при выключении arm64 не находит вообще ничего. На x86 он находит один блок с тегом 'rust' и три 16-байтных блока без тега, и ни один из них не задача.
Паника, которая похоронила бы саму себя
Оставалась ещё одна дыра, в пути паники (893c11d). С 4 сентября паника просит остальные процессоры прислать свои стеки через NMI (edf299c). Обращалась она ко всем CPU из набора работающих, а припаркованный CPU из этого набора никогда не выходит. На x86 доставка NMI кладёт кадр на стек, на котором процессор остановился, а остановка к этому времени могла этот стек уже освободить. Следующий за этим сбой похоронил бы тот самый отчёт, ради которого и слали NMI.
Поэтому Park() выставляет Parked до того, как публикует StateExited, а Panicker::CollectRemoteStacks такие CPU пропускает. Там, где был бы их backtrace, он печатает Cpu N parked. Мы проверили это на панике, нарочно вызванной в FinalizeOnStaticStack. Теперь отчёт заканчивается строками от Cpu 1 parked до Cpu 3 parked, а раньше эти три CPU отвечали кадрами ExcNMI со стеков, на которых их припарковали. arm64 пока не отправляет NMI, так что на практике это исправление для x86.
Что исправлено насовсем
Исправления утечек закрыли отдельные случаи. Три других закрывают дверь навсегда. У выходящего CPU ровно одно место, где он заканчивает работу: цикл в функции с noreturn, и CPU, который освобождает память, ждёт, пока у того не останется никаких дел. Утечка при остановке — это строка в логе, где владелец назван по тегу, а не page fault в чужом деструкторе. А паника во время остановки называет припаркованные CPU и не лезет на их стеки.
Мы вынесли из этой истории и две привычки на случай следующего сбоя по дороге вниз. Прежде чем доверять backtrace из остановки, мы ищем, не вплетён ли в него вывод второго CPU. И проверяем, не случился ли сбой после ~AllocatorImpl: всё, что уничтожается после кучи, работает уже без неё.