В 1982 году, с появлением процессора 80286, фирма Intel ввела понятие «режим работы процессора». Режим, совместимый с процессором 8086, был назван режимом реальной адресации (Real-Address Mode) или просто реальным (Real Mode), новый режим – защищённым (Protected Mode). Программный интерфейс процессора в разных режимах отличается весьма существенно, так, при работе в защищённом режиме, 80286 поддерживает управление памятью, виртуальную память (пока только для сегментов), переключение задач и уровни привилегий, то есть практически всё, что необходимо для реализации многозадачной операционной системы.
На этом прогресс не остановился, и в 1985 году был выпущен 32-разрядный процессор Intel386, который работал уже в трёх режимах:
ПРИМЕЧАНИЕ Поскольку термины «реальный/защищённый/… режим» относятся к режимам работы процессора, говорить о том, что в реальном/защищённом/.. режиме работает какая-либо программа не совсем корректно. Правильнее было бы сказать, что программа исполняется процессором, находящимся в реальном/защищённом/… режиме. Но, так как первый вариант в два раза короче, общепринят и не вносит путаницы, используется он. |
Последующие поколения процессоров добавили к программному интерфейсу защищенного режима несколько новых понятий, флагов и команд, но никаких фундаментальных изменений не принесли.
ПРИМЕЧАНИЕ Начиная с Pentium и некоторых поздних моделей Intel486, появился четвёртый режим: режим системного управления (System Management Mode; SMM), но он в курсе не рассматривается. |
Основное предназначение главы – описание задач, стоявших перед разработчиками защищённого режима и некоторых следствий из них. Задумываться на тему «зачем же они всё это придумали?» чрезвычайно полезно, так как только взгляд с этой точки зрения позволяет объяснить причины принятия тех или иных архитектурных решений. Как это ни странно, несмотря на не слишком большую сложность такого анализа, я не встретил ничего подобного ни в одном из источников.
В качестве примера – наиболее простая из виденных мною программ, переключающаяся в защищённый режим.
Описание базового механизма управления памятью в защищённом режиме – сегментации (segmentation). Объясняются основные понятия, описывается упрощённый формат дескриптора сегмента (segment descriptor), глобальная таблица дескрипторов (Global Descriptor Table; GDT), упрощённый формат селектора (selector).
Примеры: unreal-mode и переключение сегмента кода.
Основные сведения об обработке прерываний в защищенном режиме: программные прерывания, таблица дескрипторов прерываний (Interrupt Descriptor Table, IDT), упрощённый формат дескриптора шлюза ловушки (trap gate descriptor).
Пример – обработка программного прерывания int 0.
Рассуждения на тему «а зачем вообще нужны исключения», перечисление существующих типов исключений, описание отличий механизма обработки исключений от программных прерываний. Длинная, но несложная глава.
Пример – программа, приводящая к исключению #GP (General Protection) и успешно его обрабатывающая.
Рассуждения на тему «а зачем вообще нужны внешние прерывания», описание механизма возникновения и обработки внешних прерываний, взаимодействия процессора и ПКП (программируемый контроллер прерываний), управление стандартным ПКП. В главе много фактических данных, не относящихся напрямую к архитектуре процессора, но без них не обойтись.
Пример – программа, обрабатывающая прерывание от RTC (Real Time Clock, часы реального времени).
Да, вы угадали. Начинается всё с рассуждений на тему «а зачем вообще нужна защита». Дальше всё как обычно: дескрипторы, уровни привилегий, DPL, CPL, RPL.
Пример – программа, приводящая к #GP из-за высокого уровня RPL, и успешно исправляющая ситуацию в обработчике исключения.
Кратко описан сегмент состояния задачи (task state segment, TSS), его дескриптор, инструкция ltr. Глава-врезка. Всего пять страниц (вместе с кодом!), никаких рассуждений, очень простой пример, заданий нет вообще.
Несколько вопросов, которые, с одной стороны, хочется рассмотреть (потому что интересно или нужно), с другой стороны, не хочется ради этого заводить по отдельной главе на каждый или расширять существующие главы. А именно:
Сначала два общеизвестных утверждения:
В приложении к курсу, это будет звучать так:
Именно это – выбранный уровень детализации. Описано, зачем вообще нужен защищённый режим, почему он именно такой, и как на его основе реализуются необходимые для работы ОС механизмы. Следующий шаг «вглубь» не делается, рассмотрение базы, на которой построен сам защищённый режим (особенностей реализации процессора) находится далеко за рамками курса.
Для системного программиста защищённый режим очень, очень сильно отличается от реального.
защищённый режим нужен для того, чтобы разрабатывать многозадачные операционные системы
ПРИМЕЧАНИЕ Как здесь, так и далее, речь идёт о многозадачных ОС разделения времени. Существуют другие классы ОС, имеющие существенно отличающиеся потребности. Они тоже могут использовать защищённый режим, но его основные «клиенты» это многозадачные ОС разделения времени. |
А теперь попытаемся понять, что же именно пришло в голову разработчикам Intel, чем их не устраивала старая архитектура процессора, и что у них получилось в итоге.
Как уже было сказано, основная мысль – поддержка многозадачных ОС. Чуть более подробно о том, что такое многозадачная ОС, что ей надо, и о чем ещё пришлось побеспокоиться разработчикам.
ПРЕДУПРЕЖДЕНИЕ Естественно, о том, что приходило в головы разработчиков, и какие задачи ставило перед ними руководство можно судить только по результатам их труда. То есть, грубо говоря, это всё мои домыслы, основанные на косвенных признаках. К сожалению, даже если принять все «домыслы» за истину и считать, что мы понимаем цели разработчиков, это не поможет вам получить «единую, стройную картину» – система слишком сложна. Но, возможно, вы сможете понять, какие требования предъявлялись к каждой подсистеме процессора, увидеть, как именно эти требования учитывались и, тем самым, вместо «набора необъяснимых фактов» получить «набор объяснимых фактов», что уже гораздо лучше. |
Основное предназначение многозадачной ОС – позволять одновременное выполнение нескольких пользовательских приложений и не позволять им мешать друг другу. Кроме этого, ОС должна обеспечивать некоторое удобство программирования, иначе она вряд ли будет пользоваться успехом. Разберём по пунктам.
«Позволять одновременное выполнение нескольких пользовательских приложений» это:
«Не позволять им мешать друг другу»:
ПРИМЕЧАНИЕ Это не паранойя и речь идёт не только и не столько о вирусах и прочих «зловредных» программах. Одна из основных задач изоляции приложений друг от друга – уменьшение влияния программных ошибок на систему. Классический пример: если в ОС оперативная память доступна всем, случайная запись по неверному адресу, произведённая одним приложением, может привести к краху совершенно другого приложения, данные которого оказались испорчены. |
Но, очевидно, что сама ОС должна иметь доступ и к портам, и к памяти, и к прерываниям, иначе она просто не сможет работать. Отсюда вытекает:
«Удобство программирования»:
Окончательный список основных требований к ПО, которое, по предположениям разработчиков Intel, будет выполняться на их процессоре (да, ещё раз почти то же самое, но всё вместе и иногда другими словами; повторение – мать учения):
Эмерсон, с присущей ему изобретательной памятью, приписал Гёте идею о том, что архитектура – это застывшая музыка. Это изречение и наша личная неудовлетворённость созданиями современной эпохи внушает нам порой мечту об архитектуре, которая, подобно музыке, была бы прямым языком страстей, не стесненным требованиями, предъявляемыми к жилью или общественным зданиям. Хроники Бустоса Домека
Поскольку интересующий нас защищённый режим это только один из блоков архитектуры процессора, а различные «блоки» связаны между собой, перед описанием собственно защищённого режима полезно кратко описать «окружение» в котором находится защищённый режим, «метаархитектуру».
Именно эта часть называется защищённым режимом (Protected Mode; наконец-то я ввёл термин, который уже столько раз использовал!). Единственное, что стоит сказать об этом блоке в этой главе – он есть. То есть, в архитектуре процессора поддержаны все пункты, перечисленные выше в разделе «Мысль #1: Многозадачная ОС» и реализованы ещё кое-какие детали. Подробностям посвящены все остальные главы.
Как и обещалось в разделе «Обратная совместимость в новом режиме», старые регистры и команды сохранены.
А это – режим реальной адресации (Real-Address Mode) или просто реальный режим (Real Mode). Он не только существует, но именно в нём начинает свою работу процессор после включения питания, для переключения в защищённый режим необходимо выполнить специальные действия. «Правильное» переключение режимов нетривиально и описывается на протяжении всего курса, начало этому положено ниже, в разделе «Переключение режимов».
ПРЕДУПРЕЖДЕНИЕ Полной совместимости, конечно, не получилось. Реальный режим Intel386 и следующих поколений процессоров имеет многочисленные, хотя и не очень крупные отличия от 8086. Но, поскольку подробное рассмотрение реального режима выходит за рамки курса, нигде далее эти отличия даже не упоминаются. Кому интересно, идите на http://wasm.ru, ищите там пользователя The Svin и спрашивайте его лично, он всё знает :) Большое спасибо ему за это замечание. |
Регистры являются неотъемлемой частью процессоров Intel x86 и объединяют оба блока, не относясь к какому-либо одному, поэтому краткая сводка по ним приведена здесь.
Все регистры можно поделить на следующие группы (FPU/MMX/SSE/… - не рассматриваем! Ни здесь, ни далее):
Совсем новые регистры скорее относятся к защищённому режиму и будут рассмотрены по мере надобности в течение курса, а вот изменения, произошедшие со старыми, лучше описать сразу.
ПРИМЕЧАНИЕ В старых программах все команды работы с регистрами (в том числе pusha/popa, pushf/popf, call/ret/retf, …) будут работать точно так же, как и раньше, используя только младшие 16 разрядов 32-х разрядных регистров, у новых программ есть выбор. О том, как это получается, написано в приложении, в разделе «32-х разрядные сегменты кода» |
Переключение режима работы процессора выполняется изменением флага PE, который находится в младшем бите регистра CR0.
ПРИМЕЧАНИЕ Регистр CR0 появился в Intel386 и относится к группе управляющих регистров (Control Registers). Он содержит различные флаги, но кроме PE нам из него пока ничего не нужно. |
Примерно так:
; Устанавливает флаг PE, переводит процессор в защищённый режим set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE, переводит процессор в реальный режим clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret |
Как это обычно бывает при изменении флагов, в результате некоторым образом меняется логика работы процессора (а иначе зачем этот флаг?). Отличие от известных вам по реальному режиму флагов (Carry, Direction, Overflow, Interrupt Enable, …) только в том, что в данном случае «изменение логики работы процессора» по масштабу напоминает революцию, так как в него вовлечены практически все механизмы управления системой.
Поскольку меняется логика работы процессора, для нормальной работы соответствующим образом должно изменится и всё остальное. В идеале, исполняемый код, содержимое регистров, памяти, системных структур (например, таблицы прерываний) до переключения должны быть корректны для одного режима, а после переключения – уже для другого. Но, естественно, на практике добиться этого не получается, и переключение режимов – непростой, довольно длительный, многоступенчатый процесс, который начинается значительно раньше, а заканчивается значительно позже изменения флага PE.
ПРИМЕЧАНИЕ Революция («классическая» революция, а не то, что сейчас называется этим словом) – очень хорошая аналогия. Как в процессорах Intel, так и в жизни, для успешной смены режима нужно захватить и перевести на свою сторону различные системные структуры, только в одном случае это вокзалы, телефон и телеграф, а в другом – система прерываний и механизмы управления памятью. Длительность, многоступенчатость процесса, в котором «изменение флага» служит некой формальной границей, но ни в коем случае не окончанием процесса, также имеет прямые параллели. |
В результате, между фактическим переключением режима процессора (установка/сброс флага) и окончательной инициализацией соответствующих системных структур, есть некоторое количество команд, которые выполняются в «не до конца инициализированном» режиме. Но у процессора такого состояния нет! Он может находиться либо в реальном режиме, либо в защищённом, никаких «промежуточных» режимов не предусмотрено. Поэтому, с точки зрения процессора, эти команды выполняются в нормальном, полноценном режиме, хотя им и не доступны все его возможности (доступны только те, которые уже успели инициализировать).
Отсюда следует, что, если все возможности не нужны, вполне можно ограничиться «не до конца инициализированным» режимом. А, поскольку «правильная» (как советует [Intel 2004]) инициализация защищённого режима дело трудное и неблагодарное, мы будем активно использовать этот факт, инициализируя защищённый режим лишь настолько, насколько нам необходимо.
ПРЕДУПРЕЖДЕНИЕ Вообще-то, это использование недокументированных возможностей, что почти всегда плохо, так как эти возможности могут внезапно исчезнуть в одной из следующих моделей процессора. В реальных продуктах желательно следовать рекомендациям Intel. |
Ещё один важный момент: в реальном режиме любая программа может переключить процессор в защищённый режим, но обратное переключение может выполнить только ОС (иначе о надёжности и безопасности можно было бы забыть), при попытке изменить флаг PE пользовательской программой будет выброшено исключение. Подробнее эта тема рассмотрена в главе «Теоретическое введение в защиту».
Как уже было сказано выше, «переключение режимов – непростой, довольно длительный, многоступенчатый процесс». Пока что мы сократим его до минимума, оставив всего две ступени. Точно так же окажутся урезанными и возможности нашей программы, но зато она будет проста, понятна и работоспособна.
Последовательность действий:
В данном случае, пункты (1), (2) это переключение из реального в защищённый режим, а пункты (4), (5) – из защищённого в реальный.
С маскируемыми прерываниями всё просто – достаточно вызвать cli/sti. Про немаскируемые в [Intel 2004] написано, что они должны блокироваться «внешней схемой» (NMI interrupts can be disabled with external circuitry). В [Зубков 1999] описан следующий вариант:
; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить маскируемые прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить маскируемые прерывания ret |
ПРЕДУПРЕЖДЕНИЕ Очевидно, что для проверки работоспособности этого кода необходимо добиться возникновения немаскируемого прерывания. Поскольку, насколько я знаю, эта задача не решается программно, я просто поверил Сергею Владимировичу на слово. При написании коммерческой ОС проверьте самостоятельно! |
Теория это, конечно, хорошо, но надо же когда-то начинать программировать. Для начала, по традиции, напишем небольшой Hello world.
ПРЕДУПРЕЖДЕНИЕ Этот и все последующие примеры должны запускаться только из реального режима! Иначе, в лучшем случае, они не будут работать, в худшем (хотя и очень маловероятном) – снесут то, что работало. |
СОВЕТ При сборке com-программ MASM-ом, командная строка выглядит так: ml.exe /AT <имя файла исходного кода> |
; hello.asm ; Программа, выполняющая переход в защищенный режим и немедленный возврат. .model tiny .code .386p ; все наши примеры рассчитаны на 80386 org 100h ; это COM-программа start: ; подготовить сегментные регистры push cs pop ds ; DS - сегмент данных (и кода) нашей программы push 0B800h pop es ; ES - сегмент видеопамяти ; запретить прерывания call disable_interrupts ; перейти в защищенный режим call set_PE ; Мы в защишённом режиме! ; вывод на экран xor di, di ; ES:DI - начало видеопамяти mov si, offset message ; DS:SI - выводимый текст mov cx, message_l rep movsb ; вывод текста mov ax, 0720h ; пробел с атрибутом 07h mov cx, rest_scr ; заполнить этим символом остаток экрана rep stosw ; переключиться в реальный режим call clear_PE ; разрешить прерывания call enable_interrupts ; подождать нажатия любой клавиши mov ah, 0 int 16h ; выйти из COM-программы ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; текст сообщения с атрибутом после каждого символа для прямого вывода на экран message db 'H',7,'e',7,'l',7,'l',7,'o',7,',',7,' ',7,'w',7 db 'o',7,'r',7,'l',7,'d',7,'!',7, ; его длина в байтах message_l = $ - message ; длина оставшейся части экрана в словах rest_scr = (80*25) - (message_l / 2) end start |
После того, как программа напечатала на экране «Hello, world!» и успешно завершилась, возникает резонный вопрос: а было ли переключение в защищённый режим? Ведь вывести на экран такую строчку несложно и из реального…
К сожалению, на текущем этапе наших знаний недостаточно для получения «положительного» подтверждения, то есть мы пока не можем сделать что-либо, недоступное в реальном режиме. Зато можно получить «отрицательное»! Для этого достаточно записать число больше 7 (почему больше 7 – в следующей главе) в любой сегментный регистр, скорее всего, это закончится повисанием компьютера или аварийной перезагрузкой. Чувствуете мощь? В реальном режиме добиться такого результата значительно сложнее! :)
Для большинства прикладных программистов основные преимуществами защищённого режима (из тех, о которых они слышали…) это значительное увеличение объёма адресуемой памяти и возможность практически отказаться от сегментации. Системные программисты видят ситуацию с несколько другой стороны. Погрузившись в защищённый режим глубже, они понимают, что сегменты размером 4 Гб и плоский (flat) режим адресации это, конечно, замечательно и очень полезно с практической точки зрения, но для ОС это далеко не главное. Куда важнее то, что памятью теперь действительно можно управлять.
ПРИМЕЧАНИЕ В реальном режиме всё управление памятью сводилось к записи значения в сегментный регистр, ничего большего нельзя было сделать в принципе. В защищённом режиме возможности гораздо шире (в частности, можно организовать тот самый плоский режим). |
Ну а, в качестве естественной компенсации, в защищённом режиме управлять памятью не только можно, но и необходимо, и, конечно же, это выливается в весьма ощутимые дополнительные усилия, как при изучении, так и при кодировании.
Управление памятью в защищённом режиме разбивается на две почти независимые части:
Необходимой является только сегментация, и именно она рассмотрена в этой главе.
Перед тем, как переходить к собственно управлению памятью, надо выяснить, что такое «память», и в каком виде она доступна программисту.
ПРИМЕЧАНИЕ Скорее всего, вы знаете ответы на эти вопросы для реального режима, но в защищённом режиме будет немного иначе. Привыкайте, в нём почти всё немного иначе. |
В защищённом режиме существует три различных типа адресов и три соответствующих им адресных пространства (address space):
Логическим адресом называется пара <сегмент>:<смещение>, на практике встречающаяся в виде <сегментный регистр>:<смещение>. Так как разрядность сегментного регистра 16, а смещения 32 бита, теоретически, таким образом можно было бы адресовать 248 байт (256 Тб). Но из-за некоторых особенностей сегментных регистров защищённого режима, это число уменьшается до 246 байт (64 Тб), что, конечно, тоже немало.
32 разряда, плоское (несегментированное) адресное пространство размером 4 Гб.
Физический адрес это число, выставляемое процессором на шину адреса. Разрядность зависит от модели процессора:
Фактически, это множество адресов, к которым может обращаться процессор.
ПРИМЕЧАНИЕ Физический адрес не обязательно является адресом в ОЗУ. Это может быть адрес в ПЗУ; в видеопамяти; регистр какого-либо устройства; адрес, которому ничего не соответствует; ... Куда относится конкретный физический адрес зависит от «железа», причём далеко не только от процессора. |
Поскольку программист использует логические адреса, а процессор физические, необходимо преобразование. Оно происходит в два этапа: логический – линейный и линейный – физический. Первый этап выглядит так:
<линейный адрес> = <базовый адрес сегмента> + <смещение>
ПРИМЕЧАНИЕ Обращаю внимание: сегменты могут перекрываться. Хуже того, скорее всего, они будут перекрываться. А как иначе вместить 64 логических терабайта в 4 линейных гигабайта? |
Если страничная адресация не используется (наш случай), второй этап получается вырожденным:
<физический адрес> = <линейный адрес>
ПРИМЕЧАНИЕ Таким образом, в отсутствии страничной адресации, даже при 36-ти битной шине адреса невозможно обратиться к памяти по адресам старше 4 Гб. Зато всё гораздо проще… |
Для программиста, сегмент это адресуемый блок памяти с заданными свойствами. Подходя к вопросу чуть более формально, можно сказать, что сегмент описывается следующими параметрами:
ПРИМЕЧАНИЕ Сегмент – логическое понятие. Физически есть: - Некая структура, описывающая свойства сегмента (про неё ниже в этой же главе). Очевидно, что это не сегмент. - Часть линейного адресного пространства, на которую проецируется сегмент. Во-первых, она не обладает никакими «заданными свойствами», во-вторых, на любую часть линейного адресного пространства могут проецироваться несколько сегментов. Поэтому и это не сегмент. - Процессор, который связывает первое и второе. Естественно, он тоже не сегмент. |
Как уже было сказано, логический адрес <сегмент>:<смещение> преобразуется процессором в линейный адрес по формуле:
<линейный адрес> = <базовый адрес сегмента> + <смещение>
Поскольку смещение 32-х разрядное, таким образом можно адресовать область линейного адресного пространства, начинающуюся от базового адреса (base address) сегмента и имеющую размер 4 Гб. Но допустимые смещения ограничиваются вторым параметром – размером сегмента (segment size). Процессор считает, что к сегменту относится непрерывная область линейного адресного пространства, начинающаяся с базового адреса сегмента и содержащая количество байт, равное его размеру. Логические адреса со смещением, равным или превосходящим размер сегмента, выходят за пределы сегмента, обращение к таким адресам приводит к исключению #GP (General Protection Fault; подробнее об исключениях в соответствующей главе).
ПРИМЕЧАНИЕ Размер сегмента имеет тот же смысл, что и размер массива в C. Например, размер массива char array[100]; равен 100, но последний элемент массива – array[99]. Так же и с сегментами: смещение последнего адресуемого байта это размер сегмента – 1. |
Взаимосвязь между всеми этими понятиями продемонстрирована на Рис.1.
ПРИМЕЧАНИЕ Помимо обычных сегментов существуют expand down (переводится как «растущий вниз») сегменты, для которых ограничения задаются иначе. Их полезность в реальных программах представляется мне сомнительной, тем не менее, expand down сегменты кратко описаны в приложении. |
Свойства сегмента определяют его тип и правила использования (попытка использовать сегмент не по правилам обычно приводит к исключению #GP). Не вдаваясь в детали, можно выделить следующие типы сегментов:
Как видите, в списке нет сегментов стека. Вообще-то, специально для этого предназначены упомянутые выше expand down сегменты, но с точки зрения классификации они являются частным случаем сегмента данных. Кроме того, к сожалению, они обладают тремя недостатками:
К счастью, использование expand down сегментов не обязательно, сегментом стека может быть любой доступный для чтения и записи сегмент данных.
Логически, дескриптор (descriptor) это структура, описывающая некую системную сущность. В частности, дескриптор сегмента (segment descriptor; сегментный дескриптор) описывает сегмент. Физически дескриптор занимает восемь байт и имеет вполне определённый формат, зависящий от типа дескриптора.
ПРИМЕЧАНИЕ Форматы дескрипторов могут показаться вам несколько странными и даже нелогичными. Но будьте снисходительны к разработчикам, учтите, что им приходилось соблюдать обратную совместимость с 16-ти разрядным 80286. В 80286 дескрипторы занимали те же восемь байт, но вся «полезная нагрузка» была сосредоточена в шести младших байтах, а старшие два были зарезервированы на будущее. Вот в них-то и пришлось уложить всю 32-х разрядность. |
Формат дескриптора сегмента кода/данных (code or data segment descriptor, это официальное название) таков:
ПРИМЕЧАНИЕ Напоминаю, что в борьбе точности и грамотности победила точность. Биты и байты считаются от нуля, то есть нулевой, первый, второй и т.п. |
Положение | Название | Краткое описание |
---|---|---|
Два младших байта (нулевой и первый) | Segment Limit (part 1) | Младшие 16 бит 20-ти битного поля Segment Limit. Поле Segment Limit используется для вычисления размера сегмента, содержит номер последнего блока (блоки нумеруются от 0, размер блока определяет флаг G, см. ниже), являющегося частью сегмента. Алгоритм вычисления размера сегмента подробно описан ниже. |
Второй, третий, четвёртый байты | Base Address (part 1) | Младшие три байта 32-х битного поля Base Address. Поле Base Address содержит базовый адрес сегмента в линейном адресном пространстве. |
0-й бит пятого байта | ?? | Пока неважно, устанавливайте в 0. |
1-й бит пятого байта | R/W | Для сегмента кода называется R (Read enable), для сегмента данных – W (Write enable). В случае сегмента кода управляет возможностью чтения его содержимого, в случае сегмента данных управляет возможностью модификации. Если флаг установлен, то можно, если нет, то нельзя. |
2-й бит пятого байта | ?? | Пока неважно, устанавливайте в 0. |
3-й бит пятого байта | Code/Data | Если флаг установлен, дескриптор описывает сегмента кода, если сброшен – сегмент данных. |
4-й – 7-й биты пятого байта | ?? | Пока неважно, устанавливайте в 1001b. |
0-й – 3-й биты шестого байта | Segment Limit (part 2) | Старшие 4 бита поля Segment Limit. |
4-й – 6-й биты шестого байта | ?? | Пока неважно, устанавливайте в 0. |
7-й бит шестого байта | G | Granularity. Флаг гранулярности. Используется для вычисления размера сегмента, определяет, в каких единицах он указан. Если флаг сброшен, размер сегмента указан в байтах, если установлен – в 4096-ти байтных блоках (4096 == 1000h). |
Седьмой байт | Base Address (part 2) | Старший байт поля Base Address. |
Графически это выглядит так:
И обещанный алгоритм вычисления размера сегмента (на языке «псевдо-С»):
<количество блоков> = <Segment Limit> + 1; if (G == 1) // Если флаг гранулярнсти установлен { <размер блока> = 4096; } else { <размер блока> = 1; } <размер сегмента> = <размер блока> * <количество блоков>; |
В результате:
ПРЕДУПРЕЖДЕНИЕ Вся эта арифметика относится только к размеру сегмента! Базовый адрес сегмента всегда совпадает со значением поля Base Address. |
ПРЕДУПРЕЖДЕНИЕ Относится ко всем встречающимся в курсе структурам, в том числе к дескрипторам! Поля, занимающие больше одного байта, находятся в стандартном для Intel x86 формате Little Endian, то есть в байте с младшим адресом расположена менее значимая часть числа (например, число 12345678h будет представлено последовательностью байт 78h 56h 34h 12h). Применительно к дескриптору сегмента кода/данных, это означает следующее: - в нулевом байте – младшие 8 разрядов Segment Limit, в первом – от 8-го до 15-го. - во втором байте – младшие 8 разрядов Base Address, в третьем – от 8-го до 15-го, в четвёртом – от 16-го до 23-го. |
Дескриптор сегмента данных, размер сегмента 4 Гб, базовый адрес 0, Read/Write:
db 0FFh ; Segment Limit db 0FFh db 0 ; base address db 0 db 0 db 10010010b ; 1001, C/D – 0, 0, R/W – 1, 0 db 10001111b ; G - 1, 000, Limit - 1111 db 0 ; base address |
Дескриптор сегмента данных, размер сегмента 64 Кб, базовый адрес 0, Read/Write:
db 0FFh ; Segment Limit db 0FFh db 0 ; base address db 0 db 0 db 10010010b ; 1001, C/D – 0, 0, R/W – 1, 0 db 00000000b ; G - 0, 000, Limit - 0000 db 0 ; base address |
Дескриптор сегмента кода, размер сегмента 64 Кб, базовый адрес 12345678h, Execute/Read:
db 0FFh ; Segment Limit db 0FFh db 78h ; base address db 56h db 34h db 10011010b ; 1001, C/D – 1, 0, R/W – 1, 0 db 00000000b ; G - 0, 000, Limit - 0000 db 12h ; base address |
Как видите, определять дескрипторы таким образом вполне возможно и даже не слишком сложно. Но всё-таки, когда основной принцип уже понятен, удобнее использовать вот такую структуру:
segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги. Этот байт отвечает за то, кто и как может ; использовать дескриптор. Подробнее – в следующих ; главах. flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends |
Если внимательно посмотреть на примеры дескрипторов, приведённые выше, несложно заметить, что это всего лишь объявления некоторых восьмибайтных структур. Но просто объявить в своей программе такую структуру и мысленно назвать её дескриптором совершенно недостаточно для того, чтобы начать использовать описываемый этим дескриптором сегмент. Потому что процессор читать мысли не умеет и задумку не поймёт... Для того чтобы процессор понял, дескриптор должен находиться в глобальной таблице дескрипторов (Global Descriptor Table, GDT).
ПРИМЕЧАНИЕ А почему бы вместо этого не увеличить размер сегментных регистров до восьми байт, чтобы туда влезал дескриптор? Об этом ниже, в разделе «Селекторы и сегментные регистры». |
Таблица дескрипторов состоит из двух частей:
Рассмотрим по частям.
Формат области памяти – массив дескрипторов, в котором нулевой дескриптор начинается со смещения 0, первый – 8, второй – 16 и т.п. Подобный массив, содержащий 3 дескриптора, мог бы выглядеть так (дескрипторы взяты из примера выше):
; сегмент данных, 4 Гб, базовый адрес 0, Read/Write segment_descriptor <0ffffh, 0, 0, 10010010b, 10001111b, 0> ; сегмент данных, 64 Кб, базовый адрес 0, Read/Write segment_descriptor <0ffffh, 0, 0, 10010010b, 0, 0> ; сегмент кода, 64 Кб, базовый адрес 12345678h, Execute/Read segment_descriptor <0ffffh, 5678h, 34h, 10011010b, 0, 12h> |
Местоположение и размер GDT задаёт регистр GDTR (очевидно, от GDT Register; это не ключевое слово ассемблера, а условное название, которое применяется в документации для обозначения этого регистра), вот описание его формата:
Положение | Описание |
---|---|
Два младших байта | Смещение последнего байта таблицы дескрипторов. То есть размер таблицы в байтах минус 1. Так как дескрипторов должно быть целое число, размер должен делиться на 8 (размер дескриптора в байтах), а смещение последнего байта должно быть равно 8n-1. |
Оставшиеся четыре байта | Линейный базовый адрес таблицы дескрипторов. |
Для загрузки регистра GDTR существует специальная команда lgdt
lgdt pointer_to_new_gdtr |
Здесь pointer_to_new_gdtr это указатель на шестибайтную структуру, повторяющую формат GDTR.
Инициализация GDT может выглядеть так:
… ; Вычисляем линейный адрес начала массива дескрипторов mov eax, 0 mov ax, ds shl eax, 4 add eax, offset GDT ; Записываем его в структуру mov dword ptr gdtr + 2, eax ; Загружаем GDTR. ; fword ptr – указатель на шестибайтную структуру lgdt fword ptr gdtr … ; Global Descriptor Table GDT label byte db … ; Дескриптор #0 … db … ; Дескриптор #N gdt_len equ $ - GDT ; размер GDT gdtr dw gdt_len – 1 ; 16-битный размер GDT – 1 dd ? ; Место для 32-х битного базового адреса GDT … |
Опять же, для удобства можно определить структуру:
table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends |
В реальном режиме сегменты похожи друг на друга как братья-близнецы, и для описания конкретного сегмента не нужно никаких «структур данных», достаточно знать адрес его начала, а для обращения к сегменту достаточно просто записать этот адрес в какой-нибудь сегментный регистр. Одним из следствий (это может быть как плюс, так и минус) такого подхода является то, что любое приложение достаточно легко получает доступ на чтение, запись или исполнение к любой области памяти, не зависимо от желания ОС или других приложений.
Защищённый режим позволяет разумной ОС более-менее контролировать этот процесс:
ПРИМЕЧАНИЕ Резонное возражение: но ведь пользовательские приложения могут сами вызвать lgtr и создать собственную, «альтернативную» GDT, через которую они получат доступ к любому участку памяти! К счастью, разработчики процессора не забыли о такой возможности, нормальное приложение не сможет сделать ничего подобного, так как для того чтобы успешно использовать инструкцию lgdt оно должно уже взломать защиту ОС каким-то другим способом. Подробнее этот вопрос будет описан в главе, посвящённой защите. Ещё несколько не менее резонных возражений обсуждается в конце главы. |
Селектором называется 16-ти битная структура, используемая для ссылки на находящийся в GDT дескриптор. Формат селектора:
Положение | Название | Описание |
---|---|---|
0-й – 2-й биты | ?? | Пока неважно, устанавливайте в 0. |
3-й – 15-й биты | Index | Номер дескриптора в GDT. |
ПРИМЕЧАНИЕ Как вы помните, максимальное смещение последнего байта, которое можно записать в GDTR это FFFFh, поэтому максимальный размер GDT – 216. А, так как один дескриптор занимает 8 байт, GDT может содержать не более 213 дескрипторов, то есть как раз столько, сколько влезает в поле Index селектора. |
Примеры:
8 ; 0..01000 – первый дескриптор GDT 16 ; 0..10000 – второй дескриптор GDT 0 ; 0..00000 – нулевой дескриптор, см. про него ниже |
ПРИМЕЧАНИЕ Поскольку селектор ссылается на дескриптор, а уже дескриптор описывает сегмент, наверное, правильно было бы говорить «селектор дескриптора сегмента хххх» или даже «селектор, ссылающийся на дескриптор, описывающий сегмент хххх». Но вместо этого я буду употреблять просто «селектор сегмента ххх». Потому что так короче и понятнее… |
Как уже было сказано выше, при работе в защищённом режиме, процессор интерпретирует значения сегментных регистров как селекторы, а не как адреса начала сегментов. Но это не единственная новость :)
Начиная с 80286, сегментные регистры состоят из двух частей: видимой и скрытой (visible part, hidden part). Видимая часть это доступный для чтения/записи 16-ти разрядный регистр, скрытая часть недоступна никак, её наличие можно определить только по некоторым косвенным признакам и документации. Содержимое видимой части зависит от режима работы процессора, а вот в скрытой части регистра в обоих режимах находится некоторый аналог дескриптора, полностью описывающий сегмент. Работает это примерно так (для защищённого режима):
В результате поиск дескриптора, его чтение, и проверка его корректности выполняется всего один раз, что значительно ускоряет обращения к памяти. Но появляются некоторые побочные эффекты:
Второй «побочный эффект» нуждается в нескольких комментариях:
ПРЕДУПРЕЖДЕНИЕ Повторюсь, это недокументированная особенность, теоретически поведение процессора может измениться (на данный момент – Pentium 4 – полёт нормальный, всё осталось, как было), поэтому в реальных программах её использования следует избегать. |
Есть несколько простых правил совместного использования селекторов и сегментных регистров, соблюдение которых убережёт вас от лишних исключений GP# и связанных с ними неприятностей.
Селектор, указывающий на нулевой дескриптор GDT, отличается от прочих. Он имеет три особенности:
Предназначение нулевого селектора – явно «пометить» сегментные регистры, использование которых не планируется. Если этого не сделать, случайное использование таких сегментных регистров может привести к непонятным и плохо воспроизводимым ошибкам.
ПРИМЕЧАНИЕ Побочный эффект – реальное содержимое нулевого дескриптора GDT никогда не используется, так как к нему невозможно обратиться. |
Селектор, загруженный в SS, должен быть селектором сегмента данных, доступного для чтения/записи.
Селектор, загруженный в CS, должен быть селектором сегмента кода.
Селектор, загруженный в любой из этих регистров, должен быть либо селектором сегмента данных, либо селектором сегмента кода, доступного для чтения, либо нулевым селектором.
Итак, к данному моменту рассмотрены все основные понятия и артефакты, относящиеся к сегментации, осталось выяснить только две вопроса:
Иголка в яйце, яйцо в утке, утка в зайце.. К. Бессмертный, «Современные системы безопасности», V в. н.э.
Для начала рассмотрим «концептуально чистый» случай. Пусть на входе логический адрес в виде пары <селектор>:<смещение>, а на выходе нужно получить соответствующий линейный адрес. Алгоритм работы процессора:
Нарисовать это можно так:
Более «правильный» алгоритм должен учитывать то, что селекторы «в свободном виде» встречаются только в командах загрузки адреса (lds, les, lfs, lgs, lss) и дальнего перехода, в остальных же случаях используется селектор, загруженный в сегментный регистр. А, поскольку во время загрузки сегментного регистра процессор уже проверил корректность селектора, нашёл соответствующий дескриптор и сохранил его в скрытой части сегментного регистра, то есть, фактически выполнил шаги (1), (2) и (3), алгоритм можно начинать сразу с (4).
ПРИМЕЧАНИЕ При изучении схемы получения линейного адреса и сравнении её с «целями», описанными в первой главе, возникают естественные вопросы: Где же здесь раздельные адресные пространства? Где отделение кода/данных ОС от кода/данных пользователя? Да, пользователь не может обращаться куда угодно, но всё, что есть в GDT – в его распоряжении! Вопросы хорошие, полные ответы на них можно получить, изучив курс полностью, но чтобы вы не мучались до последней главы, кратко отвечу сейчас. Для разделения адресных пространств пользовательских приложений Intel предлагает использовать два механизма: локальную таблицу дескрипторов (Local Descriptor Table, LDT) и страничную адресацию. LDT не очень интересна и в курсе не рассматривается, а вот страничная адресация обязательно будет описана, но позже, в соответствующей главе. Отделение кода/данных ОС от кода/данных пользователя реализуется при помощи одного из «пока неважных» полей дескриптора. Это поле и предоставляемые им возможности подробно рассмотрены в главе «Теоретическое введение в защиту». |
С учётом существования дескрипторов сегментов и GDT, можно усложнить процедуру переключения режимов, немного приближаясь к эталону и, соответственно, получая больше возможностей.
Переключение из реального режима в защищённый:
Обратное переключение:
Приведённые ниже примеры выполняют эти требования по частям: первый пример демонстрирует работу с сегментом данных, второй – с сегментом кода.
Как известно, процессор 8086 имел 20-ти разрядную шину адреса, поэтому мог адресовать ровно 1 Мб памяти. 80286 имел уже 24-х битную шину адреса, что, теоретически, позволяло ему обращаться к 16 Мб памяти. Но, по замыслу разработчиков, вся эта «роскошь» должна была быть доступна программисту только из защищённого режима, в реальном режиме 80286 должен был полностью повторять 8086. Однако разработчики 80286 не учли, что механизм адресации памяти реального режима (<адрес> = <16-ти битный адрес сегмента> * 16 + <16-ти битный адрес смещения>) позволяет адресовать несколько больше, чем 1 Мб. Например, так:
FFFFh * 16 + 11h = FFFF0h + 11h = 100001h
В подобной ситуации 8086 будет обращаться к младшим байтам всё того же первого мегабайта, а 80286 – к младшим байтам второго. Отличие вызвано тем, что 8086 при всём желании не может выставить на свою 20-ти разрядную адресную шину 1 в 20-ом разряде (они тоже нумеруются от 0), а 80286 не только может, но и выставляет.
Программисты всего мира с восторгом приветствовали эту ошибку: ещё бы, почти 64 Кб дополнительной оперативной памяти! И, когда разработчики процессора спохватились, было поздно – ошибка уже широко использовалась. В результате ошибка стала «особенностью» (it is not a bug, it is a feature), а для полной совместимости с 8086 в процессор был добавлен вентиль A20 (gate A20), принудительно обнуляющий 20-й бит шины адреса.
По умолчанию вентиль A20 закрыт, поэтому перед работой с адресами старше 100000h его нужно открыть. Именно для этого предназначена следующая функция:
; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret |
Простая программка, переводящая процессор в защищенный режим, загружающая FS селектором 4-х гигабайтного сегмента данных и переключающаяся обратно в реальный режим.
; fs4gb.asm ; Программа, устанавливающая размер сегмента, адерсуемого регистром FS, 4 Гб .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Регистр, описывающий GDT table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; Открываем вентиль A20 call open_A20 ; Инициалиируем GDT call initialize_gdt call disable_interrupts call set_PE ; загрузить новый селектор в регистр FS mov ax, 8 mov fs, ax call clear_PE call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента данных, базовый адрес 0, размер 4Gb, Read-Write segment_descriptor <0ffffh, 0, 0, 10010010b, 10001111b, 0> ; 10010010b - 1001, C/D - 0, 0, R/W - 1, 0 ; 10001111b - G - 1, 000, Limit - 1111 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov dword ptr gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сгмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret end start |
После выполнения этой программы можно делать, например, такие вещи:
… mov eax, 10000000 ; десять мегабайт mov word ptr fs:[eax], 1234h … |
И оно будет работать! Примерно это и называется unreal-режимом.
С сегментом кода есть одна проблема: после переключения, выполнение должно продолжаться с того адреса, с которого нужно, а не с того, с которого получится. Проиллюстрировать проблему может следующий кусок псевдокода:
… call disable_interrupts call set_PE ; Дальний переход. После него в CS должен оказаться ; селектор первого дескриптора GDT, то есть 8, ; а выполнение должно родолжиться с метки next_command_PM. jmp <8> : ???? next_command_PM: … ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 4 Gb ; Адрес начала - ???? segment_descriptor <0ffffh, ?, ?, 10010010b, 10001111b, ?> … |
Поскольку программа загружается DOS по произвольному адресу, адрес next_command_PM не известен на этапе компиляции, и мы не можем просто прописать какие-то константы в качестве базового адреса сегмента и смещения. Есть два решения:
В примерах продемонстрированы оба варианта, первый в «практичном» примере, второй – в «пафосном».
ПРИМЕЧАНИЕ Ещё один вариант – писать exe-программу, в этом случае загрузчик DOS самостоятельно пропишет вместо имени сегмента адрес его начала. Но, поскольку exe-программы несколько сложнее, да и не должен разработчик ОС рассчитывать на присутствие в памяти DOS и его умного загрузчика, этот вариант не рассматривается. |
Кроме того, после возврата в реальный режим необходимо выполнить дальний переход, перезаписывающий CS, иначе в CS останется селектор сегмента, а не адрес. На первый взгляд, ничего страшного в этом нет, но при первом же прерывании содержимое CS будет сохранено в стеке, а при возврате из прерывания оно будет интерпретироваться уже не как селектор, а как адрес сегмента. И, естественно, возврат произойдёт не туда, куда хотелось бы.
Ограничение на размер сегмента данных уже успешно преодолено, осталось повторить это для сегмента кода.
ПРИМЕЧАНИЕ Совместив эту программу с предыдущей, то есть, установив всем сегментам кода/данных базовый адрес в 0 и размер 4 Gb, можно получить «почти тот самый» плоский режим, используемый современными ОС. Чтобы получить «совсем тот самый» 32-разрядный плоский режим, надо прочитать в приложение о 32-х разрядных сегментах кода. |
; cs4gb.asm ; Программа, устанавливающая размер сегмента кода 4 Gb, а потом обратно 64 Kb .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Регистр, описывающий GDT table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; Вычисляем и устанавливаем правильное смещение в long-jmp-to-PM call cs_to_eax add eax, offset next_command_PM mov cs:pm_offs, eax ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегмента call cs_to_eax mov dsc64kb.base_low, ax shr eax, 16 mov dsc64kb.base_high0, al ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 32-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; 32-разрядность нужна для потому, что смещение может занимать ; больше 16-ти разрядов. ; Базовый адрес целевого сегмента 0, поэтому смещение вычисляется и ; записывается время выполнения db 66h ; префикс изменения разрядности операнда db 0EAh ; код команды дальнего перехода pm_offs dd 0 ; смещение dw 8 ; селектор next_command_PM: ; В данный момент сегмент кода - 4 Гб, базовый адрес 0 ; 16-разрядный дальний переход. Переключает сегмент кода с ; сегмента размером 4 Гб на сегмент размеров 64 Кб. ; Базовый адрес сегмента вычисляется во время выполнения, ; поэтому смещение можно прописать заранее db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 16 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16- разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 4 Gb segment_descriptor <0ffffh, 0, 0, 10011010b, 10001111b, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 10001111b - G - 1, 000, Limit - 1111 ; Дескриптор сегмента кода, размер 64 Kb dsc64kb segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ... ; Служебные функции – те же, что и ранее end start |
4 Гб это, конечно, здорово, но, поскольку на протяжении курса мы не будем писать таких больших программ, нам нигде не потребуется сегмент кода размером более 64 Кб. Поэтому переключение сегмента кода можно упростить:
ПРИМЕЧАНИЕ В приведённой ниже реализации, сегмент кода устанавливается практически таким же, какой был в реальном режиме, то есть базовые адреса совпадают, размер 64 Кб. Помимо того, что в результате упрощается переключение, имеется ещё один приятный побочный эффект: смещения команд при переключении сегментов останутся неизменными, а значит, вычисленные компоновщиком адреса останутся правильными. Это тоже упрощает программу. |
; cs64kb.asm ; Программа, переключающаяся в защищённый режим ; и устанавливающая сегмента кода такой же ка был в реальном .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Регистр, описывающий GDT table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегментов call cs_to_eax mov dsc64kb.base_low, ax shr eax, 16 mov dsc64kb.base_high0, al ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; Базовый адрес целевого сегмента совпадает с cs, ; поэтому смещение можно прописать сразу db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 8 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим, ; потому можно без проблем переключаться обратно call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 64 Kb dsc64kb segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ... ; Служебные функции – те же, что и ранее end start |
Any time at all, any time at all, any time at all, All you've gotta do is call and I'll be there. John Lennon
Конечно же, вы знаете, что такое прерывание (interrupt), слышали о таблице векторов прерываний (interrupt vector table) и вообще довольно неплохо представляете, как прерывания обрабатываются в реальном режиме :)
СОВЕТ Если это случайно не так, пожалуйста, обратитесь к литературе по программированию на ассемблере или спросите у более опытных в этом вопросе товарищей. Несмотря на то, что глава содержит всю необходимую информацию, вам будет значительно проще, если перед прочтением вы более-менее разберётесь с обработкой прерываний в реальном режиме. Причём желательно не ограничиваться теорией, а написать хотя бы одну работающую программу. |
Но, тем не менее, пару общих слов сказать нужно, так как тема важная и непростая.
Программные прерывания (software generated interrupts) вызываются инструкциями int x, где x – любое число от 0 до 255 (int 3 несколько отличается от прочих, подробнее эта тема освещена в следующей главе). Это, в общем-то, почти и не прерывания вовсе, так как ничего непонятного в них нет :) Фактически это просьба к процессору вызвать определённую подпрограмму, которая должна быть специальным образом зарегистрирована в системе, она называется обработчик прерывания (interrupt handler)
ПРИМЕЧАНИЕ В защищённом режиме это не обязательно будет подпрограмма, есть ещё один вариант, но он в курсе не рассматривается. |
Естественно, защищённый режим, так же как и реальный, предоставляет возможность обработки прерываний, не менее естественно, что, по сравнению с реальным режимом, в нём многое изменилось. Но, что приятно, сохранились основные положения:
Аналог таблицы векторов прерываний, существующий в защищённом режиме, называется таблицей дескрипторов прерываний (Interrupt Descriptor Table, IDT).
ПРИМЕЧАНИЕ Это термин из официального руководства Intel. В книге Дэвида Соломона и Марка Руссиновича «Внутреннее устройство Windows 2000» (Питер, Русская Редакция, 2001) упоминается IDT, но, почему-то, там она названа таблица диспетчеризации прерываний (interrupt dispatch table), причём это не ошибка русских переводчиков, в английском оригинале используется такое же название. На мой взгляд, следует придерживаться терминологии Intel. |
По формату и способу инициализации IDT практически идентична GDT. Основные отличия:
Таким образом, слегка модифицируя код из предыдущей главы, получаем пример инициализации IDT:
… ; Вычисляем линейный адрес начала массива дескрипторов mov eax, 0 mov ax, ds shl eax, 4 add eax, offset IDT ; Записываем его в структуру mov dword ptr offset idtr + 2, eax ; Загружаем IDTR. fword ptr – указатель на шестибайтную структуру lidt fword ptr idtr … ; Interrupt Descriptor Table IDT label byte db … ; Дескриптор #0 … db … ; Дескриптор #N idt_len equ $ - IDT ; размер IDT idtr dw idt_len – 1 ; 16-битный размер IDT – 1 dd ? ; Место для 32-х битного базового адреса IDT |
ПРИМЕЧАНИЕ Точно так же, как и в случае с lgdt, нормальные пользовательские приложения не имеют доступа к инструкции lidt. Этот факт вместе с возможностью поместить IDT в недоступную приложениям область памяти позволяет ОС контролировать обработку прерываний. Подробнее, как обычно, в главе про защиту. |
Несложно заметить, что та же самая структура table_register подходит и для загрузки в IDTR.
Дескриптор – не шлюза, а порядочная девушка! Надпись маркером в лифте.
Как уже говорилось в предыдущей главе, дескриптор это структура, описывающая некую системную сущность. Очередным типом дескрипторов, с которым вам придется познакомиться, будет дескриптор шлюза ловушки (trap gate descriptor; в [Гук 1999] слово «gate» переводится как «вентиль», это стандартный перевод для схемотехники, программисты обычно говорят «шлюз»), описывающий обработчик прерывания. Он имеет следующий формат:
Положение | Название | Краткое описание |
---|---|---|
Нулевой и первый байты | Offset (part 1) | Младшие два байта 32-х битного поля Offset. Поле Offset содержит смещение обработчика прерывания. |
Второй и третий байты | Segment Selector | Селектор сегмента, содержащего обработчик прерывания. |
Четвёртый байт | ?? | Пока неважно, устанавливайте в 0. |
0-й – 3-й биты пятого байта | ?? | Для дескриптора шлюза ловушки значение должно быть равно 1111b. Подробности про это и следующее поле в разделе «[Лирическое отступление] Классификация дескрипторов». |
4-й бит пятого байта | ?? | Устанавливайте в 0. |
5-й – 7-й биты пятого байта | ?? | Пока неважно, устанавливайте в 100b. |
Шестой и седьмой байты | Offset (part 2) | Старшие два байта поля Offset. |
То же самое на картинке:
Основная информация, которую несёт в себе дескриптор шлюза ловушки, это:
Поскольку формат практически прозрачен, остаётся только привести пример:
db 04h ; Offset – два младших байта db 03h db 8 ; Segment selector db 0 db 0 ; 0 db 10001111b ; 10001111 – магическое число.. db 02h ; Offset – два старших байта db 01h |
Это дескриптор шлюза ловушки, селектор сегмента указывает на первый дескриптор GDT, смещение – 01020304h. В виде структуры дескриптор шлюза выглядит так:
gate_descriptor struct offset_low dw 0 ; Два младших байта поля Offset selector dw 0 ; Поле Segment Selector zero db 0 type_and_permit db 0 ; Флаги. Этот байт отвечает за то, кто и как может ; использовать дескриптор. Подробнее – в следующих ; главах. offset_high dw 0 ; Старшие байты поля Offset gate_descriptor ends |
Первый признак классификации дескрипторов – значение 4-го бита пятого байта дескриптора. Полное название этого бита – флаг «descriptor type», краткое – флаг «S» (довольно странное сокращение, но везде применяется именно оно). В соответствии с этим признаком, дескрипторы делятся на два класса:
Принципиальное отличие системных дескрипторов от дескрипторов сегмента кода/данных в том, что селекторы первых не могут быть загружены в сегментные регистры и участвовать в формировании логического адреса <сегмент>:<смещение>. Даже если системный дескриптор описывает сегмент (вы познакомитесь с таким дескриптором в главе «Введение в многозадачность»), для доступа к данным придётся использовать сегмент кода/данных (с соответствующим дескриптором и селектором), проецирующийся на нужную область памяти.
Системные дескрипторы имеют различный формат и единственное общее отличие их формата от формата дескрипторов сегмента кода/данных – младшие четыре бита пятого байта. Для обоих видов дескрипторов это поле называется «Type» и уточняет тип дескриптора, но при этом используются разные подходы:
Второй признак классификации – характер сущности, описываемой дескриптором. Это может быть:
Дескрипторы, описывающие похожие сущности, имеют близкие форматы. Так, любой дескриптор сегмента по формату похож на дескриптор сегмента кода/данных, а любой дескриптор шлюза – на дескриптор шлюза ловушки.
Объединим всё, уже известное про прерывания и добавим недостающие детали.
Целью всего механизма обработки прерываний является своевременный вызов обработчика. В нашем случае (остальные случаи – в остальных главах, описывать всё сразу совершенно ни к чему) вызов происходит примерно так:
ПРЕДУПРЕЖДЕНИЕ Ещё раз обращаю ваше внимание: независимо от префиксов команд, разрядности сегмента стека, разрядности целевого и исходного сегмента кода и т.п. (всё это будет обсуждаться в приложении в разделе про разрядность), под EFLAGS, CS, EIP в стеке выделяется 12 байт, по 4 байта на каждый регистр. |
Упрощённая версия алгоритма в виде комикса:
Ключевые признаки, отделяющие «наш» случай от «не нашего»:
Добавим к алгоритмам переключения ещё несколько шагов. Из реального режима в защищённый:
ПРЕДУПРЕЖДЕНИЕ В данном случае нельзя использовать CS, оставшийся «в наследство» от реального режима, так как при возврате из обработчика прерывания сохранённое в стеке значение CS будет толковаться как селектор сегмента. |
ПРИМЕЧАНИЕ Поскольку обрабатывать аппаратные прерывания мы пока не планируем, разрешать прерывания будет преждевременно. |
Обратное переключение:
Для этого предназначена команда sidt.
sidt pointer_to_idtr |
Пример использования:
… ; Сохраняем IDTR sidt fword ptr old_idtr … old_idtr table_register <> |
Практически минимальная программа, устанавливающая обработчик нулевого прерывания и вызывающая его командой int 0.
ПРИМЕЧАНИЕ Программа написана на основе примера cs64kb.asm из предыдущей главы. |
; int0.asm ; Программа, устанавливающая и вызывающая обработчик нулевого прерывания .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Дескриптор шлюза gate_descriptor struct offset_low dw 0 ; Два младших байта поля Offset selector dw 0 ; Поле Segment Selector zero db 0 type_and_permit db 0 ; Флаги offset_high dw 0 ; Старшие байты поля Offset gate_descriptor ends ; Регистр, описывающий таблицу table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; В es - начало видеобуфера. Можно было сделать то же ; самое средствами защищённого режима, но так проще push 0b800h pop es ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегмента call cs_to_eax mov dsc64kb.base_low, ax shr eax, 16 mov dsc64kb.base_high0, al ; Сохраняем IDTR реального режима sidt fword ptr old_idtr ; Инициализируем IDT call initialize_idt ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; Базовый адрес целевого сегмента совпадает с cs, ; поэтому смещение можно прописать сразу db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 8 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим. int 0 ; вызываем прерывание call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент ; восстанавливаем IDTR реального режима lidt fword ptr old_idtr ; разрешаем прерывания call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Обработчик прерывания ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Обработчик прерывания int 0, меняет символы и их атрибуты по всему экрану ; Предпологает, что база es - начало видеобуфера int0_handler: ; В данный момент, в стеке: ; esp + 0 - EIP ; esp + 4 – CS и два байта мусора ; esp + 8 - EFLAGS push eax push ecx mov eax, 0 ; Текущий символ mov ecx, 80 * 25 ; Колическтво сиволов на экране screen_loop: inc byte ptr es:[eax] ; Меняем символ inc eax inc byte ptr es:[eax] ; Меняем атрибут inc eax loop screen_loop pop ecx pop eax iretd ; 32-х разрядный возврат из прерывания ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 64 Kb dsc64kb segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ; Таблица дескрипторов прерываний IDT label byte ; Дескриптор шлюза ловушки. ; Обработчик прерывания находится в сегменте, соответствующем первому ; дескриптору GDT. Поскольку базовый адрес сегмента такой же, как ; в реальном режиме, смещение обработчика тоже совпадает. gate_descriptor <int0_handler, 8, 0, 8Fh, 0> ; Данные для загрузки в IDTR idtr table_register <$ - IDT - 1, 0> ; Место для IDTR реального режима old_idtr table_register <> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret ; Инициализирует IDT initialize_idt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset IDT ; Записываем его в структуру mov idtr.base, eax ; Загружаем IDTR lidt fword ptr idtr ret ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сгмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret end start |
Мне и больно, мне и сладко, что он не такой как все Сергей Чиграков Приложение выполнило недопустимую операцию и будет закрыто Microsoft Windows Человеку свойственно ошибаться Истина
Очередная особенность защищённого режима – важность исключений. В отличие от реального режима, в котором исключения используются довольно слабо, для ОС защищённого режима они важны так же, как и остальные виды прерываний, то есть исключительно важны. Уважающая себя ОС защищённого режима просто вынуждена корректно обрабатывать все возможные исключения. Причины три:
Глава начинается с абстрактных (не относящихся к конкретному процессору) размышлений на тему «Исключения: что, зачем и почему?», продолжается описанием реализации обработки исключений на базе Intel x86 и завершается примером программы, обрабатывающей исключение #GP.
Знаете, почему стиральная машина лучше персонального компьютера? Вот почему:
И, если первая проблема решается разработкой соответствующих периферийных устройств, не представляет интереса и не рассматривается в курсе, то вторая напрямую связана с темой главы, зато, видимо, практически не решаема.
Завидная устойчивость стиральных машин к программным сбоям, связана не с тем, что они «проще» (это может быть не так, особенно если сравнивать с PC XT), а с тем, что они всегда работают по одному из нескольких предопределённых сценариев, и никогда не исполняют «пользовательское программное обеспечение». Примерно то же можно сказать и про холодильники, пылесосы, телефоны, автомобили и т.п.
В отличие от всей перечисленной техники, компьютеры задуманы относительно универсальными.
ПРИМЕЧАНИЕ Можно попытаться сравнить возможности настройки. Средняя стиральная машина имеет три ручки с десятком возможных состояний каждая и три кнопки с двумя состояниями. Итого 10^3 * 2^3 – примерно 8000 состояний. Это, конечно, немало, но сравните с компьютером. «Настраиваемыми элементами» являются регистры, память и порты ввода-вывода (а ещё можно вспомнить про жесткий диск... но мы не будем). Каждый бит каждого регистра, байта ОЗУ, порта ввода-вывода, может быть 0 или 1. Так как регистров не больше сотни, портов 64 Кб, а памяти процессор умеет адресовать до 4 Гб, получим примерно 2^(количество битов ОЗУ), то есть что-то около 2^(32 миллиарда) состояний. Это число вам не удастся даже записать в десятичной системе счисления (оно содержит примерно 10 миллиардов символов), перебирать варианты можно и не начинать. Очевидно, что подходы к тестированию стиральной машины и компьютера должны отличаться. |
На компьютере исполняются самые разные программы, написанные ещё более разными людьми, имевшими совершенно различные цели (иногда – деструктивные) и квалификацию (часто – недостаточную). И ни кому из них процессор ни в коем случае не должен доверять слишком сильно. Что характерно, защищённый режим всё только усложняет: в случае работы под управлением многозадачной ОС, процессор должен доверять пользовательским программам ещё меньше, так как некорректное поведение одной программы ставит под угрозу не только её, но и все остальные запущенные программы.
ПРИМЕЧАНИЕ Вообще-то, это скорее относится к ОС, а не к процессору. Но поскольку ОС опирается на возможности, предоставляемые процессором (напоминаю, в первой главе мы договорились не использовать виртуальные машины из соображений производительности), если он не имеет механизма обработки ошибок, она будет бессильна. |
Поскольку объекты «пользователь» и «процессор» обладают очень разными характеристиками, понятие «ошибка» для пользователя и для процессора – очень разные вещи. Поясняю на примере:
Отсюда можно сделать два вывода:
ПРИМЕЧАНИЕ Если всё-таки хочется переложить поиск ошибок на процессор, нужно чтобы при несоблюдении некоторых условий, текущая команда рассматривалась процессором как ошибочная. Этого можно добиться либо меняя по условию поток выполнения (имеется ввиду что-то типа if (условие) {ошибочная команда}), либо добавив к процессору специальные команды, самостоятельно проверяющие условия. Поскольку рядовому программисту довольно сложно добавить к процессору команду, проверяющую нужное ему условие, на практике чаще применяется первый подход (примерно так реализованы assert-ы), но специальные команды тоже существуют. |
Мама, мама, что мы будем делать, Когда настанут зимние холода. Из фильма «Кин-дза-дза»
Поскольку ошибки неизбежны, на них надо как-то разумно реагировать. Рассмотрим варианты (для определённости, предположим, что команда mov пытается писать в read-only сегмент).
а) Физическое самоуничтожение. В соответствии с кодексом чести самурая, процессор, не способный выполнить поставленную перед ним задачу, делает себе харакири (сеппуку, если получится). Без комментариев.
б) Автоматически перезагрузиться или, наоборот, зависнуть до аппаратного сброса. В некоторых ситуациях, такая реакция допустима для (почти?) любых процессоров. А какой-нибудь микроконтроллер вполне может «обрабатывать» так вообще все ошибки. Процессор компьютера общего назначения не должен использовать подобный подход слишком часто, так как пользователи хотят работать/играть/…, но уж точно не любоваться каждые пять минут на загрузку любимой ОС.
в) Выполнить команду. Во-первых, это не всегда возможно, например, не ясно, что делать при обращении к несуществующему сегменту. Во-вторых, непонятно зачем нужны ограничения, которые так просто обходятся.
г) Проигнорировать команду. Представьте себе, что в вашей программе некоторые ассемблерные команды иногда (не стабильно) просто не исполняются. И никому об этом не сообщают… И все последующие команды полагают, что всё в порядке… Сомневаюсь, что такой процессор завоюет популярность. В некоторых ситуациях такая стратегия обработки ошибок работает, но, видимо, процессоры – не тот случай.
д) Проигнорировать команду и выставить флаг ошибки. Несколько лучше… Но теперь после каждого mov придётся проверять, успешно ли он выполнился. Печально? Это ещё цветочки, ведь ошибка может быть не только в mov. Например, push и pop могут выйти за границу сегмента, call и jmp могут попытаться передать управление по «неправильному» адресу, и т.п. Фактически, придётся проверять все команды, кроме тех, которые имеют дело только с регистрами общего назначения (с прочими регистрами тоже не всё просто). В принципе, с учётом того, что сейчас большая часть кода генерируется компиляторами, это допустимый вариант. Но у него слишком много недостатков:
е) Просто завершить процесс и переключиться на следующий (пусть у нас многозадачная ОС). Соблазнительно, но, во-первых, плохо, во-вторых, невозможно. Плохо, потому что некоторые ошибки всё-таки не фатальны. Плохо и невозможно, потому что, даже если ошибка фатальна, закрытие процесса должно сопровождаться некоторыми специальными действиями: освобождение занятых ресурсов, удаление процесса из списка запущенных процессов и т.п. Процессор «сам» не в состоянии это сделать, так как он ничего не знает ни про ресурсы, ни про списки, ни про любые другие структуры, которые может использовать ОС для управления процессами. Он даже не сможет корректно «переключиться на следующий».
Help me if you can, I'm feeling down And I do appreciate you being round. Help me, get my feet back on the ground, Won't you please, please help me. John Lennon
Поскольку, как видите, сам по себе процессор практически не способен адекватно реагировать на ошибки, остаётся последний вариант:
ё) Обратиться за помощью к программисту. Примерно так:
Это называется обработкой исключений (exception handling), и именно этот прогрессивный подход используется в процессорах Intel x86, начиная с 8086. Поэтому особого выбора у разработчиков Intel 386 не было: обратная совместимость это святое. Но, конечно, защищённый режим всё расширил и углубил.
Понятно, что подобный алгоритм можно использовать в любой ситуации, когда необходимо прервать текущий поток выполнения и вызвать некоторый системный код. Можно выделить несколько частных случаев:
Всё это вместе, обычно, называется прерываниями (interrupts) и, соответственно, обработкой прерываний (interrupt handling). При этом аппаратные прерывания относят к отдельному классу (они будут обсуждаться в следующей главе), а всё остальное (обычно) называют исключениями.
К «основам» я отнёс то, что объединяет все типы исключений: базовую терминологию и общие сведения о регистрации/обработке.
ПРИМЕЧАНИЕ Поскольку, с точки зрения поведения процессора, программные прерывания можно рассматривать как один из подтипов исключений, эти «сведения» в основном являются ссылками на главу «Программные прерывания». |
Исключение (exception) это нештатная ситуация, вызванная обработкой текущей команды. Обычно это связано с невозможностью выполнить команду по причине некорректности самой команды, параметров, или состояния системных структур, но есть несколько специальных случаев (например, команды into, bound; подробнее – ниже). Говорят, что процессор генерирует (generate) исключение.
Подпрограмма, зарегистрированная в системе и вызываемая при возникновении исключения, называется обработчиком исключения (exception handler). В различных ситуациях процессоры Intel x86 генерируют различные исключения, в результате вызываются различные обработчики. Типы и причины исключений документированы, программисту остаётся только соответствующим образом написать и зарегистрировать обработчик.
Обработчики исключений регистрируются точно так же, как обработчики программных прерываний: в таблице дескрипторов прерываний, при помощи дескриптора шлюза ловушки. Для исключений в IDT предназначены дескрипторы с номерами от 0 до 31, кроме 2 (о нём – в приложении, в описании NMI).
ПРЕДУПРЕЖДЕНИЕ [Intel 2004] не рекомендует использовать эти номера для других целей. Но в тестах, конечно, можно :) |
Описание наиболее интересных, на мой взгляд, исключений приведено в таблице.
Номер | Название | Описание |
---|---|---|
0 | #DE | Divide error. Ошибка при исполнении команды div или idiv. Два варианта: либо делитель равен 0, либо результат не влезает в отведённый для него регистр. С делением на 0 всё понятно, а вот пример ошибки второго типа: команда div bl делит ax на bl и помещает результат в al; если, к примеру, ax равен 256, а bl – 1, результат в al не поместится, и будет выброшено исключение. На мой взгляд, такое поведение команд деления – ошибка, допущенная разработчиками 8086 при проектировании. |
1 | #DB | Debug exception. Упоминавшееся выше событие отладки. Текущая команда может быть любой. |
3 | #BP | Breakpoint. Точка останова отладчика, команда int 3. Некоторые детали о команде int 3 приведены в приложении, в разделе «int 3». |
4 | #OF | Overflow. Арифметические команды (add, sub, inc, dec и некоторые другие) при переполнении устанавливают в 1 флаг OF. При желании, с помощью команды into можно проверить значение этого флага: если OF равен 1, процессор сгенерирует исключение #OF. |
5 | #BR | Bound range exceeded. Для проверки попадания индекса в пределы массива (шире – числа в заданный диапазон) можно использовать банальные cmp, а можно продвинутую команду bound. В последнем случае, при неверном индексе, процессор сгенерирует исключение #BR. |
6 | #UD | Undefined opcode (Invalid opcode). Непонятный процессору код команды или специальная команда ud2 (начиная с Pentium Pro). |
8 | #DF | Double fault. Во время обработки первого исключения произошло второе, причём очень неудачно. Интересная, но отдельная тема, подробнее – в приложении, в разделе «Double fault». |
12 | #SS | Stack-segment fault. Проблемы со стеком. Основное – попытка чтения/записи за пределы стека. Команды типа push, pop, call, int, ret, iret и даже mov, если с сегментом SS. |
13 | #GP | General protection. Самые разные ошибки, связанные с памятью и защитой. Например, попытка записи в read-only сегмент. |
14 | #PF | Page fault. «Страничная ошибка». Запомните его! Это исключение является одной из основ реализации виртуальной памяти, подробнее – в главе «Страничная адресация». |
Обработчик исключения очень похож на обработчик прерывания: более-менее обычная подпрограмма, заканчивающаяся вызовом iretd (скорее всего, действия, выполняемые обработчиками, должны отличаться по смыслу, но сейчас это нас не касается). Некоторые отличия между обработчиками связаны с тем, что процессор их немного по-разному вызывает. К началу работы обработчика прерывания на вершине стека находится содержимое регистров CS, EIP и EFLAGS (по 4 байта на регистр, и того 12 байт). Это относится и к обработчикам исключений, но:
Ещё одно отличие связано с тем, что возникновение программного прерывания, обычно, планируется программистом заранее, а возникновение исключения, обычно, нет. Поэтому «обычный» обработчик исключения не предполагает, что в регистрах программист передал ему какие-то дополнительные параметры. Но, конечно, никто не мешает вам написать «необычный» обработчик и использовать его «необычным» образом.
Есть люди у которых обращаются на Вы, Есть люди у которых сто четыре головы, Есть загадочные девушки с магнитными глазами, Есть большие пассажиры мандариновой травы... Борис Гребенщиков
Выше, в разделе «Обработчики» описаны основные отличия обработчика исключения от обработчика программного прерывания. С технической точки зрения, именно они и являются основой классификации. Правда, к первому «отличию» (значение адреса возврата в стеке) мы подойдём с неожиданной стороны, так как оно является следствием общего признака классификации: существования трёх типов исключений
ПРИМЕЧАНИЕ Названия типов исключений разные авторы переводят по-своему, поэтому, чтобы не добавлять сумятицы, я буду пользоваться английскими терминами. |
ПРИМЕЧАНИЕ В [Зубков 1999] и [Орловский 1992] переведено как «ошибка», в [Гук 1999] как «отказ», в [Григорьев 1993] – «нарушение». |
Fault-исключение генерируется процессором до начала выполнения команды. Поэтому:
После возврата из fault-исключения (если обработчик не поменяет в стеке адрес возврата) процессор попытается ещё раз выполнить ту же самую команду. Это наиболее часто используемый вид исключений, практически в любой ошибочной ситуации процессор с неизменным оптимизмом предлагает «попробовать ещё разик».
ПРИМЕЧАНИЕ Везде переведено как «ловушка». Сговорились… |
Trap-исключение генерируется процессором сразу после исполнения команды. Соответственно:
К типу trap (на данный момент) относятся только исключения, вызванные успешно исполнившимися командами, например int 3 или into. Сюда же можно отнести и int n, то есть все программные прерывания. По каким-то причинам (видимо, особенности реализации) bound генерирует fault-исключение.
ПРИМЕЧАНИЕ В [Зубков 1999] переведено как «отказ», в [Орловский 1992] как «неудача», в [Гук 1999] как «аварийное завершение», [Григорьев 1993] – «авария». Обратите внимание: по [Гук 1999] «отказ» это fault. |
Abort-исключение генерируется процессором в самом крайнем случае: когда, с его точки зрения, восстановление и нормальная работа уже невозможны, и системе (ну, как минимум, задаче) остаётся только красиво уйти. Поскольку значение адреса возврата в стеке не определено, скорее всего, ОС придётся смириться с диагнозом процессора.
К счастью для разработчиков ОС, пользовательские приложения не способны самостоятельно вызвать abort-исключение. Причиной такого исключения может быть либо аппаратный сбой, либо ошибка в ОС, либо серьёзный недочёт в архитектуре/реализации ОС, дающий пользовательскому ПО лишние возможности.
Он может либо присутствовать, либо отсутствовать. Это и является признаком классификации: к первой группе относятся исключения, которым в стеке передаётся дополнительная информация, ко второй группе исключения, обходящиеся адресом возврата.
ПРИМЕЧАНИЕ Формат кода ошибки, кроме кода ошибки исключения #PF, в курсе не рассматривается. Формат кода ошибки #PF рассматривается в главе «Страничная адресация». |
Описанные выше исключения классифицируются следующим образом:
Номер | Название | Тип | Код ошибки |
---|---|---|---|
0 | #DE (Divide error) | Fault | Нет |
1 | #DB (Debug exception) | Fault или Trap | Нет |
3 | #BP (Breakpoint) | Trap | Нет |
4 | #OF (Overflow) | Trap | Нет |
5 | #BR (Bounds range exceeded) | Fault | Нет |
6 | #UD (Undefined opcode) | Fault | Нет |
8 | #DF (Double fault) | Abort | Есть |
12 | #SS (Stack segment fault) | Fault | Есть |
13 | #GP (General protection) | Fault | Есть |
14 | #PF (Page fault) | Fault | Есть |
ПРИМЕЧАНИЕ Единственный случай, когда классификация исключений по типам неоднозначна это исключение #DB. В зависимости от того, какое из установленных условий отладки сработало, оно может стать trap- или fault-исключением. |
- Каждому исключению – уважительное отношение и полноценную обработку! - Ура!!! ... Из популистского выступления разработчиков ОС на собрании процессоров.
Комбинируя признаки классификации, можно насчитать шесть типов исключений:
Все они вызываются немножко по-разному, и все их можно было бы рассмотреть. Но, поскольку:
Рассмотрены только fault- и abort-исключения. Оговорки те же, что и в случае программных прерываний:
Примерно так:
ПРИМЕЧАНИЕ В отличие от обработчиков программных прерываний, которые (обычно) послушно возвращают управление по адресу, сохранённому процессором в стеке, обработчики fault-исключений поступают таким образом гораздо реже, поскольку часто это приведёт только к повторной генерации того же самого исключения и ещё одному вызову того же самого обработчика. Поэтому пункты 8-9 могут либо не выполняться вообще, либо выполняться не так. Подробнее на ту тему см. ниже в разделе «Случилось страшное. Ну и?.. - II». |
И на картинке, на примере исключения #GP:
Так как корректный возврат даже и не планируется, алгоритм упрощается:
Соответствующая картинка:
Итак, процессор обнаружил ошибку, сгенерировал исключение, нашёл в IDT адрес обработчика и передал ему управление… ну и что с ним делать дальше? Допустим, понятно, как обрабатывать исключения, не являющиеся ошибками (в списке это #DB, #BP и #PF, соответственно нужно либо передать управление отладчику, либо подкачивать в память требуемую страницу), но что делать с остальными? Мы уже выяснили, что процессор «сам» не в состоянии справиться с обработкой и просит помощи у ОС, но ведь и ОС тоже далеко не всегда может предложить что-то разумное (например, что вы будете делать, если произошло исключение #DE?). Однако у ОС есть преимущества:
ПРИМЕЧАНИЕ Наиболее интересен, конечно, первый вариант, и именно он реализован в большинстве современных ОС. В соответствии со стандартом POSIX, реализация пользовательских обработчиков исключений основана на сигналах (signal; есть такое понятие в UNIX-ах), Microsoft придумала свой подход – структурную обработку исключений (Structured Exception Handling, SEH). Рассмотрение использования и реализации сигналов и SEH выходит за рамки курса. |
Остерегайтесь подделок!
Поскольку обработчики исключений регистрируются в той же таблице и по тем же правилам, что и обработчики программных прерываний, выполнение команды int n с соответствующим значением n приведёт к вызову обработчика исключения (кстати, пример из главы «Программные прерывания», обрабатывающий нулевое прерывание – по совместительству исключение #DE, именно так и поступает). А, поскольку, (насколько я знаю) простого способа определить причину вызова изнутри обработчика не существует, скорее всего, обработчик примет всё за чистую монету.
Это была завязка. А теперь представьте, что пользователь вызвал обработчик #GP через int 13. Как вы помните, #GP это fault-исключение с кодом ошибки, то есть обработчик рассчитывает увидеть в стеке код ошибки, адрес вызвавшей исключение команды и EFLAGS. К сожалению, ожидания не оправдаются: процессор, обрабатывающий команду int 13 как программное прерывание (trap-исключение без кода ошибки), положит в стек только адрес следующей команды и EFLAGS. В результате:
Так как обработчик #GP это часть ОС, скорее всего, всё это приведёт к падению системы. Печально? Это ещё что, ведь пользователь может вызвать int 8 и обработчик #DF, который просто завершит систему!
К счастью, разработчики процессора предусмотрели решение этой проблемы: дескриптор шлюза ловушки в IDT может запрещать пользователю явный вызов обработчика прерывания командой int n, но разрешать обработку соответствующего исключения (точнее, при генерации исключения проверка прав производиться не будет). При нарушении будет сгенерировано исключение #GP.
ПРИМЕЧАНИЕ Команды int 3 (для читавших приложение – обе формы int 3) и into считаются «явным» вызовом, то есть тоже могут быть запрещены. Команда bound «явным» вызовом не считается. |
Подробнее эта тема рассмотрена в главе «Теоретическое введение в защиту».
Программа устанавливает обработчик исключения #GP и генерирует соответствующее исключение при попытке изменить сегмент кода. Код полностью повторяет пример int0.asm из предыдущей главы, кроме следующих отличий:
ПРЕДУПРЕЖДЕНИЕ В нормальных программах так делать, конечно, нельзя: если для генерации исключения использовать команду с другим размером, такой обработчик не приведёт ни к чему хорошему (см. первое задание, последний вариант). |
; gpf.asm ; Программа, устанавливающая и вызывающая обработчик исключения #GP .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Дескриптор шлюза gate_descriptor struct offset_low dw 0 ; Два младших байта поля Offset selector dw 0 ; Поле Segment Selector zero db 0 type_and_permit db 0 ; Флаги offset_high dw 0 ; Старшие байты поля Offset gate_descriptor ends ; Регистр, описывающий таблицу table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; В es - начало видеобуфера. Можно было сделать то же ; самое средствами защищённого режима, но так проще push 0b800h pop es ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегмента call cs_to_eax mov dsc64kb.base_low, ax shr eax, 16 mov dsc64kb.base_high0, al ; Сохраняем IDTR реального режима sidt fword ptr old_idtr ; Инициализируем IDT call initialize_idt ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; Базовый адрес целевого сегмента совпадает с cs, ; поэтому смещение можно прописать сразу db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 8 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим. mov cs:[0], eax ; Вызываем обработчик исключения call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент ; восстанавливаем IDTR реального режима lidt fword ptr old_idtr ; разрешаем прерывания call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Обработчик прерывания ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Обработчик исключения #GP, меняет символы и их атрибуты по всему экрану, ; Перед завершением увеличивает адрес возврата в стеке ; на размер команды mov cs:[0] (5 байт) gpf_handler: ; В данный момент, в стеке: ; esp + 0 - error code ; esp + 4 - EIP ; esp + 8 - CS ; esp + 12 - EFLAGS push eax push ecx mov eax, 0 ; Текущий символ mov ecx, 80 * 25 ; Колическтво сиволов на экране screen_loop: inc byte ptr es:[eax] ; Меняем символ inc eax inc byte ptr es:[eax] ; Меняем атрибут inc eax loop screen_loop pop ecx pop eax ; Удаляем из стека код ошибки add esp, 4 ; Добавляем к адресу возврата 5 – размер команды mov cs:[0], eax ; В результате возврат произойдёт на начало следующей команды add dword ptr [esp], 5 iretd ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 64 Kb dsc64kb segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ; Таблица дескрипторов прерываний IDT label byte gate_descriptor <> ;0 - #DE gate_descriptor <> ;1 - #DB gate_descriptor <> ;2 gate_descriptor <> ;3 - #BP gate_descriptor <> ;4 - #OF gate_descriptor <> ;5 - #BR gate_descriptor <> ;6 gate_descriptor <> ;7 gate_descriptor <> ;8 - #DF gate_descriptor <> ;9 gate_descriptor <> ;10 gate_descriptor <> ;11 gate_descriptor <> ;12 - #SS ; 13 - #GP ; Дескриптор шлюза ловушки. ; Обработчик исключения находится в сегменте, соответствующем первому ; дескриптору GDT. Поскольку базовый адрес сегмента такой же, как ; в реальном режиме, смещение обработчика тоже совпадает. gate_descriptor <gpf_handler, 8, 0, 8Fh, 0> ; Данные для загрузки в IDTR idtr table_register <$ - IDT - 1, 0> ; Место для IDTR реального режима old_idtr table_register <> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret ; Инициализирует IDT initialize_idt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset IDT ; Записываем его в структуру mov idtr.base, eax ; Загружаем IDTR lidt fword ptr idtr ret ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сегмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret end start |
And I wait for them to interrupt Me drinking from that broken cup And ask me to open up the gate for you. Bob Dylan Что стучишься в дверь моя, видишь – дома нет никто, если ты моя жена – заходи по одному... Присказка из недалёкого детства
Как вы, без сомнения, знаете, помимо процессора и оперативной памяти, в состав компьютера входят и другие полезные устройства, с которыми ОС так или иначе приходится взаимодействовать. И, естественно, этот факт нельзя было проигнорировать при разработке процессора.
Собственно способ взаимодействия тривиален: поскольку единственное, что умеет делать процессор – обработка данных, представленных набором битов/байтов/слов, «взаимодействие» сводится к чтению или записи во внутренние регистры устройства. От устройства зависит то, как именно доступны регистры (через заданные физические адреса в памяти [команда mov], либо через порты ввода/вывода [команды in и out]), что за данные передаются, и какого протокола нужно придерживаться, но это всё детали. Конечно, настоящая ОС должна уметь работать хоть с какими-то реальными устройствами, но к архитектуре процессора адреса/порты/протоколы имеют слабое отношение, и рассмотрение программирования конкретных устройств выходит за рамки курса.
А вот что действительно интересно – возможность начинать взаимодействие по инициативе устройства. В случае Intel x86 это называется внешним (external) или аппаратным (hardware generated) прерыванием (interrupt), а результатом прерывания является исполнения зарегистрированной в системе подпрограммы – обработчика прерывания (interrupt handler).
О внешних прерываниях, о том, для чего они используются, об их преимуществах и недостатках по сравнению с опросом готовности (polling; иногда называют иначе, но пусть будет «опрос готовности»), должно быть сказано в любом приличном курсе по архитектуре ОС (если не сказано – курс не приличный), надеюсь, вы прочитали/прослушали хотя бы один. Тем не менее, из-за исключительной важности темы, я попытаюсь ещё раз всё это объяснить. На всякий случай.
Скорее всего, все представляют себе, что такое внешнее прерывание. Описать можно примерно так: некоторое внешнее устройство (клавиатура, таймер, COM-порт, …) хочет что-то сообщить процессору (нажата клавиша, прошло 0.001 секунды, приняты новые данные, …), причём оно не ждёт, пока процессор обратит на него внимание, а самостоятельно его «дёргает». В результате возникает особая ситуация, называемая внешним прерыванием, и, если это прерывание не заблокировано тем или иным способом, вызывается обработчик прерывания. Как и программные, внешние прерывания тоже нумеруются, и их обработчики тоже регистрируются в IDT, причём почти так же.
Это всё хорошо и верно, но совершенно недостаточно, так как не отражает должным образом важность и уникальность механизма внешних прерываний. Нужно понимать, что они, и только они:
Внешние прерывания дают возможность диалога с процессором. Без них компьютер способен работать только в режиме «пакетной обработки заданий»: загрузили ящик данных, подумал, погудел, выдал результат. Готов принять следующий ящик… Ну, то есть, это некоторое преувеличение, система типа DOS сможет более-менее прожить без внешних прерываний (от некоторых возможностей, конечно, придётся отказаться, да и программировать будет не так удобно), но ни о какой ОС с вытесняющей многозадачностью речь не будет идти точно.
Основные области применения:
ПРИМЕЧАНИЕ Попробуйте придумать устройство, не попадающее ни в одну из категорий :) Но при этом нельзя сказать, что внешние прерывания – всегда оптимальное решение. В некоторых случаях это может оказаться чрезмерно дорого именно с точки зрения производительности: иногда дешевле доделать дело до конца, а потом переключиться на следующее, вместо того чтобы пытаться сделать оба одновременно, бегая туда-сюда. Тем более, если переключение тоже не бесплатное (о том, почему переключение может оказаться не бесплатным, написано в главе «Защита: передача управления»). Кроме того, если делать всё последовательно, алгоритм может получиться значительно проще. |
Элементом IDT, соответствующим внешнему прерыванию, может быть и дескриптор шлюза ловушки, но специально для этой цели предназначен дескриптор шлюза прерывания (interrupt gate descriptor). Он очень похож на дескриптор шлюза ловушки, единственное отличие – при входе в обработчик через дескриптор шлюза прерывания, автоматически сбрасывается флаг IF, маскируя внешние прерывания (как вы помните, в реальном режиме внешние прерывания маскируются при входе в обработчик любого прерывания). Форматы дескрипторов тоже практически одинаковые, отличается только один бит (0-й бит пятого байта), тем не менее, для полноты картины, привожу описание:
Положение | Название | Краткое описание |
---|---|---|
Нулевой и первый байты | Offset (part 1) | Младшие два байта 32-х битного поля Offset. Поле Offset содержит смещение обработчика прерывания. |
Второй и третий байты | Segment Selector | Селектор сегмента, содержащего обработчик прерывания. |
Четвёртый байт | ?? | Пока неважно, устанавливайте в 0. |
0-й – 3-й биты пятого байта | Type | Дескриптору шлюза прерывания соответствует значение 1110b. Младший бит этого поля – единственное отличие от дескриптора шлюза ловушки по формату. |
4-й бит пятого байта | S | Это системный дескриптор, поэтому 0. |
5-й – 7-й биты пятого байта | ?? | Пока неважно, устанавливайте в 100b. |
Шестой и седьмой байты | Offset (part 2) | Старшие два байта поля Offset. |
То же самое на картинке:
И, конечно, пример:
db 04h ; Offset – два младших байта db 03h db 8 ; Segment selector db 0 db 0 ; 0 db 10001110b ; 10001110 – магическое число.. db 02h ; Offset – два старших байта db 01h |
Это дескриптор шлюза прерывания, селектор сегмента указывает на первый дескриптор GDT, смещение – 01020304h.
Никто не мешает использовать дескриптор шлюза прерывания для программных прерываний и исключений, и наоборот, дескриптор шлюза ловушки для внешних прерываний, это вполне возможно, а иногда может оказаться даже наиболее правильно.
Два типа дескрипторов существуют для того, чтобы можно было явно отмечать обработчики, которые могут быть прерваны асинхронным внешним прерыванием и обработчики, которые не должны быть прерваны практически никогда. С одной стороны, чем меньше в системе обработчиков второго типа, тем лучше, так как тем выше реактивность системы, но с другой стороны, написание прерываемых обработчиков может оказаться неоправданно сложным. Выбор остаётся за разработчиками ОС, задачей разработчиков процессора было предоставить им этот выбор.
- Приведите пример преобразователя стека в очередь. - Автомат Калашникова! Анекдот
Возникновение и обработка внешнего прерывания – сложный процесс, в котором задействовано несколько устройств, различные внутрипроцессорные структуры (регистры, флаги, стек) и собственно обработчик. Поэтому, чтобы не мешать всё в одну кучу, описание разделено на две части: «вне процессора» и «внутри процессора»; первая часть в основном посвящена сигналам, шинам и соединениям, вторая – более привычным вопросам: регистры, стек и обработчик.
ПРЕДУПРЕЖДЕНИЕ То, что написано ниже в этом разделе, как минимум, не совсем правда, особенно для современных процессоров. Это – простая и полезная схема, без которой очень сложно понимать и работать с внешними прерываниями, а «правда» вам и не нужна :) |
Примерно половину задач, возникающих при возникновении прерывания, берёт на себя программируемый контроллер прерываний (programmable interrupt controller, PIC; по-русски – ПКП). Схема подключения показана на Рисунке 2.
ПРИМЕЧАНИЕ Ниже описывается схема работы ПКП i8259A, использовавшегося в компьютерах на основе ранних версий x86 (более подробно он описан ниже, в соответствующем разделе). Не фиксируйтесь на деталях, сначала нужно представить себе круг задач, для которых предназначен любой ПКП, детали здесь приведены для того, чтобы описание не стало чересчур абстрактным. |
Последовательность действий при обработке прерывания:
В результате, ПКП отвечает за:
И всем этим значительно облегчает работу процессора.
После того, как ПКП передал процессору номер прерывания, в обработку включается собственно процессор. Тут всё как всегда, с минимальными отличиями:
На картинке:
Естественно, при желании, обработчик может возвращать управление вовсе не туда, откуда он был вызван. На этом основана реализация многозадачности.
Когда вошёл контролёр, Скорость перевалила за сто. Он даже не стал проверять билеты, Он лишь попросил снять пальто. Борис Гребенщиков
В ранних моделях x86 ПКП действительно был устроен так, как написано ниже. Но, начиная с Pentium, Intel озаботилась возможностью установки нескольких процессоров на одну материнскую плату, в связи с чем ПКП был основательно переработан. Тем не менее, из соображений обратной совместимости, с ним можно работать так же, как и раньше. Возможно, это не лучший вариант, но зато самый простой и наиболее полно описанный в литературе. Итак, перенесёмся в далёкие 80-е. Представьте: Гребенщиков молодой и худенький, Цой жив и только-только записал «45», а Intel использует для реализации ПКП микросхему i8259A.
Замечательно простое и функциональное устройство. Краткие ТТХ:
Управляется через два регистра размером по одному байту каждый, они доступны программисту через порты ввода-вывода (команды in и out).
ПРИМЕЧАНИЕ ОС имеет возможность запретить пользователям прямые обращения к портам, поэтому пользователь не сможет нарушить работу ОС, управляя ПКП напрямую. Подробнее, как обычно, в главах, посвященных защите. |
Поскольку собственных названий регистры не имеют, они будут обозначаться как нулевой и первый (о том, какие именно номера портов соответствуют регистрам – в следующем разделе). Ниже кратко перечислены наиболее полезные возможности.
ПРИМЕЧАНИЕ Описание, мягко говоря, не полное: во-первых, перечислено далеко не всё, во-вторых, даже перечисленное толком не объяснено (за всеми этими битовыми масками скрывается глубокий смысл!). На мой взгляд, более подробные знания не нужны, но если вы вдруг заинтересуетесь, обращайтесь к какой-нибудь хорошей книжке времён MS-DOS. Лично я рекомендую [2B ProGroup 1995]. Описание ПКП, приведённое в [Зубков 1999], не очень удачно: во-первых, встречаются ошибки, во-вторых, хотя там и перечислены все флаги, их смысл не всегда остаётся ясен. Возможно, в более новых изданиях ситуация улучшена. |
Пример маскирования всех прерываний, кроме, устройства, подключённого к ir1:
mov al, 11111101b out <первый>, al |
Каждый разряд числа соответствует входу микросхемы, разряды, установленные в 1 маскируют соответствующий им вход, разряды установленные в 0 демаскируют.
Для получения списка замаскированных прерываний нужно читать тот же регистр, результат возвращается в том же формате. Это даёт следующую возможность:
in al, <первый> or al, 00000001b out <первый>, al |
То есть замаскировать ir0, а все остальные оставить в том же состоянии.
В рамках курса – так:
mov al, 20h out <нулевой>, al |
ПРЕДУПРЕЖДЕНИЕ Напоминаю, это не просто «полезная возможность», это глубокая жизненная необходимость! Если не завершать прерывания, система просто не будет работать. |
ПРИМЕЧАНИЕ Это называется «неспецифичное завершение прерывания» (специфичное отличается тем, что нужно указывать номер входа irX, а этот вариант подходит для всех случаев). После выполнения этих команд ПКП удалит наиболее приоритетное прерывание из своего списка «обрабатываемые в данный момент». Если обрабатывается всего одно прерывание, оно и будет удалено, если их несколько, ситуация сложнее. Несколько прерываний может оказаться только в том случае, если менее приоритетное было прервано более приоритетным, и обработка более приоритетного ещё не закончилась. Таким образом (если программист нигде не нарушил правила, никто ведь не мешает вызвать эти команды несколько раз подряд в одном и том же обработчике, или наоборот пропустить их), завершая наиболее приоритетное прерывание из списка, ПКП завершает именно то прерывание, обработчик которого действительно выполняется процессором в данный момент. |
Для этого надо заново инициализировать ПКП. Всё в тех же рамках, это должно выглядеть так:
out <нулевой>, 00010001b out <первый>, <номер обработчика для ir0> ; должен быть кратен восьми out <первый>, 00000100b ; или 2, см. следующий раздел out <первый>, 00000001b |
ПРЕДУПРЕЖДЕНИЕ В [Зубков 1999] предложен другой вариант, который в корне неверен, хотя и работает :) Оригинальный ПКП i8259A был предназначен для широкого применения, его можно было гибко настраивать, но эти возможности никогда не использовались в PC. Современный ПКП, работающий в режиме эмуляции i8259A, не понимает и игнорирует примерно половину устанавливаемых при инициализации флагов. Если бы он вдруг начал их понимать, соответствующий код из [Зубков 1999] перестал бы работать. |
Команды, посылаемые ПКП при инициализации, называются ICW1 – ICW4 (Initialization Command Word). Команда ICW3 связана с каскадным соединением i8259A, более подробно она описана ниже.
Всего восемь устройств – маловато для серьёзной работы. Разработчики i8259A это понимали, поэтому предусмотрели возможность объединения нескольких микросхем в один ПКП. Грубо говоря, для этого нужно выход int одной микросхемы i8259A подать на вход irX другой, после чего они смогут работать «почти как одна», это и называется каскадным соединением.
ПРИМЕЧАНИЕ Для того чтобы говорить «не грубо», надо описать, что происходит с сигналами inta и D. Ну да, с ними действительно что-то происходит, в этом процессе задействовано ещё несколько сигналов, но, к счастью, это абсолютно не влияет на программную архитектуру, поэтому не должно вас интересовать. Хуже того, поскольку в современных ПКП микросхемы i8259A уже давно не используются, это неважно вообще. |
Таким способом можно соединить до девяти микросхем (одна ведущая и восемь ведомых, многоуровневое каскадирование не поддерживается), получив возможность работать с 64-мя устройствами. При этом на уровне сигналов int/intr/inta/D достигается полная прозрачность, то есть, с точки зрения «аппаратного уровня» ничего не меняется. Но на «программном уровне» появляются отличия: каждый i8259A имеет свои собственные управляющие регистры, их надо отдельно инициализировать (ведущий и ведомые по-разному) и ими надо по отдельности командовать.
Но это теория. Практически, в компьютерах на основе 80286 используется две микросхемы i8259A, ведомая подключена к входу ir2 ведущей. Схема показана на Рисунке 4, на нём же отображено соответствие irX – IRQX и несколько наиболее полезных прерываний (при желании вы можете найти «классический» список внешних прерываний в любой книжке по ассемблеру, например в [Зубков 1999]).
Что ещё можно сказать про ПКП:
И, наконец, правильная инициализация. Для ведущего:
out 20h, 00010001b out 21h, <номер обработчика для ir0> ; должен быть кратен восьми out 21h, 00000100b ; битовая маска, единицей отмечены входы, ; к которым подключены не обычные устройства, ; а ведомые ПКП. out 21h, 00000001b |
Для ведомого:
out A0h, 00010001b out A1h, <номер обработчика для ir0> ; должен быть кратен восьми out A1h, 2 ; номер ведомого ПКП, совпадает с номером входа ; ведущего ПКП, к которому подключен ведомый ПКП out A1h, 00000001b |
Точно так же, как и в случае с обработчиком исключения, непредсказуемый пользователь может попытаться вызвать через int обработчик внешнего прерывания. При этом, хотя стек вызова будет правильный, если не повезёт, результат может оказаться даже более эффектным – с аппаратурой лучше не шутить. Рекомендуемое решение проблемы стандартное – запретить пользователям вызывать обработчики напрямую, дескриптор шлюза прерывания такую возможность даёт, подробнее в главах про защиту.
Но если вам вдруг захочется «поиграть в демократию» (лучше не надо!) или подстраховаться от ошибок в самой ОС (а вот это может быть оправдано), есть ещё один вариант. Можно в обработчике запросить у ПКП список обрабатываемых в данный момент прерываний и проверить присутствие в списке «себя». Примерно так:
mov al, 00001011b ; 00001011b - команда запроса списка ; обрабатываемых прерываний out <нулевой>, al in al, <нулевой> ; Теперь в al маска, единицами отмечены ; обрабатываемые прерывания |
ПРЕДУПРЕЖДЕНИЕ В [Зубков 1999] в описании этой команды допущена ошибка: вместо чтения нулевого регистра, предлагается читать первый. Возможно, в более новых изданиях ошибка исправлена. |
Счастливые часов не наблюдают. Александр Сергеевич Грибоедов
Часы реального времени (Real Time Clock, RTC) – замечательное устройство, очень привлекательное в качестве примера:
Единственная проблема – просто так RTC работать не будет, его тоже надо немного программировать. К счастью, это не сложно. У RTC довольно много регистров, но для работы с ним используется только два порта: 70h и 71h. Порт 70h – индексный, в него записывается номер регистра RTC, который будет прочитан/записан при следующем обращении к порту 71h. Если подряд идет несколько обращений к одному и тому же регистру RTC (например, чтение – наложение маски – запись нового значение), переустанавливать значение индекса необязательно.
Ниже, с самыми минимальными пояснениями, приведён код, который используется в примере, за более подробной информацией обращайтесь, например, к [Зубков 1999].
Перевод RTC в режим периодических прерываний:
; Разрешение периодического прерывания RTC mov al, 0bh ; прочитать регистр 0Bh out 70h, al in al, 71h or al, 01000000b ; Установить шестой бит в 1 out 71h, al ; Записать обратно |
Аналогично, возврат в режим по умолчанию:
; Запрещение периодического прерывания RTC mov al, 0bh ; прочитать регистр 0Bh out 70h, al in al, 71h and al, 10111111b ; Сбросить шестой бит out 71h, al ; Записать обратно |
Настройка частоты прерываний:
; Установка частоты периодического прерывания RTC mov al, 0ah ; прочитать регистр 0Ah out 70h, al in al, 71h or al, 0fh ; Четыре младших бита определяют частоту. ; 1111 - 2 раза в секунду. out 71h, al ; Записать обратно |
После возникновения прерывания, RTC устанавливает флаги (вам не важно какие :)) в регистре 0Ch, при чтении этого регистра флаги сбрасываются. А пока они не сброшены, новых прерываний не происходит (аналог списка обрабатываемых прерываний ПКП и команды завершения обработки прерывания).
; Читаем из регистра 0Ch RTC, иначе прерываний больше не будет mov al, 0ch out 70h, al in al, 71h |
Программа последовательно выполняет следующие действия:
Код написан на основе примера int0.asm из главы про программные прерывания.
; int_rtc.asm ; Программа, устанавливающая и вызывающая обработчик прерывания RTC .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Дескриптор шлюза gate_descriptor struct offset_low dw 0 ; Два младших байта поля Offset selector dw 0 ; Поле Segment Selector zero db 0 type_and_permit db 0 ; Флаги offset_high dw 0 ; Старшие байты поля Offset gate_descriptor ends ; Регистр, описывающий таблицу table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; В es - начало видеобуфера. Можно было сделать то же ; самое средствами защищённого режима, но так проще push 0b800h pop es ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегмента call cs_to_eax mov dsc64kb.base_low, ax shr eax, 16 mov dsc64kb.base_high0, al ; Сохраняем IDTR реального режима sidt fword ptr old_idtr ; Инициализируем IDT call initialize_idt ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; Базовый адрес целевого сегмента совпадает с cs, ; поэтому смещение можно прописать сразу db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 8 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим. ; Сохраняем состояние масок ПКП in al, 021h mov old_mask1, al in al, 0A1h mov old_mask2, al ; Инициализация ПКП, в bl и bh - базовые номера прерываний ; Ведущий ПКП ставим с 20h, ведомый с 28h mov bl, 020h mov bh, 028h call initialize_pic mov al, 0fbh ; на ведущем маскируем всё, кроме IRQ2 out 021h, al mov al, 0feh ; на ведомом маскируем все, кроме IRQ8 (RTC) out 0A1h, al ; Установка частоты периодического прерывания RTC mov al, 0ah out 70h, al in al, 71h or al, 0fh ; 2 раза в секунду out 71h, al ; Разрешение периодического прерывания RTC mov al, 0bh out 70h, al in al, 71h or al, 01000000b out 71h, al mov ecx, 10 ; в ecx – счётчик прерываний call enable_interrupts ; в цикле ждём, пока прерывание произойдёт 10 раз и обнулит ecx test_end: cmp ecx, 0 jne test_end call disable_interrupts ; Запрещаем переодическое прерывание от RTC mov al, 0bh out 70h, al in al, 71h and al, 10111111b out 71h, al ; Возвращаем стандартные номера прерываний mov bl, 08h mov bh, 70h call initialize_pic mov al, old_mask1 ; снимаем маскировку out 021h, al mov al, old_mask2 ; снимаем маскировку out 0A1h, al call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент ; восстанавливаем IDTR реального режима lidt fword ptr old_idtr ; разрешаем прерывания call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Обработчик прерывания ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Обработчик прерывания RTC, ведёт обратный отсчёт intRtc_handler: push eax push ebx dec ecx ; уменьшение ecx mov bh, 07h ; белый текст на чёрном фоне mov bl, '0' add bl, cl ; если cl меньше 10, то в bl соответствующая цифра mov eax, 80 * 24 * 2 mov word ptr es:[eax], bx call upscroll_screen ; прокручивает экран на строчку вверх ; читаем из регистра 0Ch RTC, иначе прерываний больше не будет mov al, 0ch out 70h, al in al, 71h mov al, 020h ; «неспецифичное» завершение прерывания out 020h, al ; на ведущем ПКП out 0A0h, al ; на ведомом ПКП pop ebx pop eax iretd ; 32-х разрядный возврат из прерывания ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 64 Kb dsc64kb segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ; Таблица дескрипторов прерываний IDT label byte db 32 dup ( 8 dup (0)) ; 0 – 1Fh db 8 dup ( 8 dup (0)) ; 20h – 27h, ведущий ПКП ; Дескриптор шлюза прерывания. ; Обработчик прерывания находится в сегменте, соответствующем первому ; дескриптору GDT. Поскольку базовый адрес сегмента такой же, как ; в реальном режиме, смещение обработчика тоже совпадает. gate_descriptor <intRtc_handler, 8, 0, 8Eh, 0> ; Данные для загрузки в IDTR idtr table_register <$ - IDT - 1, 0> ; Место для хранения IDTR реального режима old_idtr table_register <> ; Место для хранения старых значений масок ПКП old_mask1 db 0 old_mask2 db 0 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret ; Инициализирует IDT initialize_idt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset IDT ; Записываем его в структуру mov idtr.base, eax ; Загружаем IDTR lidt fword ptr idtr ret ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сгмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret ; Инициализация ПКП. ; bl – базовый номер прерывания ведущего ПКП ; bh - базовый номер прерывания ведомого ПКП initialize_pic: push eax mov al, 00010001b ; ICW1 out 020h, al out 0A0h, al mov al, bl ; ICW2, ведущий out 021h, al mov al, bh ; ICW2, ведомый out 0A1h, al mov al, 00000100b ; ICW3, ведущий out 021h, al mov al, 2 ; ICW3, ведомый out 0A1h, al mov al, 00000001b ; ICW4 out 021h, al out 0A1h, al pop eax ret ; Прокручивает экран на строчку вверх upscroll_screen: push eax push ecx push edx mov eax, 0 ; Текущий символ mov ecx, 80 * 24 ; Колическтво символов на экране ; (без последней строки, она отдельно) screen_loop: mov dx, word ptr es:[eax * 2 + 80*2] mov word ptr es:[eax * 2], dx ; Меняем символ inc eax loop screen_loop mov ecx, 80 ; Длинна последней строки mov dx, 0720h ; символ, которым заполняется строка last_line_loop: mov word ptr es:[eax * 2], dx ; Меняем символ inc eax loop last_line_loop pop edx pop ecx pop eax ret end start |
Jah love, Jah love - protect us Vincent Ford (performed by Bob Marley) Ты должен быть сильным, Ты должен уметь сказать «Руки прочь, прочь от меня». Ты должен быть сильным, Иначе зачем тебе быть. Виктор Цой Нет защиты, кроме любви. Борис Гребенщиков
Если вы успешно дочитали курс до этой главы, то, во-первых, вас можно и нужно поздравить! Поздравляю! Во-вторых, скорее всего, вы уже понимаете, зачем нужна подсистема защиты – в каждой из предыдущих глав были ссылки приблизительно следующего вида: «чтобы всё было хорошо, пользователь не должен иметь возможность …, подробнее этот вопрос рассмотрен в главах, посвящённых защите». Что ж, пришло время выполнять обещания. Но сначала, как обычно, ещё немного о «зачем».
- Ты хочешь поговорить об этом? Собирательный образ психолога.
Как вы могли заметить, все описанные до сих пор возможности более-менее повторяют реальный режим: память есть и там, прерывания и исключения – тоже. Защита – первое и решающее отличие. Да, я хочу поговорить об этом :)
Не случайно режим называется «Protected» – защищённый. Не Advanced, не Extended, не Improved, и даже не ++ или #, а именно Protected. Защита – его соль и суть. Ради неё всё и затевалось, именно она склеивает воедино всё остальное, без неё не было бы ни современных ОС, ни современных приложений. И это притом, что защита не даёт каких-то невероятных новых возможностей, наоборот, она отнимает многие старые. Нелогично? Ну, например, можно провести аналогию с переходом от «макаронного» кода, изобилующего goto, к структурному программированию. Или с ассемблера на язык высокого уровня. Только наличие правильно заданных ограничений позволяет системе оставаться управляемой и стабильной, и, как следствие, развиваться и расти дальше.
СОВЕТ А теперь подумайте о своей жизни, как о разрабатываемой вами сложной системе. А о морали, нравственности, порядочности – как о тех самых правильных ограничениях, без которых невозможно развитие. Подумали? Действуйте! Извиняюсь за отступление от темы, мысль слишком важна. |
«Старый» подход упёрся в свой потолок: при появлении многозадачности, графических интерфейсов и т.п., система «компьютер + ОС + запущенное ПО» становится слишком сложной, утверждение «программисты умные, всё будет в порядке» перестаёт работать, и, если других гарантий надёжности нет, всё начинает падать с удручающим постоянством.
ПРИМЕЧАНИЕ Хотя бы потому, что в понятие «программисты» теперь входит слишком много людей: несколько сотен на ОС, по двое-трое-десятеро на каждое приложение. Вы правда думаете, что они все умные? Ну так я вас огорчу :) |
Для примера, допустим, что вы всё-таки написали многозадачную ОС реального режима:
ПРИМЕЧАНИЕ Те, кто писал резиденты под DOS, поняли, что я имею в виду. Остальным объясняю: 1. Есть стандартный обработчик прерывания 2. Запускается Программа1, которой это прерывание нужно. Она залезает в таблицу векторов прерываний и меняет стандартный обработчик на свой. Как правильная, вежливая программа, она запоминает адрес старого обработчика. Во-первых, этот адрес надо будет восстановить, когда Программа1 будет выгружаться, во-вторых, можно вызывать этот обработчик из своего, для того чтобы не мешать работе стандартных механизмов. 3. Запускается Программа2, которой тоже нужно это же прерывание. Она проделывает ту же операцию, но поскольку стандартный обработчик уже подменён, она запоминает адрес обработчика Программы1. 4. Если обе программы вызывают «старые» обработчики, вся цепочка может корректно работать. Если нет, будет вызываться только наиболее новый. 5. При выгрузке, программы попытаются восстановить в таблице векторов прерываний запомненные адреса обработчиков. И вот тут всё зависит от очерёдности. Если первой будет выгружаться Программа1, она запишет в таблицу адрес стандартного обработчика. А Программа2 – адрес обработчика из уже выгруженной Программы1. 6. И в этот момент ваша ОС накроется медным тазом. Конечно, такая ситуация могла возникнуть и в DOS, существует и классическое решение – если программа видит, что в таблице адрес не её обработчика, то есть после неё кто-то уже перехватил это же прерывание, она ничего не восстанавливает и не выгружается. Но вы же понимаете, что так будут поступать далеко не все… |
Согласитесь, так жить нельзя. Очевидны две проблемы:
Классическим решением проблемы сложности является абстракция, в данном случае это отделение ОС от пользовательских приложений, и самих пользовательских приложений друг от друга. Кроме того, введение уровня абстракции над системными механизмами позволяет реализовать принудительный контроль ресурсов. В общем-то, этим и занимается защита – отделяет. В нашем случае это понижает сложность и одновременно повышает устойчивость (в грамотно написанной ОС приложение можно почти безболезненно выгрузить).
Так что защита в защищённом режиме играет одну из ключевых ролей. Пытаться абстрагироваться можно и без неё… Но только работать это не будет.
Описанный в предыдущих главах механизм управления памятью тоже относится к «необходимым ограничениям». Проверки существования дескриптора, типа дескриптора, размеров, разрешения чтения/записи, дают большую структурированность, в частности становится невозможно писать по случайному адресу. И в общем-то всё это тоже относится к защите, в том числе и с точки зрения Intel. Согласно терминологии Intel, система защиты в целом называется «protection» и все перечисленные выше проверки относятся к «просто protection» (это не термин). Но есть еще один раздел системы защиты, называемый privilege level protection (а это термин, он довольно неуклюже переводится на русский как «защита по привилегиям»), и именно ему посвящена эта и следующие главы.
Если проводить аналогии, то «просто protection» это стены, крыша и дверь дома, а privilege level protection – возможность запереть дверь на ключ. Если дверь не запирать, дом прекрасно защищён от ветра, снега и дождя, но практически не защищён от человека. С другой стороны, «возможность запереть дверь» сама по себе не существует и появляется только тогда, когда дом и дверь уже есть. Это некоторая дополнительная надстройка над существующими проверками, отделяющая тех, у кого есть ключ, от тех, у кого ключа нет. И, конечно, если у дома разваливаются стены, даже запертая дверь плохо поможет (в качестве примера: по непроверенным слухам, в ОС семейства Windows 9x IDT находится в области памяти, доступной на запись для обычных приложений).
Но спустимся с небес к нашим баранам. Начнём со списка потребностей в защите, которые «всплыли» в предыдущих главах.
Обобщая, получаем следующее:
ПРИМЕЧАНИЕ Вопросу защиты портов ввода-вывода посвящена отдельная глава «Защита: ввод-вывод», здесь эта тема только несколько раз упоминается. |
Как вы помните, дескрипторы сегментов и дескрипторы шлюзов довольно сильно отличаются по формату. Единственное, что объединяет форматы абсолютно всех дескрипторов, это формат пятого байта (напоминаю, нумеруем байты от нуля). Рассмотрим его подробнее.
Положение | Название | Описание |
---|---|---|
Биты 0 – 3 | Type | Уточняет тип дескриптора (флаги в дескрипторах сегмента кода/данных, номер в системных дескрипторах). |
Бит 4 | S | Определяет тип дескриптора – системный или сегмента кода/данных. |
Биты 5 – 6 | DPL | Descriptor Privilege Level. Это ОНО! Именно на это поле обратите своё внимание, во многом ему посвящена глава. Описано ниже. |
Бит 7 | P | Для завершённости – флаг Segment Present. В рамках курса всегда установлен в 1, больше нигде не упоминается, подробно рассмотрен в приложении. |
На картинке:
С точки зрения защиты, ключевое поле – DPL, Descriptor Privilege Level. Название переводится как «уровень привилегий дескриптора», и очень точно отражает предназначение поля.
Царь-царевич, Король-королевич, Сапожник, портной. Кто ты будешь такой? Считалка
Процессоры Intel x86 поддерживают четыре уровня привилегий.
ПРЕДУПРЕЖДЕНИЕ Обратите внимание: меньшее численное значение соответствует большему уровню привилегий. Во избежание двусмысленности, в скобках будет уточняться, что имеется в виду. Например: «… минимальный (численно максимальный) уровень ..». |
Под разными названиями, поле, содержащее уровень привилегий, встречается в нескольких системных структурах. Смыслы тоже разные, хотя и близкие.
DPL это пятый и шестой биты пятого байта дескриптора. Для разных дескрипторов DPL имеет разный смысл, по этому признаку все дескрипторы можно разбить на три класса и одно исключение из правил.
Значение DPL это уровень привилегий, которыми обладает находящийся в сегменте код. Именно этим отличается ОС и пользовательские приложения: код ОС находится в сегментах с нулевым DPL и обладает максимальным (нулевым) уровнем привилегий, код приложений – минимальным (третьим).
ПРИМЕЧАНИЕ Передаче управления между сегментами кода и куче связанных с этим проблем посвящена глава «Защита: передача управления». |
Кроме дескрипторов «обычного» сегмента кода, существуют дескрипторы подчинённого (conforming) сегмента кода. Их описание вынесено в приложение.
В поле DPL хранится минимальный (численно максимальный) уровень привилегий, необходимый для доступа к ресурсу. Если к ресурсу обращается код с недостаточными привилегиями, процессор генерирует исключение #GP.
Из описанных в предыдущих главах, к этому классу относятся дескрипторы сегментов данных и обработчиков прерываний (оба варианта). Сюда же можно отнести и дескрипторы сегментов кода (только «обычные», подчинённые будут работать иначе), при использовании с сегментными регистрами DS, ES, FS, GS, то есть не для передачи управления, а как «данные-только-для-чтения». Все неизученные типы дескрипторов также относятся к этому классу.
Например, такое прерывание может вызвать только код с нулевым уровнем привилегий:
gate_descriptor <int26_handler, 8, 0, 10001111b, 0>
|
А такое – любой:
gate_descriptor <int26_handler, 8, 0, 11101111b, 0>
|
Аналогично с любыми другими дескрипторами ресурсов.
ПРИМЕЧАНИЕ В данном случае проверка производится только при явном вызове обработчика инструкцией int 26. При возникновении внешнего прерывания или исключения проверки нет, обработчик будет вызван независимо от уровня привилегий текущего кода (в [Григорьев 1993] утверждается обратное, это ошибка автора). Поскольку никакие другие ресурсы (на данный момент из всех «других» описаны только сегменты кода/данных) не могут быть использованы подобным «самопроизвольным» образом, к ним это замечание не относится. |
Сначала рассмотрим ситуацию (ситуация предполагает периодическое изменение привилегий, сейчас нам не важно как это происходит). Пусть имеется:
Два варианта развития событий:
Для предотвращения таких ситуаций системный код должен использовать свой сегмент стека. А, видимо, для повышения мотивации использования разных стеков, разработчики Intel просто запретили использовать один и тот же: при загрузке селектора в регистр SS, текущий уровень привилегий должен точно совпадать с DPL дескриптора сегмента.
Подробнее о переключении стеков при смене уровней привилегий написано в главе «Защита: передача управления».
Текущий уровень привилегий (Current Privilege Level, CPL) – уровень привилегий исполняемого в данный момент кода, или, другими словами, уровень привилегий, на котором в данный момент находится процессор. Обычно CPL совпадает со значением двух младших битов регистра CS.
Именно CPL определяет, что разрешено, а что запрещено, именно он проверяется при попытке выполнения привилегированных команд (см. ниже), сравнивается с DPL ресурса (см. выше) и с IOPL (см. ниже). В общем, несколько упрощая, при нулевом CPL можно сделать с системой вообще всё, в любом другом случае придётся спрашивать разрешения.
ПРИМЕЧАНИЕ Защита многослойна, нулевой уровень привилегий разрешает «вообще всё» только с точки зрения доступа к любым ресурсам независимо от их DPL (а со стеками и это не получится, см. выше), к любым портам ввода-вывода и к любым инструкциям. А, например, обращаться за пределы сегмента, менять read-only сегменты и передавать исполнение сегментам данных запрещёно независимо от текущего уровня привилегий. Но, если очень нужно, имея нулевой CPL, можно определить свою собственную GDT и описать там те дескрипторы, которые хочется, обходя таким образом практически любые механизмы защиты. |
Значение CPL определяется в соответствии с двумя простыми правилами (подчинённые сегменты кода добавят третье, но пока – два):
Изменение CPL тоже рассмотрено в главе «Защита: передача управления».
Два младших бита «нормального» селектора (не загруженного в CS) называются уровень привилегий запроса (Request Privilege Level, RPL), и примерно в 99% ситуаций, единственно разумным значением этого поля будет 0. Тем не менее, поле придумано разработчиками не совсем напрасно, и ситуация, когда ненулевой RPL имеет смысл, всё-таки существует.
Рассмотрим следующий сценарий:
Теперь добавим уровни привилегий:
Поскольку «F» выполняется с нулевым уровнем привилегий, она может читать/записывать данные независимо от значения DPL сегмента SEG. Может быть, это как раз то поведение, которое требуется, но может быть, нужно ограничить возможности работы только теми сегментами, которые доступны «A». И вот тут-то на помощь приходит RPL! С ним сценарий выглядит так:
ПРИМЕЧАНИЕ Это близко к понятию имперсонализации (impersonation), отличие в том, что с помощью RPL невозможно увеличить свои права, только уменьшить. |
Описанное выше применение RPL сводится к следующему правилу: при загрузке селектора сегмента в сегментный дескриптор, система защиты сравнивает c DPL сегмента не только CPL, но и RPL селектора.
Это единственный разумный случай использования RPL. К сожалению, есть ещё несколько не очень разумных, в которых значение RPL всё-таки играет роль:
Уровень привилегий ввода-вывода (Input-Output Privilege Level, IOPL). Это поле описано в главе «Защита: ввод-вывод», а здесь упомянуто для комплекта.
Следующие инструкции в защищённом режиме можно выполнять только при нулевом CPL (в реальном режиме таких проблем нет, можно всем, всё и всегда):
Эти инструкции называются привилегированными (privileged instructions), попытка выполнить их при ненулевом CPL приведёт к генерации исключения #GP.
ПРИМЕЧАНИЕ Обратите внимание: cli, sti, in, out – не привилегированные инструкции, контроль за их исполнением осуществляется иначе, более гибко. Это тема главы «Защита: ввод-вывод». |
Если вернуться к началу главы, к разделу «Насущные проблемы», и вспомнить, чего требовалось достичь, то окажется, что практически всё уже успешно достигнуто. Список результатов приведён в Таблице 2.
Проблема | Решение |
---|---|
Отличать пользовательское приложение от ОС | Динамически – CPL, статически – DPL дескриптора сегмента кода. |
Запретить для пользовательских приложений потенциально опасные инструкции. | Привилегированные инструкции |
Разделять ресурсы, к которым пользователю обращаться можно, и ресурсы, к которым пользователю обращаться нельзя | DPL дескриптора ресурса, CPL |
Порты ввода-вывода. | CPL, IOPL, подробнее – в главе «Защита: ввод вывод». |
Для сегментов кода предложенные решения порождают проблему следующего уровня: отделять мы уже умеем, теперь надо как-то наладить связь между кодом с разными уровнями привилегий. Этому вопросу посвящена глава «Защита: передача управления».
А с сегментами данных новых проблем нет, получена логичная законченная система проверок, даже две – для обычного сегментного регистра и для SS. В качестве некоторого подведения итогов, ниже они приведены полностью.
При загрузке селектора в регистр:
При выполнении операции:
При загрузке селектора в регистр:
При выполнении операции – то же, что и в случае обычного сегментного регистра, отличие только в том, что две первые проверки никогда не сработают: в SS не может находиться нулевой селектор или селектор read-only сегмента.
Программа выполняет следующие действия:
В итоге на экране будет три восклицательных знака.
ПРЕДУПРЕЖДЕНИЕ Как выяснилось, VMWare Workstation 4.0.5 build-6030 ведёт себя не совсем корректно. Сценарий: * Тот же пример, но значение RPL селектора установлено в 1 * После последней перезагрузки виртуальной машины не запускались версии примера с RPL равным 2 или 3 Результат: * На экране нет ни одного восклицательного знака, т.е. установка fs отработала без ошибок Версии с RPL равным 2 и 3 работают нормально, после них версия с RPL равным 1 тоже работает нормально. В VMWare Workstation 5.0.0 build-12124 ошибка исправлена. |
; rpl.asm ; Программа, проверяющая действие поля RPL .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Дескриптор шлюза gate_descriptor struct offset_low dw 0 ; Два младших байта поля Offset selector dw 0 ; Поле Segment Selector zero db 0 type_and_permit db 0 ; Флаги offset_high dw 0 ; Старшие байты поля Offset gate_descriptor ends ; Регистр, описывающий таблицу table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; В es - начало видеобуфера. Можно было сделать то же ; самое средствами защищённого режима, но так проще push 0b800h pop es ; Устанавливаем правильный сегмент в long-jmp-to-RM mov ax, cs mov cs:rm_cs, ax ; Прописываем адрес начала cs в качестве базового адреса сегментов call cs_to_eax mov code_dsc.base_low, ax shr eax, 16 mov code_dsc.base_high1, al ; Сохраняем IDTR реального режима sidt fword ptr old_idtr ; Инициализируем IDT call initialize_idt ; Инициализируем GDT call initialize_gdt ; Открываем вентиль A20 call open_A20 call disable_interrupts call set_PE ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для реального режима (адрес) в нормальное для защищённого (селектор). ; Базовый адрес целевого сегмента совпадает с cs, ; поэтому смещение можно прописать сразу db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение dw 8 ; селектор ; В данный момент сегмент кода - 64 Кб, базовый адрес равен ; адресу сегмента кода до переключения в защищённый режим, ; потому можно без проблем переключаться обратно ; Селектор второго дескриптора GDT, RPL равен 3 mov ax, 10011b ; Попытка поместить селектор в сегментный регистр mov fs, ax call clear_PE ; Мы в реальном режиме, осталось разобраться с ; значением регистра cs ; 16-разрядный дальний переход. Перключает содержимое cs из нормального ; для защищённог режима (селектор) в нормальное для реальног (адрес). ; Адрес сегмента вычисляется и прописывается во время выполнения. db 0EAh ; код команды дальнего перехода dw $ + 4 ; смещение rm_cs dw 0 ; сегмент ; восстанавливаем IDTR реального режима lidt fword ptr old_idtr call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Обработчики ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; intGpf_handler: push eax push ebx mov bh, 07h ; белый текст на чёрном фоне mov bl, '!' mov eax, 80 * 24 * 2 mov word ptr es:[eax], bx call upscroll_screen ; прокручивает экран на строчку вверх ; Запоминаем пятый байт дескриптора в al и ah mov al, res_dsc.type_and_permit mov ah, al ; Оставляем в al только DPL (биты 5-6), инкрементируем это поле and al, 01100000b add al, 00100000b and al, 01100000b ; Обнуляем в ah поле DPL and ah, 10011111b ; Совмещаем or ah, al ; И кладём в дескриптор mov res_dsc.type_and_permit, ah pop ebx pop eax add esp, 4 iretd ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор сегмента кода, размер 64 Kb code_dsc segment_descriptor <0ffffh, 0, 0, 10011010b, 0, 0> ; 10011010b - 1001, C/D - 1, 0, R/W - 1, 0 ; 0 - G - 0, 000, Limit - 0 ; Дескриптор ресурса, в данном случае – сегмента данных res_dsc segment_descriptor <0ffffh, 0, 0, 10010010b, 10001111b, 0> ; 10011010b - 1001, C/D - 0, 0, R/W - 1, 0 ; 10001111b - G - 1, 000, Limit - 1111 ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ; Таблица дескрипторов прерываний IDT label byte db 13 dup ( 8 dup (0)) ; 0 – 12 ; Дескриптор шлюза ловушки, обработчик #GP gate_descriptor <intGpf_handler, 8, 0, 8Fh, 0> ; Данные для загрузки в IDTR idtr table_register <$ - IDT - 1, 0> ; Место для хранения IDTR реального режима old_idtr table_register <> ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Открывает вентиль A20 open_A20: in al, 92h or al, 2 out 92h, al ret ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov dword ptr gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Инициализирует IDT initialize_idt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset IDT ; Записываем его в структуру mov dword ptr idtr.base, eax ; Загружаем GDTR lidt fword ptr idtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сгмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret ; Прокручивает экран на строчку вверх upscroll_screen: push eax push ecx push edx mov eax, 0 ; Текущий символ mov ecx, 80 * 24 ; Колическтво символов на экране ; (без последней строки, она отдельно) screen_loop: mov dx, word ptr es:[eax * 2 + 80*2] mov word ptr es:[eax * 2], dx ; Меняем символ inc eax loop screen_loop mov ecx, 80 ; Длинна последней строки mov dx, 0720h ; символ, которым заполняется строка last_line_loop: mov word ptr es:[eax * 2], dx ; Меняем символ inc eax loop last_line_loop pop edx pop ecx pop eax ret end start |
Я один, но это не значит что я одинок.. Виктор Цой За двумя зайцами погонишься – ни одного не поймаешь Пословица
Задача главы предельно проста: объяснить, как правильно создать одну задачу (task) и как проинициализировать регистр задачи (Task Register, TR). Это даже почти не глава. Сначала это была маленькая врезка, но когда она неожиданно разрослась до трёх страниц и потребовала отдельного примера, пришлось выделить её в самостоятельную боевую единицу. Но «дотянуть» до честной главы по объёму так и не получилось – как было три страницы, так и осталось, больше просто не о чем писать.
Пример, приведённый в конце, не просто минимальный, а жестоко минимальный.
Заданий нет вообще.
Хуже того. Я даже не буду писать о «зачем» и углубляться в проблематику многозадачности. В данном случае механизм задач интересен не сам по себе, а только как некоторая инфраструктура, которую процессор использует при передаче управления между сегментами кода с различными уровнями привилегий. Поэтому никакой воды, никаких лишних мыслей, рублёный слог, факты, таблицы, диаграммы, код.
Сегмент состояния задачи (task state segment, TSS) содержит «состояние задачи» – некоторые специфичные для задачи данные, определяющие её поведение. Грубо и упрощённо, можно разбить эти данные на две части:
Минимальный размер TSS – 104 байта, именно столько занимает стандартная структура, содержащая все обязательные данные. Поскольку в течение этой главы мы не будем делать с задачами ничего интересного, структура и содержимое TSS пока не имеют значения, то есть он может состоять из 104-х байт абсолютно произвольного содержания. Для простоты, пусть будут нули.
Выглядит это так:
tss_seg db 104 dup(0) |
Естественно, сегмент не может существовать без дескриптора. Формат дескриптора сегмента состояния задачи (task state segment descriptor) таков:
Положение | Название | Краткое описание |
---|---|---|
Два младших байта (нулевой и первый) | Segment Limit (part 1) | Младшие 16 бит поля Segment Limit. |
Второй, третий, четвёртый байты | Base Address (part 1) | Младшие три байта поля Base Address. |
0-й – 3-й биты пятого байта | Type | Дескриптору сегмента состояния задачи соответствует 1001 (на самом деле, тут немного интереснее, более подробно в настоящей главе про многозадачность). |
4-й бит пятого байта | S | Это системный дескриптор, поэтому 0. |
5-й – 6-й биты пятого байта | DPL | Пока 0, подробнее в главе про многозадачность. |
7-й бит пятого байта | P | Устанавливайте в 1, подробнее в приложении. |
0-й – 3-й биты шестого байта | Segment Limit (part 2) | Старшие 4 бита поля Segment Limit. |
4-й – 6-й биты шестого байта | ?? | Устанавливайте в 0. |
7-й бит шестого байта | G | Granularity. Флаг гранулярности. Теоретически, вам может потребоваться сегмент состояния задачи, превышающий 1 мегабайт, но в рамках курса этого не произойдёт. |
Седьмой байт | Base Address (part 2) | Старший байт поля Base Address. |
На картинке это выглядит так:
В качестве примера – дескриптор сегмента состояния задачи, размер сегмента 104 байта, базовый адрес 01020304h:
db 103 ; Segment Limit db 0 db 4 ; base address db 3 db 2 db 10001001b ; 1, DPL – 00, S - 0, 1001 db 0 ; G - 0, 000, Limit - 0 db 1 ; base address |
Или так:
; Дескриптор сегмента состояния задачи, 104 байта, базовый адрес 01020304h segment_descriptor <103, 0304h, 2h, 10001001b, 0, 1h> |
Для того, чтобы корректно инициализировать механизм управления задачами, нужно выполнить три действия:
Инструкция ltr относится к привилегированным и имеет следующий формат:
ltr selector |
Где selector это 16-ти разрядный регистр или переменная типа dw.
Замечательный пример, сложностью и объёмом возвращающий нас примерно ко второй главе. Коротенькая инициализация, две инструкции, ради которых всё это затевалось, возврат в реальный режим. Всегда бы так!
; task.asm ; Программа, инициализирующая TR .model tiny .code .386p org 100h ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Структуры ; ;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Сегментный дескриптор segment_descriptor struct limit_low dw 0 ; Младшие два байта поля Segment limit base_low dw 0 ; Младшие два байта поля Base Address base_high0 db 0 ; Второй байт поля Base Address type_and_permit db 0 ; Флаги flags db 0 ; Ещё одни флаги base_high1 db 0 ; Старший байт поля Base Address segment_descriptor ends ; Регистр, описывающий таблицу дескриптров table_register struct limit dw 0 ; Table Limit base dd 0 ; Linear Base Address table_register ends ;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Код ; ;;;;;;;;;;;;;;;;;;;;;;;;;; start: ; Подготавливаем DS push cs pop ds ; Получаем адрес начала сегмента TSS call cs_to_eax add eax, offset tss_seg ; Записываем его в дескриптор mov word ptr tss_dsc.base_low, ax shr eax, 16 mov byte ptr tss_dsc.base_high0, al ; Инициалиируем GDT call initialize_gdt call disable_interrupts call set_PE ; загрузить селектор дескриптора TSS в TR mov ax, 8 ltr ax call clear_PE call enable_interrupts ret ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Данные ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Глобальная таблица дескрипторов GDT label byte ; Нулевой дескриптор segment_descriptor <> ; Дескриптор TSS, 104 байта, базовый адрес 0 tss_dsc segment_descriptor <103, 0, 0, 10001001b, 0, 0> ; Данные для загрузки в GDTR gdtr table_register <$ - GDT - 1, 0> ; Сегмент TSS. Можно было его вообще не объявлять и оставить ; базовый адрес 0, всё равно бы сработало. Но в будущем пригодится. tss_seg db 104 dup(0) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Служебные функции ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Инициализирует GDT initialize_gdt: ; Вычисляем линейный адрес начала массива дескрипторов call cs_to_eax add eax, offset GDT ; Записываем его в структуру mov dword ptr gdtr.base, eax ; Загружаем GDTR lgdt fword ptr gdtr ret ; Запрещает маскируемые и немаскируемые прерывания disable_interrupts: cli ; запретить прерывания in al, 70h ; индексный порт CMOS or al, 80h ; установка бита 7 в нем запрещает NMI out 70h, al ret ; Разрешает маскируемые и немаскируемые прерывания enable_interrupts: in al, 70h ; индексный порт CMOS and al, 7Fh ; сброс бита 7 отменяет блокирование NMI out 70h, al sti ; разрешить прерывания ret ; Устанавливает флаг PE set_PE: mov eax, cr0 ; прочитать регистр CR0 or al, 1 ; установить бит PE, mov cr0, eax ; с этого момента мы в защищенном режиме ret ; Сбрасывает флаг PE clear_PE: mov eax, cr0 ; прочитать CR0 and al, 0FEh ; сбросить бит PE mov cr0, eax ; с этого момента мы в реальном режиме ret ; Вычисляет линейный адрес начала сгмента кода cs_to_eax: mov eax, 0 mov ax, cs shl eax, 4 ret end start |
[2B ProGroup 1995] Коллектив авторов «2B ProGroup» (Вегнер В.А., Крутяков А.Ю., Серегин В.В., Сидоров В.А., Спесивцев А.В.) «Аппаратура персональных компьютеров и её программирование. IBM PS/XT/AT и PS/2». Москва, Радио и Связь, 1995. Замечательная книжка, посвященная взаимодействию с различной периферией, в частности, очень подробно и понятно описана работа с ПКП i8259A. Большое спасибо за неё Диме Голубеву.
[Intel 2004] «IA-32 Intel® Architecture Software Developer’s Manual Volume 3: System Programming Guide». http://www.intel.com Официальное руководство от Intel.
[Григорьев 1993] В.Л. Григорьев «Микропроцессор i486. Архитектура и программирование (в 4-х книгах). Книга 1. Программная архитектура». Москва 1993.
[Гук 1999] М. Гук «Процессоры Pentium® II, Pentium® Pro и просто Pentium®», издательство «Питер», 1999 (на данный момент существует более новое издание). Энциклопедия, со всеми вытекающими последствиями: информации много и она хорошо структурирована, но изучать предмет по этой книжке не стоит, поберегите нервные клетки и стены, об которые вы будете ими биться. А вот в качестве справочника – вполне.
[Зубков 1999] С.В. Зубков, «Assembler. Для DOS, Windows, Unix», издательство ДМК, 1999 (на данный момент существует более новое издание). Практически всеобъемлющий труд о программировании на ассемблере, с точки зрения программирования в защищённом режиме ценен, в первую очередь, приведёнными примерами. Остальные авторы на работающие примеры значительно более скупы.
[Орловский 1992] Г.В. Орловский «Введение в архитектуру микропроцессора 80386», Санкт-Петербург, 1992. Неплохое описание защищённого режима процессора Intel386. Написано более-менее понятно, хотя и лаконично – никакой воды, никаких примеров…
Broken Sword, рассылка «Процессор Intel в защищённом режиме» http://www.brokensword.narod.ru, копия есть на http://www.wasm.ru. Понятно и подробно, а, кроме того, живо и абсолютно неакадемично. К сожалению, автор прекратил работу над рассылкой, не дойдя даже до середины.