![]() |
Внутренние и внешние процедуры |
![]() |
Виды исполняемых файлов
Во всех традиционных операционных системах (семейства Unix и Windows) выделяются следующие виды исполняемых файлов:
- Приложение (application);
- Динамическая библиотека (dynamically linked library или shared object).
В ОС Windows файлы приложений имеют расширение .exe, а библиотеки - .dll. В Unix-подобных системах файлы приложений расширения обычно не имеют, а библиотеки имеют расширение .so. Несмотря на отличия между видами исполняемых файлов в именовании и функциональности, структурно они являются идентичными. Говоря иначе, приложение и динамическая библиотека являются файлами одного и того же формата.
Формат исполняемых файлов, используемых в ОС Windows называется PE (Portable Executable). Формат файлов в Unix-подобных ОС называется ELF (Executable and Library Format). Структура каждого из этих форматов достаточно сложна, и подробно рассматриваться в данной работе не будет.
Функциональное отличие динамических библиотек от приложений заключается в следующем. Приложение обязано иметь одну и только одну точку входа, т.е. адрес инструкции, с которой начинается выполнение программы. Именно из этого возникает требование к программам на ЯВУ (C, C++, Java, C# и др.) иметь функцию main (в зависимости от языка ее название может отличаться).
Благодаря тому, что приложение имеет единственную точку входа, его можно выполнять, т.е. запускать процесс выполнения кода программы. Библиотеки, напротив, могут иметь множество точек входа, по числу функций, предоставляемых этой библиотекой. Это означает, что библиотеку нельзя запустить (т.к. не известно с какой точки входа начинать выполнение), однако можно использовать из уже запущенного приложения.
Применение динамических библиотек решает следующие задачи:
- Программы, имеющие общую функциональность, могут не дублировать часть кода, а вынести ее в отдельную библиотеку, и пользоваться ей по мере необходимости;
- Чтобы воспользоваться новой версией библиотеки достаточно заменить её файл. Саму программу нет необходимости компилировать заново;
- ОС контролирует все загруженные в данный момент динамические библиотеки, и не загружает их повторно. Это уменьшает потребление памяти.
Создание библиотек на языке ассемблера
Пример создания приложений с помощью ассемблера FASM был приведён в лабораторной работе №1. Ниже приведен пример кода, создающий динамическую библиотеку, экспортирующую две точки входа. Стоит отметить, что с т.з. формата PE, экспортируемые точки входа являются просто адресами каких-либо областей внутри исполняемого файла. Это означает, что экспортируемый символ не обязательно должен содержать код, он так же может быть просто некоторой областью памяти. В этом случае, такой символ можно рассматривать как экспортированную глобальную переменную. Также, в случае ОС Windows, для динамических библиотек требуется наличие особой точки входа, называемой DllMain. Эта функция вызывается при загрузке библиотеки по запросу приложения, что позволяет ей произвести необходимую инициализацию.
format PE GUI DLL
include 'win32a.inc'
func1:
mov ebx, 1 ; код процедуры func1
ret
func2:
mov ecx, 2 ; код процедуры func2
ret
section '.edata' export data readable ; объявление секции экспорта
'mydll.DLL',\ ; перечисление экспортируемых точек входа
export , 'func1',\
func1, 'func2'
func2
data fixups ; объявление секции релокаций символов
end data
В приведенном выше коде были использованы следующие новые языковые конструкции:
- Ключевое слово
DLL
в первой строке означает, что скомпилированный исполняемый файл будет динамической библиотекой; - Строчка
include 'win32a.inc'
подключает библиотеку макросов FASM, которые упрощают создание таблицы экспортов ниже. Иными словами, ключевые словаsection
иexport
являются, на самом деле, не ключевыми словами, а макрокомандами, определенными в файле “win32a.inc”; - Строка
section '.edata' export data readable
объявляет в результирующем файле секцию с именем.edata
и свойствамиexport data readable
. Свойствоexport
означает, что создаваемая секция является секцией экспорта. Подробная информация о секциях и их свойствах может быть найдена в документации к формату PE. - Макрос
export
непосредственно перечисляет экспортируемые точки входа. Первым аргументом он принимает название создаваемой библиотеки, а затем следуют пары аргументов видаметка, имя функции
для каждой экспортированной точки входа. Легко увидеть, что имя эскпортируемой функции не обязательно должно совпадать с названием метки. Символы ’' используются для переноса длинного выраженияexport
на несколько строк; - Последние две строки объявляют пустую секцию релокаций. Этот код можно было бы опустить, однако в текущих версиях FASM присутствует ошибка, в результате которой скомпилированная библиотека не может быть загружена, если данная секция явно не создана.
Обратите внимание, что в коде динамической библиотеки не использовалась
директива entry
, объявляющая основную точку входа. В данном случае FASM сам
добавляет эту функцию, которая просто возвращает 0 во всех случаях. Однако,
директиву entry
можно было бы использовать, чтобы определить собственную
реализацию DllMain
.
Рассмотрим теперь код приложения, использующего созданную библиотеку:
format PE GUI
entry start
include 'win32a.inc'
start:
mov ebx, [func1] ; загружаем адрес процедуры func1 из dll.dll
ret
section '.idata' import data readable writeable ; объявление секции импорта
, 'dll.DLL' ; указание импортируемой библиотеки
library mydll,\ ; для указанной библиотеки
import mydll, 'func1',\ ; перечисление импортируемых символов из неё
func1, 'func2' func2
Основное отличие кода приложения от кода библиотеки заключается в последней
секции. Чтобы приложение могло использовать символы из динамической библиотеки,
необходимо создать запись о ней в секции импорта. Макрокоманда library
создает
ссылку на библиотеку, которая, в последствии, используется в макрокоманде
import
для импорта конкретного символа. Имена символов в кавычках должны
совпадать с именами экспортируемых символов библиотеки. Метки, назначаемые
импортируемым символам могут быть использованы как обычные переменные в коде
программы. Так, в примере выше адрес, хранящийся в импортированном символе
func1
может быть использован для безусловного перехода (jmp [func1]
),
осуществляя, по сути, вызов функции из библиотеки.
Понятие стека и методы работы с ним
Стеком называется отдельная область памяти, обычно используемая для хранения временных значений и передачи аргументов вызываемым процедурам. Область памяти, выделенная под стек, на самом деле, не имеет особых отличий от всей остальной памяти, доступной программе. Например, значения, хранящиеся в стеке, можно просмотреть в отладчике не только с помощью специализированного окна стека, но и в окне дампа, перейдя на нужный адрес. Однако, для работы со стеком существуют специальные инструкции, делающие стек важным элементом в процессе выполнения программного кода.
Когда операционная система загружает приложение, она выделяет память для стека, и устанавливает регистр esp/rsp в значение адреса конца выделенной области. В процессе работы приложения, по мере того, как ему будет требоваться больше памяти, значение регистра esp/rsp будет уменьшаться, отступая, таким образом, от конца области. В этом случае говорят, что стек растет вверх. При освобождении памяти на стеке происходит обратная операция. Инструкции для управления стеком приведены ниже:
sub esp, операнд
– уменьшает значение регистра esp на операнд, выделяя, таким образом заданное количество байт памяти на стеке;add esp, операнд
– увеличивает значение регистра esp на операнд, освобождая, таким образом заданное количество байт памяти на стеке;push операнд
– уменьшает значение esp на 4 (в случае 64-битной архитектуры - на 8), и записывает значение операнда в выделенную на стеке память. Эквивалентно последовательности инструкцийsub esp, 4 ; mov [esp], операнд
;pop операнд
– операция, обратнаяpush
. Прочитывает значение по адресу esp в операнд, а затем увеличивает esp на 4 (8). Эквивалентноmov операнд, [esp] ; add esp, 4
.
В графическом отладчике окно стека обычно находится в правом нижнем углу, и по функциональности сходно с окном дампа. В первом столбце находятся адреса ячеек стека, во втором - их значения на данный момент выполнения программы, а в третьем - пояснения к некоторым значениям. Ширина столбца значений обычно равна 4 или 8 байтам, т.к. переменные именно такого размера чаще всего размещаются в стековой памяти. Строчка, адрес которой выделен жирным является текущей вершиной стека, т.е. значением регистра esp/rsp. Ячейки, располагающиеся выше этой строчки считаются свободными, в то время как находящиеся ниже содержат использующиеся программой данные. Экранный снимок окна стека представлен на рисунке 2.

Приведём пример как использовать стек в случае нехватки свободных регистров.
Допустим, в процессе вычислений нам требуется произвести операцию умножения
(инструкция mul
), которая возвращает результат через регистр eax. Однако,
в этом регистре уже находится необходимое нам значение, и мы не можем позволить
инструкции mul
затереть его. Выходом из этой ситуации может быть копирование
значения eax в какой-либо другой регистр, но т.к. число регистров конечно,
все они так же могут быть заняты. В этом случае можно воспользоваться инструкцией
push eax
, чтобы сохранить значение из eax в памяти на стеке, затем
произвести операцию умножения, и восстановить старое значение регистра eax
с помощью инструкции pop eax
. Этот приём широко используется как
программистами на ассемблере, так и компиляторами языков высокого уровня, и
называется распределением регистров (register allocation; так же
используется выражение “размазывание по стеку”, stack spilling).
Использование стека для вызова процедур
Из приведенного выше примера кода видно, что для вызова процедуры (как локальной,
так и из внешней библиотеки) достаточно осуществить переход на её адрес -
jmp [процедура]
. Однако после завершения выполнения процедуры остаётся неясным
что программа должна делать далее.
Чтобы вернуться к выполнению кода, вызвавшего текущую процедуру, программе необходимо иметь в своём распоряжении адрес возврата. Ответственность за предоставление адреса возврата лежит на вызывающем процедуру коде. Т.к. практически любая процедура должна возвращать управление в то же место, откуда она была вызвана, для автоматической передачи адреса возврата и перехода по нему были введены следующие инструкции:
call операнд
. Сохраняет на стеке адрес инструкции, следующей за собой, а затем осуществляет переход на адрес значения операнда. Эквивалентно последовательности инструкцийpush eip+6 ; jmp операнд
;ret
. Извлекает адрес возврата из стека, освобождает занимаемую адресом память, и осуществляет прыжок на него. Эквивалентно инструкцииpop eip
;ret операнд
. Аналог инструкцииret
, принимающий целочисленную константу. Освобождает на стеке не 4 (8) байт, а количество, указанное в операнде.
Таким образом, для успешного вызова процедуры на языке ассемблера необходимо:
- В вызывающем коде использовать инструкцию
call
, либо поместить корректный адрес возврата на стек и использовать инструкциюjmp
; - В вызываемом коде использовать инструкцию
ret
, чтобы вернуться в место вызова и очистить стек.
Передача аргументов в процедуры
Ещё один важный нюанс при вызове процедур заключается в корректной передаче параметров. Вызываемая процедура всегда ожидает, что её аргументы будут переданы ей определенным способом и в определенном порядке. В общем случае, и способ, и порядок может выбираться произвольно по желанию программиста. Однако, при написании библиотек, рассчитанных на широкое применение другими программистами, возникла необходимость в стандартизации этого процесса.
Способ передачи аргументов в процедуры называется соглашением вызова (calling convention). Существует множество различных соглашений вызова, однако некоторые из них наиболее распространены:
- cdecl, C calling convention. Соглашение вызовов, используемое в языке С для 32-битных приложений. Является де-факто стандартным соглашением вызова. Аргументы добавляются на стек в обратном порядке, результат возвращается через регистр eax/rax, стек очищается вызвавшей процедурой;
- stdcall, Win32 API calling convention. Используется в программном интерфейсе ОС Windows для 32-битных приложений. Аналогичен cdecl, однако стек очищается вызываемой процедурой;
- fastcall. Соглашение вызовов для 32-битных программ, в котором первые два параметра передаются через регистры ecx и edx, при условии что они могут в них поместиться. В остальном аналогичен cdecl;
- thiscall. Соглашение вызовов для 32-битных программ, написанных на С++. Аналогичен cdecl, однако при вызове методов классов, указатель на текущий объект передаётся как последний аргумент через стек. В компиляторе Microsoft Visual C++ этот указатель передаётся через регистр ecx, а сама вызываемая процедура так же очищает стек, подобно соглашению stdcall;
- System V AMD64 ABI. Соглашение вызовов, используемое в 64-битных программах на ОС Linux, FreeBSD, MacOS, Solaris и др. Является де-факто стандартным соглашением вызова. Первые 6 целочисленных аргументов передаются через регистры rdi, rsi, rdx, rcx, r8, r9. Дополнительные аргументы передаются через стек в обратном порядке. Возвращаемое значение сохраняется в rax и rdx;
- Microsoft x64 calling convention. Соглашение вызовов, используемое в программном интерфейсе Windows на 64-битных системах. Является де-факто стандартом в приложениях для Windows. Регистры rcx, rdx, r8, r9 используются для первых 4 целочисленных параметров. Дополнительные аргументы кладутся на стек в обратном порядке. Возвращаемое значение передаётся через rax.
Программист на ассемблере может создавать свои соглашения вызовов, однако для корректного функционирования программы требуется чтобы соглашение, которому следует вызывающий код, совпадало с соглашением, которому следует вызываемый.
Графические отладчики способны выводить структурированную информацию о значениях на стеке, путем анализа выполняемых программой инструкций. Например, при вызове процедуры с одним из стандартных соглашений вызова, отладчик может выделить её аргументы из стека и отобразить их в отдельном окне. На рисунке 3 представлен интерфейс отладчика x64dbg, отображающий эту информацию.

Еще один пример полезных подсказок отладчика - вывод адресов возврата из функций.
Контрольные вопросы
- Виды исполняемых файлов.
- Инструкции для работы со стеком.
- Инструкции для вызова процедур и возврата из них.
- Соглашения вызовов.
Задание на лабораторную работу
Реализовать программу из 1 п. лабораторной работы №1, соответствующую своему варианту (кроме варианта 3, см. ниже), в виде библиотеки и приложения, использующего эту библиотеку. Указания о размещении начальных значений игнорировать. Вместо этого, начальные значения должны передаваться функции приложением в виде аргументов. Экспортируемые функции реализовать в двух вариантах - с соглашением вызова, указанным в варианте ниже, и своим собственным. Убедиться в корректном функционировании программы и библиотеки с помощью отладчика.
Варианты заданий
Вариант 1
Соглашение вызова cdecl.
Вариант 2
Соглашение вызова stdcall.
Вариант 3
В качестве задания использовать задание варианта 1 со следующей формулой: \((a⁄d)*b+c*a+c*20\). Соглашение вызова fastcall.
Вариант 4
Соглашение вызова cdecl.
Вариант 5
Соглашение вызова stdcall.
Вариант 6
Соглашение вызова fastcall.
Вариант 7
Соглашение вызова cdecl.
Вариант 8
Соглашение вызова stdcall.
Вариант 9
Соглашение вызова fastcall.
Вариант 10
Соглашение вызова cdecl.
Вариант 11
Соглашение вызова stdcall.
Вариант 12
Соглашение вызова fastcall.
Вариант 13
Соглашение вызова cdecl.
