GraphLMS

ОС
Начать

ОС · Виртуализация памяти · 15 мин

Адресное пространство: иллюзия приватной памяти

Зачем вообще что-то усложнять

Давай начнём с простого вопроса: где живёт твоя программа, когда она работает? Ответ, который приходит в голову новичку: «в оперативной памяти, в RAM». Это правда, но не вся. Если бы программы лежали в RAM как попало и видели её напрямую — мир был бы адом. Любая программа могла бы случайно (или нарочно) затереть память соседней программы или даже самой операционной системы. Один кривой указатель в твоём сервисе — и упал не только он, а вся машина целиком.

Чтобы этого не было, операционная система (дальше — ОС) дарит каждому процессу красивую ложь. Она говорит каждому: «вся память — твоя, от адреса 0 и до огромного числа, делай что хочешь». И каждый процесс ей верит. На самом деле никакой отдельной памяти у процесса нет — физическая RAM одна на всех, и её ещё и меньше, чем процессам кажется. Эта ложь и называется адресным пространством (address space).

Сначала разберёмся, что такое процесс. Процесс — это запущенная программа (подробно — в главе /os/book/process). Программа на диске лежит мёртвая, как книга на полке. Когда ты её запустил — ОС создала процесс: выделила ему память, дала номер (PID), и начала по очереди давать ему процессор. Так вот, у каждого такого процесса — своё личное адресное пространство.

Ключевая мысль главы: то, что видит программа — это не настоящая память. Это аккуратно нарисованная для неё иллюзия. Адреса, которыми оперирует твой код, — виртуальные, и ОС с железом тайком переводят их в реальные.

Аналогия: многоквартирный дом

Представь большой многоквартирный дом. В каждой квартире — свой жилец. Каждый жилец думает: «моя квартира — это квартира номер 1, второй этаж, и она только моя». И у соседа — тоже «квартира номер 1», и он тоже уверен, что она его. Как так? А вот так: у каждого жильца своя нумерация изнутри. «Квартира 1» — это адрес внутри головы жильца. А управляющий домом (это ОС) держит секретную таблицу: «жилец Петров, его внутренняя квартира 1 = реальная квартира 314 на девятом этаже». Жильцы друг друга не видят, в чужие квартиры не заходят, и каждый счастлив в иллюзии, что дом крутится вокруг него.

   ЧТО ДУМАЕТ КАЖДЫЙ ЖИЛЕЦ          ЧТО НА САМОМ ДЕЛЕ (реальный дом)
   (его личная нумерация)            (физические квартиры)
 
   Петров:  "я в кв. 1"  ───┐
   Иванов:  "я в кв. 1"  ───┼──►  управляющий (ОС) ──► кв. 314  (Петров)
   Сидоров: "я в кв. 1"  ───┘        + таблица        кв. 27   (Иванов)
                                                       кв. 512  (Сидоров)

Здесь нарисовано главное недоразумение, которое ОС создаёт специально: три жильца называют свою квартиру одинаково («1»), но управляющий разводит их по разным настоящим квартирам. Никто никому не мешает. Ровно так же ОС разводит процессы по разным кускам физической RAM, хотя внутри себя они используют одни и те же адреса.

Что видит процесс: раскладка адресного пространства

Теперь заглянем внутрь одной «квартиры» — то есть в адресное пространство одного процесса. Процессу кажется, что у него есть непрерывная лента памяти: от адреса 0 до какого-то огромного числа (например, до 2^48 байт на современных 64-битных системах — это сотни терабайт «как бы памяти», которой физически нет и в помине).

Эта лента разбита на участки. Вот классическая раскладка:

   ВИРТУАЛЬНОЕ АДРЕСНОЕ ПРОСТРАНСТВО ОДНОГО ПРОЦЕССА
 
   высокие адреса
   ┌──────────────────────────┐  ← большой адрес (напр. 2^48)
   │          СТЕК (stack)     │   локальные переменные, кадры функций
   │            │              │
   │            ▼ растёт ВНИЗ  │
   ├──────────────────────────┤
   │                          │
   │      БОЛЬШАЯ ДЫРА        │   пустое место «про запас»
   │     (свободно, не занято)│   чтобы стеку и куче было куда расти
   │                          │
   ├──────────────────────────┤
   │            ▲ растёт ВВЕРХ │
   │            │              │
   │          КУЧА (heap)      │   динамическая память (new/malloc)
   ├──────────────────────────┤
   │     ДАННЫЕ (data/bss)     │   глобальные переменные
   ├──────────────────────────┤
   │      КОД (text)          │   машинные инструкции программы
   └──────────────────────────┘  ← адрес 0
   низкие адреса

Разберём по этажам снизу вверх:

  • Код (text) — сами инструкции твоей программы, переведённые в машинный язык. Обычно лежит у низких адресов и помечен «только чтение»: программа сама себя не переписывает, поэтому случайная запись сюда сразу падает с ошибкой.
  • Данные (data / bss) — глобальные и статические переменные. Их размер известен заранее, при компиляции, поэтому они занимают фиксированное место.
  • Куча (heap) — память, которую программа просит во время работы, когда заранее неизвестно, сколько её надо. Куча растёт вверх, в сторону больших адресов. Про то, как её просить и отдавать — следующая глава /os/book/memory-api.
  • Стек (stack) — память для вызовов функций: аргументы, адрес возврата, локальные переменные. Каждый вызов функции кладёт сверху новый «кадр» (frame), выход из функции — снимает. Стек растёт вниз, навстречу куче.
  • Дыра (the gap) — огромное пустое пространство между кучей и стеком. Оно нужно специально: куча и стек растут навстречу друг другу, и эта дыра — их общий «запас хода». Памяти физически под дырой нет — это просто зарезервированный диапазон адресов.

Почему стек растёт вниз, а куча вверх

Их посадили на противоположные концы и пустили навстречу, чтобы они делили одну большую дыру между собой. Если бы оба росли в одну сторону, пришлось бы заранее угадывать, кому сколько отрезать. А так: сегодня программа активно зовёт функции (стек пухнет вниз), завтра аллоцирует много данных (куча пухнет вверх) — и пока они не встретились посередине, всем хватает. Если же встретились — это, по сути, переполнение, классический stack overflow (если упёрся стек) или нехватка памяти при аллокации.

   ВРЕМЯ →   функция вызвала функцию вызвала функцию...
 
   ┌───────┐ стек        ┌───────┐         ┌───────┐
   │ stack │ ↓           │ stack │ ↓↓      │ stack │ ↓↓↓
   │  ...  │             │  .... │         │ ..... │
   │       │             │       │         │  ← дыра тает
   │  heap │             │ heap  │ ↑       │ heap  │ ↑↑
   └───────┘             └───────┘         └───────┘

Здесь видно, как при глубокой рекурсии или больших аллокациях дыра между стеком и кучей постепенно «съедается» с обеих сторон.

Виртуальный адрес против физического

Самое важное и самое неочевидное. Когда твой код пишет:

   x = *p;   // прочитать значение по указателю p

— адрес в p (скажем, 0x7ff...) это виртуальный адрес. Виртуальный значит «ненастоящий, придуманный для процесса». В реальной микросхеме RAM такого байта по этому адресу может не быть вовсе. Между процессором и памятью стоит переводчик — специальный блок железа MMU (Memory Management Unit, блок управления памятью). На каждое обращение к памяти он берёт виртуальный адрес и по таблице, которую ведёт ОС, превращает его в физический адрес — настоящий номер ячейки в микросхеме RAM.

   ПРОЦЕСС               ЖЕЛЕЗО (MMU)                ФИЗИЧЕСКАЯ RAM
   видит виртуальные     переводит на лету           реальные ячейки
   адреса
 
   читать VA 0x4000  ──►  [ таблица перевода ]  ──►  PA 0x9C000  (где реально лежит)
   читать VA 0x4000  ──►  у ДРУГОГО процесса   ──►  PA 0x12000  (совсем другое место!)
        (тот же                своя таблица
       виртуальный)

Гениальность в том, что один и тот же виртуальный адрес у разных процессов ведёт в разные физические ячейки. Поэтому процессы могут все думать, что живут с адреса 0, и при этом не топтаться друг по другу. На этом уровне нам достаточно самой идеи: «программа видит виртуальное, железо переводит в физическое». А вот как именно устроен этот перевод — отдельная большая тема, начинается со следующих глав: /os/book/address-translation (простейший способ — base-and-bounds), дальше сегментация и страницы.

Изоляция: почему один процесс не лезет к другому

Раз у каждого процесса своя таблица перевода, то процесс физически не может назвать адрес чужой памяти. У него попросту нет «слова» для неё: все его виртуальные адреса ОС перевела на его собственные физические страницы. Если он наберёт случайное число как адрес и попробует туда залезть, возможны два исхода:

  1. Адрес не отображён ни на что (попал в дыру или за пределы) — MMU поднимает тревогу, ОС присылает процессу сигнал, и тот падает. На практике ты видишь это как segmentation fault (segfault) — «обращение по запрещённому адресу».
  2. Адрес попал в его собственную память — он просто читает/пишет свои же данные, соседу это ничем не грозит.
   БЕЗ изоляции (плохой мир)        С изоляцией (как на самом деле)
 
   ┌────────────────────┐          ┌─────────┐  ┌─────────┐  ┌─────────┐
   │  одна общая RAM     │          │ proc A  │  │ proc B  │  │   ОС    │
   │  A пишет куда хочет │          │ своя    │  │ своя    │  │ своя    │
   │  → затёр B и ОС 💥  │          │ таблица │  │ таблица │  │ таблица │
   └────────────────────┘          └────┬────┘  └────┬────┘  └────┬────┘
                                        ▼            ▼            ▼
                                   ┌──────────────────────────────────┐
                                   │   ОДНА физическая RAM, но ОС      │
                                   │   развела всех по разным кускам   │
                                   └──────────────────────────────────┘

Важно: эту стену строит не сама программа, а связка ОС + железо. Программа не может её отключить, потому что таблицами перевода управляет только ОС в привилегированном режиме (см. /os/book/limited-direct-execution). Именно поэтому изоляция — это защита, а не вежливая договорённость.

Зачем ещё нужна эта абстракция

Кроме защиты, отдельное адресное пространство даёт ещё бонусы:

  • Простота для программиста. Ты пишешь код так, будто память твоя и начинается с нуля. Не надо думать, в какой угол RAM тебя засунули в этот раз.
  • Перемещаемость. ОС может физически переложить твои данные в другое место RAM (или даже на диск), а виртуальные адреса не изменятся — поменяется только таблица перевода. Программа ничего не заметит.
  • Переподписка (overcommit). Сумма «обещанной» памяти всем процессам может превышать физическую RAM, ведь обещания виртуальны. Реальную страницу ОС выдаёт лишь когда к ней впервые обратились. Подробнее — в /os/book/swapping и /os/book/memory-cloud.

Сравнение: виртуальное и физическое

Свойство Виртуальный адрес Физический адрес
Кто видит процесс / твой код железо (RAM), ядро ОС
С чего начинается у каждого процесса с 0 один общий диапазон на всю машину
Уникальность один и тот же VA у разных процессов = разные данные каждый PA — конкретная ячейка
Кто переводит между ними MMU + таблицы, которыми рулит ОС
Можно ли поменять расположение да, прозрачно для программы это и есть реальное место

Go-связка: а где живут мои переменные

Когда ты пишешь на Go, ты работаешь ровно внутри такого виртуального адресного пространства своего процесса. Несколько практичных моментов:

  • Локальная переменная в функции по умолчанию метит на стек — тот самый, что растёт вниз. Глобальные — в сегмент данных.
  • Но Go не такой прямолинейный, как C. Компилятор делает escape analysis — анализ убегания: смотрит, не «утечёт» ли указатель на переменную за пределы функции. Если утечёт (например, ты вернул &x), переменную «переселяют» в кучу, чтобы она пережила выход из функции. То есть var x int может оказаться и на стеке, и в куче — решает компилятор. Это прямо влияет на производительность: куча дороже и нагружает сборщик мусора (GC).
  • У горутин стек не один на процесс, а свой маленький стек на каждую горутину (начинается с пары килобайт и растёт по мере надобности). Все эти стеки живут в одном и том же виртуальном адресном пространстве процесса.
  • Сборщик мусора Go ходит по куче этого же адресного пространства, ищет, на что уже никто не ссылается, и освобождает.

Подробный разбор «стек против кучи», malloc/free и утечек — в следующей главе /os/book/memory-api. А внутреннюю кухню Go-памяти и модель памяти смотри в курсе Go: /book/memory-model.

Проверь себя· Адресное пространство

Что такое виртуальный адрес?

Куда растут стек и куча в классической раскладке адресного пространства?

Почему один процесс не может прочитать память другого процесса?

Что из перечисленного — выгоды абстракции адресного пространства? (выбери все верные)

Какой блок железа на лету переводит виртуальные адреса в физические?

Сколько минимум ASCII-схем требует формат главы (по правилам блока)?

Что спрашивают на собесе

  • Что такое виртуальное адресное пространство и зачем оно нужно? Жди ответа про изоляцию (защиту процессов друг от друга и от ОС), про простоту для программиста (память «своя, с нуля») и про возможность переподписки/перемещения.
  • В чём разница между виртуальным и физическим адресом? Кто их переводит? Виртуальный видит программа, физический — настоящая ячейка RAM; переводит MMU по таблицам, которыми управляет ОС.
  • Почему один процесс не может прочитать память другого? Потому что у него нет даже способа назвать чужой адрес: его виртуальные адреса отображены только на его собственные физические страницы; чужое попадание — segfault.
  • Как устроена раскладка адресного пространства и куда растут стек и куча? Код/данные внизу, куча растёт вверх, стек вниз, между ними дыра-резерв.
  • Что такое segmentation fault? Обращение по виртуальному адресу, который не отображён на разрешённую память; MMU ловит, ОС шлёт сигнал, процесс падает.
  • (Go-уклон) Где живут переменные в Go и что такое escape analysis? Компилятор решает стек/куча по тому, убегает ли указатель за пределы функции; это влияет на нагрузку на GC.
Планирование на многоядерныхAPI памяти: стек vs куча, утечки и escape