Мы с вами уже выяснили как работает система в целом, что может делать z80 и как работает видеопроцессор. Сегодня мы разберёмся с тем как в SMS устроена память, как с ней работать и какие для средства этого нам предоставляет ассемблер.
Организация и типы памяти.
Как вы помните адресное пространство процессора z80 составляет 65536 ячеек памяти, размер одной ячейки памяти - 1 байт. То есть процессор "видит" 64кб памяти. В это адресное пространство подключена оперативная память самой приставки - RAM (англ. Random Access Memory), и память картриджа с игрой - ROM. Оперативная память - это такой тип памяти который предназначен для хранения изменяемых данных, таких как игровой счёт, очки жизней игрока, боезапас и тд. RAM переводится как "память с произвольным доступом", то есть мы можем в любой момент получить доступ к любой её ячейке. Системная оперативная память находится внутри самой приставке на её материнской плате.
ROM (англ. Read Only Memory - Постоянное запоминающее устройство) - это вид памяти предназначенный только для чтения, как видно из его названия. Такая память находится в картридже и в ней находится непосредственно код нашей игры и все её ресурсы, такие как графика, звуки, музыка, данные уровней, карт игровых предметов и тд.
Как правило, на картриджах с играми находится один чип ROM и иногда второй чип - маппер, о мапперах мы поговорим чуть позже. "Ромы" с играми для эмуляторов поэтому так и называются, потому что в этих файлах как раз и находится копия содержимого таких чипов. Так как основа картриджа это микросхема с памятью, то многие ошибочно считают картридж просто носителем информации на котором записана игра. В большинстве случаев с этим можно было бы условно, согласиться, но на деле это совсем не так. Картридж - это скорее плата расширения, такая же как и карты в современном компьютере, сетевая карта, звуковая карта и тд. Картридж помимо чипа с памятью может содержать, например дополнительную оперативную память с независимым питанием от батарейки, что позволяет "сохранять" игровой прогресс между игровыми сессиями, вычислительные чипы такие как различные сопроцессоры или даже контроллеры дополнительной периферии. Такие возможности картриджу даёт способ его подключения, дело в том что он подключается непосредственно в системную шину, вследствие чего картридж и приставка становятся единой системой.
Давайте теперь посмотрим как различные типы памяти расположены в адресном пространстве. Адреса будут указаны в шестнадцатеричной системе.
$0000 ┌────────────────────┐
│ ROM 1кб │
│ $0000-$00FF │
$0100 ├────────────────────┤
│ 15кб │
│ │
│ ROM слот 0 │
│ $0100-$3FFF │
│ │
│ │
$4000 ├────────────────────┤
│ 16кб │
│ │
│ │
│ ROM слот 1 │
│ $4000-$7FFF │
│ │
│ │
│ │
$8000 ├────────────────────┤
│ 16кб │
│ │
│ │
│ ROM слот 2 │
│ $8000-$BFFF │
│ │
│ │
│ │
$C000 ├────────────────────┤
│ 8кб │
│ RAM │
│ $C000-$DFFF │
│ │
$E000 ├────────────────────┤
│ 8кб │
│ RAM "зеркало" │
│ $E000-$FFFF │
│ │
$FFFF └────────────────────┘
Сейчас мы разберём эту схему. Адресное пространство поделено на 4 равные части по 16кб. В первых трёх частях находится ROM память из картриджа, в последней четверти - оперативная память. Давайте пока на ней и остановимся. В SMS есть всего 8кб оперативной памяти. Может показаться что этого мало, но повторюсь, эта память нужна только для хранения изменяемых данных, неизменяемые данные хранятся в ROM памяти, к которой процессор может обращаться напрямую с той же скоростью что и к RAM.
Как вы видите на схеме, последняя четверть адресного пространства поделена на две равные части по 8кб. В первой половине в диапазоне адресов $C000-$DFFF находится вся имеющаяся оперативная память. Но что же за зверь такой "RAM "Зеркало"" спросите вы? А это та же самая оперативная память. Давайте я объясню как так вышло и как это работает.
Зеркалирование.
Существует метод подключения памяти называемый "зеркалированием". Это когда микросхема с меньшей ёмкостью подключается к более широкому адресному пространству и одна и та же ячейка памяти становится доступной сразу по нескольким адресам. Давайте разберём на упрощённом примере как так получается.
У нас есть общая адресная шина в 16 бит и память объёмом в 8кб, которой для адресации её памяти хватает 13 бит. 2^13 = 8192 = 8кб. Поскольку наше адресное пространство разделено на 4 равные части, то два старших бита адреса служат для определения того в какой четверти адресного пространства мы будем работать. Давайте посмотрим как это получается.
$0000 - 00 00 0000 0000 0000
$4000 - 01 00 0000 0000 0000
$8000 - 10 00 0000 0000 0000
$C000 - 11 00 0000 0000 0000
Как видите два старших бита определяют четверть адресного пространства. У нас остаётся 14 бит, для того чтобы адресовать 16кб памяти внутри этой четверти, к 13 младшим из которых мы подключим нашу оперативную память. Как на схеме ниже.
┌─────────────┐
│ Шина адреса │
│ │ ┌─────────────┐
│ A15 ┼─┐ │ RAM 8kb │
│ A14 ┼─┴──────────┼ E │
│ A13 ┼─X │ │
│ A12 ┼────────────┼ A12 │
│ A11 ┼────────────┼ A11 │
│ A10 ┼────────────┼ A10 │
│ A9 ┼────────────┼ A9 │
│ A8 ┼────────────┼ A8 │
│ A7 ┼────────────┼ A7 │
│ A6 ┼────────────┼ A6 │
│ A5 ┼────────────┼ A5 │
│ A4 ┼────────────┼ A4 │
│ A3 ┼────────────┼ A3 │
│ A2 ┼────────────┼ A2 │
│ A1 ┼────────────┼ A1 │
│ A0 ┼────────────┼ A0 │
└─────────────┘ └─────────────┘
Входы/выходы An это адресные контакты, число обозначает номер разряда. E - специальный вход "включающий" именно эту микросхему памяти. Обратите внимание что A13 никуда не подключен. Теперь давайте посмотрим как процессор будет запрашивать ячейку памяти и какую ячейку внутри микросхемы он получит на самом деле
Адрес ячейки в адресном Фактический адрес
пространстве процессора ячейки в микросхеме памяти
$C000 = 1100 0000 0000 0000
0 0000 0000 0000 = $0000
$C100 = 1100 0001 0000 0000
0 0001 0000 0000 = $0100
$C130 = 1100 0001 0011 0000
0 0001 0011 0000 = $0130
$CFA7 = 1100 1111 1010 0111
0 1111 1010 0111 = $0FA7
$E000 = 1110 0000 0000 0000
0 0000 0000 0000 = $0000
$E100 = 1110 0001 0000 0000
0 0001 0000 0000 = $0100
$E130 = 1110 0001 0011 0000
0 0001 0011 0000 = $0130
$EFA7 = 1100 1111 1010 0111
0 1111 1010 0111 = $0FA7
Как вы можете видеть, 14й бит (ножка A13) адреса ни на что не влияет, вне зависимости от его значения мы получаем одну и ту же ячейку памяти. И именно поэтому получается что в адресном пространстве процессора начиная с адреса $C000 идут по порядку все ячейки оперативной памяти, а потом с $E000 идут они же. К примеру, если вы в ячейку $C925 сохраните число $22, то оно будет доступно и по адресу $E925 и наоборот. В этом и состоит метод зеркалирования.
Слоты и банки
Вернёмся к схеме адресного пространства. С RAM мы разобрались, теперь ROM. Давайте посчитаем сколько ROM памяти нам доступно в адресном пространстве, три четверти по 16кб. 3 * 16кб = 48кб. Выглядит неплохо. Некоторым играм хватало такого количества памяти на всё, некоторые игры умещались даже в 32кб. Но если вы играли на эмуляторе SMS, то могли заметить что большинство "ромов" занимает куда больше места, от 128кб и даже до 1Мб. Давайте разберёмся как имея всего 48кб адресного пространства можно работать с куда бОльшими объёмами памяти.
Основной объём в играх, как правило, занимают игровые ресурсы, такие как графика, музыка, локации, иногда ещё и различные игровые таблицы, например большой список игровых предметов со всеми их свойствами. Но у игровых ресурсов в игре есть одна особенность. В подавляющем большинстве случаев все они не нужны одновременно. Например в вашей игре есть уровень где действие происходит в руинах замка, значит в этот момент вам нужна только графика этого уровня, одна или две фоновые композиции набор звуков и прочее. То есть другие саундтреки, графика других уровней, текст, карта уровня и прочие ресурсы в данный момент вообще не востребованы. Эта особенность и является ключевой. Мы можем в один и тот же диапазон адресного пространства динамически подключать разные блоки памяти. При этом мы можем контролировать этот процесс и подключать только те ячейки памяти которые нам нужны в данный момент. Этот метод называется memory mapping. Я не нашел подходящего русскоязычного термина для этого приёма. Самым близким будет постраничная адресация, но как мне кажется - он немного о другом, поэтому я буду называть его просто маппинг.
Получается, что теоретически, в нашем роме может быть настолько много памяти, насколько позволит бюджет и схемотехнические ограничения. На практике же максимальный размер картриджа на котором официально выходила какая-либо игра не превышал одного мегабайта.
Давайте теперь более подробно рассмотрим реализацию. Как вы можете видеть на самой первой схеме, вторая, третья и почти вся первая четверть обозначены таинственным словом "Слот" и номером. Само слово слот (англ. slot) можно перевести как разъём, но по смыслу это скорее "некий контейнер предназначенный для определённого типа предметов". К примеру разъёмы на материнской плате для подключения сетевых карт, видеокарт и прочих тоже называются слотами. Тот же смысл это название несёт и здесь. Слот - это специальный диапазон в адресном пространстве куда может подключаться какой-нибудь участок памяти. Подключаемый в слот участок памяти называется банком. Как правил, размеры банка и слота равны. Давайте на примере посмотрим как банки из памяти в 128кб могут быть подключены в слоты в адресном пространстве.
Адресное пространство ROM в картридже
128кб
$0000 ┌─────────────┐ ┌─────────────┐ $000000
│ ROM слот 0 ┼────────┐ │ ROM банк 0 │
$4000 ├─────────────┤ │ ╔═════════════╗ $004000
│ ROM слот 1 ┼─────┐ └──║ ROM банк 1 ║
$8000 ├─────────────┤ │ ╚═════════════╝ $008000
│ ROM слот 2 ┼──┐ │ │ ROM банк 2 │
$C000 ├─────────────┤ │ │ ╔═════════════╗ $00C000
│ RAM │ │ └─────║ ROM банк 3 ║
$FFFF └─────────────┘ │ ╠═════════════╣ $010000
└────────║ ROM банк 4 ║
╚═════════════╝ $014000
│ ROM банк 5 │
├─────────────┤ $018000
│ ROM банк 6 │
├─────────────┤ $01C000
│ ROM банк 7 │
└─────────────┘ $01FFFF
Мапперы.
Вы могли заметить небольшое расхождение с первой схемой, так как слот 0 начинается не с $0000, а с $0100 и как следствие имеет размер не 16кб, а 15кб. Давайте для простоты, пока что, договоримся считать весь диапазон $0000-$4000 слотом 0. Теперь посмотрим как подобный фокус реализован "в железе". Для подобных операций, между памятью в картридже и адресной шиной появляется посредник - специальная микросхема называемая маппером. Маппер читает из шины адрес и исходя из собственных внутренних настроек и поступающего адреса, "подставляет" системной шине нужный участок ROM'а
Давайте для примера рассмотрим вот такой простейший гипотетический маппер и способ его подключения.
┌────────────┐
│ Маппер │ ┌─────────────┐
┌─────────────┐ │ │ │ ROM 128кб │
│ Шина адреса │ │ OA16 ┼─────┐ │ │
│ │ ┌────┼ IA15 OA15 ┼───┐ └─┼ A16 │
│ A15 ┼─┘ ┌──┼ IA14 OA14 ┼─┐ └───┼ A15 │
│ A14 ┼───┘ └────────────┘ └─────┼ A14 │
│ A13 ┼───────────────────────────┼ A13 │
│ A12 ┼───────────────────────────┼ A12 │
│ A11 ┼───────────────────────────┼ A11 │
│ A10 ┼───────────────────────────┼ A10 │
│ A9 ┼───────────────────────────┼ A9 │
│ A8 ┼───────────────────────────┼ A8 │
│ A7 ┼───────────────────────────┼ A7 │
│ A6 ┼───────────────────────────┼ A6 │
│ A5 ┼───────────────────────────┼ A5 │
│ A4 ┼───────────────────────────┼ A4 │
│ A3 ┼───────────────────────────┼ A3 │
│ A2 ┼───────────────────────────┼ A2 │
│ A1 ┼───────────────────────────┼ A1 │
│ A0 ┼───────────────────────────┼ A0 │
└─────────────┘ └─────────────┘
ROM содержит 8 банков. Младшие 14 бит адреса подключены напрямую к ROM. Помимо 5 выводов на схеме в маппере ещё есть свои регистры с настройками, которые мы можем менять, например через какой-нибудь порт. Входы в маппере IA14 и IA15 определяют слот для которого запрашивается память, и исходя из настроек в своих внутренних регистрах, маппер выставляет на выводах OA14-OA16 старшие биты, определяя какой банк отдать какому слоту. В процессе выполнения программы мы можем менять содержимое регистров тем самым влиять на то какой банк будет отдаваться на запрос к какому слоту.
Мы рассмотрели простейший гипотетический маппер. В настоящих мапперах всё практически также. Мапперов для SMS существовало не так много как например для NES, что конечно облегчает задачу. Помимо этого подавляющая часть игр была сделана на официальном маппере от самой Sega, который мы и будем рассматривать в дальнейшем и дальше я буду называть его стандартный маппер.
Управляется такой маппер записью в нижние ячейки памяти с адресами $fffc-$ffff, как вы помните по этому адресу находится зеркало оперативной памяти. Такой путь выбрали проектировщики маппера. Просто выводы управления маппером подключены параллельно памяти. Таким образом значения идут и в маппер и хранятся в памяти по этим адресам. Так что эти ячейки памяти для других целей использовать не выйдет. Как именно управлять маппером мы разберём в будущих статьях.
Теперь давайте разберёмся с первым килобайтом памяти который находится в промежутке $0000-$00ff. И с нулевым слотом в промежутке $0100-$3fff. Это особенность стандартного маппера. Первый килобайт памяти всегда указывает на первый килобайт ROM'а в картридже вне зависимости от того какой банк подключен в 0-й слот. Раз первый килобайт памяти жестко привязан к первому килобайту ROM'а, то на 0-й слот остаётся всего 15кб, а банк у нас по 16кб. Получается что если банк подключен в 0-й слот, то нам доступны только последние 15кб этого банка, но такая проблема возникает только если в слоте 0 не подключен 0-й банк.
А зачем так сложно сделали? В компании Sega таким образом перестраховались. Первый килобайт памяти очень важен для процессора. В нём находятся точки входа разных служебных подпрограмм, таких как обработчики прерываний которые мы рассмотрим ниже. Поэтому такая защита первого килобайта гарантирует что при смене банка мы не потеряем эти важные подпрограммы.
Метки
Мы разобрались с организацией памяти в SMS. Теперь давайте посмотрим какие средства для работы с памятью нам предоставляет ассемблер WLA DX.
Взгляните на такой вот маленький кусочек кода. В комментариях я напишу адреса всех команд:
nop ; $0000
nop ; $0001
nop ; $0002
ld b, 3 ; $0003 - Загрузить в регистр b число 3
ld a, 0 ; $0005 - Загрузить в регистр a число 0
add a, 3 ; $0007 - Прибавить к регистру a число 3
dec b ; $0009 - Уменьшить b на единицу
jp nz, $0007 ; $000a - если b не стало нулём - перейти по адресу $0007
В этом примере мы три раза прибавляем к содержимому регистра a число 3. как видите в последней строчке указан адрес перехода. Но если мы, к примеру захотим стереть один из первых nop'ов или добавить в начало нашей программы какие-то другие операции, то адреса наших команд изменятся. И в строке с переходом придётся вычислять новый адрес. А если в нашей программе будет не одно такое место, а десятки или даже сотни? Не очень то это удобно. Но, к счастью нам не придётся самостоятельно заниматься подобными вещами, потому что любой ассемблер предоставляет нам такой замечательный инструмент как метки.
До этого мы рассматривали только непосредственно команды процессора которые одна за другой последовательно преобразуются в машинный код. Но метки работают несколько иначе. Сами по себе они ни в какой код не преобразуются. Но на их основе ассемблер для нас рассчитывает правильные значения адресов. В качестве метки используются последовательности букв, цифр и подчёркиваний. В каком-то месте нашей программы мы эту метку объявляем. Пишем её название и ставим после неё двоеточие. Это называется объявлением метки это значит, что ассемблер встречая объявление метки, запомнит адрес следующей за ним комманды, и будет подставлять этот адрес во все остальные места в программе куда мы напишем название метки. Для ясности давайте перепишем наш код с использованием метки.
ld b, 3 ; $0003
ld a, 0 ; $0005
loop: ; Объявление метки loop
add a, 3 ; Загрузить в регистр b число 3
dec b ; Загрузить в регистр a число 0
jp nz, loop ; если b не стало нулём - перейти
; по адресу обозначенному меткой loop
Как видите, теперь мы не указываем точные адреса. Ассемблер сам за нас вычислит адрес команды add a, 3 и подставит его во все места где мы напишем имя метки.
Меток может быть сколько угодно, но объявляться они должны один раз, то есть имена разных меток не должны совпадать, иначе ассемблер посчитает их одной и той же меткой.
Директивы
Помимо меток в языке ассемблера есть такие штуки как директивы. Они, как и метки не преобразуются в машинный код, они дают самому ассемблеру определённые инструкции как именно дальше преобразовывать команды в машинный код или где именно разместить результат преобразования в машинный код, команд следующих за этой директивой.
Давайте снова разбираться на примере. Допустим нам надо разместить подпрограмму точно по адресу $0010. Самое простое что мы можем сделать - это забить всё лишнее пространство nop'ами.
ld b, 3 ; $0000
ld a, 0 ; $0002
nop ; $0004
nop ; $0005
nop ; $0006
nop ; $0007
nop ; $0008
nop ; $0009
nop ; $000a
nop ; $000b
nop ; $000c
nop ; $000d
nop ; $000e
nop ; $000f
loop: ; Объявление метки loop
add a, 3 ; $0010 Загрузить в регистр b число 3
dec b ; $0012 Загрузить в регистр a число 0
jp nz, loop ; $0013 если b не стало нулём - перейти
; по адресу обозначенному метрой loop
Как видите ситуация чем-то схожа с той, что была когда бы рассматривали метки. Для того чтобы нужная нам операция оказалась по точному адресу, нам снова пришлось вручную считать адреса, и выравнивать их при помощи добавления "пустых команд" - nop'ов. Это тоже не очень удобно, потому что при изменении кода выше нашей подпрограммы, нам придётся постоянно корректировать число nop'ов. Подобное решение - это тоже достаточно сомнительное удовольствие, ещё и может провоцировать лишние ошибки, если мы забудем откорректировать число nop'ов. К счастью и эта проблема решена.
Существует специальная директива .org. Она указывает ассемблеру с какого адреса начинать записывать машинный код следующих команд. Давайте перепишем наш пример с использованием этой директивы.
ld b, 3
ld a, 0
.org $0010 ; весь последующий код начинается с адреса $0010
loop:
add a, 3
dec b
jp nz, loop
Как видите теперь мы можем как угодно менять программу до этой директивы и ничего пересчитывать не надо, ассемблер всё сделает за нас.
Помимо директивы .org существует ещё огромное множество директив. Разбирать мы их будем по мере надобности. Но стоит сразу запомнить что все директивы начинаются с точки.
Данные
До этого момента мы разбирались только с программой для процессора, но иногда нам требуется разместить в памяти, не инструкции для процессора, а непосредственно данные. К примеру если нам надо вывести текстовое сообщение, то нам придётся сохранить его где-то в памяти. Для этого в ассемблере есть специальные директивы самая часто используемая это .db (англ. define byte - определить байт). По своему использованию эта директива похожа на команду с произвольным количеством операндов. Сколько байт вам надо определить, столько и пишите.
.db $12, $fa, 0 ; записать с текущего места три байта $12, $fa, $00
Помимо .db существует ещё одна похожая директива для "слов", то есть 16-битных чисел - .dw (англ. define word - определить слово). Работает точно так же как и .db, только с 16-битными числами. Для размещения в памяти длинных последовательностей одинаковых данных существуют директивы .dsb (англ. define series of bytes) и .dsw (англ. define series of words). Эти директивы имеют по два операнда: первый - количество определяемых байт или слов, второй - повторяемое значение.
.dsb 120, $12 ; записать начиная с текущего места
; 120 байт со значениями $12
.dsw 120, $f412 ; записать начиная с текущего места
; 120 слов (240 байт) со значениями $f412
Стек
Мы много раз уже упоминали про стек, а я всё обещал рассмотреть его позже. И вот наконец-то дошла очередь и до него. Стек (англ. stack - стопка каких либо объектов) - это область оперативной памяти для хранения временной информации необходимой для нормальной работы программ. Данные в стеке хранятся только в виде 16-битных значений.
Классической аналогией для понимания устройства стека служит колода карт, стопка бумаг или монет, однако мне куда больше нравится сравнивать его с одной ханойской башенкой. То есть числа в стек можно помещать только над теми что уже есть в стеке и читать только верхнее число из стека. Самое верхнее число в стеке называется вершиной стека. Давайте для наглядности нарисуем схему того как это выглядит:
Адрес
┌───────┐
$DFE0 │ │
$DFE2 │ │
$DFE4 │ │
$DFE6 │ $2344 │ <-- Вершина
$DFE8 │ $4764 │ стека
$DFEA │ $005a │
$DFEC │ $f431 │
$DFEE │ $1202 │
$DFF0 └───────┘
Стек "растёт" от старших адресов к младшим, давайте проиллюстрируем процесс как помещения и извлечения числа из стека.
В стек помещено В стек помещено Из стека извлечено
новое число $ff34 новое число $22f4 число $22f4
Адрес
┌───────┐ ┌───────┐ ┌───────┐
$DFE0 │ │ │ │ │ │
$DFE2 │ │ │ $22f4 │ <-- Вершина │ │
$DFE4 │ $ff34 │ <-- Вершина │ $ff34 │ стека │ $ff34 │ <-- Вершина
$DFE6 │ $2344 │ стека │ $2344 │ │ $2344 │ стека
$DFE8 │ $4764 │ │ $4764 │ │ $4764 │
$DFEA │ $005a │ │ $005a │ │ $005a │
$DFEC │ $f431 │ │ $f431 │ │ $f431 │
$DFEE │ $1202 │ │ $1202 │ │ $1202 │
$DFF0 └───────┘ └───────┘ └───────┘
Адрес вершины стека хранится в специальном регистре SP. При помещении числа в стек значение регистра SP уменьшается на 2, затем в ячейки памяти расположенные по адресу хранящемуся в SP и адресу SP+1 сохраняется 16-битное число. При извлечении из стека происходит обратный процесс. Число по адресу хранящемуся в SP и SP+1 сохраняется куда-то, например в регистровую пару, и после этого значение регистра SP увеличивается на 2.
Для работы со стеком существуют две команды push и pop. Обе эти команды имеют один аргумент, любой 16-битный регистр: af, bc, de, hl, ix, iy. Вот такой синтаксис:
push <регистр>
pop <регистр>
push - служит для помещения числа из регистровой пары в стек. pop - для извлечения числа из стека и помещения его в регистровую пару.
Помимо этих команд со стеком так же работают команды call и ret. При выполнении команды call перед переходом по адресу указанному в операнде этой команды, адрес следующей операции помещается в стек. После этого исполнение переходит по адресу из операнда call. Далее когда процессор встречает команду ret, то из стека извлекается адрес по которому снова происходит переход (возврат).
Работать со стеком нужно осторожно. Например если вы помещаете в стек какие-то свои данные, затем происходит вызов подпрограммы. В которой вы пытаетесь извлечь данные, полагая что вы получите ранее сохранённые данные, но вы получите адрес возврата из подпрограммы, а после этого сам возврат произойдёт по некорректному адресу. Давайте проиллюстрируем кодом.
ld hl, $22f1 ; пеместить число $22f1 в регистр hl
push hl ; поместить содержимое hl в стек
ld hl, $3400 ; пеместить число $3400 в регистр hl
push hl ; поместить содержимое hl в стек
call subroutione ; перейти по адресу subroutione
ld hl, $0001 ; пеместить число $0001 в регистр hl
; ...
subroutine: ; подпрограмма по адресу subroutine
pop hl ; извлечь из число стека и поместить в hl
; ошибка состоит в том что программист думает,
; что извлекает из стека число $3400,
; однако на самом деле он извлекает из стека
; число соответствующее адресу команды ld, hl, $0001
ret ; произойдёт некорректный возврат по адресу $3400
Поэтому между вызовами подпрограмм и внутри подпрограмм надо делать равное количество push'ей и pop'ов, либо внимательно следить за вашими действиями со стеком, если пытаетесь реализовать сложную логику переходов.
Давайте напоследок исправим наш пример:
ld hl, $22f1 ; пеместить число $22f1 в регистр hl
push hl ; поместить содержимое hl в стек
ld hl, $3400 ; пеместить число $3400 в регистр hl
push hl ; поместить содержимое hl в стек
call subroutine ; перейти по адресу subroutione
ld hl, $0001 ; пеместить число $0001 в регистр hl
; ...
subroutine: ; подпрограмма по адресу routione
pop de ; извлечь из число стека и поместить в de
; запомним что это наш адрес возврата
pop hl ; извлечь из стека число $3400 в hl
; ... ; произвести какие-то манипуляции с этим числом
pop hl ; извлечь из стека число $22f1 в hl
; ... ; произвести какие-то манипуляции с этим числом
push de ; поместить в стек адрес возврата
ret ; произойдёт корректный возврат
; по адресу команды ld hl, $0001
Прерывания
В описании VDP мы вскользь упомянули такую штуку как прерывание. Давайте разберёмся что это такое. Как мы знаем процессор выполняет последовательно программу в памяти, после каждой инструкции в регистр PC заносится адрес следующей инструкции и так далее. Однако в процессоре есть специальные контакты по которым приходит сигнал от периферийных устройств. Этот сигнал сообщает о событии на которое процессор должен максимально быстро отреагировать, то есть обработать это событие, а затем вернуться к выполнению основной программы.
Разберёмся как это происходит. Процессор заранее "знает" что по определённому адресу в памяти находится специальная подпрограмма. При поступлении сигнала прерывания, процессор заканчивает выполнение текущей инструкции. Помещает содержимое регистра PC в стек, затем помещает в PC адрес начала этой специальной подпрограммы. Она служит для обработки прерывания, ещё каждую такую подпрограмму обычно называют вектор прерывания.
Таким образом получается что в любой момент процессор может прервать выполнение основной программы, перейти к вектору прерывания, который заканчивается специальной инструкцией обозначающей возврат из прерывания. И после неё процессор возвращается к тому же месту на котором он прервался. Это очень удобный и используется он и повсеместно в компьютерной технике.
В процессоре z80 существует два типа прерываний, маскируемые и немаскируемые, для каждого из этих прерываний существует специальный вывод в процессоре. Немаскируемые прерывания или NMI (англ. Non-Maskable Interrupt), служат для обработки экстренных событий, обычно это проблемы с питанием или какая-то физическая неисправность. Немаскируемые прерывания имеют высший приоритет. То есть при поступлении этого прерывания процессор всегда переходит к его обработчику, что бы у него там не происходило и какими бы важными делами он там не был занят. Обработка немаскируемого прерывания всегда должна начинаться по адресу $0066 и завершаться инструкцией retn (англ. Return from non-maskable interrupt - возврат из немаскируемого прерывания).
В SMS на контакт немаскируемого прерывания заведена кнопка Pause расположенная на корпусе приставки.
А зачем? ¯\_(ツ)_/¯
Я сам теряюсь в догадках. Нам остаётся только работать с тем что есть. =) Но видимо инженеры из компании Sega решили что эта кнопка нужна для полной и тотальной приостановки игры. Впрочем, в более поздних моделях SMS от этой кнопки отказались вовсе, но обрабатывать её мы по-прежнему обязаны.
Второй вид прерываний - маскируемые или INT (англ. interrupts - прерывания). Здесь всё несколько интереснее. Этот тип прерываний мы можем настроить куда более гибко. Например мы можем по собственному усмотрению включать и выключать обработку маскируемых прерываний. Для этого существуют специальные инструкции ei (англ. enable interrupts - включить прерывания) и di (англ. disable interrupts - отключить прерывания). Важный момент который стоит запомнить. При входе в обработчик маскируемого прерывания, автоматически отключается обработка прерываний, аналогично вызову инструкции di, и перед выходом из прерывания, надо её заново вручную включить вызовом команды ei, если это необходимо. Обработчик маскируемого прерывания должен оканчиваться инструкцией reti (англ. Return from interrupt - возврат из прерывания).
Расположением подпрограммы обработки маскируемого прерывания мы тоже можем управлять в определённой степени. Для этого в процессоре существуют три режима работы прерываний. Для активации каждого из которых существуют специальные инструкции, объединённые в единую мнемонику. im (англ. interrupt mode - режим прерывания), после которой указывается номер режима, 0, 1 и 2 соответственно.
Для работы в режиме 0 процессору необходима дополнительная микросхема - генератор прерываний. И после получения сигнала прерывания на вводе INT процессор обращается за следующей инструкцией не к памяти, а к генератору прерываний и получает эту команду уже от него. Обычно это просто переход по какому-то адресу в памяти в зависимости от того что сочтёт необходимым генератор прерываний. В SMS такой микросхемы нет, поэтому можем сразу забыть про этот режим.
Когда активирован режим 1, при поступлении сигнала прерывания на вводе INT, процессор переходит по адресу $0038, аналогично обработке NMI только по другому адресу.
В режиме 2 Адрес вектора прерывания формируется из значения специального регистра процессора I и значения на шине данных. То есть. Если устройство генерирующее сигнал прерываний, способно в этот же самый момент выставить на шине данных нужное значение, то адрес будет сформирован следующим образом: старший байт адреса будет взят из регистра I, а младший из шины данных.
Это очень интересный и гибкий режим прерываний, при условии что периферийные устройства способны с ним работать корректно. Однако в SMS генерировать маскируемые прерывания способен только VDP, а он так делать не умеет, и получается что в момент прерывания на шине данных оказывается "мусор". Это поведение конечно можно обойти парой ухищрений, но обработчик прерывания получится большим, неповоротливым и займёт очень много места. Поэтому использование этого режима в SMS является нецелесообразным.
Так как режим 0 использовать не получится вовсе, а режим 2 практически непригоден для нас, остаётся только режим 1. В котором обработчик прерываний находится по фиксированному адресу. Как видите обработчики и маскируемого и немаскируемого прерывания находятся в пределах первого килобайта адресного пространства, становится ясно для чего этот участок памяти "защищён" в стандартном маппере от смены банка.
На этом на сегодня пожалуй всё. Получилось немного больше информации чем я планировал изначально, но надеюсь это окупится понятностью и доступностью изложения. А в следующей статье мы научимся уже работать с VDP через порты ввода/вывода и напишем нашу первую программу: классический "Hello World".
- Оглавление
- Предыдущая статья: 04. Введение в язык ассемблера Z80
- Следующая статья: 06. Порты ввода-вывода. Hello World