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

Когда я только знакомился с 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.bin
qemu-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 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 назад. Здесь я не старался научить вас ассемблеру полностью, но рассказал те возможности, которые обычно никто не использует.

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

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

Комментарии (2)


  1. n0isy
    14.07.2026 02:19

    Питер Нортон, «Программно-аппаратная организация IBM PC» (Inside the IBM PC)  1983 + переиздания
    Питер Нортон, «Программно-аппаратная организация IBM PC» (Inside the IBM PC) 1983 + переиздания
    • Гл. 16 «ROM-BIOS: основные положения» — что происходит при включении питания: самотестирование (POST), инициализация векторов прерываний, поиск расширений ROM на платах, и в конце — вызов загрузчика (INT 19h).

    • Гл. 9 «Диски: рассматриваем дисковую операционную систему» и гл. 10 «Диски: особенности» — разметка диска: загрузочная запись (тот самый нулевой сектор), FAT, корневой каталог, область данных. Тут как раз объясняется, зачем boot-сектор лежит первым и что он содержит.


  1. SIISII
    14.07.2026 02:19

    Вектор прерываний — это некая таблица, содержащая в себе 256 обработчиков различных прерываний

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

    Прерывание может быть и ошибкой, и просто обращением к обработчику прерывания.

    Как прикажете понимать сей текст? Прерывание, вообще-то, всегда приводит к вызову соответствующего обработчика, да и ошибкой прерывание быть не может -- хотя некая ошибка может быть причиной возникновения прерывания.

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

    1) А где же прерывания, запрашиваемые внешними устройствами?

    2) Вообще-то, 8086 и его потомки не способны обнаружить факт обращения к несуществующей памяти, и никаких прерываний при этом не возникает. Возможные прерывания, возникающие при работе программ под управлением достаточно развитых операционных систем типа Windows или Linux связаны с обращением не к "несуществующей памяти", а по виртуальным адресам, которые ОС сделала для данной программы недоступными -- т.е. связаны с работой механизма защиты памяти.

    кстати, в ассемблере 10h и 0x10 — это просто два способа записи числа в 16-ричной системе, и они абсолютно эквивалентны друг другу

    Это конкретно у FASM так, у других трансляторов дело может обстоять по-другому. В частности, MASM (по крайней мере, древний) понимает только 10h, но не 0x10.

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

    Строго говоря, это неточно -- как неверным является ранее появившееся утверждение, что размер сектора диска равен 512 байтам. Это наиболее типичный размер; возможно, что сейчас уже и не выпускают диски с иными размерами секторов -- но это не единственный возможный размер. В частности, контроллер гибких дисков позволяет работать с секторами от 128 байт до, если склероз не изменяет 4 Кбайт.

    Так вот, два указанных байта должны быть 510-м и 511-м байтами сектора, а не двумя его последними байтами. Если сектор имеет размер 512 байт -- да, они будут последними байтами, но если сектор будет иметь больший размер, они окажутся где-то внутри.

    скажем ассемблеру, куда код будет загружен, с помощью org

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

    только что вы поняли, что действительно происходит на самых ранних этапах загрузки компьютера

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

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

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