Пролог и эпилог функции

Метки:  , ,

Одной из основ исследования структуры исполняемых образов программ (реверсивная инженерия), является изучение пролога и эпилога функции. Обычно об этих понятиях мало кто задумывается, до той самой поры, пока не начинают изучать строение исходного кода на уровне машинных (процессорных) команд, иными словами - в дизассемблированном виде. Очевидно, что знакомый с одноименными терминами мира искусства, дальновидный читатель сразу же догадается что пролог это ни что иное как своеобразное "предисловие", некая вводная часть, вступление, а эпилог - это нечто завершающее, "послесловие", заключительная часть. Проводя аналогию, логично было бы предположить, что механизмы эти призваны выполнять некие предваряющие и завершающие действия по отношению к какой-либо сущности, а именно к функциям. И все же не совсем понятно, как именно термины пролога и эпилога относятся к функциям/процедурам из мира вычислительной техники? Довольно нетривиальной задачей является попытка интуитивного сопоставления терминов из мира искусства с областью программирования. К тому же, у данной области имеется множество особенностей, которые мы сегодня и попытаемся понять.

Что такое функция

Для начала давайте немного определимся с терминологией и определим, что же есть функция:

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

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

Применение функции называется вызовом функции (function call).

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

Функция без параметров

Для начала, давайте вернемся в прошлое, в те времена, когда на просторах компьютерной эры безраздельно господствовал реальный режим работы процессора x86 и старые операционные системы реального же режима. В качестве основы возьмем популярную в то время операционную систему MSDOS, а пример приведем на низкоуровневом языке Ассемблера. В общем то, синтаксис Ассемблера x86 не очень то отличается между разными реализациями/операционными системами, поэтому ничего страшного в выборе MSDOS нет, просто в то время удалось поковырять именно её. Давайте посмотрим, как выглядел вызов простой (типовой) функции:

кто пусть даже поверхностно разбирается в языке Ассемблера, может уверенно констатировать, что приведенный пример весьма и весьма прост. Осуществляется вызов некой функции с именем some_function, в момент вызова в стеке (пара регистров SS:SP) сохраняется адрес возврата определенной размерности (длиной в слово, два байта) из процедуры, который указывает на следующую за инструкцией call команду. Потом управление передается на точку входа (по адресу первого байта) функции. Код функции выполняется, затем команда ret извлекает из стека сохраненный (ранее) адрес возврата и передает по этому адресу управление, тем самым осуществляя возврат из функции. Для пущей наглядности проиллюстрируем на конкретном (сюрприз) примере:

call function

Как видно из кода, происходит вызов (инструкция call 000000065), затем в функции осуществляется ряд действий и выполняется возврат (инструкция retn). Тут я намеренно привел достаточно простую функцию, а все ради того, что бы читатель ощутил существенный контраст с публикуемым далее материалом.

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

Функция с параметрами

Как мы уже отметили, в приведенном выше (довольно простом) примере, в функцию не передаются какие-либо аргументы.

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

Тем не менее, в "дикой природе" компьютерных программ значительно чаще можно встретить более сложные функции, то есть те, при вызове которых требуется передача (в функцию) одного или более входных параметров. К тому же программ, написанных на высокоуровневых языках программирования, значительно больше, нежели тех, что написаны на чистом Ассемблере, поэтому сейчас мы обратимся к современному опыту и приведем более распространенный пример, реализованный на языке MS Visual C++. Давайте набросаем простенький пример функции на C++:

По коду можно определить, что функция f1 (описана в строке 5) имеет ряд входных параметров (три параметра - определенные в самой функции как a, b, c).

Если функция объявлена с параметрами, при вызове ей нужно передать аргументы (являющиеся значениями, требуемыми функцией в ее списке параметров). Очевидно, что без инициализации/передачи параметров, функция не имеет смысла.

Функция f1 вызывается (строка 12) с тремя аргументами (значения 1, 2, 3) и осуществляет вывод на консоль (посредством printf) этих значений. А теперь посмотрим, как будет выглядеть вызов функции после компиляции и дизассемблирования (на низком уровне):

Как интересно, сразу возникает довольно таки много вопросов. Во-первых, почему аргументы передаются через стек (команда push)? Во-вторых, почему аргументы передаются в обратном порядке (начиная с 3-го)? Все эти вопросы, безусловно, требуют пояснения, но на данный момент я не буду давать ответа, Вы его получите позже, в процессе изучения материала статьи. Теперь, неплохо было бы взглянуть на ассемблерный код и самой функции f1:

prologue epilogue

и приведем её в очищенном текстовом варианте, для того, что бы проще было объяснять:

Видно невооруженным глазом, что функция f1, написанная на высокоуровневом языке, значительно сложнее функции, приведенной в первом примере выше (написанном на Ассемблере). Конечно, мною выбрана не совсем уж простая функция для первого примера, тем не менее интересующие нас места в коде я выделил цветом. Все дело в том, что все сложные функции, написанные на высокоуровневах языках и имеющие какие-либо аргументы, вне зависимости от операционной системы, на уровне машинного кода стали значительно сложнее. Сразу обращает на себя внимание дополнительная работа со стеком (строки 1-3 и 25, 28, 29), работа с переданными в функцию f1 параметрами (строки 13, 15, 17). Все эти трансформации структуры функций не являются прихотью конкретного компилятора, а объясняются соглашениями о вызовах.

Соглашение о вызовах

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

  1. при помощи регистров [процессора] общего назначения (к примеру, для 32-битного процессора это: eax, ebx, acx, edx, esi, edi и прч.);
  2. посредством записи параметров непосредственно в стек (передача через стек);
  3. посредством использования ячеек памяти (запись/чтение значений по выделенным адресам памяти);

Первый вариант крайне неудобен, поскольку:

  • регистров общего назначения в процессорах архитектуры x86 было немного (в отличие, например, от более поздних x64 или ARM);
  • значения данных регистров активно используются по ходу выполнения "основной" ветви кода, поэтому потребуется обеспечивать "пересохранение" регистров;
  • из второго утверждения следует, что перед вызовом функции содержимое общих регистров надо сохранять, а по завершении выполнения функции восстанавливать, что влечет за собой довольно изрядные "накладные расходы", что сказывается на производительности для всех видов языков, а для низкоуровневых (ассемблер) превращает процесс программирования в форменную головоломку.

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

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

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

  • аргументы передаются в функцию посредством регистров?
  • аргументы передаются в функцию посредством стека?
  • аргументы передаются в функцию посредством указателя на область памяти?
  • аргументы передаются в функцию одновременно разными способами: частично через регистры, частично через стек/область памяти (указатель)?
  • аргументы передаются в функцию сначала первый или сначала последний ("слева направо" или "справа налево", соответственно)?
  • чей код ответственен за сохранение/восстановление (чистку) стека: вызывающей или вызываемой функции?
  • чей код ответственен за сохранение/восстановление регистров: вызывающей или вызываемой функции?

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

cdecl
Аргументы функций передаются через стек, справа налево. Аргументы, размер которых меньше 4х байт, расширяются до 4х байт. Очистку стека выполняет вызывающая программа;
pascal
Аргументы функций передаются через стек, слева направо. Указатель на вершину стека (значение регистра ESP) в изначальную позицию возвращает вызываемая подпрограмма;
stdcall (WinAPI)
Аргументы функций передаются через стек, справа налево. Очистку стека производит вызываемая подпрограмма.
fastcall
Аргументы в функцию передаются через регистры, если для сохранения аргументов (и промежуточных результатов) регистров оказывается не достаточно, используется стек. Включает в себя несколько соглашений, которые действуют по сходим принципам, отличаясь только незначительными деталями.
safecall
Соглашение о вызовах, используемое для вызова методов интерфейсов COM;
thiscall
Аргументы функции передаются через стек, справа налево. Очистку стека производит вызываемая функция. Незначительно отличается от stdcall тем, что указатель на объект сохраняется в регистр ECX;
Соглашения о вызовах, которым необходимо следовать при выполнении кода приложения, имеет возможность определять автор программы.

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

Формат вызова stdcall стал стандартном де-факто, он имеет поддержку во всех современных компиляторах, и используется в подавляющем большинстве динамических библиотек, представляющих глобальные API-сервисы. В stdcall для передачи аргументов в функцию используется стек, передача происходит справа налево (обратный порядок, от конца к началу: сначала передается последний параметр, затем предпоследний, и так далее до первого). Очистку стека производит вызываемая подпрограмма.

С соглашением о вызовах тесно связано понятие указателя кадра стека, зачастую называемого так же указателем фрейма стека (stack frame), указателем фрейма (frame pointer) или стековым кадром. Теперь давайте плавно перейдем к его изучению.

Стековый фрейм (кадр стека, стековый кадр)

Ну стек то уж вы должны знать что такое? :) Так вот, а стековый фрейм - это часть данных стека, или:

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

Из определения уже начинает проясняться, что это ни что иное как фрагмент стека (часть памяти, выделенной под стек) потока, используемый исключительно для служебных целей: передачи параметров функции из кода вышестоящей (вызывающей) функции и выделения памяти для использования внутри вызываемой функции (как правило, для хранения локальных переменных). Ну, определение то не такое уж и простое, поэтому хотелось бы посмотреть как всё это это используется на практике. В процессе сборки/компоновки (перевод исходного кода в машинный и сборка объектных модулей) программы, написанной на одном из высокоуровневых языков программирования, код всех используемых разработчиком "собственных"/"встраиваемых" функций транслируется таким образом, что для передачи параметров в эти функции и доступа к локальным переменным этих функций, тем или иным образом используются регистры и/или стек. На (самом низком) уровне машинного кода, процессор, выполняющий какой-либо код, встречает в нем инструкцию вызова функции (например call), начинает её исполнять. Исполнение заключается в некоей предварительной подготовке, и непосредственно перед передачей управления на адрес (точку входа) функции, процессор должен положить (разместить, засунуть) аргументы функции в стек в установленном порядке (определяемым соглашением о вызовах), и только после этого произвести вызов функции (передачу ей управления). Когда вызванная таким образом (функция|процедура) получает управление, она наследует и программный стек от вызвавшей функции, на вершине которого располагается адрес возврата из функции, после которого идут входные параметры (аргументы), с которыми данная функция была вызвана. Ну хорошо, стек вызывающей функции мы получили, но можно ли с ним сразу активно работать, ведь мы его тогда "испортим"? Именно так, поэтому для того, чтобы не портить стек вызвавшей функции, вызываемая функция настраивает так называемый собственный стековый кадр, с целью оперирования собственными (локальными) переменными, сохранения произвольных локальных значений и прч. Новый стековый кадр (фрейм) настраивается через инициализацию указателя кадра.

Указатель кадра - регистр общего назначения (обычно EBP), содержащий значение текущей (на момент входа в функцию) вершины стека (ESP). Используется для организации индексируемого доступа (например: SS:[EBP+X]) к стековому фрейму (выделенной области стека).

Давайте немного проиллюстрируем сказанное:

stack frame

Ну вот, собственно, перед Вами визуализация стекового фрейма. На первый взгляд нетривиально, тем не менее, для того, что бы разобраться в том, что происходит в произвольно взятом дизассемблированном коде, написанном на высокоуровневых языках программирования, нам придется во всем этом досконально разобраться, увы, иного пути у нас нет!! Взгляните на картинку и сфокусируйте своё внимание на области стекового кадра вызванной процедуры. Вы увидите, что тут присутствуют:

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

Пролог и эпилог функции

В языках высокого уровня имеется два основных типа функций:

  • Кадровая функция (frame function) - функция, которая: выделяет пространство стека, сохраняет содержимое используемых в функции регистров, использует обработку исключений, создает собственный кадровый фрейм в стеке потока, имеет пролог и эпилог. может вызывать другие функции.
  • Простая функция (leaf function) - функция, которая: не создает стековый фрейм в стеке потока, не должна модифицировать указатель стека, не должна вызывать другие функции. Может выходить через инструкцию jmp на стековый кадр другой функции. В самом названии leaf (лист) отражается весь смысл данного типа функции - лист, если рассматривать иерархию вызовов функций в качестве разветвленного дерева, то лист - это "конечная его точка", из которой другие функции вызываться не должны (ничего не произрастает).

Таким образом, любая функция, которая:

  • выделяет под собственные нужды часть пространства стека;
  • вызывает (под)функции;
  • сохраняет (использует) регистры общего назначения;
  • использует обработку исключений;

..называется кадровой и должна состоять из трех основных частей: пролог, тело и эпилог. Конечно же, могут встречаться функции, которые не имеют пролога, эпилога, а содержат только тело, в котором выполняется некоторая логика и результат возвращается через один из регистров общего назначения. В коде, полученном при компиляции исходных текстов, написанных на языках высокого уровня подобное встречается не часто, а вот в низкоуровневом языке Ассемблере это можно запросто организовать.
И вот не случайно мы в предыдущем разделе говорили о стековом фрейме. Упоминаемый нами указатель фрейма стека для функции настраивается именно в прологе. Тем не менее, пролог предназначается не только для этого.
Прологом называют часть кода функции, который используется:

  • для настройки стекового фрейма (сохранение оригинального содержимого регистра ESP, настройку нового и инициализацию указателя кадра EBP);
  • для сохранения значений регистров (через которые в функцию переданы аргументы) в локальный стек для последующей работы с ними в коде функции;
  • для сохранения (в стеке) значений регистров, которые будут использованы внутри подпрограммы, поскольку грамотно написанный код должен заботиться о том, чтобы значения регистров процессора, до и после работы функции оставались неизменными. Это своеобразные правила хорошего тона и просто перестраховка. Однако, если Вы или компилятор уверены в том, что порча содержимого определенных регистров не вызовет деструктивных воздействий на последующее выполнение программы - это условие становится не обязательным;
  • (для пролога вне функции) для записи (в регистры или стек) аргументов функции;
  • для резервирования места в стеке с целью хранения локальных переменных. Достигается это смещением указатель стека в сторону уменьшения адресов, то есть выше, в сторону увеличения стека. После этого мы получаем часть памяти, которая доступна (через указатель фрейма) и не задействована. Это пространство может быть использовано кодом функции по своему усмотрению, чаще всего для хранения локальных переменных функции;

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

  • восстановления (из стека) значений регистров, сохранённых кодом пролога (в том числе и регистра стекового фрейма ebp);
  • очистка (корректировка указателя) стека (от локальных переменных функции).
Как всегда, относительно пролога и эпилога существуют разночтения. В одних источниках можно встретить утверждение, что это код, который вставляется непосредственно перед и после вызова функции (в основном коде). Другие же настаивают, что это код, который вставляется непосредственно внутрь самой вызываемой функции, в самом начале, перед основным кодом функции и в самом конце, перед выходом из неё. Так вот, пролог и эпилог функции могут присутствовать как перед вызовом вызываемой функции, так и непосредственно внутри вызываемой функции, все регламентируется тем или иным соглашением о вызове.

Пролог

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

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

Частный случай 1:

Рассмотрим подробнее вышеприведенный код:

  1. Код пролога сохраняет текущее значение регистра EBP для последующего восстановления при возврате из функции. Ведь значение EBP в вызывающей функции тоже может использоваться в качестве указателя на стековый фрейм.
  2. Регистр EBP инициализируется значением регистра ESP. Это и есть настройка указателя стекового фрейма, через него мы обеспечим доступ к аргументам функции, переданным из вышестоящей (вызывающей) функции. Одновременное команда имеет смысл с точки зрения сохранения значения регистра ESP, поскольку перед выходом из функции нам надо будет восстановить значение регистра.
  3. Сохраняет в стеке регистры общего назначения, которые будут использованы (испорчены) в коде функции (строки 3-4). Сделано этого с целью предотвращения "порчи" значений регистров по возвращению из функции в вызвавшую ветку кода. Надо учитывать, что сохранение регистров общего назначения может выполняться в разных местах пролога: до/после выделения памяти в стеке, после установления кадра функции, так же регистры могут сохраняться без применения команды push, при помощи прямой записи в память стека.
  4. команда sub esp, X "резервирует" (выделяет) место в стеке для хранения локальных переменных, где X - требуемое количество байт для хранения локальных переменных. Какая сакраментальная цель данного действа? Дело в том, что после выполнения данного "резервирования" мы уже получаем как бы дополнительное место в общем стеке под названием "локальный стек функции", которое может использоваться исключительно на нужды текущей функции, то есть можем смело использовать команды работы со стеком (например: push/pop), не опасаясь испортить стек основной программы. Зачастую резервируется больше места, чем в действительности необходимо коду функции, потому что количество выделяемого места в локальном стеке должно быть выровнено по 16-байтной границе (ссылка: выравнивание данных).

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

Частный случай 2:

От приведенного в первом варианте, код, показанный в варианте №2, отличается тем, что в нем присутствует инструкция выравнивания указателя стека 3, для того, чтобы адрес был кратен 4 байтам. Существует еще вариант с 0FFFFFFF0h. Происходит выравнивание значения в регистре ESP по 8-байтной границе, делая все впоследствии помещаемые в стек значения также выровненными по этой границе. Зачем? Считается что процессор более эффективно работает с переменными, расположенными в памяти по адресам кратным 4 или 16, то есть скорость выполнения увеличивается или что-то в конвейере?. Таким образом в языках высокого уровня (например С/С++) выравниваются члены классов и прочие переменные.

Частный случай 3:

Довольно элегантно, неправда ли? Раз и одной командой все сделал.

В своё время в процессоре Intel 80286 для облегчения работы по сопровождению стекового фрейма была введена пара инструкций enter/enterleave. Хотя с виду все элегантно, реализация получилась довольно тормозной и поэтому продвинутые компиляторы эти инструкции не используют.

Эпилог

Эпилог - машинный код в самом конце функции (процедуры, подпрограммы), который:

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

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

  • x32: add esp, <константа>;
  • x64: add rsp, <константа>;

..либо коррекции через указатель кадра:

  • x32: lea esp, XXX;
  • x64: lea rsp, [указатель кадра + константа];

Частный случай 1:

  1. Код эпилога восстанавливает значение указателя ESP (из регистра EBP, содержимое которого не изменяется на протяжении всей функции), фактически возвращая его в исходное состояние, которые было на момент вызова функции. Таким образом мы восстанавливаем стек, который был ДО входа в функцию и принадлежал коду основной программы/вышестоящей функции. Если бы мы этого не сделали, то после выхода из функции указатель стека указывал бы на другое смещение, из-за чего дальнейшее выполнение с большой вероятностью привело бы к ошибке.
  2. Команда pop ebp восстанавливает сохраненное в стеке значение ebp, поскольку ebp мы использовали как указатель на стековый фрейм функции.
  3. Команда ret выполняет возврат управления в вызывающую функцию.

Частный случай 2:

как то тут все хитро, не находите? Взяли и заменили команды восстановления указателей ESP и EBP на некую непонятную инструкцию leave, похоже что она содержит в себе некую расширенную логику. И действительно, фактически это аналог хорошо знакомой нам пары команд mov esp, ebp и pop ebp, возвращающей исходное значение ESP (из EBP) и затем восстанавливающей из стека значение EBP. Ну а последняя команда ret X производит сперва выталкивание из стека адреса возврата из подпрограммы (функции), затем выталкивание X байт, и уже потом только выполняет переход на адрес возврата.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *