28. Page Table, MMU и трансляция адресов

Иллюзия прямого доступа

Мы пишем код на Go, создаем слайсы, мапы и работаем с указателями. Когда мы выводим адрес переменной через выражение fmt.Printf("%p", &myVar), мы видим аккуратное шестнадцатеричное значение вроде 0xc0000a4000. Нам кажется, что это конкретная ячейка в планке нашей DDR5 памяти.

В статье 27. Виртуальная память. Взгляд со стороны железа мы выяснили, что это иллюзия. Этот адрес — виртуальный. Процессор и операционная система договорились обманывать каждую программу, предоставляя ей собственное, изолированное и непрерывное адресное пространство.

Но как именно процессор на лету, миллиарды раз в секунду, превращает иллюзорный адрес 0xc0000a4000 в реальный физический адрес, по которому ток потечет в чипы памяти? За эту магию отвечает аппаратный блок MMU, а правит бал структура данных под названием Page Table.


MMU: Memory Management Unit

MMU (Блок управления памятью) — это аппаратный компонент, который сегодня встроен прямо в кристалл CPU (обычно располагается рядом с ядрами и кэшами). Его единственная задача — перехватывать любое обращение процессора к виртуальной памяти и транслировать этот адрес в физический.

Когда ваша программа на Go выполняет чтение по указателю:

Go
val := *ptr // ptr содержит виртуальный адрес 0xc0000a4000

CPU не отправляет 0xc0000a4000 на шину памяти. Он сначала отдает его MMU. MMU должен найти соответствующий физический адрес, и только после этого происходит фактическое обращение к L1-кэшу или оперативной памяти.

Чтобы выполнить трансляцию, MMU использует карту перевода — Page Table (Таблицу страниц), которая хранится в самой оперативной памяти и управляется ядром ОС (Linux, Windows, macOS).


Анатомия адреса: VPN и Offset

Память не транслируется побайтово — это было бы безумием (таблица трансляции заняла бы больше места, чем сама память). Вместо этого виртуальная и физическая память бьется на блоки фиксированного размера — Страницы (Pages).

Стандартный размер страницы в современных системах x86_64 и ARM64 составляет 4 КБ (4096 байт).

Любой 64-битный виртуальный адрес технически разделен на две логические части:

  1. Смещение внутри страницы (Offset): Младшие биты адреса. Для 4 КБ страницы нужно 12 бит ($2^{12} = 4096$). Это смещение не меняется при трансляции — 5-й байт виртуальной страницы останется 5-м байтом в физическом фрейме.
  2. Виртуальный номер страницы (VPN - Virtual Page Number): Оставшиеся старшие биты адреса. Именно этот номер MMU должен перевести в PFN (Physical Frame Number — физический номер страницы).
DIAGRAM
Анатомия 48-битного канонического виртуального адреса:
┌────────────────────────────────────────────────────────┬─────────────┐
│          Virtual Page Number (VPN) — 36 бит            │ Offset 12 b │
│      [PML4: 9b]  [PDP: 9b]  [PD: 9b]  [PT: 9b]         │   0..4095   │
└────────────────────────────────────────────────────────┴─────────────┘
 47                                                    12 11           0

Проблема плоской таблицы и Многоуровневый Page Table

Представим, что мы решили хранить Page Table как обычный массив (slice в терминах Go), где индекс — это VPN, а значение — физический адрес.

Если у нас 48-битный адрес и 12 бит под Offset, то под VPN остается 36 бит.
$2^{36}$ — это около 68 миллиардов страниц. Каждая запись (PTE - Page Table Entry) весит 8 байт.
$$68 \times 10^9 \times 8\text{ байт} \approx \mathbf{512\text{ Гигабайт}}!$$

И это на каждый запущенный процесс! Плоская таблица страниц физически невозможна: сервер с десятком Go-микросервисов потребовал бы терабайты оперативной памяти только на хранение самих таблиц адресов.

Дерево трансляции (Radix Tree)

Инженеры решили эту проблему элегантно: они сделали таблицу иерархической (многоуровневой). В архитектуре x86_64 используется 4-уровневая таблица страниц (4-Level Paging).

36 бит виртуального номера страницы бьются на 4 части по 9 бит. Каждый уровень — это массив ровно из 512 записей ($2^9 = 512$), который идеально помещается в одну страницу 4 КБ ($512 \times 8\text{ байт} = 4096\text{ байт}$).

  1. PML4 (Page Map Level 4) — корень дерева процесса
  2. PDP (Page Directory Pointer) — указатель на директорию страниц
  3. PD (Page Directory) — директория страниц
  4. PT (Page Table) — таблица страниц 4-го уровня, хранящая физические адреса (PFN)

Дерево строится динамически (разреженное дерево — Sparse Radix Tree). Если процесс не использует какой-то огромный диапазон памяти, промежуточные ветви и таблицы просто не создаются! Для типичного Go-микросервиса таблицы страниц занимают всего пару мегабайт физической памяти.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    CR3["Регистр CR3<br>базовый адрес PML4"]:::process --> PML4["PML4<br>Уровень 1"]
    PML4 -->|"биты 39-47 (9b)"| PDP["PDP<br>Уровень 2"]
    PDP -->|"биты 30-38 (9b)"| PD["PD<br>Уровень 3"]
    PD -->|"биты 21-29 (9b)"| PT["PT<br>Уровень 4"]
    PT -->|"биты 12-20 (9b)"| PHY["Физическая память<br>Фрейм 4 КБ (PFN)"]
    PHY -->|"Offset биты 0-11"| DATA["Конкретный байт<br>данных"]
  

Как происходит трансляция (Hardware Page Walk):

  1. В процессоре есть специальный регистр CR3, в который ОС записывает физический адрес корня дерева (PML4) текущего процесса.
  2. При переключении контекста (когда планировщик ОС меняет процесс), значение CR3 меняется. Это и есть та самая магия изоляции процессов.
  3. MMU берет первые 9 бит адреса (биты 39–47), использует их как индекс в массиве PML4 и достает базовый адрес таблицы PDP.
  4. И так далее по цепочке, пока не доберется до PT, где лежит физический адрес (PFN) и флаги прав доступа (Read/Write/Execute, Present, User/Supervisor).
  5. Склеивает PFN и Offset = физический адрес готов.

Page Fault: Когда страницы нет

Каждая запись в таблице страниц (PTE) содержит служебные биты, главнейший из которых — флаг Present (Присутствует).

Что, если Go-программа обращается к адресу, а MMU доходит до уровня PT и видит, что Present == 0?

Происходит Page Fault (Страничный сбой) — аппаратное исключение процессора.
Конвейер немедленно замораживает выполнение инструкции, переключается в Ring 0 (Kernel Space) и вызывает системный обработчик Page Fault в ядре операционной системы.

Страничные сбои делятся на две фундаментальные категории:

  1. Minor Page Fault (Мягкий): Память зарезервирована в виртуальном пространстве, но ОС еще не выдала под нее физический фрейм (Demand Paging), либо фрейм уже находится в памяти (в page cache ядра). Ядро за доли микросекунды находит свободный фрейм, обновляет запись в Page Table (выставляет Present = 1) и бесшовно возвращает управление программе.
  2. Major Page Fault (Жесткий): Нужных данных физически нет в оперативной памяти, их необходимо поднимать со вторичного накопителя — например, с диска при вытеснении страницы в раздел подкачки (swap) или при чтении файла через отображение памяти (mmap). Это тяжелая блокирующая операция: процесс засыпает на миллисекунды, пока диск не завершит ввод-вывод.

Mechanical Sympathy: Как это влияет на Go?

Понимание MMU и Page Tables критически важно для бэкендера, когда речь заходит о масштабировании аллокаций памяти и профилировании сервисов.

Ленивая аллокация (Demand Paging)

Когда вы делаете make([]byte, 1<<30) (выделяете 1 ГБ памяти), Go рантайм запрашивает эту память у ОС через системный вызов mmap.

Но вот в чем фокус: ОС не выделяет вам 1 ГБ физической памяти (RAM). Она лишь обновляет свои внутренние структуры (список областей VMA в структуре mm_struct ядра Linux) и говорит рантайму: «Хорошо, вот тебе виртуальный адрес, считай, что гигабайт твой». В таблице страниц эти адреса остаются с флагом Present = 0.

Настоящая память выделяется только в тот момент, когда вы начнете писать в этот слайс. При каждой записи в новую 4 КБ страницу будет происходить Minor Page Fault, ОС будет находить свободный физический фрейм, прописывать его в Page Table и возвращать управление.

Сборщик мусора и возврат памяти

Когда Go GC собирает мусор и видит, что огромный кусок памяти (mspan) больше не нужен, он не просто помечает его свободным внутри себя.

Чтобы вернуть память операционной системе, фоновый процесс очистки (Scavenger) вызывает сисколл madvise(MADV_DONTNEED).
Это приказывает ядру Linux обнулить физические фреймы и сбросить флаг Present в таблице страниц этого процесса. Виртуальные адреса остаются зарезервированными у Go, но физическая память возвращается в общий пул операционной системы (что приводит к мгновенному падению метрики RSS процесса в мониторинге Prometheus/Grafana).


Итог и Цена абстракции

Многоуровневые таблицы страниц и аппаратный MMU — гениальное архитектурное изобретение, обеспечивающее:

  1. Полную аппаратную изоляцию процессов (у каждого процесса свой корень CR3 и персональное дерево страниц).
  2. Ленивое выделение памяти (Demand Paging), экономящее гигабайты RAM.
  3. Полное устранение внешней фрагментации оперативной памяти.

Но давайте подсчитаем цену этой гибкости:
Чтобы процессор прочитал всего 1 байт по неизвестному виртуальному адресу, MMU обязан выполнить 4 последовательных чтения из оперативной памяти (чтение PML4, затем PDP, затем PD, затем PT), и только 5-е обращение доставит процессору полезные данные!

Вспомним задержки: оперативная память DRAM отвечает за 60–100 наносекунд. Умножьте это на 5 — и в худшем случае, когда ни одна запись таблицы страниц не лежит в кэшах, обращение растянулось бы на сотни наносекунд! (На практике записи таблиц страниц кэшируются в обычных кэшах данных и в специальных кэшах страничных структур, так что типичный обход обходится в десятки наносекунд — об этом в следующей статье.)

Как микроэлектроника спасает мир от этой чудовищной деградации? Что такое Translation Lookaside Buffer и почему частые переключения контекста убивают его эффективность?

Ответы ждут нас в следующей статье: 29. TLB. Translation Lookaside Buffer и стоимость промаха.