Виртуализация памяти · 12 мин
Контент v0.12.0Аппаратная трансляция адресов: base-and-bounds
Иллюзию надо чем-то поддерживать
В главе про адресное пространство мы договорились о важной вещи: каждый процесс думает, что владеет памятью единолично. Его код лежит «с нуля», стек — где-то наверху, куча — посередине, и адреса у всех процессов как будто одинаковые. Это иллюзия приватной памяти.
Но физическая память (та самая планка RAM в сервере) — одна на всех. На ней одновременно живут десятки процессов: ваш Go-сервис, БД, sidecar, агент мониторинга. Их данные лежат вперемешку в реальных ячейках. Возникает вопрос:
Процесс обращается к виртуальному адресу (что он «видит»), а данные физически лежат по другому, физическому адресу. Кто и как переводит одно в другое — да так быстро, чтобы это происходило на каждое обращение к памяти и не тормозило?
Этот перевод и называется трансляцией адресов (address translation). А занимается им специальный кусок железа внутри процессора — MMU (Memory Management Unit, блок управления памятью). В этой главе разберём самый простой механизм трансляции — base-and-bounds (он же динамическая релокация). Он наивный, но именно на нём проще всего понять разделение труда «железо vs ОС», которое тянется через все следующие главы — сегментацию и страницы.
Три слова
Они будут повсюду в следующих главах.
- Виртуальный адрес (VA, virtual address) — адрес, который видит и использует
программа. Когда в Go вы берёте
&xили процессор читает следующую инструкцию — это всё виртуальные адреса. Процесс уверен, что они «настоящие». - Физический адрес (PA, physical address) — реальный номер ячейки в RAM, куда в итоге пойдёт чтение или запись.
- MMU — железо в CPU, которое на лету превращает VA в PA по правилам, которые ему задала ОС.
Что видит программа Что есть на самом деле
(виртуальный мир) (физический мир, RAM)
адрес 0 ┌─────────┐ ┌─────────┐ 0 KB
│ код │ │ ОС │
адрес 100 │ данные │ MMU │ (ядро) │
│ ... │ ──────────▶ ├─────────┤
│ куча │ перевод │ процесс │ ← реально тут
│ ... │ VA → PA │ лежит │
│ стек │ ├─────────┤
адрес MAX └─────────┘ │ другой │
│ процесс │
└─────────┘Главная мысль картинки: программа живёт в «удобном» виртуальном мире от нуля, а MMU незаметно подменяет адреса так, чтобы они попадали в нужное место настоящей RAM.
Идея base-and-bounds
Представьте тетрадь (RAM), страницы которой вы сдаёте в аренду. Пришёл процесс, вы говорите: «ваш кусок начинается со страницы 320». Процесс продолжает нумеровать свои страницы с нуля: 0, 1, 2... Чтобы найти его «страницу 5» в тетради, достаточно прибавить: 320 + 5 = страница 325.
Вот и весь фокус. Заводим два регистра (две специальные ячейки прямо в CPU):
- base (база) — с какого физического адреса начинается память этого процесса.
- bounds (граница, она же limit) — насколько большой кусок ему выделен (размер).
Формула трансляции — одно сложение:
физический адрес = виртуальный адрес + baseСлово «динамическая» в названии — потому что перевод происходит во время выполнения (runtime), на каждом доступе, а не один раз при загрузке программы. Можно даже переместить процесс в другое место RAM на ходу: поменял base — и все его адреса автоматически указывают на новое место. Программа об этом и не узнает.
Разбор одного доступа по шагам
Пусть ОС загрузила процесс в RAM начиная с адреса 32 KB. Значит:
base = 32768 (это 32 KB)
bounds = 16384 (процессу выделено 16 KB)Процесс выполняет инструкцию, которая хочет прочитать виртуальный адрес 100
(например, обращение к переменной). Смотрим, что делает MMU:
VA = 100
│
▼
┌───────────────────────────────────────────┐
│ 1) проверка границы: VA < bounds ? │
│ 100 < 16384 → ДА, всё ок │
│ │
│ 2) трансляция: PA = VA + base │
│ PA = 100 + 32768 = 32868 │
└───────────────────────────────────────────┘
│
▼
PA = 32868 → идём в RAM по этому адресуПрограмма думала, что читает ячейку 100. На самом деле прочиталась ячейка 32868. И это нормально: внутри своего куска у процесса смещения те же самые (0, 100, 200...), просто весь кусок целиком сдвинут на base.
Важно: эти два действия (проверка + сложение) MMU делает на каждое обращение к памяти — а их миллиарды в секунду. Поэтому механизм обязан быть тупым и быстрым. Сложение и сравнение — это как раз то, что железо умеет делать за доли наносекунды.
Зачем нужен bounds: защита
Вторая половина названия — bounds — это не про адресацию, а про безопасность. Без неё процесс мог бы выйти за пределы своего куска и полезть в чужую память (в БД, в ядро ОС, в соседний контейнер). Поэтому перед каждым сложением MMU проверяет: а влезает ли виртуальный адрес в выделенный размер?
Процесс с base=32KB, bounds=16KB
VA = 100 → 100 < 16384 → OK → PA = 32868
VA = 16383 → 16383 < 16384 → OK → PA = 49151 (последний свой байт)
VA = 20000 → 20000 < 16384 → НЕТ! → ПРЕРЫВАНИЕЕсли адрес вылез за границу, MMU не делает трансляцию. Вместо этого он генерирует
аппаратное прерывание (exception, исключение) и передаёт управление ОС. ОС видит:
«процесс полез не туда» — и обычно убивает его, послав сигнал SIGSEGV. Снаружи это
выглядит как segmentation fault (segfault, «ошибка сегментации»).
Процесс лезет за bounds
──────────────────────────────────────────────
CPU: VA = 20000
│
▼ MMU: 20000 >= bounds → НАРУШЕНИЕ!
│
▼ железо генерит exception, прыгает в ОС
│
ОС: «нарушитель границ» → kill(SIGSEGV)
│
▼
Процесс падает: "Segmentation fault (core dumped)"Паника Go invalid memory address or nil pointer dereference устроена так же:
железо поймало недопустимое обращение, ядро ОС послало SIGSEGV, рантайм Go
перехватил сигнал и превратил его в панику. В современных системах недопустимость
адреса около нуля определяет не регистр bounds, а страничная трансляция (страница 0
просто не отображена, см. страничная организация), но разделение
труда то же: проверяет железо, решает ОС.
Кто что делает: ОС против железа
Ключ к пониманию всей темы — чёткое разделение ролей. Есть вещи, которые делаются редко (при создании процесса, при переключении) — их берёт на себя медленная, но умная ОС (это программа). А есть то, что делается на каждый доступ — это отдаёт быстрому, но тупому железу (MMU). Связку «железо делает рутину, ОС настраивает правила и разбирает исключения» мы подробно ввели в главе прямое исполнение с ограничениями, и здесь она продолжается.
| Когда | Кто | Что делает |
|---|---|---|
| Старт системы | ОС | настраивает обработчики прерываний (что делать при выходе за bounds) |
| Создание процесса | ОС | находит в RAM свободный кусок нужного размера, запоминает где |
| Запуск процесса | железо | переключается в режим пользователя, передаёт управление коду |
| Каждый доступ к памяти | железо (MMU) | проверяет VA < bounds, считает PA = VA + base |
| Переключение контекста | ОС | сохраняет старые base/bounds, ставит новые из PCB следующего процесса |
| Выход за границу | железо → ОС | железо ловит, ОС убивает процесс (SIGSEGV) |
| Завершение процесса | ОС | освобождает кусок RAM обратно в пул свободного |
Регистры base и bounds процесс сам поменять не может: запись в них — привилегированная инструкция, доступная только в режиме ядра. Иначе любой процесс поставил бы себе base = 0 и bounds = вся память, и изоляция бы исчезла.
Обратите внимание на строку про переключение контекста. Регистры base/bounds — это часть состояния процесса. Они хранятся в его управляющем блоке (PCB, process control block). Когда планировщик переключается с процесса A на процесс B, ОС перезаписывает base/bounds значениями B. После этого ровно те же виртуальные адреса начинают указывать на кусок памяти B.
Два процесса в одной RAM
Покажем главное преимущество механизма: оба процесса написаны так, будто живут с нуля, а в реальности лежат в разных местах. Их виртуальные адресные пространства одинаковы, а base — разный.
Виртуальный мир A ФИЗИЧЕСКАЯ RAM Виртуальный мир B
(думает: с 0) (думает: с 0)
0 ┌────────┐ 0 ┌──────────┐
│ код A │ │ ОС │
│ куча A │ 16K ├──────────┤
│ стек A │ │ процесс B│ ◀── base_B = 16K 0 ┌────────┐
16K └────────┘ │ (16K) │ │ код B │
32K ├──────────┤ │ куча B │
│ процесс A│ ◀── base_A = 32K │ стек B │
│ (16K) │ 16K └────────┘
48K ├──────────┤
│ свободно │
└──────────┘
A: VA 100 → PA 100 + 32K = 32868
B: VA 100 → PA 100 + 16K = 16484Один и тот же виртуальный адрес 100 у A превращается в 32868, а у B — в 16484.
Они физически не пересекаются, и bounds гарантирует, что один не залезет к другому.
Это и есть аппаратная основа изоляции процессов: дёшево, на одном сложении.
Почему это быстро, но негибко
Плюс очевиден: трансляция — это одно сложение и одно сравнение. Дешевле некуда, железо справляется без задержки на каждый доступ.
А теперь минус, ради которого вообще придумали следующие главы. Base-and-bounds выделяет процессу память одним сплошным куском фиксированного размера (bounds). Но реальное адресное пространство процесса устроено иначе: код и куча растут снизу вверх, стек — сверху вниз, а между ними — огромная пустота (см. адресное пространство). Эту пустоту процесс не использует.
Виртуальное пространство A Что лежит в RAM (base..base+bounds)
0 ┌──────────┐ код ┌──────────┐
│ код+куча │ (используется) │ код+куча │ ← реально нужно
├──────────┤ ├──────────┤
│ │ │ │
│ ПУСТОТА │ ← её нет в │ ПУСТОТА │ ← а место в RAM
│ (не юзаем)│ программе, │ (занята │ ЗАНЯТО ВПУСТУЮ
│ │ но место под │ зря!) │
├──────────┤ неё выделено ├──────────┤
│ стек │ (используется) │ стек │ ← реально нужно
16K └──────────┘ └──────────┘Поскольку весь диапазон от base до base+bounds резервируется целиком, эта дыра между кучей и стеком физически занимает RAM, хотя там ничего нет. Это называется внутренняя фрагментация (internal fragmentation) — память выделена процессу, но простаивает внутри его куска. На каждый процесс — мегабайты впустую. На сервере с тысячами процессов это гигабайты RAM, занятые пустотой.
Есть и второй минус: чтобы переместить или подгрузить процесс, нужен непрерывный свободный блок нужного размера. А память со временем дробится на мелкие свободные кусочки (об этом — глава управление свободной памятью), и большой непрерывный кусок может просто не найтись.
Куда это ведёт
Корень проблемы — «вся память процесса одним куском». Напрашивается идея: а что если выдавать память не одним куском, а несколькими — отдельно под код, отдельно под кучу, отдельно под стек? Тогда пустоту между ними не придётся резервировать. Это и есть сегментация: по сути несколько пар base/bounds, по одной на каждый «сегмент». Она лечит главную боль этой главы.
А когда и сегменты окажутся слишком крупными и неудобными, придёт идея резать память на одинаковые мелкие кусочки фиксированного размера — это страничная организация. Но и там, и там фундамент тот же, что мы разобрали здесь: железо переводит виртуальный адрес в физический по правилам, которые задаёт ОС. Меняются только сами правила и их сложность.
- Виртуальный адрес: va = 1000
- Граница процесса: bound = 16384 (валидны адреса 0 … 16383)
- Проверка пройдена: 0 ≤ 1000 < 16384
- Физический адрес: pa = base + va = 32768 + 1000 = 33768
Процесс имеет base = 32 KB (32768) и bounds = 16 KB (16384). По какому физическому адресу пойдёт обращение к виртуальному адресу 200?
Кто и когда выполняет проверку «VA < bounds» и сложение «VA + base»?
Что произойдёт, если процесс с bounds = 16 KB обратится к виртуальному адресу 20000?
Почему base-and-bounds приводит к внутренней фрагментации?
Что из перечисленного делает именно ОС (а не железо)?
Почему у двух процессов один и тот же виртуальный адрес 100 не конфликтует в физической RAM?
Частые ошибки
- Сравнивать с bounds физический адрес. Проверяется виртуальный:
VA < bounds. Иначе процесс с base = 32 KB вообще не смог бы обратиться к своему первому байту. - Писать условие как
VA <= bounds. Последний допустимый адрес —bounds − 1, потому что адреса считаются с нуля. - Считать, что ОС участвует в каждом обращении к памяти. ОС вступает только при нарушении или при переключении контекста.
Проверьте себя
- base = 49152 (48 KB), bounds = 4096. Переведите виртуальные адреса 0, 4095 и 4096.
- ОС решила переместить процесс из примера выше по адресу 16 KB. Что ей нужно сделать?
- Процесс занимает 4 KB кода и кучи, 2 KB стека, а между ними 10 KB пустоты. Сколько физической памяти займёт он при base-and-bounds?
Показать ответы
Ответы. (1) 0 → 49152; 4095 → 53247; 4096 — нарушение, 4096 < 4096 ложно.
(2) Остановить процесс, скопировать его 4 KB по новому адресу и записать в его PCB
base = 16384; при следующем запуске в регистр попадёт новое значение. Программа
ничего не заметит. (3) Все 16 KB: пустота резервируется вместе с остальным.
Что спрашивают на собесе
- В чём идея base-and-bounds и какая формула трансляции? Ждут: к виртуальному
адресу прибавляется base (
PA = VA + base), а bounds задаёт размер куска и проверяется на каждом доступе для защиты. - Кто делает трансляцию — ОС или процессор? Сам перевод и проверку границ — MMU (железо), на каждое обращение к памяти. ОС лишь задаёт значения base/bounds и обрабатывает нарушения. Если сказать, что трансляцией на каждый доступ занимается ОС — это красный флаг (было бы дико медленно).
- Что происходит при выходе за bounds? Железо генерирует exception, управление уходит в ОС, та обычно убивает процесс сигналом SIGSEGV (segmentation fault).
- Что такое внутренняя фрагментация и почему base-and-bounds ей страдает? Память выделяется одним непрерывным куском под всё адресное пространство, включая пустоту между кучей и стеком — она занимает RAM, хотя не используется.
- Зачем base/bounds сохраняются при переключении контекста? Это часть состояния процесса (лежит в PCB). У каждого процесса свой base, поэтому одинаковые виртуальные адреса разных процессов ведут в разные физические места — основа изоляции.
- Чем сегментация лучше base-and-bounds? Даёт несколько пар base/bounds (на код, кучу, стек), не резервируя пустоту между ними, и убирает основную внутреннюю фрагментацию.