Страница Ивана Рощина > Статьи >

© Иван Рощин, Москва

ZXNet: 500:95/462.53
E-mail: bestview@mtu-net.ru
WWW: http://www.ivr.da.ru

Недокументированная особенность процессора Z80

(ZX.SPECTRUM,
 Deja Vu #8,
 Voyager #3,
 «Радиолюбитель. Ваш компьютер» 4/2000 (дополненная версия))
Дата последнего редактирования: 9.04.2003.

С чего всё началось
Ситуация начинает проясняться
Зависания: дубль второй
Ложный след
Приём «упрощение программы»
Неужели ошибка в процессоре?
Окончательное подтверждение
К чему это приводит и что делать?
Как это можно использовать?
Исправление STS 6.2
Послесловие

С чего всё началось

Писал я как-то очередную версию программы BestView (v2.4), и использовал в ней вот такой фрагмент:

        ....
        EI
        CALL    SUBR1
        HALT
        ....


SUBR1   LD      A,R
        PUSH    AF
        DI

        ....

        POP     AF
        DI
        RET     PO
        EI
        RET

В этом фрагменте происходит вызов процедуры SUBR1, которая на время своей работы запрещает прерывания, а при выходе восстанавливает прежний режим их работы.

Проверка того, разрешены или запрещены прерывания при вызове процедуры, и восстановление режима прерываний происходит следующим образом:

Я стал замечать, что при работе этого фрагмента BestView зависает — не всегда, и даже не слишком часто, а в очень редких случаях. Но всё равно это было не очень-то приятно. Программа, вроде бы, не содержала никаких ошибок, по крайней мере с первого взгляда ничего подозрительного я не заметил. Оставалось лишь прибегнуть к более сильным средствам…

Ситуация начинает проясняться

После очередного зависания я вставил чистый диск и уверенно нажал кнопку «MAGIC». Затем загрузил отладчик «STS 6.2 +@» (не зря я его переделывал: теперь с его помощью после загрузки @-файла можно восстановить содержимое регистров процессора на момент сброса программы на диск). Нажатие пары клавиш — и вот я вижу, в каком месте программы произошло зависание.

        ....
        EI
        CALL    SUBR1
        HALT <------------ Вот здесь!
        ....

Типичный случай: прерывания запрещены, и процессор прекратил выполнение программы на команде HALT. Но почему прерывания оказались запрещены — неясно. Ведь перед вызовом процедуры SUBR1 они были разрешены командой EI, а после окончания работы SUBR1 они тоже должны быть разрешены: процедура SUBR1 не должна оказывать влияния на режим их работы.

Трассирую SUBR1. Всё идёт как положено: и при входе, и при выходе прерывания остаются разрешёнными. Повторяю трассировку: раз, другой, …, десятый. Всё идёт нормально.

А может быть, дело в том, что в SUBR1 что-то происходит со стеком? И из-за этого иногда неправильно восстанавливается содержимое AF? Надо бы проверить…

Зависания: дубль второй

Ну вот, переделал программу. Теперь уж точно буду знать, в чём дело:

SUBR1   LD      A,R
        PUSH    AF
        DI

        PUSH    HL
        PUSH    AF
        POP     HL
        LD      (WR_HH1),HL
        POP     HL

        ....

        POP     AF

        PUSH    HL
        PUSH    AF
        POP     HL
        LD      (WR_HH2),HL
        POP     HL

        DI
        RET     PO
        EI
        RET

WR_HH1  DW  0
WR_HH2  DW  0

Содержимое AF теперь запоминается не только в стеке, но и в переменной WR_HH1 (для контроля), а при выходе из процедуры — снятое со стека значение запоминается в WR_HH2. Если процедура работает правильно, WR_HH1 и WR_HH2 должны совпадать, а флаг P/V — быть установленным.

Запускаю… Вот уже минуту BestView работает нормально… Просматриваю с её помощью тот самый файл, при просмотре которого она зависла в прошлый раз… Ну вот, опять! Да и неудивительно, ведь причину я не устранил. Ладно, будем разбираться.

Снова нажимаю «MAGIC», загружаю «STS 6.2 +@» и сразу же проверяю значения WR_HH1 и WR_HH2. И там, и там записано #5908. Значения совпадают — следовательно, при работе со стеком ошибок не было. Но если в регистре флагов содержится #08 — значит, флаг P/V сброшен, и при вызове процедуры SUBR1 прерывания были запрещены.

Но это же совершенно невозможно! Ведь в программе стоит EI: CALL SUBR1! Наверное, просто Спектрум перегрелся, и потому такие глюки. Ничего более умного я в этот день так и не придумал.

Ложный след

На следующий день я нашёл возможное объяснение таинственному запрещению прерываний. Допустим, после команды EI, но до выполнения команды LD A,R произошло прерывание. Как известно, процедура его обработки должна заканчиваться командами EI: RET (потому что в начале обработки происходит автоматическое запрещение прерываний). Если же обработчик прерываний завершается просто командой RET, то прерывания останутся запрещёнными.

Конечно, вероятность того, что прерывание произойдёт именно между командами EI и LD A,R, очень мала, но ведь и зависания происходят очень редко. Так что это лишний раз подтверждало мою гипотезу.

Тем не менее, оставалось неясным, с чего бы это обработчик прерываний завершался командой RET, а не EI: RET. Я решил проверить, действительно ли в этом всё дело, и для этого добавил после EI команду HALT (см. ниже). Если обработчик прерываний действительно завершается некорректно, то после добавленного HALT’а прерывания всегда будут запрещены, и, соответственно, BestView всегда будет зависать.

        ....
        EI
        HALT <------------ Добавленная команда
        CALL    SUBR1
        HALT
        ....


SUBR1   LD      A,R
        PUSH    AF
        DI

        ....

        POP     AF
        DI
        RET     PO
        EI
        RET

Компилирую, запускаю… Совершенно неожиданный результат: зависания полностью прекратились! Хотел всё так и оставить, но всё же решил разобраться, почему так получается.

Приём «упрощение программы»

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

        ORG     #6000

        EI

M1      CALL    SUBR1
        JR      M1

SUBR1   LD      A,R
        DI
        JP      PO,M2
        EI
        RET
M2      LD      A,4
        OUT     (254),A
        RET

Вот такая программа, всего 19 байтов. Разрешаются прерывания, и в бесконечном цикле вызывается процедура SUBR1. Эта процедура устанавливает зелёный цвет бордюра, если при входе в неё прерывания были запрещены, и не меняет цвет бордюра, если прерывания разрешены. Таким образом, если произойдёт самопроизвольное запрещение прерываний, это сразу же будет заметно.

Запускаю — да, бордюр становится зелёным. Причина столь странного поведения программы остаётся неизвестной. Может быть, в этом виновата процедура обработки прерываний 1-го рода? Добавляю к программе несколько команд, устанавливающих режим IM 2 с обработчиком, состоящим всего из двух команд: EI: RET.

        ORG     #6000

        LD      HL,#8000
        LD      (HL),#81
        LD      DE,#8001
        LD      BC,#100
        LDIR
        LD      A,#80
        LD      I,A
        IM      2

        EI

M1      CALL    SUBR1
        JR      M1

SUBR1   LD      A,R
        DI
        JP      PO,M2
        EI
        RET
M2      LD      A,4
        OUT     (254),A
        RET

        ORG     #8181

        EI
        RET

Запускаю — тот же результат! Хотя при трассировке и этой, и предыдущей программы в отладчике бордюр остаётся чёрным. Внимательное изучение программы приводит к предположению: может быть, команда LD A,R иногда устанавливает бит P/V так, как будто прерывания запрещены, в то время как на самом деле они разрешены?

Неужели ошибка в процессоре?

Ещё раз изменяю программу. Теперь прерывания вообще не будут запрещаться (убрана команда DI). Если при выполнении команды LD A,R бит P/V станет равным 0, бордюр на некоторое время станет зелёным (для этого предусмотрена задержка):

        ORG     #6000

        LD      HL,#8000
        LD      (HL),#81
        LD      DE,#8001
        LD      BC,#100
        LDIR
        LD      A,#80
        LD      I,A
        IM      2

        EI

M1      CALL    SUBR1
        JR      M1

SUBR1   LD      A,R
        RET     PE

        LD      A,4
        OUT     (254),A
        LD      HL,0
        LD      DE,0
        LD      BC,#600
        LDIR             ;WAIT
        XOR     A
        OUT     (254),A
        RET

        ORG     #8181

        EI
        RET

Запускаю… И что я вижу? Верхняя часть бордюра мигает зелёным цветом!

Рис. 1

Это говорит о том, что, во-первых, команда LD A,R действительно иногда неверно устанавливает бит P/V, и, во-вторых, — что это происходит в момент прихода прерывания, а не когда угодно (действительно, тогда бы бордюр мигал зелёным в совершенно произвольных местах).

То, что верхняя часть бордюра мигает, а не постоянно окрашена в зелёный цвет, тоже получает своё объяснение. По-видимому, команда LD A,R неправильно работает лишь тогда, когда импульс прерывания приходит во время её выполнения, а так бывает далеко не всегда: прерывание может произойти и во время выполнения другой команды.

Окончательное подтверждение

Проверим этот факт. Пусть обработчик прерываний определяет, в каком месте была прервана программа. Если она была прервана именно после команды LD A,R, пусть бордюр на некоторое время станет жёлтым.

        ORG     #6000

        LD      HL,#8000
        LD      (HL),#81
        LD      DE,#8001
        LD      BC,#100
        LDIR
        LD      A,#80
        LD      I,A
        IM      2

        EI

M1      CALL    SUBR1
        JR      M1

SUBR1   LD      A,R
BP1     RET     PE

        LD      A,4
        OUT     (254),A
        LD      HL,0
        LD      DE,0
        LD      BC,#600
        LDIR             ;WAIT
        XOR     A
        OUT     (254),A
        RET


        ORG     #8181

        EXX
        EX      AF,AF'

        POP     HL
        PUSH    HL
        LD      DE,BP1
        AND     A
        SBC     HL,DE
        JR      NZ,NE_BP1

        LD      A,6
        OUT     (254),A
        LD      HL,0
        LD      DE,0
        LD      BC,#600
        LDIR             ;WAIT

NE_BP1  EXX
        EX      AF,AF'

        EI
        RET

Если неправильная работа команды LD A,R не связана с тем, что во время её выполнения приходит импульс прерывания, то мы увидим, как верхняя часть бордюра будет мигать то зелёным цветом, то жёлтым. Но если связь между этими двумя событиями существует, то мы должны увидеть, как верхняя часть бордюра мигает жёлтым цветом, а нижняя часть — зелёным, причём они должны мигать совершенно синхронно.

Запускаю — и вижу именно то, что и предполагал. Действительно, такая связь существует.

Рис. 2

Но как могут быть связаны прерывания и работа команды LD A,R? Эта команда помещает во флаг P/V содержимое триггера прерываний IFF2. При разрешённых прерываниях этот триггер равен 1, а когда приходит импульс прерывания, он автоматически сбрасывается в 0, чтобы исключить повторную обработку прерывания. Но обработка запроса на прерывание начинается во время выполнения последнего такта выполняемой команды (т.е. команды LD A,R). И, видимо, уже сброшенный триггер IFF2 копируется во флаг P/V (действительно, с точки зрения процессора, прерывания в этот момент уже запрещены).

Всё сказанное относится и к команде LD A,I. Приведённая информация была проверена на оригинальном процессоре Z80 фирмы ZiLOG и на отечественном аналоге КР1858ВМ1.

К чему это приводит и что делать?

Применение команд LD A,R и LD A,I для определения состояния триггера прерываний, вообще говоря, используется во многих программах (и даже в ПЗУ TR-DOS). Вот вам и объяснение некоторого числа странных зависаний. Кажется, будто вероятность прихода импульса прерывания именно во время выполнения команды LD A,R невелика. Но, во-первых, вероятность увеличивается за счёт того, что эта команда может выполняться в программе не один раз (а для зависания достаточно единственного неверного выполнения), и, во-вторых, если в программе до этого встречалась команда HALT, т.е. синхронизация с прерываниями, то может случиться так, что команда LD A,R будет каждый раз выполняться в то время, когда наиболее вероятно очередное прерывание (так и было в BestView).

Итак, этот способ ненадёжен. Как же быть? Оказывается, можно со 100% точностью определять состояние триггера прерываний по следующему простому правилу:

Вот соответствующий фрагмент программы:

SUBR1   LD      A,R
        JP      PE,M1
        LD      A,R
M1      PUSH    AF
        DI

        ....

        POP     AF
        DI
        RET     PO
        EI
        RET

Как это можно использовать?

С помощью команды LD A,R удобно выполнять тестирование процессора, чтобы распознать выполнение программы под эмулятором. Эмулятор выполняет команды Z80 последовательно, одну за другой, и команда LD A,R всегда будет правильно устанавливать флаг P/V. А в реальном Z80 это не так.

Вот простейшая процедура для тестирования процессора, которая возвращает в аккумуляторе 1, если она запущена на реальном Z80, и 0 в противном случае. Она пытается 65536 раз прочитать регистр R при разрешённых прерываниях, и если при этом хотя бы один раз флаг P/V установится в 0 — делается вывод, что процедура работает на реальном Z80.

TESTZ80 EI
        LD      BC,0
M1      LD      A,R
        JP      PO,QUIT
        INC     BC
        LD      A,B
        OR      C
        JR      NZ,M1
        RET
QUIT    LD      A,1
        RET

Если распознался эмулятор, можно либо прекращать выполнение программы (своеобразная защита), либо отключать некоторые участки программы, которые могут неверно работать под эмулятором (например, вместо прямой работы с ВГ93 использовать точку входа #3D13 и т.п.).

Исправление STS 6.2

В известном отладчике STS определение состояния триггера прерываний также происходит с помощью команды LD A,R. Из-за этого может неправильно выполняться трассировка программы. При трассировке STS запускает каждую команду (кроме команд передачи управления) с помощью резидента, а после окончания её выполнения запоминает содержимое регистров процессора и состояние триггера прерываний. Вот тут и возможны ошибки.

Допустим, прерывания разрешены и трассируется такая простейшая программа:

        #8000   NOP
        #8001   JR      #8000

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

Совершенно очевидно, что в STS надо внести исправления. Вот как это сделать для версии 6.2.

Сначала нужно запустить STS и загрузить файл «sts6.2 <C>», в котором и будут производиться исправления.

Затем нужно отыскать свободные 14 байтов — их назначение будет объяснено ниже. Можно использовать буфер функции пользователя (с адреса #FE37). Но в той версии STS, которую я использую, этот буфер занят под процедуру дизассемблирования с метками ассемблера ZX ASM, поэтому я решил сократить некоторые текстовые сообщения:

"Block"    —> "Bl."  (экономится 2 байта)
"Save"     —> "S."   (----/----- 2 --/--)
"Load"     —> "L."   (----/----- 2 --/--)
" DEFB"    —> " "    (----/----- 4 --/--)
"FileName" —> "Name" (----/----- 4 --/--)

Для этого с адреса #EB24 нужно ввести следующую последовательность байтов:

#EB24: AE 46 72 6F ED 54 EF 46
#EB2C: 69 6C E5 53 65 63 74 6F
#EB34: F2 53 AE 4C AE 53 74 6F
#EB3C: 70 20 69 E6 42 61 6E EB
#EB44: 51 75 69 F4 54 72 61 63
#EB4C: E5 53 74 61 72 F4 44 69
#EB54: 73 61 73 ED A0 46 69 6C
#EB5C: E5 42 41 53 49 C3 20 44
#EB64: 4F D3

По адресу #E702 заменяем значение #0A на #0E, чтобы правильно печаталось имя файла (т.к. вместо строки «FileName» осталось просто «Name»).

Итак, теперь с адреса #EB66 свободно 14 байтов. Смотрим, где в STS происходит определение состояния триггера прерываний:

        #DFFE:  LD (#5BA1),SP
                LD SP,#5BA1
                PUSH BC
                PUSH AF
                LD A,R
                DI
                LD BC,#7FFD
                LD A,#1F
                OUT (C),A
                LD B,#BF
                LD A,#00
                OUT (C),A
                JP #E028

Заменим команды LD A,R: DI на NOP, а команду JP #E028 — на JP #EB66. С адреса #EB66 поместим такой фрагмент:

        #EB66:  LD A,R
                JP PO,#EB6E \
                NOP         |
             .- JR #EB70    |
             |  LD A,R  <---'
             `> DI
                JP #E028

Обратите внимание: этот фрагмент в любом случае при своей работе увеличивает регистр R на одинаковую величину (на 7). Дело в том, что далее будет выполнена ещё одна команда LD A,R, на этот раз нужная уже для определения значения регистра R, и будет произведена коррекция полученного значения, т.к. значение регистра R увеличивается с каждой выполненной командой, а надо узнать его значение на момент окончания выполнения трассируемой команды. Вот как это выглядит:

        #DCA2:  LD A,#5A
                LD HL,#FEF4
                SLA (HL)
                RLA
                ADD A,(HL)
                RRCA
                LD (HL),A
                RET

Константу #5A по адресу #DCA3 следует заменить на #53, т.е. уменьшить на 7: ведь в программу был добавлен дополнительный фрагмент, увеличивающий регистр R на 7, и надо скомпенсировать это изменение.

После этого остаётся только записать изменённый файл на диск.

Послесловие

С тех пор, как эта статья была написана, прошло уже много времени, и у меня накопилась дополнительная информация по рассматриваемому вопросу:

  1. Уже появились эмуляторы, которые выполняют команды LD A,R и LD A,I так же, как и реальный Z80. Это, например, эмулятор ZXEmul, начиная с версии 0.33 (автор — Владимир Юдин). Так что описанный в статье способ распознавания выполнения программы под эмулятором теряет свою универсальность.
  2. Выяснилось, что зарубежные программисты давно знали о том, что команды LD A,R и LD A,I не всегда выполняются правильно. Так, в программе «Pack Maker v2.2» (Copyright Thixoft 1989) можно встретить фрагмент, где при определении состояния прерываний дважды выполняется команда LD A,I.
  3. Как оказалось, ZiLOG знал об этой проблеме ещё с 1977 года! О ней упоминается и в фирменном описании Z80. В CMOS-версии Z80 проблема была устранена. Желающим узнать об этом подробнее рекомендую прочесть статью (© Mac Buster^XTM & UnBEL!EVER^XTM) в электронной газете «BornDead» #0E.

    Остаётся лишь пожалеть, что в отечественной литературе такой важный вопрос не был освещён. Это тем более странно, что различные «справочные пособия» по микропроцессору Z80 были основаны на фирменной документации.

  4. По поводу исправления STS 6.2. Рекомендую исправить и другую ошибку, связанную с неправильным позиционированием при работе с диском (см. моя статья «Ошибка в STS 6.2 и её исправление»). Уже исправленную версию STS вы можете получить по Fido или E-mail (см. адреса в начале статьи).
  5. Как уже упоминалось, для правильного определения состояния прерываний вместо одной команды LD A,R нужно использовать такой фрагмент:
            LD      A,R
            JP      PE,M1
            LD      A,R
    M1      ............
    

    Если состояние прерываний требуется определять более чем в одном месте, имеет смысл реализовать этот фрагмент в виде подпрограммы:

            LD      A,R
            RET     PE
            LD      A,R
            RET
    

    Но если состояние прерываний нужно определять в одиннадцати и более местах программы, и при этом установлен режим прерываний IM 2, то можно ещё сильнее сократить объём! Для этого в программе надо использовать команду LD A,R, а обработчик прерываний должен проверять, в каком месте была прервана программа, и уменьшать адрес возврата на два, если она была прервана после команды LD A,R. Тогда после возврата процессор ещё раз выполнит эту команду, правильно установив флаги.

    Вот пример обработчика прерываний, решающего эту задачу:

    ;Запоминаем AF:
    
               PUSH HL
    
               PUSH AF
               POP  HL
               LD   (SAVE_AF),HL
    
               POP  HL
    
    ;Проверяем, после какой команды
    ;произошло прерывание:
    
               EX   (SP),HL
               DEC  HL
               LD   A,(HL)
               CP   #5F
               JR   NZ,NO_LD_A_R
    
               DEC  HL
               LD   A,(HL)
               CP   #ED
               JR   Z,YES_LD_A_R
    
               INC  HL
    NO_LD_A_R  INC  HL
    
    YES_LD_A_R EX   (SP),HL
    
    ;Сохраняем в стеке HL и ранее
    ;запомненное значение AF:
    
               PUSH HL
               LD   HL,0
    SAVE_AF    EQU  $-2
               PUSH HL
    
    ;Если в вашей процедуре обработки
    ;прерываний используются ещё какие-либо
    ;регистры, они также должны быть
    ;сохранены с последующим восстановлением
    
    ......... (ваша процедура обработки)
    
               POP  AF
               POP  HL
               EI
               RET
    

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

               JR   M1 (или другая команда
                       передачи управления на M1)
               .......
               .......
               LD   A,R
    M1         .......
    

    Ведь если прерывание произойдёт после команды JR M1, то обработчик не сможет отличить эту ситуацию от той, когда прерывание произошло после команды LD A,R. И в результате команда LD A,R будет выполнена после выхода из обработчика, что совершенно не нужно.

    Также надо учитывать, что код команды LD A,R — это два байта: #ED #5F. И если в вашей программе встретится такое:

               DB   #XX,#XX,#XX,#ED  ;Какие-то данные.
    M1         LD   E,A              ;Код этой команды - #5F!
    

    то в случае, когда прерывание произойдёт после команды LD E,A, обработчик опять-таки подумает, что прерывание произошло после команды LD A,R, что приведёт к неверным действиям.

  6. Допустим, в вашей программе имеется много подпрограмм, которые на время своей работы должны запрещать прерывания, а при выходе — восстанавливать их состояние. В этом случае может оказаться полезной процедура, которая определяет состояние прерываний, запрещает прерывания, и, если они были разрешены, заносит в стек адрес #0051. В начале каждой вашей подпрограммы вы вызываете эту процедуру, а при выходе из подпрограммы по команде RET произойдёт вот что: если при входе прерывания были запрещены, то и при выходе они останутся запрещёнными, а если при входе прерывания были разрешены, то управление будет передано на снятый со стека адрес #0051 (а там находятся команды EI, RET), и, соответственно, прерывания окажутся разрешёнными.

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

    INT_AND_DI PUSH AF
    
               LD   A,R
               JP   PE,NOW_EI
               LD   A,R
               JP   PE,NOW_EI
    
    ;Прерывания при вызове запрещены -
    ;просто выходим:
    
               POP  AF
               RET
    
    ;Прерывания при вызове разрешены -
    ;запрещаем их, заносим в стек #51
    ;и выходим:
    
    NOW_EI     DI
    
    ;Далее выполнение идет при запрещённых
    ;прерываниях, поэтому сохранение HL в
    ;теле процедуры не нанесёт ущерба её
    ;реентерабельности.
    
               POP  AF
    
               LD   (INT_S_HL),HL
               LD   HL,#0051
               EX   (SP),HL
               PUSH HL      ;Адрес возврата.
               LD   HL,0
    INT_S_HL   EQU  $-2
    
               RET
    

Другие мои статьи о недокументированных особенностях процессора Z80:

1. 

«Время выполнения недокументированных команд процессора Z80». «ZX-Ревю» 7—10/1997.

2. 

«Влияние команды OUTD на флаг переноса». «Радиолюбитель. Ваш компьютер» 5/2001, под псевдонимом BV_Creator.

Страница Ивана Рощина > Статьи >