В данной части курса рассматриваются основы программирования на языке ассемблера для архитектуры Win32.
Все процессы в машине на самом низком, аппаратном уровне приводятся в действие только командами (инструкциями) машинного языка. Язык ассемблера – это символическое представление машинного языка. Ассемблер позволяет писать короткие и быстрые программы. Однако этот процесс чрезвычайно трудоёмкий. Для написания максимально эффективной программы необходимо хорошее знание особенностей команд языка ассемблера, внимание и аккуратность. Поэтому реально на языке ассемблера пишутся в основном программы, которые должны обеспечить эффективную работу с аппаратной частью. Также на языке ассемблера пишутся критичные по времени выполнения или расходованию памяти участки программы. Впоследствии они оформляются в виде подпрограмм и совмещаются с кодом на языке высокого уровня.
1. Регистры
Регистры – это специальные ячейки памяти, расположенные непосредственно в процессоре. Работа с регистрами выполняется намного быстрее, чем с ячейками оперативной памяти, поэтому регистры активно используются как в программах на языке ассемблера, так и компиляторами языков высокого уровня.
Регистры можно разделить на регистры общего назначения, указатель команд, регистр флагов и сегментные регистры.
1.1. Регистры общего назначения
К регистрам общего назначения относится группа из 8 регистров, которые можно использовать в программе на языке ассемблера. Все регистры имеют размер 32 бита и могут быть разделены на 2 или более частей.
Как видно из рисунка, регистры ESI, EDI, ESP и EBP позволяют обращаться к младшим 16 битам по именам SI, DI, SP и BP соответственно, а регистры EAX, EBX, ECX и EDX позволяют обращаться как к младшим 16 битам (по именам AX, BX, CX и DX), так и к двум младшим байтам по отдельности (по именам AH/AL, BH/BL, CH/CL и DH/DL).
EBP/BP (base pointer register) – регистр указателя базы кадра стека.
Несмотря на существующую специализацию, все регистры можно использовать в любых машинных операциях. Однако надо учитывать тот факт, что некоторые команды работают только с определёнными регистрами. Например, команды умножения и деления используют регистры EAX и EDX для хранения исходных данных и результата операции. Команды управления циклом используют регистр ECX в качестве счётчика цикла.
Ещё один нюанс состоит в использовании регистров в качестве базы, т.е. хранилища адреса оперативной памяти. В качестве регистров базы можно использовать любые регистры, но желательно использовать регистры EBX, ESI, EDI или EBP. В этом случае размер машинной команды обычно бывает меньше.
К сожалению, количество регистров катастрофически мало, и зачастую бывает трудно подобрать способ их оптимального использования.
1.2. Указатель команд
Регистр EIP (указатель команд) содержит смещение следующей подлежащей выполнению команды. Этот регистр непосредственно недоступен программисту, но загрузка и изменение его значения производятся различными командами управления, к которым относятся команды условных и безусловных переходов, вызова процедур и возврата из процедур.
1.3. Регистр флагов
Флаг – это бит, принимающий значение 1 («флаг установлен»), если выполнено некоторое условие, и значение 0 («флаг сброшен») в противном случае. Процессор имеет регистр флагов, содержащий набор флагов, отражающий текущее состояние процессора.
№ бита
Обозначение
Название
Описание
Тип флага
FLAGS
0
CF
Carry Flag
Флаг переноса
Состояние
1
1
Зарезервирован
2
PF
Parity Flag
Флаг чётности
Состояние
3
0
Зарезервирован
4
AF
Auxiliary Carry Flag
Вспомогательный флаг переноса
Состояние
5
0
Зарезервирован
6
ZF
Zero Flag
Флаг нуля
Состояние
7
SF
Sign Flag
Флаг знака
Состояние
8
TF
Trap Flag
Флаг трассировки
Системный
9
IF
Interrupt Enable Flag
Флаг разрешения прерываний
Системный
10
DF
Direction Flag
Флаг направления
Управляющий
11
OF
Overflow Flag
Флаг переполнения
Состояние
12
IOPL
I/O Privilege Level
Уровень приоритета ввода-вывода
Системный
13
14
NT
Nested Task
Флаг вложенности задач
Системный
15
0
Зарезервирован
EFLAGS
16
RF
Resume Flag
Флаг возобновления
Системный
17
VM
Virtual-8086 Mode
Режим виртуального процессора 8086
Системный
18
AC
Alignment Check
Проверка выравнивания
Системный
19
VIF
Virtual Interrupt Flag
Виртуальный флаг разрешения прерывания
Системный
20
VIP
Virtual Interrupt Pending
Ожидающее виртуальное прерывание
Системный
21
ID
ID Flag
Проверка на доступность инструкции CPUID
Системный
22
Зарезервированы
...
31
Значение флагов CF, DF и IF можно изменять напрямую в регистре флагов с помощью специальных инструкций (например, CLD для сброса флага направления), но нет инструкций, которые позволяют обратиться к регистру флагов как к обычному регистру. Однако можно сохранять регистр флагов в стек или регистр AH и восстанавливать регистр флагов из них с помощью инструкций LAHF, SAHF, PUSHF, PUSHFD, POPF и POPFD.
1.3.1. Флаги состояния
Флаги состояния (биты 0, 2, 4, 6, 7 и 11) отражают результат выполнения арифметических инструкций, таких как ADD, SUB, MUL, DIV.
Флаг переноса CF устанавливается при переносе из старшего значащего бита/заёме в старший значащий бит и показывает наличие переполнения в беззнаковой целочисленной арифметике. Также используется в длинной арифметике.
Флаг чётности PF устанавливается, если младший значащий байт результата содержит чётное число единичных битов. Изначально этот флаг был ориентирован на использование в коммуникационных программах: при передаче данных по линиям связи для контроля мог также передаваться бит чётности и инструкции для проверки флага чётности облегчали проверку целостности данных.
Вспомогательный флаг переноса AF устанавливается при переносе из бита 3-го результата/заёме в 3-ий бит результата. Этот флаг ориентирован на использование в двоично-десятичной (binary coded decimal, BCD) арифметике.
Флаг нуля ZF устанавливается, если результат равен нулю.
Флаг знака SF равен значению старшего значащего бита результата, который является знаковым битом в знаковой арифметике.
Флаг переполнения OF устанавливается, если целочисленный результат слишком длинный для размещения в целевом операнде (регистре или ячейке памяти). Этот флаг показывает наличие переполнения в знаковой целочисленной арифметике.
Из перечисленных флагов только флаг CF можно изменять напрямую с помощью инструкций STC, CLC и CMC.
Флаги состояния позволяют одной и той же арифметической инструкции выдавать результат трёх различных типов: беззнаковое, знаковое и двоично-десятичное (BCD) целое число. Если результат считать беззнаковым числом, то флаг CF показывает условие переполнения (перенос или заём), для знакового результата перенос или заём показывает флаг OF, а для BCD-результата перенос/заём показывает флаг AF. Флаг SF отражает знак знакового результата, флаг ZF отражает и беззнаковый, и знаковый нулевой результат.
В длинной целочисленной арифметике флаг CF используется совместно с инструкциями сложения с переносом (ADC) и вычитания с заёмом (SBB) для распространения переноса или заёма из одного вычисляемого разряда длинного числа в другой.
Инструкции условного перехода Jcc (переход по условию cc), SETcc (установить значение байта-результата в зависимости от условия cc), LOOPcc (организация цикла) и CMOVcc (условное копирование) используют один или несколько флагов состояния для проверки условия. Например, инструкция перехода JLE (jump if less or equal – переход, если «меньше или равно») проверяет условие «ZF = 1 или SF ≠ OF».
Флаг PF был введён для совместимости с другими микропроцессорными архитектурами и по прямому назначению используется редко. Более распространено его использование совместно с остальными флагами состояния в арифметике с плавающей запятой: инструкции сравнения (FCOM, FCOMP и т. п.) в математическом сопроцессоре устанавливают в нём флаги-условия C0, C1, C2 и C3, и эти флаги можно скопировать в регистр флагов. Для этого рекомендуется использовать инструкцию FSTSW AX для сохранения слова состояния сопроцессора в регистре AX и инструкцию SAHF для последующего копирования содержимого регистра AH в младшие 8 битов регистра флагов, при этом C0 попадает во флаг CF, C2 – в PF, а C3 – в ZF. Флаг C2 устанавливается, например, в случае несравнимых аргументов (NaN или неподдерживаемый формат) в инструкции сравнения FUCOM.
1.3.2. Управляющий флаг
Флаг направления DF (бит 10 в регистре флагов) управляет строковыми инструкциями (MOVS, CMPS, SCAS, LODS и STOS) – установка флага заставляет уменьшать адреса (обрабатывать строки от старших адресов к младшим), обнуление заставляет увеличивать адреса. Инструкции STD и CLD соответственно устанавливают и сбрасывают флаг DF.
1.3.3. Системные флаги и поле IOPL
Системные флаги и поле IOPL управляют операционной средой и не предназначены для использования в прикладных программах.
Флаг разрешения прерываний IF – обнуление этого флага запрещает отвечать на маскируемые запросы на прерывание.
Флаг трассировки TF – установка этого флага разрешает пошаговый режим отладки, когда после каждой выполненной инструкции происходит прерывание программы и вызов специального обработчика прерывания.
Поле IOPL показывает уровень приоритета ввода-вывода исполняемой программы или задачи: чтобы программа или задача могла выполнять инструкции ввода-вывода или менять флаг IF, её текущий уровень приоритета (CPL) должен быть ≤ IOPL.
Флаг вложенности задач NT – этот флаг устанавливается, когда текущая задача «вложена» в другую, прерванную задачу, и сегмент состояния TSS текущей задачи обеспечивает обратную связь с TSS предыдущей задачи. Флаг NT проверяется инструкцией IRET для определения типа возврата – межзадачного или внутризадачного.
Флаг возобновления RF используется для маскирования ошибок отладки.
VM – установка этого флага в защищённом режиме вызывает переключение в режим виртуального 8086.
Флаг проверки выравнивания AC – установка этого флага вместе с битом AM в регистре CR0 включает контроль выравнивания операндов при обращениях к памяти: обращение к невыравненному операнду вызывает исключительную ситуацию.
VIF – виртуальная копия флага IF; используется совместно с флагом VIP.
VIP – устанавливается для указания наличия отложенного прерывания.
ID – возможность программно изменить этот флаг в регистре флагов указывает на поддержку инструкции CPUID.
1.4. Сегментные регистры
Процессор имеет 6 так называемых сегментных регистров: CS, DS, SS, ES, FS и GS. Их существование обусловлено спецификой организации и использования оперативной памяти.
16-битные регистры могли адресовать только 64 Кб оперативной памяти, что явно недостаточно для более или менее приличной программы. Поэтому память программе выделялась в виде нескольких сегментов, которые имели размер 64 Кб. При этом абсолютные адреса были 20-битными, что позволяло адресовать уже 1 Мб оперативной памяти. Возникает вопрос – как имея 16-битные регистры хранить 20-битные адреса? Для решения этой задачи адрес разбивался на базу и смещение. База – это адрес начала сегмента, а смещение – это номер байта внутри сегмента. На адрес начала сегмента накладывалось ограничение – он должен был быть кратен 16. При этом последние 4 бита были равны 0 и не хранились, а подразумевались. Таким образом, получались две 16-битные части адреса. Для получения абсолютного адреса к базе добавлялись четыре нулевых бита, и полученное значение складывалось со смещением.
Сегментные регистры использовались для хранения адреса начала сегмента кода (CS – code segment), сегмента данных (DS – data segment) и сегмента стека (SS – stack segment). Регистры ES, FS и GS были добавлены позже. Существовало несколько моделей памяти, каждая из которых подразумевала выделение программе одного или нескольких сегментов кода и одного или нескольких сегментов данных: tiny, small, medium, compact, large и huge. Для команд языка ассемблера существовали определённые соглашения: адреса перехода сегментировались по регистру CS, обращения к данным сегментировались по регистру DS, а обращения к стеку – по регистру SS. Если программе выделялось несколько сегментов для кода или данных, то приходилось менять значения в регистрах CS и DS для обращения к другому сегменту. Существовали так называемые «ближние» и «дальние» переходы. Если команда, на которую надо совершить переход, находилась в том же сегменте, то для перехода достаточно было изменить только значение регистра IP. Такой переход назывался ближним. Если же команда, на которую надо совершить переход, находилась в другом сегменте, то для перехода необходимо было изменить как значение регистра CS, так и значение регистра IP. Такой переход назывался дальним и осуществлялся дольше.
32-битные регистры позволяют адресовать 4 Гб памяти, что уже достаточно для любой программы. Каждую Win32-программу Windows запускает в отдельном виртуальном пространстве. Это означает, что каждая Win32-программа будет иметь 4-х гигабайтовое адресное пространство, но вовсе не означает, что каждая программа имеет 4 Гб физической памяти, а только то, что программа может обращаться по любому адресу в этих пределах. А Windows сделает все необходимое, чтобы память, к которой программа обращается, «существовала». Конечно, программа должна придерживаться правил, установленных Windows, иначе возникает ошибка General Protection Fault.
Под архитектурой Win32 отпала необходимость в разделении адреса на базу и смещение, и необходимость в моделях памяти. На 32-битной архитектуре существует только одна модель памяти – flat (сплошная или плоская). Сегментные регистры остались, но используются по-другому1. Раньше необходимо было связывать отдельные части программы с тем или иным сегментным регистром и сохранять/восстанавливать регистр DS при переходе к другому сегменту данных или явно сегментировать данные по другому регистру. При 32-битной архитектуре необходимость в этом отпала, и в простейшем случае про сегментные регистры можно забыть.
1.5. Использование стека
Каждая программа имеет область памяти, называемую стеком. Стек используется для передачи параметров в процедуры и для хранения локальных данных процедур. Как известно, стек – это область памяти, при работе с которой необходимо соблюдать определённые правила, а именно: данные, которые попали в стек первыми, извлекаются оттуда последними. С другой стороны, если программе выделена некоторая память, то нет никаких физических ограничений на чтение и запись. Как же совмещаются два этих противоречивых принципа?
Пусть у нас есть функция f1, которая вызывает функцию f2, а функция f2, в свою очередь, вызывает функцию f3. При вызове функции f1 ей отводится определённое место в стеке под локальные данные. Это место отводится путём вычитания из регистра ESP значения, равного размеру требуемой памяти. Минимальный размер отводимой памяти равен 4 байтам, т.е. даже если процедуре требуется 1 байт, она должна занять 4 байта.
Функция f1 выполняет некоторые действия, после чего вызывает функцию f2. Функция f2 также отводит себе место в стеке, вычитая некоторое значение из регистра ESP. При этом локальные данные функций f1 и f2 размещаются в разных областях памяти. Далее функция f2 вызывает функцию f3, которая также отводит себе место в стеке. Функция f3 других функций не вызывает и при завершении работы должна освободить место в стеке, прибавив к регистру ESP значение, которые было вычтено при вызове функции. Если функция f3 не восстановит значение регистра ESP, то функция f2, продолжив работу, будет обращаться не к своим данным, т.к. она ищет их, основываясь на значении регистра ESP. Аналогично функция f2 должна при выходе восстановить значение регистра ESP, которое было до её вызова.
Таким образом, на уровне процедур необходимо соблюдать правила работы со стеком – процедура, которая заняла место в стеке последней, должна освобождать его первой. При несоблюдении этого правила, программа будет работать некорректно. Но каждая процедура может обращаться к своей области стека произвольным образом. Если бы мы были вынуждены соблюдать правила работы со стеком внутри каждой процедуры, пришлось бы перекладывать данные из стека в другую область памяти, а это было бы крайне неудобно и чрезвычайно замедлило бы выполнение программы.
Каждая программа имеет область данных, где размещаются глобальные переменные. Почему же локальные данные хранятся именно в стеке? Это делается для уменьшения объёма памяти занимаемого программой. Если программа будет последовательно вызывать несколько процедур, то в каждый момент времени будет отведено место только под данные одной процедуры, т.к. стек занимается и освобождается. Область данных существует всё время работы программы. Если бы локальные данные размещались в области данных, пришлось бы отводить место под локальные данные для всех процедур программы.
Локальные данные автоматически не инициализируются. Если в вышеприведённом примере функция f2 после функции f3 вызовет функцию f4, то функция f4 займёт в стеке место, которое до этого было занято функцией f3, таким образом, функции f4 «в наследство» достанутся данные функции f3. Поэтому каждая процедура обязательно должна заботиться об инициализации своих локальных данных.
2. Основные понятия языка ассемблера
2.1. Идентификаторы
Понятие идентификатора в языке ассемблера ничем не отличается от понятия идентификатора в других языках. Можно использовать латинские буквы, цифры и знаки _ . ? @ $, причём точка может быть только первым символом идентификатора. Большие и маленькие буквы считаются эквивалентными.
2.2. Целые числа
В программе на языке ассемблера целые числа могут быть записаны в двоичной, восьмеричной, десятичной и шестнадцатеричной системах счисления. Для задания системы счисления в конце числа ставится буква b, o/q, d или h соответственно. Шестнадцатеричные числа, которые начинаются с «буквенной» цифры, должны предваряться нулём, иначе компилятор не сможет отличить число от идентификатора. Примеры чисел см. в разделе 2.6.
2.3. Символьные данные
Символы и строки в языке ассемблера могут заключаться в апострофы или двойные кавычки. Если в качестве символа или внутри строки надо указать апостроф или кавычку, то делается это следующим образом: если символ или строка заключены в апострофы, то апостроф надо удваивать, а кавычку удваивать не надо, и наоборот, если символ или строка заключены в двойные кавычки, то надо удваивать кавычку и не надо удваивать апостроф. Все следующие примеры корректны и эквивалентны: 'don''t', 'don"t', "don't", "don""t".
2.4. Комментарии
Комментарии в языке ассемблера начинаются с символа «точка с запятой» и могут начинаться как в начале строки, так и после команды.
2.5. Директива эквивалентности
Директива эквивалентности позволяет описывать константы:
<имя> EQU <операнд>
Все вхождения имени заменяются операндом. Операндом может быть константное выражение, строка, другое имя.
2.6. Директивы определения данных
Языки высокого уровня обычно являются типизированными. Каждая переменная имеет тип, который накладывает ограничения на операции над переменной и на использование в одном выражении переменных разных типов. Кроме того, языки высокого уровня позволяют работать со сложными типами, таким как указатели, записи/структуры, классы, массивы, строки, множества и т.п.
Язык Паскаль имеет достаточно жёсткую структуру типов. Присваивания между переменными разных типов минимальны, над указателями определены только операции присваивания, взятия значения и получение адреса. Поддерживается много сложных типов.
Язык С, который создавался как высокоуровневая замена языку ассемблера, имеет гораздо менее жёсткую структуру типов. Все целочисленные типы совместимы, тип char, конечно, хранит символы, но также сопоставим с целыми типами, логический тип отсутствует в принципе (для языка С это именно так!), над указателями определены операции сложения и вычитания. Сложные типы, такие как массивы, строки и множества, не поддерживаются.
Что касается языка ассемблера, то тут вообще вряд ли можно говорить о какой-либо структуре типов. Команды языка ассемблера оперируют объектами, существующими в оперативной памяти, т.е. байтом и его производными (слово, двойное слово и т.д.). Символьный, логический тип? Какая глупость! Указатели? Вот тебе 4 байта и делай с ними, что хочешь. В итоге, конечно, и можно сделать, что хочешь, только предварительно стоит хорошо подумать, что из этого получится.
Соответственно, в языке ассемблера существует 5 (!) директив для определения данных:
DB (define byte) – определяет переменную размером в 1 байт;
DW (define word) – определяет переменную размеров в 2 байта (слово);
DD (define double word) – определяет переменную размером в 4 байта (двойное слово);
DQ (define quad word) – определяет переменную размером в 8 байт (учетверённое слово);
DT (define ten bytes) – определяет переменную размером в 10 байт.
Все директивы могут быть использованы как для объявления простых переменных, так и для объявления массивов. Хотя для определения строк, в принципе, можно использовать любую директиву, в связи с особенностями хранения данных в оперативной памяти лучше использовать директиву DB.
Операнд задаёт начальное значение переменной. В качестве операнда может использоваться число, символ или знак вопроса, с помощью которого определяются неинициализированные переменные.
Если в качестве операнда указывается строка или если указано несколько операндов через запятую, то память отводится под несколько переменных указанного типа, т.е. получается массив. При этом именованным оказывается только первый элемент, а доступ к остальным элементам массива осуществляется с помощью выражения <имя> + <смещение>.
Для того чтобы не указывать несколько раз одно и то же значение, при инициализации массивов можно использовать конструкцию повторения DUP.
a db 10011001b ; Определяем переменную размером 1 байт с начальным значением, заданным в двоичной системе счисления
b db '!' ; Определяем переменную в 1 байт, инициализируемую символом '!'
d db 'string',13,10 ; Определяем массив из 8 байт
e db 'string',0 ; Определяем строку из 7 байт, заканчивающую нулём
f dw 1235o ; Определяем переменную размером 2 байта с начальным значением, заданным в восьмеричной системе счисления
g dd -345d ; Определяем переменную размером 4 байта с начальным значением, заданным в десятичной системе счисления
h dd 0f1ah ; Определяем переменную размером 4 байта с начальным значением, заданным в шестнадцатеричной системе счисления
i dd ? ; Определяем неинициализированную переменную размером 4 байта
j dd 100 dup (0) ; Определяем массив из 100 двойных слов, инициализированных 0
k dq 10 dup (0, 1, 2) ; Определяем массив из 30 учетверённых слов, инициализированный повторяющимися значениями 0, 1 и 2
l dd 100 dup (?) ; Определяем массив из 100 неинициализированных двойных слов
К переменным можно применить две операции – offset и type. Первая определяет адрес переменной, а вторая – размер переменной. Однако размер переменной определяется по директиве, и даже если с директивой, например, DD определён массив из нескольких элементов, размер всё равно будет равен 4.
2.7. Команды
Команды языка ассемблера – это символьная форма записи машинных команд. Команды имеют следующий синтаксис:
Метка – это имя. Метка обязательно должна отделяться двоеточием, но может размещаться отдельно, в строке, предшествующей остальной части команды.
Метки нужны для ссылок на команды из других мест, например, в командах перехода. Компилятор языка ассемблера заменяет метки адресами команд.
Мнемокод – это служебное слово, указывающее операцию, которая должна быть выполнена. Язык ассемблера использует не цифровые коды операций, а мнемокоды, которые легче запоминаются. Мнемокод является обязательной частью команды.
Операнды команды, если они есть, отделяются друг от друга запятыми.
2.8. Операнды команд
В качестве операндов команд языка ассемблера могут использоваться:
регистры, обращение к которым осуществляется по именам;
непосредственные операнды – константы, записываемые непосредственно в команде;
ячейки памяти – в команде записывается адрес нужной ячейки.
Для задания адреса существуют следующие возможности.
Имя переменной, по сути, является адресом этой переменной. Встретив имя переменной в операндах команды, компилятор понимает, что нужно обратиться к оперативной памяти по определённому адресу. Обычно адрес в команде указывается в квадратных скобках, но имя переменной является исключением и может быть указано как в квадратных скобках, так и без них. Например, для обращения к переменной x в команде можно указать x или [x].
Если переменная была объявлена как массив, то к элементу массива можно обратиться, указав имя и смещение. Для этого существует ряд синтаксических форм, например: <имя>[<смещение>] и [<имя> + <смещение>] (см. раздел 5). Однако следует понимать, что смещение – это вовсе не индекс элемента массива. Индекс элемента массива – это его номер, и этот номер не зависит от размера самого элемента. Смещение же задаётся в байтах, и при задании смещения программист сам должен учитывать размер элемента массива.
Адрес ячейки памяти может храниться в регистре. Для обращения к памяти по адресу, хранящемуся в регистре, в команде указывается имя регистра в квадратных скобках, например: [ebx]. Как уже говорилось, в качестве регистров базы рекомендуется использовать регистры EBX, ESI, EDI и EBP.
Адрес может быть вычислен по определённой формуле. Для этого в квадратных скобках можно указывать достаточно сложные выражения, например, [ebx + ecx] или [ebx + 4 * ecx].
В описаниях команд языка ассемблера для обозначения возможных операндов используют сокращения, состоящие из буквы r (для регистров), m (для памяти) или i (для непосредственного операнда) и числа 8, 16 или 32, указывающего размер операнда. Например:
add r8/r16/r32, r8/r16/r32 ; Сложение регистра с регистром
add r8/r16/r32, m8/m16/m32 ; Сложение регистра с ячейкой памяти
add r8/r16/r32, i8/i16/i32 ; Сложение регистра с непосредственным операндом
add m8/m16/m32, r8/r16/r32 ; Сложение ячейки памяти с регистром
add m8/m16/m32, i8/i16/i32 ; Сложение ячейки памяти с непосредственным операндом
Команды языка ассемблера обычно имеют 1 или 2 операнда, или не имеют операндов вообще. Во многих, хотя не во всех, случаях операнды (если их два) должны иметь одинаковый размер. Команды языка ассемблера обычно не работают с двумя ячейками памяти.
3. Пересылка и арифметические команды
3.1. Команды пересылки и обмена
Одна из основных команд языка ассемблер – это команда пересылки. С её помощью можно записать в регистр значение другого регистра, константу или значение ячейки памяти, а также можно записать в ячейку памяти значение регистра или константу. Команда имеет следующий синтаксис:
MOV <операнд1>, <операнд2>
По команде MOV значение второго операнда записывается в первый операнд. Операнды должны иметь одинаковый размер. Команда не меняет флаги.
mov eax, ebx ; Пересылаем значение регистра EBX в регистр EAX
mov eax, 0ffffh ; Записываем в регистр EAX шестнадцатеричное значение ffff
mov x, 0 ; Записываем в переменную x значение 0
mov eax, x ; Переслать значение из одной ячейки памяти в другую нельзя.
mov y, eax ; Но можно использовать две команды MOV.
На самом деле процессор имеет много команд пересылки – код команды зависит от того, куда и откуда пересылаются данные. Но компилятор языка ассемблера сам выбирает нужный код в зависимости от операндов, так что, с точки зрения программиста, команда пересылки только одна.
Для перестановки двух величин используется команда обмена:
XCHG <операнд1>, <операнд2>
Каждый из операндов может быть регистром или ячейкой памяти. Однако переставить содержимое двух регистров можно, а двух ячеек памяти – нет. Операнды должны иметь одинаковый размер. Команда не меняет флаги.
3.2. Оператор указания типа
Как было сказано, операнды команды MOV должны иметь одинаковый размер. В некоторых случаях компилятор может определить размер операнда. Например, регистр EAX имеет размер 32 бита, а регистр DX – 16 бит. Размер переменной определяется по директиве, указанной в её объявлении. Если можно определить размер только одного операнда, то размер второго операнда подгоняется под размер первого, если это возможно. Если же можно определить размеры обоих операндов, то они должны совпадать.
x db ?
mov x, 0 ; 0 может иметь любой размер, в данном случае берётся 1 байт
mov eax, 0 ; 0 может иметь любой размер, в данном случае берётся 4 байта
mov al, 1000h ; Ошибка – попытка записать 2-байтное число в 1-байтный регистр
mov eax, cx ; Ошибка – размеры операндов не совпадают
Однако не всегда бывает возможно определить размер пересылаемой величины по операндам команды MOV. Например, если один из операндов является ячейкой памяти, адрес которой записан в регистре, то по этому адресу можно записать и 1 байт, и 2 байта, и 4 байта. Если второй операнд является регистром, то размер пересылаемых данных определяется по размеру регистра. Если же второй операнд является константой, то размер пересылаемых данных определить нельзя, и компилятор фиксирует ошибку. Для того чтобы избежать этой ошибки, надо явно указать размер пересылаемых данных. Для этого используется оператор PTR:
<тип> PTR <выражение>
В качестве типа используется BYTE, WORD или DWORD.
mov [ebx], 0 ; Ошибка, т.к. 0 может иметь любой размер
mov byte ptr [ebx], 0 ; Пересылаем 1 байт
mov dword ptr [ebx], 0 ; Пересылаем 4 байта
3.3. Команды сложения и вычитания
Команды сложения и вычитания реализуют хорошо всем известные арифметические операции. Единственное, что нужно учитывать при использовании этих команд – особенности сложения и вычитания, связанные с представлением чисел в памяти компьютера.
ADD <операнд1>, <операнд2>
SUB <операнд1>, <операнд2>
Команда ADD складывает операнды и записывает их сумму на место первого операнда. Команда SUB вычитает из первого операнда второй и записывает полученную разность на место первого операнда. Операнды должны иметь одинаковый размер. Если первый операнд – регистр, то второй может быть также регистром, ячейкой памяти и непосредственным операндом. Если первый операнд – ячейка памяти, то второй операнд может быть регистром или непосредственным операндом. Возможно сложение и вычитание как знаковых, так и беззнаковых чисел любого размера. Команды меняют флаги AF, CF, OF, PF, SF и ZF.
a dd 45d
b dd -32d
c dd ?
mov eax, a
add eax, b
mov c, eax ; c = a + b
Команды инкремента и декремента увеличивают и уменьшают на 1 свой операнд.
INC <операнд>
DEC <операнд>
Операндом может быть регистр или ячейка памяти любого размера. Команды меняют флаги AF, OF, PF, SF и ZF. Команды инкремента и декремента выгодны тем, что они занимают меньше места, чем соответствующие команды сложения и вычитания.
inc eax
К арифметическим операциям можно также отнести команду изменения знака:
NEG <операнд>
Операндом может быть регистр или ячейка памяти любого размера. Команда NEG рассматривает свой операнд как число со знаком и меняет знак операнда на противоположный. Команда меняет флаги AF, CF, OF, PF, SF и ZF.
Сложение и вычитание знаковых и беззнаковых чисел производятся по одним и тем же алгоритмам. Поэтому нет отдельных команд сложения и вычитания для знаковых и беззнаковых чисел. А вот умножение и деление знаковых и беззнаковых чисел производятся по разным алгоритмам, поэтому существуют по две команды умножения и деления.
Для беззнакового умножения используется команда MUL:
MUL <операнд>
Операнд, указываемый в команде, – это один из сомножителей. Он может быть регистром или ячейкой памяти, но не может быть непосредственным операндом.
Местонахождение второго сомножителя и результата фиксировано, и в команде явно не указывается. Если операнд команды MUL имеет размер 1 байт, то второй сомножитель берётся из регистра AL, а результат помещается в регистр AX. Если операнд команды MUL имеет размер 2 байта, то второй сомножитель берётся из регистра AX, а результат помещается в регистровую пару DX:AX. Если операнд команды MUL имеет размер 4 байта, то второй сомножитель берётся из регистра EAX, а результат помещается в регистровую пару EDX:EAX.
Команда меняет флаги CF и OF. Если произведение имеет такой же размер, что и сомножители, то оба флага сбрасываются в 0. Если же размер произведения удваивается относительно размера сомножителей, то оба флага устанавливаются в 1.
x dw 256
mov ax, 105
mul x ; AX = AX * x, AX = 26880, CF = OF = 0
mov eax, 500000
mov ebx, 100000
mul ebx ; EDX:EAX = EAX * EBX, EDX:EAX = 50000000000, CF = OF = 1
Для знакового умножения используется команда IMUL:
Команда знакового умножения имеет несколько вариантов. Первый соответствует команде MUL – один из сомножителей указывается в команде, второй должен находиться в регистре EAX/AX/AL, а результат помещается в регистры EDX:EAX/DX:AX/AX.
Второй вариант команды IMUL позволяет указать регистр, который будет содержать один из сомножителей. В этот же регистр будет помещён результат. Второй сомножитель указывается непосредственно в команде.
Третий вариант команды IMUL позволяет указать и результат, и оба сомножителя. Однако результат может быть помещён только в регистр, а второй сомножитель может быть только непосредственным операндом. Первый сомножитель может быть регистром или ячейкой памяти.
Четвёртый вариант команды IMUL позволяет указать оба сомножителя. Первый должен быть регистром, а второй – регистром или ячейкой памяти. Результат помещается в регистр, являющийся первым операндом.
Команда IMUL устанавливает флаги так же, как и команда MUL. Однако расширение результата в регистр EDX/DX происходит только при использовании первого варианта команды IMUL. В остальных случаях часть произведения, не помещающаяся в регистр-результат, теряется, даже если в качестве результата указан регистр EAX/AX. При умножении двух 1-байтовых чисел, произведение которых больше байта, но меньше слова, в регистре-результате получается корректное произведение.
Деление, как и умножение, реализуется двумя командами, предназначенными для знаковых и беззнаковых чисел:
DIV <операнд> ; Беззнаковое деление
IDIV <операнд> ; Знаковое деление
В командах указывается только один операнд – делитель, который может быть регистром или ячейкой памяти, но не может быть непосредственным операндом. Местоположение делимого и результата для команд деления фиксировано.
Если делитель имеет размер 1 байт, то делимое берётся из регистра AX. Если делитель имеет размер 2 байта, то делимое берётся из регистровой пары DX:AX. Если же делитель имеет размер 4 байта, то делимое берётся из регистровой пары EDX:EAX.
Поскольку процессор работает с целыми числами, то в результате деления получается сразу два числа – частное и остаток. Эти два числа также помещаются в определённые регистры. Если делитель имеет размер 1 байт, то частное помещается в регистр AL, а остаток – в регистр AH. Если делитель имеет размер 2 байта, то частное помещается в регистр AX, а остаток – в регистр DX. Если же делитель имеет размер 4 байта, то частное помещается в регистр EAX, а остаток – в регистр EDX.
mov ax, 127
mov bl, 5
div bl ; AL = 19h = 25, AH = 02h = 2
mov ax, 127
mov bl, -5
idiv bl ; AL = e7h = -25, AH = 02h = 2
mov ax, -127
mov bl, 5
idiv bl ; AL = e7h = -25, AH = feh = -2
mov ax, -127
mov bl, -5
idiv bl ; AL = 19h = 25, AH = feh = -2
; x = a * b + c
mov eax, a
imul b
add eax, c ; Операнды команды сложения вычисляются слева направо
mov x, eax
; x = a + b * c
mov eax, b
imul c
add eax, a ; Операнды команды сложения вычисляются справа налево
mov x, eax
3.5. Изменение размера числа
В операциях деления размер делимого в два раза больше, чем размер делителя. Поэтому нельзя просто загрузить данные в регистр EAX и поделить его на какое-либо значение, т.к. в операции деления будет задействован также и регистр EDX. Поэтому прежде чем выполнять деление, надо установить корректное значение в регистр EDX, иначе результат будет неправильным. Значение регистра EDX должно зависеть от значения регистра EAX. Тут возможны два варианта – для знаковых и беззнаковых чисел.
Если мы используем беззнаковые числа, то в любом случае в регистр EDX необходимо записать значение 0: aaaaaaaah → 00000000aaaaaaaah.
Если же мы используем знаковые числа, то значение регистра EDX будет зависеть от знака числа: 55555555h → 0000000055555555h, aaaaaaaah → ffffffffaaaaaaaah.
Записать значение 0 не сложно, а вот для знакового расширения необходимо анализировать знак числа. Однако нет необходимости делать это вручную, т.к. язык ассемблера имеет ряд команд, позволяющих расширять байт до слова, слово до двойного слова и двойное слово до учетверённого слова.
cbw ; Знаковое расширение AL до AX
cwd ; Знаковое расширение AX до DX:AX
cwde ; Знаковое расширение AX до EAX
cdq ; Знаковое расширение EAX до EDX:EAX
Таким образом, если делитель имеет размер 2 или 4 байта, то нужно устанавливать значение не только регистра AX/EAX, но и регистра DX/EDX. Если же делитель имеет размер 1 байт, то можно просто записать делимое в регистр AX.
x dd ?
mov eax, x ; Заносим в регистр EAX значение переменной x, которое заранее неизвестно
cdq ; Знаковое расширение EAX в EDX:EAX
mov ebx, 7
idiv ebx
В языке ассемблера существуют также команды, позволяющие занести в регистр значение другого регистра или ячейки памяти со знаковым или беззнаковым расширением.
Операнд1 и операнд2 могут иметь любой размер. Понятно, что операнд1 должен быть больше, чем операнд2. В случае равенства размера операндов следует использовать обычную команду пересылки MOV, которая выполняется быстрее.
Рассмотрим пример: необходимо вычислить x * x * x, где x – 1-байтовая переменная.
; Первый вариант
mov al, x ; Пересылаем x в регистр AL
imul al ; Умножаем регистр AL на себя, AX = x * x
movsx bx, x ; Пересылаем x в регистр BX со знаковым расширением
imul bx ; Умножаем AX на BX. Но! – результат размещается в DX:AX
; Второй вариант
mov al, x ; Пересылаем x в регистр AL
imul al ; Умножаем регистр AL на себя, AX = x * x
cwde ; Расширяем AX до EAX
movsx ebx, x ; Пересылаем x в регистр EBX со знаковым расширением
imul ebx ; Умножаем EAX на EBX. Поскольку x – 1-байтовая переменная, результат благополучно помещается в EAX
Рассмотрим ещё один пример.
mov eax, x
mov ebx, 429496730 ; 429496730 = 4294967296 / 10
imul ebx ; EDX = x / 10. Выполняется в ≈5 раз быстрее, чем деление
Чем обусловлено получение такого результата? Всегда ли будет работать этот механизм?
4. Переходы и циклы
Для изменения порядка выполнения команд в языке ассемблера используются команды условного и безусловного перехода, а также команды управления циклом. Все эти команды не меняют флаги.
4.1. Безусловный переход
Команда безусловного перехода имеет следующий синтаксис:
JMP <операнд>
Операнд указывает адрес перехода. Существует два способа указания этого адреса, соответственно различают прямой и косвенный переходы.
4.1.1. Прямой переход
Если в команде перехода указывается метка команды, на которую надо перейти, то переход называется прямым.
jmp L
...
L: mov eax, x
Вообще, любой переход заключается в изменении адреса следующей исполняемой команды, т.е. в изменении значения регистра EIP. Казалось бы, в команде перехода должен задаваться именно адрес перехода. Однако в команде прямого перехода задаётся не абсолютный адрес, а разность между адресом перехода и адресом команды перехода. Действие команды перехода заключается в прибавлении этой величины к текущему значению регистра EIP2. Операнд команды перехода рассматривается как поле со знаком, поэтому при сложении его со значением регистра EIP значение в этом регистре может как увеличиться, так и уменьшиться, т.е. возможен переход и вперёд, и назад.
Запись в команде перехода не абсолютного, а относительного адреса перехода позволяет уменьшить размер команды перехода. Абсолютный адрес должен быть 32-битным, а относительный может быть и 8-битным, и 16-битным.
4.1.2. Косвенный переход
При косвенном переходе в команде перехода указывается не адрес перехода, а регистр или ячейка памяти, где этот адрес находится. Содержимое указанного регистра или ячейки памяти рассматривается как абсолютный адрес перехода. Косвенные переходы используются в тех случаях, когда адрес перехода становится известен только во время работы программы.
jmp ebx
4.2. Команды сравнения и условного перехода
Команды условного перехода осуществляют переход, который выполняется только в случае истинности некоторого условия. Истинность условия проверяется по значениям флагов. Поэтому обычно непосредственно перед командой условного перехода ставится команда сравнения, которая формирует значения флагов:
CMP <операнд1>, <операнд2>
Команда сравнения эквивалентна команде SUB за исключением того, что вычисленная разность никуда не заносится. Назначение команды CMP – установка и сброс флагов.
Что касается команд условного перехода, то их достаточно много, но все они записываются единообразно:
Jxx <метка>
Все команды условного перехода можно разделить на три группы.
В первую группу входят команды, которые обычно ставятся после команды сравнения. В их мнемокодах указывается тот результат сравнения, при котором надо делать переход.
Мнемокод
Название
Условие перехода после команды CMP op1, op2
Значения флагов
Примечание
JE
Переход если равно
op1 = op2
ZF = 1
Для всех чисел
JNE
Переход если не равно
op1 ≠ op2
ZF = 0
JL/JNGE
Переход если меньше
op1 < op2
SF ≠ OF
Для чисел со знаком
JLE/JNG
Переход если меньше или равно
op1 ≤ op2
SF ≠ OF или ZF = 1
JG/JNLE
Переход если больше
op1 > op2
SF = OF и ZF = 0
JGE/JNL
Переход если больше или равно
op1 ≥ op2
SF = OF
JB/JNAE
Переход если ниже
op1 < op2
CF = 1
Для чисел без знака
JBE/JNA
Переход если ниже или равно
op1 ≤ op2
CF = 1 или ZF = 1
JA/JNBE
Переход если выше
op1 > op2
CF = 0 и ZF = 0
JAE/JNB
Переход если выше или равно
op1 ≥ op2
CF = 0
Рассмотрим пример: даны две переменные x и y, в переменную z нужно записать максимальное из чисел x и y.
mov eax, x
cmp eax, y
jge/jae L ; Используем JGE для знаковых чисел и JAE – для беззнаковых
mov eax, y
L: mov z, eax
Во вторую группу команд условного перехода входят те, которые обычно ставятся после команд, отличных от команды сравнения, и которые реагируют на то или иное значение какого-либо флага.
Мнемокод
Условие перехода
Мнемокод
Условие перехода
JZ
ZF = 1
JNZ
ZF = 0
JS
SF = 1
JNS
SF = 0
JC
CF = 1
JNC
CF = 0
JO
OF = 1
JNO
OF = 0
JP
PF = 1
JNP
PF = 0
Рассмотрим пример: пусть a, b и c – беззнаковые переменные размером 1 байт, требуется вычислить c = a * a + b, но если результат превосходит размер байта, передать управление на метку ERROR.
mov al, a
mul al
jc ERROR
add al, b
jc ERROR
mov c, al
И, наконец, в третью группу входят две команды условного перехода, проверяющие не флаги, а значение регистра ECX или CX:
JCXZ <метка> ; Переход, если значение регистра CX равно 0
JECXZ <метка> ; Переход, если значение регистра ECX равно 0
Однако эта команда выполняется достаточно долго. Выгоднее провести сравнение с нулём и использовать обычную команду условного перехода.
С помощью команд перехода можно реализовать любые разветвления и циклы.
; if (x > 0) S
cmp x, 0
jle L
... ; S
L:
; if (x) S1 else S2
cmp x, 0
je L1
... ; S1
jmp L2
L1: ... ; S2
L2:
; if (a > 0 && b > 0) S
cmp a, 0
jle L
cmp b, 0
jle L
... ; S
L:
; if (a > 0 || b > 0) S
cmp a, 0
jg L1
cmp b, 0
jle L2
L1: ... ; S
L2:
; if (a > 0 || b > 0 && c > 0) S
cmp a, 0
jg L1
cmp b, 0
jle L2
cmp c, 0
jle L2
L1: ... ; S
L2:
; while (x > 0) do S
L1: cmp x, 0
jle L2
... ; S
jmp L1
L2:
; do S while (x > 0)
L: ... ; S
cmp x, 0
jg L
4.3. Команды управления циклом
4.3.1. Команда LOOP
Команда LOOP позволяет организовать цикл с известным числом повторений:
mov ecx, n
L: ......
loop L
Команда LOOP требует, чтобы в качестве счётчика цикла использовался регистр ECX. Собственно, команда LOOP вычитает единицу именно из этого регистра, сравнивает полученное значение с нулём и осуществляет переход на указанную метку, если значение в регистре ECX больше 0. Метка определяет смещение перехода, которое не может превышать 128 байт.
При использовании команды LOOP следует также учитывать, что с её помощью реализуется цикл с постусловием, следовательно, тело цикла выполняется хотя бы один раз. Хуже того, если до начала цикла записать в регистр ECX значение 0, то при вычитании единицы, которое выполняется до сравнения с нулём, в регистре ECX окажется ненулевое значение, и цикл будет выполняться 232 раз.
Команда LOOP не относится к самым быстрым командам. В большинстве случаев её можно заменить последовательностью других команд.
4.3.2. Команды LOOPE/LOOPZ и LOOPNE/LOOPNZ
Эти команды похожи на команду LOOP, но позволяют также организовать и досрочный выход из цикла.
LOOPE <метка> ; Команды являются синонимами
LOOPZ <метка>
Действие этой команды можно описать следующим образом: ECX = ECX - 1; if (ECX != 0 && ZF == 1) goto <метка>;
До начала цикла в регистр ECX необходимо записать число повторений цикла. Команда LOOPE/LOOPZ, как и команда LOOP ставится в конце цикла, а перед ней помещается команда, которая меняет флаг ZF (обычно это команда сравнения CMP). Команда LOOPE/LOOPZ заставляет цикл повторяться ECX раз, но только если предыдущая команда фиксирует равенство сравниваемых величин (вырабатывает нулевой результат, т.е. ZF = 1).
По какой именно причине произошёл выход из цикла надо проверять после цикла. Причём надо проверять флаг ZF, а не регистр ECX, т.к. условие ZF = 0 может появиться как раз на последнем шаге цикла, когда и регистр ECX стал нулевым.
Команда LOOPNE/LOOPNZ аналогична команде LOOPE/LOOPZ, но досрочный выход из цикла осуществляется, если ZF = 1.
Рассмотрим пример: пусть в регистре ESI находится адрес начала некоторого массива двойных слов, а в переменной n – количество элементов массива, требуется проверить наличие в массиве элементов, кратных заданному числу x, и занести в переменную f значение 1, если такие элементы есть, и 0 в противном случае.
mov ebx, x
mov ecx, n
mov f, 1
L1: mov eax, [esi]
add esi, 4
cdq
idiv ebx
cmp edx, 0
loopne L1
je L2
mov f, 0
L2:
5. Массивы
5.1. Модификация адресов
Как уже было сказано, массивы в языке ассемблера описываются по директивам определения данных с использованием конструкции повторения (см. раздел 2.6). Для того чтобы обратиться к элементу массива, необходимо так или иначе указать адрес начала массива и смещение элемента в массиве. Смещение первого элемента массива всегда равно 0. Смещения остальных элементов массива зависят от размера элементов.
Пусть X – некий массив. Тогда адрес элемента массива можно вычислить по следующей формуле:
адрес(X[i]) = X + (type X) * i, где i – номер элемента массива, начинающийся с 0
Напомним, что имя переменной эквивалентно её адресу (для массива – адресу начала массива), а операция type определяет размер переменной (для массива определяется размер элемента массива в соответствии с использованной директивой).
Для удобства в языке ассемблера введена операция модификации адреса, которая схожа с индексным выражением в языках высокого уровня – к имени массива надо приписать целочисленное выражение или имя регистра в квадратных скобках:
x[4]
x[ebx]
Однако принципиальное отличие состоит в том, в программе на языке высокого уровня мы указываем индекс элемента массива, а компилятор умножает его на размер элемента массива, получая смещение элемента массива. В программе на языке ассемблера указывается именно смещение, т.е. программист должен сам учитывать размер элемента массива. Компилятор же языка ассемблера просто прибавляет смещение к указанному адресу. Приведённые выше команды можно записать по-другому:
Обратите внимание, что при использовании регистра для модификации адреса наличие квадратных скобок обязательно. В противном случае компилятор зафиксирует ошибку.
Адрес может вычисляться и по более сложной схеме:
<база> + <множитель> * <индекс> + <смещение>
База – это регистр или имя переменной. Индекс должен быть записан в некотором регистре. Множитель – это константа 1 (можно опустить), 2, 4 или 8. Смещение – целое положительное или отрицательное число.
Команда LEA осуществляет загрузку в регистр так называемого эффективного адреса:
LEA <регистр>, <ячейка памяти>
Команда не меняет флаги. В простейшем случае с помощью команды LEA можно загрузить в регистр адрес переменной или начала массива:
x dd 100 dup (0)
lea ebx, x
Однако поскольку адрес может быть вычислен с использованием операций сложения и умножения, команда LEA имеет также ряд других применений (см. раздел 8.3.2).
5.3. Обработка массивов
Пусть есть массив x и переменная n, хранящая количество элементов этого массива.
x dd 100 dup(?)
n dd ?
Для обработки массива можно использовать несколько способов.
В регистре можно хранить смещение элемента массива.
mov eax, 0
mov ecx, n
mov ebx, 0
L: add eax, x[ebx]
add ebx, type x
dec ecx
cmp ecx, 0
jne L
В регистре можно хранить номер элемента массива и умножать его на размер элемента.
mov eax, 0
mov ecx, n
L: dec ecx
add eax, x[ecx * type x]
cmp ecx, 0
jne L
В регистре можно хранить адрес элемента массива. Адрес начала массива можно записать в регистр с помощью команды LEA.
mov eax, 0
mov ecx, n
lea ebx, x
L: add eax, [ebx]
add ebx, type x
dec ecx
cmp ecx, 0
jne L
При необходимости можно в один регистр записать адрес начала массива, а в другой – номер или смещение элемента массива.
mov eax, 0
mov ecx, n
lea ebx, x
L: dec ecx
add eax, [ebx + ecx * type x]
cmp ecx, 0
jne L
Модификацию адреса можно производить также по двум регистрам: x[ebx][esi]. Это может быть удобно при работе со структурами данных, которые рассматриваются как матрицы. Рассмотрим для примера подсчёт количества строк матриц с положительной суммой элементов.
mov esi, 0 ; Начальное смещение строки
mov ebx, 0 ; EBX будет содержать количество строк, удовлетворяющих условию
mov ecx, m ; Загружаем в ECX количество строк
L1: mov edi, 0 ; Начальное смещение элемента в строке
mov eax, 0 ; EAX будет содержать сумму элементов строки
mov edx, n ; Загружаем в EDX количество элементов в строке
L2: add eax, y[esi][edi] ; Прибавляем к EAX элемент массива
add edi, type y ; Прибавляем к смещению элемента в строке размер элемента
dec edx ; Уменьшаем на 1 счётчик внутреннего цикла
cmp edx, 0 ; Сравниваем EDX с нулём
jne L2 ; Если EDX не равно 0, то переходим к началу цикла
cmp eax, 0 ; После цикла сравниваем сумму элементов строки с нулём
jle L3 ; Если сумма меньше или равна 0, то обходим увеличение EBX
inc ebx ; Если же сумму больше 0, то увеличиваем EBX
L3: mov eax, n ; Загружаем в EAX количество элементов в строке
imul eax, type y ; Умножаем количество элементов в строке на размер элемента
add esi, eax ; Прибавляем к смещению полученный размер строки
dec ecx ; Уменьшаем на 1 счётчик внешнего цикла
cmp ecx, 0 ; Сравниваем ECX с нулём
jne L1 ; Если ECX не равно 0, то переходим к началу цикла
6. Поразрядные операции
Поразрядные операции реализуют одну и ту же логическую операцию над всеми битами переменной. К поразрядным операциям относят также операции сдвига.
6.1. Логические команды
Операция отрицания меняет значение всех битов переменной на противоположное. Операция имеет один операнд, который может быть регистром или ячейкой памяти. Операция не меняет флаги.
NOT <операнд>
Операция поразрядное «и» выполняет логическое умножение всех пар бит операндов.
AND <операнд1>, <операнд2>
Операция поразрядное «или» выполняет логическое сложение всех пар бит операндов.
OR <операнд1>, <операнд2>
Операция поразрядное исключающее «или» выполняет сложение по модулю 2 всех пар бит операндов.
XOR <операнд1>, <операнд2>
Операции AND, OR и XOR имеют по два операнда. Первый может быть регистром или ячейкой памяти, а второй – регистром, ячейкой памяти или непосредственным операндом. Операнды должны иметь одинаковый размер. Результат помещается на место первого операнда. Операции меняют флаги CF, OF, PF, SF и ZF.
Операция XOR имеет интересную особенность – если значения операндов совпадают, то результатом будет значение 0. Поэтому операцию XOR используют для обнуления регистров – она выполняется быстрее, чем запись нуля с помощью команды MOV.
xor eax, eax ; При любом значении EAX результат будет равен 0
Операцию XOR можно также использовать для обмена значений двух переменных.
xor eax, ebx ; EAX = EAX xor EBX
xor ebx, eax ; Теперь EBX содержит исходное значение EAX
xor eax, ebx ; А теперь EAX содержит исходное значение EBX
6.2. Команды сдвига
Операции сдвига вправо и сдвига влево сдвигают биты в переменной на заданное количество позиций. Каждая команда сдвига имеет две разновидности:
Первый операнд должен быть регистром или ячейкой памяти. Именно в нём осуществляется сдвиг. Второй операнд определяет количество позиций для сдвига, которое задаётся непосредственным операндом или хранится в регистре CL (и только CL).
Команды сдвига меняют флаги CF, OF, PF, SF и ZF.
Существует несколько разновидностей сдвигов, которые отличаются тем, как заполняются «освобождающиеся» биты.
6.2.1. Логические сдвиги
При логическом сдвиге «освобождающиеся» биты заполняются нулями. Последний ушедший бит сохраняется во флаге CF.
Арифметический сдвиг влево эквивалентен логическому сдвигу влево (это одна и та же команда) – «освобождающие» биты заполняются нулями. При арифметическом сдвиге вправо «освобождающиеся» биты заполняются знаковым битом. Последний ушедший бит сохраняется во флаге CF.
SAL <операнд>, <количество> ; Арифметический сдвиг влево
SAR <операнд>, <количество> ; Арифметический сдвиг вправо
6.2.3. Циклические сдвиги
При циклическом сдвиге «освобождающиеся» биты заполняются ушедшими битами. Последний ушедший бит сохраняется во флаге CF.
Расширенные сдвиги немного отличаются от остальных сдвигов. В расширенных сдвигах участвуют два регистра или ячейка памяти и регистр, которые как бы объединяются в единое целое и «освобождающиеся» биты одного операнда заполняются битами из другого операнда.
Команда SHLD сдвигает влево биты операнда1 на указанное количество позиций. Младшие («освободившиеся») биты операнда1 заполняются старшими битами операнда2. Сам операнд2 не меняется.
Команда SHRD сдвигает вправо биты операнда1 на указанное количество позиций. Старшие («освободившиеся») биты операнда1 заполняются младшими битами операнда2. Сам операнд2 не меняется.
Количество, как и в других операциях сдвига, задаётся непосредственным операндом или хранится в регистре CL. Но используются только последние 5 бит операнда, определяющего количество, т.е. максимальное количество позиций сдвига равно 32.
Команды расширенного сдвига обычно используют для создания упакованных данных.
6.3. Умножение и деление с помощью поразрядных операций
Для любой системы счисления сдвиг числа влево или вправо соответствует умножению или делению на основание системы счисления в некоторой степени. Двоичная система счисления, используемая в компьютере, не является исключением. Причём команды сдвига работают на порядок быстрее обычных операций умножения и деления.
6.3.1. Умножение
Для умножения используется сдвиг влево. Несмотря на наличие двух команда, по сути, сдвиг влево один. Он используется для умножения как знаковых, так и беззнаковых чисел. Однако результат будет правильным, только в том случае, если он умещается в регистр или ячейку памяти.
mov ax, 250 ; AX = 00fah = 250
sal ax, 4 ; Умножение на 24 = 16, AX = 0fa0h = 4000
mov ax, 1 ; AX = 1
sal ax, 10 ; Умножение на 210, AX = 0400h = 1024
mov ax, -48 ; AX = ffd0h = -48 (в дополнительном коде)
sal ax, 2 ; AX = ff40h = -192 (в дополнительном коде)
mov ax, 26812 ; AX = 68bch = 26812
sal ax, 1 ; AX = d178h = -11912
; Знаковое положительное число перешло в отрицательное
mov ax, 32943 ; AX = 80afh = 32943
sal ax, 2 ; AX = 02bch = 700
; Большое беззнаковое число стало гораздо меньше
Сочетая сдвиги со сложением и вычитанием можно выполнить умножение на любое положительное число. Для умножения на отрицательное число следует добавить команду NEG.
mov ebx, x
mov eax, ebx
sal eax, 2
add eax, ebx ; EAX = x * 5
mov ebx, x
mov eax, ebx
sal eax, 3
sub eax, ebx ; EAX = x * 7
mov ebx, x
mov eax, ebx
sal eax, 2
add eax, ebx
sal eax, 1 ; EAX = x * 10
Такой набор операций выполняется в 1.5-2 раза быстрее, чем обычное умножение. Но если оба сомножителя заранее неизвестны, то лучше использовать умножение.
6.3.2. Деление
Для деления используется сдвиг вправо. При делении нет проблем с переполнением, но для знаковых и беззнаковых чисел надо использовать разные механизмы.
Для деления беззнаковых чисел следует использовать логический сдвиг вправо.
Со знаковыми числами дело обстоит несколько сложнее. В принципе, для деления знаковых чисел следует использовать арифметический сдвиг вправо. Однако для отрицательных чисел получается не совсем корректный результат: 1 / 2 = 0, 3 / 2 = 1, но -1 / 2 = -1, -3 / 2 = -2,, т.е. результат отличается от правильного на единицу. Для того чтобы получить правильный результат, необходимо прибавить к делимому делитель, уменьшенный на 1. Однако это необходимо только для отрицательных чисел, поэтому для того, чтобы не делать проверок, используют следующий алгоритм.
; Деление на 2
mov eax, x
cdq ; Расширяем двойное слово до учетверённого. Если в регистре EAX находится положительное число,
; то регистр EDX будет содержать 0, а если в регистре EAX находится отрицательное число,
; то регистр EDX будет содержать -1 (ffffffffh)
sub eax, edx ; Если регистр EDX содержит 0, то регистр EAX не меняется. Если же регистр EDX содержит -1
; (при отрицательном EAX), то к EAX будет прибавлена требуемая единица
sar eax, 1
; Деление на 2n (в данном примере n = 3)
mov eax, x
cdq ; Расширяем двойное слово до учетверённого
and edx, 111b ; Если EAX отрицателен, то EDX содержит делитель, уменьшенный на 1
add eax, edx ; Если EAX отрицателен, прибавляем полученное значение
sar eax, 3 ; Если EAX был положителен, то EDX = 0, и предыдущие две операции ничего не меняют
Если число беззнаковое или если мы знаем, что число положительное, можно просто использовать сдвиг вправо, который выполняется примерно в 10 раз быстрее, чем деление. Если же для знакового числа не известно, положительное оно или отрицательное, то придётся использовать вышеприведённую последовательность команд, которая, однако, также выполняется примерно в 5-7 раз быстрее, чем деление.
6.3.3. Получение остатка от деления
Для беззнаковых и положительных чисел остаток от деления на 2n – это последние n бит числа. Поэтому для получения остатка от деления на 2n нужно выделить эти последние n бит с помощью операции AND.
mov eax, x
and eax, 111b ; EAX = EAX % 23
Для отрицательного делимого x и положительного делителя n(x % n) = -(-x % n).
mov eax, x
neg eax
and eax, 1111b ; EAX = EAX % 24
neg eax
7. Программа. Процедуры
7.1. Структура программы на языке ассемблера
Программа на языке ассемблера имеет следующую структуру:
Директива .686 указывает компилятору ассемблера, что необходимо использовать набор операций процессора определённого поколения.
Директива .model позволяет указывать используемую модель памяти и соглашение о вызовах. Как уже было сказано, на архитектуре Win32 используется только одна модель памяти – flat, что и указано в приведённом примере. Соглашения о вызовах определяют порядок передачи параметров и порядок очистки стека.
Директива option casemap: none заставляет компилятор языка ассемблера различать большие и маленькие буквы в метках и именах процедур.
Директивы .data, .data?, .const и .code определяют то, что называется секциями. В Win32 нет сегментов, но адресное пространство можно поделить на логические секции. Начало одной секции отмечает конец предыдущей. Есть две группы секций: данных и кода.
Секция .data содержит инициализированные данные программы.
Секция .data? содержит неинициализированные данные программы. Иногда нужно только предварительно выделить некоторое количество памяти, не инициализируя её. Эта секция для этого и предназначается. Преимущество неинициализированных данных в том, что они не занимают места в исполняемом файле. Вы всего лишь сообщаете компилятору, сколько места вам понадобится, когда программа загрузится в память.
Секция .const содержит объявления констант, используемых программой. Константы не могут быть изменены. Попытка изменить константу вызывает аварийное завершение программы.
Задействовать все три секции не обязательно.
Есть только одна секция для кода: .code. В ней содержится весь код.
Предложения <метка> и end <метка> устанавливают границы кода. Обе метки должны быть идентичны. Весь код должен располагаться между этими предложениями.
Любая программа под Windows должна, как минимум, корректно завершится. Для этого необходимо вызвать функцию Win32 API ExitProcess.
.686
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.code
program:
push 0
call ExitProcess
end program
Выше приведён пример минимальной программы на языке ассемблера, которая делает только одно – корректно завершается. В ней появились две новые директивы: include и includelib. Первая позволяет включать в программу файлы, содержащие прототипы процедур, а также определения констант и структур, которые могут понадобиться для программирования под Win32. Вторая директива указывает, какие библиотеки использует программа. Компоновщик должен будет прилинковать их. Без указания включаемого файла kernel2.inc и библиотеки импорта kernel32.lib невозможно будет вызвать процедуру ExitProcess. Файл windows.inc в данном случае включать не обязательно, но он требуется достаточно часто, а включаемые файлы не увеличивают размер получаемой программы.
Команда PUSH кладёт в стек параметр для процедуры ExitProcess. Этот параметр определяет код завершения. Значение 0 – это код нормального завершения программы.
Команда CALL вызывает процедуру ExitProcess.
Если вы используете компилятор MASM32, то пункт меню Project содержит команды Assemble & Link и Console Assemble & Link, которые позволяют скомпилировать обычное и консольное приложение под Windows. Приведённую программу можно откомпилировать обоими способами.
7.2. Команды работы со стеком
Работа со стеком имеет непосредственное отношение к процедурам, т.к. стек используется для передачи параметров и для хранения локальных данных процедур. В принципе, для работы со стеком существуют всего две операции: положить данные и взять данные. Для каждой операции существует несколько команд, которые отличаются тем, с какими данными они работают.
Для того чтобы положить данные в стек используется команда PUSH:
PUSH <операнд>
Операнд может быть регистром, ячейкой памяти или непосредственным операндом. Размер операнда должен быть 2 или 4 байта. Операнд кладётся на вершину стека, а значение регистра ESP уменьшается на размер операнда.
Для того чтобы взять данные из стека используется команда POP:
POP <операнд>
Операнд может быть регистром или ячейкой памяти. Размер операнда должен быть 2 или 4 байта. В соответствии с размером операнда из вершины стека берутся 2 или 4 байта и помещаются в указанный регистр или ячейку памяти. Значение регистра ESP увеличивается на размер операнда.
Кроме этих основных команд существуют ещё команды, которые позволяют сохранять в стеке и восстанавливать из стека содержимое всех регистров общего назначения, и команды, которые позволяют сохранять в стеке и восстанавливать из стека содержимое регистра флагов.
PUSHAPUSHAD
Команда PUSHA сохраняет в стеке содержимое регистров AX, CX, DX, BX, SP, BP, SI, DI. Команда PUSHAD сохраняет в стеке содержимое регистров EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI. Для регистра (E)SP сохраняется значение, которое было до того, как мы положили регистры в стек. После этого значение регистра (E)SP изменяется как обычно.
POPAPOPAD
Эти команды противоположны предыдущим – они восстанавливают из стека значения регистров (E)DI, (E)SI, (E)BP, (E)SP, (E)BX, (E)DX, (E)CX, (E)AX. Содержимое регистра (E)SP не восстанавливается из стека, а изменяется как обычно.
PUSHFPUSHFD
Команда PUSHF сохраняет в стеке младшие 16 бит регистра флагов. Команда PUSHFD сохраняет в стеке все 32 бита регистра флагов.
POPFPOPFD
Команда POPF восстанавливает из стека младшие 16 бит регистра флагов. Команда POPFD восстанавливает из стека все 32 бита регистра флагов.
7.3. Синтаксис процедуры
Описание процедуры на языке ассемблера выглядит следующим образом:
Несмотря на то, что после имени процедуры не ставится двоеточие, это имя является меткой, обозначающей первую команду процедуры.
В языке ассемблера имена и метки, описанные в процедуре, не локализуются внутри неё, поэтому они должны быть уникальны.
Размещать процедуру в программе на языке ассемблера следует таким образом, чтобы команды процедуры выполнялись не сами по себе, а только тогда, когда происходит обращение к процедуре. Обычно процедуры размещают либо в конце секции кода после вызова функции ExitProcess, либо в самом начале секции кода, сразу после директивы .code.
7.4. Вызов процедуры и возврат из процедуры
Вызов процедуры – это, по сути, передача управления на первую команду процедуры. Для передачи управления можно использовать команду безусловного перехода на метку, являющуюся именем процедуры. Можно даже не использовать директивы proc и endp, а написать обычную метку с двоеточием после вызова функции ExitProcess.
С возвратом из процедуры дело обстоит сложнее. Дело в том, что обращаться к процедуре можно из разных мест основной программы, а потому и возврат из процедуры должен осуществляться в разные места. Сама процедура не знает, куда надо вернуть управление, зато это знает основная программа. Поэтому при обращении к процедуре основная программа должна сообщить ей адрес возврата, т.е. адрес той команды, на которую процедура должна сделать переход по окончании своей работы. Поскольку при разных обращениях к процедуре будут указываться разные адреса возврата, то и возврат управления будет осуществляться в разные места программы. Адрес возврата принято передавать через стек.
.686
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.code
program:
push L
jmp Procedure
L: nop
push 0
call ExitProcess
Procedure:
pop eax
jmp eax
end program
Однако так обычно не делают – система команд языка ассемблера включает специальные команды для вызова процедуры и возврата из процедуры.
CALL <имя процедуры> ; Вызов процедуры
RET ; Возврат из процедуры
Команда CALL записывает адрес следующей за ней команды в стек и осуществляет переход на первую команду указанной процедуры. Команда RET считывает из вершины стека адрес и выполняет переход по нему.
.686
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.code
program:
call Procedure
push 0
call ExitProcess
Procedure proc
ret
Procedure endp
end program
7.5. Передача параметров процедуры
Существуют несколько способов передачи параметров в процедуру.
Параметры можно передавать через регистры.
Если процедура получает небольшое число параметров, идеальным местом для их передачи оказываются регистры. Существуют соглашения о вызовах, предполагающие передачу параметров через регистры ECX и EDX. Этот метод самый быстрый, но он удобен только для процедур с небольшим количеством параметров.
Параметры можно передавать в глобальных переменных.
Параметры процедуры можно записать в глобальные переменные, к которым затем будет обращаться процедура. Однако этот метод является неэффективным, и его использование может привести к тому, что рекурсия и повторная входимость3 станут невозможными.
Параметры можно передавать в блоке параметров.
Блок параметров – это участок памяти, содержащий параметры и располагающийся обычно в сегменте данных. Процедура получает адрес начала этого блока при помощи любого метода передачи параметров (в регистре, в переменной, в стеке, в коде или даже в другом блоке параметров).
Параметры можно передавать через стек.
Передача параметров через стек – наиболее распространённых способ. Именно его используют языки высокого уровня, такие как С++ и Паскаль. Параметры помещаются в стек непосредственно перед вызовом процедуры.
При внимательном анализе этого метода передачи параметров возникает сразу два вопроса: кто должен удалять параметры из стека, процедура или вызывающая её программа, и в каком порядке помещать параметры в стек. В обоих случаях оказывается, что оба варианта имеют свои «за» и «против». Если стек освобождает процедура, то код программы получается меньшим, а если за освобождение стека от параметров отвечает вызывающая программа, то становится возможным вызвать несколько функций с одними и теми же параметрами просто последовательными командами CALL. Первый способ, более строгий, используется при реализации процедур в языке Паскаль, а второй, дающий больше возможностей для оптимизации, – в языке С++.
Основное соглашение о вызовах языка Паскаль предполагает, что параметры кладутся в стек в прямом порядке. Соглашения о вызовах языка С++, в том числе одно из основных соглашений о вызовах ОС Windows stdcall, предполагают, что параметры помещаются в стек в обратном порядке. Это делает возможной реализацию функций с переменным числом параметров (как, например, printf). При этом первый параметр определяет число остальных параметров.
В приведённом выше участке кода в стек кладутся несколько параметров и затем вызывается процедура. Следует помнить, что команда CALL также кладёт в стек адрес возврата. Таким образом, перед выполнением первой команды процедуры стек будет выглядеть следующим образом.
Адрес возврата оказывается в стеке поверх параметров. Однако поскольку в рамках своего участка стека процедура может обращаться без ограничений к любой ячейки памяти, нет необходимости перекладывать куда-то адрес возврата, а потом возвращать его обратно в стек. Для обращения к первому параметру используют адрес [ESP + 4] (прибавляем 4, т.к. на архитектуре Win32 адрес имеет размер 32 бита), для обращения ко второму параметру – адрес [ESP + 8] и т.д.
После завершения работы процедуры необходимо освободить стек. Если используется соглашение о вызовах stdcall (или любое другое, предполагающее, что стек освобождается процедурой), то в команде RET следует указать суммарный размер в байтах всех параметров процедуры. Тогда команда RET после извлечения адреса возврата прибавит к регистру ESP указанное значение, освободив таким образом стек. Если же используется соглашение о вызовах cdecl (или любое другое, предполагающее, что стек освобождается вызывающей программой), то после команды CALL следует поместить команду, которая прибавит к регистру ESP нужное значение.
; Передача параметров и возврат из процедуры с использованием соглашения о вызовах stdcall
.686
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
x dd 0
y dd 4
.code
program:
push y ; Кладём в стек два параметра размером по 4 байта
push x
call Procedure
push 0
call ExitProcess
Procedure proc
ret 8 ; В команде возврата указываем, что надо освободить 8 байт стека
Procedure endp
end program
; Передача параметров и возврат из процедуры с использованием соглашения о вызовах cdecl
.686
.model flat, c
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
x dd 0
y dd 4
.code
program:
push y ; Кладём в стек два параметра размером по 4 байта
push x
call Procedure
add esp, 8 ; Освобождаем 8 байт стека
push 0
call ExitProcess
Procedure proc
ret ; Используем команду возврата без параметров
Procedure endp
end program
Параметры можно передавать в потоке кода.
В этом необычном методе передаваемые процедуре данные размещаются прямо в коде программы, сразу после команды CALL. Чтобы прочитать параметр, процедура должна использовать его адрес, который автоматически передаётся в стеке как адрес возврата из процедуры. Разумеется, процедура должна будет изменить адрес возврата на первый байт после конца переданных параметров перед выполнением команды RET.
.686
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.code
program:
call Procedure ; Команда CALL кладёт в стек адрес следующей команды
db 'string',0 ; В нашем случае – адрес начала строки
push 0
call ExitProcess
Procedure proc
pop esi ; Извлекаем из стека адрес начала строки
xor eax, eax ; Обнуляем EAX, в нём будет храниться количество символов
L1: mov bl, [esi] ; Заносим в регистр BL байт, хранящийся по адресу ESI
inc esi ; Увеличиваем значение в регистре ESI на 1
inc eax ; Увеличиваем значение в регистре EAX на 1
cmp bl, 0 ; Сравниваем прочитанный символ с нулём
jne L1 ; Если не 0, переходим к началу цикла
push esi ; Кладём в стек адрес байта, следующего сразу за строкой
ret ; Возврат из процедуры
Procedure endp
end program
7.6. Передача результата процедуры
Для передачи результата процедуры обычно используется регистр EAX. Этот способ используется не только в программах на языке ассемблера, но и в программах на языке С++. Объекты, имеющие размер не более 8 байт, могут передаваться через регистровую пару EDX:EAX. Вещественные числа передаются через вершину стека вещественных регистров. Если эти способы не подходят, то следует передать в качестве параметра адрес ячейки памяти, куда будет записан результат.
; Передача параметров через стек, возврат результата через регистр EAX
.686
.model flat, c
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
a dd 76
b dd -8
d dd ?
.code
program:
push b ; Кладём параметры в стек
push a
call Procedure
add esp, 8 ; Освобождаем 8 байт стека
mov d, eax ; d = a – b
push 0
call ExitProcess
Procedure proc
mov eax, [esp + 4] ; Заносим в регистр EAX первый параметр
mov edx, [esp + 8] ; Заносим в регистр EDX второй параметр
sub eax, edx ; В регистре EAX получилась разность параметров
ret
Procedure endp
end program
; Передача параметров через стек, возврат результата по адресу
.686
.model flat, c
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
a dd 76
b dd -8
d dd ?
.code
program:
push offset d ; Кладём в стек адрес переменной, куда будет записан результат
push b
push a
call Procedure
add esp, 12 ; Освобождаем 12 байт стека
push 0
call ExitProcess
Procedure proc
mov eax, [esp + 4] ; Заносим в регистр EAX первый параметр
mov edx, [esp + 8] ; Заносим в регистр EDX второй параметр
sub eax, edx ; В регистре EAX получилась разность параметров
mov edx, [esp + 12] ; Заносим в регистр EDX третий параметр – адрес результата
mov [edx], eax ; Записываем результат по адресу в регистре EDX
ret
Procedure endp
end program
7.7. Сохранение регистров в процедуре
Практически любые действия в языке ассемблера требуют использования регистров. Однако регистров очень мало и даже в небольшой программе невозможно будет разделить регистры между частями программы, т.е. договориться, что основная программа использует, например, регистры EAX, ECX, EBP, ESP, а процедура – регистры EBX, EDX, ESI, EDI. В принципе, сделать так можно, но смысла в этом нет, т.к. программировать будет крайне неудобно, придётся перемещать данные из регистров в оперативную память и обратно, что замедлит выполнение программы. Кроме того, существуют правила, которые изменить нельзя – в регистре ESP хранится адрес вершины стека, а команды умножения и деления всегда используют регистры EAX и EDX. Поэтому получается, что основная программа и процедура вынуждены использовать одни и те же регистры, причём, вычисления в основной программе прерываются для того, чтобы выполнить вычисления процедуры. Таким образом, чтобы основная программа могла продолжить вычисления, процедура должна при выходе восстановить те значения регистров, которые были до начала выполнения процедуры. Естественно, для этого процедуре придётся предварительно сохранить значения регистров. Всё вышесказанное относится также к случаю, когда одна процедура вызывает другую процедуру.
Особенно внимательно следует относиться к регистрам ESI, EDI, EBP и EBX. ОС Windows использует эти регистры для своих целей и не ожидает, что вы измените их значение.
Если вы пишите всю программу целиком, то, в принципе, можете добиться того, что после вызова процедуры в основной программе нужные регистры будут правильно проинициализированы. Если же вы пишите отдельные процедуры, которые затем будут использоваться в другой программе, то никаких гарантий нет, и сохранение и восстановление регистров становится жизненно необходимой операцией.
Где можно сохранить значения регистров? Конечно же, в стеке. Можно сохранить используемые регистры по одному с помощью команды PUSH, или все сразу с помощью команды PUSHAD. В первом случае в конце процедуры нужно будет восстановить значения сохранённых регистров с помощью команды POP в обратном порядке. Во втором случае для восстановления значений регистров используется команду POPAD.
При сохранении регистров указатель стека изменится на некоторое значение, зависящее от количества сохранённых регистров. Это нужно будет учитывать при вычислении адресов параметров процедуры, передаваемых через стек.
; Процедура получает два параметра по 4 байта
Procedure proc
push esi ; Сохраняем используемые регистры
push edi
mov esi, [esp + 12] ; Извлекаем параметры из стека. Адрес вычисляется
mov edi, [esp + 16] ; с учётом 8 байт, использованных при сохранении регистров
...
pop edi ; Извлекаем сохранённые регистры из стека
pop esi ; в обратном порядке
ret
Procedure endp
; Процедура получает два параметра по 4 байта
Procedure proc
pushad ; Сохраняем все регистры
mov eax, [esp + 4 + 32] ; Извлекаем параметры из стека. Адрес вычисляется
mov ebx, [esp + 8 + 32] ; с учётом 32 байт, использованных при сохранении регистров
...
popad ; Извлекаем сохранённые регистры из стека
ret
Procedure endp
7.8. Локальные данные процедур
Процедуры часто нуждаются в локальных данных. Локальные переменные размещаются в стеке. Для того чтобы отвести место под локальные переменные в процедуре на языке ассемблера, достаточно просто вычесть из регистра ESP размер требуемой памяти. После этого все вызываемые процедуры будут «знать», что место в стеке занято, и размещать свои данные в незанятой части стека.
При вызове других процедур, а также в ходе выполнения текущей процедуры в стек могут быть положены другие данные. При этом значение регистра ESP изменится. Поэтому регистр ESP не является надёжной точкой отсчёта для адресов локальных переменных. Для того чтобы получить такую точку отсчёта, значение регистра ESP переписывают в регистр EBP, предварительно сохранив значение регистра EBP в стеке. В этом случае регистр EBP отмечает часть стека, занятую на момент начала работы процедуры (отсюда происходит название регистра EBP – указатель базы кадра стека). При таком подходе первый параметр процедуры всегда находится по адресу [EBP + 8]. Адреса локальных переменных отсчитываются от регистра EBP с отрицательным смещением. По окончании работы процедуры значение регистра ESP восстанавливается по регистру EBP, а значение регистра EBP – из стека.
Procedure proc
var_104 = byte ptr -104h
var_4 = dword ptr -4
arg_0 = dword ptr 8
arg_4 = dword ptr 0ch
push ebp
mov ebp, esp
sub esp, 104h
mov edx, [ebp + arg_0]
mov eax, [ebp + arg_4]
push ebx
push esi
push edi
...
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret
Procedure endp
Такой способ позволяет также отводить различное количество места под локальные данные, и при необходимости не заботится о парности команд PUSH и POP.
7.9. Рекурсивные процедуры
Рекурсия – ресурсоёмкий способ реализации алгоритмов. Она требует много места для хранения локальных данных на каждом шаге рекурсии, кроме того, рекурсивные процедуры обычно выполняются не очень быстро. Поэтому языку ассемблера, предназначенному для написания быстрых программ, рекурсия, в общем, не свойственна. Но при желании и на ассемблере можно написать рекурсивную процедуру. Принципы реализации рекурсивной процедуры на языке ассемблера такие же, как и на других языках. В процедуре должна быть терминальная ветвь, в которой нет рекурсивного вызова, и рабочая ветвь.
При реализации рекурсивных процедур становится особенно важным использование стека для передачи параметров и адреса возврата, что позволяет хранить данные, относящиеся к разным уровням рекурсивных вызовов, в разных областях памяти.
Для примера рассмотрим рекурсивную процедуру вычисления факториала целого беззнакового числа. Процедура получает параметр через стек и возвращает результат через регистр EAX.
factorial proc
mov eax, [esp + 4] ; Заносим в регистр EAX параметр процедуры
test eax, eax ; Проверяем значение в регистре EAX
jz L1 ; Если EAX = 0, то обходим рекурсивную ветвь
dec eax ; Уменьшаем значение в регистре EAX на 1
push eax ; Кладём в стек параметр для следующего рекурсивного вызова
call factorial ; Вызываем процедуру
add esp, 4 ; Очищаем стек, т.к. процедура использует RET без параметров
mul dword ptr [esp + 4] ; Умножаем EAX, хранящий результат предыдущего вызова, на параметр текущего вызова процедуры
ret ; Возврат из процедуры (без параметров)
L1: inc eax ; Если EAX был равен 0, записываем в EAX единицу
L2: ret ; Возврат из процедуры (без параметров)
factorial endp
8. Оптимизация программ, написанных на языке ассемблера
Наиболее популярным применением ассемблера обычно считается именно оптимизация программ, то есть уменьшение времени выполнения программ по сравнению с языками высокого уровня. Но если просто переписать текст, например с языка С на ассемблер, переводя каждую команду наиболее очевидным способом, часто оказывается, что процедура на языке С выполняется быстрее. Вообще говоря, ассемблер, как и любой другой язык, сам по себе не является панацеей от неэффективного программирования – чтобы действительно оптимизировать программу, требуется не только знание команд процессора, но и знание алгоритмов, навык оптимальных способов их реализации и подробная информация об архитектуре процессора.
Проблему оптимизации принято делить на три основных уровня:
выбор наиболее оптимального алгоритма – высокоуровневая оптимизация;
наиболее оптимальная реализация алгоритма – оптимизация среднего уровня;
подсчёт тактов, тратящихся на выполнение каждой команды, и оптимизация их порядка для конкретного процессора – низкоуровневая оптимизация.
8.1. Высокоуровневая оптимизация
Выбор оптимального алгоритма для решения задачи всегда приводит к лучшим результатам, чем любой другой вид оптимизации. Действительно, при замене пузырьковой сортировки, время выполнения которой пропорционально n2, на быструю сортировку, время выполнения которой пропорционально n * log(n), вторая программа будет выполняться быстрее в подавляющем большинстве случаев, как бы она ни была реализована. Поиск лучшего алгоритма – универсальная стадия, и она относится не только к ассемблеру, но и к любому языку программирования, поэтому будем считать, что оптимальный алгоритм уже выбран.
8.2. Оптимизация среднего уровня
Реализация алгоритма на данном конкретном языке программирования – самая ответственная стадия оптимизации. Именно здесь можно получить выигрыш в скорости в десятки раз или сделать программу в десятки раз медленнее, при серьёзных ошибках в реализации. Методы оптимизации сильно зависят от конкретного реализуемого алгоритма, поэтому невозможно описать правила на все случаи жизни, хотя, конечно, есть ряд общих приёмов, например, хранение переменных, с которыми выполняется активная работа, в регистрах, использование таблиц переходов вместо длинных последовательностей проверок и условных переходов и т.п. Тем не менее, даже плохо реализованные операции не вносят заметных замедлений в программу, если они не повторяются в цикле. Практически можно говорить, что все проблемы оптимизации на среднем уровне так или иначе связаны с циклами, и именно поэтому мы рассмотрим основные правила, которые стоит иметь в виду при реализации любого алгоритма, содержащего циклы.
8.2.1. Вычисление констант вне цикла
Самым очевидным и самым важным правилом при создании цикла на любом языке программирования является вынос всех переменных, которые не изменяются на протяжении цикла, за его пределы. В случае ассемблера имеет смысл также по возможности разместить все переменные, которые будут использоваться внутри цикла, в регистры, а старые значения нужных после цикла регистров сохранить в стеке.
8.2.2. Перенос проверки условия в конец цикла
Циклы типа while или for, которые так часто применяются в языках высокого уровня, оказываются менее эффективными по сравнению с циклами типа until из-за того, что в них требуется лишняя команда перехода.
; for (i = start_i; i < n; i++) <тело цикла>
mov edi, start_i ; Начальное значение счётчика
mov esi, n ; Конечное значение счётчика
loop_start:
cmp edi, esi ; Пока EDI < ESI – выполнять
je loop_end
<тело цикла>
inc edi
jmp loop_start
loop_end:
; i = start_i; do { <тело цикла> } while (i < n);
mov edi, start_i
mov esi, n
loop_start:
<тело цикла>
inc edi
cmp edi, esi
jb loop_start ; Пока EDI < ESI – выполнять
Предположим, в цикле должен быть один шаг. Тогда в цикле с предусловием будет выполнено сравнение, тело цикла, безусловный переход к началу цикла, сравнение и переход за цикл. В цикле с постусловием будет выполнено тело цикла, сравнение и нереализованный переход. Таким образом, в цикле с предусловием выполняется одно лишнее сравнение и два реализованных перехода (2 * 3 такта = 6 тактов) вместо одного нереализованного (1 такт). Вроде бы и немного, но если цикл окажется внутри другого цикла, то все эти лишние такты будут повторяться многократно. Кроме того, цикл с постусловием содержит на одну команду меньше.
Конечно, цикл с постусловием всегда выполняется хотя бы один раз, и во многих случаях перед циклом приходится добавлять ещё одну проверку, но в любом случае даже небольшое уменьшение тела цикла всегда оказывается необходимой операцией.
8.2.3. Выполнение цикла задом наперёд
Циклы, в которых значение счётчика растёт от единицы или нуля до некоторой величины, можно реализовать вообще без операции сравнения, выполняя цикл в обратном направлении. Флаги меняются не только командой сравнения, но и многими другими. В частности, команда DEC меняет флаги AF, OF, PF, SF и ZF. Команда сравнения кроме этих флагов меняет также флаг CF, но для сравнения с нулём можно обойтись флагами SF и ZF.
; Цикл от 10 до 1
mov edx, 10
loop_start:
<тело цикла>
dec edx ; Уменьшаем EDX на 1. Если EDX = 0, то ZF = 1
jnz loop_start ; Переход если ZF = 0. Когда EDX = 0, ZF = 1, поэтому выходим из цикла
; Цикл от 10 до 0
mov edx, 10
loop_start:
<тело цикла>
dec edx ; Уменьшаем EDX на 1. Если EDX = -1, то SF = 1
jns loop_start ; Переход если SF = 0. Когда EDX = -1, SF = 1, поэтому выходим из цикла
Циклы от 0 и от 1 являются, наверное, самыми распространёнными. Конечно, не все циклы можно заставить выполняться в обратном направлении сразу. Например, иногда приходится изменять формат хранения массива данных также на обратный, иногда приходится вносить другие изменения, но в целом, если это возможно, всегда следует стремиться к циклам, выполняющимся задом наперёд.
8.2.4. Разворачивание циклов
Для небольших циклов время выполнения проверки условия и перехода на начало цикла может оказаться значительным по сравнению со временем выполнения самого тела цикла. В таких случаях можно вообще не создавать цикл, а просто повторить его тело нужное число раз (разумеется, только в случае, если нам заранее известно это число!). Для очень коротких циклов можно, например, удваивать или утраивать тело цикла, если, конечно, число повторений кратно двум или трём. Кроме того, бывает удобно часть работы сделать в цикле, а часть развернуть.
; Цикл от 10 до -1
mov edx, 10
loop_start:
<тело цикла>
dec edx
jns loop_start ; Выходим из цикла, когда EDX станет равны -1
<тело цикла> ; Но повторяем тело цикла ещё раз
Естественно, эти простые методики не перечисляют все возможности оптимизации среднего уровня, более того, они не описывают и десятой доли всех её возможностей. Умение оптимизировать программы нельзя сформулировать в виде набора простых алгоритмов – слишком много существует различных ситуаций, в которых всякий алгоритм оказывается неоптимальным. При решении любой задачи оптимизации приходится пробовать десятки различных небольших изменений, далеко не все из которых оказываются полезными. Именно потому, что оптимизация всегда занимает очень много времени, рекомендуется приступать к ней только после того, как программа окончательно написана.
8.3. Низкоуровневая оптимизация
8.3.1. Основные принципы
Так как современные процессоры используют весьма сложный набор команд, большинство операций можно выполнить на низком уровне очень многими способами. При этом иногда оказывается, что наиболее очевидный способ – не самый быстрый. Часто простыми перестановками команд, зная механизм выполнения команд на современных процессорах, можно заставить ту же процедуру выполняться на 50–200% быстрее. Разумеется, переходить к этому уровню оптимизации можно только после того, как текст программы окончательно написан и максимально оптимизирован на среднем уровне.
Перечислим основные рекомендации.
Используйте регистр ЕАХ всюду, где возможно. Команды с непосредственным операндом, с операндом – абсолютным адресом переменной и команды XCHG с регистрами занимают на один байт меньше, если другой операнд – регистр ЕАХ.
Если к переменной в памяти, адресуемой со смещением, выполняется несколько обращений – загрузите её в регистр.
Не используйте сложные команды – ENTER, LEAVE, LOOP, строковые команды, если аналогичное действие можно выполнить небольшой последовательностью простых команд.
Не используйте умножение или деление на константу – его можно заменить другими командами (см. раздел 6.3).
Старайтесь программировать условия и переходы так, чтобы переход выполнялся по менее вероятному событию.
Следующее эмпирическое правило, относящееся к переходам и вызовам, очень простое: избавляться от них везде, где только можно. Для этого организуйте программу так, чтобы она исполнялась прямым, последовательным образом, с минимальным числом точек принятия решения. В результате очередь команд будет почти всегда заполнена, а вашу программу будет легче читать, сопровождать и отлаживать. Процедуры, особенно небольшие, нужно не вызывать, а встраивать. Это, конечно, увеличивает размер программы, но даёт существенный выигрыш во времени её исполнения.
Используйте короткую форму команды JMP, где возможно (jmp short <метка>).
Команда LEA быстро выполняется и имеет много неожиданных применений (см. раздел 8.3.2).
Многие одиночные команды, как это ни странно, выполняются дольше, чем две или три команды, приводящие к тому же результату. Это может быть связано с различными особенностями выполнения команд, в том числе, с возможностью/невозможность попарного выполнения команд в разных конвейерах (см. раздел 8.3.3).
Старайтесь выравнивать данные и метки по адресам, кратным 2/4/8/16 (см. раздел 8.3.4).
Если команда обращается к 32-битному регистру, например ЕАХ, сразу после команды, выполнявшей запись в соответствующий частичный регистр (АХ, AL, АН), может происходить пауза в один или несколько тактов.
8.3.2. Использование команды LEA
Команда LEA может использоваться для трёхоперандного сложения (но только сложения, а не вычитания).
lea eax, [ebx + edx]
Команда LEA может использоваться для сложения значения регистра с константой или вычитания константы из значения регистра. В данном случае вычитание возможно, т.к. оно рассматривается как сложение с отрицательной константой. Результат может быть помещён в тот же или другой регистр (кроме регистра ESP). Такой способ используется для сохранения флагов, т.к. команда LEA, в отличие от команд ADD, SUB, INC и DEC, не меняет флаги.
Команда LEA может использоваться для быстрого умножения на константы 2, 3, 4, 5, 7(?), 8, 9. Адрес, загружаемый командой LEA, может быть суммой двух регистров, один из которых может быть умножен на константу 2, 4 или 8. Поэтому комбинируя умножение и сложение можно получить вышеперечисленные константы. Третье слагаемое может быть константой.
Вместо команды AND лучше использовать команду TEST, если нужен не результат, а проверка. Команда TEST лучше спаривается. Команда TEST также может быть использована для проверки на равенство нулю.
test eax, eax
jz <метка> ; Переход, если EAX = 0
Если за командой CALL сразу же следует команда RET, замените эти команды командой JMP. Вызываемая процедура осуществит возврат по адресу возврата, переданному вызывающей процедуре.
call dest jmp dest
ret
Команду CBW можно заменить засылкой нуля, если расширяемое число положительное. Команду CDQ можно заменить засылкой нуля, если расширяемое число положительное, или парой команд MOV + SAR, если знак расширяемого числа не известен. Недостаток – команды XOR и SAR меняют флаги.
cdq xor edx, edx
cdq mov edx, eax
sar edx, 31
Вместо команд инкремента и декремента можно использовать команду LEA.
Сложение и вычитание с константой можно заменить командой LEA.
Вместо умножения и деления на степень числа 2 используйте сдвиги.
Умножение и деление на константу можно заменить командой LEA или сочетанием команд сдвига и команд сложения и вычитания.
Деление на константу можно заменить умножением на константу.
Обнуление регистров производится с помощью команды XOR.
xor eax, eax ; EAX = 0 при любом значении EAX, которое было до этой команды
Не используйте команду MOVZX для чтения байта – это требует 3 тактов для выполнения. Заменой может служить такая пара команд, выполняющаяся за 2 такта:
xor еах, еах
mov al, <источник>
Засылку непосредственного операнда в ячейку памяти можно производить через регистр – такие команды лучше спариваются.
Аналогично команды PUSH и POP, работающие с ячейкой памяти, можно заменить парой команд MOV + PUSH или POP + MOV.
push x mov eax, x
push eax
pop x pop eax
mov x, eax
8.3.4. Выравнивание
80-битные данные должны быть выравнены по 16-байтным границам (то есть четыре младших бита адреса должны быть равны нулю).
Восьмибайтные данные должны быть выравнены по восьмибайтным границам (то есть три младших бита адреса должны быть равны нулю).
Четырёхбайтные данные должны быть выравнены по границе двойного слова (то есть два младших бита адреса должны быть равны нулю).
Двухбайтные данные должны быть выравнены по границе слова.
Метки для переходов, особенно метки, отмечающие начало цикла, должны быть выравнены по 16-байтным границам.
Каждое невыравненное обращение к данным означает потерю тактов процессора.
Для выравнивания данных и кода используется директива ALIGN:
ALIGN <число>
Число должно быть степенью двойки. Данные и команда, расположенные после директивы ALIGN, будут размещены по адресу, кратному указанному числу.
9. Примеры
Процедура вычисления наибольшего общего делителя двух беззнаковых чисел. Для нахождения НОД используется алгоритм Евклида: пока числа не равны, надо вычитать из большего числа меньшее. Процедура получает параметры через регистры EAX и EDX и возвращает результат через регистр EAX.
NOD proc
N1: cmp eax, edx ; Сравниваем числа
je N3 ; Если числа равны, завершаем работу процедуры
ja N2 ; Если первое число больше, обходим обмен
; Поскольку команды перехода не меняют флаги, оба перехода
; выполняются или не выполняются по результатам одного сравнения
xchg eax, edx ; Если первое число было меньше, выполняем обмен
N2: sub eax, edx ; Вычитаем из большего числа меньшее
jmp N1 ; Переход к началу цикла
N3: ret
NOD endp
Ввод и вывод в консольном приложении. В программе используются следующие функции Win32 API.
SetConsoleTitle – меняет заголовок окна консоли. Получает один параметр – указатель на строку, которая будет выведена в заголовке. Строка должна заканчиваться нулём.
GetStrHandle – возвращает идентификатор устройства ввода, устройства вывода или устройства отчёта об ошибках. Для консольного приложения всё три устройства являются консолью, но идентификаторы будут разными. Функция получает один параметр – указание, идентификатор какого устройства нужно вернуть. Чтобы получить идентификатор устройства ввода, надо передать функции число -10, чтобы получить идентификатор устройства вывода – число -11, а чтобы получить идентификатор устройства отчёта об ошибках – число -12. Функция возвращает требуемый идентификатор через регистр EAX.
WriteConsole – выводит строку в консоль. Получает следующие параметры – идентификатор устройства вывода, адрес выводимой строки, количество символов для вывода, адрес переменной, куда будет записано количество выведенных символов, зарезервированный указатель.
ReadConsole – вводит строку из консоли. Получает следующие параметры – идентификатор устройства ввода, адрес памяти, куда будет записана введённая строка, максимальное количество читаемых символов, адрес переменной, куда будет записано реальное количество введённых символов, зарезервированный указатель.
Не забывайте, что параметры кладутся в стек, начиная с последнего, и что введённая строка всегда будет содержать в конце символы с кодами 13 и 10, которые появляются при нажатии на клавишу ВВОД (без чего, однако, ввод не завершится).
.686
.model flat, c
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
str db 256 dup(0)
hStdIn dd 0
hStdOut dd 0
slength dd 0
.const
sConsoleTitle db 'Input and Output',0 ; Заголовок окна консоли. Заканчивается нулём
prompt db 'Input a string', 13,10 ; Приглашение для ввода. Символы с кодами 13 и 10
; обеспечивают перевод курсора на следующую строку
STD_INPUT_HANDLE equ -10d ; Определяем символические имена для констант,
STD_OUTPUT_HANDLE equ -11d ; указывающих требуемое устройство
.code
program:
; Вывод заголовка консоли
push offset sConsoleTitle ; Кладём в стек адрес начала строки заголовка консоли
call SetConsoleTitle ; Вызываем функцию
; Получаем идентификатор устройства ввода
push STD_INPUT_HANDLE ; Кладём в стек параметр функции GetStdHandle
call GetStdHandle ; Вызываем функцию
mov hStdIn, eax ; Сохраняем полученный идентификатор
; Получаем идентификатор устройства вывода
push STD_OUTPUT_HANDLE
call GetStdHandle
mov hStdOut, eax
; Выводим приглашение
push 0 ; Зарезервированный параметр, в стек кладём 0
push 0 ; Указатель на переменную для записи количества выведенных символов,
; в данном случае не нужен, поэтому в стек кладём 0
push 10h ; Количество выводимых символов
push offset prompt ; Адрес выводимой строки
push hStdOut ; Идентификатор устройства вывода
call WriteConsole ; Вызываем функцию
; Вводим строку
push 0 ; Зарезервированный параметр, в стек кладём 0
push offset slength ; Адрес переменной, куда будет записано количество введённых символов
push 256 ; Максимальное количество вводимых символов
push offset str ; Адрес для записи введённой строки
push hStdIn ; Идентификатор устройства ввода
call ReadConsole ; Вызываем функцию
; Выводим строку
push 0
push 0
push slength
push offset str
push hStdOut
call WriteConsole
; Задержка
push 1800h
call Sleep
push 0
call ExitProcess
end program
Процедура ввода целого числа в 16-ричной системе счисления. Процедура предназначена для использования в консольном приложении и предполагает, что идентификатор устройства ввода был получен основной программой и сохранён в переменной hStdIn.
InputNumber proc
push ebp ; Сохраняем в стеке значение регистра EBP
mov ebp, esp ; Заносим в регистр EBP текущее значение вершины стека
sub esp, 16 ; Резервируем 16 байт. Вводимая строка может содержать до 8 цифр.
; 2 байта требуются для символов с кодами 13 и 10. Итого 10 байт.
; 4 байта нужно для целочисленной переменной, куда будет записываться количество
; введённых символов. Итого 14 байт. Но выделим 16 байт, т.е. 4 двойных слова
push ebx ; Сохраняем значения важных регистров
push esi
; Вводим строку
push 0
lea eax, [ebp - 16] ; 4 байта по адресу [EBP – 16] предназначены для хранения количества введённых символов
push eax
push 10d
lea eax, [ebp - 12] ; По адресу [EBP – 12] начинается память для вводимой строки
push eax
push hStdIn
call ReadConsole
; Преобразуем строку в число
xor eax, eax ; Обнуляем регистр EAX ...
xor ebx, ebx ; ... и регистр EBX
mov ecx, [ebp - 16] ; Заносим в регистр ECX количество введённых символов
sub ecx, 2 ; Символы с кодами 13 и 10 обрабатывать не надо
lea esi, [ebp - 12] ; Заносим в регистр ESI адрес начала строки
test ecx, ecx ; Используем команду TEST для сравнения с нулём
jz L2 ; Если ECX = 0, то завершаем работу процедуры
L1: mov bl, [esi] ; Заносим в регистр BL текущий символ (три старших байта EBX
; содержат 0, т.к. ранее была команда XOR EBX, EBX)
lea edx, [ebx - '0'] ; Заносим в регистр EDX разность между кодом текущего символа и кодом символа '0'
cmp edx, 9 ; Сравниваем значение в регистре EDX с 9
ja M1 ; Если выше, то переходим к следующему сравнению
sub bl, '0' ; Иначе получаем число из кода символа
jmp M3 ; Переходим к действиям, учитывающим текущую цифру
M1: lea edx, [ebx - 'a'] ; Заносим в регистр EDX разность между кодом текущего символа и кодом символа 'a'
cmp edx, 'f' - 'a' ; Сравниваем значение в регистре EDX с 5
ja M2 ; Если выше, то переходим к следующему сравнению
sub bl, 'a' - 10d ; Иначе получаем число из кода символа
jmp M3 ; Переходим к действиям, учитывающим текущую цифру
M2: lea edx, [ebx - 'A'] ; Заносим в регистр EDX разность между кодом текущего символа и кодом символа 'A'
cmp edx, 'F' - 'A' ; Сравниваем значение в регистре EDX с 5
ja L2 ; Если выше, то завершаем процедуру. Результат не определён,
; т.к. был введён некорректный символ
sub bl, 'A' - 10d ; Иначе получаем число из кода символа
M3: sal eax, 4 ; Умножаем EAX на 16
add eax, ebx ; Прибавляем текущую цифру
inc esi ; Переходим к следующему символу
dec ecx ; Уменьшаем ECX на 1
jnz L1 ; Если ECX не равно 0, продолжаем цикл
L2: pop esi ; Восстанавливаем значения использовавшихся регистров
pop ebx
mov esp, ebp ; Освобождаем стек
pop ebp ; Восстанавливаем значение регистра EBP
ret
InputNumber endp
Процедура вывода числа в 16-ричной системе счисления. Процедура получает один параметр – выводимое число. Для вывода всегда формируется строка из 8-ми шестнадцатеричных цифр с лидирующими нулями. Поскольку количество символов заранее известно, они будут сразу же записываться в строку с конца, и инвертировать строку не придётся. Процедура предназначена для использования в консольном приложении и предполагает, что идентификатор устройства ввода был получен основной программой и сохранён в переменной hStdOut.
digits db '0123456789abcdef' ; Массив шестнадцатеричных цифр
OutputNumber proc
push ebp ; Сохраняем в стеке значение регистра EBP
mov ebp, esp ; Заносим в регистр EBP текущее значение вершины стека
sub esp, 12 ; Выделяем в стеке место под формируемую строку
push esi
; Преобразуем число в строку
mov eax, [ebp + 8] ; Заносим в регистр EAX переданный параметр
mov ecx, 8 ; Заносим в регистр ECX количество символов строки
mov byte ptr [ebp - 1], 10 ; Добавляем в конец строки символы с кодами 13 и 10 для перевода курсора
mov byte ptr [ebp - 2], 13
lea esi, [ebp - 3] ; Начиная с адреса [EBP - 3] будут заносится цифры
L3: mov edx, eax ; Копируем значение регистра EAX в регистр EDX
and edx, 1111b ; Получаем остаток от деления на 16
shr eax, 4 ; Делим исходное число на 16
mov dl, digits[edx] ; По полученному остатку от деления берём цифру ...
mov [esi], dl ; ... и записываем её в строку
dec esi ; Уменьшаем адрес, т.к. строка формируется с конца
dec ecx ; Уменьшаем ECX на 1
jnz L3 ; Если ECX не равно 0, продолжаем цикл
; Выводим строку
inc esi ; Регистр ESI указывает на начало строки
push 0
push 0
push 10
push esi
push hStdOut
call WriteConsole
pop esi
mov esp, ebp ; Освобождаем стек
pop ebp ; Восстанавливаем значение регистра EBP
ret 4 ; Удаляем из стека переданный параметр и возвращаемся
OutputNumber endp
Функция, находящая в одномерном массиве x сумму значений f(x[i]), где f – некоторая функция одного целочисленного аргумента, адрес которой передаётся через параметры. Функции используют соглашение о вызовах cdecl.
Sum proc
push ebp
mov ebp, esp
push esi
push edi
mov ecx, [ebp + 8] ; Заносим в ECX первый параметр – количество элементов массива
mov esi, [ebp + 12] ; Заносим в ESI второй параметр – адрес начала массива
mov edi, [ebp + 16] ; Заносим в EDI третий параметр – адрес функции
xor edx, edx ; Обнуляем регистр EDX
L: push [esi] ; Кладём в стек элемент массива
call edi ; Вызываем функцию, адрес которой находится в регистре EDI
add esp, 4 ; Освобождаем стек
add edx, eax ; Прибавляем результат функции к общей сумме
add esi, 4 ; Переходим к следующему элементу массива
dec ecx ; Уменьшаем значение регистра ECX на 1
jnz L ; Если ECX не равно 0, продолжаем цикл
mov eax, edx ; Записываем полученную сумму в регистр EAX,
; через который должен возвращаться результат функции
pop edi
pop esi
mov esp, ebp
pop ebp
ret
Sum endp
Sqr proc
mov eax, [esp + 4]
imul eax
ret
Sqr endp
Negation proc
mov eax, [esp + 4]
neg eax
ret
Negation endp
Для вызова функции Sum будет использовать следующая последовательность команд.
push Sqr
push offset a
push na
call Sum
add esp, 12
mov sa, eax
push Negation
push offset a
push na
call Sum
add esp, 12
mov sa, eax
Процедура, проверяющая сбалансированность круглых и квадратных скобок в строке. Строка должна заканчиваться нулём. Для проверки сбалансированности открывающие скобки будем класть в стек, а при нахождении в строке закрывающей скобки будем извлекать из стека последнюю положенную туда открывающую скобку и проверять, что она соответствует закрывающей скобке. Будем считать, что скобок в тексте меньше, чем других символов, поэтому после сравнения делаем переход «если равно», считая, что это событие менее вероятно. При любом выходе из процедуры нужно очистить стек. Поскольку мы не можем заранее знать, сколько скобок будет туда положено и сколько извлечено, восстановление значения регистра ESP можно сделать только с помощью регистра EBP. Процедура возвращает значение через регистр EAX: если скобки сбалансированы, регистр EAX будет содержать значение истина (-1), в противном случае регистр EAX будет содержать значение ложь (0).
Brackets proc
push ebx ; Сохраняем регистры
push ebp
mov ebp, esp ; Сохраняем начальное значение регистра ESP
mov ebx, [ebp + 12] ; Заносим в регистр EBX адрес начала строки
mov eax, -1 ; Заносим в регистр EAX предварительное значение результата
xor edx, edx ; Обнуляем регистр EDX
L1: mov dl, [ebx] ; Заносим в регистр DL очередной символ
test edx, edx ; Проверяем значение в регистре EDX
jz E1 ; Если EDX = 0, выходим из цикла
inc ebx ; Меняем адрес символа
cmp dl, '(' ; Сравниваем символ с открывающей круглой скобкой
je L2 ; Если равно, ...
cmp dl, '[' ; Сравниваем символ с открывающей квадратной скобкой
je L2 ; Если равно, ...
cmp dl, ')' ; Сравниваем символ с закрывающей круглой скобкой
je L3 ; Если равно, переходим к сравнению со скобкой из стека
cmp dl, ']' ; Сравниваем символ с закрывающей квадратной скобкой
je L4 ; Если равно, переходим к сравнению с другой скобкой из стека
jmp L1 ; Если символ – не скобка, возвращаемся к началу цикла
L2: push dx ; ... заносим открывающую скобку в стек (один байт записать в стек нельзя)
jmp L1 ; Возвращаемся к началу цикла
L3: cmp ebp, esp ; Если была закрывающая скобка, прежде всего проверяем, есть ли скобки в стеке –
; если мы положили что-то в стек, значение регистра ESP будет отличаться от регистра EBP
je E2 ; Если значения регистров равны, выходим из процедуры
pop cx ; Извлекаем из стека последнюю открывающую скобку
cmp cl, '(' ; Сравниваем
jne E2 ; Если скобки не равны, выходим из процедуры
jmp L1 ; Иначе возвращаемся к началу цикла
L4: cmp ebp, esp ; При нахождении закрывающей квадратной скобки,
je E2 ; выполняем те же действия, что и при нахождении закрывающей круглой скобки,
pop cx ; только скобку из стека сравниваем с другим значением
cmp cl, '[' ; Дублирование сделано для того, чтобы уменьшить
jne E2 ; количество переходов
jmp L1
E1: cmp ebp, esp ; При достижении конца строки, сравниваем регистры ESP и EBP
je E3 ; Если значения равны, обходим обнуление регистра EAX
E2: xor eax, eax ; Если была несбалансированность, обнуляем регистр EAX
E3: mov esp, ebp ; Восстанавливаем значение регистра ESP
pop ebp
pop ebx
ret
Brackets endp