ОС · Виртуализация памяти · 15 мин
Аппаратная трансляция адресов: 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, исключение) и передаёт управление ОС. ОС видит: «процесс полез не туда» — и обычно убивает его, послав сигнал. На уровне Linux ты это знаешь как 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 при
разыменовании nil — это та же механика снизу: обращение по адресу около нуля улетает
в защиту памяти, ядро шлёт сигнал, рантайм Go его перехватывает и превращает в
понятную панику. Под капотом — всё та же проверка границ железом.
Кто что делает: ОС против железа
Ключ к пониманию всей темы — чёткое разделение ролей. Есть вещи, которые делаются редко (при создании процесса, при переключении) — их берёт на себя медленная, но умная ОС (это программа). А есть то, что делается на каждый доступ — это отдаёт быстрому, но тупому железу (MMU). Связку «железо делает рутину, ОС настраивает правила и разбирает исключения» мы подробно ввели в главе ограниченное прямое выполнение — это её прямое продолжение.
| Когда | Кто | Что делает |
|---|---|---|
| Старт системы | ОС | настраивает обработчики прерываний (что делать при выходе за bounds) |
| Создание процесса | ОС | находит в RAM свободный кусок нужного размера, запоминает где |
| Запуск процесса | железо | переключается в режим пользователя, передаёт управление коду |
| Каждый доступ к памяти | железо (MMU) | проверяет VA < bounds, считает PA = VA + base |
| Переключение контекста | ОС | сохраняет старые base/bounds, ставит новые из PCB следующего процесса |
| Выход за границу | железо → ОС | железо ловит, ОС убивает процесс (SIGSEGV) |
| Завершение процесса | ОС | освобождает кусок RAM обратно в пул свободного |
Обрати внимание на строку про переключение контекста. Регистры 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) — память выделена процессу, но простаивает внутри его куска. На каждый процесс — мегабайты впустую. На сервере с тысячами процессов это катастрофа.
Есть и второй минус: чтобы переместить или подгрузить процесс, нужен непрерывный свободный блок нужного размера. А память со временем дробится на мелкие свободные кусочки (об этом — глава управление свободной памятью), и большой непрерывный кусок может просто не найтись.
Куда это ведёт
Корень проблемы — «вся память процесса одним куском». Напрашивается идея: а что если выдавать память не одним куском, а несколькими — отдельно под код, отдельно под кучу, отдельно под стек? Тогда пустоту между ними не придётся резервировать. Это и есть сегментация: по сути несколько пар 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?
Что спрашивают на собесе
- В чём идея 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 (на код, кучу, стек), не резервируя пустоту между ними, и убирает основную внутреннюю фрагментацию.