В первые дни создание компьютерных систем было простым делом. Почему, спросите вы? Потому что пользователи не ожидали многого. Именно эти проклятые пользователи с их ожиданиями “простоты использования”, “высокой производительности”, “надежности” и т. д. действительно привели ко всем этим головным болям. В следующий раз, когда вы встретите одного из этих пользователей компьютера, поблагодарите его за все проблемы, которые они вызвали.
Ранние системы
С точки зрения памяти, ранние машины не давали пользователям большой абстракции. В основном физическая память машины выглядела примерно так, как показано на рис. 13.1
ОС представляла собой набор подпрограмм (на самом деле библиотеку), которые находились в памяти (начиная с физического адреса 0 в этом примере), и была бы одна запущенная программа (процесс), которая в данный момент находилась бы в физической памяти (начиная с физического адреса 64k в этом примере) и использовала бы остальную память. Здесь было мало иллюзий, и пользователь не ожидал многого от ОС. В те дни разработчикам ОС жилось легко, не так ли?
Multiprogramming and Time Sharing
Через некоторое время, поскольку машины стоили дорого, люди стали делиться машинами более эффективно. Таким образом, родилась эра мультипрограммирования, в которой несколько процессов были готовы к запуску в данный момент времени, и ОС переключалась между ними, например, когда кто-то решал выполнить ввод-вывод. Такое повышение эффективности было особенно важно в те дни, когда каждая машина стоила сотни тысяч или даже миллионы долларов (а вы думали, что ваш Mac стоит дорого!).
Однако довольно скоро люди стали требовать от машин большего, и родилась эра разделения времени. В частности, многие осознали ограничения пакетных вычислений, особенно сами программисты, которые устали от длительных (и, следовательно, неэффективных) циклов отладки программ. Понятие интерактивности стало важным, поскольку многие пользователи могут одновременно использовать машину, каждый из которых ждет (или надеется) своевременного ответа от своих текущих задач.
Одним из способов реализации совместного использования времени было бы запустить один процесс на короткое время, предоставив ему полный доступ ко всей памяти (рис. 13.1), затем остановить его, сохранить все его состояние на каком-то диске (включая всю физическую память), загрузить состояние какого-то другого процесса, запустить его на некоторое время и таким образом реализовать некое грубое совместное использование машины.
К сожалению, у этого подхода есть большая проблема: он слишком медленный, особенно по мере роста памяти. В то время как сохранение и восстановление состояния на уровне регистров (ПК, регистры общего назначения и т. д.) происходит относительно быстро, сохранение всего содержимого памяти на диск является жестоко неэффективным. Таким образом, мы бы предпочли оставить процессы в памяти при переключении между ними, что позволило бы ОС эффективно реализовать разделение времени (как показано на рис.13.2).
На диаграмме есть три процесса (А, В и С), и каждый из них имеет небольшую часть физической памяти 512 КБ, выделенной для них. Предполагая наличие одного процессора, ОС решает запустить один из процессов (скажем, A), в то время как другие (B и C) сидят в очереди готовности, ожидая запуска.
Поскольку совместное использование времени стало более популярным, вы, вероятно, можете догадаться, что к операционной системе были предъявлены новые требования. В частности, разрешение нескольким программам одновременно находиться в памяти делает защиту важной проблемой; вы не хотите, чтобы процесс мог читать или, что еще хуже, записывать память другого процесса.
Адресное пространство памяти
Однако мы должны помнить об этих надоедливых пользователях, и для этого требуется, чтобы ОС создала простую в использовании абстракцию физической памяти. Мы называем эту абстракцию адресным пространством, и это представление запущенной программы о памяти в системе. Понимание этой фундаментальной абстракции памяти ОС является ключом к пониманию того, как память виртуализируется.
Адресное пространство процесса содержит все состояние памяти запущенной программы. Например, код программы (инструкции) должен где-то жить в памяти, и таким образом они находятся в адресном пространстве. Программа во время выполнения использует стек для отслеживания того, где она находится в цепочке вызовов функций, а также для выделения локальных переменных и передачи параметров и возвращаемых значений в подпрограммы и из них. Наконец, куча используется для динамически выделяемой, управляемой пользователем памяти, такой как та, которую вы можете получить от вызова malloc() в C или new в объектно-ориентированном языке, таком как C++ или Java. Конечно, там есть и другие вещи (например, статически инициализированные переменные), но пока давайте просто предположим эти три компонента: код, стек и куча.
В примере на рис. 13.3 мы имеем крошечное адресное пространство (всего 16 КБ). Программный код находится в верхней части адресного пространства (начиная с 0 в этом примере и упаковывается в первый 1K адресного пространства). Код статичен (и, следовательно, легко помещается в память), поэтому мы можем поместить его в верхнюю часть адресного пространства и знать, что ему не понадобится больше места во время выполнения программы.
Далее, у нас есть две области адресного пространства, которые могут расти (и сжиматься) во время выполнения программы. Это куча (вверху) и стек (внизу). Мы размещаем их так, потому что каждый хочет иметь возможность расти, и, поместив их на противоположных концах адресного пространства, мы можем позволить такой рост: они просто должны расти в противоположных направлениях. Таким образом, куча начинается сразу после кода (в 1 КБ) и растет вниз (скажем, когда пользователь запрашивает больше памяти через malloc()); стек начинается с 16 КБ и растет вверх (скажем, когда пользователь делает вызов процедуры). Однако такое размещение стека и кучи - всего лишь условность; вы можете расположить адресное пространство по-другому, если хотите (как мы увидим позже, когда несколько потоков сосуществуют в адресном пространстве, увы, никакой хороший способ разделить адресное пространство таким образом больше не работает).
Конечно, когда мы описываем адресное пространство, мы описываем абстракцию, которую ОС предоставляет запущенной программе. Программа действительно не находится в памяти по физическим адресам от 0 до 16 КБ; скорее она загружается по какому-то произвольному физическому адресу(адресам). Рассмотрите процессы A, B и C на рис.13.2; там вы можете увидеть, как каждый процесс загружается в память по другому адресу. А отсюда и проблема:
СУТЬ: КАК ВИРТУАЛИЗИРОВАТЬ ПАМЯТЬ
Как ОС может построить эту абстракцию частного, потенциально большого адресного пространства для нескольких запущенных процессов (все они совместно используют память) поверх одной физической памяти?
Когда ОС делает это, мы говорим, что ОС виртуализирует память, потому что запущенная программа думает, что она загружена в память по определенному адресу (скажем, 0) и имеет потенциально очень большое адресное пространство (скажем, 32-битное или 64-битное); реальность совершенно иная.
Когда, например, процесс А на рис. 13.2 пытается выполнить загрузку по адресу 0 (который мы будем называть виртуальным адресом), каким-то образом ОС в тандеме с некоторой аппаратной поддержкой должна будет убедиться, что нагрузка на самом деле идет не по физическому адресу 0, а по физическому адресу 320КБ (где А загружается в память). Это ключ к виртуализации памяти, которая лежит в основе любой современной компьютерной системы в мире.
Цели
Таким образом, мы приходим к задаче ОС в этом наборе заметок: виртуализировать память. Однако ОС не только виртуализирует память, но и делает это со стилем. Чтобы убедиться, что ОС делает это, нам нужны некоторые цели, которые будут направлять нас. Мы видели эти цели раньше (подумайте о введении), и мы увидим их снова, но они, безусловно, заслуживают повторения.
Одной из основных целей системы виртуальной памяти (ВМ) является прозрачность. ОС должна реализовывать виртуальную память таким образом, чтобы она была невидима для запущенной программы. Таким образом, программа не должна знать о том, что память виртуализирована; скорее, программа ведет себя так, как будто у нее есть своя собственная физическая память. За кулисами ОС (и аппаратное обеспечение) выполняет всю работу по мультиплексированию памяти между многими различными заданиями и, следовательно, реализует иллюзию.
Еще одна цель ВМ-эффективность. ОС должна стремиться сделать виртуализацию максимально эффективной как с точки зрения времени (т. е. не заставлять программы работать намного медленнее), так и пространства (т. е. не использовать слишком много памяти для структур, необходимых для поддержки виртуализации). При реализации эффективной по времени виртуализации ОС придется полагаться на аппаратную поддержку, включая аппаратные функции, такие как TLBs (о которых мы узнаем в свое время).
Наконец, третья цель виртуальной машины-защита. ОС должна обеспечивать защиту процессов друг от друга, а также самой ОС от процессов. Когда один процесс выполняет загрузку, хранение или выборку инструкций, он не должен иметь возможности получить доступ или каким-либо образом повлиять на содержимое памяти любого другого процесса или самой ОС (то есть на что-либо вне ее адресного пространства). Защита, таким образом, позволяет нам обеспечить свойство изоляции между процессами; каждый процесс должен работать в своем собственном изолированном коконе, защищенном от разрушительных воздействий других неисправных или даже вредоносных процессов.
СОВЕТ: ПРИНЦИП ИЗОЛЯЦИИ
Изоляция-ключевой принцип построения надежных систем. Если две сущности должным образом изолированы друг от друга, это означает, что одна может потерпеть неудачу, не затрагивая другую. Операционные системы стремятся изолировать процессы друг от друга и таким образом предотвратить причинение вреда друг другу. Используя изоляцию памяти, ОС дополнительно гарантирует, что запущенные программы не могут повлиять на работу базовой ОС. Некоторые современные ОС берут изоляцию еще дальше, отгородив части ОС от других частей ОС. Таким образом, такие микроядра могут обеспечить большую надежность, чем типичные монолитные конструкции ядер.
В следующих главах мы сосредоточимся на основных механизмах, необходимых для виртуализации памяти, включая аппаратную поддержку и поддержку операционных систем. Мы также рассмотрим некоторые из наиболее важных политик, с которыми вы столкнетесь в операционных системах, включая то, как управлять свободным пространством и какие страницы выбрасывать из памяти, когда у вас мало места. Таким образом, мы создадим ваше понимание того, как на самом деле работает современная система виртуальной памяти.
Вывод
Мы уже видели появление основной подсистемы ОС-виртуальной памяти. Система виртуальной машины отвечает за создание иллюзии большого, разреженного, частного адресного пространства для программ, которые хранят в нем все свои инструкции и данные. ОС, с некоторой серьезной аппаратной помощью, возьмет каждую из этих ссылок виртуальной памяти и превратит их в физические адреса, которые могут быть представлены в физическую память, чтобы получить желаемую информацию. ОС будет делать это для многих процессов одновременно, обеспечивая защиту программ друг от друга, а также защиту ОС. Весь подход требует большого количества механизмов (много низкоуровневых механизмов), а также некоторых критических политик для работы; мы начнем снизу вверх, сначала описав критические механизмы. И так мы продолжаем!
В СТОРОНЕ: КАЖДЫЙ АДРЕС, КОТОРЫЙ ВЫ ВИДИТЕ, ЯВЛЯЕТСЯ ВИРТУАЛЬНЫМ
Вы когда-нибудь писали программу на языке Си, которая выводит указатель? Значение, которое вы видите (какое-то большое число, часто напечатанное в шестнадцатеричном формате), является виртуальным адресом. Вы никогда не задумывались, где находится код вашей программы? Вы также можете распечатать его, и да, если вы можете распечатать его, это также виртуальный адрес. Фактически, любой адрес, который вы видите как программист программы пользовательского уровня, является виртуальным адресом. Только ОС, благодаря своим хитрым методам виртуализации памяти, знает, где в физической памяти машины лежат эти инструкции и значения данных. Так что никогда не забывайте: если вы распечатываете адрес в программе, это виртуальный адрес, иллюзия того, как все разложено в памяти; только ОС (и аппаратное обеспечение) знает настоящую правду. Вот небольшая программа (va.c), которая выводит местоположение подпрограммы main() (где живет код), значение выделенного кучи значения, возвращаемого из malloc(), и местоположение целого числа в стеке:
Из этого вы можете видеть, что код идет сначала в адресном пространстве, затем в куче, а стек полностью находится на другом конце этого большого виртуального пространства. Все эти адреса являются виртуальными и будут преобразованы ОС и аппаратным обеспечением для извлечения значений из их истинных физических местоположений