Архитектура процессора i8086 и системное программирование на ассемблере TASM/MASM

Комплексный курс по изучению 16-разрядной архитектуры Intel, охватывающий путь от базовых регистров до механизмов защищенного режима. Студенты изучат принципы сегментации, обработку прерываний и аппаратную поддержку многозадачности с примерами на языке ассемблера.

1. Основы архитектуры i8086 и функциональная система регистров общего и специального назначения

Основы архитектуры i8086 и функциональная система регистров общего и специального назначения

В 1978 году компания Intel выпустила процессор 8086, который не просто стал очередным чипом на рынке, а заложил фундамент всей современной вычислительной техники. Если вы откроете диспетчер задач на самом мощном современном компьютере, вы обнаружите, что его «сердце» до сих пор понимает команды, сформулированные почти полвека назад. Для программиста на ассемблере процессор — это не кусок кремния, а строго упорядоченная система ячеек памяти и логических блоков. Понимание того, как внутри 8086 циркулируют данные, является ключом к написанию эффективного и предсказуемого кода.

Философия CISC и внутренняя структура процессора

Процессор i8086 относится к архитектуре CISC (Complex Instruction Set Computer). Это означает, что набор его команд довольно обширен и включает в себя инструкции разной длины и сложности. Чтобы эффективно справляться с таким потоком задач, инженеры Intel разделили внутреннюю логику процессора на два автономных блока, работающих параллельно. Это было революционным решением, позволившим реализовать зачатки конвейеризации.

Первый блок — BIU (Bus Interface Unit), или устройство сопряжения с шиной. Его задача — взаимодействие с внешним миром: оперативной памятью и портами ввода-вывода. BIU отвечает за подкачку кодов команд из памяти в специальную очередь (префетч-буфер) объемом 6 байт. Пока процессор выполняет одну команду, BIU уже «тащит» следующую.

Второй блок — EU (Execution Unit), или исполнительное устройство. Оно не знает, откуда берутся команды. Оно просто забирает их из очереди, декодирует и выполняет. В состав EU входит арифметико-логическое устройство (АЛУ), которое производит вычисления, и набор регистров, о которых мы будем говорить подробно.

Такое разделение труда позволяет процессору не простаивать. Если EU занято сложным умножением, BIU в это время может заполнять очередь команд. Однако стоит процессору встретить команду перехода (прыжок в другую часть программы), как вся очередь BIU становится бесполезной: её приходится очищать и начинать загрузку с нового адреса. Именно здесь кроется причина, по которой опытные программисты стараются минимизировать количество ветвлений в коде.

Регистровая модель: сверхбыстрая память процессора

Главный инструмент программиста на ассемблере — это регистры. Представьте, что оперативная память — это огромный склад, до которого нужно идти несколько минут. Регистры же — это ваши карманы. Если вам нужно сложить два числа, вы не можете сделать это прямо «на складе» (в большинстве случаев). Вам нужно достать числа из памяти, положить их в «карманы»-регистры, выполнить операцию и, если нужно, вернуть результат обратно на склад.

В i8086 все основные регистры имеют размер 16 бит. Это накладывает фундаментальное ограничение: максимальное число, которое можно поместить в такой регистр без знака, составляет .

Регистры общего назначения (GPR)

Группа регистров общего назначения включает в себя четыре 16-битных регистра: AX, BX, CX и DX. Уникальность этой четверки в том, что каждый из них можно разделить на две независимые 8-битные части (старший байт — High, младший — Low).

  • AX (Accumulator register) — Аккумулятор.
  • Хотя технически большинство операций можно проводить с любым регистром, AX является «привилегированным». Многие команды (например, умножение MUL или деление DIV) жестко завязаны на использование AX. Операции с аккумулятором часто кодируются более короткими инструкциями, что экономит память. * AH — старшие 8 бит. * AL — младшие 8 бит.

  • BX (Base register) — Базовый регистр.
  • Единственный из этой четверки, который может использоваться для адресации памяти. Он часто хранит начальный адрес массива или структуры данных. * BH — старшие 8 бит. * BL — младшие 8 бит.

  • CX (Count register) — Регистр счетчика.
  • Автоматически используется в инструкциях циклов (LOOP) и при строковых операциях. Если вы хотите повторить действие 10 раз, вы записываете число 10 в CX, и команда LOOP будет уменьшать его на единицу при каждой итерации, пока не достигнет нуля. * CH — старшие 8 бит. * CL — младшие 8 бит.

  • DX (Data register) — Регистр данных.
  • Используется в операциях умножения/деления как расширение аккумулятора (для хранения больших чисел), а также для хранения номеров портов при вводе-выводе данных через инструкции IN и OUT. * DH — старшие 8 бит. * DL — младшие 8 бит.

    Важно понимать: если вы измените значение в AL, это мгновенно отразится на младшей части AX. Например, если в AX было 0000h, а вы записали в AL значение FFh, то в AX станет 00FFh.

    Индексные регистры и указатели

    В отличие от AX-DX, эти регистры нельзя делить пополам. Они всегда 16-битные и служат в основном для работы с адресами внутри сегментов.

    * SI (Source Index) — Индекс источника. Используется в строковых операциях для указания адреса, откуда берутся данные. * DI (Destination Index) — Индекс приемника. Используется для указания адреса, куда данные записываются. * BP (Base Pointer) — Базовый указатель. Крайне важен для работы с высокоуровневыми языками (как C или Pascal). Он помогает обращаться к параметрам функций и локальным переменным, которые лежат в стеке. * SP (Stack Pointer) — Указатель стека. Он всегда указывает на текущую вершину стека — область памяти, работающую по принципу LIFO (Last In, First Out). Программисту редко приходится менять SP напрямую, это делают команды PUSH, POP, CALL и RET.

    Сегментная организация памяти: решение проблемы 1 МБ

    Одной из самых сложных для понимания тем в i8086 является адресация. Процессор имеет 20-битную шину адреса. Это означает, что он может физически адресовать байт, что равно 1 048 576 байтам (1 МБ). Однако все регистры процессора — 16-битные. Максимальное число в 16 битах — 65535. Как же с помощью 16-битных регистров «дотянуться» до миллионного байта?

    Инженеры Intel применили сегментацию. Память рассматривается не как единый массив, а как набор перекрывающихся сегментов. Каждый сегмент имеет размер ровно 64 КБ (поскольку это максимум, который описывается 16 битами).

    Для управления этой схемой выделены четыре сегментных регистра:

  • CS (Code Segment) — Сегмент кода. Указывает на область памяти, где лежат инструкции вашей программы.
  • DS (Data Segment) — Сегмент данных. Здесь обычно хранятся глобальные переменные.
  • SS (Stack Segment) — Сегмент стека. Выделенная область для временного хранения данных и адресов возврата.
  • ES (Extra Segment) — Дополнительный сегмент. Часто используется в паре с DS для операций копирования данных из одного места в другое.
  • Физический адрес вычисляется по формуле:

    Здесь Segment — значение в сегментном регистре, а Offset (смещение) — значение в одном из регистров общего назначения или указателей. Умножение на 16 в шестнадцатеричной системе эквивалентно сдвигу числа на одну цифру влево (добавлению нуля в конце).

    > Пример вычисления адреса: > Пусть CS = 1000h, а указатель на команду IP = 0012h. > Физический адрес будет равен: . > > Благодаря этой схеме, программа может быть загружена в любое место памяти. Достаточно изменить значения в сегментных регистрах, и все относительные смещения внутри программы останутся верными.

    Специальные регистры: IP и FLAGS

    Существуют два регистра, которые стоят особняком. Программист не может просто взять и записать в них значение командой MOV.

    IP (Instruction Pointer) — Указатель команд

    Этот регистр всегда содержит смещение следующей команды, которую должен выполнить процессор. Он работает в паре с CS. Пара CS:IP однозначно определяет точку в памяти, где сейчас находится «курсор» исполнения программы. Вы не можете написать MOV IP, 1234h. Изменить IP можно только косвенно, используя команды переходов (JMP), вызова подпрограмм (CALL) или возврата (RET). Когда процессор считывает команду, IP автоматически увеличивается на длину этой команды.

    FLAGS — Регистр флагов

    Это 16-битный регистр, но он не хранит число. Каждый его бит — это отдельный «лампочка-индикатор» (флаг), который сообщает о состоянии процессора после выполнения последней арифметической или логической операции.

    Основные флаги состояния:

    * CF (Carry Flag) — Флаг переноса. Устанавливается в 1, если при сложении произошел перенос из старшего разряда или при вычитании потребовался заем. Это «лишний бит» для математических операций. * ZF (Zero Flag) — Флаг нуля. Самый популярный флаг. Если результат операции равен нулю, ZF = 1. На этом строятся все проверки условий (например, cmp ax, bx — если числа равны, результат вычитания 0, и ZF станет 1). * SF (Sign Flag) — Флаг знака. Копирует старший бит результата. В компьютерной логике 1 в старшем бите означает отрицательное число. * OF (Overflow Flag) — Флаг переполнения. Сигнализирует о том, что результат операции со знаковыми числами не поместился в разрядную сетку. Не путайте его с CF (который для беззнаковых). * AF (Auxiliary Carry) — Вспомогательный перенос. Используется для двоично-десятичной коррекции (BCD). * PF (Parity Flag) — Флаг четности. Указывает, четное ли количество единиц в младшем байте результата.

    Управляющие флаги:

    * DF (Direction Flag) — Флаг направления. Определяет, в какую сторону будут двигаться индексы SI и DI при обработке строк (от начала к концу или наоборот). * IF (Interrupt Flag) — Флаг прерываний. Если он равен 0, процессор игнорирует внешние прерывания (например, от клавиатуры). * TF (Trap Flag) — Флаг трассировки. Используется отладчиками для пошагового выполнения программы.

    Взаимодействие регистров в реальном коде

    Чтобы увидеть, как эта сухая теория превращается в работающую программу, рассмотрим классический фрагмент кода на языке ассемблера TASM. Допустим, нам нужно сложить два числа, хранящихся в памяти.

    Разберем, что произошло на уровне архитектуры:

  • Мы использовали AX как посредник. Архитектура 8086 не позволяет напрямую загружать константы в сегментные регистры (такие как DS). Это аппаратное ограничение: в системе команд просто нет соответствующей электрической цепи для такой операции.
  • Команда add ax, [var2] не только изменила число в AX, но и повлияла на регистр FLAGS. Если сумма var1 и var2 оказалась равна нулю, флаг ZF поднялся бы в единицу. Если сумма превысила 65535, поднялся бы CF.
  • Адреса var1, var2 и result вычислялись процессором автоматически как DS:смещение.
  • Стек: зачем нужен SS:SP?

    Стек — это область памяти, необходимая для временного сохранения данных. Представьте стопку тарелок: вы кладете новую сверху (PUSH) и забрать можете только верхнюю (POP). В i8086 стек растет «вниз» — от больших адресов к меньшим.

    * Когда вы выполняете PUSH AX, указатель стека SP уменьшается на 2 (так как AX — это 2 байта), и значение записывается по адресу SS:SP. * Когда вы выполняете POP BX, значение из памяти по адресу SS:SP копируется в BX, а SP увеличивается на 2.

    Стек критически важен для вызова функций. Когда выполняется команда CALL, процессор автоматически сохраняет текущий IP в стек, чтобы после завершения функции знать, куда вернуться. Если программист по ошибке изменит SP внутри функции и не восстановит его, команда RET (возврат) возьмет из стека «мусор», запишет его в IP, и процессор начнет выполнять случайные данные в памяти, что приведет к немедленному краху программы.

    Нюансы использования регистров: ортогональность и специализация

    В идеальной архитектуре (которую называют ортогональной) любую операцию можно было бы выполнить с любым регистром. i8086 — не такая. Она полна исключений, которые нужно просто запомнить:

  • Только BX, BP, SI, DI могут использоваться внутри квадратных скобок для адресации. Вы не можете написать mov ax, [cx]. Это вызовет ошибку компиляции.
  • Регистр BP по умолчанию работает с сегментом SS, в то время как BX, SI и DI работают с DS. Это сделано специально для удобства работы с локальными переменными в стеке.
  • Для умножения и деления делимое и множимое всегда должны быть в AX (или паре DX:AX для больших чисел).
  • Сегментные регистры нельзя использовать в арифметических операциях. Нельзя написать add ds, 1. Нужно сначала скопировать DS в AX, прибавить единицу там, и вернуть обратно.
  • Эти ограничения кажутся странными современному программисту, но в 1978 году каждый транзистор был на счету. Инженеры экономили на логических связях, создавая специализированные пути для данных.

    Режимы работы и взгляд в будущее

    Процессор 8086 работал в так называемом реальном режиме (Real Mode). В этом режиме у программы есть полный доступ ко всему миллиону байт памяти и ко всем портам ввода-вывода. Никакой защиты нет: одна программа может легко затереть код другой программы или даже самой операционной системы (которой в те времена был DOS).

    Позже, начиная с процессора 80286 и особенно 80386, появился защищенный режим (Protected Mode). В нем сегментные регистры перестали хранить реальный адрес. Вместо этого они стали хранить «селекторы» — индексы в специальных таблицах дескрипторов. Это позволило адресовать гигабайты памяти и запретить программам лезть в чужие данные. Однако даже в самых современных 64-битных процессорах Intel Core i9 или AMD Ryzen, при включении питания процессор первым делом инициализируется в режиме, практически идентичном старому доброму 8086. Это называется обратной совместимостью.

    Понимание регистровой модели 8086 — это не просто изучение истории. Это изучение «языка», на котором говорит железо. Каждый раз, когда вы создаете переменную в высокоуровневом языке программирования, компилятор решает сложнейшую задачу: в какой из этих немногих регистров её поместить, чтобы программа работала максимально быстро. Зная, как устроены эти «карманы» процессора, вы сможете писать код, который по-настоящему эффективно использует ресурсы системы.

    2. Сегментация памяти и механизмы формирования физического адреса в реальном режиме

    Сегментация памяти и механизмы формирования физического адреса в реальном режиме

    Почему процессор i8086, обладая всего лишь 16-разрядными регистрами, способен адресовать целый мегабайт оперативной памяти? Если бы мы использовали прямое сопоставление «один регистр — один адрес», наш предел составил бы всего 65 536 байт (), что даже для 1978 года выглядело бы крайне стесненным условием. Инженеры Intel решили эту проблему через изящный, хотя и специфический механизм сегментации. Понимание того, как «сшиваются» два 16-битных значения в один 20-битный физический адрес, является фундаментом для любого системного программиста, работающего с низкоуровневой архитектурой x86.

    Логика сегментного деления: зачем дробить память

    В архитектуре i8086 память не представляется программисту как единый непрерывный массив от нуля до бесконечности. Вместо этого она рассматривается как набор перекрывающихся областей — сегментов. Каждый сегмент имеет максимальный размер 64 КБ. Это ограничение продиктовано разрядностью регистров смещения (таких как IP, SP, BX, SI, DI), которые физически не могут хранить число больше .

    Сегментация решает сразу несколько фундаментальных задач:

  • Расширение адресного пространства. Использование двух регистров (сегментного и регистра смещения) позволяет сформировать 20-разрядный адрес, открывая доступ к 1 048 576 байтам (1 МБ).
  • Релоцируемость кода. Программу можно загрузить в любой участок памяти, просто изменив значения в сегментных регистрах. Самому коду не нужно знать свой абсолютный адрес; он оперирует смещениями относительно «начала», которое задает сегментный регистр.
  • Логическое разделение данных и команд. Архитектура поощряет хранение инструкций в одном сегменте (CS), переменных — в другом (DS), а временных данных — в сегменте стека (SS). Это не только упрощает организацию программы, но и закладывает основу для механизмов защиты, которые полноценно разовьются в более поздних моделях процессоров.
  • Математика формирования физического адреса

    Физический адрес — это реальный электрический сигнал на шине адреса, указывающий на конкретную ячейку в микросхеме ОЗУ. Чтобы получить этот 20-битный адрес из двух 16-битных логических компонентов, процессор выполняет операцию сдвига и сложения.

    Логический адрес всегда записывается в формате Segment:Offset. Например, 1234h:0005h.

    Процесс вычисления выглядит следующим образом:

  • Берется значение из сегментного регистра.
  • Оно сдвигается влево на 4 бита (что эквивалентно умножению на 16 в десятичной системе или на в шестнадцатеричной). При этом справа дописывается один нулевой полубайт (ниббл).
  • К полученному 20-битному значению прибавляется 16-битное смещение.
  • Математически это можно выразить так:

    Где:

  • — содержимое одного из сегментных регистров (CS, DS, SS, ES).
  • — множитель, сдвигающий сегмент на границу так называемого «параграфа» (16 байт).
  • — значение из указателя команд (IP), указателя стека (SP) или индексного регистра.
  • Разбор на конкретном примере

    Предположим, регистр кода CS содержит значение , а указатель команд IP указывает на смещение . Процессор должен извлечь следующую инструкцию. Какой физический адрес будет выставлен на шину?

  • Умножаем сегмент на : .
  • Складываем со смещением: .
  • Результат — это и есть искомая точка в физической памяти. Важно заметить, что минимальный шаг, на который мы можем сдвинуть начало сегмента, равен 16 байтам. Именно поэтому в системном программировании часто встречается термин «параграф» — это блок памяти размером 16 байт, начинающийся с адреса, кратного 16.

    Феномен перекрытия сегментов и алиасинг адресов

    Одной из самых специфических черт реального режима i8086 является то, что один и тот же физический адрес может быть представлен множеством различных логических комбинаций Segment:Offset. Это следствие того, что 20-битное пространство адресов перекрывается 16-битными сегментами, которые могут начинаться через каждые 16 байт.

    Рассмотрим физический адрес . Его можно получить следующими способами: - - - -

    Такая избыточность называется «алиасингом» (псевдонимами) адресов. С одной стороны, это дает гибкость: мы можем обращаться к одним и тем же данным, используя разные сегментные регистры. С другой стороны, это усложняет отладку, так как указатели могут выглядеть по-разному, но указывать на одну и ту же ячейку ОЗУ.

    Всего существует 4096 различных комбинаций Segment:Offset для большинства физических адресов (за исключением самых начальных и самых конечных участков памяти).

    Границы и лимиты: проблема 64 КБ

    Поскольку смещение (Offset) ограничено 16 битами, из одной фиксированной точки сегмента процессор может «дотянуться» только до данных в пределах 65 536 байт. Если ваша программа требует массив данных размером 100 КБ, вы не сможете адресовать его целиком, просто меняя индексный регистр. Вам придется либо динамически изменять значение сегментного регистра в процессе обхода массива, либо разбивать данные на несколько сегментов.

    Это породило концепцию моделей памяти в языках высокого уровня (Small, Medium, Compact, Large, Huge), которые активно использовались в эпоху DOS. Программист на ассемблере должен вручную следить за тем, чтобы смещение не «перевалило» за границу . Если к адресу прибавить 1, произойдет интересное событие, зависящее от конкретной реализации процессора и включенной адресной линии A20.

    Линия A20 и «заворот» адреса

    Если мы вычислим максимально возможный адрес в реальном режиме:

    Мы увидим, что результат чуть больше 1 мегабайта (). Этот излишек (почти 64 КБ без 16 байт) называется High Memory Area (HMA).

    На оригинальном процессоре 8086, у которого было всего 20 адресных линий, при попытке обращения к адресу выше происходил «заворот» (wrap-around) в начало памяти. То есть адрес превращался в . Однако в процессоре 80286 и последующих появилась 21-я адресная линия (A20). Чтобы сохранить совместимость со старыми программами, которые полагались на этот «заворот», инженеры IBM добавили специальный вентиль (Gate A20), который принудительно обнулял 21-й бит адреса, пока операционная система не разрешит обратное.

    Практическое применение в TASM/MASM

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

    Инициализация сегмента данных

    Когда вы объявляете переменные в секции .data, ассемблер вычисляет их смещения относительно начала этой секции. Но чтобы программа работала корректно, в регистр DS (Data Segment) должно быть загружено правильное значение адреса начала этой секции.

    ``assembly .model small .stack 100h .data message db "Hello, Segment!", "CS \times 10h + BXSS \times 10h + SP0000hFFFEh10h00000h - 003FFh00400h - 004FFh00500h - 9FFFFhA0000h - BFFFFhC0000h - FFFFFh$ | BIOS, ROM-расширения, системные области |

    Когда вы создаете программу, операционная система находит свободный блок в пределах "Conventional Memory", загружает туда ваш код и данные, и устанавливает регистры CS, DS, SS на начало этого блока. Программист чаще всего не знает заранее, какой именно физический адрес ему достанется, но благодаря сегментации и относительной адресации Segment:Offset`, программа будет работать идентично в любом месте ОЗУ.

    Замыкание мысли

    Сегментация памяти в i8086 — это компромиссное, но гениальное решение своего времени. Оно позволило преодолеть барьер в 64 КБ, сохранив 16-битную архитектуру исполнительного блока. Для программиста на ассемблере это означает необходимость всегда мыслить «двумерными» адресами. Понимание того, как сегментный регистр задает базу, а смещение определяет положение внутри окна размером 64 КБ, позволяет эффективно управлять памятью, создавать релоцируемый код и корректно работать с системными ресурсами компьютера. Несмотря на то, что современные системы перешли к плоской (flat) модели памяти, принципы сегментации остаются ключевыми для понимания работы процессора в момент его инициализации и при переходе между различными режимами работы.

    3. Структура программы в TASM и MASM: сегменты кода, данных и директивы определения

    Структура программы в TASM и MASM: сегменты кода, данных и директивы определения

    Почему одна программа на ассемблере занимает 50 байт, а другая — 50 килобайт, при этом обе выполняют схожие вычисления? Ответ кроется не только в алгоритме, но и в том, как программист распорядился архитектурными особенностями процессора i8086 через директивы ассемблера. В мире низкоуровневого программирования текст исходного кода — это не просто последовательность команд, а детальный чертеж того, как данные и инструкции будут размещены в сегментированной памяти.

    Две философии оформления кода: EXE и COM

    Прежде чем разбирать конкретные директивы, необходимо понять фундаментальное различие в форматах исполняемых файлов DOS, которые диктуют структуру исходного кода. Процессор i8086 воспринимает память через призму сегментов, и то, как мы описываем эти сегменты в TASM или MASM, определяет итоговый тип файла.

    Формат COM — это реликт эпохи жесткой экономии памяти. В нем вся программа (код, данные и стек) должна уместиться в одном сегменте объемом КБ. С точки зрения программиста это выглядит максимально просто: все сегментные регистры (CS, DS, SS, ES) указывают на одно и то же место в памяти. Физически это означает, что логический адрес любой инструкции или переменной имеет один и тот же сегментный компонент.

    Формат EXE, напротив, является полноценным воплощением сегментированной архитектуры. Программа может состоять из множества сегментов кода, данных и стека, каждый из которых может достигать КБ. Именно формат EXE требует от программиста явного управления сегментными регистрами и понимания того, как транслятор (TASM/MASM) и компоновщик (TLINK/LINK) превращают текстовые описания в заголовки исполняемого файла.

    Директивы определения сегментов: полный и упрощенный форматы

    В ассемблерах семейства x86 существует два подхода к описанию структуры программы. Исторически первым появился «полный» (standard) формат записи сегментов, который дает максимальный контроль над размещением кода. Позже, для облегчения жизни разработчикам, были введены «упрощенные» директивы.

    Полное описание сегмента

    Директива SEGMENT открывает блок, а ENDS его закрывает. Синтаксис выглядит следующим образом:

    Каждый параметр здесь критически важен для компоновщика:

  • Выравнивание (Alignment): определяет, с какого адреса может начинаться сегмент.
  • * BYTE: с любого адреса. * WORD: с четного адреса. * PARA (Paragraph): с адреса, кратного 16. Это значение по умолчанию, так как сегментные регистры в i8086 могут хранить только адреса, кратные 16 (граница параграфа).
  • Комбинирование (Combine): указывает, как объединять сегменты с одинаковыми именами из разных объектных модулей.
  • * NONE: сегмент автономен. * PUBLIC: сегменты склеиваются в один общий блок. * STACK: аналогично PUBLIC, но помечает блок как сегмент стека.
  • Класс (Class): строка в кавычках (например, 'CODE', 'DATA'), помогающая компоновщику группировать сегменты в памяти.
  • Упрощенные директивы

    Для большинства задач достаточно использовать упрощенный формат. Он активируется директивой .MODEL, которая задает модель памяти (tiny, small, medium, compact, large, huge).

    * .CODE: начало сегмента кода. * .DATA: начало сегмента инициализированных данных. * .STACK [размер]: резервирование места под стек.

    Использование .MODEL SMALL сообщает ассемблеру, что у нас будет один сегмент кода и один сегмент данных. Это наиболее частый выбор для учебных программ. В этом случае ассемблер сам подставит нужные параметры выравнивания и классов, избавляя программиста от рутины.

    Сегмент данных: хранение и инициализация

    Сегмент данных — это область памяти, где мы выделяем место под переменные. В ассемблере нет «типов данных» в привычном понимании высокоуровневых языков (как int или string), есть только размер выделяемой памяти.

    Директивы определения данных

    Для выделения памяти используются директивы: * DB (Define Byte): 1 байт (8 бит). * DW (Define Word): 2 байта (16 бит). * DD (Define Doubleword): 4 байта (32 бита, часто используется для хранения полных адресов "сегмент:смещение"). * DQ (Define Quadword): 8 байт. * DT (Define Ten bytes): 10 байт (обычно для чисел с плавающей точкой).

    Пример инициализации:

    Оператор DUP (Duplicate) незаменим, когда нужно создать массив или буфер. Запись 100 DUP(?) зарезервирует 100 байт, содержимое которых не определено до момента выполнения программы.

    Порядок байтов Little-Endian

    Важнейший нюанс архитектуры i8086, который проявляется именно в сегменте данных — это порядок хранения байтов. Процессор использует схему Little-Endian: младший байт числа записывается по младшему адресу.

    Если мы определим val DW 1234h, то в памяти это будет выглядеть так:

  • По адресу val будет лежать байт 34h.
  • По адресу val + 1 будет лежать байт 12h.
  • Это часто сбивает с толку новичков при отладке, когда они видят в дампе памяти «перевернутые» числа. Однако при выполнении команды MOV AX, val процессор автоматически соберет эти байты в правильном порядке и поместит в регистр AX значение 1234h.

    Сегмент кода и точка входа

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

    Директива ASSUME

    В полном формате записи сегментов критически важна директива ASSUME. Она не генерирует машинный код, а дает подсказку ассемблеру: «Считай, что в регистре CS сейчас находится адрес сегмента MyCode, а в DS — MyData».

    Без этой директивы ассемблер не сможет вычислить смещения переменных. Если вы обращаетесь к переменной var1, ассемблеру нужно знать, относительно какого сегментного регистра вычислять адрес. ASSUME связывает логические имена сегментов с физическими регистрами.

    Точка входа и директива END

    Программа заканчивается директивой END. Но у нее есть и вторая функция: если после END указана метка (например, END start), то эта метка становится точкой входа. Компоновщик запишет адрес этой метки в заголовок EXE-файла, и при запуске DOS установит регистры CS:IP именно на эту позицию.

    Если забыть указать метку в END, программа может начать выполняться с самого начала файла, где могут находиться данные (строки, константы), что приведет к немедленному краху, так как процессор попытается интерпретировать данные как команды.

    Сегмент стека: зачем он нужен в структуре?

    Стек — это область памяти, работающая по принципу LIFO (Last In, First Out). В структуре программы на ассемблере он выделяется отдельно.

    Зачем нам явно определять стек?

  • Сохранение адресов возврата: при вызове подпрограмм (команда CALL) адрес следующей инструкции сохраняется в стеке.
  • Временное хранение регистров: команды PUSH и POP позволяют освободить регистры для вычислений, сохранив их текущие значения.
  • Передача параметров: многие языки высокого уровня (например, C) используют стек для передачи аргументов функциям.
  • В i8086 регистр SS указывает на начало сегмента стека, а SP — на текущую вершину. Важно помнить, что стек в x86 «растет вниз»: при добавлении данных в стек значение SP уменьшается. Если мы выделили 100h байт, то изначально SP будет указывать на смещение 0100h. После первой команды PUSH AX значение SP станет 00FEh.

    Инициализация сегментных регистров в коде

    Это одна из самых распространенных точек преткновения. Даже если мы использовали упрощенные директивы или ASSUME, процессор i8086 при запуске EXE-файла автоматически инициализирует только CS (сегмент кода) и SS (сегмент стека). Регистр DS (сегмент данных) содержит мусор или адрес префикса программного сегмента (PSP).

    Программист обязан вручную инициализировать DS в самом начале программы:

    Почему нельзя сделать mov ds, @data напрямую? Архитектура i8086 не поддерживает команду загрузки непосредственного значения (immediate value) напрямую в сегментный регистр. Данные должны пройти через регистр общего назначения (обычно AX).

    Директивы определения местоположения: ORG и : возвращает текущее значение счетчика адреса. Это мощный инструмент для вычисления длин строк.

    assembly .MODEL SMALL ; Выбираем модель памяти .STACK 100h ; Резервируем 256 байт под стек

    .DATA ; Начало сегмента данных var1 DB 5 ; Переменная размером в байт var2 DB 10 ; Еще один байт res DB ? ; Место под результат

    .CODE ; Начало сегмента кода start: ; Метка точки входа mov ax, @data ; Настройка регистра сегмента данных mov ds, ax

    mov al, var1 ; Загружаем данные add al, var2 ; Складываем mov res, al ; Сохраняем результат

    mov ax, 4C00h ; Функция завершения программы DOS int 21h ; Вызов прерывания END start ; Конец файла и указание точки входа ``

    В этом примере четко прослеживается иерархия:

  • Декларация среды: .MODEL и .STACK.
  • Декларация данных: .DATA, где переменные var1 и var2 расположены последовательно.
  • Декларация логики: .CODE, начинающаяся с настройки DS` и заканчивающаяся корректным выходом в ОС.
  • Понимание этой структуры — это фундамент. Без него невозможно двигаться к изучению сложных режимов адресации или системных прерываний, так как любая ошибка в описании сегментов приведет к тому, что процессор начнет исполнять данные как код или искать переменные там, где их нет.

    4. Организация стека и эффективное управление данными в оперативной памяти

    Организация стека и эффективное управление данными в оперативной памяти

    Представьте ситуацию: процессору необходимо вычислить сложное математическое выражение, где результаты промежуточных действий должны сохраняться, пока выполняются другие операции. Или, что еще важнее, программе нужно вызвать подпрограмму, выполнить её и вернуться точно в ту точку, откуда был совершен переход. Куда деть адрес возврата? Куда временно «спрятать» значения регистров, чтобы подпрограмма их не испортила? Использование фиксированных ячеек памяти для этих целей невозможно при рекурсии или вложенных вызовах. Решением этой фундаментальной проблемы в архитектуре i8086 является стек — динамическая структура данных, работающая по принципу «последним пришел — первым ушел» (LIFO, Last-In-First-Out).

    Механика стека в архитектуре i8086

    Стек в процессоре i8086 — это не отдельное устройство, а выделенная область оперативной памяти. Его работа обеспечивается парой регистров: SS (Stack Segment) и SP (Stack Pointer). Регистр SS указывает на начало сегмента стека, а SP содержит смещение текущей «вершины» стека.

    Ключевая особенность стека i8086, которая часто сбивает новичков с толку, заключается в векторе его роста. Стек растет «вниз» — от больших адресов к меньшим. Когда мы помещаем данные в стек, значение SP уменьшается. Когда извлекаем — увеличивается.

    Процесс инициализации и логика работы

    При запуске программы типа EXE программист сам определяет размер стека с помощью директивы .STACK или описания сегмента. Например:

    В этот момент операционная система при загрузке программы установит SS на начало этого блока, а SP — на его конец (в данном случае 0100h). Почему на конец? Потому что первая же операция записи в стек должна уменьшить SP и записать данные в свободное пространство.

    Если мы попытаемся записать слово (2 байта) в стек при SP = 0100h, процессор выполнит следующие действия:

  • Вычтет 2 из SP ().
  • Запишет младший байт данных по адресу .
  • Запишет старший байт данных по адресу .
  • Заметьте, что даже в стеке соблюдается принцип Little-Endian: младший байт по младшему адресу. Однако для программиста стек почти всегда выглядит как набор 16-битных слов. В i8086 невозможно положить в стек или извлечь из него один байт — операции PUSH и POP работают строго с 16-битными операндами (регистрами или ячейками памяти).

    Основные операции: PUSH и POP

    Команда PUSH (от англ. push — проталкивать) — это основной инструмент сохранения данных. Она может принимать в качестве операнда регистр общего назначения, сегментный регистр или ячейку памяти.

    Алгоритм работы PUSH на микропрограммном уровне:

  • .
  • .
  • Команда POP (от англ. pop — выталкивать) выполняет обратное действие: извлекает слово из вершины стека и помещает его в указанное место, после чего «освобождает» место в стеке, увеличивая указатель.

    Алгоритм работы POP:

  • .
  • .
  • Важно понимать, что данные в памяти физически не стираются при выполнении POP. Они просто объявляются «несуществующими», так как SP теперь указывает на адрес выше них. Если выполнить PUSH снова, старые данные будут просто перезаписаны. Это классический источник уязвимостей и ошибок: если вы забудете восстановить SP или обратитесь к «мусору» ниже вершины стека, поведение программы станет непредсказуемым.

    Сохранение контекста и вложенные вызовы

    Зачем нам постоянно использовать PUSH и POP? Главная причина — дефицит регистров. В i8086 всего 4 регистра общего назначения. Если вам нужно выполнить сложный расчет, требующий использования AX, BX, CX и DX, а в них уже лежат важные данные, вы «сбрасываете» их в стек.

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

    Пример типичного паттерна:

    Внимание на порядок: так как стек — это LIFO, восстанавливать регистры нужно в обратном порядке. Если вы сохранили AX, а затем BX, то первым нужно извлечь BX. Ошибка в порядке POP — одна из самых частых причин логических сбоев в ассемблерном коде.

    Стек также является фундаментом для работы процедур (подпрограмм). Команда CALL автоматически помещает адрес следующей за ней инструкции (регистр IP) в стек перед переходом к процедуре. Команда RET (Return) извлекает этот адрес из стека и помещает его обратно в IP, обеспечивая возврат. Без стека была бы невозможна рекурсия, так как каждый новый вызов функции перезаписывал бы единственный адрес возврата.

    Использование регистра BP для доступа к данным в стеке

    Хотя SP всегда указывает на вершину стека, обращаться к данным внутри стека через него неудобно. Во-первых, SP постоянно меняется. Во-вторых, в архитектуре i8086 регистр SP нельзя использовать в качестве базового регистра для адресации памяти (например, инструкция mov ax, [sp+2] в 16-битном режиме i8086 недопустима).

    Для решения этой задачи предназначен регистр BP (Base Pointer). По умолчанию обращения через BP адресуются к сегменту стека (SS), а не к сегменту данных (DS). Это позволяет создавать «кадры стека» (stack frames).

    Создание кадра стека

    Когда процедура получает параметры через стек, она обычно выполняет следующую последовательность действий (пролог):

  • push bp — сохраняем старое значение базового указателя (принадлежащее вызывающей функции).
  • mov bp, sp — устанавливаем BP на текущую вершину стека.
  • Теперь BP зафиксирован. Даже если внутри процедуры мы будем что-то класть в стек (изменяя SP), смещение параметров относительно BP останется неизменным.

  • [bp+4] — первый параметр (пропустили сохраненный BP и адрес возврата).
  • [bp+6] — второй параметр.
  • [bp-2] — первая локальная переменная (если мы выделили под неё место, уменьшив SP).
  • Перед выходом из процедуры выполняется эпилог:

  • mov sp, bp — восстанавливаем SP, «выбрасывая» локальные переменные.
  • pop bp — восстанавливаем старый BP.
  • ret.
  • Эффективное управление данными: адресация и выравнивание

    Работа с оперативной памятью в i8086 не ограничивается стеком. Процессор поддерживает несколько режимов адресации, которые позволяют гибко оперировать массивами и структурами данных.

    Режимы адресации памяти

  • Прямая адресация:
  • mov ax, [1234h] — адрес зашит прямо в код команды. Это самый простой, но наименее гибкий способ.
  • Косвенная регистровая адресация:
  • mov ax, [bx] — адрес берется из регистра. Позволяет организовывать циклы для обработки массивов.
  • Базовая и индексная адресация:
  • mov ax, [bx+si] или mov ax, [bp+di]. Здесь адрес вычисляется как сумма двух регистров.
  • Адресация со смещением:
  • mov ax, [bx+5] или mov ax, array[si].

    Эффективность управления данными напрямую зависит от выбора правильного режима. Например, для обхода двумерного массива удобно использовать базово-индексную адресацию со смещением: mov ax, [bx+si+displacement] Здесь bx может указывать на начало строки, si — на индекс столбца, а displacement — на конкретное поле в структуре данных.

    Проблема выравнивания данных

    Хотя i8086 является 16-разрядным процессором, его шина данных позволяет считывать байты по любому адресу. Однако физически память организована в виде двух банков: четного и нечетного.

  • Если слово (16 бит) начинается по четному адресу, процессор считывает его за один цикл обращения к шине, так как оба байта попадают в разные банки и читаются одновременно.
  • Если слово начинается по нечетному адресу, процессору приходится выполнять два цикла чтения: сначала прочитать второй байт слова из нечетного банка, а затем — первый байт из четного банка по следующему адресу.
  • Это приводит к падению производительности почти в два раза при массовых операциях с памятью. Для оптимизации в TASM/MASM используется директива ALIGN:

    Эта директива заставляет ассемблер вставить пустой байт (NOP), если текущий адрес нечетный, гарантируя, что my_word окажется на четной границе.

    Работа с большими объемами данных и строковые инструкции

    Для эффективного перемещения или поиска данных в памяти i8086 предлагает набор строковых инструкций: MOVS (Move String), STOS (Store String), LODS (Load String), CMPS (Compare String) и SCAS (Scan String).

    Эти команды уникальны тем, что они используют фиксированные пары регистров:

  • DS:SI — адрес источника (Source Index).
  • ES:DI — адрес приемника (Destination Index).
  • CX — счетчик элементов.
  • В сочетании с префиксом REP (Repeat), эти команды позволяют выполнять операции над целыми блоками памяти на аппаратной скорости.

    Например, копирование блока данных:

    Здесь movsw за один шаг копирует слово из [ds:si] в [es:di] и автоматически увеличивает (или уменьшает, если флаг DF установлен) регистры SI и DI на 2. Это гораздо быстрее, чем писать цикл на ассемблере вручную, так как логика декремента CX и изменения указателей встроена в микрокод процессора.

    Граничные случаи и опасности при работе с памятью

    При управлении данными на низком уровне программист берет на себя ответственность за контроль границ сегментов. В реальном режиме i8086 нет аппаратной защиты памяти.

  • Переполнение стека (Stack Overflow):
  • Если SP станет равным 0 и вы выполните PUSH, SP превратится в FFFEh. Стек «завернется» и начнет затирать данные в начале сегмента стека или даже выйдет за его пределы, если сегмент не ограничен физически. Это часто случается при бесконечной рекурсии.
  • Встречное движение стека и данных:
  • В некоторых моделях памяти (например, TINY для COM-файлов) сегмент кода, данных и стека — это один и тот же физический сегмент. Данные обычно растут вверх от начала, а стек — вниз от конца. Если данных станет слишком много или стек станет слишком глубоким, они встретятся и начнут уничтожать друг друга.
  • Потеря сегмента:
  • Поскольку смещение в i8086 16-битное, вы не можете адресовать более 64 КБ данных через один сегментный регистр без его изменения. Если ваш массив данных превышает 64 КБ, вам придется вручную пересчитывать значение сегментного регистра (например, DS), что значительно усложняет код и замедляет работу.

    Оптимизация доступа: регистры против памяти

    Самое важное правило эффективного управления данными: избегайте памяти, если это возможно. Обращение к регистру занимает 0-1 такт, в то время как обращение к памяти (даже при попадании в кэш, которого в оригинальном i8086 не было, но он есть в эмуляторах и потомках) требует обращения к шине, что в разы медленнее.

    Эффективный код на ассемблере строится по принципу:

  • Загрузить данные из памяти в регистры.
  • Выполнить максимально возможное количество операций над регистрами.
  • Сохранить финальный результат обратно в память.
  • Стек здесь выступает как промежуточный буфер. Если регистров не хватает, стек — ваш лучший друг, так как доступ к нему через PUSH/POP или BP оптимизирован на уровне процессора лучше, чем произвольный доступ к переменным в сегменте данных.

    Финальные соображения по управлению ресурсами

    Организация стека и памяти в i8086 отражает философию эпохи жесткой экономии ресурсов. Сегментация, которая кажется нам сегодня неудобной, позволила 16-битному процессору работать с «огромным» по тем временам мегабайтом памяти. Стек, растущий вниз, позволил эффективно разделять адресное пространство между статическими данными и динамическими вызовами.

    Понимание того, как SP и BP взаимодействуют с памятью, как строковые инструкции ускоряют обработку массивов и почему выравнивание данных по четным адресам критично для скорости, отделяет простого кодера от системного программиста. Эти знания остаются актуальными и сегодня: современные 64-битные процессоры Intel по-прежнему несут в себе наследие i8086, и принципы работы их стека и кэш-линий (современный аналог выравнивания) уходят корнями в архитектурные решения конца 70-х годов.

    5. Система прерываний и программно-аппаратное взаимодействие с периферийными устройствами

    Система прерываний и программно-аппаратное взаимодействие с периферийными устройствами

    Представьте, что процессор — это шеф-повар, который методично нарезает овощи, следуя строгому рецепту. В этот момент на кухню вбегает официант с криком, что на плите горит соус. Если бы повар умел только следовать линейному списку команд, соус бы сгорел, пока не закончилась бы нарезка последней моркови. В мире вычислительных систем роль такого «крика» играют прерывания. Без них компьютер превратился бы в изолированный калькулятор, неспособный мгновенно реагировать на нажатие клавиши, приход сетевого пакета или системную ошибку.

    Природа прерывания: от линейного кода к событийной модели

    В классической архитектуре i8086 выполнение программы представляется как последовательный захват инструкций из памяти блоком BIU и их исполнение блоком EU. Однако реальный мир асинхронен. Периферийные устройства (клавиатура, таймер, дисковый контроллер) работают на скоростях, которые на порядки ниже частоты процессора. Если бы процессор постоянно опрашивал статус каждого устройства в цикле (метод опроса или polling), он тратил бы своего времени на бесполезное ожидание.

    Прерывание (Interrupt) — это механизм, позволяющий приостановить текущую последовательность команд и временно передать управление специальной подпрограмме — обработчику прерывания (ISR, Interrupt Service Routine). После завершения обработки состояние процессора восстанавливается, и он продолжает выполнение основной программы ровно с того места, где остановился.

    Для реализации этого механизма в i8086 предусмотрена сложная иерархия, разделяющая прерывания на три фундаментальных типа:

  • Аппаратные (внешние): инициируются физическими устройствами через специальные выводы процессора.
  • Программные (внутренние): вызываются непосредственно кодом программы с помощью инструкции INT.
  • Исключения (трапы): возникают автоматически при попытке выполнения недопустимой операции (например, деление на ноль).
  • Таблица векторов прерываний (IVT)

    В реальном режиме работы i8086 процессор должен знать, по какому адресу в памяти находится код обработчика для каждого конкретного события. Поскольку прерываний может быть до 256, архитекторы Intel выделили самый первый килобайт оперативной памяти (адреса от 00000h до 003FFh) под Таблицу векторов прерываний (Interrupt Vector Table, IVT).

    Каждый элемент этой таблицы называется «вектором» и занимает ровно 4 байта. Структура вектора жестко фиксирована:

  • Первые 2 байта (младшее слово) содержат смещение (Offset) обработчика.
  • Следующие 2 байта (старшее слово) содержат адрес сегмента (Segment) обработчика.
  • Математически адрес вектора для прерывания с номером вычисляется по формуле:

    Где — номер прерывания в диапазоне от до . Например, вектор для прерывания INT 08h (системный таймер) будет находиться по физическому адресу (или 0000:0020).

    > Важно понимать: IVT — это не код, а указатели. Процессор считывает эти 4 байта, загружает их в регистры IP и CS соответственно, тем самым совершая «прыжок» в другую область памяти.

    Механизм обработки: что происходит внутри процессора

    Когда процессор получает сигнал прерывания (или встречает инструкцию INT), он не может просто изменить CS:IP. Ему необходимо сохранить «контекст», чтобы вернуться назад. В отличие от обычного вызова процедуры (CALL), прерывание сохраняет больше данных.

    Алгоритм работы процессора при возникновении прерывания:

  • Сохранение флагов: Регистр FLAGS проталкивается в стек. Это критично, так как обработчик может изменить флаги состояния (например, ZF или CF), что сломает логику основной программы после возврата.
  • Сброс флагов IF и TF: Процессор автоматически устанавливает флаг прерываний IF = 0 (запрет других аппаратных прерываний на время входа в обработчик) и флаг трассировки TF = 0.
  • Сохранение адреса возврата: Текущие значения CS и IP сохраняются в стек.
  • Загрузка вектора: Процессор обращается к IVT, считывает 4 байта, соответствующих номеру прерывания, и обновляет CS и IP.
  • Выполнение ISR: Начинается выполнение кода обработчика.
  • Завершается обработчик специальной инструкцией IRET (Interrupt Return). Она выполняет действия в обратном порядке: извлекает из стека IP, затем CS, и, наконец, FLAGS. Именно IRET восстанавливает состояние флага IF, снова разрешая внешние прерывания.

    Аппаратные прерывания и контроллер i8259A

    Процессор i8086 имеет всего два физических вывода для приема сигналов прерываний:

  • NMI (Non-Maskable Interrupt): Немаскируемое прерывание. Процессор обязан на него среагировать немедленно. Обычно используется для сигнализации о катастрофических сбоях (ошибка четности памяти, падение напряжения). Ему всегда присвоен номер 2.
  • INTR (Interrupt Request): Маскируемый запрос. Процессор может игнорировать его, если флаг IF (Interrupt Flag) в регистре FLAGS сброшен в 0.
  • Так как устройств много, а вход INTR один, в архитектуру PC введен посредник — Программируемый контроллер прерываний (PIC) i8259A. К нему подключаются линии запросов от периферии (IRQ — Interrupt Request lines). В классической архитектуре AT используются два таких контроллера, соединенных каскадом, что дает 15 доступных линий IRQ.

    Когда устройство активирует свою линию IRQ, контроллер:

  • Проверяет приоритет запроса.
  • Подает сигнал на вход INTR процессора.
  • Дождавшись подтверждения от процессора, передает по шине данных номер прерывания (тип), который процессор затем использует для обращения к IVT.
  • Например, системный таймер висит на IRQ 0, что соответствует INT 08h. Клавиатура — на IRQ 1, что соответствует INT 09h.

    Программные прерывания: мост между кодом и ОС

    Инструкция INT n позволяет программисту вызвать любой обработчик из таблицы IVT вручную. Это основной способ взаимодействия программы с операционной системой DOS и базовой системой ввода-вывода BIOS.

    Вместо того чтобы знать физические адреса процедур печати на экране или чтения файла, программист использует стандартизированные номера прерываний. Самое известное из них — INT 21h (вызов функций MS-DOS). Параметры передаются через регистры общего назначения.

    Рассмотрим пример вывода символа на экран через BIOS:

    Здесь int 10h заставляет процессор:

  • Сохранить FLAGS, CS, IP в стек.
  • Перейти по адресу, указанному в 0000:0040 (так как ).
  • Выполнить код внутри BIOS, который умеет рисовать пиксели буквы 'A'.
  • Вернуться в программу по IRET.
  • Исключения процессора

    Исключения — это прерывания, которые процессор генерирует сам «от безысходности». Они делятся на:

  • Faults (отказы): возникают до исполнения инструкции. Если исправить причину (например, подгрузить страницу памяти в защищенном режиме), инструкцию можно повторить. В i8086 примером является INT 0 — деление на ноль.
  • Traps (ловушки): возникают сразу после исполнения инструкции. Используются в отладчиках (например, INT 3 или пошаговый режим через флаг TF).
  • Если ваша программа на ассемблере попытается выполнить div с нулевым делителем, процессор мгновенно выполнит INT 0. Если вы не переопределили этот вектор, управление получит стандартный обработчик DOS, который просто выведет сообщение "Divide overflow" и завершит вашу программу.

    Взаимодействие с портами ввода-вывода (I/O)

    Прерывание — это лишь сигнал о событии. Чтобы обменяться данными с устройством, процессор использует порты ввода-вывода. В архитектуре x86 пространство портов отделено от пространства оперативной памяти. Это так называемая архитектура с изолированным вводом-выводом (Isolated I/O).

    Для работы с портами существуют всего две инструкции: IN (чтение из порта в регистр AL/AX) и OUT (запись из регистра в порт). Адрес порта может быть от 0000h до FFFFh.

    Пример взаимодействия с системным динамиком (PC Speaker):

    Связь прерываний и портов выглядит так:

  • Устройство сигнализирует через IRQ.
  • Процессор переходит в обработчик (ISR).
  • Внутри ISR программист использует IN, чтобы забрать данные из порта устройства (например, скан-код нажатой клавиши из порта 60h).
  • ISR завершается, и основная программа может обработать полученные данные.
  • Конфликты и маскирование прерываний

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

    Для управления этим процессом используются инструкции:

  • CLI (Clear Interrupt Flag): Сбрасывает IF в 0. Процессор перестает реагировать на сигналы на входе INTR. Это необходимо при выполнении критических участков кода (например, при модификации самой таблицы IVT или переключении стека).
  • STI (Set Interrupt Flag): Устанавливает IF в 1, разрешая аппаратные прерывания.
  • Стоит помнить, что CLI не блокирует программные прерывания INT n и исключения. Также оно не блокирует NMI.

    Практическая реализация: перехват прерывания в TASM

    Одна из самых частых задач системного программиста — «подменить» стандартный обработчик своим. Это называется перехватом прерывания.

    Алгоритм безопасного перехвата:

  • Сохранить старый вектор прерывания (чтобы в конце работы программы вернуть всё как было).
  • Записать в IVT адрес своего обработчика.
  • В своем обработчике выполнить нужные действия.
  • (Опционально) Вызвать старый обработчик, чтобы не нарушать работу системы.
  • Перед выходом из программы восстановить старый вектор.
  • Для работы с векторами в DOS существуют специальные функции INT 21h:

  • AH = 35h: Получить вектор (Get Vector). Возвращает ES:BX.
  • AH = 25h: Установить вектор (Set Vector). Берет адрес из DS:DX.
  • Пример кода для перехвата прерывания таймера (INT 1Ch, которое вызывается 18.2 раза в секунду):

    Нюансы работы со стеком при прерываниях

    При написании обработчиков (ISR) нужно быть предельно осторожным со стеком. Поскольку прерывание может возникнуть в любой момент, оно использует тот стек, который был активен у прерванной программы. Если это была маленькая утилита с крошечным стеком, ваш обработчик может вызвать переполнение.

    Более того, если вы используете функции DOS (через INT 21h) внутри обработчика аппаратного прерывания, вы рискуете столкнуться с проблемой «нереентерабельности» (non-reentrancy) DOS. Ядро MS-DOS не рассчитано на то, чтобы его вызывали повторно, пока предыдущий вызов не завершен. Именно поэтому в резидентных программах (TSR) используются сложные проверки флага InDOS.

    Взаимодействие с контроллером прерываний (EOI)

    Если вы пишете обработчик для аппаратного прерывания (IRQ), вы обязаны сообщить контроллеру прерываний i8259A, что обработка завершена. Если этого не сделать, контроллер будет считать, что линия всё еще занята, и заблокирует все последующие прерывания того же или более низкого приоритета.

    Для этого в порт контроллера (20h) посылается команда EOI (End of Interrupt):

    В случае программных прерываний (INT n) или исключений слать EOI не нужно, так как контроллер PIC в них не участвует.

    Роль прерываний в развитии архитектуры

    Система прерываний i8086 заложила фундамент для всей последующей эволюции x86. Хотя в реальном режиме мы ограничены таблицей IVT в первом килобайте памяти, концепция векторизации событий сохранилась и в защищенном режиме. Там на смену IVT приходит IDT (Interrupt Descriptor Table), которая может находиться в любом месте памяти и содержит не просто адреса, а дескрипторы шлюзов прерываний с указанием уровней привилегий.

    Понимание того, как процессор сохраняет контекст, как работает контроллер прерываний и как программный код взаимодействует с «железом» через порты, является критическим для любого системного программиста. Это те самые «рельсы», по которым движется любая операционная система, обеспечивая многозадачность и отзывчивость интерфейса.

    6. Реальный режим работы процессора: особенности адресации и ограничения архитектуры

    Реальный режим работы процессора: особенности адресации и ограничения архитектуры

    Почему процессор, способный на сложные вычисления, в момент включения ведет себя как антикварный чип из 1978 года? Каждый современный процессор Intel Core i9 или AMD Ryzen начинает свою жизнь в так называемом реальном режиме (Real Address Mode). Это состояние — не просто дань уважения предку i8086, а фундамент, на котором строится инициализация системы. Однако для программиста реальный режим — это пространство парадоксов: здесь нет защиты памяти, любой код может обрушить систему, а адресное пространство ограничено магическим числом в 1024 килобайта.

    Философия «реальности» и прямого доступа

    Название «реальный режим» появилось лишь с выходом процессора i80286, чтобы отличить классический метод работы i8086 от нового «защищенного» режима. Слово «реальный» здесь означает, что программный адрес напрямую соответствует физическому расположению данных в микросхемах памяти. Между инструкцией MOV [BX], AL и шиной адреса нет сложных преобразователей, таблиц страниц или механизмов виртуализации.

    В этой архитектуре процессор доверяет программисту абсолютно. Если вы решите записать данные по адресу, где хранятся векторы прерываний или системные переменные BIOS, процессор не выдаст ошибку Access Violation. Он просто выполнит команду, что неизбежно приведет к непредсказуемому поведению системы. Эта свобода действий является одновременно и мощнейшим инструментом оптимизации, и главным источником критических уязвимостей.

    Анатомия адресного пространства первого мегабайта

    Процессор i8086 имеет 20 линий адреса. Это означает, что он может адресовать ровно байт.

    Где — количество адресных линий (A0–A19).

    Этот мегабайт в реальном режиме распределен крайне специфично. В отличие от современных систем, где память кажется однородным массивом, здесь она жестко сегментирована по назначению на уровне архитектуры IBM PC:

  • Нижняя память (Conventional Memory): Первые 640 КБ (адреса 00000h9FFFFh). Это основная область для операционной системы (DOS), драйверов и прикладных программ.
  • Верхняя память (Upper Memory Area, UMA): Область от 640 КБ до 1 МБ (A0000hFFFFFh). Она зарезервирована для аппаратных нужд:
  • * A0000hBFFFFh: Видеопамять графических адаптеров. * C0000hEFFFFh: ROM-BIOS расширения периферийных устройств (например, сетевых карт или контроллеров дисков). * F0000hFFFFFh: Системный BIOS (Basic Input/Output System).

    Важнейшая точка в этом пространстве — адрес FFFF0h. Именно сюда передается управление сразу после подачи питания или сброса процессора (Reset). Здесь обычно находится инструкция JMP, ведущая на основной код инициализации BIOS.

    Механика сегментной переадресации: за пределами 16 бит

    Основная техническая проблема i8086 заключалась в том, что его внутренние регистры (AX, BX, IP, SP) имеют размер 16 бит. Максимальное число, которое можно поместить в такой регистр — (). Если бы мы использовали только один регистр для адресации, мы были бы ограничены жалкими 64 КБ памяти.

    Для расширения горизонта до 1 МБ была введена схема «сегмент:смещение». Физический адрес формируется путем сдвига значения сегментного регистра на 4 бита влево (что эквивалентно умножению на 16 в десятичной системе или на в шестнадцатеричной) и прибавления к нему смещения.

    Рассмотрим пример, когда сегментный регистр данных DS содержит 2000h, а регистр смещения SI содержит 0100h. Процесс вычисления физического адреса (PA) выглядит так:

  • Берем сегмент: 2000h.
  • Сдвигаем влево на 4 бита (добавляем 0 справа): 20000h.
  • Прибавляем смещение: 20000h + 0100h = 20100h.
  • Математически это выражается формулой:

    Где — значение в CS, DS, SS или ES, а — эффективный адрес, вычисленный на основе режимов адресации.

    Особенности перекрытия сегментов

    Из-за того, что сегмент умножается на 16, новый сегмент может начинаться через каждые 16 байт (такая граница называется параграфом). Поскольку смещение может принимать значения до 64 КБ, сегменты неизбежно накладываются друг на друга.

    Один и тот же физический адрес 00400h (где начинается таблица векторов прерываний) может быть представлен множеством способов: * 0000h:0400h (базовый вариант) * 0040h:0000h (сегмент начинается прямо по адресу) * 0020h:0200h (сегмент начинается раньше, смещение больше)

    Для программиста это означает, что сравнение двух указателей в реальном режиме — нетривиальная задача. Нельзя просто сравнить два 32-битных значения (сегмент и смещение), так как они могут быть разными, но указывать на одну и ту же ячейку ОЗУ. Для корректного сравнения необходимо привести оба адреса к их «абсолютному» 20-битному виду.

    Ограничения «плоской» модели и барьер 64 КБ

    Хотя процессор видит 1 МБ, отдельная структура данных или непрерывный блок кода не могут превышать 64 КБ без смены сегментного регистра. Это создает значительные трудности при написании программ на ассемблере:

  • Межсегментные переходы (Far Jumps): Если ваш код разросся и перестал помещаться в один сегмент, обычная команда JMP (ближний переход, меняющий только IP) не сработает. Приходится использовать JMP FAR, которая меняет и CS, и IP. Это работает медленнее и усложняет логику.
  • Управление данными: Если массив данных больше 64 КБ, вы не можете просто инкрементировать индексный регистр (например, SI). После достижения значения FFFFh следующий инкремент превратит его в 0000h (произойдет wrap-around внутри сегмента), и вы окажетесь в начале того же сегмента, а не на следующем байте физической памяти. Программисту приходится вручную проверять переполнение и корректировать сегментный регистр (DS или ES).
  • Эффект «заворота» (Wrap-around)

    В оригинальном i8086, если вы вычисляли адрес, результат которого превышал FFFFFh (например, FFFFh:0010h), адрес просто «заворачивался» в начало первого мегабайта.

    Это происходило потому, что 21-й бит адреса просто отбрасывался, так как физической линии A20 не существовало. Когда появились процессоры i80286 и выше, у которых было больше 20 линий адреса, этот «баг» стал «фичей». Для совместимости со старыми программами DOS была введена логика управления линией A20 через контроллер клавиатуры. Если A20 выключена — система имитирует поведение i8086. Если включена — мы получаем доступ к области HMA (High Memory Area), первым 64 КБ выше мегабайтного барьера, оставаясь в реальном режиме.

    Стек в реальном режиме: опасная гибкость

    Стек в архитектуре i8086 определяется парой SS:SP. В реальном режиме нет никакой аппаратной защиты от выхода стека за пределы отведенного сегмента.

    Когда выполняется инструкция PUSH AX, происходят следующие действия:

  • Значение SP уменьшается на 2: .
  • Регистр AX записывается по адресу SS:SP.
  • Если SP изначально равен 0001h, то после PUSH он станет равен FFFFh. Процессор не проверит, не «наехал» ли стек на сегмент данных или кода. В реальном режиме стек растет «вниз» (к младшим адресам), и если программист не рассчитал его размер, стек может незаметно затереть переменные программы или даже исполняемый код, что приведет к фатальному сбою, который крайне трудно отладить.

    Отсутствие защиты и уровней привилегий

    В реальном режиме концепция «колец защиты» (Ring 0 – Ring 3) отсутствует. Процессор всегда работает в состоянии, эквивалентном максимальным привилегиям. Любая инструкция является разрешенной.

    Это влечет за собой несколько критических следствий: * Прямой доступ к портам I/O: Любая программа может отправить команду OUT в порт контроллера жесткого диска или прерываний. Нет возможности запретить приложению обращаться к оборудованию напрямую. * Модификация векторов прерываний: Таблица IVT (Interrupt Vector Table) находится по фиксированному адресу 0000:0000. Любой код может изменить адрес обработчика прерывания таймера или клавиатуры. Это активно использовалось создателями резидентных программ (TSR) и вирусов эпохи DOS. * Отсутствие изоляции задач: Если в памяти загружено несколько программ (например, через менеджеры многозадачности для DOS), одна программа может легко прочитать или изменить память другой. Понятия «чужая память» в реальном режиме не существует.

    Программная модель TASM/MASM для реального режима

    При написании программ для реального режима на ассемблере мы чаще всего сталкиваемся с двумя типами исполняемых файлов: .COM и .EXE. Они по-разному используют особенности реального режима.

    COM-файлы: Модель Tiny

    Это самая простая форма программы. Весь код, данные и стек должны уместиться в один сегмент (64 КБ). * Регистры CS, DS, ES, SS указывают на одно и то же место — начало PSP (Program Segment Prefix). * Смещение начинается с 100h (первые 256 байт зарезервированы под PSP). * Адресация максимально быстрая, так как сегментные регистры никогда не меняются.

    EXE-файлы: Многосегментная модель

    Позволяют использовать весь мегабайт памяти. * Имеют заголовок, в котором указана таблица релокации (настройки сегментов). * При загрузке DOS настраивает CS и SS автоматически, но программист обязан сам инициализировать DS: * Здесь активно используются директивы ASSUME, которые подсказывают ассемблеру, какой сегментный регистр в данный момент «отвечает» за конкретный сегмент данных или кода.

    Использование префиксов замены сегмента

    По умолчанию процессор использует определенные сегментные регистры для разных операций: * Для извлечения инструкций — всегда CS. * Для работы со стеком (PUSH, POP, CALL) — всегда SS. * Для общих операций с данными (MOV AX, [BX]) — по умолчанию DS. * Для строковых операций (приемник) — всегда ES.

    Однако реальный режим позволяет нарушать эти правила с помощью префиксов. Если ваши данные лежат в сегменте кода (что часто бывает с константами), вы можете написать:

    Это добавляет один байт к машинному коду инструкции, заставляя блок BIU (Bus Interface Unit) использовать CS вместо DS при формировании физического адреса. Это мощный механизм, позволяющий обходить ограничения стандартной привязки сегментов.

    Производительность и конвейер в реальном режиме

    Интересной особенностью реального режима в i8086 является работа блока предвыборки команд (Prefetch Queue). Пока исполнительное устройство (EU) занято перемножением чисел в регистрах, блок интерфейса шины (BIU) не простаивает. Он считывает следующие байты кода из памяти по адресу CS:IP в 6-байтовую очередь.

    В реальном режиме это работает очень эффективно, пока не встречаются команды перехода (JMP, CALL, RET, INT). Как только происходит переход, вся предвыбранная очередь сбрасывается, так как IP (и, возможно, CS) меняется, и BIU приходится начинать чтение с нового адреса. Программисты на ассемблере в реальном режиме стараются минимизировать «далекие» переходы (Far Jumps), так как они требуют перезагрузки двух регистров и вызывают более длительную паузу в работе конвейера, чем «ближние» переходы.

    Граничные случаи и «странности» архитектуры

    Реальный режим полон нюансов, которые могут поставить в тупик новичка. Один из них — работа с регистром SP. Существует аппаратная особенность: после инструкции MOV SS, AX прерывания автоматически запрещаются на время выполнения следующей за ней инструкции. Это сделано специально для того, чтобы программист мог безопасно обновить пару SS:SP. Если бы прерывание произошло сразу после смены SS, но до смены SP, процессор попытался бы сохранить данные в стек по «кривому» адресу (старый SP в новом сегменте SS), что привело бы к краху.

    Еще один нюанс — отсутствие защиты от выполнения данных. В реальном режиме вы можете направить CS:IP на сегмент данных, где лежат строки или таблицы, и процессор начнет интерпретировать их как команды. Это свойство использовалось в полиморфных кодах и механизмах самораспаковывающихся архивов, но оно же делает систему крайне хрупкой.

    Итог: почему мы все еще изучаем это?

    Несмотря на то, что современные ОС работают в защищенном или «длинном» (64-битном) режиме, понимание реального режима критически важно по трем причинам:

  • Загрузка системы: Любая ОС (Windows, Linux) начинает загрузку в реальном режиме. Загрузчик (GRUB, NTLDR) — это программа, работающая в ограничениях 1 МБ, которая подготавливает таблицы дескрипторов и переводит процессор в защищенный режим.
  • Программирование микроконтроллеров и встроенных систем: Многие промышленные контроллеры на базе x86-совместимых ядер все еще используют реальный режим из-за его предсказуемости и малых накладных расходов.
  • Понимание основ адресации: Сегментная модель реального режима — это простейшая форма управления памятью. Поняв, как формируется адрес здесь, гораздо проще осознать логику работы селекторов, дескрипторов и страниц в более продвинутых режимах.
  • Реальный режим — это «чистое железо». Здесь нет посредников между кодом и электрическими сигналами на шине адреса. Это делает его идеальным полигоном для изучения того, как на самом деле работает компьютер на самом низком уровне.