8. Рефакторинг


Содержание:

  • Константы
  • Файл заголовка
  • Импорт и экспорт ca65
  • Собственная конфигурация компоновщика
  • Соединяем всё вместе

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

Константы


Во многих местах нашего кода есть конкретные числа, которые не меняются, например, MMIO-адреса для общения с PPU. Глядя на код, сложно понять, что означают эти числа.

К счастью, эти абстрактные значения можно заменить понятным текстом, объявив константы. По сути, константа — это имя одного числа, которое нельзя изменить. Давайте создадим константы для адресов PPU, которые мы уже использовали:

[Обычно эти имена являются стандартными наименованиями для различных MMIO-адресов NES. При изучении источников, например, NESDev wiki, вы их встретите.]


Благодаря этим константам наш код main становится гораздо более читабельным:


Куда поместить эти константы? Обычно создают отдельный фай констант, который можно включить в основной файл ассемблерного кода. Файл констант мы назовём constants.inc.

[Почему этот файл имеет расширение .inc, а не .asm? Файл констант содержит не совсем ассемблерный код; в нём нет никаких опкодов. Мы будем использовать расширение .asm для файлов с ассемблерным кодом, а .inc для файлов, которые мы включаем в файл с ассемблерным кодом.]

Затем мы добавим файл констант в начало файла .asm следующим образом:


Файл заголовка


То же самое можно проделать и с сегментом .header, потому что в целом он будет одинаковым для разных проектов. Давайте создадим файл header.inc, в котором будет находиться содержимое заголовка. Также это подходящее время для добавления комментариев:


Теперь мы можем удалить раздел .segment "HEADER" нашего основного файла .asm и добавить новый файл заголовка. Начало файла .asm должно выглядеть вот так:


При запуске ассемблера и компоновщика они будут брать содержимое header.inc
и помещать его в нужное место готового ROM, точно так же, как если бы мы поместили его непосредственно в файл с ассемблерным кодом.

Импорт и экспорт ca65


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

Этого можно достичь благодаря тому, что ca65 способен импортировать и экспортировать код .proc. Мы используем директиву .export, чтобы сообщить ассемблеру, что определённая proc должна быть доступна в других файлах, и директиву .import, чтобы использовать proc в другом месте.

Для начала давайте создадим reset.asm, содержащий директиву .export:


В этом файле стоит упомянуть несколько вещей. Во-первых, файл имеет расширение .asm, потому что содержит опкоды. Во-вторых, мы добавляем файл констант, чтобы можно было здесь его использовать. В-третьих, нам нужно указать, какому сегменту кода принадлежит эта .proc, чтобы компоновщик знал, как собирать всё вместе. В-четвёртых, обратите внимание, что мы импортируем main. Благодаря этому ассемблер знает, в каком адресе памяти расположена процедура main, чтобы обработчик сброса мог перейти по правильному адресу.

Теперь, когда у нас есть отдельный файл сброса, мы воспользуемся reset_handler внутри кода:


В строке 13, где раньше находилась .proc reset_handler, теперь импортируется процедура из внешнего файла. Обратите внимание, что нам не нужно указывать, из какого файла берётся процедура — перед ассемблированием ассемблер сканирует все файлы .asm на наличие экспорта, поэтому уже знает, какие внешние процедуры доступны и где они расположены. (Стоит также заметить, что из-за этого вы не сможете экспортировать две процедуры с одним именем — ассемблер не поймёт, на какую из них вы ссылаетесь в .import.)

[Возможно, вы заметили, что в reset.asm используется .segment "CODE", а в нашем основном файле с ассемблерным кодом тоже используется .segment "CODE". Что произойдёт, когда мы ассемблируем и скомпонуем эти файлы? Компоновщик находит всё, что принадлежит к тому же сегменту, и соединяет это. Порядок не особо важен, потому что метки преобразуются в адреса на этапе компоновки.]

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

Собственная конфигурация компоновщика


При компоновке примера проекта в Главе 3 мы использовали следующую команду:

ld65 helloworld.o -t nes -o helloworld.nes

-t nes приказывает ld65 использовать стандартную конфигурацию компоновщика для NES. Именно поэтому у нас есть сегмент "STARTUP", хотя мы его никогда не использовали. Стандартная конфигурация подходит для примера проекта, однако когда код станет сложнее и больше, она может привести к проблемам. Поэтому вместо использования стандартной конфигурации мы напишем нашу собственную конфигурацию компоновщика только с теми сегментами и свойствами, которые нам нужны.

Наша собственная конфигурация компоновщика будет находиться в файле nes.cfg, который выглядит вот так:


Код в текстовом виде
MEMORY {
  HEADER: start=$00, size=$10, fill=yes, fillval=$00;
  ZEROPAGE: start=$10, size=$ff;
  STACK: start=$0100, size=$0100;
  OAMBUFFER: start=$0200, size=$0100;
  RAM: start=$0300, size=$0500;
  ROM: start=$8000, size=$8000, fill=yes, fillval=$ff;
  CHRROM: start=$0000, size=$2000;
}

SEGMENTS {
  HEADER: load=HEADER, type=ro, align=$10;
  ZEROPAGE: load=ZEROPAGE, type=zp;
  STACK: load=STACK, type=bss, optional=yes;
  OAM: load=OAMBUFFER, type=bss, optional=yes;
  BSS: load=RAM, type=bss, optional=yes;
  DMC: load=ROM, type=ro, align=64, optional=yes;
  CODE: load=ROM, type=ro, align=$0100;
  RODATA: load=ROM, type=ro, align=$0100;
  VECTORS: load=ROM, type=ro, start=$FFFA;
  CHR: load=CHRROM, type=ro, align=16, optional=yes;
}

В разделе MEMORY указывается структура областей памяти, в которые можно помещать сегменты, а в разделе SEGMENTS заданы имена сегментов, используемых в нашем коде, и области памяти, в которые они должны компоноваться. Я не буду подробно объяснять, что значит каждый из параметров, описание можно найти в документации ld65.

Чтобы использовать собственную конфигурацию компоновщика, нам сначала нужно обновить имена сегментов в нашем коде, чтобы они соответствовали именам сегментов из файла конфигурации. В нашем случае достаточно переместить "CHARS" в "CHR" и удалить "STARTUP".

Соединяем всё вместе


И, наконец, нам нужно немного изменить структуру файлов. Все файлы .asm и .inc мы переместим в подпапку src, а новая конфигурация компоновщика будет находиться на верхнем уровне. После рефакторинга структура кода должна выглядеть вот так:

08-refactoring
   |
   |-- nes.cfg
   |-- src
      |
      |-- constants.inc
      |-- header.inc
      |-- helloworld.asm
      |-- reset.asm

Для ассемблирования и компоновки нашего кода мы используем следующие команды (запускаемые из папки верхнего уровня 08-refactoring):

ca65 src/helloworld.asm
ca65 src/reset.asm
ld65 src/reset.o src/helloworld.o -C nes.cfg -o helloworld.nes

Поясню: здесь мы сначала ассемблируем каждый файл .asm, создавая файлы .o. После этого мы передаём все файлы .o компоновщику. Вместо стандартной конфигурации компоновщика для NES (-t nes) мы используем нашу новую собственную конфигурацию (-C nes.cfg). Результат работы компоновщика помещается в тот же файл ROM helloworld.nes.

Копию всех перечисленных выше файлов можно скачать в формате ZIP отсюда. Мы будем использовать эту схему в качестве основы будущих проектов, поэтому прежде чем двигаться дальше, убедитесь, что у вас получится ассемблировать, скомпоновать и запустить код.

Создание игр для NES на ассемблере 6502: Picture Processing Unit (PPU)

Комментарии (2)


  1. tictac17
    24.03.2022 10:22

    Интересная статья. К прочтению привлек скриншот из игры в начале статьи. Это случайно не Battle Kid?


    1. PatientZero Автор
      24.03.2022 10:59

      Да, она, в предыдущем посте упоминается.