Вторая часть уже здесь.
В первой части мы:
посмотрим, как работать с памятью и регистрами 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 ~/dosc:
Перезапускаем 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".

108hТеперь мы знаем, что строка начинается со смещения 108hex. Отредактируем инструкцию mov dx,0. Для этого переходим в режим ввода инструкций a с аргументом 102- адрес нужной инструкции. Не забываем нажать "Enter":
a 102
Отладчик предложит ввести инструкцию по смещению 102hex. Важно, чтобы новая инструкция имела точно такой же размер, как и прежняя, т.к. это просто замена кода процессорной инструкции в памяти и никакого сдвига кода программы, как в текстовом редакторе, не происходит. Именно с целью зарезервировать нужное место мы ранее написали mov dx,0. Вводим инструкцию mov dx, 108 и нажимаем "Enter" два раза: чтобы сохранить код инструкции и затем выйти из режима ассемблирования:

mov dx, 0Проверим, что у нас получилось. Вводим команду 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 соответственно. Сразу после этого процессор автоматически приступает к выполнению кода с заданного сегмента и смещения.

n задаём путь и имя файла, в который сохраним программу.Далее, в регистр CX командой r (register) нужно записать количество сохраняемых байт в шестнадцатеричном виде. 23dec = 17hex:
r cx 17

CXИ, наконец, записываем командой w (write) в файл:

wВыходим из отладчика командой 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, то именно это и произойдёт:

INT 20hНо мы с вами обратим внимание на другой момент. На скриншоте "Результат выполнения 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
Получился более осмысленный результат:

INT 8h без зондирующего механизма DOSBoxЕсли к этому добавить инструкцию 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 = 70hex1Chex × 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 использует little-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 - это Матрица со своими зондами-разведчиками
Познакомились с устройством видеобуфера текстового режима
Подключили свой обработчик прерывания от таймера
Успешно деинициализировали обработчик и корректно завершили программу!
