Хабрахабр

[Из песочницы] OS1: примитивное ядро на Rust для x86

На данный момент мою поделку нельзя назвать даже операционной системой — это небольшое ядро, которое умеет загружаться из Multiboot (GRUB), управлять памятью реальной и виртуальной, а также выполнять несколько бесполезных функций в режиме многозадачности на одном процессоре. Я решил написать статью, а если получится — то и серию статей, чтобы поделиться своим опытом самостоятельного исследования как устройства Bare Bone x86, так и организации операционных систем.

Что мне на самом деле захотелось сделать — разобраться, как устроена архитектура i386 на самом базовом уровне, и как именно операционные системы делают свою магию, ну и покопать хайповый Rust. При разработке я не ставил себе целей написать новый Linux (хотя, признаюсь, лет 5 назад мечтал об этом) или впечатлить кого-либо, поэтому особо впечатлительных прошу дальше не смотреть.

Конкретно в этой статье я расскажу о компоновке файла ядра, его загрузке и инициализации. В своих заметках я постараюсь поделиться не только исходными текстами (их можно найти на GitLab) и голой теорией (ее можно найти на многих ресурсах), но и тем путем, который я прошел, чтобы найти неочевидные ответы.

Я понимаю, что аналогичные материалы и блоги уже есть в сети, но чтобы прийти к моему текущему положению, мне пришлось долго собирать их воедино. Мои цели — структурировать информацию у себя в голове, а так же помочь тем, кто идет похожим путем. Всеми источниками (во всяком случае, которые вспомню), я поделюсь прямо сейчас.

Во вторую очередь я назову Филиппа Оппермана с его блогом — большое количество информации о связке Rust и железа. Большую часть я черпал, конечно же, с отличного ресурса OSDev — как с вики, так и с форума.

Разработка и реализация”, книга Роберта Лава “Ядро Linux. Некоторые моменты подсмотрены в ядре Linux, Minix — не без помощи специальной литературы, такой как книга Таненбаума “Операционные системы. Сложные вопросы об организации архитектуры x86 решались при помощи мануала “Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide”. Описание процесса разработки”. Спасибо CoreTeamTech за напоминание о чудесной системе Redox OS. В понимании формата бинарников, компоновки — гайды по ld, llvm, nm, nasm, make.
UPD. К сожалению, официальный GitLab системы недоступен с русских IP, так что можно посмотреть на GitHub. Из ее исходников я не вылезал.

Поэтому реализация может показаться вам совершенно некорректной — заранее хочу попросить о снисхождении к моему коду и буду рад комментариям и предложениям. Я осознаю, что я не хороший программист на Rust, более того — это мой первый проект на этом языке (не лучший способ начать знакомство, не так ли?). Некоторые фрагменты кода могут быть скопированы из туториалов как есть и незначительно модифицированы, но к таким участкам я постараюсь дать максимально понятные пояснения, чтобы у вас не возникли те же вопросы, что и у меня при их разборе. Если уважаемый читатель сможет подсказать мне, куда и как двигаться дальше — также буду очень благодарен. Я так же не претендую на использование верных подходов в проектировании, поэтому если мой менеджер памяти вызовет у вас желание писать гневные комментарии — я прекрасно понимаю почему.

В качестве среды я выбрал хороший и удобный редактор кода VS Code с плагинами для Rust и отладчика GDB. Итак, я начну с погружения в средства разработки, которыми я пользовался. VS Code иногда не очень хорошо дружит с RLS, особенно при переопределении его в конкретном каталоге, поэтому после каждого обновления Rust nightly мне приходилось переустанавливать RLS.

Во-первых, его растущая популярность и приятная философия. Язык Rust был выбран по нескольким причинам. В-третьих, как любитель Java и Maven, я очень пристрастился к системам сборки и управлениям зависимостями, а cargo уже встроен в toolchain языка. Во-вторых, его возможности работать с низким уровнем но с меньшей вероятностью “выстрелить себе в ногу”. В-четвертых, мне просто захотелось чего-то нового, не такого как Си.

я уверенно себя чувствую в Intel-синтаксисе, а также мне комфортно работать с его директивами. Для низкоуровневого кода я взял NASM, т.к. Можно было обойтись и build-скриптами для cargo. Я осознанно отказался от ассемблерных вставок в Rust, чтобы явственно разделить работу с железом и высокоуровневую логику.
В качестве общей сборки и компоновки использованы Make и линкер из поставки LLVM LLD (как более быстрый и качественный линкер) — это дело вкуса.

Чтобы загружаться и сразу иметь всю информацию о железе — конечно же GRUB (Legacy более просто в организации заголовка, так что берем его). Для запуска использован Qemu — мне нравится его скорость, интерактивный режим и возможность прицепить GDB.

Было крайне тяжело осознать после долгих разбирательств с сегментными регистрами x86, что сегменты и секции — это не одно и то же. Как ни странно, для меня это оказалось одной из самых сложных тем. В программировании под существующую среду нет необходимости задумываться о том, как разместить программу в памяти — для каждой платформы и формата компоновщик уже имеет готовый рецепт, поэтому писать скрипт линкера нет необходимости.

Здесь я хочу подчеркнуть, что речь идет о линейном (виртуальном) адресе, при помощи механизма страниц. Для голого железа наоборот необходимо указать, как именно размещать и адресовать программный код в памяти. OS1 использует страничный механизм, но отдельно я остановлюсь на нем в соответствующем разделе статьи.

Логический, линейный, виртуальный, физический...

Я сломал голову с на этом вопросе, поэтому за деталями хочу адресовать к этой отличной статье Логический, линейный, виртуальный, физический адреса.

Это происходит как раз за счет страничной организации памяти, отсутствие страниц в оперативной памяти обрабатывается соответствующим образом. Для операционных систем, которые используют страничную организацию памяти, в 32-х разрядной среде каждой задаче доступно 4 ГиБ адресного пространства памяти, даже если у вас установлено 128 МиБ ОЗУ.

Это объясняется тем, что ОС должна обслуживать прерывания, системные вызовы, а значит — как минимум их обработчики должны находиться в этом адресном пространстве. При этом на самом деле приложениям обычно доступно несколько меньше, чем 4 ГиБ. Перед нами встает вопрос: куда именно в эти 4 ГиБ поместить адреса ядра, чтобы программы могли корректно работать?

Если посмотреть, как именно компиляторы собирают программы на этапе линковки, то окажется, что они начинаются с линейного адреса ноль или около ноля. В современном мире программ используется такая концепция: каждая задача считает, что она безраздельно властвует процессором и является единственной запущенной программой на компьютере (на этом этапе мы не говорим про коммуникацию между процессами). Поэтому если мы хотим в будущем пользоваться не только самописными программами, разумно отдать приложению как можно больший кусок памяти около ноля, а образ ядра разместить повыше. Это значит, что если образ ядра займет пространство памяти около ноля, программы собранные таким образом не смогут исполняться, любая jmp инструкция программы приведет ко входу в защищенную память ядра и ошибке защиты.

Какой участок памяти выбрать — зависит только от ваших аппетитов. Эта концепция называется Higher-half kernel (здесь отсылаю вас к osdev.org, если хотите сопутствующую информацию). Для моего исполняемого файла это будет функция _loader, написанная на ассемблере, на которой я остановлюсь подробнее в следующем разделе. Кому-то достаточно 512 МиБ, я же решил отхватить себе 1 ГиБ, поэтому мое ядро размещается по адресу 3 ГиБ + 1 МиБ (+ 1 МиБ необходим, чтобы соблюсти границы lower-higher memory, GRUB загружает нас в физическую память после 1 МиБ).
Так же для нас важно указать точку входа в наш исполняемый файл.

О точке входа

На самом деле, main() — это конвенция языка Си и языков, им порожденных. Вы знали, что вам всю жизнь врали насчет того, что main() является точкой входа в программу? Если покопаться, то выяснится примерно следующее.

Во-вторых, эти точки можно переопределить, но тогда не получится пользоваться прелестями libc. Во-первых, каждая платформа имеет свою спецификацию и название точки входа: для linux это обычно _start, для Windows — mainCRTStartup. В-третьих, эти точки входа по умолчанию предоставляет компилятор и они находятся в файлах crt0..crtN (CRT — C RunTime, N — количество аргументов main).

Собственно что делают компиляторы типа gcc или vc — выбирают платформозависимый скрипт линковки, в котором прописана стандартная точка входа, выбирают нужный объектный файл с готовой функцией инициализации рантайма Си и вызовом функции main и производят линковку с выходом в виде файла нужного формата со стандартной точкой входа.

Так вот, для наших целей стандартную точку входа и инициализацию CRT нужно отключать, так как у нас нет совсем ничего, кроме голого железа.

Как будут располагаться секции данных (.rodata, .data), неинициализированных переменных (.bss, common), а также помнить, что GRUB требует расположения заголовков мультизагрузки в первых 8 КиБ бинарника. Что еще необходимо знать для линковки?

Итак, теперь мы можем написать скрипт линковщика!

ENTRY(_loader)
OUTPUT_FORMAT(elf32-i386) SECTIONS .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 байта)
  • Должно присутствовать магическое число 0x1BADB002
  • Нужно указать мультизагрузчику, какую информацию мы хотим получить и как размещать модули (в моем случае я хочу, чтобы модуль ядра был выровнен на страницу 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 находится магическое число 0x2BADB002, которое говорит, что загрузка прошла успешно
  • В регистре EBX находится физический адрес структуры с информацией о результатах загрузки (о ней поговорим значительно позже)
  • Процессор переведен в защищенный режим, страничная память выключена, сегментные регистры и стек находятся в неопределенном (для нас) состоянии, GRUB использовал их для своих нужд и нужно их переопределить как можно скорее.

Главное, чем хочу поделиться — страницы это не сегменты! Первое, что нам необходимо сделать — включить страничную организацию памяти, настроить стек и уже наконец передать управление в высокоуровневый код Rust.
Я не буду детально останавливаться на страничной организации памяти, Page Directory и Page Table, ибо про это написаны отличные статьи (одна из них). Для таблицы страниц предназначен регистр CR3! Пожалуйста, не повторяйте мою ошибку и не грузите адрес таблицы страниц в GDTR! Страница может иметь различный размер в разных архитектурах, для простоты работы (чтобы иметь только одну таблицу страниц), я выбрал размер в 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

Что в таблице?

  1. Самая первая страница должна вести на текущий участок кода (0-4 МиБ физической памяти), так как все адреса в процессоре физические и трансляция в виртуальные еще не выполняется. Отсутствие этого дескриптора страницы приведет к немедленному краху, так как процессор не сможет взять следующую инструкцию после включения страниц. Флаги: бит 0 — таблица присутствует, бит 1 — страница записываемая, бит 7 — размер страницы 4 МиБ. После включения страниц запись обнулим.
  2. Пропуск до 3 ГиБ — нули гарантируют, что страницы нет в памяти
  3. Отметка 3 ГиБ — наше ядро в виртуальной памяти, ссылающееся на 0 в физической. После включения страниц будем работать именно здесь. Флаги аналогичны первой записи.
  4. Пропуск до 4 ГиБ.

Не забываем про смещение адреса в 3 ГиБ на этапе линковки. Итак, мы объявили таблицу и теперь хотим загрузить ее физический адрес в CR3. Поэтому берем адрес BootPageDirectory и вычитаем из него 3 ГиБ, кладем в CR3. Попытка загрузить адрес как есть отправит нас по реальному адресу 3 ГиБ + смещение переменной и приведет к немедленному краху. Включаем 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 ГиБ, помним об этом) и делаем безусловный переход по нему. Пока все идет хорошо, но как только мы обнулим первую страницу, чтобы окончательно перейти в верхнюю половину 3 ГиБ, все рухнет, так как в регистре EIP все еще лежит физический адрес в районе первого мегабайта. После этого ненужную страницу можно обнулить для будущих приложений.

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

Что нужно знать об этом участке кода:

  1. Согласно Си-конвенции вызовов (она применима и для Rust), переменные в функцию передаются через стек в обратном порядке. Все переменные выровнены на 4 байта в x86.
  2. Стек растет с конца, поэтому указатель на стек должен вести в конец стека (добавляем STACKSIZE к адресу). Размер стека я взял 16 КиБ, должно хватать.
  3. В ядро передается: магическое число Multiboot, физический адрес структуры загрузчика (там лежит ценная для нас карта памяти), виртуальный адрес таблицы страниц (где-то в пространстве 3 ГиБ)

Также не забываем объявить, что kmain — extern, а _loader — global.

В следующих заметках я расскажу о настройке сегментных регистров, кратко пробегусь по выводу информации через VGA-буфер, расскажу как организовал работу с прерываниями, управление страницами, а самое сладкое — многозадачность — оставлю на десерт.

Полный код проекта доступен на GitLab.

Спасибо за внимание!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть