Pull to refresh

Как я написал эмулятор Nintendo Gameboy на C++ за две недели

Level of difficultyEasy
Reading time7 min
Views1.5K

В свободное время, (прим., во время отпуска) я бывает берусь за какие-нибудь небольшие проекты не связанные с моей основной деятельностью. В этот раз решил создать эмулятор консоли. Вопреки моде на Rust, взял проверенный годами C++. Эмуляция — непростая задача. Производители редко публикуют полные спецификации аппаратной части, поэтому сообщество занимается восстановлением поведения системы по косвенным признакам и тестированию. Полное решение таких задач требует больше времени, чем пара недель отпуска. В условиях жестких временных рамок, желания получить быстрый результат я остановился на эмуляции Nintento Gameboy. Активное сообщество, популярность и долгая жизнь консоли привели к появлению огромного количества открытых ресурсов, которые делают возможным получить быстрый и наглядный результат. Например, на archive.org доступно руководство разработчика (GameBoyProgManVer1.1), а поиск по Github даёт более 8000 репозиториев, так или иначе связанных с данной консолью.

Архитектура консоли

  • Ядро консоли: Sharp LR35902 — специально разработанный под консоль SoC, инженеры из Sharp и Nintendo взяли регистры и команды Intel 8080, добавили некоторые команды от Zilog Z80, оптимизировали по потреблению, удалив все на их взгляд ненужные части. Получился некий гибрид не совместимый ни с тем ни с другим своим родителем, но достаточно на них похожий, чтобы разработчикам ПО не пришлось переучиваться.

  • Тактовая частота: ровно 2²² Гц (4.19 МГц), но как я понял, обращение к памяти происходит за 4 такта, поэтому инструкции эффективно выполняются в 4 раза медленнее.

  • Дисплей: 160х144 пикселя, 4 оттенка. На оригинальном Gameboy дисплей был 4 оттенков зеленого, на новых моделях Gameboy pocket экран был уже по настоящему Ч/Б, таким я его запомнил из детства.

  • Память: всех доступные адреса 0x0000-0xFFFF были поделены между картриджем, Video RAM (8Кб), Work RAM (8Кб), областью I/O периферийных устройств. Еще на самом SoC была область 256 байт со стартовым кодом консоли Boot ROM, который выключался после завершения стартовой последовательности и был после этого недоступен штатными средствами, а также 127 байт High RAM — специальная область памяти, что-то вроде кэша куда ЦПУ имел более быстрый доступ всегда, даже если шина в данный момент занята.

  • Картридж: официальное название Game Pak, в самом простом виде представляли собой 32Кб ROM памяти программы (к примеру, такой картридж был у тетрис). Игры могли нести на борту еще SRAM 8Кб память питаемую батарейкой — так делали сохранения. Если требовалось больше памяти, задействовали специальные микросхемы MBC, которые переключали банки памяти, подставляя в адресное пространство консоли другие части памяти.

На 13-ой странице руководства, приведена блок-схема связей ЦПУ и периферии, а на 15-ой карта памяти с распределением по адресам. Оставлю глубокое описание архитектуры за пределами данной статьи, хочу воздержаться от переписывания документации.

Программная модель эмулятора

Я строил модель эмулятора, стремясь повторить архитектуру Gameboy на уровне программных классов. Так была построена следующая схема компонент:

  • Bus — модель шины устройства, позволяет читать (read_byte) и писать (write_byte) по 8-бит по указанному 16-битному адресу. Метод advance - эмулирует передачу тактового сигнала в периферийные устройства подключенные к шине. В общем случае, на каждую запись или каждое чтение, время устройств продвигается на 4 такта. Однако, выполнение некоторых сложных инструкций может потреблять дополнительные такты, даже без походов в шину.

  • IDevice — абстрактный класс периферийного устройства, интерфейс требует всё те же имплементации методов read_byte, write_byte и advance. Так, модель эмулятора собирается по кусочкам из следующих устройств:

    • BootRom - хранит стартовый код консоли. В интернете можно найти дамп оригинального кода Nintendo, но авторы эмуляторов обычно пишут свои имплементации. Nintendo реализовала защиту от пиратства с помощью boot rom — образ вшит в SoC, проверяет есть ли в ROM картриджа надпись Nintendo со знаком копирайта. Надпись двигают по экрану до середины экрана, после чего консоль воспроизводит фирменный к-чинк динамиком. Последней командой, boot rom пишет бит по специальному адресу, что выключает его отображение в адресное пространство, после чего уже начинается выполнение ROM картриджа с адреса 0x100. К концу выполнения Boot Rom, у разработчика на дисплее отображается экран с надписью Nintendo, инициализирован регистр SP, проведена первичная инициализация устройств. Для разработчика эмулятора полный проход через код Boot ROM - первый шаг проверки корректности имплементации. В целом, можно начинать эмуляцию вообще пропустив boot rom и сразу перевести CPU в состояние после завершения стартовой последовательности.

    • Cartridge — хранит код программы в виде дампа ROM, эмулирует MBC и SRAM память. Я решил ограничиться поддержкой только MBC1, но как я понимаю, другие модели не сильно отличаются. В дампах ROM каждого картриджа есть секция заголовка, по нему можно определить производителя, тип MBC, сколько ROM и RAM памяти должно быть доступно. Управление MBC происходит через запись байт определенного вида по адресам в области ROM-памяти картриджа, так можно переключать банки памяти и включать и выключать отображение RAM памяти картриджа.

    • Классы WorkRam и HighRam эмулируют соответственно RAM самой консоли. Nuff-said.

    • PixelProcessor — модель видеопроцессора консоли (PPU). Хоть консоль и использует жидкокристаллический экран, но память была всё еще дорогим удовольствием, честный framebuffer 160х144 пикселя потребовал бы около 12 кб (double buffering по 2 бита на пиксель). В итоге - железо работает по строкам, как экран с лучевой трубкой. Всего на рисование одной строки уходит максимум 456 тактов. Часть из этого времени CPU не может обращаться к разным частям VRAM, так пока идет выборка спрайтов для строки - нельзя обращаться к OAM - памяти где описаны «объекты». Мне понравилось, как процесс описан на gbdev.io (еще один большой источник информации о разработке для gameboy собранный сообществом).

    • Timer — модель встроенного таймера, у него ряд режимов работы, в остальном типовой счетчик тактов, поднимает флаг прерывания, когда доходит до заданной отметки.

    • Joypad — модель кнопок управления. Важно, что кнопки на Gameboy подтягиваются резистором вверх, поэтому когда ничего не нажато, чтение из регистра P1 возвращает все единицы - 0xFF, это важно, так как к примеру тетрис на старте проверяет нажатие кнопок и если видит 0x00, интерпретирует их как soft-reboot - я много времени потерял, пока пытался понять, что же всё-таки не даёт модели пройти первый экран и почему он при этом моргал.

    • Serial, Audio - модели последовательного интерфейса и аудио-устройства, я их не стал реализовывать на уровне выше чем просто заглушки.

  • Cpu — класс процессора стоит отдельно, так как в моей модели он выполняя инструкции, тактирует остальные компоненты при обращении шине. Также на него ушло больше всего времени, так как надо было правильно реализовать все инструкции. Тут мне сильно помогли таблицы инструкций с указанием таймингов (прим. https://meganesu.github.io/generate-gb-opcodes/). Всего есть две таблицы команд — базовая и 0xCB расширение для битовых операций. По группировке опкодов, можно увидеть, что для каких-то команд в битах кодируются номера регистров с которыми работают определенные команды - этим можно пользоваться и например использовать общий код для интерпретации «классов» опкодов и подстановки нужных регистров в рантайме. Я решил не экономить код и все такие команды реализовать в виде шаблонов, которые инстанциируются указателями на члены класса таблицы регистров процессора. Сначала, я заполнил все таблицы виртуальным опкодом hcf (aka halt-and-catch-fire), который бросал панику, роняя приложение с указанием HEX-кода команды, состоянием регистров CPU и дампа памяти около указателя команды PC. Так, постепенно пытаясь пройти код boot rom-а я заполнял таблицу уже правильными имплементациями команд, пока не увидел заветную надпись Nintendo и PC не дошел до адреса 0x100. 

Визуализация и кроссплатформенность

Чтобы не тратить время на реализацию пользовательского интерфейса, я выбрал SDL3. Это уже третья итерация мощного кроссплатформенного фреймворка, позволила мне подключить графику и управление не привязываясь к конкретным оконным библиотекам. Я получил вывод изображения без возни с OpenGL, DirectX, Vulkan и прочими, обработку пользовательского ввода, возможность сборки на Windows, Linux и MacOS с минимальными доделками, если я не буду выходить за далеко за рамки стандартной библиотеки C++20.

Оригинальная консоль показывает чуть меньше 60 кадров в секунду, я решил не стремиться к идеалу и обновлять модель ровно 60 раз в секунду, иначе говоря синхронизировал показ каждого кадра c экраном по мере готовности, после сигнала срабатывания прерывания vblank.

Тестирование и отладка

В общем, можно было найти ROM программы, которая мне нравилась и продолжить по шагам ловить ошибки и дописывать, чего не хватает. К примеру — тот же тетрис, это просто ROM с кодом и тайлами, сама программа простая, без каких-то хитрых графических трюков. Однако, первым делом я обнаружил, что в тетрисе есть баги. Код обращается к памяти, которая никуда не отображается и тут я оказался бы в положении - либо я что-то не так реализовал, либо сама программа что-то делает не так. В общем-то, обращения к неотображенной памяти в gameboy игнорируются - записи пропадают, а чтение возвращает 0xFF, но у меня не было на тот момент еще половины устройств, а та что была - явно была потенциально неправильно написана. Тут на помощь приходят тестовые ROM-ы от Blargg. Я их взял из github репозитория retrio, как я понял, оригинальный хостинг был потерян, но кто-то у себя сохранил копии. В коллекции ROM-ов есть следующие программы:

  • cpu_instrs — тесты для всех инструкций Gameboy. Большой тест требует поддержки MBC1, но отдельные тесты работают без него. Я методично сел за реализацию всех инструкций, которые роняли мне каждый тест в отдельности, а потом дописал поддержку MBC1 и смог запустить полный тест. В сумме это заняло большую часть времени разработки.

  • instr_timing — тест таймингов выполнения инструкций. Тут я обнаружил, что мой первый вариант модели не учитывал внутренние циклы CPU у сложных инструкций, после чего я стратегически добавил дополнительных вызовов advance, в нужных местах и тоже прошел этот тест.

  • mem_timing и mem_timing-2 — я не особо понял чем эти тесты отличаются, код особо не читал, но вторая требует поддержки RAM на картридже, что я добавил в класс Cartridge. Эти тесты считают сколько времени требуется командам на доступ к памяти, тут я обнаружил, что надо очень точно следить за обновлениями таймера и нельзя особо копить такты. В итоге, после правок эти тесты тоже удалось пройти.

В наборе есть еще несколько тестовых программ, после того как эмулятор стал проходить все указанные выше тесты, у меня уже стали нормально запускаться обычные программы:

  • tetris — Тут мне нечего сказать, классический почти первый тетрис. Программа делала лишние действия, которые не влияли на состояние модели, экран копирайта отсчитывает время по прерываниям vblank, во время которых готовит какие-то внутренние данные и что-то заполняет. Также, обнаружилась проблема с обработкой клавиш. После того, как учел все эти моменты в модели - эмулятор начал правильно отрабатывать прерывания от PixelProcessor, тетрис дошел до меню. Тут понадобилась уже поддержка DMA-копирования и отрисовка спрайтов. Когда всё было готово - я наконец-то поиграл в тетрис.

  • merken - внезапно, обнаружилось, что эмулятор достаточно созрел, чтобы запустить какие-то ROM-ы демосцены. Мне понравилась программа merken от bitnenfer. Прикладываю скриншоты.

Заключение

Отпуск и выходные подходят к концу, проект удалось довести до рабочего состояния. В коде PixelProcessor есть еще баги, так в том же merken неправильно отображается одна из сцен, в The Legend of Zelda: Link’s Awakening неправильно работает HUD и меню.

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

Tags:
Hubs:
+15
Comments3

Articles