Отладка nos с GDB
GDB через заглушку QEMU, отчёт о панике и бэктрейс, уровни трассировки, поиск причины неудачной загрузки и лог с машины без последовательного порта.
В итоге вы сможете остановить работающий nos в GDB и увидеть, где находится каждый CPU, прочитать отчёт о панике, включить подробный лог подсистемы, сузить круг причин неудачной загрузки параметрами ядра и получить лог с машины без последовательного порта.
Понадобится:
- собранное ядро; скрипты GDB загружают
bin/kernel64.elfилиbin/kernel-arm64.elf; - QEMU (см. «Запуск nos в QEMU»);
- GDB для x86-64. Для arm64 — GDB с поддержкой aarch64 (
gdb-multiarchилиaarch64-elf-gdb) либо LLDB.
Подключение GDB через заглушку QEMU
x86-64
qemu.sh, qemu-disk.sh и run.sh запускают QEMU с -s, а это GDB-заглушка на TCP-порту 1234. Пока один из них работает:
./scripts/gdb64.sh
Скрипт запускает GDB с командами из scripts/gdb-debug64:
set architecture i386:x86-64
target remote 127.0.0.1:1234
symbol-file bin/kernel64.elf
set print pretty on
set height 20000
thread apply all bt
QEMU показывает GDB каждый виртуальный CPU как отдельный поток, поэтому последняя команда выводит бэктрейс каждого CPU. Именно на него стоит смотреть первым делом, когда машина перестала отвечать. Затем GDB оставляет вас в своём приглашении, а машина стоит. info threads, thread N, bt, break и continue работают как обычно, а Ctrl-C снова останавливает машину. То же самое без скрипта:
gdb -ex "symbol-file bin/kernel64.elf" \
-ex "set architecture i386:x86-64" \
-ex "target remote :1234"
C++ компилируется с полной отладочной информацией (-g3). Rust-staticlib — релизная сборка без неё, поэтому во фрейме Rust GDB покажет имя функции, но не строку исходника.
Чтобы начать отладку с первой инструкции, добавьте к -s ключ QEMU -S: тогда QEMU ничего не выполняет, пока не подключится GDB. qemu.sh не принимает дополнительных аргументов, поэтому запустите его команду QEMU сами:
qemu-system-x86_64 -smp 2 -cdrom nos.iso -serial file:nos.log -s -S
arm64
qemu-arm64.sh передаёт свои аргументы QEMU:
./scripts/qemu-arm64.sh -s -S
Затем в другом терминале:
./scripts/gdb-arm64.sh
Скрипт запускает gdb-multiarch или, если установлен он, aarch64-elf-gdb с командами из scripts/gdb-debug-arm64. Этот файл делает то же, что и для x86-64, но с bin/kernel-arm64.elf. Чтобы запустить ядро, введите continue. Если ни один из этих GDB не установлен, скрипт сообщит об этом и предложит LLDB:
lldb bin/kernel-arm64.elf
В приглашении LLDB подключитесь командой gdb-remote 1234.
Как читать отчёт о панике
Отчёт о панике уходит в последовательный порт и на экран, а также в netconsole и дисковый лог, если они включены. CPU, на котором случилась паника, останавливает остальные CPU, и машина стоит с отчётом на консоли, если только её не загрузили с panic=N: тогда она перезагружается через N секунд (1–3600) после отправки отчёта. Вот как выглядит отчёт — построчно, как его печатает kernel/panic.cpp:
PANIC:<function>():<file>,<line>: <message>
CPU: <index>
Task: pid <pid> name <task>
Backtrace:
[0] 0x<address> <function>+0x<offset>
[1] 0x<address> <function>+0x<offset>
Modules: <name> 0x<base>+0x<size>
Cpu <index> backtrace:
[0] 0x<address> <function>+0x<offset>
- Первая строка говорит, где случилась паника и почему. Нарушенный инвариант, пойманный
BugOn, даётBUG. Исключение процессора показывает то, что сообщил CPU:EXC: PageFault cr2 0x... rip 0x... rsp 0x... err 0x... cr3 0x...на x86-64 илиsync exception: ... (esr 0x... ec 0x... iss 0x...) far 0x... elr 0x...на arm64. Паника в Rust начинается сRUST PANIC: panicked at <file>:<line>:<column>:, за которым идёт её сообщение. Backtrace— стек CPU, на котором случилась паника. Имена ядро разрешает само по встроенной в образ таблице символов, так что файл символов не нужен. Каждый фрейм — адрес возврата, и он назван по вызову, который ему предшествует.- Фрейм в коде модуля заканчивается именем модуля в квадратных скобках. Строка
Modules:, которая печатается, если загружен хоть один модуль, даёт диапазон адресов каждого модуля — по нему можно определить фрейм, который больше ничем не назван. - На x86-64 ядро запрашивает стеки остальных CPU через NMI. Каждый ответ идёт следом как
Cpu N backtrace:, а CPU, который не ответил, называется по номеру (Cpu N did not answer the NMI). - Фрейм, напечатанный голым адресом, лежит вне кода ядра и всех загруженных модулей.
Пример из раздела Backtraces:
Backtrace:
[0] 0xFFFF800012DA1C40 mod_hello::init::{closure#0}+0x3c [hello]
[1] 0xFFFF80000104A2F1 Kernel::Cmd::DispatchDynamic+0xd1
...
Modules: hello 0xFFFF800012DA0000+0x5000
Некоторые зависания превращаются в панику, чтобы о них было сообщение. Спинлок, удерживаемый слишком долго, вызывает панику Spinlock 0x... stuck, held ... ns. Если на рассылку TLB shootdown нет ответа десять секунд, ядро паникует и называет CPU, которые не ответили.
Чтобы посмотреть на отчёт о панике на одноразовой виртуальной машине, в шелле есть panic (прямая паника), panic pf (page fault) и panic ud (недопустимая инструкция).
Если задача жива, но застряла, ps покажет список задач, а bt <pid> — бэктрейс одной из них: команда опрашивает CPU, на котором задача выполняется, через IPI. stacks показывает самую глубокую точку, которой достигал каждый стек.
Лог ядра и уровни трассировки
Каждая строка трассировки имеет вид level:seconds.microseconds:function(),file,line: message. Она попадает в лог ядра, в последовательный порт, а также в netconsole и дисковый лог, если они включены. Когда шелл запущен, строки трассировки больше не выводятся в консоль. Читайте их через dmesg [lines] [filter], например dmesg 40 или dmesg 100 virtio.
Строка печатается, если её уровень не выше текущего уровня трассировки, который бывает от 0 до 5 и по умолчанию равен 1. Изменить уровень можно двумя способами:
loglevel=Nв командной строке ядра. Только так можно получить подробности о самой загрузке.loglevel Nв шелле работающего ядра.loglevelбез аргумента показывает текущий уровень. Команда работает и через UDP-шелл, и по SSH — это важно на машине, где другой консоли нет.
Уровни самых многословных подсистем — константы в начале src/cpp/kernel/trace.h:
| Уровень | Константы | Что видно на этом уровне |
|---|---|---|
| 0 | ExcLL, AcpiLL, CmdLL, LapicLL, IoApicLL |
печатается всегда |
| 3 | KbdLL, UsbLL, TestLL, IcmpLL, TaskLL, ModuleLL |
клавиатура и USB, тест многозадачности, три строки на каждый ping, создание и удаление каждой задачи, к чему привязан каждый импорт модуля |
| 4 | PageAllocatorLL, AllocatorLL, PoolLL, MmIoLL |
аллокаторы (каждое выделение памяти) и MMIO; очень многословно |
Чтобы слышать одну подсистему без остальных, понизьте её константу и пересоберите ядро. Код на Rust пишет в лог через trace!(level, ...) по той же шкале.
Поиск причины неудачной загрузки
Где задаётся командная строка ядра, зависит от способа загрузки:
| Загрузка | Где | Что затем |
|---|---|---|
| x86-64 с ISO | строка multiboot2 в build/grub.cfg |
пересобрать nos.iso |
| x86-64 с образа диска | строка multiboot2 в build/grub-disk.cfg |
заново запустить ./scripts/build-disk.sh |
| arm64 в QEMU | APPEND= в scripts/qemu-arm64.sh |
загрузиться снова |
| сервер, развёрнутый из системного образа | ключ --cmdline "ARGS" у nos-install |
см. «Установка nos на выделенный сервер» |
Ничто в командной строке не останавливает загрузку: неизвестный ключ или значение ядро записывает в лог и пропускает, а длина строки ограничена 255 символами. Исключайте подсистемы из загрузки по одной:
smp=off: только загрузочный CPU. Если так ядро загружается, проблема в запуске остальных CPU.maxcpus=2: загрузочный CPU и ещё один, потом больше. SMP остаётся, но число запускаемых CPU ограничено.loglevel=4: всё, что делает загрузка, включая выделения памяти.- Затем подсистемы, по одной:
usb=off(x86-64): не инициализировать xHCI;its=off(arm64): отключить GICv3 ITS; MSI для PCIe деградирует, а virtio-mmio он не нужен;hwrng=off: не использовать инструкцию процессора для случайных чисел;rc=off: не выполнять/etc/rc— так можно обойти строку в нём, из-за которой машина не поднимается;dhcp=off: полностью отключить DHCP;console=serialилиconsole=vga: оставить шелл только на одной консоли.
На x86-64 загрузка, которая падает раньше, чем появилась хоть какая-то консоль, печатает на экране ERR: и цифру. Это значит, что не прошла одна из ранних проверок в boot64.asm: Multiboot, CPUID или long mode.
Все параметры перечислены в разделе Kernel parameters, а что происходит между маркерами — в разделе Boot.
Лог с машины без последовательного порта
На ноутбуке, облачной виртуальной машине или выделенном сервере последовательного порта, который можно читать, часто нет. Вот что nos предлагает взамен:
-
Netconsole — для загрузки, которая доходит до сети. Загрузите ядро с
netconsole=ip:port dhcp=auto nctail=64и запустите на принимающей машинеpython3 scripts/netconsole.py -o boot.log.nctail=64заставляет машину, у которой после появления связи осталось мало времени, потратить его на самые свежие строки. Лог приходит с первой строки загрузки, и в него попадает отчёт о панике, если MAC-адрес сборщика уже известен. Настройка описана в руководстве «Подключение к работающему nos». -
Дисковый лог — для загрузки, которая останавливается раньше, чем поднимется сеть. Под Linux на этой машине подготовьте область для лога на свободном разделе, загрузите nos с
disklog=on, а при следующей загрузке Linux прочитайте лог:sudo ./scripts/disklog.py format /dev/nvme0n1p1 sudo ./scripts/disklog.py read /dev/nvme0n1p1formatотказывается работать с устройством, на котором, судя по всему, ещё есть файловая система, член RAID или swap. Безdisklog=onnos ради этого даже не читает диски. С ним, но без подготовленной области загрузка пишет в логDiskLog: disklog=on, but no prepared area on any diskи продолжается. Команда шеллаdisklogпоказывает, что найдено. -
Экран.
trace=vga(x86-64) выводит лог загрузки на экран, а не только в последовательный порт. На машине без UART так и так происходит по умолчанию. -
panic=N. Машина, у которой единственная консоль — сеть, возвращается через N секунд после паники, а не стоит там, где её никто не видит.
Как эти средства применялись на трёх машинах, где работает nos, рассказано в разделе Real hardware.
Сборка с проверками неопределённого поведения
Загрузка, которая зависает в каком-то постороннем месте и только на одной архитектуре, может оказаться неопределённым поведением, проявившимся далеко от своей причины. Соберите ядро с включёнными проверками и загрузите так же:
make nocheck UBSAN=1 RUSTUB=1
./scripts/smoke-test.sh --skip-build
Первая же операция с неопределённым поведением в C++ или первое нарушенное предусловие unsafe-операции, которое core найдёт в Rust, вызывает панику с указанием места. С ubsan=warn в командной строке ядро печатает все отчёты C++ за загрузку — каждое место один раз, с бэктрейсом, — а не останавливается на первом. Подробнее — в руководстве «Сборка nos».
Самотесты при загрузке
Самотесты выполняются при каждой загрузке из Test() в src/cpp/kernel/test.cpp, и неудачный тест вызывает панику Self test failed. Чтобы запустить только один тест, измените Test() так, чтобы она вызывала только эту функцию, затем пересоберите ядро, загрузите его и следите за nos.log.
Решение проблем
target remoteне подключается. QEMU запущен без-s, или порт 1234 уже занят другим QEMU: пользоваться им одновременно может только один.gdb-arm64.shвыводитNo aarch64 GDB found. Установитеgdb-multiarchилиaarch64-elf-gdbлибо используйте LLDB, как показано выше.- Нет файла
bin/kernel-arm64.elf. Сборка ISO для x86-64 удаляет и заново создаётbin/. Тот же ELF лежит в корне репозитория, так что скопируйте его обратно:cp kernel-arm64.elf bin/. - Символы в GDB не совпадают с тем, что выполняется. Загружайте ELF из той же сборки, что и загруженный образ. После пересборки перезапустите и QEMU, и GDB.
loglevel 4всё заливает. На этом уровне трассируется каждое выделение памяти, и каждая строка ещё и уходит в последовательный порт. Вернитесь к уровню по умолчанию командойloglevel 1.- Отчёт о панике так и не дошёл до netconsole. Во время паники отчёт уходит, только если MAC-адрес сборщика есть в кэше ARP. Загружайтесь дополнительно с
disklog=onи подготовленной областью или используйтеpanic=N, чтобы машина вернулась.
Что дальше
- Запуск гейтов: проверка для каждой подсистемы — для сбоев, которых загрузка не замечает
- Подключение к работающему nos: UDP-шелл, SSH и netconsole подробно
- Запуск nos в QEMU
- Полные справочники: Debug, Kernel parameters, Profiler