Одной из основ исследования структуры исполняемых образов программ (реверсивная инженерия), является изучение пролога и эпилога функции. Обычно об этих понятиях мало кто задумывается, до той самой поры, пока не начинают изучать строение исходного кода на уровне машинных (процессорных) команд, иными словами - в дизассемблированном виде. Очевидно, что знакомый с одноименными терминами мира искусства, дальновидный читатель сразу же догадается что пролог это ни что иное как своеобразное "предисловие", некая вводная часть, вступление, а эпилог - это нечто завершающее, "послесловие", заключительная часть. Проводя аналогию, логично было бы предположить, что механизмы эти призваны выполнять некие предваряющие и завершающие действия по отношению к какой-либо сущности, а именно к функциям. И все же не совсем понятно, как именно термины пролога и эпилога относятся к функциям/процедурам из мира вычислительной техники? Довольно нетривиальной задачей является попытка интуитивного сопоставления терминов из мира искусства с областью программирования. К тому же, у данной области имеется множество особенностей, которые мы сегодня и попытаемся понять.
Что такое функция
Для начала давайте немного определимся с терминологией и определим, что же есть функция:
С понятием функции определились, но ведь если есть такие логические блоки кода, как функции, значит они должны как то задействоваться, или, проще говоря, применяться.
А вызов функции, это, в свою очередь, как раз и есть последовательность действий, которую мы и будем сегодня изучать.
Сразу хочу оговориться, что в дикой природе высокоуровневых приложений существует огромное многоообразие реализаций, используемых современными компиляторами для организации пролога/эпилога, а так же работы с локальными переменными и аргументами, поэтому досконально описать их в полном объеме не представляется возможным. Соответственно, пока что начнем с основных (самых распространенных) методик, возможно со временем расширяя описание. Давайте попробуем привести пару наглядных примеров.
Функция без параметров
Для начала, давайте вернемся в прошлое, в те времена, когда на просторах компьютерной эры безраздельно господствовал реальный режим работы процессора x86 и старые операционные системы реального же режима. В качестве основы возьмем популярную в то время операционную систему MSDOS, а пример приведем на низкоуровневом языке Ассемблера. В общем то, синтаксис Ассемблера x86 не очень то отличается между разными реализациями/операционными системами, поэтому ничего страшного в выборе MSDOS нет, просто в то время удалось поковырять именно её. Давайте посмотрим, как выглядел вызов простой (типовой) функции:
1 2 3 4 5 6 7 8 9 10 11 12 |
. . . call some_function // вызов функции . . . . . . . . . some_function: // точка входа функции mov ax,42h xor cx,cx cwd int 21h ret // возврат из функции . . . |
кто пусть даже поверхностно разбирается в языке Ассемблера, может уверенно констатировать, что приведенный пример весьма и весьма прост. Осуществляется вызов некой функции с именем some_function
, в момент вызова в стеке (пара регистров SS
:SP
) сохраняется адрес возврата определенной размерности (длиной в слово, два байта) из процедуры, который указывает на следующую за инструкцией call команду. Потом управление передается на точку входа (по адресу первого байта) функции. Код функции выполняется, затем команда ret
извлекает из стека сохраненный (ранее) адрес возврата и передает по этому адресу управление, тем самым осуществляя возврат из функции. Для пущей наглядности проиллюстрируем на конкретном (сюрприз) примере:
Как видно из кода, происходит вызов (инструкция call 000000065), затем в функции осуществляется ряд действий и выполняется возврат (инструкция retn
). Тут я намеренно привел достаточно простую функцию, а все ради того, что бы читатель ощутил существенный контраст с публикуемым далее материалом.
Функция с параметрами
Как мы уже отметили, в приведенном выше (довольно простом) примере, в функцию не передаются какие-либо аргументы.
Тем не менее, в "дикой природе" компьютерных программ значительно чаще можно встретить более сложные функции, то есть те, при вызове которых требуется передача (в функцию) одного или более входных параметров. К тому же программ, написанных на высокоуровневых языках программирования, значительно больше, нежели тех, что написаны на чистом Ассемблере, поэтому сейчас мы обратимся к современному опыту и приведем более распространенный пример, реализованный на языке MS Visual C++. Давайте набросаем простенький пример функции на C++:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <iostream> using namespace std; void f1(int a, int b, int c) { printf("%d, %d, %d\n", a, b, c); }; int main() { f1(1,2,3); } |
По коду можно определить, что функция f1
(описана в строке 5
) имеет ряд входных параметров (три параметра - определенные в самой функции как a, b, c).
Функция f1 вызывается (строка 12) с тремя аргументами (значения 1, 2, 3) и осуществляет вывод на консоль (посредством printf) этих значений. А теперь посмотрим, как будет выглядеть вызов функции после компиляции и дизассемблирования (на низком уровне):
1 2 3 4 |
PUSH 3 ; 3-Й ПАРАМЕТР В СТЕК PUSH 2 ; 2-Й ПАРАМЕТР В СТЕК PUSH 1 ; 1-Й ПАРАМЕТР В СТЕК CALL 00351433 ; ВЫЗОВ ФУНКЦИИ F1 |
Как интересно, сразу возникает довольно таки много вопросов. Во-первых, почему аргументы передаются через стек (команда push)? Во-вторых, почему аргументы передаются в обратном порядке (начиная с 3-го)? Все эти вопросы, безусловно, требуют пояснения, но на данный момент я не буду давать ответа, Вы его получите позже, в процессе изучения материала статьи. Теперь, неплохо было бы взглянуть на ассемблерный код и самой функции f1
:
и приведем её в очищенном текстовом варианте, для того, что бы проще было объяснять:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
PUSH EBP ; СОХРАНИМ ОРИГИНАЛЬНЫЙ EBP (ДОСТАВШИЙСЯ ОТ ВЫЗЫВАЮЩЕЙ ФУНКЦИИ) MOV EBP,ESP ; ИНИЦИАЛИЗИРУЕМ УКАЗАТЕЛЬ КАДРА SUB ESP,0C0 ; РЕЗЕРВИРОВАНИЕ МЕСТА В СТЕКЕ ПОД ЛОКАЛЬНЫЕ ПЕРЕМЕННЫЕ ФУНКЦИИ F1 PUSH EBX ; СОХРАНИМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ, ИСПОЛЬЗУЮЩИЙСЯ ВНУТРИ ФУНКЦИИ PUSH ESI ; СОХРАНИМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ, ИСПОЛЬЗУЮЩИЙСЯ ВНУТРИ ФУНКЦИИ PUSH EDI ; СОХРАНИМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ, ИСПОЛЬЗУЮЩИЙСЯ ВНУТРИ ФУНКЦИИ LEA EDI,[EBP-0C0] MOV ECX,30 MOV EAX,CCCCCCCC REP STOS DWORD PTR ES:[EDI] MOV ECX,OFFSET 0081F027 CALL 0081127B MOV EAX,DWORD PTR SS:[EBP+10] ; ПОЛУЧАЕМ АРГУМЕНТ 3, ПЕРЕДАННЫЙ В ФУНКЦИЮ F1 PUSH EAX ; ЗАТАЛКИВАЕМ В СТЕК MOV ECX,DWORD PTR SS:[EBP+0C] ; ПОЛУЧАЕМ АРГУМЕНТ 2, ПЕРЕДАННЫЙ В ФУНКЦИЮ F1 PUSH ECX ; ЗАТАЛКИВАЕМ В СТЕК MOV EDX,DWORD PTR SS:[EBP+8] ; ПОЛУЧАЕМ ПАРАМЕТР 1, ПЕРЕДАННЫЙ В ФУНКЦИЮ F1 PUSH EDX ; ЗАТАЛКИВАЕМ В СТЕК PUSH OFFSET 00819B30 ; ASCII "%d, %d, %d" CALL 0081142E ; ВЫЗЫВАЕМ ФУНКЦИЮ PRINTF ADD ESP,10 ; ОЧИЩАЕМ СТЕК ОТ ПЕРЕМЕННЫХ PRINTF POP EDI ; ВОССТАНАВЛИВАЕМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ POP ESI ; ВОССТАНАВЛИВАЕМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ POP EBX ; ВОССТАНАВЛИВАЕМ РЕГИСТР ОБЩЕГО НАЗНАЧЕНИЯ ADD ESP,0C0 ; ВОССТАНАВЛИВАЕМ СТЕК ОТ РЕЗЕРВАЦИИ МЕСТА ПОД ЛОКАЛЬНЫЕ ПЕРЕМЕННЫЕ ФУНКЦИИ F1 CMP EBP,ESP CALL 00811285 MOV ESP,EBP ; ВОССТАНАВЛИВАЕМ ESP POP EBP ; ВОССТАНАВЛИВАЕМ EBP RETN ; ВОЗВРАТ ИЗ ФУНКЦИИ |
Видно невооруженным глазом, что функция f1
, написанная на высокоуровневом языке, значительно сложнее функции, приведенной в первом примере выше (написанном на Ассемблере). Конечно, мною выбрана не совсем уж простая функция для первого примера, тем не менее интересующие нас места в коде я выделил цветом. Все дело в том, что все сложные функции, написанные на высокоуровневах языках и имеющие какие-либо аргументы, вне зависимости от операционной системы, на уровне машинного кода стали значительно сложнее. Сразу обращает на себя внимание дополнительная работа со стеком (строки 1-3 и 25, 28, 29), работа с переданными в функцию f1 параметрами (строки 13, 15, 17). Все эти трансформации структуры функций не являются прихотью конкретного компилятора, а объясняются соглашениями о вызовах.
Соглашение о вызовах
В отличии от математической парадигмы, функции в программировании должны четко регламентировать способы/методы передачи аргументов в вызываемую функцию. В своё время требовалось прийти к некоторому общему, устраивающему все стороны, знаменателю, поскольку у разработчиков как программной так и аппаратной частей при всем многообразии машинных инструкций, имелось множество потенциальных способов передачи аргументов в вызываемую функцию:
- при помощи регистров [процессора] общего назначения (к примеру, для 32-битного процессора это:
eax
,ebx
,acx
,edx
,esi
,edi
и прч.); - посредством записи параметров непосредственно в стек (передача через стек);
- посредством использования ячеек памяти (запись/чтение значений по выделенным адресам памяти);
Первый вариант крайне неудобен, поскольку:
- регистров общего назначения в процессорах архитектуры x86 было немного (в отличие, например, от более поздних x64 или ARM);
- значения данных регистров активно используются по ходу выполнения "основной" ветви кода, поэтому потребуется обеспечивать "пересохранение" регистров;
- из второго утверждения следует, что перед вызовом функции содержимое общих регистров надо сохранять, а по завершении выполнения функции восстанавливать, что влечет за собой довольно изрядные "накладные расходы", что сказывается на производительности для всех видов языков, а для низкоуровневых (ассемблер) превращает процесс программирования в форменную головоломку.
Вероятно как раз после того как были приняты во внимание все перечисленные проблемы передачи аргументов, было решено как-то стандартизировать данный процесс. Так на свет появилось такое понятие как соглашение о вызовах, определяющее некие правила вызова функций.
Другими словами, соглашение о вызовах определяет, как передаются параметры в функцию и как происходит очистка стека при выходе из функции. Естественно должны определяться некоторые критерии вызова, такие как:
- аргументы передаются в функцию посредством регистров?
- аргументы передаются в функцию посредством стека?
- аргументы передаются в функцию посредством указателя на область памяти?
- аргументы передаются в функцию одновременно разными способами: частично через регистры, частично через стек/область памяти (указатель)?
- аргументы передаются в функцию сначала первый или сначала последний ("слева направо" или "справа налево", соответственно)?
- чей код ответственен за сохранение/восстановление (чистку) стека: вызывающей или вызываемой функции?
- чей код ответственен за сохранение/восстановление регистров: вызывающей или вызываемой функции?
На все эти вопросы и призвано было ответить соглашение о вызовах. Соответственно, поскольку мы тут пытаемся изучать архитектуру операционной системы Windows, то в ней то, как раз можно встретить следующие соглашения о вызовах:
- cdecl
- Аргументы функций передаются через стек, справа налево. Аргументы, размер которых меньше 4х байт, расширяются до 4х байт. Очистку стека выполняет вызывающая программа;
- pascal
- Аргументы функций передаются через стек, слева направо. Указатель на вершину стека (значение регистра
ESP
) в изначальную позицию возвращает вызываемая подпрограмма; - stdcall (WinAPI)
- Аргументы функций передаются через стек, справа налево. Очистку стека производит вызываемая подпрограмма.
- fastcall
- Аргументы в функцию передаются через регистры, если для сохранения аргументов (и промежуточных результатов) регистров оказывается не достаточно, используется стек. Включает в себя несколько соглашений, которые действуют по сходим принципам, отличаясь только незначительными деталями.
- safecall
- Соглашение о вызовах, используемое для вызова методов интерфейсов COM;
- thiscall
- Аргументы функции передаются через стек, справа налево. Очистку стека производит вызываемая функция. Незначительно отличается от stdcall тем, что указатель на объект сохраняется в регистр
ECX
;
Задаются эти соглашения посредством использования специальных макросов непосредственно в коде или умолчаний компилятора той или иной среды разработки.
С соглашением о вызовах тесно связано понятие указателя кадра стека, зачастую называемого так же указателем фрейма стека (stack frame), указателем фрейма (frame pointer) или стековым кадром. Теперь давайте плавно перейдем к его изучению.
Стековый фрейм (кадр стека, стековый кадр)
Ну стек то уж вы должны знать что такое? :) Так вот, а стековый фрейм - это часть данных стека, или:
Из определения уже начинает проясняться, что это ни что иное как фрагмент стека (часть памяти, выделенной под стек) потока, используемый исключительно для служебных целей: передачи параметров функции из кода вышестоящей (вызывающей) функции и выделения памяти для использования внутри вызываемой функции (как правило, для хранения локальных переменных). Ну, определение то не такое уж и простое, поэтому хотелось бы посмотреть как всё это это используется на практике. В процессе сборки/компоновки (перевод исходного кода в машинный и сборка объектных модулей) программы, написанной на одном из высокоуровневых языков программирования, код всех используемых разработчиком "собственных"/"встраиваемых" функций транслируется таким образом, что для передачи параметров в эти функции и доступа к локальным переменным этих функций, тем или иным образом используются регистры и/или стек. На (самом низком) уровне машинного кода, процессор, выполняющий какой-либо код, встречает в нем инструкцию вызова функции (например call), начинает её исполнять. Исполнение заключается в некоей предварительной подготовке, и непосредственно перед передачей управления на адрес (точку входа) функции, процессор должен положить (разместить, засунуть) аргументы функции в стек в установленном порядке (определяемым соглашением о вызовах), и только после этого произвести вызов функции (передачу ей управления). Когда вызванная таким образом (функция|процедура) получает управление, она наследует и программный стек от вызвавшей функции, на вершине которого располагается адрес возврата из функции, после которого идут входные параметры (аргументы), с которыми данная функция была вызвана. Ну хорошо, стек вызывающей функции мы получили, но можно ли с ним сразу активно работать, ведь мы его тогда "испортим"? Именно так, поэтому для того, чтобы не портить стек вызвавшей функции, вызываемая функция настраивает так называемый собственный стековый кадр, с целью оперирования собственными (локальными) переменными, сохранения произвольных локальных значений и прч. Новый стековый кадр (фрейм) настраивается через инициализацию указателя кадра.
EBP
), содержащий значение текущей (на момент входа в функцию) вершины стека (ESP
). Используется для организации индексируемого доступа (например: SS:[EBP+X]
) к стековому фрейму (выделенной области стека).Давайте немного проиллюстрируем сказанное:
Ну вот, собственно, перед Вами визуализация стекового фрейма. На первый взгляд нетривиально, тем не менее, для того, что бы разобраться в том, что происходит в произвольно взятом дизассемблированном коде, написанном на высокоуровневых языках программирования, нам придется во всем этом досконально разобраться, увы, иного пути у нас нет!! Взгляните на картинку и сфокусируйте своё внимание на области стекового кадра вызванной процедуры. Вы увидите, что тут присутствуют:
- все аргументы (параметры) функции, переданные вызывающим кодом в нашу (текущую) процедуру;
- адрес возврата (заносимый процессором перед обработкой инструкции call при вызове);
- затем сохраненный адрес предыдущего кадра (ebp);
- затем уже следуют локальные переменные, которые наша функция использует в рамках собственного кода, то есть компилятор на этапе сборки посмотрел какие локальные переменные и сколько использует наша функция и выделил соответствующее количество места в стеке;
- и далее размещены регистры, которые сохраняются и восстанавливаются по ходу выполнения кода процедуры;
Пролог и эпилог функции
В языках высокого уровня имеется два основных типа функций:
- Кадровая функция (frame function) - функция, которая: выделяет пространство стека, сохраняет содержимое используемых в функции регистров, использует обработку исключений, создает собственный кадровый фрейм в стеке потока, имеет пролог и эпилог. может вызывать другие функции.
- Простая функция (leaf function) - функция, которая: не создает стековый фрейм в стеке потока, не должна модифицировать указатель стека, не должна вызывать другие функции. Может выходить через инструкцию jmp на стековый кадр другой функции. В самом названии leaf (лист) отражается весь смысл данного типа функции - лист, если рассматривать иерархию вызовов функций в качестве разветвленного дерева, то лист - это "конечная его точка", из которой другие функции вызываться не должны (ничего не произрастает).
Таким образом, любая функция, которая:
- выделяет под собственные нужды часть пространства стека;
- вызывает (под)функции;
- сохраняет (использует) регистры общего назначения;
- использует обработку исключений;
..называется кадровой и должна состоять из трех основных частей: пролог, тело и эпилог. Конечно же, могут встречаться функции, которые не имеют пролога, эпилога, а содержат только тело, в котором выполняется некоторая логика и результат возвращается через один из регистров общего назначения. В коде, полученном при компиляции исходных текстов, написанных на языках высокого уровня подобное встречается не часто, а вот в низкоуровневом языке Ассемблере это можно запросто организовать.
И вот не случайно мы в предыдущем разделе говорили о стековом фрейме. Упоминаемый нами указатель фрейма стека для функции настраивается именно в прологе. Тем не менее, пролог предназначается не только для этого.
Прологом называют часть кода функции, который используется:
- для настройки стекового фрейма (сохранение оригинального содержимого регистра
ESP
, настройку нового и инициализацию указателя кадраEBP
); - для сохранения значений регистров (через которые в функцию переданы аргументы) в локальный стек для последующей работы с ними в коде функции;
- для сохранения (в стеке) значений регистров, которые будут использованы внутри подпрограммы, поскольку грамотно написанный код должен заботиться о том, чтобы значения регистров процессора, до и после работы функции оставались неизменными. Это своеобразные правила хорошего тона и просто перестраховка. Однако, если Вы или компилятор уверены в том, что порча содержимого определенных регистров не вызовет деструктивных воздействий на последующее выполнение программы - это условие становится не обязательным;
- (для пролога вне функции) для записи (в регистры или стек) аргументов функции;
- для резервирования места в стеке с целью хранения локальных переменных. Достигается это смещением указатель стека в сторону уменьшения адресов, то есть выше, в сторону увеличения стека. После этого мы получаем часть памяти, которая доступна (через указатель фрейма) и не задействована. Это пространство может быть использовано кодом функции по своему усмотрению, чаще всего для хранения локальных переменных функции;
Эпилогом называют код, который предназначается для действий, обратных прологу:
- восстановления (из стека) значений регистров, сохранённых кодом пролога (в том числе и регистра стекового фрейма ebp);
- очистка (корректировка указателя) стека (от локальных переменных функции).
Пролог
В прологе функции располагается код, выполняющий предварительные действия, которые необходимы перед работой тела функции. Создается компилятором при формировании кода функции. Нужно понимать, что стандарта тут никакого нет, и различные компиляторы языков высокого уровня по-разному организуют стековый фрейм.
Частный случай 1:
1 2 3 4 5 |
push ebp mov ebp, esp push eax push ebx sub esp, X |
Рассмотрим подробнее вышеприведенный код:
- Код пролога сохраняет текущее значение регистра
EBP
для последующего восстановления при возврате из функции. Ведь значениеEBP
в вызывающей функции тоже может использоваться в качестве указателя на стековый фрейм. - Регистр
EBP
инициализируется значением регистраESP
. Это и есть настройка указателя стекового фрейма, через него мы обеспечим доступ к аргументам функции, переданным из вышестоящей (вызывающей) функции. Одновременное команда имеет смысл с точки зрения сохранения значения регистраESP
, поскольку перед выходом из функции нам надо будет восстановить значение регистра. - Сохраняет в стеке регистры общего назначения, которые будут использованы (испорчены) в коде функции (строки
3
-4
). Сделано этого с целью предотвращения "порчи" значений регистров по возвращению из функции в вызвавшую ветку кода. Надо учитывать, что сохранение регистров общего назначения может выполняться в разных местах пролога: до/после выделения памяти в стеке, после установления кадра функции, так же регистры могут сохраняться без применения командыpush
, при помощи прямой записи в память стека. - команда sub esp, X "резервирует" (выделяет) место в стеке для хранения локальных переменных, где X - требуемое количество байт для хранения локальных переменных. Какая сакраментальная цель данного действа? Дело в том, что после выполнения данного "резервирования" мы уже получаем как бы дополнительное место в общем стеке под названием "локальный стек функции", которое может использоваться исключительно на нужды текущей функции, то есть можем смело использовать команды работы со стеком (например: push/pop), не опасаясь испортить стек основной программы. Зачастую резервируется больше места, чем в действительности необходимо коду функции, потому что количество выделяемого места в локальном стеке должно быть выровнено по 16-байтной границе (ссылка: выравнивание данных).
Очевидно, что с этого момента регистр ebp на протяжении работы кода функции начинает использоваться для доступа к локальным переменным и аргументам функции через смещение, задаваемое в явном виде (например: ebp+0Ch). Теоретически, для подобных целей вместо ebp можно использовать любой другой регистр, однако остальные регистры, в том числе и esp, часто меняются, поэтому их не очень удобно использоваться в целях хранения указателя.
Частный случай 2:
1 2 3 4 |
push ebp mov ebp, esp and esp, fffffff8 sub esp, X |
От приведенного в первом варианте, код, показанный в варианте №2, отличается тем, что в нем присутствует инструкция выравнивания указателя стека 3
, для того, чтобы адрес был кратен 4 байтам. Существует еще вариант с 0FFFFFFF0h. Происходит выравнивание значения в регистре ESP
по 8-байтной границе, делая все впоследствии помещаемые в стек значения также выровненными по этой границе. Зачем? Считается что процессор более эффективно работает с переменными, расположенными в памяти по адресам кратным 4 или 16, то есть скорость выполнения увеличивается или что-то в конвейере?. Таким образом в языках высокого уровня (например С/С++) выравниваются члены классов и прочие переменные.
Частный случай 3:
1 |
enter |
Довольно элегантно, неправда ли? Раз и одной командой все сделал.
Эпилог
Эпилог - машинный код в самом конце функции (процедуры, подпрограммы), который:
- восстанавливает резервирование пространства стека до начального состояния;
- восстанавливающая регистры до состояния, предшествовавшего вызову функции;
- Производит возврат управления из функции с восстановлением N-го количества зарезервированных слов;
По аналогии с прологом, код эпилога создается в исполняемом образе программы компилятором, поэтому следует понимать, что логика фрагмента кода зависит исключительно от соглашения о вызовах, используемого тем или иным компилятором языка высокого уровня. Собственно, поскольку они по-разному организуют восстановление стека перед возвратом в код основной программы, то и код может варьироваться.
В некоторых случаях, в прологе функции, сразу после инициализации указателя кадра, производится резервирование стека под локальные переменные функции. В этом случае, в эпилоге резервирование должно освобождаться посредством константной коррекции:
- x32: add esp, <константа>;
- x64: add rsp, <константа>;
..либо коррекции через указатель кадра:
- x32: lea esp, XXX;
- x64: lea rsp, [указатель кадра + константа];
Частный случай 1:
1 2 3 |
mov esp, ebp pop ebp ret X |
- Код эпилога восстанавливает значение указателя ESP (из регистра EBP, содержимое которого не изменяется на протяжении всей функции), фактически возвращая его в исходное состояние, которые было на момент вызова функции. Таким образом мы восстанавливаем стек, который был ДО входа в функцию и принадлежал коду основной программы/вышестоящей функции. Если бы мы этого не сделали, то после выхода из функции указатель стека указывал бы на другое смещение, из-за чего дальнейшее выполнение с большой вероятностью привело бы к ошибке.
- Команда pop ebp восстанавливает сохраненное в стеке значение ebp, поскольку ebp мы использовали как указатель на стековый фрейм функции.
- Команда ret выполняет возврат управления в вызывающую функцию.
Частный случай 2:
1 2 |
leave ret X |
как то тут все хитро, не находите? Взяли и заменили команды восстановления указателей ESP
и EBP
на некую непонятную инструкцию leave
, похоже что она содержит в себе некую расширенную логику. И действительно, фактически это аналог хорошо знакомой нам пары команд mov esp, ebp и pop ebp, возвращающей исходное значение ESP
(из EBP
) и затем восстанавливающей из стека значение EBP
. Ну а последняя команда ret X производит сперва выталкивание из стека адреса возврата из подпрограммы (функции), затем выталкивание X байт, и уже потом только выполняет переход на адрес возврата.