Akceleracja grafiki 2D STM32F429, część 3

Artykuł publikujemy dzięki uprzejmości Andy’ego Browna, autora bloga AnydsWorkshop.

Rzut oka na schemat (patrz część 2) pozwala zauważyć, że pamięć Flash jest podłączona jedynie do FPGA, a nie do mikrokontrolera. Zatem aby ją zaprogramować, potrzebny jest odpowiedni projekt FPGA. Prostym rozwiązaniem byłoby zaprogramowanie FPGA w taki sposób, że przesyła ona sygnały przychodzące z mikrokontrolera bezpośrednio do pamięci Flash. Wówczas układ FPGA byłby swego rodzaju buforem, a cała logika byłaby obsługiwana przez mikrokontroler. Drugą możliwością jest jest sterowanie logiką programowania pamięci Flash z FPGA, która akceptuje polecenia i dane przychodzące z mikrokontrolera. To oczywiście bardziej skomplikowane rozwiązanie, ale jestem gotów na podjęcie tego wyzwania.

Powyższy schemat przedstawia czynności potrzebne do zaprogramowania pamięci Flash. Ogólny pomysł polega na tym, że mikrokontroler odczytuje odpowiednio sformatowane grafiki z karty SD i po kolei zapisuje je do układu FPGA, a następnie sprawdza, czy każda z grafik została zapisana poprawnie. Kolejne kroki algorytmu są następujące:

  1. Ponieważ projekt pracuje na dość małej częstotliwości 40 MHz, rejestr konfiguracji pamięci nieulotnej Flash (CR) jest ustawiony na domyślną prędkość. Bit uruchamiający poczwórną prędkość transmisji jest wyłączony.
  2. Wysyłam komendę wyzerowania całej pamięci Flash i czekam, aż FPGA zdejmie sygnał BUSY informując, że operacja została zakończona. FPGA odpytuje rejestr statusu układu Flash.
  3. Po kolei zapisuję każdy z plików na karcie SD za pomocą instrukcji zapisania bloku dla każdej strony o rozmiarze 256 bajtów.
  4. FPGA weryfikuje stan każdego pliku. Ponownie przesyłam dane dla każdego bloku, a FPGA odczytuje ten blok z pamięci Flash i porównuje z przesłanymi danymi. W przypadku rozbieżności wystawia sygnał na wyjściu DEBUG.
  5. Ustawiam bity rejestru konfiguracyjnego tak, aby pamięć Flash pracowała z częstotliwością 100 MHz. Włączam czterobitowy tryb pracy wyjścia przy założeniu, że następnie uruchomiony zostanie właściwy akcelerator grafiki.

Wszystkie kody źródłowe projektów są dostępne na Githubie. Program mikrokontrolera znajduje się tu,a projekt FPGA – tu. Przeprowadziłem symulację projektu przed napisaniem kodu mikrokontrolera, aby upewnić się, że logika jest poprawna. Wszystko dobrze zadziałało za pierwszym razem. Jednym szczegółem, który musiałem poprawić, było próbkowanie asynchronicznej linii WR przez FPGA. Układy FPGA co do zasady nie lubią asynchronicznych sygnałów i należy podjąć dodatkowe kroki, aby uniknąć problemu metastabilności podczas próbkowania asynchronicznych wejść.

Debugowanie pracującego układu FPGA jest niezwykle trudne. Należy zatem przeprowadzić dokładną symulację wszystkich projektów na początku. Mój debugger pracującego układu ogranicza się do pojedynczego wyprowadzenia, które jest przełączane w zależności od pewnego stanu wewnętrznego.

 

Główny projekt

Główny projekt VHDL jest podzielony na komponenty połączone poprzez sygnały wejściowe i wyjściowe. Specjaliści nazywają taki styl projektowania hierarchicznym. Dla każdego, kto jest przyzwyczajony do współczesnych języków programowania, będzie to naturalne rozwiązanie w odróżnieniu od wrzucania całego projektu do jednego pliku (niestety takie przypadki się zdarzają).

Plik main odpowiada za rozmaite deklaracje, takie jak mapowanie portów wejścia-wyjścia na wyprowadzenia obudowy VQ100. Main odpowiada za stworzenie pozostałych komponentów i połączenie ich wyjść oraz wejść w jedną całość. Przyjrzymy się bliżej każdemu komponentowi z osobna:

 

mcu_interface

Układ FPGA jest połączony z mikrokontrolerem 10-bitową szyną danych i asynchronicznym sygnałem strobującym WR. Mikrokontroler zapisuje dane na tej szynie i wysyła impuls w stanie niskim na linii WR, która następnie wraca do stanu wysokiego. FPGA reaguje na narastające zbocze WR, zapisując 10-bitową wartość do wewnętrznej kolejki FIFO mieszczącej 64 rekordy, która jest zrealizowana jako rozproszona pamięć RAM.

W tym samym czasie mcu_interface wczytuje dane z drugiego końca FIFO. Gdy odczyta wystarczającą liczbę parametrów, aby wykonać żądane polecenie, układ poświęci kilka 10-nanosekundowych cykli na wykonanie tej komendy, zanim będzie w stanie odczytać kolejne dane z FIFO.

Na mikrokontrolerze spoczywa odpowiedzialność sprawdzenia, czy nie zapisuje danych do FIFO szybciej, niż FPGA jest w stanie je odczytać. W praktyce jest jednak prawdopodobne, że pomiędzy kolejnymi zapisami mikrokontroler spędzi dużo czasu na obsłudze logiki gry, dając FPGA na odczytanie i wykonanie wszystkich poleceń z FIFO.

Zaimplementowałem komendy, które pozwalają mikrokontrolerowi zapisywać surowe dane do wyświetlacza LCD w trybie transferu, przełączyć FPGA w tryb renderowania bitmap i wywoływać komendy ładowania, przesuwania, pokazywania i ukrywania bitmap.

 

sprite_writer

To duży plik. Jest odpowiedzialny za ładowanie rekordów bitmap z wewnętrznej pamięci blokowej RAM (BRAM), odczytanie grafik z pamięci Flash i zapisanie ich do odpowiedniego miejsca w buforze ramki SRAM.

Zewnętrzna pętla tego kodu iteruje po wszystkich 512 rekordach bitmap i wybiera te, które mają ustawiony bit widoczności. Dla każdej widocznej bitmapy są wywoływane dwie wewnętrzne pętle poruszające się we współrzędnych X oraz Y, aby umieścić piksele bitmapy na odpowiedniej pozycji wyświetlacza.

Wewnątrz pętli X/Y znajduje się zasadniczy kod, który wczytuje dane z pamięci Flash i zapisuje je do SRAM. To krytyczny fragment kodu, ponieważ na kompletne przetworzenie każdego piksela są tylko 4 cykle zegara (40 ns). Pętla pracuje w trybie potokowym – w każdej iteracji nowy piksel jest wczytywany z pamięci Flash, a piksel wczytany w poprzednim cyklu jest zapisywany do pamięci SRAM. Tu obsługiwana jest przezroczystość bitmap – wewnętrzna logika zapewnia przezroczystość piksela.

sprite_writer tworzy instancje kilku wewnętrznych komponentów na własny użytek. Osoby przyzwyczajone do konwencjonalnego programowania mogą być zaskoczone, że takie standardowe operacje, jak dodawanie, odejmowanie czy nawet licznik nie są na FPGA darmowe. Aby dodać dwie liczby, trzeba zaimplementować sumator. Aby pomnożyć dwie liczby, potrzebna jest mnożarka. W czasach maszyn wirtualnych i języków interpretowanych warto przypomnieć sobie, że niewiele zmieniło się na fundamentalnym poziomie, odkąd zacząłem przygodę z komputerami.

Ten fakt nie umknął uwadze producentów FPGA, którzy w wielu modelach implementują gotowe implementacje sumatorów i mnożarek (zwane czasami blokami DSP) rozmieszczone w sieci bramek. Sumatory, które realizuję, korzystają z potokowych implementacji, dzięki czemu nie spowalniają znacząco układów – co miałoby miejsce, gdyby zamiast gotowych bloków xst użył operatora + języka VHDL.

Komponent o nazwie OFDDRSSE jest jednym z bloków rodziny OFDDR, które pozwalają wyprowadzić sygnał zegara na pin IOB. Mogłoby się wydawać, że wystarczy tylko podłączyć wewnętrzny sygnał zegarowy do wyjścia lub też obsługiwać zegar jakimś wewnętrznym układem logicznym i wyprowadzić ten sygnał na IOB. To byłoby naiwne, ponieważ wprowadzałoby znaczne  przesunięcie fazy zegara na wyjściu. Zegary są przez FPGA traktowane szczególnie i zawsze istnieje specjalna metoda realizacji standardowych operacji na zegarze. Blok OFDDR jest poprawnym sposobem wyprowadzenia zegara na pin wyjściowy. Wykorzystałem tą możliwość, aby stworzyć zegar 100 MHz taktujący pamięć Flash z wejściem CE (clock-enable), które pozwala włączyć i wyłączyć ten zegar.

 

frame_counter

We wstępnym opisie projektu zapowiedziałem, że zamierzam użyć parzystych ramek do zapisu danych z pamięci SRAM do LCD, a nieparzystych – do wczytania danych z pamięci Flash do SRAM. Plik frame_counter odpowiada za monitorowanie sygnału LCD TE – po wykryciu narastającego zbocza przełącza bit oznaczający nieparzystą lub parzystą ramkę.

TE jest sygnałem asynchronicznym. Prosty rejestr przesuwający pozwala spróbkować bieżący stan i  zapamiętać dwa poprzednie stany, aby stwierdzić, czy na pewno właśnie pojawiło się zbocze narastające.

 

lcd_sender

lcd_sender to dodatkowy komponent, który wysyła na szynę LCD 16-bitową wartość, obsługując przy tym zatrzask. Zapewnia też odpowiednią pracę sygnału WR strobującego LCD. Jest wywoływany przez mcu_interface, gdy system działa w trybie transferu i muszę przepisać wartość z mikrokontrolera na LCD. Ta operacja zajmuje dokładnie 70 ns. Występuje to sygnał wyjściowy busy i sygnał wyjściowy go, które pozwalają na synchronizację z wyświetlaczem.

 

sprite_memory

sprite_memory to instancja komponentu IP BRAM dostarczonego przez Xilinx. Blokowa pamięć RAM w tym układzie FPGA jest prawdziwą dwuportową pamięcią o konfigurowalnej szerokości szyn danych i adresu. Używam jej do przechowywania informacji o bitmapach.

 

Tak wygląda definicja rekordu bitmapy:

Ponieważ ten rekord ma rozmiar 127 bitów, konfiguruję pamięć BRAM tak, aby linia danych miała długość 127 bitów. Adres musi być oczywiście potęgą 2, co oznacza, że mogę zmieścić w pamięci tego układu 512 rekordów bitmap.

 

frame_writer

Plik frame_writer to komponent odpowiedzialny za całą pracę wykonywaną podczas parzystych ramek, gdy FPGA jest w trybie obsługi bitmap. Ten komponent odczytuje wyrenderowaną ramkę z pamięci SRAM i zapisuje ją do wyświetlacza LCD. Pracuje w trybie potokowym, odczytując pojedynczy piksel z pamięci SRAM i jednocześnie zapisując poprzedni odczytany piksel do LCD w pętli, której ciało zajmuje 70 ns. Na ekranie mieści się 640 x 360 = 230.400 pikseli, co oznacza, że cała operacja trwa 16,128 ms. LCD wczytuje dane z wewnętrznej pamięci GRAM i wyświetla je na fizycznym ekranie raz na każde 16,2 ms, zatem rozwiązanie dokładnie mieści się w wymaganym czasie.

frame_writer narzuca pewne wymagania, które mikrokontroler musi spełnić, zanim system przejdzie w tryb obsługi bitmap. Okno wyświetlacza musi być ustawione na pełny ekran. Tryb zapisu należy ustawić na auto-reset, aby uruchomić wyświetlacz, a ostatnią komendą wysłana do LCD musi być write data. Po tym przygotowaniach dokonanych przez mikrokontroler FPGA może obsługiwać ciągły strumień danych o grafice. Zajmuje się tym klasa AseAccesMode.

Decyzja o stworzeniu trybu transferu i trybu obsługi bitmap oznacza, że istnieją potencjalnie dwie różne części projektu, które chcą zapisywać dane na szynie LCD. mcu_interface będzie zapisywał dane za pomocą komponentu lcd_sender w trybie transferu, a frame_writer będzie zapisywał dane w trybie bitmapowym.

Nie ma sensu tworzyć wielu sterowników podłączonych do tego samego sygnału. W wypadku takiej próby narzędzie syntezy zgłosi błąd. Rozwiązaniem jest realizacja procesu arbitrażu, który zbada zmienną stanu i zgodnie z jej wartością przełączy wyjście.

 

Jaki widać, realizacja arbitrażu jest bardzo prostym zadaniem.

 

reset_conditioner

Sygnały resetu, podobnie jak zegary, są traktowane specjalnie przez projektantów FPGA. Każdy ma własną opinię na temat tego, jak najlepiej zaimplementować reset. Ostatnie trendy, ku którym się skłaniam, polegają na realizacji resetu jak sygnału synchronicznego. Ponadto powinien on być podłączony tylko do tych komponentów, w których jest faktycznie potrzebny. Nie ma sensu marnować zasobów płytki i niepotrzebnie zwiększać obciążenia sygnału, doprowadzając go do komponentów, których nie trzeba resetować.

Reset jest drastyczną operacją, której wykonania nie można dopuścić przypadkiem. Komponent reset_conditioner implementuje nieco dłuższy i bardziej restrykcyjny rejestr przesuwający, aby zapewnić, że asynchroniczny sygnał z mikrokontrolera został poprawnie potwierdzony. Dopiero wówczas komponent wysyła synchroniczny sygnał wyjściowy rozprowadzony do wszystkich komponentów, które muszą podjąć jakąś akcję w przypadku resetu.

 

clock_generator

Wcześniejsze układy FPGA Xilinx zawsze miały wbudowana pętlę PLL, która pozwała pomnożyć częstotliwość zegara wejściowego, aby uzyskać odpowiednią częstotliwość do taktowania synchronicznych elementów projektu. Firma Xilinx znacznie udoskonaliła tą funkcjonalność i teraz zapewnia komponenty o nazwie Digital Clock Manager (DCM). DCM są wielofunkcyjnymi układami kondycjonowania i syntezy zegara. Pozwalają realizować dowolne operacje regulacji fazy, dublowania zegara, a także mnożenia i dzielenia częstotliwości – wszystko to, gwarantując niskie opóźnienie na synchronicznym wyjściu.

Powyższy schemat został zaczerpnięty z karty katalogowej Xilinx – przedstawia strukturę układu DCM. Mój projekt pracuje z wewnętrzną częstotliwością 100 MHz – wykorzystuję zatem funkcję CLKFX, aby pomnożyć i podzielić zegar wejściowy 40 MHz, by otrzymać sygnał 100 MHz na wyjściu.

Nie jest wcale oczywiste, że muszę wykorzystać wyjście CLKFX180, aby uzyskać sygnał o częstotliwości 100 MHz przesunięty o 180° w fazie. Ten sygnał jest wymagany przez wejście komponentu OFDDRSSE, który rekonstruuje zegar 100 MHz, aby wyprowadzić go na wyjście dla pamięci Flash. Zgaduję, że jest on potrzebny ze względu na stosowane wewnętrzne układy logiczne wyzwalane wyłącznie zboczem narastającym.

 

Wykorzystanie zasobów FPGA

Każdy projekt FPGA musi zmieścić się w ograniczeniach na powierzchnię i szybkość pracy układu.  Powierzchnia to maksymalna liczba bramek dostępnych w FPGA, w których muszą zmieścić się wszystkie układy logiczne. W przypadku problemów możliwe są dalsze sztuczki i optymalizacja, ale jeśli nie okażą się one skuteczne, może zajść potrzeba kupna większego układu – a to bywa kosztowne. Poniżej widać uzyskane przeze mnie wykorzystanie układu:

Lubię dostawać to, za co płacę, więc wykorzystanie 103% komórek logicznych przyjąłem za dobrą monetę. Ale czy to nie oznacza przekroczenia dostępnych zasobów? Tak, ale to tylko wstępne oszacowanie narzędzia syntezy xst. Ważne są wyniki narzędzia map, które faktycznie umieszcza skompilowany projekt w fizycznym układzie i próbuje go optymalizować. Użyłem map z flagą oznaczającą maksymalną optymalizację zasobów, co dało następujące rezultaty:

Teraz sytuacja wygląda znacznie lepiej i daje prawdziwy wgląd w rzeczywisty stopień wykorzystania zasobów układu.

Spełnienie wymagań czasowych oznacza, że opóźnienie najwolniejszej ścieżki sygnału musi być krótsze, niż okres między dwoma zboczami zegara. Opóźnienie sygnału wynika z czasów, których potrzebują układy kombinacyjne, a także opóźnień ścieżek wynikających ze skończonej szybkości przepływu prądu w układzie. Próba spełniania wymagań czasowych może być błądzeniem po omacku – czasem z pozoru nieistotne modyfikacje zmieniają końcowy wynik o całe megaherce. Jeśli jednak wymagania czasowe zostaną spełnione, dalsza praca nie ma sensu – nie spowoduje to już żadnej zmiany w działaniu projektu.

Narzędzia Xilinx raportują najgorszy przypadek opóźnienia w zakładce „post-place and route static timing results”. Wymagana częstotliwość pracy mojego projektu to 100 MHz, a wyniki są następujące:

 

Jest to wystarczający margines. Jak wspomniałem wyżej, nie ma sensu poprawiać tego wyniku – projekt będzie pracował dokładnie z taką samą szybkością.

 

Aplikacje testowe

Pierwsza aplikacja testowa ma na celu sprawdzić, czy poprawnie działa obsługa wyświetlacza LCD w trybie transferu. Aby to sprawdzić, wykorzystam bibliotekę graficzną stm32plus, by wyświetlić pewne testowe kolory. Podsystem grafiki stm32plus jest oparty o hierarchiczną strukturę, która rozdziela odpowiedzialność za algorytmy rysowania wysokiego poziomu od obsługi sterownika LCD. Ten z kolei jest oddzielony od metod zapewniających dostęp do sterownika.

Do tej pory używałem trybów dostępu, które polegają albo na wykorzystanie układu peryferyjnego STM32 FSMC, albo użyciu pinów GPIO do sterowania wyświetlaczem LCD. Aby moja własne płytka działała z całą zawartością biblioteki stm32plus, musiałem jedynie napisać klasę trybu dostępu, która realizuje zadanie wysyłania danych na 10-bitową szynę. Nazwałem ją AseAccessMode, gdzie „Ase” oznacza „Andy’s Sprite Engine”.

 

Przewidywalne zależności czasowe są bardzo istotne, aby zapewnić poprawną pracę trybu transferu. Ważny jest czas włączenia, a szczególnie czas trwania sygnału WR. Układ FPGA wymaga 4 cykli zegara lub 40 ns pomiędzy narastającymi zboczami sygnału WR, aby być gotowym  na odbiór kolejnego narastającego zbocza. Następujący kod assemblera pozwala klasie AseAccessMode na przesłanie polecenia do FPGA:

Instrukcja dsb (Data Synhronization Barrier) jest bardzo ważna, aby zagwarantować przewidywalny czas wykonania. Bez niej zaawansowany rdzeń mikrokontrolera F4 dokonałby optymalizacji potoku instrukcji i doprowadził do rezultatu, który nie spełnia ostrych wymagań czasu wykonania instrukcji podanych w podręczniku użytkownika ARM. Zaprojektowałem tryb transferu tak, aby wymagał jedynie dwóch taktów do przesłania 16-bitowych danych lub treści polecenia do wyświetlacza LCD, albo też mógł w tym czasie przejść w tryb obsługi bitmap.

Pierwszy transfer polega na przesłaniu pierwszych 8 bitów 16-bitowej wartości dla wyświetlacza LCD. Jeśli ustawiony jest najstarszy bit portu E, system przejdzie natychmiast w tryb obsługi bitmap i drugi transfer nie nastąpi.

Drugi transfer polega na przesłaniu starszych 8 bitów 16-bitowej wartości. Jeśli ustawiony jest najstarszy bit, wartość ta oznacza wybór linii (register select, RS) wyświetlacza LCD.

Kod źródłowy testu transferu jest dostępny na Githubie. Byłem bardzo zadowolony, gdy ten test się powiódł – po raz pierwszy zobaczyłem działający ekran LCD, który wyświetlał dane pod kontrolą FPGA, nawet jeśli całością zarządzał mikrokontroler w trybie transferu.

 

Manic Knights

Na samym początku artykułu obiecałem demo gry i zamierzam dotrzymać tej obietnicy. Stworzę demo, wykorzystując wysokiej jakości grafiki, które przedstawiają przykładową platformówkę, jaką można zrealizować na tym systemie. Gra będzie wykorzystywać animowane bitmapy, które poruszają się po nieliniowych trajektoriach wykorzystujących funkcje wygładzania. Funkcje te obciążają jednostkę zmiennoprzecinkową (FPU) układu F4, aby płynnie zmieniać szybkość animacji. Gra pozwoli również na przewijanie okna widoku we wszystkich 4 kierunkach, co pozwala graczowi na poruszanie się w świecie znacznie większym od ekranu.

 

Kafelkowa mapa

Świat gry stanowi matryca kafelków o wymiarach 20 x 30. Każdy kafelek jest kwadratem o boku 64 pikseli. Wykorzystałem darmowy edytor Tiled, aby stworzyć mapę z użyciem zestawu grafik kupionych w serwisie cartoonsmart.com. Dostępne są też darmowe grafiki, ale ich jakość nie jest powalająca. Uznałem, że lepiej będzie wydać kilka dolarów na grafiki komercyjnej jakości.

Edytor map Tiled pozwala szybko zbudować świat gry i zapisać go w pliku XML, który następnie można sparsować i przedstawić w dowolnym wymaganym formacie. Głównym problemem jest to, że edytor działa w trybie poziomym (landscape), natomiast silnik gry pracuje w trybie pionowym (portrait). Musi tak być, aby zachować synchronizację z odświeżaniem wyświetlacza, które zawsze przebiega pionowo, niezależnie od logicznej orientacji wyświetlacza.

Aby rozwiązać ten problem, napisałem mały program C#, który eksportuje kafelki do formatu PNG i obraca je przy tym o 90° w kierunku przeciwnym ruchu do wskazówek zegara. To, wraz z kodem Perl łączącym całość, pozwala edytorowi Tiled na tworzenie plików wyjściowych, które można łatwo załadować do pamięci Flash.

Powyższy obrazek przedstawia kompletny świat gry, obrócony z powrotem do formatu poziomego na potrzeby artykułu. Ten świat będzie stanowił tło gry. W implementacji gry rezerwuję na początku tablicy bitmap zapas slotów, które są przeznaczone na tło. Gdy gracz porusza się w świecie gry, te zarezerwowane sloty są aktualizowane, dzięki czemu cały czas znajdują się na odpowiedniej siatce tła w danej pozycji. Ponieważ bitmapy są umieszczone na początku tablicy, zawsze będą wyświetlane za innymi bitmapami, które będą rysowane w dalszej kolejności. Konkretnie będą to…

 

Przeciwnicy

Każda gra wymaga jakichś przeciwników, których nasz bohater musi unikać, poruszając się po świecie. Jak w tradycyjnych platformówkach dodałem przeciwników, którzy chodzą w przód i w tył po platformach. Idea jest taka, że bohater ma ich unikać, przeskakując ich w odpowiednich momentach.

Obrazek przedstawia pierwszych 6 spośród 12 klatek animacji jednego z przeciwników. Różowe tło (255, 0, 255) jest zakodowane w FPGA jako przezroczysty kolor – jakikolwiek piksel narysowany wcześniej w tym punkcie pozostanie widoczny.

W grze wykorzystałem klasę Actor, aby zrealizować przemieszczenie postaci wzdłuż ciągu ścieżek. Ruch postaci jest wygładzany za pomocą funkcji wygładzającej. Mam do wyboru funkcje, które przyspieszają i hamują ruch z różną prędkością, a także wywołują ruch oscylacyjny.

Te funkcje matematyczne są umieszczone w przestrzeni nazw stm32plus fx. Aby zapewnić ich działanie w rozsądnym czasie (16,2 ms odstępu między ramkami), potrzebna jest jednostka zmiennoprzecinkowa (FPU) wbudowana w mikrokontroler F4. Mnożenie i dodawanie liczb zmiennoprzecinkowych przechowywanych w wewnętrznych rejestrach zabiera jeden cykl zegara, podobnie jak zamiana z liczby zmiennoprzecinkowej na całkowitą i na odwrót. Oczywiście animacje nie są przeznaczone tylko dla przeciwników. Każda prawdziwa platformówka zawiera mnóstwo obiektów, takich jak windy i statyczne, lecz animowane dekoracje, na przykład oświetlenie. Zaimplementowałem kilka z nich, aby pokazać, jak można to zrobić.

W mojej implementacji demo animuję cały świat, ale nie dodałem bohatera, którym można byłoby sterować. Stworzenie logiki gry, która obejmowałaby podstawową fizykę i wykrywanie kolizji zajęłoby więcej czasu, niż mam do dyspozycji. Zapewniłem jedynie możliwość przewijania świata za pomocą poleceń góra / dół / lewo / prawo przy użyciu podłączonych przycisków lub joysticka.

Logika tego dema obejmuje aktualizację pozycji w świecie, animację bitmap i przesłanie tych danych do FPGA. To wszystko musi się zmieścić w maksymalnym limicie 32 ms, z czego zmienną część stanowi bezpieczne okno na wgranie nowych bitmap do FPGA. W trybie debug cała logika dema (bez optymalizacji) wykonuje się w ciągu 1 ms. To bardzo krótko i pozostawia dużo czasu na dodanie dodatkowej logiki obsługującej głównego bohatera gry. Wykorzystanie zasobów mikrokontrolera widać poniżej (opcja optymalizacji -Os).

 

text    data     bss     dec     hex filename

86500    2128    1116   89744   15e90 manic_knights.elf

 

Czas na wideo

Wgrałem krótki filmik demonstrujący działanie gry. Najlepiej jest obejrzeć go w wysokiej jakości na Youtube.

 

Integralność sygnałowa

Integralność sygnałowa zawsze stanowiła problem w przypadku dwuwarstwowej płytki, która zawiera nie tylko złożony i wymagający układ FPGA, ale też zaawansowany mikrokontroler Cortex-M4. Zbadałem niektóre sygnały pod oscyloskopem, aby zobaczyć, jak bardzo są w rzeczywistości zniekształcone. Poniżej widać sygnał na wyjściu oscylatora 40 MHz po zaprogramowaniu FPGA i uruchomieniu gry:

Nie jest to zły sygnał jak na oscylator. Widać pewne tętnienia na dole i na górze, ale są one zbyt słabe, aby powodować problemy – nie zauważyłem żadnych zaburzeń.

Teraz przyjrzyjmy się sygnałowi WR płynącemu od mikrokontrolera do FPGA. Mój oscyloskop jest wystarczająco szybki, aby odtworzyć szczegóły tego sygnału:

Tutaj widać nieco inną sytuację. W dolnej części sygnału jest przestrzał, który przekracza poziom masy. Widać również dzwonienie za narastającym zboczem. Nie są to jednak tak poważne problemy, aby spowodować błędne wykrycie zbocza.

Projekt pracował całkowicie poprawnie podczas okresu, w którym go uruchamiałem. Sądzę jednak, że to zasługa dużego marginesu bezpieczeństwa między poziomami logicznymi w standardzie LVCMOS 3,3 V. Gdyby projekt miał pracować przy coraz popularniejszych napięciach 2,5 lub 1,8 V, ten margines bezpieczeństwa spadłby do wartości, która już nie chroni przed wystąpieniem zaburzeń.

Bardzo przydatną funkcją dostępną w nowszych układach FPGA firmy Xilinx jest impedancja kontrolowana cyfrowo (DCI). Pozwala ona automatycznie załączyć rezystancję terminującą dopasowaną do podłączonej ścieżki. Z pewnością skorzystałbym z tej funkcji, gdyby była dostępna w używanym przeze mnie modelu.

 

Wnioski

W takim dużym projekcie, jak ten, zawsze pozostaje miejsce na poprawki. Mimo to podczas testów płytka okazała się działać niezawodnie przy zasilaniu z zewnętrznego źródła napięcia od 4,6 do 5 V. Poniżej przedstawiam pomysły na udoskonalenie całego systemu.

  • Odprowadzanie ciepła wokół zasilacza AMS1117 w obudowie SOT-223 zasilanego napięciem 3,3 V nie jest wystarczające. Powinienem zwiększyć rozmiar pola, do którego jest przylutowana powierzchnia odprowadzająca ciepło, a także zdublować to pole po drugiej stronie płytki, łącząc oba siecią przelotek.
  • Brakuje obsługi audio. Było to celowe założenie w pierwszej części projektu. Skoro wiem już, że projekt działa, mógłbym dodać kilka przetworników cyfrowo-analogowych i wzmacniacz słuchawkowy, aby zapewnić obsługę audio.
  • Integralność sygnałowa. To zawsze jest problem w przypadku płytki dwuwarstwowej. Z pewnością można by wprowadzić pewne zmiany w projekcie płytki, aby zoptymalizować ścieżki prądu powrotnego i poprawić integralność sygnałową. Planuję publikację oddzielnego artykułu, w którym przedstawię swoje odkrycia dotyczące kształtu i jakości sygnałów w różnych miejscach płytki.
  • Mikrokontroler STM32F429 okazał się zbyt mocny (zgodnie z przewidywaniami), a stanowi najdroższy element na płytce. Podejrzewam, że dobrym wyborem byłby model F401 taktowany zegarem 84 MHz, który zachowuje ważny układ peryferyjny SDIO i jednostkę zmiennoprzecinkową. Pracuje wystarczająco szybko, aby obsłużyć logikę gry i jednocześnie jest o połowę tańszy od F429.

 

Słowo końcowe

Aby zrealizować kompletny, sensownie działający projekt, poświęciłem miesiące, pracując wieczorami i w weekendy. Jednak projekt okazał się sukcesem i uważam, że był wart tego wysiłku.

Kody źródłowe dla mikrokontrolera/FPGA są dostępne na Githubie. Natomiast na mojej stronie downloads można pobrać też pliki gerbera dla samej płytki PCB.

Andy Brown