Дампер картриджей для Денди/Famicom
Опубликовано 27 апреля, 2023
Восемь лет назад я уже писал статью о том, как я делал простенький дампер (устройство для чтения картриджей) для Денди/Famicom. Думаю, пора рассказать о том, как этот проект преобразился спустя эти годы вместе с ростом моих скиллов.
Напомню, как вообще работает дампер. По сути это устройство, которое имитирует шину Famicom (наша Денди — это клон Famicom, если кто-то не знает). Точнее там целых две шины — на одной работает сам процессор, а на второй видео чип. Картридж по сути становится частью общей с консолью системы. Это что-то вроде неперезаписываемой оперативки, в которую уже загружена игра. При этом на шине могут быть и другие устройства, и дополнительные микросхемы в картридже.
Записывать картриджи такой дампер тоже может, но только в том случае, если в картридже стоит перезаписываемая память, и возможность записи через шину предусмотрена разработчиком.
Так что же мне захотелось поменять в дампере?
Ну, во-первых, захотелось перевести его на микроконтроллер STM32. Просто ради прокачки своего опыта. Ведь даже когда делаешь какую-то абсолютно фигню, это всё равно даёт тебе опыт. В частности хотелось освоить технологию FSMC.
Во-вторых, хотелось бы, чтобы дампер работал побыстрее, ведь в наше время существуют просто гигантские картриджи по сравнению с тем, что было в девяностые. Например, мой же собственный картридж на 128 мегабайт на старом дампере мог записываться более трёх часов! Такая медленная скорость обусловлена тем, что в старом дампере использовалась микросхема FT232, которая по сути является просто переходником между USB и UART, А UART достаточно медленный протокол, к тому же он асинхронный, что приводит к тому, что данные бьются при малейшем отклонении от нужной частоты.
В-третьих, хочется получить стабильное тактирование линии M2. Дело в том, что у Famicom на процессорной шине есть линия с основным тактом процессора. Ну, куда же без неё. На этой линии процессор выставляет высокий уровень каждый раз, когда он обращается к чему-либо на шине. И делает он это постоянно, так как не переставая либо читает код, либо просто обращается к памяти.
И многие картриджи полагаются на этот сигнал для определения факта нажатия на кнопку reset. Дело в том, что когда мы нажимаем на ресет на консоли, на линии M2 тактовый сигнал исчезает. Точнее даже линия переходит в Z-состояние. Это и используют картриджи для определения сброса. Особенно это актуально для картриджей, в которых несколько игр, и они переключаются именно ресетом. Помните такие? Обычно в них используется простенькая схема из резистора, конденсатора и диода.
Когда на линии M2 есть импульсы, конденсатор заряжается через диод. Когда импульсы пропадают, он постепенно разряжается через резистор, и обычно это триггерит счётчик, который переключает картридж на супербанк с другой игрой.
Вот на старом дампере у меня не было генерации постоянных импульсов на линии M2, и такие картриджи дампились некорректно. А именно один байт читался из одной игры, а следующий уже из другой, ведь картридж думал, что нажали на ресет. Есть ещё картриджи, которые при нажатии на ресет сбрасываются в исходное состояние. Например, переключаются на банк памяти с меню для выбора игр.
Выбор компонентов
В общем, на выводе M2 нужно постоянно генерировать сигнал с частотой в 1.79 мегагерца. И это было бы просто, если бы этот сигнал не нужно было синхронизировать с операциями чтения и записи. То есть M2 вот молотит постоянные такты, и если мы хотим что-то прочитать, то именно в момент низкого уровня мы должны выставить адрес и направление данных, а в момент высокого уровня их уже прочитать. С записью всё еще строже.
Вот тут я и начал смотреть в сторону микроконтроллеров STM32 с технологией FSMC — flexible static memory controller. Эта функция позволяет подключать к микроконтроллеру внешнюю память. Обычно так подключают оперативку или флеш-память, но так то нормальные люди. Почему бы в качестве внешней памяти не подключить так картридж для Famicom? Нужно только выбрать микроконтроллер с полноценным параллельным доступом к памяти, без мультиплексирования. Для этого нам понадобится достаточно крутая STMка с кучей ног.
Я выбрал STM32F103ZET. У него аж 144 ноги, 512 килобайт флеш-памяти, 64 килобайта оперативки, USB, само собой, ну и полноценный FSMC контроллер, к которому можно сразу подключить аж четыре микросхемы внешней памяти. Нам нужно подключить две шины — основного процессора и видео чипа. Подключить их можно хоть и четыре, но они разделяют общие линии данных и адреса. И это совершенно не проблема, ведь в каждый момент времени мы можем обращаться только к чему-то одному. Линии, указывающие на тип операции — чтения или записи, тоже для всех шин общие. Разные только enable линии, с помощью которых микроконтроллер и указывает, к какой из микросхем он обращается.
Однако, при сопряжении этого контроллера и картриджа возникает несколько несовместимостей. На процессорной шине вместо пятнадцатой адресной линии используется сигнал /romsel, который является результатом операции NAND между этой адресной линией и сигналом M2. Соответственно нам в схеме нужна аналогичная логика, чтобы этот сигнал имитировать.
На шине видео чипа у картриджа есть вывод, на который должно подаваться логическое отрицание тринадцатой адресной линии. Для этого тоже нужна логика.
Помимо этого не стоит забывать про вечную проблему сопряжения 5-вольтовых линий у древней техники и 3-вольтовых у современной. Для этого нужно поставить специальные микросхемы — шифтеры уровней. Я использую SN74LVC8T245PWR, они на восемь линий, могут работать в обе стороны, и их понадобится аж 6 штук. И этими микросхемами тоже надо управлять — ведь данные могут идти как в картридж, так и из картриджа. За выбор направления у них отвечает одна из ног. И на эти выводы надо подавать соответствующий сигнал при чтении и записи. Ну и стоит не забывать, что в каждый момент времени можно активировать шифтер только одной из шин, иначе возникнет конфликт шин. Для управления всем этим тоже нужна логика.
Ну и о самом сложном я уже говорил — на процессорной шине операции чтения и записи нужно синхронизировать с тактовым сигналом M2. Сам тактовый сигнал можно элементарно генерировать через ШИМ, а вот для синхронизации опять нужна логика. Слишком много надо логики, да?
Конечно, в данном случае проще будет использовать какую-нибудь простенькую микросхему программируемой логики — ПЛИС. Для таких задач я обычно использую Altera EPM3064. Точнее теперь это уже Intel, а не Altera. Эта микросхема уже снята с производства, но ее всё равно легко купить на аликспрессе по цене в полтора бакса. Не знаю даже, каким современным аналогом ее можно заменить. Все новое стоит гораздо дороже, а функционал местами даже хуже. Например, у EPM3064 есть толерантность к этим пресловутым пяти вольтам.
Схема и плата
В общем, к этой ПЛИС мы подключаем основные контрольные сигналы, старшие адресные линии, шифтеры уровней, сюда же подключим светодиоды для индикации обращения к каждой шине. Ещё надо предусмотреть выводы для прошивки COOLBOY картриджей (об этом ниже) и master clock линию от микроконтроллера.
В остальном микроконтроллер через шифтеры уровней подключаем к слоту картриджа. Как я уже сказал, линии данных обеих шин работают в обе стороны, все остальные же линии передают информацию либо только в картридж, либо только из картриджа. И у остальных шифтеров вывод для определения направления подключен либо напрямую на землю, либо напрямую к питанию.
Но при этом надо предусмотреть возможность выключать эти шифтеры, чтобы имитировать нажатие кнопки ресет на консоли. Это будет как раз нужно для переключаться между играми на картриджах, где игры переключаются ресетом, и для выхода на банк с меню в многоигровках с меню, в которых зачастую это можно сделать опять же только через ресет.
Помимо этого подключу к микроконтроллеру ещё цифровой RGB светодиод WS2812D-F8. Буду разными цветами сигнализировать о текущей операции.
Их можно поставить сразу несколько, соединив в цепочку, и управлять ими при этом всего по одной линии, но это уже излишне.
Схема получается весьма замороченная.
И я совру, если я скажу, что сделал всё верно с первого раза. Постепенно добавлялись многие мелочи. Например, разъём не только для фамикомовских картриджей, но и картриджей для NES (аналог Фамикома в США и Европе). У них всё то же самое, просто разъем другой.
Скажу честно: разводить плату вручную мне было лень. Сам я развёл только питание, в остальном использовал авто трассировку. Она местами выдаёт очень странные результаты вплоть до того, что дорожки могут ходить кругами. Поэтому потом всё равно стоит всё править и вручную.
Прошивка
Для разработки под STM32 я сейчас использую STM32CubeIDE. В нём настройка периферии выполняется до неприличия легко. Просто выбираем нужные параметры, а кубик генерирует код инициализации.
Например, очень сложная схема настройки тактирования различных модулей микроконтроллера выглядит тут как простая визуальная схема. Включаем тактирование от внешнего кварца на 16 мегагерц и настраиваем множители так, чтобы микроконтроллер работал на частоте в 72 мегагерца.
Но без чтения документации всё равно никуда. Никакой справки о том, за что отвечают те или иные настройки, в кубике нет. В частности, например, в даташите сказано, как пользоваться этим самым контроллером памяти. Впрочем, с ним всё просто. Указываем, что мы используем первые две шины. Шина для процессорной памяти у нас будет как SRAM память. Адрес имеет длину в 16 бит, данные — 8 бит. Тут важно ещё указать, что мы используем внешний сигнал ожидания.
Сигнал ожидания работает так: на соответствующую ногу подаётся логическая единица, если память еще не готова выдать данные. Именно его мы будем использовать для синхронизации с тактовым сигналом M2.
Ну а с видео шиной все совсем просто. Адрес имеет длину 14 бит, никакого внешнего сигнала ожидания не надо, выставим лишь тайминги, чтобы ограничить скорость примерно до той, с которой к картриджу обращается настоящая консоль.
Нужно настроить ещё таймеры. Их я использую аж пять штук. Один просто для подсчета времени. Выставлю прескалер так, чтобы при частоте работы микроконтроллера в 72 мегагерца значение таймера увеличивалось каждую микросекунду.
Второй таймер с помощью ШИМ будет генерировать тот самый тактовый сигнал M2. Выставляем настройки так, чтобы частота была примерно как у NTSC консоли — 1.78 мегагерца. Ну как примерно. Получается около одной сороковой частоты работы самого микроконтроллера. Указываем, что сигнал надо выводить на первый канал.
Третий таймер будет тактоваться от второго таймера, так можно будет считать количество прошедших M2 циклов. Точнее тысяч циклов, настроим прескалер.
А вот четвёртый будет работать так же от второго, но уже без прескалера, для очень точного отсчета циклов.
Наконец, пятый таймер будет управлять цифровым светодиодом. Он будет генерировать ШИМ, а через DMA будут передаваться длительности импульсов. Так микроконтроллер будет писать данные в него в фоне, почти не задерживая основную программу.
Настроим ещё вывод Master Clock Output, на него можно вывести половину частоты работы самого микроконтроллера, то есть 36 мегагерц. Эта линия соединена с ПЛИС и будет помогать ей отсчитывать время.
Всякие GPIO пины настраиваем, их получается не особо-то и много. Управление шифтерами, получение прерываний от картриджа, включение режима прошивки COOLBOY картриджей, ну и пара линий для работы с шиной видео чипа.
Ну и, наконец, надо настроить USB. Скорость будет 12 мегабит, это максимум без дополнительных компонентов. Работать будет как виртуальный COM-порт.
Ой, сейчас мне скажут, что это по-бумерски. Нет, по-бумерски — это использовать микросхему FT232, котору я ставил в старые дамперы, и это по сути просто переходник между USB и UART, а UART работает медленно и нестабильно.
Новый же дампер будет у нас только видеться как COM-порт, но при этом будет пропускать через себя все 12 мегабит и иметь контроль ошибок. При этом такой подход не требует установки никаких драйверов и под Windows, и под Linux. И в клиентском приложении порт можно определять автоматически, по текстовым дескрипторам устройства. То есть для пользователя получается всё прозрачно, возиться с настройкой порта не надо.
Кроме этого, со стороны устройства так можно отслеживать момент, когда пользователь открыл порт, что очень удобно, без этого не всегда понятно, где начало передаваемого пакета.
Протокол передачи данных я менять не стал, тем более это сохранит совместимость со старыми дамперами. Он вполне справляется со своей задачей. Данные передаются пакетами, сначала идёт магическое число, потом номер команды, длина, параметры и контрольная сумма.
Обращение к внешней памяти у STM32 с точки зрения кода происходит абсолютно так же, как к внутренней. Для каждой шины выделены соответствующие диапазоны адресов. Я написал дефайн, который преобразует адреса шин Famicom в адреса внутри адресного пространства микроконтроллера. И работа с памятью картриджей стала совсем прозрачной.
#define PRG(address) (*(volatile uint8_t*) ((address) + 0x60000000))
#define CHR(address) (*(volatile uint8_t*) ((address) + 0x64000000))
Первым делом я проверил работу с шиной видео чипа, т.к. там нет никаких заморочек. Когда оно заработало, радости моей не было предела. С процессорной же шиной, как я уже говорил, все несколько сложнее.
Надо написать прошивку для ПЛИС, которая будет синхронизировать сигнал ожидания с тактовой линией M2.
Там получается этакий конечный автомат. Когда видим, что микроконтроллер обращается к памяти, начинаем ждать низкого уровня на M2. Выставляем направление передачи данных, ждем теперь высокого уровня на M2, выжидаем некоторое время, по окончании которого через линию nwait сообщаем микроконтроллеру, что операция завершена. Ну и еще в прошивке ПЛИС я сделал управление светодиодами, чтобы они загорались при чтении или записи в соответствующие шины, при этом пришлось сделать таймер с задержкой, а то они гасли слишком быстро. Ну и остальную простенькую логику тоже туда прикрутил.
Не буду копипастить сюда код, вот он полностью: https://github.com/ClusterM/famicom-dumper-writer/blob/main/CPLD/FamicomDumper.v
В результате работать с картриджем из прошивки стало очень удобно, как будто я обычные переменные читаю и пишу.
PRG(0x8000 | 0x0000) = 0xF0;
PRG(0x8000 | 0x0AAA) = 0xAA;
PRG(0x8000 | 0x0555) = 0x55;
v = PRG(0x8123);
v = CHR(0x0000);
При этом с подключенной памятью можно работать функциями, например, memcpy или DMA.
Клиентское ПО для компьютера
В общем-то весь базовый функционал превращается в пересылку байтов туда-сюда. Просто пишем и читаем память по указанным адресам при запросе с компьютера.
Гораздо больше изменений претерпела клиентская программа для компьютера. Я её писал на C# под .NET с планами на мультиплатформенность. Приложение консольное, и на вход ему передаётся куча параметров.
Самым сложным было сделать систему скриптов. Они обязательно нужны, чтобы описывать алгоритмы дампинга картриджей с различными мапперами. Ну и для всяких других действий с дампером они необходимы. Например, для тестирования памяти или для записи картриджей, которые это поддерживают.
Сначала я сделал скрипты на языке Lua. Просто потому что нашёл подходящую для этого библиотеку. Но потом с удовольствием обнаружил, что .NET позволяет компилировать скрипты на самом C#. Это получилось гораздо удобнее. Для создания скрипта работы с маппером нужно описать в файле класс, реализующий интерфейс IMapper: https://github.com/ClusterM/famicom-dumper-client/blob/master/FamicomDumper/IMapper.cs
У этого класса нужно описать основные свойства вроде номера маппера и размера памяти по умолчанию. И несколько методов. Основные два метода выполняют дампинг программной памяти и видео памяти соответственно. Им в параметрах передаётся объект класса, реализующего интерфейс IFamicomDumperConnection, и списки, которые надо заполнить сдампленными байтами. У этого класса есть методы для работы с дампером — чтение и запись данных в обе шины. Так надо написать код, который пишет в регистры соответствующего маппера и читает данные по соответствующим адресам. Ещё можно описать методы, определяющие тип мирроринга и включающие дополнительную оперативку в картридже для чтения и записи сохранений.
Вот код скрипта для дампинга картриджей без маппера:
class NROM : IMapper
{
public string Name { get => "NROM"; }
public int Number { get => 0; }
public int DefaultPrgSize { get => 0x8000; }
public int DefaultChrSize { get => 0x2000; }
public void DumpPrg(IFamicomDumperConnection dumper, List<byte> data, int size)
{
Console.Write("Reading PRG... ");
data.AddRange(dumper.ReadCpu((ushort)(0x10000 - size), size));
Console.WriteLine("OK");
}
public void DumpChr(IFamicomDumperConnection dumper, List<byte> data, int size)
{
Console.Write("Reading CHR... ");
data.AddRange(dumper.ReadPpu(0x0000, size));
Console.WriteLine("OK");
}
public void EnablePrgRam(IFamicomDumperConnection dumper)
{
// Actually PRG RAM is present in Family Basic
}
}
А вот код для чтения картриджей на основе маппере MMC3: https://github.com/ClusterM/famicom-dumper-client/blob/master/FamicomDumper/mappers/MMC3.cs
В итоге в аргументах командной строки мы указываем имя операции, например dump, а в параметрах номер, название или файл маппера, после чего подгружается и компилируется соответствующий скрипт.
>famicom-dumper dump --mapper MMC3.cs
Чтобы не компилировать скрипты каждый раз, собранные бинарники сохраняются в кеш, и повторная сборка происходит только тогда, когда время редактирования скрипта (или время сборки клиента) становиться позже времени создания файла в кеше.
Еще в параметрах командной строки по желанию можно указать размер основной и видео памяти, которую надо сдампить, имя выходного файла, нужно ли установить у ROM’а флаг использования сохранений, издавать ли звук по завершению операции и еще кучу всего.
Available options:
--port <com> serial port of dumper or serial number of dumper device, default - auto
--tcp-port <port> TCP port for gRPC communication, default - 26673
--host <host> enable gRPC client and connect to specified host
--mappers <directory> directory to search mapper scripts
--mapper <mapper> number, name or path to C# script of mapper for dumping, default - 0 (NROM)
--file <output.nes> output/input filename (.nes, .fds, .sav, etc.)
--prg-size <size> size of PRG memory to dump, you can use "K" or "M" suffixes
--chr-size <size> size of CHR memory to dump, you can use "K" or "M" suffixes
--prg-ram-size <size> size of PRG RAM memory for NES 2.0 header, you can use "K" or "M" suffixes
--chr-ram-size <size> size of CHR RAM memory for NES 2.0 header, you can use "K" or "M" suffixes
--prg-nvram-size <size> size of PRG NVRAM memory for NES 2.0 header, you can use "K" or "M" suffixes
--chr-nvram-size <size> size of CHR NVRAM memory for NES 2.0 header, you can use "K" or "M" suffixes
--battery set "battery" flag in ROM header after dumping
--unif-name <name> internal ROM name for UNIF dumps
--unif-author <name> author of dump name for UNIF dumps
--fds-sides <sides> number of FDS sides to dump (default - 1)
--fds-skip-sides <sides> number of FDS sides to skip while writing (default - 0)
--fds-no-header do not add header to output file during FDS dumping
--fds-dump-hidden try to dump hidden files during FDS dumping (used for some copy-protected games)
--coolboy-submapper <submapper>submapper number to use while writing COOLBOY (default - auto, based on a ROM header)
--reset simulate reset first
--cs-file <C#_file> execute C# script from file
--bad-sectors <bad_sectors> comma separated list of bad sectors for COOLBOY/COOLGIRL writing
--ignore-bad-sectors ignore bad sectors while writing COOLBOY/COOLGIRL
--verify verify COOLBOY/COOLGIRL/UNROM-512/FDS after writing
--lock write-protect COOLBOY/COOLGIRL sectors after writing
--sound play sound when done or error occured
Пример:
famicom-dumper dump --mapper MMC3 --psize 512K --csize 256K --file output.nes --battery --sound
Помимо дампа есть еще куча команд. Например, чтение и запись сохранений для картриджей, в которых стоит батарейка, сохраняющая прогресс, имитация нажатия кнопки reset или выполнение кастомного скрипта.
Usage: famicom-dumper.exe <command> [<options>] [- <cs_script_arguments>]
Available commands:
list-mappers list available mappers to dump
dump dump cartridge
server start gRPC server
script execute C# script specified by --cs-file option
reset simulate reset (M2 goes to Z-state for a second)
dump-fds dump FDS card using RAM adapter and FDS drive
write-fds write FDS card using RAM adapter and FDS drive
read-prg-ram read PRG RAM (battery backed save if exists)
write-prg-ram write PRG RAM
write-coolboy write COOLBOY cartridge
write-coolboy-gpio write COOLBOY cartridge using dumper's GPIO pins
write-coolgirl write COOLGIRL cartridge
write-unrom512 write UNROM-512 cartridge
info-coolboy show information about COOLBOY cartridge's flash memory
info-coolboy-gpio show information about COOLBOY cartridge's flash memory using dumper's GPIO pins
info-coolgirl show information about COOLGIRL cartridge's flash memory
info-unrom512 show information about UNROM-512 cartridge's flash memory
Кастомные скрипты тоже пишутся на C#, в них нужно определить класс только с функцией с названием Run().
Пример скрипта: https://github.com/ClusterM/famicom-dumper-client/blob/master/FamicomDumper/scripts/DemoScript.cs
При этом в функцию может передаваться не только объект дампера, но и параметры, указанные в командной строке. Например, маппер, объём памяти, имя файла или просто что-то своё. При этом не обязательно определять у функции кучу параметров. После компиляции скрипта через reflection программа определяет, какие у функции есть параметры и при наличии соответствующих имён подставляет необходимые данные. Ещё выдаёт предупреждение, если параметр у функции есть, а в командной строке его значение не указано.
Вроде получилось достаточно красиво и удобно.
Ещё после того, как люди начали покупать у меня дамперы, и им нужна была помощь в реверс инжиниринге некоторых неизученных картриджей (а Китай постоянно поставляет что-то новое), мне пришла в голову идея сделать удалённую работу с дампером. Чтобы человек на другом конце света мог запустить у себя сервер, а я у себя на компьютере писал и выполнял скрипты, работающие с его дампером.
Сначала я использовал родную для .NET технологию remoting, которая позволяет работать с удалёнными объектами, как будто они находятся на локальном компьютере. Весьма удобная штука. Но из .NET 5 её выпилили. А на него (и позже на .NET 6) нужно было перейти хотя бы из-за лучшей мультиплатформенности.
И я перешёл на фреймвок gRPC от Google. Если кто не в курсе, это технология для удаленного вывода процедур, при которой на специальном языке в .proto файле описываешь функции, которые надо вызывать удалённо, а специальная программа генерирует код для их вызова на самых разных языках программирования.
Вот такой у меня получился .proto файл: https://github.com/ClusterM/famicom-dumper-client/blob/master/FamicomDumperConnection/Dumper.proto
Так появилась возможность не только работать с дампером удаленно, но и писать для него скрипты на C, Python, Java и ещё куче разных языков. Для запуска сервера или подключения к серверу тоже предусмотрены соответствующие параметры командной строки.
При этом .NET позволяет собрать клиент и под Windows, и под Linux и под Mac. При этом под самые разные архитектуры, например Arm. Это позволяет подключить дампер хоть к Raspberry Pi. На GitHub‘е через GitHub Actions я сделал автоматическую сборку релизов для этих платформ. Ну и публикацию промежуточных сборок после каждого коммита, для тех, кому не терпится увидеть новые функции. В код при этом подставляется номер коммита и время сборки, чтобы выводить их при запуске.
Усложняем файл прошивки
Но поддержку кое-каких более сложных операций всё-таки пришлось добавить прямо в прошивку дампера. Например, запись картриджей с флеш-памятью. В первую очередь картриджей моего собственного производства.
Просто там очень быстро чередуются операции чтения и записи. Например, мы пишем блок данных, а потом опрашиваем флеш-память на тему того, записались они или нет. Если всё это делать по USB, то из-за задержек между запросами и ответами процесс записи будет очень медленным. Так что я добавил команды для выполнения сразу ряда заранее заданных последовательностей обращений к памяти картриджа, чтобы так можно было записать флеш-память. Ну и чтобы стереть текущий сектор.
Ну а в клиент добавил соответствующую команду и функциую, которая посекторно стирает флеш-память и записывает данные из файла. К этой команде добавилась и куча параметров командной строки, например можно указать номера битых секторов у флеш-памяти. Вы уже видели их выше.
Кстати, на новом дампере теперь картридж на 128 мегабайт записывается уже не три с лишним часа, а всего 10 минут. В 20 раз быстрее!
Эти же команды работают и с дешёвыми китайскими COOLBOY и MINDKIDS картриджами, в которые китайцы тупо ставят флеш-память.
Только если раньше у таких картриджей обязательно нужно было отпаивать ноги микросхемы, то сейчас многие из них пишутся без каких-либо танцев с бубном. Купил, воткнул, перезаписал. Сейчас у меня таких картриджей скопилась целая пачка разных ревизий. Там и разные версии мапперов, и разная память, где-то есть батарейка, где-то нет. Ну и одни, как я уже сказал, перезаписываются прям так, а другие всё-таки требуют пайки. Для последних я предусмотрел два вывода на плате дампера, которые соответственно нужно подключить к ногам чтения и записи микросхемы флеш-памяти картриджа.
Ну и необходима отдельная команда для перевода дампера в режим записи таких картриджей. Этот режим отличается тем, что при записи картриджа он всё равно остаётся в режиме чтения (линия R/W остаётся в низком уровне вместо высокого), чтобы микросхема флеш-памяти оставалась активной. Это реализуется парочкой простых условий в прошивке ПЛИС, а выбор между режимами осуществляется по специальной линии между ПЛИС и микроконтроллером.
Но есть более актуальные картриджи, поддержку перезаписи которых стоит добавить. UNROM-512. Это современный маппер, который часто используется разработчиками современных игр для NES. Да, таких достаточно много, если кто не знал. Схема у него достаточно простая, но несколько варьируется от игры к игре. И есть вариант с самоперезаписываемой флеш-памятью. То есть, опять же, когда игра может сама перезаписать содержимое флеш-памяти в картридже. А соответственно это позволяет перезаписать такие картриджи в дампере. Я недавно разводил и собирал такие картриджи на заказ.
Вот и решил заодно добавить поддержку их записи в свой дампер. Опять же, их можно записывать простым скриптом, но это будет очень медленно. Поэтому я тоже реализовал соответствующие операции на уровне прошивки.
Бутлоадер и обновление прошивки
Кстати, об обновлениях прошивки! Вскоре стало ясно, что нужно предусмотреть какой-то способ, чтобы пользователи могли сами её обновить, без программатора.
Для таких целей используется бутлоадер. Напомню, это такая маленькая програма, которая запускается перед основной прошивкой и при необходимости позволяет эту основную прошивку каким-либо образом обновить. У меня есть опыт в написании бутлоадеров под микроконтроллеры AVR, с STM32 же у меня это впервые. Но с ними оказалось отчасти всё даже проще.
Если у AVR бутлоадер заливается в специальную область флеш-памяти, а затем запускается в соответствии с фьюзами, то у STM32 для бутлоадера ничего особенного не предусмотрено. Но код выполняющийся из любой области флеш-памяти может перезаписывать любую область флеш-памяти. Так что программиста в этом плане ничего не ограничивает.
Итак, что я задумал. Мне очень хотелось чтобы дампер в режиме обновления виделся как USB-флешка, на которую можно просто кинуть файл прошивки. Без какого-либо дополнительного софта. Благо, что у нашего микроконтроллера целых 512 килобайт флеш-памяти. Я поделил её так:
- 64 килобайта под бутлоадер
- 192 килобайта под виртуальную флешку
- 192 килобайта под основную прошивку.
По идее должно хватить.
Итак, создаём отдельную программу под микроконтроллер и правим скрипт линкера, указывая, что мы можем использовать только 64 килобайта флеш-памяти, а остальное место зарезервировано.
/* Memories definition */
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K
BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 64K
MSD (rw) : ORIGIN = 0x08010000, LENGTH = 192K
APP (xrw) : ORIGIN = 0x08040000, LENGTH = 192K
}
Аналогично правим скрипт линкера и у основной прошивки, чтобы компилятор знал, что мы ее будем запускать не из начала памяти, а со смещением в 256 килобайт.
/* Memories definition */
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K
BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 64K
APP (rx) : ORIGIN = 0x08040000, LENGTH = 192K
}
Из периферии в бутлоадере настраиваем USB и указываем, что мы будем работать как USB накопитель. Важно при этом указать, что I/O buffer size равет двум килобайтам — размеру страницы флеш-памяти микроконтроллера.
Кубик автоматически сгенерирует весь необходимый для этого код, надо только дописать свой код чтения и записи в специально отведенные функции. Там мы описываем, как производится чтение из флеш-памяти и как производится запись. С чтением всё просто.
int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len)
{
/* USER CODE BEGIN 6 */
int i;
uint32_t address = MSD_ADDRESS + blk_addr * MSD_BLOCK_SIZE;
for (; blk_len > 0; blk_len--) {
for (i = 0; i < MSD_BLOCK_SIZE; i++) {
*buf = *((volatile uint8_t*) address);
buf++;
address++;
}
}
return (USBD_OK);
/* USER CODE END 6 */
}
А вот для записи надо сначала разблокировать флеш-память, потом стереть страницу размером в два килобайта и только после этого уже записывать. Ссылка на код.
Компилируем, прошиваем, подключаем дампер к USB… и он видится как флешка. Перед первым использованием ее надо отформатировать, будем использовать файловую систему FAT. И вуаля, у нас USB-флешка на 192 килобайта!
Теперь надо сделать, чтобы микроконтроллер имел доступ к файлам на этой флешке. Для этого подключаем библиотеку FATFS, а в сгенерированном коде тоже подставляем код чтения и записи во флеш-память. Он абсолютно такой же, как и код выше.
Важно сделать, чтобы компьютер и микроконтроллер не обращались к файловой системе, это может вызвать конфликты, например компьютер ещё только записывает файл, а микроконтроллер уже начинает его читать. Я сделал таймер, который обнуляется при операциях записи со стороны компьютера. И если компьютер ничего не пишет несколько секунд, а это скорее всего завершилась запись файлов, то со стороны микроконтроллера проверяем наличие файла с расширением .bin. Если такой файл есть, отключаем USB интерфейс, чтобы компьютер больше ничего не мог писать, и записываем содержимое файла во флеш-память по адресу, откуда у нас запускается основная программа. По окончании процесса удаляем этот файл, чтобы он точно не записался по несколько раз. Ну и сигнализируем о процессе разными цветами светодиода, чтобы пользователь знал, когда запись прошивки завершилась, или когда произошла какая-то ошибка. Ссылка на код.
Надо не забыть сделать какое-то условие перехода в режим обновления, ведь обычно мы будем использовать основную прошивку, и не надо каждый раз переходить в режим USB-флешки. Для этого можно сделать специальную кнопку, но я не стал менять схему. Решил сделать так, чтобы нужно было замкнуть какие-то контакты в разъёме картриджа. Для этого важно выбрать контакт, который не мог бы замкнуть сам картридж при включении. Отлично подходит контакт для генерации прерываний. И рядом как раз контакт с землёй. И находятся они ровно посередине разъема. Идеально.
Замыкаем эти два контакта пинцетом или ножницами, втыкаем дампер в USB и вуаля — он видится уже как USB-флешка. На всякий случай сделал, чтобы контакты надо было быстро разомкнуть после изменения цвета светодиода, чтобы в режим обновления дампера уж точно нельзя было перейти случайно.
А что же с прошивкой ПЛИС? За всё время разработки её прошивка ни разу не менялась, там всё достаточно прямолинейно. Но возможность обновления всё равно надо предусмотреть. Напомню, я подключил JTAG контакты ПЛИС к микроконтроллеру. Осталось научить микроконтроллер прошивать эту ПЛИС.
Для этого будем использовать прошивку в формате SVF. Это текстовый файл, который по сути содержит команды для JTAG программатора, чтобы он залил прошивку. Но этот скрипт ещё надо как-то интерпретировать. Благо, что для этого уже есть библиотека Lib(X)SVF. Она не обновлялась лет десять, но при этом всё равно неплохо справляется со своей задачей.
Её код достаточно легко переносится на STM32. Нам надо только описать callback функции, которые будут дергать нужные ноги микроконтроллера, после чего скармливаем специальной функции содержимое .svf файла, и ПЛИС прошита. Ну как прошита. Процесс занимает секунд 30. Долго, но работает. В итоге этот .svf файл надо кидать на ту же USB флешку, куда мы кидаем основную прошивку.
Ещё я сделал, чтобы бутлоадер записывал в определённую часть флеш-памяти номер аппаратной версии. Чтобы отображать его в клиенте, чтобы клиент знал об аппаратных возможностях, и чтобы обновление прошивки при этом никак его не затирало.
Итак, как в итоге происходит обновление:
- Замыкаем два контакта в разъеме дампера, втыкаем его в USB, ждем зелёного цвета светодиода.
- Размыкаем контакты. Светодид должен стать жёлтым, дампер должен определиться компьютером как USB флешка.
- Кидаем на нее файлы прошивки и ждем несколько секунд.
- Светодиод загорается белым, значит процесс пошел — ждем.
- Светодиод загорелся зеленым — всё, обновление прошивки завершено.
Заключение
Устройство получилось навороченное, дорогое и очень узкоспециализированное. Можно сказать, что оно профессиональное, что по отношению к дамперу картриджей консоли 30-летней давности звучит смешно. Оно в первую очередь ориентировано на людей, которые занимаются реверс инжинирингом картриджей, и которые при этом понимают, как работают консоль и картриджи. Поэтому я даже корпус для этого дампера не делал, у таких устройств его обычно нет. Но отверстия под винтики на будущее я всё-таки предусмотрел.
Но дампер нашёл применение и у более казуальных пользователей. Многие перезаписывают им картриджи или просят меня удаленно поковырять какой-нибудь очередной китайский новодел. Но есть у него и еще кое-какие возможности, но о них я расскажу уже в следующий раз (хотя тут в статье уже есть спойлеры).
Исходный код самого дампера: https://github.com/ClusterM/famicom-dumper-writer
Исходный код клиентского ПО: https://github.com/ClusterM/famicom-dumper-client