Генерация кодаВступлениеАссемблер (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.586
2.model flat, stdcall
3
4include <\masm32\include\msvcrt.inc>
5include <\masm32\include\kernel32.inc>
6includelib <\masm32\lib\msvcrt.lib>
7includelib <\masm32\lib\kernel32.lib>
8
9data segment
10 print_format db "%s ", 0
11 hello_world db "Hello World", 0
12data ends
13
14text segment
15
16print PROC
17 enter 0, 0
18 mov eax, [ebp + 8]
19 invoke crt_printf, offset print_format, eax
20 leave
21 ret 4
22print ENDP
23
24__main:
25 enter 0, 0
26
27 push offset hello_world
28 call print
29
30 leave
31 ret
32
33text ends
34end __main
Далее, последовательно введите в PowerShell
две команды:
xxxxxxxxxx
31ml /c /coff test.asm
2
3link /subsystem:console test.obj
Если все верно, то вы должны получить файл test.exe
. Если вы его запустите, то увидите надпись Hello World!
.
Если вы получаете ошибку на подобии этой:
xxxxxxxxxx
11ml : The term 'ml' is not recognized as the name of a cmdlet, ....
то это означает, что есть проблемы с установкой masm32
. Возможно вы не прописали путь в переменной окружения Path
, которая описана в 7 пункте инструкции по установке. Если проблема все еще существует, попробуйте перезапустить PowerShell
с правами администратора.
Теперь давайте разберем, что это за команды:
xxxxxxxxxx
11ml /c /coff test.asm
Первая команда отвечает за компиляцию программы в объектный файл. Это файл еще не является исполняемым, поэтому, чтобы сделать из него исполняемый, мы используем линкер с помощью второй командой:
xxxxxxxxxx
11link /subsystem:console test.obj
В результате мы получим готовый исполняемый файл, который можем запустить.
Итак, теперь мы умеем компилировать ассемблерный код.
Давайте выделим основное:
Чтобы скомпилировать ассемблерный код, нужно в первую очередь перейти в каталог с исходным кодом, чтобы не прописывать длинные пути, с помощью команды
cd [путь_до_папки]
, а затем выполнить следующие две команды:xxxxxxxxxx
31ml /c /coff test.asm
2
3link /subsystem:console test.obj
Первая из которых скомпилирует ассемблерный код в объектный файл, а вторая создаст на его основе исполняемый файл.
Прописывать эти команды каждый раз, долго. Однако есть способ избежать этого. Для этого создадим в папке файл run.bat
со следующим содержанием:
xxxxxxxxxx
51ml /c /coff test.asm
2
3link /subsystem:console test.obj
4
5./test.exe
Это те же команды, что мы прописывали в консоли, однако добавилась еще одна команда, которая будет запускать полученный исполняемый файл. Это избавит нас от лишнего действия.
И теперь, чтобы перекомпилировать ассемблерный код, достаточно запустить файл run.bat
двойным кликом.
Если вы хотите компилировать файл с названием отличным от
test.asm
, просто поменяйте название во всех командах на необходимое.
Основа программ на ассемблере — это команды. Команды выполняются одна за другой до тех пор, пока не будет встречен конец программы. Благодаря некоторым конструкциям языка, мы можем переходить к любому месту в программе при необходимости, это позволяет создавать все возможные конструкции циклов или условий.
Давайте рассмотрим "костяк" любой программы на ассемблере. Это код можно просто копировать из программы в программу, он везде будет одинаков.
xxxxxxxxxx
141.586
2.model flat, stdcall
3
4data segment
5
6data ends
7
8text segment
9
10__main:
11 ret
12
13text ends
14end __main
Рассмотрим код по-блочно:
xxxxxxxxxx
21.586
2.model flat, stdcall
Первый блок — это блок определений для ассемблера.
xxxxxxxxxx
11.586
В первой строке обозначается набор используемых инструкций, в данном случае мы используем i586
набор, который является достаточно универсальным для процессоров Intel и AMD.
xxxxxxxxxx
11.model flat, stdcall
Во второй строке задается модель памяти программы, а также модель вызова процедур. Так как мы программируем под WIndows, то модель памяти должна быть flat
, а модель вызова процедур — stdcall
.
В данный момент примем это, как данность и будем просто копировать из программы в программу.
Следующий блок, это сегмент данных:
xxxxxxxxxx
31data segment
2 ; место для объявления переменных
3data ends
Сегмент данных используется для задания всех необходимых в программе переменных. Объявлять переменные вне этого сегмента нельзя.
Следующий блок, это сегмент команд:
xxxxxxxxxx
71text segment
2
3__main:
4 ret
5
6text ends
7end __main
Сегмент команд может называться любым именем, но стандартно его называют text
. Сегмент команд — это то место в коде, где пишутся все исполняемые команды программы. Писать команды вне сегмента команд нельзя.
В сегменте команд обязательна начальная метка (о том, что это такое мы поговорим дальше) :
xxxxxxxxxx
11__main:
Эта метка должны быть закрыта, сразу же после завершения сегмента команд:
xxxxxxxxxx
21text ends ; <- конец сегмента команды
2end __main ; <- закрываем метку
Данная метка, как функция main
в С/С++, с нее начинается выполнение команд, то есть программа начнет свое исполнение с первой команды после метки __main:
.
В нашем случае это команда ret
. Сейчас не будем вдаваться в подробности, эта команда завершает выполнение программы.
Однако команды можно писать и до метки __main:
, но тогда они не будут исполнены, в нормальном течении программы. Так как команды выполняются друг за другом, пока не будет встречен конец.
До метки __main
обычно пишут функции, которые вызываются командами после метки __main:
. Об этом мы поговорим в разделе про функции.
Это все блоки, которые понадобятся нам в написании нашего ассемблерного кода.
Отмечу еще один факт, большая часть ассемблера не учитывает регистр, поэтому записи:
xxxxxxxxxx
31data segment
2; и
3Data Segment
равноценны. Однако некоторые части являются регистрозависимыми, в этом случае, это будет указано явно.
Подведем итоги:
Переменные определяются между
data segment
иdata ends
! Этот блок называется сегментом данных!xxxxxxxxxx
31data segment
2; место для объявления переменных
3data ends
Весь код программы пишется между
text segment
иtext ends
(этот блок называется сегментом команд) после метки__main:
! Однако если задается функция, то она обычно пишется до метки:xxxxxxxxxx
81text segment
2; вот тут различные вспомогательные функции
3__main:
4; вот тут код программы, который будет исполнятся
5; после запуска программы
6ret
7
8text ends
Весь остальной код, можно просто копировать из программы в программу, он не изменяется!
Комментарии начинаются с символа точка с запятой
;
Большая часть ассемблера не учитывает регистр, поэтому записи:
xxxxxxxxxx
31data segment
2; и
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 байта.
Переменные в ассемблере не отличаются от переменных в привычных нам языках. Помните в каком сегменте они задаются? Правильно в сегменте данных:
xxxxxxxxxx
31data segment
2 ; место для объявления переменных
3data ends
Числовые переменные могут быть следующих типов:
Директива | Название | Размер |
---|---|---|
DB | Byte | 1 байт |
DW | Word | 2 байта |
DD | DoubleWord | 4 байта |
DQ | QuadWord | 8 байт |
DT | TWord | 10 байт |
Переменные объявляются следующим образом:
xxxxxxxxxx
11[ИмяПеременной] [Директива] [НачальноеЗначение] или ?
Если начального значения нет, то необходимо поставить на его место знак вопроса (?
)
Давайте посмотрим на объявление нескольких переменных:
xxxxxxxxxx
71data segment
2 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-размера заполненный каким-то значением.
Первый вариант имеет следующий синтаксис:
xxxxxxxxxx
11[ИмяМассива] [Директива] [СписокИнициализаторовЧерезЗапятую]
Давайте посмотрим на объявление некоторых массивов:
xxxxxxxxxx
41data segment
2 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
Второй вариант имеет следующий синтаксис:
xxxxxxxxxx
11[ИмяМассива] [Директива] [РазмерМассива] dup ([ЗначениеДляЗаполнения])
Ключевое слово dup
задает массив определенного размера заполненный некоторым значением.
Давайте посмотрим на объявление некоторых массивов:
xxxxxxxxxx
41data segment
2 empty_array DQ 10 dup (0) ; задает массив 8 байтных чисел размера 10, где каждый элемент равен 0
3 nine_array DD 100 dup (9) ; задает массив 4 байтных чисел размера 100, где каждый элемент равен 9
4data ends
А что, если попробовать объявить массив однобайтных символов? Например такой:
xxxxxxxxxx
31data segment
2 string DB 'W','o','r','l','d', 0 ; задает массив однобайтных чисел, который инициализируется символами
3data ends
И так и правда можно, таким образом мы задали строку World
! Обратите внимание на ноль, он будет говорить программе, что эта строка завершена. Вспомните, в Си все строки заканчиваются нулем терминатором по-умолчанию. Ассемблер же сам не вставляет в конец нуль-терминатор, поэтому его нужно объявлять явно, с помощью еще одного элемента массива в виде нуля.
Однако так задавать строки очень неудобно, поэтому в ассемблере есть более удобный синтаксис для задания строк.
Синтаксис задания строки следующий:
xxxxxxxxxx
11[НазваниеПеременной] DB "[ЗначениеСтроки]", 0
Здесь тип DB
обязателен, так как мы задаем строку с однобайтными символами. Не стоит забывать, что строки, как и в Си хранятся в виде массива, на что явно указывал способ объявления выше. Ноль после строки выполняет ту же роль, что была обозначена выше.
Давайте объявим несколько строк:
xxxxxxxxxx
41data segment
2 hello DB "Hello ", 0 ; переменная хранящая строку "Hello "
3 world DB "World", 0 ; переменная хранящая строку "World"
4data ends
А помните, пример вывода Hello World!
в самом начале, когда мы только изучали компиляцию? Там как раз таки в сегменте данных были объявлены две строки:
xxxxxxxxxx
41data segment
2 print_format db "%s ", 0
3 hello_world db "Hello World", 0
4data ends
Выделим самое основное:
- Числовые переменные могут быть следующих типов:
Директива Название Размер DB
Byte
1 байт DW
Word
2 байта DD
DoubleWord
4 байта DQ
QuadWord
8 байт DT
TWord
10 байт
Синтаксис объявления численной переменной:
xxxxxxxxxx
11[ИмяПеременной] [Директива] [НачальноеЗначение] или ?
Массивы можно задавать двумя способами:
xxxxxxxxxx
11[ИмяМассива] [Директива] [СписокИнициализаторовЧерезЗапятую]
xxxxxxxxxx
11[ИмяМассива] [Директива] [РазмерМассива] dup ([ЗначениеДляЗаполнения])
Строки задаются следующим образом:
xxxxxxxxxx
11[НазваниеПеременной] DB "[ЗначениеСтроки]", 0
После строки обязательно надо поставить ноль, чтобы явно обозначить завершение строки!
Очень важной частью программирования на ассемблере являются метки и переходы к ним.
Метка — это конструкция языка, которая имеет уникальное имя, после которого идет двоеточие (:
) и перенос строки, позволяющая переходить между исполняемыми командами:
xxxxxxxxxx
21label:
2 ; после метки, обычно ставят табуляцию для последующего кода
Метки ставятся в любом месте сегмента команд. Метка — это место в коде, в которое можно перейти и начать исполнение команд непосредственно с этой метки, то есть вне зависимости от места где сейчас исполняется код, можно перейти к метке и продолжить выполнять команды расположенные после этой метки.
Помните начальную метку __main:
, с которой начинается выполнение команд? Эта также метка, только переход к ней происходит в автоматическом режиме при запуске программы.
Рассмотрим пример использования метки:
xxxxxxxxxx
301.586
2.model flat, stdcall
3
4data segment
5data ends
6
7text segment
8
9
10__start:
11 enter 0, 0
12
13plus: ; <- объявляем метку в коде
14 mov ebx, 3 ; <-- не обращайте внимание на эти команды
15 mov eax, 100 ; пока что они просто что-то выполняют
16 add ebx, eax
17
18 jmp plus ; <- команда jmp будет описано позже
19 ; однако она позволяет "перепрыгнуть к метке"
20 ; в нашем случае к "plus" и команды после
21 ; этой метки будут выполнятся вновь, а после
22 ; снова будет переход к метке и снова
23 ; выполнение этих же команд, таким образом
24 ; получается бесконечный цикл
25
26 leave
27 ret
28
29text ends
30end __start
Дальше мы переходим к основной части ассемблера, а именно к командам. Однако изучать сухую теорию не очень весело, поэтому сейчас мы создадим тестовый стенд, где сможем выводить значение регистра eax
, после использования каких-то команд. Тем самым мы сразу будем видеть результат выполнения команды.
Для начала, изменим содержание нашего файл test.asm
на следующее:
xxxxxxxxxx
381.586
2.model flat, stdcall
3
4include <\masm32\include\msvcrt.inc>
5include <\masm32\include\kernel32.inc>
6includelib <\masm32\lib\msvcrt.lib>
7includelib <\masm32\lib\kernel32.lib>
8
9data segment
10 print_format db "%d ", 0
11data ends
12
13text segment
14
15print PROC
16 enter 0, 0
17 mov eax, [ebp + 8]
18 invoke crt_printf, offset print_format, eax
19 leave
20 ret 4
21print ENDP
22
23
24__start:
25 enter 0, 0
26
27
28 ; вот тут будем писать наш код
29
30
31 push eax
32 call print
33
34 leave
35 ret
36
37text ends
38end __start
В дальнейшем, я буду описывать только код между enter 0, 0
и push eax
. Пока что не заморачивайтесь, что здесь написано, главное, что эта программа будет выводить значение регистра eax
(помните, что регистры — это переменные? Так что мы выводим просто значение переменной, ничего сложного).
Теперь попробуем скомпилировать программу. Для этого используем наш файл run.bat
, запустим его двойным щелчком. Если все хорошо, то в консоли будет выведено случайное число. (Посмотрите, что будет, если несколько раз подряд запустить программу?).
Теперь переходим к командам.
[]
в описании команды, означает, что на этом месте будет что-либо. Конкретное описание того, что там может быть будет в описании команды.
MOV [приемник], [источник]
Первая команда, это команда mov
, расшифровывается, как move
, что переводится как "перемещать".
Эта команда перемещает значение из источника в приемник.
Итак, давайте поиграем с этой командой. Помните, что мы изменяем только код между enter 0, 0
и push eax
? Если да, то начинаем.
Давайте напишем следующий код:
xxxxxxxxxx
11mov eax, 100
Сохраним и запустим компиляцию файлом run.bat
. В выводе должно появится число 100
. (Помните, что мы выводим значение eax
?) Таким образом, мы поместили значение 100
в регистр eax
. Здорово, не правда ли? А теперь давайте поместим значение в другой регистр, и значение этого регистра поместим в eax
:
xxxxxxxxxx
21mov ebx, 200
2mov eax, ebx
После компиляции в выводе должно быть число 200
. То есть, здесь, мы сначала поместили в регистр ebx
значение 200
, а затем значение ebx
(которое равно 200
) мы поместили в регистр eax
. Держите в голове тот факт, что регистр можно рассматривать, как переменную.
Следующие команды описывают команды для арифметических действий с числами. Пришло время посчитать.
ADD [приемник], [источник]
Команда add
расшифровывается, как addition
, что переводится как "сложение".
Эта команда складывает значения из приемника со значением источника и кладет его в приемник.
Давайте напишем следующий код:
xxxxxxxxxx
21mov eax, 100
2add eax, 5
Здесь мы сначала поместили значение 100
в регистр eax
, а затем прибавили к значению eax
число 5
. Тем самым на выводе мы должны получить число 105
. Так и есть.
Все просто, а что если сначала поместить значение в ebx
, затем в eax
, а затем сложить eax
и ebx
? Это ваше задание, напишите такую программу, значения могут быть любыми.
Если вы не знаете, как это написать, попробуйте написать по подобию примеров, это не так сложно, однако именно практика дает 50% запоминания и понимания материала, так что обязательно делайте эти небольшие задания.
SUB [приемник], [источник]
Команда sub
расшифровывается, как subtraction
, что переводится как "вычитание".
Эта команда вычитает значение источника из значения приемника и кладет его в приемник.
Мы уже умеем складывать, теперь пробуем вычитать:
xxxxxxxxxx
21mov eax, 100
2sub eax, 5
У нас есть такой код, скажите, как вы думаете, что должно быть выведено? Правильно, 95. Ничего сложного, все как и со сложением.
А если написать такой код, то что выведется?
xxxxxxxxxx
21mov eax, 100
2sub eax, eax
Да, выведется 0
, так как мы из eax
вычитаем eax
.
Теперь, когда мы знаем две операции, напишите программу, которая посчитает значение выражения:
xxxxxxxxxx
112 + 56 - 100 + 54
На выходе вы должны получить 12
.
IMUL [приемник], [источник]
Команда imul
расшифровывается, как integer multiplication
, что переводится как "умножение целых чисел".
Эта команда перемножает значение источника со значением приемника и кладет результат в приемник.
Наконец то мы добрались до умножения. Здесь все также очень похоже на две команды выше.
xxxxxxxxxx
21mov eax, 100
2imul eax, 4
Что должно быть выведено в итоге? Правильно, 400
.
Теперь, когда вы знаете 3 арифметические операции, ваша задача написать код, который будет рассчитывать дискриминант.
Формула дискриминанта:
Значения, a
, b
, c
можно взять любыми. Главное, чтобы результат был выведен на экран. (Не забывайте, мы выводим регистр eax
, поэтому результат должен быть именно в нем).
DIV [источник]
Команду для деления мы пока что разбирать не будем, так как она слишком сложная, на этом этапе изучения ассемблере. Вернемся к ней позже.
Следующие команды, это команды для сравнения и условных переходов.
Условные переходы — это переходы в какую-то метку программы, в зависимости от того, какой результат был получен в результате последней команды cmp
.
CMP [значение_1], [значение_2]
Команда cmp
расшифровывается, как compare
, что переводится как "сравнить".
Эта команда сравнивает значение 1 со значением 2, а результат записывает в регистр флагов. О регистре флагов мы поговорим дальше, сейчас просто поймите это, как то, что результат записывается в регистре флагов и мы можем его использовать с помощью следующих команд.
Например:
xxxxxxxxxx
21cmp eax, 10 ; сравниваем значение 10 и значение регистра eax
2cmp ebx, eax ; сравниваем значение регистра eax и значение регистра ebx
Следующие команды описывают условные переходы. То есть эти команды переходят к меткам в зависимости от результата предыдущей команды, например, команды cmp
.
JNE [имя_метки]
Команда jne
расшифровывается, как jump if not equal
, что переводится как "прыгнуть если НЕравно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp
первый операнд был НЕравен второму
Давайте попробуем использовать эту и предыдущую команды и попробуем создать цикл:
xxxxxxxxxx
121mov eax, 1 ; <-- помещаем в eax единицу
2mov ebx, 1 ; <-- помещаем в ebx единицу
3mov ecx, 10 ; <-- помещаем в ecx десять
4
5loop_start: ; <-- определяем метку
6 imul eax, 2 ; <-- умножаем eax на 2
7 add ebx, 1 ; <-- добавляем к ebx единицу
8
9 cmp ebx, ecx ; <-- сравниваем ebx и ecx
10 jne loop_start ; <-- если в результате сравнения оказалось, что
11 ; ebx неравен ecx, то переходим к метке loop_start
12 ; и снова выполняем команды
Данный код описывает простой цикл, в нем значение ebx
увеличивается на 1, а затем проверяется на равенство ecx
. Таким образом, переход к метке loop_start
будет происходить до тех пор, пока значения ebx
и ecx
не станут равными, тогда код продолжит выполнять код за пределами данного отрывка (помним, что мы описываем не весь код, а его часть, дальше идет вывод значения eax
).
Если вы запустите код, вы должны получить 512
. Таким образом мы написали расчет степени двойки, чтобы поменять расчетную степень, надо изменить первоначальное значение ecx
.
Ваша задача написать на основе этого кода программу, которая будет рассчитывать степень числа 5. Ответ в приложении.
JE [имя_метки]
Команда je
расшифровывается, как jump if equal
, что переводится как "прыгнуть если равно".
Эта команда переходит к метке, если в результате последнего вызова команды cmp
первый операнд был равен второму
Сейчас мы рассмотрим простейшую реализацию конструкции if else
. Предположим, что нам нужно сравнить значение ebx
и ecx
и в случае равенства вывести 1
, а в обратном случае — 0
.
xxxxxxxxxx
131mov ebx, 1 ; <-- помещаем в ebx единицу
2mov ecx, 10 ; <-- помещаем в ecx десять
3
4cmp ebx, ecx ; <-- сравниваем ebx и ecx
5je if_equal ; <-- если в результате сравнения оказалось, что
6 ; ebx равен ecx, то переходим к метке if_equal
7jne if_not_equal ; <-- если в результате сравнения оказалось, что
8 ; ebx НЕравен ecx, то переходим к метке if_not_equal
9
10if_equal:
11 mov eax, 1
12if_not_equal:
13 mov eax, 0
Казалось бы, вроде все верно, если равно, то переходим к одной метке, если нет — к другой. Но здесь проявляется особенность ассемблера, он выполняет команды одну за другой, несмотря ни на что. Поэтому в данном случае, если числа будут равны, eax
станет равным 1
, но после этого же, ему будет присвоен 0
и в результате работы будет выведен 0
.
Чтобы избежать такого, создают специальную метку, в случае, если числа равны, эта метка будет переходить к коду после команд, которые должны были быть выполнены в случае неравенства.
Для этого используется команды jmp
, давайте отвлечемся на ее описание, а после вернемся к примеру.
JMP [имя_метки]
Команда jmp
расшифровывается, как jump
, что переводится как "прыгнуть".
Эта команда переходит к метке, вне зависимости от чего-либо. Это безусловный переход.
Теперь вернемся к нашему примеру и добавим эту метку и переход к ней:
x
1mov ebx, 1 ; <-- помещаем в ebx единицу
2mov ecx, 10 ; <-- помещаем в ecx десять
3
4cmp ebx, ecx ; <-- сравниваем ebx и ecx
5je if_equal ; <-- если в результате сравнения оказалось, что
6 ; ebx равен ecx, то переходим к метке if_equal
7jne if_not_equal ; <-- если в результате сравнения оказалось, что
8 ; ebx НЕравен ecx, то переходим к метке if_not_equal
9
10if_equal:
11 mov eax, 1
12 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, то мы получим бесконечный цикл, так как значения никогда не будут равны и программа будет постоянно переходить к метке.
Вам нужно исправить этот код, используя команды выше, чтобы он не уходил в бесконечный цикл.
xxxxxxxxxx
121mov eax, 1 ; <-- помещаем в eax единицу
2mov ebx, 1 ; <-- помещаем в ebx единицу
3mov ecx, 10 ; <-- помещаем в ecx десять
4
5loop_start: ; <-- определяем метку
6 imul eax, 2 ; <-- умножаем eax на 2
7 add ebx, 2 ; <-- добавляем к ebx двойку
8
9 cmp ebx, ecx ; <-- сравниваем ebx и ecx
10 jne loop_start ; <-- если в результате сравнения оказалось, что
11 ; ebx неравен ecx, то переходим к метке loop_start
12 ; и снова выполняем команды
Ответ в приложении.
На этом основные команды закончены.
Мы переходим к одной из самых сложных частей ассемблера — стеку. В первую очередь, давайте рассмотрим, что вообще такое стек, как структура данных.
Стек — это структура данных, при использовании которой возможны только две операции:
Таким образом, если мы поместили в стек 3 значения, то получить доступ мы можем только к последнему добавленному элементу, а чтобы получить доступ к элементу посередине, нужно сначала извлечь все элементы до него.
Стек можно сравнить со стопкой тарелок. Если вы хотите достать тарелку из середины, то вам нужно сначала убрать все тарелки над ней. Также, когда вы добавляете новую тарелку, вы кладете ее на вершину стопки.
Если говорить о том, что такое стек в программе, то — это область программы для временного хранения произвольных данных.
Конечно, можно хранить все данные в сегменте памяти, в виде переменных, но это увеличивает размер программы и количество используемых имен.
Память в стеке используется многократно, что удобно, так как занимает меньше памяти.
Для работы со стеком есть две основных команды:
PUSH [значение]
Команда push
переводится как "протолкнуть".
Эта команда добавляет значение в стек.
Значение может быть регистром, переменной или константой;
POP [приемник]
Команда pop
переводится как "вытолкнуть".
Эта команда извлекает значение вершины стека и помещает его в приемник.
Приемник может быть регистром или переменной;
Давайте попробуем написать что-нибудь с использованием стека:
Предположим у нас есть 5 чисел:
10, 5, 6, 3, 2
Наша задача сложить их. Но мы не сможем их задать одновременно, так как регистров у нас только 4, а чисел 5. Конечно, здесь можно использовать и переменные, но давайте реализуем это с помощью стека:
В первую очередь помещаем все наши значения в стек:
xxxxxxxxxx
51push 10
2push 5
3push 6
4push 3
5push 2
После того, как значения в стеке, нам надо 5 раз достать значение из стека и прибавить его к eax
:
xxxxxxxxxx
181push 10
2push 5
3push 6
4push 3
5push 2
6
7mov eax, 0
8
9pop ebx ; <-- достаем значение из стека в ebx
10add eax, ebx ; <-- складываем eax и ebx
11pop ebx ; <-- достаем значение из стека в ebx
12add eax, ebx ; <-- складываем eax и ebx
13pop ebx ; <-- достаем значение из стека в ebx
14add eax, ebx ; <-- складываем eax и ebx
15pop ebx ; <-- достаем значение из стека в ebx
16add eax, ebx ; <-- складываем eax и ebx
17pop ebx ; <-- достаем значение из стека в ebx
18add 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]
Давайте попробуем поместить в стек пару значений и получить значение элемента после самого последнего:
xxxxxxxxxx
41push 10 ; <-- помещаем значение в стек
2push 20 ; <-- помещаем значение в стек
3
4mov eax, [esp + 4]
В последней строке мы используем разыменование, но однако до этого мы сдвигаем указатель на 4 байта, такой синтаксис корректен, он означает, что начала из адреса вычитается 4, а потом происходит разыменование. В итоге, мы получим в выводе 20
, так как адрес esp + 4
указывает на следующий, после вершины стека, элемент.
Однако, вас может смутить, почему для того, чтобы получить элемент под верхним, нужно прибавлять 4, а не отнимать. Дело в том, что адреса в стеке уменьшаются снизу вверх:
Поэтому для получения элементов ниже последнего нужно прибавлять, а выше — вычитать.
Но почему именно 4
? Дело в том, что мы помещаем в стек 4
байтные значения по-умолчанию, и чтобы обратится именно к следующему элементу нужно прибавить 4
. Если вы попробуете прибавить, например, 2
, то вы получите число, которое описывается 4
байтами, начиная с текущего, то есть программа просто возьмет 32
бита из стека и поместит их в eax
абсолютно не думая о том, что это могут быть не те данные, обычно это приведет к ошибкам.
Следующая очень важная тема, это функции. Функции в ассемблере задаются с помощью ключевого слова PROC
(регистр не важен, см. введение).
Так, например, объявление функции выглядит следующим образом:
x
1function_name PROC
2 ; команды
3function_name ENDP
Каждая функция должна заканчиваться командой ret
для явного выхода из функции.
x
1function_name PROC
2 ; команды
3 ret
4function_name ENDP
Вызвать функцию можно с помощью команд условного и безусловного перехода, но для этого есть более специализированные команды.
Для вызова функции используется команда call
.
CALL [имя_функции]
Команда call
переводится как "вызвать".
Эта команда помещает в стек адрес возврата (адрес следующей, после этой, команды) и переходит к началу функции. Адрес возврата нужен, чтобы после завершения выполнения перейти к команде, которые должны быть выполнены после вызова функции.
Каждая функция начинается с так называемого пролога процедуры.
Пролог процедуры — это фрагмент кода, нужный для того, чтобы сохранить текущее состояние стека, то есть сохранить адрес последнего элемента стека в момент вызова функции.
Код пролога следующий:
xxxxxxxxxx
21push ebp
2mov ebp, esp
В первой строке мы помещаем в стек ebp
, чтобы потом, после того, как функция завершит свою работу восстановить изначальное значение. Это нужно, так как в следующей строке мы кладем значение esp
в ebp
. Но что это означает на деле.
Предположим, у нас в стеке лежит два значения. Мы вызываем функцию с помощью команды call
. В стек кладется адрес возврата (см. описание функции call
):
После того, как мы выполним первую команду пролога, стек будет следующим:
То есть мы помещаем ebp
в стек, при этом esp
указывает на последний элемент, то есть на значение ebp
, которое было только что добавлено. Сам регистр ebp
все еще указывает на дно стека.
После того, как мы выполним вторую команду пролога, стек будет следующим:
Все что изменилось, это то, что ebp
стало равным esp
.
Таким образом, после пролога, в ebp
будет хранится адрес вершины стека (или верхнего элемента стека) в момент вызова функции. Это нужно, чтобы, когда мы в функции будем добавлять элементы в стек, то в ebp
адрес все еще будет указывать на начальный для esp
в начале функции:
Это нужно, чтобы можно было обращаться к элементам стека, которые были до вызова функции по фиксированным сдвигам. Так, например, если мы хотим получить значение 20
то достаточно прибавить 8 к ebp
, а вот для esp
необходимо сначала вычесть количество добавленных в функции новых значений и только потом вычесть 8, что намного сложнее. Поэтому мы будем использовать ebp
для доступа к элементам стека, которые были добавлены до вызова функции, это понадобится нам в дальнейшем для передачи аргументов.
После того, как функция завершена, выполняется так называемый эпилог процедуры. Эпилог процедуры восстанавливает значение ebp
, которое мы сохранили в стек в прологе.
Код эпилога следующий:
xxxxxxxxxx
11pop ebp
Стек после эпилога будет следующий:
То есть теперь ebp
будет вновь указывать на дно стека (будет иметь первоначальное значение, которые мы сохранили в прологе), а значение из стека будет изъято. Теперь на вершине стека лежит адрес возврата. Встречая команду ret
, из стека извлекается значение и трактуется, как адрес возврата, то есть в виде адреса команды, с которой нужно продолжить выполнение программы после того, как функция закончена.
Например, у вас есть следующий код:
x
1add eax, 100
2call someFunction
3mov ebx, 100
Когда функция someFunction
вызывается, то в стек помещается адрес следующей команды, то есть в нашем случае адрес команды mov
.
После того, как функция закончила работу, из стека извлекается этот адрес и выполнение продолжается, начиная с команды по этому адресу, то есть с команды mov
.
Однако здесь таится большая возможная проблема, а что если мы в функции поместили в стек какое-то значение?
И после добавления у нас идет эпилог функции. Тогда в ebp
будет помещено значение из вершины стека, где у нас лежит значение 5
.
Теперь ebp
указывает на неизвестно что, а на вершине стека лежит адрес ebp
, который мы поместили еще в прологе. Теперь, так как после пролога идет команда ret
, будет извлечено значение из стека и оно будет рассматриваться, как адрес возврата, но это не адрес возврата, а адрес ebp
. Поэтому мы получим ошибку, так как пытаемся получить доступ к памяти, которая нам недоступна.
Чтобы этого избежать, нужно тщательно следить за стеком!
Если вы в функции добавляете какие-то значения в стек, то их обязательно нужно извлечь оттуда до пролога!