Разворачивал в очередной раз Linux-образ на USB-drive (почему-то им оказался Manjaro, но это совсем другая история), и в голову пробрались странные мысли: BIOS увидел флешку, а дальше-то что? Ну да, там MBR, скорее всего GRUB и… А раз в MBR затесался чей-то кастомный код, значит и простой человек из Адыгеи может запрограммировать что-нибудь на «большом» компьютере, но вне операционной системы.
А так как делать такие штуки на языках высокого уровня слишком жирно, а ассемблеров мы не знаем, будем шпарить прямо на опкодах для 8086.
Вступление
План:
- Вывести
#
- Вывести
Hello, Habrauser!
- Выводить вводимые символы (уже можно детей развлекать).
Предупреждения и отказы от ответственности
Чтобы не докучать домашних грохотом флоповода, тренироваться будем на кошках 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. Но мы ведь не хотим неприятностей?)
В итоге скриптецкий набросать пришлось, но он оказался не таким большим, каким имел шансы быть.
#!/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 думает, что эта балалайка его загрузит! Ура, товарищи!
PRINT "#"
Хватит уже мять мышку, пора печатать!
Для нашей первой проказы проделки не понадобится практически ничего, даже думать. Посмотрите сами:
# 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
, но возможно мне просто повезло)
PRINT "Hello, Habrauser!"
Но все эти одиночные символы, конечно, цветочки. Волчьи ягодки нас ждут впереди.
Давайте же принтанём что-нибудь посерьезнее. Тем более сделать это на опкодах — это вам не 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
И, в принципе, мы добились результата:
Давайте немного очеловечим эту штуку:
- Строка целичком и подряд у нас зайдет в отдельный блок,
- Бахнем цикл, в котором будем перебирать наши символы и печатать их,
- Предварительно почистим экран, чтобы убрать сообщения старичка BIOS'а — но тут что-то немножко пошло не так, мне не удается избавиться от навязчивого
Booting from Hard Disk...
.
Го смотреть, что получилось в итоге:
# 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
!
На самом деле я изрядно подпортил себе нервишки, копаясь в документации к командам процессора и подбирая вручную байтики для переходов. Но пара сеансов у психотерапевта всё поправят, не переживайте.
PRINT user_input$
Это всё уже почти похоже на настоящее программирование, но что-то маловато в нашем софте интерактива. Давайте сделаем примитивнейшую печатную машинку: будем с помощью тех же 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
Давайте разбираться:
- Очистку экрана (которая не работает в QEMU) мы уже видели.
- Дальше мы с помощью
AH = 00h
скомандуем прерыванию16h
, которое отвечает за работу с кливиатурой, что нам нужно достать символ нажатой кнопки, который функция окунёт в регистрAL
. - Далее я натнулся на маленькую траблу, связанную с переводами строк: если мы возьмем символ
OD
(akaCR
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
- Сперва мы переключаем наш вывод (монитор? видеокарту? BIOS?) на цветной режим, а то не будет цветной красоты,
- Кладём в
BL
нужный цвет шрифта (чёрный. Да, чёрный.) - С помощью инкрементирующего
FE
инкрементируемBL
- Ну а следующий фрагмент вы уже видели: печатаем текст, который лежит отдельно, но по завершению не выходим, а возвращаемся к операции инкремента цвета.
Вот и весь меджик.
Ссылки
Определенно, самая полезная глава в моём рассказе.
- x86 Opcode Cheat Sheet — наверное, с неё всё и началось.
- Intel 80386 Reference Programmer's Manual — в открытом и удобочитаемом виде. Особо полезные странички:
- X86 Opcode and Instruction Reference — сводные таблицы опкодов, мнемоник и их атрибутов.
- x86 Instruction Set Reference — Мнемоники ассемблера с кое-каким описанием.
- Intel 80x86 Assembly Language OpCodes — тоже список мнемоник и опкодов, все прямо на одной странице, удобно Ctrl-F-ать.
- Ralf Brown's Interrupt List — Справочник BIOS-прерываний в более удобоваримом виде, чем оригинал и с весёлыми баннерами.
- Values for standard video mode — Список видеорежимов (в Ralf Brown's Interrupt List табличка развалилась).
- Для особо ленивых (да, я читил: эти ребята помогали мне, когда биты уже закатывались за байты):
- Онлайн-дебаггер ассемблера (очень ограниченный)
- Не менее онлайн и не менее ограниченный ассемблер-дизассемблер
- Википедии, куда ж без них.
man ascii
тоже помог, молодец.
Постскриптум
Жена подходит, говорит:
— Хватит работать!
— А я и не работаю.
Заглядывает в экран, видит Sublime Text со всем этим безобразием:
— А-а-а, какой ужас! Это зашифрованная порнуха!
Занавес.
В чём-то ведь она права.
UPD: Спасибо eisaev, Andrew_Pinkerton, MrSmith33, Anthony1025 и Юле, которая не желает регистрироваться на Хабре, за правку моих ошибок и опечаток.
drWhy
Мсье знает толк…
Осталось сетевой стек запрограммировать (конечно, тоже в опкодах), и вуаля — идеальный компьютер для написания постов на Хабр, имунный к любым обновлениям ОС.
PS. Жена определённо права.
DmitryAnatolich Автор
Думаю, сначала всё-таки драйвер swap на опкодах и, а потом уже и TCP/IP, а то в 640 килобайт даже хромовый тирекс не влезет
/конец ночному пятничному сарказму
drWhy
Но необязательно. Есть расширители DOS, переводящие процессор в защищённый режим, Open Watcom, например, поддерживает парочку (правда, +700К Б к исполнимому файлу). Есть стеки экономичные, в Watcom поддерживается wattcp. smb толстоват, но можно и его впихнуть, зато самый маленький ftp сервер, который удалось найти добавляет всего 27 КБ.
Для пуристов есть Unreal mode процессора, предоставляющий 4 ГБ ОЗУ. 4 ГБ опкодов, ага. Программа unreal.com, переводящая процессор в этот замечательный режим весит 2,5 КБ ЕМНИП.
JerleShannara
При работе с Unreal надо учитывать, что читать/писать в 4Гб можно, а вот выполнять код… Уж проще в flat protected перейти с одним сегментом. Правда прийдется написать кучу драйверов, т.к. биосных прерываний больше не будет.
rogoz
Биос и весь первый мегабайт вызывается в V8086 когда надо.
JerleShannara
Это конечно можно (и Windows 95/98 это даже делали штатно, когда отсутствовали драйвера для некоторых железок(контроллера жесткого диска например)), но это дикая трата процессорного времени.
dartraiden
Всё уже придумано до нас, у ASUS была Express Gate на десктопных материнках и ноутбуках (в бюджетных моделях ставилась прямо на системный накопитель, а в топовых — на распаянный 16-гиговый SSD). Как раз для того, чтобы фоточки посмотреть, в скайпе пообщаться и интернет посерфить без необходимости грузиться в основную ОС.
DmitryAnatolich Автор
Да что уж там, на ноуте RoverBook в 2006-м было нечто подобное. Но, насколько помню, там был сверхподрезанный Линукс с какой-то по-дизайнерски изысканной оболочкой
Javian
Еще встречался встроенный CD плеер.
DmitryAnatolich Автор
Причем аппаратный, и прямо в CD-ROM