Компилятор выдал исполняемый файл. Дальше за дело берётся операционная система, и полезно представлять, что именно она делает: почти все загадочные падения C++-программ объясняются тем, где лежат данные и как долго они там живут.
Одна память на всё
В основе почти всех современных машин – архитектура фон Неймана: инструкции и данные хранятся в одной и той же памяти и адресуются одинаково. Процессор держит указатель на текущую инструкцию, читает её, выполняет, сдвигает указатель. Всё.
Из этого следует главное свойство C++: указатель – это число. Не «ссылка на объект», не дескриптор, а адрес ячейки. Язык знает, какого типа объект должен лежать по этому адресу, но проверять это во время выполнения никто не будет.
Отсюда же растут и переполнения буфера: если запись вышла за границы массива на стеке, она может затереть адрес возврата, и процессор после return уйдёт исполнять то, что ему подсунули. Ни компилятор, ни процессор здесь ничего не нарушают – они делают ровно то, что написано.
Как программа лежит в памяти
Запущенный процесс видит своё адресное пространство разбитым на области с разным назначением и разными правами.
Сегмент кода – машинные инструкции, прочитанные из исполняемого файла. Помечен как исполняемый и доступный только для чтения. Сюда же обычно кладут строковые литералы и другие константы.
Статические данные – глобальные переменные и всё, помеченное static. Место под них
известно на этапе компиляции, инициализируются они до входа в main, живут до выхода из
программы.
Стек – область для локальных переменных и служебных данных вызовов. Размер ограничен (типично 1–8 МБ) и задаётся при запуске потока.
Куча – всё, что выделяется через new. Растёт по запросу, ограничена доступной памятью
машины.
int g_total = 0; // статические данные
void Handle() {
int local = 0; // стек
static int calls = 0; // статические данные, хотя объявлена внутри функции
int* buffer = new int[1000]; // указатель на стеке, тысяча int – в куче
delete[] buffer;
}
Обратите внимание на buffer: сама переменная-указатель лежит на стеке и занимает 8 байт, а
массив, на который она смотрит, – в куче. Это разные вещи с разным временем жизни, и путаница
между ними – источник большинства ошибок с памятью.
Стек против кучи
| Стек | Куча | |
|---|---|---|
| Кто освобождает | компилятор, на выходе из блока | вы, вызовом delete |
| Стоимость выделения | сдвиг регистра, наносекунды | поиск подходящего блока |
| Размер | фиксированный лимит потока | ограничена памятью машины |
| Размер объекта | должен быть известен на компиляции | любой, вычисленный во время работы |
| Типичная ошибка | переполнение стека | утечка и висячий указатель |
Правило простое: пока объект помещается на стек и не должен пережить функцию – держите его на стеке. Куча нужна там, где размер выясняется во время работы или объект должен жить дольше породившей его функции.
Что делает загрузчик
Запуск программы – это не «процессор начал читать файл с начала». Загрузчик операционной системы:
- Отображает секции исполняемого файла в адресное пространство, расставляя права (код – исполнять, данные – читать и писать, константы – только читать).
- Подгружает динамические библиотеки, которые нужны программе, и досвязывает вызовы к ним – ту самую работу компоновщика, только уже во время запуска.
- Инициализирует статические данные и вызывает конструкторы глобальных объектов.
- Передаёт управление на
main.
Пункт 3 – источник неприятной проблемы, известной как «фиаско порядка статической инициализации»: порядок инициализации глобальных объектов из разных единиц трансляции стандартом не определён. Если один глобальный объект в конструкторе трогает другой, программа работает или падает в зависимости от порядка файлов в командной строке компоновщика. Лечится тем, что глобальных объектов со сложными конструкторами просто не заводят.
Зачем это знать
Дальше в модуле каждая тема – это подробный разбор одной из этих областей: стек вызовов, указатели в них, ссылки, куча. Общая картина нужна, чтобы читать симптомы:
- Segmentation fault – обращение по адресу, который процессу не принадлежит или недоступен на запись. Почти всегда это разыменование нулевого, неинициализированного или уже освобождённого указателя.
- Stack overflow – кадры вызовов заполнили отведённую стеку область. Обычно бесконечная рекурсия, реже – огромный массив на стеке.
- Программа съедает память – кучу выделяют и не освобождают.
- Программа падает не там, где ошибка – запись за границы массива испортила соседние
данные, а обнаружилось это позже. Самый неприятный класс, и именно поэтому в C++ так ценят
инструменты вроде санитайзеров и
valgrind.