4 глава

Генерация кода

Генерация кодаВступлениеАссемблер (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)

Ассемблер — это низкоуровневый язык программирования. Все команды в нем, являются более удобными заменами двоичного представления команд процессора.

Сам ассемблер не является сложным языком в плане синтаксиса, у него простая структура и небольшой набор доступных команд. Однако ассемблер сложен тем, что такие вещи как циклы, условия не существуют, как готовые языковые конструкции, они описываются вручную с помощью определенного набора доступных команд. Это довольно непривычно, но мы разберем все основные конструкции на примерах.

Установка компилятора для MASM

Для того, чтобы исходный код на ассемблере преобразовывать в готовые для запуска, исполняемые файлы, нам понадобится компилятор ассемблера. Для masm существует набор инструментов под общим названием masm32, который включает в себя компилятор.

Инструкция по установке:

  1. Переходим по ссылке на официальный сайт и выбираем любой из вариантов для скачивания;
  2. Открываем архив и запускаем файл install.exe;
  3. Далее нажимаем кнопку install и следуем дальнейшим инструкциям. Процесс установки довольно долгий, так что придется подождать. После установки откроется редактор, его можно спокойно закрывать;
  4. Теперь необходимо добавить путь к папке с установленным masm32 в переменную окружения Path. Откройте проводник и вставьте в адресную строку Control Panel\System and Security\System и нажмите Enter;
  5. Дальше в меню слева выберите пункт Расширенные настройки системы;
  6. В появившемся окне кликнете на кнопку Переменные среды;
  7. В верхней таблице найдите запись у которой первый столбец имеет значение Path и кликнете по ней два раза;
  8. В проводнике, найдите папку, в которую вы установили MASM (обычно это папка masm32 на диске, который вы выбрали в начале установки);
  9. Скопируйте путь до папки bin в папке, где установлен MASM.;
  10. В появившемся ранее окне нажмите на пустое место, и в появившееся поле вставьте скопированный путь. Это необходимо, чтобы получить быстрый доступ к таким программам, как ml.exe и link.exe, которые понадобятся для компиляции;
  11. Нажмите ОК и еще раз ОК;
  12. Откройте PowerShell (воспользуйтесь поиском Window, для быстрого поиска) и введите команду ml, если ошибки нет, значит компилятор для MASM установлен верно.

Первая программа на ассемблере

Теперь, когда компилятор установлен, давайте скомпилируем небольшую тестовую программу, которая будет выводить "Hello World!" в консоль.

В папке, где вы пишите код компилятора, создайте папку с любым названием, например, test_asm.

Для дальнейших действий вы можете использовать любую консоль, будь то PowerShell или стандартную консоль Windows. Я буду показывать все в PowerShell, хотя все действия полностью идентичны.

Откроем PowerShell. Скопируем полный путь до папки test_asm и пропишем следующую команду в консоли:

где [path_to_test_asm] заменим на путь к папке.

Выполните команду нажатием клавиши Enter. Эта команда сменит текущий каталог, на каталог который был прописан на месте [path_to_test_asm], тем самым мы перейдем в каталог в котором будет файл, где мы будем писать ассемблерный код. Это удобно, так как, не надо прописывать длинный путь к файлу с ассемблерным кодом, а достаточно указать его название: test.asm.

Важно!

Весь код, который рассматривается в этой главе должен быть сохранен в кодировке ASCII или другой кодировке на основе ASCII (например, windows-1251).

Время добавить этот файл с ассемблерным кодом. Создайте файл test.asm со следующим содержанием:

Далее, последовательно введите в PowerShell две команды:

Если все верно, то вы должны получить файл test.exe. Если вы его запустите, то увидите надпись Hello World!.

Если вы получаете ошибку на подобии этой:

то это означает, что есть проблемы с установкой masm32. Возможно вы не прописали путь в переменной окружения Path, которая описана в 7 пункте инструкции по установке. Если проблема все еще существует, попробуйте перезапустить PowerShell с правами администратора.

 

Теперь давайте разберем, что это за команды:

Первая команда отвечает за компиляцию программы в объектный файл. Это файл еще не является исполняемым, поэтому, чтобы сделать из него исполняемый, мы используем линкер с помощью второй командой:

В результате мы получим готовый исполняемый файл, который можем запустить.

 

Итак, теперь мы умеем компилировать ассемблерный код.

Давайте выделим основное:

Чтобы скомпилировать ассемблерный код, нужно в первую очередь перейти в каталог с исходным кодом, чтобы не прописывать длинные пути, с помощью команды cd [путь_до_папки], а затем выполнить следующие две команды:

Первая из которых скомпилирует ассемблерный код в объектный файл, а вторая создаст на его основе исполняемый файл.

Лайфхак

Прописывать эти команды каждый раз, долго. Однако есть способ избежать этого. Для этого создадим в папке файл run.bat со следующим содержанием:

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

И теперь, чтобы перекомпилировать ассемблерный код, достаточно запустить файл run.bat двойным кликом.

Если вы хотите компилировать файл с названием отличным от test.asm, просто поменяйте название во всех командах на необходимое.

 

Основы ассемблера

Основа программ на ассемблере — это команды. Команды выполняются одна за другой до тех пор, пока не будет встречен конец программы. Благодаря некоторым конструкциям языка, мы можем переходить к любому месту в программе при необходимости, это позволяет создавать все возможные конструкции циклов или условий.

Пустая программа

Давайте рассмотрим "костяк" любой программы на ассемблере. Это код можно просто копировать из программы в программу, он везде будет одинаков.

Рассмотрим код по-блочно:

Первый блок — это блок определений для ассемблера.

В первой строке обозначается набор используемых инструкций, в данном случае мы используем i586 набор, который является достаточно универсальным для процессоров Intel и AMD.

Во второй строке задается модель памяти программы, а также модель вызова процедур. Так как мы программируем под WIndows, то модель памяти должна быть flat, а модель вызова процедур — stdcall.

В данный момент примем это, как данность и будем просто копировать из программы в программу.

Следующий блок, это сегмент данных:

Сегмент данных используется для задания всех необходимых в программе переменных. Объявлять переменные вне этого сегмента нельзя.

Следующий блок, это сегмент команд:

Сегмент команд может называться любым именем, но стандартно его называют text. Сегмент команд — это то место в коде, где пишутся все исполняемые команды программы. Писать команды вне сегмента команд нельзя.

В сегменте команд обязательна начальная метка (о том, что это такое мы поговорим дальше) :

Эта метка должны быть закрыта, сразу же после завершения сегмента команд:

Данная метка, как функция main в С/С++, с нее начинается выполнение команд, то есть программа начнет свое исполнение с первой команды после метки __main:.

В нашем случае это команда ret. Сейчас не будем вдаваться в подробности, эта команда завершает выполнение программы.

Однако команды можно писать и до метки __main:, но тогда они не будут исполнены, в нормальном течении программы. Так как команды выполняются друг за другом, пока не будет встречен конец.

До метки __main обычно пишут функции, которые вызываются командами после метки __main:. Об этом мы поговорим в разделе про функции.

Это все блоки, которые понадобятся нам в написании нашего ассемблерного кода.

Отмечу еще один факт, большая часть ассемблера не учитывает регистр, поэтому записи:

равноценны. Однако некоторые части являются регистрозависимыми, в этом случае, это будет указано явно.

 

Подведем итоги:

  1. Переменные определяются между data segment и data ends! Этот блок называется сегментом данных!

  2. Весь код программы пишется между text segment и text ends (этот блок называется сегментом команд) после метки __main:! Однако если задается функция, то она обычно пишется до метки:

  3. Весь остальной код, можно просто копировать из программы в программу, он не изменяется!

  4. Комментарии начинаются с символа точка с запятой ;

  5. Большая часть ассемблера не учитывает регистр, поэтому записи:

    равноценны. Однако некоторые части являются регистрозависимыми, в этом случае, это будет указано явно!

 

Регистры

Следующее, что мы рассмотрим — это регистры.

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

Регистры по сути такие же переменные, в которые можно записывать и считывать данные.

Регистров не так много, ниже приведена сводная таблица:

НазваниеРазрядностьОсновное назначение
EAX32Аккумулятор
EBX32База
ECX32Счётчик
EDX32Регистр данных
EBP32Указатель базы
ESP32Указатель стека
ESI32Индекс источника
EDI32Индекс приёмника
EFLAGS32Регистр флагов
EIP32Указатель инструкции (команды)
CS16Сегментный регистр
DS16Сегментный регистр
ES16Сегментный регистр
FS16Сегментный регистр
GS16Сегментный регистр
SS16Сегментный регистр

 

Регистры 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 байта.

 

Переменные

Переменные в ассемблере не отличаются от переменных в привычных нам языках. Помните в каком сегменте они задаются? Правильно в сегменте данных:

Числовые

Числовые переменные могут быть следующих типов:

ДирективаНазваниеРазмер
DBByte1 байт
DWWord2 байта
DDDoubleWord4 байта
DQQuadWord8 байт
DTTWord10 байт

Переменные объявляются следующим образом:

Если начального значения нет, то необходимо поставить на его место знак вопроса (?)

Давайте посмотрим на объявление нескольких переменных:

Массивы

В ассемблере массивы можно задавать несколькими способами, мы рассмотрим два варианта, как набор значений через запятую, и как массив n-размера заполненный каким-то значением.

Первый вариант имеет следующий синтаксис:

Давайте посмотрим на объявление некоторых массивов:

Второй вариант имеет следующий синтаксис:

Ключевое слово dup задает массив определенного размера заполненный некоторым значением.

Давайте посмотрим на объявление некоторых массивов:

 

А что, если попробовать объявить массив однобайтных символов? Например такой:

И так и правда можно, таким образом мы задали строку World! Обратите внимание на ноль, он будет говорить программе, что эта строка завершена. Вспомните, в Си все строки заканчиваются нулем терминатором по-умолчанию. Ассемблер же сам не вставляет в конец нуль-терминатор, поэтому его нужно объявлять явно, с помощью еще одного элемента массива в виде нуля.

Однако так задавать строки очень неудобно, поэтому в ассемблере есть более удобный синтаксис для задания строк.

 

Строки

Синтаксис задания строки следующий:

Здесь тип DB обязателен, так как мы задаем строку с однобайтными символами. Не стоит забывать, что строки, как и в Си хранятся в виде массива, на что явно указывал способ объявления выше. Ноль после строки выполняет ту же роль, что была обозначена выше.

Давайте объявим несколько строк:

 

А помните, пример вывода Hello World! в самом начале, когда мы только изучали компиляцию? Там как раз таки в сегменте данных были объявлены две строки:

 

Выделим самое основное:

  1. Числовые переменные могут быть следующих типов:
ДирективаНазваниеРазмер
DBByte1 байт
DWWord2 байта
DDDoubleWord4 байта
DQQuadWord8 байт
DTTWord10 байт
  1. Синтаксис объявления численной переменной:

  2. Массивы можно задавать двумя способами:

  3. Строки задаются следующим образом:

    После строки обязательно надо поставить ноль, чтобы явно обозначить завершение строки!

Метки

Очень важной частью программирования на ассемблере являются метки и переходы к ним.

Метка — это конструкция языка, которая имеет уникальное имя, после которого идет двоеточие (:) и перенос строки, позволяющая переходить между исполняемыми командами:

Метки ставятся в любом месте сегмента команд. Метка — это место в коде, в которое можно перейти и начать исполнение команд непосредственно с этой метки, то есть вне зависимости от места где сейчас исполняется код, можно перейти к метке и продолжить выполнять команды расположенные после этой метки.

Помните начальную метку __main:, с которой начинается выполнение команд? Эта также метка, только переход к ней происходит в автоматическом режиме при запуске программы.

Рассмотрим пример использования метки:

Команды

Дальше мы переходим к основной части ассемблера, а именно к командам. Однако изучать сухую теорию не очень весело, поэтому сейчас мы создадим тестовый стенд, где сможем выводить значение регистра eax, после использования каких-то команд. Тем самым мы сразу будем видеть результат выполнения команды.

Для начала, изменим содержание нашего файл test.asm на следующее:

В дальнейшем, я буду описывать только код между enter 0, 0 и push eax. Пока что не заморачивайтесь, что здесь написано, главное, что эта программа будет выводить значение регистра eax (помните, что регистры — это переменные? Так что мы выводим просто значение переменной, ничего сложного).

Теперь попробуем скомпилировать программу. Для этого используем наш файл run.bat, запустим его двойным щелчком. Если все хорошо, то в консоли будет выведено случайное число. (Посмотрите, что будет, если несколько раз подряд запустить программу?).

 

Теперь переходим к командам.

[] в описании команды, означает, что на этом месте будет что-либо. Конкретное описание того, что там может быть будет в описании команды.

MOV [приемник], [источник]

Первая команда, это команда mov, расшифровывается, как move, что переводится как "перемещать".

Эта команда перемещает значение из источника в приемник.

  1. Приемником может быть регистр или переменная;
  2. Источником может быть регистр, переменная или константа.

 

Итак, давайте поиграем с этой командой. Помните, что мы изменяем только код между enter 0, 0 и push eax? Если да, то начинаем.

Давайте напишем следующий код:

Сохраним и запустим компиляцию файлом run.bat. В выводе должно появится число 100. (Помните, что мы выводим значение eax?) Таким образом, мы поместили значение 100 в регистр eax. Здорово, не правда ли? А теперь давайте поместим значение в другой регистр, и значение этого регистра поместим в eax:

После компиляции в выводе должно быть число 200. То есть, здесь, мы сначала поместили в регистр ebx значение 200, а затем значение ebx (которое равно 200) мы поместили в регистр eax. Держите в голове тот факт, что регистр можно рассматривать, как переменную.

Следующие команды описывают команды для арифметических действий с числами. Пришло время посчитать.

ADD [приемник], [источник]

Команда add расшифровывается, как addition, что переводится как "сложение".

Эта команда складывает значения из приемника со значением источника и кладет его в приемник.

  1. Приемником может быть регистр или переменная;
  2. Источником может быть регистр, переменная или константа.

 

Давайте напишем следующий код:

Здесь мы сначала поместили значение 100 в регистр eax, а затем прибавили к значению eax число 5. Тем самым на выводе мы должны получить число 105. Так и есть.

Все просто, а что если сначала поместить значение в ebx, затем в eax, а затем сложить eax и ebx? Это ваше задание, напишите такую программу, значения могут быть любыми.

Если вы не знаете, как это написать, попробуйте написать по подобию примеров, это не так сложно, однако именно практика дает 50% запоминания и понимания материала, так что обязательно делайте эти небольшие задания.

 

SUB [приемник], [источник]

Команда sub расшифровывается, как subtraction, что переводится как "вычитание".

Эта команда вычитает значение источника из значения приемника и кладет его в приемник.

  1. Приемником может быть регистр или переменная;
  2. Источником может быть регистр, переменная или константа.

Мы уже умеем складывать, теперь пробуем вычитать:

У нас есть такой код, скажите, как вы думаете, что должно быть выведено? Правильно, 95. Ничего сложного, все как и со сложением.

А если написать такой код, то что выведется?

Да, выведется 0, так как мы из eax вычитаем eax.

Теперь, когда мы знаем две операции, напишите программу, которая посчитает значение выражения:

На выходе вы должны получить 12.

 

IMUL [приемник], [источник]

Команда imul расшифровывается, как integer multiplication, что переводится как "умножение целых чисел".

Эта команда перемножает значение источника со значением приемника и кладет результат в приемник.

  1. Приемником может быть регистр или переменная;
  2. Источником может быть регистр, переменная или константа.

 

Наконец то мы добрались до умножения. Здесь все также очень похоже на две команды выше.

Что должно быть выведено в итоге? Правильно, 400.

Теперь, когда вы знаете 3 арифметические операции, ваша задача написать код, который будет рассчитывать дискриминант.

Формула дискриминанта:

Значения, a, b, c можно взять любыми. Главное, чтобы результат был выведен на экран. (Не забывайте, мы выводим регистр eax, поэтому результат должен быть именно в нем).

DIV [источник]

Команду для деления мы пока что разбирать не будем, так как она слишком сложная, на этом этапе изучения ассемблере. Вернемся к ней позже.

 

Следующие команды, это команды для сравнения и условных переходов.

Условные переходы — это переходы в какую-то метку программы, в зависимости от того, какой результат был получен в результате последней команды cmp.

CMP [значение_1], [значение_2]

Команда cmp расшифровывается, как compare, что переводится как "сравнить".

Эта команда сравнивает значение 1 со значением 2, а результат записывает в регистр флагов. О регистре флагов мы поговорим дальше, сейчас просто поймите это, как то, что результат записывается в регистре флагов и мы можем его использовать с помощью следующих команд.

  1. Значение 1 может быть регистром, переменной или константой;
  2. Значение 2 может быть регистром, переменной или константой.

Например:

 

Следующие команды описывают условные переходы. То есть эти команды переходят к меткам в зависимости от результата предыдущей команды, например, команды cmp.

 

JNE [имя_метки]

Команда jne расшифровывается, как jump if not equal, что переводится как "прыгнуть если НЕравно".

Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был НЕравен второму

Давайте попробуем использовать эту и предыдущую команды и попробуем создать цикл:

Данный код описывает простой цикл, в нем значение ebx увеличивается на 1, а затем проверяется на равенство ecx. Таким образом, переход к метке loop_start будет происходить до тех пор, пока значения ebx и ecx не станут равными, тогда код продолжит выполнять код за пределами данного отрывка (помним, что мы описываем не весь код, а его часть, дальше идет вывод значения eax).

Если вы запустите код, вы должны получить 512. Таким образом мы написали расчет степени двойки, чтобы поменять расчетную степень, надо изменить первоначальное значение ecx.

Ваша задача написать на основе этого кода программу, которая будет рассчитывать степень числа 5. Ответ в приложении.

 

JE [имя_метки]

Команда je расшифровывается, как jump if equal, что переводится как "прыгнуть если равно".

Эта команда переходит к метке, если в результате последнего вызова команды cmp первый операнд был равен второму

Сейчас мы рассмотрим простейшую реализацию конструкции if else. Предположим, что нам нужно сравнить значение ebx и ecx и в случае равенства вывести 1, а в обратном случае — 0.

Казалось бы, вроде все верно, если равно, то переходим к одной метке, если нет — к другой. Но здесь проявляется особенность ассемблера, он выполняет команды одну за другой, несмотря ни на что. Поэтому в данном случае, если числа будут равны, eax станет равным 1, но после этого же, ему будет присвоен 0 и в результате работы будет выведен 0.

Чтобы избежать такого, создают специальную метку, в случае, если числа равны, эта метка будет переходить к коду после команд, которые должны были быть выполнены в случае неравенства.

Для этого используется команды jmp, давайте отвлечемся на ее описание, а после вернемся к примеру.

JMP [имя_метки]

Команда jmp расшифровывается, как jump, что переводится как "прыгнуть".

Эта команда переходит к метке, вне зависимости от чего-либо. Это безусловный переход.

Теперь вернемся к нашему примеру и добавим эту метку и переход к ней:

Теперь, если числа равны, то программа перейдет к метке 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, то мы получим бесконечный цикл, так как значения никогда не будут равны и программа будет постоянно переходить к метке.

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

Ответ в приложении.

На этом основные команды закончены.

Стек

Мы переходим к одной из самых сложных частей ассемблера — стеку. В первую очередь, давайте рассмотрим, что вообще такое стек, как структура данных.

Стек — это структура данных, при использовании которой возможны только две операции:

  1. Поместить на вершину стека значение;
  2. Извлечь значение из вершины стека.

 

Таким образом, если мы поместили в стек 3 значения, то получить доступ мы можем только к последнему добавленному элементу, а чтобы получить доступ к элементу посередине, нужно сначала извлечь все элементы до него.

Стек можно сравнить со стопкой тарелок. Если вы хотите достать тарелку из середины, то вам нужно сначала убрать все тарелки над ней. Также, когда вы добавляете новую тарелку, вы кладете ее на вершину стопки.

Если говорить о том, что такое стек в программе, то — это область программы для временного хранения произвольных данных.

Конечно, можно хранить все данные в сегменте памяти, в виде переменных, но это увеличивает размер программы и количество используемых имен.

Память в стеке используется многократно, что удобно, так как занимает меньше памяти.

Для работы со стеком есть две основных команды:

PUSH [значение]

Команда push переводится как "протолкнуть".

Эта команда добавляет значение в стек.

Значение может быть регистром, переменной или константой;

POP [приемник]

Команда pop переводится как "вытолкнуть".

Эта команда извлекает значение вершины стека и помещает его в приемник.

Приемник может быть регистром или переменной;

 

 

Давайте попробуем написать что-нибудь с использованием стека:

Предположим у нас есть 5 чисел:

10, 5, 6, 3, 2

Наша задача сложить их. Но мы не сможем их задать одновременно, так как регистров у нас только 4, а чисел 5. Конечно, здесь можно использовать и переменные, но давайте реализуем это с помощью стека:

В первую очередь помещаем все наши значения в стек:

После того, как значения в стеке, нам надо 5 раз достать значение из стека и прибавить его к eax:

Таким непритязательным кодом, мы реализовали нашу задачу. Однако у вас будет задача чуть сложнее, она будет охватывать и все предыдущие команды.

Ваша задача написать программу, которая в самом начале поместит в стек числа от 100 до 1, а потом извлечет их и сложит. Нужно использовать циклы из прошлых команд, а также стек. Ответ, как всегда, в приложении.

 

Регистрыesp и ebp

Помните в самом начале мы рассматривали вторую четверку общих регистров? И было сказано, что у них есть конкретные назначения. Так вот два из них используются в стеке. Это регистры esp и ebp. Это регистры-указатели, то есть их значения трактуются, как адреса. Ассемблер работает с ними, предполагая, что в них хранится адрес, вне зависимости от тог, что там находится в реальности. Это означает, что если вы поместите в них число 4, то при разыменовании будет обращение к памяти по адресу 4, а если эта память недоступна, то вы получите ошибку.

Давайте посмотрим, на что они указывают, когда программа только запущена:

Оба указателя находятся под стеком, то есть указывают на элемент под стеком.

Когда мы помещаем значение в стек, регистр esp начинает указывать на верхний, то есть на последний добавленный элемент в стеке:

Если мы добавим еще один элемент, то esp вновь будет указывать на вершину стека:

Таким образом, с помощью регистра esp мы можем получить доступ к последнему добавленному элементу стека, а также к произвольному значению в стеке. Для того, чтобы получить значение по некоторому адресу используется синтаксис разыменования.

Разыменование указателей

Для того, чтобы разыменовать указатель нужно заключить регистр в квадратные скобки:

mov eax, [esp]

Таким образом в eax будет помещено значение по адресу esp. Если написать просто:

mov eax, esp

То тогда в регистре eax будет адрес, который хранится в esp, но однако никто не запрещает разыменовать и его:

mov eax, [eax]

 

Давайте попробуем поместить в стек пару значений и получить значение элемента после самого последнего:

В последней строке мы используем разыменование, но однако до этого мы сдвигаем указатель на 4 байта, такой синтаксис корректен, он означает, что начала из адреса вычитается 4, а потом происходит разыменование. В итоге, мы получим в выводе 20, так как адрес esp + 4 указывает на следующий, после вершины стека, элемент.

Однако, вас может смутить, почему для того, чтобы получить элемент под верхним, нужно прибавлять 4, а не отнимать. Дело в том, что адреса в стеке уменьшаются снизу вверх:

Поэтому для получения элементов ниже последнего нужно прибавлять, а выше — вычитать.

Но почему именно 4? Дело в том, что мы помещаем в стек 4 байтные значения по-умолчанию, и чтобы обратится именно к следующему элементу нужно прибавить 4. Если вы попробуете прибавить, например, 2, то вы получите число, которое описывается 4 байтами, начиная с текущего, то есть программа просто возьмет 32 бита из стека и поместит их в eax абсолютно не думая о том, что это могут быть не те данные, обычно это приведет к ошибкам.

 

Функции

Следующая очень важная тема, это функции. Функции в ассемблере задаются с помощью ключевого слова PROC (регистр не важен, см. введение).

Так, например, объявление функции выглядит следующим образом:

Каждая функция должна заканчиваться командой ret для явного выхода из функции.

Вызвать функцию можно с помощью команд условного и безусловного перехода, но для этого есть более специализированные команды.

Для вызова функции используется команда call.

CALL [имя_функции]

Команда call переводится как "вызвать".

Эта команда помещает в стек адрес возврата (адрес следующей, после этой, команды) и переходит к началу функции. Адрес возврата нужен, чтобы после завершения выполнения перейти к команде, которые должны быть выполнены после вызова функции.

 

Пролог функции (Начало функции)

Каждая функция начинается с так называемого пролога процедуры.

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

Код пролога следующий:

В первой строке мы помещаем в стек ebp, чтобы потом, после того, как функция завершит свою работу восстановить изначальное значение. Это нужно, так как в следующей строке мы кладем значение esp в ebp. Но что это означает на деле.

Предположим, у нас в стеке лежит два значения. Мы вызываем функцию с помощью команды call. В стек кладется адрес возврата (см. описание функции call):

После того, как мы выполним первую команду пролога, стек будет следующим:

То есть мы помещаем ebp в стек, при этом esp указывает на последний элемент, то есть на значение ebp, которое было только что добавлено. Сам регистр ebp все еще указывает на дно стека.

После того, как мы выполним вторую команду пролога, стек будет следующим:

Все что изменилось, это то, что ebp стало равным esp.

Таким образом, после пролога, в ebp будет хранится адрес вершины стека (или верхнего элемента стека) в момент вызова функции. Это нужно, чтобы, когда мы в функции будем добавлять элементы в стек, то в ebp адрес все еще будет указывать на начальный для esp в начале функции:

Это нужно, чтобы можно было обращаться к элементам стека, которые были до вызова функции по фиксированным сдвигам. Так, например, если мы хотим получить значение 20 то достаточно прибавить 8 к ebp, а вот для esp необходимо сначала вычесть количество добавленных в функции новых значений и только потом вычесть 8, что намного сложнее. Поэтому мы будем использовать ebp для доступа к элементам стека, которые были добавлены до вызова функции, это понадобится нам в дальнейшем для передачи аргументов.

Эпилог функции (Конец функции)

После того, как функция завершена, выполняется так называемый эпилог процедуры. Эпилог процедуры восстанавливает значение ebp, которое мы сохранили в стек в прологе.

Код эпилога следующий:

Стек после эпилога будет следующий:

То есть теперь ebp будет вновь указывать на дно стека (будет иметь первоначальное значение, которые мы сохранили в прологе), а значение из стека будет изъято. Теперь на вершине стека лежит адрес возврата. Встречая команду ret, из стека извлекается значение и трактуется, как адрес возврата, то есть в виде адреса команды, с которой нужно продолжить выполнение программы после того, как функция закончена.

Например, у вас есть следующий код:

Когда функция someFunction вызывается, то в стек помещается адрес следующей команды, то есть в нашем случае адрес команды mov.

После того, как функция закончила работу, из стека извлекается этот адрес и выполнение продолжается, начиная с команды по этому адресу, то есть с команды mov.

 

Проблема при работе со стеком в функции

Однако здесь таится большая возможная проблема, а что если мы в функции поместили в стек какое-то значение?

И после добавления у нас идет эпилог функции. Тогда в ebp будет помещено значение из вершины стека, где у нас лежит значение 5.

Теперь ebp указывает на неизвестно что, а на вершине стека лежит адрес ebp, который мы поместили еще в прологе. Теперь, так как после пролога идет команда ret, будет извлечено значение из стека и оно будет рассматриваться, как адрес возврата, но это не адрес возврата, а адрес ebp. Поэтому мы получим ошибку, так как пытаемся получить доступ к памяти, которая нам недоступна.

Чтобы этого избежать, нужно тщательно следить за стеком!

Если вы в функции добавляете какие-то значения в стек, то их обязательно нужно извлечь оттуда до пролога!