---
tags: [kernel]
verified: 2026-09-06
---

# Адресное пространство: зачем трансляция и какие есть альтернативы

## Три задачи в одном механизме

Страничная память выглядит одним механизмом, и в учебниках её обычно так и подают: есть таблица, она превращает виртуальный адрес в физический, попутно проверяя права. На самом деле в этот механизм связаны три независимые задачи.

**Релокация.** Программа собрана под какие-то адреса, но не знает, по какому адресу окажется в момент запуска: там может лежать другая программа, или ядро, или ничего.

**Защита.** Один процесс не должен читать и портить память другого и ядра. Это задача про проверку каждого обращения, и она не требует, чтобы адрес при этом менялся.

**Абстракция над физической памятью.** Адрес не обязан соответствовать памяти вообще: пространство может быть разрежённым, одна физическая страница может быть видна по нескольким адресам сразу, запись может порождать копию, страница может подгружаться с диска в момент первого обращения или уезжать обратно.

Это разделение не академическое. Первые две задачи — про сдвиг и про проверку; их решают арифметикой над адресом и сравнением с границей, и железа для этого нужно немного, а иногда не нужно вовсе. Третья — про отображение: соответствие «адрес → физическая память» должно быть частичным (адрес может не значить ничего), многие-к-одному (несколько адресов на одну страницу) и меняться на лету. Хранить такое соответствие негде, кроме таблицы, и просматривать её на каждом обращении может только железо. Отсюда простой критерий, к которому сводится вся статья: **трансляция нужна ради третьей задачи; первые две за неё не платят, а получают бесплатно, потому что механизм всё равно есть.**

Статья открывает цикл про устройство операционных систем и разбирает пространство решений целиком — от систем без всякой трансляции до единого адресного пространства на всю машину. Механика самих таблиц страниц, разрядность, форматы записей и TLB — предмет следующей статьи цикла, здесь они появляются только там, где определяют цену. Состав и порядок цикла — в [плане](./os-knowledge-base-plan.md).

## Релокация: адрес, по которому программа не обязана лежать

Способов не зависеть от адреса загрузки четыре, и полезно видеть, чем каждый платит.

**Фиксированный адрес.** Программа собрана под конкретный адрес и грузится только туда. Так устроен CP/M: транзитная область программ (TPA) начинается с 0100H, ниже лежит страница с системными данными и точкой входа в BDOS по адресу 0005H, и загруженная программа всегда оказывается в одном и том же месте. То же у формата `.COM` в DOS. Это работает, пока в памяти ровно одна прикладная программа; вторая программа с тем же адресом загрузки уже не помещается.

**Правка адресов при загрузке.** Файл несёт список мест, где записаны абсолютные адреса, и загрузчик проходит по этому списку, прибавляя фактический адрес загрузки. Так работает таблица перемещений в формате MZ (`.EXE` в DOS), так же — динамические перемещения `R_*_RELATIVE` в ELF, и ровно так же ядро Linux правит само себя, когда включён KASLR: чтобы образ можно было положить по случайному адресу, он должен быть перемещаемым. Цена в том, что загрузка перестаёт быть чистым отображением файла в память: страницы кода изменяются, а значит их либо нельзя разделить между процессами, либо нужен механизм копирования при записи, то есть та самая третья задача.

**Позиционно-независимый код.** Программа обращается к своим данным относительно счётчика команд, а к внешним символам — через таблицу указателей, заполняемую при загрузке (GOT). Требование здесь к системе команд, а не к MMU, и это заметно по истории: на 32-битном x86 PC-относительной адресации данных нет, и пролог позиционно-независимой функции вынужден добывать счётчик команд трюком `call`/`pop`, а один регистр из семи навсегда уходит под базу GOT. В x86-64 появился режим RIP-относительной адресации, и цена PIC упала настолько, что позиционно-независимые исполняемые файлы стали умолчанием сборки в Fedora, Debian и Ubuntu во второй половине 2010-х. У ARM64 (`adrp`/`add`) и RISC-V (`auipc`) PC-относительная адресация есть в базовой системе команд, и вопрос «дорого ли PIE» там не стоял никогда.

**Трансляция.** Каждому процессу дают одинаковый вид на память: все программы собраны под один и тот же виртуальный адрес и все его получают. Это самый удобный способ, но самый дорогой — ради него в машине должны быть MMU, таблицы и TLB.

Вывод из этого перечня прямой: релокация — самая дешёвая из трёх задач и единственная, которую целиком закрывает тулчейн. Если механизм трансляции обосновывают тем, что «программа не знает своего адреса», обоснование несостоятельно: этот вопрос давно решён компоновщиком. Живое доказательство — Linux без MMU: не отдельная ветка, как во времена uClinux, а конфигурация mainline (`CONFIG_MMU=n`) для процессоров без блока трансляции. Там пользовательские программы обязаны исполняться по произвольному адресу, и формат для них другой: либо плоский bFLT, либо ELF FDPIC, в котором каждый сегмент размещается независимо от остальных, что позволяет разделять код между процессами, даже когда данные у каждого свои. Поддержка FDPIC в ядре есть для ARM, а также для m68k, RISC-V, SuperH и Xtensa на системах без MMU.

## Защита: проверка, а не отображение

Защиту без трансляции даёт любой механизм, умеющий на каждом обращении сравнить адрес с описанием разрешённой области. Исторически это пара регистров базы и предела: адрес программы складывается с базой и сравнивается с пределом, выход за границу — ловушка. Получается сразу и релокация (база), и изоляция (предел), причём ценой двух регистров и одного сумматора вместо дерева таблиц.

Чего этот механизм не даёт — так это всего остального. Область непрерывна, поэтому адресное пространство обязано быть плотным: между кодом, кучей и стеком нельзя оставить дыру, не оплатив её физической памятью. Разделить кусок между двумя процессами нечем: пара «база, предел» описывает ровно один диапазон. Расти можно только вверх и только если сверху свободно, а физическая память при этом неизбежно фрагментируется внешне — как куча из блоков разного размера, которую нельзя дефрагментировать, не двигая работающие программы.

Из процессоров общего назначения база и предел исчезли не потому, что защита стала не нужна, а потому что рядом появился механизм, который решает и её тоже, — страницы. Но сам подход никуда не делся: он вернулся в микроконтроллеры, где трансляции нет и не должно быть.

### Ключи защиты памяти

Та же мысль — защита есть проверка, а не отображение — дожила на процессорах общего назначения и до сегодняшнего дня, и живёт она в ключах защиты памяти. Intel MPK отводит под номер ключа четыре бита в каждой записи таблицы страниц — отсюда шестнадцать ключей, — а сами права на ключ держит в регистре `PKRU`, своём у каждого потока; ARM делает то же расширением FEAT_S1POE (Permission Overlay Extension) тремя битами записи, восемью ключами и регистром `POR_EL0`. Существенно здесь то, кто и какой ценой меняет права: страница остаётся отображённой там же и с теми же правами в таблице, переписывается только маска в регистре — одной инструкцией в пользовательском режиме, без системного вызова и без инвалидации TLB, потому что отображение не менялось. Linux подаёт обе реализации одним интерфейсом: ключ выделяется `pkey_alloc()`, приписывается страницам через `pkey_mprotect()`, дальше домены переключает сама программа. Отсюда и область применения — ровно та, где обычный `mprotect()` слишком дорог: Wasmtime документирует ключи как способ убрать сторожевые области между экземплярами в пуловом аллокаторе, то есть как прямой ответ на дефицит виртуальных адресов, в который упирается быстрая песочница (о ней ниже). Есть у ключей и применение, где они закрывают дыру в самих таблицах: страницу «исполняемая, но нечитаемая» x86-64 записью таблицы выразить не может, и Linux собирает её из ключа с запретом доступа — подробности в [статье про страничную трансляцию](./os-paging-and-tlb.md). Ограничений три: ключей мало, и это тот же дефицит, что у регионов на микроконтроллерах; x86-вариант правами на выборку инструкций не управляет, а ARM управляет; надзорный близнец PKS для страниц ядра у Intel описан, но mainline-ядро из ключей знает только пользовательский `pku`. Паритет трёх архитектур даёт здесь содержательную асимметрию: у RISC-V ратифицированного эквивалента нет — ни поля ключа в записи таблицы, ни регистра прав, — и предложения вроде Donky остаются исследовательскими.

## Абстракция: то, ради чего замены трансляции нет

Третья задача отличается от первых двух тем, что требует от соответствия «адрес → память» четырёх свойств сразу, и ни одно из них не получается ни сдвигом, ни сравнением.

Соответствие должно быть **частичным**: большая часть адресов не значит ничего. Оно должно быть **многие-к-одному**: одна физическая страница видна по нескольким адресам, возможно в разных процессах. Оно должно **меняться во время работы**, причём незаметно для программы, которая продолжает пользоваться теми же адресами. И, самое главное, обращение по адресу, за которым сейчас ничего нет, должно порождать **отказ, обрабатываемый операционной системой** и возобновляющий прерванную инструкцию.

Дальше вся привычная функциональность собирается из этих четырёх свойств, и полезно видеть, какое свойство за что отвечает. Разрежённость даёт сторожевые страницы, которыми ловят переполнение стека, и позволяет стеку расти по требованию, а рандомизации раскладки — расставлять области далеко друг от друга бесплатно. Многие-к-одному даёт разделяемые библиотеки, разделяемую память и объединение одинаковых страниц. Многие-к-одному вместе с отказом по записи даёт копирование при записи, а с ним `fork()`, который иначе означал бы копирование всей памяти. Отказ по чтению даёт отображение файла и подкачку по требованию, из-за чего страничный кэш и анонимная память оказываются одним механизмом. Изменчивость плюс отказ дают вытеснение и отложенное выделение, то есть возможность обещать больше памяти, чем есть.

До появления трансляции всё это решали статически: программу резали на оверлеи, которые она сама подгружала друг на друга, а объём сверх адресуемого доставали переключением банков. Разница не в удобстве, а в том, кто принимает решение. Без отказа оно принимается заранее, автором программы, в терминах кусков кода; с отказом — операционной системой в момент обращения, в терминах страниц, и одинаково для всех программ. Перехватить обращение к памяти без железа нельзя иначе как проверкой, вставленной в сам код, — а это уже программная изоляция, к которой статья вернётся ниже. Показательно, что она приходит к тому же: быстрая реализация WebAssembly отказывается от собственных проверок и опирается на страничный отказ.

## Сегментация: правильная идея с неудачной ценой

Сегментация — попытка дать не один диапазон, а много, и сделать каждый самостоятельным объектом со своими правами. В самой честной своей реализации, Multics, адрес состоял из пары «номер сегмента, смещение в словах», номеров было 2¹⁸, у каждого сегмента был дескриптор с длиной, расположением и правами доступа, проверявшимися железом на каждом обращении, а сегменты при этом ещё и разбивались на страницы, потому что иначе их пришлось бы целиком держать в памяти. Сегмент был не куском адресного пространства, а объектом файловой системы с именем и правами; связывание с ним происходило динамически, при первом обращении.

Это ровно та модель, которой сегодня не хватает: единица защиты совпадает с единицей смысла, права принадлежат объекту, а не диапазону адресов. Проиграла она не идейно, а по цене. Сегмент был ограничен по размеру жёстче, чем поле смещения в адресе: в реализации, описанной в 1972 году, — 64 страницы по 1024 слова, и объект, не влезающий в сегмент, приходилось разбивать вручную. Адрес перестал быть числом: пара «сегмент, смещение» не складывается с целым и не сравнивается с указателем в другой сегмент, а языки, на которых стали писать системы, устроены вокруг плоского указателя, который есть просто число. Страницы же дали всю нужную гибкость, ничего не потребовав ни от языка, ни от программиста.

В x86 сегментация прожила дольше всех и в двух несовместимых видах. В реальном режиме 8086 сегмент — это только релокация: адрес считается как сегмент, умноженный на 16, плюс смещение, и никакой защиты в этом нет вообще. В защищённом режиме 80286 и 80386 сегмент получил дескриптор с базой, пределом и правами — то есть стал честным механизмом изоляции, к которому в 386 добавились ещё и страницы. Операционные системы этим почти не воспользовались: и Unix-подобные системы, и Windows выставили сегменты на всё адресное пространство и всю работу переложили на страницы. Причина всё та же — переносимость и плоский указатель: механизм, которого нет на других архитектурах, в переносимом ядре можно только не использовать.

### Что уцелело в x86-64

Документация ядра формулирует итог без обиняков: «В 64-битном режиме сегменты CS/SS/DS/ES игнорируются, и база всегда равна нулю, чтобы дать полное 64-битное адресное пространство. Сегменты FS и GS в 64-битном режиме продолжают работать». Предел не проверяется, база не прибавляется — от механизма остались два регистра базы, и они превратились в то, чем пользуется каждое ядро и каждая многопоточная программа.

FS держит базу локальной памяти потока: переменные с `__thread` компилятор адресует с префиксом `%fs:`, у каждого потока своя база, и один и тот же код обращается к своему экземпляру переменной без единого вычисления адреса. GS архитектурой ни за кем не закреплён, и ядро занимает его под данные, свои для каждого процессора: при входе в ядро инструкция `swapgs` меняет местами текущую базу GS и значение из MSR `KERNEL_GS_BASE`, после чего `%gs:`-адресация указывает на структуры этого процессора, а не на пользовательский TLS.

```bash
# обращение к __thread-переменной на x86-64
$ objdump -d prog | grep 'fs:'
  mov    %fs:0xfffffffffffffffc,%eax
```

Читать и писать базы можно системным вызовом `arch_prctl()` — он есть на всех 64-битных процессорах и во всех версиях ядра — либо инструкциями `RDFSBASE`/`WRFSBASE`/`RDGSBASE`/`WRGSBASE`, появившимися у Intel с поколения Ivy Bridge и поддержанными AMD начиная с семейства 17h. Инструкции доступны не автоматически: ядро обязано включить их в CR4, а программа узнаёт о доступности по флагу `fsgsbase` в `/proc/cpuinfo` и по биту `HWCAP2_FSGSBASE` во вспомогательном векторе ELF.

Полезнее всего здесь сравнение с соседними архитектурами, потому что оно показывает: уцелевший кусок сегментации — не механизм, а просто регистр. Ту же работу ARM64 делает системными регистрами `TPIDR_EL0` (указатель потока для пользовательского кода, ровно то же, что FS) и `TPIDR_EL1` (данные ядра), а RISC-V — обычным регистром `tp` из целочисленного файла, за которым ABI закрепил роль указателя потока. Ни там, ни там для этого не понадобилось хранить остаток сегментной модели: нужна была одна база на поток, и её дали регистром.

Отсюда же видно, почему остальная сегментация в x86-64 никого не интересует и почему её пытались убрать. В 2023 году Intel предложила X86S — вариант архитектуры без 16-битного режима, без колец 1 и 2 и без части наследия сегментации вроде гейтов; в конце 2024 года инициатива была свёрнута в пользу совместной с AMD консультативной группы по экосистеме x86, объявленной 15 октября 2024 года. Отказ — заявление самой компании, а не пересказ слухов: прессе Intel сказала, что «отошла от инициативы X86S», сохранив фокус на развитии экосистемы x86, а её собственный документ с описанием X86S сегодня открывается признанием, что по итогам отзывов экосистемы развивать его она не будет. Не датировано только само решение: документ даты не несёт, и она известна по публикации в прессе.

## Регионы вместо страниц: MPU и PMP

![Путь адреса в пяти моделях: без трансляции адрес идёт в память как есть; регионы MPU и PMP адрес не меняют, а проверяют его рядом с обращением; база и предел прибавляют базу и отдельно сравнивают с границей; сегментация по номеру сегмента берёт дескриптор и проверяет длину и права; страничная трансляция ищет запись через TLB и собирает адрес заново, а промах и отсутствие записи отдаёт операционной системе. Видно, где адрес меняется, где только проверяется и стоит ли проверка на пути адреса](./assets/os-address-space-models-address-path.svg)

На микроконтроллерах трансляции нет, а изоляция нужна — и там живёт прямой потомок базы и предела, размноженный до нескольких регионов.

В Armv8-M это блок защиты памяти (MPU) архитектурного расширения PMSAv8. Регион задаётся двумя регистрами — базой (`MPU_RBAR`) и пределом (`MPU_RLAR`); минимальный размер региона 32 байта, начало и конец выровнены по 32 байтам, максимум — все 4 ГиБ, и никакой трансляции при этом не происходит: адрес идёт в память как есть, MPU только разрешает или запрещает обращение. Число регионов выбирает изготовитель кристалла: типичная реализация даёт от нуля до восьми на состояние безопасности, а нынешние ядра Cortex-M33, M55 и M85 — до шестнадцати. Разница с Armv7-M поучительна: там регион был обязан иметь размер, равный степени двойки, и выравниваться по собственному размеру, зато регионы могли перекрываться, а приоритет решался номером; в Armv8-M выравнивание послабили до 32 байт, а перекрытие вовсе запретили — при гибком задании границ оно перестало быть нужным.

RISC-V решает ту же задачу механизмом PMP. Он тоже проверяет, а не переводит, но проверяет физические адреса — то есть работает после трансляции, если она вообще включена. Записей поддерживается до 64 (реализация вправе иметь ноль, 16 или 64), регион задаётся либо парой записей по схеме «верхняя граница диапазона» (TOR), либо одной записью в виде естественно выровненной степени двойки (NAPOT), стандартное кодирование добирается до четырёх байт. Важнее всего умолчание, сформулированное спецификацией прямо: PMP «выдаёт разрешения режимам S и U, которые по умолчанию не имеют никаких, и отбирает их у режима M, который по умолчанию имеет все». Проверка распространяется и на обращения к таблицам страниц при трансляции.

Практическая цена регионной защиты — не производительность, а дефицит: регионов единицы, и они кончаются. У FreeRTOS-MPU непривилегированная задача получает свой стек плюс до трёх настраиваемых регионов, и это весь её мир. Zephyr документирует то же ограничение в общем виде: «Максимальное число разделов памяти в домене ограничено числом доступных регионов MPU», разделы внутри домена не могут перекрываться, а на многих MPU обязаны иметь размер, равный степени двойки, и выравниваться по нему. Там же видно, во что обходится отсутствие разрежённого пространства: сторожевую страницу под стеком, которая на системе со страницами достаётся бесплатно простым пропуском отображения, приходится делать отдельным регионом MPU только для чтения — то есть тратить на неё дефицитный ресурс.

Такова же общая логика выбора: на микроконтроллере регионы — правильный ответ не потому, что дёшевы, а потому что дают детерминированное время доступа. Промаха TLB не бывает, обращение к памяти стоит ровно столько же, сколько без защиты, и худший случай считается статически. Для системы, где отвечать нужно за микросекунды, это весомее любых удобств отображения.

## Что каждая модель даёт и чего не даёт

| Модель | Релокация | Изоляция | Разрежённое пространство | Разделение и CoW | Чем платит |
| --- | --- | --- | --- | --- | --- |
| Без трансляции | только компоновкой или PIC | нет | нет | нет | ничем; ничего и не даёт |
| База и предел | есть | есть | нет | нет | внешняя фрагментация физической памяти |
| Сегментация | есть | есть, по объектам | частично | есть, посегментно | адрес перестаёт быть числом |
| Регионы (MPU, PMP) | нет | есть | нет | только грубо, целыми регионами | дефицит регионов |
| Страницы | есть | есть | есть | есть | TLB, промахи, инвалидация |

Читать таблицу стоит по последнему столбцу: он объясняет, почему на процессорах общего назначения выбора фактически нет, а на микроконтроллерах он есть. Полноценно разрежённое адресное пространство даёт только последняя строка. Ближе всех к ней сегментация: сегментов 2¹⁸ и каждый разбит на страницы, так что дыры между объектами достаются бесплатно, — но выкроить их можно лишь по границам сегментов, а внутри сегмент упирается в собственный предел. Остальные модели обязаны быть плотными, а без разрежённости нет ни дешёвых сторожевых страниц, ни `fork`, ни отображения файла, ни отложенного выделения. Все они при этом не платят за промах TLB, и на классе задач, где важен худший случай, а не средний, это решающий довод.

## Изоляция без участия железа

Отдельная ветка пространства решений — изоляция, которую делают не проверкой в железе, а свойствами кода. Здесь три подхода, и у них общая особенность, которую видно только если смотреть, что осталось снаружи.

![Где проходит граница изоляции в четырёх подходах: у классического процесса — между двумя адресными пространствами, и она же аппаратная; у SFI и WebAssembly — внутри одного процесса, где внутреннюю границу держит либо вставленный в код модуля контроль, либо сторожевой диапазон неотображённых страниц, а аппаратная остаётся снаружи, на процессе; у языковой изоляции — вокруг объектов одного адресного пространства, аппаратной границы нет вовсе, а компилятор входит в доверенную базу; у unikernel — вокруг всей виртуальной машины, и её держит гипервизор](./assets/os-address-space-models-software-isolation.svg)

### Программная изоляция сбоев и её потомки

Идея SFI (software fault isolation) сформулирована в 1993 году: недоверенный модуль грузят в свой участок адресного пространства приложения, а его машинный код правят так, чтобы он физически не мог сформировать адрес за пределами участка. Авторы описали две техники. Проверка сравнения (segment matching) вставляет перед каждым небезопасным обращением четыре инструкции, сверяющие старшие биты адреса с идентификатором участка, и умеет назвать виновную инструкцию. Загон адреса (sandboxing) короче — две арифметические инструкции, которые просто затирают старшие биты правильным значением: нарушение не диагностируется, а обезвреживается. Обе требуют выделенных регистров, недоступных недоверенному коду, иначе проверку можно обойти прыжком мимо неё.

Цена по замерам самих авторов на SPEC92: 4,3 % в среднем за изоляцию записи и переходов и 21,8 % — за полную защиту, включающую и чтение. Взамен вызов через границу доверия перестаёт быть переключением контекста и стоит около микросекунды на машинах того времени, на порядок меньше любого тогдашнего RPC. Формулировка авторов — лучшее описание всей ветки целиком: «мы замедляем частый случай (обычное исполнение), чтобы ускорить редкий (переход через границу)».

Массовая реализация той же идеи сегодня — WebAssembly. Память модуля — это линейное пространство, обращения в которое индексируются от нуля, и проверка границ вроде бы обязана быть на каждом доступе. Дальше начинается самое интересное для этой статьи. При 32-битной индексации максимальный адрес модуля не превышает 4 ГиБ, и среда исполнения резервирует под линейную память ровно такой диапазон виртуальных адресов плюс сторожевую область за ним; всё, что модуль вообще способен адресовать, попадает либо в его память, либо в неотображённые страницы. После этого проверку границ можно не генерировать вовсе: нарушение поймает страничный отказ. Документация Wasmtime описывает это прямо — резерв в 4 ГиБ и обработка ловушек по сигналу нужны, чтобы проверки убрать.

То есть быстрая программная песочница быстра потому, что берёт взаймы у MMU. Убедиться в этом проще всего от противного: у 64-битной памяти (предложение memory64, вышедшее на четвёртую фазу и появившееся в Chrome 133 и Firefox 134) трюк не работает — зарезервировать 2⁶⁴ адресов нельзя, — и явные проверки возвращаются вместе с замедлением, которое реализаторы SpiderMonkey оценивают в диапазоне от 10 % до более чем 100 % на разных нагрузках. Цена статического резерва тоже реальна: виртуальные адреса кончаются, и число одновременно живущих экземпляров упирается в них раньше, чем в физическую память.

### Языковая изоляция

Singularity довела идею до конца: границы процессов там проведены системой типов, а не железом. Программно изолированный процесс (SIP) не может разделять с другим изменяемую память, не может динамически загружать или порождать код внутри себя, а обмен данными идёт через каналы с передачей исключительного владения через специальную обменную кучу. Раз недоверенный код проверен статически и заведомо не содержит привилегированных инструкций, все процессы можно исполнять в одном адресном пространстве и в нулевом кольце.

Выигрыш измерен авторами и он велик: минимальный вызов ядра — 80 тактов против 437 у Linux 2.6.11, 627 у Windows XP и 878 у FreeBSD 5.3 на одной и той же машине (Athlon 64 3000+); переключение с пользовательского кода одного SIP на код другого — от 394 тактов; создание процесса — 388 тысяч тактов против 719 тысяч у Linux и 5,4 миллиона у Windows, потому что не нужно ни строить таблицы страниц, ни сбрасывать TLB.

Ценность этого эксперимента, однако, не в таблице, а в том, что та же группа реализовала в Singularity и аппаратную изоляцию тоже — и сравнила на одной кодовой базе. Результат: аппаратная изоляция обходится в 25–33 % на их тестах, программная — менее чем в 5 %. И там же сказано, чем это оплачено: доверенная база пополняется оптимизирующим компилятором Bartok, который обязан не только проверить типобезопасность, но и не сломать её в ходе оптимизаций, а также рантаймом и сборщиком мусора, написанными на небезопасном коде. Собственный вывод авторов стоит того, чтобы его повторить: аппаратная изоляция остаётся ценной как эшелонированная защита — именно на случай ошибок в механизме программной изоляции, полную верификацию которого они не считали достижимой.

### Библиотечные ОС и unikernel'ы

Третий подход не изолирует вообще ничего: приложение и ядро компонуются в один образ, исполняются в одном адресном пространстве и на одном уровне привилегий, а системный вызов становится вызовом функции. Изоляция при этом не исчезает — она вынесена наружу, в гипервизор: снаружи образ выглядит виртуальной машиной, и границу держит вторая ступень трансляции.

Что до применимости, честнее всего высказываются сами авторы направления. В ретроспективе двенадцати лет MirageOS сказано прямо: unikernel как продукт рынка не занял — облако выиграли контейнеры, — зато библиотеки уехали дальше, чем кто-либо ожидал, и сетевой стек MirageOS попал в Docker Desktop через vpnkit, где им ежедневно пользуются миллионы разработчиков; Unikraft тем временем увёл модель развёртывания в POSIX-совместимую сторону под serverless. Интереснее другой вывод той же ретроспективы — про то, где свойства unikernel'а перестают быть оптимизацией и становятся требованием: бортовое ПО спутника поставляется фиксированным образом, оболочки на орбите нет, конфигурацию после запуска не меняют, и «никакого неиспользуемого кода» — условие квалификации, а не пожелание. Туда линия и ушла: спутники, аппаратные модули безопасности, встроенные контроллеры.

Претензии к безопасности при этом не сняты. Обзор состояния направления, написанный командой Unikraft в 2021 году, формулирует их без снисхождения: заявления о «сильной безопасности» unikernel'ов были раздуты, и «большинство прошлых реализаций не имели базовых механизмов безопасности вроде стековых канареек и ASLR» — при том что маленькая доверенная база у них действительно есть.

### Вердикт по применимости

Все три подхода работают в проде, но ни один — вместо аппаратной изоляции. WebAssembly в браузере исполняется внутри процесса, который сам изолирован ядром, и его дешевизна на 32-битной памяти прямо опирается на сторожевые страницы MMU. Unikernel по построению нуждается в гипервизоре. Языковая изоляция не имеет массовых внедрений как единственная граница, а её собственные авторы называют аппаратную изоляцию нужной как второй эшелон.

Практический вывод такой: программная изоляция — это способ провести границу **внутри** аппаратной, там, где процессов слишком много или переходы слишком часты, чтобы платить за переключение контекста. Плагин в редакторе, недоверенный фильтр в прокси, пользовательская функция в СУБД — её территория. Как замена страничной защиты в ядре общего назначения она не годится, и это не вопрос зрелости технологии: ошибка в компиляторе там стоит всей системы, а компилятор верифицировать труднее, чем блок проверки прав в MMU.

## Единое адресное пространство

Отдельная развилка — не отменить трансляцию, а оставить её, но сделать адресное пространство одним на всю систему. Идея выглядит соблазнительно: 64 бита достаточно велики, чтобы адрес стал глобальным именем объекта, а не координатой внутри процесса. Тогда указатель можно передать кому угодно, разделяемая память перестаёт требовать возни с отображениями, а объект остаётся тем же самым при передаче между задачами и между машинами.

Так устроены Opal и Mungi, и различаются они тем, чем заменена привычная граница адресных пространств. В Opal адресуемость отделена от доступа: адрес виден всем одинаково, а право на сегмент определяется доменом защиты, в котором исполняется поток. В Mungi всё содержимое системы, включая долговременное хранилище, лежит в едином 64-битном пространстве, а защита строится не на разделении адресов, а на разрешениях: используются капабилити-пароли — пара из 64-битного адреса и 64-битного пароля, где пароль представляет собой случайное число, назначенное объекту системой. Выбор именно такой схемы обоснован тем, что криптографические разреженные капабилити дороги в создании и проверке.

Именно здесь видна главная особенность модели: **отняв у трансляции задачу изоляции, её приходится решать чем-то другим** — капабилити, языком или проверкой при обращении. И плата описывается тем же способом. Рандомизация раскладки теряет не всё, а самое ценное. Выбрать адрес объекта случайно в момент создания 64 бита позволяют — в Mungi по сути так и сделано, только случайность вынесена в отдельное поле пароля. Невозможно другое: перевыбирать раскладку заново на каждый процесс и на каждый запуск, потому что адрес — глобальное имя, одинаково видимое всем участникам. Вместе с перерандомизацией пропадает и локальность утечки: узнанный адрес верен для всех и навсегда, а не до следующего запуска, — поэтому защита от угадывания обязана превратиться в защиту секретом. Оборванный указатель тоже перестаёт быть локальной неприятностью: он указывает не в собственную мёртвую страницу, а в чей-то живой объект. Замечу, что это рассуждение из устройства модели, а не цитата: явного разбора несовместимости с ASLR в источниках выше я не нашёл.

Возвращается идея по двум причинам. Первая — цена смены адресного пространства на современном железе: даже с тегированными TLB (PCID на x86-64, ASID на ARM64 и в поле `satp` на RISC-V) переход стоит заметно дороже вызова функции, а после Meltdown к нему добавилась изоляция таблиц страниц. Вторая — разделение данных: в единой карте оно бесплатно, тогда как обычная система вынуждена ставить и снимать отображения. По существу к той же точке приходят и unikernel, и Wasm-рантайм с сотнями экземпляров: во всех трёх случаях адресное пространство одно, а границы проведены чем-то, кроме таблицы страниц.

Продолжение этой линии, у которого сегодня есть железо, — CHERI, где право доступа переносится в сам указатель, а не в таблицу. Проект вышел из исследовательской стадии: спецификация CHERIoT достигла версии 1.0 в ноябре 2025 года, первые кристаллы ICENI на ядре CHERIoT Ibex вернулись с фабрики к марту 2026-го, а плата Arm Morello распространяется через CHERI Alliance. Для проектировщика своей ОС это самая интересная из живых альтернатив: она отвечает ровно на вопрос «откуда взять защиту, если не из трансляции», — но требует железа, которого в массовых машинах пока нет.

Вердикт здесь такой. Единое пространство сегодня — не модель системы общего назначения, а ответ на узкую ситуацию, и признаки её перечислимы: экземпляров много, живут они недолго, переходы между ними часты настолько, что смена таблицы страниц становится заметной статьёй расхода, а снаружи есть аппаратная граница, которая держит всю конструкцию разом, — процесс, виртуальная машина или отдельная машина. Изоляцию внутри проводит тогда не таблица, а то, что дешевле её менять: капабилити (Mungi, а на нынешнем железе CHERI), система типов (Singularity) или проверки, вставленные в код (Wasm). Как только требование меняется на «одна ошибка внутри не должна стоить всего пространства», возвращается отдельная таблица на процесс — это единственный из перечисленных механизмов, чья правильность не зависит от компилятора и рантайма.

## Что чего стоит

Цена трансляции сосредоточена не в самом преобразовании, а в кэше, который делает его возможным. Попадание в TLB почти ничего не стоит; промах превращается в обход дерева таблиц, то есть в несколько обращений к памяти ради того, чтобы узнать адрес одного обращения к памяти. Дальше добавляются сброс или тегирование TLB при переключении контекста и рассылка инвалидаций другим процессорам при изменении отображения. Порядок величины — до 10–30 %, и это именно верхняя граница, а не типичное значение. Авторы работы про изоляцию в Singularity приводят её со ссылкой на измерения Jacob и Mudge, у которых само обслуживание TLB стоит 5–10 %, вытесненные из кэшей код и данные приложения удваивают эту цену до 10–20 %, а до 10–30 % её доводят прерывания, порождённые виртуальной памятью. Получено это трассовой симуляцией 1998 года на SPEC CINT95, со страницами по 4 КиБ, TLB на 128 входов для команд и столько же для данных и прямо отображаемыми кэшами; авторы там же замечают, что большие страницы снизили бы все накладные расходы разом. То есть принимать число стоит как порядок и как верхнюю оценку для машин того времени, а не как ожидаемую цену на нынешних. Устройство таблиц, разрядность и способы уменьшить эту цену — тема следующей статьи цикла.

Цена отсутствия трансляции описана в документации ядра для сборок без MMU, и она поучительно конкретна. `fork()` не существует вовсе: скопировать адресное пространство, не имея отображений, нечем, и `clone()` обязан вызываться с `CLONE_VM`. Разделяемое отображение обычного файла на запись (`MAP_SHARED`) не поддерживается, а для приватных отображений документация замечает мимоходом то, что и составляет суть режима: записи в такое отображение видны другим процессам, «потому что защиты MMU нет». Анонимное отображение обеспечивается непрерывным набором физических страниц, а весь диапазон обнуляется в момент выделения — что превращается в заметную задержку внутри `malloc()` в пользовательской программе. Выделение округляется вверх до степени двойки числа страниц, и борьба с получившейся фрагментацией вынесена в настройку `vm.nr_trim_pages`. К этому добавляется всё, чего просто нет: отложенное выделение и overcommit, вытеснение, копирование при записи, сторожевые страницы, разделение библиотек между процессами без специального формата.

Итог сравнения такой. Трансляция берёт постоянный налог с каждого обращения к памяти и превращает переключение контекста в дорогую операцию. Её отсутствие берёт разовую, но неустранимую плату: физическая память фрагментируется, размещение задач становится статическим, число задач упирается в потолок, а часть привычных механизмов ОС просто нереализуема.

## Вердикт для своей ОС

Для учебного или экспериментального ядра на x86-64, ARM64 или RISC-V выбор один — страничная трансляция, и причин три. Железо уже содержит MMU, и он оплачен независимо от вашего решения. Все три архитектуры проектировались в расчёте на страничные ОС, и всё остальное — включая PMP на RISC-V — сделано для других задач. Наконец, отказ обратим только переписыванием всего: механизмы, требующие разрежённого пространства, пронизывают ядро насквозь. Как это сформулировано в FAQ seL4, использовать систему без полноценного MMU «не имеет большого смысла, поскольку управление ресурсами в ней принципиально основано на виртуальной памяти».

Ценность разбора не в самом выборе, а в умении узнать ситуации, где верен другой ответ:

- **микроконтроллер** — регионы (MPU или PMP) вместо страниц, ради детерминированного времени доступа и отсутствия таблиц; за это платят дефицитом регионов и статической раскладкой;
- **гипервизор** — вторая ступень трансляции, где изоляция и есть единственная задача, а разрежённость гостевой памяти обеспечивается уже гостем;
- **песочница внутри процесса** — программная изоляция, потому что переходы через границу здесь частые, а границ много: платят налогом на исполнение недоверенного кода и доверием к компилятору.

И даже выбрав страницы, три задачи полезно держать раздельно. Ядро обычно решает релокацию компоновкой под фиксированный виртуальный адрес, а не трансляцией; изоляция ядра от пользователя берётся из битов прав в самой записи таблицы, а не из отдельного механизма (на x86-64 это один бит U/S, на RISC-V — бит U, а на ARM64 — биты AP вместе с разделением пространства между `TTBR0_EL1` и `TTBR1_EL1`); ради разрежённости же существует всё остальное — отображение файлов, копирование при записи, сторожевые страницы и вытеснение. Когда что-то из этого проектируется, полезно знать, какую из трёх задач вы сейчас решаете: путаница здесь порождает механизмы, которые дублируют железо или, наоборот, ждут от него того, чего оно не обещало.

## Источники

- [CP/M 2.2 Operating System Manual, Section 5: CP/M 2 System Interface](http://www.gaby.de/cpm/manuals/archive/cpm22htm/ch5.htm) — TPA с адреса 0100H и точка входа в BDOS
- [The Multics Virtual Memory: Concepts and Design](https://multicians.org/multics-vm.html) — пара «номер сегмента, смещение», дескрипторы сегментов, страницы внутри сегментов
- [Using FS and GS segments in user space applications — Linux kernel documentation](https://docs.kernel.org/arch/x86/x86_64/fsgs.html) — состояние сегментации в 64-битном режиме, TLS, `swapgs`, FSGSBASE
- [All about Global Offset Table — MaskRay](https://maskray.me/blog/2021-08-29-all-about-global-offset-table) — устройство GOT, пролог PIC на i386 и RIP-относительная адресация в x86-64
- [Changes/Harden All Packages — Fedora Project Wiki](https://fedoraproject.org/wiki/Changes/Harden_All_Packages), [Hardening/PIEByDefaultTransition — Debian Wiki](https://wiki.debian.org/Hardening/PIEByDefaultTransition) и [SecurityTeam/PIE — Ubuntu Wiki](https://wiki.ubuntu.com/SecurityTeam/PIE) — переход дистрибутивов на PIE по умолчанию
- [No-MMU memory mapping support — Linux kernel documentation](https://docs.kernel.org/admin-guide/mm/nommu-mmap.html) — отсутствие `fork()`, ограничения `mmap`, округление и фрагментация
- [MMU-less systems and FDPIC — MaskRay](https://maskray.me/blog/2024-02-20-mmu-less-systems-and-fdpic) — устройство bFLT и ELF FDPIC
- [`fs/Kconfig.binfmt` — исходники ядра Linux](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/Kconfig.binfmt) — список архитектур с FDPIC: `depends on ARM || ((M68K || RISCV || SUPERH || XTENSA) && !MMU)`
- [Arm A-profile Architecture Registers: `TPIDR_EL0`](https://developer.arm.com/documentation/ddi0601/latest/AArch64-Registers/TPIDR-EL0--EL0-Read-Write-Software-Thread-ID-Register), [`TPIDR_EL1`](https://developer.arm.com/documentation/ddi0601/latest/AArch64-Registers/TPIDR-EL1--EL1-Software-Thread-ID-Register) и [`POR_EL0`](https://developer.arm.com/documentation/ddi0601/latest/AArch64-Registers/POR-EL0--Permission-Overlay-Register-0--EL0-) — указатели потока для EL0 и EL1, регистр прав Permission Overlay Extension
- [RISC-V psABI: соглашение о регистрах](https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc) — `tp` (x4) как указатель потока, недоступный для перераспределения
- [Memory Protection Keys — Linux kernel documentation](https://docs.kernel.org/core-api/protection-keys.html) — шестнадцать ключей на x86 и восемь на arm64, `pkey_alloc()` и `pkey_mprotect()`, смена прав без системного вызова и без правки таблиц
- [Intel 64 and IA-32 Architectures Software Developer's Manuals](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html) — `PKRU` и `WRPKRU`, надзорные ключи PKS и `IA32_PKRS`
- [Memory Protection Keys — Wasmtime](https://docs.wasmtime.dev/examples-mpk.html) и [`PoolingAllocationConfig`](https://docs.wasmtime.dev/api/wasmtime/struct.PoolingAllocationConfig.html) — ключи вместо сторожевых областей между экземплярами в пуловом аллокаторе
- [Donky: Domain Keys — Efficient In-Process Isolation for RISC-V and x86 (USENIX Security '20)](https://www.usenix.org/system/files/sec20-schrammel.pdf) — ключи защиты для RISC-V как исследовательское предложение
- [Armv8-M Memory Model and Memory Protection User Guide (Arm, 107565)](https://developer.arm.com/documentation/107565/latest/) — регионы MPU, `MPU_RBAR`/`MPU_RLAR`, гранулярность 32 байта, отличия от Armv7-M
- [RISC-V Privileged Architecture: Physical Memory Protection](https://docs.riscv.org/reference/isa/v20260120/priv/machine.html) — число записей PMP, режимы TOR и NAPOT, умолчания для режимов M, S и U
- [Memory Protection Design — Zephyr Project Documentation](https://docs.zephyrproject.org/latest/kernel/usermode/memory_domain.html) — ограничения разделов домена числом регионов MPU
- [Memory Protection Unit (MPU) Support — FreeRTOS](https://www.freertos.org/Security/04-FreeRTOS-MPU-memory-protection-unit) и [`xTaskCreateRestricted`](https://freertos.org/xTaskCreateRestricted.html) — три настраиваемых региона на непривилегированную задачу
- [Efficient Software-Based Fault Isolation (Wahbe, Lucco, Anderson, Graham, SOSP '93)](https://cs155.stanford.edu/papers/sfi.pdf) — segment matching и sandboxing, накладные расходы 4,3 % и 21,8 %
- [Tuning Wasmtime for Fast Wasm Execution](https://docs.wasmtime.dev/examples-fast-execution.html) — резерв 4 ГиБ и сторожевая область как условие отказа от проверок границ
- [Is Memory64 actually worth using? — SpiderMonkey](https://spidermonkey.dev/blog/2025/01/15/is-memory64-actually-worth-using.html) — почему при 64-битной индексации проверки возвращаются и чего это стоит
- [memory64 proposal — WebAssembly](https://github.com/WebAssembly/memory64) — статус предложения и его семантика
- [Singularity: Rethinking the Software Stack (Hunt, Larus, 2007)](https://www.microsoft.com/en-us/research/publication/singularity-rethinking-the-software-stack/) — SIP, запрет динамической загрузки кода, таблица стоимости базовых операций
- [Deconstructing Process Isolation (Aiken et al., MSPC '06)](https://cs.uwaterloo.ca/~brecht/courses/702/Possible-Readings/oses/singularity-deconstructing-process-isolation-mem-system-perf-2006.pdf) — 25–33 % против менее 5 %, состав доверенной базы, вывод про эшелонированную защиту; там же «overheads up to 10–30%» со ссылкой на работу ниже
- [A Look at Several Memory Management Units, TLB-Refill Mechanisms, and Page Table Organizations (Jacob, Mudge, ASPLOS '98)](https://user.eng.umd.edu/~blj/papers/asplos98.pdf) — откуда взялись 5–10 %, 10–20 % и 10–30 %, условия симуляции и оговорка про большие страницы
- [Sharing and Protection in a Single-Address-Space Operating System (Chase, Levy, Feeley, Lazowska, TOCS 1994)](https://homes.cs.washington.edu/~levy/opal/opal-tocs.pdf) — Opal: адресуемость отделена от доступа, право на сегмент даёт домен защиты потока
- [Mungi: A Distributed Single Address-Space Operating System (Heiser et al.)](https://trustworthy.systems/publications/papers/Heiser_ERV_94.pdf) — единое 64-битное пространство и капабилити-пароли
- [From ASPLOS to Orbit: Unikernels Twelve Years Later](https://gazagnaire.org/blog/2026-02-23-asplos-unikernels.html) — ретроспектива авторов MirageOS
- [The Unikraft Project Makes Using Unikernels Easy — USENIX ;login: (2021)](https://www.usenix.org/publications/loginonline/unikraft-and-coming-age-unikernels) — причины, по которым unikernel'ы не пошли в прод, и признание об отсутствии стековых канареек и ASLR в прошлых реализациях
- [CHERIoT 1.0 Released](https://cheriot.org/sail/specification/release/2025/11/03/cheriot-1.0.html) и [First CHERIoT Silicon](https://cheriot.org/silicon/2026/03/04/cheriot-first-silicon.html) — состояние капабилити-железа
- [Arm Morello — CHERI Alliance](https://cheri-alliance.org/discover-cheri/cheri-products/morello/) — плата Morello и то, через кого она распространяется
- [seL4 FAQ](https://sel4.systems/About/FAQ.html) — почему seL4 не имеет смысла без полноценного MMU
- [Envisioning a Simplified Intel Architecture](https://www.intel.com/content/www/us/en/developer/articles/technical/envisioning-future-simplified-architecture.html) — что именно предлагалось убрать в X86S и признание Intel: «Based on inputs from the ecosystem, Intel has chosen not to pursue X86S»
- [Intel and AMD Form x86 Ecosystem Advisory Group to Accelerate Innovation — Intel Newsroom, 15 октября 2024](https://newsroom.intel.com/corporate/october-2024-intel-news) — создание совместной группы
- [Intel terminates x86S initiative — Tom's Hardware](https://www.tomshardware.com/pc-components/cpus/intel-terminates-x86s-initiative-unilateral-quest-to-de-bloat-x86-instruction-set-comes-to-an-end) — заявление Intel изданию и дата сворачивания инициативы

---

## Связанные заметки

- [Страничная трансляция: таблицы, TLB, разрядность](./os-paging-and-tlb.md) — вторая статья цикла: механика таблиц, разрядность и уровни в трёх архитектурах, цена промаха TLB и инвалидация
- [Устройство ОС — план цикла статей](./os-knowledge-base-plan.md) — состав и порядок цикла; карта памяти ядра и структура ядра разбираются отдельными статьями
- [TLPI — база знаний](./tlpi-start.md) — то же адресное пространство со стороны прикладного программиста: `mmap`, `fork`, разделяемая память
- [Linux — контейнеризация](./linux-containerization.md) — изоляция имён поверх той же аппаратной изоляции адресных пространств
- [FreeBSD — bhyve: виртуализация после jails](./freebsd-bhyve.md) — граница, которую держит гипервизор, когда её вынесли из ядра наружу
- [Стеки: ядра, прерываний, пользовательские](./os-stacks.md) — четвёртая статья цикла: разрежённость адресного пространства как условие дешёвой сторожевой страницы у стека
- [Ядро как программа: freestanding, компоновка, ABI, выбор языка](./os-kernel-as-program.md) — FS/GS, `TPIDR_EL1` и `tp` с другой стороны: как о них узнаёт компилятор при сборке ядра
- [Карта памяти ядра: половины, прямое отображение, KASLR](./os-kernel-memory-map.md) — третья статья цикла: куда именно ложатся ядро и процесс в выбранной модели и чем платят за окно физической памяти
- [Физическая память: от карты firmware до аллокатора](./os-physical-memory.md) — пятая статья цикла: как регионы PMP и защищённого мира выглядят со стороны ядра — строчкой «не отображать» в карте памяти
- [Загрузка: от питания до первой инструкции ядра](./os-boot-sequence.md) — шестая статья цикла: как система выбирается из модели без трансляции, в которой начинает жизнь
- [Структура ядра: монолит, микроядро и что между](./os-kernel-structure.md) — девятая статья цикла: та же изоляция, выбранная структурой ядра, а не средствами языка или гипервизором
