Генерация кодаВступлениеАссемблер (MASM)Установка компилятора для MASMПервая программа на ассемблереЛайфхакОсновы ассемблераПустая программаРегистрыПеременныеЧисловыеМассивыСтрокиМеткиКомандыMOV [приемник], [источник]ADD [приемник], [источник]SUB [приемник], [источник]IMUL [приемник], [источник]DIV [источник]CMP [значение_1], [значение_2]JNE [имя_метки]JE [имя_метки]JMP [имя_метки]JG [имя_метки]JL [имя_метки]JGE [имя_метки]JLE [имя_метки]СтекPUSH [значение]POP [приемник]Регистрыesp и ebpРазыменование указателейФункцииCALL [имя_функции]Пролог функции (Начало функции)Эпилог функции (Конец функции)Проблема при работе со стеком в функции
Генерация кода — это процесс перевода промежуточного представления, в частности абстрактного синтаксического дерева, в выходной код на некотором языке, в том числе и на языке ассемблера.
Мы не будем рассматривать генерацию машинных кодов, потому что они имеют очень много тонкостей, которые выходят за рамки этой главы. Мы остановимся на генерации в ассемблерный код. В качестве ассемблера выберем MASM (Macro Assembler).
Задача генерации кода, в нашем случае, будет состоять в переводе AST в ассемблерный код!
В первую очередь мы поговорим о самом ассемблере, о том, как он работает, как на нем писать простейшие конструкции, а также как его компилировать в исполняемый файл. Я буду показывать только самые необходимые вещи, так что некоторые, неважные в рамках этой статьи, могут остаться вне поля зрения.
Ассемблер — это низкоуровневый язык программирования. Все команды в нем, являются более удобными заменами двоичного представления команд процессора.
Сам ассемблер не является сложным языком в плане синтаксиса, у него простая структура и небольшой набор доступных команд. Однако ассемблер сложен тем, что такие вещи как циклы, условия не существуют, как готовые языковые конструкции, они описываются вручную с помощью определенного набора доступных команд. Это довольно непривычно, но мы разберем все основные конструкции на примерах.
Для того, чтобы исходный код на ассемблере преобразовывать в готовые для запуска, исполняемые файлы, нам понадобится компилятор ассемблера. Для masm существует набор инструментов под общим названием masm32, который включает в себя компилятор.
Инструкция по установке:
install.exe;install и следуем дальнейшим инструкциям. Процесс установки довольно долгий, так что придется подождать. После установки откроется редактор, его можно спокойно закрывать;masm32 в переменную окружения Path. Откройте проводник и вставьте в адресную строку Control Panel\System and Security\System и нажмите Enter;Расширенные настройки системы;Переменные среды;Path и кликнете по ней два раза;masm32 на диске, который вы выбрали в начале установки);bin в папке, где установлен MASM.;ml.exe и link.exe, которые понадобятся для компиляции;ml, если ошибки нет, значит компилятор для MASM установлен верно.Теперь, когда компилятор установлен, давайте скомпилируем небольшую тестовую программу, которая будет выводить "Hello World!" в консоль.
В папке, где вы пишите код компилятора, создайте папку с любым названием, например, test_asm.
Для дальнейших действий вы можете использовать любую консоль, будь то PowerShell или стандартную консоль Windows. Я буду показывать все в PowerShell, хотя все действия полностью идентичны.
Откроем PowerShell. Скопируем полный путь до папки test_asm и пропишем следующую команду в консоли:
x
1cd [path_to_test_asm]где [path_to_test_asm] заменим на путь к папке.
Выполните команду нажатием клавиши Enter. Эта команда сменит текущий каталог, на каталог который был прописан на месте [path_to_test_asm], тем самым мы перейдем в каталог в котором будет файл, где мы будем писать ассемблерный код. Это удобно, так как, не надо прописывать длинный путь к файлу с ассемблерным кодом, а достаточно указать его название: test.asm.
Важно!
Весь код, который рассматривается в этой главе должен быть сохранен в кодировке ASCII или другой кодировке на основе ASCII (например, windows-1251).
Время добавить этот файл с ассемблерным кодом. Создайте файл test.asm со следующим содержанием:
x1.5862.model flat, stdcall34include <\masm32\include\msvcrt.inc>5include <\masm32\include\kernel32.inc>6includelib <\masm32\lib\msvcrt.lib>7includelib <\masm32\lib\kernel32.lib>89data segment10 print_format db "%s ", 011 hello_world db "Hello World", 012data ends1314text segment1516print PROC17 enter 0, 018 mov eax, [ebp + 8]19 invoke crt_printf, offset print_format, eax20 leave21 ret 422print ENDP2324__main:25 enter 0, 02627 push offset hello_world28 call print29 30 leave31 ret3233text ends34end __mainДалее, последовательно введите в PowerShell две команды:
xxxxxxxxxx31ml /c /coff test.asm23link /subsystem:console test.objЕсли все верно, то вы должны получить файл test.exe. Если вы его запустите, то увидите надпись Hello World!.
Если вы получаете ошибку на подобии этой:
xxxxxxxxxx11ml : The term 'ml' is not recognized as the name of a cmdlet, ....то это означает, что есть проблемы с установкой masm32. Возможно вы не прописали путь в переменной окружения Path, которая описана в 7 пункте инструкции по установке. Если проблема все еще существует, попробуйте перезапустить PowerShell с правами администратора.
Теперь давайте разберем, что это за команды:
xxxxxxxxxx11ml /c /coff test.asmПервая команда отвечает за компиляцию программы в объектный файл. Это файл еще не является исполняемым, поэтому, чтобы сделать из него исполняемый, мы используем линкер с помощью второй командой:
xxxxxxxxxx11link /subsystem:console test.objВ результате мы получим готовый исполняемый файл, который можем запустить.
Итак, теперь мы умеем компилировать ассемблерный код.
Давайте выделим основное:
Чтобы скомпилировать ассемблерный код, нужно в первую очередь перейти в каталог с исходным кодом, чтобы не прописывать длинные пути, с помощью команды
cd [путь_до_папки], а затем выполнить следующие две команды:xxxxxxxxxx31ml /c /coff test.asm23link /subsystem:console test.objПервая из которых скомпилирует ассемблерный код в объектный файл, а вторая создаст на его основе исполняемый файл.
Прописывать эти команды каждый раз, долго. Однако есть способ избежать этого. Для этого создадим в папке файл run.bat со следующим содержанием:
xxxxxxxxxx51ml /c /coff test.asm23link /subsystem:console test.obj45./test.exeЭто те же команды, что мы прописывали в консоли, однако добавилась еще одна команда, которая будет запускать полученный исполняемый файл. Это избавит нас от лишнего действия.
И теперь, чтобы перекомпилировать ассемблерный код, достаточно запустить файл run.bat двойным кликом.
Если вы хотите компилировать файл с названием отличным от
test.asm, просто поменяйте название во всех командах на необходимое.
Основа программ на ассемблере — это команды. Команды выполняются одна за другой до тех пор, пока не будет встречен конец программы. Благодаря некоторым конструкциям языка, мы можем переходить к любому месту в программе при необходимости, это позволяет создавать все возможные конструкции циклов или условий.
Давайте рассмотрим "костяк" любой программы на ассемблере. Это код можно просто копировать из программы в программу, он везде будет одинаков.
xxxxxxxxxx141.5862.model flat, stdcall34data segment56data ends78text segment910__main:11 ret1213text ends14end __mainРассмотрим код по-блочно:
xxxxxxxxxx21.5862.model flat, stdcallПервый блок — это блок определений для ассемблера.
xxxxxxxxxx11.586В первой строке обозначается набор используемых инструкций, в данном случае мы используем i586 набор, который является достаточно универсальным для процессоров Intel и AMD.
xxxxxxxxxx11.model flat, stdcallВо второй строке задается модель памяти программы, а также модель вызова процедур. Так как мы программируем под WIndows, то модель памяти должна быть flat, а модель вызова процедур — stdcall.
В данный момент примем это, как данность и будем просто копировать из программы в программу.
Следующий блок, это сегмент данных:
xxxxxxxxxx31data segment2 ; место для объявления переменных3data endsСегмент данных используется для задания всех необходимых в программе переменных. Объявлять переменные вне этого сегмента нельзя.
Следующий блок, это сегмент команд:
xxxxxxxxxx71text segment23__main:4 ret56text ends7end __mainСегмент команд может называться любым именем, но стандартно его называют text. Сегмент команд — это то место в коде, где пишутся все исполняемые команды программы. Писать команды вне сегмента команд нельзя.
В сегменте команд обязательна начальная метка (о том, что это такое мы поговорим дальше) :
xxxxxxxxxx11__main:Эта метка должны быть закрыта, сразу же после завершения сегмента команд:
xxxxxxxxxx21text ends ; <- конец сегмента команды2end __main ; <- закрываем меткуДанная метка, как функция main в С/С++, с нее начинается выполнение команд, то есть программа начнет свое исполнение с первой команды после метки __main:.
В нашем случае это команда ret. Сейчас не будем вдаваться в подробности, эта команда завершает выполнение программы.
Однако команды можно писать и до метки __main:, но тогда они не будут исполнены, в нормальном течении программы. Так как команды выполняются друг за другом, пока не будет встречен конец.
До метки __main обычно пишут функции, которые вызываются командами после метки __main:. Об этом мы поговорим в разделе про функции.
Это все блоки, которые понадобятся нам в написании нашего ассемблерного кода.
Отмечу еще один факт, большая часть ассемблера не учитывает регистр, поэтому записи:
xxxxxxxxxx31data segment2; и3Data Segmentравноценны. Однако некоторые части являются регистрозависимыми, в этом случае, это будет указано явно.
Подведем итоги:
Переменные определяются между
data segmentиdata ends! Этот блок называется сегментом данных!xxxxxxxxxx31data segment2; место для объявления переменных3data endsВесь код программы пишется между
text segmentиtext ends(этот блок называется сегментом команд) после метки__main:! Однако если задается функция, то она обычно пишется до метки:xxxxxxxxxx81text segment2; вот тут различные вспомогательные функции3__main:4; вот тут код программы, который будет исполнятся5; после запуска программы6ret78text endsВесь остальной код, можно просто копировать из программы в программу, он не изменяется!
Комментарии начинаются с символа точка с запятой
;Большая часть ассемблера не учитывает регистр, поэтому записи:
xxxxxxxxxx31data segment2; и3Data Segmentравноценны. Однако некоторые части являются регистрозависимыми, в этом случае, это будет указано явно!
Следующее, что мы рассмотрим — это регистры.
Регистры — это специальные ячейки памяти расположенные прямо в процессоре. Работа с ними происходит намного быстрее, чем с оперативной памятью, поэтому они предпочтительнее для большинства операций, чем переменные.
Регистры по сути такие же переменные, в которые можно записывать и считывать данные.
Регистров не так много, ниже приведена сводная таблица:
| Название | Разрядность | Основное назначение |
|---|---|---|
EAX | 32 | Аккумулятор |
EBX | 32 | База |
ECX | 32 | Счётчик |
EDX | 32 | Регистр данных |
EBP | 32 | Указатель базы |
ESP | 32 | Указатель стека |
ESI | 32 | Индекс источника |
EDI | 32 | Индекс приёмника |
EFLAGS | 32 | Регистр флагов |
EIP | 32 | Указатель инструкции (команды) |
CS | 16 | Сегментный регистр |
DS | 16 | Сегментный регистр |
ES | 16 | Сегментный регистр |
FS | 16 | Сегментный регистр |
GS | 16 | Сегментный регистр |
SS | 16 | Сегментный регистр |
Регистры EAX, EBX, ECX, EDX — это регистры общего назначения. Они имеют определённое историческое назначение, однако в них можно хранить любую информацию. Они имеют размер 32 бита или 4 байта, что очень похоже на переменные типа int, которые также занимают 4 байта.
Регистры EBP, ESP, ESI, EDI— это также регистры общего назначения. Однако они имеют уже более конкретное назначение, поэтому использовать их нужно аккуратно. Они имеют размер также 32 бита или 4 байта.
Регистр флагов и сегментные регистры мы оставим на потом, так как их описание довольно большое и сложное.
Регистры можно рассматривать, как обычные переменные с предопределенными именами и имеющие размер 4 байта.
Регистры общего назначения также можно использовать не полностью, можно использовать их первые 16 бит или использовать первые 8 бит и вторые 8 бит, это сделано для совместимости со старыми процессорами, однако использовать это мы не будет.
Давайте выделим основное:
Регистр — это ячейка в памяти процессора, которую можно рассматривать, как переменную с предопределенным именем. Есть 4 регистра (
EAX,EBX,ECX,EDX), которые можно свободно использовать для своих целей, каждый из которых имеет размер 32 бита или 4 байта.
Переменные в ассемблере не отличаются от переменных в привычных нам языках. Помните в каком сегменте они задаются? Правильно в сегменте данных:
xxxxxxxxxx31data segment2 ; место для объявления переменных3data endsЧисловые переменные могут быть следующих типов:
| Директива | Название | Размер |
|---|---|---|
DB | Byte | 1 байт |
DW | Word | 2 байта |
DD | DoubleWord | 4 байта |
DQ | QuadWord | 8 байт |
DT | TWord | 10 байт |
Переменные объявляются следующим образом:
xxxxxxxxxx11[ИмяПеременной] [Директива] [НачальноеЗначение] или ?Если начального значения нет, то необходимо поставить на его место знак вопроса (?)
Давайте посмотрим на объявление нескольких переменных:
xxxxxxxxxx71data segment2 number DD 0 ; целочисленная переменная размером 4 байта 3 neg_number DD -5 ; целочисленная переменная размером 4 байта, хранящая отрицательное число4 big_number DQ 20000000000 ; целочисленная перменная размером 8 байт, хранящая число, которое не помещается в 4 байта5 real_nuber DQ 5.421 ; вещественное число двойной точности (double в С/С++) размером 8 байт6 question_number DD ? ; неинициализированная переменная7data endsВ ассемблере массивы можно задавать несколькими способами, мы рассмотрим два варианта, как набор значений через запятую, и как массив n-размера заполненный каким-то значением.
Первый вариант имеет следующий синтаксис:
xxxxxxxxxx11[ИмяМассива] [Директива] [СписокИнициализаторовЧерезЗапятую]Давайте посмотрим на объявление некоторых массивов:
xxxxxxxxxx41data segment2 array_numbers DD 1,2,3,4 ; массив 4 байтных чисел размера 4. [1, 2, 3, 4]3 array_numbers_2 DQ 1.23, 453.5, 32.3 ; массив 4 байтных вещественных чисел размера 3. [1.23, 453.5, 32.3]4data endsВторой вариант имеет следующий синтаксис:
xxxxxxxxxx11[ИмяМассива] [Директива] [РазмерМассива] dup ([ЗначениеДляЗаполнения])Ключевое слово dup задает массив определенного размера заполненный некоторым значением.
Давайте посмотрим на объявление некоторых массивов:
xxxxxxxxxx41data segment2 empty_array DQ 10 dup (0) ; задает массив 8 байтных чисел размера 10, где каждый элемент равен 03 nine_array DD 100 dup (9) ; задает массив 4 байтных чисел размера 100, где каждый элемент равен 94data ends
А что, если попробовать объявить массив однобайтных символов? Например такой:
xxxxxxxxxx31data segment2 string DB 'W','o','r','l','d', 0 ; задает массив однобайтных чисел, который инициализируется символами3data endsИ так и правда можно, таким образом мы задали строку World! Обратите внимание на ноль, он будет говорить программе, что эта строка завершена. Вспомните, в Си все строки заканчиваются нулем терминатором по-умолчанию. Ассемблер же сам не вставляет в конец нуль-терминатор, поэтому его нужно объявлять явно, с помощью еще одного элемента массива в виде нуля.
Однако так задавать строки очень неудобно, поэтому в ассемблере есть более удобный синтаксис для задания строк.
Синтаксис задания строки следующий:
xxxxxxxxxx11[НазваниеПеременной] DB "[ЗначениеСтроки]", 0Здесь тип DB обязателен, так как мы задаем строку с однобайтными символами. Не стоит забывать, что строки, как и в Си хранятся в виде массива, на что явно указывал способ объявления выше. Ноль после строки выполняет ту же роль, что была обозначена выше.
Давайте объявим несколько строк:
xxxxxxxxxx41data segment2 hello DB "Hello ", 0 ; переменная хранящая строку "Hello "3 world DB "World", 0 ; переменная хранящая строку "World"4data ends
А помните, пример вывода Hello World! в самом начале, когда мы только изучали компиляцию? Там как раз таки в сегменте данных были объявлены две строки:
xxxxxxxxxx41data segment2 print_format db "%s ", 03 hello_world db "Hello World", 04data ends
Выделим самое основное:
- Числовые переменные могут быть следующих типов:
Директива Название Размер DBByte1 байт DWWord2 байта DDDoubleWord4 байта DQQuadWord8 байт DTTWord10 байт
Синтаксис объявления численной переменной:
xxxxxxxxxx11[ИмяПеременной] [Директива] [НачальноеЗначение] или ?Массивы можно задавать двумя способами:
xxxxxxxxxx11[ИмяМассива] [Директива] [СписокИнициализаторовЧерезЗапятую]xxxxxxxxxx11[ИмяМассива] [Директива] [РазмерМассива] dup ([ЗначениеДляЗаполнения])Строки задаются следующим образом:
xxxxxxxxxx11[НазваниеПеременной] DB "[ЗначениеСтроки]", 0После строки обязательно надо поставить ноль, чтобы явно обозначить завершение строки!
Очень важной частью программирования на ассемблере являются метки и переходы к ним.
Метка — это конструкция языка, которая имеет уникальное имя, после которого идет двоеточие (:) и перенос строки, позволяющая переходить между исполняемыми командами:
xxxxxxxxxx21label:2 ; после метки, обычно ставят табуляцию для последующего кодаМетки ставятся в любом месте сегмента команд. Метка — это место в коде, в которое можно перейти и начать исполнение команд непосредственно с этой метки, то есть вне зависимости от места где сейчас исполняется код, можно перейти к метке и продолжить выполнять команды расположенные после этой метки.
Помните начальную метку __main:, с которой начинается выполнение команд? Эта также метка, только переход к ней происходит в автоматическом режиме при запуске программы.
Рассмотрим пример использования метки:
xxxxxxxxxx301.5862.model flat, stdcall34data segment5data ends67text segment8910__start:11 enter 0, 01213plus: ; <- объявляем метку в коде14 mov ebx, 3 ; <-- не обращайте внимание на эти команды15 mov eax, 100 ; пока что они просто что-то выполняют16 add ebx, eax17 18 jmp plus ; <- команда jmp будет описано позже19 ; однако она позволяет "перепрыгнуть к метке"20 ; в нашем случае к "plus" и команды после 21 ; этой метки будут выполнятся вновь, а после22 ; снова будет переход к метке и снова 23 ; выполнение этих же команд, таким образом24 ; получается бесконечный цикл25 26 leave27 ret2829text ends30end __startДальше мы переходим к основной части ассемблера, а именно к командам. Однако изучать сухую теорию не очень весело, поэтому сейчас мы создадим тестовый стенд, где сможем выводить значение регистра eax, после использования каких-то команд. Тем самым мы сразу будем видеть результат выполнения команды.
Для начала, изменим содержание нашего файл test.asm на следующее:
xxxxxxxxxx381.5862.model flat, stdcall34include <\masm32\include\msvcrt.inc>5include <\masm32\include\kernel32.inc>6includelib <\masm32\lib\msvcrt.lib>7includelib <\masm32\lib\kernel32.lib>89data segment10 print_format db "%d ", 011data ends1213text segment1415print PROC16 enter 0, 017 mov eax, [ebp + 8]18 invoke crt_printf, offset print_format, eax19 leave20 ret 421print ENDP222324__start:25 enter 0, 0262728 ; вот тут будем писать наш код293031 push eax32 call print33 34 leave35 ret3637text ends38end __startВ дальнейшем, я буду описывать только код между enter 0, 0 и push eax. Пока что не заморачивайтесь, что здесь написано, главное, что эта программа будет выводить значение регистра eax (помните, что регистры — это переменные? Так что мы выводим просто значение переменной, ничего сложного).
Теперь попробуем скомпилировать программу. Для этого используем наш файл run.bat, запустим его двойным щелчком. Если все хорошо, то в консоли будет выведено случайное число. (Посмотрите, что будет, если несколько раз подряд запустить программу?).
Теперь переходим к командам.
[] в описании команды, означает, что на этом месте будет что-либо. Конкретное описание того, что там может быть будет в описании команды.
MOV [приемник], [источник]Первая команда, это команда mov, расшифровывается, как move, что переводится как "перемещать".
Эта команда перемещает значение из источника в приемник.
Итак, давайте поиграем с этой командой. Помните, что мы изменяем только код между enter 0, 0 и push eax? Если да, то начинаем.
Давайте напишем следующий код:
xxxxxxxxxx11mov eax, 100Сохраним и запустим компиляцию файлом run.bat. В выводе должно появится число 100. (Помните, что мы выводим значение eax?) Таким образом, мы поместили значение 100 в регистр eax. Здорово, не правда ли? А теперь давайте поместим значение в другой регистр, и значение этого регистра поместим в eax:
xxxxxxxxxx21mov ebx, 2002mov eax, ebxПосле компиляции в выводе должно быть число 200. То есть, здесь, мы сначала поместили в регистр ebx значение 200, а затем значение ebx (которое равно 200) мы поместили в регистр eax. Держите в голове тот факт, что регистр можно рассматривать, как переменную.
Следующие команды описывают команды для арифметических действий с числами. Пришло время посчитать.
ADD [приемник], [источник]Команда add расшифровывается, как addition, что переводится как "сложение".
Эта команда складывает значения из приемника со значением источника и кладет его в приемник.
Давайте напишем следующий код:
xxxxxxxxxx21mov eax, 1002add eax, 5Здесь мы сначала поместили значение 100 в регистр eax, а затем прибавили к значению eax число 5. Тем самым на выводе мы должны получить число 105. Так и есть.
Все просто, а что если сначала поместить значение в ebx, затем в eax, а затем сложить eax и ebx? Это ваше задание, напишите такую программу, значения могут быть любыми.
Если вы не знаете, как это написать, попробуйте написать по подобию примеров, это не так сложно, однако именно практика дает 50% запоминания и понимания материала, так что обязательно делайте эти небольшие задания.
SUB [приемник], [источник]Команда sub расшифровывается, как subtraction, что переводится как "вычитание".
Эта команда вычитает значение источника из значения приемника и кладет его в приемник.
Мы уже умеем складывать, теперь пробуем вычитать:
xxxxxxxxxx21mov eax, 1002sub eax, 5У нас есть такой код, скажите, как вы думаете, что должно быть выведено? Правильно, 95. Ничего сложного, все как и со сложением.
А если написать такой код, то что выведется?
xxxxxxxxxx21mov eax, 1002sub eax, eaxДа, выведется 0, так как мы из eax вычитаем eax.
Теперь, когда мы знаем две операции, напишите программу, которая посчитает значение выражения:
xxxxxxxxxx112 + 56 - 100 + 54На выходе вы должны получить 12.
IMUL [приемник], [источник]Команда imul расшифровывается, как integer multiplication, что переводится как "умножение целых чисел".
Эта команда перемножает значение источника со значением приемника и кладет результат в приемник.
Наконец то мы добрались до умножения. Здесь все также очень похоже на две команды выше.
xxxxxxxxxx21mov eax, 1002imul eax, 4Что должно быть выведено в итоге? Правильно, 400.
Теперь, когда вы знаете 3 арифметические операции, ваша задача написать код, который будет рассчитывать дискриминант.
Формула дискриминанта:
Значения, a, b, c можно взять любыми. Главное, чтобы результат был выведен на экран. (Не забывайте, мы выводим регистр eax, поэтому результат должен быть именно в нем).
DIV [источник]Команду для деления мы пока что разбирать не будем, так как она слишком сложная, на этом этапе изучения ассемблере. Вернемся к ней позже.
Следующие команды, это команды для сравнения и условных переходов.
Условные переходы — это переходы в какую-то метку программы, в зависимости от того, какой результат был получен в результате последней команды cmp.
CMP [значение_1], [значение_2]Команда cmp расшифровывается, как compare, что переводится как "сравнить".
Эта команда сравнивает значение 1 со значением 2, а результат записывает в регистр флагов. О регистре флагов мы поговорим дальше, сейчас просто поймите это, как то, что результат записывается в регистре флагов и мы можем его использовать с помощью следующих команд.
Например:
xxxxxxxxxx21cmp eax, 10 ; сравниваем значение 10 и значение регистра eax2cmp ebx, eax ; сравниваем значение регистра eax и значение регистра ebx
Следующие команды описывают условные переходы. То есть эти команды переходят к меткам в зависимости от результата предыдущей команды, например, команды cmp.
JNE [имя_метки]Команда jne расшифровывается, как jump if not equal, что переводится как "прыгнуть если НЕравно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был НЕравен второму
Давайте попробуем использовать эту и предыдущую команды и попробуем создать цикл:
xxxxxxxxxx121mov eax, 1 ; <-- помещаем в eax единицу2mov ebx, 1 ; <-- помещаем в ebx единицу3mov ecx, 10 ; <-- помещаем в ecx десять45loop_start: ; <-- определяем метку6 imul eax, 2 ; <-- умножаем eax на 27 add ebx, 1 ; <-- добавляем к ebx единицу89 cmp ebx, ecx ; <-- сравниваем ebx и ecx 10 jne loop_start ; <-- если в результате сравнения оказалось, что11 ; ebx неравен ecx, то переходим к метке loop_start12 ; и снова выполняем командыДанный код описывает простой цикл, в нем значение ebx увеличивается на 1, а затем проверяется на равенство ecx. Таким образом, переход к метке loop_start будет происходить до тех пор, пока значения ebx и ecx не станут равными, тогда код продолжит выполнять код за пределами данного отрывка (помним, что мы описываем не весь код, а его часть, дальше идет вывод значения eax).
Если вы запустите код, вы должны получить 512. Таким образом мы написали расчет степени двойки, чтобы поменять расчетную степень, надо изменить первоначальное значение ecx.
Ваша задача написать на основе этого кода программу, которая будет рассчитывать степень числа 5. Ответ в приложении.
JE [имя_метки]Команда je расшифровывается, как jump if equal, что переводится как "прыгнуть если равно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был равен второму
Сейчас мы рассмотрим простейшую реализацию конструкции if else. Предположим, что нам нужно сравнить значение ebx и ecx и в случае равенства вывести 1, а в обратном случае — 0.
xxxxxxxxxx131mov ebx, 1 ; <-- помещаем в ebx единицу2mov ecx, 10 ; <-- помещаем в ecx десять34cmp ebx, ecx ; <-- сравниваем ebx и ecx 5je if_equal ; <-- если в результате сравнения оказалось, что6 ; ebx равен ecx, то переходим к метке if_equal7jne if_not_equal ; <-- если в результате сравнения оказалось, что8 ; ebx НЕравен ecx, то переходим к метке if_not_equal910if_equal:11 mov eax, 112if_not_equal:13 mov eax, 0 Казалось бы, вроде все верно, если равно, то переходим к одной метке, если нет — к другой. Но здесь проявляется особенность ассемблера, он выполняет команды одну за другой, несмотря ни на что. Поэтому в данном случае, если числа будут равны, eax станет равным 1, но после этого же, ему будет присвоен 0 и в результате работы будет выведен 0.
Чтобы избежать такого, создают специальную метку, в случае, если числа равны, эта метка будет переходить к коду после команд, которые должны были быть выполнены в случае неравенства.
Для этого используется команды jmp, давайте отвлечемся на ее описание, а после вернемся к примеру.
JMP [имя_метки]Команда jmp расшифровывается, как jump, что переводится как "прыгнуть".
Эта команда переходит к метке, вне зависимости от чего-либо. Это безусловный переход.
Теперь вернемся к нашему примеру и добавим эту метку и переход к ней:
x
1mov ebx, 1 ; <-- помещаем в ebx единицу2mov ecx, 10 ; <-- помещаем в ecx десять34cmp ebx, ecx ; <-- сравниваем ebx и ecx 5je if_equal ; <-- если в результате сравнения оказалось, что6 ; ebx равен ecx, то переходим к метке if_equal7jne if_not_equal ; <-- если в результате сравнения оказалось, что8 ; ebx НЕравен ecx, то переходим к метке if_not_equal910if_equal:11 mov eax, 112 jmp if_end ; <-- переходим к метке конца сравнения13 ; как бы перепрыгивая команды, которые выполняются14 ; в случае неравенства15if_not_equal:16 mov eax, 0 17if_end: ; <-- метка, которая означает окончания условияТеперь, если числа равны, то программа перейдет к метке if_equal, а потом "перепрыгнет" команды, так как встретит безусловный перед к метке if_end, таким образом команды которые должны были выполнится в случае неравенства будут пропущены.
В случае же неравенства, программа перейдет к метке if_not_equal и продолжит выполнять программу до конца, метка if_end в данном случае будет просто пропущена.
JG [имя_метки]Команда jg расшифровывается, как jump if greater, что переводится как "прыгнуть если больше".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был строго больше второму.
JL [имя_метки]Команда jl расшифровывается, как jump if less, что переводится как "прыгнуть если меньше".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был строго меньше второму.
JGE [имя_метки]Команда jge расшифровывается, как jump if greater or equal, что переводится как "прыгнуть если больше или равно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был больше или равен второму.
JLE [имя_метки]Команда jle расшифровывается, как jump if less or equal, что переводится как "прыгнуть если меньше или равно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был меньше или равен второму.
Все эти команды работают также как и предыдущие 3, поэтому не будем на них обращать пристальное внимание.
Однако, у вас есть задача. Предыдущий цикл будет корректно работать только в том случае, если в результате прибавления, ebx когда то станет равным ecx, но если мы поменяем приращение на 2, то мы получим бесконечный цикл, так как значения никогда не будут равны и программа будет постоянно переходить к метке.
Вам нужно исправить этот код, используя команды выше, чтобы он не уходил в бесконечный цикл.
xxxxxxxxxx121mov eax, 1 ; <-- помещаем в eax единицу2mov ebx, 1 ; <-- помещаем в ebx единицу3mov ecx, 10 ; <-- помещаем в ecx десять45loop_start: ; <-- определяем метку6 imul eax, 2 ; <-- умножаем eax на 27 add ebx, 2 ; <-- добавляем к ebx двойку89 cmp ebx, ecx ; <-- сравниваем ebx и ecx 10 jne loop_start ; <-- если в результате сравнения оказалось, что11 ; ebx неравен ecx, то переходим к метке loop_start12 ; и снова выполняем командыОтвет в приложении.
На этом основные команды закончены.
Мы переходим к одной из самых сложных частей ассемблера — стеку. В первую очередь, давайте рассмотрим, что вообще такое стек, как структура данных.
Стек — это структура данных, при использовании которой возможны только две операции:
Таким образом, если мы поместили в стек 3 значения, то получить доступ мы можем только к последнему добавленному элементу, а чтобы получить доступ к элементу посередине, нужно сначала извлечь все элементы до него.
Стек можно сравнить со стопкой тарелок. Если вы хотите достать тарелку из середины, то вам нужно сначала убрать все тарелки над ней. Также, когда вы добавляете новую тарелку, вы кладете ее на вершину стопки.
Если говорить о том, что такое стек в программе, то — это область программы для временного хранения произвольных данных.
Конечно, можно хранить все данные в сегменте памяти, в виде переменных, но это увеличивает размер программы и количество используемых имен.
Память в стеке используется многократно, что удобно, так как занимает меньше памяти.
Для работы со стеком есть две основных команды:
PUSH [значение]Команда push переводится как "протолкнуть".
Эта команда добавляет значение в стек.
Значение может быть регистром, переменной или константой;
POP [приемник]Команда pop переводится как "вытолкнуть".
Эта команда извлекает значение вершины стека и помещает его в приемник.
Приемник может быть регистром или переменной;
Давайте попробуем написать что-нибудь с использованием стека:
Предположим у нас есть 5 чисел:
10, 5, 6, 3, 2
Наша задача сложить их. Но мы не сможем их задать одновременно, так как регистров у нас только 4, а чисел 5. Конечно, здесь можно использовать и переменные, но давайте реализуем это с помощью стека:
В первую очередь помещаем все наши значения в стек:
xxxxxxxxxx51push 102push 53push 64push 35push 2После того, как значения в стеке, нам надо 5 раз достать значение из стека и прибавить его к eax:
xxxxxxxxxx181push 102push 53push 64push 35push 267mov eax, 089pop ebx ; <-- достаем значение из стека в ebx10add eax, ebx ; <-- складываем eax и ebx11pop ebx ; <-- достаем значение из стека в ebx12add eax, ebx ; <-- складываем eax и ebx13pop ebx ; <-- достаем значение из стека в ebx14add eax, ebx ; <-- складываем eax и ebx15pop ebx ; <-- достаем значение из стека в ebx16add eax, ebx ; <-- складываем eax и ebx17pop ebx ; <-- достаем значение из стека в ebx18add eax, ebx ; <-- складываем eax и ebxТаким непритязательным кодом, мы реализовали нашу задачу. Однако у вас будет задача чуть сложнее, она будет охватывать и все предыдущие команды.
Ваша задача написать программу, которая в самом начале поместит в стек числа от 100 до 1, а потом извлечет их и сложит. Нужно использовать циклы из прошлых команд, а также стек. Ответ, как всегда, в приложении.
esp и ebpПомните в самом начале мы рассматривали вторую четверку общих регистров? И было сказано, что у них есть конкретные назначения. Так вот два из них используются в стеке. Это регистры esp и ebp. Это регистры-указатели, то есть их значения трактуются, как адреса. Ассемблер работает с ними, предполагая, что в них хранится адрес, вне зависимости от тог, что там находится в реальности. Это означает, что если вы поместите в них число 4, то при разыменовании будет обращение к памяти по адресу 4, а если эта память недоступна, то вы получите ошибку.
Давайте посмотрим, на что они указывают, когда программа только запущена:
Оба указателя находятся под стеком, то есть указывают на элемент под стеком.
Когда мы помещаем значение в стек, регистр esp начинает указывать на верхний, то есть на последний добавленный элемент в стеке:
Если мы добавим еще один элемент, то esp вновь будет указывать на вершину стека:
Таким образом, с помощью регистра esp мы можем получить доступ к последнему добавленному элементу стека, а также к произвольному значению в стеке. Для того, чтобы получить значение по некоторому адресу используется синтаксис разыменования.
Для того, чтобы разыменовать указатель нужно заключить регистр в квадратные скобки:
mov eax, [esp]
Таким образом в eax будет помещено значение по адресу esp. Если написать просто:
mov eax, esp
То тогда в регистре eax будет адрес, который хранится в esp, но однако никто не запрещает разыменовать и его:
mov eax, [eax]
Давайте попробуем поместить в стек пару значений и получить значение элемента после самого последнего:
xxxxxxxxxx41push 10 ; <-- помещаем значение в стек2push 20 ; <-- помещаем значение в стек34mov eax, [esp + 4]В последней строке мы используем разыменование, но однако до этого мы сдвигаем указатель на 4 байта, такой синтаксис корректен, он означает, что начала из адреса вычитается 4, а потом происходит разыменование. В итоге, мы получим в выводе 20, так как адрес esp + 4 указывает на следующий, после вершины стека, элемент.
Однако, вас может смутить, почему для того, чтобы получить элемент под верхним, нужно прибавлять 4, а не отнимать. Дело в том, что адреса в стеке уменьшаются снизу вверх:
Поэтому для получения элементов ниже последнего нужно прибавлять, а выше — вычитать.
Но почему именно 4? Дело в том, что мы помещаем в стек 4 байтные значения по-умолчанию, и чтобы обратится именно к следующему элементу нужно прибавить 4. Если вы попробуете прибавить, например, 2, то вы получите число, которое описывается 4 байтами, начиная с текущего, то есть программа просто возьмет 32 бита из стека и поместит их в eax абсолютно не думая о том, что это могут быть не те данные, обычно это приведет к ошибкам.
Следующая очень важная тема, это функции. Функции в ассемблере задаются с помощью ключевого слова PROC (регистр не важен, см. введение).
Так, например, объявление функции выглядит следующим образом:
x
1function_name PROC2 ; команды3function_name ENDPКаждая функция должна заканчиваться командой ret для явного выхода из функции.
x
1function_name PROC2 ; команды3 ret4function_name ENDPВызвать функцию можно с помощью команд условного и безусловного перехода, но для этого есть более специализированные команды.
Для вызова функции используется команда call.
CALL [имя_функции]Команда call переводится как "вызвать".
Эта команда помещает в стек адрес возврата (адрес следующей, после этой, команды) и переходит к началу функции. Адрес возврата нужен, чтобы после завершения выполнения перейти к команде, которые должны быть выполнены после вызова функции.
Каждая функция начинается с так называемого пролога процедуры.
Пролог процедуры — это фрагмент кода, нужный для того, чтобы сохранить текущее состояние стека, то есть сохранить адрес последнего элемента стека в момент вызова функции.
Код пролога следующий:
xxxxxxxxxx21push ebp2mov ebp, espВ первой строке мы помещаем в стек ebp, чтобы потом, после того, как функция завершит свою работу восстановить изначальное значение. Это нужно, так как в следующей строке мы кладем значение esp в ebp. Но что это означает на деле.
Предположим, у нас в стеке лежит два значения. Мы вызываем функцию с помощью команды call. В стек кладется адрес возврата (см. описание функции call):
После того, как мы выполним первую команду пролога, стек будет следующим:
То есть мы помещаем ebp в стек, при этом esp указывает на последний элемент, то есть на значение ebp, которое было только что добавлено. Сам регистр ebp все еще указывает на дно стека.
После того, как мы выполним вторую команду пролога, стек будет следующим:
Все что изменилось, это то, что ebp стало равным esp.
Таким образом, после пролога, в ebp будет хранится адрес вершины стека (или верхнего элемента стека) в момент вызова функции. Это нужно, чтобы, когда мы в функции будем добавлять элементы в стек, то в ebp адрес все еще будет указывать на начальный для esp в начале функции:
Это нужно, чтобы можно было обращаться к элементам стека, которые были до вызова функции по фиксированным сдвигам. Так, например, если мы хотим получить значение 20 то достаточно прибавить 8 к ebp, а вот для esp необходимо сначала вычесть количество добавленных в функции новых значений и только потом вычесть 8, что намного сложнее. Поэтому мы будем использовать ebp для доступа к элементам стека, которые были добавлены до вызова функции, это понадобится нам в дальнейшем для передачи аргументов.
После того, как функция завершена, выполняется так называемый эпилог процедуры. Эпилог процедуры восстанавливает значение ebp, которое мы сохранили в стек в прологе.
Код эпилога следующий:
xxxxxxxxxx11pop ebpСтек после эпилога будет следующий:
То есть теперь ebp будет вновь указывать на дно стека (будет иметь первоначальное значение, которые мы сохранили в прологе), а значение из стека будет изъято. Теперь на вершине стека лежит адрес возврата. Встречая команду ret, из стека извлекается значение и трактуется, как адрес возврата, то есть в виде адреса команды, с которой нужно продолжить выполнение программы после того, как функция закончена.
Например, у вас есть следующий код:
x
1add eax, 1002call someFunction3mov ebx, 100Когда функция someFunction вызывается, то в стек помещается адрес следующей команды, то есть в нашем случае адрес команды mov.
После того, как функция закончила работу, из стека извлекается этот адрес и выполнение продолжается, начиная с команды по этому адресу, то есть с команды mov.
Однако здесь таится большая возможная проблема, а что если мы в функции поместили в стек какое-то значение?
И после добавления у нас идет эпилог функции. Тогда в ebp будет помещено значение из вершины стека, где у нас лежит значение 5.
Теперь ebp указывает на неизвестно что, а на вершине стека лежит адрес ebp, который мы поместили еще в прологе. Теперь, так как после пролога идет команда ret, будет извлечено значение из стека и оно будет рассматриваться, как адрес возврата, но это не адрес возврата, а адрес ebp. Поэтому мы получим ошибку, так как пытаемся получить доступ к памяти, которая нам недоступна.
Чтобы этого избежать, нужно тщательно следить за стеком!
Если вы в функции добавляете какие-то значения в стек, то их обязательно нужно извлечь оттуда до пролога!