Изучаем архитектуру Intel x86-64 при помощи ассемблера (Часть 1 — Hello World на голом железе)

Я давно хотел научиться писать операционную систему или хотя бы попробовать эту задачу на зуб. Почему ОС начинают писать на языке ассемблера? Дело в том, что работа ОС обеспечивается определенными особенностями архитектуры процессора. Это, например, наличие нескольких уровней привилегий (режим ядра, режим пользователя), поддержка виртуальной памяти, поддержка многозадачности. Работа с этими особенностями архитектуры процессора подразумевает использование таких машинных команд и регистров процессора, о которых компиляторы высокоуровневых языков программирования (например C/C++) ничего не знают. Поэтому — ассемблер. В следующей серии заметок я буду писать о своих опытах исследования архитектуры процессоров Intel как то: работа в реальном режиме, переход в защищенный режим, написание загрузчика (bootloader), обработка прерываний, включение механизма виртуальной памяти и пр.

В Интернете я нашел несколько хороших ресурсов по теме:

Кроме того, если вы не знакомы с языком ассемблера для процессоров семейства x86, рекомендую вам книгу Kip Irvine — Assembly Language for x86 Processors, 7th edition — 2014.

Начнем с того, что напишем программу HelloWorld, запускающуюся на голом железе (без операционной системы) и запустим ее на виртуальной машине.

Программа HelloWorld

Ниже показан исходный код программы HelloWorld на языке ассемблера Flat Assembler (FASM), которая будет стартовать на голом железе в реальном режиме работы x86-совместимого процессора (комментарии пишу на английском — привычка, которая возникла у меня в связи с проблемами с кодировками кириллицы).

; HelloWorld real mode
use16               ; generate 16-bit code
org 7C00h           ; the code starts at 0x7C00 memory address

start:
    jmp far dword 0x0000:entr ; makes CS=0, IP=0x7c00
entr:
    xor  ax, ax     ; ax = 0
    mov  ds, ax     ; setup data segment ds=ax=0
    cli             ; when we set up stack we need disable interrupts because stack is involved in interrupts handling
    mov  ss, ax     ; setup stack segment ss=ax=0
    mov  sp, 0x7C00 ; stack will grow starting from 0x7C00 memory address
    sti             ; enable interrupts
 
    mov  si, message
    cld             ; clear direction flag (DF is a flag used for string operations)
    mov  ah, 0Eh    ; BIOS function index (write a charachter to the active video page)
puts_loop:
    lodsb           ; load to al the next charachter located at [si] address in memory (si is incremented automatically because the direction flag DF = 0)
    test al, al     ; zero in al denotes the end of the string
    jz   puts_loop_exit
    int  10h        ; call BIOS standard video service's function
    jmp  puts_loop
puts_loop_exit:
    cli             ; disable interrupts before halting the processor
    hlt             ; halt the processor
    ;jmp  $         ; alternatively to hlt we could run an infinite loop
 
message db 'Hello World!', 0
finish:
    ; The size of a disk sector is 512 bytes. Boot sector signature occupies the two last bytes.
    ; The gap between the end of the source code and the boot sector signature is filled with zeroes.
    times 510-finish+start db 0
    db 55h, 0AAh    ; boot sector signature

Компиляция исходного кода

Объяснения того, как работает программа — потом, сначала давайте скомпилируем исходный код. Компилировать будем ассемблером Flat Assembler (FASM). Скачиваем с официального сайта архив с дистрибутивом FASM. Внутри архива находится компилятор fasm.exe. Я рекомендовал бы распаковать архив в папку C:\FASM и добавить путь к этой папке в переменную окружения PATH. Далее запускаем командную строку и компилируем исходный код:

fasm HelloWorld.asm HelloWorld.bin

HelloWorld.asm — это приведенный выше файл с исходным кодом, HelloWorld.bin — это скомпилированный машинный код.

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

Операционная система должна загружаться с какого-то носителя (жесткого диска, CD-ROM, флешки, дискеты и т. д.). Создадим образ загрузочной дискеты с нашим машинным кодом. Виртуальная машина сможет загрузиться с виртуальной дискеты, используя этот образ. Создавать образ дискеты умеет unix’овая утилита под названием dd. Существует версия этой утилиты под Windows. Если вы пользуетесь средой Cygwin, то утилита dd есть в ее составе. Итак, запускаем командную строку:

dd if="/dev/zero" of="floppy.img" bs=1024 count=1440
dd if="HelloWorld.bin" of="floppy.img" conv=notrunc

1-я команда создает образ дискеты floppy.img и заполняет его нулями, 2-я — записывает в самое начало образа нашу программу.

Установка и запуск виртуальной машины Bochs

Я имел дело всего с тремя виртуальными машинами: Oracle VM VirtualBox, VMware Workstation Player и Bochs. Bochs хотя и обладает очень скромным графическим интерфейсом, хорош тем, что он легкий и может выполнять машинный код пошагово, т. е. с ним можно производить отладку исходного кода программы. Скачиваем с официального сайта программу установки (файл с расширением .exe) и запускаем его (все настройки я оставлял по-умолчанию). После установки запускаем Bochs. Возникает диалоговое окно Bochs Start Menu. В списке Edit Options выбираем Disk & Boot и нажимаем кнопку Edit. Открывается диалог Bochs Disk Options. На вкладке Floppy Options в группе First Floppy Drive устанавливаем следующие настройки:

Type of floppy drive 3.5 1.44M
First floppy image/device жмем Browse и выбираем ранее созданный нами файл floppy.img
Type of floppy media 1.44M
Write Protection галка снята
Status inserted

Жмем кнопку OK. В окне Bochs Start Menu жмем кнопку Start (предварительно можно сохранить сделанные нами настройки в текстовом файле с расширением .bxrc нажав кнопку Save, впоследствии их можно будет загрузить, нажав кнопку Load). Открывается окно, в котором мы видим надпись

Hello World!

Особенности FASM

Прежде всего обратите внимание на документацию Flat Assembler Documentation and Tutorials, которая также поставляется в дистрибутиве FASM в виде файла PDF. Синтаксис FASM имеет особенности, которые отличают его например от MASM, которым мне доводилось пользоваться до сих пор. Особенности касаются обращений к памяти и работы с метками:

MASM FASM
metka dword 1234h
metka dd 1234h
mov eax, metka
mov eax, [metka]
mov eax, ptr metka
mov eax, offset metka
mov eax, metka
mov ax, word ptr metka
mov ax, word ptr metka
mov ax, word [metka]

Объяснение исходного кода программы HelloWorld

Теперь в двух словах о том, как работает программа HelloWorld. Когда процессор выполняет нашу программу, он считывает машинные команды из оперативной памяти. Откуда в памяти возьмется наша программа? Ответ: с одного из носителей, коими могут быть жесткий диск, дискета, флешка, компакт-диск или локальная сеть. Кто загрузит программу с носителя в оперативную память? Ответ: процессор, который сразу после включения начинает выполнять программу, записанную в BIOS — микросхеме памяти, которая хранит программу, которая проецируется на адресное пространство процессора и которая заставляет процессор перебирать все носители и искать на них т. н. загрузочный сектор — блок данных размером 512 байт, в последних двух байтах которого содержится т. н. сигнатура загрузочного сектора. Найдя загрузочный сектор, процессор копирует его с носителя в оперативную память по адресу 0x7C00 и переходит к выполнению машинной инструкции, расположенной по этому адресу. Чтобы рассчитать адреса меток, компилятор должен знать, по какому адресу будет расположена наша программа — для этого мы используем директиву

org 7C00h

Заметим, что директива org никак не влияет на расположение кода внутри двоичного файла, она влияет только на адреса, на которые указывают метки.
Чтобы поместить в последние два байта (всего в секторе 512 байт) сектора сигнатуру загрузочного сектора (байты 55h, AAh), используем в конце файла директиву times:

finish:
    times 510-finish+start db 0
    db 55h, 0AAh ; boot sector signature

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

use16

Настройка сегментных регистров:

start:
    jmp far dword 0x0000:entr ; makes CS=0, IP=0x7c00
entr:
    xor  ax, ax     ; ax = 0
    mov  ds, ax     ; setup data segment ds=ax=0
    mov  ss, ax     ; setup stack segment ss=ax=0
    mov  sp, 0x7C00 ; stack will grow starting from 0x7C00 memory address

Зачем помещать нули в сегментные регистры? Дело в том, что мы не знаем, каковы значения этих регистров в момент запуска программы. Единственное, что мы знаем — это что наша программа будет загружена по адресу 0x7C00 и что первая машинная команда будет выполнена. В то же время, наша программа рассчитана на то, что в сегментных регистрах cs и ds будут нули, т. е. все метки в программе обозначают смещения относительного базового адреса, равного нулю. Стек мы пока не используем, но это пока — нелишним будет проинициализировать и регистры ss и sp.

Как вывести текстовую строку на экран. После старта процессора в реальном режиме в оперативную память из BIOS загружена таблица прерываний и загружены обработчики прерываний. Среди них есть программные прерывания — те, обработчики которых можно вызвать командой int. Эти прерывания по сути являются функциями, которые BIOS предоставляет нашей программе, и эти функции позволяют осуществлять ввод-вывод, в том числе на экран компьютера. Функции BIOS могут принимать параметры через регистры. Например функция int 10h принимает два параметра: параметр ah=0Eh уточняет задачу функции — «записать символ в видеопамять»; параметр al — ASCII-код символа, который надо записать в видеопамять. И мы последовательно в цикле помещаем в регистр al символы строки «Hello World!» и вызываем прерывание int 10h:

puts_loop:
    lodsb           ; load to al the next charachter located at [si] address in memory (si is incremented automatically because direction flag DF = 0)
    test al, al     ; zero in al means the end of the string
    jz   puts_loop_exit
    int  10h        ; call BIOS standard video service's function
    jmp  puts_loop
puts_loop_exit:

Некоторые функции BIOS приведены на странице OsDev.org — BIOS.
Заканчивается программа инструкцией hlt, которая останавливает работу процессора. Из этого состояния он может быть выведен только прерыванием (как немаскируемым, так и маскируемым) или перезагрузкой (reset).

В следующей заметке я расскажу о настройке проекта osdevlearning, который размещен на BitBucket, и в который я буду помещать наши эксперименты по изучению архитектуры Intel x86-64.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *