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

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

Загрузчики также являются самым первым этапом запуска любой операционной системы.

Что происходит, когда ваш компьютер x86 запускается

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

Для этого он считывает первые 512 байтов с загрузочных устройств и проверяет, содержат ли последние два из этих 512 байтов магическое число (0x55AA). Если это последние два байта, BIOS перемещает 512 байтов по адресу памяти 0x7c00 и обрабатывает все, что было в начале 512 байтов, как код, так называемый загрузчик. В этой статье мы напишем такой фрагмент кода, чтобы он напечатал текст Hello World!. а затем войдите в бесконечный цикл.
Настоящие загрузчики обычно загружают реальный код операционной системы в память, переводят процессор в так называемый защищенный режим и запускают реальный код операционной системы .

Вкратце:

  1. Блок питания обеспечивает стабильное низковольтное питание всех необходимых компонентов.
  2. На компьютере запущена программа самопроверки под названием POST.
  3. ЦП начинает выполнение кода в небольшой доступной только для чтения памяти на вашем компьютере, которая называется BIOS.
  4. BIOS считывает 512 байт с каждого устройства, которое настроено как загрузочное.
  5. Как только он находит 512 байт, заканчивающихся двумя конкретными числами, он считает 512 байт загрузчиком и запускает его как программу.
  6. Затем этот загрузчик обычно загружает реальную операционную систему и запускает ее, среди прочего, для администрирования.

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

Учебник по сборке x86 с помощью ассемблера GNU

Чтобы сделать нашу жизнь немного проще (sic!) И сделать ее более увлекательной, мы будем использовать язык ассемблера x86 для нашего загрузчика. В статье будет использоваться ассемблер GNU для создания двоичного исполняемого файла из нашего кода, а ассемблер GNU использует «синтаксис AT&T» вместо довольно широко распространенного «синтаксиса Intel». Я повторю пример в синтаксисе Intel в конце статьи.

Для тех из вас, кто не знаком с ассемблером x86 и / или ассемблером GNU, я создал это описание, в котором объясняется ровно столько ассемблера, чтобы вы быстро освоили оставшуюся часть этой статьи. . Код сборки в этой статье также будет прокомментирован, чтобы вы могли просматривать фрагменты кода, не зная подробностей сборки.

Готовим наш код

Хорошо, пока мы знаем: нам нужно создать двоичный файл размером 512 байт, который содержит 0x55AA в конце. Также стоит упомянуть, что независимо от того, какой у вас 32-битный или 64-битный процессор x86, во время загрузки процессор будет работать в 16-битном реальном режиме, поэтому наша программа должна иметь дело с этим.

Давайте создадим наш boot.s файл для нашего исходного кода сборки и скажем ассемблеру GNU, что мы будем использовать 16 бит:

.code16 # tell the assembler that we're using 16 bit mode

Ах, все идет отлично! Далее мы должны дать нам отправную точку для нашей программы и сделать ее доступной для компоновщика (подробнее об этом через несколько минут):

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  jmp init # jump to "init"

Примечание. Вы можете называть свой лейбл как угодно. Стандарт будет _start, но я выбрал init, чтобы проиллюстрировать, что на самом деле вы можете называть это как угодно.

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

Пора превратить наш код в какой-то двоичный файл, запустив ассемблер GNU (as), и посмотреть, что у нас получилось:

$ as -o boot.o boot.s 
$ ls -lh . 
784 boot.o 152 boot.s

Ого, подождите! Наш вывод уже составляет 784 байта? Но у нас всего 512 байт для загрузчика!

Что ж, в большинстве случаев разработчики, вероятно, заинтересованы в создании исполняемого файла для операционной системы, на которую они нацелены, то есть файла exe (Windows), elf (Unix). Эти файлы имеют заголовок (читай: дополнительные, предшествующие байты) и обычно загружают несколько системных библиотек для доступа к функциям операционной системы.

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

Обычно ассемблер создает файл ELF или EXE, который готов к запуску, но нам нужен один дополнительный шаг, который удаляет нежелательные дополнительные данные в этих файлах. На этом этапе мы можем использовать компоновщик (компоновщик GNU называется ld).

Компоновщик обычно используется для объединения различных библиотек и двоичных исполняемых файлов из других инструментов, таких как компиляторы или ассемблеры, в один окончательный файл. В нашем случае мы хотим создать «простой двоичный файл», поэтому мы передадим --oformat binary в ld при его запуске. Мы также хотим указать, где начинается наша программа, поэтому мы говорим компоновщику использовать начальную метку (я назвал ее init) в нашем коде в качестве точки входа, используя флаг -e init.

Когда мы запускаем это, мы получаем лучший результат:

$ as -o boot.o boot.s 
$ ld -o boot.bin --oformat binary -e init boot.s 
$ ls -lh . 
  3 boot.bin 
784 boot.o 
152 boot.s

Хорошо, три байта звучат намного лучше, но это не загрузится, потому что в байтах 511 и 512 нашего двоичного файла отсутствует магическое число 0x55AA ...

Делаем его загрузочным

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

Для этого мы можем использовать директиву препроцессора .fill из as. Синтаксис: .fill, count,size,value - он добавляет count раз size байтов со значением value везде, где мы будем записывать эту директиву в наш ассемблерный код в boot.s.

Но как узнать, сколько байтов нам нужно заполнить? Удобно, что ассемблер снова нам помогает. Нам нужно всего 510 байт, поэтому мы заполним 510 - (размер байта нашего кода) байтами нулями. Но каков «размер нашего кода в байтах»? К счастью, у as есть помощник, который сообщает нам текущую позицию байта в сгенерированном двоичном файле: . - и мы также можем получить положение меток. Таким образом, размер нашего кода будет соответствовать текущей позиции . после нашего кода за вычетом позиции первого оператора в нашем коде (которая является позицией init). Итак, .-init возвращает количество сгенерированных байтов нашего кода в конечном двоичном файле ...

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  jmp init # jump to "init" 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long

А теперь соберите новую версию нашего двоичного файла:

$ as -o boot.o boot.s 
$ ld -o boot.bin --oformat binary -e init boot.s 
$ ls -lh . 
 510 boot.bin 
1.3k boot.o 
 176 boot.s

Мы приближаемся - по-прежнему не хватает последних двух байтов нашего волшебного слова:

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  jmp init # jump to "init" 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long 
.word 0xaa55 # magic bytes that tell BIOS that this is bootable

Ой, подождите ... если магические байты 0x55aa, почему мы меняем их местами?
Это потому, что x86 является прямым порядком байтов, поэтому байты меняются местами в памяти.

Теперь, если мы создадим обновленный двоичный файл, он будет иметь длину 512 байт.

Теоретически вы могли бы записать этот двоичный файл в первые 512 байт на USB-накопителе, гибком диске или в другом месте, с которого ваш компьютер успешно загружается, но давайте вместо этого воспользуемся простым эмулятором x86 (он похож на виртуальную машину).

Для этого я буду использовать QEmu с системной архитектурой x86:

qemu-system-x86_64 boot.bin

Выполнение этой команды дает что-то относительно не впечатляющее:

Тот факт, что QEmu перестает искать загрузочные устройства, означает, что наш загрузчик работал, но пока ничего не делает!

Чтобы доказать это, мы можем вызвать цикл перезагрузки вместо бесконечного цикла, который ничего не делает, изменив наш код сборки на это:

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  ljmpw $0xFFFF, $0 # jumps to the "reset vector", doing a reboot 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long 
.word 0xaa55 # magic bytes that tell BIOS that this is bootable

Эта новая команда ljmpw $0xFFFF, $0 переходит к так называемому вектору сброса.
Это фактически означает повторное выполнение первой инструкции после повторной загрузки системы без фактической перезагрузки. Иногда это называют теплой перезагрузкой.

Использование BIOS для печати текста

Хорошо, давайте начнем с печати одного символа.
У нас нет доступной операционной системы или библиотек, поэтому мы не можем просто позвонить printf или одному из его друзей и закончить.

К счастью, BIOS все еще доступен и доступен, поэтому мы можем использовать его функции. Эти функции (наряду с набором функций, которые предоставляет различное оборудование) доступны нам через так называемые прерывания.

В Списке прерываний Ральфа Брауна мы можем найти видео прерывание 0x10.

Одно прерывание может выполнять множество различных функций, которые обычно выбираются путем установки в регистре AX определенного значения. В нашем случае функция« Телетайп » звучит как хорошее совпадение - она ​​печатает символ, указанный в al, и автоматически перемещает курсор. Отлично! Мы можем выбрать эту функцию, установив ah в 0xe, поместив код ASCII, который мы хотим напечатать, в al, а затем вызвать int 0x10:

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  mov $0x0e41, %ax # sets AH to 0xe (function teletype) and al to 0x41 (ASCII "A") 
  int $0x10 # call the function in ah from interrupt 0x10 
  hlt # stops executing 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long 
.word 0xaa55 # magic bytes that tell BIOS that this is bootable

Теперь мы загружаем необходимое значение в регистр ax, вызываем прерывание 0x10 и останавливаем выполнение (используя hlt).

Когда мы запускаем as и ld, чтобы получить обновленный загрузчик, QEmu показывает нам следующее:

Мы даже можем видеть, что курсор мигает в следующей позиции, так что эту функцию должно быть легко использовать с более длинными сообщениями, верно?

Чтобы отобразить полное сообщение, нам понадобится способ сохранить эту информацию в нашем двоичном файле. Мы можем сделать это аналогично тому, как мы храним волшебное слово в конце нашего двоичного файла, но мы будем использовать другую директиву, чем .byte, поскольку мы хотим сохранить полную строку. as, к счастью, поставляется с .ascii и .asciz для строк. Разница между ними в том, что .asciz автоматически добавляет еще один байт, который установлен в ноль. Это пригодится через мгновение, поэтому мы выбрали .asciz для наших данных.
Кроме того, мы будем использовать метку, чтобы предоставить нам доступ к адресу:

.code16 
.global init # makes our label "init" available to the outside 
init: # this is the beginning of our binary later. 
  mov $0x0e, %ah # sets AH to 0xe (function teletype) 
  mov $msg, %bx # sets BX to the address of the first byte of our message 
  mov (%bx), %al # sets AL to the first byte of our message 
  int $0x10 # call the function in ah from interrupt 0x10 
  hlt # stops executing 
msg: .asciz "Hello world!" # stores the string (plus a byte with value "0") and gives us access via $msg 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long 
.word 0xaa55 # magic bytes that tell BIOS that this is bootable

У нас есть одна новая функция:

mov $msg, %bx
mov (%bx), %al

Первая строка загружает адрес первого байта в регистр bx (мы используем весь регистр, потому что адреса имеют длину 16 бит).

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

Но теперь мы получаем ошибку при запуске ld:

$ as -o boot.o boot.s 
$ ld -o boot.bin --oformat binary -e init -o boot.bin boot.o 
boot.o: In function `init': (.text+0x3): relocation truncated to fit: R_X86_64_16 against `.text'+a

Черт, что это значит?

Оказывается, адрес, по которому перемещается msg в файле ELF (boot.o), не помещается в наше 16-битное адресное пространство. Мы можем исправить это, указав ld, где должна начинаться память нашей программы. BIOS загрузит наш код по адресу 0x7c00, поэтому мы сделаем его нашим начальным адресом, указав -Ttext 0x7c00 при вызове компоновщика:

$ as -o boot.o boot.s 
$ ld -o boot.bin --oformat binary -e init -Ttext 0x7c00 -o boot.bin boot.o

QEmu теперь напечатает «H», первый символ текста нашего сообщения.

Теперь мы можем распечатать всю строку, выполнив следующие действия:

  1. Поместите адрес первого байта строки (т.е. msg) в любой регистр, кроме ax (потому что мы используем его для фактической печати), скажем, мы используем cx.
  2. Загрузите байт по адресу в cx в al
  3. Сравните значение в al с 0 (конец строки, благодаря .asciz)
  4. Если AL содержит 0, переходим в конец нашей программы
  5. Прерывание вызова 0x10
  6. Увеличить адрес в cx на единицу
  7. Повторите с шага 2

Что также полезно, так это то, что x86 имеет специальный регистр и набор специальных инструкций для работы со строками.
Чтобы использовать эти инструкции, мы загрузим адрес нашей строки (msg) в специальный регистр. si, которая позволяет нам использовать удобную инструкцию lodsb, которая загружает байт из адреса, на который указывает si, в al и одновременно увеличивает адрес в si.

Соберем все вместе:

.code16 # use 16 bits 
.global init 
init: 
  mov $msg, %si # loads the address of msg into si 
  mov $0xe, %ah # loads 0xe (function number for int 0x10) into ah print_char: 
  lodsb # loads the byte from the address in si into al and increments si 
  cmp $0, %al # compares content in AL with zero 
  je done # if al == 0, go to "done" 
  int $0x10 # prints the character in al to screen 
  jmp print_char # repeat with next byte 
done: 
  hlt # stop execution 
msg: .asciz "Hello world!" 
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long 
.word 0xaa55 # magic bytes that tell BIOS that this is bootable

Давайте посмотрим на этот новый код в QEmu:

🎉 Ура! 🎉

Он печатает наше сообщение путем цикла от print_char до jmp print_char, пока мы не достигнем нулевого байта (который находится сразу после последнего символа нашего сообщения) в si. Найдя нулевой байт, мы переходим к done и останавливаем выполнение.

Версия синтаксиса Intel и nasm

Как и обещал, я также покажу вам альтернативный способ использования nasm вместо ассемблера GNU.

Перво-наперво: nasm может самостоятельно создавать необработанные двоичные файлы и использует синтаксис Intel:

operation target, source - я помню порядок с 'W, T, F' - 'What, To, From' ;-)

Итак, вот версия предыдущего кода, совместимая с nasm:

[bits 16] ; use 16 bits 
[org 0x7c00] ; sets the start address 
init: 
  mov si, msg ; loads the address of "msg" into SI register 
  mov ah, 0x0e ; sets AH to 0xe (function teletype) 
print_char: 
  lodsb ; loads the current byte from SI into AL and increments the address in SI 
  cmp al, 0 ; compares AL to zero 
  je done ; if AL == 0, jump to "done" 
  int 0x10 ; print to screen using function 0xe of interrupt 0x10
  jmp print_char ; repeat with next byte 
done: 
  hlt ; stop execution 
msg: db "Hello world!", 0 ; we need to explicitely put the zero byte here 
times 510-($-$$) db 0 ; fill the output file with zeroes until 510 bytes are full 
dw 0xaa55 ; magic number that tells the BIOS this is bootable

После сохранения как boot.asm его можно скомпилировать, запустив nasm -o boot2.bin boot.asm.

Обратите внимание, что порядок аргументов для cmp противоположен порядку, который использует as, а [org] в nasm и .org в as не одно и то же!

nasm не выполняет дополнительный шаг через файл ELF (boot.o), поэтому он не будет перемещать наш msg по памяти, как это делали as и ld.

Тем не менее, если мы забудем установить начальный адрес нашего кода на 0x7c00, адрес, который двоичный файл использует для msg, все равно будет неправильным, потому что nasm по умолчанию предполагает другой начальный адрес. Когда мы явно устанавливаем его на 0x7c00 (где BIOS загружает наш код), адреса будут правильно рассчитаны в двоичном формате, и код будет работать так же, как и другая версия.

Первоначально опубликовано на 50linesofco.de 28 февраля 2018 г.