Сага о Windows. Глава вторая. Часть первая
Во всем мне хочется дойти
До самой сути.
В работе, в поисках пути,
В сердечной смуте.
До сущности протекших дней,
До их причины,
До оснований, до корней,
До сердцевины.
Б. Пастернак
Во многих официальных источниках можно найти информацию о том, что следующим после Windows 1.04 выпуском операционной системы стала версия Windows 2.03, выпущенная 9 декабря 1987. Пиши я эту статью всего пару месяцев назад, она бы начиналась с обзора именно этой версии.
Но появившаяся недавно информация пролила новый свет на хронологию Windows 2.0 и заставила изменить наши планы как на порядок изложения материала, так и на само содержание первой статьи цикла. Тем не менее, для сохранения интриги, мы прибегнем к широко распространенному в литературе приему задержанной экспозиции - мы не сообщим, в чем же интрига заключается, и раскроем ее только в одной из следующих статей.
В то же время, должен предупредить, что данная статья будет не совсем обычной. Дело в том, что вторую версию своей новой операционной системы Microsoft выпустила сразу в двух редакциях - Windows/286 и Windows/386. Чтобы понять различие между ними, необходимо обратиться к истории архитектуры центральных процессоров и рассмотреть такой вопрос, как адресация памяти.
Изначально мы планировали ограничиться лишь краткой теоретической справкой, но рассмотрение данного вопроса завело нас так далеко, что было решено первую статью о Windows 2.0 посвятить целиком рассмотрению данного вопроса.
Конечно, эта сугубо теоретическая статья может показаться излишней. Ведь кому-то эта информация и так знакома, для кого-то она покажется слишком скучной и неинтересной. Тем не менее, нам показалось уместным привести здесь этот материал, поскольку без него невозможно понять, с какими проблемами столкнулась архитектура x86 на заре своего существования, и как Windows адаптировалась под сменяющие друг друга поколения процессоров Intel.
Замечу также, что в ходе подготовки статьи мы постарались изложить историю интересующего нас вопроса предельно доступно и понятно (настолько, насколько это позволяет сам предмет). Если же вы достаточно знакомы с архитектурой x86, вы можете пропустить это теоретическое отступление.
Начнем же мы с самого начала.
Предположим, что процессору требуется обратиться к определенным данным, хранящимся в оперативной памяти. Понятно, что для этого ему необходимо знать адрес того слова (равного одному байту), которое он хочет из памяти извлечь. Для этого он передает известный ему адрес по специальной шине (шине адреса). Очевидно, что чем большее число битов шина может передать, тем больший объем памяти может адресовать процессор. Заметим, что адрес представляет собой, по сути, просто порядковый номер байта в памяти, начиная с нулевого (поэтому его также называют линейным адресом).
В свою очередь, сами адреса сохраняются на время процессором в регистрах (ячейках памяти процессора), которые в архитектуре процессоров Intel того времени могли хранить до 16 бит данных. До появления архитектуры х86 в регистрах сохранялись физические адреса, и ЦП при необходимости передавал их по шине адреса. Увеличивая шину памяти до 16 бит, Intel тем самым расширяла и объем адресуемой памяти.
Лимит был достигнут с выпуском в 1974 году процессора Intel 8080 и составил 64 Кб (то есть 216 байт, где 16 - ширина шины памяти). Для дальнейшего увеличения поддерживаемой оперативной памяти требовалось или увеличение объема регистров ЦП (чтобы хранить более длинные адреса), или изменение схемы адресации.
Intel 8080
С появлением процессора Intel 8086 (1976 год) адресация была изменена. Шина адреса была расширена до 20 бит, что позволяло адресовать 1 Мб памяти (220 байт). В то же время, была введена логическая адресация, позволяющая ограничиться 16-битными регистрами и обойти необходимость в хранении 20-битных адресов.
Intel 8086
Упрощенно новую схему адресации можно объяснить следующим образом. Вся оперативная память условно делится на независимые блоки (сегменты), каждый из которых по умолчанию равен 64 Кб, то есть предельно допустимому объему адресуемой памяти при 16-битных регистрах. Но важно отметить, что 64 Кб - лишь максимально возможное значение, так как процессор не контролирует размеры сегментов и "разметка" памяти в конечном итоге ложится на программиста.
Логический адрес, с которым теперь имеет дело процессор, состоит из двух частей (компонент) - адреса сегмента и смещения. Адрес сегмента - это физический адрес начала сегмента, то есть его первого байта. Смещение же указывает на нужный байт в пределах данного сегмента (на сколько байт необходимо сместиться вправо от начала сегмента, чтобы прийти к нужному байту). Очевидно, что 16 бит смещения (216, то есть 65536 возможных значений) достаточно для указания любого байта в пределах 64-килобайтного сегмента.
Итак, чтобы получить физический адрес необходимого слова (байта), достаточно сложить базовый адрес сегмента со смещением.
Таким образом, каждая из компонент (адрес сегмента и смещение) занимают 16 бит, что в сумме дает 32 бита - исходный логический адрес. Как правило, они записываются в шестнадцатеричной форме через двоеточие, например, 1234h:5678h (сегмент:смещение). Заметьте, что написание "h" (от "hex") в конце каждого адреса восходит к традиции ассемблера, в котором оно является указанием на запись шестнадцатеричного, а не десятичного числа.
Заметим, что для хранения адресов были введены специальные сегментные регистры - CS (Code Segment), DS (Data Segment), EX (Extra Segment) и SS (Stack Segment). Это позволило дифференцировать сегменты по типам хранимых в них данных. Так, CS хранит адрес сегмента, содержащего код (инструкции приложения), DS - данные (переменные), SS указывает на сегмент стека. Мы еще вернемся к этому в дальнейшем.
У читателей может возникнуть справедливый вопрос - как физический адрес начала сегмента записывается в 16 битах, если выше было указано, что физический адрес памяти на платформе Intel 8086 занимает 20 бит? Дело в том, что сегмент может начинаться лишь с байта, порядковый номер которого (то есть его физический адрес) кратен 16. В двоичной системе счисления любое число, кратное 16, имеет четыре младших разряда равные нулям. Например, 16 = 0001 0000, 80 = 0101 0000, 2672 = 1010 01111 0000 и т.д. Таким образом, хранящийся в регистре адрес сегмента есть не что иное, как адрес его первого байта с отброшенными последними четырьмя нулями (или, в десятичной системе, адрес первого байта, деленный на 16).
Если же выражаться в терминологии информатики, то можно сказать, что адреса сегментов обязательно выравниваются по границам параграфов (параграф - объем данных, равный 16 байтам), а длина сегментов всегда равна целому числу параграфов.
Далее в ход идет двоичная математика. 16-битный адрес сегмента дополняется до 20 бит приписыванием справа четырех нулевых бит (что тождественно умножению данного числа на 16 в десятичной системе). Операция над адресом сегмента из примера выше будет выглядеть следующим образом:
0001 0010 0011 0100 * 16 =
0001 0010 0011 0100 0000
В шестнадцатеричной записи результатом будет 12340h. Таким образом, достигается получение физического (20-битного) адреса начала сегмента.
Далее получившийся адрес, называемый базовым, складывается с 16-битным смещением. Непосредственно перед сложением 16-битное смещение также расширяется до 20 разрядов, но нули приписываются слева (для соответствия разрядов смещения разрядам базового адреса). Например,
0101 0110 0111 1000 (5678h)
будет преобразовано в
0000 0101 0110 0111 1000 (05678h)
и сложено с ранее вычисленным базовым адресом.
Результатом вычислений будет
0001 0010 0011 0100 0000 +
0000 0101 0110 0111 1000 =
0001 0111 1001 1011 1000
(12340h + 05678h = 179B8h).
Это и есть физический адрес ячейки памяти, который необходимо передать по шине адреса.
Такой режим работы позже был назван реальным режимом, и именно в нем работали операционные системы MS-DOS, Windows 1.x и Windows 2/286. Ключевым для реального режима является то, что процессор, в конечном итоге, передает по шине физический адрес того слова, которое ему необходимо извлечь из оперативной памяти.
Впрочем, нам придется остановиться еще на одном нюансе, связанном с описанной выше логической адресацией. Как мы уже сказали, 20-битная шина адреса позволяет передать 220, то есть 1048576 значений. Напомним, что нумерация байтов начинается с 0, так что в десятичной системе адресация байтов будет представлена диапазоном 0 - 1048575. В шестнадцатеричной записи адрес последнего, 1048575-го байта, будет равен FFFFFh, а в двоичной, как легко догадаться, - 1111 1111 1111 1111 1111 1111.
Но, как мы уже сказали, вовсе не обязательно, что сегменты оперативной памяти неизменно равны 64 Кб. Единственным обязательным условием является кратность физического адреса первого байта сегмента 16-ти. Таким образом, максимально возможным адресом сегмента будет являться FFFF0 или 1111 1111 1111 1111 0000. Переведя это значение в десятичную систему, получим 1048560-й байт (1048576 - 16 Б).
Адресуя данный сегмент, и имея возможность использовать 16-битное смещение, мы можем указать физический адрес больше 20 бит. Другими словами, возникнет перенос из старшего разряда, и в адресе появится 21-й бит. Например (в качестве значения смещения взято произвольное значение ABCDh),
1111 1111 1111 1111 0000 +
0000 1111 1111 1111 0100 =
1 0000 1010 1011 1011 1101
(FFFF0h + 0ABCDh = 10ABBD).
Такая схема теоретически позволяет адресовать 1 Мб + 64 Кб - 16 Б данных. Отнимаемые 16 байт - то пространство, куда "не дотянется" адресация, поскольку начало последнего сегмента приходится на 1 Мб - 16 Б (на границе последнего параграфа).
Процессоры до Intel 80286, то есть Intel 8086, 8088, 80186 и 80188, попросту игнорировали этот "лишний" бит и передавали по шине последние 20 бит адреса. В приведенном выше примере результирующий адрес получился бы 0FFE4h (0000 1111 1111 1110 0100), то есть совершенно иной байт.
Обратившись к истории персональных компьютеров, можно узнать, что первая модель IBM PC 5150 и оригинальный IBM PC/XT 5160 оснащались именно Intel 8088. Следующее семейство процессоров (Intel 80186 и Intel 80188) не получило широкого распространения на рынке персональных компьютеров, в частности, IBM отказалось от него из-за аппаратной несовместимости с оригинальным оборудованием IBM PC. В новых же моделях - PC/XT (5162) и PC/AT (5170) - уже использовались процессоры Intel 80286.
Intel 80186 и Intel 80286
Поскольку к тому времени (а речь идет о первой половине 80-х годов) уже наступала необходимость адресовать более 1 Мб памяти, потребовалось предпринять новые шаги по изменению адресации памяти.
Реальный режим (который, между прочим, получил свое название постфактум) не только накладывал ограничения на объем адресуемой памяти, но не был оптимален и в других отношениях. Укажем две наиболее очевидные проблемы, являющиеся следствием такой адресации.
Поскольку ничто не препятствует приложению указать в качестве адреса сегмента и смещения совершенно любые значения, появляется опасность (в результате сбоя или выполнения злонамеренного кода) повреждения данных других приложений. Другими словами, программа в ходе работы может привести к сбою в работе другой, вовсе с ней не связанной программы. В архитектуре не предусмотрено никаких аппаратных средств защиты данных. Процессор никак не контролирует выход за пределы сегмента, что позволяет указать физически несуществующий адрес.
Кроме того, не защищены и типы сегментов. Напомним, что они определяются использованием регистров CS, DS и SS. Но ничто не мешает загрузить в регистр CS, предназначенный для хранения адреса сегмента кода, адрес сегмента данных или стека. Аналогично ситуация обстоит и с регистрами DS и SS.
Наконец, реальный режим не поддерживает аппаратную многозадачность, то есть в момент времени может исполняться только одна задача. Так, при запуске Windows 1.x единственной работающей задачей становилась Windows. В свою очередь, внутри Windows, многозадачность была реализована программно. Забегая вперед скажем, что архитектура защищенного режима, появившаяся в Intel 80286, позволила обеспечить многозадачность на аппаратном уровне.
Итак, обратимся к изменениям, произошедшим с появлением Intel 80286. Во-первых, в Intel 80286 до 24 битов была расширена шина адреса, что позволило адресовать до 16 Мб памяти (224 байт). Но вместе с тем потребовалось изменить и логическую адресацию, чтобы процессор, имея в своем распоряжении только 16-битные регистры, смог сформировать 24-битный адрес.
Но прежде, чем перейти к рассмотрению новой адресации, которая, по сути, и формирует так называемый защищенный режим, необходимо сказать, как изменилась работа реального режима.
Выше мы сказали, что реальный режим теоретически позволяет адресовать 1 Мб + 64 Кб - 16 Б данных, но в Intel 8086 это было невозможно из-за ограничения, накладываемого 20-битной адресной шиной. Там возможный выход за пределы последнего сегмента просто игнорировался и возникавший "лишний" бит отбрасывался. Таким образом, указание на пространство, выходящее за пределы 1 Мб памяти, превращалось в указание на самое начало памяти.
С появлением 24-битной адресной шиной возникла возможность использовать 21-й бит, то есть адреса, лежащие в диапазоне с 100000h до 10FFEFh. Адрес 100000h соответствует первому байту за пределами 1 Мб, то есть:
1111 1111 1111 1111 0000 +
0000 0000 0000 0001 0000 =
1 0000 0000 0000 0000 0000
Напомню, что 1111 1111 1111 1111 0000 (FFF0h или 65520) соответствует предельно допустимому адресу последнего сегмента, поскольку он в обязательном порядке должен быть выравнен по границе параграфа.
Адрес 10FFEFh, в свою очередь, соответствует последнему байту, до которого "дотянется" адресация при указанном адресе последнего сегмента:
1111 1111 1111 1111 0000 +
0000 1111 1111 1111 1111 =
1 0000 1111 1111 1110 1111
Для того, что бы передать этот 21-битный адрес используется так называемая адресная линия A20, о которой вы могли слышать. При этом не удивляйтесь, что линия A20 служит для передачи 21-го бита. Дело в том, что нумерация адресных линий начинается с A0, и линии A0 по A19 (итого 20 линий) служат для передачи 20-битных адресов в реальном режиме.
Дополнительное пространство, которое становится доступно в реальном режиме при использовании Intel 80286, получило название High Memory Area (HMA). Запомните это, поскольку в последующем приложения научились использовать преимущества HMA, и в Windows 2.1 появился небезызвестный драйвер HIMEM.SYS, о котором мы будем говорить в одной из следующих статей.
На этом мы на время завершаем разговор о реальном режиме, и переходим к куда более сложному и интересному вопросу, а именно - защищенному режиму Intel 80286, позволяющему использовать 24-битную адресацию.
Физические адреса сегментов (то есть адреса их первых байтов) стали записываться в специальную таблицу, называемую дескрипторной. Каждая запись в такой таблице, соответствующая одному сегменту, называется дескриптором. Заметим, что помимо самого адреса в дескрипторе содержится и другая информация, о которой мы скажем в дальнейшем. Каждый дескриптор занимает 64 бита (8 байт), и лишь 24 бита из них занимает базовый адрес сегмента.
Таким образом, процессору больше нет нужды хранить адрес сегмента, ему достаточно помнить месторасположение соответствующего дескриптора. И действительно, в сегментных регистрах теперь хранятся не адреса сегментов, а так называемые селекторы. Селектор выполняет несколько функций, но основная из них - указание на номер дескриптора в таблице. То есть процессор "помнит", что нужный дескриптор (который, в свою очередь, содержит физический адрес нужного сегмента) имеет такой-то номер в таблице, и обращается к нему.
Отметим, что хотя Intel 80286 имеет 16-битные регистры, под номер дескриптора (поле Index) отводится лишь 13 бит. Три младших бита отведены под другие возможности защищенного режима.
Итак, сегментные регистры теперь содержат селекторы, указывающие на дескрипторы. При этом адресация конкретного байта происходит так же, как и в реальном режиме - при помощи 16-битного смещения. Руководствуясь селектором, процессор обращается к соответствующей дескрипторной таблице, находит нужный дескриптор, извлекает из него 24-битный физический адрес сегмента, складывает его со смещением, и получает физический адрес нужного байта:
селектор > дескриптор > адрес сегмента > + смещение > физический адрес байта.
Руководствуясь только этой информацией, уже можно подсчитать, какой объем памяти может адресовать процессор. 213, то есть возможное количество значений поля Index селектора, составляет 8192. Поскольку каждый дескриптор содержит адрес одного сегмента (максимальный объем которого равен 64 Кб), при помощи одной таблицы дескрипторов можно адресовать 8192*64, то есть 524288 Кб или 512 Мб виртуальной памяти.
Впрочем, сразу нужно оговориться, что одновременно процессор может работать с двумя таблицами дескрипторов. Одна из них называется глобальной (Global Descriptor Table, сокращенно GDT). В ней хранятся данные о сегментах, к которым могут обращаться все приложения. Вторая таблица называется локальной (Local Descriptor Table или LDT) и содержит дескрипторы, описывающие сегменты, к которым может обращаться лишь определенное приложение.
Отсюда следует, что таблица GDT - единственная в системе, единая для всех приложений, в то время как таблиц LDT может быть несколько, по числу запущенных задач.
Впрочем, в момент времени ЦП может работать лишь с одной локальной таблицей. Таким образом, общий объем памяти, который может быть адресован процессором, увеличивается вдвое и для Intel 80286 составляет 1 Гб. Но не стоит забывать, что фактически поддерживалось не более 16 Мб - лимит, налагаемый 24-битной шиной адреса.
Может возникнуть справедливый вопрос о местонахождении дескрипторных таблиц. Как это ни парадоксально, но они также хранятся в оперативной памяти, причем в любом ее месте (но таблица GDT должна располагаться в пределах первого мегабайта памяти, поскольку только из режима реальных адресов можно перевести процессор в защищённый режим).
Физические адреса таблиц сохраняются процессором в специальных регистрах - GDTR и LDTR для глобальной и локальной таблицы соответственно. Размер GDTR составляет 6 байт, LDTR - 10 байт. Структура GDTR предельно проста.
Старшие 24 бита (биты 39-16) указывают физический адрес начала глобальной таблицы дескрипторов в памяти. Поскольку здесь указывается полный 24-битных адрес, необходимости в выравнивании таблицы в памяти по границе параграфа нет. Оставшиеся младшие биты (15-0) указывают размер глобальной таблицы в байтах. Значение поле "Предел" здесь, правда, равно размеру таблицы минус 1 байт, поскольку значение 0 определяет сегмент размером 1 байт. Это вполне логично, поскольку 1 байт - минимальный размер сегмента, и сегмента объемом 0 байт существовать не может.
Заметим, впрочем, что нулевой дескриптор GDT процессор не использует, и обращение к нему вызывает прерывание.
Поскольку каждый дескриптор занимает 8 байт, то предел всегда равен 8*n - 1 байт, где n - количество дескрипторов. 16-битный размер предела ограничивает размер GDT 64 Кб.
Регистр LDTR, в свою очередь, работает "поверх" таблицы GDT. Фактически, в этот 16-битный регистр помещается селектор таблицы LDT. Другими словами , LDTR указывает на дескриптор, содержащийся в GDT, который в свою очередь содержит физический адрес локальной таблицы.
Рассмотрев, таким образом, как организована работа дескрипторных таблиц, обратимся к структуре селектора, при помощи которого происходит указание на тот или иной дескриптор.
Как мы сказали, лишь 13 бит селектора отводится под указание номера дескриптора. Полная структура селектора (16 бит) выглядит следующим образом:
Index указывает номер дескриптора. Конечно, процессору необходимо произвести дополнительные вычисления, чтобы узнать физический адрес дескриптора, который необходимо извлечь. Поскольку каждый дескриптор равен 8 байтам, то для вычисления адреса достаточно произвести умножение значения поля Index (номер дескриптора) на 8, и сложить это произведение с известным базовым адресом таблицы GDT или LDT. Произведение Index*8 - последовательный номер первого байта дескриптора от начала таблицы (или, если хотите, смещение от начала таблицы). Складывая его с базовым адресом таблицы, получаем необходимый физический адрес.
Заметим, что поскольку процессор знает размер дескрипторных таблиц, он блокирует использование селекторов со значением Index, превышающем допустимое для данной таблицы.
Флаг TI определяет тип таблицы (0 - GDT, 1 - LDT).
Два последних бита (поле RPL) определяют запрашиваемый уровень привилегий (00 - наивысший, 11 - низший). Благодаря указанию уровня привилегий можно запретить одним программным компонентам доступ к сегментам памяти, выделенным для других компонентов. О том, как это работает, мы еще скажем ниже.
Далее уместно сказать о структуре самих дескрипторов - записей в таблице. Дескриптор, как сказано выше, занимает 64 бита. 24 бита занимает физический адрес сегмента (базовый адрес). Полная структура дескриптора выглядит следующим образом (обратите внимание, что в таблицы ниже нумеруются байты, а не биты):
Первые два байта заполняются нулями и не используются в защищенном режиме Intel 80286. Поле права доступа (1 байт) имеют более сложную структуру, и о нем мы скажем в дальнейшем. Базовый адрес, занимающий 3 байта (то есть 24 бита), содержит физический адрес начала сегмента. Наконец, предел (последние 16 бит) указывает размер сегмента в байтах. Как легко догадаться, максимальный размер сегмента при этом составляет 216 байт, то есть 64 Кб (как и в случае со значением "Предел" в LDTR, значение 0 определяет сегмент размером 1 байт).
Поскольку сегмент адресуется полностью (указываются все 24 бита), то, как и в случае с адресацией глобальной таблицы в регистре GDTR, необходимости в выравнивании сегментов по границам параграфов нет. То есть сегмент может располагаться в любом месте оперативной памяти и занимать от 1 до 64 Кб.
Заметим, что наличие поля "Предел" позволяет процессору блокировать выход за пределы сегмента (то, чего не было в реальном режиме).
Поле прав доступа позволяет гибко настроить доступ к тому или иному сегменту. Его структура следующая:
Рассмотрим вкратце, за что отвечает каждый бит, и какие значения приемлемы.
Флаг P (от англ. Presentation) служит для организации виртуальной памяти. Значение "1" устанавливается для сегмента, присутствующего в памяти, "0" - для сегмента, сохраненного в файл подкачки на жестком диске. Этот флаг устанавливается операционной системой. При последующем обращении соответствующий сегмент загружается с жесткого диска в память. Заметим, что ранее сохраненный в файл подкачки сегмент не обязательно будет загружен в то же место памяти, где находился изначально. Он может быть загружен в другое место, а операционная система просто исправит физический адрес сегмента в дескрипторе. Конечное приложение сможет продолжить работу как ни в чем не бывало.
Очевидно, что флаг P может служить для освобождения памяти от неиспользуемых данных. Для учета неиспользуемых сегментов используется флаг A (от англ. Accessed). Значение "1" устанавливается процессором при обращении к сегменту. Операционная система время от времени может обнулять данный флаг, и впоследствии перемещать неиспользуемые сегменты (для которых спустя некоторое время значение остается "0") в файл подкачки.
Одним из недостатков данного метода является подверженность памяти фрагментации. Поскольку сегменты могут быть различного размера, освобожденного при сохранении одного сегмента в файл подкачки пространства может не хватить для сохранения туда другого, большего по объему. Операционная система может произвести перемещение соседних сегментов с последующим редактированием их дескрипторов, но на это требуется время и, кроме того, иногда необходимо освободить пространство меньшее, чем размер любого из соседних сегментов. В таких случаях фрагментация памяти становится неизбежной.
На практике управление виртуальной памятью при помощи флагов P и A не получило широкого распространения. В качестве операционных систем, использующих его, можно назвать OS/2 версий 1.0 - 1.3.
Вслед за флагом P следует DPL (Descriptor Privilege Level). В нем указывается уровень доступа, который необходим приложению для доступа к данному сегменту. Выше мы уже говорили, что аналогичное поле (RPL - Requested Privilege Level) присутствует в селекторе. 2 бита, отведенные под эти поля, позволяют определить 4 значения. 00 - наивысший уровень доступа (как правило, это ядро операционной системы, системные драйверы и тому подобное), 11 - наименьший уровень (прикладные программы). Впрочем, точное распределение уровней доступа остается за разработчиком ОС.
Доступ работает по следующему принципу: обращение к сегменту возможно, если значение RPL селектора равно или больше значения DPL данного сегмента.
Для удобства уровни часто представляют в виде четырех вложенных друг в друга колец, напоминающих мишень. Наименьшее, центральное кольцо защиты, обладает наибольшим доступом. Доступ уменьшается по мере приближения к крайнему кольцу.
Кольца защиты
Так, селектор со значением RPL "00" имеет доступ ко всем сегментам памяти; напротив, если RPL селектора равен "10" (2 уровень доступа), то он сможет обратиться только к дескрипторам, у которых поле DPL равно "10" или "11".
Возможно, у вас возник справедливый вопрос - откуда проистекают значения RPL и DPL? Ответ уместнее дать теперь. При запуске приложения, операционная система создает дескриптор сегмента кода этого приложения, и устанавливает соответствующий флаг DPL этого дескриптора. Приложение не может самопроизвольно изменить данное значение. Таким образом, поле DPL дескриптора сегмента кода обозначает тот уровень доступа, в котором будет работать данная программа - Current Privilege Level (CPL).
На дескриптор текущего выполняемого сегмента кода указывает селектор, хранящийся в сегментном регистре CS. CPL копируется в поле DPL селектора, так что приложение в любой момент времени может узнать собственный уровень доступа.
Когда возникнет необходимость получить доступ к другим сегментам, в селекторе, загружаемом в один из сегментных регистров, поле RPL будет установлено равным CPL. Доступ к соответствующему дескриптору будет получен по правилам, которые мы уже описали выше, когда говорили об уровнях привилегий.
С дальнейшей последовательностью бит все обстоит несколько сложнее, и рассматриваться они должны в своей совокупности.
Флаг S служит для определения того, является ли данный дескриптор системным (S=0) или это дескриптор кода или данных (S=1). Чтобы понять, о чем идет речь, необходимо сказать о данном аспекте классификации сегментов.
Под системными дескрипторами понимаются такие, которые описывают сегменты, содержащие специфическую информацию. Выделяется несколько типов системных дескрипторов, но мы для примера назовем только тип дескриптора, описывающий сегмент таблицы LDT.
Поле прав доступа дескриптора сегмента таблицы LDT
Заметьте, что для различения типов системных дескрипторов релевантным оказывается младший бит (флаг A в несистемных дескрипторах).
Поскольку для дальнейшего повествования назначение и функционирование системных дескрипторов не играет ключевой роли, мы оставим этот разговор в стороне и обратимся к несистемным дескрипторам, которые могут отписывать сегменты кода и данных.
Дескриптор кода описывает сегмент, содержимое которого принципиально не может изменяться приложением. Код может только исполняться или считываться. То есть это непосредственно инструкции самого приложения. Дескриптор данных, напротив, описывает данные, подлежащие как чтению, так и изменению (как правило, это переменные, сохраненные приложением).
Следующие за флагом S биты обычно называются полем TYPE, поскольку они определяют точный тип дескриптора. Заметим, что в дальнейшем речь идет только о дескрипторах кода или данных, то есть о дескрипторах, для которых S=1.
Флаг E определяет, является ли данный сегмент сегментом данных (E=0) или кода (E=1). В соответствии с этим различаются и флаги, следующие за битом E.
Рассмотрим сначала поле прав дескриптора, описывающего сегмент данных, то есть при E=0.
Поле прав доступа дескриптора, описывающего сегмент данных
Флаг ED указывает на то, расширяется ли сегмент вверх (тогда сегмент содержит данные) или вниз (тогда это так называемый сегмент стека).
Под стеком традиционно понимается такая структура данных, в которой для чтения в любой момент времени доступен только "верхний" элемент, то есть элемент, загруженный в стек последним. Иначе стек описывается через принцип LIFO - "last in - first out" (последним пришел - первым вышел). Как сказано выше, в архитектуре Intel 80286 сегмент стека определяется направлением роста. Сегмент стека всегда растет в обратном направлении, и для считывания доступна лишь последняя записанная ячейка стека. Другими словами, сегмент стека растет от конца сегмента в направлении младших адресов (то есть по направлению к базовому адресу сегмента), а считывается в обратном направлении.
Заметим, что сегменты стека преимущественно и используются программами для хранения произвольных данных, в то время как сегменты данных хранят преимущественно пользовательские переменные, необходимые для вычислений.
Аппаратное указание на то, что данный сегмент является сегментом стека, позволяет предотвратить переполнение стека и возможный сбой в работе приложения или ОС.
Флаг W указывает на права записи (как мы говорили выше, сегмент данных может быть изменен приложением). При W=0 запись запрещена, при W=1 разрешена.
Для сегмента кода последующие биты обретают иное значение.
Поле прав доступа дескриптора, описывающего сегмент кода
Бит C служит для ограничения доступа к данному коду приложений, имеющих более низкий уровень доступа. Чтобы объяснить назначение этого флага, нужно вкратце рассмотреть проблему вызова подпрограмм в архитектуре x86.
В ходе выполнения программы может потребоваться частое выполнение последовательностей одних и тех же операций. Чтобы сократить объем приложения, эти операции объединяются в подпрограмму (процедуру). Когда наступает такая необходимость, программа вызывает подпрограмму, та производит необходимые вычисления и передает управление обратно основной программе. Подпрограмма может вызываться неоднократно из различных точек программы.
Напомню, что указание на исполняемый в данный момент код (селектор сегмента кода) находится в регистре CS. Если подпрограмма располагается в одном с текущим кодом сегменте, достаточно изменить значение смещения, и содержимое регистра CS не изменяется. Если же она располагается в другом сегменте, то потребуется загрузить новый селектор.
Для передачи управления в процессорах Intel используются команды CALL и JMP. Если подпрограмма располагается в текущем сегменте (селектор которого загружен в регистр CS), в качестве аргументы команды CALL или JMP указывается новое смещение.
Если же подпрограмма располагается в другом сегменте, то передача управления может производиться двояко, но в данный момент нас интересует способ, называемый прямым вызовом.
В прямом вызове в качестве аргумента команды указывается селектор дескриптора сегмента, которому необходимо передать управление. Здесь-то и вступает в игру флаг C прав доступа сегмента кода, с которого мы и начинали. Если C=0, то данный сегмент может быть вызван только приложением, имеющем уровень привилегий не ниже, чем указан для данного сегмента. Такой уровень защиты не позволяет приложениям с низким уровнем доступа вызвать подпрограммы операционной системы.
Но зачастую обращение к компонентам операционной системы необходимо, а вынести их за пределы 0 кольца защиты не представляется возможным, так как доступ к ним необходим самой системе, а вызов менее привилегированных процедур запрещен. Другими словами, код, исполняющийся, скажем, в кольце 0, не может передать управление сегменту кода, находящемуся в кольце 1. Следовательно, необходим механизм, который позволит вызов менее привилегированному коду более привилегированных процедур.
В качестве одной из возможностей является присвоение флагу C дескриптора сегмента кода значения "1". Такой сегмент называется подчиненным. При C=1 код в описываемом сегменте может быть вызван любым приложением, вне зависимости от того, в каком кольце оно работает (за тем исключением, что его уровень доступа должен быть не выше, чем уровень вызываемой процедуры). Но исполняться данный код будет с привилегиями вызвавшей его программы (то есть значение CPL остается прежним). Таким образом, превысить свои права приложение все равно не сможет, но если подпрограмма операционной системы, к которой происходит обращение, и не требует более высоких прав, код будет успешно выполнен.
Выше мы сказали, что передать управление подпрограмме можно двумя способами; второй из них - так называемая передача управления через вентиль вызова.
В этом случае команда CALL или JMP обращается к так называемому дескриптору вентиля вызова. Формат данного дескриптора следующий:
Структура дескриптора вентиля вызова
Селектор и смещение указывают на тот сегмент, которому требуется передать управление.
Зачастую исполняемой программе необходимо передать вызываемой подпрограмме набор параметров. Параметры, как правило, передаются через стек - они копируются из стека вызывающей процедуры в стек вызываемой процедуры.
Для управления копированием параметров в поле Счетчик слов, под которое отводится 8 бит, указывается количество 16-битных слов, которое будет скопировано из стека вызывающей программы в стек целевой подпрограммы. Соответственно, может быть передан 31 параметр (25 дает 32 значения, 00000 отключает передачу параметров).
Обратите внимание, что дескриптор вентиля вызова относится к системному типу (в поле прав доступа S=0):
Поле прав доступа дескриптора вентиля вызова
Поле DPL дескриптора вентиля вызова определяет минимальный уровень доступа, которым должна обладать задача, чтобы получить доступ через данный вентиль. Таким образом, операционная система может создать вентили вызова, через которые приложения смогут получить доступ к определенным ее компонентам. Например, если приложение запущено с третьим уровнем доступа (CPL=3), то оно сможет получить доступ только через те вентили вызова, в дескрипторах которых DPL=3. В свою очередь, точное указание целевого сегмента, к которому организуется доступ, позволяет ОС точно контролировать, каким приложениям какие из компонентов будут доступны.
Наконец, флаг R прав доступа устанавливает права на чтение. При R=0 чтение запрещено, при R=1 разрешено. Запрет на чтение позволяет защитить программный код от взлома.
Таким образом, при S=1 поле TYPE (то есть флаги E, ED/C, R/W) может определить 8 типов дескрипторов:
Дифференциация сегментов на аппаратном уровне позволила предотвратить различные сценарии, такие как использование сегментов не по назначению. Например, в регистр CS (Code Segment) может быть записан только селектор, соотнесенный с сегментом кода.
Может возникнуть вопрос - неужели процессор при выполнении операций в пределах одного сегмента должен каждый раз обращаться к селектору, и с помощью него находить нужный дескриптор в дескрипторной таблице? Вовсе нет.
Intel нашла компромиссное решение, которое позволило процессору после обращения к дескриптору "запоминать" его. Для этого используются специальные 64-битные теневые регистры, в которые копируется содержимое дескриптора. Каждому сегментному регистру, содержащему селектор, соответствует теневой регистр, в который загружается дескриптор, указанный в селекторе.
Более того, теневой регистр имеется и у LDTR. То есть процессор "помнит" содержимое дескриптора сегмента текущей таблицы LDT и не считывает его каждый раз при обращении к сегменту, находящемуся в локальной дескрипторной таблице. Разумеется, при переходе к другой задаче, теневой регистр LDTR будет перезагружен и в него будет записано содержимое дескриптора сегмента новой таблицы LDT.
Важно отметить, что теневые регистры (в отличие от селекторов) недоступны программисту. Правда, как и всегда, существовали обходные недокументированные пути, о которых, возможно, нам еще представится случай поговорить.
Такая система адресации и возможности, ею предоставляемые, получили название защищенного режима. Помимо возможности адресации до 16 Мб оперативной памяти, архитектура Intel 80286 позволила дифференцировать типы сегментов памяти и посредством дескрипторных таблиц и прав доступа обеспечить соответствующую защиту хранящимся в памяти данным. Появился инструмент выгрузки неиспользуемых данных из оперативной памяти во внешнюю память.
Но разговор о Intel 80286 был бы неполным, если бы мы не сказали несколько слов о поддержке многозадачности. Реальный режим обеспечивал, по существу, однозадачную среду. В момент времени могла исполняться только одна задача, и до ее завершения ни одно другое приложение исполняться не могло. Так, по существу, работал MS-DOS. Дальнейшая эволюция операционных систем показала необходимость перехода к многозадачной среде. Windows 1.x обеспечила такой переход в рамках реального режима.
Но реальный режим, который идеально подходил для однозадачной среды, не мог решить такие проблемы, как ограничение памяти, выделенной одним задачам, от участков памяти других задач. Ограничение адресуемой памяти в 1 Мб и отсутствие возможности временного сохранения неиспользуемых данных на жестком диске для последующей их подгрузки препятствовало развитию полноценной многозадачной среды. Intel 80286 решил большинство этих проблем, а также ввел новые средства для аппаратного управления задачами. Рассмотрим их вкратце.
Перед переключением с одной задачи на другую, процессор прежде должен "запомнить" контекст приложения. Сюда входит содержимое различных регистров (в том числе регистра LDTR, содержащего селектор дескриптора таблицы LDT данного приложения) и некоторые другие данные. Все они сохраняются в специальный сегмент состояния задачи (Task State Segment - TSS). В Intel 80286 сегмент TSS состоит из 16-битных полей и имеет следующую структуру (слева приведена нумерация полей в байтах, каждое поле - 2 байта, то есть 16 бит):
Как видите, помимо типичных для архитектуры Intel 80286 регистров (IP, FLAGS, AX, CX, DX, BX, SP, BP, SI, DI, ES, CS, SS и DS) сегмент TSS содержит и дополнительную информацию. Так, сохраняется поле обратной связи Back Link, логические адреса отдельных для каждого кольца защиты стеков и содержимое регистра LDTR.
Рассмотрим вкратце назначение тех компонентов, которые нам неизвестны.
Поле Back Link называется полем обратной связи и используется для организации вложенных задач. Принцип их работы вкратце следующий: при использовании команды CALL для переключения из одной задачи в другую, в поле Back Link сегмента TSS задачи, в которую происходит переключение, будет записан селектор сегмента TSS первой задачи. Впоследствии вторая задача, выполнив команду IRET, сможет выполнить переключение обратно, к первой задаче, воспользовавшись селектором, сохраненным в поле Back Link.
Выше мы говорили о том, как происходит вызов подпрограмм, и среди популярных способов отметили использование вентиля вызова. Также мы вкратце упомянули о возможности передачи параметров от исполняемой программы целевому сегменту и сказали, что передача происходит через стек.
Дело, однако, усложняется тем, что каждая задача должна задать 4 сегмента стека - по одному на каждое кольцо защиты.
Обратимся еще раз к тому, как функционирует стек. Мы уже говорили о том, что стек работает по принципу LIFO - в момент времени доступны только те данные, которые были записаны в стек последними. Часто проводят параллель между стеком и стопкой тарелок - каждую следующую тарелку кладут вверх стопки, и в то же время только верхнюю тарелку вы можете взять.
Селектор сегмента стека хранится в регистре SS (Stack Segment). Второй регистр - SP (Stack Pointer) - является относительным указателем на вершину стека. Вершина стека - это ячейка стека, доступная в данный момент (то есть последние записанные в стек данные).
Как мы сказали выше, задача в архитектуре Intel 80286 предусматривает создание сегментов стека для каждого кольца защиты. Как они используются?
Допустим, приложение с уровнем привилегий 3 запрашивает через вентиль вызова подпрограмму, работающую во 2-м круге защиты. Процессор выбирает сегмент стека, соответствующий второму уровню (для которого DPL=2). При этом текущий сегмент стека, селектор которого загружен в регистр SS, имеет DPL=3. Далее процессор сохраняет текущие значения SS и SP (то есть указание на текущий стек), и загружает в эти регистры новые значения, указывающие на сегмент стека с DPL=2. В этот новый сегмент стека записываются прежние значения SS и SP (они потребуются по завершении работы подпрограммы), а затем параметры, число которых, как мы говорили, определено полем Счетчик слов в дескрипторе вентиля вызова.
Поля SP0, SS0, SP1, SS1, SP2 и SS2 в TSS и есть адреса этих сегментов стеков для разных уровней привилегий - 0, 1 и 2 соответственно. Данные о нахождении сегмента стека для 3 уровня нет необходимости указывать отдельно, поскольку если приложение исполняется в 3 круге, то селектор и указатель находятся в регистрах SS и SP.
Зачем это нужно? Опять же, для обеспечения защиты, чтобы вызывающее приложение не могло вмешаться в ход выполнения привилегированной подпрограммы (например, в нашем примере, вызывающее подпрограмму приложение с CPL=3, разумеется, не сможет произвести изменения в сегменте стека с DPL=2).
Дескриптор, описывающий сегмент TSS, относится к системным (S=0) и имеет следующее поле прав доступа:
Флаг B называется флагом занятости и устанавливается в "1" процессором в дескрипторе TSS задачи, к которой происходит переключение. Вместе с тем, процессор сбрасывает флаг в дескрипторе TSS задачи, из которой происходит переключение (которая исполнялась ранее). Флаг занятости необходим для того, чтобы задача не смогла вызывать сама себя рекурсивно (то есть, бесконечно).
Адресация сегмента TSS происходит при помощи специального 16-битного регистр TR (Task Register), который содержит селектор дескриптора TSS.
Как легко догадаться, для каждой задачи создается свой сегмент TSS. Описание того, как именно происходит переключение между задачами, мы опустим, поскольку разговор об этом перевел бы нас на стезю команд процессора и, в конечном счете, языка ассемблера. Заметим только, что это возможно как при помощи уже упоминавшихся команд CALL и JMP, так и по прерыванию.
Казалось бы, вот и наступило счастье - рассмотрение защищенного режима завершено. Но не тут-то было.
Выше мы уже говорили о некоторых недостатках защищенного режима Intel 80286 - отсутствие совместимости с приложениями реального режима, несовершенной схемой управления файлом подкачки, приводящей к фрагментации памяти и т.п. Скажем и еще об одной существенной проблеме - невозможности возвращения процессора из защищенного режима в реальный. Единственным способом был аппаратный сброс процессора. Это не связано с какими-либо техническими ограничениями - просто реализация данной возможности показалась Intel нецелесообразной.
Таким образом, защищенный режим Intel 80286 на практике широкого применения не получил, и, как уже сказано выше, название Windows/286 стало обозначать версию Windows для работы в реальном режиме. Поддержка защищенного режима появилась лишь в редакции Windows/386 и связана с рядом нововведений, появившихся в Intel 80386. Рассмотрению именно этого продукта посвящена наша следующая статья.
Райкер, TheVista.Ru Team,
Сентябрь 2011
Комментарии
Титанический труд. Нет слов выразить моё восхищение вашей работой!
Поддерживаю. Правда, меня хватило только на начало статьи, а на остальное, как говорил Алекс Экслер, «потребуется много-много недель и много-много всяких горячительных напитков, стимулирующих работу мозга». Но я уже узнал много нового. Спасибо! Так держать!
труд велик, и сразу было понятно по 5 частям только по Win 1, что добром для автора это не кончиться...
Похоже сага будет писаться в реальном времени )
nopasaran, изначально предполагалось, что статьи "Саги" будут заполнять затишье между новыми версиями Windows. А пока все бушует вокруг Windows 8, руки просто не доходят. Надеюсь, после выхода бета-версии, когда страсти улягутся, удастся продолжить. Две статьи, как минимум, лежат полунаписанные и ждут, когда же их закончат)