
«Студент-программист реализовал на FPGA полноценную игровую приставку с нуля за полтора месяца, не имея опыта цифрового проектирования». Для меня самого это звучит как фантастика или реклама очередных онлайн-курсов, но я расскажу эту историю. Реальную историю моего пути — студента четвертого курса направления «Программная инженерия». Приставка «Брус-16», о которой недавно писали в блоге YADRO, работает на FPGA-платах уровня Tang Nano 9K, поддерживает ввод с джойстиков DualShock 2 и выводит изображение на обычный HDMI-монитор. Статья будет интересна студентам, новичкам в области аппаратного проектирования и всем тем, кто хочет увидеть, как работают игры для «Брус-16» в «железе».
Содержание
Как все началось
18 cентября 2025 года я получил неожиданное сообщение — о возможности поучаствовать в аппаратной реализации какой-то учебной игровой приставки, которая должна в реальности работать на FPGA самого бюджетного сегмента.
На тот момент я учился на четвертом курсе по специальности «Системы поддержки принятия решений» на направлении «Программная инженерия» в РТУ МИРЭА. Такое предложение довольно необычно для прикладного разработчика ПО, ведь для цифрового проектирования требуются совсем иные навыки.
Оглядываясь, я могу выделить события, которые, как мне кажется, повлияли на успех проекта. На первом курсе меня увлек предмет «Информатика». После школьной программы было интересно углубиться в изучение компьютера до уровня триггеров, мультиплексоров, шифраторов и других логических схем. Позже я узнал, что программа этого курса нетипична для университета, и в этом заслуга преподавателя — к. т. н. Карпова Дмитрия Анатольевича. На его лекциях порой освещали общие вопросы о типах памяти, различиях гарвардской архитектуры и архитектуры фон Неймана, разбирали варианты схемы сумматора. Благодаря Дмитрию Анатольевичу я даже ознакомился с микроархитектурой процессора Intel Core 2 Duo, пусть и в формате справки.
При подготовке к экзамену по этому предмету я решил, что было бы интересно проверить теорию где-то еще кроме бумаги и программы Logisim. Зная, что процессор, способный запускать тетрис, создавали в Minecraft, я решил реализовать в этой игре 4-битный сумматор с дешифратором и выводом на семисегментный дисплей. Это может показаться несерьезным, но логические элементы в Minecraft все те же, а многие проблемы проектирования имеют сходство с реальными.


Чуть позже я начал знакомство с системным программированием. Создал пусть и простейший, но свой язык программирования, который выполнялся на виртуальной машине, реализованной по образу ВМ CPython. Такой проект дал общее понимание основных стадий компиляции, а также цикла выполнения программы стековой виртуальной машиной.
В следующем семестре, когда язык был реализован, из всех преподавателей нашего большого университета вести практические занятия по предмету «Программирование на языке Python» назначили к. т. н. Советова Петра Николаевича — специалиста в области разработки компиляторов для спецпроцессоров. Тогда я сильно обрадовался, потому что был знаком с некоторыми его докладами и находил их очень интересными. Под руководством Петра Николаевича я реализовал библиотеку llvm2py, которая изначально была идеей творческого проекта, написал первую научную статью и познакомился с литературой о компиляторах.
Кстати, в этой области особенно хочу отметить книгу Essentials of compilation. Она примечательна тем, что начинается сразу с генерации кода, а написание компилятора происходит итеративно, где каждую главу добавляется новый проход. В начале книги компилятор поддерживает программы, где есть только сложение и константы, а в конце — программы с массивами, замыканиями и разными моделями типизации. Есть версии книги на Racket и Python, где цель компиляция — x86-ассемблер.
Наконец, уже на четвертом курсе я посетил первую лекцию по предмету «Программирование нейропроцессоров», которую вел профессор Тарасов Илья Евгеньевич — специалист в области цифрового проектирования. Хотя в названии предмета присутствует слово «программирование», на занятиях освещали и общие вопросы цифрового проектирования на примере реализации нейронных сетей. На первой лекции я сразу загорелся желанием углубиться в предмет и сделать интересный проект.
Примерно тогда я и получил сообщение от Петра Николаевича об учебной игровой приставке. Илья Евгеньевич был не против дополнительно проконсультировать меня по этому проекту. Дополнительным мотиватором стало участие в кейс-чемпионате в номинации «Retro Game Console — разработка игровой консоли на FPGA/ПЛИС», где решение нужно был предоставить до 8 октября.
То есть до финала оставалось всего три недели, а я даже не знал, что представляет собой FPGA.
Ранее я участвовал в хакатонах по ИИ, где за три дня в команде приходилось с нуля создавать полноценно работающую систему. В некоторых хакатонах моя команда даже занимала призовые места. Но здесь нужно было в кратчайшие сроки переквалифицироваться из прикладного программиста в аппаратного инженера. Звучит как неминуемый провал, но цена ошибки в студенческие годы практически равна нулю, так что я решил попробовать.
Кроме того, в приставке обещали крайний минимализм на всех этапах как со стороны ПО, так и со стороны аппаратуры. А в случае больших трудностей у меня была возможность спросить совета у Петра Николаевича и Ильи Евгеньевича, чем я активно пользовался.
Подготовка
Была выбрана методология совместного программно-аппаратного проектирования, когда на протяжении ряда итераций программная и аппаратная реализация приставки разрабатывались параллельно и с обратной связью.
Хотя времени было мало, начинать писать «боевой» код я не спешил. Перед тем как решать задачу, я обычно собираю начальные сведения. Здесь было важно два момента:
Выдержать баланс между фундаментальными знаниями о проектировании под FPGA, которые сэкономят время на длинной дистанции, и практическими примерами, которые помогут уже сейчас.
Не закопаться в литературе и в определенный момент начать писать свою реализацию приставки.
Одни книги мне порекомендовали, другие источники я нашел сам.
Из книги Тарасова И. Е. ПЛИС Xilinx. Языки описания аппаратуры VHDL и Verilog, САПР, приемы проектирования я почерпнул основные сведения о том, что вообще представляет собой мир FPGA, какие есть ресурсы, общие подходы к проектированию, основные трудности и ограничения.
В заметках пользователя jborza о реализации приставки CHIP-8 на Verilog я прочитал, какие шаги пришлось пройти автору проекта, наиболее близкого к тому, что собирался делать я.
Особенности языка Verilog я изучал из туториала сайта chipverify, поскольку информация там хорошо структурированна и среди нее можно быстро ориентироваться по ходу разработки.
Книга DESIGNING VIDEO GAME HARDWARE IN VERILOG помогла повысить насмотренность на реализациях основных аппаратных компонентов, используемых для создания простых игр прямо в аппаратуре.
Поскольку создаваемый процессор был стековым, я познакомился с устройством форт-процессора J1, который довольно сильно повлиял на итоговое RTL-описание процессора приставки.
Кроме стекового J1, я посмотрел на процессоры TINYCPU и A Tiny Computer.
Помимо этого списка, мне рекомендовали изучить основы цифрового проектирования по фундаментальным книгам — Цифровая схемотехника и архитектура компьютера. Дэвид М. Хэррис и Сара Л. Хэррис и Цифровая схемотехника и архитектура компьютера: RISC-V. Дэвид М. Хэррис и Сара Л. Хэррис. Но глубоко познакомиться с ними в процессе проектирования я не успел.
Когда появились начальные знания, небольшая насмотренность и понимание, с чем придется работать, можно было начинать аппаратное проектирование. В качестве языка описания аппаратуры я выбрал Verilog.
Конечная цель
Изначально было ясное понимание, что приставка должна работать не в абстрактном вакууме, а на вполне реальной FPGA уровня Tang Nano 9K.

С информацией о плате можно ознакомиться на википедии Sipeed. Основные характеристики:
8640 LUT.
6480 Reg.
26 блоков блочной памяти, 468K бит (~57 КБайт).
20 18x18 умножителей.
2 PLL.
Есть HDMI.
17280 бит ShadowSRAM, но она реализуется на LUT.
Хотя на этот момент платы на руках у меня ещё не было, проектировать я старался под нее, оценивая результат синтеза в Gowin EDA.
Архитектура
Пока я читал литературу по теме, «Брус-16» обрел форму и название. Автор реализовал программный эмулятор и первый прототип игры Racing, написанный тогда ещё на языке стекового ассемблера.

Игра оживляла разработку. Она напоминала, что конечный результат — это не то, что будет удивлять своими результатами на бумаге, а то, во что можно будет поиграть, что можно будет показать любому.
Из кода эмулятора стало понятно, что представляет собой процессор и видеоядро:
Разрядность: 16 бит. Как для инструкций, так и для данных. Адресация доступна только по словам.
Гарвардская архитектура: 8192 слова кода и 8192 слова данных.
Целочисленное АЛУ + умножение.
Два стека: данных на 32 слова и возвратов на 16 слов.
Работает с локальными переменными через фреймы.
Команда wait.
Разберемся с основными особенностями процессора.
Система команд
Система команд разбита на два формата. OP1 и OP2 — опкоды, а F — формат команды.

Это финальный вид системы команд, который она приняла не сразу. Было достаточно много итераций, из-за чего я в будущем постепенно пришел к более чистому и параметризованному стилю написания на Verilog.
Первый формат ориентирован на работу с памятью. Здесь поддерживается 13-битный беззнаковый литерал (IMM), что позволяет адресовать сразу всю память данных и выполнять инструкции переходов и вызов функции всего за одну команду.
Второй формат содержит знаковый 9-битный литерал (SIMM) и бит I, который служит индикатором, должна ли команда использовать этот литерал или взять значение со стека.
Один только такой флаг I позволил сократить размер программы для игры Gerion на 9,7%.
Стеки
Процессор стековый, причём стек не один, их целых два.
Для вычислений и передачи аргументов в функции используется стек данных на 32 слова. Мне показалось, что это очень мало, но из-за механизма фреймов оказалось достаточно.
Дополнительно есть стек возвратов на 16 слов. Его задача — хранить адреса возвратов при вызовах функций.
Стек возвратов встречается редко, но автор проекта утверждал, что его наличие упростит аппаратуру, что можно сказать и про фиксированную длину слова, и про выбор Гарвардской архитектуры.
Команда call addr заносит адрес следующей инструкции (PC + 1) на стек возвратов и выполняет переход по адресу addr, а команда ret берет сохранённый адрес со стека возвратов и записывает в регистр PC.
Фреймы
Теперь перейдем к механизму, благодаря которому стека на 32 слова хватает. Поскольку в «Брус-16» поддерживаются локальные переменные функций, требуется механизм выделения памяти для них.
Выбран подход с использованием фреймов. Локальные переменные адресуются относительно регистра FP (Frame Pointer). Можно провести прямую аналогию с регистром rbp в x86.
В начале выполнения функция выделяет себе n переменных с помощью команды locals n (FP = FP - n), а одновременно с возвратом освобождает пространство при помощи ret n (FP = FP + n). За число n полностью отвечает компилятор.
def sum(a, b): return a + b
У функции выше локальные переменные a и b будут иметь адреса FP+0 и FP+1 соответственно.
Такая относительная адресация имеет преимущество в системе команд «Бруса». Для чтения по абсолютному адресу требуется три команды (push abs_addr, load 0, push_mr), но поскольку FP уже установлен на начало фрейма, достаточно двух команд (load rel_addr, push_mr). То есть на одну команду меньше.
Вот что будет, если скомпилировать функцию sum.
LABEL sum LOCALS 2 SET_LOCAL 0 SET_LOCAL 1 GET_LOCAL 0 PUSH_MR GET_LOCAL 1 PUSH_MR ADD RET 2
Locals 2 и ret 2 фигурируют в начале и в конце функции, а варианты load и store (set_local и get_local) оперируют относительными адресами. Таким образом, стека данных хватает, потому что локальные переменные там просто не хранятся.
Команда wait
Перейдём к самой заметной команде «Бруса». Изначальная задумка была в том, чтобы пользовательский код вызывался заново для каждого кадра. Это реальный сценарий, но менее удобный, поэтому появилась команда wait.
Эта команда — важная деталь синхронизации. Она переводит процессор в состояние ожидания следующего кадра, сохраняя его состояние, чтобы в новом кадре он продолжил выполнение ровно с того же места и с тем же состоянием, в котором остановился — подобно сопрограмме (async/await). Поддержать такой механизм аппаратно оказалось довольно просто.
Другой загадкой раздела архитектуры является механизм, благодаря которому процессор связан с внешней средой — способен обрабатывать ввод с джойстиков и работать с графикой.
Поскольку основным приоритетом при создании «Бруса» была простота, то и решение получилось довольно простым.
Системная область памяти данных процессора
Адрес |
Назначение |
0–7791 |
Данные программы |
7792–7807 |
Данные кнопок |
7808–8191 |
Данные прямоугольников |
Как данные кнопок, так и данные прямоугольников обслуживаются автоматически каждый кадр. Все, что нужно процессору для работы с ними, — это возможность читать и писать в память.
До адреса 7791 включительно находятся данные программы. С одного конца, начиная с адреса 0, — глобальные переменные, включая объемные массивы, данные уровней и параметры любого рода. С другого конца, с адреса 7791, располагаются фреймы, которые растут навстречу.
Диапазон в 16 слов (7792—7807) занимают значения кнопок от джойстиков DualShock 2. В текущем варианте — по 8 кнопок с контроллера. «Брус» поддерживает игры на двоих!
Последние 384 слова с адреса 7808 занимают параметры прямоугольников для отрисовки графики.
Графика
Графические возможности «Брус-16» — это отрисовка 64 закрашенных прямоугольников каждый кадр. Поскольку кадровый буфер отсутствует, то выбран подход к отрисовке через интерфейс команд.
В «Брус-16» только один тип команды — отрисовка закрашенного прямоугольника:
rect(abs/rel, x, y, w, h, color)
Интерфейс команды:
Абсолютные координаты или относительные — относительно последнего прямоугольника с абсолютными координатами.
Координата x.
Координата y.
Ширина w.
Высота h.
Цвет RGB в формате 565 (5 бит на красный, 6 на зелёный и 5 на синий).
Поскольку все значения имеют разрядность в 16 бит, получаем 64 x 6 = 384 слова.
Это была основная часть спецификации. Пришло время думать, как реализовать всю эту функциональность в железе.
Микроархитектура
Анализ эмулятора прояснил многое, но оставил большое пространство для маневра по реализации этой функциональности в аппаратуре.
Появилось много аппаратных вопросов:
Частота и CPI (Cycles Per Instruction) процессора: хватит ли производительности для игр?
Как реализовать видеоядро?
Какими подсистемами реализовать чисто аппаратные задачи (поддержка DualShock 2, HDMI, обновление памяти данных)?
На какой частоте должны работать подсистемы и по каким протоколам взаимодействовать?
Влезет ли вся система в ресурсы платы?
Я начал отвечать на эти вопросы с основного аппаратного требования.
«Брус-16» — это система реального времени, перед которой стоит задача подготавливать 60 кадров в секунду в разрешении 640x480 с 16-битным цветом. По стандарту VGA это означает, что пиксели нужно отдавать с частотой 25.2 МГц, независимо от игровых условий (без просадок).
При таком разрешении экрана и частоте на один кадр приходится 420 000 тактов. Это ресурс, который можно потратить как на работу процессора, выполняющего код игры, так и на другие аппаратные задачи.
Нужно было понять, какая часть этого ресурса уже используется и хватит ли запаса для более тяжелых игр, например будущего AAA-проекта «Gerion». Поскольку программный эмулятор был уже реализован, то с его помощью была собрана первая статистика. Игра Racing требовала порядка 6000 инструкций в момент инициализации и до 1000 инструкций на кадр в процессе работы. Было решено, что запас достаточный, поэтому я решил начать с простого пути — все подсистемы работают на одной частоте.
В голове у меня начала вырисовываться цельная картина, состоящая из следующих подсистем:
CPU (процессор) выполняет программу и подготавливает следующий кадр.
GPU (Видеоядро) отвечает за отрисовку изображения, имеет собственную память.
DMA копирует данные прямоугольников в GPU, а также данные кнопок в память данных CPU.
VGA controller формирует сигналы для вывода пикселя на экран (hpos, vpos, HSync, VSync) и определяет начало копирования данных прямоугольников.
DS2-контроллеры запрашивают состояние кнопок у джойстиков через интерфейс SPI.
HDMI передает данные от VGA controller и GPU монитору по протоколу HDMI.
В динамике картина имела следующий вид.
Цикл отрисовки кадра

Последовательность действий:
Процессор готовит новый кадр в памяти данных, отрабатывая до 408 000 тактов (остальные 12 000 — на задачи копирования), после чего выполняет команду wait и засыпает.
Контроллер DMA копирует данные прямоугольников из памяти данных во внутреннюю память GPU, одновременно записывая свежие состояния кнопок, полученные от DS2-контроллеров.
Видеоядро отрисовывает кадр на основе данных из внутренней памяти, процессор параллельно подготавливает следующий кадр.
Получилась конвейеризация на уровне отрисовки кадра.

Процессор и видеоядро работают одновременно, но видеоядро отрисовывает текущий кадр, а процессор подготавливает следующий.
В текущей реализации процессор сам отвечает за своевременную остановку, поэтому присутствие команды wait в коде программы обязательно. Причем процессор должен выполнить команду в течение 408 000 тактов — остальные 12 000 тактов занимает копирование. Если не успеть, то возникнет задержка кадра или изображение будет разрушено, когда данные скопируются во время их обновления процессором.
Хотя выше микроархитектура расписана довольно детально, такая картина была лишь в голове, причем поначалу в более концептуальном виде.
Нужно было начинать описывать каждый компонент на Verilog. В первую очередь вопрос вызывало видеоядро, вокруг которого витал туман неопределенности, поскольку у видеоядра не было видеобуфера! Если веру в процессор мне давал опыт реализации виртуальной машины и примеры типа J1, то с видеоядром было понятно: решение лежит где-то в другой плоскости мышления, в которой, наверно, легко ориентируются аппаратные проектировщики, но еще плохо ориентировался я.
На бумаге я сперва начал обдумывать идею работы видеоядра, но первые строки на Verilog были именно о процессоре.
Процессор
Как новичок в вопросах аппаратного проектирования, изначально я взял самую простую задачу — добиться рабочей версии в симуляторе Verilog любым способом, а затем итеративно улучшать результат. В качестве спецификации служил эмулятор и система команд.
Из скромного опыта реализации простой стековой виртуальной машины у меня сложилось предубеждение, что стековый процессор будет медленным и на порядок уступать регистровому (больше операций работы с памятью, больше размер программ), но «железный вариант» в итоге приятно удивил.
Что нужно сделать, чтобы, к примеру, выполнить команду сложения?
Прочитать команду из памяти кода.
Декодировать команду.
Прочитать первый и второй операнды со стека.
Выполнить операцию сложения.
Записать результат обратно в стек.
При этом не забывать про обновление счетчика команд и указателя стека данных. Звучит как немалый объем работы для одной инструкции, но как он будет выглядеть в аппаратуре?
Начнём с состояния процессора. В аппаратуре оно имело те же компоненты, что и в эмуляторе.
Состояние процессора
Состояние процессора состоит из:
регистра PC — счетчик команд;
регистра SP — указатель на вершину стека данных;
регистра RSP — указатель на вершину стека возвратов;
регистра FP — указатель на текущий кадр памяти;
регистра WAIT — принимает значение 1, когда процессор спит;
состояния памяти;
состояния стека данных и стека возвратов.
Хотя итераций при создании процессора было больше, я сгруппировал их в три наиболее значимые с точки зрения достижения однотактного выполнения инструкций процессором.
Первая итерация создания процессора
В первой рабочей итерации я написал классический конечный автомат, который работал за шесть тактов и имел стадии в духе fetch, decode, execute, writeback. Такой вариант мало чем отличался от кода эмулятора. Это была довольно запутанная синхронная схема, но в симуляторе Verilog она могла корректно вычислить факториал, что воодушевляло.
В дальнейшем мне удалось сократить шесть стадий до трех, обдумав все зависимости по данным и введя вспомогательные регистры.
Описание для трех стадий выглядело примерно так.
always @(posedge clk) begin case (state) FETCH: begin case (opcode) CMD_A: ... CMD_B: ... CMD_C: ... endcase state <= EXECUTE; end EXECUTE: begin case (opcode) CMD_A: ... CMD_B: ... CMD_C: ... endcase state <= WRITEBACK; end WRITEBACK: begin case (opcode) CMD_A: ... CMD_B: ... CMD_C: ... endcase state <= FETCH; end endcase end
Хотелось добиться хорошего результата, которым в идеале является однотактовая реализация. Одним из вариантов, как достичь пропускной способности в один такт, является конвейеризация. Это могла бы быть интересная затея, но автор приставки подталкивал искать простые решения, а конвейер бы сильно усложнил тракт данных, потребовалось бы заводить предсказатель переходов, который к тому же мог ошибаться. Мне и самому хотелось отложить идею конвейера, потому что времени до предоставления решения оставалось довольно мало. Хотелось успеть показать приставку в сборе.
Более простым вариантом уменьшить количество стадий на выполнение инструкции стал пересмотр реализации стеков.
Стеки
Какие варианты действий могут произойти со стеком данных за инструкцию «Брус-16»? Здесь важны действия со стеком, поскольку именно они потребовали дополнительные стадии.
Команда |
Действия со стеком |
Стек до |
Стек после |
add |
pop, pop, push |
A, B |
A + B |
store |
pop, pop |
A, B |
- |
add 5 |
pop, push |
A |
A + 5 |
locals 0 |
- |
A |
A |
push A |
push |
- |
A |
Нет никакой проблемы в том, чтобы за такт сделать push или pop, но когда нужно сделать сразу pop, pop для команды store или pop, pop и push, например, для выполнения команды add, возникают нюансы.
На чипе FPGA предоставляется на выбор два варианта памяти.
Синхронная (BSRAM) — может иметь объём в килобайты, но отдает результат чтения только на следующий такт.
Асинхронная (REG/LUT) — имеет очень маленький объем, но может читаться в тот же такт.
Блоки синхронной памяти поддерживают два порта, которые могут менять режим во время выполнения (чтение/запись). Реализовать стеки на синхронной памяти можно, но это будет труднее либо потребует больше тактов.
В текущей итерации я реализовал стеки на синхронной памяти с интерфейсом 1R1W (один порт на чтение, один на запись) и выделенным регистром под вершину стека. Именно возможные манипуляции со стеком заняли целых три такта на полное изменение состояния процессора в моей реализации.
Можно было бы совершенствовать вариант с синхронной памятью, но здесь мне подсказали, что вообще-то стеки такого маленького размера можно вполне реализовать и на регистрах, причем взять удобный интерфейс 2R1W.
Вторая итерация создания процессора
Во второй итерации я прислушался к совету и попробовал реализовать стеки на регистрах с интерфейсом 2R1W. При таком подходе пришел к тому, что является стандартным способом реализации регистрового файла.
Имеется два адреса для асинхронного чтения, в которые подаются SP и SP - 1. Получаем вершину и следующий элемент стека, которые всегда актуальны для текущего SP. Порт для записи синхронный, это значит, что результат будет записан к следующему такту, чего достаточно.
Полную реализацию стека можно посмотреть в репозитории.
Решение перейти к такой реализации стеков колоссально упростило логику процессора и убрало дополнительную задержку, приоткрыв путь к однотактному выполнению.
Стек возвратов реализован аналогичным образом, но с интерфейсом 1R1W. Напомню, с ним работают две команды — call imm13 и ret simm9, call — записывает в стек значение PC + 1, а ret — берёт значение и устанавливает в PC.
Другой совет, который я получил — это перейти к синхронному дизайну.
В отличие от обычной программы, где инструкции выполняются последовательно одна за другой, описание на Verilog — это будущая схема, где все элементы работают одновременно.
Понимая этот принцип и вооружившись полученным советом, я перешел на новую модель описания, в которой регистры меняют свое значение по приходу тактового сигнала, а новые значения для них формируются асинхронно в блоках комбинационной логики.
С этим подходом последовательная часть логики выглядит так:
always_ff @(posedge clk) begin if (reset) begin pc <= {CODE_ADDR_WIDTH{1'b1}}; sp <= STACK_DEPTH'(0); rsp <= RSTACK_DEPTH'(0); fp <= DATA_ADDR_WIDTH'(KEY_MEM); wait_flag <= 1'b0; end else begin pc <= pc_new; sp <= sp_new; rsp <= rsp_new; fp <= fp_new; wait_flag <= wait_flag_new; end end
Стало гораздо чище, теперь для каждого регистра нужно выбрать все команды, которые могут на него влиять, и выстроить логику исходя из этого. К примеру, вот так формируется новое значение регистра RSP.
always_comb begin casez ({cmd_type, opcode}) // CALL, заносится значение на стек возвратов, инкремент RSP 6'b1_10_???: rsp_new = rsp + 1; // RET, берётся значение со стека возвратов, декремент RSP {1'b0, `RET}: rsp_new = rsp - 1; // Во всех иных случаях default: rsp_new = rsp; endcase end
Новые значения для остальных регистров формируются по аналогичному принципу и особых проблем при описании не составили.
Третья попытка
На этом этапе я решил перейти с Verilog на SystemVerilog, но скорее из эстетических причин: язык поддерживал чуть больше удобных конструкций. Например, с ним я стал использовать тип logic только для сигналов, формируемых комбинационной логикой, а слово reg теперь точно означало именно регистр.
Остался вопрос с командой load: она была единственной, требующей два такта на выполнение. Задача ее проста: выставить адрес на чтение памяти данных и положить значение на стек, которое придет только на следующий такт.
Реализовать 16 КБ памяти данных на асинхронной памяти для платы Tang Nano 9K никак бы не получилось, поэтому было решено разбить команду load на две более мелкие: load только устанавливает адрес и push_mr (от Memory Result) только кладет прочитанное значение на стек.
Такое решение имело цену — увеличение размера программ вплоть до 29% в случае «Gerion».
Наконец, процессор стал однотактным!
Что получилось после всех итераций
Декодер
Автор специально проектировал процессор так, чтобы упростить аппаратную реализацию декодера, поэтому он занимает всего несколько строк!
wire [15:0] instr = reset ? 18944 : instruction; wire [15:0] instr_simm9 = {{7{instr[8]}}, instr[8:0]}; wire [15:0] instr_imm13 = {3'b0, instr[12:0]}; wire [4:0] opcode = instr[14:10]; wire cmd_type = instr[15]; // F wire mode = instr[9]; // I
В первой строке 18944 — это закодированная команда locals 0, эффект которой можно сравнить с командой nop (No OPeration). Она служит заглушкой, чтобы процессор ничего не делал, пока не придёт первая команда после reset.
Вторая и третья выполняют знакорасширение simm9 и imm13 до 16-битных значений, а строки 4–6 несколько упрощают работу в дальнейшем, давая более понятные имена.
АЛУ
Первые 15 команд второго формата — команды АЛУ.
Самой накладной командой может показаться умножение, но поскольку на чипе умножители реализованы аппаратно в виде блоков DSP, функциональность которых сильно шире, синтезатор просто использует один из них, и мы получим умножение за один такт, можно сказать, бесплатно.
Другой довольно тяжелой операцией представляется сдвиг на произвольное число бит, но посмотрим, сколько ресурсов займет процессор на стадии синтеза.
Описание АЛУ будет выглядеть следующим образом:
`include "constants.svh" module alu( input wire [4:0] opcode, input wire [15:0] a, input wire [15:0] b, output logic [15:0] out ); always_comb begin casez (opcode) `ADD: out = a + b; `SUB: out = a - b; `MUL: out = $signed(a) * $signed(b); `AND: out = a & b; `OR: out = a | b; `XOR: out = a ^ b; `SHL: out = a << b; `SHR: out = a >> b; `SHRA: out = $signed(a) >>> b; `EQ: out = 16'(a == b); `NEQ: out = 16'(a != b); `LT: out = 16'($signed(a) < $signed(b)); `LE: out = 16'($signed(a) <= $signed(b)); `GT: out = 16'($signed(a) > $signed(b)); `GE: out = 16'($signed(a) >= $signed(b)); `LTU: out = 16'(a < b); default: out = 16'b0; endcase end endmodule
Вся логика АЛУ уместилась в 33 строки, довольно выразительно, код мало отличается от программной реализации. Синтезатор сам преобразует такое описание в качественную схему.
Реализация wait
В текущей реализации выбран следующий подход:
Команда wait устанавливает значение регистра WAIT в единицу.
При команде wait или значении регистра WAIT = 1 PC сохраняет своё значение, пока не будет получен сигнал resume. Из-за этого до сигнала resume каждой следующей командой снова оказывается wait.
always_comb begin casez ({cmd_type, opcode, wait_flag, resume}) // Получение сигнала resume 8'b?_??_???_?_1: pc_new = pc_plus_1; // При WAIT=1 8'b?_??_???_1_0, // При команде wait {1'b0, `WAIT, 2'b0}: pc_new = pc; ... endcase end
Схема потоков данных процессора
С точки зрения потоков данных процессор «Брус-16» можно изобразить так.

На этом рисунке показаны пути, которые проходят все сигналы процессора за такт выполнения.
Данные идут слева направо. По бокам в виде прямоугольников расположены модули, с которыми взаимодействует процессор. Слева — чтение, справа — запись. Овалами обозначены участки комбинационной логики, а красными прямоугольниками изображены регистры.
Сравнение ресурсов Брус-16 и PicoTiny
Когда процессор заработал, стало интересно сравнить его с промышленным решением. Я смог найти лишь процессор PicoTiny — доступный для Tang Nano 9K RISC-V процессор с разрядностью 32 бита.
Может показаться странным сравнивать процессор с разрядностью 32 бита и 16 бит, но 32 бита не нужны «Брус-16», а найти 16-битное RISC-V ядро у меня не получилось.
Характеристика |
Процессор |
|
PicoTiny |
Брус-16 |
|
LUT |
1577 |
785 |
Register |
605 |
36 |
BSRAM, блоки |
2 |
0 |
CPI (AVG. cycles/instr.) |
4 |
1 |
Разрядность |
32 |
16 |
Набор команд |
Полный |
Сокращённый |
Про регистры и блочную память
Регистр Брус-16 |
Размер, бит |
SP |
5 |
RSP |
4 |
PC |
13 |
FP |
13 |
WAIT |
1 |
В сумме — как раз 36 бит. Стеки в процессоре «Брус-16» реализованы через SSRAM — реализацию асинхронной памяти на LUT, в таблице они пересчитаны в LUT. Процессор «PicoTiny» SSRAM не использовал, регистровый файл для него реализован через два блока блочной памяти.
CPI = 1
Как я писал выше, изначально мои ожидания от стекового процессора были крайне низкими, я думал, что его простота отберет производительность. Но оказалось, что он не так прост и тратит всего один такт на инструкцию, причём без конвейера!
Конечно, регистровый процессор для «Брус-16» был бы производительнее, но он и сложнее в реализации — как в аппаратуре, так и в компиляторе. Таким образом, создание процессора с нуля под конкретную задачу дало свои плоды — экономию ресурсов, гибкость и хорошую производительность.
Видеоядро
Как я говорил ранее, реализацию видеоядра справедливо можно назвать самой туманной и интересной частью приставки. Отсутствие видеобуфера, 64 прямоугольника и 60 кадров в секунду — довольно жесткие условия, которые меняют подход к решению задачи под аппаратные возможности.
VGA-controller непрерывно отправляет координаты пикселя на отрисовку с частотой 25,2 МГц, задача видеоядра — отдавать цвет для этого пикселя.
Первые мысли
К этому моменту мне рассказали, что для слабых систем использовался подход с буфером строки, который очищался по приходу горизонтального прерывания (сигнал HSync). Здесь также можно использовать двойную буферизацию: пока из одного буфера отдаётся текущая строка, в другом буфере формируется следующая.
Потратив около двух дней, размышляя над процессом отрисовки на бумаге, я пришел к экзотическому варианту, который, по моему предположению, справился бы с игрой Racing, но накладывал много дополнительных ограничений и был настолько запутанным, что во время написания статьи я не смог вспомнить всех его подробностей.
Полноценным решением мою наработку назвать было нельзя. Подход, казалось, лишен перспектив, а время поджимало. Тогда даже проскочила мысль, что, возможно, с видеоядром вообще не сложится и нужно было начинать с чистого листа с совершенно другим подходом.
Вариант 1. Программная реализация
Почему вообще я начал думать над буфером строки? Что не так с программной реализацией, разве нельзя перенести ее в железо напрямую?
Вот программный вариант с видеобуфером — то, как его можно реализовать в эмуляторе, не используя готовые команды библиотек на отрисовку прямоугольников, а честно закрашивая каждый пиксель.
for x in range(640): for y in range(480): for z in range(64): if is_hit(rect[z], x, y): frame[x][y] = color[z]
Здесь индекс z выбран не случайно: это действительно z-index, потому что прямоугольник, у которого этот индекс больше, должен перекрашивать прямоугольник, у которого он меньше. Получаем глубину сцены в 64 слоя. В чем проблема такого подхода?
Память. Для хранения видеобуфера потребуется 614 КБ памяти, на чипе есть всего 58,5 КБ блочной памяти. Пришлось бы иметь дело с SDRAM — дополнительные сложности.
Частота. 60 x 64 x 480 x 640 = ~1.2 миллиарда итераций на 60 кадров в секунду. Из предположения в 1 такт на итерацию получаем частоту в 1.2 ГГц. Осциллятор Tang Nano 9K выдаёт частоту в 27 МГц, достичь гигагерца на FPGA вряд ли получится.
В итоге становится ясно, что нужен другой подход, где было бы меньше итераций, а за раз выполнялось больше работы. Когда мои размышления над буфером строки зашли в тупик, мне подсказали идею следующего способа, которая сильно удивила.
Я знал, что в FPGA много ресурсов LUT и на их основе можно параллельно производить много вычислений. Но даже при самом оптимистичном сценарии я не мог предположить, что следующий вариант удастся поместить в плату. Оставалось лишь попробовать.
Вариант 2. Преимущественно вычисления

Разберём идею отрисовки на примере персонажа из игры Flippy. Необходимо отрисовать одну конкретную точку с координатами (x=330, y=210). Всего в кадре задействовано пять прямоугольников с разными цветами. Рассчитаем вектор столкновений каждого прямоугольника с интересующей точкой. Как видно по картинке, в точке находится два прямоугольника, зеленый и синий. Из-за этого у них в векторе столкновений стоит единица, но какой из них нужно отрисовать? Тот, у кого больше z-index.
Теперь посмотрим на каждый из этапов такого подхода.
Этап 1. Формирование вектора столкновений

Здесь для наглядности изображена схема для четырех прямоугольников, в приставке их 64.
В самой левой части находятся данные прямоугольников — четыре их абсолютные координаты (x, x + width, y, y + width), заранее пересчитанные из относительных координат во время копирования.
Слева сверху поступают значения текущих координат для вывода на экран, в нашем примере — (x=330, y=210).
Посередине располагаются компараторы — блоки, которые одновременно и независимо друг от друга рассчитывают значение функции is_hit для своего прямоугольника.

Как видно — это четыре сравнения и три элемента И.
Количество используемых ресурсов для такой схемы зависит от разрядности данных. Экран предполагает разрешение в 640x480, для этого достаточно иметь 10 бит для x и 9 бит для y. Задачу перевода координат из 16-битной знаковой версии в 10 и 9-битную беззнаковую можно выполнить при копировании, одновременно с переводом в абсолютные координаты.
Этап 2. Определение max-индекса столкновения

Предположим, у нас есть только два прямоугольника и мы хотим определить color_index — индекс активного прямоугольника с наибольшим z-индексом.
На этой картинке главную роль выполняет мультиплексор, который в зависимости от значения управляющего входа (hit_1) выбирает одну из констант (индексы заранее известны и всегда константны 0-63). В данном случае — 0 или 1, поскольку мы выбираем между первым и вторым прямоугольником.
Что, если ни один из прямоугольников не активен? Тогда хотелось бы выводить цвет по умолчанию — например, черный или же синий, если сделать в стиле Windows. С этой целью формируется флаг any_hit — простой элемент ИЛИ.
Теперь обобщим схему на большее количество входов — например, восемь.

Так получается бинарное дерево, глубина которого — логарифм по основанию 2 от количества входов.
Глубина дерева для 64 прямоугольников составит всего 6. Можно сделать последовательный вариант с линейной глубиной, но глубина важна для временных характеристик, от нее зависит частота, на которой сможет работать схема. Как бы ни был прост мультиплексор, если сигналу нужно будет пройти 64 таких в течение одного такта, частота будет ниже.
Помимо дерева, решить проблему глубины можно при помощи конвейеризации — вставить регистры, чтобы разбить длинный путь на несколько стадий. Это увеличит задержку от момента поступления сигнала до получения конечного результата, но повысит возможную частоту схемы. Конвейеризация полезна даже для структуры в виде дерева, поскольку дополнительно упростит трассировку сигнала синтезатором, а задержка в шесть тактов при установке регистров после каждого слоя некритична.
Этап 3. Определение цвета точки на основе max-индекса

Имея индекс z_max и флаг any_hit, остается прочитать цвет из памяти colors по адресу z_max, проверить, что хотя бы один прямоугольник был активен, и получить цвет для точки. Вот такой длинный путь проходят координаты точки перед тем, как получить цвет.
Несмотря на длину пути и количество стадий, на практике такой вариант способен работать с пропускной способностью в 1 такт на пиксель. Нужно лишь добавить некоторое количество регистров между стадиями. Вызванная этими добавлениями задержка на практике будет незаметна глазу, поскольку даже 10 тактов задержки займут ~0,4 микросекунды при частоте в 25.2 МГц.
Оценка
Такой вариант можно назвать идеальным для «Брус-16» из-за его простоты и элегантности, однако стоит цель — запустить приставку на Tang Nano 9K. Оценим вариант в ресурсах.
В таблице приведено количество ресурсов после этапа синтеза.
Ресурс |
Необходимо на текущий вариант видеоядра |
Частота, МГц |
25.2 |
LUT |
3251 |
REG |
2539 |
BSRAM, блоки |
0 |
Используемые ресурсы при размещении всей приставки (CLS) на Tang Nano 9K |
>100% |
Видно, что ресурсов используется много, но в допустимых пределах, однако картину портит последняя строка — CLS. CLS — это один из внутренних блоков FPGA, о котором можно отдельно прочесть в документации. Значение «>100%» означает, что синтезатор просто не может разместить всю приставку при таком варианте видеоядра. Возможное объяснение — нехватка ресурсов маршрутизации.
В это время кейс-чемпионата подходил к концу, платы на руках у меня не было, а решение презентовать было нужно.
Сначала я попытался запустить решение в IDE от 8bitworkshop, но результат не порадовал. Машины не ехали (почему-то не работал $signed()), а цвета... не особенно раскрывали преимущества 16-битного цвета Бруса.

Здесь в срочном порядке было решено написать системный тест, о котором написано в разделе тестирования. Как выяснилось, проблема была в 8bitworkshop, а не в описании на Verilog.
После написания теста на кейс-чемпионате удалось представить «Брус-16» в сборе, участие можно было считать успешным. Однако шанса запустить приставку в железе так и не представилось. Текущий вариант показал, что можно двигаться дальше, и цель изменилась — нужно запустить приставку на плате, чтобы закончить начатое и в будущем даже представить ее на выставке ИСП РАН.
Даже при наличии платы на руках текущий вариант видеоядра в нее не уместится. Взгляд упал на количество использованных блоков блочной памяти — 0. Можно попытаться задействовать больше.
Вариант 3. Компромиссный
Вернёмся к первому этапу — формирование вектора столкновений.

Можно заметить, что верхняя часть компаратора hit_x (выделена зеленым цветом) — зависит только от координаты x, а нижняя hit_y (выделена фиолетовым цветом) — только от y. Координаты прямоугольников не меняются в течение кадра. Во время отрисовки строки координата y постоянна, следовательно, значение hit_y за это время меняться не будет. С другой стороны, hit_x во время отрисовки каждой строки будут принимать одни и те же значения.
Возникает идея: заранее рассчитать все вектора hit_x и hit_y. Если записать вектора в память, то во время отрисовки останется только читать их из нее, а последний элемент И выполнять на ходу.
Чтобы проиллюстрировать идею, приведу простой пример. Пусть будет дисплей 4x4, доступно 4 прямоугольника, состояние памяти следующее:
rect(abs, 0, 0, 0, 0, WHITE) # неактивен rect(abs, 1, 1, 2, 2, GREEN) # зелёный, 2x2 rect(abs, 1, 1, 1, 1, BLUE) # синий, 1x1 rect(abs, 0, 0, 0, 0, WHITE) # неактивен
Здесь разрядность векторов hit_x и hit_y будет составлять 4 бита (4 прямоугольника). Тогда в динамике формирование вектора is_hit для каждого пикселя в течение кадра будет выглядеть так.

Видно, что выделенные красным вектора hit_x и hit_y во время кадра не меняют свои значения.
Такой подход позволяет заменить компараторы и регистры для хранения координат прямоугольников на две независимые области блочной памяти для hit_x и hit_y. X и y будут выполнять роль адресов в памяти, из которой будут читаться 64-битные вектора и только потом будет итоговое вычисление (элемент И).

Оценка
В таблице приведено количество ресурсов после этапа синтеза.
Ресурс |
Необходимо на текущий вариант видеоядра |
Частота, МГц |
25.2 |
LUT |
653 |
REG |
487 |
BSRAM, блоки |
9 |
Используемые ресурсы при размещении всей приставки (CLS) на Tang Nano 9K |
40% |
Как видно, такой вариант занимает значительно меньше ресурсов, его синтезатор смог разместить уже без каких-либо проблем. Ура! Можно сделать вывод, что в случае видеоядра «Брус-16» удалось заменить одни ресурсы FPGA (LUT и REG) на другие (BSRAM).
На самом деле я рассматривал и другие варианты видеоядра, но они были различными вариациями описанных выше, поэтому я упомянул только о самых значимых и различающихся вариантах.
DMA
Вообще, обычно под DMA понимают программируемый контроллер памяти, но когда каждый кадр действия одни и те же, а ресурс тактов на кадр ограничен, я решил реализовать необходимую функциональность полностью аппаратно.
Роль контроллера DMA — копировать данные прямоугольников в память видеоядра, попутно переводя относительные координаты в абсолютные, а 16-битный знаковый формат — в 10- и 9-битный беззнаковый. Также в его роль входит копирование состояния кнопок от контроллеров DualShock 2 в память данных.
Всю эту функциональность я реализовал в виде трех конечных автоматов:
автомат, отправляющий данные прямоугольников в видеоядро;
автомат, принимающий данные прямоугольников в видеоядро (считается частью видеоядра);
автомат, копирующий состояния кнопок в память данных.
Последний вариант видеоядра существенно повысил нагрузку на первые два конечных автомата. Ранее в видеоядре присутствовали компараторы и было достаточно просто записать координаты на соответствующие позиции. Теперь вектора hit_x и hit_y все еще нужно вычислить для всего дисплея (640 векторов для x и 480 векторов для y), причем сохраняя разумное количество тактов.
Если раньше компаратор проверял попадание сразу по обеим координатам, то теперь только по одной за раз. Это снизило потребление ресурсов компараторами вдвое, но даже с этим сокращением хотелось бы использовать меньшее их количество.
При 64 компараторах потребовалось бы 640 + 480 = 1120 тактов их чистой работы. Я решил сократить их количество до 16 (1120 x 4 = 4480 тактов) и дополнительно сократить сам компаратор до одной операции сравнения. Если компаратор для одной координаты вычисляет выражение l <= x && x < r, теперь он вычисляет либо выражение l <= x, либо x < r (4480 x 2 = 8960 тактов).
Помимо чистой работы компараторов потребовалось еще некоторое количество на перемещение данных и управляющие сигналы. Так и получилось число в 12 000 тактов, которое упоминалось в разделе микроархитектуры.
Логика получилась довольно сложной и запутанной, не очень хорошо вписывающейся в простоту «Брус-16». Возможно, в следующей итерации получится найти более элегантный вариант — например, с использованием нового управляющего ядра. В данном же этапе было важно, что удалось сэкономить большое количество ресурсов для Tang Nano 9K.
Ввод и вывод
Контроллер DualShock 2
Идея написания аппаратного драйвера для работы с коммерческим геймпадом может показаться сложной, но все, что понадобилось сделать на практике, — это с определённой периодичностью (до 250 кГц) отправлять команду из конкретных трех байт по интерфейсу SPI. В ответ приходят несколько байт, из которых 16 бит сигнализируют о нажатии одной из кнопок геймпада.
Может возникнуть вопрос: как получить настолько низкую частоту? Чтобы минимизировать количество проблем (метастабильность, новый тактовый домен), я выбрал подход с использованием сигнала clock enable. Контроллер работает на частоте приставки (25,2 МГц), но обновляет состояние только по сигналу clock enable, который генерируется с помощью счетчика раз в n тактов от основной частоты.
Подробнее о взаимодействии с DualShock 2 можно прочитать на Hackaday.
HDMI
HDMI — последний шаг для того, чтобы увидеть изображение на экране. Здесь сигналы от VGA преобразуются в стандартизированный формат.
Я взял готовую реализацию HDMI, которую нашёл в интернете, потому что его написание — это трудоемкий процесс со своими особенностями, специфичными для платы, а время, которое можно было бы потратить на реализацию HDMI с нуля, было потрачено на исследование вариантов видеоядра.
Загрузка программ
В текущей версии бинарный файл игры загружается в память при программировании FPGA, таким образом, каждая игра представляет собой отдельную прошивку. В дальнейшем планируются улучшения по этой части.
Тестирование
Многое уже сказано про необходимость тестирования в мире ПО. Но когда проект «игрушечный», а команда разработки состоит из одного человека, растет желание сэкономить время и не писать тесты для кода, в котором уверен. В случае ошибки можно увидеть место, где она произошла, воспользоваться отладчиком, поправить код и просто перезапустить программу.
Если допустить ошибку в процессоре и запустить его на плате, можно увидеть только одно: он почему-то не работает. Никакой помощи ожидать не приходится, ошибка в одной строчке может привести к абсолютно непредсказуемому исходу. С FPGA цена ошибки не так велика, как при выпуске интегральной схемы, но с таким подходом можно потерять много времени и просто не завершить проект.
Именно поэтому я посчитал, что крайне важно быть уверенным в корректности RTL-описания, проверить его на уровне, когда ошибку отследить проще — в симуляторе.
Модульное тестирование
Библиотека для верификации cocotb показалась мне довольно доступной. Конечно, SystemVerilog предоставляет больше возможностей для верификации, но для проекта уровня «Брус-16» ее оказалось вполне достаточно, а разобраться было довольно просто.
Эту библиотеку я подсмотрел в репозитории проекта tiny-tpu. Она позволяет писать код на Python в асинхронном стиле, работая с сигналами HDL, и запускать его на симуляторе. Для маленьких тестов я выбрал симулятор Icarus Verilog, поскольку он не требует компиляции и может быстро запустить тест. С ее помощью были написаны и интеграционные тесты.
К примеру, в одном из тестов для процессора я описал загрузку программы, вычисляющей факториал, симуляцию пары сотен тактов и проверил выходное значение в памяти прямо на Python. При этом во время потактового выполнения можно было выводить любую информацию, скажем, в виде временной диаграммы или логов. При этом логи оказалось легко сделать более наглядными — например, представить стек не как 32 отдельных значения в памяти, а как массив в Python.
При наличии эмулятора на Python можно сравнивать работу схемы прямо на ходу — например, во время косимуляции.
Самые сложные тесты для процессора были выполнены следующим образом.

Брался бинарный файл игры и выполнялся на эмуляторе.
Записывался покадровый дамп памяти данных.
Бинарный файл игры выполнялся на симуляторе Verilog.
Дампы памяти сравнивались.
Пока дампы памяти не будут совпадать, правилось RTL-описание процессора.
Системное тестирование
Когда процессор и отдельные компоненты отлажены, а также есть определенная степень уверенности, что работать они будет исправно, остается неясно, как поведет себя приставка в сборе. Для этой цели был написан системный тест, который позволяет оценить всю приставку целиком.
Для эмуляции всей приставки я использовал симулятор Verilator, потому что его производительность явно выше, чем у Icarus Verilog. Verilator позволяет писать расширения на C++ и также работать с аппаратными сигналами. Так было написано расширение, эмулирующее VGA-вывод и поддерживающее ввод с клавиатуры.

И все это доступно, когда у меня на руках даже не было платы!
Verilator мне посоветовали заранее и вооружили воодушевляющей ссылкой на репозиторий, где уже была реализована симуляция VGA-вывода. Оставалось только адаптировать код для «Брус-16».
Можно найти и другие применения:
эмуляция аппаратного VGA-вывода,
моделирование сетевых устройств,
запись/стриминг звука,
и многое другое...
Запись звука пригодится уже в «Брус-16», поскольку аппаратно звуковой синтезатор пока не реализован.
Запуск на FPGA
Когда прямиком из Китая мне на руки попала Tang Nano 9K, наступил очень важный момент. Момент, когда нужно было проверить приставку на жизнеспособность не в предсказуемом симуляторе, а на реальном железе. Все что угодно могло пойти не так. Возможно, из-за недостатка опыта я забыл какую-то важную деталь, из-за которой придётся перепроектировать всю приставку с нуля. Было ощущение, как будто все это время я играл в песочнице и наконец мне дали возможность действовать в реальном мире.
Наконец, оно заработало! Но криво.
Отладочная программа Zoom работала корректно, но с Racing вылезали глюки. Возникла ситуация, когда в симуляторе все работает, а на плате — нет. Отлаживать в такой ситуации очень трудно. Удалось найти минимальную программу, на которой воспроизводится расхождение между реальным результатом и ожидаемым, но понять причину было трудно.
У меня была возможность прийти в университет и попробовать запустить приставку на плате от уже хорошо зарекомендовавшей себя компании Xilinx. Так я и сделал — почти сразу, Racing заработала как ни в чем не бывало. Это было настоящим облегчением, приставка заработала в железе! Поддержку геймпадов я пока сделать не успел, поэтому приходилось довольствоваться одним лишь изображением.
Дополнительно Vivado нашел циклическую зависимость в процессоре и предоставил возможность сделать post-synthesys симуляцию, которая более точно воспроизводит компоненты конкретной платы. Таким образом, Vivado оказался полезным еще и как инструмент тестирования.
В EDA от GOWIN же не было даже встроенного симулятора. Я попробовал сделать Post-Synthesys симуляцию с блоками от GOWIN через Modelsim, но ничего подозрительного найти не смог. Тогда я заказал себе Tang Nano 20K и Tang Primer 25K, чтобы сравнить работу приставки на них.
К тому времени, пока платы до меня доехали, совпало два события — появилась поддержка джойстиков и на «Брус-16» вышла новая игра — Flippy Rect. Именно она изображена на обложке статьи.
Вместе оба этих события привели к игровой сессии на диване с самым стабильным FPS, с которым мне только приходилось играть. Аппаратные 60 FPS ощущались иначе, чем программные. Наступил момент, когда можно было пожинать плоды.
Попробовать Flippy Rect и другие игры можно в онлайн-эмуляторе приставки.
Грустная же часть оказалась в том, что глюки появляются только на Tang Nano 9K. И поскольку следующим шагом было стендовое выступление на приближающейся конференции ИСП РАН 2025, было решено притормозить с Tang Nano 9K и использовать Tang Nano 20K и Tang Primer 25K.
Синтез
Для синтеза использована бесплатная EDA от GOWIN. После PnR для Tang Nano 20K цифры получились следующие:
Ресурс |
Занято |
LUT |
2395 |
Reg |
1235 |
BSRAM |
25 блоков |
FMAX:
Tang Nano 9K — 32 МГц
Tang Nano 20K — 54 МГц
Tang Primer 25K — 64 МГц
Выводы
Всего за полтора месяца удалось создать аппаратную реализацию, которая работает на Tang Nano 20K, Tang Primer 25K, Xilinx XC7A200T и успешно показала себя на стенде выставки ИСП РАН 2025.
Надеюсь, в этой статье мне удалось объяснить аппаратное устройство приставки и поделиться полученным опытом, из которого читатель сможет почерпнуть для себя что-то полезное или по крайней мере любопытное.
«Брус-16» — это учебная приставка, и данная реализация — всего лишь одна из возможных. Быть может, кто-то из читателей сможет сделать ее лучше — добавить к прямоугольникам угол поворота, сделать другой вариант архитектуры, добавить поддержку UART или в учебных целях воссоздать «Брус-16» самостоятельно.
Сейчас доступны готовые прошивки с играми для плат Tang Nano 20K и Tang Primer 25K, репозитории с аппаратной реализацией и с программной частью.
Неравнодушных к судьбе Tang Nano 9K в этой истории могу заверить, что сейчас получена реализация, в которой удалось исправить глюки, и в будущем можно ожидать поддержку платы по крайней мере для текущей версии реализации приставки.
Благодарности:
Петру Николаевичу Советову, автору проекта «Брус-16», за предложение поучаствовать в аппаратной реализации, наставничество, литературу и рецензирование данной статьи.
Илье Евгеньевичу Тарасову за консультации по аппаратным вопросам.
Артёму Владимировичу Горчакову, Владимиру Миклашевичу и Денису Ананьеву за игры и помощь со стендовым докладом на конференции.
Фёдору Рыжову и Михаилу Лысову за пайку плат Tang Nano 20K и Tang Nano 9K.
Дмитрию Анатольевичу Карпову за курс информатики.
Комментарии (10)

yamifa_1234
02.06.2026 08:53Студент-программист реализовал на FPGA полноценную игровую приставку с нуля за полтора месяца, не имея опыта цифрового проектирования
Не верится что за полтора месяца проделана такая работа. Или в институт не ходил и только проектом занимался?)

ThisPapr1ka Автор
02.06.2026 08:53Ходил, но, честно говоря, чуть реже, чем обычно. Всё-таки у такого сжатого срока есть своя цена, по крайней мере в моём случае.

JerryI
02.06.2026 08:53Да я думаю вполне реально. Ресурсы есть хорошие по FPGA, тот же Марсоход - это просто кладесь, а их форумы еще лучше.
Во многом может проще, чем в современном bloatware писать софт для обычных ПК.

Rub_paul
02.06.2026 08:53На четвертом курсе вообще мало кто ходит в институт, все либо работают, либо вот такие пет-проекты пилят по ночам

JerryI
02.06.2026 08:53Я ведь правильно понимаю, никаких ЦПУ, только стейт машина? ;)
Шикарная работа. Поворчу, что маловато-то деталей, но невероятно приятно видеть такое на хабре. Жирный плюсДешифратор слева и 4-битный сумматор по центру
Пустил слезу. Лет 10 назад я пытался изобрести VGA контроллер на MAXII, но сильно тупил с диаграммами по таймингам. Чтобы облегчить дело, помогло построить это на редстоуне в старой версии Minecraft. Тогда как-то легче было визуализировать, как потом это на диаграммах в Altium сделать. Но по итогу оказалось, что на verilog проще было описать то же самое.

ThisPapr1ka Автор
02.06.2026 08:53Спасибо, приятно слышать! Если бы деталей было больше, то до конца дочитали бы единицы) Не очень понял вопрос по поводу ЦПУ, вроде написан целый раздел про реализацию софт-процессора)
Да, во время создания сумматора многое прояснилось, потому что всё было видно в 3D, как идёт сигнал, как работает схема, видны все задержки. Из интересных проектов видел ChatGPT и 3D-принтер на основе квадратного уравнения.

Rub_paul
02.06.2026 08:53Крутой учебный проект получился. Хорошо что вы не полезли сразу пилить конвейер, иначе бы точно закопались в отладке предсказателя ветвлений и не довели бы железо до рабочего состояния)

ThisPapr1ka Автор
02.06.2026 08:53Спасибо! Да, согласен, думаю это было правильным решением. Сейчас я и сам удивляюсь, насколько компактным и простым получился процессор, как по ресурсам, так и по размеру RTL-описания. При этом производительности более чем хватает для текущих игр. Конечно тут есть заслуга Петра Николаевича по части проектирования ядра ещё на этапе программной модели)
unreal_undead2
Так оно завелось на 9K? В репозитории вижу
ThisPapr1ka Автор
Да, завелось! В репозитории она пока не поддерживается из коробки, но в скором времени будет, по крайней мере для текущей версии приставки.