Перейти к содержанию

Виртуальная память

Виртуальная память — это абстракция, которую операционная система предоставляет каждому запущенному процессу. Процесс работает с непрерывным виртуальным адресным пространством и не знает, где именно его данные находятся физически — в оперативной памяти, в файле на диске или в своп-разделе. Преобразование виртуальных адресов в физические выполняет аппаратный блок MMU (Memory Management Unit) при каждом обращении к памяти.

Зачем нужна виртуальная память

Без виртуальной памяти каждый процесс работал бы напрямую с физическими адресами RAM. Это порождает ряд проблем: ошибка в одном процессе повреждает память другого, невозможно одновременно запустить две копии одной программы по одному адресу, физическая память быстро заканчивается при запуске многих программ.

Виртуальная память решает все эти проблемы разом:

  • Изоляция процессов. Каждый процесс видит только своё адресное пространство. Обращение к чужой памяти невозможно без явного согласия ОС (разделяемая память, mmap).
  • Удобство программирования. Каждый процесс считает, что занимает память с нулевого адреса, и не заботится о других процессах.
  • Непрерывное пространство. Массив из миллиона элементов выглядит непрерывным, хотя физически его страницы могут быть разбросаны по RAM.
  • Эффективное использование RAM. ОС выгружает редко используемые страницы в своп, загружает их обратно по первому обращению, делит одну физическую страницу между несколькими процессами (разделяемые библиотеки, fork+COW).
  • Защита памяти. Разным областям назначаются разные права: код (r-x), данные (rw-), константы (r--).
  • ASLR. Рандомизация базовых адресов затрудняет атаки типа return-to-libc.

Типичная карта виртуального адресного пространства

На x86-64 пользовательскому процессу доступна «нижняя» половина 48-битного адресного пространства (0x0000_0000_0000_0000 — 0x0000_7fff_ffff_ffff). Ядро занимает «верхнюю» половину (0xffff_8000_0000_0000 — 0xffff_ffff_ffff_ffff).

Высокие адреса (0xffff…)
    ┌─────────────────────────────┐
    │   Kernel space              │  недоступно из user-space
    ├─────────────────────────────┤
    │   Stack                     │  растёт вниз ↓
    │   (локальные переменные,    │
    │    параметры функций)       │
    ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
    │                             │
    │   Gap (свободное место)     │
    │                             │
    ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
    │   Heap                      │  растёт вверх ↑
    │   (malloc, new, mmap)       │
    ├─────────────────────────────┤
    │   .bss                      │  неинициализированные глобальные переменные (нули)
    ├─────────────────────────────┤
    │   .data                     │  инициализированные глобальные переменные
    ├─────────────────────────────┤
    │   .rodata                   │  строковые литералы, константы
    ├─────────────────────────────┤
    │   .text                     │  машинный код (r-x)
    ├─────────────────────────────┤
    │   ELF-заголовки             │
Низкие адреса (0x0000…)

Карта памяти процесса: /proc/PID/maps

Файл /proc/<pid>/maps показывает все активные отображения виртуального адресного пространства процесса.

# Карта текущего процесса
cat /proc/self/maps

# Карта произвольного процесса
cat /proc/1234/maps

Пример вывода:

Address                   Perms  Offset   Dev     Inode  Pathname
55c3d3000000-55c3d302f000 r--p   00000000 08:10  123456  /usr/bin/a.out
55c3d302f000-55c3d304d000 r-xp   0002f000 08:10  123456  /usr/bin/a.out
55c3d304d000-55c3d3056000 r--p   0004d000 08:10  123456  /usr/bin/a.out
55c3d3057000-55c3d305a000 rw-p   00056000 08:10  123456  /usr/bin/a.out
55c3d305a000-55c3d307b000 rw-p   00000000 00:00  0       [heap]
7fe04e000000-7fe04e21b000 r-xp   00000000 08:10  234567  /lib/x86_64-linux-gnu/libc.so.6
7fe04e21b000-7fe04e3ab000 ---p   0021b000 08:10  234567  /lib/x86_64-linux-gnu/libc.so.6
7fe04e3ab000-7fe04e3af000 r--p   0021b000 08:10  234567  /lib/x86_64-linux-gnu/libc.so.6
7fe04e3af000-7fe04e3b1000 rw-p   0021f000 08:10  234567  /lib/x86_64-linux-gnu/libc.so.6
7fff0e5dd000-7fff0e5fe000 rw-p   00000000 00:00  0       [stack]
7fff0e5fe000-7fff0e601000 r--p   00000000 00:00  0       [vvar]
7fff0e601000-7fff0e603000 r-xp   00000000 00:00  0       [vdso]

Поле Perms задаёт права доступа:

Символ Значение
r чтение
w запись
x исполнение
p private — Copy-on-Write, изменения локальны
s shared — изменения видны другим процессам

Специальные области:

Область Описание
[heap] Динамическая память, управляемая malloc; растёт вверх
[stack] Стек главного потока; растёт вниз
[vdso] Virtual Dynamic Shared Object — код ядра в user-space для быстрых syscall
[vvar] Переменные ядра, доступные vdso (например, время)
[vsyscall] Устаревший механизм (заменён vdso), фиксированный адрес

Страничная организация памяти

Физическая и виртуальная память делятся на страницы фиксированного размера — обычно 4 КБ на x86-64. Каждая виртуальная страница независимо отображается на физическую страницу (или помечается как отсутствующая в памяти).

Фиксированный размер страницы упрощает управление: ОС работает не с отдельными байтами, а с блоками одного размера, что исключает фрагментацию на уровне управления адресным пространством.

Таблицы страниц

Отображение виртуальных адресов на физические хранится в таблицах страниц (page tables). На x86-64 используется четырёхуровневая иерархия:

Виртуальный адрес (64 бит, значимы 48):

  Биты 47–39 → индекс в PML4  (Page Map Level 4, 512 записей)
  Биты 38–30 → индекс в PDPT  (Page Directory Pointer Table)
  Биты 29–21 → индекс в PD    (Page Directory)
  Биты 20–12 → индекс в PT    (Page Table)
  Биты 11–0  → смещение внутри страницы (0–4095)

PML4[47:39] → PDPT[38:30] → PD[29:21] → PT[20:12] → физическая страница

Каждая таблица занимает ровно одну страницу (512 записей × 8 байт = 4 КБ). Вся иерархия для одного процесса хранится в оперативной памяти; корневой указатель на PML4 хранится в регистре CR3. При переключении контекста ядро записывает в CR3 указатель на таблицу нового процесса.

Запись в таблице страниц (Page Table Entry, PTE) — 64-битное значение:

Биты 51–12  Физический номер страницы (Page Frame Number)
Бит  6      D — Dirty (страница была изменена)
Бит  5      A — Accessed (к странице обращались)
Бит  2      U/S — User/Supervisor (1 = доступна user-space)
Бит  1      R/W — Read/Write (0 = только чтение, 1 = чтение+запись)
Бит  0      P — Present (1 = страница в памяти, 0 = отсутствует)

Статистику по виртуальной и физической памяти процесса можно посмотреть в /proc/<pid>/status:

cat /proc/self/status | grep -E "VmPeak|VmSize|VmRSS|VmHWM"
# VmPeak — пиковый размер виртуального адресного пространства
# VmSize — текущий размер виртуального адресного пространства
# VmRSS  — текущее количество страниц в RAM (Resident Set Size)
# VmHWM  — пиковый RSS

MMU и TLB

MMU (Memory Management Unit) — аппаратный блок процессора, который выполняет трансляцию виртуального адреса в физический при каждом обращении к памяти. Без дополнительного кэша каждая такая трансляция потребовала бы четырёх обращений к RAM (обход всей иерархии таблиц), что катастрофически медленно.

TLB (Translation Lookaside Buffer) — небольшой ассоциативный кэш внутри MMU, в котором хранятся последние трансляции «виртуальная страница → физическая страница». Благодаря принципу локальности большинство обращений к памяти попадает в TLB.

Алгоритм трансляции адреса

1. CPU выдаёт виртуальный адрес.

2. MMU проверяет TLB.
   ├─ TLB Hit → физический адрес получен (~1–4 нс, ~99% случаев).
   └─ TLB Miss → переходим к Page Walk.

3. Page Walk: обход таблиц страниц в RAM.
   PML4 → PDPT → PD → PT → физический адрес.
   (~200 нс, четыре обращения к памяти)

4. Физический адрес добавляется в TLB (вытесняя старую запись).

5. Если страница отсутствует или нарушены права — генерируется Page Fault.

Современные процессоры имеют раздельные TLB для инструкций и данных:

  • iTLB (Instruction TLB) — используется на этапе fetch конвейера.
  • dTLB (Data TLB) — используется на этапах load/store.

Раздельность позволяет параллельно выбирать инструкцию и обращаться к данным.

TLB при переключении контекста

После переключения на другой процесс все записи TLB из предыдущего пространства невалидны. Наивное решение — полная очистка TLB (TLB flush), что приводит к волне TLB-промахов сразу после переключения. Современные процессоры поддерживают ASID (Address Space ID): каждая запись в TLB помечается идентификатором процесса, что позволяет сохранять записи при переключении контекста.

Влияние локальности на TLB

Обходы структур с хорошей пространственной локальностью (массивы) порождают значительно меньше TLB-промахов, чем обходы разбросанных структур (связные списки):

#include <vector>
#include <list>

// Хорошая локальность: последовательный обход массива.
// Одна страница вмещает 1024 int — большинство обращений дают TLB Hit.
std::vector<int> vec(100'000);
for (int& x : vec) x++;

// Плохая локальность: узлы списка разбросаны по heap.
// Каждый узел может находиться на отдельной странице — много TLB Miss.
std::list<int> lst(100'000);
for (int& x : lst) x++;

Посмотреть статистику TLB можно через perf:

sudo perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses ./program

Page Fault

Page Fault — исключение процессора (вектор 14 на x86), возникающее при обращении к виртуальной странице, которую MMU не может транслировать напрямую: страница отсутствует в памяти или нарушены права доступа. Управление передаётся обработчику в ядре ОС, который решает, что делать.

Виды Page Fault

Minor (мягкий) Page Fault — страница уже в RAM, но в таблицах страниц данного процесса запись отсутствует или устарела. ОС просто обновляет PTE без каких-либо дисковых операций. Возникает, например:

  • при первом обращении к странице после fork() (COW);
  • при повторном использовании анонимной страницы, которую ядро уже подготовил;
  • при разделяемых отображениях (MAP_SHARED), которые уже загружены другим процессом.

Стоимость: единицы–десятки микросекунд.

Major (жёсткий) Page Fault — страница отсутствует в RAM и должна быть загружена с диска. Возникает при:

  • первом обращении к страницам mmap-файла;
  • обращении к странице, вытесненной в своп;
  • загрузке нового исполняемого файла или разделяемой библиотеки.

Стоимость: единицы–десятки миллисекунд (на SSD) — примерно в тысячу раз дороже Minor Fault.

Segmentation Fault (SIGSEGV) — обращение к адресу, для которого у процесса нет разрешения:

  • разыменование нулевого указателя;
  • выход за границы стека;
  • запись в страницу только для чтения;
  • обращение к неотображённому адресу.

Обработка Page Fault в ядре

1. Исключение Page Fault, CR2 содержит виртуальный адрес.
2. ОС проверяет: является ли адрес валидным для данного процесса?
   ├─ Нет → SIGSEGV → процесс убит.
   └─ Да  → продолжаем.
3. ОС определяет тип fault:
   ├─ Страница в RAM (COW, shared) → Minor Fault → обновить PTE.
   └─ Страница на диске (своп, файл) → Major Fault:
        а. Найти свободный физический фрейм (возможно, вытеснить другую страницу).
        б. Инициировать ввод-вывод.
        в. Процесс переходит в состояние ожидания, CPU отдаётся другому процессу.
        г. По завершении IO обновить PTE, разбудить процесс.
4. Процесс повторяет прерванную инструкцию — теперь успешно.

Copy-on-Write и Minor Fault

Системный вызов fork() создаёт дочерний процесс, не копируя физическую память: обе копии таблиц страниц указывают на одни и те же физические страницы, помеченные как «только чтение». При первой же записи в такую страницу возникает Minor Fault, ядро создаёт физическую копию страницы и перенаправляет PTE дочернего процесса на неё. Родительский процесс продолжает видеть исходное значение.

#include <unistd.h>
#include <stdio.h>

int data = 42;

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // Дочерний процесс: первая запись вызовет COW Minor Fault.
        data = 100;
        printf("child: data = %d\n", data);   // 100
    } else {
        wait(NULL);
        printf("parent: data = %d\n", data);  // 42 — не изменилось
    }
}

Просмотр статистики Page Fault

# В колонках MINFLT и MAJFLT — счётчики Minor и Major Fault для каждого процесса.
ps -eo pid,comm,minflt,majflt

# Подробная статистика для одного запуска:
/usr/bin/time -v ./program 2>&1 | grep -E "page fault"
# Major (requiring I/O) page faults: 3
# Minor (reclaiming a frame) page faults: 482

Системные вызовы для работы с виртуальной памятью

mmap и munmap

mmap создаёт новое отображение в адресном пространстве процесса — анонимное (память) или файловое.

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int   munmap(void *addr, size_t length);
Флаг flags Значение
MAP_PRIVATE Copy-on-Write; изменения не видны другим процессам и не пишутся в файл
MAP_SHARED Изменения видны всем, кто отобразил тот же файл
MAP_ANONYMOUS Анонимное отображение (не связано с файлом); fd = -1
MAP_FIXED Ядро обязано разместить отображение по точному адресу addr
// Анонимная память (аналог malloc для больших блоков)
char *buf = mmap(NULL, 4096,
                 PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS,
                 -1, 0);
buf[0] = 'A';
munmap(buf, 4096);

// Разделяемое файловое отображение
int fd = open("data.bin", O_RDWR);
char *view = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
view[0] = 'X';   // запись видна другим процессам и попадает в файл
munmap(view, 4096);
close(fd);

mprotect

mprotect изменяет права доступа к уже существующему отображению (подробно рассматривается в теме «Защита памяти»).

#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
char *mem = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
mem[0] = 'A';

mprotect(mem, 4096, PROT_READ);  // теперь только чтение
// mem[0] = 'B';  // вызвало бы SIGSEGV

brk и sbrk

Традиционный интерфейс для управления концом сегмента heap (program break). Современный malloc использует их только для небольших выделений; для крупных блоков (обычно > 128 КБ) применяется mmap.

#include <unistd.h>

int    brk(void *addr);       // установить program break
void  *sbrk(intptr_t incr);  // сдвинуть break на incr байт, вернуть старое значение
void *before = sbrk(0);
char *mem    = sbrk(4096);   // расширить heap на 4 КБ
void *after  = sbrk(0);
// after - before == 4096

Прямой вызов sbrk в пользовательском коде не рекомендуется: это нарушает работу стандартного malloc.

mremap

Linux-специфичный вызов для изменения размера или перемещения существующего отображения без копирования данных.

#include <sys/mman.h>
void *mremap(void *old_address, size_t old_size,
             size_t new_size, int flags, ...);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

// Расширить до 8 КБ; MREMAP_MAYMOVE разрешает переместить отображение.
ptr = mremap(ptr, 4096, 8192, MREMAP_MAYMOVE);

Связанные темы

Источники

  • man 5 proc — формат /proc/PID/maps и других файлов
  • man 2 mmap — создание отображений в памяти
  • man 2 munmap — удаление отображений
  • man 2 mprotect — изменение прав доступа к страницам
  • man 2 brk — управление program break
  • man 2 mremap — изменение размера отображения
  • man 2 madvise — подсказки ядру по управлению страницами
  • Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 3A — глава 4 (Paging)
  • Linux kernel source: mm/memory.c — обработчик Page Fault