Каждая программа на компьютере исполняется не просто на процессоре, а внутри строго очерченного уровня привилегий, который задаёт набор дозволенных действий. Архитектура x86 предлагает для этого четыре кольца защиты, но практические операционные системы давно свели всю драму к двум актерам: Ring 0, где живёт ядро с полной властью над железом, и Ring 3, где трудятся приложения, лишённые права даже напрямую поговорить с устройствами. Эта граница не условность и не соглашение между программистами, а аппаратный механизм: процессор буквально на каждой инструкции проверяет, имеет ли исполняемый код право делать то, что он делает. От того, насколько надёжно выстроена эта граница, зависит, останется ли ошибка в текстовом редакторе локальной неприятностью или обрушит целую систему, а атаке вредоносного кода предоставит абсолютный контроль или оставит его в тесной песочнице пользовательского режима.

Механика уровня привилегий внутри процессора

Текущий уровень привилегий процессора называется CPL, Current Privilege Level, и физически хранится в двух младших битах селектора сегмента кода, загруженного в регистр CS. Когда CPL равен нулю, код исполняется в Ring 0 и может всё: переключать страничные таблицы, программировать контроллеры прерываний, читать и писать любые управляющие регистры, обращаться к любой физической памяти. Когда CPL равен трём, процессор включает режим подозрительности: целый класс инструкций становится запрещённым. Это так называемые привилегированные инструкции: LGDT и LIDT для загрузки дескрипторных таблиц, MOV в управляющие регистры CR0, CR3, CR4, чтение и запись регистров MSR, инструкции ввода-вывода IN и OUT при соответствующих настройках IOPL, команда HLT, останавливающая ядро до прерывания. Если код пользовательского режима осмелится их выполнить, процессор мгновенно генерирует исключение #GP, General Protection Fault. Управление по специальному вектору прерывания уходит в ядро, ядро смотрит на виновника и обычно отправляет ему сигнал о фатальной ошибке, после чего процесс завершается, а система продолжает работать как ни в чём не бывало.

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

Виртуальная память и разделённые половины адресного пространства

Второй слой изоляции строится таблицами страниц. Каждая страница виртуального адресного пространства имеет в записи таблицы бит U/S, User или Supervisor. Страницы с битом Supervisor недоступны из Ring 3: попытка чтения возбуждает #PF, страничное исключение. Традиционная раскладка на 32-битных системах делила четыре гигабайта пополам или в пропорции три к одному: нижние адреса принадлежали процессу, верхние - ядру. На современных 64-битных системах идея та же, только масштаб иной: пользовательские адреса занимают нижнюю часть канонического диапазона, примерно до 128 терабайт, а ядро живёт в верхней, в отрицательных канонических адресах. Ядро исторически отображало свои страницы в адресное пространство каждого процесса, чтобы при системном вызове не переключать таблицы страниц, а просто подняться в привилегиях и тут же видеть свои данные. Это быстрое и удобное решение много лет казалось бесплатным, пока аппаратные уязвимости не показали, что видимость сама по себе может быть дырой.

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

Драйверы и цена доверия в Ring 0

Драйвер устройства обязан работать в Ring 0, потому что именно там доступны программирование контроллеров, обработка аппаратных прерываний, прямой доступ к памяти DMA и привилегированные инструкции. Но вместе с этой необходимостью приходит и цена: код драйвера получает тот же уровень доверия, что и само ядро, даже если написан сторонней компанией и мало кем проверен. Статистика аварийных остановок годами показывала, что значительная доля BSOD вызвана вовсе не ядром, а сторонними драйверами фильтров, антивирусов, утилит мониторинга железа. Поэтому индустрия движется в сторону вытеснения драйверов из кольца нуля там, где это возможно: сетевые и накопительные стеки остаются в ядре, а всё второстепенное переводят в пользовательский режим через фреймворки вроде Windows User-Mode Driver Framework или механизмов Linux uio и vfio. Падение драйвера камеры или принтера ограничивается перезапуском службы, а не остановкой сервера посреди рабочего дня. Доверие в системе стараются выдавать минимально необходимыми порциями, и каждая строка кода в Ring 0 воспринимается как обещание, за которое придётся нести ответственность.

Механика перехода из пользовательского кода в ядро через системные вызовы

Между двумя кольцами нельзя просто прыгнуть. Вход в ядро возможен только через строго определённые ворота, которые железо проверяет на соответствие правилам. Современный способ - инструкция syscall на x86-64 или sysenter на 32-битных системах. Перед её выполнением ядро при инициализации настраивает регистры MSR: адрес точки входа в ядре, селекторы сегментов кода ядра и пользователя, значение указателя стека после переключения. Когда приложение исполняет syscall, процессор в одну операцию поднимает CPL до нуля, загружает указатель стека ядра для текущего потока, перепрыгивает на заранее записанный адрес в ядре и отключает регистрацию адреса возврата в обычных регистрах, сохраняя его в R11 и RCX парами, чтобы вернуться через sysret. Номер системного вызова приложение указывает в регистре RAX, аргументы кладутся в фиксированные регистры. Побочный момент: ядро обязано и гарантированно переключает свой GS через swapgs, потому что в пользовательском режиме GS указывает на поточные данные приложения, а в ядре - на структуру процессора. Забыть swapgs - значит подарить атакующему обращение к пользовательским данным с привилегиями ядра.

Путь из прикладного кода выглядит как лестница абстракций. Приложение вызывает знакомую функцию CreateFile или ReadFile из kernel32.dll. Та выполняет подготовительные проверки и передаёт управление тонкой обёртке в ntdll.dll. Именно ntdll складывает номер системного вызова в RAX, расставляет аргументы и исполняет инструкцию syscall. Дальше запрос проходит по диспетчеру ввода-вывода ядра, через подходящие драйверы и обратно. Длительность стабильна и предсказуема: для простейшего вызова вроде получения тика времени переход туда-обратно обычно укладывается в диапазон от полутораста до пятисот тактов процессора в зависимости от микроархитектуры, мер защиты от аппаратных уязвимостей и промахов кеша. Это немного, но в программах, исполняющих десятки тысяч вызовов в секунду, суммарные затраты становятся доминирующими. Отсюда рецепты оптимизации: группировать запросы в пакеты, перечитывать файлы большими буферами, использовать io_uring в Linux, где приложение кладёт заявки в общее кольцо и получает ответы без отдельного перехода на каждую операцию, применять readahead и копирование без лишних переходов границы.

Эволюция механизма ворот наглядно показывает цену вопроса:

  1. Старый способ через программное прерывание int 0x2E требовал полного прохода по таблице дескрипторов прерываний и сохранения контекста и легко съедал свыше тысячи тактов.
  2. Инструкция sysenter убрала лишние обращения к таблицам и сократила путь до нескольких сотен тактов.
  3. Инструкция syscall в длинном режиме закрепила этот выигрыш и сделала переход дешёвым настолько, что на каждый чих приложения не приходится думать о цене границы.

Meltdown, Spectre и дорогостоящая изоляция KPTI

В начале 2018 года выяснилось, что спекулятивное исполнение процессоров позволяет читать память ядра из пользовательского кода: уязвимость Meltdown эксплуатировала ситуацию, когда спекулятивно выполненная инструкция, имеющая формально запрещённый доступ, всё же оставляла след в кеше, который снимался временным анализом. Аппаратной защиты не хватило, и операционные системы ответили радикально: механизм KPTI, Kernel Page Table Isolation, полностью убрал страницы ядра из пользовательских таблиц страниц. Теперь при каждом системном вызове приходится переключать таблицы страниц, записывать новое значение в CR3 и сбрасывать буфер TLB, а при возврате проделывать то же самое. Отдельно добавились барьеры LFENCE, вымывание косвенных предсказателей ветвей, микрокодные заплаты. Сумма мер заметно подняла цену перехода: на затронутых процессорах нагрузки, построенные на частых маленьких системных вызовах, теряли от единиц процентов до двух-трёх десятков процентов производительности. Безопасность оказалась не бесплатной, а явной статьёй расходов в бюджете каждого сервиса, и практичные инженеры стали ещё активнее выбирать пакетные интерфейсы и асинхронные очереди, чтобы реже платить за пересечение границы.

Кольца глубже нуля и уровни привилегий в ARM

Гипервизор получил собственный режим с появлением аппаратной виртуализации: VMX root operation, который шутливо прозвали Ring минус один. В этом режиме исполняется гипервизор, а гостевые системы работают в VMX non-root и даже не знают, что их Ring 0 теперь не настоящий ноль. Глубже лежат ещё два мира, давшие пищу инженерному юмору: системный режим управления SMM, куда процессор проваливается по сигналу SMI и где живёт прошивочный код с доступом ко всей памяти, и отдельный микроконтроллер управления Intel ME со своей прошивкой, обрабатывающий сеть и питание независимо от основного процессора. Шутки про Ring минус два и минус три - это полушутки: как раз эти режимы и есть реальные уровни привилегий, оставшиеся вне классической модели колец.

Архитектура ARM решает ту же задачу другим словарём, но с той же математикой. Уровень EL0 - приложения, EL1 - ядро ОС, EL2 - гипервизор, EL3 - монитор безопасного мира TrustZone. Вызов ядра выполняется инструкцией svc, переход на верхний уровень сопровождается сохранением состояния в банки регистров исключений, а обратный путь идёт через eret. Разделение памяти обеспечивают два базовый регистра таблиц трансляции: TTBR0 хранит таблицы нижней половины адресов для пользователя, TTBR1 - верхнюю для ядра, и переключение миров получается естественно и аппаратно. Модель выглядит чище, но суть неизменна: чем выше уровень, тем шире власть и тем дороже цена ошибки.

Проверить разделение труда можно без отладчика. Диспетчер задач Windows в расширенных колонках показывает для каждого процесса время пользователя и время ядра. Высокая доля ядерного времени у самого обычного приложения - верный знак, что оно изводит систему частыми мелкими вызовами. Из этого наблюдения напрямую следуют оптимизации вроде пакетирования и io_uring.

Отдельный спорный фронт - античиты и технические средства защиты контента. Они тянутся в ядро, потому что наблюдатель в Ring 3 всегда уязвим перед противником в Ring 0: из ядра можно переписать память любого процесса, подменить системные вызовы, сделать видимым то, чего нет. Поэтому защита игры или видеопотока, исполняющаяся в пользовательском режиме, технически проигрывает загруженному в ядро читу. Решение тащить свой драйвер в Ring 0, однако, превращает игрового издателя в писателя кода с властью ядра на миллионах машин. Ошибка в таком драйвере обрушивает системы пользователей, а уязвимость дарит атакующему мгновенный полный компрометацией. Иногда обновление подписанного ядерного античит-драйвера раскатывается глобально, и один плохой релиз способен положить огромное число машин одновременно, что наглядно показал печально известный инцидент слетающих в синий экран корпоративных систем из-за логической ошибки в подобном драйвере. Разумный компромисс индустрия ищет через вынос функции проверки в облачную сторону, через аттестацию целостности на уровне прошивки и TPM и через сокращение объёма кода в ядре до минимума с проверками остальных функций снаружи.

Кольца защиты - редкий пример того, как решение сорокалетней давности продолжает держать на себе весь цифровой мир. Всё разнообразие безопасности современных систем в конечном счёте упирается в то, что железо умеет проверять два бита на каждом шагу и безжалостно наказывать нарушителя исключением. Дальше уже дело инженеров: правильно поставить ворота, разумно платить за их проход и не выдавать ключи от Ring 0 тому, кто не готов за них отвечать.