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

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

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

Менеджер вызова подпрограмм из различных банков памяти

Радиомир. Ваш компьютер» 12/2001, 2/2002, 4/2002)
Дата последнего редактирования: 26.03.2003.

Немного теории
Подпрограммы в верхней памяти и особенности их вызова
Менеджер вызова: что это такое и как он работает
Листинг менеджера вызова
Возможности оптимизации

Немного теории

Адресное пространство процессора Z80 невелико — всего 64 килобайта. Для доступа к большему количеству памяти в компьютере ZX Spectrum 128 используется страничная адресация. Оперативная память (а именно она будет нас интересовать) разбита на банки по 16 килобайтов (всего получается 8 банков с номерами от 0 до 7). На адреса #4000—#7FFF и #8000—#BFFF постоянно подключены банки 5 и 2 соответственно, а на адреса #C000—#FFFF может быть подключён любой из банков (рис. 1).

Рис. 1

Банки 5 и 2 я буду в дальнейшем называть нижней памятью. Они всегда находятся в адресном пространстве процессора. Все остальные банки (их я буду называть верхней памятью) не обладают этим свойством. Только один из них может быть подключён.

Номер подключённого на адреса #C000—#FFFF банка памяти задаётся битами 0, 1 и 2 числа, выводимого в порт #7FFD. Чтение из этого порта невозможно. Поэтому, чтобы можно было узнать, какой банк подключён, надо при каждом выводе в порт запоминать выводимое значение в специальной переменной.

При выводе в порт мы не можем обратиться только к трём младшим его разрядам, не трогая остальные. Поэтому придётся рассказать и о назначении других битов порта #7FFD. В третьем бите указывается номер видеостраницы: 0 — стандартная, 1 — расположенная в 7 банке памяти. В четвёртом бите — номер подключённого банка ПЗУ: 0 — BASIC-128, 1 — BASIC-48. И, наконец, вывод единицы в пятый бит приведёт к отключению дополнительной памяти до аппаратного «сброса» компьютера. Остальные (6 и 7) биты в ZX Spectrum 128 не используются.

Подпрограммы в верхней памяти и особенности их вызова

При написании программы может возникнуть необходимость размещения отдельных её подпрограмм в различных банках верхней памяти. Причины этого могут быть самыми разными. Может быть, программа получается настолько большой, что по-другому просто не умещается в памяти. Может быть, требуется выделить как можно больше нижней памяти под данные, которые должны быть всегда доступны из любого банка памяти, и из-за этого приходится уменьшать место, занимаемое в нижней памяти кодом программы, перенося большую его часть в верхнюю память. Может быть, программа пишется с учетом неодинаковой скорости доступа к различным банкам памяти (такой особенностью обладает как фирменный ZX Spectrum 128, так и некоторые совместимые модели), и исполняемый код требуется размещать только в «быстрых» банках. А может быть, для обработки данных нужен непрерывный участок памяти как можно большей длины.

Так вот, при обращении к размещённым в верхней памяти подпрограммам, если банк, в котором находится вызываемая подпрограмма, не подключён, возникают сложности. Как, например, вызвать из нижней памяти подпрограмму, находящуюся в не подключённом на данный момент банке верхней памяти (рис. 2, стрелка 1)? Или как вызвать из подключённого банка верхней памяти подпрограмму, находящуюся в другом банке верхней памяти (рис. 2, стрелка 2)?

Рис. 2

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

Процедура SETPORT - установка банка памяти.
Вход: A - выводимое в порт #7FFD значение.
Выход: значение выведено в порт и записано в переменную BANK.
Значения регистров: не изменены.

SETPORT    PUSH BC
           LD   BC,#7FFD
           LD   (BANK),A
           OUT  (C),A
           POP  BC
           RET

BANK       DS   1   ;Хранится текущее состояние порта #7FFD.

При вызове этой процедуры в аккумуляторе должно содержаться уже подготовленное для вывода в порт значение, т.е. кроме того, что в битах 0—2 должен быть номер требуемого банка памяти, остальные биты также должны быть установлены соответствующим образом (см. предыдущий раздел). Если, например, при работе программы подключено ПЗУ BASIC-48, для вывода изображения используется стандартная видеостраница, и надо подключить 3-й банк ОЗУ, то для этого нужно вывести в порт #7FFD число #13.

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

И ещё, обратите внимание: в процедуре сначала производится запись выводимого значения в переменную BANK, а только потом — вывод в порт #7FFD. Почему нужна именно такая последовательность действий? Предположим, что между командами записи и вывода в порт произошло прерывание, а процедура обработки прерывания устроена так: сначала она запоминает, какой банк памяти был подключён, по содержимому переменной BANK, после этого устанавливает нужный ей банк, выполняет какие-то действия, а затем восстанавливает «старый» банк памяти. Так вот, если бы процедура SETPORT сначала выводила значение в порт, а потом записывала в переменную BANK, то в этом случае после окончания обработки прерывания оказался бы установлен банк памяти, соответствующий старому значению переменной BANK. А это нам совершенно ни к чему.

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

Последовательность необходимых для этого действий такова:

Тогда вызов подпрограммы можно оформить следующим образом:

           LD   A,(BANK) ;Запоминаем номер
           PUSH AF       ;текущего банка.
           LD   A,N      ;Устанавливаем банк, в котором
           CALL SETPORT  ;находится вызываемая подпрограмма.
           CALL subrout  ;Вызываем её.
           POP  AF       ;Устанавливаем банк,
           CALL SETPORT  ;который был до вызова.

Как видим, по сравнению с обычным CALL’ом тратятся лишних 13 байтов. И это ещё не всё. Если для передачи данных в вызываемую подпрограмму используется аккумулятор, то может потребоваться сохранение его значения (например, в каком-либо свободном регистре) перед установкой нужного банка памяти и восстановление перед вызовом подпрограммы, а на это также потратятся лишние байты. Точно так же, если аккумулятор (или регистр флагов!) используется для возвращения результата работы подпрограммы, возможно, потребуется где-то сохранять его значение (я говорю «возможно», потому что в некоторых случаях можно сразу же обработать возвращённый подпрограммой результат и уже потом, когда он больше не нужен, выполнить команды POP AF: CALL SETPORT). В итоге, как видим, дополнительные 13 байтов на вызов подпрограммы — это минимум, а в действительности может быть и больше.

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

Рассмотрим теперь другой случай — вызов из подключённого банка верхней памяти подпрограммы, находящейся в другом банке верхней памяти. Очевидно, что фрагмент программы, переключающий банки, придётся вынести в нижнюю память. В результате вызов будет выглядеть примерно так:

;В банке памяти, откуда происходит вызов:

           CALL l_subrout

;В нижней памяти:

l_subrout  LD   A,(BANK) ;Запоминаем номер банка верхней памяти,
           PUSH AF       ;откуда был вызов.
           LD   A,N      ;Устанавливаем банк, в котором
           CALL SETPORT  ;находится вызываемая подпрограмма.
           CALL subrout  ;Вызываем её.
           POP  AF       ;Устанавливаем банк, откуда был вызов,
           JP   SETPORT  ;и возвращаем туда управление (JP здесь
                         ;используется вместо CALL: RET, чтобы
                         ;сэкономить байт).

Как видите, на каждую вызываемую таким образом подпрограмму тратится, не считая CALL, ещё не менее 16 байтов.

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

Менеджер вызова: что это такое и как он работает

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

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

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

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

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

Вопрос 1. Как передавать менеджеру номер вызываемой подпрограммы?

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

Указывать номер в байте, непосредственно следующем за командой вызова менеджера? Тогда в каждой точке вызова будет тратиться лишний байт (а если подпрограмм больше 256, то даже два байта). Казалось бы, один байт — пустяки, но если подсчитать общее количество вызовов… К тому же затруднится отладка программы из-за того, что отладчик будет считать байт номера байтом начала следующей команды.

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

;Точки входа:

subr_1     NOP  ;Для вызова подпрограммы SUBR_1.
subr_2     NOP  ;Для вызова подпрограммы SUBR_2.
...............................................
subr_n     NOP  ;Для вызова подпрограммы SUBR_N.

;Началась обработка...

Тогда можно установить, какая точка входа была использована. Пусть n — адрес команды обращения к менеджеру (рис. 3). При её выполнении в стек будет помещён адрес первого байта следующей команды — n+3. Взяв этот адрес и уменьшив его на два, мы получим адрес n+1, с которого размещается адрес точки входа. Взяв адрес точки входа и отняв от него адрес первой точки входа (subr_1), мы получим номер использованной точки входа, т.е. номер вызванной подпрограммы. Легко и просто, не правда ли? Номер не требуется явно указывать ни в регистрах, ни в памяти, и на это не тратятся лишние байты!

Рис. 3

Тем не менее, у этого способа есть свои особенности, которые надо учитывать.

Во-первых, при вызове подпрограммы какое-то время будет затрачено на выполнение цепочки NOP’ов (4 такта на каждый). Чем меньше порядковый номер точки входа, тем больше будет длина этой цепочки и, соответственно, задержка. Так что, если для каких-то подпрограмм задержка при вызове нежелательна, лучше располагать их точки входа последними.

Во-вторых, часто используют такой приём оптимизации: последовательность CALL subrout: RET заменяют просто на JP (или JR) subrout, тем самым экономя память и повышая быстродействие (выигрыш составляет 1 байт/17 тактов для JP и 2 байта/15 тактов для JR). Но обращение к менеджеру должно происходить только с помощью команды CALL! Иначе в стек не будет занесён адрес следующей после CALL команды и, соответственно, нельзя будет определить номер вызванной подпрограммы. Так что данный способ оптимизации в случае, когда подпрограмма вызывается с помощью менеджера, использовать нельзя.

Если имена самих подпрограмм записывать прописными буквами, а имена точек входа — строчными, то в тексте программы сразу будет видно, какая подпрограмма как вызывается: с использованием менеджера или нет. Видим в тексте, например, CALL PRINT — это обычный вызов. Видим CALL cls — это вызов с использованием менеджера. Так что сразу становится понятно, где можно выполнять оптимизацию, а где нельзя.

Вопрос 2. Как определять номер банка, в котором находится вызываемая подпрограмма?

Наиболее естественный ответ — по таблице. Можно в каждом байте таблицы хранить номер банка соответствующей подпрограммы, уже подготовленный для вывода в порт. Можно использовать тот факт, что на хранение номера банка требуется только три бита, и хранить данные более экономно: в одном байте — два номера, или (что более сложно) в трёх байтах — восемь номеров. Памяти будет расходоваться меньше, но придётся затратить больше времени на извлечение номера банка из таблицы и подготовку к выводу в порт.

Но обратите внимание: с одной стороны, таблица неизбежно займёт дополнительное место в памяти, а с другой — мы имеем столько же занятых командами NOP ячеек памяти, сколько имеется подпрограмм. Нельзя ли их совместить?

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

Табл. 1
Команда Код Три младших бита кода
LD A,A#7F=%011111117
LD B,B#40=%010000000
LD C,C#49=%010010011
LD D,D#52=%010100102
LD E,E#5B=%010110113
LD H,H#64=%011001004
LD L,L#6D=%011011015

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

Вот она, красота кода!!! Команды, которые казались совершенно бесполезными, вдруг оказались единственно нужными! При этом ещё смотрите, как удачно получается: у всех семи NOP-подобных команд разные три младших бита кода (см. табл. 1). Так что удобно сопоставить каждой из этих команд банк памяти, номер которого — три младших бита кода этой команды. Тогда для определения номера банка по коду команды достаточно будет обнулить пять старших битов командой AND %111.

Как можете видеть, в таблице нет команды, у которой три младших бита кода равны 6. Поэтому шестому банку мы поставим в соответствие команду NOP. Код этой команды — 0. Было бы, конечно, гораздо удобнее, если бы три младших бита кода NOP были бы равны 6 (или если бы среди семи NOP-подобных команд не оказалось команды с тремя младшими битами кода, равными 0): тогда при определении номера банка по коду команды не потребовалось бы дополнительных проверок. Но чего нет, того нет…

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

Но если в реентерабельном участке кода требуется что-то сохранить, а затем восстановить, то использовать для этого можно только стек! В самом деле, смотрите, что будет, если сохранять значения в фиксированных ячейках памяти: если между сохранением и восстановлением произойдёт прерывание, и процедура обработки прерывания обратится к этому же участку кода, то сохранение опять будет выполнено в те же ячейки памяти, и их первоначальное значение будет потеряно!

Но если мы сначала сохраним в стеке значения используемых регистров, то уже не сможем получить доступ к адресу возврата, ведь он теперь не будет на вершине стека! И это не единственная коллизия такого рода. Смотрите: перед запуском подпрограммы нам надо запомнить в стеке номер текущего банка памяти, чтобы потом, после запуска, восстановить его. Но если сначала мы запомним в стеке значения регистров, а потом — номер банка, то как будем восстанавливать значения регистров перед вызовом подпрограммы? Они ведь уже не будут на вершине стека! И ещё: как будем извлекать из стека этот номер банка после окончания работы запущенной подпрограммы? Перед этим придётся сохранить в стеке значения используемых регистров, а значит, номер банка уже не будет на вершине стека. И как же быть???

Вопрос 4. А как, собственно, запускать подпрограмму? Пусть нам известен её адрес, ну и что? Записать этот адрес в поле операнда команды CALL и выполнить её? Ага, разбежались! :–) Ещё раз повторю: если в реентерабельном участке кода требуется что-то сохранить (в данном случае адрес запуска), чтобы потом использовать (в данном случае при выполнении команды CALL), то фиксированные ячейки памяти (в данном случае поле операнда команды CALL) использовать для этого нельзя!

Ответы на эти два вопроса заключаются в нетрадиционном использовании стека и команд работы с ним. Думаю, лучший способ объяснить это — подробно рассмотреть все манипуляции со стеком в менеджере.

На рисунках справа от каждого элемента стека указывается его длина в байтах. Вершина стека изображена сверху, но учитывайте, что в памяти стек хранится перевёрнутым: вершине соответствует самый низкий адрес, или, как говорят, стек растёт вниз. Адрес вершины стека содержится в регистре SP.

Исходное состояние при вызове менеджера:

Адрес возврата2
.................

Резервируем в стеке 5 байтов. Для этого достаточно уменьшить SP на 5 (не забываем: стек растёт вниз!). Уменьшение выполняется так: DEC SP: PUSH HL: PUSH HL. Что будет занесено в стек командами PUSH, в данном случае совершенно не важно: мы используем их лишь для уменьшения SP (потому что PUSH на байт короче, чем две команды DEC SP).

Резерв5
Адрес возврата2
.................

Сохраняем в стеке HL, DE, AF.

AF211
DE2
HL2
Резерв5
Адрес возврата2
.................

Зная адрес вершины стека и смещение элемента в стеке, можно получить к нему доступ, даже если это не верхний элемент! А смещение адреса возврата мы знаем: как видно из рисунка, оно равно 11. По адресу возврата определяем номер вызываемой подпрограммы и номер банка памяти, в котором она находится.

Для описания следующих действий удобно изобразить содержимое стека так:

AF210
DE2
HL2
Резерв4
Резерв1
Адрес возврата2
.................

Определяем адрес вызываемой подпрограммы по её номеру. Сохраняем номер текущего (т.е. установленного при вызове менеджера) банка памяти в нижнем из ранее зарезервированных байтов стека. Как видно из вышеприведённого рисунка, его адрес равен SP+10.

AF2
DE2
HL2
Резерв4
Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

Записываем в оставшиеся четыре байта резерва адрес, по которому будет передано управление при выполнении команды RET в конце вызываемой подпрограммы (это адрес SEL_EXIT, относящийся к менеджеру), и адрес вызываемой подпрограммы.

AF2
DE2
HL2
Адрес
вызываемой
подпрограммы
2
Адрес SEL_EXIT2
Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

Подключаем банк подпрограммы. Снимаем ранее запомненные в стеке значения HL, DE, AF. Теперь значения всех регистров такие же, какими они были при вызове менеджера.

Адрес
вызываемой
подпрограммы
2
Адрес SEL_EXIT2
Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

Выполняем команду RET, при этом процессор снимет со стека адрес подпрограммы и передаст по нему управление. Как видите, RET здесь используется для вызова подпрограммы, хотя обычное назначение этой команды — напротив, выход из подпрограммы. Вот такой нестандартный приём. :–)

Адрес SEL_EXIT2
Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

После того, как подпрограмма выполнится, при выходе из неё по команде RET управление будет передано на следующую команду менеджера (с адресом SEL_EXIT).

Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

Запоминаем в стеке HL и AF.

AF24
HL2
Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

По адресу SP+4 находится номер банка памяти, который был подключён при вызове менеджера. Устанавливаем этот банк. Снимаем ранее запомненные в стеке значения HL и AF. Теперь содержимое всех регистров такое же, какое было при выходе из подпрограммы.

Номер банка,
подключённого при
вызове менеджера
1
Адрес возврата2
.................

Командой INC SP убираем из стека не нужный теперь номер банка.

Адрес возврата2
.................

Работа менеджера завершена! По команде RET возвращаем управление.

И ещё, обратите внимание: область применения менеджера оказалась шире, чем задумывалось при его создании, т.е. его можно использовать не только для вызова подпрограмм, расположенных в верхней памяти. Пусть, например, нам надо вызвать подпрограмму, находящуюся в нижней памяти, но обрабатывающую данные, расположенные в определённом банке верхней памяти. Ничего нет проще! Помещаем сведения о ней (адрес и номер банка с данными) в менеджер, и можно будет её вызывать, причём из любого банка памяти. А если подпрограмму надо вызвать сначала для обработки данных в одном банке памяти, потом — в другом банке, то можно просто несколько раз описать её в менеджере, указывая каждый раз один и тот же адрес подпрограммы, но различные номера банков памяти.

Ну что же, осталось лишь привести листинг менеджера. После столь подробных объяснений, думаю, непонятных мест в нём не будет!

Листинг менеджера вызова

;Коды NOP-подобных команд, соответствующих каждому
;из 8 банков памяти:

bank_0     EQU  #40 ;LD B,B
bank_1     EQU  #49 ;LD C,C
bank_2     EQU  #52 ;LD D,D
bank_3     EQU  #5B ;LD E,E
bank_4     EQU  #64 ;LD H,H
bank_5     EQU  #6D ;LD L,L
bank_6     EQU  #00 ;NOP
bank_7     EQU  #7F ;LD A,A

;Таблица адресов подпрограмм:

SEL_TAB    DW   SUBR_1
           DW   SUBR_2
           ...........
           DW   SUBR_N

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

;Точки входа:

SEL_BEG
subr_1     DB   bank_3
subr_2     DB   bank_6
......................
subr_n     DB   bank_1

;Началась обработка:

           DEC  SP ;Резервируем в стеке
           PUSH HL ;5 байтов.
           PUSH HL

           PUSH HL ;Сохраняем
           PUSH DE ;используемые
           PUSH AF ;регистры.

           LD   HL,11
           ADD  HL,SP
           LD   E,(HL)
           INC  HL
           LD   D,(HL)

;DE - адрес возврата после вызова;
;перед этим адресом стоит команда
;CALL XXXX; вот этот адрес XXXX и берём:

           EX   DE,HL
           DEC  HL
           LD   D,(HL)
           DEC  HL
           LD   E,(HL)
           EX   DE,HL

;В HL адрес XXXX; смотрим, какая команда по нему расположена, и
;по её коду определяем номер банка памяти, в котором расположена
;вызываемая подпрограмма:

           LD   A,(HL)
           AND  A
           JR   Z,BANK_M_1  ;Если NOP.

           AND  7           ;Иначе - номер банка в младших трёх
           OR   #10         ;битах кода команды; OR #10 - для
           JR   BANK_M_2    ;установки остальных разрядов порта.

BANK_M_1   LD   A,#16       ;NOP соответствует 6 банку.

;Внимание! Если в программе используется не основной экран и/или
;не ПЗУ BASIC-48, то значения в командах OR #10 и LD A,#16
;должны быть скорректированы!

;Теперь в аккумуляторе число, которое нужно вывести в порт #7FFD
;для подключения банка памяти, где расположена подпрограмма.

;По адресу точки входа (заданному в HL) определяем адрес в
;таблице, по которому расположен адрес вызываемой подпрограммы.
;Искомый адрес в таблице равен (HL-SEL_BEG)*2+SEL_TAB, или, что
;то же самое (но проще для вычисления), 2*HL-2*SEL_BEG+SEL_TAB.

BANK_M_2   ADD  HL,HL    ;*2

;Скобки в загружаемом в DE выражении нужны, т.к. умножение
;отрицательных чисел при вычислении выражений в ассемблерe
;ZX ASM, которым я пользуюсь, выполняется неверно.

           LD   DE,-(2*SEL_BEG)+SEL_TAB
           ADD  HL,DE

;Читаем в DE адрес вызываемой подпрограммы:

           LD   E,(HL)
           INC  HL
           LD   D,(HL)

;Заносим в стек номер банка:

           LD   HL,10
           ADD  HL,SP
           LD   (HL),N  ;N - начальное значение переменной BANK.
BANK       EQU  $-1

;Адрес возврата:

           DEC  HL
           LD   (HL),SEL_EXIT/256  ;Старший байт.
           DEC  HL
           LD   (HL),SEL_EXIT\256  ;Младший байт.

;Адрес вызываемой подпрограммы:

           DEC  HL
           LD   (HL),D
           DEC  HL
           LD   (HL),E

;Устанавливаем банк вызываемой подпрограммы:

           CALL SETPORT  ;N банка.

           POP  AF
           POP  DE
           POP  HL

;В стеке сейчас адрес вызываемой подпрограммы, а за ним - адрес
;команды SEL_EXIT.

           RET ;Запуск подпрограммы.

;Восстанавливаем банк, номер которого был сохранён в стеке:

SEL_EXIT   EX   (SP),HL
           PUSH AF
           LD   A,L
           CALL SETPORT
           POP  AF
           EX   (SP),HL
           INC  SP

           RET

Возможности оптимизации

Фрагмент, в котором по коду NOP-подобной команды, расположенной в точке входа, определяется число, которое нужно вывести в порт #7FFD для установки банка памяти с вызываемой подпрограммой:

           LD   A,(HL)
           AND  A
           JR   Z,BANK_M_1  ;Если NOP.

           AND  7           ;Иначе - номер банка в младших трёх
           OR   #10         ;битах кода команды; OR #10 - для
           JR   BANK_M_2    ;установки остальных разрядов порта.

BANK_M_1   LD   A,#16       ;NOP соответствует 6 банку.

BANK_M_2   ..........

может быть оптимизирован так:

           LD   A,(HL)
           AND  A
           JR   NZ,BANK_M
           LD   A,6
BANK_M     AND  7
           OR   #10

Получается на два байта короче. При этом, если вызываемая подпрограмма расположена не в шестом банке памяти, то выигрыш во времени составит 7 тактов, а если в шестом банке — наоборот, время работы будет на 9 тактов больше. Выгодна такая оптимизация или нет, зависит от конкретного случая и выбранных критериев выгодности. Например, если главное — сократить размер, или если вызываемые подпрограммы расположены в основном не в шестом банке (т.е. в среднем будет выигрыш во времени), то способ пригоден. А если ставится целью как можно больше ускорить время запуска подпрограмм именно в шестом банке памяти, то способ не подойдёт (можно, впрочем, попытаться разместить такие подпрограммы в каком-либо другом банке).

Если в шестом банке вообще нет подпрограмм, можно записать даже так:

           LD   A,(HL)
           AND  7
           OR   #10

что будет уже на 7 байтов короче и на 23 такта быстрее исходного варианта.

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

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

Смотрите: если определять выводимое в порт #7FFD число по коду NOP-подобной команды с помощью команд AND 7: OR #10, то можно вызывать подпрограммы из всех банков памяти, кроме шестого, т.е. из банков 0, 1, 2, 3, 4, 5, 7. А нам надо, скажем, вызывать подпрограммы из всех банков, кроме третьего. Тогда запишем так: AND 7: XOR 5: OR #10. Что получается? Команда XOR 5 превращает числа 0, 1, 2, 3, 4, 5, 7 соответственно в 5, 4, 7, 6, 1, 0, 2. А это и есть все числа от 0 до 7, кроме 3.

Две команды XOR 5: OR #10 можно заменить на одну XOR #15, так как после AND 7 старшие разряды аккумулятора обнулены. В итоге определение выводимого в порт числа по коду команды будет выглядеть так: AND 7: XOR #15. Выполнение этого фрагмента занимает ровно столько же времени, как и AND 7: OR #10, и длина у него такая же.

А вот неиспользуемый банк мы теперь можем выбирать сами. Если номер этого банка — n, то операнд в команде XOR вычисляется по формуле 6 XOR n. В данном случае он равен 6 XOR 3 (%110 XOR %011), т.е. 5 (%101). Объединяя с операндом команды OR #10, в итоге получаем #15.

И, разумеется, нужно ещё переопределить значения констант bank_0—bank_7. Смотрим, какие числа в какие превращаются при выполнении команды XOR, и переименовываем константы. В данном случае bank_0 переименовываем в bank_5, bank_1 — в bank_4 и так далее, в итоге получаем:

bank_5     EQU  #40 ;LD B,B
bank_4     EQU  #49 ;LD C,C
bank_7     EQU  #52 ;LD D,D
bank_6     EQU  #5B ;LD E,E
bank_1     EQU  #64 ;LD H,H
bank_0     EQU  #6D ;LD L,L
bank_2     EQU  #7F ;LD A,A

Другие мои статьи о различных приёмах оптимизации при программировании на ассемблере Z80:

1. 

«По поводу релоцируемых программ». «ZX-Ревю» 5—6/1997.

2. 

«Z80: оптимизация загрузки констант в регистры». «Радиолюбитель. Ваш компьютер» 9/2000, 2/2001 (под псевдонимом BV_Creator).

3. 

«Ещё о программировании арифметических операций». «Радиолюбитель. Ваш компьютер» 12/2000, 1—4/2001.

4. 

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

5. 

«Оптимизация на примере intro „Start“». «Радиомир. Ваш компьютер» 7—10/2001.

6. 

«Улучшение сжатия программ на ассемблере Z80». «Радиомир. Ваш компьютер» 4/2003.

7. 

«Процедура сравнения строк на ассемблере Z80». «Радиомир. Ваш компьютер» 6/2003.

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