Основы низкоуровневого программирования: 16-разрядный ассемблер x86 в среде TASM/MASM

Курс ориентирован на глубокое изучение архитектуры x86 в реальном режиме, обеспечивая переход от высокоуровневых концепций C++ к машинному коду. Студенты освоят управление регистрами, сегментацию памяти и системные прерывания для создания эффективных программ.

1. Архитектура x86 и регистровая модель процессора в реальном режиме

Архитектура x86 и регистровая модель процессора в реальном режиме

Когда вы пишете на C++ или Python, переменная x — это абстракция, живущая в оперативной памяти, за управление которой отвечает компилятор или интерпретатор. Однако на уровне процессора Intel 8086, с которого началась история архитектуры x86, никаких переменных не существует. Есть лишь ограниченный набор сверхбыстрых ячеек памяти внутри самого кристалла — регистров. Процессор не умеет складывать два числа, находящихся в оперативной памяти напрямую: он должен сначала «втянуть» их в свои регистры, выполнить операцию и отправить результат обратно. Понимание регистровой модели — это не просто заучивание имен ячеек, это понимание того, как процессор «думает» и как он видит данные.

В реальном режиме (Real Mode), в котором работает 16-разрядный ассемблер, процессор ведет себя как прямой наследник чипа 1978 года. Здесь нет защиты памяти, нет разделения прав доступа и всего 1 мегабайт адресуемого пространства. Но именно в этой «песочнице» лучше всего видна механика взаимодействия железа и кода.

Анатомия вычислительного узла: Зачем нужны регистры

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

В 16-разрядной архитектуре x86 основные регистры имеют размер 16 бит. Это означает, что в один регистр можно записать число от до , что в десятичной системе составляет , или в шестнадцатеричной — 0xFFFF. Если мы работаем со знаковыми числами, диапазон смещается: от до .

Процессор 8086 содержит 14 основных регистров, которые принято делить на группы по их функциональному назначению:

  • Регистры общего назначения (General Purpose Registers — GPR).
  • Индексные регистры и регистры указателей.
  • Сегментные регистры.
  • Регистр флагов и указатель команд.
  • Каждый из них имеет свою «специализацию», заложенную в саму логику команд процессора. Хотя многие из них взаимозаменяемы в простых операциях, использование «не того» регистра часто приводит к увеличению размера машинного кода или невозможности использовать специфические инструкции.

    Регистры общего назначения: Квартет AX, BX, CX, DX

    Эти четыре регистра — основные рабочие лошадки программиста. Их уникальная особенность в том, что к их младшей и старшей половинам можно обращаться как к самостоятельным 8-разрядным регистрам.

    Например, 16-битный регистр AX состоит из:

  • AH (High) — старшие 8 бит (биты 8–15);
  • AL (Low) — младшие 8 бит (биты 0–7).
  • Это разделение было введено для совместимости с кодом для 8-битных процессоров (таких как Intel 8080) и для удобства обработки байтовых данных, например, символов ASCII. Если вы запишете в AL значение 0x55, а в AH0xAA, то в регистре AX окажется число 0xAA55.

    AX (Accumulator) — Аккумулятор

    Это главный регистр для арифметических и логических операций. Хотя большинство команд позволяют использовать другие регистры, AX оптимизирован для скорости.
  • Особенности: Инструкции, работающие с AX, часто занимают на один байт меньше, чем аналогичные с другими регистрами. Он незаменим в операциях умножения (MUL) и деления (DIV). Например, при умножении 16-битных чисел результат всегда помещается в пару DX:AX.
  • Пример: При вызове функций DOS через прерывание INT 21h, номер функции почти всегда передается в регистре AH.
  • BX (Base) — Базовый регистр

    В реальном режиме это единственный из регистров общего назначения, который может использоваться для адресации памяти (в качестве базового указателя).
  • Особенности: Если вы хотите прочитать данные из памяти по адресу, хранящемуся в регистре, вы можете написать MOV AL, [BX]. Сделать то же самое с AX или CX напрямую нельзя — процессор просто не поймет такую команду. Это делает BX мостом между вычислениями и структурами данных в памяти.
  • CX (Count) — Регистр-счетчик

    Его имя говорит само за себя. Он используется как неявный счетчик в циклах и операциях со строками.
  • Особенности: Команда LOOP автоматически уменьшает CX на единицу и проверяет, не стал ли он равен нулю. Если не стал — происходит переход. Также CX (точнее, его младшая часть CL) используется в операциях сдвига (например, SHL AX, CL), указывая, на сколько бит нужно сдвинуть значение.
  • DX (Data) — Регистр данных

    Используется в связке с AX для расширения разрядности.
  • Особенности: При делении 32-битного числа на 16-битное, старшая часть делимого должна находиться в DX, а младшая — в AX. Также DX используется для хранения адреса порта ввода-вывода при работе с внешними устройствами через команды IN и OUT.
  • Индексные регистры и указатели: SI, DI, BP, SP

    В отличие от универсального квартета, эти регистры чаще используются для работы с адресами, а не с данными. Они не делятся на байтовые половины (вы не можете обратиться к «SL» или «DH» в контексте индекса).

    SI (Source Index) и DI (Destination Index)

    Эти регистры предназначены для обработки массивов и строк.
  • SI (Индекс источника): Обычно хранит смещение (offset) данных, которые мы читаем.
  • DI (Индекс приемника): Хранит смещение места, куда мы записываем данные.
  • Нюанс: Существуют специальные «строковые» команды, такие как MOVSB (Move String Byte), которые за один такт копируют байт из адреса DS:SI в адрес ES:DI и автоматически сдвигают оба указателя. Это основа высокопроизводительной обработки данных в x86.
  • BP (Base Pointer) — Указатель базы кадра

    Этот регистр критически важен для связи ассемблера с языками высокого уровня (C/C++).
  • Зачем он нужен: Когда вызывается функция, в стеке создается «кадр» (frame) с локальными переменными и параметрами. BP используется для фиксации точки внутри этого кадра. Обращение [BP+4] или [BP-2] позволяет программисту достучаться до аргументов функции или её локальных данных, не теряя связи с вершиной стека.
  • SP (Stack Pointer) — Указатель стека

    Это самый динамичный регистр. Он всегда указывает на текущую вершину стека в памяти.
  • Механика: Когда вы выполняете PUSH AX, значение SP уменьшается на 2, и данные записываются по новому адресу. При POP — данные считываются, и SP увеличивается.
  • > Важно: Стек в x86 растет «вниз» — от больших адресов к меньшим. Это частая причина ошибок у новичков, привыкших к росту структур данных «вверх».

    Сегментные регистры: CS, DS, SS, ES

    Реальный режим процессора использует сегментированную модель памяти. Поскольку регистры 16-битные, они могут адресовать только Килобайта. Чтобы обойти это ограничение и адресовать 1 Мегабайт, инженеры Intel ввели понятие сегмента.

    Полный адрес состоит из двух частей: Сегмент : Смещение.

  • CS (Code Segment): Указывает на начало сегмента, где лежит исполняемый код. Процессор берет команды именно отсюда. Изменить CS напрямую командой MOV нельзя — это делается только командами перехода (JMP, CALL).
  • DS (Data Segment): По умолчанию указывает на сегмент с данными программы. Большинство операций обращения к памяти используют DS как базу.
  • SS (Stack Segment): Определяет сегмент, в котором выделено место под стек. Работает в паре с SP или BP.
  • ES (Extra Segment): Дополнительный сегмент данных. Часто используется в строковых операциях как приемник данных (в паре с DI).
  • В более поздних процессорах (начиная с 80386) появились также регистры FS и GS, но в классическом 16-разрядном программировании под DOS они используются редко.

    Регистр флагов (FLAGS) и управление логикой

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

    Основные флаги, которые вы будете проверять постоянно:

    * ZF (Zero Flag): Устанавливается в 1, если результат операции равен нулю. Это основа всех условий if (x == 0). * CF (Carry Flag): Флаг переноса. Устанавливается, если при сложении произошел выход за границы разрядности (перенос из старшего бита) или при вычитании потребовался заем. Используется для реализации длинной арифметики (например, сложение 64-битных чисел на 16-битном процессоре). * SF (Sign Flag): Флаг знака. Дублирует старший бит результата. Если , результат отрицательный. * OF (Overflow Flag): Флаг переполнения. Показывает, что результат арифметической операции со знаковыми числами не поместился в разрядную сетку. Не путайте его с CF! CF — для беззнаковых, OF — для знаковых вычислений. * DF (Direction Flag): Флаг направления. Определяет, в какую сторону будут двигаться SI и DI при строковых операциях (автоинкремент или автодекремент).

    Пример работы флагов

    Допустим, в регистре AL число 0xFF (). Мы выполняем команду ADD AL, 1. Результат в AL станет равен 0x00, так как 8-битный регистр переполнился. При этом:
  • ZF станет 1 (результат нулевой).
  • CF станет 1 (произошел перенос из 8-го бита).
  • SF станет 0 (старший бит результата — ноль).
  • На этих флагах строятся команды условных переходов: JZ (Jump if Zero), JNC (Jump if No Carry) и другие.

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

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

    Программист не может записать значение в IP напрямую через MOV. IP меняется автоматически по мере чтения команд или принудительно при выполнении переходов, вызовов процедур и возвратов из них. Если вы слышите фразу «передать управление по адресу X», это означает «загрузить в CS:IP значения, соответствующие адресу X».

    Взаимодействие регистров: Пример из практики

    Рассмотрим, как высокоуровневая концепция копирования массива превращается в работу с регистрами. Представьте код на C:

    На ассемблере x86 (TASM/MASM) в реальном режиме это превращается в элегантную последовательность:

  • В DS загружаем сегмент источника, в SI — смещение начала массива src.
  • В ES загружаем сегмент приемника, в DI — смещение начала массива dest.
  • В CX записываем число итераций — 10.
  • Устанавливаем флаг направления DF в 0 (команда CLD), чтобы двигаться вперед.
  • Выполняем префикс REP с командой MOVSW.
  • Процессор берет значение из DS:SI, копирует в ES:DI, увеличивает SI и DI на 2 (так как MOVSW работает с 16-битными словами), уменьшает CX и повторяет это, пока CX не станет равен нулю. Здесь задействована почти вся регистровая модель: сегментные регистры для адресации, индексные для навигации, счетчик для цикла и флаг для управления логикой.

    Особенности использования регистров в TASM/MASM

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

  • Регистры BP, SI, DI и сегментные регистры должны быть сохранены и восстановлены в исходное состояние перед выходом из функции. Обычно это делается через стек (PUSH в начале, POP в конце).
  • Регистры AX, BX, CX, DX считаются «грязными» — их можно использовать свободно, так как вызывающая сторона ожидает, что их значения могут измениться (например, AX часто несет в себе возвращаемое значение функции).
  • Граничные случаи и "подводные камни"

    Одним из самых запутанных моментов для начинающих является использование регистра BP. По умолчанию, любая адресация через BX, SI или DI использует сегмент данных DS. Однако, как только в квадратных скобках появляется BP (например, MOV AX, [BP+2]), процессор автоматически начинает использовать сегмент стека SS.

    Это аппаратная оптимизация: предполагается, что раз вы используете BP, значит, вы работаете с локальными переменными в стеке. Если вам нужно через BP обратиться к данным в DS, придется использовать переопределение сегмента: MOV AX, DS:[BP+2].

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

    Эволюционный контекст

    Хотя мы изучаем 16-разрядную модель, важно понимать, что современные процессоры Intel Core i9 или AMD Ryzen в момент включения все еще стартуют в режиме, совместимом с 8086. Регистры EAX, RAX — это всего лишь расширения того самого AX, о котором мы говорили.

  • AX (16 бит) — база.
  • EAX (32 бита, Extended AX) — добавился в 80386.
  • RAX (64 бита, Register AX) — добавился в x86-64.
  • Изучая 16-битную модель, вы изучаете «ядро» архитектуры. Все принципы работы флагов, сегментации (пусть и в измененном виде) и специализации регистров остаются актуальными и при разработке драйверов или ядер операционных систем сегодня.

    Понимание того, что CX — это счетчик, а SI/DI — это индексы, позволяет писать код, который «нравится» процессору. Такой код не просто работает — он работает максимально эффективно, используя заложенные в кремний микропрограммы ускорения специфических операций.

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

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

    Почему процессор Intel 8086, будучи 16-разрядным устройством, способен адресовать ровно 1 048 576 байт (1 МБ) оперативной памяти, в то время как стандартный 16-битный регистр ограничен числом 65 535? Этот парадокс разрешается через механизм сегментации — архитектурное решение, которое не только расширило адресное пространство, но и заложило основы модульного программирования и разделения данных и кода, сохранив при этом компактность машинных инструкций.

    Проблема двадцати бит и архитектурный компромисс

    В конце 1970-х годов инженеры Intel столкнулись с жестким ограничением. 16-битная архитектура позволяла использовать указатели длиной в 2 байта. Максимальное значение, которое можно поместить в такой указатель, составляет , что дает доступ лишь к 64 КБ памяти. Для серьезных вычислений того времени этого было уже недостаточно. Конкуренты и требования рынка диктовали необходимость перехода к мегабайтному барьеру.

    Для адресации 1 МБ памяти требуется 20-битная шина адреса (). Однако делать все регистры 20-битными означало бы полностью переработать архитектуру, увеличить размер инструкций и потерять совместимость с логикой накопленных наработок. Решением стала сегментация: использование двух 16-битных значений для формирования одного 20-битного физического адреса.

    Этот подход позволил сохранить 16-битную арифметику внутри процессора, но при этом «дотянуться» до любой ячейки в пределах первого мегабайта. Физический адрес в реальном режиме x86 никогда не хранится в одном регистре целиком — он всегда вычисляется «на лету» блоком управления памятью (BIU — Bus Interface Unit).

    Математика сегментной адресации

    Механизм формирования адреса базируется на сложении двух компонентов: сегмента (базового адреса) и смещения (дистанции от начала базы). В литературе и коде ассемблера это записывается через двоеточие — Segment:Offset.

    Алгоритм вычисления физического адреса

    Чтобы получить итоговый 20-битный адрес, процессор выполняет следующие действия:

  • Берет значение из сегментного регистра (например, DS).
  • Сдвигает его влево на 4 бита (что эквивалентно умножению на 16 в десятичной системе или на в шестнадцатеричной).
  • Прибавляет к полученному результату значение смещения (например, из регистра SI или прямой константы).
  • Формула выглядит так:

    Рассмотрим конкретный пример. Пусть в сегментном регистре DS находится значение , а в регистре смещения BX — .

  • Сдвигаем сегмент: .
  • Добавляем смещение: .
  • Число — это и есть реальный электрический сигнал, который пойдет по шине адреса к микросхемам оперативной памяти.

    Особенности умножения на 16

    Сдвиг на 4 бита означает, что каждый сегмент обязан начинаться с адреса, кратного 16. Такие границы называются параграфами. В памяти может быть 65536 возможных начальных точек для сегментов, расположенных через каждые 16 байт. Это ограничение кажется незначительным, но оно критично при выравнивании данных в памяти через директивы ассемблера ALIGN или ORG.

    Перекрытие сегментов и логическая неоднозначность

    Уникальная особенность реального режима x86 заключается в том, что один и тот же физический адрес может быть представлен множеством различных комбинаций Segment:Offset. Это следствие того, что сегменты имеют длину 64 КБ, а начинаться могут каждые 16 байт.

    Представим физический адрес . Его можно представить как:

  • 0010:0000 ()
  • 0000:0100 ()
  • 000F:0010 ()
  • Всего существует 4096 способов (комбинаций) обратиться к одной и той же ячейке памяти (за исключением адресов в самом начале и самом конце мегабайта).

    Для программиста на C++ это выглядит дико: два разных указателя могут указывать на одну ячейку памяти, даже если их численные значения не равны. В системном программировании на ассемблере это часто используется для «нормализации» указателей — приведения их к виду, где смещение находится в диапазоне , чтобы упростить сравнение адресов.

    Роль сегментных регистров в коде

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

    CS (Code Segment) — Сегмент кода

    Этот регистр всегда указывает на начало сегмента, содержащего исполняемые инструкции. Процессор автоматически использует пару CS:IP (Instruction Pointer) для выборки следующей команды. > Важное правило: Программист не может изменить CS прямой командой MOV CS, AX. Это запрещено на уровне архитектуры. Изменение CS происходит только косвенно — через команды дальних переходов (JMP FAR), вызовов (CALL FAR) или прерываний (INT).

    DS (Data Segment) — Сегмент данных

    По умолчанию большинство инструкций, работающих с памятью (например, MOV AX, [1234h]), предполагают, что данные находятся в сегменте, на который указывает DS. При инициализации программы на ассемблере (особенно в формате .EXE) одной из первых задач программиста является настройка DS на начало своего сегмента данных.

    SS (Stack Segment) — Сегмент стека

    Указывает на область памяти, выделенную под стек. Все операции PUSH, POP, а также автоматическое сохранение адресов возврата при CALL используют пару SS:SP. Крайне важно, чтобы стек находился в отдельной области памяти, иначе он может «наползти» на данные или код, безвозвратно их разрушив.

    ES (Extra Segment) — Дополнительный сегмент

    Используется как вспомогательный регистр для данных. Он незаменим в строковых операциях (например, MOVSB), где требуется одновременный доступ к двум разным сегментам: источнику (DS:SI) и приемнику (ES:DI).

    Механизм замены сегмента (Segment Override)

    Хотя процессор имеет предпочтения по умолчанию (например, BP всегда работает с SS, а BX — с DS), ассемблер позволяет принудительно указать, какой сегмент использовать. Это делается с помощью префикса замены сегмента.

    Пример:

    В машинном коде это добавляет один байт перед инструкцией. Однако стоит помнить, что не все комбинации допустимы. Например, нельзя заставить IP брать инструкции из SS простым префиксом. Замена сегмента — мощный инструмент для работы с массивами в разных областях памяти без постоянной перезагрузки регистра DS.

    Модели памяти в TASM/MASM

    Директивы моделей памяти (.MODEL) — это высокоуровневая надстройка над сегментной архитектурой, которая автоматизирует настройку регистров. Выбор модели определяет, как сегменты будут располагаться относительно друг друга.

    | Модель | Описание | | :--- | :--- | | TINY | Код, данные и стек находятся в одном сегменте (64 КБ на всё). Используется для .COM файлов. CS=DS=SS. | | SMALL | Один сегмент кода (64 КБ) и один сегмент данных (64 КБ). Самая частая модель для учебных задач. | | MEDIUM | Код может занимать много сегментов, но данные — только один. | | COMPACT | Код в одном сегменте, данные могут занимать несколько сегментов. | | LARGE | И код, и данные могут занимать по несколько сегментов. |

    Выбор модели SMALL генерирует инструкции ближних переходов (внутри 64 КБ), что делает код быстрее и компактнее. Модели LARGE и HUGE вынуждают использовать 32-битные указатели (сегмент + смещение) для каждого обращения, что существенно замедляет выполнение из-за постоянных вычислений адреса.

    Граничные случаи: Барьер 640 КБ и High Memory Area

    Хотя формула теоретически дает доступ к 1 МБ, реальная архитектура IBM PC накладывает свои ограничения.

  • 0 - 640 КБ (Conventional Memory): Область для программ пользователя и DOS.
  • 640 КБ - 1 МБ (Upper Memory Area): Зарезервировано для BIOS, видеопамяти (например, видеобуфер VGA начинается с адреса A000:0000) и ПЗУ расширений.
  • Парадокс адреса FFFF:FFFF

    Если мы возьмем максимальный сегмент FFFFh и максимальное смещение FFFFh, расчет даст:

    Это значение чуть больше 1 МБ (). В оригинальном процессоре 8086, где было всего 20 адресных линий, происходило «заворачивание» (wrap-around): адрес превращался в . Однако на более поздних процессорах (начиная с 80286) появилась 21-я адресная линия (A20). Если она включена, процессор может обращаться к этим дополнительным 64 КБ (минус 16 байт) памяти даже в реальном режиме. Эта область получила название HMA (High Memory Area).

    Практика: Описание сегментов в коде

    В ассемблерах TASM/MASM существует два подхода к описанию сегментации: упрощенные директивы и полные описания.

    Упрощенные директивы

    Это современный стандарт для большинства задач:

    Здесь @DATA — это магическая константа, которую линковщик заменит на реальный номер сегмента при сборке.

    Полное описание (Full Segment Definitions)

    Используется в системном программировании, когда нужно точно контролировать порядок сегментов или их атрибуты (например, при написании драйверов или TSR-программ):

    Здесь PARA означает выравнивание на границу параграфа (16 байт), а PUBLIC сообщает линковщику, что этот сегмент можно объединять с одноименными сегментами из других модулей. Директива ASSUME не генерирует код, она лишь «подсказывает» ассемблеру, какие значения программист планирует держать в сегментных регистрах, чтобы ассемблер мог правильно вычислять смещения меток.

    Стек и сегментация: Опасности SS:SP

    Стек в x86 растет «вниз» — от больших адресов к меньшим. При выполнении команды PUSH AX:

  • Значение SP уменьшается на 2.
  • Регистр AX записывается по адресу SS:SP.
  • Если вы установите SS:SP слишком близко к началу сегмента данных, стек при переполнении начнет затирать ваши переменные. В реальном режиме нет аппаратной защиты памяти (Memory Protection), поэтому процессор не выдаст ошибку Stack Overflow. Программа просто начнет вести себя непредсказуемо, так как значения переменных будут внезапно меняться «сами собой».

    Хорошей практикой считается выделение под стек отдельного сегмента размером не менее 256–1024 байт для простых программ и значительно больше, если используются рекурсивные алгоритмы.

    Сравнение с плоской моделью (Flat Model)

    Для программиста, привыкшего к C++ в Windows или Linux (32/64 бита), сегментация кажется избыточным усложнением. В современных ОС используется «плоская» модель памяти, где все сегментные регистры указывают на одну и ту же область памяти, охватывающую всё доступное пространство.

    Однако понимание сегментации в 16-битном режиме дает ключ к осознанию того, как работают:

  • Селекторы и дескрипторы в защищенном режиме (где сегментный регистр — это уже не адрес, а индекс в таблице).
  • Базово-индексная адресация, которая повсеместно применяется в оптимизации циклов.
  • Организация памяти на уровне ядра ОС, где разделение на сегменты кода и данных до сих пор существует на уровне прав доступа (NX-бит, Read/Only секции).
  • Изучение вычисления физического адреса — это первый шаг к пониманию того, что «переменная» в памяти — это не абстрактное имя, а жесткая связка из базового вектора и дистанции до него.