[Из песочницы] OS1: примитивное ядро на Rust для x86
Я решил написать статью, а если получится — то и серию статей, чтобы поделиться своим опытом самостоятельного исследования как устройства Bare Bone x86, так и организации операционных систем. На данный момент мою поделку нельзя назвать даже операционной системой — это небольшое ядро, которое умеет загружаться из Multiboot (GRUB), управлять памятью реальной и виртуальной, а также выполнять несколько бесполезных функций в режиме многозадачности на одном процессоре.
При разработке я не ставил себе целей написать новый Linux (хотя, признаюсь, лет 5 назад мечтал об этом) или впечатлить кого-либо, поэтому особо впечатлительных прошу дальше не смотреть. Что мне на самом деле захотелось сделать — разобраться, как устроена архитектура i386 на самом базовом уровне, и как именно операционные системы делают свою магию, ну и покопать хайповый Rust.
В своих заметках я постараюсь поделиться не только исходными текстами (их можно найти на GitLab) и голой теорией (ее можно найти на многих ресурсах), но и тем путем, который я прошел, чтобы найти неочевидные ответы. Конкретно в этой статье я расскажу о компоновке файла ядра, его загрузке и инициализации.
Мои цели — структурировать информацию у себя в голове, а так же помочь тем, кто идет похожим путем. Я понимаю, что аналогичные материалы и блоги уже есть в сети, но чтобы прийти к моему текущему положению, мне пришлось долго собирать их воедино. Всеми источниками (во всяком случае, которые вспомню), я поделюсь прямо сейчас.
Большую часть я черпал, конечно же, с отличного ресурса OSDev — как с вики, так и с форума. Во вторую очередь я назову Филиппа Оппермана с его блогом — большое количество информации о связке Rust и железа.
Некоторые моменты подсмотрены в ядре Linux, Minix — не без помощи специальной литературы, такой как книга Таненбаума »Операционные системы. Разработка и реализация», книга Роберта Лава »Ядро Linux. Описание процесса разработки». Сложные вопросы об организации архитектуры x86 решались при помощи мануала »Intel 64 and IA-32 Architectures Software Developer«s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide». В понимании формата бинарников, компоновки — гайды по ld, llvm, nm, nasm, make.
Я осознаю, что я не хороший программист на Rust, более того — это мой первый проект на этом языке (не лучший способ начать знакомство, не так ли?). Поэтому реализация может показаться вам совершенно некорректной — заранее хочу попросить о снисхождении к моему коду и буду рад комментариям и предложениям. Если уважаемый читатель сможет подсказать мне, куда и как двигаться дальше — также буду очень благодарен. Некоторые фрагменты кода могут быть скопированы из туториалов как есть и незначительно модифицированы, но к таким участкам я постараюсь дать максимально понятные пояснения, чтобы у вас не возникли те же вопросы, что и у меня при их разборе. Я так же не претендую на использование верных подходов в проектировании, поэтому если мой менеджер памяти вызовет у вас желание писать гневные комментарии — я прекрасно понимаю почему.
Итак, я начну с погружения в средства разработки, которыми я пользовался. В качестве среды я выбрал хороший и удобный редактор кода VS Code с плагинами для Rust и отладчика GDB. VS Code иногда не очень хорошо дружит с RLS, особенно при переопределении его в конкретном каталоге, поэтому после каждого обновления Rust nightly мне приходилось переустанавливать RLS.
Язык Rust был выбран по нескольким причинам. Во-первых, его растущая популярность и приятная философия. Во-вторых, его возможности работать с низким уровнем, но с меньшей вероятностью «выстрелить себе в ногу». В-третьих, как любитель Java и Maven, я очень пристрастился к системам сборки и управлениям зависимостями, а cargo уже встроен в toolchain языка. В-четвертых, мне просто захотелось чего-то нового, не такого как Си.
Для низкоуровневого кода я взял NASM, т.к. я уверенно себя чувствую в Intel-синтаксисе, а также мне комфортно работать с его директивами. Я осознанно отказался от ассемблерных вставок в Rust, чтобы явственно разделить работу с железом и высокоуровневую логику.
В качестве общей сборки и компоновки использованы Make и линкер из поставки LLVM LLD (как более быстрый и качественный линкер) — это дело вкуса. Можно было обойтись и build-скриптами для cargo.
Для запуска использован Qemu — мне нравится его скорость, интерактивный режим и возможность прицепить GDB. Чтобы загружаться и сразу иметь всю информацию о железе — конечно же GRUB (Legacy более просто в организации заголовка, так что берем его).
Как ни странно, для меня это оказалось одной из самых сложных тем. Было крайне тяжело осознать после долгих разбирательств с сегментными регистрами x86, что сегменты и секции — это не одно и то же. В программировании под существующую среду нет необходимости задумываться о том, как разместить программу в памяти — для каждой платформы и формата компоновщик уже имеет готовый рецепт, поэтому писать скрипт линкера нет необходимости.
Для голого железа наоборот необходимо указать, как именно размещать и адресовать программный код в памяти. Здесь я хочу подчеркнуть, что речь идет о линейном (виртуальном) адресе, при помощи механизма страниц. OS1 использует страничный механизм, но отдельно я остановлюсь на нем в соответствующем разделе статьи.
Логический, линейный, виртуальный, физический адреса. Я сломал голову с на этом вопросе, поэтому за деталями хочу адресовать к этой отличной статье
Для операционных систем, которые используют страничную организацию памяти, в 32-х разрядной среде каждой задаче доступно 4 ГиБ адресного пространства памяти, даже если у вас установлено 128 МиБ ОЗУ. Это происходит как раз за счет страничной организации памяти, отсутствие страниц в оперативной памяти обрабатывается соответствующим образом.
При этом на самом деле приложениям обычно доступно несколько меньше, чем 4 ГиБ. Это объясняется тем, что ОС должна обслуживать прерывания, системные вызовы, а значит — как минимум их обработчики должны находиться в этом адресном пространстве. Перед нами встает вопрос: куда именно в эти 4 ГиБ поместить адреса ядра, чтобы программы могли корректно работать?
В современном мире программ используется такая концепция: каждая задача считает, что она безраздельно властвует процессором и является единственной запущенной программой на компьютере (на этом этапе мы не говорим про коммуникацию между процессами). Если посмотреть, как именно компиляторы собирают программы на этапе линковки, то окажется, что они начинаются с линейного адреса ноль или около ноля. Это значит, что если образ ядра займет пространство памяти около ноля, программы собранные таким образом не смогут исполняться, любая jmp инструкция программы приведет ко входу в защищенную память ядра и ошибке защиты. Поэтому если мы хотим в будущем пользоваться не только самописными программами, разумно отдать приложению как можно больший кусок памяти около ноля, а образ ядра разместить повыше.
Эта концепция называется Higher-half kernel (здесь отсылаю вас к osdev.org, если хотите сопутствующую информацию). Какой участок памяти выбрать — зависит только от ваших аппетитов. Кому-то достаточно 512 МиБ, я же решил отхватить себе 1 ГиБ, поэтому мое ядро размещается по адресу 3 ГиБ + 1 МиБ (+ 1 МиБ необходим, чтобы соблюсти границы lower-higher memory, GRUB загружает нас в физическую память после 1 МиБ).
Так же для нас важно указать точку входа в наш исполняемый файл. Для моего исполняемого файла это будет функция _loader, написанная на ассемблере, на которой я остановлюсь подробнее в следующем разделе.
Вы знали, что вам всю жизнь врали насчет того, что main () является точкой входа в программу? На самом деле, main () — это конвенция языка Си и языков, им порожденных. Если покопаться, то выяснится примерно следующее.
Во-первых, каждая платформа имеет свою спецификацию и название точки входа: для linux это обычно _start, для Windows — mainCRTStartup. Во-вторых, эти точки можно переопределить, но тогда не получится пользоваться прелестями libc. В-третьих, эти точки входа по умолчанию предоставляет компилятор и они находятся в файлах crt0…crtN (CRT — C RunTime, N — количество аргументов main).
Собственно что делают компиляторы типа gcc или vc — выбирают платформозависимый скрипт линковки, в котором прописана стандартная точка входа, выбирают нужный объектный файл с готовой функцией инициализации рантайма Си и вызовом функции main и производят линковку с выходом в виде файла нужного формата со стандартной точкой входа.
Так вот, для наших целей стандартную точку входа и инициализацию CRT нужно отключать, так как у нас нет совсем ничего, кроме голого железа.
Что еще необходимо знать для линковки? Как будут располагаться секции данных (.rodata, .data), неинициализированных переменных (.bss, common), а также помнить, что GRUB требует расположения заголовков мультизагрузки в первых 8 КиБ бинарника.
Итак, теперь мы можем написать скрипт линковщика!
ENTRY(_loader)
OUTPUT_FORMAT(elf32-i386)
SECTIONS {
. = 0xC0100000;
.text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) {
*(.multiboot1)
*(.multiboot2)
*(.text)
}
.rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) {
*(.rodata*)
}
.data ALIGN (4K) : AT(ADDR(.data) - 0xC0000000) {
*(.data)
}
.bss : AT(ADDR(.bss) - 0xC0000000) {
_sbss = .;
*(COMMON)
*(.bss)
_ebss = .;
}
}
Как уже было сказано выше, спецификация Multiboot требует, чтобы заголовок находился в первых 8 КиБ загрузочного образа. Полную спецификацию можно посмотреть здесь, я же остановлюсь только на интересующих подробностях.
- Должно соблюдаться выравнивание по 32 бита (4 байта)
- Должно присутствовать магическое число 0×1BADB002
- Нужно указать мультизагрузчику, какую информацию мы хотим получить и как размещать модули (в моем случае я хочу, чтобы модуль ядра был выровнен на страницу 4 КиБ, а также получить карту памяти, чтобы сэкономить себе время и силы)
- Предоставить контрольную сумму (контрольная сумма + магическое число + флаги должны давать ноль)
MB1_MODULEALIGN equ 1<<0
MB1_MEMINFO equ 1<<1
MB1_FLAGS equ MB1_MODULEALIGN | MB1_MEMINFO
MB1_MAGIC equ 0x1BADB002
MB1_CHECKSUM equ -(MB1_MAGIC + MB1_FLAGS)
section .multiboot1
align 4
dd MB1_MAGIC
dd MB1_FLAGS
dd MB1_CHECKSUM
После загрузки Multiboot гарантирует некоторые условия, которые мы должны учесть.
- В регистре EAX находится магическое число 0×2BADB002, которое говорит, что загрузка прошла успешно
- В регистре EBX находится физический адрес структуры с информацией о результатах загрузки (о ней поговорим значительно позже)
- Процессор переведен в защищенный режим, страничная память выключена, сегментные регистры и стек находятся в неопределенном (для нас) состоянии, GRUB использовал их для своих нужд и нужно их переопределить как можно скорее.
Первое, что нам необходимо сделать — включить страничную организацию памяти, настроить стек и уже наконец передать управление в высокоуровневый код Rust.
Я не буду детально останавливаться на страничной организации памяти, Page Directory и Page Table, ибо про это написаны отличные статьи (одна из них). Главное, чем хочу поделиться — страницы это не сегменты! Пожалуйста, не повторяйте мою ошибку и не грузите адрес таблицы страниц в GDTR! Для таблицы страниц предназначен регистр CR3! Страница может иметь различный размер в разных архитектурах, для простоты работы (чтобы иметь только одну таблицу страниц), я выбрал размер в 4 МиБ за счет включения PSE.
Итак, мы хотим включить виртуальную страничную память. Для этого нам нужна таблица страниц, и ее физический адрес, загруженный в CR3. При этом наш бинарный файл был слинкован так, чтобы работать в виртуальном адресном пространстве со смещением в 3 ГиБ. Это значит, что все адреса переменных и метки имеют смещение в 3 ГиБ. Таблица страниц — всего лишь массив, в котором по индексу страницы находится ее реальный адрес, выровненный на размер страницы, а также флаги доступа и состояния. Так как я использую 4 МиБ страницы, мне нужно всего одну таблицу страниц PD с 1024 записями:
section .data
align 0x1000
BootPageDirectory:
dd 0x00000083
times (KERNEL_PAGE_NUMBER - 1) dd 0
dd 0x00000083
times (1024 - KERNEL_PAGE_NUMBER - 1) dd 0
Что в таблице?
- Самая первая страница должна вести на текущий участок кода (0–4 МиБ физической памяти), так как все адреса в процессоре физический и трансляция в виртуальные еще не выполняется. Отсутствие этого дескриптора страницы приведет к немедленному краху, так как процессор не сможет взять следующую инструкцию после включения страниц. Флаги: бит 0 — таблица присутствует, бит 1 — страница записываемая, бит 7 — размер страницы 4 МиБ. После включения страниц запись обнулим.
- Пропуск до 3 ГиБ — нули гарантируют, что страницы нет в памяти
- Отметка 3 ГиБ — наше ядро в виртуальной памяти, ссылающееся на 0 в физической. После включения страниц будем работать именно здесь. Флаги аналогичны первой записи.
- Пропуск до 4 ГиБ.
Итак, мы объявили таблицу и теперь хотим загрузить ее физический адрес в CR3. Не забываем про смещение адреса в 3 ГиБ на этапе линковки. Попытка загрузить адрес как есть отправит нас по реальному адресу 3 ГиБ + смещение переменной и приведет к немедленному краху. Поэтому берем адрес BootPageDirectory и вычитаем из него 3 ГиБ, кладем в CR3. Включаем PSE в регистре CR4, включаем работу со страницами в регистре CR0:
mov ecx, (BootPageDirectory - KERNEL_VIRTUAL_BASE)
mov cr3, ecx
mov ecx, cr4
or ecx, 0x00000010
mov cr4, ecx
mov ecx, cr0
or ecx, 0x80000000
mov cr0, ecx
Пока все идет хорошо, но как только мы обнулим первую страницу, чтобы окончательно перейти в верхнюю половину 3 ГиБ, все рухнет, так как в регистре EIP все еще лежит физический адрес в районе первого мегабайта. Чтобы поправить это, проводим простую манипуляцию: в ближайшем месте ставим метку, загружаем ее адрес (он уже со смещением в 3 ГиБ, помним об этом) и делаем безусловный переход по нему. После этого ненужную страницу можно обнулить для будущих приложений.
lea ecx, [StartInHigherHalf]
jmp ecx
StartInHigherHalf:
mov dword [BootPageDirectory], 0
invlpg [0]
Теперь дело за совсем малым: инициализировать стек, передать структуры GRUB и хватит уже ассемблера!
mov esp, stack+STACKSIZE
push eax
push ebx
lea ecx, [BootPageDirectory]
push ecx
call kmain
hlt
section .bss
align 32
stack:
resb STACKSIZE
Что нужно знать об этом участке кода:
- Согласно Си-конвенции вызовов (она применима и для Rust), переменные в функцию передаются через стек в обратном порядке. Все переменные выровнены на 4 байта в x86.
- Стек растет с конца, поэтому указатель на стек должен вести в конец стека (добавляем STACKSIZE к адресу). Размер стека я взял 16 КиБ, должно хватать.
- В ядро передается: магическое число Multiboot, физический адрес структуры загрузчика (там лежит ценная для нас карта памяти), виртуальный адрес таблицы страниц (где-то в пространстве 3 ГиБ)
Также не забываем объявить, что kmain — extern, а _loader — global.
В следующих заметках я расскажу о настройке сегментных регистров, кратко пробегусь по выводу информации через VGA-буфер, расскажу как организовал работу с прерываниями, управление страницами, а самое сладкое — многозадачность — оставлю на десерт.
Полный код проекта доступен на GitLab.
Спасибо за внимание!