Статья ASM. Программирование ОС [1] – загрузчик

OSDevLogo.webp

В данном цикле из трёх частей займёмся хардкором, и с нуля разработаем свою операционную систему «CodebyOS». Это отличная школа, которую должен пройти любой системный программист. Холодное дыхание компьютерного железа передаёт незабываемые ощущения, а работа с портами ввода-вывода контролёров оставит свой отпечаток на всю оставшуюся жизнь. Только исследуя нёдра системы на нижнем уровне можно осознать, какие гениальные умы придумали это всё. Материал рассчитан на пользователя среднего уровня, который знаком с таким понятием как «регистр процессора». Краткое описание частей приводится ниже:

Часть #1.

1. Планирование архитектуры ОС

• Интерфейс взаимодействия с пользователем
• Текстовый режим на входе
• Переключение в графический режим

2. Базовые сведения о загрузки компьютерной системы
• Начальная инициализация при включении
• Карта распределения памяти в реальном режиме

3. Загрузчик операционной системы
• Поиск носителей
• Практическая часть
• Отладка и тестирование

Часть #2.

1. Ядро операционной системы

• Сбор информации об устройствах
• Конфигурационное пространство PCI

2. Память
• Сегментная модель – таблица GDT (Global Descriptor Table)
• Страничная модель – таблица PDT (Page Directory Table)
• Обработка прерываний – таблица IDT (Interrupt Dispatch Table)
• Режимы Flat-x32, и Long-x64

Часть #3.

1. Драйверы устройств

• Диск, клавиатура, сеть
• Работа с портами ввода-вывода

2. Файловая система
• FAT – File Allocation Table NTFS
• NTFS – NT File System




1. Планирование архитектуры ОС

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

1. При загрузке ставим текстовый режим 80х25 16-цветов, а всю информацию будем выводить напрямую в память видео-адаптера по сегментному адресу реального режима B800:0000. Во-первых это проще реализовать, во-вторых получим более высокую скорость по сравнению с сервисом видео int-10h, ну и в третьих позволит использовать 16 цветов основной палитры, которые передадут дух ушедшей от нас эпохи. В этом режиме для каждого символа выделяются по 2-байта: первый собственно ANSI-код символа, а второй для его цвета и фона. Поддержка фона с символом «Space» (пробел) позволит отображать на экране разнообразные блоки, например кнопки, или выделить шапку консольного окна как на рис.ниже. Задающий цвет байт-атрибута кодируется следующим образом – в старшей тетраде фон, а в младшей цвет символа. Например чтобы вывести белый текст на красном фоне, нужно будет указать атрибут 64+8=72=48h=0100`1111, и т.д:

ColorAttr.webp

2. Интерфейс взаимодействия с юзером скоммуниздим у такого софта как Far/Hiew/Total, где в качестве меню используются клавиши от F1 до F12. Пародию на буттоны можно расположить хоть сверху, хоть снизу окна – здесь уже по желанию. Тогда получим фейс, как на следующем скрине. Учитывая текст.режим 80 столбцов и 25 строк, в рабочем пространстве остаётся всего 23 строки для вывода текста и данных, т.к. две отправятся в штрафбат под шапку и кнопки меню. Этого вполне достаточно для большинства типичных задач.

Face.webp

3. Один из пунктов меню оставим для перехода в защищённый режим, в котором раскрывается вся сила и мощь современных процессоров. Здесь полностью настроим виртуальную память, страничный режим «Пейджинг», напишем драйвер диска и клавиатуры, и переведём ЦП в 64-битный режим «Long-Mode». Именно на эту часть операционной системы сделаем основную ставку – она будет занимать львиную долю в нашем ядре.


2. Начальный этап инициализации

Коротко рассмотрим начальный этап загрузки компьютерной системы.
По логике вещей, после включения кнопки питания Power, какой-то участок кода должен получить управление, чтобы вдохнуть жизнь в огромный механизм. Однако в этот момент «живых» существ в системе ещё нет, и в наличии имеем лишь электрические цепи. Тогда кто-же первым раскручивает маховик? Ответ можно найти в библии Интела том(3) на стр.314, где представлена табличка ниже.

Оказывается инженеры процессоров х86 ещё на заводе программируют свои железяки так, что при включении машины в регистровую пару CS:EIP аппаратно прописываются значения F000:FFF0, в следствии чего управление получает расположенный именно по этому адресу код. Обратите внимание, что значения селекторов остальных сегментных регистров SS\DS\ES\FS\GS равно нулю, и только сегменту кода выпала такая честь CS=F000h:

Init_Reg.webp

Ладно, с указателем разобрались.. а как-же код, который получит бразды правления?
Здесь тоже имеем аппаратную фишку, только на этот раз «проецирования микросхемы ROM-BIOS в физ.память ОЗУ», за что отвечает уже регистр чипсета PAM-0 (Programmable Attribute Map #0). Как результат, содержимое флэшки биоса отображается/мапится в самые верхние адреса реального режима 1МБ, куда процессор и передаёт управление. При наличии 32-битной WinXP, этот участок кода можно просмотреть в достопочтенном отладчике debug.exe – как видим здесь расположен прыжок JMP F000:E05B, с датой выпуска текущего биос «23 июня 1999-года»:

Init_Ep.webp

С этого момента в дело вступает процедура инициализации системы POST (Power On Self Test), далее идёт вся остальная кухня типа выделения ресурсов аппаратным устройствам, и наконец код биоса ищет в конце первого сектора указанного в настройках диска сигнатуру загрузочного девайса 55AAh, и если таковая обнаружится, то копирует этот сектор в память по адресу 0000:7С00, после чего передаёт на него управление. Такому алгоритму следуют все чипсеты на процессорах х86. От сюда следует, что если мы напишем код загрузчика ОС и расположим его в первом секторе диска, то биос сам подтянет наш бутлоадер в память, а мы получим управление.


2.1. Распределение памяти в реальном режиме

В процессе инициализации, код биоса настраивает и карту физической памяти ОЗУ, сохраняя некоторые полезные блоки данных по определённым адресам – эти данные известны как BDA, или «Bios Data Area». В частности, в нижний 256-байтный блок по адресу 0000:0400 сбрасывается всевозможного рода инфа, а в верхнем 128-Кбайтном блоке 0008:0000 лежит уже код обработчиков 32-х прерываний биос с номерами от int-00 до int-19h (прерывания ms-dos начинаются с int-20h и нас не интересуют). Позже мы можем использовать эти инты в своих целях, например для работы с видео int-10h, жёстким диском int-13h, или клавиатурой int-16h.

Таким образом, среди всей доступной нам физ.памяти ОЗУ имеются и занятые регионы, трогать которые можно, но не рекомендуется (получим выстрел в ногу). В таблице ниже, доступные разработчику области я выделил серым. Как видим, внутри первого мегабайта в нашем распоряжении аж 510 КБ памяти, что для реального режима работы ЦП представляет собой целый материк.

MemMap.webp

Ну и немного про адресацию физической памяти в реальном режиме..
При включении компьютера, пока мы самостоятельно не откроем т.н. шлюз А20, шина-адреса ЦП имеет разрядность всего 20-бит. Это ограничивает доступную память ОЗУ значением 2^20 = 1 Мбайт = 0FFFFFh. Поскольку на данном этапе разрядность регистров итого меньше 16-бит, то приходится прыгать по памяти блоками по 2^16=64 КБ = FFFFh, которые и назвали сегментами. Итого для 1 МБ ОЗУ фактически имеем 16 сегментов по 64К в каждом, что можно представить в виде адреса 000F:FFFF (segment : offset). Любое другое представление сегментной/левой части адреса превращает адрес в логический, например F23A:0015, поскольку значение сегмента не может превышать отметки 000F:xxxx.

Процессор оперирует только линейным/физическим адресом, поэтому перед тем-как выставить его на шину, специальный транслятор в блоке MMU (Memory Management Unit) переводит сегментный адрес в физический по формуле: Physical = (Segment*16) + Offset. Учитывая, что умножение на 16 равносильно сдвигу на 4-бита влево, можно легко переводить сегм.адреса в физические например так: F000:FFF0 = 0x000FFFF0.

И вообще с адресами в архитектуре х86 полный кошмар – в штате имеется аж 5 типов адресации:

1. DRAM-адрес используется внутри чипов ОЗУ, и адресует прямоугольную матрицу ячеек памяти как «банк/строка/столбец».​
2. Физический адрес, который в большинстве случаях совпадает с линейным от нуля и до объёма установленной ОЗУ.​
3. Сегментный адрес введён для адресации 20-бит, при помощи двух 16-битных регистров, например CS:IP, DS:SI, SS:SP.​
4. Логический адрес, как форма сегментного адреса.​
5. Виртуальный адрес защищённого режима, когда поверх сегментной накладывается страничная адресация Paging.​


3. Пишем первичный загрузчик

Таким образом, код биоса во-флэшке ROM аппаратно получает управление по адресу F000:FFF0, и после инициализации оборудования процедурой POST, эстафетой передаёт его загрузчику ОС по жёстко назначенному адресу 0000:7С00. Получив руль и педали управления от биоса, загрузчик должен проделать теперь следующие операции:

1. Определить разметку диска – устаревшая MBR (Master Boot Record), или более современная GPT (Guid Partition Table);
2. По сигнатуре 80h в записях Entry таблицы разделов, найти загрузочный раздел Volume на указанном диске;
3. Передать управление непосредственно загрузчику операционной системы.

От сюда следует, что во всех современных ОС имеется 2 типа загрузчиков – первичный для поиска Boot-раздела на диске (как правило это С:\), и вторичный для загрузки ОС с найденного раздела. Например раньше на системах WinXP вторичным загрузчиком являлся файл NTLDR, а начиная с Vista его заменили на BOOTMGR (если диск имеет разметку GPT см.зарезервированный том размером 100 МБ).

Когда системный биос передаёт управление первичному загрузчику в точку 0000:7С00, в регистре DL мы получаем сразу и номер диска, который указан в BIOS-Setup как загрузочный. В нумерации дисков, старший бит определяет контролёр на материнской плате: 0000`0000=Floppy#A (старший бит сброшен), 1000`0000=80h=HDD#0 (старший бит взведён), 1000`0001=81h=HDD#1 и т.д. Номер активного диска потребуется для чтения его секторов, поэтому имеет смысл на входе в загрузчик сразу сохранить его прозапас в переменной.

Двоичный редактор HxD может работать с дисками. Если открыть в нём физический хард, то упрёмся как-раз в загрузчик, а в конце первого сектора 200h=512 байт, будет лежать сигнатура загрузочного девайса 55AAh. Помимо загрузчика, в хвосте сектора располагается и таблица разделов диска «Partition Table», которая начинается с со-смещения 01BEh и описывает 4 основных тома текущего накопителя – на скрине ниже я выделил эту таблицу в блок.

BootLoader.webp

Чтобы было наглядней, можно скопировать таблицу разделов в новый файл. Каждая запись Entry в ней размером 16-байт (одна строка в дампе), и описывает один раздел. Здесь видно, что на моём харде всего 2 раздела C:\ и D:\ , при этом по флагу 80h в первом байте можно сделать вывод, что загрузочным является именно C:\. Два последних дворда в каждой записи позволяют вычислить геометрию тома в логических блоках LBA «Logical Block Address». Один блок – это сектор размером 512-байт. В первом дворде лежит номер сектора с которого начинается том, а во-втором общее кол-во блоков в нём. В данном случае, раздел C:\ начинается у меня с сектора 3Fh=63, и содержит всего 03AE49C5=61.753.797 секторов. Соответственно размер его 61.753.797*512 примерно 30 ГБайт.

MBR.webp

На современных дисках разметку MBR заменили уже на GPT, однако для совместимости устаревшая таблица разделов присутствует и на них. Такие диски можно опознать по флагу файловой системы «BootID» – в байте по смещению(4) у них будет прописано значение EEh. В данном случае, в первой записи видим флаг 07h = основной раздел NTFS, а во-второй записи 0Fh = расширенный раздел. Эта тема уже обсуждалась в статье Диски MBR и GPT, поэтому детально рассматривать её здесь не будем.

Таким образом, получив управление от биоса, наш первичный лоадер должен по флагу 80h найти загрузочный раздел на диске, далее прочитав дворд по-смещению(8) взять LBA его начала, и наконец загрузив первый сектор загрузочного раздела (не путать с первым сектором диска), передать на него управление. Как упоминалось выше, в этом секторе должен лежать вторичный загрузчик ОС.

Здесь нужно отметить, что если в качестве Boot-девайса выступает гибкий диск Floppy (указывается в Bios-Setup), то никакие флаги проверять не нужно, поскольку их попросту нет, ..как нет и самой таблицы разделов. В этом случае просто загружаем в память ОС из заранее известного сектора флопа, и передаём ей управление.


3.1. Практическая часть

Вот пример загрузчика, который реализует всё вышеизложенное. На данном этапе ОС у нас ещё нет, а потому акцент делается на интерфейс пользователя, а так-же проверку типа загрузочного устройства HDD или FDD. Если это FDD, то тест пропускается и загрузчик просто виснет с выводом соответствующего сообщения. Для печати текстовых строк, обязательно нужны переменные с их длиной, т.к. вывод осуществляется по счётчику в регистре CX. В свою очередь для работы с диском (чтение/запись секторов), я использую не стандартный, а расширенный сервис биоса, который требует специального пакета с адресом. Остальное всё в комментах:

C-подобный:
;// FASM code: Первичный загрузчик ОС
;//-----------------------------------------------
org  0x7c00           ;// адрес загрузки в память
jmp  @start           ;// уходим на точку-входа

disk          db  0   ;// переменная под номер диска

header        db  '*** [ CodebyOS ] *** : ver0.1x Build 04.2025'
hLen          =   $ - header
bootDev       db  ' Boot device:'
bootDevLen    =   $ - bootDev
floppy        db  ' Floppy(A) 1.44 Mb'
floppyLen     =   $ - floppy
hdd           db  ' Hard #'
hddLen        =   $ - hdd
error         db  ' Error! Boot partition not found'
errorLen      =   $ - error
loadOs        db  ' Load OS into memory? y/n...'
loadLen       =   $ - loadOs

;// Пакет адреса для расширенного сервиса INT-13h (см.Гук стр.417)
;//---------------------------------------------------------------
DiskPacket:
SizeOf        dw  16      ;// размер пакета
Counter       dw  0       ;// секторов для R/W
BuffOffs      dw  0       ;// адрес буфера приёма-передачи,
BuffSeg       dw  0       ;// ...и его сегмент.
StartLBA      dd  0,0     ;// 64-бит LBA для чтения-записи

align    4
@start:  mov    [disk],dl    ;//<------------- Точка входа! Биос передаёт нам № диска - запомнить его

         xor    ax,ax          ;// настраиваем сегментные регистры
         mov    ds,ax          ;// все по нулям!
         mov    es,ax
         mov    ss,ax
         mov    sp,0x7c00      ;// настраиваем стек SS:SP
         sub    sp,4           ;//

;// Ставим видео-режим 80х25 и прячем курсор
         mov    ax,3
         int    10h
         mov    ah,1
         mov    cx,0x2000
         int    10h

;// Выводим красную шапку сверху окна
         push   es 0 0xb800    ;// 0xb800 = сегмент видео/буфера
         pop    es di          ;// ES:DI = позиция в окне для вывода
         mov    cx,80          ;// счётчик символов для вывода
         mov    al,' '         ;// сам символ
         mov    ah,01001111b   ;// атрибуты: белый текст на красном фоне
         rep    stosw          ;// запись AX в видео/буфер!

;// Напечатаем текст в шапку
         mov    si,header      ;// источник
         mov    cx,hLen        ;// длина строки
         mov    di,36          ;// позиция в окне
@@:      lodsb                 ;// читаем из источника DS:SI по 1-байту,
         stosw                 ;// и выводим в в/буфер ES:DI по 2-байта (с атрибутом)
         loop   @b             ;// повторить СХ-раз..

;// Нарисуем ниже шапки пока пустое меню
         mov    ah,00110000b   ;// атрибут: чёрный на пурпурном
         mov    di,164         ;// позиция в окне: (80*2)+4 = строка(2)
         mov    al,' '         ;// печатать пробелы
         mov    bp,7           ;// счётчик пунктов меню
@@:      mov    cx,10          ;// ширина одного пункта
         rep    stosw          ;// первый пошёл!
         add    di,2           ;// пропустить одну позицию
         dec    bp             ;// счётчик -1
         jnz    @b             ;// промотать, пока счётчик не станет нуль

;// Сообщение о типе загрузочного устройства
         mov    ah,00000111b   ;// серый на чёрном
         mov    di,(160*4)     ;// позиция в окне = строка(4)
         mov    si,bootDev     ;// адрес строки для вывода
         mov    cx,bootDevLen  ;// её длина
@@:      lodsb                 ;// печать в цикле!
         stosw
         loop   @b

         mov    si,floppy      ;// в дефолте выводить "Floppy"
         mov    cx,floppyLen   ;//
         cmp    [disk],0       ;// это флоп?
         je     @f             ;// да - печать
         mov    si,hdd         ;// иначе: меняем адрес строки на "HDD"
         mov    cx,hddLen      ;//
@@:      lodsb                 ;// печать!
         stosw
         loop   @b

         pop    es             ;// восстановить ES (нужен для int-13h)
         cmp    [disk],0       ;// это флоп?
         je     @fdd           ;// да - пропустить
                               ;// иначе: загрузочный девайс = HDD
;//-------------------------------------------------
;// Найти и скопировать вторичный загрузчик в память

         mov    si,0x01BE        ;// смещение к таблице разделов HDD
         mov    cx,4             ;// всего записей в ней
@find:   cmp    byte[si],0x80    ;// проверить флаг загрузочного раздела!
         je     @ok              ;// если нашли..
         add    si,0x10          ;// иначе: переход к сл.записи в таблице
         loop   @find            ;// промотать СХ-раз..

         push   es 0xb800        ;// Ошибка! Boot раздел не найден!
         pop    es
         mov    si,error
         mov    cx,errorLen
         mov    di,(160*5)
@@:      lodsb
         stosw
         loop   @b
         pop    es
         jmp    $

;// Нашли загрузочный раздел - копируем его сектор в память
;// Сначала заполним пакет-адреса для расширенного сервиса INT-13h
@ok:     mov    eax,dword[si+8]     ;// взять стартовый сектор раздела "LBA-Start"
         mov    [StartLBA],eax      ;// указать его в пакете-адреса
         mov    [Counter],1         ;// секторов для чтения (зависит от размера загрузчика)
         mov    ax,ds               ;//
         mov    [BuffSeg],ax        ;// сегмент приёмного буфера,
         mov    [BuffOffs],0x600    ;// ...и его адрес = 0000:0600

         mov    ah,0x42             ;// функция 42h = чтение секторов HDD в память
         mov    dl,[disk]           ;// номер диска
         mov    si,DiskPacket       ;// указатель на пакет-адреса
         int    13h                 ;// зовём сервис диска биоса!

;// Запрос юзеру на загрузку ОС
@fdd:    push   es 0xb800           ;// сегмент видео-буфера
         pop    es
         mov    si,loadOs           ;// адрес строки
         mov    cx,loadLen          ;// её длина
         mov    di,(160*5)          ;// позиция в окне = строка(5)
@@:      lodsb                      ;// печать!
         stosw
         loop   @b
         pop    es

;// Пока ОС`ки у нас нету, поэтому зависнем
         jmp    $

;//-------------------------------------------------------------------
;// Забить нулями оставшееся пространство в 512-байтном секторе
;//-------------------------------------------------------------------
times    510-($-$$) db 0
dw       0xAA55           ;//<--- вставим в хвост Boot-сигнатуру 55AAh


3.2. Отладка и тестирование загрузчика

Скомпилировав исходник выше, получим бинарный файл размером в сектор 512-байт. На следующем этапе нужен образ загрузочного диска, в первый сектор которого и поместим свежеиспечённый Boot.bin. Самый простой вариант – это создать в двоичном редакторе HxD новый файл, перейти в меню «Правка», и выбрав пункт «Вставить байты» указать размер в DEC ровно 1.474.560 байт. Если сохранить теперь этот файл с расширением *.img, то получим образ пустого/чистого диска «Floppy 1.44 Mb». На заключительном этапе, чтобы сделать из него загрузочный, тупо выделив удаляем из образа первые 200h=512 байт, и вставляем на их место Boot.bin. Обратите внимание, что в редакторе HxD обязательно нужно сперва удалить блок нужного размера, а потом вставить бинарные данные, иначе размер образа превысит лимит в 1.474.560 байт, и виртуальная машина откажется принимать на борт такой флоп, посчитав его невалидным.

BootImage.webp

Для тестов нашего загрузчика можно использовать любой эмулятор или вирт.машину – лично у меня это VirtualBox. В настройках ставим первым девайсом флоп, указываем путь до образа дискеты, и жмякнув Go получаем картину ниже. Как видим, меню пока девственно чистое (его заполнит позже код ядра самой ОС), но главное загрузчик получил управление от биоса, и это уже гуд.

Result.webp

Иногда в коде лоадера могут возникнуть ошибки, обнаружить которые не так просто, как может показаться на первый взгляд. Проблема в том, что отладчики работают под управлением какой-либо ОС, а загрузчик получает управление намного раньше. Здесь на помощь приходит эмулятор «Bochs» (в народе борщь), в составе которого имеется весьма простенький, но исправно функционирующий дебагер с графическим интерфейсом. Чтобы его активировать, нужно при установке взвести галку «DLX Linux Demo», после чего в папке с установленной программой появиться дир C:\ProgramFiles(x86)\Bochs-2.xx\dlxlinux.

Bochs_Setup.webp

В дефолте эмуль запускает окно вирт.машины, и чтобы переключить его в режим отладки, нужно внести небольшие правки в файл Run.bat, и конфигуратор BochsRc.bxrc. Оба уже пропатченных этих файла я положил в скрепку, и вам остаётся лишь указать полный путь до образа дискеты *.img в строке «floppya» последнего – у меня она выглядит так:

floppya: type=1_44, 1_44="F:\Install\DEBUG\ASM\CODE\CodebyOS.img", status=inserted, write_protected=0

При удачных обстоятельствах, после запуска Run.bat получим окно ниже где видно, что отладчик останавливается на точке-входа в биос по сегментному адресу F000:FFF0 (линейный 0xFFFF0) с процедурой POST, а не на входе в загрузчик. Соответственно чтобы прыгнуть сразу на полезную нагрузку, придётся командой b 0x7c00 поставить брэйкпоинт, и запустить процесс на исполнение Continue [c]. Одной из полезных фишек этого отладчика является то, что он может дампить все основные структуры в памяти типа GDT/IDT, а так-же в строке статуса отображает текущий режим работы процессора Real или Protected-Mode:

Bochs_1.webp


4. Заключение

В следующей части перейдём к более сложным механизмам ядра самой операционной системы, и попробуем из конфигурационного пространства PCI собрать информацию об установленном оборудовании, для последующей работы с ним. В скрепку положил конфиг-файлы Bochs, а так-же скомпилированный загрузчик Boot.bin (если захотите в HxD собрать имидж самостоятельно), и готовый образ дискеты CodebyOs.img. Всем удачи, пока!

Хек-редактор HxD:
Эмулятор Bochs:
 

Вложения

Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!