...
...

Позиционно-независимый код

Разрешите напомнить, какие вопросы обсуждались в прошлый раз. Отметив, насколько важна роль системного таймера компьютера, мы решили использовать его для собственных нужд. Для этого требовалось перехватить аппаратное прерывание таймера 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 Вспомним, какую работу выполняет наша процедура обработки. Как только случается int 08, команды PUSHF и CALL FAR [101] вызывают для исполнения прежнюю программу обработки, адрес которой в виде сегмент:смещение мы планируем разместить в байтах с адресами от 101 до 104. Последующие команды уменьшают значение счетчика (адрес 100), если его значение отлично от 0. Команда IRET завершает процедуру обработки прерывания.

Все последующие дополнения этой процедуры, которые придется сделать, необходимы только для решения проблемы создания позиционно-независимого, или перемещаемого, кода. Часть вопросов, требующих ответа, связана с самой природой прерываний. С ними затруднений не будет: тема хорошо изучена, и написано об этом немало. Другая часть сложностей вызвана тем, что разрабатывается низкоуровневая вставка в язык высокого уровня, а не цельная программа на языке ассемблера. По моим наблюдениям, подавляющее большинство программистов просто не знает, что делать в таких случаях. Этим и объясняются высказывания рода: "На Си - запросто, а Бейсик (Паскаль, Модула и т.д.) - не годится".

Давайте разбираться. Специфика программ обработки прерываний в том, что нельзя изменять значения регистров процессора. Обойти это требование несложно: можно делать с регистрами все, что заблагорассудится, только сначала их значения следует сохранить, а перед выходом из программы обработки - восстановить. Обычно пользуются парными командами сохранения/восстановления значений в стеке/из стека, хотя можно придумать и другие способы. Вот пример: PUSH AX PUSH BX PUSH CX ......

POP CX POP BX POP AX Сначала значения АХ, ВХ и СХ сохраняются, потом восстанавливаются. В программах обработки прерываний такие последовательности команд встречаются часто. Обратите внимание, что значения регистров восстанавливаются в порядке, обратном последовательности их сохранения. Так и должно быть: стек работает по принципу пистолетной обоймы.

Следующее затруднительное обстоятельство в том, что команды CALL FAR [101] TEST BYTE PTR [100],FF DEC BYTE PTR [100] ссылаются на данные, расположенные по адресам 100 и 101, но содержат неполные значения адресов. То есть вместо абсолютных адресов вида сегмент:смещение указаны только величины смещений. Выполняя эти команды, процессор сформирует абсолютные значения адресов, воспользовавшись текущим значением регистра данных DS. В результате получится DS:100 и DS:101. Чему равно значение DS в момент прерывания - неизвестно.

Будем рассуждать. Когда происходит прерывание, мы не знаем о значениях регистров ничего, кроме того, что регистры CS и IP содержат абсолютный адрес (сегмент:смещение) первой команды нашей программы обработки прерывания. Поскольку данные в нашей программе лежат в том же сегменте памяти, что и коды команд, то вместо значения DS процессору следовало бы пользоваться значением сегмента кода CS.

Есть два пути. Во-первых, можно явно указать процессору, значением какого регистра следует пользоваться для формирования абсолютного адреса. Делается это заданием так называемого префикса сегмента перед соответствующей командой: CS: CALL FAR [101] CS: TEST BYTE PTR [100],FF CS: DEC BYTE PTR [100] Во-вторых, можно сохранить текущее значение DS в стеке, занести в DS значение сегмента кода из CS, а перед выходом из программы обработки прерывания восстановить прежнее значение DS.

Давайте воспользуемся и первым и вторым способами. Наша программа примет следующий вид.

0D84:0100 DB 0 0 0 0 0 0D84:0105 PUSHF 0D84:0106 CS: 0D84:0107 CALL FAR [101] 0D84:010B PUSH DS 0D84:010C PUSH CS 0D84:010E POP DS 0D84:010F TEST BYTE PTR [100],FF 0D84:0114 JZ 011A 0D84:0116 DEC BYTE PTR [100] 0D84:011A POP DS 0D84:010B IRET Не смущайтесь парой соседствующих команд PUSH CS и POP DS. Это всего лишь трюк, благодаря которому в регистре DS оказывается значение CS. Смотрите: сохраняем в стеке значение из CS и сразу же извлекаем его из стека, но не в CS, а в DS. Если вы заметили, я везде говорил о сохранении/восстановлении значений регистров, то есть чисел, записанных в регистрах. Если бы сохранялись не значения, а сами регистры, трюк PUSH CS/POP DS был бы невозможен.

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

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

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

Прекрасно. Но что означают слова "должное значение сегментного регистра"? Предположим, что при запуске программы на QBASIC, куда мы встроим нашу программу обработки, она окажется загруженной в память по абсолютному адресу 055А0. Каково "должное" значение регистра CS? Давайте считать. 055А0 - адрес байта счетчика, на который мы ссылаемся как на CS:100. Запишем абсолютный адрес 055А0 в виде 054А0 + 100, то есть 054А:100.

Вы уже должны почувствовать, что о независимости нашей программы от значения CS больше нет и речи. Чтобы наши относительные ссылки были верны, мало знать абсолютный адрес области, куда загружен код, нужно еще вычислить "должное" значение сегмента кодов. Представим, что абсолютный адрес оказался равным не 055А0, как в предыдущем примере, а 055А8. Его удается представить в виде 054А:108, но в виде ХХХХ:100 - нет. Ответ на "коварный" вопрос оказался неутешительным.

Проблема позиционной независимости, или перемещаемости, или независимости от значений сегментных регистров, для кода программ даже шире, чем можно было предвидеть, думая об адресации данных. Существует проблема с адресацией условных и безусловных передач управления. В случае нашего обработчика int 08 ссылки на данные при помощи внутрисегментных смещений оказались бесполезными. Столь же бесполезными могут быть команды перехода, в которых фигурируют внутрисегментные ссылки на другие команды. Если не работает команда DEC BYTE PTR [100], то может не сработать и JZ 011A. И в первой, и во второй команде фигурируют абсолютные величины смещений.

Знаете, если бы писалась не вставка, а самостоятельная программа на ассемблере, не пришлось бы и задаваться такими вопросами. Дело в том, что DOS, запуская программы на исполнение, берет на себя всю черную работу, связанную с адресацией. Во-первых, DOS размещает программы типа *.СОМ в памяти так, чтобы абсолютные адреса загрузки всегда могли быть преобразованы к виду ХХХХ:100. Во-вторых, DOS вычисляет "должные" значения сегментных регистров и, что очень важно, заносит эти значения в соответствующие регистры. В случае программ типа *.EXE DOS, загрузив код, даже настраивает адресные ссылки команд перехода и команд, обращающихся к данным.

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

Положение спасают две вещи: режим косвенной адресации данных и наличие специального типа команд передачи управления. Начнем с последних.

Разработчики процессоров Intel предусмотрели в них три вида команд передач управления. Первые из них - безусловные, их мнемоника выглядят как JMP адрес. При выполнении таких команд передача управления происходит всегда. Другие - условные, имеют много разновидностей, скажем JC адрес или JE адрес. Они выполняют переход только при соблюдении некоторого условия. Для приведенных команд такими условиями является значение "1" бита (флага) переноса и бита (флага) нуля в регистре флагов процессора.

Последний вид команд передачи управления - вызовы процедур, мнемоника которых CALL адрес. Когда процессор исполняет команду CALL, передача управления происходит безусловно, всегда. При этом в стек заносится адрес возврата, то есть адрес команды, следующей непосредственно за командой CALL. Когда в вызванной процедуре встретится команда RET, она извлечет из стека адрес возврата и передаст управление по этому адресу - команде, записанной сразу после CALL. Таким образом, RET тоже можно считать командой безусловной передачи управления. Хорошенько запомните этот абзац! Изучим форму записи команд передачи управления, где адреса представлены константами, то есть конкретными числами. Случай адрес=сегмент:смещение интереса не представляет. Такие адреса называются далекими, передачи управления - межсегментными. Настраивать и значения сегментов, и значения смещений в зависимости от абсолютного адреса загрузки кода мы не собираемся. Кстати, мнемоники этих команд будут выглядеть так: JMP FAR XXXX:YYYY или CALL FAR XXXX:YYYY.

Случай адрес=смещение мы уже разобрали, выяснив, что необходима настройка значений сегментных регистров и/или смещений. Такие адреса называют близкими, а передачи - внутрисегментными. Мнемоники команд при этом записываются как JMP NEAR YYYY или CALL NEAR YYYY.

На наше счастье, есть и третий вариант, когда передачи управления называют короткими, а управление передается командам, отстоящим от CALL или JMP не далее 128 байт в ту или иную сторону (вперед или назад). Записываются эти команды так: JMP SHORT YYYY или CALL SHORT YYYY. Читайте внимательно: при выполнении "коротких" передач, процессор отыскивает адрес, куда следует перейти, "откладывая" смещение не от начала сегмента, а от текущего адреса команды передачи управления.

Когда мы напишем в отладчике JMP SHORT YYYY или CALL SHORT YYYY, он превратит эту запись в код, означающий JMP текущий адрес+/-YY или CALL текущий адрес+/-YY. В переводе на человеческий язык это звучит наподобие "прыгнуть отсюда на YY байт вперед/назад". Причем YYYY мы укажем как привыкли, в виде смещения от начала сегмента, а величину прыжка YY отладчик посчитает сам. Ура! Мы нашли форму записи команд передачи управления, где не требуется никаких настроек - ни сегментов, ни смещений, ни значений регистров. Такой код позиционно-независим и действительно перемещаем. Он безошибочно исполнится по любому абсолютному адресу памяти. Теперь можно вздохнуть спокойно: команда JZ 011A нас не подведет: условные переходы являются короткими (SHORT) по определению.

А как же с адресацией данных? Нам придется сделать точно такой же финт, но самостоятельно, без помощи отладчика. Будем ссылаться на данные относительно некоторого адреса, заведомо находящегося в нашей программе обработки прерывания. Для этого воспользуемся режимом косвенной адресации данных, который выглядит так: DEC BYTE PTR [BX + YY] или DEC BYTE PTR [BX - YY]. Это означает, что мы имеем право не указывать явно смещения данных относительно начала сегмента. Мы можем сослаться на данные, сказав: "наши данные лежат на расстоянии плюс/минус YY байт относительно значения которое записано в регистре BX". Осталось придумать, откуда взять значение BX.

Как вы помните, при входе в прерывание, когда начинает выполняться наша программа обработки, значения всех регистров непредсказуемы. Можно быть твердо уверенными только в том, что значения регистров CS и IP в каждый отдельный момент равны сегменту и смещению команды, которую будет исполнять процессор. Говоря проще, адрес исполняемой команды равен CS:IP. Вот, кажется, и пришла минута вынуть значение BX из стека, как фокусник вынимает из шляпы кролика. Взгляните: 0D84:0100 CALL SHORT 103 Первая часть фокуса: управление передано не куда-либо "в сторону", а на следующую команду (в данном случае - POP BX), то есть линейная последовательность исполнения команд программы не нарушена. Когда CALL передает управление по адресу 103, в стек заносится адрес возврата. Как мы знаем, он равен смещению команды, следующей за CALL, то есть 103. "Шляпа" готова.

0D84:0103 POP BX А вот и "кролик". Команда POP BX извлекла из стека значение адреса возврата и поместила его в регистр BX. После этого в регистре BX оказалось смещение команды POP BX в текущем сегменте кода. Не зря я обращал ваше внимание на трюк PUSH CS/POP DS и на разницу между понятиями "регистр" и "значение регистра". Повторюсь: если бы мы считали, что команды PUSH и POP оперируют не значениями, то есть числами, а собственно регистрами - ничего бы не вышло. Действительно, в голову не пришло бы восстанавливать из стека регистр, коли его не сохранял.

Поскольку команда CALL записана в перемещаемой форме, она будет правильно выполнена по любому абсолютному адресу CS:IP, то есть передаст управление на 3 байта вперед по адресу CS:IP+3. В стек при этом будет занесено значение IP+3. Команда POP BX расположена по абсолютному адресу CS:IP+3. После ее выполнения значение BX будет равно IP+3.

Архимеду не хватало точки опоры, чтобы перевернуть Землю. У нас она появилась. Теперь можно адресовать данные относительно смещения команды POP BX в тексте нашей программы. Так и поступим. Будем писать, что байт счетчика находится на столько-то байт до команды POP BX. Кстати, раз решено пользоваться ВХ, нужно не забыть сохранить и восстановить его значение. Окончательный вариант позиционно-независимого кода программы обработки int 08 выглядит следующим образом: 0D84:0100 DB 0 0 0 0 0 0D84:0105 PUSH BX 0D84:0106 CALL SHORT 0109 0D84:0109 POP BX 0D84:010A PUSHF 0D84:010B CS: 0D84:010C CALL FAR [BX-08] 0D84:010F PUSH DS 0D84:0110 PUSH CS 0D84:0111 POP DS 0D84:0112 TEST BYTE PTR [BX-09],FF 0D84:0116 JZ 011B 0D84:0118 DEC BYTE PTR [BX-09] 0D84:011B POP DS 0D84:011C POP BX 0D84:011D IRET Программа обработки прерывания готова. В следующий раз обсудим способ, которым нужно перехватить прерывание, то есть как назначить прерыванию программу обработки собственного изготовления.

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

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