Выполнение программы
Содержание
Последовательное выполнение
При старте процесса создается единица выполнения кода — поток. Потоку передается точка входа — адрес нахождения первой команды программы в адресном пространстве. Можно считать, что поток передает команду и ее аргументы процессору, тот выполняет ее и сообщает результат, после этого поток переходит на следующую команду с помощью счетчика команд. Каждая команда в адресном пространстве занимает несколько байт, не обязательно постоянное число.
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: дописать
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: включить в главу про кучу
Динамическая память - это память, получающаяся из свободной памяти при выделении памяти. Слово "динамическая" здесь означает "динамически распределяемая". Динамическая память выделяется и освобождается функциями
и . Вызывая , указывая размер блока памяти и желаемый атрибут доступа (обычно: чтение-запись). Система выделяет от свободной памяти блок. Теперь в программе выделена память, и есть указатель на нее. Когда память надо освободить - вызывайте . Система переведёт память обратно в свободную. Чем плохо такое выделением памяти? Оно выделяет больше памяти, чем нужно, и происходит частое обращение к ядру. Для этого в языке c++ есть особенные функции, для работы с областью памяти - куча "heap".В стандартной библиотеке, пришедшей из языка C,
реализованы функции и , соответственно для выделения и освобождения памяти. В самом C++ есть аналогичные функции и .Для каждого
должны вызываться , т.к. память сама не освобождается при выходе из функций. Не вызвав эти функции, куча останется неосвобожденнной, и произойдут утечки памяти.
TODO: запилить картиночки, поправить корявые фразочки
Рассмотрим процесс выделения памяти в куче на примере простой программы:
int main() { int *a = new int [1000000]; delete [] a; return 0; }
До выполнения
int *a = new int [1000000];
куча в адресном пространстве программы занимает
, а после выполнения команды - . Значит, память была выделена именно в куче, и объем выделенной памяти равен , что почти в точности соответствует объему массива из элементов при выделении на элемент.После выполнения
delete [] a;
размер кучи снова равен
, как и до выделение памяти под массив.
TODO: Показать в адресном пространстве ран-тайм, библиотеки ядра
Проблема, возникающая при использовании многих библиотек
TODO: насколько я помню, это позиционировалось не столько как проблема, больше - просто фича. Поправить
TODO: запилить картиночки, поправить корявые фразочки
Для начала рассмотрим пример программы, где используются две библиотеки: пользовательская (
) и стандартная ( ). В случае, когда библиотека использует для работы с кучей библиотеку , никаких проблем не возникнет, т.к. при работе программы куча будет одна и функции для работы с ней будут одинаковые, что в пользовательской библиотеке, что в нашей программе.Теперь рассмотрим другую ситуацию, когда в программе используется всё та же
, но в пусть используется другая стандартна библиотека для работы с кучей: . Здесь может случится неприятная вещь: если мы будем пытаться освободить память из кучи, используя средства пользовательской библиотеки, у нас произойдет ошибка, т.к. кучи для библиотеки и программы будут разные и функция может просто не понять, от куда ей надо освобождать, потому что в библиотеке она использовала из , а при вызове из программы мы обратимся к .Согласитесь, не очень приятная ошибка. Для борьбы с ней есть верный способ --- использовать статическое объявление функций из стандартных библиотек: мы "выдёргиваем" только те функции, которые нас интересуют и объявляем их через
. У нас будет явное обращение к ним и тогда не произойдет ошибки, возникающей во втором примере.