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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Важно, что IVT работает только в реальном режиме (как на 8086), а как только процессор переключается в защищенный или 64-битный режим, используется исключительно IDT.

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

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

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

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

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

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

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

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

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

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

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

QEMU запустился. Но BIOS решил, что это не загрузочный диск и пошел дальше искать, откуда можно загрузиться. Почему? Он просто смотрит байты со смещением 510 и 511 (в секторе на 512 байт это два последних байта) первого сектора диска, на котором и хранится наш загрузчик. Эти два байта должны быть равны 0x55 и 0xAA. Исправим это одним хитрым, но единственным способом.

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

Лично мне раньше не была понятна строка times 510-($-$$) db 0. В times N мы говорим ассемблеру повторить инструкцию 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 0
db 0x55
db 0xAA

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

org 0x7C00

start:
    xor ax, ax        ; обнуляем AX
    mov ds, ax        ; обнуляем DS
    mov es, ax        ; обнуляем ES

times 510-($-$$) db 0
db 0x55
db 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:                 ; останавливаем код, чтобы процессор не исполнял мусор
    hlt

times 510-($-$$) db 0
db 0x55
db 0xAA

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

org 0x7C00

start:
    xor ax, ax        ; обнуляем AX
    mov ds, ax        ; обнуляем DS
    mov es, ax        ; обнуляем ES

    mov si, msg       ; ставим в si адрес первого символа msg
print_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          ; на всякий случай прыгаем на метку stop

stop:                 ; останавливаем код, чтобы процессор не исполнял мусор
    hlt
    jmp stop

msg db "Hello, world", 0

times 510-($-$$) db 0
db 0x55
db 0xAA

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

Заключение

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

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