Система пакетной обработки
Не бывает бага, который нельзя исправить, бывает система, которую не понимают
PA3 — это раздел для всей серии PA. Начиная с PA3 сложность системы начинает постепенно расти, будет добавляться всё больше слоёв абстракции, система станет всё более полной, а приложения — всё более реалистичными.
Это означает, что цепочка распространения багов станет сложнее, а отладка будет всё сильнее проверять ваше понимание поведения системы. Если раньше вы исправляли баги, полагаясь на чужую помощь, не вникая в детали системы в процессе отладки, то в PA3 вы обнаружите, что разрыв между вами и более опытными товарищами стремительно увеличивается: они всё лучше и лучше овладевают системой целиком, а вы — чем дальше, тем сильнее чувствуете, что топчетесь на месте, например, методичка и код становятся всё труднее для понимания, а баги — всё более загадочными.
Есть только один способ наверстать упущенное: действительно больше нельзя лениться.
В PA2 мы уже реализовали компьютерную систему архитектуры фон Неймана и успели запустить на AM игру-тренажёр печати и FCEUX. Благодаря IOE теперь можно импортировать на AM практически любые небольшие игры. У способа работы этих небольших игр на компьютере есть одна особенность: они монополизируют всю компьютерную систему целиком. Мы можем сколько угодно играть в тренажёр печати на NEMU, а когда надоест — выйти из NEMU и заново запустить FCEUX, чтобы поиграть в неё.
На самом деле именно так и работали ранние компьютеры: системный администратор загружал в компьютер конкретную программу (по сути, перфокарты давних времён), и компьютер выполнял эту программу до тех пор, пока она не завершится или пока администратор не остановит её вручную, после чего администратор вручную загружал следующую программу. Программы тех лет были далеко не такими впечатляющими, как Super Mario, в которую играете вы, в основном это были задачи научных вычислений и физического моделирования (например, расчёт баллистических траекторий).
Позже люди задумались: каждый раз вручную загружать новую программу — это слишком хлопотно. Вспоминая принцип работы фон-неймановского компьютера, можно отметить одну его особенность: когда компьютер завершает выполнение одной инструкции, он автоматически переходит к выполнению следующей. Аналогично, нельзя ли позволить администратору заранее подготовить набор программ, чтобы после завершения одной программы компьютер автоматически переходил к выполнению следующей?
В этом и заключается идея системы пакетной обработки. С появлением такой системы можно освободить руки администратора. Ключевая особенность системы пакетной обработки — наличие фоновой программы, которая, как только одна пользовательская программа завершает выполнение, автоматически загружает для выполнения новую пользовательскую программу.
Такая фоновая программа — это, по сути, и есть операционная система. Да, вы не ослышались: эта фоновая программа, которая как будто вообще ничего не делает, на самом деле и есть операционная система! Когда речь заходит об операционных системах, вам, возможно, сразу приходит на ум Windows с установочным пакетом в несколько гигабайт. Но на самом деле самая первая операционная система, введённая в эксплуатацию в истории, — GM-NAA I/O — появилась ещё в 1956 году, и одной из её главных задач было упомянутое выше «автоматическое подключение новой программы», для чего конкретно требовалось реализовать следующие две функции:
- после завершения работы пользовательской программы можно перейти к коду операционной системы и продолжить выполнение;
- операционная система может загрузить новую пользовательскую программу для выполнения.
Что такое операционная система? (рекомендуется обдумать при повторном прохождении)
Это большой вопрос, и мы также рекомендуем вам вернуться и переосмыслить его после того, как вы пройдёте курс по операционным системам.
Новые требования со стороны операционной системы
Если задуматься как следует, мы обнаружим, что в двух вышеописанных функциях на самом деле скрыто новое требование: переключение потока исполнения между программами. Мы знаем, что вызов функции обычно происходит внутри одной программы (за исключением динамически подключаемых библиотек) — это переключение потока исполнения внутри самой программы, и его можно реализовать с помощью инструкций call/jal. Однако оба вышеописанных требования подразумевают переключение потока исполнения между операционной системой и пользовательской программой. При этом суть переключения потока исполнения — это всего лишь изменение PC с одного значения на другое (именно так это понимают хакеры). Так можем ли мы точно так же использовать инструкции call/jal для переключения потока исполнения между программами?
Возможно, в те времена, когда рождалась GM-NAA I/O, дело действительно обстояло именно так: операционная система была просто библиотечной функцией, и когда пользовательская программа завершалась, достаточно было вызвать эту особую библиотечную функцию — точно так же, как мы вызываем halt() в наших программах на AM. Однако впоследствии люди постепенно осознали, что операционная система всё же отличается от остальных пользовательских программ: если ошибка возникает в пользовательской программе, операционная система может запустить следующую пользовательскую программу; но если рушится сама операционная система, вся компьютерная система перестаёт работать. Поэтому люди по-прежнему стремились защитить операционную систему и по возможности гарантировать её корректную работу.
Перед лицом этого требования использование инструкций call/jal для переключения между операционной системой и пользовательскими процессами выглядит слишком небрежным решением. Операционная система по своей сути тоже является программой, состоящей из функций, но независимо от того, действует ли пользовательская программа непреднамеренно или целенаправленно, мы не хотим, чтобы она могла переключить поток исполнения на произвольную функцию внутри операционной системы. Нам нужен способ переключения потока исполнения с ограниченными точками входа, и очевидно, что такой способ невозможно реализовать средствами обычного программного кода.
Строгая иерархическая система
Чтобы помешать программам переключать поток исполнения на произвольное место в операционной системе, в аппаратном обеспечении постепенно появились функции, связанные с механизмами защиты: например, в i386 были введены понятия защищённого режима (protected mode) и уровней привилегий (privilege level), процессор mips32 может работать в режиме ядра и пользовательском режиме, а у riscv32 есть машинный режим (M-mode), режим супервизора (S-mode) и пользовательский режим (U-mode). Идея, стоящая за этими понятиями, во всех случаях похожа: если говорить просто, только программа с высоким уровнем привилегий может выполнять определённые операции системного уровня; если программа с низким уровнем привилегий попытается выполнить операцию, на которую у неё нет прав, процессор выбросит сигнал исключения, чтобы предотвратить это неправомерное действие. Как правило, роль системного администратора лучше всего подходит самой операционной системе — она обладает наивысшим уровнем привилегий и может выполнять любые операции; а пользовательские программы, работающие поверх операционной системы, обычно имеют наинизший уровень привилегий, если только им не предоставлено особое разрешение, и если такая программа попытается нарушить общественную гармонию, её ждёт «смертный приговор».
Возьмём в качестве примера процессоры RISC-V, поддерживающие современные операционные системы: у них есть три режима привилегий — M, S и U, обозначающие соответственно машинный режим, режим супервизора и пользовательский режим. Наивысший уровень привилегий у режима M, наинизший — у режима U; ресурсы, доступные при низком уровне привилегий, доступны и при высоком. Как же процессор определяет, выполнил ли процесс операцию без соответствующих прав? Ответ прост: достаточно поддерживать в аппаратном обеспечении регистр, обозначающий текущий режим привилегий (являющийся частью состояния компьютера), а затем при обращении к ресурсам, доступным только при высоком уровне привилегий, проверять текущий режим привилегий. Например, в RISC-V есть привилегированная инструкция sfence.vma, и руководство требует, чтобы она могла выполняться только тогда, когда текущий режим привилегий процессора не ниже S. Поэтому мы можем добавить в аппаратное обеспечение немного простой логики для реализации проверки режима привилегий:
is_sfence_vma_ok = (priv_mode == M_MODE) || (priv_mode == S_MODE);
Как видно, проверка режима привилегий — это всего лишь несколько логических вентилей. Если проверка не пройдена, данная операция будет признана неправомерной, процессор выбросит сигнал исключения и перейдёт по адресу памяти, заранее согласованному с операционной системой, передав ей дальнейшую обработку.
Как правило, операционная система работает в режиме S, и поэтому имеет право доступа ко всему коду и данным; а обычные программы работают в режиме U, из-за чего им доступны только код и данные режима U. Таким образом, пока операционная система хранит свой закрытый код и данные в режиме S, вредоносная программа никогда не сможет получить к ним доступ.
Аналогично, в x86 операционная система работает в кольце 0 (ring 0), а пользовательские процессы — в кольце 3 (ring 3) — эти «кольца» представляют собой уровни привилегий, где кольцо 0 наделено максимальными правами, а кольцо 3 — минимальными, то есть работает тот же принцип, что и с режимами M/S/U в RISC-V выше; в mips32 операционная система работает в режиме ядра, а пользовательские процессы — в пользовательском режиме. Все эти связанные с защитой понятия и процессы проверки реализуются аппаратно, и пока программное обеспечение работает поверх аппаратного, ему не уйти от этой всеобъемлющей сети. Аппаратный механизм защиты не позволяет вредоносным программам безнаказанно скрыться, внося огромный вклад в построение гармоничного компьютерного общества.
Какая замечательная возможность! К сожалению, многие из упомянутых выше понятий на самом деле были рассмотрены лишь поверхностно, а настоящий механизм защиты требует учёта гораздо большего числа деталей. В руководствах по ISA обычно есть отдельная глава, посвящённая описанию механизма защиты, уже одно это показывает, что механизм защиты не так прост, как может показаться на словах. Следуя принципу KISS, мы не планируем добавлять механизм защиты в NEMU. Мы позволяем всем пользовательским процессам работать с наивысшим уровнем привилегий, и хотя формально все пользовательские процессы имеют право выполнять любые инструкции, поскольку все пользовательские программы в PA написаны нами самими, всё в итоге остаётся под нашим контролем. В конце концов, из истории выше мы уже уловили саму суть механизма защиты: добавить в аппаратное обеспечение немного логических вентилей, связанных с проверкой уровня привилегий (например, схемы сравнения), и если обнаружена неправомерная операция — выбросить сигнал исключения, заставив процессор перейти на согласованный целевой адрес и выполнить дальнейшую обработку.
Распадающийся порядок
Защита по уровням привилегий — это ключевой механизм современных компьютерных систем, но наличие столь строгой иерархической системы вовсе не означает полной безопасности: хакеры всегда изо всех сил стараются нащупать границы этой системы. Из последних событий, всколыхнувших мир компьютерных технологий, стоит выделить две печально известные аппаратные уязвимости — Meltdown и Spectre, обнародованные в январе 2018 года. Эти две эпохальные уязвимости потрясли весь мир именно потому, что они разрушили границы уровней привилегий: при определённых условиях вредоносная программа может похищать информацию операционной системы с чрезвычайно высокой скоростью. Было установлено, что уязвимости Meltdown подвержены все чипы Intel, а уязвимость Spectre угрожает чипам всех архитектур без единого исключения — можно сказать, что это две самые разрушительные по своему влиянию уязвимости в истории архитектуры вычислительных систем на сегодняшний день. Если вы выполните команду cat /proc/cpuinfo, то, возможно, увидите тень этих двух уязвимостей в информации bugs.
Meltdown и Spectre стали тревожным звонком для тех разработчиков чипов, которые прежде слепо гнались за производительностью: без безопасности не имеет значения, насколько быстро работает чип, — всё это напрасно. Любопытно, что напрямую расплачиваться за этот фарс пришлось инженерам крупнейших облачных платформ: в ту неделю, когда были обнародованы уязвимости, инженеры Alibaba Cloud и Microsoft Azure работали сверхурочно несколько ночей подряд, стараясь всеми способами наложить на облачные платформы патчи безопасности, чтобы не допустить злонамеренного похищения данных клиентов.
Впрочем, как учебный эксперимент, тема безопасности всё ещё слишком далека от PA, да и производительность тоже не является главной целью PA. Смысл этого примера в том, что реальные компьютерные системы очень сложны и далеко не совершенны. Появление этих уязвимостей в какой-то мере также показывает, что люди уже не в состоянии сразу и полностью осознать взаимное влияние всех модулей друг на друга; но принципы, лежащие в основе компьютеров, восходят к единым истокам, и понимание этих принципов на примере небольшой, но продуманной учебной системы, а затем стремление понять и улучшить реальные системы — это тоже одно из ценных приобретений, которые даёт выполнение PA.
