nos_
ENRU

Конструктор, который никто не вызвал, и линковка, которая его больше не пропустит

nos не исполняет .init_array, и глобальный объект с настоящим конструктором остаётся нулями. Так два лока выпали из watchdog — теперь такое не слинкуется.

6 мин чтения
БезопасностьЗагрузкаОтладкаТестирование
Read in English

В обычной программе на C++ до main кое-что происходит: C runtime обходит .init_array и вызывает конструкторы всех глобальных объектов, которым они нужны. У ядра C runtime нет. nos загружается GRUB по Multiboot2 или по протоколу Linux Image, из ассемблерной точки входа сразу попадает в C++ (в Main на x86-64 и в MainArm64 на arm64), и на этом пути .init_array никто не обходит.

Пока ни одному глобальному объекту это не нужно, вреда нет. Глобальный объект типа с constexpr-конструктором и тривиальным деструктором инициализируется константно: его байты вычисляет компилятор, и они уже лежат в образе, который загрузчик кладёт в память. Если же конструктор не является константным выражением — скажем, определён в другом файле или делает настоящую работу, — инициализация динамическая: компилятор генерирует маленькую функцию, которая вызывает конструктор, и кладёт указатель на неё в .init_array. В nos эту функцию никто не вызывает. Объект остаётся таким, каким его оставил образ, — нулями в .bss. Ни исключения, ни предупреждения, ни строчки в логе, а очень часто нули — ровно то, что построил бы конструктор.

Верно по совпадению

Правило против этого не было новым. С февраля 2026 года соглашения проекта гласили «Static constructors are unreliable in freestanding» и предписывали вместо этого синглтон, явно инициализируемый при загрузке. Никто его соблюдение не проверял, и ядро уже однажды наступило на эти грабли. Счётчик, на котором каждый AP крутится, пока BSP не включит вытеснение, с 2018 года (9bf8fcb) был объявлен как Atomic PreemptOnWaiting(1);, а конструктор Atomic был определён вне класса — так что счётчик начинал с 0, а не с 1, и барьер никогда не работал так, как был написан. Июльское ревью 2026 года перевело этот барьер на счётчик, которому и положено начинаться с нуля (fc5b154), и оставило комментарий почему: «this kernel runs no .init_array, so a dynamically initialized Atomic x(1) would in fact be left at 0».

Остальное нашлось случайно. 19 сентября у C++ появилась сборка с UBSan (e66e67c), и её список секций показал записи в .init_array у шести слинкованных файлов на x86-64 и семи на arm64: pci, msix (на arm64 — msix_its), interrupt_stats, sched, preempt, rust_ffi и, на arm64, pl011. Большинство были верны по совпадению: никогда не сконструированный Atomic или RawRwSpinLock — это сплошные нули, а их конструкторы построили бы ровно то же самое.

Неверно и молча

Два — нет. Лок конфигурационного пространства Pci и Pl011::OutLock, лок вывода консоли на arm64, — это RawSpinLock, а конструктор RawSpinLock делает настоящую работу: регистрирует лок в watchdog локов (raw_spin_lock.cpp).

RawSpinLock::RawSpinLock(bool watched)
    : Watched(watched)
    , PreemptTask(nullptr)
    , WatchdogReported(0)
    , WatchdogLockTime(0)
{
    if (Watched)
        Watchdog::GetInstance().RegisterSpinLock(*this);
}

Если объект остался нулями, Watched равен false. Как лок такой объект работает прекрасно — Value, равный 0, означает «свободен», — но ни в одном списке watchdog его нет, и при захвате он не записывает время. Watchdog сообщает о любом наблюдаемом локе, который держат дольше 25 мс, и паникует на десяти секундах, — а этих двух он не видел. Один — лок за каждым обращением к конфигурационному пространству PCI, включая обращения Rust-драйверов после запуска SMP; другой — лок, через который проходит каждая строка, напечатанная в последовательную консоль arm64. Как раз такие локи и подвешивают машину, и именно для этих двух watchdog был слеп.

Хуже всего то, как они стали неверными. В июле 2026 года, когда оба глобальных объекта появились, они были безвредны: конструктор RawSpinLock был тогда пустым и построил бы те же нули. Сломались они 4 сентября, когда инструментирование watchdog переехало в этот конструктор после дедлока на EX44 (та история). Изменение было в другом файле, и никакое ревью pci.cpp или pl011.cpp его бы не предвидело. Глобальный объект с динамической инициализацией — это ставка на то, что конструктор его типа навсегда останется тривиальным.

Три формы без кода при старте

Исправление (6f83be8) переводит каждый глобальный объект в форму, которой при старте не нужен никакой код.

constexpr-конструктор и тривиальный деструктор — для типов, чьё построение сводится к значениям. Их получили Atomic и RawRwSpinLock, и статические экземпляры теперь инициализирует компилятор (atomic.h):

    constexpr Atomic()
        : Value(0)
    {
    }

    constexpr Atomic(long value)
        : Value(value)
    {
    }
    // ...
    ~Atomic() = default;

Каждый член инициализирован. У RustIrqSlot и RustMsixSlot — таблиц, в которых регистрируются обработчики прерываний со стороны Rust, — были члены без инициализатора. Конструктор по умолчанию, который оставляет член неопределённым, не является константным выражением, и компилятор сделал из него конструктор времени выполнения — для таблицы, которая и так была бы нулями:

struct RustIrqSlot
{
    void (*Handler)(void*) = nullptr;
    void* Ctx = nullptr;
    u8 Vector = 0;
    bool Used = false;
    Kernel::Atomic InFlight; /* ISRs currently executing Handler */
};

Статическая переменная внутри функции — для объекта, конструктор которого обязан что-то сделать. Оба лока теперь строятся при первом использовании (pci.cpp, pl011.cpp):

Kernel::RawSpinLock& ConfigLock()
{
    static Kernel::RawSpinLock lock;
    return lock;
}

Первое использование компилятор охраняет вызовом __cxa_guard_acquire, который в nos выключает вытеснение и прерывания вокруг атомарного бита (icxxabi.cpp), так что два CPU, наперегонки пришедшие к первому обращению к PCI, построят лок один раз. Изменение перечитали и на предмет того, что может сломать новая форма. Построение лока консоли ничего не печатает — не печатают ни конструктор watchdog, ни RegisterSpinLock, — так что первая напечатанная строка не может рекурсивно войти в построение собственного лока, а Panic пишет по пути, который вообще не берёт локов. После исправления watchdog на arm64 насчитал 816 наблюдаемых локов вместо прежних 814 — в той же точке той же загрузки.

От одного короткого пути отказались. Флаг -fno-c++-static-destructors одним махом убрал бы из .init_array регистрации деструкторов, но nos при выключении вызывает __cxa_finalize, и деструкторы синглтонов там кое-что проверяют — деструктор BlockAllocatorImpl, например, ищет утечки. Поэтому требование, наоборот, стало строже: глобальному объекту нужен не только constexpr-конструктор, но и тривиальный деструктор.

Линковка, которая отказывает

Само по себе ничего из этого исправленным бы не осталось. Правило было записано ещё в феврале, и оба лока появились уже после него. Поэтому оба скрипта линкера теперь собирают все секции конструкторов и деструкторов в секцию INFO — ту, которую образ никогда не загружает, — и проверяют, что она пуста (linker-arm64.ld):

+    .static_init (INFO) : {
+        *(.preinit_array)
+        *(.init_array) *(.init_array.*)
+        *(.ctors) *(.ctors.*)
+        *(.fini_array) *(.fini_array.*)
+        *(.dtors) *(.dtors.*)
+    }
+    ASSERT(SIZEOF(.static_init) == 0, "a static constructor or destructor: this kernel runs none (CLAUDE.md)")

Глобальный объект, которому нужен код при старте, теперь означает проваленную сборку, а не тихие нули. Проверили это напрямую, добавив глобальный RawSpinLock: обе линковки — GNU ld для x86-64 и ld.lld для arm64 — падают с этим сообщением. Проверка видит каждый объектный файл, попадающий в образ, на каком бы языке он ни был написан, и ничего не стоит во время выполнения.

Вместе с этим изменилось и правило в CLAUDE.md: было «Static constructors are unreliable», стало «Static constructors do not run, and the link refuses one» (сборка). Разница между этими двумя фразами и есть смысл всей затеи. Первая — совет, о котором должен помнить тот, кто напишет следующий глобальный объект. Вторая — свойство сборки, которая помнит за него. Когда ошибка возможна только потому, что кто-то забыл правило, этот проект ищет место, где забыть его означает не собраться: тип, гейт или, как здесь, несколько строк скрипта линкера (цель проекта).