В первой части мы:

  • посмотрим, как работать с памятью и регистрами 8086

  • узнаем, как написать простую программу на ассемблере прямо в отладчике

  • изучим работу механизма прерываний и сделаем демонстрационный пример

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

Примеры в бинарном виде доступны по ссылке https://github.com/galilov/habr/blob/main/asm-8086-galilov.zip.

Что нужно знать перед стартом

Перед тем, как писать код переключателя задач, давайте разберёмся, как работают память и регистры в процессоре Intel 8086.

Как устроена память в i8086?

Память в системах на базе 8086 обычно распределяется следующим образом:

Адресный диапазон

Назначение

Комментарий

00000h - 003FFh

Interrupt Vector Table

Таблица векторов прерываний. Номера векторов от 0 до FFhex (255dec)

00400h – 9FFFFh

Conventional Memory

Оперативная память для программ и данных

A0000h – BFFFFh

Video Memory

Графика (CGА/EGA/VGA), текстовый режим

C0000h – DFFFFh

Расширенная видеопамять

BIOS видеокарт, сетевых карт и т.п.

E0000h – FFFFFh

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) - нужен для работы механизма вызова процедур, обработчиков прерываний, инструкций PUSH/POP.

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, то, возможно, сталкивались с разыменованием указателя. Вот это оно и есть.

При аппаратном прерывании процессор:

  1. Ждёт завершения текущей исполняемой инструкции

  2. Сохраняет в стек регистры FLAGS, CS, IP.

  3. Загружает адрес обработчика из таблицы векторов прерываний в CS:IP.

  4. Выполняет код обработчика.

  5. После выполнения обработчика команда IRET (interrupt return) возвращает процессор к прерванной задаче, извлекая из стека регистры IP, CS, FLAGS.

Последовательность вызова обработчика аппаратного прерывания в процессоре 8086

Примеры аппаратных прерываний

Номер

Описание

INT 08h

Таймер. По-умолчанию обработчик вызывается каждые ~55 мс

INT 09h

Клавиатура

INT 0Ah-INT 0Fh

Прерывания других периферийных устройств (настраиваются через контроллер PIC)

Программные прерывания в Intel 8086

Программные прерывания — это прерывания, которые инициируются самим кодом программы с помощью специальной инструкции INT.

То есть, мы можем принудительно "прервать" выполнение программы и вызвать системную функцию или собственный обработчик.

Как вызвать программное прерывание?

Очень просто:

INT вектор

Примеры программных прерываний

Инструкция

Назначение

INT 10h

Работа с видео (BIOS-функции для вывода текста и графики)

INT 13h

Доступ к дисковым устройствам (чтение/запись секторов)

INT 16h

Чтение клавиатуры

INT 21h

DOS-функции: ввод/вывод, работа с файлами

Что происходит при программном прерывании?

Когда процессор выполняет INT xx:

Он делает примерно то же самое, что при аппаратном прерывании:

  1. Сохраняет FLAGS, CS, IP в стек.

  2. Загружает новый CS:IP из таблицы векторов прерываний по номеру прерывания.

  3. Выполняет код обработчика.

  4. После выполнения обработчика команда IRET (interrupt return) возвращает процессор к прерванной задаче, извлекая из стека регистры IP, CS, FLAGS.

То есть, механизм тот же - отличается только источник сигнала:
аппаратура генерирует - аппаратное прерывание, программа вызывает INT - программное прерывание.

Маленькое резюме

Вид прерывания

Источник

Пример

Аппаратное

Устройство (таймер, клавиатура)

INT 08h, INT 09h

Программное

Инструкция INT в коде

INT 10h, INT 21h

Настраиваем эмулятор 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 Staging: подключение дисков и подготовка

Эмулятор DOSBox при старте создает виртуальный диск Z:, который предназначен только для чтения. Чтобы иметь возможность сохранять файлы и запускать собственные программы, нужно подключить директорию с файлами из хост-машины как отдельный логический диск.

Подключение директории хоста к эмулятору

Создадим папку dos в домашней директории компьютера. Подключим её к эмулятору как диск C:.

Внутри DOSBox выполняем команду:

mount c ~/dos

Теперь можно проверить доступность диска C:

dir c:

Если всё сделано правильно, вы увидите список файлов в директории ~/dos, которые там были на момент подключения командой mount. У меня это выглядит так:

Каталог из домашней директории хост-машины подключен как диск C: командой 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.

Запускаем отладчик и видим приглашение -:

Приглашение отладчика debug

Набираем код нашего первенца. Вводим команду 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

Получилась немного странная картинка из-за того, что дамп попадает на конец сегмента. Тем не менее, всё отображается верно: нули показаны для смещения FFFEhex.

Это значит, что если сейчас выполнить предлагаемую отладчиком программу - инструкцию RET, расположенную по смещению 100hex, то из стека будет вытолкнуто 0000hex и загружено в IP. Другими словами, инструкция RET передаст управление на смещение 0 в текущем сегменте. Проверяем при помощи команды пошаговой трассировки t:

Результат выполнения RET

Так и вышло: управление перешло на смещение 0 с инструкцией INT 20h - программным прерыванием, обработчик которого является частью DOS и выполняет завершение программы, освобождение выделенной ей памяти и возврат в родительский процесс. Если мы снова воспользуемся командой t, то именно это и произойдёт:

Завершение программы при вызове INT 20h

Но мы с вами обратим внимание на другой момент. На скриншоте "Результат выполнения RET" я пометил зелёной и синей рамками значения SP до и после выталкивания сохранённого в стеке значения. До выталкивания SP=FFFEhex, после SP=0000. Вот что произошло:

  1. Получателю отдаётся одно слово, расположенное в стеке по смещению из SP:
    receiver := SS:[SP]

  2. 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 загружено 5555h

В AX получили хорошо заметную в дампе константу.

Теперь инструкцией PUSH AX поместим это значение в стек и посмотрим, как изменится SP. Снова даём команду t:

Изменение регистра IP после выполнения PUSH AX

Регистр SP уменьшился на 2. Посмотрим, где в теперь хранится константа 5555hex при помощи команды d fffc:

По смещению FFFC видим байты константы 5555h

По смещению FFFChex видим байты константы 5555hex, далее, начиная с FFFEhex - уже привычные нули. Делаем вывод, что PUSH сначала уменьшает SP на 2, а потом, по полученному в SP смещению, сохраняет слово.

Для извлечения (выталкивания) из стека при помощи инструкции POP BX ранее помещённого туда значения снова выполним команду t:

Извлечение слова из стека инструкцией POP

Отмечаем, что регистр 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 - смещение:

Вектор прерывания 8h выделен красной рамкой

Нас интересуют только 4 байта, относящиеся к вектору 8. На рисунке они обведены красной рамкой. Жёлтая рамка показывает сегмент и смещение, где расположен вектор.

В младшей части вектора расположены 2 байта или, что тоже самое, 1 слово - смещение кода обработчика прерывания. Причём, в самой младшей позиции находится младший байт смещения A5hex, далее, уже по адресу 0000:0021hex, старший байт смещения FEhex. После смещения в точно таком же порядке находятся младший и старший байты сегмента обработчика прерывания 00hex и F0hex.

В архитектуре x86 младший байт двухбайтового слова (WORD) или четырехбайтового двойного слова (DWORD) расположен по наименьшему адресу. Этот адрес считается адресом всего объекта в памяти.

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

Такой подход к размещению многобайтных числовых значений называется little-endian.

Так что же там такое, в этом обработчике?

Срываем покров тайны с таймерного вектора

Мы на самом деле в матрице.

Дизассемблируем код существующего обработчика командой u (unassemble) с сегментом и смещением, взятыми из вектора 8. Конечно, переставляем байты аргумента в человеческий порядок и записываем их в виде сегмент:смещение:

u f000:fea5

Обработчик таймерного прерывания по-умолчанию в DOSBox Staging

Код обработчика начинается с инструкции 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. Другими словами, это специальный механизм эмулятора, позволяющий вклиниваться в код эмулируемой среды и выполнять нужные самому эмулятору действия. Матрица.

Вот так расположены спецбайты в криво дизассемблированном коде:

Жёлтыми рамками показаны байты специальной "инструкции" 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
Программа 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
Запись программы в файл

Проверяем наличие файла:

Файл myprog2.com размером 88 байт

и запускаем:

Программа показывает непрерывно меняющийся жёлтый символ на синем фоне (обведён желтой рамкой)

Программа показывает непрерывно меняющийся жёлтый символ на синем фоне (обведён желтой рамкой). Для завершения нажимаем клавишу на клавиатуре, например пробел или "Enter":

Программа завершила работу

Резюме по прерываниям

В этой главе мы научились

  • Находить и дизассемблировать обработчики прерываний в составе DOS/BIOS

  • Поняли, что DOSBox - это Матрица со своими зондами-разведчиками

  • Познакомились с устройством видеобуфера текстового режима

  • Подключили свой обработчик прерывания от таймера

  • Успешно деинициализировали обработчик и корректно завершили программу!

Список источников

  1. https://community.intel.com/cipcp26785/attachments/cipcp26785/c-compiler/31103/1/121703-003_ASM86_Language_Reference_Manual_Nov83.pdf page B.2

  2. DOSBox Staging https://www.dosbox-staging.org/

  3. PSP https://en.wikipedia.org/wiki/Program_Segment_Prefix

  4. http://www.techhelpmanual.com/382-dos_fn_09h__display_string.html

  5. http://www.techhelpmanual.com/229-int_16h_00h__read__wait_for__next_keystroke.html

  6. Примеры в бинарном виде https://github.com/galilov/habr/blob/main/asm-8086-galilov.zip