Вызов функции. Часть 2. Стек и соглашения о вызовах.


На главную


Автор статьи - kirants

(Статья на английском заимствована отсюда, перевод - Трубецкой А.)

Следующая страница

Введение

В части первой вы познакомились с основами механизма вызова функции, который был рассмотрен с точки зрения генерируемого кода. При этом вы узнали о двух регистрах, которые используются процессорами семейства х86, - о регистре EIP (указатель на инструкцию) и регистре ESP (указатель на стек). Во второй части вы узнаете немного больше о стеке и его важной роли в работе механизма вызова функций. Кроме того, вы познакомитесь с двумя наиболее популярными соглашениями о вызовах [calling conventions] в Windows/C программировании.


Стек. Что это?

Стек - это область памяти (в пределах отведенных процессу четырех гигабайт), в которой поток может хранить данные, необходимые ему для выполнения. В частности в стеке могут храниться локальные переменные, используемые вашим кодом, временные переменные, используемые компилятором, аргументы функций и т.д. Поведение стека напоминает поведение колоды карт [stack of cards], отсюда он получил свое название. Это означает, что когда вы кладете в стек объекты, они всегда оказываются на его вершине, а когда вы удаляете объект из стека, вы всегда удаляете самый верхний объект. В технической терминологии подобный метод доступа к данным называется LIFO (Last In First Out - последним пришел, первым вышел).

Как мы выяснили в части первой, система предоставляет стек каждому потоку. По умолчанию размер стека равен 1 Мб, но он может быть заменен значением, содержащимся в заголовке образа процесса [the process' image header value]. Размер стека также можно задать при вызове функций CreateThread() или _beginthreadex().

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

Например:
* Инструкция push неявно уменьшает значение ESP на 4 и кладет 32-битное значение по указанному адресу. Это напоминает добавление карты на вершину колоды. После этого действия стек увеличивается на 4 байта.
* Инструкция pop неявным образом извлекает 32 бита из места, на которое указывает ESP, и затем увеличивает значение ESP на 4. Если использовать аналогию с картами, то она как будто удаляет карту с вершины колоды. Соответственно стек уменьшается на 4 байта.
* Такие инструкции как mov ESP, [source] или sub ESP, [value] уменьшают/увеличивают/изменяют значение ESP явным образом, реально изменяя расположение вершины стека.


Каким образом параметры передаются функции?

Ответ прост: через стек. Код, вызывающий функцию, знает, сколько параметров ей передать и каковы значения этих параметров. Таким образом, если код вызывает функцию sum, которая принимает два параметра типа int, т.е. имеет такую сигнатуру
int sum(int argument1, int argument2)
то вызывающий код делает следующее:
* Он кладет два параметра в стек с помощью двух инструкций push. В результате этого указатель стека (ESP) неявно уменьшается на 2 * 4 байта. Другими словами, вершина стека сдвигается на 8 байт.
* Он вызывает инструкцию call, передавая ей адрес функции sum. При этом значение ESP неявно уменьшается еще на 4 байта, потому что при вызове инструкции call в стек кладется адрес инструкции, к которой возвратится поток после выполнения функции sum (в дальнейшем будем называть его "адрес возврата").


Каким образом функция получает параметры?

Ответ тоже прост: она извлекает их из стека. Сразу при входе в функцию sum, до выполнения любых инструкций стек выглядит следующим образом: текущее значение ESP (вершина стека) указывает на адрес возврата. Если вы углубитесь в стек на 4 байта (т.е. посмотрите значение, хранящееся в ESP + 4), то там вы найдете один из параметров, переданных функции sum. Пропустите еще 4 байта и по адресу ESP + 8 вы найдете второй параметр функции sum.

Таким образом, при входе в функцию код имеет все, что ему нужно для выполнения.


Соглашения о вызовах [Calling conventions]

Теперь будет уместно рассмотреть соглашения о вызовах. Соглашение о вызовах - это протокол для передачи аргументов функциям. Другими словами, это договоренность между вызывающим и вызываемым кодом. Рассмотренное нами в темах "Каким образом параметры передаются функции?" и "Каким образом функция получает параметры?" - и есть этот протокол, его самое общее описание. Однако если вы имеете дело с программами Microsoft, то здесь есть дополнительные соглашения. Наиболее полезные из них:

__cdecl
__stdcalll
thiscall

В этой части статьи мы подробно рассмотрим механизмы соглашений __cdecl и __stdcall и узнаем, как выглядит стек и скомпилированный код в каждом из этих случаев.


__cdecl

Подробное описание протокола __cdecl можно найти здесь. Особенно важны следующие моменты:

* Порядок передачи аргументов: справа налево
* Ответственность за целостность стека: вызывающая функция должна удалить аргументы из стека


Порядок передачи аргументов в протоколе __cdecl

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

Относительно содержимого стека это означает следующее: если вы заглянете в стек, как только попадете в вызываемую функцию, до выполнения каких-либо инструкций внутри нее, то первые 4 байта по адресу, хранящемуся в ESP, будут содержать адрес возврата. Следующие 4 байта (т.е. 4 байта по адресу ESP + 4) будут содержать первый параметр, в четырех байтах по адресу (ESP + 8) будет второй параметр и т.д.

* Запустите Visual Studio 2005. Создайте проект Win32 Console Application и назовите его sum.
* Вызовите окно свойств проекта [Project Properties]. Теперь, чтобы облегчить нашу задачу, нужно отключить настройки, которые заставляют компилятор генерировать некоторый код, что затрудняет понимание сути процесса. В будущем я постараюсь рассмотреть использование этих настроек.
- В окне свойств проекта откройте Configuration Properties->C/C++->Advanced. Здесь в поле Calling Convention установите __cdecl.
- На вкладке Configuration Properties->C/C++->General в поле Debug Information Format выберите Program Database(/Zi).
- На вкладке Configuration Properties->C/C++->Code Generation в поле Basic Runtime Checks установите Default.
- На вкладке Configuration Properties->Linker->General в поле Enable Incremental Linking выберите No.
- Нажмите Ok.
* Измените код, как показано на рисунке ниже, и поставьте точку останова на 13-ую строку (для этого переместите курсор на 13-ую строку и нажмите F9):

Sample code

* Соберите проект (пункт меню Build solution).
* Нажмите F5 для запуска программы под отладчиком. Выполнение программы остановится на 13-ой строке.
* Нажмите Alt+5. Появится окно, отображающее содержимое регистров [Registers Window].
* Нажмите Alt+6. Появится окно, отображающее содержимое памяти [Memory Watch Window].
* Перейдите на строку 13, вызовите контекстное меню и выберите Go To Disassembly.
* В дизассемблере снова вызовите контекстное меню и убедитесь, что отмечены следующие пункты:
- Show Address
- Show Source Code
- Show Code Bytes

Окно дизассемблера должно выглядеть следующим образом:

Disassembler

* Обратите внимание на передачу аргументов функции. Поскольку мы используем протокол __cdecl, вы увидите, что параметры действительно кладутся в стек "справа налево". И сразу за ними в стек попадает адрес возврата.
* Запомните значение ESP. Затем выполните первую из инструкций push (другими словами, push 2), нажав F10. Обратите внимание, что значение регистра ESP уменьшилось на 4.
* Снова нажмите F10 и вы увидите, что значение ESP уменьшилось еще на 4.
* Теперь нажмите F11, чтобы войти в вызываемую функцию sum. Окно дизассемблера должно выглядеть так:

Disassembler

* Если вы теперь наберете ESP в поле Address окна Memory Watch Window, вы увидите, что память по адресу ESP + 4 содержит первый аргумент функции, а по адресу ESP + 8 находится второй аргумент. Другими словами, крайний справа аргумент расположен по наибольшему адресу.
* Нажмите F5, чтобы завершить работу отладчика.


Ответственность за целостность стека в протоколе __cdecl

Согласно спецификации, при использовании протокола __cdecl ответственность за поддержание целостности стека лежит на вызывающем коде. Что означает поддержание целостности? Если объяснить коротко, то это означает следующее. Как вы знаете, перед вызовом функции вызывающий код кладет в стек аргументы функции. В результате этих действий размер стека увеличивается. После того, как функция завершилась и вернула управление вызывающему коду, размер стека должен быть уменьшен до исходного значения так, чтобы при ссылке на переменные и аргументы функции _tmain() они снова находились бы по нужному адресу. Таким образом, возвращение регистра ESP к изначальному значению после возврата из функции - это и есть поддержание целостности стека. Вы увидите это, рассмотрев следующий пример.

* Нажмите F5, чтобы запустить программу под отладчиком. Выполнение программы остановится на 13-ой строке.
* Нажмите Alt+5. Появится окно, отображающее содержимое регистров [Registers Window].
* Нажмите Alt+6. Появится окно, отображающее содержимое памяти [Memory Watch Window].
* Перейдите на строку 13, вызовите контекстное меню и выберите Go To Disassembly.
* В дизассемблере снова вызовите контекстное меню и убедитесь, что отмечены следующие пункты:
- Show Address
- Show Source Code
- Show Code Bytes

Окно дизассемблера должно выглядеть следующим образом:

Disassembler

* Запомните, какое значение находится в регистре ESP сейчас (вы еще не положили аргументы в стек). На нашем рисунке это значение равно 0x12FF64. Первое, что должно произойти сразу после выполнения инструкции call, - это восстановление в ESP того значения, которое вы запомнили. Нажмите F10, чтобы пропустить блок, содержащий две инструкции push и инструкцию call. Затем посмотрите на значение ESP.

Disassembler

* В нашем примере в регистре ESP будет находиться число 0x12FF5C. Как видите, оно не равно изначальному значению. Следовательно, необходимо восстанавливать целостность стека. Теперь посмотрите на строку кода, на которой в данный момент остановилось выполнение. Это строка
add      esp,8
При выполнении этого действия мы получим 12FF5Ch + 8h = 12FF64h - нужное нам число. Таким образом, эта строка восстанавливает целостность стека, поскольку ESP возвращается к своему изначальному значению. Обратите внимание, что эта строка принадлежит вызывающему коду, а не вызываемому. Именно это в протоколе __cdecl называется "Вызывающая функция удаляет аргументы из стека [Calling function pops the arguments from the stack]". Хотя при этом не используются непосредственно инструкции pop, все равно результат получается тот же - указатель ESP получает прежнее значение.


Выводы по протоколу __cdecl

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


Следующая страница


На главную

Rambler's Top100
Хостинг от uCoz