Программирование процессоров Intel x86 в защищённом режиме

Историческая справка

В 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, чем их не устраивала старая архитектура процессора, и что у них получилось в итоге.

Новые мысли

Как уже было сказано, основная мысль – поддержка многозадачных ОС. Чуть более подробно о том, что такое многозадачная ОС, что ей надо, и о чем ещё пришлось побеспокоиться разработчикам.

ПРЕДУПРЕЖДЕНИЕ

Естественно, о том, что приходило в головы разработчиков, и какие задачи ставило перед ними руководство можно судить только по результатам их труда. То есть, грубо говоря, это всё мои домыслы, основанные на косвенных признаках.

К сожалению, даже если принять все «домыслы» за истину и считать, что мы понимаем цели разработчиков, это не поможет вам получить «единую, стройную картину» – система слишком сложна. Но, возможно, вы сможете понять, какие требования предъявлялись к каждой подсистеме процессора, увидеть, как именно эти требования учитывались и, тем самым, вместо «набора необъяснимых фактов» получить «набор объяснимых фактов», что уже гораздо лучше.

Мысль #1: Многозадачная ОС

Основное предназначение многозадачной ОС – позволять одновременное выполнение нескольких пользовательских приложений и не позволять им мешать друг другу. Кроме этого, ОС должна обеспечивать некоторое удобство программирования, иначе она вряд ли будет пользоваться успехом. Разберём по пунктам.

«Позволять одновременное выполнение нескольких пользовательских приложений» это:

«Не позволять им мешать друг другу»:

ПРИМЕЧАНИЕ

Это не паранойя и речь идёт не только и не столько о вирусах и прочих «зловредных» программах. Одна из основных задач изоляции приложений друг от друга – уменьшение влияния программных ошибок на систему. Классический пример: если в ОС оперативная память доступна всем, случайная запись по неверному адресу, произведённая одним приложением, может привести к краху совершенно другого приложения, данные которого оказались испорчены.

Но, очевидно, что сама ОС должна иметь доступ и к портам, и к памяти, и к прерываниям, иначе она просто не сможет работать. Отсюда вытекает:

«Удобство программирования»:

Окончательный список основных требований к ПО, которое, по предположениям разработчиков Intel, будет выполняться на их процессоре (да, ещё раз почти то же самое, но всё вместе и иногда другими словами; повторение – мать учения):

Новая архитектура

Эмерсон, с присущей ему изобретательной памятью, приписал Гёте идею о том, 
что архитектура – это застывшая музыка. Это изречение и наша личная
 неудовлетворённость созданиями современной эпохи внушает нам порой мечту об
 архитектуре, которая, подобно музыке, была бы прямым языком страстей, не
 стесненным требованиями, предъявляемыми к жилью или общественным зданиям.

Хроники Бустоса Домека

Поскольку интересующий нас защищённый режим это только один из блоков архитектуры процессора, а различные «блоки» связаны между собой, перед описанием собственно защищённого режима полезно кратко описать «окружение» в котором находится защищённый режим, «метаархитектуру».

Блок #1: Многозадачные ОС

Именно эта часть называется защищённым режимом (Protected Mode; наконец-то я ввёл термин, который уже столько раз использовал!). Единственное, что стоит сказать об этом блоке в этой главе – он есть. То есть, в архитектуре процессора поддержаны все пункты, перечисленные выше в разделе «Мысль #1: Многозадачная ОС» и реализованы ещё кое-какие детали. Подробностям посвящены все остальные главы.

Как и обещалось в разделе «Обратная совместимость в новом режиме», старые регистры и команды сохранены.

Блок #2: Режим полной совместимости

А это – режим реальной адресации (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. Запрет маскируемых и немаскируемых прерываний (Nonmaskable Interrupt, NMI; что такое NMI в общем-то не важно, но если интересно – смотрите описание в приложении).
  2. Переключение процессора в защищённый режим (установка флага PE).
  3. Ура, мы в защищённом режиме.
  4. Переключение процессора в реальный режим (сброс флага PE).
  5. Разрешение прерываний.

В данном случае, пункты (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.

Hello, world! [Protected Mode version]

ПРЕДУПРЕЖДЕНИЕ

Этот и все последующие примеры должны запускаться только из реального режима! Иначе, в лучшем случае, они не будут работать, в худшем (хотя и очень маловероятном) – снесут то, что работало.

СОВЕТ

При сборке 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.


Рисунок 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.
Таблица 1. Формат дескриптора сегмента кода/данных.

Графически это выглядит так:


Рисунок 2. Формат дескриптора сегмента кода/данных.

И обещанный алгоритм вычисления размера сегмента (на языке «псевдо-С»):

<количество блоков> = <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.
Оставшиеся четыре байта Линейный базовый адрес таблицы дескрипторов.
Таблица 2. Регистр GDTR


Рисунок 3. Регистр GDTR

Для загрузки регистра 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; Дескриптор #0db; Дескриптор #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.
Таблица 3. Формат селектора.


Рисунок 4. Формат селектора

ПРИМЕЧАНИЕ

Как вы помните, максимальное смещение последнего байта, которое можно записать в 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

Селектор, загруженный в SS, должен быть селектором сегмента данных, доступного для чтения/записи.

Регистр CS

Селектор, загруженный в CS, должен быть селектором сегмента кода.

Регистры DS, ES, FS, GS

Селектор, загруженный в любой из этих регистров, должен быть либо селектором сегмента данных, либо селектором сегмента кода, доступного для чтения, либо нулевым селектором.

Всё вместе

Итак, к данному моменту рассмотрены все основные понятия и артефакты, относящиеся к сегментации, осталось выяснить только две вопроса:

Получение линейного адреса

Иголка в яйце, яйцо в утке, утка в зайце..

К. Бессмертный, «Современные системы безопасности», V в. н.э.

Для начала рассмотрим «концептуально чистый» случай. Пусть на входе логический адрес в виде пары <селектор>:<смещение>, а на выходе нужно получить соответствующий линейный адрес. Алгоритм работы процессора:

  1. Если хранящийся в <селекторе> индекс дескриптора выходит за границы GDT (определяются по содержимому GDTR), выбрасывается исключение.
  2. По адресу начала GDT (расположен в GDTR) и индексу дескриптора определяется адрес начала дескриптора, дескриптор считывается.
  3. Анализируется дескриптор и характер запроса с принципиальной точки зрения. На этом этапе отсекаются попытки использовать дескриптор способами, для которых он не предназначен, например, исполнять сегмент данных или читать/писать нечитаемый сегмент кода. Если проверка не проходится, генерируется исключение. Это уровень загрузки селектора в сегментный регистр: пока что не важно, как именно будет применяться этот регистр, есть только имя регистра и селектор.
  4. Анализируется дескриптор и характер запроса с конкретной точки зрения. Если по каким-то причинам обращение невозможно (например, попытка записи в Read-Only сегмент или выход <смещения> за пределы сегмента) генерируется исключение.
  5. Из дескриптора извлекается базовый адрес сегмента и складывается со <смещением>.

Нарисовать это можно так:


Рисунок 5. Алгоритм получения линейного адреса.

Более «правильный» алгоритм должен учитывать то, что селекторы «в свободном виде» встречаются только в командах загрузки адреса (lds, les, lfs, lgs, lss) и дальнего перехода, в остальных же случаях используется селектор, загруженный в сегментный регистр. А, поскольку во время загрузки сегментного регистра процессор уже проверил корректность селектора, нашёл соответствующий дескриптор и сохранил его в скрытой части сегментного регистра, то есть, фактически выполнил шаги (1), (2) и (3), алгоритм можно начинать сразу с (4).

ПРИМЕЧАНИЕ

При изучении схемы получения линейного адреса и сравнении её с «целями», описанными в первой главе, возникают естественные вопросы: Где же здесь раздельные адресные пространства? Где отделение кода/данных ОС от кода/данных пользователя? Да, пользователь не может обращаться куда угодно, но всё, что есть в GDT – в его распоряжении!

Вопросы хорошие, полные ответы на них можно получить, изучив курс полностью, но чтобы вы не мучались до последней главы, кратко отвечу сейчас.

Для разделения адресных пространств пользовательских приложений Intel предлагает использовать два механизма: локальную таблицу дескрипторов (Local Descriptor Table, LDT) и страничную адресацию. LDT не очень интересна и в курсе не рассматривается, а вот страничная адресация обязательно будет описана, но позже, в соответствующей главе.

Отделение кода/данных ОС от кода/данных пользователя реализуется при помощи одного из «пока неважных» полей дескриптора. Это поле и предоставляемые им возможности подробно рассмотрены в главе «Теоретическое введение в защиту».

Переключение режимов: инициализация системы управления памятью

С учётом существования дескрипторов сегментов и GDT, можно усложнить процедуру переключения режимов, немного приближаясь к эталону и, соответственно, получая больше возможностей.

Переключение из реального режима в защищённый:

  1. Запретить маскируемые и немаскируемые прерывания.
  2. Инициализировать GDT и загрузить её адрес в GDTR.
  3. Установить флаг PE (младший бит регистра CR0).
  4. Выполнить дальний переход (jmp или call) для перезагрузки регистра CS.
  5. Перезагрузить все сегментные регистры.

Обратное переключение:

  1. Сделать текущим сегментом кода доступный для чтения сегмент с пределом FFFFh байт.
  2. Загрузить во все сегментные регистры селекторы дескрипторов доступных для записи сегментов данных с пределом FFFFh.
  3. Сбросить флаг PE
  4. Выполнить дальний переход (jmp или call) для перезагрузки регистра CS.
  5. Перезагрузить сегментные регистры.
  6. Разрешить прерывания.

Приведённые ниже примеры выполняют эти требования по частям: первый пример демонстрирует работу с сегментом данных, второй – с сегментом кода.

Вентиль A20

Как известно, процессор 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

Примеры

Сегмент данных размером 4 Гб

Простая программка, переводящая процессор в защищенный режим, загружающая 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 будет сохранено в стеке, а при возврате из прерывания оно будет интерпретироваться уже не как селектор, а как адрес сегмента. И, естественно, возврат произойдёт не туда, куда хотелось бы.

[Пафосный вариант] Сегмент кода 4 Гб

Ограничение на размер сегмента данных уже успешно преодолено, осталось повторить это для сегмента кода.

ПРИМЕЧАНИЕ

Совместив эту программу с предыдущей, то есть, установив всем сегментам кода/данных базовый адрес в 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 

[Практичный вариант] Сегмент кода 64 Кб

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; Дескриптор #0db; Дескриптор #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.
Таблица 1. Формат дескриптора шлюза ловушки.

То же самое на картинке:


Рисунок 1. Формат дескриптора шлюза ловушки.

Основная информация, которую несёт в себе дескриптор шлюза ловушки, это:

Поскольку формат практически прозрачен, остаётся только привести пример:

    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» и уточняет тип дескриптора, но при этом используются разные подходы:

Сегмента/шлюза

Второй признак классификации – характер сущности, описываемой дескриптором. Это может быть:

Дескрипторы, описывающие похожие сущности, имеют близкие форматы. Так, любой дескриптор сегмента по формату похож на дескриптор сегмента кода/данных, а любой дескриптор шлюза – на дескриптор шлюза ловушки.

Всё вместе

Объединим всё, уже известное про прерывания и добавим недостающие детали.

Вызов обработчика прерывания

Целью всего механизма обработки прерываний является своевременный вызов обработчика. В нашем случае (остальные случаи – в остальных главах, описывать всё сразу совершенно ни к чему) вызов происходит примерно так:

  1. По номеру прерывания в IDT отыскивается нужный дескриптор, из него извлекается селектор сегмента и смещение начала обработчика.
  2. Проверяется корректность селектора (указывает на дескриптор в пределах GDT, это дескриптор сегмента кода, и т.п.) и смещения (попадает в сегмент).
  3. В стеке сохраняются регистр EFLAGS и дальний указатель на команду, следующую за int x, то есть регистр CS и значение регистра EIP, увеличенное на размер команды int x. На каждый из регистров в стеке выделяется по 4 байта, рядом с CS находятся два «лишних» байта.
  4. CS и EIP загружаются новыми значениями.
  5. Начинается выполнение обработчика. Дескриптор шлюза ловушки не предназначен для обработки внешних (аппаратных) прерываний, поэтому аппаратные прерывания не маскируются автоматически при входе в обработчик.
  6. После своего завершения обработчик возвращает управление командой iretd. В отличии от iret, она предназначена для случая, когда в стеке сохранены 32-х разрядные EIP и EFLAGS, а не 16-ти разрядные IP и FLAGS (подробнее про 16-ти и 32-х разрядность см. в приложении).
  7. Возврат к исходной программе. В регистры EFLAGS, CS и EIP загружается значение из стека, при этом происходит обращение к GDT и проверка корректности устанавливаемого селектора и смещения.
ПРЕДУПРЕЖДЕНИЕ

Ещё раз обращаю ваше внимание: независимо от префиксов команд, разрядности сегмента стека, разрядности целевого и исходного сегмента кода и т.п. (всё это будет обсуждаться в приложении в разделе про разрядность), под EFLAGS, CS, EIP в стеке выделяется 12 байт, по 4 байта на каждый регистр.

Упрощённая версия алгоритма в виде комикса:


Рисунок 2. Немного упрощенный алгоритм вызова обработчика прерывания.

Ключевые признаки, отделяющие «наш» случай от «не нашего»:

Переключение режимов: инициализация IDT

Добавим к алгоритмам переключения ещё несколько шагов. Из реального режима в защищённый:

  1. Запретить маскируемые и немаскируемые прерывания.
  2. Сохранить значение IDTR для реального режима.
  3. Инициализировать IDT и загрузить её адрес в IDTR.
  4. Инициализировать GDT и загрузить её адрес в GDTR.
  5. Установить флаг PE (младший бит регистра CR0).
  6. Выполнить дальний переход (jmp или call). Это нужно для перезагрузки регистра CS.
  7. Перезагрузить все сегментные регистры.
ПРЕДУПРЕЖДЕНИЕ

В данном случае нельзя использовать CS, оставшийся «в наследство» от реального режима, так как при возврате из обработчика прерывания сохранённое в стеке значение CS будет толковаться как селектор сегмента.

ПРИМЕЧАНИЕ

Поскольку обрабатывать аппаратные прерывания мы пока не планируем, разрешать прерывания будет преждевременно.

Обратное переключение:

  1. Сделать текущим сегментом кода какой-либо доступный для чтения сегмент с пределом FFFFh байт.
  2. Загрузить во все сегментные регистры селекторы дескрипторов доступных для записи сегментов данных с пределом FFFFh.
  3. Сбросить флаг PE
  4. Выполнить дальний переход (jmp или call).
  5. Перезагрузить сегментные регистры.
  6. Восстановить в IDTR значение для реального режима.
  7. Разрешить прерывания.

Сохранение значения IDTR

Для этого предназначена команда sidt.

         sidt	pointer_to_idtr

Пример использования:

; Сохраняем IDTR
         sidt    fword ptr old_idtr
         …

old_idtr table_register <>

Пример

Обработчик int 0

Практически минимальная программа, устанавливающая обработчик нулевого прерывания и вызывающая его командой 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). При этом аппаратные прерывания относят к отдельному классу (они будут обсуждаться в следующей главе), а всё остальное (обычно) называют исключениями.

Основы обработки исключений

К «основам» я отнёс то, что объединяет все типы исключений: базовую терминологию и общие сведения о регистрации/обработке.

ПРИМЕЧАНИЕ

Поскольку, с точки зрения поведения процессора, программные прерывания можно рассматривать как один из подтипов исключений, эти «сведения» в основном являются ссылками на главу «Программные прерывания».

Терминология Intel

Исключение (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. «Страничная ошибка». Запомните его! Это исключение является одной из основ реализации виртуальной памяти, подробнее – в главе «Страничная адресация».
Таблица 1. Описание избранных исключений.

Обработчики

Обработчик исключения очень похож на обработчик прерывания: более-менее обычная подпрограмма, заканчивающаяся вызовом iretd (скорее всего, действия, выполняемые обработчиками, должны отличаться по смыслу, но сейчас это нас не касается). Некоторые отличия между обработчиками связаны с тем, что процессор их немного по-разному вызывает. К началу работы обработчика прерывания на вершине стека находится содержимое регистров CS, EIP и EFLAGS (по 4 байта на регистр, и того 12 байт). Это относится и к обработчикам исключений, но:

Ещё одно отличие связано с тем, что возникновение программного прерывания, обычно, планируется программистом заранее, а возникновение исключения, обычно, нет. Поэтому «обычный» обработчик исключения не предполагает, что в регистрах программист передал ему какие-то дополнительные параметры. Но, конечно, никто не мешает вам написать «необычный» обработчик и использовать его «необычным» образом.

Классификация исключений

Есть люди у которых обращаются на Вы,
Есть люди у которых сто четыре головы,
Есть загадочные девушки с магнитными глазами,
Есть большие пассажиры мандариновой травы...  

Борис Гребенщиков

Выше, в разделе «Обработчики» описаны основные отличия обработчика исключения от обработчика программного прерывания. С технической точки зрения, именно они и являются основой классификации. Правда, к первому «отличию» (значение адреса возврата в стеке) мы подойдём с неожиданной стороны, так как оно является следствием общего признака классификации: существования трёх типов исключений

ПРИМЕЧАНИЕ

Названия типов исключений разные авторы переводят по-своему, поэтому, чтобы не добавлять сумятицы, я буду пользоваться английскими терминами.

Fault

ПРИМЕЧАНИЕ

В [Зубков 1999] и [Орловский 1992] переведено как «ошибка», в [Гук 1999] как «отказ», в [Григорьев 1993] – «нарушение».

Fault-исключение генерируется процессором до начала выполнения команды. Поэтому:

После возврата из fault-исключения (если обработчик не поменяет в стеке адрес возврата) процессор попытается ещё раз выполнить ту же самую команду. Это наиболее часто используемый вид исключений, практически в любой ошибочной ситуации процессор с неизменным оптимизмом предлагает «попробовать ещё разик».

Trap

ПРИМЕЧАНИЕ

Везде переведено как «ловушка». Сговорились…

Trap-исключение генерируется процессором сразу после исполнения команды. Соответственно:

К типу trap (на данный момент) относятся только исключения, вызванные успешно исполнившимися командами, например int 3 или into. Сюда же можно отнести и int n, то есть все программные прерывания. По каким-то причинам (видимо, особенности реализации) bound генерирует fault-исключение.

Abort

ПРИМЕЧАНИЕ

В [Зубков 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 Есть
Таблица 2. Классификация избранных исключений.
ПРИМЕЧАНИЕ

Единственный случай, когда классификация исключений по типам неоднозначна это исключение #DB. В зависимости от того, какое из установленных условий отладки сработало, оно может стать trap- или fault-исключением.

Всё вместе

- Каждому исключению – уважительное отношение и полноценную обработку!
- Ура!!!
... 
Из популистского выступления разработчиков ОС
на собрании процессоров.

Комбинируя признаки классификации, можно насчитать шесть типов исключений:

Все они вызываются немножко по-разному, и все их можно было бы рассмотреть. Но, поскольку:

Рассмотрены только fault- и abort-исключения. Оговорки те же, что и в случае программных прерываний:

Fault

Примерно так:

  1. По номеру исключения в IDT отыскивается нужный дескриптор, из него извлекается селектор сегмента и смещение начала обработчика.
  2. Проверяется корректность селектора (указывает на дескриптор в пределах GDT, это дескриптор сегмента кода, и т.п.) и смещения (попадает в сегмент).
  3. В стеке сохраняются регистр EFLAGS и дальний указатель на команду, вызвавшую исключение.
  4. [Для исключений с кодом ошибки] В стеке сохраняется код ошибки.
  5. CS и EIP загружаются новыми значениями.
  6. Начинается выполнение обработчика.
  7. [Для исключений с кодом ошибки] Во время выполнения обработчик обязан вытолкнуть из стека код ошибки, иначе корректная передача управления обратно будет невозможна.
  8. После своего завершения обработчик возвращает управление командой iretd.
  9. Возврат к исходной программе. В регистры EFLAGS, CS и EIP загружается значение из стека, при этом происходит обращение к GDT и проверка корректности устанавливаемого селектора и смещения.
ПРИМЕЧАНИЕ

В отличие от обработчиков программных прерываний, которые (обычно) послушно возвращают управление по адресу, сохранённому процессором в стеке, обработчики fault-исключений поступают таким образом гораздо реже, поскольку часто это приведёт только к повторной генерации того же самого исключения и ещё одному вызову того же самого обработчика. Поэтому пункты 8-9 могут либо не выполняться вообще, либо выполняться не так. Подробнее на ту тему см. ниже в разделе «Случилось страшное. Ну и?.. - II».

И на картинке, на примере исключения #GP:


Рисунок 1. Алгоритм вызова обработчика fault-исключения на примере General Protection.

Abort

Так как корректный возврат даже и не планируется, алгоритм упрощается:

  1. По номеру исключения в IDT отыскивается нужный дескриптор, из него извлекается селектор сегмента и смещение начала обработчика.
  2. Проверяется корректность селектора (указывает на дескриптор в пределах GDT, это дескриптор сегмента кода, и т.п.) и смещения (попадает в сегмент).
  3. В стеке сохраняется EFLAGS и какие-то значения на месте адреса возврата.
  4. [Для исключений с кодом ошибки] В стеке сохраняется код ошибки.
  5. CS и EIP загружаются новыми значениями.
  6. Начинается выполнение обработчика.

Соответствующая картинка:


Рисунок 2. Алгоритм вызова обработчика abort-исключения на примере Double Fault.

Случилось страшное. Ну, и?.. - II

Итак, процессор обнаружил ошибку, сгенерировал исключение, нашёл в IDT адрес обработчика и передал ему управление… ну и что с ним делать дальше? Допустим, понятно, как обрабатывать исключения, не являющиеся ошибками (в списке это #DB, #BP и #PF, соответственно нужно либо передать управление отладчику, либо подкачивать в память требуемую страницу), но что делать с остальными? Мы уже выяснили, что процессор «сам» не в состоянии справиться с обработкой и просит помощи у ОС, но ведь и ОС тоже далеко не всегда может предложить что-то разумное (например, что вы будете делать, если произошло исключение #DE?). Однако у ОС есть преимущества:

ПРИМЕЧАНИЕ

Наиболее интересен, конечно, первый вариант, и именно он реализован в большинстве современных ОС. В соответствии со стандартом POSIX, реализация пользовательских обработчиков исключений основана на сигналах (signal; есть такое понятие в UNIX-ах), Microsoft придумала свой подход – структурную обработку исключений (Structured Exception Handling, SEH). Рассмотрение использования и реализации сигналов и SEH выходит за рамки курса.

Программные прерывания vs. Исключения

Остерегайтесь подделок!

Поскольку обработчики исключений регистрируются в той же таблице и по тем же правилам, что и обработчики программных прерываний, выполнение команды 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

Программа устанавливает обработчик исключения #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.
Таблица 1. Формат дескриптора шлюза прерывания.

То же самое на картинке:


Рисунок 1. Формат дескриптора шлюза прерывания.

И, конечно, пример:

    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 (более подробно он описан ниже, в соответствующем разделе). Не фиксируйтесь на деталях, сначала нужно представить себе круг задач, для которых предназначен любой ПКП, детали здесь приведены для того, чтобы описание не стало чересчур абстрактным.


Рисунок 2. Модель реализации внешних прерываний

Последовательность действий при обработке прерывания:

  1. Устройство посылает запрос на прерывание (Interrupt Request, IRQ) на вход irX ПКП.
  2. ПКП заносит прерывание в список «ожидающие обработки».
  3. Если это прерывание не замаскировано, и в списке «обрабатываемые в данный момент» нет прерываний с большим или равным приоритетом, ПКП устанавливает выходной сигнал int, который соединён с входом процессора intr (судя по всему, опять Interrupt Request).
  4. Если прерывания разрешены (флаг IF установлен), процессор периодически проверяет значение intr. Обнаружив запрос на прерывание, процессор отвечает установкой сигнала inta (Interrupt acknowledge).
  5. ПКП переводит прерывание из списка «ожидающие обработки» в список «обрабатываемые в данный момент» и выставляет на шину D соответствующий номер прерывания.
  6. Процессор считывает с шины D номер прерывания, находит по IDT обработчик, прерывает текущий поток исполнения, вызывает обработчик прерывания.
  7. Выполняется обработчик, что именно он делает – зависит от него, но, как минимум, он должен сообщить ПКП, что прерывание обработано.
  8. (Предполагаем, что обработчик написан корректно) ПКП удаляет прерывание из списка «обрабатываемые в данный момент». До тех пор, пока это не сделано, блокируется обработка прерываний с таким же и более низким приоритетом (см. пункт 3 этого же алгоритма), то есть некорректный обработчик может нарушить всю систему обработки прерываний.
  9. Управление возвращается к прерванной программе.

В результате, ПКП отвечает за:

И всем этим значительно облегчает работу процессора.

Внутри процессора

После того, как ПКП передал процессору номер прерывания, в обработку включается собственно процессор. Тут всё как всегда, с минимальными отличиями:

  1. По номеру прерывания в IDT отыскивается нужный дескриптор, из него извлекается селектор сегмента и смещение начала обработчика.
  2. Проверяется корректность селектора (указывает на дескриптор в пределах GDT, это дескриптор сегмента кода, и т.п.) и смещения (попадает в сегмент).
  3. В стеке сохраняются регистр EFLAGS и текущее значение регистров CS и EIP.
  4. Сбрасывается флаг IF, маскируя внешние прерывания (если в IDT дескриптор шлюза прерывания, а не ловушки), CS и EIP загружаются новыми значениями.
  5. Начинается выполнение обработчика.
  6. После своего завершения обработчик сообщает ПКП, что прерывание обработано и возвращает управление командой iretd.
  7. Возврат к прерванной программе. В регистры EFLAGS, CS и EIP загружается значение из стека (помимо прочего, флагу IF возвращается значение, которое у него было до вызова прерывания), при этом происходит обращение к GDT и проверка корректности устанавливаемого селектора и смещения.

На картинке:


Рисунок 3. Обработка внешнего прерывания

Естественно, при желании, обработчик может возвращать управление вовсе не туда, откуда он был вызван. На этом основана реализация многозадачности.

Программируемый контроллер прерываний Intel x86

Когда вошёл контролёр, 
Скорость перевалила за сто.
Он даже не стал проверять билеты,
Он лишь попросил снять пальто.

Борис Гребенщиков

В ранних моделях x86 ПКП действительно был устроен так, как написано ниже. Но, начиная с Pentium, Intel озаботилась возможностью установки нескольких процессоров на одну материнскую плату, в связи с чем ПКП был основательно переработан. Тем не менее, из соображений обратной совместимости, с ним можно работать так же, как и раньше. Возможно, это не лучший вариант, но зато самый простой и наиболее полно описанный в литературе. Итак, перенесёмся в далёкие 80-е. Представьте: Гребенщиков молодой и худенький, Цой жив и только-только записал «45», а Intel использует для реализации ПКП микросхему i8259A.

ПКП 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 в x86

Всего восемь устройств – маловато для серьёзной работы. Разработчики i8259A это понимали, поэтому предусмотрели возможность объединения нескольких микросхем в один ПКП. Грубо говоря, для этого нужно выход int одной микросхемы i8259A подать на вход irX другой, после чего они смогут работать «почти как одна», это и называется каскадным соединением.

ПРИМЕЧАНИЕ

Для того чтобы говорить «не грубо», надо описать, что происходит с сигналами inta и D. Ну да, с ними действительно что-то происходит, в этом процессе задействовано ещё несколько сигналов, но, к счастью, это абсолютно не влияет на программную архитектуру, поэтому не должно вас интересовать. Хуже того, поскольку в современных ПКП микросхемы i8259A уже давно не используются, это неважно вообще.

Таким способом можно соединить до девяти микросхем (одна ведущая и восемь ведомых, многоуровневое каскадирование не поддерживается), получив возможность работать с 64-мя устройствами. При этом на уровне сигналов int/intr/inta/D достигается полная прозрачность, то есть, с точки зрения «аппаратного уровня» ничего не меняется. Но на «программном уровне» появляются отличия: каждый i8259A имеет свои собственные управляющие регистры, их надо отдельно инициализировать (ведущий и ведомые по-разному) и ими надо по отдельности командовать.

Но это теория. Практически, в компьютерах на основе 80286 используется две микросхемы i8259A, ведомая подключена к входу ir2 ведущей. Схема показана на Рисунке 4, на нём же отображено соответствие irX – IRQX и несколько наиболее полезных прерываний (при желании вы можете найти «классический» список внешних прерываний в любой книжке по ассемблеру, например в [Зубков 1999]).


Рисунок 4. Каскадное соединение i8259A в x86, IRQ, наиболее интересные прерывания

Что ещё можно сказать про ПКП:

И, наконец, правильная инициализация. Для ведущего:

        out 20h, 00010001b 
        out 21h, <номер обработчика для ir0> ; должен быть кратен восьми
        out 21h, 00000100b ; битовая маска, единицей отмечены входы,
                           ; к которым подключены не обычные устройства, 
                           ; а ведомые ПКП. 
        out 21h, 00000001b 

Для ведомого:

        out A0h, 00010001b 
        out A1h, <номер обработчика для ir0> ; должен быть кратен восьми
        out A1h, 2 ; номер ведомого ПКП, совпадает с номером входа 
                   ; ведущего ПКП, к которому подключен ведомый ПКП
        out A1h, 00000001b

Программные прерывания vs. Внешние

Точно так же, как и в случае с обработчиком исключения, непредсказуемый пользователь может попытаться вызвать через 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

Обработчик прерывания RTC

Программа последовательно выполняет следующие действия:

Код написан на основе примера 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, больше нигде не упоминается, подробно рассмотрен в приложении.
Таблица 1. Формат пятого байта дескриптора.

На картинке:


Рисунок 1. Формат пятого байта дескриптора.

С точки зрения защиты, ключевое поле – DPL, Descriptor Privilege Level. Название переводится как «уровень привилегий дескриптора», и очень точно отражает предназначение поля.

Уровни привилегий

Царь-царевич, 
Король-королевич, 
Сапожник, портной. 
Кто ты будешь такой?

Считалка

Процессоры Intel x86 поддерживают четыре уровня привилегий.

ПРЕДУПРЕЖДЕНИЕ

Обратите внимание: меньшее численное значение соответствует большему уровню привилегий. Во избежание двусмысленности, в скобках будет уточняться, что имеется в виду. Например: «… минимальный (численно максимальный) уровень ..».

Под разными названиями, поле, содержащее уровень привилегий, встречается в нескольких системных структурах. Смыслы тоже разные, хотя и близкие.

DPL

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] утверждается обратное, это ошибка автора).

Поскольку никакие другие ресурсы (на данный момент из всех «других» описаны только сегменты кода/данных) не могут быть использованы подобным «самопроизвольным» образом, к ним это замечание не относится.

Исключение: дескриптор сегмента данных с регистром SS

Сначала рассмотрим ситуацию (ситуация предполагает периодическое изменение привилегий, сейчас нам не важно как это происходит). Пусть имеется:

Два варианта развития событий:

Для предотвращения таких ситуаций системный код должен использовать свой сегмент стека. А, видимо, для повышения мотивации использования разных стеков, разработчики Intel просто запретили использовать один и тот же: при загрузке селектора в регистр SS, текущий уровень привилегий должен точно совпадать с DPL дескриптора сегмента.

Подробнее о переключении стеков при смене уровней привилегий написано в главе «Защита: передача управления».

CPL

Текущий уровень привилегий (Current Privilege Level, CPL) – уровень привилегий исполняемого в данный момент кода, или, другими словами, уровень привилегий, на котором в данный момент находится процессор. Обычно CPL совпадает со значением двух младших битов регистра CS.


Рисунок 2. Регистр CS и CPL.

Именно CPL определяет, что разрешено, а что запрещено, именно он проверяется при попытке выполнения привилегированных команд (см. ниже), сравнивается с DPL ресурса (см. выше) и с IOPL (см. ниже). В общем, несколько упрощая, при нулевом CPL можно сделать с системой вообще всё, в любом другом случае придётся спрашивать разрешения.

ПРИМЕЧАНИЕ

Защита многослойна, нулевой уровень привилегий разрешает «вообще всё» только с точки зрения доступа к любым ресурсам независимо от их DPL (а со стеками и это не получится, см. выше), к любым портам ввода-вывода и к любым инструкциям. А, например, обращаться за пределы сегмента, менять read-only сегменты и передавать исполнение сегментам данных запрещёно независимо от текущего уровня привилегий.

Но, если очень нужно, имея нулевой CPL, можно определить свою собственную GDT и описать там те дескрипторы, которые хочется, обходя таким образом практически любые механизмы защиты.

Значение CPL определяется в соответствии с двумя простыми правилами (подчинённые сегменты кода добавят третье, но пока – два):

Изменение CPL тоже рассмотрено в главе «Защита: передача управления».

RPL

Два младших бита «нормального» селектора (не загруженного в CS) называются уровень привилегий запроса (Request Privilege Level, RPL), и примерно в 99% ситуаций, единственно разумным значением этого поля будет 0. Тем не менее, поле придумано разработчиками не совсем напрасно, и ситуация, когда ненулевой RPL имеет смысл, всё-таки существует.


Рисунок 3. Селектор и RPL

Ситуация

Рассмотрим следующий сценарий:

Теперь добавим уровни привилегий:

Поскольку «F» выполняется с нулевым уровнем привилегий, она может читать/записывать данные независимо от значения DPL сегмента SEG. Может быть, это как раз то поведение, которое требуется, но может быть, нужно ограничить возможности работы только теми сегментами, которые доступны «A». И вот тут-то на помощь приходит RPL! С ним сценарий выглядит так:

ПРИМЕЧАНИЕ

Это близко к понятию имперсонализации (impersonation), отличие в том, что с помощью RPL невозможно увеличить свои права, только уменьшить.

Строгое описание

Описанное выше применение RPL сводится к следующему правилу: при загрузке селектора сегмента в сегментный дескриптор, система защиты сравнивает c DPL сегмента не только CPL, но и RPL селектора.

Это единственный разумный случай использования RPL. К сожалению, есть ещё несколько не очень разумных, в которых значение RPL всё-таки играет роль:

IOPL

Уровень привилегий ввода-вывода (Input-Output Privilege Level, IOPL). Это поле описано в главе «Защита: ввод-вывод», а здесь упомянуто для комплекта.

VIP only

Следующие инструкции в защищённом режиме можно выполнять только при нулевом CPL (в реальном режиме таких проблем нет, можно всем, всё и всегда):

Эти инструкции называются привилегированными (privileged instructions), попытка выполнить их при ненулевом CPL приведёт к генерации исключения #GP.

ПРИМЕЧАНИЕ

Обратите внимание: cli, sti, in, out – не привилегированные инструкции, контроль за их исполнением осуществляется иначе, более гибко. Это тема главы «Защита: ввод-вывод».

Итого

Если вернуться к началу главы, к разделу «Насущные проблемы», и вспомнить, чего требовалось достичь, то окажется, что практически всё уже успешно достигнуто. Список результатов приведён в Таблице 2.

Проблема Решение
Отличать пользовательское приложение от ОС Динамически – CPL, статически – DPL дескриптора сегмента кода.
Запретить для пользовательских приложений потенциально опасные инструкции. Привилегированные инструкции
Разделять ресурсы, к которым пользователю обращаться можно, и ресурсы, к которым пользователю обращаться нельзя DPL дескриптора ресурса, CPL
Порты ввода-вывода. CPL, IOPL, подробнее – в главе «Защита: ввод вывод».
Таблица 2. Проблемы и решения

Для сегментов кода предложенные решения порождают проблему следующего уровня: отделять мы уже умеем, теперь надо как-то наладить связь между кодом с разными уровнями привилегий. Этому вопросу посвящена глава «Защита: передача управления».

А с сегментами данных новых проблем нет, получена логичная законченная система проверок, даже две – для обычного сегментного регистра и для SS. В качестве некоторого подведения итогов, ниже они приведены полностью.

Дескриптор сегмента данных, регистр не SS

При загрузке селектора в регистр:

  1. Если селектор нулевой, он просто загружается, никаких проверок не проводится.
  2. Если находящийся в селекторе индекс дескриптора выходит за границы GDT, генерируется исключение #GP.
  3. Анализируется тип дескриптора. Флаг S должен быть установлен в 1, дескриптор должен описывать либо сегмент данных, либо доступный для чтения сегмент кода, иначе генерируется исключение #GP.
  4. Анализируется поле DPL дескриптора. Должны выполняться неравенства DPL >= CPL и DPL >= RPL селектора (для подчинённых сегментов кода этот пункт будет отличаться), иначе генерируется исключение #GP.
  5. Анализируется флаг P дескриптора, он должен быть установлен в 1, подробнее – см. приложение.

При выполнении операции:

  1. Если в регистре нулевой селектор, исключение #GP.
  2. Если попытка записи в read-only сегмент, исключение #GP.
  3. Если адрес выходит за границы сегмента, исключение #GP.

Дескриптор сегмента данных, регистр SS

При загрузке селектора в регистр:

  1. Если селектор нулевой, генерируется исключение #GP.
  2. Если находящийся в селекторе индекс дескриптора выходит за границы GDT, генерируется исключение #GP.
  3. Анализируется тип дескриптора. Дескриптор должен описывать доступный для записи сегмент данных, иначе генерируется исключение #GP.
  4. Анализируется поле DPL дескриптора. Должны выполняться равенства DPL == CPL и DPL == RPL селектора, иначе генерируется исключение #GP.
  5. Анализируется флаг P дескриптора, он должен быть установлен в 1, подробнее – см. приложение.

При выполнении операции – то же, что и в случае обычного сегментного регистра, отличие только в том, что две первые проверки никогда не сработают: в 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.
Таблица 1. Формат дескриптора сегмента состояния задачи.

На картинке это выглядит так:


Рисунок 1. Формат дескриптора сегмента состояния задачи.

В качестве примера – дескриптор сегмента состояния задачи, размер сегмента 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. Понятно и подробно, а, кроме того, живо и абсолютно неакадемично. К сожалению, автор прекратил работу над рассылкой, не дойдя даже до середины.