(Статья на английском заимствована отсюда, перевод - Трубецкой А.)
В этой серии статей я хотел бы рассмотреть внутреннюю суть механизма вызова функций. Что происходит за кулисами, почему код генерируется именно таким образом, а также другие пикантные подробности. Статья посвящена компиляторам Microsoft, и я использовал Visual Studio 2005, хотя основная концепция может быть применена и к более ранним версиям IDE. Кроме того, материал статьи применим только к системам, работающим на базе процессора Intel x86.
Предполагается, что читатель знаком с:
1. основами архитектуры x86 и регистрами. Хорошее пособие Вы можете найти здесь
2. основами программирования на С/С++
3. средой разработки Visual Studio 2005.
Упрощенный взгляд на функции
Я не буду объяснять, что такое функция. Однако я хотел бы в общих чертах обрисовать ее основную структуру. У функции есть тело, которое содержит код, исполняемый функцией. Функция может иметь или не иметь аргументы. Функция может возвращать что-то или не возвращать ничего. Хотя все это кажется очевидным, надо заметить, что когда какой-либо код хочет использовать функцию, он фактически заключает контракт с этой функцией. Как будто он при этом говорит:
- Эй, функция sum! Я собираюсь вызвать тебя и передать тебе два целых числа, а ты вернешь мне сумму этих чисел. ОК?
А функция sum отвечает примерно так:
- ОК. У меня есть целое число 1 и целое число 2. Я складываю их. У меня есть результат. Теперь получи этот результат.
То, что я сейчас описал, на жаргоне программистов называется "Соглашение о вызовах [Calling convention]". Это протокол, в соответствии с которым вызывающий код и вызываемая функция согласны общаться друг с другом.
Немного подробнее о функциях
Настало время рассмотреть тему немного глубже. ПК понимает только язык процессора (машинный код). Следовательно, любой код, написанный вами, должен быть скомпилирован в машинный, прежде чем процессор сможет выполнить его. Чтобы получить EXE-файл, нужно скомпилировать и связать [linking] код с помощью вашего любимого инструмента разработки, такого как Visual C++ (чтобы упростить ситуацию, я буду рассматривать только EXE). Сгенерированный EXE может содержать в себе не только исполняемый код. В нем также могут находиться данные или ресурсы. Секция .text внутри образа EXE содержит исполняемый код. Когда пользователь запускает программу, ОС производит все необходимые действия для запуска машинного кода, находящегося в секции .text. Таким образом стартует главный поток приложения. EXE также может порождать собственные потоки. Однако главный поток управляет временем жизни EXE. Когда главный поток завершается, завершается и весь процесс.
Каждый поток, созданный EXE, выполняется независимо от того, что происходит с другими потоками. В сущности у каждого потока есть указатель на инструкцию, которая должна быть выполнена следующей. В процессорах семейства x86 этот указатель расположен в регистре EIP. Указатель регистра EIP просто указывает на место внутри EXE, где расположена инструкция для CPU. Процессор загружает инструкцию из места, указанного регистром EIP, и выполняет ее. Содержимое регистра EIP нельзя изменять явно, но он обновляется сам в следующих ситуациях:
1. Процессор закончил выполнение инструкции. Инструкция может содержать множество байт выполняемого кода. Однако процессор знает, сколько байт требуется инструкции и таким образом может сдвигать указатель на нужное количество байт после каждой инструкции.
2. Выполнена инструкция ret (return).
3. Выполнена инструкция call.
А как насчет данных, с которыми оперирует код? Данные могут быть локальными или внешними по отношению к телу функции. Данные, которые являются внешними для функции (глобальные или статические переменные), в большинстве случаев размещаются в определенной секции EXE-файла (.data). Любые локальные переменные создаются в специально отведенном месте, которое называется стеком. Стек - это область памяти, зарезервированная операционной системой для потока. Стек растягивается или уменьшается по мере того, как функции вызываются или завершают выполнение. Помимо локальных переменных в стек также помещаются аргументы, передаваемые функции.
Пример расположения EXE в памяти при наличии одного работающего потока изображен ниже. Это образец простого консольного приложения с функциями main и sum.
Когда EXE исполняется, загрузчик операционной системы отображает EXE (и все зависимые DLL) на выделенные для него 4 гигабайта памяти [a 4GB sandbox]. В течение времени жизни приложения, EXE все исполняет в пределах этой выделенной области [sandbox] и таким образом не затрагивает остальные выполняющиеся процессы. Все обращения к памяти, происходящие при работе EXE, ссылаются на различные участки памяти внутри этих 4 гигабайт. Сам код располагается в пределах этих 4 гигабайт. Стек также находится в этой области. То же самое можно сказать о ресурсах, динамически выделяемой памяти и т.д.
Вот что происходит при выполнении EXE. Загрузчик Windows загружает EXE и отображает его на 4 гигабайта памяти (область отмечена зеленым цветом). Затем он загружает все зависимые DLL и т.д. Он создает главный поток приложения, резервирует стек для этого потока (область выделена фиолетовым цветом) и устанавливает значение регистра EIP так, чтобы он указывал на точку входа, находящуюся в секции .text данного EXE. Информация о точке входа находится в заголовке EXE. В простейшем случае это просто адрес функции main, как указано на рисунке (хотя в действительности точкой входа, скорее всего, будет стартовый код CRT, который инициализирует C runtime для потока, прежде чем передать управление функции main). Указатель на стек хранится в другом регистре процессора, который называется ESP. Прежде чем начнется выполнение главного потока, в регистр ESP помещается адрес, по которому расположен стек (как изображено на рисунке).
Когда вызывается функция sum и начинается ее выполнение, указатели EIP и ESP обновляются, как показано на следующем рисунке. Обратите внимание на смещение указателя EIP от функции main к функции sum и на смещение ESP с учетом аргументов, переданных функции sum.
Функции, взгляд на уровне дизассемблера
Достаточно теории, пора изучить функции в действии. Чтобы получить нужный результат, вы должны использовать только конфигурацию Debug (в конфигурации Release настройки проекта таковы, что выполняется интенсивная оптимизация, из-за которой бывает сложно разобрать определенные участки кода).
* Запустите Visual Studio 2005. Выберите тип проекта Win32 и используйте шаблон Win32 Console Application. Введите имя проекта sum и нажмите Finish, чтобы завершить создание проекта.
* Откройте свойства проекта (project properties) и чтобы облегчить дальнейшее изучение, отключите те установки, которые позволяют компилятору удалять некоторый код, что усложнит понимание сути данной статьи. Я попытаюсь перечислить нужные установки:
- Откройте вкладку 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.
* Введите код как показано на следующем рисунке и поставьте точку останова на 13-ую строку:
* Соберите проект (пункт меню 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
Теперь начнем наше исследование. Рассмотрите следующий рисунок:
Далее сделайте следующее:
* Запомните значение регистра EIP (указатель на инструкцию). Обратите внимание, что EIP содержит адрес инструкции, на которой было остановлено выполнение программы.
* Теперь нажмите F10, чтобы выполнить данную инструкцию и перейти к следующей. Вы увидите, что значение регистра EIP автоматически увеличилось на 2. Почему именно на 2? Причина в том, что предыдущая инструкция использовала ровно 2 байта машинного кода (байты 6A 02 по адресу 0x00401014). Заметьте, что нет никаких специальных инструкций для обновления EIP, это делается автоматически.
* Нажмите F10 еще один раз, и вы перейдете к строке 00401000. Проверьте значение EIP, оно опять увеличилось на 2.
* Посмотрите на следующую строку кода. Инструкция call - это фактически вызов кода из другого участка памяти. Эта инструкция переносит поток выполнения по указанному адресу. В коде, приведенном на рисунке выше, это адрес 0x00401000. Прежде чем продолжить, запомните адрес инструкции, идущей после call. В нашем примере это адрес 0x40101D. Сюда поток должен вернуться сразу после выполнения кода, на который указывала инструкция call.
* Далее вам нужно войти в функцию sum с помощью инструкции call. Чтобы сделать это, нажмите F11 (step into). Обратите внимание, что как только вы выполнили инструкцию call, чтобы попасть в sum, значение EIP изменилось соответствующим образом.
* Теперь поток выполнения находится внутри функции sum. В Memory Watch Window в поле Address напечатайте esp. Тем самым вы сообщаете, что хотели бы посмотреть содержимое памяти по адресу, хранящемуся в регистре ESP. Сделав это, в нашем примере вы получили бы следующий результат:
* Когда завершается выполнение функции sum, поток должен каким-то образом вернуться к адресу 0x40101D. Как это можно осуществить? Ответ кроется в области памяти, на которую указывает ESP. Как только выполнение переносится на другой участок памяти, процессор автоматически кладет в стек адрес, к которому следует вернуться после выполнения указанного кода. При входе в функцию обратите внимание на память по адресу, хранящемуся в ESP, - самое первое двойное слово (DWORD) будет адресом, с которого надо продолжить выполнение потока после инструкции ret.
* Далее продолжите пошаговое выполнение и остановитесь перед выполнением инструкции ret (в нашем примере это адрес 0x0040100A). Как вы думаете, где продолжится выполнение потока? Вы угадали. Если вы снова проверите содержимое памяти по адресу ESP, то увидите значение 0x40101D.
* Нажмите F10, и выполнение переместится к адресу 0x40101D.
Итоги:
Итак, вот что мы изучили:
* Каждый поток имеет свой собственный указатель на текущую инструкцию, и его значение всегда поддерживается актуальным. Этот указатель хранится в регистре EIP.
* Каждый поток имеет свой собственный стек, где хранятся аргументы функции, локальные переменные, адрес инструкции, выполняемой после возврата из функции, и т.д. Адрес стека хранится в регистре ESP.
* Вызов функций осуществляется с помощью инструкций call.
* Для возврата из функции используется инструкция ret.
* Инструкция call делает следующее: она кладет адрес возврата (адрес участка памяти, который расположен после инструкции call) в стек (по указателю ESP). Затем она обновляет регистр EIP, помещая в него адрес вызванного в данный момент кода, и продолжает выполнение потока с этого нового адреса, сохраненного в EIP.
* Инструкция ret делает следующее: она снимает с вершины стека, на которую указывает ESP, двойное слово (DWORD) и кладет его в регистр EIP. Затем выполнение потока продолжается с адреса, который теперь находится в EIP.