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