Написание ядра ОС с нуля. Часть 1

от автора

Небольшая предыстория и мотив написания

Когда я только знакомился с OSDev, я очень долго искал соответствующие материалы. Находил и на YouTube, и на Habr, и просто какие‑то англоязычные сайты (тогда я не сильно знал английский). Но почти все что я находил было либо незаконченным, либо недостаточно понятным и полным.

В итоге я всё же смог собрать свою первую «ОС». Это был написанный на чистом ассемблере Франкенштейн, слепленный по кусочкам из чужих гайдов. По факту получился крошечный 32-битный терминал, код которого я практически полностью позаимствовал из одной удачной статьи. На этом моменте я честно признался себе, что не до конца понимаю, как эта конструкция вообще работает, и отложил OSDev до лучших времен.

Через пару‑тройку лет я снова вернулся в это дело, но уже с гуглом в руках и чуть бОльшим опытом в программировании в принципе. До этого я уже с горем пополам написал компилятор со своим бекендом, но архитектура стала такой, что ее уже было невозможно поддерживать. Сейчас я конечно пишу новый полностью бекенд, но это уже отдельная тема для статьи, которая выйдет (не буду врать), когда рак с горы слезет.

Многие скажут, что можно было просто читать спецификации, но я не тот человек, который может часами читать сухой и технически ужасающий текст. Именно по этой причине я искал статьи, где все равно ничего не понимал и брал готовый код. Но этот варварский подход все‑таки мне удалось перерасти.

Переходим к мясу. И все‑таки, как это делают?

Сразу хочу сказать, что в этой статье я буду использовать не UEFI, а BIOS. Не потому что так лучше, просто потому что с UEFI я не знаком.

В этой статье мы напишем полноценный загрузчик, который сможет сначала вывести «Hello, world», а потом уже подгрузить остальную часть ядра.

Наверняка это не первая статья, которую вы читали, задаваясь довольно сложной, нишевой и уважаемой целью — написание своей ОС, поэтому вы натыкались на что‑то по типу int 0x10, но не понимали, что вообще на здесь происходит. Именно поэтому мы сейчас разберем, что ИМЕННО делает BIOS, и что из этого нам полезно.

BIOS поможет, BIOS услышит..

При загрузке компьютера, самым первым кодом, исполняемым процессором, является BIOS (либо UEFI), который инициализирует и проверяет работу всех компонентов, ставит свой вектор прерываний и передает управление на первый сектор диска, с которого будет загружаться ОС.

Немного про диск. Диск делится на огромное количество секторов, которые в себе сохраняют 512 байт. Именно поэтому ограничение на размер загрузчика составляет лишь 512 байт, ибо BIOS загружает лишь один первый сектор по адресу в оперативной памяти 0x7C00. Теперь вы понимаете, почему мы в начале каждого загрузчика говорим ассемблеру, по какому адресу будет работать код.

Про векторы прерываний

Вектор прерываний — это некая таблица, содержащая в себе 256 обработчиков различных прерываний. Прерывание может быть и ошибкой, и просто обращением к обработчику прерывания. Независимо от этого, всегда процессор ищет номер прерывания и дергает его обработчик, что позволяет и BIOS принимать ваши просьбы по типу печати буквы на экран или смены видеорежима, и DOS’у принимать системные вызовы, которые позволяют программа звать ядро, например для работы с файловой системой.

Перед тем, как BIOS любезно загрузит весь первый сектор в память по адресу 0x7C00 и передать прямо туда управление (как раз нашему загрузчику), он всегда ставит свой вектор прерываний, в котором находятся его функции. К ним мы и обращаемся инструкцией int. Она никак не связана с типом данных int из C/C++ и многих других языков, она расшифровывается как Interrupt. Прерывания делятся на программные и аппаратные — программные это те, которые программа вызывает по собственному желанию с помощью инструкции int, но вот аппаратные вызывает уже сам процессор, когда мы пытаемся поделить на 0, обращаемся к несуществующей памяти или просто плохо действуем на кремниевые нервы процессора.

BIOS ставит в качестве своих обработчиков функции, которые как раз и выполняют всю грязную работу на ранних этапах загрузки ОС. Один из таких обработчиков находится по номеру 0x10 в нашей таблице (кстати, в ассемблере 10h и 0x10 — это просто два способа записи числа в 16-ричной системе, и они абсолютно эквивалентны друг другу), и выполняет он основную работу с экраном. Он умеет переключать видеорежимы (об этом позже), печатать символ на экран, ждать ввод от пользователя и еще очень многие вещи. Все это — подфункции. Номер нужной нам подфункции мы можем указать в ax регистре.

В целом мы готовы приступать к первому коду, написанного уже с, очень надеюсь, полным пониманием всего происходящего. Давайте напишем простой Hello, world, который работает уже без помощи ни Windows, ни Linux, ни MacOS, ни любой другой ОС. Приступим!

Hello, world по-мужски

Создаем проект, называем как угодно — это название останется с вами еще очень надолго, если не бросите это дело.

Привык к CLion, и вам советую, ибо в этой серии статей мы долго циклиться на ассемблере не станем:)

Привык к CLion, и вам советую, ибо в этой серии статей мы долго циклиться на ассемблере не станем:)

Давайте настроим компиляцию и запуск с помощью одного .bat-файла. Для этого вам нужно установить эмулятор (штука, которая нам позволит тестировать нашу ОС) и компилятор (сейчас используем только ассемблер). В этой статье мы будем использовать FASM.

Как только мы все инструменты установили, можем приступать к написанию нашего .bat, который будет покорно компилировать и запускать нашу мини-ос.

fasm os/boot.asm build/boot.binqemu-system-i386 build/boot.bin

Здесь мы просто компилируем наш бутлоадер (тот самый загрузчик) и сразу скармливаем его qemu.

Проводим тестовый запуск, который показывает что компиляция и запуск работают.

Проводим тестовый запуск, который показывает что компиляция и запуск работают.

Qemu запустился. Но BIOS понял, что это не загрузчик. Как? Он просто смотрит последние два байта первого сектора диска, на котором и хранится наш загрузчик. Эти два байта должны быть равны 0x55 и 0xAA. Исправим это одним хитрым, но единственным способом.

times 510-($-$$) db 0 ; заполнение нулями до 510 байт db 0x55 ; первая часть сигнатурыdb 0xAA ; вторая часть сигнатуры

Лично мне никогда не была понятна строка times 510-($-$$) db 0. В times N мы говорим ассемблеру повторить инструкцию, которую мы сразу после него пишем, в нашем случае это db 0. 510-($-$$) — это мы взяли общий размер загрузчика, из которого вычли эти два байта на сигнатуру (510), потом из этих 510 мы вычитаем размер уже написанного нами кода с помощью $-$$, где $ является текущей точкой, а $$ — точкой начала всей программы.

Допустим, мы написали 100 байт кода, и расположили эту заветную times 510-($-$$) db 0. Получается 510 - (100 - 0) = 410. В реальности числа могут быть другими, но всегда в $‑$$ получится размер кода, который мы уже написали, в байтах. Получается общий размер программы (без учета 2-байтовой сигнатуры) 510 байт. Потом добавляем те самые 0x55 и 0xAA и получаем ровно 512 байт. Проверим? Запускаем наш скрипт компиляции (у меня он comp.bat).

Qemu успешно загрузил наш загрузчик. BIOS увидел сигнатуру 0x55 0xAA и передал управление нашему коду. Правда, код пока ничего не делает — просто висит. Исправим это в следующем шаге.

Qemu успешно загрузил наш загрузчик. BIOS увидел сигнатуру 0x55 0xAA и передал управление нашему коду. Правда, код пока ничего не делает — просто висит. Исправим это в следующем шаге.

Работает, правда дальше убеждения BIOS’а мы ничего процессору делать не говорили. Начинаем писать реальный код.

Для начала, скажем ассемблеру, куда код будет загружен, с помощью org. В нашем случае это будет выглядеть вот так:

org 0x7C00 ; говорим ассемблеру, где код будет работать; именно с этой точки и начинается вся работа загрузчикаtimes 510-($-$$) db 0db 0x55db 0xAA

И начинаем писать наш долгожданный «Hello, world». Для начала инициализируем сегментные регистры, значения которых BIOS мог оставить любыми.

org 0x7C00 ; говорим ассемблеру, где код будет работатьstart:    xor ax, ax        ; обнуляем AX    mov ds, ax        ; обнуляем DS    mov es, ax        ; обнуляем EStimes 510-($-$$) db 0db 0x55db 0xAA

Теперь можем приступать к выводу нашей строки на экран. В ассемблере нет никаких циклов, поэтому мы используем создание меток и переход по ним. Алгоритм вывода строки прост: так как строки должны заканчиваться нулем (не ‘0’, а ‘\0’, код которого равен тоже 0), мы просто идем по байтам (символам) нашей строки и поочередно говорим BIOS вывести наш символ на экран с помощью int 0x10 и его подфункции AH=0xE. Для начала, выведем просто H.

org 0x7C00 ; говорим ассемблеру, где код будет работатьstart:    xor ax, ax        ; обнуляем AX    mov ds, ax        ; обнуляем DS    mov es, ax        ; обнуляем ES    mov ah, 0xE       ; ставим номер подфункции (напечатать символ и сдвинуть курсор)    mov al, 'H'       ; ставим сам символ    int 0x10          ; вызываем видео-сервисstop:                 ; останавливаем код, чтобы процессор не исполнял мусор    hlttimes 510-($-$$) db 0db 0x55db 0xAA

Итак, наш загрузчик вывел свой первый символ. Можем приступать к циклу.

org 0x7C00 ; говорим ассемблеру, где код будет работатьstart:    xor ax, ax        ; обнуляем AX    mov ds, ax        ; обнуляем DS    mov es, ax        ; обнуляем ES    mov si, msg       ; ставим в si адрес первого символа msgprint_cycle:    mov al, byte[si]  ; загружаем в al символ, который находится по адресу si    cmp al, 0         ; сравниваем al с нулем    je print_end      ; если al был равен нулю, то прыгаем    mov ah, 0xE       ; ставим номер подфункции (напечатать символ и сдвинуть курсор)    int 0x10          ; вызываем видео-сервис    inc si            ; инкрементируем si (сдвигаем указатель на 1)    jmp print_cycle   ; прыгаем обратно в начало циклаprint_end:    ; здесь мы уже вывели наше сообщение    jmp stop          ; на всякий случай прыгаем на метку stopstop:                 ; останавливаем код, чтобы процессор не исполнял мусор    hlt    jmp stopmsg db "Hello, world", 0times 510-($-$$) db 0db 0x55db 0xAA

Запускаем код и видим, что наш загрузчик успешно напечатал долгожданный Hello, world.

Заключение

В этой статье я постарался объяснить все непонятные моменты так, как бы я сам этого хотел года 3 назад. Здесь я не старался научить вас ассемблеру полностью, но рассказал те возможности, которые обычно никто не использует.

Только что вы поняли, что действительно происходит на самых ранних этапах загрузки компьютера, и поняли код базового загрузчика, надеюсь, полностью. В следующих статьях мы уже разберем загрузку второго этапа и войдем в лигу OSDev.

Жду ваших мнений, исправлений, критики и в целом обратной связи.

ссылка на оригинал статьи https://habr.com/ru/articles/1058812/