Sega Master System 04: Введение в язык ассемблера Z80

Сегодня мы с вами наконец-то подступимся к программированию. И изучим что же именно может делать центральный процессор SMS.

Машинный код

Как вы помните из описания работы процессора и памяти. Программа - это последовательность команд в памяти, процессор последовательно считывает эти команды и выполняет действия которые им соответствуют. Команды могут быть разной длины от одного байта до четырёх. Такая последовательность байт составляющих команды для процессора называется машиным кодом. Давайте подробнее разберём как устроены сами команды. Первый байт в команде, а иногда и второй, обозначают код операции. Сокращённо опкод (англ. Operation code). Последующие один или два байта называются операндом.

Опкод - это номер элементарной и однозначно трактуемой операции которую может совершить процессор, обычно их называют инструкциями. В процессоре z80 больше 700 инструкций, забавно что их общего числа нет даже в официальной документации к процессору. Однако их точное число нам знать и не нужно, всё дело в том что даже для многих однотипных или очень похожих действий существуют разные инструкции. Например, давайте посмотрим на одну из простейших инструкций: копирование значения из регистра B в регистр A, эта команда обозначается единственным байтом - $78, а копирование из C в A - байтом $79. Для процессора это две разные инструкции, для нас же они выглядят почти одинаково.

Теперь давайте посмотрим на двухбайтовую инструкцию в которой есть опкод и операнд. Операнд - это данные с которыми нужно произвести операцию указанную в опкоде. Например - загрузка числа $22 в регистр A. Такая операция будет выглядеть следующим образом $3E $22. Как видите всё довольно просто. $3E - опкод обозначающий загрузку числа в регистр A, $22 - само число, которое надо загрузить.

Теоретически на этом этапе мы можем разжиться справочником инструкций для z80 и начать составлять первые программы. Впрочем, на ранних этапах развития вычислительной техники так и программировали. И вы можете попробовать ради интереса, но поверьте, это занятие быстро надоедает.

Такты

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

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

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

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

Ассемблер

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

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

мнемоника операнд_1, операнд_2
мнемоника операнд_1
мнемоника

Вернёмся к нашим примерам с загрузкой чисел в регистр. Загрузка числа в регистр A из регистра B на языке ассемблера будет выглядеть вот так:

ld a, b

Разберём эту строчку. ld - это мнемоника обозначающая загрузку числа, сокращение от load, A - это то место куда загружается число или назначение. B - откуда загружается число или источник. То есть данная конструкция в общем виде выглядит вот так:

ld <назначение>, <источник>

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

ld a, b    ->   $78
ld a, c    ->   $79
ld a, $22  ->   $3e $22

Как видите, стало гораздо более читаемо. Одна мнемоника может обозначать схожие по смыслу инструкции процессора. Устраняя немного неудобные для запоминания особенности того как организованы опкоды. Мы эти особенности рассмотрели выше в разделе "Такты". Давайте посмотрим какие ещё способы загрузки есть у мнемоники ld

ld hl, $22b7   ;Загрузка 16-битного числа в регистровую пару
 
ld bc, ($10a7) ;Загрузка 16-битного числа в регистровую пару
               ;из памяти по указанному адресу, в данном случае по адресу $10a7
 
ld ($10a7), bc ;Загрузка 16-битного числа из регистра bc 
               ;в память по указанному адресу
 
ld (bc), $22b7 ;Загрузка 16-битного числа в память по адресу содержащемуся 
               ;по адресу указанному хранящемуся в регистре bc, числа $22b7
 
ld b, (hl)     ;Загрузка в регистр B 8-битного числа из памяти по адресу 
               ;хранящемуся в регистре HL

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

ВАЖНО!

Здесь нам надо договориться о терминологии, чтобы не путаться дальше. Инструкцией я буду называть непосредственно инструкции процессора, каждая инструкция имеет свой уникальный опкод. А группу инструкций которые объедены одной и той же мнемоникой, я буду называть командой. То есть "ld операнд1, операнд2" - команда, "$78" - инструкция.

Давайте посмотрим ещё на несколько простых и часто используемых команд.

inc <назначение>   ;Увеличение числа в регистре или ячейке памяти на 1
 
dec <назначение>   ;Уменьшение числа в регистре или ячейке памяти на 1
 
jp <адрес>         ;Переход программы по указанному адресу
 
add <источник>     ;Прибавить значение истоника к регистру A

Я не буду давать список всех возможных мнемоник и команд с их вариантами использования, для этого существуют справочники. Но всякую новую мнемонику появляющуюся у нас в тексте я буду максимально подробно описывать. Главное запомнить какие вообще есть группы операций в процессоре z80. Давайте обзорно пройдёмся по всем группам команд. На самых простых и необходимых я остановлюсь и дам более подробное описание. В официальной документации инструкций процессора поделены на следующие группы:

8-битные команды загрузки и 16-битовые команды загрузки

В этих двух группах содержатся все возможные инструкции по загрузке значения из одного места в другое, будь то регистры или ячейки памяти. Почти все эти инструкции реализуются единственной мнемоникой ld. Однако стоит не забывать что разные варианты загрузки имеют разную длину. Самые быстрые (короткие) из них - загрузка из регистра в регистр. Помимо ld в этой группе присутствуют инструкции по работе со стеком. Для них существуют мнемоники push и pop. Что такое стек мы разберём чуть позже.

Команды обработки блоков данных и обмена.

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

Помимо инструкций обмена в этой группе есть замечательные инструкции по работе с блоками данных. Мы будем пользоваться ими довольно часто. А что за блоки данных такие и как с ними работать? Блоками данных называют логически связанные данные расположенные на непрерывной последовательности ячеек. К примеру данные отвечающие за карту текущего уровня в вашей игре. Такие инструкции позволяют одной командой копировать такие блоки в другие участки памяти. Реализуются они следующими мнемониками: ldi, ldir, ldd, lddr, cpi, cpir, cpd, cpdr

8-битные арифметические и логические команды.

В этой группе находятся инструкции сложения, вычитания и логических операций над 8-битными числами. Для обозначения инструкций сложения служит мнемоника add (англ addition - прибавление). Типичная операция записывается в таком виде:

add a, <значение>

Работает это следующим образом. Значение второго операнда прибавляется к значению регистра A и результат сохраняется в регистре A. Для сложения 8-битных чисел первым операндом всегда будет A. Другие регистры не могут выполнять такую функцию, то есть сделать "add b, $02" не получится. Если вы складываете 8-битные числа, то в качестве первого операнда можно использовать только регистр a. Для инструкций вычитания всё аналогично с тем отличием что для них используется мнемоника sub (англ substraction - вычитание).

Также существуют дополнительные мнемоники adc (англ addition with carry - сложение с переносом) и sbc (англ substraction with carry - вычитание с переносом) - это такие же операции сложения и вычитания, но с учётом флага переноса. То есть к результату ещё прибавляется (или вычитается) значение флага C из регистра F. Зачем это нужно? Представьте себе что вы складываете большие многобайтовые числа. Тогда вам придётся их складывать побайтово начиная с младших байтов, как мы это делали в столбик в начальной школе. Если сумма двух байт будет больше $FF, то нам надо будет перенести единицу в более старший байт. При переполнении регистра A флаг C как раз устанавливается в единицу, таким образом команда adc позволяет нам сложить следующие разряды с учётом переносов. Аналогично и с вычитанием.

Кроме прибавления и вычитания чисел с регистром A существуют специальные инструкции по увеличению или уменьшению 8-битных регистров на единицу. Обозначаются мнемониками inc (англ. increment - приращение) и dec (англ. decrement - уменьшение).

И это вся арифметика на которую способен Z80. Не так много, но в дальнейшем вы увидите что и этого нам будет достаточно. Остались логические операции представленные мнемониками and, or и xor. Мнемоники эти целиком соответствуют своим названиям, "логическое и", "логическое или" и "исключающее логическое или". У данных команд всегда по одному операнду. Логическая операция проводится побитово над значением в регистре A и значением операнда. Результат помещается в регистр A.

И последним в этой группе идут инструкции сравнения, обозначающиеся мнемоникой cp (англ. compare - сравнить). Значение регистра A сравнивается с операндом, и если они равны, то в регистре F флаг Z становится единицей, если не равны - нулём. Эти инструкции работают обычно в связке с инструкциями переходов, которые мы опишем ниже.

16-битные арифметические команды

16-битные команды обозначаются теми же мнемониками что и 8-битные: add, sub, adc, sbc, inc, dec с той лишь разницей что функцию регистра A выполняет теперь регистр HL, но для 16-битного сложения ограничений еще больше, помимо того что в качестве первого операнда может быть только hl, вторым операндом может быть только регистровая пара. Например:

add hl, $1112 ; так не работает. 
 
ld bc, $1112  ; а так работает
add hl, bc

Команды переходов

Инструкции в этой группе служат для указания адреса следующей команды которая будет выполняться после текущей. Обозначаются мнемониками jp (англ. jump - прыгать) и djnz. Переходы могут быть различных типов: условные или безусловные. Если используется безусловный переход, то после этой инструкции всегда будет выполнена инструкция по указанному в операнде адресу. Если же используется условный переход, то переход к инструкции по указанному адресу произойдёт при выполнении определённого условия. Обычно это какое-то определённое значение одного из флагов или регистров.

Например вот такая команда:

jp c, $23e4

Обозначает что если флаг C равен единице, то следующей командой выполнится команда по адресу $23e4, если же нулю - выполнится следующая команда идущая после этой.

В общем случае команды выглядят так

jp <адрес>              безусловный переход по адресу
jp <условие>, <адрес>   условный переход по адресу

Немного особняком стоит команда djnz (англ. decrement register and jump if not zero - уменьшить регистр и перескочить если равен нулю), она служит для организации быстрых циклов. Имеет единственный операнд размером в один байт, который служит смещением адреса, в отличие от команды jp где указывается непосредственно сам адрес. Эта команда уменьшает значение регистра B на один, а потом, только при условии, что значение B не стало равно нулю, совершает переход по адресу который высчитывается из адреса самой этой команды плюс смещение указанное в её операнде. Поскольку смещение задаётся всего одним байтом, то итоговый адрес перехода не может быть дальше 127 ячеек в ту или другую сторону от этой команды.

Звучит немного запутанно, но вы на практике увидите что ничего сложного здесь нет.

Команды для работы с битами

Инструкции данной группы используют три мнемоники bit, set (англ. set - установить), res (англ. resset - сбросить). bit используется с двумя операндами: номер бита и регистр. операция смотрит значение бита под номером из первого операнда в значении регистра указанного во втором операнде и выставляет флаг Z соответственно этому биту. set и res работают похожим образом, разница лишь втом что они изменяют указаный бит в регистре на 1 или 0 соответственно.

Команды ввода вывода

В данной группе содержатся инструкции для работы с портами. Мнемоники этих инструкций следующие in, out и их разновидности ini, inir, ind, indr, outi, otir, outd, otdr. Данные инструкции служат для чтения данных из портов и записи данных в порты различными способами. Непосредственное использование разберём в нашей первой программе в будущей статье.

Команды вызова и возврата

В этой группе находятся инструкции позволяющие нам организовать подпрограммы. Рассмотрим в первую очередь такие как call (англ. call вызывать) и ret (англ. return - возвращать). Команда call очень похода на jp. Она так же как и jp совершает условный или безусловный переход по указанному адресу, однако в отличие от jp, команда call перед переходом по нужному адресу сохраняет в специальной области памяти называемой стек адрес следующей за ней инструкции. Далее с указанного адреса выполняются имеющиеся там инструкции в обычном порядке, пока не встретится инструкция ret. При выполнении этой инструкции совершается переход по адресу который сохранила в стеке последняя выполненная команда call. Таким образом получается что мы возвращаемся к тому месту где был совершен вызов call и продолжаем выполнение программы со следующей за этим call'ом команды.

Помимо этого в этой группе ещё есть команды reti, retn и rst. Первые две используются для возврата из прерываний, которые мы рассмотрим позже, а последняя аналогична call только более быстрая и служит для вызова подпрограмм из верхних адресов памяти.

Команды сдвига и вращения

Давайте для начала разберёмся что это за операции такие сдвиг и вращение. Сдвиг это битовая операция при которой все биты числа сдвигаются на нужное количество разрядов в определённую сторону. Давайте сразу смотреть на примере. Есть у нас число $0B ( это одиннадцать =) ). В двоичной системе выглядит так %00001011. Сдвинем его влево на два разряда и получим %00101100. А если сдвинем вправо на один разряд то получим %00000101. Если какие-то разряды выпадают за диапазон числа (в нашем случае 8 бит), то они заменяются нулями. Если записать всё это в столбик, то станет совсем очевидно. Каждая строчка это всё новый сдвиг влево на один разряд

%00001011 - 11
%00010110 - 22
%00101100 - 44
%01011000 - 88
%10110000 - 176
%01100000 - 96

Сдвиги - очень полезные операции. При их помощи можно делить и умножать числа на два, а при повторении сдвига на любые числа которые являются степенями двойки. Смотрите как это работает. Возьмём число %00000101 (5), теперь сдвинем его влево на один разряд. Получаем %00001010 (10), повторим сдвиг - получим %00010100 (20) так далее. Если проводить сдвиги в обратную сторону, то получим деление, но надо учитывать что оно будет целочисленным, например при сдвиге вправо числа 1, получим 0. Это же можно наблюдать на примере выше. А в последней операции сдвига происходит переполнение.

Если всё ещё непонятно как это получается так хитро умножать и делить, попробуйте провернуть тот же трюк с десятичными числами, только при условии что делить и умножать вы будете на 10, а не на 2 как в двоичных числах. 72 -> 720 -> 7200 и тд. И в другую сторону 946 -> 94 -> 9 -> 0.

В действительности же в z80 сдвиги производятся только на один бит в ту или иную сторону и при необходимости просто повторяются. А для контроля переполнения. Тот бит который "выпадает" из числа при сдвиге, помещается в флаг C. Таким образом мы можем контролировать переполнение чисел и переносить разряд в другие байты при необходимости, как и в арифметических действиях по сложению и вычитанию.

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

%00101100
%01011000
%10110000
%01100001
%11000010
%10000101
%00001011

Мнемоники различных вариантов сдвига и вращения: rlca, rla, rrca, rra, rlc, rl, rrc, rr, sla, slr, rld, rrd

Арифметические команды общего назначения и команды управления процессором

Это последняя группа инструкций процессора и её можно было бы назвать "Прочее". Давайте разберём что тут осталось.

  • daa (англ. decimal adjust accumulator ) - преобразование содержимого регистра A для BCD-арифметики. Это отдельная и большая тема которую мы рассмотрим когда в ней появится необходимость.
  • cpl (англ. complement - дополнение) - Все биты содержимого регистра A меняются на противоположные.
  • neg (англ. negate) - Число в регистре A меняется на противоположное по знаку.
  • ccf (англ. complement carry flag - дополнить флаг переноса) - Изменение значения флага переноса на противоположное
  • scf (англ. set carry flag - установить флаг переноса) - Установить значение флага переноса равным единице
  • nop (англ. no operation - нет операции) - ничего не делать на протяжении одного машинного цикла.
  • halt (англ. halt - остановка) - эта команда останавливает выполнение программы до поступления маскируемого прерывания. На деле эта команда выполняет команду nop пока ждёт своего завершения. Прерывания будем разбирать в следующей статье.
  • di (англ. disable interrupts - отключить прерывания) - Отключить обработку маскируемых прерываний
  • ei (англ. enable interrupts - включить прерывания) - Включить обработку маскируемых прерываний
  • im (англ./ interrupt mode - режим прерываний) - Установить режим прерываний указанный в операнде. Операнд может быть только числом 0, 1 или 2.

Вот мы рассмотрели все возможные команды процессора. Может показаться что их довольно много, но они довольно быстро запоминаются в процессе их использования. Так что заучивать специально их не надо.

Синтаксис

Давайте напоследок ещё поговорим о синтаксисе языка ассемблера и о том как работает сам ассемблер. Программа на языке ассемблера это обычный текстовый файл. Ассемблер последовательно разбирает этот файл построчно. Каждая строка это одна команда. Обрабатывая строку за строкой ассемблер в зависимости от команды генерирует в результирующем файле последовательность байт соответствующую найденным командам. Если в строчке не найдена команда и строчка содержит только табы пробелы или вообще является пустой, то она игнорируется. То есть вы вольны отделять одни команды от других как вам захочется, например отделять пустыми строками логические группы команд.

Комментарии.

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

Однострочные комментарии говорят ассемблеру о том, что весь текст начиная со специального символа и до конца строки является комментарием и его ненунжо никак обрабатывать и вообще воспринимать. Однострочный комментарий обозначается символом ; или //.

ld a, $92     ; комментарий. Тут можно писать любую белиберду
              ; и нам за это ничего не будет, но лучше всё же,
              ; написать что-то осмысленное и поясняющее 
              ; зачем выполняется команда на этой строчке
 
ld a, $92     // Загружаем число $92 в регистр A

Бывают и многострочные комментарии. У них есть специальная метка начала комментария /* и конец комментария */, между ними содержится текст комментария занимающий сколько угодно строк.

/*
Загружаем число $92 в регистр A
и подробно описываем зачем мы это делаем
*/
ld a, $92
 
ld a, $92     /* Но ими можно пользоваться и в пределах одной строки*/

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