Выполнение программы
Последовательное выполнение
При старте процесса создается единица выполнения кода — поток. Потоку передается точка входа — адрес нахождения первой команды программы в адресном пространстве. Можно считать, что поток передает команду и ее аргументы процессору, тот выполняет ее и сообщает результат, после этого поток переходит на следующую команду с помощью счетчика команд. Каждая команда в адресном пространстве занимает несколько байт, не обязательно постоянное число.
TODO: запилить пруфлинк на то, как на самом деле
TODO: можно написать про переключение потоков
Вызов функций
Адрес возврата
Представим, что в нашем коде есть функция, не принимающая аргументов, ничего не возвращающая, и ничего не выполняющая:
void f() { }
Для ее вызова в адресном пространстве будет использоваться команда
, которая переведет поток на начало функции , а для возврата обратно в место, откуда функция была вызвана — команда . Возникает вопрос: как найти адрес команды, к которой надо перейти после выполнения ? Можно сохранить этот адрес в переменной, но, в таком случае, нельзя будет вызывать функции из других функций, или создавать рекурсии:void f() { } void g() { f(); }
Поэтому, в адресном пространстве существует стек, на который при вызове функции кладется адрес возврата. Команда
удаляет текущий адрес возврата с вершины стека, и устанавливает счетчик команд по этому адресу.Передача параметров
Представим, что теперь нам необходимо вызвать чуть более сложную функцию:
void f(int a, int b) { }
Передавать аргументы внутрь функции удобно, также используя стек. Во всех конвенциях вызова C++ сначала передаются аргументы в обратном порядке, а потом - адрес возврата, то есть, вызов
будет исполнен какpush 566 push 239 call f
, где
— команда "положить на стек n". Адрес возврата положится на стек при вызове команды .Конвенции вызова
Отличия конвенций
Заметим, что мы положили аргументы на стек, но не удалили их оттуда, поэтому при следующем вызове
будет взят неверный адрес возврата. В С++ существует несколько конвенций вызова функций, рассмотрим 2 из них: и .При использовании
аргументы удалятся из стека при выполнении команды , а при использовании после возврата из функции необходимо переместить вершину стека командой . Рассмотрим пример:void __cdecl f(int a, int b) { } void __cdecl g(int a, int b, int c) { } int main() { f(2, 3); g(1, 2, 3); return 0; }
При выполнении этого кода можно будет сэкономить на количестве команд, не удаляя аргументы
и со стека:push 3 push 2 call f push 1 call g add esp, 12 //смещает вершину стека на 12 байт
Конфликт конвенций
По умолчанию в С++ используются конвенции вызова
и , которая используется для методов класса и отличается тем, что кладет один аргумент в регистр процессора.Посмотрим, что произойдет, если искусственно создать конфликт конвенций вызова:
void __decl f(int a) { } int main() { void __stdcall (*g)(int); g = (void __stdcall (*)(int)) &f; g(239); return 0; }
Так как
задана конвенция вызова , после ее вызова вершина стека не будет сдвигаться. Однако, задана конвенция вызова , поэтому аргумент не удалится со стека при вызове , и при следующем запросе адреса возврата будет выдан адрес , скорее всего, не являющийся валидным.Размещение локальных переменных
В функциях также могут быть использованы локальные переменные, для выполнения промежуточных вычислений. Эти переменные являются временными и создаются при вызове функции. Для использования локальных переменных необходимо выделить память. Эта память также выделяется на стеке, прибавлением памяти на нем.
С помощью функции
_alloca()
в С++ можно прибавлять память на стек.
Так как стек ограничен в своем обьеме, то можно увидеть его переполнение запустив примерно следующий код:
void f() { _alloca(size); } void g() { for(;;) f(); } g();
Архив
Рассмотрим процесс выделения памяти в куче на примере простой программы:
int main() { int *a = new int [1000000]; delete [] a; return 0; }
До выполнения
int *a = new int [1000000];
куча в адресном пространстве программы занимает
, а после выполнения команды - . Значит, память была выделена именно в куче, и объем выделенной памяти равен , что почти в точности соответствует объему массива из элементов при выделении на элемент.После выполнения
delete [] a;
размер кучи снова равен
, как и до выделение памяти под массив.
TODO: Показать в адресном пространстве ран-тайм, библиотеки ядра