Низкоуровневое программирование под 8086 для любопытных, часть 1
В первой части мы:
посмотрим, как работать с памятью и регистрами 8086
узнаем, как написать простую программу на ассемблере прямо в отладчике
изучим работу механизма прерываний и сделаем демонстрационный пример
Статья рассчитана на тех, кто имеет начальный опыт программирования, но хочет понять основы низкоуровневого программирования и многозадачности.
Примеры в бинарном виде доступны по ссылке https://github.com/galilov/habr/blob/main/asm-8086-galilov.zip.
Что нужно знать перед стартом
Перед тем, как писать код переключателя задач, давайте разберёмся, как работают память и регистры в процессоре Intel 8086.
Как устроена память в i8086?
Память в системах на базе 8086 обычно распределяется следующим образом:
Адресный диапазон | Назначение | Комментарий |
---|---|---|
| Interrupt Vector Table | Таблица векторов прерываний. Номера векторов от |
| Conventional Memory | Оперативная память для программ и данных |
| Video Memory | Графика (CGА/EGA/VGA), текстовый режим |
| Расширенная видеопамять | BIOS видеокарт, сетевых карт и т.п. |
| System / BIOS ROM | Постоянная память BIOS |
Биты, байты, hex, dec,,,
8-битное беззнаковое число имеет диапазон от 0000 0000bin
до 1111 1111bin
или от 0
до 28-1 = 255dec = FFhex , всего 28 = 256dec = 100hex
разных значений. Этот тип данных называется байт (byte).
16-битное беззнаковое число имеет диапазон от 0000 0000 0000 0000bin
до 1111 1111 1111 1111bin
или от 0
до 216-1 = 65 535dec = FFFFhex , всего 216 = 65 536dec = 10000hex
разных значений. Такой тип данных называется слово (word). Слово состоит из 2-х байт.
1Кб = 210 = 1024dec = 400hex
байт1Мб = 220 = 1048576dec = 100000hex
байт.
Здесь и далее в статье я использую обозначение hex
для шестнадцатеричных чисел, например 9FAhex
. Другой вариант, с суффиксом "h", используется в коде программ на Assembler: 9FAh
.
Двоичные числа имеют обозначение bin: 01001001bin
. В коде программ на Assembler к двоичным числам приписывается суффис "b", например 01001001b
.
У десятичных чисел вы видите обозначение dec, например
2025dec
, только там, где это важно для понимания контекста.
Процессор 8086 построен на 16-битной архитектуре: его регистры имеют размер 16 бит, шина данных тоже 16-битная, а вот шина адреса шире: целых 20 бит.
Адрес в памяти 8086 формируется из двух 16-битных частей: сегмента и смещения. При формировании физического адреса сегментная часть умножается на 16dec и затем к ней прибавляется смещение.
Физический адрес = (Сегмент × 16dec) + Смещение
Такой способ позволяет использовать 16-битные смещения и 16-битные указатели на выполняемые инструкции и данные в адресном пространстве от 0
байт доFFFFhex * 10hex + FFFFhex = FFFF0hex+ FFFFhex = 10FFEFhex = 1 114 095dec
байт.
Всего получается 1Мб + 65520dec
байт адресного пространства.
Однако, на 20-битной шине адреса можно выставить диапазон адресов только от 0 до FFFFFhex = 1 048 575dec
. Из-за этого 8086 не может использовать более 1Мб оперативной памяти. Процессоры 80286 и более свежие имеют более широкую шину адреса: от 24 бит и более. Поэтому, 80286, даже работая в реальном режиме с сегментной адресацией, физически имеет доступ к полному адресному пространству размером 1114096dec
байт.
Регистры процессора
Процессор 8086 использует разные типы 16-битных регистров:
Сегментные регистры
Регистр | Назначение |
---|---|
CS | Code Segment - содержит адрес сегмента кода, в котором находятся выполняемые процессором инструкции. |
DS | Data Segment - содержит адрес сегмента данных, к которым по-умолчанию обращается большинство инструкций при выполнении действий с данными в памяти. |
SS | Stack Segment - содержит адрес сегмента стека, который используется для хранения данных стека. |
ES | Extra Segment - дополнительный сегмент данных. |
Физический адрес инструкции, данных или стека рассчитывается на основе значения соответствующего сегментного регистра и смещения. Сегментные регистры могут указывать на пересекающиеся области памяти или, вообще, на один и тот же участок. Это часто используется в небольших программах для упрощения их кода. При изменении значения сегментного регистра на ±1, физический адрес памяти, на который он указывает, меняется на ±16dec байт. Другими словами,
Физический адрес начала сегмента = Сегментный регистр × 16dec
что уже не выглядит для вас чем-то неожиданным.
Регистры общего назначения
Регистр | Имя | Назначение |
---|---|---|
AX | Accumulator register | Аккумулятор для арифметики |
BX | Base register | Базовый указатель на данные |
CX | Counter register | Счётчик для циклов и сдвигов |
DX | Data register | Используется в умножении и делении вместе с AX и при обмене данными с периферийными устройствами |
Регистры AX
, BX
, CX
, DX
разбиваются на пары 8-битных регистров:
Регистр | High (биты 16 .. 8) | Low (биты 7 .. 0) |
AX | AH | AL |
BX | BH | BL |
CX | CH | CL |
DX | DH | DL |
То есть, можно работать как с полным 16-битным регистром, так и отдельно, с его младшей и старшей частью.
Специальные регистры
Регистр | Имя | Назначение |
---|---|---|
SP | Stack pointer | Указатель стека (Stack Pointer) - нужен для работы механизма вызова процедур, обработчиков прерываний, инструкций |
BP | Base pointer | Базовый указатель для работы со стеком (Base Pointer). Используется для доступа к памяти стека совместно с регистром SS. |
SI | Source index | Индекс источника в строковых инструкциях, обрабатывающих массивы данных. |
DI | Destination index | Индекс назначения в строковых инструкциях, обрабатывающих массивы данных. |
Регистр-указатель инструкции IP (instruction pointer)
Регистр IP
(instruction pointer) нельзя модифицировать или прочитать как регистр общего назначения.
В паре с регистром сегмента кода CS
он используется для адресации инструкции, подлежащей исполнению. Регистр IP
меняется при последовательном выполнении инструкций, каждый раз показывая на смещение инструкции, следующей за выполняемой в настоящий момент.
При выполнении инструкций условного или безусловного перехода Jx
, JMP
, инструкций вызова процедуры CALL
, инструкций INT,
RET
, IRET
, вызове аппаратного обработчика прерывания, и в некоторых других случаях, в IP
загружается смещение целевого кода, куда нужно выполнить переход.
Регистр флагов (FLAGS)
№ бита | Имя флага | Функциональное назначение флага [1] |
0 | CF (Carry Flag) | Флаг переноса. Устанавливается, если последняя арифметическая операция перенесла (сложение) или заимствовала (вычитание) бит за пределы размера регистра. Этот флаг проверяется инструкциями сложения с переносом или вычитания с заёмом для обработки значений, слишком больших для одного регистра. |
2 | PF (Parity Flag) | Флаг четности. Устанавливается, если количество установленных битов в младшем байте кратно 2. |
4 | AF (Auxiliary Flag) | Дополнительный флаг переноса. Флаг вспомогательного переноса устанавливается, если во время сложения происходит перенос из младшего полубайта (младшие четыре бита) в старший полубайт (старшие четыре бита) или заём из старшего полубайта в младший полубайт во время вычитания. В противном случае, если такого переноса или заёма не происходит, флаг очищается. Этот флаг нужен для вычислений с двоично-десятичными числами (BCD). |
6 | ZF (Zero Flag) | Флаг нуля. Устанавливается, если результат операции равен нулю. |
7 | SF (Sign Flag) | Флаг знака. Устанавливается, если результат операции отрицательный (старший бит результата установлен). |
8 | TF (Trap Flag) | Флаг ловушки. Устанавливается при пошаговой отладке. |
9 | IF (Interruption Flag) | Флаг прерывания. Устанавливается, если процессору разрешено обрабатывать аппаратные прерывания. |
10 | DF (Direction Flag) | Флаг направления. Направление потока. Если установлено, строковые операции будут уменьшать свой указатель, а не увеличивать его, считывая память в обратном направлении. |
11 | OF (Overflow Flag) | Флаг переполнения. Устанавливается, если знаковые арифметические операции приводят к значению, слишком большому для регистра. |
Аппаратные прерывания в Intel 8086
Аппаратные прерывания — это сигналы от внешних или внутренних устройств процессору, которые требуют немедленного внимания.
Схема вызова прерывания
Когда аппаратное устройство (например, таймер или клавиатура) хочет что-то сообщить процессору:
Оно отправляет аппаратный сигнал прерывания
Процессор временно приостанавливает выполнение текущей программы
Переходит к обработчику прерывания (специальной программе)
Как это работает внутри 8086?
На системной плате с CPU 8086 есть контроллер прерываний (обычно это 8259 PIC или аналог), который определяет, какое именно устройство вызвало прерывание.
Каждый тип прерывания связан с уникальным номером (вектором), формируемым контроллером прерываний, и обработчиком в памяти.
Адрес обработчика прерывания вычисляется так:
Смещение обработчика = WORD PTR [Вектор × 4]
Квадратные скобки сообщают нам, что из ячейки памяти размером 1 слово (2 байта или 16 бит) с адресом
Вектор × 4
нужно извлечь содержимое, тоже размером 1 слово, и уже его использовать как целевое смещение нужного нам объекта в памяти. В данном случае это будет смещение обработчика прерывания.
Сегмент обработчика = WORD PTR [(Вектор × 4) + 2]
Тот же самый смысл имеют квадратные скобки в случае сегмента: из ячейки памяти размером 1 слово с адресом
(Вектор × 4) + 2
извлекаем содержимое и используем его как сегментный адрес обработчика прерывания.
Процедура извлечения из памяти адреса другого участка памяти называется "косвенная адресация". Если вы знакомы с программированием на C, то, возможно, сталкивались с разыменованием указателя. Вот это оно и есть.
При аппаратном прерывании процессор:
Ждёт завершения текущей исполняемой инструкции
Сохраняет в стек регистры
FLAGS
,CS
,IP
.Загружает адрес обработчика из таблицы векторов прерываний в
CS:IP
.Выполняет код обработчика.
После выполнения обработчика команда
IRET
(interrupt return) возвращает процессор к прерванной задаче, извлекая из стека регистрыIP
,CS
,FLAGS
.
Примеры аппаратных прерываний
Номер | Описание |
---|---|
| Таймер. По-умолчанию обработчик вызывается каждые ~55 мс |
| Клавиатура |
| Прерывания других периферийных устройств (настраиваются через контроллер PIC) |
Программные прерывания в Intel 8086
Программные прерывания — это прерывания, которые инициируются самим кодом программы с помощью специальной инструкции INT
.
То есть, мы можем принудительно "прервать" выполнение программы и вызвать системную функцию или собственный обработчик.
Как вызвать программное прерывание?
Очень просто:
INT вектор
Примеры программных прерываний
Инструкция | Назначение |
---|---|
| Работа с видео (BIOS-функции для вывода текста и графики) |
| Доступ к дисковым устройствам (чтение/запись секторов) |
| Чтение клавиатуры |
| DOS-функции: ввод/вывод, работа с файлами |
Что происходит при программном прерывании?
Когда процессор выполняет INT xx
:
Он делает примерно то же самое, что при аппаратном прерывании:
Сохраняет
FLAGS
,CS
,IP
в стек.Загружает новый
CS:IP
из таблицы векторов прерываний по номеру прерывания.Выполняет код обработчика.
После выполнения обработчика команда
IRET
(interrupt return) возвращает процессор к прерванной задаче, извлекая из стека регистрыIP
,CS
,FLAGS
.
То есть, механизм тот же - отличается только источник сигнала:
аппаратура генерирует - аппаратное прерывание, программа вызывает INT
- программное прерывание.
Маленькое резюме
Вид прерывания | Источник | Пример |
---|---|---|
Аппаратное | Устройство (таймер, клавиатура) |
|
Программное | Инструкция |
|
Настраиваем эмулятор DOSBox Staging для экспериментов
Для экспериментов с процессором 8086 и разработки примеров простых программ мы воспользуемся популярным у любителей DOS-игр эмулятором операционной системы DOS. Разработчики DOSBox Staging в точности реализовали поведение и API операционной системы DOS и её аппаратной платформы - процессора x86, работающего в реальном режиме.
Небольшое уточнение
Строго говоря, процессор в DOS, например, Microsoft DOS, не обязан работать в реальном режиме. При использовании программ-экстендеров (DOS Extenders) для доступа к памяти за пределами достижимого в реальном режиме, процессор переключается в защищённый режим, доступный начиная с i80286. Для наших задач это не имеет особого значения и мы можем считать, что имеем дело с реальным режимом i8086.
В некоторых дистрибутивах Linux эмулятор DOSBox-Staging доступен из дефолтного репозитория. В Ubuntu 24.04 LTS имеется Snap пакет:
sudo snap install dosbox-staging
Кроме Snap, существует и Flatpak пакет, который можно установить. Например, в CentOS 10 это выглядит так:
sudo flatpak install io.github.dosbox-staging
Установочный пакет для других ОС, включая Windows, можно скачать с сайта проекта [2].
Если вы установили Snap пакет, то для запуска приложения просто пишем его имя в терминале:
dosbox-staging
Если у вас установлен Flatpak пакет, то:
flatpak run io.github.dosbox-staging
Появление такого окошка означает, что мы на пути к успеху:
Работаем с DOSBox Staging: подключение дисков и подготовка
Эмулятор DOSBox при старте создает виртуальный диск Z:
, который предназначен только для чтения. Чтобы иметь возможность сохранять файлы и запускать собственные программы, нужно подключить директорию с файлами из хост-машины как отдельный логический диск.
Подключение директории хоста к эмулятору
Создадим папку dos
в домашней директории компьютера. Подключим её к эмулятору как диск C:
.
Внутри DOSBox выполняем команду:
mount c ~/dos
Теперь можно проверить доступность диска C:
dir c:
Если всё сделано правильно, вы увидите список файлов в директории ~/dos
, которые там были на момент подключения командой mount
. У меня это выглядит так:
Чтобы не запускать mount
вручную, эту команду можно добавить в конфиг-файл DOSBox Staging. Путь к конфигу нетрудно увидеть в логе, выводимом в консоль при запуске эмулятора в терминале:
2025-04-20 19:17:26.521 | CONFIG: Loaded primary config file '/home/user/snap/dosbox-staging/118/.config/dosbox/dosbox-staging.conf'
Открываем конфиг-файл в редакторе и в самом конце, в секцию [autoexec]
, добавляем строчки:
mount c ~/dos
c:
Перезапускаем DOSBox Statging и видим, что наши команды выполняются автоматически.
Пишем первую программу на Assembler 8086 прямо в отладчике!
Знакомимся с отладчиком debug
debug - стандартный отладчик DOS. Он умеет дизассемблировать бинарный код программ, показывать содержимое памяти, регистров процессора, и вообще - штука нужная и полезная пытливому уму. Самое интересное, что в отладчике можно писать и запускать простые программы на Assembler.
Запускаем отладчик и видим приглашение -
:
Набираем код нашего первенца. Вводим команду a
(assmble), затем нажимаем "Enter":
Отладчик показывает сегмент и смещение в памяти, где будет располагаться машинный код нашей первой инструкции. Обратите внимание, в debug
все числа всегда отображаются в шестнадцатеричном представлении.
Откуда взялось смещение 100h
Смещение 0100hex - не просто какое-то совпадение. С этого смещения в DOS загружаются программы, использующие модель памяти "tiny" (крошечный). Эта модель предполагает, что код, данные и стек программы размещаются в одном и том же сегменте памяти, размером 64Кб. Первые 256 байт, от смещения 0000 до смещения 00FFhex, выделяются для системных нужд DOS. Этот блок называется PSP (program segment prefix, префикс программного сегмента). В данный момент он не представляет для нас особого интереса, прочитать подробнее можно здесь [3]. Cразу после PSP начинаются коды инструкций и данные программы.
Ввод программы на ассемблере
Всего несколько байт, но каких!
Введите в отладчике следующую программу на ассемблере:
mov ah, 9 ; в регистр ah загружаем 9h - код функции вывода текстовой строки
mov dx, 0 ; в регистр dx загружаем смещение строки, которое мы пока не знаем, ставим 0, потом исправим на правильное
int 21 ; вызываем программное прерывание по вектору 21h - это DOS API
ret ; возвращаем управление системе DOS (в нашем случае - отладчику)
Инструкции записываются сразу после подсказки сегмент:смещение
по одной в строке. В конце инструкции нажимаем "Enter". Комментарии, начинающиеся с символа ;
, вводить не нужно.
Как только нажат "Enter", текстовое представление инструкции транслируется отладчиком в один или несколько байт числового кода этой самой инструкции и сохраняется в памяти по отображаемому сегменту:смещению
.
По мере ввода программы отладчик показывает смещение для очередной инструкции и мы легко можем оценить размер нашего произведения.
При запуске программы процессор будет выполнять в точности именно то, что мы ему напишем. Если ошиблись и увидели что-то вроде этого:
просто повторим попытку ввода этой инструкции снова.
Для выхода из режима ассемблирования, в который мы вошли командой
a
, дважды нажмите "Enter". Отладчик покажет приглашение и будет ожидать команду. Список доступных действий можно получить в ответ на команду?
Наконец, все строчки записаны верно, и у нас получилось вот такое заклинание:
Первые две строчки выполняют подготовку аргументов:
AH=9h - мы хотим вывести на консоль строку символов, используем функцию DOS #9 [4].
DX=0 - смещение начала выводимой строки. Сегмент уже указан в регистре
DS
, т.к. в нашей tiny программе все сегменты уже настроены и совпадают друг с другом (CS=DS=ES=SS). Но... почему вDX
пишем 0 ??? Ответ будет чуть ниже.
Третья - вызов функции DOS API через программное прерывание INT 21h
. Четвертая строчка - инструкция ret
(return) возвращает управление в вызывающую среду. Обычно это DOS, но в нашем случае - в отладчик.
Как работает инструкция RET
Инструкция RET
извлекает из стека 16-битное значение нового смещения и записывает его в регистр-указатель инструкции IP
. Так происходит возврат из вызванной функции к вызывающему коду, поместившему в стек адрес возврата. В нашем случае - возврат в отладчик или в DOS, смотря по тому, откуда была запущена программа. Детально о стеке поговорим в следующем параграфе.
А где же, собственно, строка?
Строки нет. Пока мы писали программу, мы не могли знать, где закончится наш код, и с какого смещения можно разместить саму строку. Нажимаем "Enter" чтобы, выйти из режима ассемблирования инструкций и перейти к приглашению:
Понимаем, что начиная со смещения 0108hex никаких инструкций уже нет. Вводим команду e
(enter data) с аргументом 108
для размещения в памяти байтов строки с нужного места:
e 108 'Hello world!' d a '$'
Текст строки 'Hello world!'
в одинарных кавычках, байты CR (carriage return) и LF (line feed), равные, соответственно Dhex (13dec) и Ahex (10dec). Символ доллара '$'
служит признаком конца строки для вызываемой функции DOS и не отображается на консоли. В конце команды привычно нажимаем "Enter".
Теперь мы знаем, что строка начинается со смещения 108hex. Отредактируем инструкцию mov dx,0
. Для этого переходим в режим ввода инструкций a
с аргументом 102
- адрес нужной инструкции. Не забываем нажать "Enter":
a 102
Отладчик предложит ввести инструкцию по смещению 102hex. Важно, чтобы новая инструкция имела точно такой же размер, как и прежняя, т.к. это просто замена кода процессорной инструкции в памяти и никакого сдвига кода программы, как в текстовом редакторе, не происходит. Именно с целью зарезервировать нужное место мы ранее написали mov dx,0
. Вводим инструкцию mov dx, 108
и нажимаем "Enter" два раза: чтобы сохранить код инструкции и затем выйти из режима ассемблирования:
Проверим, что у нас получилось. Вводим команду u
(unassemble) c аргументом 100
- смещением нашего кода:
В ответ получаем несколько колонок: уже знакомые сегмент:смещение
, числовые коды введённых инструкций и сами инструкции.
На этот раз отладчик сформировал текстовое представление машинных инструкций из их числового кода. Это называется дизассемблирование. А сами текстовые представления инструкций - это мнемоники.
Крутые, и не очень, хакеры часто используют дизассемблирование для взлома или исправления программ, тексты которых на исходном языке программирования по какой-то причине недоступны.
Внимательный хакер заметил, что после инструкции ret
с кодом C3hex по смещению 107hex отображается какая-то ерунда, которую мы, вроде бы, не вводили. На самом деле, это - не ерунда! Это наша строка Hello World!
, пара служебных байтов CR и LF и символ доллара. Только отладчик об этом не знает, а потому пытается дизассемблировать текстовую информацию так, будто это машинные инструкции. Чтобы увидеть картину с другой стороны, отобразим содержимое памяти со смещения 100hex в виде байтов командой d
(dump) с аргументом 100
:
В начале показаны коды инструкций процессора: B4 09 BA 08 01 CD 21 C3
. Всего 8 байт. Это и есть код нашей программы. За программой, со смещения 108hex, идут байты выводимой строки: 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 0D 0A 24
. Эти 15 байт - данные, с которыми работает код.
Вся программа 8 + 15 = 23 байта.
В эпоху многогигабайтных монстров в это почти невозможно поверить.
Запуск!
Однако, любые сомнения могут быть легко устранены: запустим этот код на выполнение командой g
(go) с аргументом =100
- смещением, откуда нужно начать выполнение кода:
Самое время записать нашу работу в файл. Для этого, при помощи команды n
(name), зададим имя файла, в котором будем увековечивать наше детище, например:
n c:\myprog.com
Почему .com?
Программы с моделью памяти tiny хранятся в файлах с расширением COM. По такому расширению DOS распознаёт tiny-программу, выделяет ей один сегмент размером 64Кб, на код + данные + стек, и загружает содержимое COM-файла в этот сегмент памяти со смещения 100hex (256dec). После загрузки файла, размещения PSP в первых 256 байтах и настройки сегментных регистров CS
, DS
, ES
, SS
на начало выделенного блока памяти, выполнение передаётся по адресу CS:100h
*.
*) в действительности, настройка CS
и передача выполнения кода - это одна инструкция CALL FAR
(дальний вызов процедуры) или JMP FAR
(дальний безусловный переход). Поэтому, сначала настраиваются сегментные регистры DS
, ES
, SS
, инициализируется указатель стека SP
, а потом, с помощью дальнего вызова или дальнего перехода, регистры CS
и IP
получают значения сегментного адреса и смещения=100hex соответственно. Сразу после этого процессор автоматически приступает к выполнению кода с заданного сегмента и смещения.
Далее, в регистр CX
командой r
(register) нужно записать количество сохраняемых байт в шестнадцатеричном виде. 23dec = 17hex:
r cx 17
И, наконец, записываем командой w
(write) в файл:
Выходим из отладчика командой q
(quit):
Проверяем, работает ли наша программа без отладчика:
Ещё раз убеждаемся, что приложение может иметь размер 23 байта:
Чему мы научились в этой главе
Надеюсь, игра с отладчиком debug
не была слишком скучной. Конечно, его освоение потребовало некоторых усилий, зато теперь у нас есть инструмент для исследования самых мелких деталей низкоуровневого программирования.
Кроме того, теперь мы понимаем, как текстовое представление инструкции процессора - мнемоника, связано с её числовым кодом.
Мы научились вводить в отладчике простую программу на языке ассемблера, вносить корректировки в бинарный код инструкций и запускать программу на выполнение.
Увидели, чем отличаются байты кода инструкций от байтов данных...
Ничем
Байты данных являются предметом, с которым работают инструкции. Данные могут изменяться, а инструкции, чаще всего, остаются неизменными в процессе нормального исполнения программы.
...и как процессор не путает их между собой.
И сохранили программу из отладчика в .COM файл, чтобы потом запускать её, когда захотим.
Как работает стек
В нашей уютной ламповой комнатке давно притаился слон по имени Стек. Стек здесь, стек там, в стек, из стека...
Стек - это сегмент памяти, на который указывает сегментный регистр стека SS
. SS
работает совместно с указателем стека SP
и через них реализовано взаимодействие с памятью стека. Элементами стека всегда являются слова. Не байты! В используемой нами модели памяти tiny все сегментные регистры равны друг другу и SS
- не исключение. Всё живёт и трудится в одном сегменте, поэтому для начала сосредоточим своё внимание на регистре SP
и инструкцииRET
:
Воспользуемся отладчиком (просто запустите новый экземпляр DOSBox, если вы уже используете отладчик для других целей) и посмотрим на значения регистров командой r
:
Замечаем, что SP
=FFFEhex (жёлтая рамка). Это значит, что SP
указывает на слово в в сегменте стека со смещением FFFEhex. Посмотрим, что там такое, командой d FFFE
:
Получилась немного странная картинка из-за того, что дамп попадает на конец сегмента. Тем не менее, всё отображается верно: нули показаны для смещения FFFEhex
.
Это значит, что если сейчас выполнить предлагаемую отладчиком программу - инструкцию RET
, расположенную по смещению 100hex, то из стека будет вытолкнуто 0000hex и загружено в IP
. Другими словами, инструкция RET
передаст управление на смещение 0 в текущем сегменте. Проверяем при помощи команды пошаговой трассировки t
:
Так и вышло: управление перешло на смещение 0
с инструкцией INT 20h
- программным прерыванием, обработчик которого является частью DOS и выполняет завершение программы, освобождение выделенной ей памяти и возврат в родительский процесс. Если мы снова воспользуемся командой t
, то именно это и произойдёт:
Но мы с вами обратим внимание на другой момент. На скриншоте "Результат выполнения RET" я пометил зелёной и синей рамками значения SP
до и после выталкивания сохранённого в стеке значения. До выталкивания SP
=FFFEhex, после SP
=0000. Вот что произошло:
Получателю отдаётся одно слово, расположенное в стеке по смещению из
SP
:receiver := SS:[SP]
SP
увеличивается на 2:SP := SP + 2
При увеличении числа FFFEhex на 2
должно получиться 10000hex
или 1 0000 0000 0000 0000bin, но старший бит в этом числе уже не помещается в отведённые для регистра SP
16 бит и отбрасывается. В SP
остаётся только 0.
А как же в этот стек что-то положить? Снова вернёмся к отладчику и напишем простой код:
MOV AX, 5555
PUSH AX
POP BX
RET
Командой r
отобразим начальное состояние регистров и инструкцию, которая должна выполниться чуть позже командой t
:
Дадим команду t
для выполнения инструкции MOV AX, 5555
- загрузки 5555hex в AX
:
В AX
получили хорошо заметную в дампе константу.
Теперь инструкцией PUSH AX
поместим это значение в стек и посмотрим, как изменится SP
. Снова даём команду t
:
Регистр SP
уменьшился на 2
. Посмотрим, где в теперь хранится константа 5555hex при помощи команды d fffc
:
По смещению FFFChex видим байты константы 5555hex, далее, начиная с FFFEhex - уже привычные нули. Делаем вывод, что PUSH
сначала уменьшает SP
на 2
, а потом, по полученному в SP
смещению, сохраняет слово.
Для извлечения (выталкивания) из стека при помощи инструкции POP BX
ранее помещённого туда значения снова выполним команду t
:
Отмечаем, что регистр BX
получил значение из стека, а SP
увеличился на 2
. Очевидно, что увеличение произошло после получения 5555hex, так как новое смещение в SP
показывает на слово 0
.
Резюме по стеку
Помещение слова в стек (
PUSH
,CALL
,INT
):
SP := SP - 2
WORD PTR SS:[SP] := значение_из_источника
Извлечение (выталкивание) слова из стека (
POP
,RET
,IRET
):
получатель := WORD PTR SS:[SP]
SP := SP + 2
Играем в прерывание таймера
Игры бывают разные, но мы попробуем нечто оригинальное: сыграем в прерывания таймера, а заодно узнаем несколько интересных технических деталей.
Вектор аппаратного прерывания таймера имеет номер 8.
Адрес в памяти, где находится таймерный вектор рассчитывается так:
адрес_вектора_8 = 8 × 4 = 32dec = 20hex
А этот вектор, он сейчас рядом с нами?
Да, доктор. Он здесь.
Запустим debug
и посмотрим на этот вектор поближе. Для этого используем команду d
с аргументом 0:20
. Уверен, вы уже понимаете, что 0 - это сегментная часть адреса вектора, а 20hex - смещение:
Нас интересуют только 4 байта, относящиеся к вектору 8. На рисунке они обведены красной рамкой. Жёлтая рамка показывает сегмент и смещение, где расположен вектор.
В младшей части вектора расположены 2 байта или, что тоже самое, 1 слово - смещение кода обработчика прерывания. Причём, в самой младшей позиции находится младший байт смещения A5hex, далее, уже по адресу 0000:0021hex, старший байт смещения FEhex. После смещения в точно таком же порядке находятся младший и старший байты сегмента обработчика прерывания 00hex и F0hex.
В архитектуре x86 младший байт двухбайтового слова (WORD) или четырехбайтового двойного слова (DWORD) расположен по наименьшему адресу. Этот адрес считается адресом всего объекта в памяти.
То же правило распространяется и на другие числовые типы: сначала младший байт, затем следующий по старшинству, и так далее.
Такой подход к размещению многобайтных числовых значений называется little-endian.
Так что же там такое, в этом обработчике?
Срываем покров тайны с таймерного вектора
Мы на самом деле в матрице.
Дизассемблируем код существующего обработчика командой u
(unassemble) с сегментом и смещением, взятыми из вектора 8. Конечно, переставляем байты аргумента в человеческий порядок и записываем их в виде сегмент:смещение
:
u f000:fea5
Код обработчика начинается с инструкции STI
, которая устанавливает в регистре FLAGS
бит разрешения обработки прерываний IF
(interrupt flag), разрешая процессору обрабатывать запросы на прерывания. При входе в обработчик процессор автоматически запрещает обработку других прерываний, сбрасывая флаг IF
в регистре EFLAGS
. В данном случае, разработчики DOSBox решили, что таймерное прерывание само может быть прервано. На самом деле нет, но об этом чуть позже.
Следом за STI
идёт странная штука DB
FE
. Это не может быть валидной инструкцией, поскольку DB
(define byte) - это всего лишь способ указать некий один байт и не более того. Иными словами, debug
не смог декодировать следующую за STI
инструкцию и показал нам, что вот здесь какой-то байт со значением FEhex.
Но это только самое начало интриги! Следующие инструкции
CMP [BX], AL
(сравнить байт, смещение которого хранится в регистре BX
со значением в регистре AL
и установить биты FLAGS
соответственно результату сравнения)
и
ADD [5250], DL
(добавить к байту, находящемуся по смещению 5250hex, значение из регистра DL
)
не имеют смысла, т.к. содержимое AL
, BX
, DL
и сегментного регистра DS
может быть каким угодно. Таким, каким оно было оставлено прерванным кодом в момент вызова обработчика прерывания.
После нескольких минут гуглёжки я отыскал ответ: четыре байта, начиная с FEhex
, являются специальной последовательностью, распознаваемой эмулятором DOSBox. Другими словами, это специальный механизм эмулятора, позволяющий вклиниваться в код эмулируемой среды и выполнять нужные самому эмулятору действия. Матрица.
Вот так расположены спецбайты в криво дизассемблированном коде:
Попробуем дизассемблировать код, аккуратно пропустив байты "зонда матрицы" командой
u f000:feaa
Получился более осмысленный результат:
Если к этому добавить инструкцию STI
, которая находится в начале обработчика, то получается вот что:
STI ; разрешаем процессору обработку прерываний
DB FE, 38, 07, 00 ; FE38 магия DOSBox
PUSH DS ; сохраняем в стеке значение из регистра DS
PUSH AX ; сохраняем в стеке значение из регистра AX
PUSH DX ; сохраняем в стеке значение из регистра DX
INT 1C ; вызываем программное прерывание 1Ch
CLI ; запрещаем процессору обработку прерываний
; Подготавливаем для контроллера прерываний команду подтверждения
; обработки текущего прерывания (код 20h). Без этого процессор не получит
; никаких следующих запросов на прерывания.
MOV AL, 20
OUT 20, AL ; отправляем команду в порт контроллера прерываний
POP DX ; восстанавливаем из стека значение регистра DX
POP AX ; восстанавливаем из стека значение регистра AX
POP DS ; восстанавливаем из стека значение регистра DS
IRET ; выходим из обработчика прерывания (извлекаем из стека IP, CS, FLAGS)
В этом коде вызывает некоторое сомнение использование STI
(set interrupt flag) - разрешить обработку прерываний и последующее, закономерное CLI
(clear interrupt flag) - запретить обработку прерываний.
Дело в том, что пока контроллеру прерываний не отправят команду-подтверждение об окончании обработки аппаратного прерывания, они так и останутся запрещёнными на уровне контроллера. Во всяком случае, когда речь идёт о таймерном прерывании, так как все остальные прерывания имеют более низкий приоритет.
Важнейший для нас момент здесь - это вызов программного прерывания
INT 1Ch
, обработчик которого по-умолчанию буквально ничего не делает: просто возвращает управление в вызывающий код инструкциейIRET
(interrupt return). Вот его-то мы и оседлаем в своих корыстных целях!
Заметим, что "родительский" INT 8h
выполняет всю работу по посылке команды подтверждения обработки прерывания контроллеру прерываний и мы можем не думать об этом в своём коде INT 1Ch
.
Пишем свой обработчик прерываний
Вероятно, это самая большая программа, которую мы всё ещё готовы писать непосредственно в отладчике. Пример состоит из двух частей:
Главная программа выполняет подключение обработчика прерывания
INT 1Ch
, ожидание нажатия клавиши и отключение обработчика.Сам обработчик прерывания
INT 1Ch
Каждая строчка примера снабжена комментарием, в котором показано смещение инструкции и дано краткое пояснение, что она делает.
В готовом виде пример можно скачать и загрузить бинарный COM файл прямо в отладчик командой DOS:
debug C:\myprog2.com
Главная программа
Со смещения 100hex вводим инструкции кода главной программы:
CLI ; 100 - запрещаем обработку прерываний
MOV AX, 0 ; 101 - AX := 0
MOV ES, AX ; 104 - ES := AX потому что в ES нельзя непосредственно загрузить константу, т.к. нет такой инструкции
MOV AX, ES:[70] ; 106 - AX := ES:[70h] в AX загружаем смещение из вектора 1Ch. 1Сh * 4 = 70h
MOV [158], AX ; 10A - DS:[158h] = AX сохраняем в свободной области памяти полученное смещение
MOV AX, ES:[72] ; 10D - AX := ES:[72h] в AX загружаем сегмент из верктора 1Ch
MOV [15A], AX ; 111 - DS:[15Ah] := AX сохраняем полученный сегмент в следующем свободном слове памяти
MOV WORD PTR ES:[70], 13B ; 114 - ES:[70h] := 013Bh смещение обработчика прерывания 1Ch меняем на начало нашего обработчика
MOV WORD PTR ES:[72], CS ; 11B - ES:[72h] := CS сегмент обработчика прерывания 1Ch меняем на наш сегмент
MOV BYTE PTR [15C], 0 ; 120 - обнуляем байт в свободной области памяти DS:[15Ch]. Используется в оброботчике прерывания
STI ; 125 - разрешаем обработку прерываний
MOV AH, 0 ; 126 - AH := 0 для вызова ф-ии BIOS "ввод с клавиатуры"
INT 16 ; 128 - вызываем BIOS API, которое при AH = 0 ожидает нажатия клавиши
CLI ; 12A - если мы здесь, то клавиша была нажата. Запрещаем обработку прерываний
MOV AX, [158] ; 12B - AX := DS:[158h] загружаем в регистр AX ранее сохранённое смещение оригинального обработчика
MOV ES:[70], AX ; 12E - ES:[70h] := AX восстанавливаем смещение оригинального обработчика прерывания
MOV AX, [15A] ; 132 - AX := DS:[15Ah] в AX загружаем ранее сохранённый сегмент оригинального обработчика прерывания
MOV ES:[72], AX ; 135 - ES:[72h] := AX восстанавливаем сегмент оригинального обработчика прерывания
STI ; 139 - разрешаем обработку прерываний
RET ; 13A - завершаем работу программы
Код начинается с инструкции CLI
, сбрасывающей флаг IF
в регистре FLAGS
.
CLI ; 100 - запрещаем обработку прерываний
Этим действием мы запрещаем процессору реагировать на аппаратные прерывания и можем спокойно вносить изменения в таблицу векторов прерываний, не опасаясь, что на половине изменений какое-то устройство потребует внимания, и процессор перейдёт по неправильному вектору к выполнению мусорных инструкций.
Далее видим пару инструкций, приводящих к занулению сегментного регистра ES
:
MOV AX, 0 ; 101 - AX := 0
MOV ES, AX ; 104 - ES := AX потому что в ES нельзя непосредственно загрузить константу, т.к. нет такой инструкции
Дело в том, что таблица векторов прерываний, которую мы хотим модифицировать, расположена в самом начале памяти, в сегменте 0
. Для доступа к сегменту 0
мы используем регистр дополнительного сегмента ES
. Можно было бы точно также подготовить регистр сегмента данных DS
, но он нам нужен для работы с данными нашей программы.
Почему для записи 0
в ES
у нас целых две инструкции? Дело в том, что у 8086 нет поддержки кода инструкции для загрузки константы непосредственно в сегментный регистр. Новое значение такой регистр может получить либо из регистра общего назначения, либо из стека.
Адреса 0:70h
и 0:72h
- это младшее и старшее слова вектора 1Ch, в которых хранятся соответственно, смещение и сегмент кода обработчика прерывания:
1Chex × 4 = 70hex
1Chex × 4 + 2 = 72hex
Полученные из вектора 1Ch
смещение и сегмент оригинального обработчика прерывания сохраняются в два последовательных слова со смещением 158h и 158h + 2 = 15Ah в сегменте данных:
MOV AX, ES:[70] ; 106 - AX := ES:[70h] в AX загружаем смещение из вектора 1Ch. 1Сh * 4 = 70h
MOV [158], AX ; 10A - DS:[158h] = AX сохраняем в свободной области памяти полученное смещение
MOV AX, ES:[72] ; 10D - AX := ES:[72h] в AX загружаем сегмент из верктора 1Ch
MOV [15A], AX ; 111 - DS:[15Ah] := AX сохраняем полученный сегмент в следующем свободном слове памяти
Почему 158h? Это начало незанятой нашим кодом области памяти, cразу после нового обработчика прерывания, о котором пойдёт речь в следующем параграфе.
После сохранения адреса оригинального обработчика в вектор записываются смещение и сегмент нашего кода:
MOV WORD PTR ES:[70], 13B ; 114 - ES:[70h] := 013Bh смещение обработчика прерывания 1Ch меняем на начало нашего обработчика
MOV WORD PTR ES:[72], CS ; 11B - ES:[72h] := CS сегмент обработчика прерывания 1Ch меняем на наш сегмент
Смещение 13Bh прямо указывает на наш обработчик, а сегмент просто берётся из регистра сегмента кода CS
, т.к. обработчик прерывания находится в том же сегменте, где и основная программа.
WORD PTR
и BYTE PTR
уточняют смысл инструкции: работает ли она с адресом слова (WORD
), и тогда нужно использовать код инструкции для 16-битных данных, или у нас адрес байта (BYTE
) и инструкция формируется для работы с байтом.
Например:
MOV [15A], AX
Здесь мы однозначно работаем со словом, т.к. регистр AX
это одно слово и инструкция возьмёт младший байт из AL
и запишет его в память с сегментом в регистре DS
и смещением 15Ahex , а старший байт возьмёт из AH
и запишет в тот же сегмент со смещением 15Bhex.
Часто ситуация не столь однозначна:
MOV [15A], 5E
На этот раз 5Ehex может быть как байтом, так и словом, и мы вынуждены уточнить этот момент:
Разница более серьезная, чем может показаться на первый взгляд
При сохранении слова, по смещению 15Ahex будет размещён младший байт. То есть, то самое 5Ehex, а по следующему смещению, 15Bhex, запишется старший байт, в этом примере будет 0. Слово целиком выглядит как 005Еhex, что хорошо заметно на скриншоте дизассемблированного кода в отладчике.
Инструкции для слова и для байта имеют отличающиеся коды операций - они обведены зелёным контуром на скриншоте. Там же, в коде инструкций, можно увидеть смещение 015Ahex и саму константу 5Ehex.
И мы снова видим разницу: для операции со словом константа 5Ehex представлена в коде инструкции в виде слова 005Ehex. Смещение всегда является словом 015Ahex в обоих вариантах.
Поскольку 8086 использует big-endian архитектуру, все байты слов в памяти расположены в порядке от младшего к старшему, - перевёрнуты по отношению к тому, как мы записываем их в тексте программы.
Этот код
MOV AH, 0 ; 126
INT 16 ; 128
через программное прерывание INT 16h
вызывает функцию BIOS с кодом в регистре AH
=0. Вызов ожидает ввод символа с клавиатуры [5]. Возврат из INT 16h
происходит при нажатии клавиши. Нас не интересует, что именно было нажато, мы просто ждём самого события.
И как только произойдёт нажатие, код продолжит своё выполнение: запретит обработку прерываний, в вектор 1Ch
будут помещены ранее сохранённые смещение и сегмент дефолтного обработчика, после чего обработку прерываний разрешит и выйдет из программы инструкцией RET
:
CLI ; 12A - если мы здесь, то клавиша была нажата. Запрещаем обработку прерываний
MOV AX, [158] ; 12B - AX := DS:[158h] загружаем в регистр AX ранее сохранённое смещение оригинального обработчика
MOV ES:[70], AX ; 12E - ES:[70h] := AX восстанавливаем смещение оригинального обработчика прерывания
MOV AX, [15A] ; 132 - AX := DS:[15Ah] в AX загружаем ранее сохранённый сегмент оригинального обработчика прерывания
MOV ES:[72], AX ; 135 - ES:[72h] := AX восстанавливаем сегмент оригинального обработчика прерывания
STI ; 139 - разрешаем обработку прерываний
RET
Финиш :)
Код обработчика прерывания 1Ch
Код непосредственно обработчика прерывания вводим со смещения 13Bhex:
PUSH AX ; 13B - сохраняем содержимое AX в стек
PUSH ES ; 13C - сохраняем содержимое ES в стек
PUSH DS ; 13D - сохраняем содержимое DS в стек
MOV AX, B800 ; 13E - AX := B800h
MOV ES, AX ; 141 - ES := AX настраиваем ES на сегмент текстового видеобуфера
MOV AX, CS ; 143 - AX := CS
MOV DS, AX ; 145 - DS := AX сегмент данных совпадает с сегментом кода
MOV AL, [15C] ; 147 - AL := DS:[15Ch] изначально здесь 0, см код главной программы
MOV AH, 1E ; 14A - AH := 1Eh устанавливаем атрибут "желтый на синем фоне"
MOV ES:[320], AX ; 14C - ES:[320] := AX отображаем символ с кодом в AL на экран
INC BYTE PTR [15C] ; 150 - DS:[15Ch] := DS:[15Ch] + 1 для отображения следующего символа
POP DS ; 154 - извлекаем слово из стека и помещаем в DS
POP ES ; 155 - извлекаем слово из стека и помещаем в ES
POP AX ; 156 - извлекаем слово из стека и помещаем в AX
IRET ; 157 - извлекамем из стека IP, CS, FLAGS и переходим к прерванному коду
Наиболее характерные признаки того, что перед нами обработчик прерывания, - это IRET
в конце кода обработчика, сохранение всех используемых регистров в стек в начале кода:
PUSH AX ; 13B
PUSH ES ; 13C
PUSH DS ; 13D
и восстановление регистров в обратном порядке перед IRET
:
POP DS ; 154
POP ES ; 155
POP AX ; 156
Инструкции
MOV AX, B800 ; 13E - AX := B800h
MOV ES, AX ; 141 - ES := AX настраиваем ES на сегмент текстового видеобуфера
нужны для загрузки сегмента видеобуфера текстового режима в ES
.
Устройство видеобуфера
В текстовом режиме 25 строк, 80 символов в строке, используемом DOS по-умолчанию, отображаемые на экране монитора символы и их атрибуты (цвет символа + цвет фона) хранятся в видеобуфере текстового режима по адресу B8000hex. Каждая отображаемая строка занимает в видеобуфере 160dec (A0hex) байт. На один символ приходится 2 байта: код символа в таблице знакогенератора и атрибут отображения символа. Биты атрибута имеют следующие значения:
D2-D0 | Цвет символа R, G, B. |
D3 | Интенсивность символа и выбор таблицы знакогенератора. |
D6-D4 | Цвет фона символа R, G, B. |
D7 | Мигание символа или интенсивность фона символа. |
Например, жёлтый символ на синем фоне будет иметь атрибут 0001 1110bin = 1Ehex. Первый слева символ в шестой строке будет иметь смещение в видеобуфере 160dec * 5 = 800dec = 320hex. Умножаем на 5 потому, что первая строка начинается со смещения 0, вторая - с 160 и так далее.
Здесь настраивается DS
для работы с сегментом данных нашей программы:
MOV AX, CS ; 143 - AX := CS
MOV DS, AX ; 145 - DS := AX сегмент данных совпадает с сегментом кода
Обработчик прерывания может быть вызван откуда угодно, поэтому полагаться на то, что в этом регистре будет правильное значение, никак нельзя.
Далее извлекаем значение из однобайтовой переменной по смещению 15Chex, которую мы инициализировали нулём в главной программе, в AL
- младший байт AX
. Это значение будет использоваться как код выводимого символа. В AH записываем атрибут 1Ehex - жёлтый символ на синем фоне:
MOV AL, [15C] ; 147 - AL := DS:[15Ch] изначально здесь 0, см код главной программы
MOV AH, 1E ; 14A - AH := 1Eh устанавливаем атрибут "желтый на синем фоне"
Пора показать миру нашу буковку. Пишем в видеобуфер по смещению 320hex символ и его атрибут из AX
одной инструкцией MOV ES:[320], AX
:
MOV ES:[320], AX ; 14C - ES:[320] := AX отображаем символ с кодом в AL на экран
Символ отобразится в первом столбце шестой строки. Далее инкрементируем байт DS:[15Ch], чтобы при следующем вызове обработчика прерывания изобразить следующий по порядку символ:
INC BYTE PTR [15C] ; 150 - DS:[15Ch] := DS:[15Ch] + 1 для отображения следующего символа
На этом основная работа заканчивается. Восстанавливаем из стека регистры и отдаём управление в прерванный код:
POP DS ; 154 - извлекаем слово из стека и помещаем в DS
POP ES ; 155 - извлекаем слово из стека и помещаем в ES
POP AX ; 156 - извлекаем слово из стека и помещаем в AX
IRET
Вот теперь точно всё :) Записать программу из отладчика в .COM файл можно командами:
n c:\myprog2.com
r cx 58
w
Проверяем наличие файла:
и запускаем:
Программа показывает непрерывно меняющийся жёлтый символ на синем фоне (обведён желтой рамкой). Для завершения нажимаем клавишу на клавиатуре, например пробел или "Enter":
Резюме по прерываниям
В этой главе мы научились
Находить и дизассемблировать обработчики прерываний в составе DOS/BIOS
Поняли, что DOSBox - это Матрица со своими зондами-разведчиками
Познакомились с устройством видеобуфера текстового режима
Подключили свой обработчик прерывания от таймера
Успешно деинициализировали обработчик и корректно завершили программу!