В этой статье я расскажу, как собирал самопальную видеоконсоль с нуля — вдохновившись ретроконсолями и современными проектами, но придумав свою уникальную архитектуру. Мои друзья снова и снова повторяли, что нечего держать этот проект в тайне, нужно, мол, поделиться с общественностью. Ну вот я и делюсь.
Это перевод статьи Сержиу Виейры, впервые опубликованной в его блоге. Перевела Алёна Георгиева.
Меня зовут Сержиу Виейра, и я португальский парень, выросший в 80–90-е. Я всегда ностальгировал по ретроконсолям, особенно третьего и четвертого поколений. Несколько лет назад я решил глубже изучить электронику и попробовать собрать свою собственную видеоконсоль. Работаю я инженером софта, и ранее никакого опыта работы с электроникой у меня не было — если не считать сборки и апгрейда моего десктопа (что, конечно, не считается). Но, несмотря на отсутствие опыта, я сказал себе: «Почему нет?», купил несколько книг и комплектов электроники — и начал учиться.
Я хотел собрать консоль, похожую на те, что всегда мне нравились, — что-то среднее между NES и Super Nintendo или между Sega Master System и Mega Drive. Все эти консоли обладали CPU, кастомным видеочипом (тогда он еще не назывался GPU) и аудиочипом — интегрированным или выделенным. Игры к ним распространялись на картриджах, которые обычно представляли собой аппаратные расширения с чипом ПЗУ и иногда другими компонентами.
Моим изначальным планом было собрать консоль со следующими характеристиками.
Я решил использовать поддержку SD-карт вместо картриджей, потому что запускать игры с карты намного практичней — к тому же на нее проще копировать файлы с компьютера. Используй я картриджи, пришлось бы накрутить куда больше железа и завести отдельное железо для каждой программы.
Начал свою работу я с генерации видеосигнала. У всех консолей эпохи, на которую я ориентировался, были свои проприетарные графические чипы — что давало им очень разные характеристики. По этой причине я не стал использовать готовый графический чип — хотелось, чтобы у моей консоли были уникальные возможности графики. Но, поскольку собрать собственный чип я бы не потянул, а ПЛИС использовать не умел, я выбрал базирующийся на софте графический чип с двадцатимегагерцевым восьмибитным микроконтроллером. Это не перебор: у него ровно такая производительность, чтобы генерировать нужный мне тип графики.
Итак, я начал с микроконтроллера ATmega644, работающего на частоте 20 МГц, который посылал PAL-сигнал на телевизор. Поскольку сам по себе микроконтроллер этот формат не поддерживает, пришлось добавить внешний ЦАП.
Наш микроконтроллер выдает восьмибитную цветность (RGB332: три бита на красный, три бита на зеленый и два — на синий), а пассивный ЦАП конвертирует всю эту красоту в аналоговый RGB. По счастью, в Португалии внешние устройства к телевизору чаще всего подключают через разъем SCART — и большая часть телевизоров принимает RGB-сигнал через него же.
Поскольку первый микроконтроллер я хотел использовать исключительно для передачи сигнала на телевизор (я назвал его VPU — Video Processing Unit), для графики в целом я решил использовать способ двойной буферизации.
Я взял второй микроконтроллер для PPU (Picture Processing Unit) — ATmega1284 тоже на 20 МГц. Он должен генерировать изображение на чип ОЗУ (VRAM1), после чего первый микроконтроллер передаст содержимое другого чипа оперативки (VRAM2) на телевизор. После каждого кадра (два кадра в PAL или 1/25 секунды) VPU переключает чипы ОЗУ и передает изображение с VRAM1 на телевизор, пока PPU генерирует новое на VRAM2.
Видеоплата получилась довольно сложной: мне пришлось использовать дополнительное железо, чтобы дать микроконтроллерам доступ к обоим чипам ОЗУ, а также ускорить доступ к памяти, которая к тому же используется для вывода видеосигнала методом битбэнга. Для этого я добавил в цепочку несколько чипов 74-й серии в качестве счетчиков, линейных селекторов, трансиверов и прочего.
Прошивка для VPU и особенно для PPU тоже вышла довольно сложной, поскольку мне нужно было написать чрезвычайно производительный код — если я хотел получить все искомые графические возможности. Изначально я все писал на ассемблере, позже кое-что кодил на C.
В итоге мой PPU генерировал изображение 224 × 192 пикселя, которое VPU транслировал на экран телевизора. Разрешение может показаться слишком низким, но на самом деле оно немногим меньше, чем у консолей-прототипов — они обычно имели разрешение 256 × 224. Зато более низкое разрешение позволило мне втиснуть больше графических фич в тот временной отрезок, что уходил на отрисовку каждого кадра.
Прямо как в старые добрые времена, у моего PPU есть «фиксированные» графические возможности, которые можно настроить. Фон рендерится из символов размером 8 × 8 пикселей (их еще иногда называют тайлами). Это значит, что размер всего фона — 28 × 24 тайла. Для попиксельной прокрутки и возможности плавного обновления фона я сделал четыре виртуальных экрана 28 × 24 тайла — все они смежные и «обтекают» друг друга.
Поверх фона PPU рендерит до 64 спрайтов шириной и высотой от 8 до 16 пикселей (то есть один, два или четыре символа), которые можно повернуть по вертикали, горизонтали или по обеим осям. Еще над фоном можно отрендерить оверлей — такую плашку размером 28 × 6 тайлов. Она пригодится для игр, где нужны элементы интерфейса поверх основного экрана (HUD), фон скроллится, а спрайты используются не только для подачи информации, но и для других целей.
Другая «продвинутая» фича — возможность прокручивать фон в разных направлениях по отдельным строкам, что позволяет добавить эффекты типа ограниченного параллакс-скроллинга или разделенного экрана.
Еще есть таблица атрибуции, которая позволяет задать каждому тайлу значение от 0 до 3. А дальше можно, например, назначить все тайлы с определенным значением на конкретную тайловую страницу или увеличить номер их символа. Это полезно, когда конкретные элементы фона постоянно меняются, — в таком случае CPU не нужно обновлять каждый тайл в отдельности, он может просто передать команду вроде «все тайлы со значением 1 увеличивают свое значение на 2». Разными способами этот подход используется, например, в играх с Марио, где на фоне двигаются знаки вопроса, или в других играх с постоянно льющимися водопадами.
Когда была готова функциональная видеоплата, я приступил к работе над CPU — для своей консоли я выбрал Zilog Z80. Помимо того что Z80 — просто крутой ретропроцессор, у него есть отдельно 16 бит на память и 16 бит для I/O, чем другие подобные восьмибитные процессоры, например знаменитый 6502, похвастаться не могут. У того же 6502 есть только 16 бит памяти, а значит, эти 16 бит придется делить между собственно памятью и дополнительными устройствами: аудио, видео, ввода и прочими. Если же у нас есть отдельный участок для I/O, то он возьмет на себя все внешние устройства, а 16 бит памяти (то есть 64 Кбайт кода или данных) мы сможем использовать по прямому назначению.
Для начала я соединил свой CPU с EEPROM, добросив немного тестового кода. Еще я прикрутил к CPU через участок I/O микроконтроллер, который связывается с ПК по RS-232, — чтобы проверить, нормально ли работает мой процессор и все остальные соединения. Этот микроконтроллер (двадцатимегагерцевый ATmega324) должен был стать IO MCU (микроконтроллером ввода-вывода) и отвечать за доступ к игровым контроллерам, карте SD, клавиатуре PS/2 и коммуникацию с компом через RS-232.
Потом я прикрутил к процессору чип ОЗУ на 128 Кбайт, из которых были доступны 56 (это может показаться пустой тратой ресурса, но у меня были чипы ОЗУ только по 128 и по 32 Кбайт). Таким образом, вся память процессора состоит из 8 Кбайт ПЗУ и 56 Кбайт ОЗУ.
Следом я обновил прошивку своего микроконтроллера ввода-вывода с помощью этой библиотеки и добавил ему поддержку карт SD. Теперь CPU научился перемещаться по директориям SD-карты, просматривать их содержимое, открывать и читать файлы — считывая и записывая данные на конкретные адреса участка ввода-вывода.
Пришло время реализовать взаимодействие между CPU и PPU. Для этого я нашел «простое решение» — чип ОЗУ с двойным портом (то есть тот, который можно одновременно подключить по двум разным шинам). Он спас меня от накручивания новых микросхем типа линейных селекторов — и к тому же сделал доступ к оперативной памяти для обоих чипов практически одновременным. Также PPU связывается с CPU напрямую каждый кадр, активируя его немаскируемое прерывание (NMI). Это значит, что каждый кадр процессор прерывается, что ценно для синхронизации и своевременного обновления графики.
Каждый кадр взаимодействие между CPU, PPU и VPU развивается по следующему сценарию.
Примерно в то же время я добавил поддержку игровых контроллеров. Изначально я хотел использовать контроллеры Super Nintendo, но их разъем проприетарный — и достать его непросто. Поэтому я выбрал совместимые шестикнопочные контроллеры Mega Drive/Genesis: они используют стандартные, распространенные и доступные разъемы DB-9.
У меня был процессор с поддержкой игровых контроллеров, который мог управлять PPU и загружать программы с SD-карты, так что… пришло время сделать игру. Я написал ее, конечно, на языке ассемблера Z80 — это заняло у меня пару дней (исходный код игры).
Все отлично, у меня есть рабочая консоль, но… этого недостаточно. Игры пока не могут использовать кастомную графику — только ту, что хранится в прошивке PPU. А единственный способ поменять встроенную графику — обновить прошивку. Поэтому я решил добавить отдельный чип ОЗУ с графикой (символьное ОЗУ, Character RAM) — он должен быть доступен PPU и загружать графику согласно инструкциям, пришедшим из CPU. При этом нужно было использовать как можно меньше новых компонентов, потому что консоль уже получилась довольно большой и сложной.
Я нашел следующий выход: доступ к новому ОЗУ будет только у PPU, а CPU станет выгружать в него информацию через PPU. И пока эти данные передаются, наше новое ОЗУ не будет использоваться для графики — его функции временно возьмет на себя встроенная графика.
После передачи данных процессор переключится из режима встроенной графики в режим работы с символьным ОЗУ (CHR RAM на схеме ниже), и PPU сможет использовать кастомную графику. Возможно, это не идеальное решение, но оно работает. В итоге новое ОЗУ имело объем 128 Кбайт и могло хранить 1024 символа размером 8 × 8 пикселей для фона и 1024 символа того же размера для спрайтов.
Реализацию звука я оставил на финал. Изначально я собирался дать своей консоли те же звуковые возможности, что у Uzebox, и встроить микроконтроллер, который генерировал бы четыре канала PWM-звука. Однако я выяснил, что можно относительно легко достать винтажные чипы, — и заказал несколько чипов YM3438, работающих на принципе [частотно-модуляционного синтеза] (https://en.wikipedia.org/wiki/Frequency_modulation_synthesis). Они полностью совместимы с YM2612 (https://en.wikipedia.org/wiki/Yamaha_YM2612), которые установлены в Mega Drive/Genesis. Установив этот чип, я получаю музыку качества Mega Drive и звуковые эффекты, которые производит контроллер. CPU управляет звуковым модулем (я назвал его SPU, Sound Processor Unit, — он отдает команды YM3438 и сам производит звуки) снова через маленькое ОЗУ с двойным портом, на сей раз емкостью всего в 2 Кбайт.
Так же как у графического, у звукового модуля есть 128 Кбайт на хранение звуковых патчей и семплов PCM. Процессор же выгружает информацию в эту память через SPU. Таким образом, процессор может как велеть SPU воспроизводить команды из этого ОЗУ, так и обновлять команды для SPU каждый кадр.
CPU управляет четырьмя PWM-каналами через четыре кольцевых буфера, которые есть в специальном ОЗУ (SPU RAM на схеме ниже). SPU проходит через эти буферы и выполняет имеющиеся в них команды. Таким же образом работает еще один кольцевой буфер в SPU RAM — он обслуживает чип частотно-модуляционного синтеза (YM3438).
Взаимодействие между процессором и звуковым модулем похоже на историю с графикой — и устроено по следующей схеме.
После разработки всех модулей я поместил некоторые из них на макетные платы. Для модуля CPU я сумел придумать и заказать кастомную плату. Не знаю, буду ли делать то же самое для других модулей, — полагаю, мне довольно сильно повезло получить рабочую кастомную плату с первой попытки. Только звуковой модуль пока что остается в виде макета.
Вот как выглядит консоль на момент написания этого текста.
Схема ниже иллюстрирует, какие компоненты входят в каждый модуль и как они взаимодействуют друг с другом. Единственное, что не показано, — это сигнал в форме NMI, который PPU передает непосредственно процессору каждый кадр, а также аналогичный сигнал, передаваемый SPU.
Процессор:
Ввод/вывод (I/O):
Видео:
Звук:
Первый кусок софта, написанный для консоли, — это загрузчик. Он хранится в постоянной памяти процессора и занимает до 8 Кбайт. Он же использует первые 256 байт оперативки процессора. Загрузчик — первый софт, запускаемый на процессоре. Его цель — показать программы, доступные на SD-карте. Эти программы хранятся в файлах, которые содержат скомпилированный код и могут также содержать данные кастомной графики и звука.
После выбора программы она загружается в оперативку процессора, символьное ОЗУ и ОЗУ звукового модуля. Там соответствующая программа выполняется. Код программ, загружаемых на консоль, может занимать до 56 Кбайт памяти — за исключением первых 256 байт; также, конечно, нужно учитывать объем стека и оставлять место для данных.
И загрузчик, и программы для этой консоли разрабатываются похожим образом. Коротко поясню, как я их сделал.
При разработке для консоли следует обратить особое внимание на то, как CPU может получить доступ к другим модулям, поэтому представление памяти и ввода-вывода имеет решающее значение.
Процессор обращается к своему загрузчику на ПЗУ и ОЗУ через память. Представление памяти выглядит так.
К PPU-RAM и SPU-RAM, а также к IO MCU он обращается через участок ввода-вывода. Представление участка ввода-вывода процессора будет таким.
Внутри представления участка ввода-вывода IO MCU, PPU и SPU имеют свои конкретные адреса.
Мы можем управлять PPU с помощью записи на PPU-RAM, а доступ к PPU-RAM, как мы знаем из таблицы выше, организован через участок ввода-вывода с адресов от 1000h
до 1FFFh
.
Вот как выглядит этот диапазон адресов, если представить его более подробно.
Состояние PPU (PPU Status) может принимать следующие значения:
0 — режим встроенной графики;
1 — режим кастомной графики;
2 — режим записи в символьное ОЗУ;
4 — запись закончена, ожидание подтверждения от CPU.
А вот пример того, как можно работать со спрайтами. Консоль может рендерить до 64 спрайтов одновременно. Информация об этих спрайтах передается через адреса с 1004h
по 1143h
(320 байт), по 5 байт информации на каждый спрайт (5 × 64 = 320 байт):
Active
, Flipped_X
, Flipped_Y
, PageBit0
, PageBit1
, AboveOverlay
, Width16
и Height16
).Итак, чтобы сделать спрайт видимым, мы должны присвоить флагу Active
значение 1, а также установить координаты, при которых он станет видимым (координаты x = 32
и y = 32
разместят спрайт в левый верхний угол экрана; если значения x
и y
будут меньше, то спрайт окажется за пределами экрана — частично или полностью). Затем мы можем присвоить ему символ и определить, какой цвет спрайта будет прозрачным.
Например, если мы хотим сделать видимым десятый спрайт, мы должны присвоить адресу ввода-вывода 4145 (1004h + (5 x 9)
) значение 1. Затем устанавливаем координаты спрайта — скажем, x = 100
и y = 120
, — присвоив адресу 4148 значение 100, а адресу 4149 — значение 120.
Один из способов написать программу для нашей консоли — использовать язык ассемблера.
Ниже — пример кода, который заставляет первый спрайт двигаться и сталкиваться с углами экрана:
ORG 2100h PPU_SPRITES: EQU $1004 SPRITE_CHR: EQU 72 SPRITE_COLORKEY: EQU $1F SPRITE_INIT_POS_X: EQU 140 SPRITE_INIT_POS_Y: EQU 124 jp main DS $2166-$ nmi: ld bc, PPU_SPRITES + 3 ld a, (sprite_dir) and a, 1 jr z, subX in a, (c) ; increment X inc a out (c), a cp 248 jr nz, updateY ld a, (sprite_dir) xor a, 1 ld (sprite_dir), a jp updateY subX: in a, (c) ; decrement X dec a out (c), a cp 32 jr nz, updateY ld a, (sprite_dir) xor a, 1 ld (sprite_dir), a updateY: inc bc ld a, (sprite_dir) and a, 2 jr z, subY in a, (c) ; increment Y inc a out (c), a cp 216 jr nz, moveEnd ld a, (sprite_dir) xor a, 2 ld (sprite_dir), a jp moveEnd subY: in a, (c) ; decrement Y dec a out (c), a cp 32 jr nz, moveEnd ld a, (sprite_dir) xor a, 2 ld (sprite_dir), a moveEnd: ret main: ld bc, PPU_SPRITES ld a, 1 out (c), a ; Set Sprite 0 as active inc bc ld a, SPRITE_CHR out (c), a ; Set Sprite 0 character inc bc ld a, SPRITE_COLORKEY out (c), a ; Set Sprite 0 colorkey inc bc ld a, SPRITE_INIT_POS_X out (c), a ; Set Sprite 0 position X inc bc ld a, SPRITE_INIT_POS_Y out (c), a ; Set Sprite 0 position Y mainLoop: jp mainLoop sprite_dir: DB 0
Еще можно писать программы для консоли на C, используя компилятор SDCC или другие кастомные инструменты. Разработка так идет быстрее, хотя производительность кода, конечно, падает.
В качестве примера покажу код на С, который выполняет ту же самую задачу, что и приведенный выше ассемблерный. Чтобы облегчить обращение к PPU, я здесь использовал библиотеку.
#include <console.h> #define SPRITE_CHR 72 #define SPRITE_COLORKEY 0x1F #define SPRITE_INIT_POS_X 140 #define SPRITE_INIT_POS_Y 124 struct s_sprite sprite = { 1, SPRITE_CHR, SPRITE_COLORKEY, SPRITE_INIT_POS_X, SPRITE_INIT_POS_Y }; uint8_t sprite_dir = 0; void nmi() { if (sprite_dir & 1) { sprite.x++; if (sprite.x == 248) { sprite_dir ^= 1; } } else { sprite.x--; if (sprite.x == 32) { sprite_dir ^= 1; } } if (sprite_dir & 2) { sprite.y++; if (sprite.y == 216) { sprite_dir ^= 2; } } else { sprite.y--; if (sprite.x == 32) { sprite_dir ^= 2; } } set_sprite(0, sprite); } void main() { while(1) { } }
У консоли есть встроенная и предназначенная только для чтения графика, которая хранится в прошивке PPU (одна страница тайлов для фона и одна страница графики для спрайтов). Однако для программ можно использовать и кастомную графику.
Цель в том, чтобы перевести всю необходимую графику в двоичную форму — в таком виде загрузчик консоли сможет грузить ее в символьное ОЗУ. Чтобы этого добиться, я начал с нескольких изображений уже нужного размера — в данном случае они предназначены для фона сразу в нескольких игровых ситуациях.
Поскольку кастомная графика состоит из четырех страниц по 256 символов размером 8 × 8 пикселей для фона и четырех таких же страниц для спрайтов, я преобразовал графику с картинки выше в файл PNG для каждой страницы, используя специальный инструмент (за исключением повторяющихся результирующих символов).
А следом я использовал еще один инструмент, чтобы сконвертировать результат в бинарник с символами 8 × 8 пикселей в цветовой схеме RGB332.
В результате получаются двоичные файлы, состоящие из символов 8 × 8 пикселей (символы в памяти являются смежными, каждый занимает 64 байта).
Образцы звуковых волн конвертируем в восьмибитные и восьмикилогерцевые семплы PCM. Патчи звуковых эффектов и музыки PWM можно составить, используя заранее определенные инструкции. Что касается ямаховского чипа частотно-модуляционного синтеза YM3438, то для него я нашел приложение DefleMask. С помощью DefleMask делают синхронизированную с PAL музыку для звукового чипа YM2612 от Genesis, который совместим с нашим YM3438.
DefleMask конвертирует музыку в формат VGM, а дальше я уже использую другой специальный инструмент, чтобы превратить VGM в самопальный звуковой бинарник.
Бинарники со всеми тремя видами звуков объединяются в один двоичный файл, который загрузчик потом сможет загрузить в ОЗУ звукового модуля (SNDRAM).
Программный бинарник, графику и звук нужно соединить в один файл формата PRG. В файле PRG есть заголовок, который сообщает, использует ли программа кастомную графику и/или звук и каков размер каждого из этих компонентов. Там же содержится вся прочая соответствующая двоичная информация.
Затем получившийся файл можно разместить на карту SD, загрузчик консоли оттуда его прочитает, пошлет всю необходимую информацию соответствующим ОЗУ и запустит программу.
Чтобы облегчить разработку софта для консоли, я написал на C++ эмулятор, используя wxWidgets. Чтобы эмулировать процессор, я обратился к библиотеке libz80.
Я добавил в эмулятор несколько отладочных функций. В частности, я могу оказаться в конкретной точке останова и пройти из нее по всем ассемблерным инструкциям. Также есть связь с исходным кодом, если программа была скомпилирована на C. Что касается графики, то тут я могу проверить, что хранится на страницах тайлов и в именных таблицах (представление фона размером в четыре экрана), а также что находится в символьном ОЗУ (CHRRAM).
Вот пример того, как запускать программу на эмуляторе и использовать некоторые отладочные инструменты.
Видео из этого раздела — это съемка экрана электронно-лучевого телевизора на камеру телефона. Прошу прощения, что качество не очень высокое.
Запускаем с помощью бейсика и клавиатуры PS/2. На этом видео я — сразу после создания первой программы — записываю напрямую в ОЗУ графического модуля (PPU-RAM) через участок ввода-вывода команды включить и настроить спрайт, а в конце переместить его.
Демонстрация возможностей графики. На этом видео показана программа, которая отображает 64 спрайта размером 16 × 16 пикселей, кастомную прокрутку фона, а также наложенную плашку, которая двигается вверх и вниз — как перед спрайтами, так и за ними.
Демонстрация возможностей звука показывает, на что способен YM3438 в сочетании с проигрыванием семплов PCM. Частотно-модуляционная музыка вместе с семплами PCM на этом демо почти полностью занимают 128 Кбайт ОЗУ звукового модуля.
Тетрис, использующий почти исключительно фоновые тайлы для графики, YM3438 для музыки и патчи PWM для звуковых эффектов.
Этот проект стал настоящей воплощенной мечтой, я работаю над ним уже несколько лет в свободное и не очень время. Никогда не думал, что зайду так далеко, пытаясь собрать собственную игровую консоль в ретростиле. Конечно, она не идеальна — я по-прежнему вовсе не эксперт в электронике. В консоли слишком много компонентов, и ее, несомненно, можно было бы сделать лучше и эффективней — наверняка кто-нибудь из читающих этот текст думает именно так. Тем не менее, пока я занимался этим проектом, я узнал много нового об электронике, игровых консолях и компьютерном дизайне, языке ассемблера и других интересных темах. Ну и сверх того я получил огромное удовлетворение, играя в игру, которую я сделал сам, на железе, которое я тоже сдизайнил и сделал сам.
Я планирую собирать и другие консоли и компьютеры. На самом деле я уже почти закончил еще одну игровую приставку. Это упрощенная консоль в ретростиле, в основе которой лежит дешевая плата ПЛИС и несколько других компонентов (но их, очевидно, не так много, как в первом проекте). Она изначально задумана как дешевая и тиражируемая.
Сайт и каналы, которые не только вдохновили меня, но и помогли разрешить трудности, с которыми я столкнулся со время работы над проектом.
Читайте также
Последние новости