Asteroids OS або ж найкращий спосіб вивчити C

текст перекладу

Передісторія

Це був вечір середи, я дописував курсову по Computer Science і думав про новий проєкт, яким займусь на канікулах. Ідеї не приходили, тому я звернувся до списку челенджів від John Crickett (які можна знайти тут).

Листаючи останні пости, я побачив його… Asteroids... Ще в середній школі я обожнював робити маленькі іграшки-симулятори на Unity, Python і навіть Scratch. А ще більше я любив аркади та квести 90-их. Zeliard, The Legend of Kyrandia, Space Quest, ну і, звичайно ж, ігри про космос: Elite, Escape Velocity, Freelancer. Ну а який хлопчина в дванадцять років не мріє стати космічним піратом? 😀

Цього разу я вирішив спробувати щось нове, не писати черговий проєкт на Python, а зробити щось незвичне. Мене завжди цікавив C, але я ніяк не міг зібратися і написати на ньому щось більше, ніж Leetcode челендж. Python мене ніяк не відпускав, але думаю, зараз саме час вибратися з теплих обіймів і зануритися в холодне МОРЕ (Ха! ну ви зрозуміли? МОРЕ = SEA, типу C)

Хоча в мене є невеликий досвід реверсії застосунків, оскільки я деякий час присвятив вивченню мистецтва взлому ігор, але, як я вже сказав, розробкою на C я до цього не займався, тому щоб вивчити цю прекрасну мову, я вирішив почати з простого і зробити свою операційну систему 😀

Не може ж це бути настільки складно… Так?

Результат

Як виявилося, це справді не так складно, як здається на перший погляд, хоча і вимагає часу, щоб розібратися з усією документацією та тонкощами розробки.

pic

Фінальний код ОС можна знайти тут

Як я це зробив?

З чого починається будь-який проєкт? Правильно, з збору інформації та формування вимог. Мої знання в комп'ютерній архітектурі та роботі операційних систем закінчуються на першому курсі університету, тому я пішов гуглити, як взагалі розробляють операційні системи. Виявляється, є вики, присвячена цій темі, де досвідчені розробники діляться порадами з такими новачками, як я.

Прочитавши секції Introduction та Required Knowledge, я впевнено проігнорував зауваження, що без впевненого знання мови та багаторічного досвіду розробки не варто лізти в OSdev, і взагалі

Learning about programming with an OS project is considered a bad idea

Кого цікавлять такі дрібниці? 😀

Далі почалося найскладніше — зрозуміти, з чого почати. Писати свій завантажувач? Використовувати готовий? Якщо писати свій, то під UEFI чи BIOS? А як працювати з пам'яттю? Paging чи Segmentation? А як… І купа подібних питань, страшних слів і ще більш страшних визначень. Щоб не розтягувати статтю, я не буду глибоко занурюватися в технічну складову кожної деталі, але не бійтеся, fellow-кодери, без красивих діаграм і коду, звісно ж, не обійдеться.

І так, повертаючись до того, з чого почати. Почати варто з розуміння того, як взагалі комп'ютер завантажує операційну систему.

pic

Спрощена модель запуску ОС

При включенні комп'ютера запускається попередньо встановлене програмне забезпечення — BIOS або більш новий UEFI, який читає пам'ять у пошуках “магічних” двох байтів - “0xAA55”, що означають, що попередні 510 байт — це код завантажувача, і його потрібно виконати. Хоча нові комп'ютери не завжди підтримують BIOS, але мені здалося, що завантажувач під нього зробити простіше, тому моя система буде для доісторичних компів (Ну або для тих, хто ще підтримує legacy mode).

Структура проєкту

Почну я, як завжди, з “Hello world” скрипта, який перевірить, що всі залежності правильно встановлені і що компілятор/інтерпретатор взагалі працює. Для тестів системи я буду використовувати qemu — емулятор.
Так простіше і швидше, ніж створювати завантажувальний образ, записувати на флешку паралельно розбираючись, як її треба розмічати і чи треба це робити взагалі, перезавантажувати комп'ютер і сподіватися, що все запуститься. Взагалі, це економить купу часу.

І так, створивши папку для проєкту та налаштувавши git, я вирішив дотримуватись ось такої структури:

pic

boot — міститиме завантажувач і різні дебаг-скрипти, які можна буде включати в билд за необхідності. Його я напишу на NASM, бо з ним я трохи знайомий.

build — всі скомпільовані .o файли потраплятимуть сюди, тут же буде знаходитися фінальний .img образ операційної системи.

kernel — ну тут все зрозуміло, містить код ядра.

Boot, kernel та коренева папка проєкту містять Makefile. Можна було обійтись і одним, але мені подобається розділяти проєкт на модулі та збирати їх окремо, не створюючи громіздких білд-скриптів.

Hello world (Таке собі)

Якщо ви плануєте кодувати проєкт згідно з тим, що я робив, то на цьому кроку вам треба встановити всі пререквізити.

Структуру визначено, тепер саме цікаве — пишемо код. Виведення одного символа робиться досить просто. BIOS вміє обробляти так звані interrupt call. Це виглядає ось так:

В реєстр AH записується номер функції, в реєстр AL — аргумент функції, після чого викликається переривання з певним номером. Про переривання можна почитати тут.

Просто скомпілювати цей код буде недостатньо, пам'ятаємо про магічні байти, наш комп'ютер не знає, що наш код можна запустити, поки ми цього не вкажемо. Щоб звичайний код перетворився на незвичайний завантажувач, не вистачає буквально кількох рядків, давайте ж їх додамо.

Що тут відбувається?

Перша ж стрічка викликає питання org 0x7c00, що це за команда і чому знову якийсь магічний адрес? Директива org (скорочення від origin) використовується в асемблері для вказівки початкової адреси розміщення машинного коду в пам'яті. Простіше кажучи, вона повідомляє асемблеру, що згенерований код має бути вирівняний так, ніби він знаходиться за заданою адресою. Навіщо це потрібно? Все просто, щоб я міг використовувати відносні адреси, а не кожного разу вручну рахувати, за якою адресою лежать ті чи інші дані.
Але чому відступ буде 0x7c00? BIOS починає читання саме з цієї адреси, це називається MBR (почитати можна тут).

Далі йде наш код для виведення символа, а потім ще дві строки:

times 510-($-$$) db 0 можна перевести як: заповни наступні 510 байт — (довжина коду) нулями. Навіщо? Завантажувач має бути довжиною 512 байт, а 511 та 512 байти повинні бути 0xAA55 відповідно.

Остання інструкція якраз і записує в останні два байти це заветне магічне число, яке повідомляє BIOS, що перед ним завантажувач.

Щоб скомпілювати код можна використовувати команду

nasm -f bin -o bootloader.bin bootloader.asm

Але оскільки я буду робити це досить часто, я одразу написав Makefile (почитати про них можна тут), який дозволяє мені просто використовувати команду make кожного разу, коли потрібно перебілдити завантажувач.

Перейдемо в директорію boot, скомпілюємо та запустимо завантажувач…

pic

pic

Ура!!! Все, ОС працює, на цьому можна закінчувати статтю, всім, хто прочитав, дякую. Не забувайте залишати коментарі…

😀

Ні, звісно, все найцікавіше тільки починається.
Виводити символи ми вміємо, а що щодо рядків? Для дебагу одного символа буде недостатньо.

Перша debug утиліта

В асемблері є директива %include. Вона дозволяє буквально скопіювати вміст іншого асемблерного файлу на своє місце. Я буду використовувати її для логічного розподілу коду на блоки, щоб завантажувач було легше читати, хоча це й не обов'язково.

Перед тим, як реалізувати виведення рядків, я ініціалізую стек. Це дозволить мені зберігати вміст регістрів, не переживаючи, що виклик тієї чи іншої функції їх перетрете. Не дуже зрозуміло? Зараз покажу на прикладі.

Рядок — це масив символів, і все, що нам треба зробити, це пройтися по ньому, виводячи кожен символ, поки ми не дійдемо до нульового байту — символу, що означає кінець рядка. При цьому, оскільки виклик інтеррапта може відбутись перед початком операції, ми складемо значення регістрів на стек, а в кінці заберемо їх звідти.

Як це можна зробити в асемблері? Легко, складаємо в регістр bx адресу першого символа в рядку і стрибаємо в нашу print “функцію”. bx в даному випадку послужить нам аргументом функції. Тому перед викликом print в нього ми повинні покласти адресу нашої функції. Далі, ми перевіряємо, що знаходиться за адресою bx, якщо це нуль — випригаємо назад, інакше — виводимо символ і стрибаємо на початок циклу. Можете спробувати написати це самі, у мене вийшов ось такий код:

boot/print16-bit.asm

Тут дві “функції”: print16 та print16_nl (новий рядок). Чому виведення нового рядка я зробив в окрему функцію? Просто крім /n (або 0x0A), як це зазвичай буває в мовах програмування, потрібно ще повернути каретку на початок рядка, що робиться символом /r (0x0D). Звісно, можна зберігати цю послідовність в кожному рядку, наприклад, “Hello world\r\n”, але так не цікаво 🙂

Зауважу, що BIOS спочатку працює в 16-бітному режимі. В цьому режимі багато функцій недоступні, і набір регістрів сильно обмежений. Трохи пізніше ми переключимося в 32-бітний режим, або як його ще називають, “protected”. Там ця функція не буде працювати, і потрібно буде писати нову, тому я її називаю print16, а не просто print, і на початку говорю, що працюю в [BITS 16] режимі.

Оскільки це debug-модуль, я виношу його в окремий файл і в завантажувачі вже просто викликаю %include "print-16bit.asm".

Перейдемо до основного файлу bootloader.asm. Як я і сказав, потрібно ініціалізувати стек, щоб ми могли зберігати стан регістрів. Робиться це так: визначаємо, де буде його початок, і записуємо цю адресу в sp (Stack Pointer) і bp (Base Pointer). Важливо розуміти, де ми оголошуємо початок стека, оскільки BIOS може записувати і читати з деяких адрес, тому перед тим, як вибирати адресу, варто перевірити, що комп'ютер нічого не пише в ці місця. Я вибрав адресу 0x0600.

Перейдемо до фінальної версії завантажувача в цій частині статті. Він має ініціалізувати стек і вивести “Hello world 😀” на екран.

Що тут відбувається?

На початку все просто. Встановлюємо регістри bp та sp, ініціалізуємо стек. Потім записуємо в регістр bx адресу першого символу з рядка MSGHELLOWORLD, який ми оголошуємо в кінці… Хмм. Як ми працюємо з тим, що оголошуємо пізніше?

Якщо ви ніколи не стикалися з асемблером, це може здатися дивним, але насправді все просто і геніально одночасно. Ми насправді нічого не оголошуємо, ми даємо ярлик (label) цій строкі, і компілятор знає, що за цим ярликом знаходиться цей рядок. Знову ж таки, як він це знає? Якщо сказати коротко, асемблер компілюється в два проходи, в першому всі невідомі назви (label) заносяться в таблицю символів, яка пізніше заповнюється адресами, по мірі проходження коду. У другому ж проході ярлики в коді замінюються реальними адресами, тому асемблер може використовувати те, що було “оголошено” пізніше, як у випадку з MSGHELLOWORLD або процедурою print16.
Якщо цікаво почитати про це детальніше, то це можна зробити тут.

Отже, заносимо в bx адресу першого символа нашого рядка і викликаємо процедуру print16, а після неї print16_nl (Просто для краси). Далі йде інструкція jmp $. Це просто нескінченний цикл, ми стрибаємо на поточну адресу, потім ще раз, і ще. Навіщо це потрібно? %include, як я писав раніше, копіює вміст іншого файлу, а це означає, що якщо код продовжить виконуватись далі, то він пройде по коду інструкцій print16 та print16_nl ще раз і виведе “Hello world :D” двічі.

І так, нарешті запустимо наш завантажувач.

pic

Наш hello world працює, можна приступати до самої розробки. Але це ми будемо робити вже в наступній частині, посилання на яку скоро з'явиться тут.

Оновлення по серії будуть на моєму linkedin
English version of this piece of art: Поки немає.

Код готової ОС можна знайти тут

Перекладено з: Asteroids OS или же лучший способ выучить C

Leave a Reply

Your email address will not be published. Required fields are marked *