Основы Ассемблера
Когда вы пишете программу на ассемблере, вы просто пишете команды процессору. Команды процессору — это просто коды или коды операций или опкоды. Опкоды — фактически «читаемый текст»- версии шестнадцатеричных кодов. Из-за этого, ассемблер считается самым низкоуровневым языком программирования, все в ассемблере непосредственно преобразовывается в шестнадцатеричные коды. Другими словами, у вас нет компилятора, который преобразовывает язык высокого уровня в язык низкого уровня, ассемблер только преобразовывает коды ассемблера в данные.
В этом уроке мы обсудим несколько опкодов, которые имеют отношение к вычислению, поразрядным операциям, и т.д. Другие опкоды: команды перехода, сравнения и т.д, будут обсуждены позже.
Комментарии в ваших программах оставляются после точки с запятой. Точно также как в дельфи или си через //.
Числа в ассемблере могут представляться в двоичной, десятеричной или шестнадцатеричной системе. Для того, чтобы показать в какой системе использовано число надо поставить после числа букву. Для бинарной системы пишется буква b (пример: 0000010b, 001011010b), для десятеричной системы можно ничего не указывать после числа или указать букву d (примеры: 4589, 2356d), для шестнадцатеричной системы надо указывать букву h, шестнадцатеричное число надо обязательно писать с нулём в начале (примеры: 00889h, 0AC45h, 056Fh, неправильно F145Ch, С123h).
Самая первая команда будет хорошо всем известная MOV. Эта команда используется для копирования (не обращайте внимания на имя команды) значения из одного места в другое. Это ‘место’ может быть регистр, ячейка памяти или непосредственное значение (только как исходное значение). Синтаксис команды:
Вы можете копировать значение из одного регистра в другой.
Вышеприведенная команда копирует содержание ecx в edx. Размер источника и приемника должны быть одинаковыми,
например: эта команда — НЕ допустима:
Этот опкод пытается поместить DWORD (32-битное) значение в байт (8 битов). Это не может быть сделано mov командой (для этого есть другие команды).
А эти команды правильные, потому что у них источник и приемник не отличаются по размеру:
Вы также можете получить значение из памяти и поместить эго в регистр. Для примера возьмем следующую схему памяти:
| смещение | 34 | 35 | 36 | 37 | 38 | 39 | 3A | 3B | 3C | 3D | 3E | 3F | 40 | 41 | 42 |
| данные | 0D | 0A | 50 | 32 | 44 | 57 | 25 | 7A | 5E | 72 | EF | 7D | FF | AD | C7 |
(Каждый блок представляет байт)
Значение смещения обозначено здесь как байт, но на самом деле это это — 32-разрядное значение. Возьмем для примера 3A, это также — 32-разрядное значение: 0000003Ah. Только, чтобы с экономить пространство, некоторые используют маленькие смещения.
Посмотрите на смещение 3A в таблице выше. Данные на этом смещении — 25, 7A, 5E, 72, EF, и т.д. Чтобы поместить значение со смещения 3A, например, в регистр, вы также используете команду mov:
Означает: поместить значение с размером DWORD (32-бита) из памяти со смещением 3Ah в регистр eax. После выполнения этой команды, eax будет содержать значение 725E7A25h. Возможно вы заметили, что это — инверсия того что находится в памяти: 25 7A 5E 72. Это потому, что значения сохраняются в памяти, используя формат little endian . Это означает, что самый младший байт сохраняется в наиболее значимом байте: порядок байтов задом на перед. Я думаю, что эти примеры покажут это:
- dword (32-бит) значение 10203040 шестнадцатиричное сохраняется в памяти как: 40, 30, 20, 10
- word (16-бит) значение 4050 шестнадцатиричное сохраняется в памяти как: 50, 40
Вернемся к примеру выше. Вы также можете это делать и с другими размерами:
Вы, наверное, уже поняли, что префикс ptr обозначает, что надо брать из памяти некоторый размер. А префикс перед ptr обозначает размер данных:
Иногда размер можно не указывать:
Так как eax — 32-разрядный регистр, ассемблер понимает, что ему также требуется 32-разрядное значение, в данном случае из памяти со смещением 403045h.
Можно также непосредственные значения:
Эта команда просто запишет в регистр edx, значение 5006. Скобки, [ и ], используются, для получения значения из памяти (в скобках находится смещение), без скобок, это просто непосредственное значение.
Можно также использовать регистр как ячейку памяти (он должен быть 32-разрядным в 32-разрядных программах):
В mov cx, [eax], процессор сначала смотрит, какое значение (= ячейке памяти) содержит eax, затем какое значение находится в той ячейке памяти, и помещает это значение (word, 16 бит, потому что приемник, cx, является 16-разрядным регистром) в CX.
Стековые операции — PUSH, POP. Перед тем, как рассказать вам о стековых операциях, я уже объяснял вам, что такое стек. Стек это область в памяти, на которую указывает регистр стека ESP. Стек это место для хранения адресов возврата и временных значений. Есть две команды, для размещения значения в стеке и извлечения его из стека: PUSH и POP. Команда PUSH размещает значение в стеке, т.е. помещает значение в ячейку памяти, на которую указывает регистр ESP, после этого значение регистра ESP увеличивается на 4. Команда Pop извлекает значение из стека, т.е. извлекает значение из ячейки памяти, на которую указывает регистр ESP, после этого уменьшает значение регистра ESP на 4. Значение, помещенное в стек последним, извлекается первым. При помещении значения в стек, указатель стека уменьшается, а при извлечении — увеличивается. Рассмотрим пример:
- 1: поместить 100 в ecx
- 2: поместить 200 в eax
- 3: разместить значение из ecx (=100) в стеке (размещается первым)
- 4: разместить значение из eax (=200) в стеке (размещается последним)
- 5/6/7: выполнение операций над ecx, значение в ecx изменяется
- 8: извлечение значения из стека в ebx: ebx станет 200 (последнее размещение, первое извлечение)
- 9: извлечение значения из стека в ecx: ecx снова станет 100 (первое размещение, последнее извлечение)
Чтобы узнать, что происходит в памяти, при размещении и извлечении значений в стеке, см. на рисунок ниже:
(стек здесь заполнен нулями, но в действительности это не так, как здесь). ESP стоит в том месте, на которое он указывает)
Вызов подпрограмм возврат из них — CALL, RET. Команда call передает управление ближней или дальней процедуре с запоминанием в стеке адреса точки возврата. Команда ret возвращает управление из процедуры вызывающей программе, адрес возврата получает из стека. Пример:
Когда выполняется команда call, процессор передает управление на код с адреса 455659, и выполняет его до команды ret, а затем возвращает управление команде следующей за call. Код который вызывается командой call называется процедурой. Вы можете поместить код, который вы часто используете в процедуру и каждый раз когда он вам нужен вызывать его командой call.
Подробнее: команда call помещает регистр EIP (указатель на следующюю команду, которая должна быть выполнена) в стек, а команда ret извлекает его и передаёт управление этому адресу. Вы также можете определить аргументы, для вызываемой программы (процедуры). Это можно сделать через стек:
Внутри процедуры, аргументы могут быть прочитаны из стека и использованы. Локальные переменные, т.е. данные, которые необходимы только внутри процедуры, также могут быть сохранены в стеке. Я не буду подробно рассказывать об этом, потому, что это может быть легко сделано в ассемблерах MASM и TASM. Просто запомните, что вы можете делать процедуры и что они могут использовать параметры.
Одно важное замечание: регистр eax почти всегда используется для хранения результата процедуры.
Это также применимо к функциям windows. Конечно, вы можете использовать любой другой регистр в ваших собственных процедурах, но это стандарт.
Вот и кончился очередной урок. На следующем уроке мы напишем первую программу на ассемблере.
Директивы Ассемблера
Директивы указания типа процессора задают набор используемых инструкций.
.186, .286, .386, .486, .586, .686 — использование инструкций процессоров 80186, 80286, 80386, 80486, Pentium, Pentium Pro
.286P, .386P, .486P, .586P, .686P — использование инструкций процессоров, включая инструкции защищенного режима
.287, .387 — использование инструкций математического сопроцессора
Директивы для указания сегментов
Директива SEGMENT
Определяет сегмент с заданным именем name. Если сегмент с таким именем уже был определен ранее, то данный сегмент интерпретируется как продолжение предыдущего.
align — определяет выравнивание начального адреса сегмента на границу, определяемую значением параметра. Возможные значения:
| BYTE | выравнивание не выполняется. Сегмент может начинаться с любого адреса памяти |
| WORD | выравнивание на границу слова (2 байта) |
| DWORD | выравнивание на границу двойного слова (4 байта) |
| PARA | выравнивание по границе параграфа (16 байт). Используется по-умолчанию. |
| PAGE | выравнивание на границу в 256 байт |
combine — определяет, как сегменты с одним и тем же именем, но из различных модулей должны комбинироваться во время компоновки. Возможные значения:
| PUBLIC | заставляет компоновщик соединить все сегменты с одинаковым именем. Новый объединенный сегмент будет целым и непрерывным. Все адреса (смещения) объектов будут вычисляться относительно начала этого нового сегмента |
| STACK | выполняется конкатенация всех сегментов с одним и тем же именем для формирования одного непрерывного сегмента, затем регистр SS инициализируется значением начала сегмента, а SP — длиной сегмента. Если не указано ни одного сегмента стека, компоновщик выдаст предупреждение, что стековый сегмент не найден. Если сегмент стека создан, а комбинированный тип STACK не используется, программист должен явно загрузить в регистр SS адрес сегмента (подобно тому, как это делается для регистра DS) |
| COMMON | данный сегмент и все другие сегменты с этим именем помещаются по одному адресу. Все сегменты с данным именем будут перекрываться и совместно использовать память. Размер полученного в результате сегмента будет равен размеру самого большого сегмента |
| AT address | сегмент помещается по абсолютному адресу параграфа address |
| PRIVATE | данный сегмент не комбинируется с другими сегментами. Используется по-умолчанию |
use — определяет разрядность сегмента. Возможные значения:
| USE16 | сегмент с 16-разрядной адресацией. Максимальный размер сегмента 64 Кб |
| USE32 | сегмент с 32-разрядной адресацией. Максимальный размер сегмента 4 Гб. В модели памяти FLAT используется по-умолчанию |
class — задает строковое значение "класса" сегмента. Компоновщик объединяет вместе в памяти все сегменты с одним и тем же именем класса.
Директива ENDS
Определяет конец сегмента.
Директива ASSUME
Задает сегментный регистр, который будет использоваться для вычисления действующего адреса для всех меток и переменных, определенных для сегмента или группы сегментов с указанным именем.
Директивы для упрощенного указания сегментов
Директива .MODEL
Задает модель памяти для упрощенных директив определения сегментов.
memory-model — модель памяти. Возможные значения:
| TINY | Код, данные и стек объединены в одну группу с именем DGROUP и размером до 64 Кб. Используется для создания программ формата .com. Некоторые языки эту модель не поддерживают. СS=DS=SS=DGROUP |
| SMALL | Код занимает один сегмент, данные и стек объединены в одну группу с именем DGROUP (хотя для описания могут использоваться разные сегменты). Эту модель обычно используют для большинства программ на ассемблере. CS=_text DS=SS=DGROUP |
| MEDIUM | Код занимает несколько сегментов, по одному на каждый объединяемый программный модуль. Все ссылки на передачу управления — типа far (вызов подпрограмм). Данные и стек объединены в одной группе DGROUP; все ссылки на них — типа near (для доступа к данным используется только смещение). CS=<модуль>_text DS=SS=DGROUP |
| COMPACT | Код находится в одном сегменте, данные и стек в группе DGROUP и могут занимать несколько сегментов, так что для обращения к данным требуется указывать сегмент и смещение (ссылка на данные — типа far). CS=_text DS=SS=DGROUP |
| LARGE | Код может занимать несколько сегментов, по одному на каждый объединяемый программный модуль. Стек и данные находятся в группе DGROUP. Для ссылки на данные используются дальние указатели -far. CS=<модуль>_text DS=SS=DGROUP |
| HUGE | То же, что модель LARGE |
| FLAT | То же, что и модель TINY, но для 32-битных сегментов |
language-type — тип языка программирования. Возможные значения:
| C | Аргументы передаются через стек, справа налево. Стек очищает вызывающая программа. |
| PASCAL, BASIC | Аргументы передаются через стек, слева направо. Стек очищает вызываемая подпрограмма. |
| STDCALL | Аргументы передаются через стек, справа налево. Стек очищает вызываемая подпрограмма. |
stack-option — организация стека. Возможные значения для 16 бит: NEARSTACK, FARSTACK. Для 32 бит не используется. Указание NEARSTACK группирует сегменты стека в один физический сегмент (DGROUP) вместе с данными. Регистр сегмента стека SS назначается на тот же адрес что и регистр сегмента данных DS. FARSTACK не группирует стек с сегментом данных DGROUP; таким образом регистр SS не будет равен регистру DS.
Директива .CODE или CODESEG
Определяет начало сегмента кода. Если задали среднюю или большую модель памяти, то за директивой может следовать необязательное имя, которое указывает имя сегмента. По-умолчанию имя сегмента _TEXT.
Директива .DATA или DATASEG
Определяет начало инициализированного сегмента данных.
Директива .DATA?
Определяет в модуле начало неинициализированного сегмента данных.
Директива .CONST
Определяет начало сегмента данных-констант.
Сегменты .DATA, .DATA?, .CONST помещаются в одну группу с именем DGROUP
Директива .STACK или STACK
Определяет начало сегмента стека, выделяя количество байт, заданное параметром. Если размер не указывается, выделяется 1024 байт.
Группа директив для резервирования памяти
DB, DW, DD, DF, DP, DQ, DT — Резервирование памяти с размером соответственно 1 байт (DB), 2 байта (DW), 4 байта (DD), 6 байт (DF, DP), 8 байт (DQ) и 10 байт (DT)
Резервирует область памяти, заданного директивой размера, с указанным именем, и инициализирует значением выражения. Выражение может быть числом, строкой символов, специальным символом "?", а также выражением с использованием директивы DUP.
Повторяет операцию выделения памяти для указанных данных столько раз, сколько задано значением счетчика
Директива STRUC (STRUCT)
Определяет структуру данных с заданным именем, содержащую поля. В каждом поле для определения его размера используются обычные директивы выделения данных (DB, DW и т.д.). Поля структуры могут быть именованными или нет.
Директива ENDS
Определяет конец структуры.
Директива UNION
Определяет объединение структур данных, имеющих одно и то же имя. Объединение означает, что структуры будут располагаться по одному и тому же адресу в памяти. Закрытие объединений делается так же как и для структур — с помощью директивы ENDS.
Группа директив модификации размера указателей
Используются когда возникает неоднозначность в размере операнда команды. Например когда в ячейку памяти записывается число, то компилятор не может определить число какого размера требуется записать, и в этом случае требуется укзать директиву модификации размера.
BYTE [PTR] — Приводит адресное выражение к размеру в байт
DWORD [PTR] — Приводит адресное выражение к размеру в двойное слово (4 байта)
FAR [PTR] — Приводит к тому, что адресное выражение будет дальним указателем
FWORD [PTR] — Приводит к тому, что адресное выражение будет иметь размер 32-разрядного дальнего указателя
NEAR [PTR] — Приводит к тому, что адресное выражение будет ближним указателем на код
PWORD [PTR] — Приводит к тому, что адресное выражение будет иметь размер 32-разрядного дальнего указателя
QWORD [PTR] — Приводит к тому, что адресное выражение будет иметь размер четверного слова (8 байт)
SHORT — Приводит к тому, что выражение будет указателем на код короткого типа (в границах -128 до +127 байт от текущего адреса программы)
TBYTE [PTR] — Приводит к тому, что адресное выражение будет иметь размер 10 байт
WORD [PTR] — Приводит адресное выражение к размеру в слово (2 байта)
Директивы определения процедур
Директива PROC
Определяет начало процедуры с указанным именем.
язык — определяет, из какого языка выполняется вызов для доступа к данной процедуре: C, PASCAL, BASIC, STDCALL или NOLANGUAGE. Этим определяются соглашения по именам идентификаторов, порядок аргументов в стеке и то, останутся ли аргументы в стеке при возврате управления из процедуры (см. директиву MODEL). Если язык не задан, то используется язык заданный в директиве MODEL.
расстояние — это значения NEAR или FAR. Оно определяет тип инструкций RET или RETF, которые будут использоваться в процедуре.
аргумент — параметр процедуры в формате имя[:тип], где имя — имя параметра, тип — тип параметра. В качестве параметра можно задать массив в виде имя[N]:тип. Параметры будут доступны в процедуре через положительные смещения относительно регистра BP. Компилятор автоматически преобразует обращения к параметру по имени в соответствующие смещения относительно BP.
Директива ENDP
Определяет окончание процедуры
Директива USES
Показывает, какие регистры или элементы данных, состоящие из одной лексемы, вы хотите занести в стек в начале охватывающей процедуры. Перед возвратом управления из процедуры эти регистры будут извлекаться из стека. Вы должны использовать эту директиву перед первой инструкцией, которая генерирует в процедуре реальный код.
Директива LOCAL
В процедуре директива LOCAL определяет имена, которые доступны в стеке через отрицательные смещения относительно регистра BP. Если указан идентификатор, то ему присваивается количество байт, выделенных на локальные переменные (размер всего блока локальных переменных в байтах).
Директивы для макроопределений
Директива MACRO
Определяет начало макроопределения с указанным именем. У макроопредения могут быть заданы необязательные параметры, которые будут использоваться при подстановке тела макроопределения в текст программы.
При использовании макроопределения в программе, в параметры можно передавать строковые выражения, которые будут подставляться в тело макроопределения. Если передаваемая строка содержит пробелы или какие-то символы, вроде запятых, точек, то параметр можно заключить в угловые скобки <. >.
Директива ENDM
Определяет окончание макроопределения
Директива REPT
Повторяет блок операторов, заданный между директивами REPT и ENDM столько раз, сколько задается выражением. Блок операторов должен заканчиваться директивой ENDM.
Директива IRP
Повторяет блок операторов, заданный между директивой IRP и ENDM со строковой подстановкой. Аргументами может быть любой текст: символы, строки, числа и т.д. Для каждого указанного аргумента ассемблирование блока операторов выполняется только один раз. При каждом ассемблировании блока для каждого вхождения "параметра" в операторах подставляется следующий аргумент в списке.
Другие директивы
Директива COMMENT
Позволяет задать многострочный комментарий, ограниченный с начала и с конца заданным символом-ограничителем.
Директива EQU
Определяет имя как строку, псевдоним или число, содержащие результат вычисления выражения.
Директива END
Отмечает конец исполняемого модуля и задает начальный адрес, с которого будет исполняться программа.
Директива EVEN
Округляет счетчик адреса до следующего четного адреса
Директива SEG
Возвращается адрес сегмента выражения со ссылкой на память
Директива OFFSET
Возвращает смещение выражения в текущем сегменте (или в группе, которой принадлежит сегмент, если используются упрощенные директивы определения сегментов).
Директива ORG
Устанавливает счетчик инструкций в текущем сегменте в соответствии с адресом, задаваемым выражением.
Директива RADIX
Задает основание системы счисления для целочисленных констант (2, 8, 10 или 16)
Постигаем Си глубже, используя ассемблер
Вдохновением послужила эта статья: Разбираемся в С, изучая ассемблер. Продолжение так и не вышло, хотя тема интересная. Многие бы хотели писать код и понимать, как он работает. Поэтому я запущу цикл статей о том, как выглядит Си-код после декомпиляции, попутно разбирая основные структуры кода.
От читающих потребуются хотя бы базовые знания в следующих вещах:
- регистры процессора
- стек
- представление чисел в компьютере
- синтаксис ассемблера и Си
Что будем использовать?
- Нам понадобится компилятор Си, который поддерживает современный стандарт. Можно воспользоваться онлайн компилятором на сайте ideone.com.
- Так же нам нужен декомпилятор, опять же, можно воспользоваться онлайн декомпилятором на сайте godbolt.org.
- Можно так же взять компилятор для ассемблера, который есть на ideone по ссылке выше.
При более основательном подходе к изучению, лучше пользоваться оффлайн версиями компиляторов, можете взять связку из актуального gcc, OllyDbg и NASM. Отличия должны быть минимальны.
Простейшая программа
Эта статья не стремится повторить ту, которую я приводил в самом начале. Но начинать нужно с азов, поэтому часть материала будет вынуждено пересекаться. Надеюсь на понимание.
Первое, что нужно усвоить, компилятор даже при оптимизации нулевого уровня (-O0), может вырезать код, написанный программистом. Поэтому код следующего вида:
Ничем не будет отличаться от:
Поэтому придется писать таким образом, чтобы при декомпиляции мы, все же, увидели превращение нашего кода во что-то осмысленное, поэтому примеры могут выглядеть, как минимум странно.
Второе, нам нужны флаги компиляции. Достаточно двух: -O0 и -m32. Этим мы задаем нулевой уровень оптимизации и 32-битный режим. С оптимизаций должно быть очевидно: нам не хочется видеть интерпретацию нашего кода в asm, а не оптимизированного. С режимом тоже должно быть очевидно: меньше регистров — больше внимания к сути. Хотя эти флаги я буду периодически менять, чтобы углубляться в материал.
Таким образом, если вы пользуетесь gcc, то компиляция может выглядеть так:
gcc source.c -O0 -m32 -o source
Соответственно, если вы пользуетесь godbolt, то вам нужно указать эти флаги в строку ввода рядом с выбором компилятора. (Первые примеры я демонстрирую на gcc 4.4.7, потом поменяю на более поздний)
Теперь, можно посмотреть первый пример:
Итак, следующий код соответствует этому:
Первые две строчки соответствую прологу функции (точнее три, но третью хочу пояснить сейчас), и мы их разберем в статье о функциях. Сейчас просто не обращайте на них внимание, тоже самое касается последних 3х строчек. Если вы не знаете asm, давайте смотреть, что означают эти команды.
Инструкции ассемблера имеют вид:
mnemonic dst, src
т. е.
инструкция получатель, источник
Тут нужно оговориться, что AT&T-синтаксис имеет другой порядок, и потом мы к нему еще вернемся, но сейчас нас интересует синтаксис схожий с NASM.
Начнем с инструкции mov. Эта инструкция перемещает из памяти в регистры или из регистров в память. В нашем случае она перемещает число 1 в регистр ebx.
Давайте кратко о регистрах: в архитектуре x86 восемь 32х битных регистров общего назначения, это значит, что эти регистры могут быть использованы программистом (в нашем случае компилятором) при написании программ. Регистры ebp, esp, esi и edi компилятор будет использовать в особых случаях, которые мы рассмотрим позже, а регистры eax, ebx, ecx и edx компилятор будет использовать для всех остальных нужд.
Таким образом mov ebx, 1, прямо соответствует строке register int a = 1;
И означает, что в регистр ebx было перемещено значение 1.
А строчка mov eax, ebx, будет означать, что в регистр eax будет перемещено значение из регистра ebx.
Есть еще две строчки push ebx и pop ebx. Если вы знакомы с понятием «стек», то догадываетесь, что сначала компилятор поместил ebx в стек, тем самым запомнил старое значение регистра, а после окончания работы программы, вернул из стека это значение обратно в регистр ebx.
Почему компилятор помещает значение 1 из регистра ebx в eax? Это связано с соглашением о вызовах функций языка Си. Там несколько пунктов, все они нас сейчас не интересуют. Важно то, что результат возвращается в eax, если это возможно. Таким образом понятно, почему единица в итоге оказывается в eax.
Но теперь логичный вопрос, а зачем понадобился ebx? Почему нельзя было написать сразу mov eax, 1? Все дело в уровне оптимизации. Я же говорил: компилятор не должен вырезать наш код, а мы написали не return 1, мы использовали регистровую переменную. Т. е. компилятор сначала поместил значение в регистр, а затем, следуя соглашению, вернул результат. Поменяйте уровень оптимизации на любой другой, и вы увидите, что регистр ebx, действительно, не нужен.
Кстати, если вы пользуетесь godbolt, то вы можете наводить мышкой на строку в Си, и вам подсветится соответствующий этой строке код в asm, при условии, что эта строка выделена цветом.
Усложним пример и перестанем пользоваться регистровыми переменными (Вы же их нечасто используете?). Посмотрим во что превратится такой код:
Опять же, пропустим верхние 3 строчки и нижние 2. Теперь у нас переменная а локальная, следовательно память ей выделяется на стеке. Поэтому мы видим следующую магию: DWORD PTR [ebp-8], что же она означает? DWORD PTR — это переменная типа двойного слова. Слово — это 16 бит. Термин получил распространение в эпоху 16-ти битных процессоров, тогда в регистр помещалось ровно 16 бит. Такой объем информации стали называть словом (word). Т. е. в нашем случае dword (double word) 2*16 = 32 бита = 4 байта (обычный int).
В регистре ebp содержится адрес на вершину стека для текущей функции (мы к этому еще вернемся, потом), поэтому он смещается на 4 байта, чтобы не затереть сам адрес и дописывает значение нашей переменной. Только, в нашем случае он смещается на 8 байт для переменной a. Но если вы посмотрите на код ниже, то увидите, что переменная b лежит со смещением в 4 байта. Квадратные скобки означают адрес. Т. е. это строка работает следующим образом: на основе адреса, хранящегося в ebp, компилятор помещает значение 1 по адресу ebp-8 размера 4 байта. Почему минус восемь, а не плюс. Потому что плюсу бы соответствовали параметры, переданные в эту функцию, но опять же, обсудим это позже.
Следующая строка перемещает значение 1 в регистр eax. Думаю, это не нуждается в подробных объяснениях.
Далее у нас новая инструкция add, которая осуществляет добавление (сложение). Т. е. к значению в eax (1) добавляется 5, теперь в eax находится значение 6.
После этого нужно переместить значение 6 в переменную b, что и делается следующей строкой (переменная b находится в стеке по смещению 4).
Наконец, нам нужно вернуть значение переменной b, следовательно нужно переместить
значение в регистр eax (mov eax, DWORD PTR [ebp-4]).
Если с предыдущим все понятно, то можно переходить, к более сложному.
Интересные и не очень очевидные вещи.
Что произойдет, если мы напишем следующее: int var = 2.5;
Каждый из вас, я думаю, ответит верно, что в var будет значение 2. Но что произойдет с дробной частью? Она отбросится, проигнорируется, будет ли преобразование типа? Давайте посмотрим:
Компилятор сам отбросил дробную часть за ненадобностью.
Что произойдет, если написать так: int var = 2 + 3;
И мы узнаем, что компилятор сам способен вычислять константы. А в данном случае: так как 2 и 3 являются константами, то их сумму можно вычислить на этапе компиляции. Поэтому можно не забивать себе голову вычислением таких констант, компилятор может сделать работу за вас. Например, перевод в секунды из часов можно записать, как hours * 60 * 60. Но скорее, в пример тут стоит поставить операции над константами, которые объявлены в коде.
Что произойдет, если напишем такой код:
Интересно, не правда ли? Компилятор решил не пользоваться операцией умножения, а просто сложил два числа, что и есть — умножить на 2. (Я уже не буду подробно описывать эти строки, вы должны понять их, исходя из предыдущего материала)
Вы могли слышать, что операция «умножение» выполняется дольше, чем операция «сложение». Именно по этим соображениям компилятор оптимизирует такие простые вещи.
Но усложним ему задачу и напишем так:
Пусть вас не вводит в заблуждение использование нового регистра edx, он ничем не хуже eax или ebx. Может понадобиться время, но вы должны увидеть, что единица попадает в регистр edx, затем в регистр eax, после чего значение eax складывается само с собой и после уже добавляется еще одна единица из edx. Таким образом, мы получили 1+1+1.
Знаете, бесконечно он так делать не будет, уже на *4, компилятор выдаст следующее:
Итак, у нас новая инструкция sal, что же она делает? Это двоичный сдвиг влево. Эквивалентно следующему коду в Си:
Для тех, кто не очень понимает, как работает этот оператор:
0001 сдвигаем влево (или добавляем справа) на два нуля: 0100 (т. е. 4 в 10ой системе счисления). По своей сути сдвиг влево на 2 разряда — это умножение на 4.
Забавно, что если вы умножите на 5, то компилятор сделает один sal и один add, можете сами потестировать разные числа.
На 22, компилятор на godbolt.org сдается и использует умножение, но до этого числа он пытается выкрутиться самыми разными способами. Даже вычитание использует и еще некоторые инструкции, которые мы еще не обсуждали.
Ладно, это были цветочки, а что вы думаете по поводу следующего кода:
Если вы ожидаете вычитания, то увы — нет. Компилятор будет выдавать более изощренные методы. Операция «деление» еще медленнее умножения, поэтому компилятор будет также выкручиваться:
Следует сказать, что для этого кода я выбрал компилятор существенно более поздней версии (gcc 7.2), до этого я приводил в пример gcc 4.4.7. Для ранних примеров существенных отличий не было, для этого примера они используют разные инструкции в 5ой строчке кода. И пример, сгенерированный 7.2, мне сейчас легче вам объяснить.
Стоит обратить внимание, что теперь переменная a находится в стеке по смещению 4, а не 8 и сразу же забыть об этом незначительном отличии. Ключевые моменты начинаются с mov edx, eax. Но пока пропустим значение этой строки. Инструкция shr осуществляет двоичный сдвиг вправо (т. е. деление на 2, если бы было shr edx, 1). И тут некоторые смогут подумать, а почему, действительно, не написать shr edx, 1, это же то, что делает код в Си? Но не все так просто.
Давайте проведем небольшую оптимизацию и посмотрим на что это повлияет. В действительности, мы нашим кодом выполняем целочисленное деление. Так как переменная «a» является целочисленным типом и 2 константа типа int, то результат никак не может получиться дробным по логике Си. И это хорошо, так как делить целочисленные числа быстрее и проще, но у нас знаковые числа, а это значит, что отрицательное число при делении инструкцией shr может отличаться на единицу от правильного ответа. (Это все из-за того, что 0 влезает по середине диапазона для знаковых типов). Если мы заменим знаковое деление на unsigned:
То получим ожидаемое. Стоит учесть, что godbolt опустит единицу в инструкции shr, и это не скомпилируется в NASM, но она там подразумевается. Измените 2 на 4, и вы увидите второй операнд в виде 2.
Теперь посмотрим на предыдущий код. В нем мы видим sar eax, это то же самое, что и shr, только для знаковых чисел. Остальной же код просто учитывает эту единицу, когда мы делим отрицательное число (или на отрицательное число, хотя код немного изменится). Если вы знаете, как представляются отрицательные числа в компьютере, вам будет не трудно догадаться, почему мы делаем сдвиг вправо на 31 разряд и добавляем это значение к исходному числу.
С делением на большие числа, все еще проще. Там деление заменяется на умножение, в качестве второго операнда вычисляется константа. Если вам будет интересно как, можете поломать над этим голову самостоятельно, там нет ничего сложного. Нужно просто понимать, как представляются вещественные числа в памяти.
Заключение
Для первой статьи материала уже больше, чем достаточно. Пора закруглятся и подводить итоги. Мы ознакомились с базовым синтаксисом ассемблера, выяснили, что компилятор может брать на себя простейшие оптимизации при вычислениях. Увидели разницу между регистровыми и стековыми переменными. И некоторые другие вещи. Это была вводная статья, пришлось много времени уделять очевидным вещам, но они очевидны не для всех, в будущем мы постигнем больше тонкостей языка Си.