28. Page Table, MMU и трансляция адресов
Иллюзия прямого доступа
Мы пишем код на Go, создаем слайсы, мапы и работаем с указателями. Когда мы выводим адрес переменной через выражение fmt.Printf("%p", &myVar), мы видим аккуратное шестнадцатеричное значение вроде 0xc0000a4000. Нам кажется, что это конкретная ячейка в планке нашей DDR5 памяти.
В статье 27. Виртуальная память. Взгляд со стороны железа мы выяснили, что это иллюзия. Этот адрес — виртуальный. Процессор и операционная система договорились обманывать каждую программу, предоставляя ей собственное, изолированное и непрерывное адресное пространство.
Но как именно процессор на лету, миллиарды раз в секунду, превращает иллюзорный адрес 0xc0000a4000 в реальный физический адрес, по которому ток потечет в чипы памяти? За эту магию отвечает аппаратный блок MMU, а правит бал структура данных под названием Page Table.
MMU: Memory Management Unit
MMU (Блок управления памятью) — это аппаратный компонент, который сегодня встроен прямо в кристалл CPU (обычно располагается рядом с ядрами и кэшами). Его единственная задача — перехватывать любое обращение процессора к виртуальной памяти и транслировать этот адрес в физический.
Когда ваша программа на 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-битный виртуальный адрес технически разделен на две логические части:
- Смещение внутри страницы (Offset): Младшие биты адреса. Для 4 КБ страницы нужно 12 бит ($2^{12} = 4096$). Это смещение не меняется при трансляции — 5-й байт виртуальной страницы останется 5-м байтом в физическом фрейме.
- Виртуальный номер страницы (VPN - Virtual Page Number): Оставшиеся старшие биты адреса. Именно этот номер MMU должен перевести в PFN (Physical Frame Number — физический номер страницы).
Анатомия 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{ байт}$).
- PML4 (Page Map Level 4) — корень дерева процесса
- PDP (Page Directory Pointer) — указатель на директорию страниц
- PD (Page Directory) — директория страниц
- 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):
- В процессоре есть специальный регистр CR3, в который ОС записывает физический адрес корня дерева (PML4) текущего процесса.
- При переключении контекста (когда планировщик ОС меняет процесс), значение CR3 меняется. Это и есть та самая магия изоляции процессов.
- MMU берет первые 9 бит адреса (биты 39–47), использует их как индекс в массиве PML4 и достает базовый адрес таблицы PDP.
- И так далее по цепочке, пока не доберется до PT, где лежит физический адрес (PFN) и флаги прав доступа (Read/Write/Execute, Present, User/Supervisor).
- Склеивает PFN и Offset = физический адрес готов.
Page Fault: Когда страницы нет
Каждая запись в таблице страниц (PTE) содержит служебные биты, главнейший из которых — флаг Present (Присутствует).
Что, если Go-программа обращается к адресу, а MMU доходит до уровня PT и видит, что Present == 0?
Происходит Page Fault (Страничный сбой) — аппаратное исключение процессора.
Конвейер немедленно замораживает выполнение инструкции, переключается в Ring 0 (Kernel Space) и вызывает системный обработчик Page Fault в ядре операционной системы.
Страничные сбои делятся на две фундаментальные категории:
- Minor Page Fault (Мягкий): Память зарезервирована в виртуальном пространстве, но ОС еще не выдала под нее физический фрейм (Demand Paging), либо фрейм уже находится в памяти (в page cache ядра). Ядро за доли микросекунды находит свободный фрейм, обновляет запись в Page Table (выставляет
Present = 1) и бесшовно возвращает управление программе. - 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 — гениальное архитектурное изобретение, обеспечивающее:
- Полную аппаратную изоляцию процессов (у каждого процесса свой корень CR3 и персональное дерево страниц).
- Ленивое выделение памяти (Demand Paging), экономящее гигабайты RAM.
- Полное устранение внешней фрагментации оперативной памяти.
Но давайте подсчитаем цену этой гибкости:
Чтобы процессор прочитал всего 1 байт по неизвестному виртуальному адресу, MMU обязан выполнить 4 последовательных чтения из оперативной памяти (чтение PML4, затем PDP, затем PD, затем PT), и только 5-е обращение доставит процессору полезные данные!
Вспомним задержки: оперативная память DRAM отвечает за 60–100 наносекунд. Умножьте это на 5 — и в худшем случае, когда ни одна запись таблицы страниц не лежит в кэшах, обращение растянулось бы на сотни наносекунд! (На практике записи таблиц страниц кэшируются в обычных кэшах данных и в специальных кэшах страничных структур, так что типичный обход обходится в десятки наносекунд — об этом в следующей статье.)
Как микроэлектроника спасает мир от этой чудовищной деградации? Что такое Translation Lookaside Buffer и почему частые переключения контекста убивают его эффективность?
Ответы ждут нас в следующей статье: 29. TLB. Translation Lookaside Buffer и стоимость промаха.