Курс ассемблера для начинающих, часть 1

Scene+ #27

Курс ассемблера для начинающих, часть 1

Что же такое ассемблер? Ассемблер — это программа-транслятор, которая переводит мнемонические коды, такие как LD I,A или LDIR, в низкоуровневый машинный код. Это можно делать и вручную, программируя прямо в машинных кодах, но это отнимает очень много времени и практически невозможно без таблиц, которые прилагались к прошлым номерам SCENE+. Именно поэтому и были написаны ассемблеры.

Мнемоники служат для того, чтобы за голыми числами процессора можно было представить, что именно CPU должен сделать. Или все понимают, что означает 201? (Ну ладно, это просто!) Человеку куда понятнее мнемоника RET: она означает возврат из подпрограммы обратно в программу. Но то, что делает CPU, когда встречает число 201, устроено немного сложнее: он берет значение по адресу, на который указывает регистр SP, и совершает переход по этому адресу.

Подобные процессы, а также тот факт, что в Z80 инструкции не являются аппаратно жестко «зашитыми» на один такт, приводят к тому, что до завершения выполнения команды проходит довольно много тактов (в отличие от современных RISC-подобных процессоров). Некоторые команды требуют значительно больше тактов, чем другие, и по этой причине демо-кодеры не всегда используют отдельные «крупные» команды, так как они могут выполняться дольше, чем пара простых одно- или двухбайтовых. В качестве примера: LD HL,число выполняется за 10 тактов, что медленнее, чем LD H,n1 : LD L,n2, занимающие всего 8 тактов.

При современных методах производства, применявшихся, к примеру, уже в 486, вполне можно было бы сделать Z80, выполняющий все команды за один-два такта. Контроллер DMA, используемый в MB02, выполняет свои команды перемещения памяти как раз за 2 такта на байт, поскольку спроектирован специально для этого (и кое-чего еще). Сам же Z80 при выполнении LDIR тратит целых 21 такт на ячейку памяти! Дело в том, что при каждом проходе он декодирует команду LDIR и проверяет, достиг ли BC нуля: если нет — увеличивает DE, увеличивает HL, вычитает единицу из BC и повторяет всё сначала.

Для тех, кто еще не знает: LDIR означает LoaD, Increment, Repeat (загрузи, увеличь, повтори). BC всегда используется как счетчик цикла (Byte Counter) и поэтому уменьшается. HL и DE всегда увеличиваются на 1. Инструкция LDI (не путать с LD I) делает ровно то же самое, но без повторения команды, даже если BC больше нуля.

Уже подсчитали? Копирование содержимого экрана с помощью LDIR отнимает 21 × 6912 тактов! Отсюда вывод: этой командой невозможно скопировать экран за 1/50 секунды! Тем не менее машинный код — самый быстрый язык, далеко превосходящий даже компилированный BASIC. Поэтому каждому, кто хочет писать игры, демо или серьезные прикладные программы, стоит освоить ассемблер — хотя бы для написания небольших критичных ко времени подпрограмм.

Обмениваться переменными между машинным кодом и BASIC очень просто с помощью POKE, либо если запустить MC-программу через LET x=USR adr — в этом случае переменная X примет значение регистра BC на момент завершения подпрограммы. При возврате в BASIC следует помнить: стек не должен быть смещен, программа должна завершаться командой RET, а прерывания необходимо снова разрешить с помощью EI (Enable Interrupts).

Начнем с самого простого — с загрузки регистров. Но что такое регистры? Их можно назвать переменными, но только для целых чисел; кроме того, регистры предназначены и для управления системой. Есть также флаги — однобитные регистры, такие как CARRY, ZERO и т. д. Все эти флаги хранятся в регистре F и могут принимать значение только 0 или 1 (0 = сброшен, 1 = установлен). По этой причине к регистру F нельзя обратиться напрямую, кроме как через регистровую пару AF. Кстати, F означает Flag, точно так же как HL означает High/Low.

Все регистры Z80, за исключением флагов, имеют разрядность 8 бит. Индексные регистры, такие как IX или IY, изначально 16-битные, но существуют недокументированные команды Z80, которые позволяют обращаться к ним как к 8-битным регистрам (HX, LX / HY, LY). Следует отметить, что эти индексные команды требуют чуть больше процессорного времени.

Некоторые 8-битные регистры можно объединять, образуя 16-битные. Такими парами являются AF (регистры A и F), BC (регистры B и C), DE и т. д. В любом случае регистрами нельзя пользоваться абсолютно произвольно, как переменными в BASIC — каждый регистр в определенных командах имеет свое четкое назначение.

Как я уже упоминал ранее о LDIR: LDIR служит для копирования или заполнения блоков памяти. Регистр DE указывает, куда должны копироваться байты (DEstination), HL указывает на источник данных, а BC (Byte Counter) задает количество копируемых байтов. Тот, кто ищет регистры LH, CB или тем более HC, ищет напрасно — наш Z80 такого ни в коем случае не примет. Нельзя создавать фантазийные спаренные регистры, просто вписав их в ассемблерный текст.

Есть еще альтернативные регистры, обозначаемые штрихом (апострофом). К ним нельзя обратиться напрямую: нужно переключать регистры целиком с помощью команды EXX или EX AF,AF' для пары AF. Для промежуточного хранения значений они очень удобны, особенно когда требуется быстрый доступ, но все же стоит приучаться хранить переменные в памяти.

Основные команды загрузки регистров:

  • LD (Адрес),Регистр — по адресу «Адрес» заносится (POKE) содержимое 8- или 16-битного регистра.
  • LD (Регистр1),Регистр2 — по адресу, хранящемуся в регистре 1, записывается 8-битный регистр 2.
  • LD Регистр1,Регистр2 — регистр 1 (8 бит) принимает значение регистра 2 (тоже 8 бит).
  • LD Регистр,Число — регистр (8/16 бит) принимает непосредственное значение числа (той же битности).
  • LD Регистр,(Адрес) — регистр (8/16 бит) считывает значение из ячейки (или ячеек) памяти «Адрес».
  • LD I,A / LD A,I — специальная команда для регистра прерываний.
  • LD SP,(Адрес) / Число / Регистр — специальные команды для указателя стека (Stack Pointer).

И вот мы добрались до указателя стека. Здесь сохраняется всё: адреса возврата, значения, занесенные по PUSH. От адреса, на который указывает SP (в BASIC это соответствует границе CLEAR), 16-битные значения всегда сохраняются в направлении «вниз». Поэтому нужно строго следить за тем, чтобы каждый CALL закрывался через RET, а каждый PUSH — через POP, иначе не избежать красивейших зависаний системы.

Переходы

Здесь есть несколько типов, которые, в свою очередь, делятся на разновидности: CALL (соответствует GO SUB), JP (=GO TO) и JR (относительный переход!). Все они могут выполняться по условию в зависимости от того, установлены или сброшены флаги переноса (Carry), нуля (Zero), знака (Sign/Minus) и т. д.

На примере CALL:

  • CALL C,nn — вызвать подпрограмму, если установлен флаг Carry.
  • CALL NC,nn — вызвать подпрограмму, если флаг Carry равен нулю.
  • CALL Z,nn — вызвать подпрограмму, если установлен флаг Zero.
  • CALL NZ,nn — вызвать подпрограмму, если флаг Zero равен нулю.
  • CALL M,nn — вызвать подпрограмму, если установлен флаг Minus (результат сравнения отрицательный).
  • CALL P,nn — вызвать подпрограмму, если флаг Minus равен нулю (положительный/плюс).
  • CALL PE,nn — флаг четности (Parity Even) установлен.
  • CALL PO,nn — флаг нечетности (Parity Odd) — бит четности не установлен.

Битовые и арифметико-логические операции

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

  • ADC: сложение с учетом переноса (Carry), сохранение результата в A или HL.
  • ADD: как выше, но без учета флага Carry.
  • AND: логическое «И», A связывается с байтом побитно:
  01010101
AND 11110000
------------
  01010000

Только там, где биты обоих регистров (или регистра и 8-битного числа) равны 1, в результате тоже будет 1. Весь результат сохраняется в A.

  • BIT: BIT b,r проверяет бит b регистра r и копирует его состояние в инвертированном виде во флаг Zero (если бит равен 0, флаг Z устанавливается). Нумерация битов: 76543210.
  • CCF: Complement Carry Flag — инвертировать значение флага переноса.
  • CP: Compare — сравнить регистр A с регистром, числом или содержимым адреса. Флаг Zero устанавливается, если сравнение дает равенство (оба числа равны). Флаг Carry устанавливается, если содержимое регистра A меньше сравниваемого байта.
  • CPD: Compare, Decrement. Сравнение (A с ячейкой (HL)) и уменьшение HL. BC также уменьшается. Не путать с CP D!
  • CPDR: как CPD, но операция повторяется в цикле до тех пор, пока не будет найдено совпадение либо BC не станет равен нулю.
  • CPI: как CPD, но HL увеличивается. Не забывайте: BC всегда уменьшается!
  • CPIR: комбинация CPDR и CPI соответственно.
  • CPL: не путать с CP L! Все биты регистра A инвертируются. Занимает всего 4 такта — столько же, сколько NOP.
  • DAA: десятичная коррекция (BCD — Binary Coded Decimal) регистра A.
  • DEC: Decrement — уменьшить регистр или содержимое ячейки памяти на 1.
  • DJNZ: Decrement, Jump Not Zero — уменьшить регистр B на 1 и выполнить относительный переход, если результат в B не равен нулю.
  • IN: как в BASIC: LET A=IN BC. В машинном коде: IN A,(C).
  • INC: Increment — увеличить регистр или ячейку на 1.
  • IND: ввод из порта с последующим уменьшением HL и B. Процедура следующая: по адресу, на который указывает HL, заносится значение из порта (C). Затем HL и B уменьшаются на единицу.
  • INDR: как IND, но выполняется в цикле, пока B больше нуля. Это, помимо прочего, причина, почему интерфейс жесткого диска использует 8-битный адрес: C — адрес порта, а B — счетчик цикла! С 16-битной адресацией портов это никогда бы не сработало, так как B уменьшается, а читается-то IN (BC)!
  • INI: как IND, только HL увеличивается.
  • INIR: как INDR, только HL увеличивается.
  • NEG: сменить знак содержимого регистра A (инверсия с добавлением 1).
  • OR: логическое «ИЛИ» над A и байтом, результат сохраняется в A. «ИЛИ» означает: если бит в A или в маске равен 1, в результате на этой позиции тоже будет 1:
 01010101
OR 11110000
-----------
 11110101
  • OUT: OUT, OUTD, OUTI, OTDR, OTIR — работают аналогично IN, IND, INI, INDR и INIR, только с выводом OUT (C) вместо IN (C).
  • RES: Reset — сбросить (обнулить) заданный бит.
  • RL: сдвиг байта влево через перенос. Бит 0 принимает значение Carry, а флаг Carry получает то значение, которое до этого было в бите 7. Только с помощью этих команд можно реализовать плавный скроллинг! Сдвиги, кстати, используются также для деления и умножения.
  • RLA: как RL A, но в два раза быстрее (4 такта) и иначе влияет на флаги.
  • RLC: циклический сдвиг влево. Выдвигаемый бит 7 копируется как в Carry, так и в бит 0.
  • RLCA: как RLC, с теми же отличиями, что и у RLA.
  • RLD: BCD-вращение влево тетрад между регистром A и содержимым ячейки памяти (HL):
    Регистр A         (HL)
   +---------+    +---------+
   | 7654    |<<--| 7654    |
   |     3210|    |     3210|
   +---------+    +---------+
        |              ^
        +--------------+
  • RR: RR, RRA, RRC, RRCA — аналогичны опкодам RL, только сдвиг происходит вправо.
  • RRD: как RLD, но вращение тетрад выполняется вправо:
Reg. (HL)
+ - >> - +   +->-+
7654 3210    7654 3210
+ - << - +

SBC: Вычитание с учётом флага Carry, результат сохраняется в A или HL.
SCF: Set Carry Flag, Carry становится равным 1.
SET: Установка бита, то есть делает его равным 1.
SLA: Сдвиг влево, для заполнения бита 0 используется 0, а бит 7 переходит во флаг Carry.
SRA: Арифметический сдвиг вправо. Бит 7 остаётся без изменений.
SRL: Логический сдвиг вправо, бит 7 становится нулём.
SUB: Вычитание без использования флага Carry, результат сохраняется в A.
XOR: Исключающее ИЛИ (XOR), результат записывается обратно в A.

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

Остались ещё специальные команды:

DI: Отключение обработки прерываний, то есть когда программа должна выполняться без помех. Большинство процедур, таких как загрузчики с ленты (Tape Loader), начинаются с этой команды.
EI: Снова включает прерывания; если забыть эту команду, то при возврате в Бейсик клавиатура перестанет реагировать.
EX: Обмен регистров со значениями из стека, либо DE с HL, либо AF с AF'.
EXX: Обмен регистровых пар BC, DE и HL с BC', DE' и HL'.
HALT: Ожидание обратного хода луча (Vertical Blank), то есть пока изображение на телевизоре не начнёт отрисовываться заново. Отлично подходит для синхронизации, чтобы кодить программы без мерцания.
IM: Режим прерываний, их существует 3 вида: IM 0 и 1 используются Spectrum ROM, свободным остаётся только режим 2.
NOP: В течение 4 тактов не делать абсолютно ничего, кроме пустой траты времени.
POP: Забрать регистр из стека.
PUSH: Сохранить регистр в стек.
RET: Возврат из подпрограммы, вызванной по CALL, или возврат в Бейсик. Адрес возврата берётся из стека, поэтому крайне не рекомендуется менять SP, если не приняты соответствующие меры по сохранению указателя стека.
RETI / RETN: RETI = возврат из процедуры обработки прерывания, RETN = возврат из процедуры NMI; в обоих случаях можно обойтись и обычным RET.

Вот, собственно, и все группы команд процессора Z80. То, что сейчас можно с ними сделать — это пока немногое. Из этих опкодов ещё нужно составить толковые программы, а это действительно непросто, иначе программистов на ассемблере для Спектрума было бы намного больше. Чисто для сравнения: у C64 в процессоре значительно меньше опкодов, но он выполняет их быстрее — только по этой причине он почти может держаться наравне со Спектрумом (вы когда-нибудь видели Elite на C64?).

На что ещё стоит обращать внимание при программировании на ассемблере, так это на то, сколько тактов потребляет каждая конкретная команда. У некоторых команд это число не всегда одинаково. Я уже упоминал, что LDIR требует 21 такт на проход цикла, но это не совсем так: LDIR требует 21 такт только тогда, когда BC не равен нулю. Если же BC всё-таки становится нулём (что в конце концов неизбежно происходит), последний цикл занимает всего 16 тактов. Теперь, наверное, понятно, почему многие кодеры предпочитают выполнять команду LDI несколько раз подряд — она ведь тоже длится всего 16 тактов, поскольку BC не проверяется.

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

PUSH Reg требует ровно 10 тактов, чтобы забросить регистровую пару в память (кроме IX и IY — поскольку это двухбайтовые команды, они занимают 14 тактов). Но и это ещё не всё: PUSH попутно уменьшает указатель стека на 2 байта. Поэтому, чтобы очистить экран, следует сохранить Stack Pointer, переставить его в конец области экрана и затем заPUSHивать всё на полную катушку.

Можно, например, использовать 16 инструкций PUSH для очистки одной строки и повторить всё это 192 раза. Теоретически весь экран можно очистить за один фрейм (1/50 секунды), даже если экранная область находится в медленной памяти (SLOW RAM)! LDIR требует для копирования одного байта 21 такт, а PUSH — всего 10 тактов для двух байтов!

Попробуем написать такую быструю процедуру CLS:

        LD (23296),SP
        LD B,192
        LD SP,22527
        LD HL,0
Loop:   PUSH HL        ; (всего 16 раз PUSH HL, начиная отсюда!)
        ...
        DJNZ Loop
        LD SP,(23296)
        RET

Работает великолепно, а если загрузить в регистровую пару HL что-нибудь отличное от 0, экран заполнится узором. DJNZ уменьшает B, и если тот не равен нулю, делает относительный переход вперёд или назад по памяти — поэтому замечательно подходит в качестве цикла, аналога FOR..TO..NEXT в Бейсике.

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

Поделитесь вашим мнением о статье