В предыдущем разделе, занимаясь программированием для Windows, мы уже обращались к процедурам, написанным на языке высокого уровня из программ на ассемблере, и создавали процедуры на ассемблере, к которым можно обращаться из языков высокого уровня. Для этого нужно было соблюдать определенные договоренности о передаче параметров — параметры помещались в стек справа налево, результат возвращался в ЕАХ, стек освобождался от переданных параметров самой процедурой. Эта договоренность, известная как STDCALL, конечно, не единственная, и разные языки высокого уровня используют разнообразные способы передачи параметров.
Компиляторы Microsoft С (а также многие компиляторы в UNIX, как мы увидим далее) изменяют названия процедур, чтобы отразить используемый способ передачи параметров. Так, к названиям всех процедур, использующих С-конвенцию, приписывается символ подчеркивания. То есть, если в С-программе записано
some_proc();
то реально компилятор пишет
call _some_proc
и это означает, что, если эта процедура написана на ассемблере, она должна называться именно _some_proc (или использовать сложную форму записи директивы proc).
Названия процедур, использующих STDCALL, как можно было видеть из примера DLL-программы в разделе 7.4, искажаются еще более сложным образом: спереди к называнию процедуры добавляется символ подчеркивания, а сзади — символ @ и размер занимаемой параметрами области стека в байтах, (то есть в точности число, стоящее после команды ret в конце процедуры).
some_proc(a:word);
превращается в
push a call _some_proc@4
Самый очевидный способ выражения вызова процедуры или функции языка высокого уровня, после того как решено, что параметры передаются в стеке и возвращаются в регистре АХ/ЕАХ, — это способ, принятый в языке PASCAL (а также в BASIC, FORTRAN, ADA, OBERON, MODULA2), — просто поместить параметры в стек в естественном порядке. В этом случае запись
some_proc(a,b,c,d,e)
превращается в
push a push b push с push d push e call some_proc
Это значит, что процедура some_proc, во-первых, должна очистить стек по окончании работы (например, завершившись командой ret 10) и, во-вторых, параметры, переданные ей, находятся в стеке в обратном порядке:
some_proc proc push bp mov bp,sp ; создать стековый кадр a equ [bp+12] ; определения для простого ; доступа к параметрам b equ [bp+10] c equ [bp+8] d equ [bp+6] e equ [bp+4]
; текст процедуры, использующей параметры а, Ь, с, d, e
ret 10 some_proc endp
Этот код в точности соответствует усложненной форме директивы proc, которую поддерживают все современные ассемблеры:
some_proc proc PASCAL,а:word,b:word,с:word,d:word,e:word
; текст процедуры, использующей параметры а, Ь, с, d, e. ; Так как ВР используется в качестве указателя стекового кадра, ; его использовать нельзя!
ret ; эта команда RET будет заменена на RET 10 some_proc endp
Главный недостаток этого подхода — сложность создания функции с изменяемым числом параметров, аналогичных функции языка С printf. Чтобы определить число параметров, переданных printf, процедура должна сначала прочитать первый параметр, но она не знает его расположения в стеке. Эту проблему решает подход, используемый в С, где параметры передаются в обратном порядке.
Этот способ передачи параметров используется в первую очередь в языках С и C++, а также в PROLOG и других. Параметры помещаются в стек в обратном порядке, и, в противоположность PASCAL-конвенции, удаление параметров из стека выполняет вызывающая процедура. Запись
some_proc(a,b,c,d,e)
превращается в
push e push d push с push b push a call some_proc add sp,10 ; освободить стек
Вызванная таким образом процедура может инициализироваться так: some_proc proc push bp mov bp,sp ; создать стековый кадр a equ [bp+4] ; определения для простого доступа к параметрам b equ [bp+6] с equ [bp+8] d equ [bp+10] e equ [bp+12]
; текст процедуры, использующей параметры a, b, с, d, e
pop bp ret some_proc endp
Ассемблеры поддерживают и такой формат вызова при помощи усложненной формы директивы proc с указанием языка С:
some_proc proc С,а:word,b:word,с:word,d:word,e:word
; текст процедуры, использующей параметры a, b, с, d, e. ; Так как BP применяется как указатель стекового кадра, ; его использовать нельзя!
ret some_proc endp
Мы не пользовались до сих пор этими формами записи процедур в ассемблере потому, что они скрывают от нас тот факт, что регистр ВР используется для хранения параметров и его ни в коем случае нельзя изменять, и, в случае PASCAL, что команда ret на самом деле — команда ret N.
Преимущество по сравнению с PASCAL-конвенцией заключается в том, что освобождение стека от параметров в С возлагается на вызывающую процедуру, что позволяет лучше оптимизировать код программы. Например, если мы должны вызвать несколько функций, принимающих одни и те же параметры подряд, можно не заполнять стек каждый раз заново:
push param2 push param1 call proc1 call proc2 add sp,4
эквивалентно
proc1(param1,param2); proc2(param1,param2);
и это — одна из причин, почему компиляторы с языка С создают более компактный и быстрый код по сравнению с компиляторами с других языков.
Большинство языков высокого уровня передают параметры вызываемой процедуре в стеке и ожидают возвращения параметров в регистре АХ (ЕАХ) (иногда используется DX:AX (EDX:EAX), если результат не умещается в одном регистре, и ST(0), если результат число с плавающей запятой).
В главе 7 мы встречались с договоренностью о передаче параметров STDCALL, отличавшейся и от С, и от PASCAL-конвенций, которая применяется для всех системных функций Win32 API. Здесь параметры помещаются в стек в обратном порядке, как в С, но процедуры должны очищать стек сами, как в PASCAL.
Еще одно интересное отклонение от С-конвенции можно наблюдать в Watcom С. Этот компилятор активно использует регистры для ускорения работы программы, и параметры в функции также передаются по возможности через регистры. Например, при вызове функции с шестью параметрами
some_proc(a,b,с,d,e,f);
первые четыре параметра передаются соответственно в (Е)АХ, (E)DX, (Е)ВХ, (Е)СХ, а только начиная с пятого, параметры помещают в стек в обычном обратном порядке:
e equ [bp+4] f equ [bp+6]
Если требуется выполнить совсем небольшую операцию на ассемблере, например вызвать какое-то прерывание или преобразовать сложную битовую структуру, часто нерационально создавать отдельный файл ради нескольких строк на ассемблере. Чтобы этого избежать, многие языки высокого уровня поддерживают возможность вставки ассемблерного кода непосредственно в программу. Например, напишем процедуру, возвращающую слово, находящееся по адресу 0040h:006Ch, в BIOS — счетчик сигналов системного таймера, который удобно использовать для инициализации генераторов случайных чисел.
function get_seed:longint var seed:longint begin asm push es mov ax,0040h mov es,ax mov ax,es:[006Ch] mov seed,ax pop es end; get_seed:=seed; end;
int get_seed() int seed; { _asm { push es mov ax,0040h mov es,ax mov ax,es:[006Ch] mov seed,ax pop es }; return(seed); };
В этих ситуациях ассемблерная программа может свободно пользоваться переменными из языка высокого уровня, так как они автоматически преобразуются в соответствующие выражения типа word ptr [bp+4].
AGI— это ситуация, при которой регистр, используемый командой для генерации адреса как базовый или индексный, был приемником предыдущей команды. В этой ситуации процессор тратит один дополнительный такт. Последовательность команд
add edx,4 mov esi,[edx]
выполняется с AGI на любом процессоре.
Последовательность команд
add esi,4 ; U-конвейер - 1 такт (на Pentium) pop ebx ; V-конвейер - 1 такт inc ebx ; V-конвейер - 1 такт mov edi,[esi] ; в U-конвейер - *AGI*, затем 1 такт
выполняется с AGI на Pentium за три такта процессора.
Кроме того, AGI может происходить неявно, например при изменении регистра ESP и обращении к стеку:
sub esp,24 push ebx ; *AGI*
или
mov esp,ebp pop ebp ; *AGI*
но изменение ESP, производимое командами PUSH и POP, не приводит к AGI, если следующая команда тоже обращается к стеку.
Процессоры Pentium Pro и Pentium II не подвержены AGI.
Процессор Pentium включает в себя два 8-килобайтных блока кэш-памяти, один для кода и один для данных с длиной линейки 32 байта. Кэш данных состоит из восьми банков, причем он доступен из обоих конвейеров одновременно, только если обращения происходят к разным банкам. Если данные или код не находятся в кэше, минимальная дополнительная задержка составляет 4 такта.
Если происходит два кэш-промаха одновременно при записи в память из обоих конвейеров и обе записи попадают в одно учетверенное слово (например, при записи двух слов в последовательные адреса), процессор затрачивает столько же времени, сколько и на один кэш-промах.
Процессоры Pentium Pro включают в себя 8-килобайтный кэш L1 для данных и 8-килобайтный кэш L1 для кода, а процессоры Pentium II соответственно по 16 Кб, но не все кэш-промахи приводят к чтению из памяти — существует кэш второго уровня — L2, который маскирует промахи L1. Минимальная задержка при промахе в оба кэша составляет 10 – 14 тактов в зависимости от состояния цикла обновления памяти.
Микрооперации чтения и записи могут произойти одновременно, если они обращаются к разным банкам кэша L1.
LEA можно использовать (кроме прямого назначения— вычисления адреса сложно адресуемой переменной) для следующих двух ситуаций:
быстрое умножение
lea еах,[еах*2] ; ЕАХ = ЕАХ * 2 (shl eax,1 ; лучше) lea еах,[еах+еах*2] ; ЕАХ = ЕАХ * 3 lea еах,[еах*4] ; ЕАХ = ЕАХ * 4 (shl eax,2 ; лучше) lea еах,[еах+еах*4] ; ЕАХ = ЕАХ * 5 lea еах,[еах+еах*8] ; ЕАХ = ЕАХ * 9
трехоперандное сложение
lea ecx,[eax+ebx] ; ЕСХ = ЕАХ * ЕВХ
Единственный недостаток LEA — увеличивается вероятность AGI с предыдущей командой (см. ниже).
Конвейер исполнения команд FPU состоит из трех участков, на каждом из которых команда тратит по крайней мере один такт. Многие команды, однако, построены таким образом, что позволяют другим командам выполняться на ранних участках конвейера, пока эти команды выполняются на более поздних. Кроме того, параллельно с длинными командами FPU, например FDIV, могут выполняться команды в целочисленных конвейерах.
Команда FXCH может выполняться одновременно почти с любой командой FPU, что позволяет использовать ST(n) как неупорядоченный набор регистров практически без потерь в производительности.
Команды ММХ, так же как команды FPU, используют дополнительный конвейер, содержащий два блока целочисленной арифметики (и логики), один блок умножения, блок сдвигов, блок доступа к памяти и блок доступа к целочисленным регистрам. Все блоки, кроме умножителя, выполняют свои стадии команды за один такт, умножение требует трех тактов, но имеет собственный буфер, позволяющий принимать по одной команде каждый такт. Так как блоков арифметики два, соответствующие операции могут выполняться одновременно в U- или V-конвейере. Команды, использующие блок сдвигов или умножитель, способны осуществляться в любом конвейере, но не одновременно с другими командами, использующими тот же самый блок. А команды, обращающиеся к памяти или обычным регистрам, в состоянии выполняться только в U-конвейере и только одновременно с ММХ-командами.
Если перед командой, копирующей ММХ-регистр в память или в обычный регистр, происходила запись в этот ММХ-регистр, затрачивается один лишний такт.
Если команда обращается к 32-битному регистру, например ЕАХ, сразу после команды, выполнявшей запись в соответствующий частичный регистр (АХ, AL, АН), происходит пауза минимум в 7 тактов на Pentium Pro и Pentium II и в 1 такт — на 80486, но не на Pentium:
mov ax,8 add ecx,eax ; пауза
На Pentium Pro и Pentium II эта пауза не появляется, если сразу перед командой записи в АХ была команда XOR ЕАХ,ЕАХ или SUB ЕАХ,ЕАХ.
Так как процессоры Intel используют весьма сложный набор команд, большинство операций можно выполнить на низком уровне очень многими способами. При этом иногда оказывается, что наиболее очевидный способ— не самый быстрый или короткий. Часто простыми перестановками команд, зная механизм выполнения команд на современных процессорах, реально заставить ту же процедуру выполняться на 50 – 200% быстрее. Разумеется, переходить к этому уровню оптимизации можно только после того, как текст программы окончательно написан и максимально оптимизирован на среднем уровне.
Перечислим основные рекомендации, которым нужно следовать при оптимальном программировании для процессоров Intel Pentium, Pentium MMX, Pentium Pro и Pentium II.
Перед тем как команды распределяются по конвейерам, они загружаются из памяти в одну из четырех 32-байтных очередей предвыборки. Если загружается команда перехода и блок предсказания переходов утверждает, что переход произойдет, начинает работать следующая очередь предвыборки. Это сделано для того, чтобы, если предсказание было неверным, первая очередь продолжила бы выборку команд после невыполненной команды перехода.
Если условный переход не был предугадан, затрачивается 3 такта, если команда перехода находилась в U-конвейере, и 4 такта, если в V.
Если безусловный переход или вызов процедуры не был предугадан, затрачивается 3 такта в любом случае.
Очередь предвыборки считывает прямую линию кода 16-байтными выровненными блоками. Это значит, что следует организовывать условные переходы так, чтобы наиболее частым исходом было бы отсутствие перехода, и что полезно выравнивать команды на границы слова. Кроме того, желательно располагать редко используемый код в конце процедуры, чтобы он не считывался в очередь предвыборки впустую.
Наиболее популярным применением ассемблера обычно считается именно оптимизация программ, то есть уменьшение времени выполнения программ по сравнению с языками высокого уровня. Но если просто переписать текст, например с языка С на ассемблер, переводя каждую команду наиболее очевидным способом, часто оказывается, что С-процедура выполнялась быстрее. Вообще говоря, ассемблер, как и любой другой язык, сам по себе не является панацеей от неэффективного программирования — чтобы действительно оптимизировать программу, требуется не только знание команд процессора, но и знание алгоритмов, навык оптимальных способов их реализации и подробная информация об архитектуре процессора.
Проблему оптимизации принято делить на три основных уровня:
Выбор наиболее оптимального алгоритма — «высокоуровневая оптимизация».
Наиболее оптимальная реализация алгоритма — «оптимизация среднего уровня».
Подсчет тактов, тратящихся на выполнение каждой команды, и оптимизация их порядка для конкретного процессора — «низкоуровневая оптимизация».
Реализация алгоритма на данном конкретном языке программирования — самая ответственная стадия оптимизации. Именно здесь можно получить выигрыш в скорости в десятки раз или сделать программу в десятки раз медленнее, при серьезных ошибках в реализации. Многие элементы, из которых складывается оптимизация, уже упоминались — хранение переменных, с которыми выполняется активная работа, в регистрах, использование таблиц переходов вместо длинных последовательностей проверок и условных переходов и т.п. Тем не менее даже плохо реализованные операции не вносят заметных замедлений в программу, если они не повторяются в цикле. Практически можно говорить, что все проблемы оптимизации на среднем уровне так или иначе связаны с циклами, и именно поэтому мы рассмотрим основные правила, которые стоит иметь в виду при реализации любого алгоритма, содержащего циклы.
Используйте регистр ЕАХ всюду, где возможно. Команды с непосредственным операндом, с операндом — абсолютным адресом переменной и команды XCHG с регистрами занимают на один байт меньше, если другой операнд — регистр ЕАХ.
Используйте регистр DS всюду, где возможно. Префиксы переопределения сегмента увеличивают размер программы на 1 байт и время на 1 такт.
Если к переменной в памяти, адресуемой со смещением, выполняется несколько обращений — загрузите ее в регистр.
Не используйте сложные команды — ENTER, LEAVE, LOOP, строковые команды, если аналогичное действие можно выполнить небольшой последовательностью простых команд.
Не используйте команду MOVZX для чтения байта — это требует 4 тактов для выполнения. Заменой может служить такая пара команд:
xor еах,еах mov al,source
Используйте TEST для сравнения с нулем или для других проверок равенства:
test eax,eax jz if_zero ; переход, если ЕАХ = 0 test eax,source jz if_zero ; переход, если ЕАХ = source
Исцользуйте команду XOR, чтобы обнулять регистр (конечно, если текущее состояние флагов больше не потребуется), эта команда официально поддерживается Intel как команда обнуления регистра:
xor еах,еах ; ЕАХ = 0
Не используйте умножение или деление на константу — его можно заменить другими командами, например:
; ЕАХ = ЕАХ * 10 shl eax,1 ; умножение на 2 lea eax,[eax+eax*4] ; умножение на 5 ; ЕАХ = ЕАХ * 7 mov ebx,eax shl еах,3 ; умножение на 8 sub eax,ebx ; и вычитание сохраненного ЕАХ ; АХ = АХ/10 mov dx,6554 ; DX = 65 536/10 mul dx ; DX = AX/10 (умножение ; выполняется быстрее деления) ; ЕАХ = ЕАХ mod 64 (остаток от деления на степень двойки) and eax,3Fh
Используйте короткую форму команды jmp, где возможно (jmp short метка).
Как можно реже загружайте сегментные регистры.
Как можно меньше переключайте задачи — это очень медленная процедура. Часто, если надо сохранять небольшое состояние процесса, например для реализации нитей, переключение быстрее организовать программно.
Процессоры Pentium Pro и Pentium II включают в себя целый набор средств для ускорения выполнения программ. В них применяется выполнение команд не по порядку, предсказание команд, аппаратное переименование регистров и предсказание переходов.
Циклы типа WHILE или FOR, которые так часто применяются в языках высокого уровня, оказываются менее эффективными по сравнению с циклами типа UNTIL из-за того, что в них требуется лишняя команда перехода:
; цикл типа WHILE mov si,counter ; число повторов mov dx,start_i ; начальное значение loop_start: cmp dx,si ; пока dx < si - выполнять jbn exit_loop
; [тело цикла]
inc dx jmp loop_start
; почти такой же цикл типа UNTIL mov si,counter mov dx,start_i loop_start: ; выполнять
; [тело цикла]
inc dx cmp dx,si ; пока dx < si jb loop_start
Естественно, цикл типа UNTIL, в отличие от цикла типа WHILE, выполнится по крайней мере один раз, так что, если это нежелательно, придется добавить одну проверку перед телом цикла, но в любом случае даже небольшое уменьшение тела цикла всегда оказывается необходимой операцией.
Процессор поддерживает 512-байтный буфер выполненных переходов и их целей. Система предсказания может обнаруживать последовательность до четырех повторяющихся переходов, то есть четыре вложенных цикла будут иметь процент предсказания близкий к 100%. Кроме того, дополнительный буфер адресов возврата позволяет правильно предсказывать циклы, из которых происходят вызовы подпрограмм.
На неправильно предсказанный переход затрачивается как минимум девять тактов (в среднем — от 10 до 15). На правильно предсказанный невыполняющийся переход не затрачивается никаких дополнительных тактов вообще. На правильно предсказанный выполняющийся переход затрачивается один дополнительный такт. Именно поэтому минимальное время выполнения цикла на Pentium Pro или Pentium II — два такта, и, если цикл может выполняться быстрее, он должен быть развернут.
Если команда перехода не находится в буфере, система предсказания делает следующие предположения:
безусловный переход предсказывается как происходящий, и на его выполнение затрачивается 5 – 6 тактов;
условный переход назад предсказывается как происходящий, и на его выполнение также затрачивается 5 – 6 тактов;
условный переход вперед предсказывается как непроисходящий. При этом команды, следующие за ним, предварительно загружаются, и начинают исполняться, поэтому не размещайте данные сразу после команды перехода.
Префиксы LOCK, переопределения сегмента и изменения адреса операнда увеличивают время выполнения команды на 1 такт.
Для небольших циклов время выполнения проверки условия и перехода на начало цикла может оказаться значительным по сравнению с временем выполнения самого тела цикла. Более того, на Pentium Pro/Pentium II цикл не в состоянии выполняться меньше, чем за два такта процессора, хотя его тело может выполняться даже меньше, чем за такт. С этим легко справиться, вообще не создавая цикл, а просто повторив его тело нужное число раз (разумеется, только в случае, если нам заранее известно это число!). Для очень коротких циклов можно, например, удваивать или утраивать тело цикла, если, конечно, число повторений кратно двум или трем. Кроме того, бывает удобно часть работы сделать в цикле, а часть развернуть, например продолжая цепочку циклов из предыдущего примера:
; цикл от 10 до -1 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jns loop_start ; если DX не отрицательный - ; продолжить цикл ; [тело цикла]
Совершенно естественно, что эти простые методики не перечисляют все возможности оптимизации среднего уровня, более того, они не описывают и десятой доли всех ее возможностей. Умение оптимизировать программы нельзя сформулировать в виде набора простых алгоритмов — слишком много существует различных ситуаций, в которых всякий алгоритм оказывается неоптимальным. При решении любой задачи оптимизации приходится пробовать десятки различных небольших изменений, далеко не все из которых оказываются полезными. Именно потому, что оптимизация всегда занимает очень много времени, рекомендуется приступать к ней только после того, как программа окончательно написана. Как и во многих других ситуациях, с оптимизацией нельзя торопиться, но и нельзя совсем забывать о ней на любой стадии создания программы.
Самым очевидным и самым важным правилом при создании цикла на любом языке программирования является вынос всех переменных, которые не изменяются на протяжении цикла, за его пределы. В случае ассемблера имеет смысл также по возможности разместить все переменные, которые будут использоваться внутри цикла, в регистры, а старые значения нужных после цикла регистров сохранить в стеке.
Циклы, в которых значение счетчика растет от двойки, единицы или нуля до некоторой константы, можно реализовать вообще без операции сравнения, выполняя цикл в обратном направлении (и мы пользовались этим приемом неоднократно в наших примерах). Дело в том, что команда DECcounter устанавливает флаги точно так же, как и команда СМР counter,1, то есть следующая команда условного перехода будет обрабатывать результат сравнения счетчика с единицей:
; цикл от 10 до 2 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, ja loop_start ; если DX больше 1 - продолжить цикл
; цикл от 10 до 1 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jae loop_start ; если DX больше или равно 1 - продолжить цикл
; цикл от 10 до 0 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jns loop_start ; если DX не отрицательный - продолжить цикл
Конечно, не все циклы можно заставить выполняться в обратном направлении сразу. Например, иногда приходится изменять формат хранения массива данных также на обратный, иногда приходится вносить другие изменения, но в целом, если это возможно, всегда следует стремиться к циклам, выполняющимся задом наперед. Кроме того, если цикл построен в этой манере, выполняется до значения счетчика, равного нулю, и регистр СХ можно освободить для выполнения роли счетчика, есть вариант воспользоваться командой LOOP, хотя в некоторых случаях в низкоуровневой оптимизации команды DEC/JNZ оказываются более эффективными.
Процессор Pentium содержит два конвейера исполнения целочисленных команд (U и V) и один конвейер для команд FPU. Он может выполнять две целочисленные команды одновременно и поддерживает механизм предсказания переходов, значительно сокращающий частоту сброса очереди предвыборки из-за передачи управления по другому адресу.
Процессор перед выполнением команды анализирует сразу две следующие команды, находящиеся в очереди и, если возможно, выполняет одну из них в U-конвейере, а другую в V. Если это невозможно, первая команда загружается в U-конвейер, а V-конвейер пустует.
V-конвейер имеет определенные ограничения на виды команд, которые могут в нем исполняться. Приложение 2 содержит для каждой команды информацию о том, может ли она выполняться одновременно с другими командами и в каком конвейере. Кроме того, две команды не будут запущены одновременно, если:
команды подвержены одной из следующих регистровых зависимостей:
Первая команда пишет в регистр, а вторая читает из него.
Обе команды пишут в один и тот же регистр (кроме записи в EFLAGS).
Исключения из этих правил— пары PUSH/PUSH, PUSH/POP и PUSH/CALL, выполняющие запись в регистр ESP;
одна из команд не находится в кэше команд (кроме случая, если первая команда — однобайтная);
одна из команд длиннее семи байт (для Pentium);
одна команда длиннее восьми байт, а другая — семи (для Pentium ММХ).
Помните, что простыми перестановками команд можно выиграть до 200% скорости в критических ситуациях.
Каждый такт процессора до трех команд может быть прочитан и декодирован на микрооперации из очереди предвыборки. В этот момент работают три декодера, первый из которых может декодировать команды, содержащие до четырех микроопераций, а другие два— только команды из одной микрооперации. Если в ассемблерной программе команды упорядочены в соответствии с этим правилом (4–1–1), то все время на каждый такт будет происходить декодирование трех команд, например, если в последовательности команд
add eax,[ebx] ; 2m - в декодер 0 на первом такте mov есх,[еах] ; 2m - пауза 1 такт, пока декодер 0 ; не освободится add edx,8 ; 1m - декодер 1 на втором такте
переставить вторую и третью команды, команда add edx,8 будет декодирована в тот же такт, что и первая команда.
Число микроопераций для каждой команды приведено в приложении 2, но можно сказать, что команды, работающие только с регистрами, как правило, выполняются за одну микрооперацию, команды чтения из памяти — тоже за одну, команды записи в память — за две, а команды, выполняющие чтение-изменение-запись, — за четыре. Сложные команды содержат больше четырех микроопераций и требуют несколько тактов для декодирования. Кроме того, команды длиннее семи байт не могут быть декодированы за один такт. В среднем время ожидания в этом буфере составляет около трех тактов.
Затем микрооперации поступают в буфер накопления, где они ждут, пока все необходимые им данные не будут доступны. Затем они посылаются в ядро системы неупорядоченного исполнения, состоящей из пяти конвейеров, каждый из которых обслуживает несколько блоков исполнения. Если все данные для микрооперации готовы и в ядре есть свободный элемент, исполняющий данную микрооперацию, в буфере накопления не будет потрачено ни одного лишнего такта. После выполнения микрооперации скапливаются в буфере завершения, где результаты их записываются, операции записи в память упорядочиваются и микрооперации завершаются (три за один такт).
Время выполнения | Скорость | |
Конвейер 0 | ||
Блок целочисленной арифметики | 1 | 1 |
Блок команд LEA | 1 | 1 |
Блок команд сдвига | 1 | 1 |
Блок целочисленного умножения | 4 | 1 |
Блок команд FADD | 3 | 1 |
Блок команд FMUL | 5 | 2 |
Блок команд FDIV | 17 для 32-битных 36 для 64-битных 56 для 80-битных |
17 36 56 |
Блок MMX-арифметики | 1 | 1 |
Блок MMX-умножений | 3 | 1 |
Конвейер 1 | ||
Блок целочисленной арифметики | 1 | 1 |
Блок MMX-арифметики | 1 | 1 |
Блок MMX-сдвигов | 1 | 1 |
Конвейер 2 | ||
Блок чтения | 3 при кэш-попадании | 1 |
Конвейер 3 | ||
Блок записи адреса | не меньше 3 | 1 |
Конвейер 4 | ||
Блок записи данных | не меньше 1 | 1 |
Восьмибайтные данные должны быть выравнены по восьмибайтным границам (то есть три младших бита адреса должны быть равны нулю).
Четырехбайтные данные должны быть выравнены по границе двойного слова (то есть два младших бита адреса должны быть равны нулю).
Двухбайтные данные должны полностью содержаться в выравненном двойном слове (то есть два младших бита адреса не должны быть равны единице).
80-битные данные должны быть выравнены по 16-байтным границам.
Когда нарушается выравнивание при доступе к данным, находящимся в кэше, теряются 3 такта на каждое невыравненное обращение на Pentium и 9 – 12 тактов — на Pentium Pro/Pentium II.
Так как линейка кэша кода составляет 32 байта, метки для переходов, особенно метки, отмечающие начало цикла, должны быть выравнены по 16-байтным границам, а массивы данных, равные или большие 32 байт, должны начинаться с адреса, кратного 32.
Выбор оптимального алгоритма для решения задачи всегда приводит к лучшим результатам, чем любой другой вид оптимизации. Действительно, при замене пузырьковой сортировки, время выполнения которой пропорционально N2, на быструю сортировку, выполняющуюся как N * log(N), всегда найдется такое число сортируемых элементов N, что вторая программа будет выполняться быстрее, как бы она ни была реализована. Поиск лучшего алгоритма — универсальная стадия, и она относится не только к ассемблеру, но и к любому языку программирования, поэтому будем считать, что оптимальный алгоритм уже выбран.
Дескриптор — это 64-битная (восьмибайтная) структура данных, которая может встречаться в таблицах GDT и LDT. Дескриптор способен описывать сегмент кода, сегмент данных, сегмент состояния задачи, быть шлюзом вызова, ловушки, прерывания или задачи. В GDT также может находиться дескриптор LDT.
Дескриптор сегмента данных или кода (подробно рассмотрен в главе 6.1)
байт 7: биты 31 – 24 базы сегмента
байт 6:
бит 7: бит гранулярности (0 — лимит в байтах, 1 — лимит в 4-килобайтных единицах)
бит 6: бит разрядности (0 — 16-битный, 1 — 32-битный сегмент)
бит 5: 0
бит 4: зарезервировано для операционной системы
биты 3 – 0: биты 19 – 16 лимита
байт 5: (байт доступа)
бит 7: бит присутствия сегмента
биты 6 – 5: уровень привилегий дескриптора (DPL)
бит 4: 1 (тип дескриптора — не системный)
бит 3: тип сегмента (0 — данных, 1 — кода)
бит 2: бит подчиненности для кода, бит расширения вниз для данных
бит 1: бит разрешения чтения для кода, бит разрешения записи для данных
бит 0: бит доступа (1 — к сегменту было обращение)
байт 4: биты 23 – 16 базы сегмента
байты 3 – 2: биты 15 – 0 базы
байты 1 – 0: биты 15 – 0 лимита
Если в дескрипторе бит 4 байта доступа равен 0, дескриптор называется системным. В этом случае биты 0 – 3 байта доступа определяют один из шестнадцати возможных типов дескриптора (табл. 22).
Таблица 22. Типы системных дескрипторов
0 | Зарезервированный тип | 8 | Зарезервированный тип |
1 | Свободный 16-битный TSS | 9 | Свободный 32-битный TSS |
2 | Дескриптор таблицы LDT | A | Зарезервированный тип |
3 | Занятый 16-битный TSS | B | Занятый 16-битный TSS |
4 | 16-битный шлюз вызова | C | 32-битный шлюз вызова |
5 | Шлюз задачи | D | Зарезервированный тип |
6 | 16-битный шлюз прерывания | E | 32-битный шлюз прерывания |
7 | 16-битный шлюз ловушки | F | 32-битный шлюз ловушки |
Это большая группа регистров (более ста), назначение которых отличается в разных моделях процессоров Intel и даже иногда в процессорах одной модели, но разных версий. Например, регистры Pentium Pro MTRR (30 регистров) описывают, какой механизм страничной адресации используют различные области памяти — не кэшируются, защищены от записи, кэшируются прозрачно и т.д. Регистры Pentium Pro MCG/MCI (23 регистра) используются для автоматического обнаружения и обработки аппаратных ошибок, регистры Pentium TR (12 регистров) используются для тестирования кэша и т.п. Мы рассмотрим при описании соответствующих команд только регистр Pentium TSC — счетчик тактов процессора и группу из четырех регистров Pentium Pro, использующуюся для подсчета различных событий (число обращений к кэшу, умножений, команд ММХ и тому подобное), так как эти регистры оказались настолько полезными, что для работы с ними появились дополнительные команды — RDTSC и RDPMC.
Теперь рассмотрим механизм, который дал название режиму процессора, — механизм защиты. Защита может действовать как на уровне сегментов, так и на уровне страниц, ограничивая доступ в зависимости от уровня привилегий (4 уровня привилегий для сегментов и два для страниц). Защита предотвращает возможность для программ вносить изменения в области памяти, занятые операционной системой или более привилегированной программой. Процессор проверяет привилегии непосредственно перед каждым обращением к памяти и, если происходит нарушение защиты, вызывает исключение #GP.
Если процессор находится в защищенном режиме, проверки привилегий выполняются всегда и их нельзя отключить, но можно использовать во всех дескрипторах и селекторах один и тот же максимальный уровень привилегий — нулевой, и создастся видимость отсутствия защиты. Именно так мы и поступали во всех примерах до сих пор — все поля DPL и RPL инициализировались нулями. Чтобы сделать незаметной проверку прав на уровне страничной адресации, надо установить биты U и W во всех элементах таблиц страниц, что мы также делали в программе pm3.asm.
За механизм защиты отвечают следующие биты и поля:
в дескрипторах сегментов:
бит S (системный сегмент)
поле типа (тип сегмента, включая запреты на чтение/запись)
поле лимита сегмента
поле DPL, определяющее привилегии сегмента или шлюза, указывает, по крайней мере, какой уровень привилегий должна иметь программа, чтобы обратиться к этому сегменту или шлюзу
в селекторах сегментов:
поле RPL, определяющее запрашиваемые привилегии, позволяет программам, выполняющимся на высоких уровнях привилегий, обращаться к сегментам, как будто их уровень привилегий ниже
поле RPL селектора, загруженного в CS, называется CPL и является текущим уровнем привилегий программы
в элементах таблиц страниц:
бит U (определяет уровень привилегий страницы)
бит W (разрешает/запрещает запись)
Уровни привилегий в процессорах Intel определены как:
0 — максимальный (для операционной системы);
1 и 2 — промежуточные (для вспомогательных программ);
3 — минимальный (для пользовательских приложений).
Перед обращением к памяти процессор выполняет несколько типов проверок, использующих все указанные флаги и поля. Рассмотрим их по порядку.
Мы уже неоднократно описывали сегментную адресацию — рассказывая о назначении сегментных регистров в реальном режиме или о программировании для расширителей DOS в защищенном режиме, но каждый раз требовалась для немедленных нужд только часть всей этой сложной модели. Теперь самое время рассмотреть ее полностью.
Для любого обращения к памяти в процессорах Intel используется логический адрес, состоящий из 16-битного селектора, определяющего сегмент, и 32- или 16-битного смещения — адреса внутри сегмента. Отдельный сегмент памяти — это независимое защищенное адресное пространство, для которого определены размер, разрешенные способы доступа (чтение/запись/исполнение кода) и уровень привилегий (см. главу 10.7). Если доступ к памяти удовлетворяет всем условиям защиты, процессор преобразует логический адрес в 32- или 36-битный (на Р6) линейный. Линейный адрес — это адрес в несегментированном непрерывном адресном пространстве, который совпадает с физическим адресом в памяти, если отключен режим страничной адресации (см. главу 10.6). Чтобы получить линейный адрес из логического, процессор добавляет к смещению линейный адрес начала сегмента, который хранится в поле базы в сегментном дескрипторе. Сегментный дескриптор — это восьмибайтная структура данных, расположенная в таблице GDT или LDT; адрес таблицы находится в регистре GDTR или LDTR, а номер дескриптора в таблице определяется из значения селектора.
Дескриптор для селектора, находящегося в сегментном регистре, не считывается из памяти при каждом обращении, а хранится в скрытой части сегментного регистра и загружается только при выполнении команд MOV в сегментный регистр, POP в сегментный регистр, LDS, LES, LSS, LGS, LFS и дальных команд перехода.
Как мы уже знаем, при изменении режима скрытые части сегментных регистров сохраняют содержимое своих дескрипторов и ими можно пользоваться. Мы осуществили эту возможность в нашем первом примере, когда значения, занесенные в сегментные регистры в реальном режиме, использовались в защищенном. Возникает вопрос — а если сделать наоборот? В защищенном режиме загрузить сегментные регистры дескрипторами 4-гигабайтных сегментов с базой 0 и перейти в реальный режим? Оказывается, что это прекрасно срабатывает, и мы попадем в особый режим, который был обнаружен одновременно разными программистами и называется нереальным режимом (unreal mode), большим реальным режимом (BRM) или реальным flat-режимом (RFM). Чтобы перейти в нереальный режим, надо загрузить в CS перед переходом в реальный режим дескриптор 16-битного сегмента кода с базой 0 и лимитом 4 Гб и в остальные сегментные регистры — точно такие же дескрипторы сегментов данных.
Теперь весь дальнейший код программы, написанный для реального режима, больше не ограничен рамками 64-килобайтных сегментов и способен работать с любыми массивами. Можно подумать, что первый же обработчик прерывания от таймера загрузит в CS нормальное значение и все станет как обычно, но нет. Оказывается, что при создании дескриптора в скрытой части сегментного регистра в реальном режиме процессор не трогает поле лимита, а только изменяет базу: что бы мы ни записали в сегментный регистр, сегмент будет иметь размер 4 Гб. Если попробовать вернуться в DOS — DOS будет по-прежнему работать. Можно запускать программы такого рода:
.model tiny .code org 100h start: xor ax,ax mov ds,ax ; DS = 0 ; вывести символ в видеопамять: mov word ptr ds:[0B8000h],8403h ret end start
и они тоже будут работать. Единственное, что отключает этот режим, — программы, переключающиеся в защищенный режим и обратно, устанавливающие границы сегментов в 64 Кб, например любые программы, использующие расширители DOS.
Нереальный режим — идеальный вариант для программ, которые хотят пользоваться 32-битной адресацией и свободно обращаться ко всем прерываниям BIOS и DOS (традиционный способ состоял бы в работе в защищенном режиме с переключением в V86 для вызова BIOS или DOS, как это делается в случае DPMI).
До сих пор все наши программы работали в защищенном режиме с полностью отключенными прерываниями — ими нельзя было управлять с клавиатуры, они не могли работать с дисками и вообще не делали ничего, кроме чтения или записи в те или иные области памяти. Разумеется, ни одна программа не может сделать ничего серьезного в таком режиме — нам рано или поздно придется обрабатывать прерывания.
В реальном режиме адрес обработчика прерывания считывался процессором из таблицы, находящейся по адресу 0 в памяти. В защищенном режиме эта таблица, называемая IDT — таблицей дескрипторов прерываний, может находиться где угодно. Достаточно того, чтобы ее адрес и размер были загружены в регистр IDTR. Содержимое этой таблицы — не просто адреса обработчиков, как это было в реальном режиме, а дескрипторы трех типов: шлюз прерывания, шлюз ловушки и шлюз задачи (форматы этих дескрипторов рассматривались в предыдущей главе).
Шлюзы прерываний и ловушек указывают точку входа обработчика, а также его разрядность и уровень привилегий. При передаче управления обработчику процессор помещает в стек флаги и адрес возврата, так же как и в реальном режиме, но для некоторых исключений после этого в стек помещается дополнительный код ошибки, так что не все обработчики можно завершать простой командой IRETD (или IRET для 16-битного варианта). Единственное различие между шлюзом прерывания и ловушки состоит в том, что при передаче управления через шлюз прерывания автоматически запрещаются дальнейшие прерывания, пока обработчик не выполнит IRETD. Этот механизм считается предпочтительным для обработчиков аппаратных прерываний, в то время как шлюз ловушки, который не запрещает прерывания на время исполнения обработчика, предпочтителен для обработки программных прерываний (которые фактически и являются исключениями типа ловушки). Кроме того, в защищенном режиме при вызове обработчика прерывания сбрасывается флаг трассировки ТF.
Сначала рассмотрим пример программы, обрабатывающей только аппаратное прерывание клавиатуры при помощи шлюза прерываний. Для этого надо составить IDT, загрузить ее адрес командой LIDT и не забыть загрузить то, что содержится в регистре IDTR в реальном режиме, — адрес 0 и размер 4 * 256, соответствующие таблице векторов прерываний реального режима.
; pm2.asm ; Программа, демонстрирующая обработку аппаратных прерываний в защищенном ; режиме, переключается в 32-битный защищенный режим и позволяет набирать ; текст при помощи клавиш от 1 до +. Нажатие Backspace стирает предыдущий ; символ, нажатие Esc - выход из программы. ; ; Компиляция TASM: ; tasm /m /D_TASM_ pm2.asm ; (или, для версий 3.x, достаточно tasm /m pm2.asm) ; tlink /x /3 pm2.obj ; Компиляция WASM: ; wasm /D pm2.asm ; wlink file pm2.obj form DOS ; ; Варианты того, как разные ассемблеры записывают смещение из 32-битного ; сегмента в 16-битную переменную: ifdef _TASM_ so equ small offset ; TASM 4.x else so equ offset ; WASM endif ; для MASM, по-видимому, придется добавлять лишний код, который преобразует ; смещения, используемые в IDT
бит 0: | 1, если причина ошибки — нарушение привилегий; 0, если было обращение к отсутствующей странице |
бит 1: | 1, если выполнялась операция записи, 0, если чтения |
бит 2: | 1, если операция выполнялась из CPL = 3, 0, если CPL < 3 |
бит 3: | 0, если ошибку вызвала попытка установить зарезервированный бит в каталоге страниц |
остальные биты зарезервированы |
Эти восемь 32-битных регистров (DR0 – DR7) позволяют программам, выполняющимся на уровне привилегий 0, определять точки останова, не модифицируя код программ, например для отладки ПЗУ или программ, применяющих сложные схемы защиты от трассировки. Пример отладчика, использующего эти регистры, — SoftICE.
DR7 (DCR) — регистр управления отладкой
биты 31 – 30: поле LEN для точки останова 3 (размер точки останова)
00 — 1 байт
01 — 2 байта
10 — не определен (например, для останова при выполнении)
11 — 4 байта
биты 29 – 28: поле R/W для точки останова 3 (тип точки останова)
00 — при выполнении команды
01 — при записи
10 — при обращении к порту (если бит DE в регистре CR4 = 1)
11 — при чтении или записи
биты 27 – 26: иоле LEN для точки останова 2
биты 25 – 24: поле R/W для точки останова 2
биты 23 – 22: поле LEN для точки останова 1
биты 21 – 20: поле R/W для точки останова 1
биты 19 – 18: поле LEN для точки останова 0
биты 17 – 16: поле R/W для точки останова 0
биты 15 – 14: 00
бит 13: бит GD — включает режим, в котором любое обращение к любому отладочному регистру, даже из кольца защиты 0, вызывает исключение #DB (этот бит автоматически сбрасывается внутри обработчика этого исключения)
биты 12 – 10: 001
бит 9: бит GE — если этот бит 0, точка останова по обращению к данным может не сработать или сработать на несколько команд позже, так что лучше всегда сохранять его равным 1
бит 7: бит G3 — точка останова 3 включена
бит 5: бит G2 — точка останова 2 включена
бит 3: бит G1 — точка останова 1 включена
Переключение задач осуществляется, если:
текущая задача выполняет дальний JMP или CALL на шлюз задачи или прямо на TSS;
текущая задача выполняет IRET, если флаг NT равен 1;
происходит прерывание или исключение, в качестве обработчика которого в IDT записан шлюз задачи.
При переключении процессор выполняет следующие действия:
Для команд CALL и JMP проверяет привилегии (CPL текущей задачи и RPL селектора новой задачи не могут быть больше, чем DPL шлюза или TSS, на который передается управление).
Проверяется дескриптор TSS (его бит присутствия и лимит).
Проверяется, что новый TSS, старый TSS и все дескрипторы сегментов находятся в страницах, отмеченных как присутствующие.
Сохраняется состояние задачи.
Загружается регистр TR. Если на следующих шагах происходит исключение, его обработчику придется доделывать переключение задач, вместо того чтобы повторять ошибочную команду.
Тип новой задачи в дескрипторе изменяется на занятый и устанавливается флаг TS в CR0.
Загружается состояние задачи из нового TSS: LDTR, CR3, EFLAGS, EIP, регистры общего назначения и сегментные регистры.
Если переключение задачи вызывается командами JUMP, CALL, прерыванием или исключением, селектор TSS предыдущей задачи записывается в поле связи новой задачи и устанавливается флаг NT. Если флаг NT установлен, команда IRET выполняет обратное переключение задач.
При любом запуске задачи ее тип изменяется в дескрипторе на занятый. Попытка вызвать такую задачу приводит к #GP, сделать задачу снова свободной можно, только завершив ее командой IRET или переключившись на другую задачу командой JMP.
Покажем, как создавать задачи и переключаться между ними на следующем примере.
; pm4.asm ; Пример программы, выполняющей переключение задач. ; Запускает две задачи, передающие управление друг другу 80 раз, задачи выводят ; на экран символы ASCII с небольшой задержкой ; ; Компиляция: ; TASM: ; tasm /m pm4.asm ; tlink /x /3 pm4.obj ; WASM: ; wasm pm4.asm ; wlink file pm4.obj form DOS ; MASM: ; ml /c pm4.asm ; link pm4.obj,,NUL,,,
Мы будем пользоваться различными дескрипторами по мере надобности, а для начала выполним переключение в 32-битную модель памяти flat, где все сегменты имеют базу 0 и лимит 4 Гб. Нам потребуются два дескриптора — один для кода и один для данных. Кроме того, нужны два 16-битных дескриптора с лимитами 64 Кб, чтобы загрузить их в CS и DS перед возвратом в реальный режим.
В комментариях к примеру pm0.asm мы заметили, что его можно выполнять в DOS-окне Windows 95, хотя программа и запускается уже в защищенном режиме. Это происходит потому, что Windows 95 перехватывает обращения к контрольным регистрам и позволяет программе перейти в защищенный режим, но только с минимальным уровнем привилегий. Все следующие наши примеры в этом разделе будут рассчитаны на работу с максимальными привилегиями, поэтому добавим в программу проверку на запуск из-под Windows (функция 1600h прерывания мультиплексора INT 2Fh).
Еще одно дополнительное действие, которое будем теперь выполнять при переключении в защищенный режим, — управление линией А20. После запуска компьютера для совместимости с 8086 используются 20-разрядные адреса (работают адресные линии А0 – А19), так что попытка записать что-то по линейному адресу 100000h приведет к записи по адресу 0000h. Этот режим отменяется установкой бита 2 в порту 92h и снова включается сбрасыванием этого бита в 0. (Существуют и другие способы, зависящие от набора микросхем, используемых на материнской плате, но они бывают необходимы, только если требуется максимально возможная скорость переключения.)
; pm1.asm ; Программа, демонстрирующая работу с сегментами в защищенном режиме, ; переключается в модель flat, выполняет вывод на экран и возвращается в DOS ; ; Компиляция: TASM: ; tasm /m pm1.asm ; tlink /x /3 pm1.obj ; MASM: ; ml /c pm1.asm ; link pm1.obj,,NUL,,, ; WASM: ; wasm pm1.asm ; wlink file pm1.obj form DOS
.386p ; 32-битный защищенный режим появился в 80386
; 16-битный сегмент, в котором находится код для входа ; и выхода из защищенного режима RM_seg segment para public "code" use16 assume CS:RM_seg,SS:RM_stack start: ; подготовить сегментные регистры push cs pop ds ; проверить, не находимся ли мы уже в РМ mov еах,cr0 test al,1 jz no_V86 ; сообщить и выйти mov dx,offset v86_msg err_exit: mov ah,9 int 21h mov ah,4Ch int 21h, v86_msg db "Процессор в режиме V86 - нельзя переключиться в РМ$" win_msg db "Программа запущена под Windows - нельзя перейти в кольцо 0$"
Мы уже неоднократно сталкивались с защищенным режимом и даже программировали приложения, которые работали в нем (главы 6 и 7). но мы пользовались только средствами, которые предоставляла операционная система, и до сих пор не рассматривали то, как процессор переходит и функционирует в защищенном режиме, то есть как работают современные операционные системы. Дело в том, что управление защищенным режимом в современных процессорах Intel — это самый сложный раздел программирования и самая сложная глава в этой книге. Но материал можно легко освоить, если рассматривать этот раздел шаг за шагом — отдельные механизмы работы процессора достаточно мало перекрываются друг с другом. Прежде чем рассматривать собственно программирование, познакомимся с регистрами и командами процессора, которые пока были от нас скрыты.
Поле лимита в дескрипторе сегмента запрещает доступ к памяти за пределами сегмента. Если бит G дескриптора равен нулю, значения лимита могут быть от 0 до FFFFFh (1 Мб). Если бит G установлен — от FFFh (4 Кб) до FFFFFFFFh (4 Гб). Для сегментов, растущих вниз, лимит принимает значения от указанного плюс 1 до FFFFh для 16-битных сегментов данных и до FFFFFFFFh — для 32-битных. Эти проверки отлавливают такие ошибки, как неправильные вычисления адресов.
Перед проверкой лимита в дескрипторе процессор выясняет лимит самой таблицы дескрипторов на тот случай, если указано слишком большое значение селектора.
Во всех случаях вызывается исключение #GP с кодом ошибки, равным индексу селектора, вызвавшего нарушение защиты.
Все неравенства здесь арифметические, то есть А > В означает, что уровень привилегий А меньше, чем В:
при загрузке регистра DS, ES, FS или GS должно выполняться условие: DPL >= max(RPL,CPL);
при загрузке регистров SS должно выполняться условие: DPL = CPL = RPL;
при дальних JMP, CALL, RET на неподчиненный сегмент кода должно выполняться условие: DPL = CPL (RPL игнорируется);
при дальних JMP, CALL, RET на подчиненный сегмент кода должно выполняться условие: CPL >= DPL. При этом CPL не изменяется;
при дальнем CALL на шлюз вызова должны выполняться условия: CPL =< DPL шлюза, RPL =< DPL шлюза, CPL >= DPL сегмента;
при дальнем JMP на шлюз вызова должны выполняться условия: CPL =< DPL шлюза, RPL =< DPL шлюза, CPL >= DPL сегмента, если он подчиненный, CPL = DPL сегмента, если он неподчиненный.
При вызове процедуры через шлюз на неподчиненный сегмент кода с другим уровнем привилегий процессор выполняет переключение стека. В сегменте TSS текущей задачи всегда хранятся значения SS:ESP для стеков уровней привилегий 0, 1 и 2 (стек для уровня привилегий 3, потому что нельзя выполнять передачу управления на уровень 3, кроме как при помощи команд RET/IRET). При переключении стека в новый стек помещаются, до обратного адреса, параметров (их число указано в дескрипторе шлюза вызова), флагов или кода ошибки (в случае INT), старые значения SS:ESP, которые команда RET/IRET использует для обратного переключения. То, что надо выполнить возврат из процедуры, RET определяет так: RPL селектора, оставленного в стеке, больше (менее привилегированный), чем CPL.
Даже если операционная система не поддерживает многозадачность, она должна оформить сегмент TSS с действительными SS:ESP для стеков всех уровней, если она собирается использовать уровни привилегий.
Загрузка селектора (и дескриптора) в регистр:
в CS можно загрузить только сегмент кода;
в DS, ES, FS, GS можно загрузить только селектор сегмента данных, сегмента кода, доступного для чтения, или нулевой селектор;
в SS можно загрузить только сегмент данных, доступный для записи;
в LDTR можно загрузить только сегмент LDT;
в TR можно загрузить только сегмент TSS.
Обращение к памяти:
никакая команда не может писать в сегмент кода;
никакая команда не может писать в сегмент данных, защищенный от записи;
никакая команда не может читать из сегмента кода, защищенного от чтения;
нельзя обращаться к памяти, если селектор в сегментном регистре нулевой.
Исполнение команды, использующей селектор в качестве операнда:
дальние CALL и JMP могут выполняться только в сегмент кода, шлюз вызова, шлюз задачи или сегмент TSS;
команда LLDT может обращаться только к сегменту LDT;
команда LTR может обращаться только к сегменту TSS;
команда LAR может обращаться только к сегментам кода и данных, шлюзам вызова и задачи, LDT и TSS;
команда LSL может обращаться только к сегментам кода, данных, LDT и TSS;
элементами IDT могут быть только шлюзы прерываний, ловушек и задач.
Некоторые внутренние операции:
при переключении задач целевой дескриптор может быть только TSS или шлюзом задачи;
при передаче управления через шлюз сегмент, на который шлюз указывает, должен быть сегментом кода (или TSS для шлюза задачи);
при возвращении из вложенной задачи селектор в поле связи TSS должен быть селектором сегмента TSS.
Рассматривая регистры процессора в главе 2.1, мы специально оставили в стороне несколько регистров, не использующихся в обычном программировании, в основном именно потому, что они управляют защищенным режимом.
Эти четыре регистра используются для указания положения структур данных, ответственных за сегментацию в защищенном режиме.
GDTR: 6-байтный регистр, в котором содержатся 32-битный линейный адрес начала таблицы глобальных дескрипторов (GDT) и ее 16-битный размер (минус 1). Каждый раз, когда происходит обращение к памяти, по селектору, находящемуся в сегментном регистре, определяется дескриптор из таблицы GDT или LDT, в котором записан адрес начала сегмента и другая информация (см. главу 6.1).
IDTR: 6-байтный регистр, в котором содержатся 32-битный линейный адрес начала таблицы глобальных дескрипторов обработчиков прерываний (IDT) и ее 16-битный размер (минус 1). Каждый раз, когда происходит прерывание или исключение, процессор передает управление на обработчик, описываемый дескриптором из IDT с соответствующим номером.
LDTR: 10-байтный регистр, в котором содержатся 16-битный селектор для GDT и весь 8-байтный дескриптор из GDT, описывающий текущую таблицу локальных дескрипторов (LDT).
TR: 10-байтный регистр, в котором содержатся 16-битный селектор для GDT и весь 8-байтный дескриптор из GDT, описывающий TSS текущей задачи.
Пять 32-битных регистров CR0 – CR4 управляют функционированием процессора и работой отдельных его внутренних блоков.
CR0: флаги управления системой
бит 31: бит PG — включает и выключает режим страничной адресации
бит 30: бит CD — запрещает заполнение кэша. При этом чтение из кэша все равно будет происходить
бит 29: бит NW — запрещает сквозную запись во внутренний кэш — данные, записываемые в кэш, не появляются на внешних выводах процессора
бит 18: бит AM — разрешает флагу АС включать режим, в котором невыровненные обращения к памяти на уровне привилегий 3 вызывают исключение #АС
бит 16: бит WP — запрещает запись в страницы, помеченные как только для чтения на всех уровнях привилегий (если WP = 0, защита распространяется только на уровень 3). Этот бит предназначен для реализации метода создания копии процесса, популярного в UNIX, в котором вся память нового процесса сначала полностью совпадает со старым, а затем, при попытке записи со стороны нового процесса, создается копия страницы, в которую произошла запись
бит 5: бит NE — включает режим, в котором ошибки FPU вызывают исключение #MF, а не IRQ13
бит 4: бит ЕТ — использовался только на 80386DX и указывал, что FPU присутствует
бит 3: бит TS — устанавливается процессором после переключения задачи. Если затем выполнить любую команду FPU, произойдет исключение #NM, обработчик которого может сохранить/восстановить состояние FPU, очистить этот бит командой CLTS и продолжить программу
бит 2: бит ЕМ — эмуляция сопроцессора. Каждая команда FPU вызывает исключение #NM
бит 1: бит МР — управляет тем, как исполняется команда WAIT. Должен быть установлен для совместимости с программами, написанными для 80286 и 80386 и использующими эту команду
бит 0: бит РЕ — если он равен 1, процессор находится в защищенном режиме
Сегмент состояния задачи (TSS) — это структура данных, в которой сохраняется вся информация о задаче, если ее выполнение временно прерывается.
TSS имеет следующую структуру:
+00h: 4 байта — селектор предыдущей задачи (старшее слово содержит нули — здесь и для всех остальных селекторов)
+04h: 4 байта — ESP для CPL = 0
+08h: 4 байта — SS для CPL = 0
+0Ch: 4 байта — ESP для CPL = 1
+10h: 4 байта — SS для CPL = 1
+14h: 4 байта — ESP для CPL = 2
+18h: 4 байта — SS для CPL = 2
+1Сh: 4 байта — CR3
+20h: 4 байта — EIP
+24h: 4 байта — EFLAGS
+28h: 4 байта — ЕАХ
+2Ch: 4 байта — ЕСХ
+30h: 4 байта — EDX
+34h: 4 байта — ЕВХ
+38h: 4 байта — ESP
+3Ch: 4 байта — ЕВР
+40h: 4 байта — ESI
+44h: 4 байта — EDI
+48h: 4 байта — ES
+4Ch: 4 байта — CS
+50h: 4 байта — SS
+54h: 4 байта — DS
+58Н: 4 байта — FS
+5Ch: 4 байта — GS
+60h: 4 байта — LDTR
+64h: 2 байта — слово флагов задачи. Бит 0 — флаг Т: вызывает #DB при переключении на задачу остальные биты не определены и равны нулю
+66h: 2 байта — адрес битовой карты ввода-вывода. Это 16-битное смещение от начала TSS, по которому начинается битовая карта разрешения ввода-вывода (см. главы 10.7.4 и 10.9.2) и заканчивается битовая карта перенаправления прерываний (см. главу 10.9.1) данной задачи.
TSS является полноценным сегментом и описывается сегментным дескриптором, формат которого мы приводили раньше (в главе 10.4.3). Кроме того, лимит TSS не может быть меньше 67h — обращение к такому дескриптору приводит к исключению #TS. Размер TSS может быть больше, если в него входят битовые карты ввода-вывода и перенаправления прерываний и если операционная система хранит в нем дополнительную информацию. Дескриптор TSS способен находиться только в GDT — попытка загрузить его из LDT вызывает исключение #GP. Для передачи управления задачам удобнее использовать дескрипторы шлюза задачи, которые можно помещать как в GDT, так и в LDT или IDT.
Селектор — это 16-битное число следующего формата:
биты 16 – 3: номер дескриптора в таблице (от 0 до 8191)
бит 2: 1 — использовать LDT, 0 — использовать GDT
биты 1 – 0: запрашиваемый уровень привилегий при обращении к сегменту и текущий уровень привилегий для селектора, загруженного в CS
Селектор, содержащий нулевые биты 16 – 3, называется нулевым и используется для загрузки в неиспользуемые сегментные регистры. Любое обращение в сегмент, адресуемый нулевым селектором, приводит к исключению #GP(0), в то время как даже загрузка в сегментный регистр ошибочного селектора вызывает исключение #GР(селектор). Попытка загрузки нулевого селектора в SS или CS также вызывает #GP(0), так как эти селекторы используются всегда.
Регистр флагов EFLAGS — это 32-битный регистр, в то время как в главе 2.1.4 рассмотрена только часть из младших 16 бит. Теперь мы можем обсудить все:
биты 31 – 22: нули
бит 21: флаг идентификации (ID)
бит 20: флаг ожидания виртуального прерывания (VIP)
бит 19: флаг виртуального прерывания (VIF)
бит 18: флаг контроля за выравниванием (АС)
бит 17: флаг режима V86 (VM)
бит 16: флаг продолжения задачи (RF)
бит 15: 0
бит 14: флаг вложенной задачи (NT)
биты 13 – 12: уровень привилегий ввода-вывода (IOPL)
бит 11: флаг переполнения (OF)
бит 10: флаг направления (DF)
бит 9: флаг разрешения прерываний (IF)
бит 8: флаг трассировки (TF)
биты 7 – 0: флаги состояния (SF, ZF, AF, PF, CF) были рассмотрены подробно раньше
Флаг TF: | если он равен 1, перед выполнением каждой команды генерируется исключение #DB (INT 1). |
Флаг IF: | если он равен 0, процессор не реагирует ни на какие маскируемые аппаратные прерывания. |
Флаг DP: | если он равен 1, регистры EDI/ESI при выполнении команд строковой обработки уменьшаются, иначе — увеличиваются. |
Поле IOPL: | уровень привилегий ввода-вывода, с которым выполняется текущая программа или задача. Чтобы программа могла обратиться к порту ввода-вывода, ее текущий уровень привилегий (CPL) должен быть меньше или равен IOPL. Это поле можно модифицировать, только имея нулевой уровень привилегий. |
Флаг NT: | равен 1, если текущая задача является вложенной по отношению к какой-то другой — в обработчиках прерываний и исключений и вызванных командой call задачах. Флаг влияет на работу команды IRET. |
Флаг RF: | когда этот флаг равен 1, отладочные исключения временно запрещены. Он устанавливается командой IRETD из обработчика отладочного прерывания, чтобы #DB не произошло перед выполнением команды, которая его вызвала, еще раз. На флаг не влияют команды POPF, PUSHF и IRET. |
Флаг VM: | установка этого флага переводит процессор в режим V86 (виртуальный 8086). |
Флаг АС: | если установить этот флаг и флаг AM в регистре CR0, каждое обращение к памяти из программ, выполняющихся с CPL = 3, не выровненное на границу слова для слов и на границу двойного слова для двойных слов, будет вызывать исключение #АС. |
Флаг VIF: | это виртуальный образ флага IF (только для Pentium и выше). |
Флаг VIP: | этот флаг указывает процессору, что произошло аппаратное прерывание. Флаги VIF и VIP используются в многозадачных средах для того, чтобы каждая задача имела собственный виртуальный образ флага IF (только для Pentium и выше — см. главу 5.9.1). |
Флаг ID: | если программа может изменить значение этого флага — процессор поддерживает команду CPUID (только для Pentium и выше). |
Команда: | LGDT источник |
Назначение: | Загрузить регистр GDTR |
Процессор: | 80286 |
Команда загружает значение источника (6-байтная переменная в памяти) в регистр GDTR. Если текущая разрядность операндов 32 бита, в качестве размера таблицы глобальных дескрипторов используются младшие два байта операнда, а в качестве ее линейного адреса — следующие 4. Если текущая разрядность операндов — 16 бит, для линейного адреса используются только байты 3, 4, 5 из операнда, а в самый старший байт адреса записываются нули.
Команда выполняется только в реальном режиме или при CPL = 0.
Команда: | SGDT приемник |
Назначение: | Прочитать регистр GDTR |
Процессор: | 80286 |
Помещает содержимое регистра GDTR в приемник (6-байтная переменная в памяти). Если текущая разрядность операндов — 16 бит, самый старший байт этой переменной заполняется нулями (начиная с 80386, а 286 заполнял его единицами).
Команда: | LLDT источник |
Назначение: | Загрузить регистр LDTR |
Процессор: | 80286 |
Загружает регистр LDTR, основываясь на селекторе, находящемся в источнике (16-битном регистре или переменной). Если источник — 0, все команды, кроме LAR, LSL, VERR и VERW, обращающиеся к дескрипторам из LDT, будут вызывать исключение #GP.
Команда выполняется только в защищенном режиме с CPL = 0.
Команда: | SLDT приемник |
Назначение: | Прочитать регистр LDTR |
Процессор: | 80286 |
Помещает селектор, находящийся в регистре LDTR, в приемник (16- или 32-битный регистр или переменная). Этот селектор указывает на дескриптор в GDT текущей LDT. Если приемник 32-битный, старшие 16 бит обнуляются на Pentium Pro и не определены на предыдущих процессорах.
Команда выполняется только в защищенном режиме.
Команда: | LTR источник |
Назначение: | Загрузить регистр TR |
Процессор: | 80286 |
Загружает регистр задачи TR, основываясь на селекторе, находящемся в источнике (16-битном регистре или переменной), указывающем на сегмент состояния задачи (TSS). Эта команда обычно используется при инициализации системы для загрузки первой задачи в многозадачной системе.
Команда: | STR приемник |
Назначение: | Прочитать регистр TR |
Процессор: | 80286 |
Команда: | LIDT источник |
Назначение: | Загрузить регистр IDTR |
Процессор: | 80286 |
Команда: | SIDT приемник |
Назначение: | Прочитать регистр IDTR |
Процессор: | 80286 |
Команда: | MOV приемник, источник |
Назначение: | Пересылка данных в/из управляющих и отладочных регистров |
Процессор: | 80386 |
Команда: | LMSW источник |
Назначение: | Загрузить слово состояния процессора |
Процессор: | 80286 |
Команда: | SMSW приемник |
Назначение: | Прочитать слово состояния процессора |
Процессор: | 80286 |
Команда: | CLTS |
Назначение: | Сбросить флаг TS в CR0 |
Процессор: | 80286 |
Команда: | ARPL приемник,источник |
Назначение: | Коррекция поля RPL селектора |
Процессор: | 80286 |
Команда: | LAR приемник,источник |
Назначение: | Прочитать права доступа сегмента |
Процессор: | 80286 |
Команда: | LSL приемник,источник |
Назначение: | Прочитать лимит сегмента |
Процессор: | 80286 |
Команда: | VERR источник |
Назначение: | Проверить права на чтение |
Команда: | VERW источник |
Назначение: | Проверить права на запись |
Процессор: | 80286 |
Команда: | INVD |
Назначение: | Сбросить кэш-память |
Команда: | WBINVD |
Назначение: | Записать и сбросить кэш-память |
Процессор: | 80486 |
Команда: | INVLPG источник |
Назначение: | Аннулировать страницу |
Процессор: | 80486 |
Команда: | HLT |
Назначение: | Остановить процессор |
Процессор: | 8086 |
Команда: | RSM |
Назначение: | Выйти из режима SMM |
Процессор: | Р5 |
Команда: | RDMSR |
Назначение: | Чтение из MSR-регистра |
Команда: | WRMSR |
Назначение: | Запись в MSR-регистр |
Процессор: | Р5 |
Команда: | RDTSC |
Назначение: | Чтение из счетчика тактов процессора |
Процессор: | Р5 |
Команда: | RDPMC |
Назначение: | Чтение из счетчика событий |
Процессор: | Р6 |
Команда: | SYSENTER |
Назначение: | Быстрый системный вызов |
Команда: | SYSEXIT |
Назначение: | Быстрый возврат из системного вызова |
Процессор: | РII |
Линейный адрес, который формируется процессором из логического адреса, соответствует адресу из линейного непрерывного пространства памяти. В обычном режиме в это пространство могут попадать области памяти, в которые нежелательно разрешать запись, — системные таблицы и процедуры, ПЗУ BIOS и т.д. Чтобы этого избежать, система может разрешать программам создавать только небольшие сегменты, но тогда теряется такая привлекательная идея flat-памяти. Сегментация — не единственный вариант организации памяти, который поддерживают процессоры Intel. Существует второй, совершенно независимый механизм — страничная адресация (pagination).
При страничной адресации непрерывное пространство линейных адресов памяти разбивается на страницы фиксированного размера (обычно 4 Кб (4096 или 1000h байт), но Pentium Pro может поддерживать и страницы по 4 Мб). При обращении к памяти процессор физически обращается не по линейному адресу, а по тому физическому адресу, с которого начинается данная страница. Описание каждой страницы из линейного адресного пространства, включающее в себя ее физический адрес и дополнительные атрибуты, хранится в одной из специальных системных таблиц, как и в случае сегментации, но в отличие от сегментации страничная адресация абсолютно невидима для программы.
Страничная адресация включается при установке бита PG регистра CR0, если бит РЕ установлен в 1 (попытка установить PG, оставаясь в реальном режиме, приводит к исключению #GP(0)). Кроме того, предварительно надо поместить в регистр CR3 физический адрес начала каталога страниц — главной из таблиц, описывающих страничную адресацию. Каталог страниц имеет размер 4096 байт (ровно одна страница) и содержит 1024 4-байтных указателя на таблицы страниц. Каждая таблица страниц тоже имеет размер 4096 байт и содержит указатели до 1024 4-килобайтных страниц. Если одна страница описывает 4 килобайта, то полностью заполненная таблица страниц описывает 4 мегабайта, а полный каталог полностью заполненных таблиц — 4 гигабайта, то есть все 32-битное линейное адресное пространство. Когда процессор выполняет обращение к линейному адресу, он сначала использует его биты 31 – 22 как номер таблицы страниц в каталоге, затем биты 21 – 12 как номер страницы в выбранной таблице, а затем биты 11 – 0 как смещение от физического адреса начала страницы в памяти. Так как этот процесс занимает достаточно много времени, в процессоре предусмотрен специальный кэш страниц — TLB (буфер с ассоциативной выборкой), так что, если к странице обращались не очень давно, процессор определит ее физический адрес сразу.
Следующий очень важный механизм, действующий только в защищенном режиме, — многозадачность. Задача — это элемент работы, который процессор может исполнять, запустить или отложить. Задачи используют для выполнения программ, процессов, обработчиков прерываний и исключений, ядра операционной системы и пр. Любая программа, выполняющаяся в защищенном режиме, должна осуществляться как задача (хотя мы пока игнорировали это требование). Процессор предоставляет средства для сохранения состояния задачи, запуска задачи и передачи управления из одной задачи в другую.
Задача состоит из сегмента состояния задачи (TSS), сегмента кода, одного или нескольких (для разных уровней привилегий) сегментов стека и одного или нескольких сегментов данных.
Задача определяется селектором своего сегмента TSS. Когда задача выполняется, ее селектор TSS (вместе с дескриптором в скрытой части) загружен в регистр TR процессора.
Запуск задачи осуществляется при помощи команды CALL или JMP на сегмент TSS или на шлюз задачи, а также при запуске обработчика прерывания или исключения, который описан как шлюз задачи. При этом автоматически осуществляется переключение задач. Состояние текущей задачи записывается в ее TSS, состояние вызываемой задачи считывается из ее TSS, и управление передается на новые CS:EIP. Если задача не была запущена командой JMP, селектор сегмента TSS старой задачи сохраняется в TSS новой и устанавливается флаг NT, так что следующая команда IRET выполнит обратное переключение задач.
Задачи не могут вызываться рекурсивно. В дескрипторе TSS-задачи, которая была запущена, но не была завершена, тип изменяется на «занятый TSS» и переход на такой TSS невозможен.
Задача может иметь собственную таблицу дескрипторов (LDT) и полный комплект собственных таблиц страниц, так как регистры LDTR и CR3 входят в состояние задачи.
Итак, чтобы перейти в защищенный режим, достаточно установить бит РЕ — нулевой бит в управляющем регистре CR0, и процессор немедленно окажется в защищенном режиме. Единственное дополнительное требование, которое предъявляет Intel, — чтобы в этот момент все прерывания, включая немаскируемое, были отключены.
; pm0.asm ; Программа, выполняющая переход в защищенный режим и немедленный возврат. ; Работает в DOS в реальном режиме и в DOS-окне Windows 95 (Windows ; перехватывает исключения, возникающие при попытке перехода в защищенный ; режим из V86, и позволяет нам работать, но только на минимальном уровне ; привилегий) ; ; Компиляция: ; TASM: ; tasm /m pm0.asm ; tlink /x /t pm0.obj ; MASM: ; ml /c pm0.asm ; link pm0.obj,,NUL,,, ; exe2bin pm0.exe pm0.com ; WASM: ; wasm pm0.asm ; wlink file pm0.obj form DOS COM
.model tiny .code .386p ; все наши примеры рассчитаны на 80386 org 100h ; это СОМ-программа start: ; подготовить сегментные регистры push cs pop ds ; DS - сегмент данных (и кода) нашей программы push 0B800h pop es ; ES - сегмент видеопамяти ; проверить, находимся ли мы уже в защищенном режиме mov еах,cr0 ; прочитать регистр CR0 test al,1 ; проверить бит РЕ, jz no_V86 ; если он ноль - мы можем продолжать, ; иначе - сообщить об ошибке и выйти mov ah,9 ; функция DOS 09h mov dx,offset v86_msg ; DS:DX - адрес строки int 21h ; вывод на экран ret ; конец СОМ-программы ; (раз это защищенный режим, в котором работает наша DOS-программа, это должен ; быть режим V86) v86_msg db "Процессор в режиме V86 - нельзя переключиться в РМ$"
; сюда передается управление, если мы запущены в реальном режиме no_V86: ; запретить прерывания cli ; запретить немаскируемое прерывание in al,70h ; индексный порт CMOS or al,80h ; установка бита 7 в нем запрещает NMI out 70h,аl ; перейти в защищенный режим mov еах,cr0 ; прочитать регистр CRO or al,1 ; установить бит РЕ, mov cr0,eax ; с этого момента мы в защищенном режиме ; вывод на экран 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 ; переключиться в реальный режим mov еах,cr0 ; прочитать CR0 and al,0FEh ; сбросить бит РЕ mov cr0,eax ; с этого момента процессор работает в ; реальном режиме ; разрешить немаскируемое прерывание in al,70h ; индексный порт CMOS and al,07Fh ; сброс бита 7 отменяет блокирование NMI out 70h,al ; разрешить прерывания sti ; подождать нажатия любой клавиши mov ah,0 int 16h ; выйти из СОМ-программы ret ; текст сообщения с атрибутом после каждого символа для прямого вывода на экран message db 'Н',7,'е',7,'l',7,'l',7,'о',7,' ',7,'и',7,'з',7 db ' ',7,'Р',7,'М',7 ; его длина в байтах message_l = $ - message ; длина оставшейся части экрана в словах rest_scr = (80*25)-(2*message_l) end start
Команды LGDT, LLDT, LTR, LIDT, MOV CRn, LMSW, CLTS, MOV DRn, INVD, WBINVD, INVLPG, HLT, RDMSR, WRMSR, RDPMC, RDTSC, SYSEXIT могут выполняться, только если CPL = 0 (хотя биты РСЕ и TSD регистра CR4 разрешают использование команд RDPMC и RDTSC с любого уровня).
Команды LLDT, SLDT, LTR, STR, LSL, LAR, VERR, VERW и ARPL можно выполнять только в защищенном режиме — в реальном и V86 возникает исключение #UD.
Команды CLI и STI выполняются, только если CPL =< IOPL (IOPL — это двухбитная область в регистре флагов). Если установлен бит PVI в регистре CR4, эти команды выполняются с любым CPL, но управляют флагом VIF, а не IF.
Команды IN, OUT, INSB, INSW, INSD, OUTSB, OUTSW, OUTSD выполняются, только если CPL =< IOPL и если бит в битовой карте ввода-вывода, соответствующий данному порту, равен нулю. (Эта карта — битовое поле в сегменте TSS, каждый бит которого отвечает за один порт ввода-вывода. Признаком ее конца служит слово, в котором все 16 бит установлены в 1.)
Обращение к странице памяти с битом U в атрибуте страницы или таблицы страниц, равным нулю, приводит к исключению #PF, если CPL = 3.
Попытка записи в страницу с битом W в атрибуте страницы или таблицы страниц, равным нулю, с CPL = 3 приводит к исключению #РF.
Попытка записи в страницу с битом W в атрибуте страницы или таблицы страниц, равным нулю, если бит WP в регистре CR0 равен 1, приводит к исключению #РF.