Jak to działa?
Czy pamiętasz takie programy, jak Norton Commander, Turbo Pascal czy mks_vir? Dzisiaj jest to już cyfrowa archeologia. Te programy, za sprawą pewnych trików, umożliwiały wyświetlenie informacji w atrakcyjnej, jak na tamte czasy, szacie graficznej, przy wykorzystaniu bardzo ubogich zasobów sprzętowych (rysunek 1). Działały one w tzw. trybie tekstowym, czyli wyświetlały tylko i wyłącznie litery, cyfry oraz różne kreski, strzałki i inne symbole przygotowane przez producenta karty graficznej. Wszystkie symbole musiały mieć te same wymiary, a najczęściej stosowało się znaki o wysokości 16 pikseli i szerokości 8 pikseli. Jeżeli monitor miał rozdzielczość 640×480 px, to można było na nim wyświetlić symbole zorganizowane w 80 kolumn i 30 wierszy.
Do dyspozycji jest 256 symboli, ponieważ tyle można zakodować w 8-bitowej zmiennej, czyli w jednym bajcie. Przykład takiego zestawu symboli, zwanego stroną kodową, widzimy na rysunku 2. Istnieje dużo różnych stron kodowych, obejmujących znaki diakrytyczne w wielu różnych językach i innych alfabetach.
We wczesnych systemach komputerowych kolory były 4-bitowe. Trzy bity na składowe poszczególnych kolorów RGB, które pozwalały uzyskać osiem kolorów: czerwony, żółty, zielony, cyjan, niebieski, magentę, biały i czarny. Czwarty bit pozwalał rozjaśnić kolor, co w rezultacie dawało 16 różnych barw.
Każdy symbol ma pierwszy plan (foreground) oraz tło (background). Można im przypisać różne kolory, jednak w obrębie jednego znaku może być wybrana tylko jedna barwa pierwszego planu i jeden kolor tła. Cztery bity kodowały więc kolor pierwszego planu, a kolejne cztery – kolor tła znaku, czyli wystarczył jeden bajt, aby zapisać wszystkie te informacje.
Dochodzimy do wniosku, że za każdy znak wyświetlany na monitorze odpowiedzialne były dwa bajty. Pierwszy ustalał, jaki znak ma być pokazany, a drugi decydował o kolorach. Zatem jeżeli mamy 80 kolumn i 30 wierszy, to razem otrzymujemy 2400 znaków – czyli potrzebujemy 4800 bajtów. To zaskakująco mało jak na obraz o rozdzielczości 640×480 px i 4-bitowej kolorystyce. Gdybyśmy chcieli taki obraz przechowywać w pamięci jako bitmapę, to potrzebowalibyśmy już 153 600 bajtów, czyli 32 razy więcej!
W MachXO2-1200 mamy do dyspozycji siedem bloków EBR o pojemności 1 kB każdy, zatem potrzebujemy pięć takich bloków na pamieć RAM odpowiedzialną za przechowywanie tekstu i koloru – dwa kolejne zostaną nam do innych celów.
Zastanówmy się teraz nad czcionką, ponieważ ona także musi być zapisana w pamięci. Zapewne już domyślasz się, do czego użyjemy pozostałych dwóch bloków EBR. Zobacz rysunek 3, na którym zaprezentowano sposób zapisu jednego znaku w pamięci. Symbole mają rozmiar 16 pikseli w pionie i 8 pikseli w poziomie. Te liczby są nieprzypadkowe i wynikają ze sprytnej optymalizacji – rozdzielczość pozioma to osiem pikseli, dlatego że w jednym bajcie jest właśnie tyle bitów. Każdy bit tego bajtu odpowiada zatem za jeden piksel. Stan wysoki bitu oznacza, że zostanie on wyświetlony kolorem pierwszego planu, a stan niski spowoduje, że będzie miał taki kolor, jaki wybrany został dla tła.
Cały znak tworzy 16 bajtów i ta liczba również nie jest przypadkowa – wszak to czwarta potęga dwójki (24=16), a 16 kombinacji możemy zapisać na czterech bitach. Każdy symbol ma swój adres w pamięci, a ponieważ każdy z nich wykorzystuje dokładnie 16 bajtów, adres początku znaku (tzn. zerowego bajtu znaku) możemy bardzo łatwo obliczyć wzorem
Adres=16·KodASCII
gdzie KodASCII to numer znaku od 0 do 255. Jednak wcale nie musimy wykonywać żadnego mnożenia! W FPGA operacje mnożenia i dzielanie na liczbach będących potęgami dwójki to de facto... przestawianie bitów. Popatrz na rysunek 4, który ilustruje skład informacji doprowadzanych do wejścia pamięci ROM czcionki. Skoro mamy 256 znaków w pamięci, to potrzebujemy 8 bitów, by zapisać tę liczbę (28=256) – umieszczamy je na bardziej znaczących pozycjach, a numer jednej z szesnastu linii bitmapy czcionki umieszczamy na czterech młodszych bitach. W ten sposób powstaje nam 12-bitowy adres w pamięci.
212=4096 – potrzebujemy więc czterech bloków EBR, aby móc pomieścić całą czcionkę. Jednak jest z tym pewien problem… pozostały nam tylko dwa bloki do dyspozycji. No cóż, albo weźmiemy układ FPGA o większej pamięci, albo obetniemy plik czcionki i zadowolimy się znakami od 0 do 127, czyli wszystkimi literami, cyframi, interpunkcją, znakami kontrolnymi i niektórymi znakami diakrytycznymi. Wybrałem tę drugą opcję, czyli 5 bloków EBR przeznaczyłem na pamięć RAM tekstu i koloru, a 2 bloki EBR będą funkcjonować jako pamięć ROM czcionki. Kody znaków ASCII będą nie 8-bitowe, jak to zwykle bywa, lecz tylko 7-bitowe.
Zastanówmy się teraz, w jaki sposób będziemy dostarczać dane do pamięci RAM tekstu i koloru. W założeniu nasz terminal ma być połączony z komputerem, zatem najprościej będzie wyposażyć go w odbiornik UART (nadajnik nie jest potrzebny, bo wystarczy nam komunikacja jednokierunkowa). Musimy opracować jakiś prosty protokół komunikacji. Zobacz rysunek 5, na którym pokazano dwie ramki transmisji.
Najstarszy bit z przesyłanego bajtu decyduje o znaczeniu pozostałych bitów. Zero oznacza, że pozostałe bity [6:0] to kod ASCII znaku do wyświetlenia, zaś jedynka na tej pozycji – że bity [6:4] to kolor znaku, a pole [2:0] to kolor tła.
Po odebraniu bajtu tekstu przez interfejs UART odpowiadający mu znak ma zostać natychmiast wyświetlony na ekranie, na pozycji aktualnie wskazywanej przez kursor, po czym kursor zostaje przesunięty o jedną pozycję w prawo lub na początek kolejnej linii. Domyślnie terminal będzie wyświetlał białe znaki na czarnym tle. Odebranie bajtu koloru spowoduje zapisanie do rejestru kolorów nowych ustawień i będą one dotyczyły kolejnych odebranych bajtów tekstu – tak długo, aż zostanie przesłany kolejny znak koloru.
Taki sposób komunikacji jest bardzo wygodny ze względu na kompatybilność z różnymi systemami nadrzędnymi. Terminal będzie mógł prezentować znaki pochodzące od dowolnego nadajnika UART, a jeżeli nie obsługuje on kolorów, to terminal wyświetli wszystko białą czcionką na czarnym tle.
Moduł top
Analizę kodu modułu top rozpoczniemy od zapoznania się ze schematem, który został automatycznie wygenerowany przez Netlist Analyzer. W ten sposób kod stanie się bardziej zrozumiały.
Zobacz rysunek 6.
Moduł top składa się z instancji czterech innych modułów, połączonych ze sobą w logiczną całość. Są to:
Odbiornik UartRx, który odbiera dane z komputera i przekazuje je dalej do kontrolera pamięci.
Kontroler pamięci, w którym znajduje się pamięć ROM czcionki oraz pamięć RAM tekstu i koloru. Pamięć RAM zrealizujemy jako pamięć pseudodwuportową, gdzie jednym portem będą zapisywane dane (odebrane z UART), a drugim – odczytywane dane (żądane przez moduł VGA). Ponadto kontroler pamięci będzie wyposażony w prosty interpreter znaków kontrolnych, takich jak Enter, Backspace czy Escape.
Moduł VGA, który generuje sygnały przesyłane następnie do monitora. Na schemacie widać pętlę pomiędzy tym modułem a kontrolerem pamięci. Moduł VGA musi poinformować, który znak za chwilę będzie wyświetlany, a dokładniej rzecz ujmując: która z szesnastu linii znaku, leżącego w żądanej kolumnie i wierszu. W odpowiedzi na takie żądanie moduł pamięci podaje osiem pikseli do wyświetlenia oraz informację o kolorach pierwszego planu i tła. Pamiętać należy, że odczytanie tych danych z pamięci zajmuje kilka taktów zegarowych, dlatego moduł VGA musi sformułować żądanie odpowiednio wcześniej, przed wygenerowaniem sygnałów VGA odpowiadających żądanemu znakowi.
Moduł DisplayMultiplex, którego celem jest ułatwienie poszukiwania błędów. Jego zadaniem jest wyświetlenie bajtu, ostatnio odebranego przez UART, na wyświetlaczu 7-segmentowym. Nie ma on nic wspólnego z terminalem i można go usunąć, jeżeli stwierdzisz, że jest niepotrzebny.
Omówione wcześniej moduły składają się z podmodułów. Hierarchię całego projektu zobrazowano na rysunku 7. Moduł VGA nie ma żadnych podmodułów. Moduły UartRX oraz DisplayMultiplex omawialiśmy już w poprzednich odcinkach.
Zaraz, zaraz... coś dziwnego jest z modułem Memory. Składa się on z jednego modułu ROM, zawierającego dwa bloki EBR, a także z pięciu modułów RAM z jednym blokiem EBR. Dlaczego nie można by zrobić jednej instancji PseudoDualPortRAM z pięcioma blokami EBR? Niestety w Lattice Diamond jest pewien błąd. Liczba bloków EBR, a także całkowita pojemność pamięci obliczane są na podstawie szerokości wejścia adresowego. W naszym przypadku potrzeba 5 kB pamięci, czyli szyna adresowa musi mieć 13 bitów. Syntezator z jakiegoś powodu ignoruje rozmiar tablicy podany w nawiasach kwadratowych i tworzy tyle pamięci, ile wynika z szerokości adresu, czyli 213=8192 bajtów, a zatem osiem bloków EBR... lecz my potrzebujemy tylko pięciu! Rozwiązaniem jest utworzenie pamięci poprzez IP Express lub ręczne zadeklarowanie pojedynczych bloków i później scalenie ich w jedną część własnym dekoderem adresów. Wybrałem drugie rozwiązanie.
Przejdźmy teraz do omówienia kodu modułu top, pokazanego na listingu 1. Lista parametrów oraz portów wejściowych i wyjściowych jest oczywista, więc od razu przeskoczymy do deklaracji zmiennych.
`default_nettype none
module top #(
parameter CLOCK_HZ = 25000000,
parameter BAUD = 115200
)(
input wire Clock, // Pin 20, musi być 25 lub 25.175 MHz
input wire Reset, // Pin 17
input wire UartRx_i, // Pin 75
output wire [7:0] Cathodes_o,
output wire [7:0] Segments_o,
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
);
// Aktualnie wyświetlany znak
wire [6:0] Column; // 1 (Zakres 0..79)
wire [4:0] Row; // 2 (Zakres 0..29)
wire [3:0] Line; // 3 (Zakres 0..15)
// Sygnały pomiędzy pamięcią i modułem VGA
wire GetImageRequest; // 4
wire [7:0] Pixels; // 5
wire [2:0] ColorForeground; // 6
wire [2:0] ColorBackground; // 7
// Odbiornik UART
wire DataReceivedEvent; // 8
wire [7:0] DataFromUART; // 9
UartRx #( // 10
.CLOCK_HZ(CLOCK_HZ),
.BAUD(BAUD)
) UartRx_Inst(
.Clock(Clock),
.Reset(Reset),
.Rx_i(UartRx_i),
.Done_o(DataReceivedEvent), // 11
.Data_o(DataFromUART) // 12
);
// Kontroler pamięci
Memory Memory_inst( // 13
.Clock(Clock),
.Reset(Reset),
.AnalyzeRequest_i(DataReceivedEvent), // 14
.DataFromUART_i(DataFromUART), // 15
.GetImageRequest_i(GetImageRequest), // 16
.Column_i(Column), // 17
.Row_i(Row), // 18
.Line_i(Line), // 19
.Pixels_o(Pixels), // 20
.ColorForeground_o(ColorForeground), // 21
.ColorBackground_o(ColorBackground) // 22
);
// Moduł VGA
VGA VGA_inst( // 23
.Clock(Clock),
.Reset(Reset),
.GetImageRequest_o(GetImageRequest), // 24
.Column_o(Column), // 25
.Row_o(Row), // 26
.Line_o(Line), // 27
.PixelsToDisplay_i(Pixels), // 28
.ColorForeground_i(ColorForeground), // 29
.ColorBackground_i(ColorBackground), // 30
.Red_o(Red_o),
.Green_o(Green_o),
.Blue_o(Blue_o),
.HSync_o(HSync_o),
.VSync_o(VSync_o)
);
// Sterownik 7-segmentowego wyświetlacza LED
DisplayMultiplex #( // 31
.CLOCK_HZ(CLOCK_HZ),
.SWITCH_PERIOD_US(1000),
.DIGITS(8)
) DisplayMultiplex_inst(
.Clock(Clock),
.Reset(Reset),
.Data_i({
24’d0,
DataFromUART // 32
}),
.DecimalPoints_i(8’d0),
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o),
.SwitchCathode_o()
);
endmodule
`default_nettype wire
Listing 1. Kod pliku top.v
W liniach 1, 2 i 3 tworzymy trzy zmienne typu wire, które łączą wyjścia modułu VGA z wejściami modułu Memory i informują o tym, co za chwilę ma zostać wyświetlone (tj. znak ze wskazanej kolumny i wiersza, a dodatkowo także numer linii pikseli tegoż znaku). Zmienne te są ustawiae w liniach 25...27, a odczytywane są w liniach 17...19.
Moduł VGA żąda podania kolejnej porcji danych do wyświetlenia za pomocą zmiennej GetImageRequest typu wire, która jest tworzona w linii 4, sterowana wyjściem modułu VGA w linii 24, a odczytywana (przez moduł Memory) na wejściu w linii 16. Żądanie sygnalizowane jest poprzez ustawienie tego sygnału w stan wysoki na jeden takt zegarowy. Po wystąpieniu takiego zdarzenia moduł pamięci rozpoczyna pracę, co zajmuje kilka taktów zegarowych, zanim odpowiedź (tzn. osiem pikseli oraz kolory) zostanie podana na wyjścia Pixels_o, ColorForeground_o i ColorBackground_i (linie 28...30). Pixels_o jest 8-bitową zmienną, a każdy bit tej zmiennej informuje o ośmiu kolejnych pikselach do wyświetlenia. Jeżeli bit jest w stanie wysokim, to odpowiadający mu piksel ma zostać wyświetlony kolorem pierwszego planu, a w przeciwnym wypadku – kolorem tła.
Można zapytać, dlaczego nie ma żadnego sygnału od kontrolera pamięci do VGA o tym, że nowe dane do wyświetlenia są już dostępne na wyjściach? Odpowiedź jest prosta. Odczytanie bajtów tekstu i koloru zawsze zajmuje taką samą liczbę taktów zegarowych. Zatem skoro moduł VGA wie, kiedy wystosował żądanie, to wie również, kiedy otrzyma odpowiedź. Poza tym moduł VGA ciągle zajmuje się odliczaniem pikseli z każdym taktem zegara, zatem nawet nie potrzebujemy żadnego dodatkowego licznika – wystarczy nam licznik pikseli w osi poziomej.
W linii 10 tworzymy instancję modułu UartRx, który znamy już z poprzednich odcinków kursu. Dla przypomnienia: interesują nas wyjścia 8-bitowe Data_o, zawierające ostatni bajt odebrany przez odbiornik (linia 12) oraz Done_o, informujące o tym, że są nowe dane do odczytania z wyjścia Data_o (linia 11). Informacja ta, jak zawsze w naszym kursie, sygnalizowana jest poprzez ustawienie tego wyjścia w stan wysoki na czas jednego taktu zegarowego. Omawiane wyjścia podłączone są do zmiennych wire DataFromUART i DataReceivedEvent, utworzonych w liniach 8 i 9, a doprowadzonych następnie do wejść AnalyzeRequest_i i DataFromUART_i (linie 14 i 15) modułu Memory.
W linii 13 widzimy instancję kontrolera pamięci. Następnie w linii 23 znajduje się instancja modułu VGA, a w linii 31 mamy sterownik wyświetlacza 7-segmentowego. Do jego 32-bitowego wejścia danych (linia 32) doprowadzono 8-bitowe wyjście odbiornika UART, do którego zostały „doklejone” 24 zera za pomocą operatora konkatenacji {}.
Moduł VGA
Omówimy teraz kod modułu VGA, odpowiedzialnego za generowanie sygnałów do obsługi monitora i współpracującego z kontrolerem pamięci. Moduł ten, podobnie jak wszystkie pozostałe w tym odcinku kursu, musi być taktowany zegarem o częstotliwości 25 MHz (a idealnie by było 25,175 MHz). W każdym takcie zegara, trwającym 39,722 ns, wyświetlany jest tylko jeden piksel. Szczegóły działania interfejsu VGA były omówione w 27 odcinku kursu, opublikowanym w EP 01/2025. Ze względu na konieczność precyzyjnego timingu wszystko w tym module musi być zrobione bardzo dokładnie i nie możemy żadnego sygnału wygenerować wcześniej lub później, bo wtedy otrzymamy obraz drżący, rozmazany, a w skrajnym przypadku monitor straci synchronizację i w ogóle przestanie wyświetlać jakikolwiek obraz.
Moduł VGA wysyła do kontrolera pamięci zapytanie o to, które piksele należy wyświetlić w danym fragmencie obrazu. Kluczowym problemem dla nas jest to, że od zapytania do uzyskania odpowiedzi mija 5 taktów zegarowych. Musimy mieć to na uwadze, generując sygnały RGB, a w szczególności – sygnały synchronizacji pionowej i poziomej, razem z front porch i back porch.
W linii 1 tworzymy liczniki HCounter i VCounter, które wyznaczają współrzędne: pionową i poziomą aktualnie wyświetlanego piksela. Pamiętaj, że poza pikselami w obszarze widocznym mamy także obszary niewidoczne, w których występują sygnały synchronizacji. Następnie w linii 2 mamy blok always, który w każdym takcie zegara inkrementuje licznik poziomy HCounter. Licznik pionowy VCounter inkrementuje się natomiast dopiero wtedy, gdy HCounter osiągnie wartość maksymalną.
Obydwa liczniki służą do tworzenia bardziej abstrakcyjnych zmiennych, takich jak współrzędne znaku do wyświetlenia czy numery kolumny i wiersza, a także do wyboru, która z szesnastu linii znaku ma być aktualnie wyświetlana. Dzięki temu, że każdy znak ma rozmiar 8×16 pikseli, wystarczy wybrać odpowiednie fragmenty licznika poziomego i pionowego. Robimy to w liniach 3, 4 i 5, gdzie wyjściom Column_o, Row_o i Line_o przypisujemy odpowiednie bity liczników zgodnie z tym, co zostało już opisane kilka stron wcześniej.
`default_nettype none
module VGA(
input wire Clock,
input wire Reset,
output wire GetImageRequest_o,
output wire [6:0] Column_o,
output wire [4:0] Row_o,
output wire [3:0] Line_o,
input wire [7:0] PixelsToDisplay_i,
input wire [2:0] ColorForeground_i,
input wire [2:0] ColorBackground_i,
output reg Red_o,
output reg Green_o,
output reg Blue_o,
output reg HSync_o,
output reg VSync_o
);
// Liczniki dla rozdzielczości 640*480, a wraz z obszarem nieaktywnym 800*525
reg [9:0] HCounter; // Max 799 // 1
reg [9:0] VCounter; // Max 524
// Pionowy i poziomy licznik pikseli
always @(posedge Clock, negedge Reset) begin // 2
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
assign Column_o[6:0] = HCounter[9:3]; // 3
assign Row_o[4:0] = VCounter[8:4]; // 4
assign Line_o[3:0] = VCounter[3:0]; // 5
assign GetImageRequest_o = (HCounter[2:0] == 3’b000); // 6
// Licznik linii w obrębie znaku
reg [2:0] CharHCounter; // 7
always @(posedge Clock, negedge Reset) begin // 8
if(!Reset)
CharHCounter <= 3’d7;
else if(HCounter[2:0] == 4) // 9
CharHCounter <= 3’d7;
else
CharHCounter <= CharHCounter – 1’b1;
end
// Timing poziomy
always @(posedge Clock, negedge Reset) begin // 10
if(!Reset) begin
HSync_o <= 1;
{Red_o, Green_o, Blue_o} <= 3’b000;
end
// Poziomy obszar aktywny
else if(HCounter >= 5 && HCounter <= 644 && VCounter <= 451) begin // 11
if(PixelsToDisplay_i[CharHCounter]) // 12
{Red_o, Green_o, Blue_o} <= ColorForeground_i; // 13
els
{Red_o, Green_o, Blue_o} <= ColorBackground_i; // 14
end
// Horizontal front porch
else if(HCounter >= 645 && HCounter <= 660)
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1000;
// Horizontal sync pulse
else if(HCounter >= 661 && HCounter <= 756)
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b0000;
// Horizontal back porch
else
{HSync_o, Red_o, Green_o, Blue_o} <= 4’b1000;
end
// Timing pionowy
always @(posedge Clock, negedge Reset) begin // 15
if(!Reset)
VSync_o <= 1;
else if(HCounter == 5) begin // 16
// Vertical sync pulse
if(VCounter == 490 || VCounter == 491)
VSync_o <= 0;
// Poziomy obszar aktywny, front porch i back porch
else
VSync_o <= 1;
end
end
endmodule
`default_nettype wire
Listing 2. Kod pliku vga.v
Aby poinstruować kontroler pamięci, by przygotował kolejne piksele do wyświetlenia, musimy ustawić wyjście GetImageRequest_o w stan wysoki na jeden cykl zegarowy. Najprościej da się to zrobić tak jak w linii 6, gdzie za pomocą operatora == sprawdzamy, czy trzy najmłodsze bity licznika HCounter są zerami.
Ponieważ istnieje kilka taktów zegarowych opóźnienia między licznikiem HCounter i wyświetlanym obrazem (ze względu na czas pracy kontrolera pamięci), musimy jakoś wprowadzić opóźnienie również do procesu generowania sygnałów VGA. Jednym ze sposobów jest utworzenie dodatkowego, 3-bitowego licznika CharHCounter w linii 7. Następnie w linii 8 rozpoczynamy blok always sterujący tym licznikiem. Liczy on od wartości maksymalnej w dół, po czym resetuje się i ponownie liczy od góry w dół. Aby zsynchronizować ten licznik z licznikiem Hcounter, w linii 9 tworzymy odpowiednią regułę: jeżeli licznik HCounter równy jest 4, to wtedy CharHCounter ustawiamy na wartość początkową, tzn. na 7.
Przejdźmy do linii 10, w której rozpoczyna się blok always, odpowiedzialny za generowanie sygnałów RGB i HSYNC. Pamiętaj, że HSYNC domyślnie ma stan wysoki, co musimy uwzględnić podczas resetu. Poszczególne etapy pracy monitora, tzn. obszar aktywny, front porch, sync pulse i back porch, zależą od wartości licznika Hcounter – podobne rozwiązanie zastosowaliśmy w poprzednich odcinkach kursu. Tym razem w drzewku decyzyjnym if-else musimy dodać kilka taktów zegarowych do porównań, aby uwzględnić opóźnienie generowane przez kontroler pamięci. Z tego powodu obszar jest aktywny wtedy, gdy HCounter ma wartość w przedziale od 5 do 644 (linia 11), a nie od 0 do 639, jak to robiliśmy w odcinku 27.
W obszarze aktywnym korzystamy z 8-bitowej zmiennej PixelsToDisplay_i, która jest dostarczana przez kontroler pamięci. Odczytujemy z niej jeden bit, wskazywany przez licznik CharHCounter (linia 12). Następnie, jeżeli ten bit jest w stanie wysokim, to do wyjść Red_o, Green_o, Blue_o przypisujemy takie stany, aby wyświetlić piksel w kolorze pierwszego planu (zapisanym w zmiennej ColorForeground_i, dostarczanej również przez kontroler pamięci). Jeżeli ten bit jest w stanie niskim, to kopiujemy kolor ze zmiennej ColorBackground_i. Wszystkie trzy składowe kolorów RGB przypisujemy w jednej linijce za pomocą operatora konkatenacji {}.
Do omówienia pozostaje już tylko blok always, odpowiedzialny za sygnał synchronizacji pionowej VSYNC, który rozpoczyna się w linii 15. Sygnał ten zmienia się w zależności od wartości licznika VCounter, lecz wyjście VSync_o musi być zsynchronizowane z pozostałymi sygnałami, doprowadzonymi do monitora. Z tego powodu w linii 16 sprawdzamy, czy HCounter jest równy 5. W innych przypadkach wyjście VSync_o pozostaje w poprzednim stanie.
Dalsza część opisu zostanie opublikowana na kolejnym numerze „Elektroniki Praktycznej” – i będzie to już ostatni odcinek kursu FPGA Lattice!
Dominik Bieczyński
leonow32@gmail.com