Консоль для ноутбука без последовательного порта
У Dell Latitude нет ни COM-порта, ни PS/2. Как nos получил на нём пиксельную консоль и USB-клавиатуру, тест, который сам набирает команды, и порт на Rust.
Два входа, и у ноутбука нет ни одного
В QEMU и в облаках на KVM ядро даром получает две вещи: последовательный порт для лога и эмулированный контроллер клавиатуры PS/2, тот самый 8042, для шелла. У Dell Latitude 5480, единственного ноутбука, на котором загружается nos, нет ни того, ни другого. Это машина на Skylake-U, которая загружается через UEFI, и консолью ей могут служить только собственный экран и встроенная клавиатура, подключённая по USB (реальное железо).
Поэтому nos понадобились там два драйвера: текстовая консоль, которая рисует в пиксельном фреймбуфере, настроенном прошивкой, и маленький стек USB для клавиатуры. Оба появились 29 августа 2026 года вместе с исправлениями того, что настоящая машина делает, а QEMU никогда не делал. А 18 сентября мы написали тест, который сам набирает команды в шелле ядра через QEMU, и в тот же вечер перенесли драйвер USB с C++ на Rust.
Под UEFI нет текстового режима
При загрузке через BIOS консоль работает в текстовом режиме EGA: коды символов записываются в память по адресу 0xB8000. В UEFI текстового режима нет. Вместо него протокол Graphics Output оставляет линейный фреймбуфер, обычный массив пикселей, и каждый глиф ядро рисует само. Первые загрузки под UEFI ничего не показывали: ядро продолжало писать в 0xB8000, а на UEFI-машине это обычная оперативная память (8b1ddd1).
GRUB включает видеорежим, только если об этом просит заголовок Multiboot2 в ядре. Поэтому в заголовок добавили запрос фреймбуфера и пометили его необязательным, чтобы при загрузке через BIOS остался текстовый режим:
dw 5 ; type: framebuffer
dw 1 ; flags: optional
dd 20 ; size
dd 1024 ; width
dd 768 ; height
dd 32 ; depth
Экран остался тёмным. Без загруженного видеодрайвера GRUB не может включить режим и молча загружает ядро без фреймбуфера. Как сказано в коммите, отсутствие insmod all_video в grub.cfg и было «настоящей причиной, по которой загрузки под UEFI оставались слепыми даже с тегом в заголовке».
Сама консоль, FbTerm, рисует шрифтом 8x16. Она держит копию сетки символов в оперативной памяти и никогда не читает фреймбуфер обратно, потому что чтение из него медленное. И всё же за логом ядра она не успевала: фреймбуфер был отображён некэшируемым, и каждая запись в него становилась отдельной транзакцией на шине. Теперь один из элементов PAT, таблицы типов памяти в x86, настроен на write-combining, при котором процессор собирает записи в пачки. Фреймбуфер отображается именно с этим типом (6ebed66).
Фреймбуфер сломал даже самотест. Его отображение сдвинуло курсор виртуальных адресов ядра, и одному из следующих тестов понадобилась новая таблица страниц нижнего уровня, а такие таблицы ядро обратно не отдаёт. Счётчик свободных страниц не сошёлся на единицу, и загрузка под UEFI упала в панику задолго до шелла (e6afd72):
TestPageAllocator: AllocMapPages(32) free count mismatch: 257360 expected 257361
PANIC:Main2():main.cpp,808: Self test failed
Что оставила прошивка
В QEMU ядро получает локальный APIC, у которого все источники прерываний замаскированы. Прошивка настоящего ноутбука передаёт его в режиме virtual wire: через него идут прерывания старого контроллера 8259, а таймер, датчик температуры и счётчики производительности остаются включёнными. В nos любой незанятый вектор попадал в общую заглушку, которая паниковала и даже не называла номер вектора, а это, по словам коммита, «фатально на настоящем ноутбуке». Теперь у каждого вектора своя заглушка, о случайном прерывании ядро сообщает с номером вектора в последовательный порт и на экран, а при включении локального APIC эти источники сначала маскируются (f584b82).
Без последовательного порта ядро теряло не только вывод, но и время. Оно всё равно писало каждую строку лога в несуществующий UART и на каждый символ крутилось в цикле до миллиона итераций с pause, дожидаясь передатчика, который так ни разу и не сообщил, что свободен. По словам коммита, это и было «главной причиной, по которой загрузка ползла» на ноутбуке. Теперь драйвер сначала проверяет scratch-регистр порта и пропускает порт, который не отвечает (3006586).
Стек USB ради одной клавиатуры
xHCI — стандартный регистровый интерфейс хост-контроллера USB. Программа и контроллер общаются через кольца в памяти из 16-байтных TRB (transfer request block): кольцо команд, кольцо событий, в которое пишет сам контроллер, и по кольцу передач на каждую конечную точку. Сами устройства и их конечные точки контроллер видит через контексты, ещё одни структуры в памяти. Запись в регистр doorbell сообщает контроллеру, что в кольце появилась работа (ff81891).
Сначала контроллер нужно забрать у прошивки. До загрузки ОС прошивка сама обслуживает USB-клавиатуру и может держать на контроллере включёнными SMI (system management interrupts, прерывания режима управления системой). Тогда её код выполняется всякий раз, когда ядро трогает регистр. Передача идёт через два бита владения в расширенной возможности контроллера USB Legacy Support. Ядро выставляет свой бит, ждёт, пока прошивка сбросит свой, и после этого выключает SMI. Некоторые прошивки контроллер так и не отпускают, поэтому у ожидания есть срок:
if now_ms() >= deadline {
/* Some firmware never releases the semaphore.
* Clear its bit by hand: the controller is about
* to be reset anyway, and leaving SMIs armed is
* worse. */
trace!(0, "Xhci: BIOS handoff timed out, forcing ownership");
let cur = self.cap_read32(off);
self.cap.write32(off, (cur & !LEGACY_BIOS_OWNED) | LEGACY_OS_OWNED);
break;
}
Клавиатура работает по загрузочному протоколу HID (boot protocol). Это фиксированный отчёт из 8 байт, настолько простой, что его читает даже BIOS, не разбирая HID-дескриптор отчётов устройства. В байте 0 лежат модификаторы, байт 1 зарезервирован, а в байтах со 2-го по 7-й — коды до шести зажатых клавиш. Отчёт перечисляет клавиши, которые зажаты сейчас, поэтому драйвер сравнивает его с предыдущим и так находит нажатия. Коды HID он переводит в скан-коды PS/2, которые понимает остальное ядро, так что для шелла 0x0E по-прежнему означает backspace. Автоповтор драйвер тоже делает сам, потому что клавиатура в загрузочном протоколе клавиши не повторяет.
Обработчика прерываний у драйвера нет. Задача usb просыпается каждые 4 мс, разбирает кольцо событий, замечает горячее подключение и ставит в очередь запрос следующего отчёта. Так клавиатура не зависит от маршрутизации прерываний на незнакомом железе. У каждого ожидания в драйвере есть таймаут, поэтому мёртвое устройство не может повесить задачу.
Ещё драйвер обходит хабы глубиной до четырёх уровней и находит каждое устройство по его route string, то есть по цепочке портов хабов на пути к нему. Уже на следующий день этот путь отработал на Hetzner EX44. В логе загрузки, который вынес с машины netconsole, оказались 26 корневых портов, три хаба и USB-клавиатура за одним из них.
Тест, который печатает
Через три недели в drivers/usb/ было 3606 строк C++, которых не касался ни один тест: ни один smoke-тест загрузки не подключает USB-контроллер. А на ноутбуке этот драйвер — единственный способ попасть внутрь, поэтому перед переносом мы написали для него гейт, то есть тест, который порт обязан пройти (33c24ab). scripts/usb-test.py загружает обычный ISO с эмулированным контроллером xHCI, клавиатурой на корневом порту, хабом со второй клавиатурой за ним и мышью:
argv = ["qemu-system-x86_64", "-m", "1024", "-smp", "2", "-cdrom", iso,
"-device", "qemu-xhci,id=xhci",
"-device", "usb-kbd,bus=xhci.0,port=1,id=kbd0",
"-device", "usb-hub,bus=xhci.0,port=2,id=hub0",
"-device", "usb-kbd,bus=xhci.0,port=2.1,id=kbd1",
"-device", "usb-mouse,bus=xhci.0,port=3,id=mouse0",
"-serial", "file:" + log, "-display", "none",
"-monitor", "unix:%s,server,nowait" % monitor_path]
Дальше скрипт печатает. В мониторе QEMU, его управляющей консоли, есть команда sendkey, которая нажимает клавишу на эмулированной клавиатуре, и скрипт отправляет по одной клавише каждые 80 мс, как это делал бы человек. Ответ шелла он читает из последовательного лога. Семнадцать проверок охватывают перечисление устройств через хаб, набранные команды и их вывод, счётчик отчётов, ошибки передачи и клавиатуру, подключённую после загрузки.
Тест построен вокруг отказа, который выглядит как успех, и контрольный эксперимент показал, что такой отказ он ловит. Мы убрали единственную запись в doorbell в SubmitReport, ту строку, которая сообщает контроллеру, что буфер для отчёта уже ждёт. Лог загрузки остался безупречным, шесть проверок прошли, но тест упал ровно на проверке a command typed on the usb keyboard reaches the shell. На ноутбуке такой баг означал бы, что клавиатуры нет вовсе, и больше ничто в наборе тестов этого бы не заметило (тесты и гейты).
Перенос на Rust
Код на C++ превратился в крейт src/rust/drivers/usb, где отдельные файлы отведены под регистры, кольца, дескрипторы USB, состояние устройств, декодер HID и контроллер (00a24ef). С первого запуска порт прошёл 17 проверок из 17, а его лог загрузки построчно совпал с тем, что печатал C++, вплоть до номеров слотов и route string.
Две вещи нарочно сделаны иначе. Чтобы прочитать дескриптор, C++ накладывал на DMA-буфер упакованную структуру. Rust читает каждое поле из среза байтов по именованному смещению, потому что упакованная структура поверх DMA — это невыровненное чтение, которое только и ждёт своего часа. А команда usb в C++ читала массив устройств мимо блокировки, и шелл мог увидеть счётчик отчётов посреди записи. Теперь задача USB сама публикует то, что печатает команда, под отдельной блокировкой.
В порте ещё оставалось восемь мест с unsafe, и два следующих коммита убрали их все той же ночью. Первый дал опубликованному состоянию блокировку, которая владеет своими данными (01b42d6). Второй заменил три строки адресной арифметики, написанные так же, как в C++ (bd03cfb):
let op = MmioRegion::new(unsafe { base.add(cap_length as usize) }, size);
let rt = MmioRegion::new(unsafe { base.add(rts_off) }, size);
let db = MmioRegion::new(unsafe { base.add(db_off) }, size);
Каждый блок регистров начинается со смещения, которое сообщает контроллер, но каждое окно получило размер всего BAR, области PCI со всеми регистрами контроллера, поэтому его проверка границ заходила за конец отображения. Теперь MmioRegion::window заканчивается там же, где отображение, а контроллер, который указывает блок за пределами своего BAR, драйвер отвергает. Тот же коммит перевёл таблицу контроллеров и дескриптор задачи с атомарных указателей на TryLock. Это блокировка из kcore, которую никто не ждёт и владелец которой может спать, как при сбросе порта. С тех пор в драйвере нет ни одного unsafe.
Чего QEMU никогда не просит
Гейт покрывает то, что делает xHCI в QEMU, то есть счастливый путь. Контроллеру Dell нужна передача от BIOS, он может вернуть другой размер контекста и другое число scratchpad-буферов (это память, которую контроллер просит у ОС для собственных нужд), а порты сбрасывает с настоящими задержками. Коммит порта говорит об этом прямо: эти пути «перенесены аккуратно, но не проверены, пока ноутбук не загрузит этот код».
Известных пробелов два: клавиатура, которая умеет только протокол отчётов HID, игнорируется, а светодиодами клавиатуры драйвер не управляет. Ни то, ни другое не мешает набирать команды в шелле. Важнее другое: баг, который закрывает единственный вход в ноутбук, теперь роняет тест на любой машине с QEMU, ещё до того, как доберётся до ноутбука.