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


На главную


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

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

Предыдущая страница

__stdcall

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

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


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

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

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

* Запустите Visual Studio 2005. Создайте проект Win32 Console Application и назовите его sum.
* Вызовите окно свойств проекта [Project Properties]. Теперь, чтобы облегчить нашу задачу, нужно отключить настройки, которые заставляют компилятор генерировать некоторый код, что затрудняет понимание сути процесса. В будущем я постараюсь рассмотреть использование этих настроек.
- В окне свойств проекта откройте Configuration Properties->C/C++->Advanced. Здесь в поле Calling Convention установите __stdcall.
- На вкладке 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

Пусть вас не удивляет спецификатор __cdecl, указанный для функции _tmain. Он здесь нужен, поскольку CRT требует, чтобы функция _tmain использовала протокол __cdecl.
Выбор соглашения о вызовах [Calling Convention] в настройках проекта отражается только на тех функциях, для которых этот протокол не указан явно. Таким образом, функция sum будет использовать выбранный в настройках протокол __stdcall.

* Соберите проект (пункт меню 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

Обратите внимание на передачу аргументов функции. Поскольку мы используем протокол __stdcall, вы увидите, что параметры действительно кладутся в стек "справа налево". И сразу за ними в стек попадает адрес возврата.

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

Disassembler

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


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

Согласно спецификации, при использовании протокола __stdcall ответственность за поддержание целостности стека лежит на вызываемом коде. Что означает поддержание целостности? Если объяснить коротко, то это означает следующее. Как вы знаете, перед вызовом функции вызывающий код кладет в стек аргументы функции. В результате этих действий размер стека увеличивается. После того, как функция завершилась и вернула управление вызывающему коду, размер стека должен быть уменьшен до исходного значения так, чтобы при ссылке на переменные и аргументы функции _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.
В нашем примере в регистре ESP будет находиться число 0x12FF64. Как видите, оно равно изначальному значению. Это означает, что вызываемый код (то есть функция sum) позаботился о восстановлении целостности стека.
Чтобы понять, каким образом это произошло, нам нужно рассмотреть вызываемый код.

* Нажмите F5, чтобы завершить работу отладчика. Затем снова нажмите F5 и войдите в вызываемую функцию, нажав F11. Дизассемблер будет выглядеть так:

Disassembler

Нажимайте F10 пока не дойдете до этой строки:
ret 8
Посмотрите на значение ESP. В нашем примере мы видим следующее:

Disassembler

Как видите, по адресу, хранящемуся в ESP, находится значение 0x0040101d. Это адрес возврата инструкции call. Далее следуют 8 байт, отведенные для аргументов функции. Для восстановления целостности стека значение ESP должно увеличиться на 0x12FF64 - 0x12FF58 (это текущее значение ESP), т.е. на 12.

Теперь посмотрите на инструкцию ret 8. В целом инструкция ret xx делает следующее. Она сначала возвращается к адресу, указанному ESP (в нашем случае 0x0040101d), и затем забирает хх байт из стека. В сущности сама инструкция ret неявно увеличивает значение ESP на 4 байта. Поскольку после ret указано число 8, это означает, что ret увеличивает ESP еще на 8 байт. Таким образом, из стека забирается 12 байт. И в результате мы получаем значение ESP, равное 0x12FF64. Целостность стека восстановлена.


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

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



Сравнение протоколов __cdecl и __stdcall

Протокол __cdecl __stdcall
Вызывающий код Disassembler

Disassembler

Вызываемый код Disassembler

Disassembler




Последствия неправильного выбора протокола

Мы изучили механизм работы протоколов. Теперь было бы интересно рассмотреть, каким образом неправильный выбор протокола повлияет (если вообще повлияет) на работу потока. В большинстве случаев сложно ошибиться в выборе протокола, если вызывающий и вызываемый код помещены в один EXE-файл. Это связано с тем, что компилятор может принудительно установить нужный для функции протокол во время компиляции. Однако когда функция реализована во внешнем модуле, скажем в DLL, то вполне возможно, что заголовочный файл, в котором объявлена функция, например int sum(int a, int b), будет скомпилирован и библиотекой DLL, и вызывающим ее модулем. Если в настройках проекта DLL указан протокол по умолчанию __stdcall, а в настройках проекта вызывающего модуля указан протокол __cdecl, то возможно возникновение несоответствия между протоколами, потому что в сигнатуре функции не содержится никакой информации, по которой компилятор мог бы определить, какой протокол следует использовать.

Что произойдет в этом случае?

Рассмотрим возможные сценарии...

Что произойдет, если вызывающий код ожидает использования протокола __stdcall от функции, реализованной с помощью протокола __cdecl?

При этом сценарии выполнится ситуация (1, 2) для вызывающего кода, а реализация вызываемой функции будет выглядеть, как изображено на рисунке (2, 1) приведенной выше таблицы. Для того чтобы программа работала корректно, сразу после завершения функции (2, 1) вызывающий код должен восстановить целостность стека, как показано на рисунке (1, 1). Однако поскольку вызывающий код ошибочно полагает, что функция использует протокол __stdcall, он не будет изменять указатель на стек. Таким образом, целостность стека будет нарушена, указатель стека не будет корректно восстановлен и размер стека будет ошибочно увеличен. Хотя может показаться, что в этом нет ничего страшного, может возникнуть опасная ситуация, поскольку если стек увеличивается без необходимости, то последствия могут быть нежелательными. Например, может произойти переполнение стека.

Я приведу простой пример, чтобы вы могли представить, какие опасные последствия могут возникнуть при таком несоответствии протокола. Измените код следующим образом:

#include "stdafx.h"

typedef int (__stdcall *SUMFUNC)(int a, int b);

int __cdecl sum(int a, int b)
{
   return a + b;
}

SUMFUNC fn = (SUMFUNC)∑
int __cdecl _tmain(int argc, _TCHAR* argv[])
{
   int nRet = (*fn)(1,2);

   return 0;
}
Теперь ваш код должен заставить компилятор создать ситуацию несоответствия протоколов. Обратите внимание на следующее:

* Вызываемый код (функция sum) использует протокол __cdecl. Таким образом, этот код не собирается поддерживать целостность стека.
* Вызывающий код использует typedef, с помощью которого создает видимость, что sum использует __stdcall. Таким образом, компилятор будет считать, что функция sum использует __stdcall и сама позаботится о целостности стека.

Теперь проанализируем ситуацию.

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

Disassembler

Запомните значение регистра ESP. В моем примере оно равно 12FF64. Теперь нажмите F10 три раза, чтобы выполнилась инструкция call. Проверьте регистр ESP. В моем случае его значение равно 12FF5C:

Disassembler

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

Сделаем пока паузу в этом месте и проанализируем тот факт, что ESP не был восстановлен и, следовательно, стек остался увеличенным на 8 байт. Если вы продолжите вызывать функцию sum, то после каждого вызова размер стека изменится еще на 8 байт. Если у вас будет большее число аргументов, то увеличение стека будет происходить быстрее. В конце концов, размер стека достигнет своего предела, и поток будет завершен из-за переполнения стека. Чтобы убедиться в этом, поместите вызов функции в цикл как показано на рисунке ниже и нажмите F5, чтобы получить ситуацию, которую я только что описал.

Disassembler

* Измените typedef следующим образом и убедитесь, что все работает правильно
typedef int (__cdecl *SUMFUNC)(int a, int b);


Что произойдет, если вызывающий код ожидает использования протокола __cdecl от функции, реализованной с помощью протокола __stdcall?

В этом случае вызывающий код будет вести себя как изображено на рисунке (1, 1), а вызываемая функция - как изображено на рисунке (2, 2) приведенной выше таблицы. При правильном выполнении кода после завершения вызываемой функции на рисунке (2, 2) вызывающий код НЕ должен восстанавливать целостность стека. Однако поскольку вызывающий код ошибочно полагает, что вызываемая функция использует протокол __cdecl, он попытается восстановить стек. В результате указатель стека будет изменен два раза - и вызываемой функцией, и вызывающим кодом. И он окажется сдвинутым на 8 байт ниже изначальной вершины. Это означает, что когда будет вызвана другая функция, то пространство стека, используемое предыдущей функцией, будет перезаписано.

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

#include "stdafx.h"

typedef int (__cdecl *SUMFUNC)(int a, int b, int c);

int __stdcall sum(int a, int b, int c)
{
   return ( a + b + c);
}

SUMFUNC fn = (SUMFUNC)∑

int __cdecl _tmain(int argc, _TCHAR* argv[])
{
   (*fn)(1,2,3);
   sum(100,200,300);
   return 0;
}
* Поставьте точку останова на строку (*fn)(1,2,3);. Далее нажмите F5. Когда выполнение программы остановится, проверьте значение переменной argc (для этого наведите на нее курсор мыши). Значение argc будет равно 1.
* Затем выполните следующие две строки (для этого нажмите два раза F10) и остановитесь на строке return 0. Снова наведите курсор на переменную argc и проверьте ее значение. Вы неожиданно обнаружите, что значение argc изменилось на 300, хотя ни одна строка кода не делала этого явно.

Объяснить это изменение можно следующим образом:
* После выполнения строки (*fn)(1,2,3) указатель стека оказывается смещенным, теперь он указывает на область памяти, в которой находятся аргументы функции _tmain, а именно на аргумент argc, если быть более точным.
* Теперь, когда мы выполняем следующую инструкцию sum(100,200,300), крайний справа аргумент, 300, помещается в стек (в ту область памяти, на которую в данный момент указывает указатель стека) и таким образом повреждает аргумент argc.

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

В качестве упражнения проследите в окне дизассемблера, что при этом происходит.

Итоги

Итак, вот что мы успели изучить:

* Стек - область памяти, выделенная для потока, чтобы он мог выполнять свою работу. Стек используется для хранения аргументов функции, адреса возврата и локальных переменных.
* Существуют различные соглашения о вызовах [Calling conventions]. Мы рассмотрели работу двух наиболее популярных протоколов - __stdcall и __cdecl.
* Несоответствие используемых протоколов может стать причиной появления коварных, трудноуловимых багов. Мы убедились в этом, рассмотрев примеры повреждения стека из-за ошибочного предположения об используемом протоколе.


Предыдущая страница

На главную

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