1
1
Все процессы в машине на самом низком, аппаратном уровне приводятся в действие только
командами (инструкциями) машинного языка. Язык ассемблера – это символическое
представление машинного языка. Ассемблер позволяет писать короткие и быстрые программы.
Однако этот процесс чрезвычайно трудоёмкий. Для написания максимально эффективной
программы необходимо хорошее знание особенностей команд языка ассемблера, внимание и
аккуратность. Поэтому реально на языке ассемблера пишутся в основном программы, которые
должны обеспечить эффективную работу с аппаратной частью. Также на языке ассемблера
пишутся критичные по времени выполнения или расходованию памяти участки программы.
Впоследствии они оформляются в виде подпрограмм и совмещаются с кодом на языке высокого
уровня.
1. Регистры
Регистры можно разделить на регистры общего назначения, указатель команд, регистр флагов
и сегментные регистры.
FLAGS
1 1 Зар
3 0 Зар
5 0 Зар
12
IOPL I/O Privilege Level Уро
13
15 0 Зар
EFLAGS
21 ID ID Flag Про
22
... Зар
31
Как видно из рисунка, регистры 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).
Ещё один нюанс состоит в использовании регистров в качестве базы, т.е. хранилища адреса
оперативной памяти. В качестве регистров базы можно использовать любые регистры, но
желательно использовать регистры EBX, ESI, EDI или EBP. В этом случае размер машинной команды
обычно бывает меньше.
Флаг – это бит, принимающий значение 1 («флаг установлен»), если выполнено некоторое
условие, и значение 0 («флаг сброшен») в противном случае. Процессор имеет регистр флагов,
содержащий набор флагов, отражающий текущее состояние процессора.
Значение флагов CF, DF и IF можно изменять напрямую в регистре флагов с помощью специальных
инструкций (например, CLD для сброса флага направления), но нет инструкций, которые
позволяют обратиться к регистру флагов как к обычному регистру. Однако можно сохранять
регистр флагов в стек или регистр AH и восстанавливать регистр флагов из них с помощью
инструкций LAHF, SAHF, PUSHF, PUSHFD, POPF и POPFD.
Флаг знака SF равен значению старшего значащего бита результата, который является
знаковым битом в знаковой арифметике.
Флаги состояния позволяют одной и той же арифметической инструкции выдавать результат трёх
различных типов: беззнаковое, знаковое и двоично-десятичное (BCD) целое число. Если результат
считать беззнаковым числом, то флаг CF показывает условие переполнения (перенос или заём),
для знакового результата перенос или заём показывает флаг OF, а для BCD-результата
перенос/заём показывает флаг AF. Флаг SF отражает знак знакового результата, флаг ZF отражает и
беззнаковый, и знаковый нулевой результат.
Инструкции условного перехода Jcc (переход по условию cc), SETcc (установить значение байта-
результата в зависимости от условия cc), LOOPcc (организация цикла) и CMOVcc (условное
копирование) используют один или несколько флагов состояния для проверки условия. Например,
инструкция перехода JLE (jump if less or equal – переход, если «меньше или равно») проверяет
условие «ZF = 1 или SF ≠ OF».
Флаг трассировки TF – установка этого флага разрешает пошаговый режим отладки, когда
после каждой выполненной инструкции происходит прерывание программы и вызов
специального обработчика прерывания.
Флаг вложенности задач NT – этот флаг устанавливается, когда текущая задача «вложена»
в другую, прерванную задачу, и сегмент состояния TSS текущей задачи обеспечивает
обратную связь с TSS предыдущей задачи. Флаг NT проверяется инструкцией IRET для
определения типа возврата – межзадачного или внутризадачного.
Флаг проверки выравнивания AC – установка этого флага вместе с битом AM в регистре CR0
включает контроль выравнивания операндов при обращениях к памяти: обращение к
невыравненному операнду вызывает исключительную ситуацию.
Процессор имеет 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.
Каждая программа имеет область памяти, называемую стеком. Стек используется для передачи
параметров в процедуры и для хранения локальных данных процедур. Как известно, стек – это
область памяти, при работе с которой необходимо соблюдать определённые правила, а именно:
данные, которые попали в стек первыми, извлекаются оттуда последними. С другой стороны, если
программе выделена некоторая память, то нет никаких физических ограничений на чтение и
запись. Как же совмещаются два этих противоречивых принципа?
Пусть у нас есть функция 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, которое было до её вызова.
Каждая программа имеет область данных, где размещаются глобальные переменные. Почему же
локальные данные хранятся именно в стеке? Это делается для уменьшения объёма памяти
занимаемого программой. Если программа будет последовательно вызывать несколько
процедур, то в каждый момент времени будет отведено место только под данные одной
процедуры, т.к. стек занимается и освобождается. Область данных существует всё время работы
программы. Если бы локальные данные размещались в области данных, пришлось бы отводить
место под локальные данные для всех процедур программы.
2.1. Идентификаторы
В программе на языке ассемблера целые числа могут быть записаны в двоичной, восьмеричной,
десятичной и шестнадцатеричной системах счисления. Для задания системы счисления в конце
числа ставится буква b, o/q, d или h соответственно. Шестнадцатеричные числа, которые
начинаются с «буквенной» цифры, должны предваряться нулём, иначе компилятор не сможет
отличить число от идентификатора. Примеры чисел см. в разделе 2.6.
Символы и строки в языке ассемблера могут заключаться в апострофы или двойные кавычки. Если
в качестве символа или внутри строки надо указать апостроф или кавычку, то делается это
следующим образом: если символ или строка заключены в апострофы, то апостроф надо
удваивать, а кавычку удваивать не надо, и наоборот, если символ или строка заключены в
двойные кавычки, то надо удваивать кавычку и не надо удваивать апостроф. Все следующие
примеры корректны и эквивалентны: 'don''t', 'don"t', "don't", "don""t".
2.4. Комментарии
Комментарии в языке ассемблера начинаются с символа «точка с запятой» и могут начинаться как
в начале строки, так и после команды.
Все вхождения имени заменяются операндом. Операндом может быть константное выражение,
строка, другое имя.
Языки высокого уровня обычно являются типизированными. Каждая переменная имеет тип,
который накладывает ограничения на операции над переменной и на использование в одном
выражении переменных разных типов. Кроме того, языки высокого уровня позволяют работать со
сложными типами, таким как указатели, записи/структуры, классы, массивы, строки, множества и
т.п.
Язык Паскаль имеет достаточно жёсткую структуру типов. Присваивания между переменными
разных типов минимальны, над указателями определены только операции присваивания, взятия
значения и получение адреса. Поддерживается много сложных типов.
Язык С, который создавался как высокоуровневая замена языку ассемблера, имеет гораздо менее
жёсткую структуру типов. Все целочисленные типы совместимы, тип char, конечно, хранит
символы, но также сопоставим с целыми типами, логический тип отсутствует в принципе (для
языка С это именно так!), над указателями определены операции сложения и вычитания. Сложные
типы, такие как массивы, строки и множества, не поддерживаются.
Что касается языка ассемблера, то тут вообще вряд ли можно говорить о какой-либо структуре
типов. Команды языка ассемблера оперируют объектами, существующими в оперативной памяти,
т.е. байтом и его производными (слово, двойное слово и т.д.). Символьный, логический тип? Какая
глупость! Указатели? Вот тебе 4 байта и делай с ними, что хочешь. В итоге, конечно, и можно
сделать, что хочешь, только предварительно стоит хорошо подумать, что из этого получится.
Все директивы могут быть использованы как для объявления простых переменных, так и для
объявления массивов. Хотя для определения строк, в принципе, можно использовать любую
директиву, в связи с особенностями хранения данных в оперативной памяти лучше использовать
директиву DB.
Если в качестве операнда указывается строка или если указано несколько операндов через
запятую, то память отводится под несколько переменных указанного типа, т.е. получается массив.
При этом именованным оказывается только первый элемент, а доступ к остальным элементам
массива осуществляется с помощью выражения <имя> + <смещение>.
Для того чтобы не указывать несколько раз одно и то же значение, при инициализации массивов
можно использовать конструкцию повторения DUP.
К переменным можно применить две операции – offset и type. Первая определяет адрес
переменной, а вторая – размер переменной. Однако размер переменной определяется по
директиве, и даже если с директивой, например, DD определён массив из нескольких элементов,
размер всё равно будет равен 4.
2.7. Команды
Команды языка ассемблера – это символьная форма записи машинных команд. Команды имеют
следующий синтаксис:
Метки нужны для ссылок на команды из других мест, например, в командах перехода.
Компилятор языка ассемблера заменяет метки адресами команд.
Мнемокод – это служебное слово, указывающее операцию, которая должна быть выполнена. Язык
ассемблера использует не цифровые коды операций, а мнемокоды, которые легче запоминаются.
Мнемокод является обязательной частью команды.
Имя переменной, по сути, является адресом этой переменной. Встретив имя переменной в
операндах команды, компилятор понимает, что нужно обратиться к оперативной памяти
по определённому адресу. Обычно адрес в команде указывается в квадратных скобках, но
имя переменной является исключением и может быть указано как в квадратных скобках,
так и без них. Например, для обращения к переменной x в команде можно
указать x или [x].
Если переменная была объявлена как массив, то к элементу массива можно обратиться,
указав имя и смещение. Для этого существует ряд синтаксических форм,
например: <имя>[<смещение>] и [<имя> + <смещение>] (см. раздел 5). Однако следует
понимать, что смещение – это вовсе не индекс элемента массива. Индекс элемента
массива – это его номер, и этот номер не зависит от размера самого элемента. Смещение
же задаётся в байтах, и при задании смещения программист сам должен учитывать размер
элемента массива.
Адрес ячейки памяти может храниться в регистре. Для обращения к памяти по адресу,
хранящемуся в регистре, в команде указывается имя регистра в квадратных скобках,
например: [ebx]. Как уже говорилось, в качестве регистров базы рекомендуется
использовать регистры EBX, ESI, EDI и EBP.
Адрес может быть вычислен по определённой формуле. Для этого в квадратных скобках
можно указывать достаточно сложные выражения, например, [ebx + ecx] или [ebx + 4 *
ecx].
Одна из основных команд языка ассемблер – это команда пересылки. С её помощью можно
записать в регистр значение другого регистра, константу или значение ячейки памяти, а также
можно записать в ячейку памяти значение регистра или константу. Команда имеет следующий
синтаксис:
По команде MOV значение второго операнда записывается в первый операнд. Операнды должны
иметь одинаковый размер. Команда не меняет флаги.
mov eax, ebx ; Пересылаем значение регистра EBX в регистр EAX mov eax, 0ffffh ; Записываем в
регистр EAX шестнадцатеричное значение ffff mov x, 0 ; Записываем в переменную x значение 0
mov eax, x ; Переслать значение из одной ячейки памяти в другую нельзя. mov y, eax ; Но можно
использовать две команды MOV.
На самом деле процессор имеет много команд пересылки – код команды зависит от того, куда и
откуда пересылаются данные. Но компилятор языка ассемблера сам выбирает нужный код в
зависимости от операндов, так что, с точки зрения программиста, команда пересылки только
одна.
Каждый из операндов может быть регистром или ячейкой памяти. Однако переставить
содержимое двух регистров можно, а двух ячеек памяти – нет. Операнды должны иметь
одинаковый размер. Команда не меняет флаги.
Как было сказано, операнды команды 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 [ebx], 0 ; Ошибка, т.к. 0 может иметь любой размер mov byte ptr [ebx], 0 ; Пересылаем 1 байт
mov dword ptr [ebx], 0 ; Пересылаем 4 байта
Операндом может быть регистр или ячейка памяти любого размера. Команды меняют флаги AF,
OF, PF, SF и ZF. Команды инкремента и декремента выгодны тем, что они занимают меньше места,
чем соответствующие команды сложения и вычитания.
inc eax
NEG <операнд>
Операндом может быть регистр или ячейка памяти любого размера. Команда NEG рассматривает
свой операнд как число со знаком и меняет знак операнда на противоположный. Команда меняет
флаги AF, CF, OF, PF, SF и ZF.
MUL <операнд>
Операнд, указываемый в команде, – это один из сомножителей. Он может быть регистром или
ячейкой памяти, но не может быть непосредственным операндом.
Команда меняет флаги 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
Команда знакового умножения имеет несколько вариантов. Первый соответствует команде MUL –
один из сомножителей указывается в команде, второй должен находиться в регистре EAX/AX/AL, а
результат помещается в регистры EDX:EAX/DX:AX/AX.
Второй вариант команды IMUL позволяет указать регистр, который будет содержать один из
сомножителей. В этот же регистр будет помещён результат. Второй сомножитель указывается
непосредственно в команде.
Третий вариант команды IMUL позволяет указать и результат, и оба сомножителя. Однако
результат может быть помещён только в регистр, а второй сомножитель может быть только
непосредственным операндом. Первый сомножитель может быть регистром или ячейкой памяти.
Четвёртый вариант команды IMUL позволяет указать оба сомножителя. Первый должен быть
регистром, а второй – регистром или ячейкой памяти. Результат помещается в регистр,
являющийся первым операндом.
Команда IMUL устанавливает флаги так же, как и команда MUL. Однако расширение результата в
регистр EDX/DX происходит только при использовании первого варианта команды IMUL. В
остальных случаях часть произведения, не помещающаяся в регистр-результат, теряется, даже
если в качестве результата указан регистр EAX/AX. При умножении двух 1-байтовых чисел,
произведение которых больше байта, но меньше слова, в регистре-результате получается
корректное произведение.
mov eax, 5 mov ebx, -7 imul ebx ; EAX = ffffffdd, EDX = ffffffff, CF = 0 mov ebx, 3 imul ebx, 6 ; EBX = EBX *
6 mov ebx, 500000 imul eax, ebx, 100000 ; EAX = EBX * 100000, старшая часть результата теряется x dd
40 mov eax, 55 imul eax, x ; EAX = EAX * x
Если делитель имеет размер 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
В операциях деления размер делимого в два раза больше, чем размер делителя. Поэтому нельзя
просто загрузить данные в регистр EAX и поделить его на какое-либо значение, т.к. в операции
деления будет задействован также и регистр EDX. Поэтому прежде чем выполнять деление, надо
установить корректное значение в регистр EDX, иначе результат будет неправильным. Значение
регистра EDX должно зависеть от значения регистра EAX. Тут возможны два варианта – для
знаковых и беззнаковых чисел.
Если мы используем беззнаковые числа, то в любом случае в регистр EDX необходимо записать
значение 0: aaaaaaaah → 00000000aaaaaaaah.
Если же мы используем знаковые числа, то значение регистра EDX будет зависеть от знака
числа: 55555555h → 0000000055555555h, aaaaaaaah → ffffffffaaaaaaaah.
Записать значение 0 не сложно, а вот для знакового расширения необходимо анализировать знак
числа. Однако нет необходимости делать это вручную, т.к. язык ассемблера имеет ряд команд,
позволяющих расширять байт до слова, слово до двойного слова и двойное слово до
учетверённого слова.
Таким образом, если делитель имеет размер 2 или 4 байта, то нужно устанавливать значение не
только регистра AX/EAX, но и регистра DX/EDX. Если же делитель имеет размер 1 байт, то можно
просто записать делимое в регистр AX.
x dd ? mov eax, x ; Заносим в регистр EAX значение переменной x, которое заранее неизвестно
cdq ; Знаковое расширение EAX в EDX:EAX mov ebx, 7 idiv ebx
В языке ассемблера существуют также команды, позволяющие занести в регистр значение другого
регистра или ячейки памяти со знаковым или беззнаковым расширением.
MOVSX <операнд1>, <операнд2> ; Знаковое расширение – старшие биты заполняются знаковым
битом MOVZX <операнд1>, <операнд2> ; Беззнаковое расширение – старшие биты заполняются
нулём
Операнд1 и операнд2 могут иметь любой размер. Понятно, что операнд1 должен быть больше,
чем операнд2. В случае равенства размера операндов следует использовать обычную команду
пересылки MOV, которая выполняется быстрее.
; Первый вариант 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. Переходы и циклы
JMP <операнд>
Операнд указывает адрес перехода. Существует два способа указания этого адреса,
соответственно различают прямой и косвенный переходы.
Если в команде перехода указывается метка команды, на которую надо перейти, то переход
называется прямым.
Вообще, любой переход заключается в изменении адреса следующей исполняемой команды, т.е.
в изменении значения регистра EIP. Казалось бы, в команде перехода должен задаваться именно
адрес перехода. Однако в команде прямого перехода задаётся не абсолютный адрес, а разность
между адресом перехода и адресом команды перехода. Действие команды перехода заключается
в прибавлении этой величины к текущему значению регистра EIP2. Операнд команды перехода
рассматривается как поле со знаком, поэтому при сложении его со значением регистра EIP
значение в этом регистре может как увеличиться, так и уменьшиться, т.е. возможен переход и
вперёд, и назад.
Запись в команде перехода не абсолютного, а относительного адреса перехода позволяет
уменьшить размер команды перехода. Абсолютный адрес должен быть 32-битным, а
относительный может быть и 8-битным, и 16-битным.
При косвенном переходе в команде перехода указывается не адрес перехода, а регистр или
ячейка памяти, где этот адрес находится. Содержимое указанного регистра или ячейки памяти
рассматривается как абсолютный адрес перехода. Косвенные переходы используются в тех
случаях, когда адрес перехода становится известен только во время работы программы.
jmp ebx
Команда сравнения эквивалентна команде SUB за исключением того, что вычисленная разность
никуда не заносится. Назначение команды CMP – установка и сброс флагов.
Что касается команд условного перехода, то их достаточно много, но все они записываются
единообразно:
Jxx <метка>
В первую группу входят команды, которые обычно ставятся после команды сравнения. В их
мнемокодах указывается тот результат сравнения, при котором надо делать переход.
mov eax, x cmp eax, y jge/jae L ; Используем JGE для знаковых чисел и JAE – для беззнаковых mov
eax, y L: mov z, eax
Во вторую группу команд условного перехода входят те, которые обычно ставятся после команд,
отличных от команды сравнения, и которые реагируют на то или иное значение какого-либо флага.
JZ ZF = 1
JS SF = 1
JC CF = 1
JO OF = 1
JP PF = 1
И, наконец, в третью группу входят две команды условного перехода, проверяющие не флаги, а
значение регистра 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
Команда LOOP требует, чтобы в качестве счётчика цикла использовался регистр ECX. Собственно,
команда LOOP вычитает единицу именно из этого регистра, сравнивает полученное значение с
нулём и осуществляет переход на указанную метку, если значение в регистре ECX больше 0. Метка
определяет смещение перехода, которое не может превышать 128 байт.
При использовании команды LOOP следует также учитывать, что с её помощью реализуется цикл с
постусловием, следовательно, тело цикла выполняется хотя бы один раз. Хуже того, если до
начала цикла записать в регистр ECX значение 0, то при вычитании единицы, которое выполняется
до сравнения с нулём, в регистре ECX окажется ненулевое значение, и цикл будет выполняться
232 раз.
Эти команды похожи на команду LOOP, но позволяют также организовать и досрочный выход из
цикла.
Действие этой команды можно описать следующим образом: ECX = ECX - 1; if (ECX != 0 && ZF == 1)
goto <метка>;
По какой именно причине произошёл выход из цикла надо проверять после цикла. Причём надо
проверять флаг ZF, а не регистр ECX, т.к. условие ZF = 0 может появиться как раз на последнем шаге
цикла, когда и регистр ECX стал нулевым.
Рассмотрим пример: пусть в регистре 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. Массивы
Как уже было сказано, массивы в языке ассемблера описываются по директивам определения
данных с использованием конструкции повторения (см. раздел 2.6). Для того чтобы обратиться к
элементу массива, необходимо так или иначе указать адрес начала массива и смещение элемента
в массиве. Смещение первого элемента массива всегда равно 0. Смещения остальных элементов
массива зависят от размера элементов.
Пусть X – некий массив. Тогда адрес элемента массива можно вычислить по следующей формуле:
Для удобства в языке ассемблера введена операция модификации адреса, которая схожа с
индексным выражением в языках высокого уровня – к имени массива надо приписать
целочисленное выражение или имя регистра в квадратных скобках:
x[4] x[ebx]
Обратите внимание, что при использовании регистра для модификации адреса наличие
квадратных скобок обязательно. В противном случае компилятор зафиксирует ошибку.
База – это регистр или имя переменной. Индекс должен быть записан в некотором
регистре. Множитель – это константа 1 (можно опустить), 2, 4 или 8. Смещение – целое
положительное или отрицательное число.
Команда не меняет флаги. В простейшем случае с помощью команды LEA можно загрузить в
регистр адрес переменной или начала массива:
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
2. В регистре можно хранить номер элемента массива и умножать его на размер элемента.
mov eax, 0 mov ecx, n L: dec ecx add eax, x[ecx * type x] cmp ecx, 0 jne L
3. В регистре можно хранить адрес элемента массива. Адрес начала массива можно записать
в регистр с помощью команды 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
4. При необходимости можно в один регистр записать адрес начала массива, а в другой –
номер или смещение элемента массива.
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. Поразрядные операции
NOT <операнд>
Операция поразрядное «и» выполняет логическое умножение всех пар бит операндов.
Операция поразрядное «или» выполняет логическое сложение всех пар бит операндов.
OR <операнд1>, <операнд2>
Операция поразрядное исключающее «или» выполняет сложение по модулю 2 всех пар бит
операндов.
Операции AND, OR и XOR имеют по два операнда. Первый может быть регистром или ячейкой
памяти, а второй – регистром, ячейкой памяти или непосредственным операндом. Операнды
должны иметь одинаковый размер. Результат помещается на место первого операнда. Операции
меняют флаги CF, OF, PF, SF и ZF.
xor eax, eax ; При любом значении EAX результат будет равен 0
Операцию XOR можно также использовать для обмена значений двух переменных.
xor eax, ebx ; EAX = EAX xor EBX xor ebx, eax ; Теперь EBX содержит исходное значение EAX xor eax,
ebx ; А теперь EAX содержит исходное значение EBX
Операции сдвига вправо и сдвига влево сдвигают биты в переменной на заданное количество
позиций. Каждая команда сдвига имеет две разновидности:
Первый операнд должен быть регистром или ячейкой памяти. Именно в нём осуществляется
сдвиг. Второй операнд определяет количество позиций для сдвига, которое задаётся
непосредственным операндом или хранится в регистре CL (и только CL).
При логическом сдвиге «освобождающиеся» биты заполняются нулями. Последний ушедший бит
сохраняется во флаге CF.
SHL <операнд>, <количество> ; Логический сдвиг влево SHR <операнд>, <количество> ; Логический
сдвиг вправо
Арифметический сдвиг влево эквивалентен логическому сдвигу влево (это одна и та же команда)
– «освобождающие» биты заполняются нулями. При арифметическом сдвиге
вправо «освобождающиеся» биты заполняются знаковым битом. Последний ушедший бит
сохраняется во флаге CF.
Команда SHLD сдвигает влево биты операнда1 на указанное количество позиций. Младшие
(«освободившиеся») биты операнда1 заполняются старшими битами операнда2. Сам операнд2 не
меняется.
Команда SHRD сдвигает вправо биты операнда1 на указанное количество позиций. Старшие
(«освободившиеся») биты операнда1 заполняются младшими битами операнда2. Сам операнд2 не
меняется.
Для любой системы счисления сдвиг числа влево или вправо соответствует умножению или
делению на основание системы счисления в некоторой степени. Двоичная система счисления,
используемая в компьютере, не является исключением. Причём команды сдвига работают на
порядок быстрее обычных операций умножения и деления.
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 ; Большое беззнаковое число стало гораздо меньше
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. Деление
Для деления используется сдвиг вправо. При делении нет проблем с переполнением, но для
знаковых и беззнаковых чисел надо использовать разные механизмы.
; Деление на 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 раз быстрее, чем деление.
Для беззнаковых и положительных чисел остаток от деления на 2n – это последние n бит числа.
Поэтому для получения остатка от деления на 2n нужно выделить эти последние n бит с помощью
операции AND.
mov eax, x neg eax and eax, 1111b ; EAX = EAX % 24 neg eax
7. Программа. Процедуры
.686 .model flat, stdcall option casemap: none .data <инициализированные данные> .data?
<неинициализированные данные> .const <константы> .code <метка> <код> end <метка>
Директива .model позволяет указывать используемую модель памяти и соглашение о вызовах. Как
уже было сказано, на архитектуре Win32 используется только одна модель памяти – flat, что и
указано в приведённом примере. Соглашения о вызовах определяют порядок передачи
параметров и порядок очистки стека.
Директива option casemap: none заставляет компилятор языка ассемблера различать большие и
маленькие буквы в метках и именах процедур.
Директивы .data, .data?, .const и .code определяют то, что называется секциями. В Win32 нет
сегментов, но адресное пространство можно поделить на логические секции. Начало одной
секции отмечает конец предыдущей. Есть две группы секций: данных и кода.
Секция .const содержит объявления констант, используемых программой. Константы не могут быть
изменены. Попытка изменить константу вызывает аварийное завершение программы.
Есть только одна секция для кода: .code. В ней содержится весь код.
Предложения <метка> и end <метка> устанавливают границы кода. Обе метки должны быть
идентичны. Весь код должен располагаться между этими предложениями.
Любая программа под Windows должна, как минимум, корректно завершится. Для этого
необходимо вызвать функцию Win32 API ExitProcess.
.686 .model flat, stdcall option casemap: none include \masm32\include\[Link] include \masm32\
include\[Link] includelib \masm32\lib\[Link] .code program: push 0 call ExitProcess end
program
Выше приведён пример минимальной программы на языке ассемблера, которая делает только
одно – корректно завершается. В ней появились две новые директивы: include и includelib. Первая
позволяет включать в программу файлы, содержащие прототипы процедур, а также определения
констант и структур, которые могут понадобиться для программирования под Win32. Вторая
директива указывает, какие библиотеки использует программа. Компоновщик должен будет
прилинковать их. Без указания включаемого файла [Link] и библиотеки
импорта [Link] невозможно будет вызвать процедуру ExitProcess. Файл [Link] в данном
случае включать не обязательно, но он требуется достаточно часто, а включаемые файлы не
увеличивают размер получаемой программы.
Команда PUSH кладёт в стек параметр для процедуры ExitProcess. Этот параметр определяет код
завершения. Значение 0 – это код нормального завершения программы.
Работа со стеком имеет непосредственное отношение к процедурам, т.к. стек используется для
передачи параметров и для хранения локальных данных процедур. В принципе, для работы со
стеком существуют всего две операции: положить данные и взять данные. Для каждой операции
существует несколько команд, которые отличаются тем, с какими данными они работают.
Операнд может быть регистром, ячейкой памяти или непосредственным операндом. Размер
операнда должен быть 2 или 4 байта. Операнд кладётся на вершину стека, а значение регистра
ESP уменьшается на размер операнда.
POP <операнд>
Операнд может быть регистром или ячейкой памяти. Размер операнда должен быть 2 или 4 байта.
В соответствии с размером операнда из вершины стека берутся 2 или 4 байта и помещаются в
указанный регистр или ячейку памяти. Значение регистра ESP увеличивается на размер операнда.
Кроме этих основных команд существуют ещё команды, которые позволяют сохранять в стеке и
восстанавливать из стека содержимое всех регистров общего назначения, и команды, которые
позволяют сохранять в стеке и восстанавливать из стека содержимое регистра флагов.
PUSHA PUSHAD
Команда PUSHA сохраняет в стеке содержимое регистров AX, CX, DX, BX, SP, BP, SI, DI.
Команда PUSHAD сохраняет в стеке содержимое регистров EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI. Для
регистра (E)SP сохраняется значение, которое было до того, как мы положили регистры в стек.
После этого значение регистра (E)SP изменяется как обычно.
POPA POPAD
PUSHF PUSHFD
Команда PUSHF сохраняет в стеке младшие 16 бит регистра флагов. Команда PUSHFD сохраняет в
стеке все 32 бита регистра флагов.
POPF POPFD
Несмотря на то, что после имени процедуры не ставится двоеточие, это имя является меткой,
обозначающей первую команду процедуры.
В языке ассемблера имена и метки, описанные в процедуре, не локализуются внутри неё, поэтому
они должны быть уникальны.
Размещать процедуру в программе на языке ассемблера следует таким образом, чтобы команды
процедуры выполнялись не сами по себе, а только тогда, когда происходит обращение к
процедуре. Обычно процедуры размещают либо в конце секции кода после вызова
функции ExitProcess, либо в самом начале секции кода, сразу после директивы .code.
Вызов процедуры – это, по сути, передача управления на первую команду процедуры. Для
передачи управления можно использовать команду безусловного перехода на метку, являющуюся
именем процедуры. Можно даже не использовать директивы proc и endp, а написать обычную
метку с двоеточием после вызова функции ExitProcess.
С возвратом из процедуры дело обстоит сложнее. Дело в том, что обращаться к процедуре можно
из разных мест основной программы, а потому и возврат из процедуры должен осуществляться в
разные места. Сама процедура не знает, куда надо вернуть управление, зато это знает основная
программа. Поэтому при обращении к процедуре основная программа должна сообщить ей адрес
возврата, т.е. адрес той команды, на которую процедура должна сделать переход по окончании
своей работы. Поскольку при разных обращениях к процедуре будут указываться разные адреса
возврата, то и возврат управления будет осуществляться в разные места программы. Адрес
возврата принято передавать через стек.
.686 .model flat, stdcall option casemap: none include \masm32\include\[Link] include \masm32\
include\[Link] includelib \masm32\lib\[Link] .code program: push L jmp Procedure L: nop
push 0 call ExitProcess Procedure: pop eax jmp eax end program
Однако так обычно не делают – система команд языка ассемблера включает специальные
команды для вызова процедуры и возврата из процедуры.
Команда CALL записывает адрес следующей за ней команды в стек и осуществляет переход на
первую команду указанной процедуры. Команда RET считывает из вершины стека адрес и
выполняет переход по нему.
.686 .model flat, stdcall option casemap: none include \masm32\include\[Link] include \masm32\
include\[Link] includelib \masm32\lib\[Link] .code program: call Procedure push 0 call
ExitProcess Procedure proc ret Procedure endp end program
Если процедура получает небольшое число параметров, идеальным местом для их передачи
оказываются регистры. Существуют соглашения о вызовах, предполагающие передачу параметров
через регистры ECX и EDX. Этот метод самый быстрый, но он удобен только для процедур с
небольшим количеством параметров.
2. Параметры можно передавать в глобальных переменных.
Передача параметров через стек – наиболее распространённых способ. Именно его используют
языки высокого уровня, такие как С++ и Паскаль. Параметры помещаются в стек непосредственно
перед вызовом процедуры.
При внимательном анализе этого метода передачи параметров возникает сразу два вопроса: кто
должен удалять параметры из стека, процедура или вызывающая её программа, и в каком
порядке помещать параметры в стек. В обоих случаях оказывается, что оба варианта имеют свои
«за» и «против». Если стек освобождает процедура, то код программы получается меньшим, а
если за освобождение стека от параметров отвечает вызывающая программа, то становится
возможным вызвать несколько функций с одними и теми же параметрами просто
последовательными командами CALL. Первый способ, более строгий, используется при
реализации процедур в языке Паскаль, а второй, дающий больше возможностей для
оптимизации, – в языке С++.
Основное соглашение о вызовах языка Паскаль предполагает, что параметры кладутся в стек в
прямом порядке. Соглашения о вызовах языка С++, в том числе одно из основных соглашений о
вызовах ОС Windows stdcall, предполагают, что параметры помещаются в стек в обратном
порядке. Это делает возможной реализацию функций с переменным числом параметров (как,
например, printf). При этом первый параметр определяет число остальных параметров.
В приведённом выше участке кода в стек кладутся несколько параметров и затем вызывается
процедура. Следует помнить, что команда CALL также кладёт в стек адрес возврата. Таким
образом, перед выполнением первой команды процедуры стек будет выглядеть следующим
образом.
Адрес возврата оказывается в стеке поверх параметров. Однако поскольку в рамках своего участка
стека процедура может обращаться без ограничений к любой ячейки памяти, нет необходимости
перекладывать куда-то адрес возврата, а потом возвращать его обратно в стек. Для обращения к
первому параметру используют адрес [ESP + 4] (прибавляем 4, т.к. на архитектуре Win32 адрес
имеет размер 32 бита), для обращения ко второму параметру – адрес [ESP + 8] и т.д.
.686 .model flat, stdcall option casemap: none include \masm32\include\[Link] include \masm32\
include\[Link] includelib \masm32\lib\[Link] .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
Для передачи результата процедуры обычно используется регистр EAX. Этот способ используется
не только в программах на языке ассемблера, но и в программах на языке С++. Объекты, имеющие
размер не более 8 байт, могут передаваться через регистровую пару EDX:EAX. Вещественные числа
передаются через вершину стека вещественных регистров. Если эти способы не подходят, то
следует передать в качестве параметра адрес ячейки памяти, куда будет записан результат.
; Передача параметров через стек, возврат результата через регистр EAX .686 .model flat, c option
casemap: none include \masm32\include\[Link] include \masm32\include\[Link] includelib
\masm32\lib\[Link] .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\[Link]
include \masm32\include\[Link] includelib \masm32\lib\[Link] .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
Особенно внимательно следует относиться к регистрам 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 переписывают в регистр 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.
Рекурсия – ресурсоёмкий способ реализации алгоритмов. Она требует много места для хранения
локальных данных на каждом шаге рекурсии, кроме того, рекурсивные процедуры обычно
выполняются не очень быстро. Поэтому языку ассемблера, предназначенному для написания
быстрых программ, рекурсия, в общем, не свойственна. Но при желании и на ассемблере можно
написать рекурсивную процедуру. Принципы реализации рекурсивной процедуры на языке
ассемблера такие же, как и на других языках. В процедуре должна быть терминальная ветвь, в
которой нет рекурсивного вызова, и рабочая ветвь.
При реализации рекурсивных процедур становится особенно важным использование стека для
передачи параметров и адреса возврата, что позволяет хранить данные, относящиеся к разным
уровням рекурсивных вызовов, в разных областях памяти.
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
Выбор оптимального алгоритма для решения задачи всегда приводит к лучшим результатам, чем
любой другой вид оптимизации. Действительно, при замене пузырьковой сортировки, время
выполнения которой пропорционально n2, на быструю сортировку, время выполнения которой
пропорционально n * log(n), вторая программа будет выполняться быстрее в подавляющем
большинстве случаев, как бы она ни была реализована. Поиск лучшего алгоритма – универсальная
стадия, и она относится не только к ассемблеру, но и к любому языку программирования, поэтому
будем считать, что оптимальный алгоритм уже выбран.
Самым очевидным и самым важным правилом при создании цикла на любом языке
программирования является вынос всех переменных, которые не изменяются на протяжении
цикла, за его пределы. В случае ассемблера имеет смысл также по возможности разместить все
переменные, которые будут использоваться внутри цикла, в регистры, а старые значения нужных
после цикла регистров сохранить в стеке.
; 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 такт). Вроде бы и немного, но
если цикл окажется внутри другого цикла, то все эти лишние такты будут повторяться многократно.
Кроме того, цикл с постусловием содержит на одну команду меньше.
Конечно, цикл с постусловием всегда выполняется хотя бы один раз, и во многих случаях перед
циклом приходится добавлять ещё одну проверку, но в любом случае даже небольшое
уменьшение тела цикла всегда оказывается необходимой операцией.
Циклы, в которых значение счётчика растёт от единицы или нуля до некоторой величины, можно
реализовать вообще без операции сравнения, выполняя цикл в обратном направлении. Флаги
меняются не только командой сравнения, но и многими другими. В частности,
команда 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, поэтому выходим из цикла
Для небольших циклов время выполнения проверки условия и перехода на начало цикла может
оказаться значительным по сравнению со временем выполнения самого тела цикла. В таких
случаях можно вообще не создавать цикл, а просто повторить его тело нужное число раз
(разумеется, только в случае, если нам заранее известно это число!). Для очень коротких циклов
можно, например, удваивать или утраивать тело цикла, если, конечно, число повторений кратно
двум или трём. Кроме того, бывает удобно часть работы сделать в цикле, а часть развернуть.
; Цикл от 10 до -1 mov edx, 10 loop_start: <тело цикла> dec edx jns loop_start ; Выходим из цикла,
когда EDX станет равны -1 <тело цикла> ; Но повторяем тело цикла ещё раз
Так как современные процессоры используют весьма сложный набор команд, большинство
операций можно выполнить на низком уровне очень многими способами. При этом иногда
оказывается, что наиболее очевидный способ – не самый быстрый. Часто простыми
перестановками команд, зная механизм выполнения команд на современных процессорах,
можно заставить ту же процедуру выполняться на 50–200% быстрее. Разумеется, переходить к
этому уровню оптимизации можно только после того, как текст программы окончательно написан
и максимально оптимизирован на среднем уровне.
Используйте короткую форму команды JMP, где возможно (jmp short <метка>).
Команда LEA быстро выполняется и имеет много неожиданных применений (см. раздел
8.3.2).
Многие одиночные команды, как это ни странно, выполняются дольше, чем две или три
команды, приводящие к тому же результату. Это может быть связано с различными
особенностями выполнения команд, в том числе, с возможностью/невозможность
попарного выполнения команд в разных конвейерах (см. раздел 8.3.3).
Старайтесь выравнивать данные и метки по адресам, кратным 2/4/8/16 (см. раздел 8.3.4).
Если команда обращается к 32-битному регистру, например ЕАХ, сразу после команды,
выполнявшей запись в соответствующий частичный регистр (АХ, AL, АН), может
происходить пауза в один или несколько тактов.
Команда LEA может использоваться для трёхоперандного сложения (но только сложения, а
не вычитания).
Команда LEA может использоваться для сложения значения регистра с константой или
вычитания константы из значения регистра. В данном случае вычитание возможно, т.к. оно
рассматривается как сложение с отрицательной константой. Результат может быть
помещён в тот же или другой регистр (кроме регистра ESP). Такой способ используется для
сохранения флагов, т.к. команда LEA, в отличие от команд ADD, SUB, INC и DEC, не меняет
флаги.
lea eax, [eax * 4 + eax] ; EAX = EAX * 5 lea eax, [ebx * 8 + ecx – 32]
Вместо команды AND лучше использовать команду TEST, если нужен не результат, а
проверка. Команда TEST лучше спаривается. Команда TEST также может быть использована
для проверки на равенство нулю.
Если за командой CALL сразу же следует команда RET, замените эти команды
командой JMP. Вызываемая процедура осуществит возврат по адресу возврата,
переданному вызывающей процедуре.
Команду CBW можно заменить засылкой нуля, если расширяемое число положительное.
Команду CDQ можно заменить засылкой нуля, если расширяемое число положительное,
или парой команд MOV + SAR, если знак расширяемого числа не известен. Недостаток –
команды XOR и SAR меняют флаги.
cdq xor edx, edx cdq mov edx, eax sar edx, 31
xor eax, eax ; EAX = 0 при любом значении EAX, которое было до этой команды
Не используйте команду MOVZX для чтения байта – это требует 3 тактов для выполнения.
Заменой может служить такая пара команд, выполняющаяся за 2 такта:
mov x, 1 mov eax, 1 mov x, eax mov [ebx], 1 mov eax, 1 mov [ebx], eax
Аналогично команды 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,
будут размещены по адресу, кратному указанному числу.
9. Примеры
NOD proc N1: cmp eax, edx ; Сравниваем числа je N3 ; Если числа равны, завершаем работу
процедуры ja N2 ; Если первое число больше, обходим обмен ; Поскольку команды перехода не
меняют флаги, оба перехода ; выполняются или не выполняются по результатам одного сравнения
xchg eax, edx ; Если первое число было меньше, выполняем обмен N2: sub eax, edx ; Вычитаем из
большего числа меньшее jmp N1 ; Переход к началу цикла N3: ret NOD endp
2. Ввод и вывод в консольном приложении. В программе используются следующие
функции Win32 API.