Minesweeper на FPGA

Привіт всім!

Прочитавши статтю «Робимо тетріс під FPGA», я згадав, що в мене завалявся схожий проект, який я колись використовував для своєрідного пропозиції «руки і серця своїй дівчині.

А чому б не зробити щось подібне самому?

Відкопавши исходники, відновив втрачені знання і вирішив на базі старого проекту на швидку руку написати просту версію гри «Сапер» на старенькій ПЛІС Spartan3E. Власне, про реалізацію гри «Сапер» на рівні логічних вентилів і основні особливості розробки на FPGA фірми Xilinx і піде мова в даній статті.

Налагоджувальна плата
Кілька років тому я шукав бюджетний варіант налагоджувальної плати з ПЛІС і найпростішої обв'язкою різними інтерфейсами типу VGA, PS/2, наявністю світлодіодів і LED-дисплеєм, а також тригерів-перемикачів. Тоді я зупинився на найпростішому китайському наборі, який найпростіше було замовити з ebay за $135,00 з урахуванням доставки. До речі, комплект прийшов неповний, тому я залишив гнівний відгук, за що продавець повернув ~20$. Так що плата обійшлася мені в ~4000р за старими цінами.


Офіційний сайт виробника кита.

Основні особливості девкита:
  • ПЛІС Spartan3E (XC3S500E-4PQ208C) — 500К логічних вентилів,
  • Джерело тактової частоти CLK = 50 MHz,
  • Зовнішня пам'ять 64M SDRAM,
  • SPI Flash (M25P80) для зберігання прошивки ПЛІС,
  • Матриця світлодіодів 8х8, лінійка світлодіодів 8 шт.,
  • 8 перемикачів і 5 кнопок,
  • Роз'єми для підключення LED-дисплеїв,
  • Роз'єм VGA для підключення дисплея,
  • Роз'єми PS/2, і т. д.
Ресурси кристала Spartan3E XC3S500E наведені в таблиці:



З усього розмаїття, для реалізації гри «Сапер» необхідні VGA та PS/2 роз'єми. Крім них я використовував перемикач для глобального скидання (reset) логіки всередині ПЛІС.

Основна концепція гри
Що було?
У старому проекті реалізовані такі штуки:
— введення команд з клавіатури (керування ШИМ-модулятором і дисплеєм);
— самописний інтерфейс VGA з роздільною здатністю 640х480;
— миготливе сердечко на матриці світлодіодів 8х8 на базі ШІМ.

Перші два пункти істотно прискорили час розробки гри, тому я не став винаходити велосипед.

Правила гри:
  • Управління з клавіатури:
    "WSAD" — кнопки-стрілки для переміщення по екрану;
    "Enter" — перевірка поля на наявність/відсутність міни;
    "Space" — почати нову гру;
    "Esc" — завершити поточну гру;
    "Y/N" — для початку нової гри;
  • Поле 8х8, всього 8 хв на поле;
  • Інші правила як у звичайній грі сапер;
Мова програмування ПЛІС: VHDL.

Ось так виглядає готовий проект у програмі «PlanAhead» після стадій синтезу і трасування. Блоки в фіолетових рамках — займані ресурси кристала.



Великий блок: основна логіка гри;
Середній блок: контролер PS/2 клавіатури;
Маленький блок: контролер VGA дисплея.

Ієрархія проекту:
На одному з перших етапів проектування необхідно прикинути, а як же буде виглядати проект і скількома компонентами його зручніше описати. Я придумав наступну структуру:

--> Верхній рівень
----> Контролер PS/2
----> Контролер VGA 640x480
----> Контролер ігри
-------> Блок відтворення кордонів прямокутника,
-------> Блок для малювання зафарбованих поля 8х8
-------> Блок для показу хв і цифр на полі
-----------> Пам'ять для розстановки хв
-----------> Пам'ять для символів
-------> Блок для показу тексту і діалогових повідомлень
-----------> Пам'ять для символів

Так це виглядає в середовищі «PlanAhead» від Xilinx.



Верхній рівень
Він описує основні порти вводу-виводу, містить блок синтезу частоти DCM для перетворення вхідної частоти з 50 МГц до 25 МГц. Код верхнього рівня виглядає наступним чином:

entity top_minesweeper is
port(
-- PS/2 IO --
PS2_CLK : in std_logic; -- CLK from PS/2 keyboard
PS2_DATA : in std_logic; -- DATA from PS/2 keyboard
-- CLOCK 50 MHz --
CLK : in std_logic; -- MAIN CLOCK 50 MHz
-- VGA SYNC --
VGA_HSYNC : out std_logic; -- Horizontal sync
VGA_VSYNC : out std_logic; -- Vertical sync
VGA_R : out std_logic; -- RED
VGA_G : out std_logic; -- GREEN
VGA_B : out std_logic; -- BLUE
-- SWITCHES --
RESET : in std_logic -- Asynchronous reset: SW(0)
);
end top_minesweeper;


Контролер PS/2
За основу взято проект. Запрацювало відразу. Інтерфейс послідовної передачі досить примітивний: дві лінії: PS2_CLK та PS2_DATA, за яким йдуть команди від клавіатури.
Підводний камінь — спочатку з допомогою «Make» коду я генерував одиничний імпульс (по фронту), який сигналізував «натискаючи» клавіші. Це призводило до імітації повторного натискання, коли відбувалося натискання іншої клавіші. Так як байт «Make» і «Break» коди збігаються, то довелося зробити умова більш явним, враховуючи «Break» код.
Таблиця кодів для PS/2 контролера наведена посилання.

Контролер VGA
Коли-то в цілях навчання написав самостійно, але алгоритм його роботи точно такий же, як і у всіх VGA-контролерів. На Хабре теж є такий.



Основні особливості:
— Частота роботи контролера: 25.175 MHz
— Дозвіл екрану: 640х480
— Частота оновлення: 60Hz
— Доступна палітра: RGB

На жаль, налагоджувальна плата не володіє вбудованими мікросхемами дешифрування колірної палітри, тому доступно 3 основних кольори (червоний, зелений, синій) і 5 комбінацій (жовтий, пурпурний, блакитний, білий і чорний). Але це не заважає придумати колірну схему і навіть вивести миготливі зображення! (див. відео в кінці)

Контролер ігри
Найпростіший спосіб опису контролера гри «Сапер» будується на базі кінцевого автомата (FSM). Необхідно придумати умови автомата, в яких будуть оброблятися ті чи інші події.

В моєму проекті використовується 5 основних комбінацій автомата:
  1. WAIT_START (обнулення всіх керуючих сигналів, лічильника хв, запуск генератора випадкових ігри;
  2. PLAY (процес гри: керування кнопками з клавіатури, пошук хв);
  3. CHECK (перевірка, якщо знайдена міна — перехід в кінець гри);
  4. GAME_OVER (визначає подія перемоги або поразки, виводить докладні повідомлення на дисплей);
  5. RST (необов'язкова стадія — очищає екран, скидає всі керуючі сигнали, без можливості запуску нової гри).
Пам'ять символів
Знайдена на просторах Інтернету. Розміри одного символу 8х16. Приклад для символу «1»:

"00000000", -- 0
"00000000", -- 1
"00011000", -- 2
"00111000", -- 3
"01111000", -- 4 **
"00011000", -- 5 ***
"00011000", -- 6****
"00011000", -- 7 **
"00011000", -- 8 **
"00011000", -- 9 **
"00011000", -- a **
"01111110", -- b **
"00000000", -- c **
"00000000", -- d******
"00000000", -- e
"00000000", -- f

Всі символи укладаються в одну клітинку блокової пам'яті RAMB16 кристала. Пам'ять влаштована так, що символ складається з 16 векторів розрядністю 8. Для виведення символів на дисплей необхідно 4 молодших розряди шини адреси підключити до вектора координати Y. Логічна '1' — забарвлює символ колір, '0' — колір фону (чорний).

Пам'ять для розстановки хв на поле
Цю частину проекту я модифікував найдовше, винаходячи різні витончені рішення. У результаті вирішив зробити наступний компонент у вигляді ROM-пам'яті, який вибирає гру.

Шматок коду:

constant N8x8 : integer:=8; -- константа поля 8х8
constant Ngames : integer:=1; -- кількість ігор

type round_array_3x64xN is array (Ngames*N8x8*N8x8-1 downto 0) of integer range 0 to 7;

constant mem_init0: round_array_3x64xN:=(
-- game 0:
1,1,1,0,0,0,0,0,
1,7,1,1,1,1,0,0,
1,1,1,1,7,2,1,0,
0,0,0,1,2,7,1,0,
0,1,1,1,1,1,1,0,
0,1,7,2,7,1,1,1,
0,1,1,2,2,2,2,7,
0,0,0,0,1,7,2,1);

Константи N8x8 і Ngames задають розмір поля і кількість ігор. Цифра на поле відповідає міні або кількістю хв навколо неї. Правила дуже прості:
  • Цифри 0-6 — визначають кількість хв,
  • Цифра 7 — зарезервована і визначає міну на полі.
Чому так?
Я не став вигадувати ситуацію, коли навколо точки можуть перебувати відразу 7 або 8 хв 8 хв і поля 8х8 це дуже нецікаві рішення. До того ж числа від 0 до 7 займають всього 3 біти, тоді як комбінації від 0 до 8, і 9 для міни займають вже 4 біта. В цьому плані я великий любитель заощадити внутрішню логіку і трассировочные ресурси кристала, навіть якщо цих ресурсів вистачить на 5 проектів.

Таким чином, всі числа укладаються в своєрідний ROM-масив, який можна дописати своїми іграми. В моєму проекті реалізовано 32 гри, що займає трохи менше 1 блоку пам'яті RAMB16. Слід зазначити, що числа задаються у форматі integer. Для їх переведення у std_logic_vector(2:0) і подальшої обробки написана спеціальна функція. Цілочисельний формат спростив запис нових ігор і значно заощадив час. Багатьох розробників ПЛІС на мові VHDL іноді вводить в ступор ситуація, коли використовується цілочисельний формат, оскільки конструкції з цілочисельним типом не завжди є синтезуючими, тобто їх не можна перевірити в реальному залозі. Але для ROM-генератора integer є оптимальним вибором.

Для того, щоб додати свою розстановку хв — потрібно грамотно заповнити поле 8х8 в масив. Варіації ігор набивав вручну. Всього в моєму проекті 32 різні комбінації розстановки хв.

Блоки растеризації кордонів і поля 8х8
Спочатку я реалізував їх на генераторі символів, але потім вирішив заощадити ресурси кристала, оскільки подумав, що заради зафарбованих квадратиків і рамочки немає сенсу використовувати цілу комірку RAMB16. (Оптимізація ресурсів!) Тому все зроблено на мультиплексорах. Докладно зупинятися на цьому не буду.

Блок для показу хв і цифр
Перетворює дані з пам'яті набору ігор в числа і міни на екрані, використовуючи пам'ять символів. Спочатку хотілося вивести квадратне поле 8х8, але потім мені стало ліньки переписувати ROM-генератор, і я залишив його прямокутним.
Для цього блоку також довелося створити спеціальну маску 8х8, з допомогою якої після натискання «Enter» зафарбовані клітинки перетворювалися б у цифру або міну.

Текст повідомлення
Текст написаний суціль — тобто на екрані все пишеться відразу, але в залежності від стадії гри якась інформація залишається невидимою (наприклад, повідомлення про поразку чи перемогу). Використовується все той же генератор символів. Розмір символу 8х16, тому поле дисплея 640х480 можна розбити на секції 80х30, в яких відображаються символи. Як це робиться?

Нижче представлений простий приклад:
addr_rom <= data_box(6 downto 0) & y_char(3 downto 0) when rising_edge(clk);

x_char_rom: ctrl_8x16_rom -- пам'ять коефіцієнтів 
port map (
clk => clk,
addr => addr_rom,
data => data_rom); 

pr_sel: process(clk, reset) is -- дані для відображення на дисплей
begin
if reset = '0' then
data <= '0';
elsif rising_edge(clk) then
data <= data_rom(to_integer(unsigned(not x_char(2 downto 0))));
end if;
end process; 

g_rgb: for ii in 0 to 2 generate -- фарбування даних колір
begin
rgb(ii) <= data and color(ii);
end generate;

Для початку потрібно придумати, як з допомогою адреси пам'яті можна вибирати той чи інший символ. Видно, що адреса складається з двох векторів «y_char» і «data_box».

y_char(3 downto 0) — це молодші розряди вектора координат по осі Y. Ці дані оновлюються автоматично і приходять з контролера VGA.
data_box(6 downto 0) — сигнал вибирає, який символ буде використовуватися на полі. Цей вектор необхідно писати самому.

Якщо записати data_box <= «000001», то вектор «data_rom» запишеться перший символ з генератора. В процесі «pr_sel» відбувається перетворення вектора даних у послідовний код. В залежності від 3 молодших бітів регістра координати Х вибирається конкретний біт вектора «data_rom». На перших порах я зіткнувся з проблемою дзеркального відображення даних на екрані. Рішення тривіальне — інверсія сигналу x_char.

Вихідні дані — сигнал RGB, який надходить на VGA-роз'єм після логічного перетворення даних із пам'яті коефіцієнтів.

Реалізація в залозі
Все це збирається в один великий проект. Для краси з допомогою простого лічильника прикрутив миготіння повідомлень перемоги/поразки, а також додав генератор для вибору випадкової гри.
До исходниками на VHDL обов'язково прикручується файл *.UCF, в якому описано підключення портів ПЛІС і різні атрибути. Приклад:
## Switches
NET "RESET" LOC = "P148" | IOSTANDARD = LVTTL | PULLUP ; ## SW<0>
NET "ENABLE" LOC = "P142" | IOSTANDARD = LVTTL | PULLUP ; ## SW<1>

## VGA ports
NET "VGA_R" LOC = "P96" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;
NET "VGA_G" LOC = "P97" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;
NET "VGA_B" LOC = "P93" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;
NET "VGA_HSYNC" LOC = "P90" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;
NET "VGA_VSYNC" LOC = "P94" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;

## CLK 50 MHz
NET "CLK" LOC = "P183" | IOSTANDARD = LVCMOS33 ;
NET "CLK" TNM = "CLK_TN";
TIMESPEC TS_CLK = PERIOD "CLK_TN" 20 ns HIGH 50%; 

# PS/2 KEYBOARD
NET "PS2_CLK" LOC = "P99" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;
NET "PS2_DATA" LOC = "P100" | IOSTANDARD = LVTTL | DRIVE = 8 | SLEW = FAST ;


За допомогою САПР Aldec Active-HDL та Xilinx ISE проводиться синтез і трасування проекту ПЛІС. Із-за складності обробки подій, налагодження проводив без написання Testbench, безпосередньо заливаючи прошивку в ПЛІС і перевіряючи висновок на дисплей. Як правило, працювало все відразу. Основні помилки полягали в синхронізації сигналів. Наприклад, одночасні операції замикання адреси і спроби читання даних. Виправляються такі помилки швидко введенням в потрібному місці додаткової затримки на такт. У важких випадках використовувався ChipScope Pro (Core Inserter та Analyzer).

Висновок
Міні-гра «Сапер» успішно запрацювала на налагоджувальної платі.
Розміри поля 8х8, кількість мін на полі — 8.
Кількість ігор — 32. Перед стартом розстановка хв вибирається випадково з пам'яті для поля.
Позичені ресурси кристала (ПЛІС майже порожня):



Фото
Результат виглядає приблизно ось так:



...Трасування в FPGA-Editor e в області контролера гри:



Схематичний вигляд проекту в RTL Schematic:



Налагодження проекту в ChipScope Pro Analyzer (підрахунок кількості відкритих порожніх полів):



Вихідний код на github.

Відео-демонстрація гри

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.