Лекция 5. Работа с памятью
Процессы и потоки, виртуальная память, сегменты программы, стек вызовов и кадры, время жизни объектов, куча с malloc, free, new и delete, placement new, типичные ошибки памяти и санитайзеры.
Программа работает с памятью постоянно: хранит переменные, вызывает функции, создаёт объекты. На этой лекции смотрим, как устроена память с точки зрения программы и операционной системы, откуда берутся стек и куча, и почему ошибка с памятью так коварна. Главное правило: отсутствие падения не означает, что программа написана правильно.
Во всех примерах полезно представлять три вещи: где лежит объект, сколько он живёт и кто за него отвечает. Ответы на эти три вопроса почти всегда подсказывают, какая ошибка может случиться.
1. Фон: процессор и память
Типичная вычислительная система по схеме фон Неймана хранит команды и данные в одной памяти, а процессор берёт команды оттуда и выполняет их. На практике памяти несколько уровней, и они различаются объёмом и скоростью.
| Уровень | Примерный объём | Где находится |
|---|---|---|
| Регистры | сотни байт — единицы килобайт | внутри ядра процессора |
| Кеш L1 | десятки — сотни килобайт | на ядро, команды и данные отдельно |
| Кеш L2 | сотни килобайт — единицы мегабайт | на ядро или группу ядер |
| Кеш L3, если есть | единицы — сотни мегабайт | общий для процессора |
| Оперативная память (RAM) | единицы — сотни гигабайт | основная память компьютера |
| SSD | сотни гигабайт — единицы терабайт | постоянное хранилище |
Чем ближе уровень к ядру, тем он меньше и быстрее. Программа этого не видит напрямую, но её скорость сильно зависит от того, как она обращается к данным.
2. Процессы и потоки
Процесс — запущенная программа с собственным адресным пространством и системными ресурсами: файловыми дескрипторами, объектами синхронизации и т. д. Процессы изолированы друг от друга.
Поток живёт внутри процесса. Потоки одного процесса разделяют адресное пространство: код, глобальные данные и динамическую память. У каждого потока есть свой стек и свой контекст выполнения — регистры и текущая позиция. При переключении ОС сохраняет и восстанавливает контекст.
Важно: стек потока не защищён от других потоков. Если у одного потока есть указатель на переменную в стеке другого, он может её читать и менять.
3. Виртуальная память
Программа работает с виртуальными адресами. Операционная система и процессор вместе переводят их в физические адреса через таблицы страниц (page tables). Благодаря этому у каждого процесса создаётся иллюзия, что вся память принадлежит ему одному.
Из этого следуют несколько фактов:
- один и тот же виртуальный адрес в двух процессах может указывать на разные страницы RAM;
- общая память и файлы могут отображаться сразу в несколько процессов;
- размер адресного пространства не равен объёму RAM: часть адресов не отображена или защищена, и выделение памяти может не удаться.
Page fault — исключение при обращении к странице, которая сейчас недоступна. Операционная система выясняет причину. Если страницу выгрузили на диск, её вернут в RAM. Если она связана с файлом, загрузят нужную часть. При ленивом выделении память предоставят при первом обращении. Только при недопустимом доступе, когда нужного отображения или прав нет, процесс получит сигнал SIGSEGV.
4. Сегменты программы
Адресное пространство процесса делится на области. Конкретная раскладка зависит от операционной системы, формата файла и архитектуры: ELF в Linux, Mach-O в macOS, PE в Windows.
Типичная схема сверху вниз, как на картинке с адресами:
- Kernel — область ядра ОС, недоступная программе;
- Stack — стек потока;
- File and library mappings — отображения библиотек, файлов и анонимная память;
- Heap — классическая область динамической памяти;
- bss — глобальные данные с начальным нулём;
- data — глобальные данные с заданными значениями;
- rodata — данные только для чтения, включая строковые литералы;
- text — машинный код.
Между областями могут оставаться промежутки, которые не отображены. Стеков и областей динамической памяти может быть несколько: аллокатор иногда использует отдельные отображения, а не только heap.
Секции файла (text, rodata, data, bss) и области адресного пространства — разные уровни описания. Секции — это часть файла программы, области — это то, как ОС отображает их в память.
Типичное размещение объектов в Linux:
| Объект | Пример | Область | Права |
|---|---|---|---|
| Машинный код | тело функции | text | чтение и исполнение |
| Строковые литералы | "Hello world" | rodata | только чтение |
| Глобальные данные с ненулевым значением | int counter = 42; | data | чтение и запись |
| Глобальные данные с нулевым начальным значением | int total; | bss | чтение и запись |
| Динамический объект | new int{42} | heap или анонимное отображение | чтение и запись |
| Локальный объект, если он в памяти | int local; | stack | чтение и запись |
Права задаются страницам памяти, а секции одного файла могут попадать в один сегмент.
4.1. Адреса на практике
Адреса объектов можно вывести программой:
#include <iostream>
int global_value = 42;
int main() {
int local = 1;
int* dynamic = new int{2};
std::cout << "function: " << reinterpret_cast<void*>(&main) << '\n';
std::cout << "global: " << &global_value << '\n';
std::cout << "local: " << &local << '\n';
std::cout << "dynamic: " << dynamic << '\n';
delete dynamic;
return 0;
}Чтобы увидеть карту памяти запущенного процесса, запустите программу с паузой (например, ожидание Enter) и посмотрите её PID в другом терминале:
| ОС | Команда |
|---|---|
| Linux | cat /proc/PID/maps |
| macOS | vmmap -interleaved PID |
| Windows | vmmap.exe -p PID (утилита Sysinternals VMMap) |
Сопоставьте адреса с диапазонами: функция попадает в область кода, глобальные переменные — в данные, динамический объект — в heap, локальная переменная — в стек. Адреса и PID при каждом запуске меняются, это нормально.
5. Стек вызовов
Каждый вызов функции получает кадр стека (stack frame). В нём хранятся локальные переменные и временные значения, стековые аргументы, адрес возврата и служебные данные. Размер кадра зависит от функции, выравнивания, платформы и оптимизаций.
Кадр живёт, пока выполняется вызов. При возврате память освобождается и может быть использована следующим вызовом. Отсюда и время жизни локальных объектов: до выхода из блока.
Как передаются аргументы и результат, определяет соглашение о вызовах (calling convention), а точнее ABI платформы:
- на 32-битном x86 в MSVC встречаются
cdecl,stdcallиfastcall, которые различаются тем, кто очищает стек и как передаются аргументы; - на x86-64 используются соглашения System V AMD64 (Linux, macOS) и Microsoft x64 (Windows), где первые аргументы передаются через регистры.
Порядок передачи аргументов не совпадает с порядком вычисления выражений, которые их задают.
5.1. Регистры x86-64
Регистр — небольшое хранилище внутри процессора. Имена R… обозначают 64-битный регистр, E… — его младшие 32 бита. Например, EAX — часть RAX.
| Регистр | Название | Роль при вызове add(40, 2) по System V |
|---|---|---|
RDI / EDI | Destination Index | первый аргумент |
RSI / ESI | Source Index | второй аргумент |
RAX / EAX | Accumulator | результат int |
RSP | Stack Pointer | вершина стека |
RBP | Base Pointer | опорный адрес кадра |
RIP | Instruction Pointer | адрес следующей инструкции |
Названия исторические, а назначение зависит от инструкции и ABI. Здесь первые два целочисленных аргумента передаются через EDI и ESI, а int возвращается через EAX.
6. Длительность хранения
Storage duration описывает, сколько живёт память для объекта:
| Вид | Пример | Как долго существует память |
|---|---|---|
| Automatic | обычная локальная переменная | до выхода из блока |
| Static | глобальная переменная или локальная static | всё время работы программы |
| Thread | thread_local | всё время работы потока |
| Dynamic | объект, созданный через new | до освобождения памяти |
Это категории языка C++, а стек и куча — способы их реализовать.
static у локальной переменной означает, что она инициализируется один раз и сохраняет значение между вызовами функции. Её имя при этом видно только внутри блока, где она объявлена:
int Counter() {
static int calls = 0;
++calls;
return calls;
}7. Куча
Динамический объект:
- может пережить функцию, которая его создала;
- имеет размер, который можно выбрать во время выполнения;
- освобождается программой явно.
Даже один int может требовать динамического времени жизни, если его нужно вернуть из функции и использовать позже.
Куча как область динамической памяти и куча как структура данных (двоичная куча для очереди с приоритетом) — разные понятия. Совпадение названий не означает, что динамическая память устроена как такая структура.
Пример: функция создаёт объект и возвращает адрес, а вызывающий код отвечает за освобождение:
int* MakeValue(int value) {
int* pointer = new int{value};
return pointer; // адрес переживает функцию, сам объект тоже
}
int main() {
int* value = MakeValue(42);
std::cout << *value << '\n';
delete value;
return 0;
}Локальная переменная-указатель pointer исчезает при возврате, но объект, созданный через new, остаётся. Функция возвращает копию адреса, а освобождает память тот, кто её получил.
8. Функции malloc, calloc, realloc, free
В языке C и в заголовке <cstdlib> есть набор функций для работы с динамической памятью:
| Функция | Что делает |
|---|---|
malloc(size) | выделяет size байт без инициализации |
calloc(count, size) | выделяет count × size байт и обнуляет их |
realloc(pointer, size) | меняет размер блока, может переместить его |
free(pointer) | освобождает блок; free(nullptr) ничего не делает |
Если выделение не удалось, malloc возвращает nullptr. При неудачном realloc старый блок остаётся выделенным, поэтому его указатель нельзя терять: присваивание результата realloc напрямую в ту же переменную приводит к утечке при сбое.
Пример выделения массива:
#include <cstdlib>
int* values = static_cast<int*>(std::malloc(5 * sizeof(int)));
if (values == nullptr) {
return 1;
}
for (int index = 0; index < 5; ++index) {
values[index] = index * 10;
}
std::free(values);
values = nullptr;Несколько правил:
- в C++ результат
mallocнужно привести изvoid*; - память освобождается через
freeровно один раз; - после
freeуказатель нельзя разыменовывать; freeне обнуляет переменную, поэтому присваиваниеnullptrделаем явно, и оно не влияет на другие копии указателя.
calloc удобен, когда нужен массив с нулями: он получает количество элементов и размер одного элемента, а затем обнуляет всю память.
9. new и delete
В C++ динамическая память чаще создаётся через new:
int* first = new int; // без начального значения
int* second = new int{}; // значение 0
int* third = new int{42}; // значение 42
delete first;
delete second;
delete third;new intоставляет значение неопределённым: до чтения нужна запись;new int{}задаёт ноль,new int{42}— конкретное значение;deleteосвобождает одиночный объект, а для массивов используютnew T[n]иdelete[];- при неудаче обычный
newбросает исключениеstd::bad_alloc.
Способ освобождения определяется способом выделения. Таблица пар:
| Выделение | Освобождение |
|---|---|
malloc, calloc, realloc | free |
new T | delete |
new T[n] | delete[] |
Смешивание пар — неопределённое поведение. malloc подходит, когда нужен просто блок памяти без конструкторов. new T выделяет память и создаёт объект типа T. Если вы работаете с C API, способ освобождения задаёт контракт библиотеки.
В обычном C++ вручную управлять памятью принято редко: контейнеры и средства автоматического владения памятью избавляют от большинства new и delete. Здесь важно понимать механику.
10. Placement new
Иногда память нужно выделить заранее, а объекты создавать в готовых слотах. Для этого используют placement new: конструирование объекта по заданному адресу.
#include <new>
struct Point {
int x;
int y;
};
alignas(Point) unsigned char storage[2 * sizeof(Point)];
Point* first = new (storage) Point{10, 20};
Point* second = new (storage + sizeof(Point)) Point{30, 40};Здесь важно несколько вещей:
- буфер вмещает две точки,
alignas(Point)обеспечивает выравнивание; - второй слот начинается через
sizeof(Point)байт после первого; - буфер должен жить всё время использования точек;
delete firstиdelete secondнедопустимы: память принадлежит буферу, а неnew.
У структуры только поля int без деструктора, поэтому повторное использование слота не требует отдельного вызова деструктора. Для типов с нетривиальным деструктором порядок другой. В полноценном пуле объектов учитывают свободные слоты и проверяют, что место есть. Здесь показан только механизм.
11. Ошибки памяти
Выход за границы массива, использование уничтоженного объекта и разыменование nullptr приводят к неопределённому поведению:
- программа может упасть сразу или позже;
- может испортить данные, но внешне работать правильно;
- результат одного запуска ничего не гарантирует для следующего.
Отсутствие падения не доказывает корректность.
11.1. Segmentation fault
SIGSEGV — сигнал ОС при некоторых недопустимых обращениях: к неотображённой странице или странице без нужных прав. Операционная система проверяет страницы, но не знает границ каждого объекта C++. Выход за границы массива может остаться внутри доступной страницы и не вызвать никакого сигнала.
Значит, SIGSEGV — это признак, что ошибка есть, но его отсутствие ошибки не исключает. Не всякое неопределённое поведение приводит к SIGSEGV.
11.2. Типичные ошибки
Ниже примеры, которые намеренно ведут к неопределённому поведению. Их нельзя запускать в рабочем коде, только разбирать.
Запись в память только для чтения. Строковый литерал обычно лежит в области без права записи. Попытка записи на типичных Linux и macOS приводит к аварийному завершению. const_cast снимает ограничение типа, но не меняет права страницы. Изменяемый массив char text[] = "..." — другое дело: он хранит собственную копию символов.
Выход за границы динамического массива. Если выделено три элемента, допустимы индексы 0, 1, 2. Указатель за последним элементом можно сформировать, но читать по нему нельзя.
Использование после освобождения. После delete указатель висячий. Присваивание nullptr одной переменной не меняет другие копии указателя, которые остались в программе.
Повторное освобождение. Если две переменные хранят адрес одного блока, освободить его можно только один раз. Вторая попытка — неопределённое поведение.
Указатель на объект после выхода из блока. Байты могут ещё содержать прежнее значение, но объекта уже нет. Чтение — неопределённое поведение.
Разыменование nullptr. Указатель не указывает на объект. Возможное падение — следствие, а не определённый языком результат.
Утечка памяти. Если потерять последний указатель на выделенный блок, освободить его уже нельзя. Утечка не обязана приводить к UB или немедленному падению, но повторяющиеся утечки со временем съедают память.
Локальные массивы живут в стеке, а стек ограничен по размеру. Объявление int big[10'000'000]; внутри функции может привести к переполнению стека. Большие данные выделяйте в куче или храните в статической области.
12. Санитайзеры
Санитайзер — инструмент, который добавляет проверки во время сборки и сообщает об ошибках во время выполнения.
- AddressSanitizer (ASan) ловит выходы за границы, использование после освобождения, повторное освобождение и использование объекта после выхода из блока;
- UndefinedBehaviorSanitizer (UBSan) ловит часть видов UB, например разыменование
nullptr.
Пример сборки и запуска с ASan и UBSan на Clang:
clang++ -std=c++23 -Wall -Wextra -pedantic -O0 -g \
-fsanitize=address,undefined -fno-sanitize-recover=all \
-fsanitize-address-use-after-scope -fno-omit-frame-pointer \
use-after-free.cpp -o memory-error
./memory-errorОтчёт санитайзера содержит вид ошибки (например, heap-use-after-free), строку и стек вызовов. Для освобождённого блока он показывает, где блок был выделен и где освобождён.
Проверяется только тот путь выполнения, который реально прошёл. Успешный запуск под санитайзером не доказывает отсутствие ошибок.
Частые ошибки
- Считать, что раз программа не падает, она корректна.
- Смешивать
mallocсdeleteилиnewсfree. - Терять указатель до освобождения памяти.
- Использовать указатель после
deleteилиfree. - Большие массивы на стеке.
- Забывать, что
freeиdeleteне обнуляют указатель.
Чек-лист
- Могу назвать сегменты программы и объяснить, что лежит в стеке, а что в куче.
- Понимаю, что такое кадр стека и почему локальный объект умирает при возврате.
- Знаю пары
malloc–free,new–delete,new[]–delete[]. - Могу назвать три типичные ошибки памяти и объяснить, почему их нельзя проверить только отсутствием падения.
- Умею запустить программу с санитайзером.
Мини-тренажёр
Ответы
- Где лежит
int x = 0;внутри функции? В стеке, на время вызова. - Где живёт
int* p = new int{5};сам объект? В динамической памяти, пока его не освободят. - Что делает
delete p;дважды подряд? Вторая попытка — неопределённое поведение. - Почему
free(nullptr)безопасен? Стандарт определяет его как no-op. - Какой сигнал ОС типичен для записи в страницу только для чтения? SIGSEGV.
- Какой инструмент поймает использование после освобождения? AddressSanitizer.
Шпаргалка
| Понятие | Суть |
|---|---|
| Виртуальная память | Адреса программы переводятся в физические через страницы |
| Page fault | Обращение к недоступной странице; не всегда ошибка |
| Сегменты | text, rodata, data, bss, heap, stack, отображения |
| Кадр стека | Данные одного вызова функции, освобождается при возврате |
| Automatic | Локальная переменная, до выхода из блока |
| Static | Живёт всю программу |
malloc / free | Блок памяти без конструкторов |
new / delete | Создание и уничтожение объекта в динамической памяти |
| Placement new | Создание объекта по заданному адресу |
| SIGSEGV | Недопустимый доступ к странице; отсутствие сигнала не значит корректность |
| Санитайзер | Проверка ошибок памяти во время выполнения |
Актуальная версия: https://m3105.ru/notes/osnovy-programmirovaniya/lektsiya-5-rabota-s-pamyatyu

Комментарии0
Пока никто ничего не написал.
Войдите, чтобы оставить комментарий