Раздел 18 — Виртуальная память, кучи, DLL

Откуда берётся память программы и как код одной программы попадает в другую

Прогресс курса Раздел 18 из 20

Что вы освоите в этом разделе

7 академических часов теории, практической работы нет. В конце — тест по разделу на портале: пока он не сдан на 70 баллов, раздел 19 закрыт. Программы раздела работают в Windows, кроме newdelete.cpp — она переносимая.
01

Виртуальное адресное пространство

Адреса, которые программа видит (раздел 9), — виртуальные. У каждого процесса своё адресное пространство: в 64-разрядной Windows программе доступны 128 ТБ адресов — во много раз больше, чем оперативной памяти в компьютере. Процессор с помощью таблиц, которые ведёт ОС, переводит каждый виртуальный адрес в физический — адрес ячейки в микросхеме памяти.

Переводится не каждый байт отдельно, а страницами — кусками по 4 КБ. Страница виртуальной памяти может быть связана со страницей оперативной памяти, лежать в файле подкачки на диске (тогда при обращении система загрузит её обратно — это медленно) или не быть связанной ни с чем.

// Фрагмент meminfo.cpp. Полностью — в примерах раздела
    SYSTEM_INFO system;
    GetSystemInfo(&system);
    std::cout << "Page size: " << system.dwPageSize << " bytes\n";
    std::cout << "Allocation granularity: " << system.dwAllocationGranularity << " bytes\n";
    std::cout << "Application addresses: " << system.lpMinimumApplicationAddress
              << " .. " << system.lpMaximumApplicationAddress << '\n';

    MEMORYSTATUSEX memory;
    memory.dwLength = sizeof(memory);           // размер структуры — до вызова, как в разделе 14
    if (GlobalMemoryStatusEx(&memory)) {
        std::cout << "Physical memory: " << memory.ullTotalPhys / MB << " MB, free "
                  << memory.ullAvailPhys / MB << " MB (" << memory.dwMemoryLoad << "% used)\n";
    }
Page size: 4096 bytes
Allocation granularity: 65536 bytes
Application addresses: 0x10000 .. 0x7ffffffeffff
Processors: 8
Physical memory: 16254 MB, free 7891 MB (51% used)
Virtual address space: 134217727 MB, free 134213210 MB

Что это даёт: изоляцию — процесс не может обратиться к чужой памяти, у него просто нет таких адресов; больше памяти, чем есть — редко используемые страницы уходят на диск; защиту — у каждой страницы свои права (пункт 02). Числа памяти у вас будут свои. «Гранулярность выделения» 64 КБ — области адресов резервируются кратно ей.

02

Состояния и защита страниц

СостояниеСмыслОбращение
свободна MEM_FREEадреса никем не занятынарушение доступа
зарезервирована MEM_RESERVEдиапазон адресов занят за программой, но памяти за ним нетнарушение доступа
передана MEM_COMMITза страницей закреплена память (в ОЗУ или в файле подкачки)по правам защиты
ЗащитаЧто можноГде встречается
PAGE_READWRITEчитать и писатьданные, стек, кучи
PAGE_READONLYтолько читатьконстанты программы, строковые литералы
PAGE_EXECUTE_READвыполнять и читатькод программы и DLL
PAGE_NOACCESSничегозарезервированная область

Нарушение прав — запись в страницу только для чтения, выполнение данных, обращение к свободной странице — процессор останавливает, а Windows завершает программу с ошибкой Access violation (код 0xC0000005). Это та самая аварийная остановка из разделов 8 и 9 при выходе за границы и разыменовании nullptr: адрес 0 и вокруг него всегда свободны именно для того, чтобы ошибка с nullptr проявилась сразу. Защита от исполнения данных (DEP) мешает атакам через переполнение буфера: даже если злоумышленник записал код в массив, выполнить его нельзя.

03

VirtualAlloc и VirtualFree

Выделение памяти в два шага: зарезервировать диапазон адресов и передать память тем страницам, которые действительно нужны. Так можно занять непрерывную область под большую таблицу, а память брать по мере заполнения:

// Фрагмент vmem.cpp. Полностью — в примерах раздела
    char* base = static_cast<char*>(VirtualAlloc(nullptr, RESERVE_SIZE, MEM_RESERVE, PAGE_NOACCESS));
    if (base == nullptr) {
        std::cout << "VirtualAlloc(reserve) failed, error " << GetLastError() << '\n';
        return 2;
    }
    printRegion("reserved", base);

    // передать память двум страницам в середине области — только они получают физическую память
    char* pages = base + 512 * page;
    if (VirtualAlloc(pages, 2 * page, MEM_COMMIT, PAGE_READWRITE) == nullptr) {
        std::cout << "VirtualAlloc(commit) failed, error " << GetLastError() << '\n';
        return 2;
    }
    printRegion("committed", pages);
    pages[0] = 'A';                             // писать можно: страница передана и доступна для записи
    pages[page + 10] = 'B';                     // вторая страница

    DWORD oldProtect = 0;
    VirtualProtect(pages, page, PAGE_READONLY, &oldProtect);   // первая страница — только чтение
    printRegion("protected", pages);

    VirtualFree(pages, 2 * page, MEM_DECOMMIT);  // вернуть физическую память, адреса остаются занятыми
    printRegion("decommitted", pages);
    VirtualFree(base, 0, MEM_RELEASE);           // освободить всю область; размер — 0
    printRegion("released", base);
reserved: 0x1e6e2f60000 reserved, -, region 1048576 KB
committed: 0x1e6e3160000 committed, read-write, region 8 KB
written: AB, new page reads as 0
protected: 0x1e6e3160000 committed, read-only, region 4 KB
next page: 0x1e6e3161000 committed, read-write, region 4 KB
decommitted: 0x1e6e3160000 reserved, -, region 1046528 KB
released: 0x1e6e2f60000 free, -, region …
04

Кучи

Куча — менеджер мелких блоков: она берёт у VirtualAlloc страницы и нарезает из них блоки любого размера, помня, какие заняты. У каждого процесса есть куча по умолчанию; можно создать и свою:

// Кучи: куча процесса и своя куча; блоки произвольного размера поверх страниц виртуальной памяти
#include <windows.h>
#include <iostream>

int main()
{
    HANDLE processHeap = GetProcessHeap();      // есть у каждого процесса; закрывать не нужно
    int* numbers = static_cast<int*>(HeapAlloc(processHeap, HEAP_ZERO_MEMORY, 10 * sizeof(int)));
    if (numbers == nullptr) {
        std::cout << "HeapAlloc failed\n";
        return 2;
    }
    numbers[9] = 99;
    std::cout << "process heap block: " << HeapSize(processHeap, 0, numbers) << " bytes, numbers[0] = "
              << numbers[0] << ", numbers[9] = " << numbers[9] << '\n';
    HeapFree(processHeap, 0, numbers);

    HANDLE heap = HeapCreate(0, 0, 0);          // своя куча, растёт по мере надобности
    if (heap == nullptr) {
        std::cout << "HeapCreate failed, error " << GetLastError() << '\n';
        return 2;
    }
    constexpr int BLOCKS = 1000;
    char* blocks[BLOCKS];
    for (int i = 0; i < BLOCKS; ++i) {
        blocks[i] = static_cast<char*>(HeapAlloc(heap, 0, 100));
    }
    std::cout << "1000 blocks of 100 bytes: first " << static_cast<void*>(blocks[0])
              << ", second " << static_cast<void*>(blocks[1]) << '\n';
    HeapDestroy(heap);                          // все блоки кучи освобождаются разом
    return 0;
}
process heap block: 40 bytes, numbers[0] = 0, numbers[9] = 99
1000 blocks of 100 bytes: first 0x2a3b1f40860, second 0x2a3b1f408d0

Соседние блоки отстоят на 112 байт, а не на 100: у кучи есть служебные данные для каждого блока. Своя куча удобна, когда много объектов живут и умирают вместе: HeapDestroy освобождает всё сразу, и утечек отдельных блоков не бывает. (void*) при выводе нужен, чтобы std::cout показал адрес, а не строку (раздел 10, пункт 05).

05

new и delete

В C++ память в куче выделяет оператор new: библиотека C++ берёт её у кучи процесса. Это решает давнюю проблему раздела 8 — массив, размер которого известен только во время работы:

// new и delete: массив, размер которого известен только при выполнении
#include <iostream>

int main()
{
    int n;
    std::cout << "How many numbers: ";
    std::cin >> n;
    if (std::cin.fail() || n < 1 || n > 100000000) {
        std::cout << "Error: n must be 1..100000000\n";
        return 1;
    }
    long long* squares = new long long[n];      // n · 8 байт в куче — не в стеке
    for (int i = 0; i < n; ++i) {
        squares[i] = static_cast<long long>(i) * i;
    }
    long long sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += squares[i];
    }
    std::cout << "sum of squares 0.." << n - 1 << " = " << sum << '\n';
    delete[] squares;                           // массив — delete[], одиночный объект — delete
    squares = nullptr;                          // чтобы по старому адресу больше не обратиться

    int* one = new int(42);
    std::cout << "one = " << *one << '\n';
    delete one;
    return 0;
}
How many numbers: 5
sum of squares 0..4 = 30
one = 42
ОшибкаЧто происходит
утечка: нет deleteблок занят до конца процесса, адрес потерян; в долго работающей программе память растёт
висячий указатель: обращение после deleteнеопределённое поведение: блок уже отдан другим
двойной deleteпорча кучи, аварийное завершение, иногда позже и в другом месте
delete вместо delete[]неопределённое поведение; g++ предупреждает -Wmismatched-new-delete

В стеке (раздел 7) память освобождается сама — при выходе из блока; в куче — только явно. Поэтому в современном C++ голые new/delete пишут редко: динамический массив — std::vector<long long> squares(n);, он освобождает память в своём деструкторе (раздел 12, пункт 02). В курсе new нужен, чтобы понимать, что происходит под std::vector, и для данных потока, которые переживают функцию, их создавшую (практическая работа №18, задание 3). Виртуальный деструктор (раздел 12, пункт 09) нужен именно тогда, когда объект удаляют delete через указатель на базовый класс.

06

Заголовочные файлы: программа из нескольких файлов

До сих пор программа была одним файлом .cpp. Функции, которые нужны нескольким программам, выносят в отдельный файл .cpp, а их прототипы — в заголовочный файл .h. Программа подключает заголовок директивой #include "имя.h" (кавычки — искать рядом с исходником, угловые скобки — в каталогах компилятора) и получает прототипы — как те, что вы писали перед main.

ФайлЧто в нём
mathlib.h#pragma once (подключать один раз, даже если #include встретился дважды), константы, структуры, прототипы
mathlib.cpp#include "mathlib.h" и определения функций
main.cpp#include "mathlib.h" и main, который вызывает функции

Сборка: g++ -std=c++17 -Wall -Wextra main.cpp mathlib.cpp -o app.exe — компилятор переводит каждый .cpp отдельно, а компоновщик соединяет их. Если функция объявлена, но нигде не определена, ошибку выдаст компоновщик: undefined reference to 'gcd' (раздел 7). В заголовке — только объявления: определение функции в .h, подключённом в два .cpp, даст multiple definition.

07

DLL: сборка и экспорт

Динамическая библиотека (DLL, Dynamic-Link Library) — файл с кодом, который загружается в процесс во время работы. Все функции Windows API живут в DLL: CreateProcessA — в kernel32.dll, WriteConsoleOutputA — там же, функции сокетов — в ws2_32.dll. Зачем DLL: один код для многих программ (одна копия на диске и в памяти), обновление без пересборки программ, модули, которые программа загружает по желанию (плагины).

// Функции библиотеки mathlib.dll: объявления для программ, которые её используют
#pragma once

#ifdef MATHLIB_BUILD
#define MATHLIB_API extern "C" __declspec(dllexport)   // при сборке DLL — экспорт
#else
#define MATHLIB_API extern "C" __declspec(dllimport)   // при сборке программы — импорт
#endif

MATHLIB_API int gcd(int a, int b);
MATHLIB_API double average(const int a[], int n);
MATHLIB_API int callCount();
g++ -std=c++17 -Wall -Wextra -DMATHLIB_BUILD -shared mathlib.cpp -o mathlib.dll -Wl,--out-implib,libmathlib.a

-shared — собрать DLL, а не программу. --out-implib — заодно создать библиотеку импорта libmathlib.a: маленький файл со списком экспортируемых функций, нужный для неявного связывания. Файл mathlib.cpp — в примерах раздела; данные DLL (счётчик вызовов) у каждого процесса, загрузившего её, свои: код DLL общий, а данные копируются.

08

Неявное связывание, DllMain, поиск DLL

// Неявное связывание: DLL загружается при запуске программы. Сборка: g++ implicit.cpp -L. -lmathlib -o implicit.exe
#include <iostream>
#include "mathlib.h"

int main()
{
    std::cout << "main started\n";
    const int a[] = {3, 8, 4};
    std::cout << "gcd(84, 36) = " << gcd(84, 36) << '\n';
    std::cout << "average = " << average(a, 3) << '\n';
    std::cout << "calls: " << callCount() << '\n';
    return 0;
}
[mathlib loaded]
main started
gcd(84, 36) = 12
average = 5
calls: 2
[mathlib unloaded]

При неявном связывании программа вызывает функции DLL, как свои; -lmathlib добавляет в неё список нужных DLL. Система загружает эти DLL до main — поэтому «loaded» выводится раньше «main started». Нет DLL — программа не запускается вовсе, с сообщением системы.

DllMain — необязательная функция DLL, которую система вызывает с причиной: DLL_PROCESS_ATTACH — загрузка в процесс, DLL_PROCESS_DETACH — выгрузка, DLL_THREAD_ATTACH/DETACH — создание и завершение потоков. В ней делают только самое простое: система в это время держит блокировку загрузчика, и загрузка других DLL, ожидание потоков, даже std::cout могут привести к зависанию. Поэтому mathlib пишет сообщение напрямую функцией WriteFile.

Где система ищет DLL, заданную только именем (безопасный порядок по умолчанию):

  1. каталог, откуда загружена программа;
  2. системный каталог C:\Windows\System32;
  3. каталог Windows;
  4. текущий каталог;
  5. каталоги из переменной PATH.
Подмена DLL. Если злоумышленник положит DLL с тем же именем в каталог, который просматривается раньше настоящего, программа загрузит его код с правами пользователя. Поэтому программы устанавливают в каталоги, куда пользователь не может писать (Program Files), а DLL, которые загружают явно, указывают полным путём. Для программ MinGW-w64 есть и практическая ловушка: им нужны DLL самого компилятора (libstdc++-6.dll и другие) — вне консоли MSYS2 программа может не найти их. Ключ -static включает библиотеки компилятора в сам .exe.
09

Явная загрузка и указатель на функцию

Программа может загрузить DLL сама, когда нужно, и выбрать её по имени во время работы — так устроены плагины. Функции тогда вызываются по адресу:

// Явная загрузка: LoadLibraryA, GetProcAddress, FreeLibrary; функция вызывается через указатель.
// Сборка без библиотеки импорта: g++ dynamic.cpp -o dynamic.exe
#include <windows.h>
#include <iostream>

using GcdFunction = int (*)(int, int);          // тип «указатель на функцию (int, int) → int»

int main(int argc, char* argv[])
{
    const char* name = argc > 1 ? argv[1] : "mathlib.dll";
    std::cout << "loading " << name << '\n';
    HMODULE library = LoadLibraryA(name);
    if (library == nullptr) {
        std::cout << "LoadLibrary failed, error " << GetLastError() << '\n';    // 126 — DLL не найдена
        return 2;
    }
    FARPROC address = GetProcAddress(library, "gcd");                          // имя — как экспортировано
    if (address == nullptr) {
        std::cout << "GetProcAddress failed, error " << GetLastError() << '\n'; // 127 — функции нет
        FreeLibrary(library);
        return 2;
    }
    // FARPROC — «адрес любой функции»; приведение к нужному типу — через void*
    const GcdFunction gcd = reinterpret_cast<GcdFunction>(reinterpret_cast<void*>(address));
    std::cout << "gcd(84, 36) = " << gcd(84, 36) << '\n';
    FreeLibrary(library);                       // после этого вызывать gcd нельзя: кода по адресу больше нет
    std::cout << "done\n";
    return 0;
}
> dynamic
loading mathlib.dll
[mathlib loaded]
gcd(84, 36) = 12
[mathlib unloaded]
done
> dynamic nosuch.dll
loading nosuch.dll
LoadLibrary failed, error 126
10

Ловушки раздела

ОшибкаПризнакСообщение g++ / ошибка WindowsКак избежать
запись в зарезервированную, но не переданную памятьаварийное завершениеAccess violationMEM_COMMIT перед использованием
MEM_RELEASE не с тем адресом или ненулевым размеромпамять не освобождается87 — неверный параметрадрес от резервирования, размер 0
нет delete / HeapFreeпамять процесса растёт—пара для каждого выделения; std::vector
delete для массиванеопределённое поведение-Wmismatched-new-deletedelete[] для new[]
обращение после delete, двойной deleteпорча кучи, падение позже—после delete — p = nullptr
нет extern "C" у экспортаGetProcAddress не находит функцию127extern "C" __declspec(dllexport)
DLL не рядом с программойпрограмма не запускается / LoadLibrary = NULL126DLL в каталоге программы
прямое приведение FARPROCпредупреждениеcast between incompatible function typesчерез void*, тип строго по заголовку
вызов после FreeLibraryаварийное завершениеAccess violationвыгружать, когда функции больше не нужны
тяжёлая работа в DllMainзависание при загрузке—в DllMain — только простое; остальное — в функции инициализации

Ключевые выводы раздела

Запомните главное

  • Адреса программы виртуальные; память — страницами по 4 КБ; у каждого процесса своё адресное пространство
  • Страница свободна, зарезервирована или передана; у переданной — права; нарушение — Access violation
  • VirtualAlloc: сначала адреса (MEM_RESERVE), потом память (MEM_COMMIT); VirtualFree — наоборот
  • Куча нарезает страницы на блоки; new/delete работают через кучу; каждому выделению — освобождение
  • Прототипы — в заголовке .h, определения — в .cpp; сборка нескольких файлов одной командой
  • DLL: extern "C" __declspec(dllexport), -shared; неявно — -l и загрузка до main; явно — LoadLibraryA, GetProcAddress, указатель на функцию, FreeLibrary
  • DLL ищется сначала рядом с программой; в DllMain — только простое
Связи раздела. Опирается на раздел 7 — стек, перегрузка и искажение имён, ошибки компоновщика; раздел 8 — размер массива как константа; раздел 9 — указатели и нарушение доступа; раздел 12 — деструктор и виртуальный деструктор; разделы 14–17 — объекты ядра, потоки. Нужен для: раздела 19 — функции сокетов живут в ws2_32.dll, программа связывается с ней -lws2_32; итогового проекта — модули в DLL.
Тест по разделу — на учебном портале. Тест, попытки и оценки — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 17: Каналы и обмен данными Тест на портале