В обзоре я лишь грубо набросал цепочку загрузки: BIOS, загрузчик, ядро, рабочий стол. Теперь пройдём её по-настоящему, от самого первого сектора до момента, когда впервые запускается код на C. Это самая техническая часть пока что, и именно поэтому моя любимая, ведь тут происходит та магия, которой потом уже никогда не видно. У меня пока что сделана загрузка через BIOS.
① Первый сектор: BIOS и MBR
Когда ты включаешь компьютер, BIOS почти ничего за тебя не делает. Он грузит ровно 512 байт, первый сектор диска, по адресу 0x7C00 и передает управление по этому адрессу - прыгает (от jump - jmp в ассемблере). И всё. Эти 512 байт, это мой загрузчик stage 1 (MBR).
512 байт, это крохи, поэтому он делает только самый минимум: найти загрузочный раздел с моей файловой системой TriaFS, отыскать в нём файл boot.bios.bin и загрузить его в 0x7E00. Затем прыгает(см. выше) туда. Больше не влезает, да больше и не нужно, первый сектор существует лишь затем, чтобы достать второй. На самом деле если вы работаете с разделами диска, то у вас остается 440 байт для кода.
Долгое время там лежала ListFS, другая, более простая файловая система. Теперь система грузится с TriaFS, моего собственного формата (похожего на EXT) с объектами и экстентами, и весь код чтения в MBR и в загрузчике stage 2 пришлось переселить вместе с ней. Именно здесь это стоило мне ловушки, об этом ниже.
② Stage 2: настоящий загрузчик
По адресу 0x7E00 теперь лежит большой загрузчик, и у него есть достаточно места. Мы всё ещё в реальном режиме: 16 бит, адресуется только чуть меньше 1 МБ, зато с полным доступом к функциям BIOS. Ровно их он сейчас и использует для всего, что позже, в защищённом режиме, уже не получится.
Видеорежим оставить ядру
Пока мы ещё в реальном режиме, можно вежливо спросить графический BIOS (VESA), какие режимы он умеет, и выбрать один с линейным фреймбуфером. У моего загрузчика был для этого список "хотелок", и он даже до сих пор лежит в коде :-) :
vesa_mode_list:
dw 0x414C ; 1920x1080x32 + LFB
dw 0x411B ; 1280x1024x32 + LFB
dw 0x4118 ; 1024x768x32 + LFB
dw 0x4115 ; 800x600x32 + LFB
dw 0 ; конец
Только его больше не вызывают, и причина, это один из тех неприятных уроков. У ядра теперь есть собственный стек дисплея, который обращается к видеокарте напрямую, на VMware например к чипу SVGA II. Если загрузчик сначала ставит VESA-режим, а ядро сразу после перепрограммирует ту же карту, эти двое мешают друг другу, и вскоре после передачи управления экран становился чёрным - приехали.
Поэтому сегодня загрузчик делает противоположное прежнему: он остаётся в простом текстовом режиме VGA и пишет в 0x4800 (никакой не системный адрес, только в моей системе) обнулённую VBE-инфо. Это сигнал ядру, "я ничего не подготовил, графикой займись ты":
call screen_mode80x50 ; остаться в текстовом режиме
mov di, 0x4800 ; обнулить VBE-инфо (mode_number = 0 → VGA)
mov cx, 12
rep stosw ; графику ядро делает само
Дальше ядро через свой HAL дисплея выбирает лучший драйвер (ищет SVGA II, ищет VESA, ищет VGA) и само настраивает фреймбуфер. Адрес 0x4800 остаётся как договорённость, просто поле теперь пустое.
Чтение TriaFS без операционной системы
Тут становится интересн: никакой системы, которая открывает файлы, ещё нет. Загрузчику приходится понимать TriaFS самому. Он читает суперблок (сигнатуру, размер блока, указатель на корневой объект), открывает корневой каталог, проходит по его записям, сравнивает имена и в конце идёт по экстентам найденного файла. Экстент, это просто непрерывный участок блоков (стартовый блок плюс количество), и именно это делает TriaFS здесь таким дружелюбным: вместо того чтобы прыгать от блока к блоку по связному списку, загрузчик за один раз читает целый отрезок на каждый экстент. Так он позже читает с диска kernel.bin, кодом файловой системы написанным вручную в неполном килобайте ассемблера. Драйверы накопителей, кстати, лежат рядом обычными файлами (drivers/ide.km, drivers/ahci.km и так далее), так что загрузчик заходит и в подкаталоги.
Трюк загрузки: перетащить ядро через стену в 1 МБ
Теперь первый из двух хитрых моментов. Ядро весит несколько мегабайт (буду уменьшать путем вывода блоков в загружаемые файлы), но реальный режим видит только память ниже 1 МБ. Куда же его деть?
Решение, это игра туда-сюда (ping-pong). Загрузчик читает часть ядра в буфер по адресу 0x9000 (ниже 1 МБ, дотуда он дотягивается), затем на миг переключается в защищённый режим, копирует кусок через rep movsd в 0x100000 (1 МБ) и выше, переключается обратно в реальный режим и достаёт следующую часть. Так всё ядро по частям переезжает наверх за пределы 1 МБ, хотя реальный режим туда заглянуть, вообще-то, не может.
Обнаружение накопителя
Ядру позже захочется "поговорить" с диском, а для этого нужен драйвер. Какой подходит, зависит от железа. Загрузчик сканирует шину PCI (через порты 0xCF8 и 0xCFC), распознаёт тип контроллера, IDE, AHCI, NVMe, virtio и т.д. (у меня пока что только IDE и AHCI) и грузит подходящий драйвер в 0x400000. Где тот лежит и какого размера, он отмечает в маленьком списке по адресу 0x6000, который передаёт ядру. Как вы заметили, ядро грузится по адресу 0x100000 а дисковый контроллер по адресу 0x400000. Это и есть максимально возможный размер ядра. Но все не так грустно, система то своя. Просто передвигаем загрузку дискового контроллера на 0x500000. Вуаля...
Получить карту памяти у BIOS
Сколько в машине RAM и какие области ядру вообще можно использовать? Это знает только BIOS, и только в реальном режиме. Вызов для этого, INT 0x15 с функцией E820:
mov di, memory_map ; цель: 0x7000
mov eax, 0xE820
mov edx, 0x534D4150 ; "SMAP"
mov ecx, 24 ; размер одной записи
int 0x15 ; на запись: база, длина, тип
Получается список областей памяти, у каждой стартовый адрес, длина и тип (свободно, зарезервировано, ...). Загрузчик кладёт его в 0x7000 и передаёт ядру позже.
③ Переход: реальный режим -> защищённый режим со страничной памятью
Теперь наступает важный момент. Три вещи должны быть подготовлены.
GDT
Защищённый режим требует таблицу сегментов - GDT. Загрузчик настраивает минимальный "плоский" вариант: сегмент кода и сегмент данных, оба на весь адресный диапазон от 0 до 4 ГБ. Это фактически выключает сегментацию, и ты просто считаешь в линейных адресах, как и ожидаешь:
gdt32:
dq 0 ; нулевой дескриптор
dq 0x00CF9A000000FFFF ; код, 0-4 ГБ -> селектор 0x08
dq 0x00CF92000000FFFF ; данные, 0-4 ГБ -> селектор 0x10
Адресное пространство ядра и страничная память
Тут второй хитрый момент, и мой любимый. Ядро слинковано на виртуальный адрес 0xFFC00000, верхние 4 МБ адресного пространства.
ENTRY(_start)
KERNEL_BASE_LD = 0xFFC00000;
SECTIONS {
.text KERNEL_BASE_LD : {
KERNEL_CODE_BASE_LD = .;
*(.text)
*(.code)
*(.rodata*)
}
...
Физически же оно, благодаря трюку загрузки только что, лежит в 0x100000. Как это сходится? Через страничную память.
Загрузчик строит таблицы страниц вручную. Каталог страниц (PDE) по адресу 0x1000 с двумя важными записями:
mov word[0x1000], 0x2000 + 11b ; PDE[0] -> таблица 0x2000: первые 4 МБ 1:1
mov word[0x1FFC], 0x3000 + 11b ; PDE[1023] -> таблица 0x3000: ядро
Первая таблица (0x2000) отображает нижние 4 МБ один к одному, чтобы код загрузчика в тот же миг, когда включается страничная память, не указывал в пустоту. Вторую таблицу (0x3000) загрузчик заполняет записями, отсчитываемыми от физического адреса 0x100000, и вешает её на PDE[1023], на верхние 4 МБ.
Это и есть трюк higher-half: ядро не копируют наверх, это и не сработало бы, по адресу 0xFFC00000 физически нет RAM. Вместо этого самая верхняя запись каталога просто указывает на физические страницы ядра внизу у 1 МБ. Когда процессор обращается к 0xFFC00000, таблица страниц отправляет его в 0x100000. Те же байты, два адреса. Об этом отдельная статья про страничную адресацию.
Щёлкнуть переключателем cr0
Когда таблицы готовы, щёлкаем переключателем :
mov cr3, eax ; eax = 0x1000, каталог страниц
lgdt [gdtr32] ; таблица сегментов
mov eax, cr0
or eax, 0x80000001 ; PE (защищённый режим) + PG (страницы) разом
mov cr0, eax
jmp SEL_CODE32:start32 ; дальний прыжок сбрасывает конвейер,
дальше 32 бита
④ Прыжок наверх
Маленький кусок 32-битного кода ещё работает в загрузчике. Он устанавливает сегментные регистры, определяет стек высоко наверху и упаковывает параметры загрузки в регистры, прежде чем окончательно прыгнуть в ядро:
start32:
mov esp, 0xFFFFDFFC ; стек, тоже в верхнем диапазоне
mov dl, [disk_id] ; с какого диска загрузились
mov esi, memory_map ; карта памяти (0x7000)
mov ebx, module_list ; инфо о драйвере (0x6000)
jmp 0xFFC00000 ; вперёд, в ядро
Этот единственный прыжок на `0xFFC00000`, это граница: до него загрузчик, после него ядро.
⑤ Прибыли: _start
По адресу 0xFFC00000 начинается ядро, причём с короткого куска ассемблера ( startup.i386.asm), прежде чем будет позволено выполниться хоть какому-то C. Он убирает за загрузчиком то, что тот оставил:
_start:
push 0x2
popf ; почистить EFLAGS (загрузчик оставил NT=1, IOPL=3)
push ebx ; module_list ┐
push esi ; memory_map ├ аргументы для kernel_main
push edx ; boot_disk_id ┘
lgdt [gdtr] ; теперь НАСТОЯЩАЯ GDT ядра
jmp 0x08:reload_cs
reload_cs:
; ... обнулить .bss, включить FPU/SSE, настроить TSS ...
call kernel_main
Пара вещей здесь важнее, чем кажется. GDT от загрузчика была голым минимумом, теперь приходит настоящая, с сегментами под пространство пользователя, TSS и доступом per-CPU через gs. , .bss (все переменные, которые должны начинаться с нуля) нужно активно обнулить, потому что при мягком сбросе (soft restart) DRAM сохраняется, и старые значения из прошлой загрузки иначе преследовали бы тебя призраками, одна из моих неприятных ошибок. И только потом, с чистым окружением, _start вызывает первую функцию на C kernel_main.
А дальше?
kernel_main получает ровно три вещи, все подготовлены загрузчиком: с какого диска загрузились, карту памяти и инфо о загруженном драйвере.
void kernel_main(uint16_t boot_disk_id, void* memory_map,BootModuleInfo* boot_module_list)
{
...
Из них ядро строит собственный менеджер памяти, перенимает страничную память (каталог, построенный загрузчиком по адресу 0x1000, пока остаётся каталогом ядра), поднимает драйверы один за другим, и в какой-то момент появляется рабочий стол. Но это уже отдельные истории.
Правда жизни
Код загрузки неблагодарен. Он выполняется ровно один раз, очень рано, без страховки. Нет ни printf, ни отладчика, часто даже экрана нет. Один неверный бит в таблицах страниц и всё что ты получаешь, это чернота. Пару таких чёрных экранов я уже прошёл.
Переход с ListFS на TriaFS подарил мне сразу два таких, оба поучительные. Первый: мой strlen в загрузчике считает через repne scasb, и завершающий нулевой байт при этом проскальзывает. Из "kernel.bin" получилось 11 символов вместо 10. А TriaFS хранит длину имени в каталоге без нуля, так что я сравнивал 10 с 11, и файл считался "не найден", хотя он был на месте. Вылечил один-единственный "dec cx". Второй, это та самая графическая коллизия выше: загрузчик послушно ставил VESA-режим, ядро сразу после загрузки перепрограммировало карту и экран умирал вскоре после передачи управления. И то и другое, это ровно те ошибки, которые не "видишь", а приходится выискивать задом наперёд из поведения системы.
Но когда эта цепочка один раз выстраивается, она на удивление надёжна, это тот самый фундамент, про который я в первой части говорил, что он должен встать раньше всего остального.
◀ Предыдущая статья Содержание Следующая статья ▶
*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением