Learning Platform
Глоссарий Troubleshooting
Урок 07.02 · 18 мин
Начальный
Virtual memoryAddress spaceLinuxMMUIsolation

Virtual memory — зачем нужна, какие проблемы решает

Когда вы пишете malloc(1_000_000_000) и получаете указатель 0x7f8b40000000 — что это вообще за число? Реальный адрес ячейки в RAM? Нет. Это виртуальный адрес — абстракция, которую kernel и MMU создают для каждого процесса. Каждый процесс видит свою «личную» 64-битную (или 48-битную на самом деле в большинстве x86_64) address space, и kernel прозрачно переводит её в реальные физические адреса.

Virtual memory — одна из главных и самых важных абстракций ОС. Без неё современный многозадачный Linux был бы невозможен. В этом уроке разберём: зачем нужна VM, какие проблемы она решает, и как выглядит адресное пространство типичного процесса в Linux.


Что было бы без virtual memory

Представим архаичную систему без VM. Все процессы работают с физическими адресами:

Жизнь без VM -- ад на земле
Process AЗагружен по физическому адресу 0x10000. Имеет указатели на 0x10000, 0x10100, ...
Process BЗагружен следом, по адресу 0x20000. Указатели на 0x20100, 0x20200
Problem 1: relocationПроцесс B мог бы быть загружен по другому адресу. Все указатели в коде B нужно поправить во время загрузки. Каждая программа должна поддерживать relocation
Problem 2: no isolationA может писать по адресу 0x20100 и испортить B. Никакой защиты между процессами
Problem 3: fragmentationA завершился, освободил 0x10000-0x20000 (64 KB). Если новый процесс требует 100 KB -- ему не хватит непрерывного блока, хотя свободно много
Problem 4: memory limitЕсли у вас 4 GB RAM, и одна программа хочет 8 GB структуры -- невозможно. Не можем 'обещать' больше, чем есть

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


Идея VM: каждому процессу свой address space

С virtual memory каждый процесс видит свое адресное пространство от 0 до 2^48 (256 TB виртуальной памяти на x86_64). Внутри этого пространства нет других процессов — только сам процесс. Адреса процесса A и процесса B независимы — адрес 0x10000 у A и у B — разные физические места.

VM -- каждому процессу свой 'остров'
Process A virtualВидит виртуальное пространство 0..2^48. Думает, что у него вся память. Адрес 0x10000 у A -- свой
Process B virtualВидит своё виртуальное пространство 0..2^48. Адрес 0x10000 у B -- другой физический
Page tables (kernel)Kernel поддерживает page tables для каждого процесса. Translation virtual -> physical address. У каждого процесса свой CR3 указатель на корень page tables
Physical RAMРеальная RAM -- общая, ограниченная (16 GB, 64 GB, ...). Kernel выделяет страницы (4 KB) разным процессам по запросу

Преобразование делает MMU (Memory Management Unit) — аппаратный блок CPU. При каждом обращении к памяти MMU смотрит page tables и переводит virtual address -> physical. Это происходит прозрачно для программы, она ничего не знает.


Преимущества virtual memory

1. Изоляция процессов

Процесс A не может прочитать или испортить память процесса B. MMU блокирует доступ — если A пытается обратиться по virtual address, который не отображён в его page tables, происходит page fault и kernel убивает процесс с SIGSEGV.

# Попробуем -- что покажет программа, обратившаяся к чужой памяти:
cat > /tmp/segv.c << 'EOF'
#include <stdio.h>
int main() {
    int* p = (int*)0x12345;  // случайный адрес
    *p = 42;  // запись по чужой памяти
    return 0;
}
EOF
gcc /tmp/segv.c -o /tmp/segv
/tmp/segv
# Segmentation fault (core dumped)
# Программа не упала бы без VM -- она бы успешно записала случайный байт в чью-то память

Изоляция — фундаментальная для безопасности. Один баг в браузере не должен взламывать ОС.

2. Нет фрагментации с точки зрения программы

В физической RAM фрагментация есть. Но программа видит непрерывное виртуальное пространство. Когда программа делает malloc(1 MB), она получает 1 MB подряд в virtual address space. Kernel может разместить эти данные на 256 разрозненных страницах в RAM — программе всё равно, она видит непрерывно.

Фрагментация только на физическом уровне
Process sees: 1 MB contiguousПрограмма имеет указатель на 1 MB, выглядит непрерывно. malloc(1 MB) гарантирует contiguous virtual
Physical: scatteredВ физической RAM может быть 256 страниц по 4 KB, разбросанных по разным местам RAM. Page tables связывают их в виртуальный непрерывный блок
MMU hides scatterПри каждом access MMU переводит virtual address на правильную физическую страницу. Программа никогда не видит реальное расположение

3. Иллюзия большой памяти (через swap)

Программа может выделять больше памяти, чем есть RAM — неиспользуемые страницы могут быть swap’ом отправлены на диск. Программа продолжает работать, swap’нутые страницы загружаются обратно при обращении (page fault + load from swap).

# Включить swap:
swapon --show
# Если есть -- видны разделы или файлы

# Сколько использует swap:
free -h
# total used free shared buff/cache available
# Mem:           64Gi  20Gi   10Gi  ...
# Swap:          8Gi   2Gi    6Gi   <- 2GB на swap'е

Swap делает приложения «отказоустойчивыми» к нехватке RAM — они не падают сразу, а замедляются. Подробнее в уроке 04.

Named volumes в Docker: виртуальная память на уровне контейнеров

4. Copy-on-write для fork()

Когда процесс делает fork(), kernel не копирует всю память ребёнка — он просто копирует page tables. Реальные страницы остаются одни на двух. Только при попытке записи (copy-on-write) страница копируется. Это делает fork() очень дешёвым.

# Демонстрация COW:
cat > /tmp/cow.py << 'EOF'
import os, time
data = list(range(10_000_000))  # ~80 MB в памяти
print(f"Parent PID: {os.getpid()}, before fork: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
pid = os.fork()
if pid == 0:
    print(f"Child immediately after fork: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
    time.sleep(2)
    data[0] = 999  # триггер COW для первой страницы
    print(f"Child after modifying: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
else:
    os.wait()
EOF
python3 /tmp/cow.py
# Parent: 100 MB
# Child immediately: 100 MB (но это виртуально, реально страницы общие)
# Child after modifying: 100 MB (страница скопирована, но это одна страница, 4 KB)

5. Удобство для shared libraries

libc.so загружается в физическую RAM один раз. Все процессы, использующие libc, маппируют эту физическую память в свои virtual address spaces. Экономия памяти — огромная.

# Сколько процессов используют libc:
lsof | grep libc.so | wc -l
# Тысячи

# Физически libc -- одна копия в памяти:
sudo cat /proc/$(pgrep firefox | head -1)/maps | grep libc
# 7f3d...-7f3d... r-xp 00000000 libc.so.6
# r-xp -- read-execute, private (но physical shared если ничего не пишет)

Address space layout процесса

Типичная схема виртуальной памяти процесса в Linux x86_64:

Process virtual memory layout
Kernel spaceВерхняя половина (0xFFFF80000000.. на x86_64). Не доступна из userspace. Содержит kernel code, kernel data, kernel stacks. Mapped в каждый процесс одинаково
Stack (grows down)Главный стек процесса. По умолчанию 8 MB на Linux. Начинается ниже kernel space, растёт вниз. Локальные переменные, аргументы функций, return-адреса
Shared librarieslibc, libpthread, и другие .so загружаются здесь. Между stack и heap. Иногда здесь же mmap-области
Heap (grows up)Куча для malloc. Растёт вверх через brk() syscall. Большие аллокации (>128 KB обычно) идут через mmap, не brk
.bssUninitialized data segment. Глобальные переменные, инициализированные нулём
.dataInitialized data. Глобальные переменные с явными значениями
.rodataRead-only data. Строковые константы, const переменные
.textCode segment. Машинные инструкции программы. Read-only
0x00000000Не используется -- NULL pointer dereference должен дать SIGSEGV. Первая страница (0..4 KB) обычно не отображена

Посмотрим реально:

# Address space любого процесса:
cat /proc/self/maps | head -20
# 55a7e8d50000-55a7e8d53000 r--p 00000000 fd:01 ... /usr/bin/cat
# 55a7e8d53000-55a7e8d57000 r-xp 00003000 fd:01 ... /usr/bin/cat   <- .text
# 55a7e8d57000-55a7e8d59000 r--p 00007000 fd:01 ... /usr/bin/cat   <- .rodata
# 55a7e8d59000-55a7e8d5a000 r--p 00008000 fd:01 ... /usr/bin/cat   <- .data
# 55a7e8d5a000-55a7e8d5b000 rw-p 00009000 fd:01 ... /usr/bin/cat   <- .bss
# 55a7e96a1000-55a7e96c2000 rw-p 00000000 00:00 0  [heap]          <- куча
# 7f3d1e000000-7f3d1e022000 r--p 00000000 fd:01 ... /usr/lib/.../libc.so.6
# ...
# 7ffd...-7ffd... rw-p 00000000 00:00 0           [stack]

Каждая строка — один mapping — регион виртуальной памяти с правами (r/w/x), и opциональным backing storage (файл или anonymous).


Permissions: r, w, x

Каждая страница виртуальной памяти имеет права доступа:

  • r — read (можно читать)
  • w — write (можно писать)
  • x — execute (можно выполнять код)
  • p/s — private (COW) или shared
Permissions для regions
.textCode segment. Permissions r-x: read и execute, не write. Пытаться писать в .text = SIGSEGV. Защищает от self-modifying code
.rodataPermissions r--: только read. Strings, const variables
.data, .bss, heap, stackPermissions rw-: read и write, не execute. Защита от выполнения данных как кода (NX bit / DEP)
No execute on stackС NX bit (DEP) на современных CPU стек помечен как non-executable. Защищает от buffer overflow exploits, выполняющих shellcode на стеке

NX bit (No-Execute, или DEP — Data Execution Prevention) — важная фича безопасности. Без неё buffer overflow атакующего могла бы записать машинный код на стек и выполнить. С NX это невозможно: страницы стека не имеют x-бита, попытка выполнить = SIGSEGV.


Memory regions: anonymous vs file-backed

В Linux есть два типа memory regions:

Anonymous vs file-backed mappings
Anonymous mappingНе связано с файлом. malloc, stack, .bss. При page fault kernel выдаёт чистую zeroed страницу из пула. При swap -- сохраняется в swap область
File-backed mappingСвязано с файлом на диске. mmap() с file fd, или .text (загружается из ELF executable). При page fault страница загружается из файла. При swap -- сбрасывается обратно в файл (не в swap)
Private (CoW)Изменения видны только этому процессу. Запись триггерит copy-on-write -- новая страница создаётся локально для процесса. По умолчанию для shared libraries
SharedИзменения видны всем процессам, маппирующим эту память. mmap(MAP_SHARED). Используется для IPC через shared memory

В /proc/PID/maps:

  • r-xp — read-execute private (.text сегменты)
  • rw-p — read-write private (heap, anonymous mmap)
  • rw-s — read-write shared (shared memory IPC)
  • r--s — read-only shared

RSS vs VSZ — что показывает ps

При просмотре ps или top две колонки про память:

RSS vs VSZ -- два разных числа памяти
VSZ (Virtual Size)Сумма всех virtual mappings процесса. Может быть огромной (TB), потому что virtual address space большой. Не отражает реального потребления RAM
RSS (Resident Set Size)Сколько физических страниц RAM реально использует процесс СЕЙЧАС. Это то, что 'потребляет' память. Включает shared страницы (libc и т.п.)
PSS (Proportional)Если страница shared между N процессами, считается 1/N для каждого. Более честная мера реального потребления чем RSS
USS (Unique)Только страницы, уникальные для этого процесса. Сколько освободится памяти если процесс закрыть
# Посмотреть память процесса:
ps -p PID -o pid,rss,vsz,comm
# PID    RSS    VSZ COMMAND
# 12345 50000 200000 firefox
# RSS = 50 MB реально в RAM
# VSZ = 200 MB виртуально (но многое не использовано)

# Полная картина через smem (если есть):
smem -P firefox
# PID User Command Swap USS PSS RSS

# Или /proc/PID/smaps_rollup:
cat /proc/PID/smaps_rollup
# Rss: ...    Pss: ...    Shared_Clean: ...    Private_Dirty: ...
# Все категории памяти процесса

Главное: не пугайтесь VSZ. Виртуальный 100 GB — может быть реально 50 MB. Меряйте RSS или PSS.

htop: как читать RES и VIRT в мониторинге процессов

Попробуй сам

# 1. Посмотреть свой address space:
cat /proc/self/maps
# Найдите [heap], [stack], shared libraries, .text сегменты

# 2. Сколько mmap-областей у разных процессов:
for pid in $(pgrep -f 'firefox|chrome|code' | head -5); do
    count=$(wc -l < /proc/$pid/maps 2>/dev/null)
    name=$(cat /proc/$pid/comm)
    echo "PID $pid ($name): $count mappings"
done

# 3. RSS vs VSZ для топ-процессов:
ps aux --sort -rss | head -10
# Колонки RSS и VSZ -- разница может быть драматичной

# 4. Запустим программу, которая выделяет много, но использует мало:
python3 -c "
import os, time
print(f'PID: {os.getpid()}')
# allocate 1 GB, но НЕ трогаем
buf = bytearray(1024*1024*1024)  # это touches страницы -- RSS вырастет
time.sleep(60)
" &
PID=$!
sleep 2
ps -p $PID -o pid,rss,vsz,comm
# RSS ≈ 1 GB (потому что bytearray touchнул)
kill $PID

# Если бы мы выделили через mmap без MAP_POPULATE -- VSZ = 1 GB, RSS = 0
# (страницы выделятся только при первом обращении)

# 5. Посмотреть NULL guard page -- доступ к 0x0:
cat > /tmp/null.c << 'EOF'
#include <stdio.h>
int main() {
    int* p = (int*)0;
    printf("%d\n", *p);  // NULL pointer dereference
    return 0;
}
EOF
gcc /tmp/null.c -o /tmp/null
/tmp/null
# Segmentation fault -- адрес 0x0 не отображён

Проверка знанийKnowledge check
Junior спрашивает: 'Я смотрю ps aux, и вижу что у firefox VSZ = 50 GB. У меня всего 16 GB RAM! Как такое может быть?'
ОтветAnswer
Это нормально и часто встречается. VSZ -- это виртуальный размер, не физический. Каждый процесс на x86_64 видит свою виртуальную память до 256 TB. Из этого: 1. Shared libraries. Firefox загружает сотни .so файлов через mmap. Каждый -- виртуальный mapping. Но в RAM эти библиотеки занимают одну физическую копию для всех процессов. 2. mmap reservations. Firefox может зарезервировать огромные виртуальные области (через mmap PROT_NONE) для своих arena allocator'ов -- jemalloc, mimalloc делают так. Эти страницы не trigger ниже page faults пока к ним не обратятся -- никакой физической памяти не тратится. 3. Stack reservations. Каждый поток имеет стек ~8 MB. Firefox с 100 потоками = 800 MB виртуально для стеков. Но физически использует только текущие фреймы вызовов. 4. Memory mapped files. Firefox mmap'ит ресурсы, базу профиля, кэши. Виртуально -- много, физически -- только горячие страницы. 5. Address Space Layout Randomization (ASLR). Дыры между mappings, gaps. Виртуально занимают место, физически нет. Правильные метрики: - RSS (Resident Set Size). Реально в RAM сейчас. Включая shared страницы (libc может быть посчитан в каждом процессе). - PSS (Proportional Set Size). Shared страницы делятся на N процессов, использующих их. Если libc 4 MB shared между 100 процессов -- каждому считается 40 KB. Более 'честная' метрика. - USS (Unique Set Size). Только страницы, уникальные для этого процесса. Сколько освободится памяти при закрытии. Самая консервативная оценка. Команды: - ps aux -- RSS колонка, грубая оценка - smem -P firefox -- USS, PSS, RSS - cat /proc/PID/smaps_rollup -- полная статистика - pmap -X PID -- детально по каждому mapping В итоге: для firefox с VSZ=50GB реальный RSS типично 1-3 GB. На системе с 16 GB это нормально. Если RSS firefox 12 GB -- тогда проблема. И ещё: даже когда RSS большой, иногда это можно ужать. Например, в firefox есть about:performance -- покажет реальное потребление по табам и можно закрыть тяжёлые.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Главные преимущества virtual memory:

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5