...
...

Техника обработки прерываний

В предыдущих статьях мы недаром уделяли особое внимание системному таймеру компьютера. Вряд ли будет преувеличением утверждение, что таймер - одно из важнейших устройств машины. С чисто прагматической точки зрения аппаратное прерывание таймера (int 08) является единственным предсказуемым событием из всех происходящих в компьютере. Оно возникает с частотой 18.2 раза в секунду независимо от воли программиста. Наверное, самое важное применение int 08 в программировании заключается в организации с его помощью процессов, выполняющихся параллельно основной программе.

BIOS использует int 08 для создания процесса, который обслуживает системные часы, постоянно обновляя значение счетчика по адресу 40:6С в области данных BIOS. С другой стороны, этот процесс отрабатывает паузу, по истечении которой отключится двигатель привода гибких дисков. Назначение такой паузы очевидно: если обращения к дискете следуют одно за другим, то двигатель не успевает отключаться, а это экономит время, которое уходило бы на включение двигателя и раскручивание дискеты. Счетчик паузы находится по адресу 40:40.

Как вы могли обратить внимание, в прошлый раз мы сделали то же самое: организовали процесс, приводимый в действие int 08, который уменьшал значение нашего собственного счетчика, позволяя нам отрабатывать паузы с точностью 0.054945 секунды. Пришлось, как говорят, "перехватить" прерывание, то есть назначить для обработки int 08 программу собственного написания. Иначе говоря, когда аппаратура компьютера инициировала аппаратное прерывание системного таймера, процессор машины, прервавшись, выполнял нашу программу обработки int 08, после чего возвращался к другой работе.

Отсюда нетрудно видеть, почему в коде низкоуровневой вставки ясно различимы три части, три процедуры, каждая из которых играет определенную роль. Первая из них - программа обработки прерывания, уменьшающая значение счетчика. Вторая служит для назначения нашей программы обработки прерывания, или перехвата int 08. Третья выполняет обратные действия: восстанавливает в правах программу обработки прерывания, которая работала до того, как мы назначили вместо нее собственную.

Запрограммировать описанные процедуры не представляет большого труда, когда работаешь на ассемблере, создавая самостоятельную программу. А вот когда нужно "состряпать" низкоуровневую вставку, которой придется функционировать в среде языка высокого уровня, вещи перестают быть простыми. Главной трудностью становится разработка кода, который был бы позиционно-независимым. Думаю, для полной ясности этого утверждения потребуется подробное обсуждение.

Боюсь, что до разбора техники написания позиционно-независимых (перемещаемых) кодов нам еще далеко. Для начала необходимо вникнуть в проблематику обработки прерываний и понять, почему нужна перемещаемость. Взгляните, разве не кажется, что процедура обработки int 08 могла бы выглядеть просто, скажем так: 0D84:0100 DB 0 0D84:0101 TEST BYTE PTR [100],FF 0D84:0106 JZ 010С 0D84:0108 DEC BYTE PTR [100] 0D84:010С IRET Вот что это значит.

0D84:0100 DB 0 "DB" - это не мнемоника команды процессора, а лишь указание отладчику (или компилятору ассемблера) разместить в байте с адресом 100 ноль. Байт предполагается использовать в качестве счетчика: число, занесенное в него, станет уменьшаться при каждом тике таймера.

0D84:0101 TEST BYTE PTR [100],FF Команда процессора TEST X,Y выполняет над двоичными представлениями чисел X и Y операцию поразрядного логического "И". То есть, если нулевой бит X или Y равен нулю, нулевой бит результата также равен нулю. Нулевой бит результата будет равен единице, если нулевые биты и X и Y равны единице. По тому же правилу формируются первый, второй и т.д. биты результата. В приведенной команде роль X играет байт, находящийся в памяти по адресу 100 (счетчик), о чем и говорит запись BYTE PTR [100]. PTR является сокращением от pointer (указатель). Значение адреса обязательно заключается в квадратные скобки, чтобы отличать его от простого числа. Роль Y играет число FF, все биты его двоичного представления равны единице. Нетрудно видеть, что результат будет нулевым только тогда, когда нулевым будет значение счетчика.

0D84:0106 JZ 010С У процессора есть так называемый регистр флагов. Биты этого регистра зовут флагами. Один из битов, шестой, именуется ZF (zero flag - флаг нуля). Если процессор выполняет какую-либо арифметическую команду и получается нулевой результат, то значение ZF становится равным 1, в противном случае - 0. Другими словами, получив нулевой результат, процессор "выбрасывает" нулевой флаг. Так вот, команда JZ (jump if zero - перейти, если нуль) - условная команда передачи управления. Она анализирует состояние ZF. Значение ZF формируется при выполнении команды TEST. Переход по адресу 10С (к команде IRET) произойдет только при значении счетчика, равном нулю.

0D84:0108 DEC BYTE PTR [100] Если условная команда перехода JZ не сработала, поскольку значение счетчика отлично от нуля, то его значение необходимо уменьшить на единицу. В следующий раз, когда произойдет прерывание 08, оно будет уменьшено еще на единицу, и так далее. Значение счетчика перестанет убывать, как только он обнулится: этой цели служит команда JZ. А пока значение счетчика, то есть значение байта по 100-му адресу, будет уменьшено на единицу командой DEC.

0D84:010С IRET IRET - самая простая из рассмотренных команд. Отработав ее, процессор вернется к выполнению программы, нарушенному возникновением аппаратного прерывания от системного таймера. Обработка int 08 завершена.

Эта коротенькая программа правильна с точки зрения задачи уменьшения показаний нашего собственного счетчика, но она не обслуживает ни компьютерные часы, ни паузу для двигателя дисковода. Значит, системные часы идти не будут, а двигатель дисковода не остановится и зеленая лампочка на его панели не погаснет. Более того, кроме BIOS прерыванием таймера до нас могли воспользоваться в своих целях другие программы, в том числе QBASIC, в чью программу мы готовим вставку. Не считаться с этим мы не имеем права. И еще: обработка аппаратного прерывания - не самое простое дело. Разве охота возиться с портами контроллера прерываний? Зачем создавать себе сложности и, возможно, выводить систему из равновесия, если можно этого избежать? Сделаем вот что: как только произойдет int 08 и наша программа обработки получит управление, пусть выполнится предыдущая программа обработки. Дадим BIOS и любым другим программам сделать то, что они должны были выполнить с int 08, а потом, без забот, поработаем со своим счетчиком. Сделаем так: 0D84:0100 DB 0 0 0 0 0 0D84:0105 PUSHF 0D84:0106 CALL FAR [101] 0D84:010A TEST BYTE PTR [100],FF 0D84:010F JZ 0115 0D84:0111 DEC BYTE PTR [100] 0D84:0115 IRET Видите, если раньше мы резервировали один байт по 100-му адресу для счетчика, то здесь добавлено еще четыре байта, то есть двойное слово, где мы будем хранить адрес (сегмент:смещение) предыдущей процедуры обработки int 08. Конечно, адрес сам туда не попадет, а его значение заранее неизвестно. Определение адреса прежней программы обработки и его сохранение в двойном слове по адресу 101 будет выполнено упоминавшейся процедурой перехвата прерывания. О ней мы поговорим еще не скоро.

Другое отличие от первой версии нашего обработчика - в командах PUSHF и CALL FAR [101]. Именно они дают отработать предыдущей программе обработки прерывания 08, - как будто бы int 08 вовсе не перехвачено. Можете поверить на слово, мол, "так надо", но лучше объяснить все по порядку. Особенно познавательным это будет для читателей, ранее не сталкивавшихся с низкоуровневым программированием.

В технике программирования, особенно на ассемблере, важное место занимает понятие стека. Не совершая ошибки, стек можно понимать как место в памяти, где следует кратковременно сохранять данные, упрятывая их "от греха подальше". У процессора даже есть стековые регистры: регистр сегмента и смещения, которые называются SS (stack segment) и SP (stack pointer) соответственно.

Говоря про стек, его часто называют магазином, имея в виду сходство принципа действия: как из магазина АК первым извлекается и попадает в ствол патрон, вложенный в рожок последним, так и из стека первыми извлекаются те данные, которые последними туда заносились. Принцип "последним пришел - первым ушел" имеет сокращенное обозначение LIFO (last in - first out).

Представьте себе следующее: процессор спокойно выполняет какую-то программу - и вдруг происходит аппаратное прерывание. Скажем, прошло около 5 сотых секунды и пора обновлять показания системных часов. Что же делает процессор? Он прячет в стек регистр флагов - на всякий случай: может быть, в прерванной программе готовилась условная передача управления и значения флагов важно сохранить. Потом в стек упрятывается адрес (сегмент:смещение) текущей программы, которая прервана. С этого адреса процессор возобновит ее выполнение, когда справится с обработкой прерывания. После всего этого процессор переходит к выполнению процедуры обработки прерывания.

Есть правило, согласно которому обработка прерывания заканчивается сразу, как только процессору встретится (и будет им исполнена) команда IRET (Interrupt RETurn - возврат из прерывания). Что происходит в этом случае? Очень просто: из стека восстанавливается адрес, с которого нужно возобновить выполнение прерванной программы, а затем и припрятанное там значение регистра флагов. Адрес, сохраняемый в стеке, часто называют адресом возврата. Так вот, регистры CS:IP получили значение адреса возврата, флаги восстановлены и - прерывания как не бывало.

Но это в общем, а как же обстоит дело в нашем случае? Случилось int 08, в стеке сохранены флаги и адрес возврата прерванной программы, управление передано нашей процедуре обработки, а нам нужно для начала вызвать прежнюю процедуру обработки. Как бы симулировать int 08, но без того, чтобы произошло прерывание и чтобы управление потом вернулось к нашей же процедуре.

Вот мы и добрались до сути: команда PUSHF (push - затолкнуть, как в магазин) прячет в стеке значения флагов. Команда CALL FAR [101] выполняет два действия. Во-первых, она сохраняет в стеке адрес возврата в виде сегмент:смещение, причем в данном случае адресом возврата является адрес команды, следующей сразу после команды CALL. В приведенном примере это команда TEST и адрес 0D84:010A. Во-вторых, команда CALL FAR [101] передает управление, то есть вызывает для выполнения коды, находящиеся в памяти по адресу, который в свою очередь записан по адресу 101 в виде двойного слова (сегмент:смещение). На то, что адрес перехода "длинный", двухсловный, указывает словечко FAR.

Итак, после отработки команд PUSHF и CALL FAR [101] стек оказывается в состоянии, как будто int 08 прервало выполнение нашей программы, а процессор переходит к "родной" процедуре обработки прерывания таймера. Поэтому, когда в исконной процедуре обработки int 08 встретится IRET, возобновится выполнение нашей процедуры обработки прерывания.

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

В действительности последняя улучшенная версия обработчика int 08 все же непригодна для применения. Знаете почему? Потому, что команды CALL, TEST и DEC ссылаются на конкретные адреса памяти 100 и 101. Помните, что я говорил о том, как вызывается прерывание? Обратили внимание на то, как "на всякий пожарный" сохраняется регистр флагов? Должен был возникнуть вопрос насчет сохранности значений прочих регистров.

Забота о сохранении в неприкосновенности состояний всех регистров процессора, за исключением CS, IP и флагов, лежит на программисте, который пишет процедуру обработки прерывания. Коль скоро прерывание приостанавливает исполнение какой-то программы, значения регистров непредсказуемы. Более того, нельзя допустить, чтобы значение какого-либо из регистров после выхода из прерывания изменилось. Этот принцип - святая святых обработки прерываний.

Произвольное изменение значения регистра равносильно ошибке аппаратуры и неизбежно приводит к ошибкам в работе BIOS, DOS и программ, что еще хуже, чем фатальный сбой, "вешающий" систему. Это нужно усвоить, как "Отче наш". Выход есть, и он уже упоминался: входные значения регистров, на использование которых покушается программист, должны быть сохранены в стеке, а перед выходом из прерывания - восстановлены, нужно только не забыть о LIFO. Как бы то ни было, от сохранения значений регистров они нам известны не станут.

Раз значения регистров непредсказуемы, то, ссылаясь на адреса 100 и 101, которые суть смещения внутри текущего сегмента кода (регистр CS), мы попадаем в затруднительное положение из-за команд CALL, TEST и DEC.

Чтобы они правильно сработали, нужно иметь верное значение регистра сегмента данных DS. При выполнении этих команд процессор молчаливо предполагает, что абсолютные адреса данных должно вычислять исходя из значения регистра DS. Чему будет равно значение DS - неизвестно, поэтому абсолютные адреса DS:100 и DS:101 будут ссылаться куда угодно, только не на наш счетчик и не на сохраненный адрес старого обработчика int 08.

Проблему с DS можно обойти в два счета, не разрешив процессору пользоваться умолчанием, а явно указать сегмент, который нужно применить для вычисления абсолютного адреса. Прием называется заданием префикса сегмента или просто префиксацией. Например: CS: CALL FAR [101] Теперь процессор будет иметь дело с адресами вида CS:100 и CS:101. Наверное, ссылки на данные станут верны. Ведь при вызове прерывания CS автоматически укажет на код нашей вставки. Кстати, конкретного значения CS мы тоже не знаем.

Эти рассуждения были бы неотразимы, пиши мы не ассемблерную вставку, а программу типа *.СОМ. При запуске таких программ DOS обеспечивает, чтобы код программы начинался с адреса CS:100. Поэтому достаточно явно указать префикс сегмента перед командами CALL, TEST и DEC для правильности адресных ссылок. Программа типа *.СОМ будет работать вне зависимости от конкретного значения CS. Она позиционно-независима, то есть перемещаема относительно CS.

Увы, в случае со вставкой никто и ничто не в силах нам гарантировать, что код вставки будет размещен по адресу CS:100. Лично я смею обещать обратное. Вспомните, что один и тот же абсолютный адрес можно записать в виде сегмент:смещение во множестве вариантов. Скажем, 0666:0100 = 0660:0160 = 0600:0760 = 0606:0700 = 0000:6760 - и так далее.

Надежды, что ссылка CS:100 укажет на счетчик, нет никакой. Мы ведь не знаем, в какое место памяти загрузится программа с нашей вставкой, где окажется код вставки и каким окажется соотношение сегмент:смещение.

Вывод отсюда только один - для того чтобы обращаться к данным, являющимся частью вставки, способ, при котором используется абсолютная величина смещения (100, 101), не подходит. Наверное, следует отыскать такой метод адресации данных, когда можно задавать не абсолютные, а относительные величины смещений. Вот тогда-то код вставки будет абсолютно перемещаемым и перестанет зависеть от априорных предположений.

Я до сих пор намеренно не касался команды JZ 115. Когда проблема перемещаемости кода вполне осознана для случая адресации данных, позвольте спросить, уверены ли вы, что команда JZ передаст управление куда нужно, то есть на IRET. Ведь в ней явно указан адрес перехода (смещение).

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

Евгений Щербатюк

© Компьютерная газета