Page cover

Создание операционной системы (0x1)

Часть 1

Введение:

Привет и добро пожаловать! В этом туториале я покажу тебе, что нужно, чтобы создать собственную операционную систему с нуля. Звучит сложно? Возможно. Но если идти шаг за шагом, не торопиться и не бояться ошибок — всё станет понятным. Начнём с самого начала — с инструментов, без которых мы не сможем двинуться дальше.

Инструменты:

Текстовый редактор

Первое, что нам понадобится — это текстовый редактор. Это программа, в которой ты будешь писать код. Подойдёт любой: от простого "Блокнота" до профессиональных сред разработки.

Я начну с редактора под названием Micro — он лёгкий, открывается прямо в терминале (командной строке), поддерживает привычные сочетания клавиш вроде Ctrl+S для сохранения. Это удобно, особенно если ты работаешь без графического интерфейса.

Позже я перейду на Microsoft Visual Studio — это уже полноценная среда разработки (IDE), в которой удобно писать, организовывать и тестировать большие проекты. Когда код станет объёмным, такая замена будет необходима.

Make

Make — это утилита для автоматизации сборки проекта. Она позволяет не вводить вручную одни и те же команды каждый раз, когда ты что-то меняешь. Вместо этого ты создаёшь специальный файл — Makefile, где прописываешь правила сборки. Далее всё происходит автоматически. Это экономит кучу времени и делает процесс более стабильным.

NASM

NASM — это ассемблер. Он переводит код, написанный на языке ассемблера (очень низкоуровневом языке, близком к "языку" самого процессора), в машинный код — тот, что реально выполняется на железе. Это нужно, чтобы писать загрузчик, ядро операционной системы и работать с "нулями и единицами", с которыми обычно не сталкиваются в обычной разработке.

Программа виртуализации

Для тестирования ОС не очень удобно использовать настоящий компьютер. Лучше использовать виртуальную машину — это программа, которая создаёт "внутри" твоей системы отдельный, независимый компьютер. В нём ты можешь запускать свою ОС, тестировать и не бояться за основной ПК.

Я буду использовать QEMU — мощный и свободный эмулятор, который поддерживает множество архитектур. Но ты можешь использовать и VirtualBox, и VMware — всё зависит от предпочтений.

Операционная система

Я начинаю работу в Ubuntu — это одна из самых популярных версий Linux. Она простая в установке, отлично подходит для разработки и позволяет быстро настроить всё необходимое. В Ubuntu большинство нужных инструментов можно установить одной строкой в терминале — это удобно и экономит время.

Но позже я планирую перейти на Windows — и заранее предусмотрел такую возможность. Для этого я буду использовать WSL (Windows Subsystem for Linux) — это встроенная возможность Windows, позволяющая запускать Linux-команды и окружение прямо внутри Windows. Это идеальный вариант, если ты хочешь совместить удобство Windows с гибкостью Linux.

Если по каким-то причинам WSL тебе не подойдёт, есть альтернатива — Cygwin. Это программа, которая создаёт в Windows среду, похожую на Linux, с поддержкой большинства нужных утилит.

Для кого эта статья

Если ты впервые видишь такие слова, как "ассемблер", "виртуализация", "машинный код" — не пугайся. Я всё объясню простыми словами, с примерами и пояснениями. Моя цель — не просто показать, как что-то сделать, а помочь тебе понять, почему это работает именно так.

И последнее

Я не гонюсь за скоростью, когда пишу статью — я вкладываю в неё сердце и душу. Иногда бывает сложно, я устаю и ощущаю подавленность. В такие моменты я просто делаю паузу, наливаю себе чай и даю себе немного времени перевести дыхание. Но затем я возвращаюсь к работе, потому что эта тема для меня действительно важна. Я хочу помочь тебе понять сложные вещи просто и понятно.

Если ты дочитал до этого места, значит, тебе тоже это интересно — и это здорово. Мы с тобой на одной волне. Спасибо, что остаёшься со мной. Впереди будет ещё больше полезного и увлекательного!

Начнем

ASM (Assembly)

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

Ассемблер работает проще: он берёт инструкции, написанные на языке ассемблера, и переводит их прямо в машинный код. Эти инструкции состоят из мнемоник (специальных коротких слов) и параметров, которые могут быть регистрами (специальными ячейками внутри процессора), адресами памяти или конкретными числами. Каждая инструкция может содержать один, два или три таких параметра.

Пример:

Основные части инструкции в ассемблере:

  • Мнемоника — это команда процессора, например: ADD (сложение), MOV (копирование данных), INC (увеличение значения).

  • Операнды — это параметры команды, то есть то, над чем команда выполняет действие. Это могут быть регистры (например, AX, BX) или числа (например, 7).

Язык ассемблера зависит от типа процессора. Например, инструкции для процессоров x86 (которые обычно используются в компьютерах) отличаются от инструкций для ARM (которые встречаются в смартфонах и планшетах). Даже у процессоров одной архитектуры могут быть свои особенности. Например, технология SSE появилась только в процессорах Pentium 3, а в Pentium 2 её не было. Но современные процессоры обычно поддерживают старые инструкции, чтобы старые программы продолжали работать.

Для примера, на современном ПК всё ещё можно запустить программы, написанные для очень старого процессора i8086, выпущенного более 40 лет назад. Я собираюсь писать операционную систему для архитектуры x86, значит буду использовать именно этот язык ассемблера.


Как включается компьютер:

  1. В память загружается специальная программа — BIOS, которая хранится в постоянной памяти (ROM).

  2. BIOS запускается и делает следующее:

    • Проверяет и настраивает оборудование

    • Выполняет тесты, чтобы убедиться, что всё работает (POST — Power-On Self Test)

  3. BIOS ищет программу-загрузчик операционной системы.

  4. BIOS передаёт управление загрузчику.

  5. Загрузчик запускает саму операционную систему.

  6. Операционная система начинает свою работу.

Иными словами: сначала запускается BIOS, проверяет компьютер, показывает логотип, а потом передаёт управление загрузчику, который запускает операционную систему.

Процесс включения ПК

Итак, как же это происходит?

На самом деле есть два способа, которыми BIOS может загрузить операционную систему.

Первый способ, который теперь называют устаревшей загрузкой (Legacy), работает следующим образом: BIOS загружает первый блок данных (или первый сектор) с каждого загрузочного устройства в память, начиная с сектора 0. Этот процесс продолжается до тех пор, пока BIOS не найдет специфическую сигнатуру, которая сигнализирует о том, что это именно загрузочный сектор. Как только BIOS находит эту сигнатуру (обычно это последовательность байтов 0xAA55), он понимает, что это загрузочный код, и переходит к выполнению первой инструкции в загруженном блоке данных. Именно с этого момента и начинается процесс загрузки операционной системы. Вся логика заключается в том, что BIOS проверяет и выполняет первый сектор, пока не найдет правильную сигнатуру, которая и запускает нашу ОС. Этот способ был основным в более старых системах и используется до сих пор в некоторых случаях, но с развитием технологий он был заменен более современными методами, такими как загрузка через UEFI.

Загрузка в режиме Legacy (устаревшая BIOS-загрузка) :

  1. BIOS (Basic Input/Output System) при включении компьютера начинает процесс инициализации оборудования и поиска загрузочных устройств (жёсткий диск, SSD, USB и т.п.).

  2. Для каждого загрузочного устройства BIOS загружает первый сектор (512 байт, известный как MBR — Master Boot Record) в память по фиксированному адресу: 0x7C00 — это стандартное место в памяти, откуда начинается выполнение загрузочного кода.

  3. После загрузки сектора BIOS проверяет наличие сигнатуры 0xAA55 в последних двух байтах этого сектора (по адресам 0x1FE и 0x1FF). Эта сигнатура указывает на то, что сектор предназначен для загрузки.

  4. Если сигнатура найдена, BIOS передаёт управление загруженному коду по адресу 0x7C00 — начинается выполнение загрузчика.

Второй метод называется EFI, и он работает немного иначе, чем привычный legacy режим. В режиме EFI BIOS ищет специальный раздел EFI на каждом устройстве, где хранятся программы для загрузки в формате EFI. Это более современный способ загрузки операционной системы, но пока я не буду углубляться в его детали, а сосредоточусь на классическом режиме legacy, который проще для начала.

EFI-загрузка (на базе UEFI) :

  1. BIOS (или UEFI) ищет специальные EFI-разделы: При старте системы UEFI или старый BIOS обращаются к специальному EFI-разделу на диске, где находятся загрузочные файлы. Этот раздел должен быть отформатирован в FAT32 и содержать файлы, которые могут быть выполнены прошивкой для загрузки операционной системы.

  2. Операционная система должна быть скомпилирована как программа EFI: Операционная система или её загрузчик должны быть скомпилированы в специальный EFI-формат. Это означает, что они должны быть в виде исполнимого файла с расширением .efi, который UEFI прошивка может выполнить непосредственно, для загрузки ОС.

Теперь, когда мы немного разобрались, как именно BIOS загружает операционную систему, следующим шагом будет написание кода. Я скомпилирую его, а затем помещу в первый сектор дискеты. Важно помнить, что BIOS требует наличие специальной сигнатуры в загрузочном секторе (сигнатура 0xAA55). Без этого BIOS просто не сможет распознать и запустить операционную систему. Как только добавим сигнатуру, мы сможем протестировать операционную систему.

Кроме того, нужно учитывать, что BIOS всегда загружает операционную систему по адресу 0x7C00. Это ключевая информация, которую нужно учесть при написании кода. Понимание того, что операционная система будет загружена именно в эту область памяти, поможет корректно настроить загрузчик. С этого момента мы можем приступать к подготовке кода и его тестированию.

Теперь нам нужно сообщить ассемблеру, где в памяти будет размещён наш код, это делается с помощью специальной директивы ORG.


Директива ORG

Директива ORG говорит ассемблеру (программе, которая переводит наш код в машинный язык), с какого адреса в памяти будет начинаться наша программа. То есть мы как бы говорим: «представь, что наша программа загрузится именно с этого места в памяти, и все команды и данные нужно считать относительно этого адреса».

Это важно, потому что без точного адреса процессор не сможет правильно найти нужные данные или выполнить переходы внутри программы.

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

Если изменить это число, программа не сможет корректно работать, хотя BIOS всё равно загрузит её по 0x7C00 — тогда адреса внутри программы будут неправильными и переходы не сработают.


Директива и инструкция — в чём разница?

  • Директива — это команда для ассемблера, она помогает собрать программу правильно, задаёт параметры и структуру кода. директивы не превращаются в команды, которые выполняет процессор.

Например:

  • ORG — задаёт адрес начала программы,

  • BITS — сообщает, какой тип инструкций (16, 32 или 64 бит) нужно использовать.

Инструкция — команда для процессора, она переводится в машинный код и выполняется во время работы программы.


Директива BITS

Директива BITS говорит ассемблеру, какую разрядность (размер) команд нужно создавать:

  • BITS 16 — 16-битный код, который используется в самом начале загрузки компьютера (реальный режим).

  • BITS 32 — 32-битный код, который используется в более современных режимах (защищённый режим).

  • BITS 64 — 64-битный код для современных 64-битных систем, например, при загрузке через UEFI.

Это важно, потому что одни и те же команды могут выглядеть по-разному в зависимости от разрядности. Например, регистр процессора называется AX в 16-битном режиме, EAX — в 32-битном, а RAX — в 64-битном.

Директива BITS сама по себе не переключает режим процессора — она просто подсказывает ассемблеру, какой код нужно создавать.

Пример простого кода загрузчика:

  • hlt — останавливает процессор, пока не придёт сигнал прерывания.

  • jmp .halt — переход к метке .halt, чтобы процессор не пошёл дальше и не выполнил случайный код.

  • times — повторяет команду или данные нужное количество раз, здесь — заполняем память нулями.

  • db — объявление байтов (чисел от 0 до 255).

  • dw — объявление слов (2 байта), в данном случае — подпись BIOS.


Объяснение символов $ и $$

  • $ — указывает на текущую позицию (смещение) в памяти, где находится эта команда.

  • $$ — начало всей программы (нулевой адрес нашего кода).


Сборка и запуск

Для удобства я создал папку build, где будут собираться все файлы, и написал файл Makefile — чтобы автоматически собирать программу и создавать образ дискеты.

Makefile:

  • Первая часть копирует бинарник в файл образа дискеты и дополняет его нулями до размера 1,44 мегабайта (размер стандартной дискеты).

  • Вторая часть собирает ассемблерный код из файла main.asm в бинарный файл main.bin.


Как собрать и запустить ?

В командной строке нужно выполнить:

  • make — соберёт программу.

  • qemu-system-i386 — запустит эмулятор компьютера с нашим загрузочным диском.

Как собрать и запустить ?

Как видите, система загружается с дискеты и останавливается — значит, всё работает так, как задумано. На этом этапе наша операционная система ещё ничего не делает, но уже запускается без ошибок.

Теперь, убедившись, что всё работает правильно, давайте вернёмся к коду и выведем на экран сообщение "Hello world!".


HE110 W0R1D!

Прежде чем я начну объяснять, как нам написать код и в принципе сделать, мне нужно рассказать вам об основных понятиях архитектуры х86.

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

Таблица регистров х86

Есть несколько типов регистров. Регистры общего назначения могут использовать практически для любой цели.

Регистры общего назначения: Это такие маленькие «ящички» внутри процессора, которые можно использовать почти для любых задач — например, хранить числа или временные данные. У них разные имена: RAX, RBX, RCX, RDX, R8–R15 и их более маленькие версии (EAX, AX, AL, AH и другие). Можно представить их как карманы, куда можно быстро положить что-то нужное.

Индексные регистры (RSI, RDI): Эти регистры обычно помогают считать позиции или адреса в памяти — например, чтобы знать, где находится нужная информация. Но их можно использовать и для других целей.

Программный счетчик (RIP): Это очень важный регистр, который говорит процессору, где именно в памяти сейчас находится команда, которую нужно выполнить. Можно представить его как указатель, показывающий «следующий шаг» в программе.

Сегментные регистры (CS, DS, ES, FS, GS, SS): Они помогают процессору ориентироваться в разных частях памяти, разбивая её на сегменты. Это как разделы в книге — чтобы быстро найти нужную главу.

Регистр флагов (RFLAGS): Содержит специальные флажки (флаги), которые меняются в зависимости от того, что делает процессор. Эти флаги помогают принимать решения, например, «если число равно нулю, сделай это», «если произошло переполнение, предупреди» и так далее.

Есть ещё несколько специальных регистров, но о них я расскажу, когда они понадобятся :)


Реальная модель памяти (Real memory model)

Поговорим немного об оперативной памяти. Процессор Intel 8086 имел 20-битную адресную шину, что означало возможность доступа к (2²⁰) ячейкам памяти - это примерно 1 мегабайт. По сегодняшним меркам это кажется ничтожно мало, но в конце 70-х в начале 80-х годов объем оперативной памяти в обычных персональных компьютерах составлял всего 64 или 128 килобайт (КБ). Именно поэтому инженеры Intel считали, что 1 МБ - это огромное пространство, которого точно хватит на долгие годы..

Но здесь возникла одна проблема: сам процессор был 16-битным, то есть он мог напрямую работать только с числами до 65 535. Из-за этого он не мог просто так обратиться ко всему 1 мегабайту линейно, как это делается в современных компьютерах.

Чтобы обойти это ограничение, инженеры Intel придумали использовать сегментную адресацию. Идея была в том, чтобы делить память на сегменты - своего рода "блоки", и указывать не один адрес, а два: сегмент и смещение внутри него. Процессор складывал эти два значения по особой формуле и таким образом получал реальный адрес в памяти.

Эта схема была не самой удобной, особенно для программистов, но на тот момент она позволила максимально использовать возможности процессора и доступную память. Благодаря ей стало возможным обращаться к большему объему данных, чем позволяла бы просто 16-битная адресация:


0x1234:0x5678 — это пример адреса в сегментно-смещённой форме. В этой записи:

  • 0x1234сегмент

  • 0x5678смещение (offset)

Оба значения являются 16-битными, то есть могут принимать числа от 0 до 65535.

Такая схема использовалась в старых процессорах (Intel 8086), чтобы обращаться к памяти, которая превышает 64 КБ. Адрес в памяти рассчитывается по формуле:

Таким образом, сегмент и смещение вместе позволяют адресовать до 1 МБ памяти.


В этой системе каждый сегмент представляет собой область памяти размером до 64 килобайт. Смещение указывает, на сколько байт нужно сдвинуться внутри этого сегмента, чтобы получить точный адрес нужного байта. Сам процессор складывает значения сегмента и смещения по специальной формуле: сегмент умножается на 16 (или сдвигается влево на 4 бита), и к результату прибавляется смещение. Таким образом получается физический адрес в памяти.

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

Память в сегментно-смещённой адресации

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

В виде формулы это выглядит так:

Этот способ вычисления используется в процессорах с сегментной адресацией, таких как Intel 8086, чтобы получить доступ к полной памяти объемом до 1 мб.

Это также означает, что к одному и тому же месту в памяти можно обратиться разными способами. Поскольку физический адрес получается путем вычисления по формуле: segment * 16 + offset, существует множество комбинаций сегмента и смещения, которые дают одинаковый результат.

Например, абсолютный адрес (0x7c00), по которому BIOS загружает операционную систему, может быть представлен по-разному в виде segment:offset, и все эти варианты указывают на одно и то же место в памяти:

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

В процессорах Intel 8086 используются специальные регистры сегментов, чтобы указать, с каким именно участком памяти сейчас работает процессор. Каждый такой регистр отвечает за определенную область:

  • CS (Code Segment) - содержит сегмент кода, из которого процессор читает и выполняет инструкции. Сам код указывается не полностью - в связке с регистром IP (Instruction Pointer), который указывает только смещение внутри сегмента.

  • DS и ES (Data Segments) - обычно используется для доступа к данным. В более новых процессорах добавлены еще два сегмента - FS и GS.

  • SS (Stack Segment) - указывает на сегмент стека, где происходят операции вроде вызова функций и хранения локальных переменных.

Если мы хотим прочитать или записать что-то в память, мы должны указать сегмент, в котором находится нужная ячейка. Для этого нужное значение загружается в один из регистров сегментов. Например, чтобы изменить сегмент кода (CS) недостаточно просто записать в него новое значение - это можно сделать только с помощью перехода (jmp,call, и тд.).

-Так как же нам обращаться к ячейкам памяти в Ассембелере ?

Отличный вопрос! Для этого используется вот такой синтаксис:

Где:

  • segment - один из регистров сегмента (CS, DS, ES, FS, GS, SS) По умолчанию используется DS ( или SS, если используется BP как база)

  • base (база): В 16-битном режиме: BX или BP В 32/64-битных режимах: любой регистр общего назначения

  • index (указатель): В 16-битном режиме: SI или DI В 32/64-битных: также любой регистр общего назначения

  • scale (масштаб) - множитель для index, может быть 1, 2, 3, 4 или 8 (только в 32/64-битных режимах)

  • displacement (смещение) - постоянное целое число (может быть отрицательным)

Процессор способен сам выполнять часть арифметики - например, умножение index на scale и прибавление смещения - и использовать результат как адрес в памяти.

В 16-битном режиме есть ряд ограничений, так как процессор Intel 8086 изначально разрабатывался с упрощённой архитектурой и целью удешевления. Например, нельзя напрямую записывать значения в сегментные регистры — сначала нужно использовать обычный регистр как промежуточный. Позже, с выходом процессора Intel 80386, появился 32-битный режим, который значительно расширил возможности и со временем вытеснил 16-битный. Многие функции просто не добавлялись в 16-битный режим, так как он стал использоваться лишь для обратной совместимости.

Тем не менее, изучение 16-битного режима полезно: многие принципы остаются актуальными и в 32-битном, и в 64-битном режимах. Сегодня 16-битный режим в основном применяется только при запуске системы. Большинство операционных систем сразу после старта переходят в 32- или 64-битный режим. Мы тоже сделаем это позже, но пока ограничены размером загрузочного сектора — всего 512 байт. Этого недостаточно для чего-то сложного. Когда мы научимся подгружать данные с диска, возможностей станет гораздо больше.

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


Я уже говорил о базовых и индексных операндах — это регистры, которые могут участвовать в вычислении адреса. Теперь добавим к ним ещё два: смещение и масштаб.

Типы операндов при обращении к памяти:

  1. Базовый (base) — обычно регистр, например bx, bp, eax, rsp и др.

  2. Индексный (index) — регистр, который добавляется к базовому, например si, di, ecx, r8 и т.д.

  3. Масштаб (scale) — множитель для индексного регистра.

  4. В коде это просто scale, может принимать только значения 1, 2, 4 или 8.

  5. Пример: [eax + esi * 4] — здесь 4 и есть масштаб.

  6. Смещение (displacement) — числовая константа (положительная или отрицательная), добавляемая ко всей адресной формуле.

  7. В коде это displacement, например: [ebx + 8] — здесь 8 это смещение.

Все эти компоненты можно объединять по желанию, и все они необязательны. Можно использовать только нужные части.

Простой пример:

В первом случае мы просто получаем смещение (адрес) метки var и сохраняем его в регистре AX.

Во втором случае — с использованием квадратных скобок [] — мы обращаемся к памяти по этому адресу и загружаем само значение, лежащее там (100).

Поскольку в mov ax, [var] не указан сегмент, по умолчанию используется сегмент DS.

Что важно понять:

  • Метки в ассемблере — это именованные адреса, которые указывают на определённые смещения в памяти.

  • При записи вроде [base + index * scale + displacement] ты создаёшь адрес в памяти, по которому будет производиться чтение или запись.

  • Если ты пишешь просто mov ax, var, это не доступ к памяти, а просто копирование адреса var.

  • Мы не использовали базу, индекс или масштаб, а только константу, которая представляет собой смещение, обозначаемое меткой “var”. В ассемблере метки - это просто константы, которые указывают на конкретные смещения памяти.


Рассмотрим более сложную ситуацию, где нужно прочитать третий элемент массива. Мы сохраняем смещение массива в регистр BX, а индекс третьего элемента — в регистр SI. Поскольку индексация в программировании начинается с нуля, третий элемент — это array[2]. Каждый элемент массива у нас — слово (word), то есть занимает 2 байта. Поэтому, чтобы обратиться к элементу с индексом 2, мы должны умножить этот индекс на 2: 2 * 2 = 4. Именно это значение мы и помещаем в SI. Здесь мы используем умножение прямо в инструкции:

Так можно делать, потому что 2 * 2 — это константное выражение, и ассемблер сам его посчитает при компиляции.

Но, например, вот так уже нельзя:

Потому что AX — это регистр, его значение становится известно только во время выполнения, а не на этапе компиляции. Поэтому такое выражение не является константным, и ассемблер не сможет его обработать. В таких случаях нужно использовать инструкцию MUL для умножения.

Также важно помнить: только в выражениях для обращения к памяти (внутри квадратных скобок, например [BX + SI]) можно использовать регистры как часть арифметики адреса. В других командах арифметику с регистрами писать напрямую нельзя.

В финальной строке:

Мы читаем третий элемент массива и сохраняем его в AX. BX — это базовый регистр, в нём находится адрес начала массива. SI — индексный регистр, в нём — смещение нужного элемента, в данном случае 4.

Пример кода целиком:

Теперь в AX будет значение 300, так как это третий элемент массива.


Вернёмся к нашей операционной системе. BIOS уже настроил для нас сегмент кода (CS), и он указывает на сегмент 0. Некоторые версии BIOS действительно могут передать управление нашему коду, используя другие значения сегмента и смещения, например 0x07C0:0x0000, но стандартное поведение предполагает адрес 0x0000:0x7C00, где и находится наш загрузчик.

Однако мы не знаем, правильно ли инициализированы сегменты данных (DS и ES), поэтому должны инициализировать их самостоятельно. Важно помнить: напрямую записывать значение в сегментные регистры нельзя, например, mov ds, 0 вызовет ошибку. Вместо этого нужно сначала записать значение в обычный регистр, например AX, а уже затем скопировать его в сегментные регистры:

Теперь настроим стек. Стек используется для временного хранения данных, возвратных адресов функций и других задач. Он работает по принципу «первым вошёл — последним вышел» (LIFO) с помощью инструкций PUSH и POP. Процессор также использует стек для возврата из подпрограмм.

Особенность стека в том, что он растёт вниз. Это значит, что при добавлении данных в стек указатель стека (SP) уменьшается. Поэтому мы и указываем SP на адрес 0x7C00 — начало загрузчика. Так стек не перезапишет код программы, который находится ниже в памяти.

Если бы стек указывал на конец нашей программы, он бы начал записывать данные туда, где хранится сам код — это привело бы к повреждению программы. Поэтому мы размещаем стек в «безопасном» месте — там, где он не повредит остальную часть загрузчика.


Мы начнём писать код для функции puts, которая выводит строку на экран.

Примечание: Всегда документируйте свои функции на ассемблере!

Сначала определим точку входа и сделаем переход к main, чтобы сохранить структуру программы:

Наша функция будет принимать указатель на строку в DS:SI и выводить символы до тех пор, пока не встретит нулевой символ (0x00). Поскольку мы определили функцию puts выше, а main всё ещё должна быть точкой входа, мы добавляем команду jmp main в самом начале, чтобы исполнение начиналось с неё.

Теперь перейдём к самой функции. В начале мы сохраняем те регистры, которые будем изменять:

Инструкция lodsb загружает байт из текущего адреса строки в AL, автоматически увеличивая SI. После этого or al, al используется для установки флагов — если AL == 0, будет установлен флаг ZF (условный переход) (ноль), и сработает переход jz .done.

Инструкция mov ah, 0x0e подготавливает вызов BIOS-прерывания 0x10, которое используется для вывода символов на экран в текстовом режиме. Поскольку мы выводим символ, хранящийся в AL, это удобно и просто.

После выхода из цикла мы возвращаем значения регистров обратно в том порядке, в котором сохраняли их, и завершаем функцию.

Таким образом, функция puts получает строку, выводит её символ за символом, пока не встретит ноль, и затем корректно завершает выполнение. Всё это делается через BIOS с помощью прерываний, которые предоставляют базовый набор операций ввода/вывода.


Теперь давайте разберёмся с прерываниями и тем, как через них BIOS помогает нам выводить текст на экран.

Что такое прерывание?

Прерывание — это сигнал, который заставляет процессор приостановить текущую задачу и обработать важное событие. Существует три способа вызова прерывания:

  1. Исключения — генерируются самим процессором при критических ошибках. Например, деление на ноль вызовет прерывание.

  2. Аппаратные прерывания — приходят от устройств (например, клавиатура, таймер, контроллеры).

  3. Программные прерывания — вызываются вручную с помощью инструкции INT, где указывается номер прерывания от 0 до 255.

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


Примеры BIOS-прерываний

  • INT 10h — видеосервисы (в том числе вывод текста)

  • INT 11h — конфигурация оборудования

  • INT 12h — размер памяти

  • INT 13h — работа с дисками

  • INT 14h — последовательные порты

  • INT 15h — расширенные сервисы

  • INT 16h — клавиатура


Вывод текста с помощью INT 10h

Чтобы вывести текст на экран, мы воспользуемся видео-прерыванием INT 10h. Нам нужно вызвать подфункцию 0Eh (TTY — телетайпный вывод). Она отображает один символ, передвигает курсор и при необходимости прокручивает экран.

Пояснение:

  • AH = 0Eh — код функции BIOS для текстового вывода

  • AL — ASCII-символ, который нужно напечатать

  • BH = 0 — номер активной страницы (обычно всегда 0)

  • BL — цвет переднего плана, используется только в графическом режиме, можно игнорировать

Примечание: специальные символы вроде 0x07 (звонок), 0x08 (backspace), 0x0A (перевод строки) и 0x0D (возврат каретки) интерпретируются корректно.

Объявление строки и макрос новой строки:

Чтобы не запоминать каждый раз коды перевода строки, удобно использовать макрос:

Теперь создадим строку:

  • db — директива объявления байтов

  • 0x0D, 0x0A — возврат каретки и перевод строки

  • 0 — завершение строки (null-терминатор)

Вывод строки с помощью puts:

Осталось лишь установить указатель DS:SI на начало строки и вызвать puts:

Функция puts, как мы уже писали выше, будет перебирать символы по одному и вызывать int 0x10 для каждого из них.

Сборка и запуск:

Теперь можно собрать и запустить программу:

И вот результат:

Заключение:

Отлично!

Итак, мы успешно написали крошечную операционную систему, которая может выводить текст на экран! Это была большая работа, и мы узнали много нового о том, как работают компьютеры. В следующий раз мы продолжим, улучшим свои навыки сборки и узнаем что-то новое, расширив нашу операционную систему, чтобы она могла выводить на экран числа. После этого мы возьмёмся за сложную задачу загрузки данных с диска.

Last updated