Барьеры, которые прощал x86: ревью порта на arm64
На x86 неверный барьер памяти ничего не стоит, а на arm64 это настоящая ошибка. Что нашло ревью порта nos на arm64, когда smoke-тесты уже проходили.
На x86 почти ни один барьер памяти в nos не превращается в инструкцию. Процессор не переставляет чтения с чтениями и записи с записями, а DMA когерентен с кэшами, поэтому барьеру достаточно остановить компилятор. Поставьте барьер не туда или возьмите не тот, и x86 этого не заметит. На arm64 каждый барьер — настоящая инструкция с точным смыслом, и неверный барьер становится ошибкой, которая ждёт своего момента.
Порт nos на arm64 появился 16 и 17 июля. К вечеру 17-го ядро загружалось на четырёх CPU, smoke-тесты проходили на обеих архитектурах, прерывания от PCIe работали. Потом мы провели ревью всего порта, и один коммит (7408805) исправил около 25 ошибок. Ниже самые поучительные из них, ещё одна ошибка, исправленная перед самым ревью, и объяснение, почему зелёная загрузка не показала ни одной.
Зачем arm64 и как шёл порт
Всё упиралось в скорость разработки. Мы пишем nos на Mac с Apple Silicon. Там x86-версия nos работает под TCG, программным эмулятором процессора в QEMU, а версия для arm64 запускается под HVF, гипервизором Apple, прямо на ядрах самого Mac. Smoke-загрузка arm64 занимала 16 секунд под TCG и 4 секунды под HVF (4171bc2). Ради этого выигрыша план проекта и поставил arm64 раньше гипервизора (план).
Сначала код x86 спрятали за HAL, слой абстракции оборудования, ничего не меняя в его поведении. После каждого шага x86 должен был проходить новый smoke-тест загрузки (4fe3796). Код, привязанный к x86, переехал в src/cpp/arch/x86_64/, а общий код перешёл на заголовки из src/cpp/hal/. Каждый из них выбирает реализацию при компиляции (mmu.h):
#if defined(__x86_64__)
#include <arch/x86_64/hal_mmu_inline.h>
#elif defined(__aarch64__)
#include <arch/arm64/hal_mmu_inline.h>
#else
#error "unsupported architecture"
#endif
Потом за шесть этапов написали саму поддержку arm64: «hello» в последовательный порт, управление памятью, GICv3 (контроллер прерываний Arm), SMP через PSCI (интерфейс прошивки, который запускает остальные CPU), virtio поверх MMIO и собственный smoke-тест. Следом пришли Rust, PCIe с MSI, W^X, таймеры на каждом CPU и широковещательный сброс TLB. Последние из этих изменений влились в master 17 июля (2971f3a).
Барьер, который не проверить на x86
В ядре для x86 был один макрос барьера, Barrier(): пустая ассемблерная вставка с memory clobber, которая останавливает только компилятор. Коммит be3767b заменил каждое его использование барьером, который говорит, что именно он упорядочивает, и удалил макрос, так что новому коду приходится выбирать (barrier.h):
#if defined(__x86_64__)
static inline __attribute__((always_inline)) void SmpWmb() { CompilerBarrier(); }
static inline __attribute__((always_inline)) void DmaWmb() { CompilerBarrier(); }
// ...
#elif defined(__aarch64__)
static inline __attribute__((always_inline)) void SmpWmb() { __asm__ __volatile__("dmb ishst" : : : "memory"); }
static inline __attribute__((always_inline)) void DmaWmb() { __asm__ __volatile__("dmb oshst" : : : "memory"); }
Smp* упорядочивает доступ к памяти между процессорами, Dma* отвечает за порядок между процессором и устройством, а dmb означает data memory barrier, барьер памяти arm64. Заголовок говорит об этом прямо: «classifying a site never changes x86 codegen», то есть выбор барьера никогда не меняет код для x86. В этом и подвох: каждый выбор делали на x86, где правильный и неправильный барьер дают одни и те же байты. Проверить их можно, только читая код с правилами arm64 в голове, и именно это сделало ревью.
Барьер не с той стороны проверки
Первая ошибка нашлась в очереди virtio, кольце, через которое диск или сетевая карта virtio возвращают выполненные запросы. Устройство записывает элемент в кольцо used и затем увеличивает индекс кольца. Драйвер сравнивает этот индекс со своим счётчиком и, если индекс сдвинулся, читает элемент. Барьер чтения стоял перед сравнением, а ревью добавило второй после него (7408805):
bool VirtQueue::GetUsed(u32& id, u32& len)
{
Hal::DmaRmb();
if (LastUsedIdx == Used->Idx)
return false;
/* Order the Idx load above before the Ring/payload loads below: usedIdx
derives from LastUsedIdx (no address dependency), and a control
dependency does not order load->load on arm64 — without this fence a
fresh Idx can pair with a stale Ring entry. Also fences the callers'
reads of device-written buffers (status bytes, RX data) that follow. */
Hal::DmaRmb();
u16 usedIdx = LastUsedIdx % QueueSize;
id = Used->Ring[usedIdx].Id;
len = Used->Ring[usedIdx].Len;
Первый барьер не пускает более ранние чтения за чтение индекса, но ничего не говорит о порядке между чтением индекса и чтениями кольца, потому что оба идут после него. Слот кольца вычисляется из LastUsedIdx, собственного счётчика драйвера, а не из только что прочитанного индекса, так что зависимости по адресу, которая заставила бы процессор подождать, нет. Чтения связывает только if, то есть зависимость по управлению. На arm64 она упорядочивает чтение относительно последующих записей, но не последующих чтений: процессор предсказывает переход и выдаёт следующее чтение заранее. Так свежий индекс может встретиться с устаревшим элементом кольца. То же касается всего, что вызывающий код читает дальше, например байта статуса дискового запроса или принятого кадра.
x86 никогда не переставляет одно чтение с другим, так что там такого не бывает. Тот же шаблон нашёлся в двух драйверах на Rust. read_cqe в драйвере NVMe проверял бит фазы в элементе очереди завершений (контроллер инвертирует этот бит на каждом проходе по очереди) и затем читал сам элемент. harvest в r8168 проверял бит OWN дескриптора приёма, который показывает, кому сейчас принадлежит дескриптор: сетевой карте или драйверу. Затем функция отдавала кадр, адрес которого брался из теневого массива. Теперь оба вызывают kcore::barrier::dma_rmb() сразу после проверки (nvme, r8168).
Правильная инструкция не для того адресата
Во втором случае барьер честно превращался в инструкцию, только не в ту. За день до ревью драйвер NVMe на Rust получил барьер перед записью в doorbell, а в r8168 барьеры компилятора при публикации дескрипторов заменили настоящими (d6983cd). Везде это был core::sync::atomic::fence(Ordering::Release): на x86 он ничего не стоит, а на arm64 превращается в dmb, как и писал коммит. Но для атомарного барьера LLVM выдаёт именно dmb ish.
ish обозначает домен разделяемости (shareability domain). Arm делит всех, кто наблюдает память, на домены, и барьер обещает порядок только наблюдателям из того домена, который в нём указан. Внутренний домен (inner shareable) обычно состоит из процессоров, на которых работает одна операционная система. Внешний (outer shareable) шире и может включать другие ведущие устройства на шине, например PCIe-устройство, которое работает через DMA. Так что dmb ish упорядочивал команду NVMe и запись в doorbell с точки зрения других процессоров, а контроллеру, который эту команду читает, не обещал ничего.
Исправлением стал новый модуль kcore::barrier, двойник Hal::DmaWmb и Hal::DmaRmb на Rust (barrier.rs):
pub fn dma_wmb() {
#[cfg(target_arch = "aarch64")]
unsafe {
core::arch::asm!("dmb oshst", options(nostack, preserves_flags));
}
#[cfg(not(target_arch = "aarch64"))]
core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
}
dmb oshst упорядочивает записи для внешнего домена, а dmb oshld в dma_rmb делает то же самое для чтений. Теперь doorbell в NVMe и обе публикации дескрипторов в r8168 идут через dma_wmb():
- core::sync::atomic::fence(core::sync::atomic::Ordering::Release);
+ kcore::barrier::dma_wmb();
TLBI, которому разрешено ничего не делать
Инструкция TLBI на arm64 удаляет записи из TLB, кэша трансляций адресов. Её варианты с is действуют сразу на все процессоры внутреннего домена. Поэтому днём 17-го arm64 перестал рассылать IPI для TLB shootdown (e531dc3, страничная память), и одна инструкция в Hal::TlbFlushPage стала единственным способом сбросить страницу на всех CPU.
Операнд tlbi vaae1is несёт номер страницы, VA[55:12], в битах 43:0. В ARMv8.4 появилось расширение FEAT_TTL, и биты 47:44 стали подсказкой: размер страницы и уровень таблицы, где лежит удаляемая запись, чтобы процессору было проще её найти. Ноль значит «подсказки нет». Код передавал virtAddr >> 12, а адреса ядра в nos начинаются с 0xFFFF8, так что после сдвига в поле подсказки оставалось 0b1111. Это читается как «страницы по 64 КиБ, уровень 3», а nos работает со страницами по 4 КиБ. Если подсказка не совпадает с записью, архитектура разрешает процессору не сбросить вообще ничего. Исправление сводится к маске (hal_mmu_inline.h):
asm volatile(
"dsb ishst\n"
"tlbi vaae1is, %0\n"
"dsb ish\n"
"isb\n"
:: "r"((virtAddr >> 12) & ((1UL << 44) - 1)) : "memory");
TCG в QEMU подсказку игнорирует, поэтому ни одна загрузка под TCG, в том числе в CI, не могла этого показать. Под HVF инструкция выполняется на собственном ядре Mac, и комментарий над исправлением прямо называет ядра Apple среди тех, где ошибка срабатывает. Пропущенный сброс ничего не роняет на месте. Процессор просто сохраняет доступ к странице через отображение, которое ядро уже убрало. Это повреждение памяти, и оно проходит молча.
Прерывание, которое так и не закончилось
Одну ошибку исправили за несколько часов до ревью, ещё в той ветке, что влилась в master. Это самый наглядный пример отказа, который выглядит как успех.
В GICv3 у каждого прерывания есть номер, INTID. Номера до 1019 занимают IPI, прерывания отдельного CPU, такие как таймер (27), и прерывания устройств, а значения с 1020 по 1023 особые и означают «подтверждать нечего». MSI от устройств PCIe идут через ITS (Interrupt Translation Service). Устройство записывает номер события в регистр ITS, а ITS превращает пару «устройство, событие» в LPI, прерывание с номером 8192 и выше (прерывания).
ITS появился 17 июля как опция, которую надо включать явно, и в сообщении коммита осталась открытая проблема. Как только NVMe включал свой вектор MSI-X, «the GIC stops delivering interrupts (the timer tick and the LPI both stop)», то есть GIC переставал доставлять прерывания: замирали и тик таймера, и LPI (7c37fb2). Причиной оказалось одно сравнение в ArmIrqEntry, цикле, который подтверждает прерывания чтением ICC_IAR1_EL1:
u32 intId = Gic::ReadIar();
if (intId >= Gic::SpuriousIntId)
{
/* An empty first read is a real spurious interrupt; an empty
re-read is just the dispatch loop draining */
if (!handled)
InterruptStats::Inc(IrqSpurious);
return;
}
SpuriousIntId равен 1020. Проверку писали для четырёх особых значений, но любое LPI тоже больше 1020. Поэтому цикл подтверждал прерывание от NVMe, принимал его за ложное и выходил, не записав ICC_EOIR1_EL1. Подтверждение поднимает текущий приоритет процессора (running priority) до приоритета этого прерывания, и опустить его обратно может только запись о завершении прерывания. У всех прерываний в nos один и тот же приоритет, 0xA0, поэтому с этого момента этот CPU не могло прервать ничто, даже таймер. Загрузка при этом всё равно печатала boot: complete, потому что этот путь синхронный. Задача оболочки просто больше ни разу не попадала на процессор.
Исправление ограничивает проверку особым диапазоном (bba6446):
if (intId >= Gic::SpuriousIntId && intId < Its::LpiIntIdBase)
После него каждая операция ввода-вывода NVMe давала ровно одно LPI 8192, и ITS включили по умолчанию. Ревью нашло в ITS и другое. Его таблицы и очередь команд были объявлены как inner non-cacheable (1<<59) вместо write-back (0x7<<59). А Its::WaitCommands, обнаружив застрявшую очередь команд, писал об этом в лог и возвращался так, будто команда выполнилась. Теперь застрявшая очередь считается ошибкой, и MapEvent больше не отдаёт драйверу прерывание, которое никогда не придёт.
Что прощал QEMU
Ревью разложило находки на три группы. В первую попали ошибки, которые проявляются в QEMU и HVF уже сейчас: барьеры, TLBI и тождественное отображение при загрузке, которое оставляло текст ядра доступным на запись и исполнение по его физическому адресу (W^X везде). В последнюю попали скрытые ошибки, например аллокатор страниц, который раздавал память с деревом устройств как свободную. Средняя группа собрала то, что сломалось бы на настоящем железе, но сходит с рук в QEMU, и читается как список того, что QEMU молча делает за вас. Два примера:
- PSCI в QEMU сохраняет регистры с x4 по x17 при вызове прошивки. Соглашение о вызовах разрешает прошивке их портить, и TF-A (Trusted Firmware-A) так и делает, поэтому
PsciCallтеперь перечисляет их среди затираемых (clobbers) (cpu_start_arm64.cpp). - QEMU нумерует процессоры подряд в младшем поле affinity регистра MPIDR, который идентифицирует CPU, и
SendSgiпринимал это поле за номер процессора. На железе с кластерами IPI ушло бы не тому CPU, поэтому affinity теперь берётся из списка CPU в дереве устройств. Аdsb ishstперед отправкой делает работу, поставленную в очередь для целевого CPU, видимой раньше, чем туда придёт IPI (gicv3.cpp).
Что остаётся
На x86 ничего из этого проявиться не могло. DMA-барьеры там сводятся к барьерам компилятора, invlpg не принимает подсказок, а GIC нет вовсе. Smoke-тесты были зелёными и до ревью, и после него, на x86 и на arm64, под TCG и под HVF.
То, чего не проверяет загрузка, должно держать правило. Тот же коммит записал выводы в правила проекта (CLAUDE.md):
core::sync::atomic::fenceis NOT a substitute: it emitsdmb ish, whose inner-shareable domain does not order against a PCIe master. Consuming device-written state needs a read barrier after the index/phase/OWN check and before the payload reads
Иначе говоря, атомарный барьер не заменяет DMA-барьер, а данные, которые записало устройство, можно читать только после барьера, стоящего за проверкой индекса, бита фазы или бита OWN.
Часть этого теперь обеспечивает сам код. Раз Barrier() больше нет, каждый новый барьер обязан сказать, что он упорядочивает. С kcore::barrier правильный DMA-барьер в Rust заодно и самый короткий в записи. А про барьер чтения после проверки автору драйвера по-прежнему нужно просто знать, поэтому в каждом из трёх мест, где он понадобился, стоит комментарий с объяснением.