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

В этой серии статей я мгновенно забуду про существование .h/.hpp файлов, CMake и прочих исторических костылей, которые уместно использовать везде, кроме проектов по типу ядра ОС. Не думаю, что ваш код или код вашей группы энтузиастов, пусть даже из 100 человек, разрастется так, что вам жизненно потребуются тонны .o файлов и применение всех техник боя современных IT‑компаний. Это чисто мое мнение, не спорю, что кто‑то думает по другому. Статья рассчитана на тех, кто уже уверенно владеет языком C++, шаблонами правильной разработки и системами сборки, и я искренне не хочу учить начинающих разработчиков тому подходу, что верен только в рамках OSDev. Если вы хотите использовать все радости корпоративной разработки, я никого не держу — следуя моему курсу, вы можете сами распоряжаться тем, как ваш проект будет собираться.

Пришло время окунаться с ног до головы в одну из самых уважаемых и нишевых тем, которую все так же умилительно называют «системное программирование».

Помните вторую часть?

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

Я вижу Землю! Она так прекрасна!

Открываем наш проект и видим знакомый Hello, world, на котором, мягко говоря, не выйдет поиграть в киберпанк или посмотреть хотя бы время.

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:
    ; здесь мы уже вывели наше сообщение

stop:                 ; останавливаем код, чтобы процессор не исполнял мусор
    hlt

msg db "Hello, world", 0

times 510-($-$$) db 0
db 0x55
db 0xAA

Давайте подготовим этот код к переходу. Начнем с того, что наши IVT моментально превратятся из вполне корректной структуры в мусор, что приведет к Triple Fault (при исключении в обработчике процессор бежит в Double Fault, и потом, если там тоже исключение, то аварийно перезагружается с Triple Fault). Мы поступаем очень радикально — просто запрещаем прерывания с помощью инструкции cli, убрав все маскируемые прерывания. Я не буду сейчас вдаваться в подробности, этот кусок торта мы отложим на часть, где будем учиться ловить прерывания. И кстати, пока мы еще не ушли из 16-битного реального режима, мы обязательно должны включить линию A20, которая позволит нам адресовать память больше 1МБ.

Немного про A20: Исторически сложилось так, что в процессорах было всего 20 адресных линий (A0-A19), что позволяло адресовать лишь 2^20 байт (1МБ) памяти. Программы начали использовать эффект закольцовывания, когда переполнение сбрасывало адресную шину в ноль, поэтому инженеры сделали явное включение новой линии ради сохранения совместимости со старым софтом.

Так как теперь мы ожидаем от нашего ядра нечто большее, чем просто скромный «привет», мы вполне можем просто убрать ненужную часть кода и начать писать уже реальные вещи.

org 0x7C00 ; говорим ассемблеру, где код будет работать

start:
    xor ax, ax        ; обнуляем AX
    mov ds, ax        ; обнуляем DS
    mov es, ax        ; обнуляем ES
    mov ss, ax        ; обнуляем SS

    mov sp, 0x7C00    ; ставим вручную верхушку стека

    mov ax, 0x2401    ; указываем номер подфункции
    int 0x15          ; вызываем BIOS, чтобы он нам любезно включил A20.
    ; включить A20 можно было и через in/out, но зачем, когда есть BIOS
    cli               ; приостанавливаем маскируемые прерывания, чтобы какой-нибудь таймер не убил наше ядро.

stop:                 ; останавливаем код, чтобы процессор не исполнял мусор
    hlt

times 510-($-$$) db 0
db 0x55
db 0xAA

Переходим в 64 битный режим

Для начала, давайте узнаем про биты в записях таблиц (те самые PML4 и ее дочерние). Нам нужны пока что только PAGE_PRESENT, PAGE_WRITE и PAGE_LARGE.

PAGE_PRESENT equ (1 shl 0) ; позволит процессору понять, существует ли страница вообще
PAGE_WRITE   equ (1 shl 1) ; можно ли писать в эту страницу
PAGE_LARGE   equ (1 shl 7) ; 2МБ или 4КБ

Возможно, у вас появился вполне резонный вопрос, откуда взялись эти 1 shl N (эквивалентно 1 << N, просто в FASM доступен только shl) и почему там не просто N. Дело в том, что это все является некими ключами к отдельным битам. В обычном программировании, bool (тот же самый флаг) является целым байтом, что в принципе удобно, но совершенно непозволительно в механизмах вроде страничной адресации. В каждой записи есть кучка строго отведенных БИТ под разные задачи (которые ведут себя естественно как флаги). Для удобных операций над этими битами существуют маски, которые используются для взятия или записи конкретно в нужные биты.

Представим, что у нас есть маска M = 0b00010001 и некий байт B = 0b10100111. Чтобы взять нужные нам биты (нужные биты помечены 1 в маске), нам нужно просто побитово перемножить M & B, откуда мы получим 0b00000001. Лично я до OSDev ни разу с такой дикой природой не сталкивался. Чтобы установить эти самые биты в маске, мы можем просто использовать побитовый ИЛИ (сложение), итого M | B дает нам 0b10110111, так мы их просто побитово сложили. А если нам нужно их сбросить, нам нужно побитово инвертировать нашу маску и опять применить к нему побитовый И (побитовое умножение). Получается ~M & B, что дает нам 0b10100110.

Итак, вернемся к изначальному вопросу происхождения 1 shl N. С помощью shl мы просто создаем эту маску: если нам нужно выделить, например 3-й бит, мы можем просто взять 0b00000001 (что является просто 1 и 0b1) и передвинуть все биты на 3 шага влево (shift left), что дает нам 0b00001000. Надеюсь, я все объяснил, идем дальше.

Думаю вы уже понимаете, что эти самые таблицы надо где‑то располагать в памяти. В своем ядре я использовал довольно простые адреса, которые вроде никем не заняты. Так как в каждой таблице по 512 записей, каждая из которых в свою очередь имеет размер 8 байт, вся таблица всегда весит ровно 4 КБ, и ее физический адрес должен быть выровнен по 4096 байт (тоже 4 КБ).

; нужные нам константы
PML4 equ 0xA000 ; расположим здесь PML4, ниже аналогично
PDPT equ 0xB000
PD   equ 0xC000

Следующим на очереди станет GDT (Global Descriptor Table). Раньше эта таблица была царицей защиты памяти, когда вовсю использовался 32 битный режим процессора, а в 64-битном эту царицу сажают под наблюдение дракона в высокую крепость: процессор просто игнорирует базовые адреса и лимиты сегментов, спрашивая прокаженную только об уровне привилегий (те самые ringN из второй части) и о факте Long Mode (флаг L, подтверждает Long Mode, который и есть 64-битный режим). Подробно вливаться в работу GDT (сегментной адресации) мы тоже не будем, ибо сейчас нас интересует только страничная адресация.

CODE_SEG     equ GDT.Code - GDT
DATA_SEG     equ GDT.Data - GDT

GDT:
.Null:     dq 0                  ; похоть процессора
.Code:     dq 0x00209A0000000000 ; 64-bit code (0x08)
.Data:     dq 0x0000920000000000 ; 64-bit data (0x10)
.UserCode: dq 0x0020FA0000000000 ; 64-bit user code (0x18)
.UserData: dq 0x0000F20000000000 ; 64-bit user data (0x20)
gdt_ptr:
    dw gdt_ptr - GDT - 1
    dq GDT

Давайте теперь к действиям. По некоторым причинам, очень хорошей практикой считается очищать все таблицы при переходе в 64-битный режим, ибо там может содержаться очень жгучий мусор. Люблю x64 за его выразительность — можно не плодить метки (в FASM эта проблема решена, но все‑же), а просто поставить префикс rep, который повторит инструкцию ECX‑раз.

cld ; направляем наши rep в нужную сторону

; очищаем наши таблицы страниц (удаляем мусор)
mov edi, PML4
mov ecx, 4096
xor eax, eax
rep stosb

mov edi, PDPT
mov ecx, 4096
rep stosb

mov edi, PD
mov ecx, 4096
rep stosb

Вернемся в ту самую дикую битовую природу. Первые 4 байта самой первой записи в PML4 хранят в себе физический адрес таблицы более низкого уровня, выровненный на те же 4 КБ, и флаги. Среди них как раз есть PAGE_PRESENT и PAGE_WRITE.

; настраиваем первую запись в PML4 - одна покроет целых 512 гигабайт, нам этого пока хватит
mov edi, PML4
mov eax, PDPT ; помещаем физический адрес таблицы PDPT
or eax, PAGE_PRESENT or PAGE_WRITE ; помечаем как существующую и доступную для записи
mov [edi], eax ; записываем
mov dword [edi+4], 0 ; процессор та еще зверушка. лучше явно обнулить последние 4 байта

Аналогично с PDPT:

; теперь настроим PDPT, и тоже только одну запись.
; нам главное перейти в C++, где мы сможем уже настраивать таблицы почти без ассемблера
; в целом все точно также, как и в PML4
mov edi, PDPT
mov eax, PD ; помещаем физический адрес таблицы PD
or eax, PAGE_PRESENT or PAGE_WRITE
mov [edi], eax
mov dword [edi+4], 0

С таблицей PD уже все немного сложнее: во-первых, таких записей нам понадобится уже 2. Во-вторых, добавляется новый флаг, имя которому PAGE_LARGE. Он заставляет MMU остановить спуск, и считать страницу не 4 КБ, а 2 МБ. Подробнее про механизм больших и огромных страниц вам нужно почитать во второй части.

; теперь настраиваем PD таблицу. нам достаточно покрыть лишь 4 МБ, дальше все сделаем из C++.
; первая запись (индекс 0)
mov edi, PD
xor eax, eax ; пишем физический адрес 0
or eax, PAGE_PRESENT or PAGE_WRITE or PAGE_LARGE ; ставим 2 МБ флаг
mov [edi], eax
mov dword [edi+4], 0

; вторая запись (индекс 1, смещение +8 байт)
add eax, 0x200000 ; перемещаем наш "курсор", чтобы отобразить следующие 2 мегабайта
; я давно увидел этот трюк. флаги никуда не деваются, можно не писать опять or'ы
mov [edi+8], eax  ; смещаемся на следующую запись в PD (они идут подряд)
mov dword [edi+12], 0

Самая сложная часть уже позади. Осталось немного бюрократии: сказать процессору, что он уже в 64 битном режиме, сделав это по всем законам и понятиям, иначе все это чудо закончится падением мыла (процессор уйдет в Triple Fault, и компьютер будет вечно перезагружаться. Это хуже, чем просто Hello, world). Давайте замаскируем прерывания, чтобы родственники (устройства) не слали нам письма, пока мы не можем их обработать. Весь прикол в том, что мы с помощью маскировки просто игнорируем все прерывания, чтобы когда мы их включили обратно, на нас не свалилась эта куча от устройств, по типу таймера и клавиатуры.

; маскируем прерывания, которые могут исходить, например из клавиатуры.
mov al, 0xFF
out 0xA1, al
out 0x21, al

Теперь стоит включить PAE (проще говоря, меняет размер одной записи с 4 байт на 8 байт) и установить физический адрес PML4.

; включаем PAE (Physical Address Extension)
mov eax, cr4
or eax, (1 shl 5)
mov cr4, eax

; ставим физический адрес в CR3 (там всегда лежит физический адрес страницы)
mov eax, PML4
mov cr3, eax

Включаем страничную адресацию (пейджинг) и наконец 64-битный режим. Есть небольшой прикол на этом этапе: пока в CS загружен старый 16-битный селектор, процессор будет исполнять код как раньше в режиме совместимости.

; включаем long mode (тот самый 64 битный режим)
mov ecx, 0xC0000080
rdmsr
or eax, (1 shl 8)
wrmsr

; включаем пейджинг
mov eax, cr0
or eax, (1 shl 31) or (1 shl 0)
mov cr0, eax

И можно ликовать! Осталось просто загрузить нашу GDT и прыгнуть обеими ногами в 64 битный режим, поставив «выгодный» для нас CS.

    ; грузим свою GDT (в следующих статьях мы подробно разберем его)
    lgdt [gdt_ptr]

    ; и наконец дальний прыжок для перехода в 64-битный режим
    jmp CODE_SEG:(start + good - $$)
use64 ; переключаем в FASM, чтобы он генерировал 64-битный код
good:     ; с этого моменты мы в 64 битном режиме
    hlt   ; хорошо, что нет 128-битного. страшно подумать, что там будет:)
    jmp good

Но мы кое-что забыли. BIOS предоставляет интерфейс для работы с диском, а значит нам стоит загрузить ядро в память перед тем, как мы страстно убежим от 16-битного режима. С помощью BIOS мы загрузим наш C++ код, который начинается с 4 сектора (скажете мне спасибо в следующих частях), в оперативную память, чтобы потом на него прыгать.

    ; загружаем ядро по адресу 0x15000 (сегмент 0x1500, смещение 0x0000)
    mov ax, 0x1500      ; ES:BX = 0x1500:0x0000
    mov es, ax
    xor bx, bx

    mov ah, 0x02
    mov al, 16            ; 8КБ (16 секторов)
    mov ch, 0             ; цилиндр 0
    mov cl, 4             ; сектор 4. мы оставили немного места
    mov dh, 0             ; головка 0
    mov dl, 0x80          ; диск 0x80 (первый жесткий диск)
    int 0x13
    jc .disk_error
    jmp long_mode         ; прыгаем при успехе


.disk_error:
    mov ax, 0xB800
    mov es, ax
    xor di, di

    mov si, DiskError
    call print

print:
    mov ah, 0x04        ; будем писать красным
._loop:
    lodsb               ; AL = [SI], SI++
    test al, al         ; проверяем, достигли ли нулевого терминатора (конец строки)
    jz .done
    stosw               ; [ES:DI] = AX (AL=символ, AH=атрибут), DI += 2
    jmp ._loop
.done:
    ret

DiskError db "disk error", 0

Готово. Чуть ниже я объясню момент с 0xB8000. Мы загружаем 64 битный код, который располагается с 4 сектора (мы оставили немного места в виде 2 и 3 сектора под будущие нужды) в оперативную память, а потом мы настраиваем страницы и переходим в 64 битный режим, прыгая на тот самый код (то есть в C++). Конечный код в студию!

org 0x7C00 ; говорим ассемблеру, где код будет работать

start:
    xor ax, ax        ; обнуляем AX
    mov ds, ax        ; обнуляем DS
    mov es, ax        ; обнуляем ES
    mov ss, ax        ; обнуляем SS

    mov sp, 0x7C00    ; ставим вручную верхушку стека

    mov ax, 0x2401    ; указываем номер подфункции
    int 0x15          ; вызываем BIOS, чтобы он нам любезно включил A20.
    ; включить A20 можно было и через in/out, но зачем, когда есть BIOS

        ; загружаем ядро по адресу 0x15000 (сегмент 0x1500, смещение 0x0000)
    mov ax, 0x1500      ; ES:BX = 0x1500:0x0000
    mov es, ax
    xor bx, bx

    mov ah, 0x02
    mov al, 16            ; 8КБ (16 секторов)
    mov ch, 0             ; цилиндр 0
    mov cl, 4             ; сектор 4. мы оставили немного места
    mov dh, 0             ; головка 0
    mov dl, 0x80          ; диск 0x80 (первый жесткий диск)
    int 0x13
    jc .disk_error

    cli               ; приостанавливаем маскируемые прерывания, чтобы какой-нибудь таймер не убил наше ядро.

    jmp long_mode

.disk_error:
    mov ax, 0xB800
    mov es, ax
    xor di, di

    mov si, DiskError
    call print

; нужные нам константы
PML4 equ 0xA000 ; расположим здесь PML4, ниже аналогично
PDPT equ 0xB000
PD   equ 0xC000

PAGE_PRESENT equ (1 shl 0) ; позволит процессору понять, существует ли страница вообще
PAGE_WRITE   equ (1 shl 1) ; можно ли писать в эту страницу
PAGE_LARGE   equ (1 shl 7) ; 2МБ или 4КБ

CODE_SEG     equ GDT.Code - GDT
DATA_SEG     equ GDT.Data - GDT

GDT:
.Null:     dq 0                  ; похоть процессора
.Code:     dq 0x00209A0000000000 ; 64-bit code (0x08)
.Data:     dq 0x0000920000000000 ; 64-bit data (0x10)
.UserCode: dq 0x0020FA0000000000 ; 64-bit user code (0x18)
.UserData: dq 0x0000F20000000000 ; 64-bit user data (0x20)
gdt_ptr:
    dw gdt_ptr - GDT - 1
    dq GDT

long_mode:
    cld ; направляем наши rep в нужную сторону

    ; очищаем наши таблицы страниц (удаляем мусор)
    mov edi, PML4
    mov ecx, 4096
    xor eax, eax
    rep stosb

    mov edi, PDPT
    mov ecx, 4096
    rep stosb

    mov edi, PD
    mov ecx, 4096
    rep stosb

    ; настраиваем первую запись в PML4 - одна покроет целых 512 гигабайт, нам этого пока хватит
    mov edi, PML4
    mov eax, PDPT ; помещаем физический адрес таблицы PDPT
    or eax, PAGE_PRESENT or PAGE_WRITE ; помечаем как существующую и доступную для записи
    mov [edi], eax ; записываем
    mov dword [edi+4], 0 ; процессор та еще зверушка. лучше явно обнулить последние 4 байта

    ; теперь настроим PDPT, и тоже только одну запись.
    ; нам главное перейти в C++, где мы сможем уже настраивать таблицы почти без ассемблера
    ; в целом все точно также, как и в PML4
    mov edi, PDPT
    mov eax, PD ; помещаем физический адрес таблицы PD
    or eax, PAGE_PRESENT or PAGE_WRITE
    mov [edi], eax
    mov dword [edi+4], 0

    ; теперь настраиваем PD таблицу. нам достаточно покрыть лишь 4 МБ, дальше все сделаем из C++.
    ; первая запись (индекс 0)
    mov edi, PD
    xor eax, eax ; пишем физический адрес 0
    or eax, PAGE_PRESENT or PAGE_WRITE or PAGE_LARGE ; ставим 2 МБ флаг
    mov [edi], eax
    mov dword [edi+4], 0

    ; вторая запись (индекс 1, смещение +8 байт)
    add eax, 0x200000 ; перемещаем наш "курсор", чтобы отобразить следующие 2 мегабайта
    ; я давно увидел этот трюк. флаги никуда не деваются, можно не писать опять or'ы
    mov [edi+8], eax  ; смещаемся на следующую запись в PD (они идут подряд)
    mov dword [edi+12], 0


.setup_longmode:
    ; маскируем прерывания, которые могут исходить, например из клавиатуры.
    mov al, 0xFF
    out 0xA1, al
    out 0x21, al

    ; включаем PAE (Physical Address Extension)
    mov eax, cr4
    or eax, (1 shl 5)
    mov cr4, eax

    ; ставим физический адрес в CR3 (там всегда лежит физический адрес страницы)
    mov eax, PML4
    mov cr3, eax

    ; включаем long mode (тот самый 64 битный режим)
    mov ecx, 0xC0000080
    rdmsr
    or eax, (1 shl 8)
    wrmsr

    ; включаем пейджинг
    mov eax, cr0
    or eax, (1 shl 31) or (1 shl 0)
    mov cr0, eax

    ; грузим свою GDT (в следующих статьях мы подробно разберем его)
    lgdt [gdt_ptr]

    ; и наконец дальний прыжок для перехода в 64 битный режим
    jmp CODE_SEG:(start + good - $$)

print:
    mov ah, 0x04
._loop:
    lodsb               ; AL = [SI], SI++
    test al, al         ; проверяем, достигли ли нулевого терминатора (конец строки)
    jz .done
    stosw               ; [ES:DI] = AX (AL=символ, AH=атрибут), DI += 2
    jmp ._loop
.done:
    ret

use64
good:     ; с этого моменты мы в 64 битном режиме
    mov rax, 0x15000
    jmp rax

stop:
    hlt
    jmp stop

DiskError db "disk error", 0

times 510-($-$$) db 0
db 0x55
db 0xAA

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

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

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

Еще один момент: BIOS жестко тупит, если мы пытаемся загрузить кучу секторов разом в память, поэтому мы сделаем еще один этап загрузки, назвав его middle. Он служит конечным загрузчиком ядра, который потом будет тащить его с файловой системы после установки, но это уже совсем другой разговор.

x86_64-elf-g++ -m64 -std=c++23 -march=x86-64 -ffreestanding -nostdlib -fno-rtti -fno-use-cxa-atexit -mno-red-zone -mno-avx -fno-pic -fno-pie -fpermissive -Iinclude -c os/kernel/middle.cpp -o build/middle.o

x86_64-elf-ld -Ttext 0x15000 --oformat binary build/middle.o -o build/middle.bin

Разберем некоторые флаги.

  • -m64 приказывает компилятору генерировать 64 битный код. Регистры общего назначения становятся RAX, RBX и так далее, а адреса берут размер 8 байт.

  • -march=x86-64 просит компилятор использовать самый базовый набор инструкций. Запустится на любой микроволновке (если она конечно на x64).

  • -ffreestanding гласит, что программа будет выполняться без окружения. Снимает ограничения с int main().

  • -nostdlib запрещает неявно тянуть тяжелые стандартные библиотеки.

  • -fno-rtti убирает весь механизм RTTI, требующий некоторых рантайм функций. В middle этапе они нам вообще не нужны.

  • -fno-use-cxa-atexit позволяет компилятору не вызывать деструкторы для глобальных объектов.

  • -mno-red-zone. Интересный случай. Дикое железо плевало на современные морали, поэтому мы обязываем компилятор честно двигать стек для переменных.

  • -mno-avx запрещает использовать AVX оптимизации, ибо они есть далеко не на всех процессорах, и, самое главное, их надо включать явно. Отбрасываем.

  • -fno-pic и -fno-pie заставляют писать позиционно зависимый код. Для этого, нужно дать понять линковщику (следующая команда), где будет запускаться код, чтобы разрешить эти самые релокации.

  • -fpermissive делает нас и наш код богом. Мы можем представить строку числом, и наоборот. Можем устранить голод, сделать мир лучше. Но не все так радужно - этот флаг просто убирает часть проверок компилятора, делая код опаснее, но дает нам больше контроля.

Теперь к линковщику:

  • -Ttext указывает ожидаемое расположение программы в оперативной памяти. Позволяет линковщику правильно разрешить все релокации.

  • --oformat binary режет весь конечный файл в обычный машинный код.

Наконец, создаем диск. Когда я только учился, я использовал программу UltraISO, в которой я вручную каждый запуск собирал этот диск и вручную запускал VirtualBox. Это работало только потому, что я искренне горел этой идеей, но потом я повзрослел, и узнал про DD.

dd if=/dev/zero of=os.img bs=512 count=32768
dd if=build/boot.bin of=os.img bs=512 count=1 conv=notrunc
dd if=build/middle.bin of=os.img bs=512 seek=4 conv=notrunc

Утилита DD предоставляет очень удобный и быстрый интерфейс для работы с образами дисков.

if означает входной файл, of — выходной, bs — размер сектора, count — сколько именно нужно скопировать из входного файла, и наконец seek указывает смещение, как раз в секторах.

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

fasm os/boot.asm build/boot.bin
if errorlevel 1 exit /b 1

x86_64-elf-g++ -m64 -std=c++23 -march=x86-64 -ffreestanding -nostdlib -fno-rtti -fno-use-cxa-atexit -mno-red-zone -mno-avx -fno-pic -fno-pie -fpermissive -Iinclude -c os/kernel/middle.cpp -o build/middle.o
if errorlevel 1 exit /b 1

x86_64-elf-ld -Ttext 0x15000 --oformat binary build/middle.o -o build/middle.bin
if errorlevel 1 exit /b 1

dd if=/dev/zero of=os.img bs=512 count=32768
if errorlevel 1 exit /b 1
dd if=build/boot.bin of=os.img bs=512 count=1 conv=notrunc
if errorlevel 1 exit /b 1
dd if=build/middle.bin of=os.img bs=512 seek=4 conv=notrunc
if errorlevel 1 exit /b 1

qemu-system-x86_64 -no-reboot -no-shutdown -smp 1 -drive format=raw,file=os.img -vga vmware -display gtk,show-tabs=on -monitor stdio -m 8G -d int,cpu_reset -cpu max -s %*

Разбирать здесь аргументы QEMU я не буду, ибо это еще тонна текста. Вкратце, мы запрещаем при Triple Fault уходить на перезагрузку, тем самым давая время посмотреть логи. Мы печатаем логи в консоль, можем писать отладочные команды и у нас сразу открывается VGA и serial0. Просто удобно.

На этом этапе нам уже доступен C++ код, и дело осталось за малым — написать простейший драйвер диска и загрузить уже остальное ядро с диска, но слово «простейший» в нашей сфере относительное. В следующей части мы уже напишем этот диск, и наконец перейдем в полноценное ядро, где сможем управлять миром. Лично я сделал небольшую заглушку, которая покажет, сработала ли наша магия, или нет.

[[noreturn]] void middle() {
    volatile short* v = (short*)0xb8000;
    constexpr char msg[] = "Hello from 64 bit C++!";
    int i = 0;
    for (char c : msg) {
        v[i++] = c + 0x0400;

    }

    while (true) {
        asm volatile ("hlt");
    }
}

Теперь мы опять пришли в Hello, world подобное ядро, но уже с гораздо более широкими возможностями.

Заключение

В этой статье мы проделали огромную работу, опираясь на уже существующие предыдущие части. Мы перешли в 64-битный режим, миновав 32-битный, начав свое путешествие уже в C++.

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

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