Вступ
Була середа ввечері, я завершував курсову з комп'ютерних наук і вже планував проект на зимові канікули. Ідеї не зовсім лилися, тому я звернувся до викликів Джона Крикета (які можна знайти тут).
Пролистуючи останні пости, я натрапив на нього… Asteroids. Ще в середній школі я любив створювати невеликі ігрові симуляції в Unity, Python та навіть Scratch 😄. І, як і будь-яка дитина, яка мала доступ до комп'ютера, я був одержимий іграми, особливо іграми 90-х років в аркадному стилі та класичними пригодницькими іграми — Zeliard, The Legend of Kyrandia, Space Quest. О, до речі, я дуже любив космічні ігри! Elite, Escape Velocity, та Freelancer — це найкраще. Я маю на увазі, який 12-річний хлопець не мріє стати космічним піратом?😅
Цього разу я хотів спробувати щось незвичайне — не ще один проект на Python, а щось нове. Я завжди цікавився C, але ніколи не міг змусити себе зробити щось більше, ніж просто вирішувати задачі на LeetCode. Python обіймав мене в своїй теплій обіймах, але здається, що зараз настав ідеальний момент вирватися і зануритися в холодне, безкрає МОРЕ (розумієте, МОРЕ як C... смішно, правда?)
Хоча в мене є певний досвід реверс-інжинірингу ігор, але, як я вже сказав, я ніколи не займався розробкою на C. Тому, щоб належним чином освоїти цю чудову мову, я вирішив почати з чогось простого; Наприклад, створити свою власну операційну систему 🙃
Наскільки це може бути складно... правильно?
Результати
Перед тим, як заглиблюватися в як я створив щось, мабуть, краще почати з чого я створив. Таким чином, ви зможете чітко уявити, що вас чекає в кінці цієї статті з трьох частин. 😄
Коротка демонстрація
Скріншот гри
Фінальний код можна знайти тут
Початок?
З чого починається будь-який проект? Звісно, з збору інформації та визначення вимог. Мої знання з комп'ютерної архітектури та операційних систем закінчуються на першому курсі університету, тому природно я відкрив Google, щоб дізнатися, як насправді розробляються ОС. Я знайшов чудову OSdev wiki, де досвідчені розробники діляться порадами з початківцями, такими як я.
Після прочитання розділів Introduction, Required Knowledge та Beginner Mistakes, я, як будь-який самовпевнений підліток, проігнорував попередження, що “Ніхто, хто ще не є досвідченим розробником з кількома роками досвіду в різних мовах та середовищах, не повинен навіть думати про розробку ОС на цьому етапі”. І…
Вивчення програмування за допомогою проекту з ОС вважається поганою ідеєю.
Та кому важливі такі дрібниці, правда? 😄
(Це жарт, будь ласка, не сприймайте це серйозно :))
Далі пішла найскладніша частина: зрозуміти, з чого почати. Чи варто писати власний завантажувач, чи використати вже існуючий? Якщо я вирішу написати свій, то вибрати UEFI чи BIOS? Як працювати з пам’яттю? Сторінкування чи сегментація? А що з…?
І багато подібних питань. Боже, здавалися безкінечними. В будь-якому разі, щоб не перетворювати цю статтю на роман, я не буду занурюватися в усі технічні аспекти; Натомість я залишу посилання на ресурси (вони виглядають ось так), які можуть бути цікавими і корисними, якщо ви хочете дослідити цю тему глибше. АЛЕ НЕ БІЙТЕСЯ, ДОРОГІ РОЗРОБНИКИ — буде багато красивих фрагментів коду та діаграм.
Отже, без зайвих слів, повернемося до питання “З чого почати?”. Ну, по-перше, я дізнався, як працює процес завантаження.
Спрощений процес завантаження
Коли комп’ютер вмикається, він запускає попередньо встановлене прошивку — BIOS на старих машинах і UEFI на нових.
(Для простоти я буду використовувати термін BIOS, оскільки його часто використовують взаємозамінно з UEFI, і він означає Basic Input/Output System.) BIOS виконує перевірки апаратного забезпечення, хоча це не наша основна мета. Те, що нас цікавить, — це наступний крок: сканування пам'яті на наявність завантажувальних образів.
Завантажувальний образ — це файл або структура даних, яка містить необхідний код і ресурси для завантаження та запуску операційної системи. Але як комп’ютер визначає, чи є цей фрагмент пам’яті завантажувальним? І відповідь — Magic bytes 0xAA55
. Це чудове число розташоване в останніх двох байтах 512-байтового завантажувального сектора, сигналізуючи, що попередні 510 байтів містять завантажувач — маленьку програму, що виконується перед ОС, часто налаштовуючи пам'ять і безпосередньо переходячи до ядра.
Хоча новіші комп’ютери не завжди підтримують “традиційний” BIOS, я вирішив, що створення завантажувача для нього буде простішим, ніж для UEFI. Тому моя система спроектована для прадавніх машин — або принаймні для тих, що ще підтримують режим сумісності.
Структура проекту
Я почну, як завжди, з скрипту “Hello World”, щоб переконатися, що всі залежності встановлені правильно і компілятор працює, як треба. Для тестування системи я буду використовувати QEMU — емулятор. Це набагато простіший, швидший і безпечніший варіант порівняно з запуском на реальному обладнанні (моєму ноутбуці). Створення завантажувального образу, запис його на USB-накопичувач, розуміння того, як його розділити (або чи потрібно взагалі розділяти), перезавантаження комп’ютера і надія, що все працюватиме. Коротше кажучи, це займає купу часу.
Отже, після створення папки для проекту та налаштування Git я вирішив дотримуватись наступної структури:
Структура проекту
boot
: Ця папка міститиме завантажувач і різні скрипти для налагодження, які можна включати у збірку за потребою. Я напишу завантажувач на NASM, оскільки я дещо знайомий з ним.
build
: Ця директорія міститиме всі скомпільовані .o
файли. Також тут зберігатиметься фінальний .img
файл ОС.
kernel
: Як і зрозуміло з назви, ця папка міститиме вихідний код ядра.
Додатково, папки boot
, kernel
та кореневий каталог проекту мають власні Makefile
. Хоча можна керувати всім через один Makefile
, я віддаю перевагу розділенню проекту на модулі і збірці їх окремо. Це допомагає зберігати процес збірки модульним і уникати створення величезних скриптів збірки.
Hello world (Типу)
Якщо ви плануєте слідувати за мною і писати проект, як я, то саме зараз вам слід встановити всі необхідні компоненти.
Добре, час для цікавої частини — написання коду.
Виведення одного символу на екран виявляється досить простим. BIOS підтримує так звані переривання (interrupts), які дозволяють взаємодіяти з апаратним забезпеченням. Ось як це виглядає:
По-перше, ви завантажуєте номер функції (0x0E в цьому випадку) в регістр AH
, поміщаєте аргумент функції в регістр AL
(Символ для відображення), і потім викликаєте переривання за його конкретним номером. Це повідомляє BIOS, що робити і з якими даними. Більше про переривання можна почитати тут.
Просто скомпілювати цей код буде недостатньо. Пам’ятаєте ті магічні байти, про які ми згадували раніше? Без них комп’ютер не розпізнає наш код як завантажувальний. Щоб перетворити звичайний код на надзвичайний завантажувач, нам потрібно додати кілька рядків.
Давайте включимо їх! 🙂
Що тут відбувається?
Перша ж рядок викликає питання: org 0x7c00
. Що це за команда, і чому інша магічна адреса? Давайте розберемося:
Що таке org 0x7c00
?
Директива org
(скорочення від origin) використовується в асемблері, щоб вказати початкову адресу, куди буде завантажений машинний код у пам'ять.
Простими словами, це вказує асемблеру, що згенерований код має бути вирівняний так, як якщо б він перебував за вказаною адресою.
Чому це необхідно? Це дозволяє нам використовувати відносні адреси в коді, не розраховуючи вручну місце для кожного фрагмента даних.
Чому 0x7c00
?
Тому що BIOS завантажує завантажувач в цю адресу в пам'яті під час процесу завантаження. Це специфічне місце відоме як MBR (Master Boot Record). Про це можна почитати тут.
Рядки 3–5 містять наш “код” для виведення символу, після чого йдуть рядки 8–9.
times 510-($-$$) db 0
Це можна інтерпретувати так: "Заповнити залишок простору в перших 510 байтах (віднімаючи довжину коду на цей момент) нулями."
Чому це необхідно? Завантажувач повинен бути рівно 512 байт в розмірі, при цьому останні два байти зарезервовані для магічного числа 0xAA55
. Це заповнення гарантує, що завантажувач має потрібний розмір, навіть якщо фактичний код коротший за 510 байт.
dw 0xaa55
Ця остання інструкція записує магічне число в останні два байти завантажувача.
Для компіляції коду можна використати таку команду
nasm -f bin -o bootloader.bin bootloader.asm
Але оскільки ми будемо робити це досить часто, я пропоную написати Makefile (більше про них можна почитати тут). Це дозволить нам просто запустити команду make
щоразу, коли нам потрібно буде перебудувати завантажувач.
Добре, давайте зайдемо в директорію boot
і скомпілюємо наш “Hello world” завантажувач.
Ура!!! Ось і все, ОС працює, і ми можемо завершити статтю тут. Дякую всім, хто її прочитав. Не забудьте залишити коментарі…
😄
Звісно, ні! Цікавість тільки починається. Ми освоїли виведення одного символу, але одного символу явно недостатньо для налагодження. Що ж, як щодо виведення цілої стрічки?
Корисні утиліти для налагодження завантажувача
В асемблері є директива, яка називається %include
. Вона працює так, що копіює вміст іншого асемблерного файлу в поточний в тому місці, де з'являється ця директива під час компіляції. Я використаю її для логічного поділу коду на блоки, що зробить завантажувач зручнішим для читання, хоча це не є строго необхідним. Крім того, це дозволяє мені “перемикати” утиліти для налагодження увімкнені або вимкнені, якщо їх стане занадто багато. Пам’ятайте, що ми маємо лише 512 байт.
Щоб зрозуміти, як вивести стрічку, нам потрібно зрозуміти, що це таке. Отже, що таке стрічка? Стрічка — це просто масив символів, і все, що нам потрібно зробити, це пройти через неї, виводячи кожен символ, поки не зустрінемо нульовий байт (0x00
), що означає кінець стрічки. Однак, оскільки може статися виклик переривання, ми спочатку збережемо значення регістрів на стеку, а потім витягнемо їх назад. (Не знаєте, як працює стек? Ви можете дізнатися про це тут. А тут ви знайдете чудову презентацію від OWASP про атаку через переповнення буфера).
Як ми це робимо в асемблері? Ми завантажуємо адресу першого символу в стрічці в регістр bx
(або будь-який інший “вільний” регістр) і переходимо до нашої процедури print
. У цьому випадку bx
буде виступати як аргумент нашої функції. Перед викликом print
нам потрібно завантажити нашу стрічку в нього, правильно? Тепер перевіримо, що зберігається за адресою в bx
.
Якщо це нуль, ми стрибаємо назад; інакше, ми виводимо символ і знову повертаємось на початок, а також збільшуємо bx
на одиницю, щоб взяти наступний символ. Проста річ, чи не так?
Досить слів, покажіть нам код!
Звісно! Ось що я придумав:
Файл: boot/print16-bit.asm
Тут є дві підпрограми: print16
та print16_nl
(новий рядок). Чому я зробив виведення нового рядка окремою підпрограмою? Справа в тому, що, окрім \n
(або 0x0A
), як це зазвичай буває в мовах програмування, потрібно також повернути курсор на початок рядка, що робиться за допомогою символа \r
(0x0D
). Звісно, я міг би просто включити цю послідовність у кожну стрічку, наприклад, "Hello world\r\n"
, але це не так цікаво 🙂
Варто зазначити, що BIOS починається в 16-розрядному режимі. У цьому режимі багато функцій недоступні, і набір регістрів досить обмежений. Згодом ми переключимось на 32-розрядний режим (також відомий як “захищений” режим). У тому режимі ці підпрограми вже не працюватимуть у такому вигляді, і нам доведеться їх переписати. Ось чому я називаю цю підпрограму
print16
, а не просто[BITS 16]
.
Оскільки це утиліта для налагодження, я виніс її в окремий файл
boot/print16-bit.asm
, і в завантажувачі підключаю її за допомогою
%include "print-16bit.asm"
Тепер давайте перейдемо до основного файлу bootloader.asm
. По-перше, щоб використовувати завантажувач, нам потрібно ініціалізувати стек. Ось як це робиться: спочатку визначаємо, де буде починатись стек, і зберігаємо цю адресу в регістрах sp
(Stack Pointer) та bp
(Base Pointer). Важливо правильно вибрати початок стека, оскільки BIOS може читати або записувати в певні адреси пам'яті, і нам не потрібно, щоб ці непотрібні дані потрапили на стек. Тому перед вибором адреси потрібно переконатись, що там не записуються критичні дані. Я вибрав адресу 0x0600
, оскільки критичні дані, такі як системні змінні, таблиці векторів переривань і так далі, розташовуються нижче за 0x0500
, а 0x0600
знаходиться трохи вище за це. Також, як ви вже знаєте, код завантажувача починається з 0x07C00
, тому 0x0600
як база для стека знаходиться добре нижче за нього. Стек не перезапише завантажувач (Ну, я сподіваюся :D)
Тепер давайте подивимось на фінальну версію завантажувача для цієї частини статті. Його мета — ініціалізувати стек і вивести на екран "Hello world :D"
.
Що відбувається тут?
На початку все просто: ми встановлюємо регістри bp
та sp
для ініціалізації стека. Потім ми завантажуємо адресу першого символу стрічки MSG_HELLO_WORLD
в регістр bx
. Ця стрічка оголошена в кінці файлу… Хмм, як ми працюємо з чимось, що оголошене пізніше?
Справжня річ у тому, що це не зовсім “оголошення”. Якщо ви ніколи не працювали з асемблером, це може здатися дивним. Але насправді все дуже просто і геніально. Ми присвоюємо мітку для стрічки, і асемблер знає, що ця мітка відповідає місцезнаходженню стрічки. Як він це знає?
Щоб пояснити коротко, асемблер обробляє код за два проходи:
- Перший прохід: Асемблер сканує код і записує всі невідомі імена (мітки) в таблицю символів, фактично відслідковуючи їх імена і залишаючи їх адреси порожніми на цей момент.
- Другий прохід: Асемблер заповнює фактичні адреси для цих міток, коли обробляє код.
Ось чому асемблер може вирішувати посилання на речі, які “оголошені” пізніше, такі як MSG_HELLO_WORLD
або підпрограма print16
.
Тепер повернемося до коду. Ми завантажуємо адресу першого символу нашої стрічки в регістр bx
і викликаємо підпрограму print16
, а потім print16_nl
(просто для естетики). Після цього йде інструкція jmp $
.
Ця інструкція створює нескінченний цикл: вона стрибає до поточної адреси і продовжує це робити вічно. Чому це необхідно? Як згадувалося раніше, %include
буквально копіює вміст іншого файлу в поточний.
Це означає, що якщо виконання продовжиться після кінця нашого запланованого коду, воно перейде до коду для підпрограм print16
та print16_nl
, що спричинить їх виконання знову і виведення "Hello world :D"
двічі.
Схема потоку завантажувача
Використовуючи нескінченний цикл (jmp $
), ми забезпечуємо, щоб виконання не продовжувалося після кінця запланованого "завантажувача".
Нарешті, запустимо завантажувач
Тепер, коли все налаштовано, ми можемо виконати наш завантажувач і побачити його в дії.
Наше "Hello World" працює! Тепер ми можемо перейти до реальної розробки. Але ми зробимо це в наступній частині, посилання на яку з'явиться тут незабаром ⏰
Оновлення про серію публікацій будуть доступні на моєму LinkedIn
Код готової ОС можна знайти тут
Перекладено з: Asteroids OS or the best way to learn C