Как стать автором
Обновить

Создание игр для NES на ассемблере 6502: оборудование NES и знакомство с ассемблером

Время на прочтение14 мин
Количество просмотров8.2K
Автор оригинала: Kevin Zurawel
image


4. Оборудование NES


Содержание:

  • Консоль
  • Картриджи
  • Как это связано с нашим тестовым проектом?
  • Цвета и палитры
  • Возвращаемся к тестовому проекту

Прежде чем приступать к разбору ассемблера, начнём с обзора самой NES.

Консоль


Если открыть консоль NES и посмотреть внутрь, то можно увидеть нечто подобное:


Материнская плата NTSC-версии NES (США/Япония). Фото Эвана Эмоса.

Внешне на материнской NES выделяются разъём для картриджей в верхней части и два больших чипа. Слева находится вот такой чип с маркировкой «RP2A03»:


Центральный процессор/аудиопроцессор Ricoh 2A03

А справа расположен другой чип с маркировкой «RP2C02»:


PPU Ricoh 2C02.

Вместе эти два чипа обеспечивают всю вычислительную мощь NES. Первый чип (2A03) — это центральный процессор (CPU, central processing unit) консоли NES. 2A03 создан на основе процессора 6502 компании MOS Technologies и дополнен его производителем Ricoh несколькими особыми возможностями.

[Когда я говорю, что 2A03 «создан на основе» 6502, то подразумеваю, что между ними есть только одно важное отличие: в 2A03 отсутствует поддержка функции, которая в 6502 называется режимом «binary coded decimal» (BCD). BCD позволяет двоичным числам, с которыми работает центральный процессор, вести себя при сложении или вычитании как десятичные числа. Электронная схема BCD в процессоре 6502 находилась в сфере действия отдельного лицензионного соглашения, стороной которого не была компания Ricoh, поэтому она не могла легально включить режим BCD в свои процессоры, несмотря вся схема работы этого режима была реализована в кремнии. Чтобы обойти эту проблему, Ricoh изготовила «полный» 6502, но перерезала все электрические соединения между участком BCD чипа и остальной частью процессора.]

Также Ricoh включила в 2A03 полный аудиопроцессор (APU, audio processing unit), обрабатывающий музыку и звуковые эффекты.

Второй чип (2C02) — это PPU консоли NES (picture processing unit, «устройство обработки изображений»). Сегодня его можно воспринимать как «графическую карту». PPU получает команды от CPU и преобразует их в выводимую на экран информацию. CPU ничего не знает о том, как работают телевизоры; всю эту обработку он оставляет на долю PPU.

Картриджи


Игры для NES распространяются на пластмассовых картриджах (или Game Pak'ах, как называла их Nintendo в США). Внутри каждого картриджа находится небольшая печатная плата, выглядящая примерно так:


Печатная плата картриджа игры Tetris. Фото Эвана Эмоса.

Как и на материнской плате консоли, на печатной плате картриджа выделяются два больших чипа. Левый имеет маркировку «PRG», а правый — «CHR». PRG-ROM — это «ROM программы», он содержит весь код игры (машинный код). CHR-ROM — это «ROM символов», содержащий все графические данные игры.

[На этой плате есть два дополнительных чипа. Справа от двух основных ROM находится CIC (Checking Integrated Circuit), или чип «блокировки». Это защита, при помощи которой Nintendo пыталась сделать так, чтобы на NES запускались только те картриджи, которые произведены компанией. (Также чип CIC является причиной того, что игры для NES часто многократно мерцают, пока не вытащишь их из разъёма и не вставишь заново, и лучше не подув при этом на контакты.) Под двумя чипами ROM находится «MMC1» — контроллер управления памятью, который мы подробно рассмотрим в других главах книги.]

Контактны этих двух чипов ROM соединены с группой золотых контактов на плате картриджа. Когда картридж вставляют в разъём картриджа на консоли, золотые контакты образуют электрическое соединение с аналогичной группой контактов в консоли. В результате этого PRG-ROM электрически соединяется напрямую с процессором 2A03, а CHR-ROM напрямую соединяется с PPU 2C02.


Схема соединения чипов ROM картриджа с процессорами консоли.

Как это связано с нашим тестовым проектом?


Если вернуться к исходникам тестового проекта на языке ассемблера, то можно увидеть множество строк, начинающихся с .segment. Это ассемблерные директивы, о которых мы поговорим в следующей главе. Они указывают, где в готовом файле ROM должны располагаться фрагменты кода.


Два сегмента не являются непосредственно частями кода самой игры. Сегмент STARTUP на самом деле ничего не делает; он необходим для кода на C, компилируемого в ассемблерный код 6502, но мы его не используем. Сегмент HEADER содержит информацию для эмуляторов о том, какие чипы есть в картридже.

Остальные сегменты относятся к разделению PRG/CHR. CODE — это, разумеется, код, сохраняемый в PRG-ROM. VECTORS — это способ задания кода, который должен находиться в самом конце блока PRG-ROM (о причинах этого мы поговорим позже). А CHARS обозначает всё содержимое CHR-ROM, обычно включаемое в виде двоичного файла.

Каким же будет содержимое чипа CHR-ROM, а значит, какую графику будет отображать игра?


.res — это ещё одна ассемблерная директива, приказывающая ассемблеру «зарезервировать» определённый объём пустого пространства; в данном случае это 8192 байта. Но если весь CHR-ROM пуст, то откуда берётся зелёный фон в нашем тестовом проекте?

Цвета и палитры


Когда выit я сказал, что в чипе CHR-ROM содержится «вся» графика игры, я немного упростил. Если точнее, чип CHR-ROM содержит паттерны различных цветов, отображаемых в игре. Однако сами цвета являются частью PPU. Устройство PPU знает, как отображать фиксированный набор из 64 цветов.

[Внимательные читатели могут заметить, что на изображении с цветовой палитрой есть только 56 цветов, а не 64. Так получилось потому, что восемь из 64 цветов, известных PPU — это просто чёрный цвет. Это вызвано особенностями отображения цвета ЭЛТ-телевизорами NTSC, а не ошибка в проектировании оборудования.


Палитра цветов NES.

Из-за аппаратных ограничений мы не можем использовать все 64 цвета одновременно. Мы назначаем цвета восьми палитрам системы из четырёх цветов. Четыре таких палитры используются для «фона», а остальные четыре — для «переднего плана».

Восемь палитр имеют одно дополнительное ограничение: первый цвет каждой из этих палитр должен быть одинаковым. Этот первый цвет используется как «стандартный» цвет фона (когда в текущем пикселе на фоне ничего не отрисовывается, используется стандартный цвет), а также как цвет прозрачности для переднего плана (при отрисовке пикселей переднего плана первый цвет считается прозрачным, что позволяет пикселям фона «за» ним просвечивать). Из-за этих ограничений NES одновременно может отображать не более 25 цветов — один «стандартный» цвет и восемь палитр по три цвета каждая.


Восемь палитр, используемых в World 1-1 игры Super Mario Bros. Верхние четыре палитры используются для переднего плана, а четыре нижние — для фона. Обратите внимание, что многие элементы переднего плана и фона составлены из одинаковых паттернов, но разных палитр, например, разноцветные черепахи на переднем плане и графика кустов/облаков на фоне. Первый голубой цвет в каждой палитре — это цвет «неба» фона в World 1-1.

PPU обозначает каждый цвет однобайтовым числом. Информация палитр хранится в конкретном месте распределения PPU (в отдельной RAM, доступ к которой есть только у самого PPU). 32-байтная область в памяти PPU от $3f00 до $3f20 содержит содержимое восьми палитр. «Стандартный цвет», являющийся первым цветом палитры, хранится в $3f00, $3f04, $3f08, $3f0c, $3f10, $3f14, $3f18 и $3f1c.

[Хотя первый цвет каждой палитры часто повторяют вручную, PPU интересует только значение, записанное в $3f00. В нашей тестовой программе это свойство используется для уменьшении объёма необходимого кода.]


Цветовая палитра NES, на которую наложены байты, которые PPU использует для обозначения каждого цвета.

Возвращаемся к тестовому проекту


Посмотрев на таблицу цветов, можно заметить, что цвет фона нашего тестового проекта соответствует $29. Если взглянуть на код тестового проекта, то можно найти следующее:


Эта строка ассемблерного кода приказывает процессору приготовиться к использованию числа $29, соответствующего зелёному цвету, который мы отображаем на фоне.
В следующей главе мы подробнее расскажем о том, как работает эта строка (и бОльшая часть остального исходного кода тестового проекта).

5. Знакомство с языком ассемблера 6502


Содержание:

  • Наши первые опкоды: перемещение данных
  • Возвращаемся к тестовому проекту
  • Завершаем разбор кода main
  • Домашняя работа

Программа «Hello World», которую вы создали в Главе 3, вероятно, непохожа ни на одну из тех, что вы видели раньше: 44 строки кода, в основном состоящих из трёхбуквенных аббревиатур и чисел, и всё это только для того, чтобы поменять цвет экрана. Что же такое мы скопипастили?

Как вы могли догадаться из названия этой главы, в helloworld.asm содержится ассемблерный код.

[Я предпочитаю использовать для ассемблерных файлов расширение .asm, но это абсолютная условность. Некоторые разработчики предпочитают для ассемблерных файлов расширение .s. В конечном итоге, выбор за вами — ca65 ассемблирует файл с любым расширением, если содержимое является ассемблерным кодом.]

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


Код в текстовом виде
.proc main
LDX PPUSTATUS
LDX #$3f
STX PPUADDR
LDX #$00
STX PPUADDR

copy_palettes:
LDA palettes,x
STA PPUDATA
INX
CPX #$20  ; 32 colors total
BNE copy_palettes

Разделение команд и данных


Трёхбуквенные слова в верхнем регистре в строках 2-6 и 9-13 называются опкодами. Каждое из них представляет собой команду из набора команд процессора, однако здесь мы обозначаем их не числами, а по названиям. Например, LDA означает «load accumulator». В этой книге мы изучим несколько десятков опкодов; всего существует 56 «официальных» опкодов ассемблера 6502.

Благодаря тому, что команды записываются короткими «словами», мы можем визуально различать команды и данные вместо того, чтобы разбирать код побайтно. Всё что находится справа от опкода — это данные, сопровождающие команду. Мы называем данные для команды операндом. Например, LDX #$3f в строке 3 — это команда «загрузить в регистр X шестнадцатеричное значение $3f». LDX — это опкод, а #$3f — операнд.

[Здесь и далее в книге я буду записывать опкоды в верхнем регистре, но это мой личный выбор. Ассемблер понимает и опкоды в нижнем регистре, а некоторые разработчики предпочитают нижний регистр. То же самое справедливо и для значений операндов — $3f можно записать как $3F.]

Константы и метки


Разделение команд и данных — невероятно полезная вещь, но ассемблер даёт нам ещё и множество других инструментов. Операнды в строках 2, 4, 6 и 10, набранные в верхнем регистре, это константы, заданные в другом месте. При запуске ассемблера (ca65) он заменяет имя константы (например, PPUSTATUS) её значением (например, $2002). Создать константу можно при помощи знака равенства: PPUSTATUS = $2002.

Ассемблер позволяет задавать метки — именованные места в коде, на которые в дальнейшем можно ссылаться. В строке 8 задаётся новая метка copy_palettes. Метка превращается в метку благодаря двоеточию (:) после неё. В строке 13 метка используется в качестве операнда опкода BNE. Позже мы подробнее рассмотрим, как это работает, а пока скажем, что строка 13, по сути, повторяет заново код из строк 8-13. В процессе своей работы ассемблер заменяет метки в вашем ассемблерном коде на адреса памяти в созданном машинном коде.

Комментарии


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

Директивы ассемблера


Кроме того, ассемблерный код позволяет использовать директивы — команды ассемблеру, влияющие на преобразование из ассемблерного кода в машинный; они отличаются от команд процессора, в которых исполняется машинный код. Директивы начинаются с точки (.). В строке 1 нашего примера кода использована директива .proc, обозначающая новую лексическую область видимости (подробнее об этом мы расскажем позже). Ещё одна распространённая директива — это .byte, обозначающая, что последующие байты должны копироваться в машинный код в «сыром» виде, а не обрабатываться как опкоды или операнды.

Наши первые опкоды: перемещение данных


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

Загрузка данных: LDA, LDX, LDY


Команды «LD» загружают данные в регистр. Напомню, что в процессоре 6502 есть три регистра, с которыми можно работать: «A», или «accumulator» («накопитель»), позволяющий выполнять математические действия, а также «X» и «Y» — «индексные регистры». LDA загружает данные в накопитель, LDX загружает данные в регистр X, а LDY загружает данные в регистр Y.

Существует два основных источника этих данных: загрузка из адреса памяти и загрузка конкретного значения. Используемый источник зависит от того, как вы запишете операнд для опкода. Если вы используете 16-битное значение (четыре шестнадцатеричных цифры), то опкод загрузит содержимое этого адреса памяти. Если вы используете знак решётки (#), за которым следует 8-битное значение (две шестнадцатеричных цифры), то он загрузит точное значение. Вот пример:


Эти отличающиеся форматы операндов называются режимами адресации (addressing modes). Процессор 6502 может использовать одиннадцать различных режимов адресации (однако большинство опкодов пользуется только их подмножеством).

[На случай, если вам интересно, эти 11 режимов адресации называются Accumulator, Immediate, Implied, Relative, Absolute, Zeropage, Indirect, Absolute Indexed, Zeropage Indexed, Indexed Indirect и Indirect Indexed.]

Два описанных выше режима называются абсолютным (absolute) (передающим адрес памяти) и непосредственным (immediate) (передающим конкретное значение) режимами. По мере необходимости в дальнейшем мы будем узнавать дополнительную информацию о режимах адресации.

Когда написанный вами код пропускается через ассемблер, разные режимы адресации транслируются в разные элементы набора команд. LDA $3f00 превращается в ad 00 3f. В наборе команд ad — это номер набора команд, соответствующий команде «LDA в абсолютном режиме», а $3f00 помещается в прямом порядке байтов. Однако LDA #$3f превращается в a9 3f. Здесь a9 — это номер из набора команд, соответствующий «LDA в непосредственном режиме». Ассемблер достаточно умён, чтобы вставить правильный номер из набора команд на основании того, как вы записали операнд, поэтому вам не нужно беспокоиться о том, какую из команд LDA необходимо использовать.

Сохранение данных: STA, STX, STY


Опкоды «ST» выполняют операцию, обратную опкодам «LD» — они сохраняют содержимое регистра в адрес памяти. STA сохраняет содержимое накопителя в место, заданное его операндом, STX сохраняет содержимое регистра X, а STY — содержимое регистра Y. Команды ST не могут использовать непосредственный режим, потому что невозможно сохранить содержимое регистра в число. После выполнения операции сохранения в сохранённом регистре остаётся то же значение, что позволяет сразу же сохранить то же значение регистра в другое место.

Передача данных: TAX, TAY, TXA, TYA


Команды «T» передают данные из одного реестра в другой. Все эти регистры читаются как «передать из регистра в регистр»; например, TAX — это команда «передать из накопителя в регистр X». Точнее будет назвать «передачу» в этих командах «копированием», потому что после применения одной из этих команд в обоих регистрах будет находиться значение из первого регистра.

Небольшой пример


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


Что делает этот код? Давайте разберём его построчно:

  1. Во-первых, мы загружаем значение $a7 в накопитель. LDA — это опкод, загружающий данные в накопитель. Знак # перед $a7 означает, что мы хотим использовать непосредственный режим, поэтому в накопитель записывается непосредственно это число, а не адрес памяти.
  2. Затем мы копируем значение $a7 из накопителя в регистр Y. Теперь и A, и Y содержат одинаковое значение.
  3. Наконец, мы сохраняем значение регистра Y ($a7) в адрес памяти $3f00.

Возвращаемся к тестовому проекту


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


Основная часть этого кода состоит из загрузок и сохранений. Мы выполняем загрузку и сохранение в адреса памяти $2001, $2002, $2006 и $2007. Загружаем значения непосредственного режима $3f, $00, $29 и %00011110 (двоичное, а не шестнадцатеричное значение).

Присмотревшись к загрузкам в непосредственном режиме, мы заметим, что первые два — это $3f00 — адрес в памяти PPU, где начинаются палитры, за которым следует $29 — зелёный цвет, который мы используем в фоне. Этот код приказывает PPU сохранить $29 по адресу $3f00, но как?

Ввод-вывод с отображением в память


На NES адреса в диапазоне $2000-$6000 зарезервированы для использования в качестве адресов ввода-вывода с отображением в память (memory-mapped I/O, MMIO). «Ввод-вывод» (I/O, input/output) — это передача данных между различными устройствами. «С отображением в память» означает, что эти интерфейсы с другими устройствами отображены в адреса памяти — иными словами, определённые адреса памяти являются не памятью, а скорее связями с другими устройствами.

Адреса памяти в нижних адресах, начиная с $2000, соответствуют связям с PPU.

[Если вы хотите подробнее узнать об MMIO-адресах PPU (или о любой другой теме, связанной с NES), то изучите NESDev Wiki. Хоть это и не лучший ресурс для обучения, он является бесценным справочным руководством по системе, в основе которого лежат тщательные исследования его участников.]

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

$2006: PPUADDR и $2007: PPUDATA


ЦП NES (в котором исполняется наш код) не имеет прямого доступа к памяти PPU. Адрес памяти ЦП $2006 позволяет коду выбрать адрес в памяти PPU, а $2007 позволяет коду записать байт данных в этот адрес. Чтобы задать адрес, в который вы хотите выполнить запись, нужно сохранить два байта данных в $2006 — сначала «старший» (левый) байт, за которым следует «нижний» (правый) байт. Вот как это делается в тестовом проекте:


Этот код сначала сохраняет байт $3f в $2006, а затем байт $00 в $2006 — иными словами, он задаёт адрес $3f00 для любых последующих операций записи в память PPU, то есть адрес первого цвета в первой палитре.

Для сохранения данных в выбранный адрес памяти PPU нужно сохранить байт в $2007:


Этот код записывает байт $29 (обозначающий зелёный цвет) в выбранный ранее адрес памяти ($3f00). Каждый раз, когда мы сохраняем байт в PPUDATA, адрес памяти для следующего сохранения увеличивается на единицу. Если бы следующими строками в программе были LDA #$21
и STA $2007, то байт $21 был бы записан в адрес PPU $3f01, хотя мы и ничего не делали с PPUADDR.

$2002: PPUSTATUS


PPUSTATUS — это MMIO-адрес только для чтения. При загрузке из $2002 получившийся байт даёт информацию о том, что в текущий момент делает PPU. Считывание из PPUSTATUS даёт один полезный побочный эффект: оно сбрасывает «фиксатор адреса» для PPUADDR. Для полного задания адреса памяти требуется две операции записи в PPUADDR, и может так оказаться, что ваш код выполняет одну запись, но никогда не добирается до второй. При считывании из PPUSTATUS следующая операция записи в PPUADDR всегда будет считаться «старшим» байтом адреса.

В нашем тестовом проекте мы считываем («загружаем») из PPUSTATUS, а затем пытаемся записать адрес в PPUADDR.

[Этот процесс — считывание из PPUSTATUS, запись двух байтов в PPUADDR и запись байтов в PPUDATA — мы будем использовать постоянно. Всё, что изменяет отображаемое на экране использует этот процесс, чтобы сообщить PPU, что отрисовывать, и буквально всё в игре будет изменять отображаемое на экране. Тщательное изучение этого процесса сильно пригодится в последующих главах.]


$2001: PPUMASK


Есть ещё одна задача, которую наш тестовый проект должен выполнить после того, как прикажет PPU использовать цвет $29 в качестве первого цвета первой палитры — он должен приказать PPU начать отрисовку на экран! PPUMASK позволяет коду дать PPU команды о том, что отрисовывать, а также настроить способ отображения цветов. Хранимый в PPUMASK байт — это набор из восьми битовых флагов, то есть каждый бит в байте действует как переключатель определённого свойства. В таблице показано, что делает каждый бит. (Помните, что биты, составляющие байт, пронумерованы 0-7, где бит 0 — самый правый, а бит 7 — самый левый.)

№ бита Действие
0 Включить режим градаций серого
(0: обычный цвет, 1: градации серого)
1 Включить фон с левого края (8 пикселей)
(0: скрыть, 1: показать)
2 Включить передний план с левого края (8 пикселей)
(0: hide, 1: show)
3 Включить фон
4 Включить передний план
5 Выделить красный
6 Выделить зелёный
7 Выделить синий

Прежде чем переходить к тестовому проекту, сделаем несколько примечаний по этим опциям. Биты 1 и 2 включают или отключают отображение графических элементов самых левых восьми пикселей экрана. В некоторых играх они отключены, чтобы избежать мерцания при скроллинге, о котором мы подробнее узнаем позже. Биты 5, 6 и 7 позволяют коду «выделить» определённые цвета — сделать зелёный, зелёный или синий ярче, а остальные два цвета сделать темнее. Использование одного из битов выделения, по сути, придаёт экрану оттенок цвета. Использование всех трёх одновременно делает весь экран темнее, что многие игры применяют для перехода от одной области к другой.

Давайте снова взглянем на код тестового проекта. Какое значение записывает наш тестовый проект в PPUMASK?


Вот побитовое описание опций, которые мы задаём:

№ бита Значение Действие
0 0 Режим оттенков серого отключен
1 1 Отображаются левые 8 пикселей фона
2 1 Отображаются левые 8 пикселей переднего плана
3 1 Фон включен
4 1 Передний план включен
5 0 Нет выделения красного
6 0 Нет выделения зелёного
7 0 Нет выделения синего

Так как при записи в PPUMASK наш тестовый проект включает рендеринг фона, после этой строки отображается зелёный фон.

Завершаем разбор кода main


Наш тестовый код записал цвет в память PPU и включил рендеринг на дисплей, так что, наверное, всё готово?

Не будем торопиться. Вспомним, что ЦП получает и исполняет команды по одной за раз, непрерывно. Если код записи не будет давать процессору работу, то он продолжит считывать память (пустую) и «исполнять» её, что может привести к катастрофическим результатам. К счастью, у этой проблемы есть простое решение:


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

Итак, мы рассмотрели всё, что находится в main (хотя и не обсудили пока, что делают .proc и .endproc), но в целом наш разбор тестового проекта практически завершён. Далее мы узнаем о векторах прерываний и о том, как код инициализует NES при первом включении питания.

Домашняя работа


Теперь, когда вы знаете, как тестовый проект задаёт цвет фона, попробуйте изменить его, чтобы он отображал другой цвет. Ниже представлена таблица цветов из Главы 4. Не забывайте переассемблировать и перекомпоновать свой код перед открытием файла в Nintaco.


Цветовая палитра NES.

Создание игр для NES на ассемблере 6502: заголовки и векторы прерываний
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 10: ↑10 и ↓0+10
Комментарии3

Публикации

Истории

Работа

Ближайшие события