Внутренние и внешние процедуры

Виды исполняемых файлов

Во всех традиционных операционных системах (семейства Unix и Windows) выделяются следующие виды исполняемых файлов:

В ОС 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  ; объявление секции экспорта
export 'mydll.DLL',\                   ; перечисление экспортируемых точек входа
       func1, 'func1',\
       func2, 'func2'

data fixups   ; объявление секции релокаций символов
end data

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

Обратите внимание, что в коде динамической библиотеки не использовалась директива 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   ; объявление секции импорта
library mydll, 'dll.DLL'    ; указание импортируемой библиотеки
import mydll,\              ; для указанной библиотеки
       func1, 'func1',\    ; перечисление импортируемых символов из неё
       func2, 'func2'

Основное отличие кода приложения от кода библиотеки заключается в последней секции. Чтобы приложение могло использовать символы из динамической библиотеки, необходимо создать запись о ней в секции импорта. Макрокоманда library создает ссылку на библиотеку, которая, в последствии, используется в макрокоманде import для импорта конкретного символа. Имена символов в кавычках должны совпадать с именами экспортируемых символов библиотеки. Метки, назначаемые импортируемым символам могут быть использованы как обычные переменные в коде программы. Так, в примере выше адрес, хранящийся в импортированном символе func1 может быть использован для безусловного перехода (jmp [func1]), осуществляя, по сути, вызов функции из библиотеки.

Понятие стека и методы работы с ним

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

Когда операционная система загружает приложение, она выделяет память для стека, и устанавливает регистр esp/rsp в значение адреса конца выделенной области. В процессе работы приложения, по мере того, как ему будет требоваться больше памяти, значение регистра esp/rsp будет уменьшаться, отступая, таким образом, от конца области. В этом случае говорят, что стек растет вверх. При освобождении памяти на стеке происходит обратная операция. Инструкции для управления стеком приведены ниже:

Рисунок 1. Операции со стеком.

В графическом отладчике окно стека обычно находится в правом нижнем углу, и по функциональности сходно с окном дампа. В первом столбце находятся адреса ячеек стека, во втором - их значения на данный момент выполнения программы, а в третьем - пояснения к некоторым значениям. Ширина столбца значений обычно равна 4 или 8 байтам, т.к. переменные именно такого размера чаще всего размещаются в стековой памяти. Строчка, адрес которой выделен жирным является текущей вершиной стека, т.е. значением регистра esp/rsp. Ячейки, располагающиеся выше этой строчки считаются свободными, в то время как находящиеся ниже содержат использующиеся программой данные. Экранный снимок окна стека представлен на рисунке 2.

Рисунок 2. Окно стека в графическом отладчике x64dbg.

Приведём пример как использовать стек в случае нехватки свободных регистров. Допустим, в процессе вычислений нам требуется произвести операцию умножения (инструкция mul), которая возвращает результат через регистр eax. Однако, в этом регистре уже находится необходимое нам значение, и мы не можем позволить инструкции mul затереть его. Выходом из этой ситуации может быть копирование значения eax в какой-либо другой регистр, но т.к. число регистров конечно, все они так же могут быть заняты. В этом случае можно воспользоваться инструкцией push eax, чтобы сохранить значение из eax в памяти на стеке, затем произвести операцию умножения, и восстановить старое значение регистра eax с помощью инструкции pop eax. Этот приём широко используется как программистами на ассемблере, так и компиляторами языков высокого уровня, и называется распределением регистров (register allocation; так же используется выражение “размазывание по стеку”, stack spilling).

Использование стека для вызова процедур

Из приведенного выше примера кода видно, что для вызова процедуры (как локальной, так и из внешней библиотеки) достаточно осуществить переход на её адрес - jmp [процедура]. Однако после завершения выполнения процедуры остаётся неясным что программа должна делать далее.

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

Таким образом, для успешного вызова процедуры на языке ассемблера необходимо:

Передача аргументов в процедуры

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

Способ передачи аргументов в процедуры называется соглашением вызова (calling convention). Существует множество различных соглашений вызова, однако некоторые из них наиболее распространены:

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

Графические отладчики способны выводить структурированную информацию о значениях на стеке, путем анализа выполняемых программой инструкций. Например, при вызове процедуры с одним из стандартных соглашений вызова, отладчик может выделить её аргументы из стека и отобразить их в отдельном окне. На рисунке 3 представлен интерфейс отладчика x64dbg, отображающий эту информацию.

Рисунок 3. Окно, отображающее аргументы вызванной функции.

Еще один пример полезных подсказок отладчика - вывод адресов возврата из функций.

Контрольные вопросы

  1. Виды исполняемых файлов.
  2. Инструкции для работы со стеком.
  3. Инструкции для вызова процедур и возврата из них.
  4. Соглашения вызовов.

Задание на лабораторную работу

Реализовать программу из 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.