Дарова! Сегодня я поделюсь с вами опытом, как я пытался написать собственную ОС и, что из этого вышло. Запасайтесь чайком с печеньками и присаживайтесь поудобнее! Пора окунуться в 16ти битный мир...


С чего начать?

Я начал с изучения ЯП ассемблера. Далее нам понадобится hex редактор (да, я его тоже использовал) и редактор образов дисков. И последнее, что понадобится виртуальная машина. Конкретных рекомендаций давать не буду, но я использовал:

  • HxD hex-редактор

  • ЯП - fasm

  • ultraISO в качестве программы для создания и редактирования образов дисков

  • VMBox - виртуальная машина, хотя во многих туториалах и гайдах использовали qemu (я просто с ним не разобрался)

На_стройки

Я надеюсь вы уже установили необходимые программы, а так же редактор кода? Тогда приступим!

Для начала, создадим структуру проекта, например так:

пример структуры проекта

Os/
bin/
obj/
src/
boot/
makefile.txt

Далее напишем простой makefile:

####################################################################################
#	Create Date: 03.01.2024 18:50
#	Goal: create a simple bootloader and simple core
#	Author: As_Almas
#	Description: wait for write...
#	
#	Status: 
####################################################################################

TARGET = As_OS.img

SRC_BOOT_PREF = ./src/boot/

BOOT_OBJ_F	  = ./obj/
BIN_PREFIX	  = ./bin/

ISO_app = UltraISO.exe
HEX_EDIT = HxD.exe

VBOX = VBoxManage.exe startvm

OS_NAME = "AS_OS" 
DEBUG_FLAGS =  -E VBOX_GUI_DBG_ENABLED=true

ASM = FASM
ASM_FLAGS = 

boot: 
	$(ASM) $(ASM_FLAGS) $(SRC_BOOT_PREF)bootloader.asm $(BOOT_OBJ_F)bootloader.bin

hex: $(BOOT_OBJ_F)bootloader.bin
	$(HEX_EDIT) $(BIN_PREFIX)$(TARGET) $(BOOT_OBJ_F)bootloader.bin

fs:
	$(ISO_app) $(BIN_PREFIX)$(TARGET)

clean:	
	del "$(BOOT_OBJ_F)\*.bin"

debug: $(BIN_PREFIX)$(TARGET)
	$(VBOX) $(OS_NAME) $(DEBUG_FLAGS)

Ну, я думаю здесь всё просто:
boot - выполняет компиляцию файла с исходным кодом загрузчика ядра (bootloader - о нём позже).
hex - сделано для моего удобства, открывается hex-редактор, в котором я спокойно и с удовольствием заменяю сектор с bootloader'ом в образе диска на мой bootloader (не переживайте, вы скоро поймёте что за образы и bootloader'ы)
fs - открывает образ диска в программе для его редактирования. Это нам понадобится на этапе загрузки ядра в образ диска.
clean - очищает папку obj от мусора (P.S. я этой командой не пользовался, но может быть вам пригодится)
debug - запускает виртуальную машину с нашей ОС в режиме отладке (debug-mode)

Далее можно создать образ диска, с помощью специальных программ. Нам требуется образ дискеты размером 1.44мб. Я создал образ диска так:

Способ создания образа

Открываем программу UltrsISO:

главная UltraISO
главная UltraISO

Далее переходим в файл -> новый:

Меню инструментов UltraISO
Меню инструментов UltraISO

Здесь выбираем образ дискеты. Откроется следующее меню, в котором всё оставляем как на картинке:

настройки образа дискеты
настройки образа дискеты

На этом создание образа дискеты завершено, не забудьте сохранить!

Настроим виртуальную машинку?

VMbox - создание машины
VMbox - создание машины

Нажимаем создать. Открывается меню как на картинке, где имя пишем что пожелаем, остальные поля оставляем пустым. Тип устанавливаем other, а версию DOS . Нажимаем далее. В следующем окне выбираете так как пожелаете. Нажимаем далее, и можете не подключать виртуальный жёсткий диск. Нажимаем далее и готово.
Следующим делом необходимо выбрать нашу только, что созданную виртуальную машину в главном меню VMbox и нажать настроить. В настройках переходим в носители и где контроллер Floppy нажимаете плюс. Нажимаем добавить и выбираем недавно созданный нами образ диска:

Картинки настроек
настройки
настройки
меню выбора гибкого диска (дискеты)
меню выбора гибкого диска (дискеты)

Непосредственно код

Перед запуском любой ОС в процессор загружается программа bios. Она проверяет наличие и исправность компонентов необходимых для нормальной работы компьютера. А затем ищет среди устройств хранения данных (жёсткие/гибкие диски и т.п.), тот, в последних двух байтах первого сектора которого находится специальная запись двухбайтовая 0x55, 0xAA, указывающая, что он содержит загрузчик ядра. Обычно сектор равняется 512 байт, но на некоторых устройствах оооочень редко может быть другое количество байт на сектор.

Bootloader или же загрузчик ядра

Коротко, он запускается после проверки систем ПК и нахождения устройства хранения данных (например, дискеты) с возможным к запуску кодом по сигнатуре 0x55, 0xAA в последних байтах первого сектора. BootLoader должен после своего запуска найти на диске ядро системы, загрузить его с диска в оперативную память, перейти из реального режима (x16) в защищённый режим (x32) и передать управление ядру.

Перейдём к написанию кода загрузчика:

use16 ; код для 16 битного режима
org 0x7C00 ; расположение кода с адресса 0x7C00

start: ; начало кода
 ; ... some code 
finish: 
    times 0x200-finish+start-2 db 0 ; заполняем до 510 байта всё нулями
    db 0x55, 0xAA ; сигнатура сектора

Так, как код загрузчика выгружается в оперативную память именно по адресу 0x7C00, мы должны указать компилятору, чтобы он позиционировал код относительно этого адреса. Например, у нас в коде есть переменная str по адресу 0x00CA, но так как наш код будет находиться по адресу 0x7C00, то компилятор должен добавить его к адресу переменной и итоговая позиция переменной в оперативной памяти получается 0x7CCA.
Далее мы должны заполнить все пустые байты вплоть до 510 (включительно) нулями, для того, чтобы сигнатура 0x55, 0xAA была именно в последних двух байтах сектора.

start:
jmp  boot_entry
nop
; запись BPB
boot_entry:
;..

В самом начале загрузчика (не считая команд jmp и nop в сумме занимающих 3 байта), должна находиться специальная запись, которая называется блок параметров биос (BPB), о нём пойдёт речь далее. Для того чтобы процессор не начал чудить пытаясь исполнить, как код область данных BPB, необходимо сразу выполнить короткий прыжок в область с исполняемым кодом.

BPB
; !!!! BPB BLOCK START !!!!
OEM_NAME                db "ASOS2024" ;8 байт - название OEM
BYTES_PER_SECTOR        dw 0x200 ; количество байт на сектор (512 байт на сектор)
SECTORS_PER_CLUSTER     db 1 ; количество секторов на кластер (о кластерах позже)
RSVD_SECTORS            dw 1 ; зарезервированные секторы (1 - сектор загрузчика)
FATS_CNT                db 2 ; количество FAT таблиц (2 - 1 оригинал, 2 копия)
ROOT_DIR_ENTRIES        dw 224 ; количество записей в корневом каталоге
LOW_SECTORS_COUNT       dw 2880 ; количество секторов (нижнее слово)
MEDIA_TYPE              db 0xF0 ; тип носителя (0xF0 - дискета)
SECTORS_PER_FAT         dw 9 ; количество секторов на одну FAT таблицу
SECTORS_PER_TRACK       dw 18 ; количество секторов на дорожку (о ней позже)
HEADS_COUNT             db 2 ; количество считывающих головок 
HEADEN_SECTORS          dd 0 ; скрытые секторы (таких у нас нет)
HIGHT_SECTOR_COUNT      dd 0 ; количество секторов (верхнее слово) - в fat12 и fat16 - пустое
; !!!! BPB BLOCK END !!!!

; !!!! EXTENDED BPB START !!!!
DRIVE_NUMBER            db 0 ; номер устройства (предоставляется в регистре dl биосом)
WIN_RTFLAGS             db 0 ; зарезервировано 
BOOT_SIG                db 0x29 ; сам не разобрался, но пусть будет
VOLUME_ID               dd 0 ; серийный номер устройства 
VOLUME_LABEL            db "AS OS start" ; метка тома 
SYS_LABEL               db "FAT12   "  ; используемая файловая система
; !!!! EXTENDED BPB END !!!!

Разъяснения к коду даны в виде комментариев

boot_entry:
    cli ; off interraps
        xor ax, ax ; ax = 0
        mov ds, ax ; ds = ax
        mov es, ax ; es = ax
        mov ss, ax ; ss = ax
        mov sp, 0x7Bff ; sp = 0x7Bff 
    sti ; on interraps

Отключаем все прерывания. Быстро и коротко обнуляем ах и сегментные регистры. Регистр начала стека sp устанавливаем 0x7Bff - ближайшая пустая область в оперативной памяти. А регистр конца стека ss обнуляем (стек растёт вниз). Включаем прерывания.

    mov [DRIVE_NUMBER], dl ; dl have the hard-drive index from bios
    jmp 0x0000:main ; jmp main | cs = 0, ip = main
main:

Помещаем номер устройства (дискеты) в DRIVE_NUMBER - обычно биос передаёт номер устройства в регистре dl. Затем совершаем длинный прыжок в область с основным нашим кодом. Длинный прыжок необходим для задания регистры cs необходимого значения (обнуляем), для того чтобы наш код выполнялся корректно и без ошибок. При этом регистр ip будет указывать на положение исполняемого кода в оперативной памяти.

main:
  .clear_screan:
        xor ax, ax ; ax = 0 
        int 10h ; video interrap
  .on_start:

Очищаем экран и выбираем режим отображения 40x25 символов. ah = 0 - код функции прерывания int 10h, а al = 0 - код выбираемого видеорежима.

Ну что же, надо бы научиться загружать данные с диска в оперативную память. Иначе это будет не загрузчик, а ерунда какая-та. В современном мире используется LBA запись, в то время, как биос для считывания использует систему CHS. LBA - линейная запись номера сектора на диске, начинается с 0 и идёт до максимального количества секторов на диске. CHS - запись номера сектора включающая в себя, номер считывающей головки, номер считываемого цилиндра (на которой расположен нужный сектор) и номер сектора. Номер сектора в CHS начинается с 1. Естественно, удобнее использовать LBA, поэтому нам нужен способ преобразования LBA в CHS. К счастью такой способ есть!
Код в студию:

; ax = lba
; es:bx = read data start addr (in)
read_sector:
    .LBA_to_CHS: ; linear sector address to  address of cylinder, head and sector
    ; s = (LBA % SECTORS_PER_TRACK) + 1
    ; h = ((LBA - (s-1)) / SECTORS_PER_TRACK) % HEADS_COUNT
    ; c = ( (LBA - (s-1) - h*S) / (HEADS_COUNT*SECTORS_PER_TRACK) )
        push ax ; save ax
        push ax ; save ax
        xor dx, dx  ; find 's'
        mov cx, [SECTORS_PER_TRACK]
        div cx 
        pop ax ; ret ax value
        inc dx
        mov [sector], dx ; sector = s
        dec dx ; dx = s - 1
        
        sub ax, dx ; ax = LBA - (s-1)
        push ax ; save ax 
        xor dx, dx
        mov cx, [SECTORS_PER_TRACK]
        div cx ; ax = ((LBA - (s-1)) / SECTORS_PER_TRACK) 
        mov cl, [HEADS_COUNT]
        div cl 
        mov [head], ah ; head = h

        xor ah, ah ; cylinder = c
        mov cx, [SECTORS_PER_TRACK]
        mul cx ; ax = h * SECTORS_PER_TRACK
        pop cx ; ax value to cx = ( LBA - (s-1))
        sub cx, ax ; cx = (LBA - (s-1) - h*SECTORS_PER_TRACK)
        push cx ; save cx
        mov ax, [SECTORS_PER_TRACK]
        mov cl, [HEADS_COUNT]
        mul cl ; ax = SECTORS_PER_TRACK * HEADS_COUNT
        
        mov cx, ax 
        pop ax 
        xor dx, dx 
        div cx  ; c = cylinder
    .read:

Для точности вычислений, лучше периодически обнулять регистр dx (как сделано в коде). В начале лучше найти значение номера сектора (s+1), так как его значение используется и в других вычислениях. Далее стоит его сохранить для дальнейшего использования. Находим значение номера считывающей головки и так же сохраняем. Находим номер цилиндра. В целом ничего сложного в этом нету, всё делается по формулам. Самое интересное ещё впереди.

Формулы для перевода LBA в CHS
sector = (LBA \mod S) + 1 head = \frac{LBA - (sector - 1)}{S}\mod Hcylinder = \frac{LBA - (sector-1) -head \times S}{H \times S}

где S - количество секторов на дорожку (цилиндр), H - количество считывающих головок

С этим разобрались, а как же считывать данные с диска? Для этого есть прерывание int 13h и его функция 0x02:

.read: 
        mov dl, [DRIVE_NUMBER]
        mov dh, [head]
        mov cx, ax ; cylinder
        shl cx, 6 ; cx << 6
        or cx, [sector] ; hight 10 bits - cylinder; low 6 bits - sector
        mov ax, 0x0201 ; al - count to read; ah - interrap function
        int 13h ; read
        jb _err ; on read error
        pop ax ; ax = LBA
ret

Эта функция принимает следующие параметры:

регистр

значение

dl

номер устройства (диска), с которого нужно считать данные

dh

номер считывающей головки

al

количество секторов к считыванию

ah

номер функции (0x02 - считывание с диска)

es:bx

адрес оперативной памяти, куда заносятся считанные данные

cx

самое интересное:) старшие 10 бит - номер дорожки; cl - номер сектора

Это ещё не всё. Что же делать, если нужно считать несколько секторов с диска, а не 1? Некоторые могут подумать, что можно указать количество секторов к считыванию в al более одного, НО, если вдруг считываемый сектор будет находиться в другом цилиндре или под другой головкой, то вернётся ошибка! Поэтому гораздо лучше каждый сектор считывать отдельно. Пример кода:

;ax = lba
;es:bx = read data start addr (in)
;cx = count of sectors to read
read_sectors:

    .read_loop:
        push cx 
        call read_sector
        inc ax 
        add bx, [BYTES_PER_SECTOR]
        pop cx 
        loop .read_loop
    ret

cx необходимо сохранить в стеке, а ax нет, потому что ax не изменяется внутри внутри функции read_sector.

С этим разобрались, а теперь разберёмся как загружается ядро системы. Для начала нужно загрузить корневой каталог, в котором хранится файловая запись ядра.

Пару слов про то, как хранятся файлы

В мире уже давно существуют системы хранения данных (в нашем случае файлов). Будем использовать систему хранения файлов FAT. А точнее его версию FAT12.
Основные части FAT - это FAT таблица, в которой хранится информация о цепочках кластеров файлов (один кластер - минимальное количество секторов, выделяемых для хранений файла размером 1 байт), а так же корневой каталог, размер которого задаётся в BPB. В корневом каталоге хранятся данные о файлах и других каталогах в файловой системе, а точнее их объявления. Каталоги представлены так же, как файлы за несколькими исключениями в флагах при объявление. А непосредственно в области данных каталогов хранятся объявления вложенных файлов и каталогов, а так же объявление каталога-"родителя" и о самого каталоге ( . - сам каталог, .. - каталог-родитель, но в корневом каталоге этих записей нет).
Файловое объявление (запись) состоит из 32 байт:

название

смещение (байт)

размер (байт)

описание

название

0

11

8 - байт название файла, 3 байта - расширение. Точка между расширением и названием не вставляется

атрибуты

11

1

Верхние два бита зарезервированы и за редким исключением - обнулены. Значения: 0x01 - только для чтения, 0x02 - скрытый, 0x04 - системный, 0x20 - архивный, 0x10 - каталог, 0x08 - метка тома, 0x0F - часть имени другого файла (об этом упоминать не буду), 0x40 - зарезервировано (устройство).

NT_byte

12

1

Зарезервировано для Windows NT

время мс.

13

1

время создания файла в миллисекундах. Часто игнорируется

время с.

14

2

время создания файла с точностью в 2 секунды

дата создания

16

2

дата создания файла

последний доступ

18

2

дата последнего доступа к файлу (чтения или записи)

кластер ст.

20

2

старшие два байта номера первого кластера файла. В FAT12 и FAT16 равен 0. P.S. Число после FAT показывает сколько бит используется для обозначения номера кластера в FAT

последняя запись время

22

2

время последней записи в файл

последняя запись дата

24

2

дата последней записи в файл

кластер мл.

26

2

младшие два байта номера первого кластера файла в таблице FAT

размер

28

4

размер файла в байтах

В таблице FAT каждый предыдущий кластер указывает на следующий кластер файла. Номер кластера на диске и номер записи кластера в таблице FAT совпадают. Если кластер пустой, то он в таблице FAT указывается, как пустой (то есть 0), если кластер повреждён, то указывается 0x0FF7, если кластер - последний кластер файла, то его значение указывается больше или равным 0x0FF8. Это в случае с FAT12, которую и будем использовать.

Давайте же загрузим корневой каталог:

.on_start:
        mov dl, [DRIVE_NUMBER]
    .loadRoot:
        xor ax, ax 
        mov al, [FATS_CNT]
        mov cx, [SECTORS_PER_FAT]
        mul cx    ; dx:ax = FATS_CNT*SECTORS_PER_FAT = size of fat in sectors
        add ax, [RSVD_SECTORS] ; ax =rootDirPos-hideSectors
        add ax, word [HEADEN_SECTORS] ; ax = rootDirPos
        push ax ; save ax
        
        mov ax, [ROOT_DIR_ENTRIES]
        mov cx, 32 
        mul cx ; dx:ax = 32*ROOT_DIR_ENTRIES
        mov cx, [BYTES_PER_SECTOR] ; 512b
        div cx ; ax = (32*ROOT_DIR_ENTRIES):BYTES_PER_SECTOR
        pop cx 
        xchg ax, cx 
        mov bx, 0x0500 ; es:bx = 0x0000:0x0500 | ax = rootDirPos | cx = rootDirSize
        call read_sectors ; read root dir
.find_file:

Для начала этот код вычисляет положение корневого каталога. Корневой каталог расположен после загрузочного сектора и таблиц FAT. Для этого необходимо вычислить размер таблиц FAT, далее прибавить загрузочный сектор, скрытые секторы и зарезервированные секторы. Сохраняем на будущее, и вычисляем размер корневого каталога в секторах (у нас 1 сектор = 1 кластер). Так, как в BPB указывается только количество файловых записей в корневом каталоге, требуется умножить на 32 и разделить на размер сектора в байтах. Загружаем положение корневого каталога и его размер в нужные регистры и считываем по адресу 0x0000:0x0500.

Зная, как устроены файловые записи, можно и нужно найти запись файла ядра в корневом каталоге (для простоты ядро хранится прямо в корневом каталоге):

.find_file:
        mov ax, bx ; mov ax - rootDir max addr in the memory
        mov bx, 0x0500 ; bx = rootDir min addr in the memory
        .check_name:
            mov cx, 10 ; kernel name length
            mov si, sys  ; kernel name pos
            .lp1:
                push bx ; save last position of bx
                add si, cx ; si - symbol position in sys
                add bx, cx ; bx - symbol position in rootDirAddr (bx)
                mov dl, [si] ; dl - symbol from si
                mov dh, [bx] ; dh - symbol from bx
                cmp dl, dh ; check symbols (strcmp)
                pop bx ; recive last position of bx
                jne .next_fn
                mov si, sys ; kernel name pos
                loop .lp1 ; loop while cx > 0
            mov dl, [si] ; check last symbol
            mov dh, [bx] ; check last symbol
            cmp dh, dl ; check last symbol
            jne .next_fn ; if not equal
            mov ax, [bx + 26] ; ax = firstFileClusterAddr
            push ax ; save cluster addr
            .load_fat: ; load fat addr table
;.................... дальнейший код (увидите его ниже) ...............;
        .next_fn: 
                cmp ax, bx 
                jb _err
                add bx, 32 
                jmp .check_name
; some code....
sys db "SYSTEM16BIN"

Загружаем в bx адрес по которому загрузили корневой каталог, в ax сохраняем адрес верхнего предела (конца) корневого каталога. Пройдёмся по корневому каталогу в поисках записи нужного файла (файла ядра). Для этого: загружаем в dh символ из переменной sys (название требуемого файла) со смещением cx , а в dl символ из названия файла корневого каталога по смещению cx. Сравниваем посимвольно, если хоть один символ не соответствует, то переходим к следующей записи в корневом каталоге и так до тех пор, пока не найдём запись файла или не закончится корневой каталог. Если нужная файловая запись найдена, то загружаем в ax адрес первого кластера файла, если нет заканчиваем программу с ошибкой:

_err:
    mov ax, 0x0E45
    mov bx, 0x0007
    int 10h ; выводит на экран символ "E"
    jmp _end 

Ну что ж, файл найден, давайте же загрузим его с диска в оперативную память:

.load_fat: ; load fat addr table
   mov ax, [SECTORS_PER_FAT] 
   mov cl, [FATS_CNT]
   mul cl ; ax = FATs size
   mov cx, ax 
   mov ax, [RSVD_SECTORS] 
   mov bx, 0x0500 ; cx - fats size; ax - fats LBA; bx - load addr
   call read_sectors ; load FAT table to 0x0000:0x0500 
   mov bx, 0x7E00
  .next_Clust:
      pop si ; si = firstFileClusterAddr or fileNextClusterAddr
      add si, 0x0500 ; si = file cluster position in FAT table - 1
      inc si ; si = = file cluster position in FAT table
      mov ax, [si] ; ax = FAT[fileClusterAddr]
      sub si, 0x0500
      test si, 1 ; check odd or even
      jz .even
      and ax, 0x0fff ; if odd: to null higth 4 bits
      jmp .load ; load cluster from disc
      .even: 
          and ax, 0xfff0 ; if even: to null low 4 bits
          shr ax, 4 ; ax >> 4
  .load: ; load file sector
      ; .... здесь код, который разберём далее ....
      cmp ax, 0x0ff7 ; cmp to last cluster
      mov si, ax ; si = NextFileClustAddr
      jc .next_Clust ; if not endClust

Для начала загрузим таблицу FAT: вычисляем размер таблиц FAT в секторах и загружаем их в оперативную память по адресу 0x0500. Здесь в bx я загрузил адрес, по которому в дальнейшем будет "лежать" наше ядро - 0x7E00. Восстанавливаем из стека в si адрес первого кластера файла (сохраняли его в стек в предыдущем коде), добавляем адрес, по которому загрузили FAT (сомневаюсь что надёжно и безопасно, но я болт клал). Увеличиваем на 1 (для точности LBA), загружаем в ax значение кластера файла (номер следующего кластера) из таблицы FAT. Проверяем является ли загруженный кластер чётным или нечётным (в FAT12 это важно), если он нечётный обнуляем старшие четыре бита, а если чётный - младшие 4 бита и сдвигаем "вправо" на 4 бита. Вот мы и получили адрес следующего кластера таблицы FAT. После загрузки этого кластера (разберём далее) необходимо проверить является он последним или следом за ним есть ещё кластер: сравниваем значение кластера в таблице FAT с 0x0ff7, если оно больше или равно - значит это последний кластер файла, если меньше - то загружаем следующий. Немного запутанно, но объяснил :)

От si (текущий кластер в si, следующий в ax) отнимаем адрес, по которому загрузили таблицу FAT. И пожалуй начнём загрузку этого кластера файла:

  .load: ; load file sector
    push ax ; save next cluster addr
    sub si, 3 ; cluster addr -> LBA
    mov ax, [ROOT_DIR_ENTRIES] ; ax = ROOT_DIR_ENTRIES * 32 / 512
    mov cx, 32 ; /
    mul cx  ; /
    mov cx, [BYTES_PER_SECTOR] ; 
    div cx ; ax = count of sectors for ROOT_DIR
    push ax ; save ROOT_DIR_SECTORS_CNT
    mov ax, [SECTORS_PER_FAT] ; ax = FATS_CNT * SECTORS_PER_FAT
    mov cl, [FATS_CNT]
    mul cl ; ax = FAT_SIZE
    add ax, [RSVD_SECTORS] ; ax = FATS + RSVDS
    add ax, si ; ax = LBA + ax
    pop cx ; pop ROOT_DIR_SECTORS_CNT
    add ax, cx ; ax = ax + ROOT_DIR_SECTORS_CNT | READ CLUSER NUM
    mov cx, 1 ; sectors to read
    call read_sectors ; read file sector
    pop ax ; pop NextFileClustAddr
; ............... Отрезок кода ниже - уже разбирали ...............
    cmp ax, 0x0ff7 ; cmp to last cluster
    mov si, ax ; si = NextFileClustAddr
    jc .next_Clust ; if not endClust 
; ............... Отрезок кода выше - уже разбирали ...............
    .start_kernel: ; startup kernel 

Отнимаем от si 3 - первые две записи в FAT заняты корневым каталогом и меткой тома, а три отнимаем потому, что мы увеличивали si на 1 в предыдущем коде (для простоты). Вычисляем размер корневого каталога в кластерах и прибавляем его, а так же прибавляем к si размер таблиц FAT в секторах и количество резервных секторов (напоминаю, у меня 1 сектор = 1 кластеру). Вот мы и вычислили LBA адрес считываемого нами сектора файла. Считываем, предварительно сохранив (в самом начале этого кода) адрес следующего кластера в таблице FAT. После считывания восстанавливаем адрес следующего кластера из стека. Проверяем последний ли это кластер и всё "по новой", а если кластер последний, то стоит уже запустить ядро передав ему управление.

Однако перед передачей управления ядру стоит перевести процессор в 32-битный режим (защищённый режим). Для этого необходимо загрузить в регистр GDT специальную таблицу (расскажу о ней ниже) и перевести бит а20 в единицу (включаем линию а20):

.start_kernel: ; startup kernel 
  cli  ; off interraps
  xor eax, eax ; eax = 0
  mov ax, ds ; ax = ds (0)
  shl eax, 4 ; ax << 4
  add eax, START_gdt ; ax = ds << 4 + START_gdt
  mov [GDTR_+2], eax ; save gdt linear addr
  mov eax, END_gdt 
  sub eax, START_gdt ; eax = gdt_end - gdt_start /|\ gdt_start
  mov [GDTR_], ax ; save gdt_size
  lgdt [GDTR_] ; load gdt

  mov eax, cr0 ; go to 32bit mode
  or al, 1 ; cr0 last byte on
  mov cr0, eax ; 32 bit mode - turn on

  jmp 08h:0x7E00
.next_fn:
; ... какой-то код который разбирали выше ... 
START_gdt:
    .null   dq 0
    .Kcode  dq 0x00CF9A000000ffff
    .Kdata  dq 0x00CF92000000ffff
END_gdt:

GDTR_:
    dw 0
    dd 0 

Отключаем прерывания. Очищаем регистр eax, загружаем в ax значение сегмента данных (ds), сдвигаем "влево" eax на 4 бита. К получившемуся прибавляем адрес положения начала таблицы GDT в памяти. Эти все манипуляции необходимы для получения линейного адреса GDT в памяти. Сохраняем его в 32-битное поле переменной GDTR_ (именно она загружается в регистр gdt командой lgdt). В первом, 16-битном поле переменной GDTR_ хранится размер таблицы (в байтах) gdt - его вычисление гораздо проще: отнимаем от адреса концаgdt адрес его начала и сохраняем в 16-битное поле. Далее загружаем в регистр eax значение cr0 , устанавливаем младший бит в единицу (включаем линию a20) и сохраняем в cr0 новое значение. Всё, мы 32 битном режиму и спокойно передаём управление ядру, выполнив дальний прыжок. Теперь адресация сегментов идёт по таблице gdt , смещение в ней = адрес сегмента.

Как устроена gdt

Это очень интересная вещь, но новичкам (мне тоже) порой бывает трудно в ней разобраться. Я постараюсь объяснить максимально просто.

GDT расшифровывается и переводится, как глобальная таблица дескрипторов. Это двоичная структура характерная для процессоров с архитектурой IA-32 и x86-64. В ней описываются сегменты памяти (данные о том, что и как хранят в себе эти сегменты, а так же как к ним возможно обратиться. При этом в регистр процессора gdt загружается не сама таблица, а структура, содержащая её линейный адрес в памяти и размер в байтах.

Таблица gdt состоит из множества 8-байтных записей, первая запись всегда равно нулю (иногда её используют, как некое хранилище данных о самой таблице, но я не рекомендую так делать - может выйти ошибка).

Каждая запись в таблице состоит из следующих частей: база - линейный 32-битный адрес начала сегмента в памяти; лимит - 20-битное число обозначающее максимальный адрес (база+лимит) сегмента (так сказать "потолок" сегмента);байт доступа и 4 бита флагов. Байт доступа состоит из:

бит

описание

0 (A)

бит доступа, устанавливается CPU. Оставьте его в 0 и забудьте

1 (RW)

если сегмент кода: 0 - чтение сегмента запрещено, 1 - разрешено.
если сегмент данных: 0 - запись в сегмент запрещена, 1 - разрешена.

2 (DC)

если сегмент данных: 0 - сегмент "растёт" вверх, 1 - сегмент "растёт" вниз подобно стеку (в этом случае база должна быть больше лимита).
если сегмент кода: 0 - код может быть вызван (исполнен) только с области с таким же DPL, как у этого сегмента, 1 - код может быть выполнен с области с таким же или ниже уровнем привилегии (DPL).

3 (E)

если 0 - сегмент данных, если 1 - сегмент кода

4 (S)

если 0 - системный сегмент (сегмент состояния задачи, например), 1 - сегмент данных или кода.

5-6 (DPL)

уровень привилегий сегмента, где 0 - высшие привилегии (ядро/система), 3 - низшие привилегии (пользователь)

7 (P)

для любого валидного (исправного) сегмента - 1

Биты флагов:

бит

описание

1 (RESERVED)

установите в 0 и забудьте

2 (L)

1 - сегмент 64-битный; 0 - другая разрядность

3 (DB)

0 - 16-битный сегмент; 1 - 32-битный сегмент

4 (G)

0 - число указанное в лимите, указано в байтах; 1 - число указанное в лимите, указано в блоках по 4 килобайта.

Подробнее рекомендую ознакомиться здесь.

Давайте напишем простейшее 32-битное ядро:

use32 
org 0x7E00

_main:
    mov ax, 0x10
    mov fs, ax
    mov ds, ax 
    mov es, ax 
    mov gs, ax 
    push ax
    pop ss
    mov sp, 0x7Bff

.white_screen:
    mov eax, 40 
    mov ecx, 25
    mul ecx 
    mov ecx, 2
    mul ecx
    mov bx, 0x7700
    mov ecx, eax 
    .cycle:
        mov eax, [VideoTextAddr]
        add eax, ecx 
        mov [eax], bx
        cmp ecx, 0 
        je .end
        sub ecx, 2
        jmp .cycle
    .end:

    push Hi_MSG
    mov eax, 13
    push eax 
    mov eax, 11
    push eax
    call print_str

    jmp $
; args: strAddr (4bytes) | xPos(4bytes) | yPos(4bytes)
print_str: 
    push ebp
    mov ebp, esp 
    push ecx
    push edx

    ; calc the CONSOLE_MAX_SIZE
    sub esp, 4 ; ebp-4 = CONSOLE_SIZE = var1
    mov eax, 40 ; columns (X)
    mov ecx, 25 ; rows (Y)
    mul ecx ; 40 * 25
    mov ecx, 2 ; bytes per symbol in console
    mul ecx ; eax = CONSOLE_SIZE
    add eax, [VideoTextAddr] ; 
    mov [ebp-4], eax ; var1 = CONSOLE_SIZE

    ; calc the cursor position in 40x25 console
    mov ecx, [ebp+8]
    mov eax, 40
    mul ecx 
    add eax, dword [ebp+12]
    mov ecx, 2 
    mul ecx  
    add eax, [VideoTextAddr] ; done

    mov ecx, [ebp+16] ; strAddr
    ; output string while cycle not meet 0-terminator
    .while:
        mov edx, [ebp-4] ; edx = MAX_CONSOLE_SIZE
        cmp eax, edx  ; eax = CURRENT_POSITION | edx = MAX_CONSOLE_SIZE
        jnb .err1_print_exit ; if eax >= edx
        push eax ; save current position
        mov al, byte [ecx] ; al = string symbol
        cmp al, 0 ; 
        je .print_exit ; if al == 0
        mov ah, 0xf0 ; ah = symbol color ( ax = color+symbol)
        inc ecx ; *strAddr++; 
        pop edx ; edx = current position 
        mov word [edx], ax
        mov eax, edx 
        add eax, 2
        jmp .while
    
    .err1_print_exit:
        mov eax, 0xffffffff
    .print_exit:
        xor eax, eax
        add esp, 4
        pop edx
        pop ecx
        mov esp, ebp
        pop ebp
    ret

VideoTextAddr dd 0x000B8000
Hi_MSG db "HELLO WORLD!!!",0

Здесь всё максимально просто: сначала настраиваем регистры в соответствии с gdt. Далее, очищаем экран и изменяем цвет текста и фона его на белый (экран заполняется белым цветом). Начало области данных видеокарты, а конкретно выбранного нами режима вывода на экран находится по адресу 0xB80000. На каждый выводимый символ выделено 2 байта - 1 байт на цвет фона и символа, а второй на сам символ. Далее, начиная с 11 строчки и 13 столбца на экран выводится сообщение с переменной Hi_MSG.

Компилируем командой: FASM.exe system16.asm system16.bin и с помощью программы по работе с образами дисков переносим файл на образ. Запускаем виртуальную машину и наслаждаемся результатом кропотливого труда!


Спасибо за уделённое время, надеюсь вам хватило чая и конфет на прочтение статьи!
Исходный код (без используемых программ) можете найти здесь.

Использованная литература, указана в качестве ссылок по ходу статьи. Буду рад советам или конструктивной критике. До новых встреч!

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


  1. HardWrMan
    30.07.2025 04:16

    Ох уж эти попытки структурирования в листинге ассемблера...


  1. NeriaLab
    30.07.2025 04:16

    Круто - слов нет, одни приятные эмоции. А что Вас побудило создать её?


    1. Procher Автор
      30.07.2025 04:16

      Спасибо ♥️

      Приятно получать такие комментарии. К написанию меня побудило то, что я бросил себе вызов, "смогу ли я?". И "научный" интерес как ОС работают "изнутри"

      В дальнейшем планирую улучшать её, или даже перейти в UEFI


  1. protorus
    30.07.2025 04:16

    "...Регистр начала стека sp устанавливаем 0x7Bff - ближайшая пустая область в оперативной памяти. А регистр конца стека ss обнуляем (стек растёт вниз)..." 

    Так неправильно, нужно 0x7С00, (выравнивание).