Разворачивал в очередной раз Linux-образ на USB-drive (почему-то им оказался Manjaro, но это совсем другая история), и в голову пробрались странные мысли: BIOS увидел флешку, а дальше-то что? Ну да, там MBR, скорее всего GRUB и… А раз в MBR затесался чей-то кастомный код, значит и простой человек из Адыгеи может запрограммировать что-нибудь на «большом» компьютере, но вне операционной системы.


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



Вступление


План:


  1. Вывести #
  2. Вывести Hello, Habrauser!
  3. Выводить вводимые символы (уже можно детей развлекать).

Предупреждения и отказы от ответственности


Чтобы не докучать домашних грохотом флоповода, тренироваться будем на кошках QEMU. Но, полагаю, желающие смогут всё то же самое нарезать с помощью dd на флешку и запустить на любой x86-совместимой железяке. Это раз.


Мы будем крушить MBR, так что если вы где-то её еще используете (зачем?) и захотите нарезать наши результаты на живой накопитель (зачем?) — думайте, прежде чем надавить Enter. Это два.


Автор — не настоящий сварщик, и может нести (и обязательно донесёт!) какую-то ересь. (У автора вообще детство Бейсиком сломано.) Набегите в комментарии и всё исправьте! Это три.


Немного про MBR


Для наших низменных целей нам достаточно знать следующее:


  • Структуру, а из самой структуры нам нужна только Bootstrap Area и финальная сигнатура.
  • Факт того, что бутстрап загрузится по фиксированному (слава богу) адресу 0x7c00 (если вы не счастливый обладатель Compaq).
  • Ну и то, что работать мы будем в реальном режиме процессора, и доступна нам будет вся память (злобный смех, муа-ха-ха). Ну как вся: все те 640KB, которых всем хватит. (Даже не знаю, чем нам это поможет или помешает.)

Опкоды


Для начала, что такое опкоды — для тех, кто не знает.


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


Если видели у бати, а то и деда какой-нибудь «Радио» за 80-е годы или «Моделист-Конструктор» за начало 90-х, то в конце наверняка находили листинги для соответствующих самопайных компьютеров: «РК» или «Специалиста». Там были и «ХО», и клоны Lode Runner, и драйверы для подключения печатной машинки «Консул».


Большой скриншоты «Пещеры» + запись стрима


И это только первая страница!



Да-да, всё это вбивали ручками, сверяли контрольные суммы, долго матерясь, искали ошибки, и еще больше матерясь — ждали следующего выпуска с errata.


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


Какие моменты нужно взять на заметку:


  • Проглядеть ассемблеровские мнемоники, все эти MOV, INT, ADD, DIV — это, наверное, и всё, что нам понадобится. Посмотрите, как они работают, какие аргументы принимают, куда складывают результаты.
  • Осознать, что обозначают типы аргументов, которые в интеловской документации выглядят, как: imm8, r16, r/m32, rel8. У меня, вот, довольно много времени (наверное, с час) ушло, чтобы сообразить, как DIV BL превращается в F6 F3 (DIV принимает r/m8, который может указывать, как на регистр, так и адрес памяти — в зависимости от хитросплетений байтов.) и почему опкод F6 — это не только DIV, но и NEG, и еще пара операций (Это зависит от opcode extension — трех байтов в операнде.)

Тулзятина


Решил я по началу писать прямо в файлик, который потом подсовывать сначала эмулятору, а потом и dd, чтобы затолкать на железку, но понял, что так для нас, зумеров, будет решительно неудобно — без красивого оформления, комментариев, да билд-системы. Посему я собрался с духом и накатал себе чудо-скрипт, а вот и он… Хотел было написать я, но подумал что умные дядьки из POSIX наверняка всё сделали за меня, и таки да почти да!


?  $ echo "48 65 6c 6c 6f 2c 20 48 61 62 72 21" | xxd -r -p 
Hello, Habr!%    

Осталось придумать синтаксис комментариев и стрипать их:


?  $ echo -e "# Comment\n48 65 6c 6c 6f 2c 20  # First line\n48 61 62 72 21        # Last line" | sed 's/#.*$//g' | xxd -r -p
Hello, Habr!%  

(На самом деле такой выхлоп будет и без sed-а, потому что xxd просто пропускает то, что не смог распарсить как hex-dump. Но мы ведь не хотим неприятностей?)


В итоге скриптецкий набросать пришлось, но он оказался не таким большим, каким имел шансы быть.


А вот и я, sh-скрипт
#!/bin/sh

IN="${1:-/dev/stdin}"
OUT="${2:-/dev/stdout}"

> $OUT

while read line
do
    echo "$line" | sed 's/#.*$//' | xxd -r -p >> $OUT
done < $IN

Скрипт в репо


В нём есть одна недоработка: в конце обязательно должен быть LF (aka \n), иначе последняя строка обработана не будет. Не могу сказать, что меня это сильно беспокоит, или я думал над тем, как это починить, но если кто-то знает, как это сделать быстро — буду рад помощи.

А теперь — делай, как я!


?  $ ./build loader.mbr loader.img && stat -f %z loader.img
512

512 — именно тот размер, который нас устроит. А как его получить, мы узнаем дальше.


Наступление


Бойлерплейтим


Для начала сделаем болванку, которая сформируется в bin-файлик размером в 512B, забитый исключительно ноликами. «Это можно было сделать с помощью dd и /dev/zero, болван!» — скажете вы и окажетесь правы. Но вы только посмотрите, как красиво я расставил эти нолики по колонкам разделил на блоки и расставил поcчитанные на калькуляторе (ну ладно, в ipython) адреса!


Ничего интересного, просто нолики с адресами
# 0x0000:0x007F (0-127)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# 0x0080:0x00FF (128-255)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# 0x0100:0x017F (256-383)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# 0x0180:0x0200 (384-512)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

Болванка в репо


Естественно, наши нули ни к чему хорошему не приведут, и, что QEMU, что живая железка обругают нас благим Exception'ом.



Расчешем деревяшку еще немного, отделив блоки, в которых должны будут описываться разделы (но они нам не пригодятся), и сигнатура MBR.


Немножко интереснее, с заготовкой таблицы разделов
...

# 0x0180:0x0200 (384-445)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00

# Partition 1     0x01BE:0x01CD (446-461) 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# Partition 2     0x01CE:0x01DD (462-477)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# Partition 3     0x01DE:0x01ED (478-493)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# Partition 4     0x01EE:0x01FD (494-509)
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

# MBR Signature   0x1FE:0x1FF (510-511)
00 00

Файл целиком


Оживим же нашего Буратино, сменив два финальных байта на валидную сигнатуру:


# MBR Signature   0x1FE:0x1FF (510-511)
55 AA

Что получилось


Запрягаем:


$ qemu-system-i386 -nic none loader.img

-nic none отключит сетевые интерфейсы, что избавит эмулятор от пустых надежд загрузиться через PXE, а нас — от лишних ожиданий.


Оно живо, BIOS думает, что эта балалайка его загрузит! Ура, товарищи!



Хватит уже мять мышку, пора печатать!


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


# 0x0000:0x007F (0-127)
B4 0E                      # Set a console output mode
B0 23                      # Set an octothorp sign
CD 10                      # Call a print function

00 00
00 00 00 00 00 00 00 00

Файл в репо


Всё остальное забито теми же нулями и сигнатуркой (следите за тем, чтобы байтов было 512).


Собираем наше ООП, заталкиваем в QEMU и вуаля!



Я уверен, мои безграмотные комментарии всё прояснили, но, на всякий случай, давайте еще разок:


  • B4 0E — здесь мы отправляем в регистр AH значение 0E (нормальные люди написали бы здесь mov ah, 0e), что укажет одной интересной функции BIOS (о ней ниже), что мы нуждаемся в консольном выводе, то есть просто будет печатать символы на экран.
  • B0 23 — тут всё столь же просто: мы заталкиваем в AL код символа #. Где я его взял? Ну что значит «где»? Я же писал выше — в ASCII-таблице из man ascii!
  • CD 10 — это вообще изян: дергаем BIOS-функцию, отвечающую за вывод всякой ерунды на экран. Она подхватит те аргументы, что мы затолкали в AL и AH, ну и сделает то, что мы от неё хотели: напечатает несчастный октоторп.

Особо инициативные могут поиграться с шрифтами с кодом, отправляемым в AL и добиться вывода:


  • $ (B0 24)
  • % (B0 25)
  • или даже a (B0 A0, но возможно мне просто повезло)


Но все эти одиночные символы, конечно, цветочки. Волчьи ягодки нас ждут впереди.


Давайте же принтанём что-нибудь посерьезнее. Тем более сделать это на опкодах — это вам не printf('Hell of word') наклепать.


Конечно же, мы можем сделать, как полные удоды:


Мне стыдно показывать, спрячу под спойлер
# 0x0000:0x007F (0-127)
B4 0E    # Set a console output mode

B0 0A    # LF
CD 10

B0 48    # H
CD 10    # print 

B0 65    # e
CD 10

B0 6C    # l
CD 10

B0 6C
CD 10

B0 6F    # o
CD 10

B0 2C    # ,
CD 10

B0 20    # SPC
CD 10

B0 48    # H
CD 10

B0 61    # a
CD 10

B0 62    # b
CD 10

B0 72    # r
CD 10

B0 61    # a
CD 10

B0 75    # u
CD 10

B0 73    # s
CD 10

B0 65    # e
CD 10

B0 72    # r
CD 10

B0 21    # !
CD 10

00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

Да, я это закоммитил


И, в принципе, мы добились результата:



Давайте немного очеловечим эту штуку:



Го смотреть, что получилось в итоге:


# 0x0000:0x007F (0-127)
B8 00 06          # Clear screen
CD 10

B4 0E             # Set a console output mode
BE 80 7C          # Place 0x0080 + 0x7c00 = 0x7c80 into SI
AC                # Load a byte at address SI into AL, increment SI
3C 00             # AL == 00?
74 06             # If yes, go to +6 bytes (to zeroes)
CD 10             # Print a char in AL
EB F7             # Go to -7 bytes (to AC opcode)

00 00 00 00 00
00 00 00 00 00 00 00 00

...

# 0x0080:0x00FF (128-255)
48 65 6C 6C 6F 2C 20 48   # Hello, H
61 62 72 61 75 73 65 72   # abrauser
21                        # !

00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

На деле всё просто:


  • Очистку экрана пропускаем
  • Важная штука: нам пришлось вспомнить, что наша программка грузится в адрес 0x7c00, а значит адреса в нашей программке должны плясать именно от этого значения. Как именно плясать? Мы ручками изобразили data-блок (да просто написали текст отдельно от кода), заботливо посчитали байты (не зря я всё делил на блоки и подписывал их!), заплюсовали с начальным адресом и положили получившуюся позицию в регистр SI — самое то для хранения адреса с данными.
  • Дальше об SI будет заботиться опкод AC (ньюфаги знают его по мнемонике LODS). Этот красавчик не просто вытащит данные из адреса, лежащего в SI и толкнёт его в AL, но и заинкрементит сам SI! Вай, молодец!
  • Теперь нужно подумать об окончании строки. Раз у нас всё забито нулями, пусть ноль и будет терминатором строки. Свежо, ново, не так ли?
  • AL, в котором лежит текущий символ будем сравнивать с нулём, и если оно так — просто выйдем за пределы кода (в нашем случае, нужно сместиться на 6 байт вперед), а 00 процессор выполнять не хочет.
  • Если же наш кремниевый друг обнаружит в AL что-то стоящее, то он вызовет уже знакомую нам BIOS-функцию...
  • … и джампнется на 7 байт назад — как раз к LODS!

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


Это всё уже почти похоже на настоящее программирование, но что-то маловато в нашем софте интерактива. Давайте сделаем примитивнейшую печатную машинку: будем с помощью тех же BIOS-функций печатать вводимые символы. А сохранять… Ну сфотографируете экран на телефон. Или потом напишем с вами не менее примитивную файловую систему — но уже в другой статье (не забывайте, статьи я пишу раз в десять лет — и изменять этому правилу я не намерен).


Я принял коньяк волевое решение и решил, что печатная машинка достойна отдельного файла. И теперь в репозитории есть printer.mbr и typewriter.mbr.

Эх, да простят меня низкоуровневые программисты:


# 0x0000:0x007F (0-127)
B4 07   # Clear screen
B0 00   #
CD 10   #

B4 00   # Set Get keystroke mode
CD 16   # Read a char -> AL

3C 0D   # AL == 0D? (CR, Return pressed)
75 06   # If no, go to +6 bytes

B4 0E   # Print CR
CD 10   #
B0 0A   # Then print NL

B4 0E   # Print a char
CD 10   # 

EB EC   # Go to -20 bytes

Новый блоб 1


Давайте разбираться:


  • Очистку экрана (которая не работает в QEMU) мы уже видели.
  • Дальше мы с помощью AH = 00h скомандуем прерыванию 16h, которое отвечает за работу с кливиатурой, что нам нужно достать символ нажатой кнопки, который функция окунёт в регистр AL.
  • Далее я натнулся на маленькую траблу, связанную с переводами строк: если мы возьмем символ OD (aka CR aka перевод каретки), который получаем от нажатия клавиши Return/Enter, и напечатаем его, то он у нас только каретку и переведёт (у нас же печатная машинка всё-таки), то есть поставит курсор в начало текущей строки.
  • Поэтому обнаружив CR мы напечатаем не только CR, но и символ LF, который провернёт барабан с бумагой на одну строку, сотворив ожидаемое поведение от Enter.
  • Если же у нас в AL вовсе не OD, то мы всё это пропускаем, перепрыгивая через шесть байтов к инструкции печати символа.
  • Мы молодцы: считали-проверили-напечатали символ, можно повторять сначала! Прыгаем на заботливо посчитанные 20 байт назад.

Ух, поразвлекаемся немножко:



Итого, наша штука может:


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

Но перемещение символов ограничено новой строкой. Что б жизнь emacs-ом не казалась.


Отступление


Маленькие дополнения для тех, кто дочитал до конца.


КДПВ


КДПВ родилась из такого выхлопа, который получился из-за неправильного подсчета байтов для джампа:



Я его немного подрихтовал, добавил красивых цветов. В общем, смотрите сами:


# 0x0000:0x007F (0-127)
B8 12 00          # Set VGA mode 640x480x16
CD 10

B4 0E             # Set a console output mode
B3 00             # Set FG color to black
FE C3             # Color++

BE 80 7C          # Place 0x0080 + 0x7c00 = 0x7c80 into SI
AC                # Load a byte at address SI into AL, increment SI
3C 00             # AL == 00?
74 F6             # If yes, go to -10 bytes (to FE C3)

CD 10             # Print a char in AL
EB F7             # Go to -9 bytes (to AC)

00
00 00 00 00 00 00 00 00

...

# 0x0080:0x00FF (128-255)
48 65 6C 6C 6F 2C 20 48   # Hello, H
61 62 72 61 68 61 62 72   # abrahabr
21 20                     # ! 

00 00 00 00 00 00
00 00 00 00 00 00 00 00

color-printer.mbr


  • Сперва мы переключаем наш вывод (монитор? видеокарту? BIOS?) на цветной режим, а то не будет цветной красоты,
  • Кладём в BL нужный цвет шрифта (чёрный. Да, чёрный.)
  • С помощью инкрементирующего FE инкрементируем BL
  • Ну а следующий фрагмент вы уже видели: печатаем текст, который лежит отдельно, но по завершению не выходим, а возвращаемся к операции инкремента цвета.

Вот и весь меджик.


Ссылки


Определенно, самая полезная глава в моём рассказе.





Постскриптум


Жена подходит, говорит:
— Хватит работать!
— А я и не работаю.
Заглядывает в экран, видит Sublime Text со всем этим безобразием:
— А-а-а, какой ужас! Это зашифрованная порнуха!
Занавес.


В чём-то ведь она права.




UPD: Спасибо eisaev, Andrew_Pinkerton, MrSmith33, Anthony1025 и Юле, которая не желает регистрироваться на Хабре, за правку моих ошибок и опечаток.