GraphLMS

ОС
Начать

ОС · Виртуализация памяти · 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
base = 32768bound = 16384va = 1000
✓ трансляция успешнаpa = base + va = 32768 + 1000 = 33768Успех: адрес перемещён прибавлением base
  1. Виртуальный адрес: va = 1000
  2. Граница процесса: bound = 16384 (валидны адреса 0 … 16383)
  3. Проверка пройдена: 0 ≤ 1000 < 16384
  4. Физический адрес: pa = base + va = 32768 + 1000 = 33768
Подвигай виртуальный адрес ползунком. Железо прибавляет base и проверяет bound: вышел за границу — segfault.
Проверь себя· Трансляция адресов: base-and-bounds

Процесс имеет 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 (на код, кучу, стек), не резервируя пустоту между ними, и убирает основную внутреннюю фрагментацию.
API памяти: стек vs куча, утечки и escapeСегментация и фрагментация