Akcelerator grafiki 2D na FPGA i STM32F429

Miganie diodą FPGA

Oto kod VHDL realizujący przełączanie FPGA:

To projekt synchroniczny, w którym cały proces jest taktowany sygnałem clk pochodzącym z oscylatora 40 MHz. Każdy takt oscylatora zwiększa wskazanie licznika, a po upływie sekundy jest przełączny sygnał led_out.

Układ FPGA nie jest połączony z diodą LED, zatem wyprowadziłem sygnał led_out na wyjście DEBUG i użyłem analizatora logicznego, aby sprawdzić efekt migania. Wszystko działa – zatem FPGA pracuje poprawnie i została prawidłowo przez mikrokontroler. Działają przynajmniej dwa wyprowadzenia i oscylator. Ten prosty kod przetestował duży fragment układu.

Programowanie pamięci Flash

Rzut oka na schemat 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.

41

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ść.

isim

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ą).

43

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.

44

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.

O autorze