Kurs FPGA Lattice (28). Sterownik VGA kompatybilny z OLED

Kurs FPGA Lattice (28). Sterownik VGA kompatybilny z OLED

W tym odcinku przygotujemy kontroler obrazu, którego sterowanie przebiega w taki sam sposób, jak w przypadku sterowników popularnych wyświetlaczy OLED, ale obraz wyświetlany będzie na monitorze z interfejsem VGA.

Jak działa sterownik wyświetlacza OLED?

W pierwszej części tego artykułu zapoznamy się z obsługą sterowników wyświetlaczy OLED, takich jak SH1106, SSD1306, SSD1309, itp. Wszystkie z nich działają bardzo podobnie. Jeżeli masz już doświadczenie z kontrolerami tego typu, to możesz pominąć ten fragment i przejść od razu do kolejnego rozdziału.

Weźmy na warsztat wyświetlacz o rozdzielczości 128×64 pikseli. Jest to bardzo typowa rozdzielczość w przypadku wyświetlaczy OLED, choć oczywiście zdarzają się także modele większe, jak i mniejsze. Spójrz na rysunek 1. Punkt zerowy układu współrzędnych znajduje się w lewym górnym rogu. Oś X jest skierowana w prawo, a oś Y w dół.

Rysunek 1. Układ współrzędnych wyświetlacza OLED

Wyświetlacz jest podzielony na osiem poziomych pasów, zwanych stronami (ang. page), a każda strona ma wysokość 8 pikseli. Jest to spowodowane faktem, że interfejsy (SPI, I²C, 8080), przez które sterownik otrzymuje dane do wyświetlenia, są 8-bitowe, a wyświetlacz jest monochromatyczny. Przesyłając jeden bajt danych do wyświetlacza jesteśmy zatem w stanie ustawić kolumnę ośmiu pikseli jednocześnie. Zobacz niebieską kolumnę widoczną na rysunku 1, która symbolicznie przedstawia jeden bajt danych do wyświetlenia, odpowiedzialnych za osiem pikseli. Stan wysoki bitu oznacza zaświecenie piksela, a stan niski powoduje, że piksel staje się niewidoczny.

Sterowniki wyświetlaczy, oprócz przesyłania danych, obsługują sporo różnych komend, które konfigurują rozmaite parametry, takie jak obsługiwana rozdzielczość, rotacja, kontrast, napięcie przetwornicy, częstotliwość odświeżania, tryby uśpienia itp.

Przed pierwszym transferem danych graficznych wymagane jest przesłanie konfiguracji do sterownika, aby mógł on poprawnie obsługiwać matrycę OLED. Konfiguracja jest zapisywana w pamięci RAM, zatem po każdym włączeniu zasilania wymagane jest ponowne przesłanie konfiguracji.

Dwoma najczęściej używanymi poleceniami jest ustawienie pozycji kursora w pionie i poziomie, przy czym ustawienie w pionie polega na wybraniu numeru aktywnej strony. Po ustawieniu kursora na wybraną pozycję, zwykle następuje przesyłanie danych grafiki do wyświetlenia. Po przesłaniu bajtu obrazu jest on natychmiast pokazywany na wyświetlaczu, a kursor przesuwa się o jedną pozycję w prawo, po czym można od razu przesyłać kolejne bajty do wyświetlenia. W ten sposób możemy zdecydować, czy chcemy przesłać całą klatkę obrazu w jednej transmisji, czy też wolimy zaktualizować tylko wybrany fragment obrazu.

Oprócz pinów typowych dla interfejsu I²C czy SPI, sterowniki te mają także linię DC (data/command). Jej zadaniem jest informowanie sterownika o tym, czy bajty przesyłane z procesora są poleceniem do wykonania, czy fragmentem obrazu do pokazania na matrycy. Stan wysoki tego pinu oznacza, że przesyłane są dane, a stan niski odpowiada za transfer polecenia.

Wyświetlacz o rozdzielczości 128×64 px ma 8192 piksele. Skoro w jednym bajcie kodujemy osiem pikseli, to jedna klatka obrazu zajmuje 1024 bajty.

Założenia projektu

W tym odcinku kursu użyjemy FPGA MachXO2 oraz mikrokontrolera ESP32, które dostępne są na płytce User Interface Board, zaprezentowanej w EP 09/2023. Program na mikrokontrolerze, napisany w MicroPythonie, będzie generować obraz do bufora, znajdującego się w pamięci RAM mikrokontrolera ESP32. Następnie obraz ma zostać przesłany z RAM (w ESP32) przez interfejs SPI do FPGA, gdzie zapisany zostanie do dwuportowej pamięci RAM, stanowiącej drugi bufor obrazu. W FPGA potrzebny będzie także sterownik VGA, odczytujący odpowiednie piksele z dwuportowej pamięci RAM i generujący na ich podstawie sygnały na wyjściu VGA.

W ten sposób opracujemy prosty silnik graficzny z podwójnym buforowaniem obrazu i jednocześnie przećwiczymy w praktyce obsługę modułów, jakie stworzyliśmy w poprzednich odcinkach kursu – w szczególności SPI i VGA.

Sterowniki wyświetlaczy OLED najczęściej są monochromatyczne. Moglibyśmy na monitorze VGA wyświetlać białe piksele na czarnym tle, ale żeby było trochę ciekawiej, będziemy pokazywać piksele w kolorze żółtym na tle niebieskim.

Moduł VGA, jaki opracowaliśmy w poprzednim odcinku kursu, obsługiwał wyświetlanie obrazu o rozdzielczości 640×480 pikseli. Gdybyśmy chcieli wyświetlać obraz monochromatyczny o takiej rozdzielczości, to potrzebowalibyśmy 38400 bajtów pamięci RAM (640×480/8=38400), co dalece przekracza zasoby nawet największego FPGA z rodziny MachXO2. Musielibyśmy sięgnąć po zewnętrzną pamięć RAM lub zastosować jakąś cwaną sztuczkę.

Spróbujmy przeskalować obraz. Gdybyśmy 640 podzielili przez 5 to dostaniemy 128. Bingo! Dokładnie tyle, ile wynosi rozdzielczość pozioma typowego wyświetlacza OLED. Natomiast jeżeli rozdzielczość pionową monitora VGA, tzn 480, podzielimy przez 5 to otrzymamy 96. Jest to trochę więcej, niż spotyka się w wyświetlaczach OLED, ale lepiej mieć za dużo niż za mało. Rozdzielczość pionowa 96 pikseli oznacza, że będziemy mieć do dyspozycji 12 stron o wysokości 8 pikseli. Zatem z punktu widzenia sterownika będziemy mieć obraz o rozdzielczości 128×96 px, ale przez monitor VGA będzie on widziany jako 640×480 i składać będzie się zawsze z bloków o rozmiarach 5×5 pikseli. Zastanówmy się też, ile pamięci potrzebujemy: pomnóżmy 128 przez 96 i podzielmy przez 8. Daje to 1536 bajtów. Bez problemu pomieścimy tyle wewnątrz FPGA.

Zastanówmy się jeszcze nad pinem DC. Moglibyśmy pobawić się w implementację różnych poleceń, jak np. ustawianie kursora, lecz stwierdziłem, że na potrzeby kursu nie ma to większego sensu. W związku z tym uprościmy komunikację tylko do przesyłania danych obrazu, a wszystkie polecenia będą ignorowane. Nasz sterownik nie będzie miał żadnych możliwości zmiany konfiguracji i po włączeniu zasilania będzie od razu gotowy do pracy.

Ponadto zrobimy jeszcze jedno uproszczenie. Rozpoczęcie transmisji na magistrali SPI, tzn. zbocze opadające na linii CS, będzie powodować postawienie kursora w lewym górnym rogu wyświetlacza, czyli w punkcie zerowym układu współrzędnych. Następnie trzeba będzie przesłać cały bufor obrazu w jednej transmisji, tzn. paczkę o rozmiarze 1536 bajtów danych.

Moduł top

Zacznijmy od przeanalizowania modułu top metodą od ogółu do szczegółu. Na rysunku 2 pokazano schemat tego modułu, automatycznie wygenerowany przez narzędzie Netlist Analyzer w Lattice Diamond.

Rysunek 2. Schemat modułu top

Po lewej stronie schematu widzimy instancję modułu SlaveSPI, który służy tylko do odbierania danych z mikrokontrolera (jego wyjście MISO_o pozostaje niepołączone do niczego). Moduł ten zawiera synchronizator sygnałów CS_i, SCK_i oraz MOSI_i.

Pod nim znajduje się instancja synchronizatora, który służy do obsługi sygnału DC_i. Jeżeli nie wiesz, dlaczego sygnały wejściowe należy zsynchronizować z domeną zegarową FPGA, to koniecznie przeczytaj odcinek 11 na temat statycznej analizy czasowej, który opublikowałem w EP 09/2023.

W środkowej części schematu mamy coś, co potocznie nazywa się glue logic, czyli warstwę pośredniczącą pomiędzy dwoma modułami, ale na tyle nieszablonową, że nie ma sensu robić z niej osobnego modułu. Te elementy logiczne sklejają ze sobą interfejs SPI i synchronizator sygnału DC z instancją dwuportowej pamięci RAM, w której zapisywane są bajty danych odebrane z SPI.

Zwróć uwagę na 11-bitowy rejestr WriteAddress, który wyznacza adres pamięci, pod który w kolejnym takcie zegara ma być zapisany bajt danych, doprowadzony na wejście Data_i dwuportowej pamięci RAM.

A co z odczytywaniem z pamięci? To, jaki adres ma być odczytany i podany na wyjście Data_o pamięci, wyznacza moduł VGA. Ma on wyjście RequestedAddress_o, które doprowadzone zostało do wejścia ReadAddress_i.

Takie rozwiązanie bardzo upraszcza całą konstrukcję. Sposób działania interfejsu VGA wymusza bardzo precyzyjny timing, zatem odczytywanie bajtów do wyświetlenia musi odbywać się w bardzo rygorystycznym reżimie czasowym. Nie można się spóźnić nawet o jeden takt zegara, bo spowodowałoby to błędne wyświetlenie piksela lub przesunięcie obrazu. Zastosowanie pamięci dwuportowej sprawia, że nie musimy zajmować się kontrolą dostępu do pamięci. Moduł VGA odczytuje bajty „wtedy, kiedy chce”, a moduł SPI zapisuje bajty wtedy, gdy zostaną one odebrane z procesora.

Mając ogólne pojęcie, jak to wszystko ma działać, przeanalizujmy kod zaprezentowany na listingu 1.

// Plik top.v

`default_nettype none
module top(
input wire Clock, // Pin 20, musi być 25 MHz lub 25.175 MHz
input wire Reset, // Pin 17

input wire CS_i, // Pin 27
input wire SCK_i, // Pin 31
input wire MOSI_i, // Pin 49
input wire DC_i, // Pin 48

output wire Red_o, // Pin 78
output wire Green_o, // Pin 10
output wire Blue_o, // Pin 9
output wire HSync_o, // Pin 1
output wire VSync_o // Pin 8
);

// Odbiornik SPI
wire TransmissionStart; // 1
wire TransactionDone; // 2
wire [7:0] DataFromSpiToRam; // 3
wire [7:0] DataFromRamToVga; // 4

SlaveSPI SlaveSPI_inst( // 5
.Clock(Clock),
.Reset(Reset),
.CS_i(CS_i),
.SCK_i(SCK_i),
.MOSI_i(MOSI_i),
.MISO_o(), // 6
.DataToSend_i(8’d0), // 7
.DataReceived_o(DataFromSpiToRam), // 8
.TransactionDone_o(TransactionDone), // 9
.TransmissionStart_o(TransmissionStart), // 10
.TransmissionEnd_o()
);

// Synchronizator wejścia DC
wire DC; // 11

Synchronizer SynchronizerDC( // 12
.Clock(Clock),
.Reset(Reset),
.Async_i(DC_i), // 13
.Sync_o(DC) // 14
);

// Pamięć obrazu
reg [10:0] WriteAddress; // 15
wire [10:0] ReadAddress; // 16

PseudoDualPortRAM #( // 17
.ADDRESS_WIDTH(11),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1536) // 18
) BitmapRAM(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(1’b1),
.WriteEnable_i(TransactionDone && DC), // 19
.ReadAddress_i(ReadAddress), // 20
.WriteAddress_i(WriteAddress), // 21
.Data_i(DataFromSpiToRam), // 22
.Data_o(DataFromRamToVga) // 23
);

// Maszyna stanów do przenoszenia danych
// z interfejsu SPI do pamięci Dual Port RAM
always @(posedge Clock, negedge Reset) begin // 24
if(!Reset)
WriteAddress <= 0;
else if(TransmissionStart) // 25
WriteAddress <= 0;
else if(TransactionDone && DC) // 26
WriteAddress <= WriteAddress + 1’b1;
end

// Instancja sterownika VGA
VGA VGA_inst( // 27
.Clock(Clock),
.Reset(Reset),
.RequestedAddress_o(ReadAddress), // 28
.DataFromRAM_i(DataFromRamToVga), // 29
.Red_o(Red_o),
.Green_o(Green_o),
.Blue_o(Blue_o),
.HSync_o(HSync_o),
.VSync_o(VSync_o)
);

endmodule

Listing 1. Kod pliku top.v

W linii 5 tworzymy instancję modułu SlaveSPI, który opracowaliśmy w 26 odcinku kursu, opublikowanym w EP 12/2024. Na potrzeby tego modułu, parę linii wcześniej musimy zadeklarować kilka zmiennych typu wire.

W linii 1 tworzymy sygnał TransmissionStart, który będzie ustawiany w stan wysoki na jeden takt zegarowy po tym, jak zostanie wykryte rozpoczęcie transmisji, czyli zbocze opadające na wejściu CS_i. Sygnał ten sterowany będzie z wyjścia TransmissionStart_o (linia 10) i w dalszej części kodu będzie wykorzystywany do ustawiania kursora na punkt zerowy układu współrzędnych.

W linii 2 mamy sygnał TransactionDone. Jest on ustawiany w stan wysoki na jeden cykl zegara po odebraniu jednego bajtu przez interfejs SPI. Sygnał ten jest podłączony do wyjścia o takiej samej nazwie w linii 9 i jest używany dalej w celu zapisywania odebranego bajtu w pamięci i inkrementacji licznika adresu.

W liniach 3 i 4 tworzymy dwie 8-bitowe zmienne DataFromSpiToRam oraz DataFromRamToVga typu wire. Zadaniem tej pierwszej jest łączenie wyjścia modułu SPI (linia 8) z wejściem pamięci RAM, a drugiej – dostarczanie danych z RAM do VGA.

Wystarczy nam jednokierunkowa transmisja SPI z mikrokontrolera do FPGA. Zatem wyjście MISO_o (linia 6) pozostaje niepodłączone do niczego. Wejście DataToSend_i informuje moduł o tym, jaki bajt ma zostać wysłany, lecz w przypadku transmisji jednokierunkowej jest to nieistotne i możemy wpisać cokolwiek (linia 7). Cała logika modułu SlaveSPI odpowiedzialna za wysyłanie danych zostanie automatycznie usunięta podczas syntezy, ponieważ jest nieużywana.

Następny moduł, jakiego instancję utworzymy, to synchronizator sygnału DC (linia 12). Do jego wejścia asynchronicznego Async_i (linia 13) doprowadzamy sygnał DC_i prosto z wejścia modułu top. Wyjście (linia 14) jest wyprowadzone przy pomocy zmiennej DC typu wire, która została utworzona w linii 11.

W dalszej części zajmiemy się pamięcią obrazu. Mamy dwa adresy – jeden do odczytu ReadAddress (linia 16), a drugi do zapisu WriteAddress (linia 15). Zwróć uwagę, że adres do zapisu jest typu reg, a do odczytu – typu wire. Dlaczego tak? Adres danych do odczytu jest ustalany przez moduł VGA i w module top wystarczy go jedynie przetransportować z VGA do pamięci, zatem potrzebujemy zmiennej typu wire (czyli de facto „kabelków”, którymi łączymy wyjście jednego układu z wejściem drugiego). Natomiast adres do zapisu musi być przechowywany w module top na podstawie tego, co odbierze moduł SlaveSPI i z tego powodu musimy użyć typu reg, aby zsyntezować przerzutniki D.

Przejdźmy do linii 17, w której tworzymy instancję dwuportowej pamięci RAM. Moduł ten opracowaliśmy w 15 odcinku kursu, opublikowanym w EP 01/2024. Moduł pamięci musimy skonfigurować przy pomocy trzech parametrów. Parametr MEMORY_DEPTH informuje, jaka ma być pojemność tworzonej pamięci, czyli 1536 bajtów. Aby zaadresować tyle bajtów, potrzebujemy 11-bitowej magistrali adresowej, co ustalamy parametrem ADDRESS_WIDTH. Chcemy przechowywać dane 8-bitowe i konfigurujemy to parametrem DATA_WIDTH. W liniach 19...23 łączymy wejścia i wyjścia pamięci ze zmiennymi utworzonymi wcześniej.

W linii 24 rozpoczyna się sekwencyjny blok always, który ustala pod jakim adresem mają być zapisane bajty, odebrane przez interfejs SPI. W gruncie rzeczy sprowadza się od do sprawdzania dwóch warunków (oprócz oczywiście sygnału Reset). Jeżeli rozpoczyna się nowa transmisja przez SPI, czyli kiedy TransmissionStart jest w stanie wysokim przez jeden takt zegara, to wtedy zerujemy rejestr adresu (linia 25).

Moduł VGA

Kod modułu, odpowiedzialnego za generowanie sygnałów sterujących monitorem VGA zaprezentowano na listingu 2. Jest on podobny do kodu, który napisaliśmy w 27 odcinku kursu, lecz został rozbudowany o możliwość odczytu danych z pamięci RAM. Omówimy tylko najważniejsze różnice między tymi modułami.

// Plik vga.v

`default_nettype none
module VGA(
input wire Clock, // Musi być 25 MHz lub 25.175 MHz
input wire Reset,

output wire [10:0] RequestedAddress_o, // 1
input wire [ 7:0] DataFromRAM_i, // 2

output reg Red_o,
output reg Green_o,
output reg Blue_o,
output reg HSync_o,
output reg VSync_o
);

// Liczniki pikseli dla rozdzielczości 640*480 pikseli
reg [9:0] HCounter; // Max 799 // 3
reg [9:0] VCounter; // Max 524 // 4

// Licznik poziomy i pionowy
always @(posedge Clock, negedge Reset) begin // 5
if(!Reset) begin
HCounter <= 0;
VCounter <= 0;
end

else if(HCounter != 799) begin
HCounter <= HCounter + 1’b1;
end

else begin
HCounter <= 0;
if(VCounter != 524)
VCounter <= VCounter + 1’b1;
else
VCounter <= 0;
end
end

// Liczniki pikseli dla rozdzielczości 128x96
reg [2:0] HDivider; // Max 4 // 6
reg [2:0] VDivider; // Max 4 // 7
reg [6:0] HPixel; // Max 127 // 8
reg [6:0] VPixel; // Max 95 // 9

always @(posedge Clock, negedge Reset) begin // 10
if(!Reset) begin
HDivider <= 0;
VDivider <= 0;
HPixel <= 0;
VPixel <= 0;
end else begin
// Prostsze, ale działa 3x wolniej
// HPixel <= HCounter / 5; // 11
// VPixel <= VCounter / 5; // 12

if(HDivider == 4) begin
HDivider <= 0;
if(HCounter == 799) begin
HPixel <= 0;
if(VCounter == 524) begin
VPixel <= 0;
VDivider <= 0;
end else if(VDivider == 4) begin
VDivider <= 0;
VPixel <= VPixel + 1’b1;
end else begin
VDivider <= VDivider + 1’b1;
end
end else begin
HPixel <= HPixel + 1’b1;
end
end else begin
HDivider <= HDivider + 1’b1;
end
end
end

// Wyższy poziom abstrakcji
wire [3:0] PageNumber = VPixel[6:3]; // 13
wire [2:0] LineInPage = VPixel[2:0]; // 14
assign RequestedAddress_o[10:0] = PageNumber * 128 + HPixel; // 15

// Sterowanie sygnałami RGB i HSYNC
always @(posedge Clock, negedge Reset) begin // 16
if(!Reset)
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1000; // 17

// Horizontal active area
else if(HCounter >= 2 && HCounter <= 641 && VCounter >= 0 && VCounter <= 479) begin // 18
if(HDivider == 2) begin // 19
if(DataFromRAM_i[LineInPage]) // 20
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1110; // 21
else
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1001; // 22
end
end

// Horizontal front porch
else if(HCounter >= 642 && HCounter <= 657) // 23
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1000;

// Horizontal sync pulse
else if(HCounter >= 658 && HCounter <= 753) // 24
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b0000;

// Horizontal back porch
else // 25
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1000;
end

// Sterowanie sygnałem VSYNC
always @(posedge Clock, negedge Reset) begin // 26
if(!Reset)
VSync_o <= 1; // 27
else if(HCounter == 2) begin // 28
if(VCounter == 490 || VCounter == 491) // Vertical sync pulse
VSync_o <= 0;
else // Active area, front and back porch
VSync_o <= 1;
end
end

endmodule
`default_nettype wire

Listing 2. Kod pliku vga.v

Na liście portów pojawiły się dwa nowe elementy. Pierwszy z nich (linia 1) to wyjście RequestedAddress_o, informujące moduł pamięci RAM, które dane ma odczytać z pamięci. Zostaną one wyświetlone w najbliższej przyszłości. Odpowiedź z modułu pamięci RAM trafia do modułu VGA poprzez port DataFromRAM_i (linia 2) i na podstawie tego moduł VGA określa dalej, jak wygenerować sygnały na wyjściach Red_o, Green_o i Blue_o.

W liniach 3 i 4 deklarujemy liczniki HCounter i VCounter, których zadaniem jest wskazywanie współrzędnych aktualnie wyświetlanego piksela w pionie i poziomie. Przypomnijmy, że w naszych eksperymentach rozdzielczość wyświetlanego obrazu to 640×480 pikseli, ale ze względu na konieczność wygenerowania sygnałów synchronizacji pionowej i poziomej musimy dodać do tego „zapasowe”, niewidoczne piksele, które znajdą się niejako „poza ekranem”. Stąd licznik poziomy ma liczyć w zakresie od 0 do 799, a licznik pionowy od 0 do 524. Przypomnijmy też, że punkt zerowy układu współrzędnych znajduje się w lewym górnym rogu monitora. W każdym cyklu zegara wyświetlamy tylko jeden piksel. Kursor przesuwa się poziomo liniami od lewej do prawej, po czym przechodzi niżej do kolejnej linii. Obsługa liczników HCounter i VCounter zrealizowana jest w bloku always, zaczynającym się w linii 5. Jest on na tyle prosty, że nie wymaga komentarza.

Jak wcześniej napisałem, ze względu na niewielką pojemność pamięci RAM wyświetlany obraz będzie przeskalowany przez 5, czyli rzeczywista rozdzielczość grafiki będzie wynosić 128×96 px. Musimy zatem liczniki HCounter i VCounter podzielić przez 5 i wtedy otrzymamy współrzędne piksela w przeskalowanym obrazie. Istnieje szereg rozwiązań, aby to uczynić.

Najprostszym (ale tylko z punktu widzenia programisty!) byłoby po prostu podzielić te liczniki przez 5 przy pomocy operatora dzielenia (linie 11 i 12). Jest to wygodne, ale powoduje utworzenie dwóch bardzo skomplikowanych bloków kombinacyjnych, które pochłoną mnóstwo zasobów. Dzielenie liczb w FPGA bywa dość kłopotliwe, jeżeli dzielimy przez liczbę inną niż potęga dwójki.

Zastosujemy inną metodę – trochę bardziej skomplikowaną do zrozumienia, ale za to zdecydowanie efektywniejszą pod kątem zapotrzebowania na zasoby. Zastosujemy kilka prostych liczników połączonych ze sobą różnymi warunkami. Są to:

  • HDivider – liczy od 0 do 4. Inkrementuje się przy każdym zboczu rosnącym zegara Clock.
  • HPixel – liczy od 0 do 127 i wyznacza współrzędną poziomą aktualnie wyświetlanego piksela po przeskalowaniu obrazu; jest inkrementowany przy zboczu rosnącym zegara Clock, o ile HDivider = 4.
  • VDivider – liczy od 0 do 4. Inkrementuje się przy zboczu rosnącym zegara Clock, jeżeli HCounter = 799, czyli kiedy kończy się cykl linii poziomej.
  • VPixel – liczy od 0 do 95 i wyznacza współrzędną pionową aktualnie wyświetlanego piksela po przeskalowaniu obrazu. Jest inkrementowany przy zboczu rosnącym zegara Clock, jeżeli HCounter = 799 i jednocześnie VDivider = 4.

Logika opisana powyżej została zaimplementowana w bloku always, który rozpoczyna się w linii 10. Mając już współrzędne kursora pionowego i poziomego dla obrazów: rzeczywistego i przeskalowanego, możemy przejść na nieco wyższy poziom abstrakcji i wprowadzić kilka nowych zmiennych, wynikających z utworzonych wcześniej liczników.

W linii 13 tworzymy 4-bitową zmienną PageNumber typu wire, w której jest zapisany numer aktualnie wyświetlanej strony obrazu. Przypomnijmy, że strony to poziome pasy obrazu o wysokości 8 pikseli. Mając rozdzielczość pionową 96 pikseli, mamy do dyspozycji 12 stron, zatem w tej zmiennej będą dostępne liczby z zakresu od 0 do 11.

Numer strony zależny jest od kursora pionowego po przeskalowaniu, czyli VPixel i musimy ten licznik podzielić przez 8. Możemy to zrobić operatorem „/”, ale możemy także posłużyć się operatorem wyboru bitów [x:y]. W tym przypadku wybierzemy tę drugą opcję, zatem do zmiennej PageNumber przypisujemy bity [6:3] licznika VPixel (linia 13).

Podobnie postępujemy ze zmienną LineInPage, informującą o tym, która z ośmiu linii w obrębie strony jest właśnie wyświetlana. Informację tę pozyskamy również z licznika VPixel. Możemy tutaj posłużyć się operatorem modulo albo również wybrać odpowiednie bity tej zmiennej. Skorzystamy również z tej drugiej możliwości, zatem w linii 14 wybieramy bity [2:0] licznika VPixel i przypisujemy je do 3-bitowej zmiennej LineInPage typu wire.

W ten sposób podzieliliśmy licznik VPixel na dwie części. Aby łatwiej było to zrozumieć, spójrz na rysunek 3.

Rysunek 3. Podział zmiennej VPixel na dwie zmienne wire

Aby określić adres bajtu w pamięci, który należy odczytać przed wygenerowaniem sygnałów RGB dla kolejnego piksela, musimy wykonać proste działanie matematyczne, które zapisano w linii 15. Numer strony (z zakresu od 0 do 11) mnożymy przez 128, ponieważ tyle znajduje się pikseli na osi poziomej i do tego dodajemy numer żądanego piksela na osi poziomej. Mnożenie przez liczby będące potęgą dwójki (27=128) jest bardzo łatwe do wykonania w sprzęcie – wystarczy tylko poprzesuwać bity, a wewnątrz FPGA to tylko kwestia poprzestawiania kilku sygnałów. W ten sposób obliczamy adres bajtu, który chcemy odczytać z pamięci RAM, zatem przypisujemy go do wyjścia RequestedAddress_o (linia 15).

Przeskoczymy teraz do linii 26, gdzie rozpoczyna się blok always, odpowiedzialny za sygnał synchronizacji pionowej VSYNC – informujący monitor o zakończeniu transmisji klatki obrazu i rychłym rozpoczęciu transmisji kolejnej klatki, zaczynając od lewego górnego narożnika. Pamiętaj, że wyjście VSync_o domyślnie ma stan wysoki, dlatego podczas resetu musimy go w taki stan ustawić (linia 27).

W linii 28 znajduje się coś, czego zapewne się nie spodziewałeś. Cała dalsza logika wykonuje się tylko wtedy, gdy licznik pikseli rzeczywistych jest równy 2. W innym przypadku sygnał synchronizacji pionowej pozostaje niezmienny, niezależnie od wszystkiego. Dlaczego tak dziwnie? Zastanówmy się i zasymulujmy działanie tego kodu w naszej wyobraźni:

  • Mamy rosnące zbocze sygnału zegarowego. Licznik HCounter inkrementuje swoją wartość, po czym rozpoczynają pracę wszystkie elementy kombinacyjne, przetwarzające wartość tego licznika. Między innymi są to elementy logiczne, powstałe w bloku always, zaczynającym się w linii 10, które ustalają nowy stan rejestrów VPixel i HPixel. Nowy stan tych rejestrów jest obliczany, ale pojawi się w tych nich dopiero przy kolejnym zboczu zegara.
  • W kolejnym takcie zegara wynik działania wspomnianego wcześniej bloku always jest wpisywany do rejestrów VPixel i HPixel, a to z kolei powoduje zmianę stanu elementów odbierających dane z tych rejestrów (a dokładniej rzecz ujmując – wyjścia RequestedAddress_o). Ten sygnał wychodzi z modułu VGA do modułu top i tam jest dostarczany do wejścia ReadAddress_i modułu dwuportowej pamięci RAM. Pamięć przetwarza to żądanie i przygotowuje wynik, który pojawi się na wyjściu pamięci w kolejnym takcie zegara.
  • Po następnym zboczu sygnału zegarowego pamięć RAM podaje wynik na wyjście Data_o, które przy pomocy sygnału wire DataFromRAM transportowane jest (poprzez moduł top) do modułu VGA, gdzie wchodzi do wejścia DataFromRAM_i. Na jego podstawie przygotowywane są sygnały koloru czerwonego, zielonego i niebieskiego w taki sposób, aby dopasować kolor wyświetlany na monitorze do stanu piksela w pamięci. Po przygotowaniu dane czekają na kolejny takt zegara.
  • W kolejnym takcie zegara dane opisane wcześniej przepisywane są do rejestrów Red_o, Green_o oraz Blue_o, po czym zostają przesłane kablem do monitora.

Jak widzisz, cała ta operacja z inkrementowaniem różnych liczników, odczytywaniem pamięci i przetwarzaniem pikseli zajmuje kilka taktów zegarowych. Można powiedzieć, że licznik HCounter wyprzedza to, co obecnie jest wyświetlane o cztery takty zegarowe. Musimy mieć na uwadze to, że sygnały synchronizacji poziomej i pionowej również muszą uwzględniać te zależności czasowe.

Z tego powodu w linii 28 sprawdzamy, czy obecny stan licznika HCounter wynosi 2. Jeżeli tak, to w następnym cyklu (jednocześnie kiedy HCounter będzie miał wartość 3) zmieni się stan sygnału VSync_o.

Proces opisany w powyższych akapitach to świetny przykład pipeliningu, czyli przetwarzania potokowego. Teoretycznie można byłoby to wszystko robić w jednym takcie zegara. Taki kod byłby zapewne prostszy do zrozumienia. Jednak realizacja tylu operacji w jednym takcie powodowałaby konieczność utworzenia gigantycznego bloku kombinacyjnego, którego czas propagacji byłby ogromny. Pipelining wydaje się zagmatwany i trudny do zrozumienia, jednak takie rozbijanie skomplikowanych operacji na kilka taktów zegara bywa niekiedy konieczne. Ponadto zwróć uwagę, że w każdym takcie zegara wykonują się wszystkie z tych punktów opisanych wyżej.

To była najtrudniejsza część tego odcinka. Jeżeli udało Ci się przez nią przebrnąć, to teraz będzie już z górki. W linii 16 mamy kolejny blok always, którego celem jest generowanie sygnałów koloru i jednocześnie sygnału synchronizacji poziomej. Aby skrócić kod, będziemy stosować operator konkatenacji {}, który pozwala przypisać jakąś wartość kilku zmiennym jednocześnie. Przykład takiej operacji jest widoczny w linii 17, w której podczas resetu ustawiamy HSync_o w stan wysoki, a wszystkie sygnały kolorów – w stan niski.

Podobnie jak w poprzednim odcinku kursu, niniejszy blok always podzielony jest na cztery części, w zależności od stanu licznika HCounter (linie 18, 23, 24, 25). Są to: obszar aktywny (w którym wyświetlany jest obraz), potem horizontal front porch, następnie sygnał synchronizacji poziomej i wreszcie horizontal back porch, po czym licznik się resetuje i cykl zostaje powtórzony.

Przejdźmy do linii 19. Znajdujemy się w części aktywnej, gdzie musimy odpowiednio ustawić sygnały kolorów. Zmieniamy je dopiero wtedy, gdy licznik HDivider jest równy 2, z powodu opisanego powyżej. W linii 20 z 8-bitowego wejścia DataFromRAM_i wybieramy bit wskazywany przez zmienną LineInPage (która zmienia się w zakresie od 0 do 7). Jeżeli ten bit ma wartość 1 to znaczy, że na monitorze powinniśmy wyświetlić żółty piksel (linia 21), a w przeciwnym wypadku piksel będzie koloru niebieskiego (linia 22).

Testbench modułu top

Przeanalizujemy teraz kod modułu testowego, który zamieszczony został na listingu 3.

// Plik top_tb.v

`timescale 1ns/1ps
`default_nettype none
module top_tb();

parameter CLOCK_HZ = 25_175_000; // 1
parameter SPI_DELAY = 40; // 2
parameter WIDTH = 128; // 3
parameter HEIGHT = 96; // 4

// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#(1_000_000_000.0 / (2.0 * CLOCK_HZ));
Clock = !Clock;
end

// Zmienne
reg Reset = 0;
reg CS = 1;
reg SCK = 0;
reg MOSI = 0;

// Instancja testowanego modułu
top DUT( // 5
.Clock(Clock),
.Reset(Reset),
.CS_i(CS),
.SCK_i(SCK),
.MOSI_i(MOSI),
.DC_i(1’b1), // 6
.HSync_o(),
.VSync_o(),
.Red_o(),
.Green_o(),
.Blue_o()
);

// Task do przesyłania bajtu danych przez SPI
task TransmitSPI(input [7:0] Data); // 7
integer i;
begin
// $display(„%t Transmitting: %H %b”, $realtime, Data, Data);
for(i=7; i>=0; i=i-1) begin
SCK = 0;
MOSI = Data[i];
#SPI_DELAY;
SCK = 1;
#SPI_DELAY;
end
end
endtask

// Eksport wyników symulacji do pliku
initial begin
$dumpfile(„top.vcd”);
$dumpvars(0, top_tb);
end

// Sekwencja testowa
initial begin
$timeformat(-6, 3, ”us”, 12);
$display(”===== START =====”);

@(posedge Clock);
Reset <= 1;

repeat(10) @(posedge Clock);

// Przesyłanie obrazu do pamięci
CS = 0; // 8
repeat(WIDTH * HEIGHT / 16) begin
TransmitSPI(8’b01010101);
TransmitSPI(8’b10101010);
end
CS = 1; // 9

// Oczekiwanie na wygenerowanie całej klatki obrazu
repeat(10) @(posedge Clock);
wait(DUT.VGA_inst.VCounter == 524 && DUT.VGA_inst.HCounter == 799);
wait(DUT.VGA_inst.VCounter == 10);

$display(”====== END ======”);
$finish;
end

// Kilka zmiennych wire aby zaglądać do środka pamięci RAM
wire [7:0] RAM_0000 = DUT.BitmapRAM.Memory[0]; // 10
wire [7:0] RAM_0001 = DUT.BitmapRAM.Memory[1];
wire [7:0] RAM_0002 = DUT.BitmapRAM.Memory[2];
wire [7:0] RAM_0003 = DUT.BitmapRAM.Memory[3];
wire [7:0] RAM_1534 = DUT.BitmapRAM.Memory[1534];
wire [7:0] RAM_1535 = DUT.BitmapRAM.Memory[1535];

endmodule
`default_nettype wire

Listing 3. Kod pliku top_tb.v

W linii 1, tak jak zawsze, umieszczamy częstotliwość sygnału zegarowego, lecz tym razem jest ona „nietypowa” i dostosowana dokładnie do timingu VGA pracującego z rozdzielczością 640×480 pikseli i odświeżaniem 60 Hz, czyli dokładnie jest to 25,175 MHz. W linii 2 mamy opóźnienie, liczone w cyklach zegarowych, jakie ma wystąpić pomiędzy zboczami sygnału SCK interfejsu SPI. Linie 3 i 4 to rozdzielczość ekranu, mierzona w blokach pikseli po przeskalowaniu obrazu.

Kod testbencha prezentuje się typowo. Instancję testowanego modułu tworzymy w linii 5. Pamiętać musimy, że nasz sterownik odbiera tylko dane i ignoruje wszelkie polecenia, dlatego w linii 6 wejście DC_i łączymy na stałe ze stanem wysokim.

Testbench ma symulować jakiś procesor, działający jako master SPI. W tym celu posłużymy się taskiem TransmitSPI (linia 7), który omówiony został w 26 odcinku kursu (EP 12/2024).

Celem testbencha jest przesłanie jakiegoś testowego obrazu przez SPI do modułu top. W linii 8 ustawiamy CS w stan niski, co sygnalizuje rozpoczęcie transmisji. Następnie w pętli wysyłamy tyle bajtów ile trzeba, by zapełnić całą pamięć. Transmisję kończymy w linii 9, ustawiając sygnał CS w stan wysoki. Następnie zawieszamy działanie testbencha oczekując, aż moduł VGA wygeneruje całą klatkę obrazu. Po tym czekamy jeszcze chwilkę i kończymy symulację.

W linii 10 i kolejnych mamy kilka sygnałów wire, służących do podglądania zawartości pamięci RAM. Wygląda to dziwnie i rzeczywiście jest dziwne. W Verilogu nie można podglądać zawartości pamięci tak samo, jak wszystkich innych zmiennych i trzeba dodatkowo robić takie nietypowe „obejścia”.

Kod skryptu top.bat, potrzebnego do uruchomienia symulatora, zaprezentowano na listingu 4.

@echo off
iverilog -o top.o ^
top.v ^
vga.v ^
top_tb.v ^
edge_detector.v ^
ram_pdp.v ^
slave_spi.v ^
synchronizer.v
vvp top.o
del top.o

Listing 4. Kod pliku top.bat

W poprzednich odcinkach zamieszczałem zrzuty ekranu z symulatora GTKWave. Tym razem jednak tego nie zrobię, bo… są one strasznie nudne. Weryfikacja wyników symulacji polegała na ręcznym mierzeniu czasu pomiędzy poszczególnymi sygnałami synchronizacji, itp. Nie było to przyjemne zajęcie, więc przejdźmy od razu do testowania na żywo.

Testy na żywo

Podobnie jak w poprzednim odcinku kursu, skorzystamy z płytki User Interface Board, która oprócz modułu FPGA zawiera również płytkę z mikrokontrolerem ESP32. Napiszemy prosty skrypt w MicroPythonie, który wygeneruje odpowiedni bitstream i prześle go do FPGA przez interfejs SPI.

W standardowym MicroPythonie mamy do dyspozycji bibliotekę FrameBuffer, która dostarcza różnych funkcji do rysowania elementów graficznych oraz napisów. Operuje ona na buforze w pamięci RAM. Jeżeli dobrze skonfigurujemy tę bibliotekę, to wygeneruje ona w buforze gotowy bitstream, który wystarczy już tylko przesłać do FPGA bez żadnych modyfikacji. Bibliotekę FrameBuffer można zastosować do wyświetlaczy OLED, LCD, TFT czy e-Paper – zarówno monochromatycznych, jak i kolorowych. Biblioteka jest bardzo prosta i dysponuje tylko jedną czcionką (strasznie brzydką), ale do testów w zupełności nam wystarczy. Pełna dokumentacja tej biblioteki znajduje się pod adresem [3].

Kod programu do tego celu pokazano na listingu 5. Ponieważ jest to kurs języka Verilog, a nie Python, kod omówimy bardzo skrótowo. W linii 1 i kolejnej importujemy biblioteki, używane w programie. W liniach 2 i 3 tworzymy instancje obiektów sterujących pinami CS oraz DS. Następnie w linii 4 tworzymy interfejs SPI i jednocześnie konfigurujemy go podając odpowiednie argumenty. Linia 5 nie jest niezbędna, ale przydatna: polecenie to spowoduje wypisanie na konsoli wszystkich parametrów utworzonego interfejsu SPI. Po co? Warto to sprawdzić np. dlatego, że częstotliwości zegara SCK nie można ustawić dowolnie, a w razie żądania jakiejś niepoprawnej częstotliwości, program sam zaokrągli wartość bez żadnego ostrzeżenia. Poeksperymentuj i sprawdź sam.

from framebuf import * # 1
from machine import Pin, SPI

cs = Pin(5, Pin.OUT) # 2
dc = Pin(21, Pin.OUT, value=1) # 3
spi = SPI(2, baudrate=10_000_000, polarity=0, phase=0, # 4
sck=Pin(18), mosi=Pin(23), miso=Pin(19))
print(spi) # 5

WIDTH = 128
HEIGHT = 96

array = bytearray(WIDTH * HEIGHT // 8) # 6
buffer = FrameBuffer(array, WIDTH, HEIGHT, MONO_VLSB) # 7

def simulate(): # 8
for y in range(HEIGHT):
print(f”{y}\t”, end=””)
for x in range(WIDTH):
bit = 1 << (y % 8)
byte = array[(y // 8) * WIDTH + x]
pixel = ”#” if byte & bit else ”.”
print(pixel, end=””)
print(””)

def transmit(): # 9
cs(0)
spi.write(array)
cs(1)

def demo(): # 10
buffer.text(‘Elektronika’, 0, 1, 1)
buffer.text(‘Praktyczna’, 48, 9, 1)

buffer.rect(28, 22, 76, 11, 1)
buffer.text(‘Kurs FPGA’, 30, 24, 1)

buffer.rect(0, 42, 128, 54, 1)
buffer.text(‘abcdefghijklm’, 1, 44, 1)
buffer.text(‘nopqrstuvwxyz’, 1, 53, 1)
buffer.text(‘ABCDEFGHIJKLM’, 1, 62, 1)
buffer.text(‘NOPQRSTUVWXYZ’, 1, 70, 1)
buffer.text(‘0123456789+-*/’, 1, 78, 1)
buffer.text(‘!@#$%^&*(),.<>?’, 1, 86, 1)

demo() # 11
simulate()
transmit()

Listing 5. Kod pliku vga_demo.py

Linia 6 odpowiada za utworzenie tablicy array w pamięci. Rozmiar tablicy określa iloczyn szerokości i wysokości wyświetlacza (po przeskalowaniu) podzielony przez 8, ponieważ w jednym bajcie zapisane jest osiem pikseli. W języku Python operator ”//” oznacza dzielenie, którego wynik jest zawsze liczbą całkowitą, a operator „/” daje wynik zmiennoprzecinkowy, nawet jeżeli wynik jest w istocie także zmienną całkowitą.

W linii 7 tworzymy obiekt klasy FrameBuffer. Podajemy mu utworzoną wcześniej tablicę, rozmiary wyświetlacza, a MONO_VLSB to stała oznaczająca monochromatyczny tryb pracy i sposób kodowania pikseli.

Następnie mamy trzy proste funkcje. W linii 8 rozpoczynamy funkcję simulate(), której zadaniem jest wyświetlenie zawartośćci bufora array na terminalu. Piksel, który ma być widoczny na wyświetlaczu, będzie pokazany w konsoli jako znak „#”, a piksele niewidoczne zostaną wyświetlone jako spacje. Dalej, w linii 9, mamy funkcję transmit(). Jej zadaniem jest przesłanie zawartości tablicy array do FPGA przez interfejs SPI. I wreszcie w linii 10 umieszczamy funkcję, która ma utworzyć w buforze różne elementy graficzne, takie jak prostokąty i napisy, umieszczone w różnych miejscach. Po dokładniejszy opis odsyłam pod adres [3].

Teraz podłączamy kabel VGA do płytki User Interface Board, zapisujemy plik ze skryptem w MicroPythonie i wciskając klawisz F5 uruchamiamy go. Natychmiast powinien pojawić się obraz na monitorze – identyczny, jak na fotografii 1. Obraz powinien być ostry, nieruchomy i bez żadnych zakłóceń.

Fotografia 1. Efekt działania kodów z niniejszego odcinka kursu
Fotografia 2. Parametry obrazu wyświetlanego na monitorze

W następnym odcinku będziemy dalej drążyć temat VGA. Podniesiemy poprzeczkę i zobaczymy, w jaki sposób można zrobić terminal, który odbiera znaki ASCII przez UART, a następnie generuje grafikę VGA – a w dodatku kolorową.

Dominik Bieczyński
leonow32@gmail.com

Zobacz więcej:
• Repozytorium modułów wykorzystywanych w kursie https://github.com/leonow32/verilog-fpga
• Projekt w programie Diamond https://ep.com.pl/files/flq/13717-Kurs28.zip
• Opis biblioteki FrameBuffer https://docs.micropython.org/en/latest/library/framebuf.html
Artykuł ukazał się w
Elektronika Praktyczna
luty 2025
DO POBRANIA
Materiały dodatkowe
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik czerwiec 2025

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio maj - czerwiec 2025

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje maj 2025

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna czerwiec 2025

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich czerwiec 2025

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów