Eksperymenty z FPGA (17). Interfejs VGA

Eksperymenty z FPGA (17). Interfejs VGA

Kilka ostatnich częściach kursu poświęciliśmy zagadnieniom związanym z przetwarzaniem dźwięku. Czas więc na coś nowego. Tym razem wyświetlimy obraz na monitorze za pomocą interfejsu VGA. Jak zwykle kod znajduje się w repozytorium http://bit.ly/33uYPxs. Przed wykonaniem eksperymentów zachęcam do pobrania najnowszej wersji (na przykład za pomocą polecenia git pull).

VGA

Interfejs VGA składa się z trzech analogowych sygnałów przesyłających informacje o kolorach: czerwonym, zielonym i niebieskim. Informacja o kolorze przesyłana jest analogowo. Napięcie 0,7 V oznacza maksymalną jasność, natomiast 0 V wygaszenie. Poza kolorami mamy dwa sygnały synchronizacji: poziomej Hsync oraz pionowej Vsync.

Rysunek 1. Podłączenie VGA do płytki Rysino

Interfejs funkcjonuje w formie złącza DB15. Sposób podłączenia do płytki Rysino został pokazany na rysunku 1. Sygnały synchronizacji łączymy przez dodatkowe rezystory o oporze 100 Ω, które zabezpieczają wyjścia układu FPGA. Ważniejszą rolę pełnią oporniki na liniach z sygnałem kolorów. Razem z rezystorem wbudowanym w monitor tworzą one dzielniki napięcia.

Rysunek 2. Dzielnik napięcia sterujący sygnałem kolorów

Jak widzimy na rysunku 2 ustawienie stanu wysokiego spowoduje wygenerowanie napięcia 0,6 V. W ten sposób otrzymaliśmy najprostszy sterownik portu VGA. Ponieważ każdy z naszych trzech „przetworników cyfrowo-analogowych” ma rozdzielczość jednego bita, możemy otrzymać tylko osiem różnych kolorów. Ich lista została pokazana w tabeli 1 [1].

Wiemy już jak kodowane są kolory. Jednak monitor musi wiedzieć, do którego piksela odnoszą się kolejne dane. Możliwe jest to dzięki sygnałom synchronizującym. Naszym celem będzie wyświetlenie obrazu o rozdzielczości 640×480, jednak z przyczyn historycznych sygnał musi zawierać dodatkowe przerwy (były niezbędne dla prawidłowej pracy starych monitorów typu CRT). Dlatego przesyłane dane odpowiadają rozdzielczości 800×525 ale tylko część tego obszaru jest aktywna, tak jak to zostało pokazane na rysunku 3.

Rysunek 3. Pojedyncza ramka

Pierwsze osiem pikseli w wierszu to tak zwany front porch. Następnie przez 96 pikseli następuje przesłanie sygnału synchronizacji, czyli podania stanu niskiego na linię Hsync. Dalej następuje 40 pikseli back porch oraz 8 pikseli lewej ramki. Dopiero teraz możemy przesłać pojedynczy wiersz, czyli 640 pikseli obrazu. Na końcu mamy jeszcze 8 pikseli prawej ramki [2].
Przebieg czasowy sygnałów został pokazany na rysunku 4.

Rysunek 4. Synchronizacja pozioma (Hsync)

Sygnał RGB obrazuje zmiany sygnału analogowego na liniach kolorów, natomiast Hsync jest sygnałem synchronizacji. Zarówno ramka jak i porch nie są dla nas istotne. Musimy jedynie wiedzieć kiedy możemy nadawać dane oraz kiedy musimy wywołać sygnał synchronizacji. Aby ułatwić sobie pracę możemy „przesunąć” nasz licznik, aby wartość 0 oznaczała rozpoczęcie właściwych danych. Gdy ten sam trik z przesunięciem licznika zastosujemy dla osi pionowej, wtedy kolejne piksele będą miały pozycję taką jak pokazano na rysunku 5.

Rysunek 5. Kolejność przesyłania pikseli

Został nam jeszcze ostatni sygnał – synchronizacja pionowa. Informuje ona o przejściu do kolejnej ramki. Jego przebieg (już przy przesunięciu licznika tak, aby zaczynać liczenie od wyświetlanych danych) został pokazany na rysunku 6.

Rysunek 6. Synchronizacja pionowa (Vsync)

Znamy już kształt przebiegów. Brakuje nam jeszcze ostatniego fragmentu układanki: czasu trwania. Będziemy wyświetlać 60 ramek na sekundę. Oznacza to, że częstotliwość zegara będzie wynosiła 60·800·525, czyli 25,2 MHz.

Generujemy sygnał synchronizujący

Znamy już działanie interfejsu. Możemy więc przejść do implementacji. Podzielimy ją na dwie części. Pierwszą będzie generowanie sygnałów synchronizacyjnych, a drugą generowanie obrazu. Pozwoli nam to później na podmianę samej grafiki, bez zmiany całego projektu.

Rysunek 7. Generowanie sygnału synchronizacyjnego

Schemat pierwszej z nich został pokazany na rysunku 7. Jak widzimy jest on zaskakująco prosty. Sercem są dwa liczniki. Pierwszy z nich zliczający modulo 800 odpowiada za zliczanie kolumn, a drugi modulo 525 za zliczanie wierszy. Następnie mamy kilka bloków logiki kombinacyjnej. Sprawdzamy tu, czy wartość licznika mieści się w zadanym zakresie. Na tej podstawie generujemy sygnały synchronizacji. Dodatkowo wytwarzamy pomocniczy sygnał valid. Będzie on miał stan wysoki wtedy, gdy pracujemy na pikselu będącym częścią wyświetlanego obrazu. Na końcu umieszczamy jeszcze przerzutniki zatrzaskujące stan wyjść modułu.

Listing 1. Implementacja bloku synchronizacji (15_VGA/vga.sv)

10 module vga #(
11 parameter H = 800,
12 parameter V = 525,
13 parameter H_BIT = $clog2(H),
14 parameter V_BIT = $clog2(V),
15 parameter H_VALID_MIN = 0,
16 parameter H_VALID_MAX = 639,
17 parameter V_VALID_MIN = 0,
18 parameter V_VALID_MAX = 479,
19 parameter H_SYNC_MIN = 656,
20 parameter H_SYNC_MAX = 751,
21 parameter V_SYNC_MIN = 490,
22 parameter V_SYNC_MAX = 491
23 ) (
24 input wire clk,
25 input wire rst,
26 output logic hsync,
27 output logic vsync,
28 output logic valid,
29 output logic [H_BIT-1:0]hcnt,
30 output logic [V_BIT-1:0]vcnt
31 );
32 logic ov;
33 logic [H_BIT-1:0]hcnt_r;
34 logic [V_BIT-1:0]vcnt_r;
35
36 counter #(.N(H)) counter_H (
37 .clk(clk),
38 .rst(rst),
39 .ce(1’b1),
40 .q(hcnt_r),
41 .ov(ov));
42
43 counter #(.N(V)) counter_V (
44 .clk(clk),
45 .rst(rst),
46 .ce(ov),
47 .q(vcnt_r),
48 .ov());
49
50 always_ff @(posedge clk)
51 if (!rst) begin
52 hcnt <= ‘0;
53 vcnt <= ‘0;
54 hsync <= ‘0;
55 vsync <= ‘0;
56 valid <= ‘0;
57 end else begin
58 hcnt <= hcnt_r;
59 vcnt <= vcnt_r;
60 hsync <= !(hcnt_r >= H_SYNC_MIN && hcnt_r <= H_SYNC_MAX);
61 vsync <= !(vcnt_r >= V_SYNC_MIN && vcnt_r <= V_SYNC_MAX);
62 valid <= hcnt_r >= H_VALID_MIN && hcnt_r <= H_VALID_MAX
63 && vcnt_r >= V_VALID_MIN && vcnt_r <= V_VALID_MAX;
64 end
65 endmodule

Implementację tego bloku pokazuje kod z listingu 1. Na początku mamy dość długą listę parametrów, w której definiujemy rozmiar ramki oraz obliczamy liczbę bitów potrzebnych do jej przechowywania. Następnie przyjmujemy dolne i górne granice dla sygnałów valid oraz synchronizacji. W liniach 24...30 określamy wejścia i wyjścia. Dalej znajdują się dwie instancje naszego modułu licznika. Samo generowanie sygnałów znajduje się w bloku always_ff (wiersze 50...64). Najpierw znajdziemy obsługę resetu, a dalej instrukcje porównań sprawdzające, czy liczniki znajdują się w zdefiniowanych zakresach.

Do sprawdzenia poprawności użyjemy testbenchu, który znajduje się w pliku 15_VGA/vga_tb.sv. Składa się on tylko z generowania sygnału zegarowego, resetu oraz instancji modułu vga.

Aby go uruchomić w symulatorze ModelSim uruchamiamy skrypt: do ./vga.do

Rysunek 8. Wynik symulacji modułu vga

Uzyskany wynik został pokazany na rysunku 8. Na górze, mamy sygnał zegarowy oraz reset. Dopiero poniżej znajdziemy interesujące nas wyjścia. Pierwsze dwa to Hsync i Vsync. Rysunek pokazuje całą ramkę, co możemy rozpoznać po pojawieniu się stanu niskiego na linii Vsync. Dalej znajdziemy sygnał valid. Na dole mamy jeszcze liczniki hcnt otaz vcnt pokazane jako liczba oraz na wykresie. Widzimy, że licznik vcnt zaczyna od 0 i dochodzi do wartości 524. Aby zobaczyć jak wygląda pojedynczy wiersz, musimy przybliżyć wykres, co zostało pokazane na rysunku 9.

Rysunek 9. Generowanie sygnałów dla pojedynczego wiersza

Widzimy tu fragment blisko końca ramki (co poznajemy po stanie niskim na Vsync). Z tego powodu sygnał valid nie jest ustawiony. Możemy się jednak przyjrzeć jak wygląda licznik hcnt zmieniający się od 0 do 799 oraz zobaczyć działanie sygnału Hsync.

Generujemy obraz testowy

Mamy gotowe sygnały sterujące. Kolejnym krokiem jest zbudowanie modułu, który na podstawie tych sygnałów wygeneruje obraz. Na początku wyświetlimy grafikę podobną do tej z rysunku 10 – osiem kwadratów, każdy w innym kolorze.

Rysunek 10. Obraz testowy – kwadraty

Schemat blokowy takiego modułu został pokazany na rysunku 11. Widzimy tu logikę kombinacyjną, która na podstawie sygnałów hcnt, vcnt oraz valid generuje trzy składowe kolorów: r, g i b. Na końcu mamy rejestry zatrzaskujące wyjścia. Zwróćmy uwagę, że wszystkie sygnały muszą mieć takie same opóźnienie, dlatego musimy dodać przerzutniki, także dla sygnałów synchronizacyjnych.

Rysunek 11. Schemat blokowy generatora kwadratów

Sama logika kombinacyjna, też nie będzie skomplikowana. Składową czerwoną dobieramy na podstawie licznika vcnt, a dwie pozostałe na podstawie hcnt. Implementacja została pokazana na listingu 2. Na początku przyjmujemy parametry określające długość wejściowych liczników. Dalej znajdziemy wejścia i wyjścia. Pierwsze dwa to oczywiście zegar i reset. Następnie przyjmujemy wszystkie wyjścia z bloku vga. Na ich podstawie będziemy generować pięć sygnałów, które bezpośrednio trafią na wyjścia układu FPGA.

Listing 2. Moduł odpowiedzialny za generowanie kwadratów (15_VGA/squares.sv)

10 module squares #(
11 parameter H = 800,
12 parameter V = 525,
13 parameter H_BIT = $clog2(H),
14 parameter V_BIT = $clog2(V)
15 ) (
16 input wire clk,
17 input wire rst,
18 input wire hsync_in,
19 input wire vsync_in,
20 input wire valid_in,
21 input wire [H_BIT-1:0]hcnt_in,
22 input wire [V_BIT-1:0]vcnt_in,
23 output logic hsync,
24 output logic vsync,
25 output logic red,
26 output logic green,
27 output logic blue
28 );
29
30 always_ff @(posedge clk)
31 if (!rst) begin
32 hsync <= ‘0;
33 vsync <= ‘0;
34 red <= ‘0;
35 green <= ‘0;
36 blue <= ‘0;
37 end else begin
38 hsync <= hsync_in;
39 vsync <= vsync_in;
40    if (valid_in) begin
41 red <= vcnt_in < 240;
42 if (hcnt_in < 160)
43 {green, blue} <= {1’b0, 1’b0};
44 else if (hcnt_in < 320)
45 {green, blue} <= {1’b0, 1’b1};
46 else if (hcnt_in < 480)
47 {green, blue} <= {1’b1, 1’b0};
48 else
49 {green, blue} <= {1’b1, 1’b1};
50 end else
51 {red, green, blue} <= ‘0;
52 end
53
54 endmodule

Całą logika zawarta jest w bloku always_ff. Rozpoczyna się on od resetu. Następnie w liniach 37...52 znajdziemy generowanie sygnałów. Jeżeli sygnał valid jest ustawiony sprawdzamy wartości liczników vcnt i hcnt i ustawiamy wyjścia r, g i b. W przeciwnym przypadku przyjmują one stan niski.

Rysunek 12. Połączenie generatora synchronizacji z generatorem obrazu

Kolejnym krokiem jest połączenie obu przygotowanych bloków. Schemat głównego modułu pokazuje rysunek 12. Poza znanymi nam blokami pojawia się tu, znana nam już z wcześniejszych eksperymentów, pętla PLL. Pozwala ona na wygenerowanie sygnału o częstotliwości 25,175 MHz. Minimalnie różni się ona od wymaganej 25,2 MHz. Nie możemy jednak uzyskać dokładniejszej wartości ze względu na możliwe zakresy mnożenia i dzielenia częstotliwości.

Gotowy moduł znajduje się w pliku 15_VGA/vga_top.sv, natomiast testbench to plik o nazwie 15_VGA/vga_top_tb.sv. Uruchamiamy go poleceniem: do ./vga_top.do

Uzyskany wynik został zaprezentowany na rysunku 13. Możemy tu zobaczyć sygnały synchronizacji oraz kolorów (red, green, blue).

Fotografia 1. Podłączenie złącza VGA do płytki Rysino

Po sprawdzeniu symulacji możemy przejść do testów ze sprzętem. Na fotografii 1 zostało pokazane złącze VGA podłączone do płytki Rysino (zgodnie ze schematem z rysunku 1). Do złącza DB15 zostały przylutowane przewody, dzięki którym można je podpiąć do płytki stykowej.

Fotografia 2. Kwadraty wyświetlone na monitorze

Teraz możemy uruchomić środowisko Quartus. Otwieramy projekt 15_VGA/vga.qpf i rozpoczynamy budowę. Po zaprogramowaniu układu FPGA zobaczymy wynik podobny do tego z fotografii 2. Mój monitor pozwala także, na wyświetlenie parametrów odebranego sygnału, tak jak to pokazuje fotografia 3.

Fotografia 3. Parametry sygnału video

Widzimy, że odbierany sygnał ma rozdzielczość 640×480 pikseli. Synchronizacja pionowa ma częstotliwość 60 Hz, a pozioma 31 kHz (60 Hz razy 525 wierszy daje 31,5 kHz). Uzyskaliśmy więc oczekiwane przez nas parametry.

Wyświetlamy grafikę

Spróbujmy jeszcze wyświetlić grafikę, którą wcześniej przygotujemy w programie MS Paint. Będziemy ją przechowywali w pamięci RAM. Najpierw jednak oszacujmy ile bitów jest nam potrzebnych. Dla obrazka o rozmiarze 640×480 z 3-bitową głębią kolorów otrzymujemy ponad 900 tysięcy bitów. Natomiast cała pamięć dostępna w naszym układzie to tylko 189 tysięcy bitów. Dlatego zdecydujemy się na mniejszy obraz. Na początek wybrałem 80×60 pikseli. Liczby te otrzymałem poprzez podzielenie poprzednich rozmiarów przez 8. Pozwoli to nam na łatwe obliczenie obecnego piksela poprzez odrzucenie trzech najmłodszych bitów liczników hcnt i vcnt.

Rysunek 14. Odczytywanie obrazu z pamięci

Teraz możemy narysować schemat nowego generatora obrazu. Został on pokazany na rysunku 14 Adres odpowiedniej komórki pamięci otrzymujemy poprzez połączenie bitów hcnt i vcnt. Samej organizacji pamięci musimy przyjrzeć się trochę bliżej.

Rysunek 15. Organizacja pamięci przechowującej obrazek

Jak widzimy na rysunku 15 pierwsze 6 bitów określa numer wiersza, a starsze 7 numer kolumny. Oznacza to że adresy od 0 do 60 przechowują piksele kolumny 0. Jednak następnie mamy 4 adresy, od 61 do 63, które nie przechowują żadnych istotnych danych. Są one zaznaczone literą X. Można powiedzieć, że marnują się. Jednak dzięki temu znacząco upraszczamy adresowanie. Poświęcamy część pamięci za zmniejszenie dodatkowej logiki odpowiedzialnej za generowanie adresów.

Listing 3. Implementacja odczytywania obrazka z pamięci (15_VGA/from_mem.sv)

10 module from_mem #(
11 parameter H = 800,
12 parameter V = 525,
13 parameter H_BIT = $clog2(H),
14 parameter V_BIT = $clog2(V)
15 ) (
16 input wire clk,
17 input wire rst,
18 input hsync_in,
19 input vsync_in,
20 input valid_in,
21 input [H_BIT-1:0]hcnt_in,
22 input [V_BIT-1:0]vcnt_in,
23 output logic hsync,
24 output logic vsync,
25 output logic red,
26 output logic green,
27 output logic blue
28 );
29 logic [6:0]hcnt;
30 logic [5:0]vcnt;
31 logic [2:0]img[5119:0];
32
33 initial $readmemb("test.mem", img);
34
35 assign hcnt = hcnt_in[9:3];
36 assign vcnt = vcnt_in[8:3];
37
38 always_ff @(posedge clk)
39 if (!rst) begin
40 hsync <= ‘0;
41 vsync <= ‘0;
42 red <= ‘0;
43 green <= ‘0;
44 blue <= ‘0;
45 end else begin
46 hsync <= hsync_in;
47 vsync <= vsync_in;
48 {red, green, blue} <= valid_in ? img[{hcnt, vcnt}] : ‘0;
49 end
50
51 endmodule

Kod naszego modułu znajduje się na listingu 3. Jego interfejs jest identyczny jak w przypadku generatora kwadratów. Obrazek jest przechowywany w tablicy img. Jej wartość początkowa jest wczytywana w bloku initial (linia 33). Następnie wycinamy z wejściowych liczników interesujące nas bity (wiersze 35...36). Samo odczytywanie znajduje się w bloku always_ff. Sygnały synchronizujące są tylko zatrzaskiwane w dodatkowym rejestrze. Linia 48 realizuje odczyt z pamięci. Jeżeli sygnał valid jest ustawiony trafiają one na wyjście, a w przeciwnym razie jest ono zerowane.

Rysunek 16. Przykładowa grafika – plik 15_VGA/test.bmp

Musimy jeszcze przygotować wsad dla pamięci. Posłużymy się tu programem MS Paint. Przykładowy obrazek znajdziemy w pliku 15_VGA/test.bmp (rysunek 16). Ma on rozmiary 80×60 pikseli. Przygotowując własną grafikę musimy zwrócić uwagę, aby używać tylko ośmiu kolorów z naszej listy.

Listing 4. Skrypt w języku Python3 przygotowujący wsad dla pamięci (15_VGA/bmp2mem.ipynb)

01 from PIL import Image
02 import numpy as np
03
04 img = Image.open(‘test.bmp’)
05 img
06
07 H = 80
08 V = 60
09 C = 3
10 img_t = np.array(img).reshape(V, H, C)
11
12 bdata = ""
13 for i in range(H):
14 for j in range(V):
15 for c in range(C):
16 bdata += "1" if img_t[j, i, c] > 128 else "0"
17 bdata += "\n"
18 for j in range(4):
19 bdata += "000\n"
20
21 f = open("test.mem", "w")
22 f.write(bdata)
23 f.close()

Następnie skorzystamy ze skryptu napisanego w języku Python3. Jego kod znajduje się na listingu 4. Najpierw ładujemy biblioteki: PIL pozwala na obsługę plików graficznych, a numpy wspiera obliczenia numeryczne. W linii 4 wczytujemy nasz obrazek i następnie go wyświetlamy. Wiersze 7...9 zawierają pomocnicze stałe: szerokość, wysokość oraz liczbę kolorów, których używamy przy konwersji obrazu na tablicę. Sama konwersja ma miejsce w pętlach (12...19). Zwróćmy uwagę na dodatkową pętlę (18...19), która wypełnia 4 nieużywane komórki pamięci. Na końcu zapisujemy uzyskany wynik w pliku test.mem.

Rysunek 17. Dołączenie bloku z rysunkiem do głównej instancji

Teraz możemy dołączyć nasz moduł do głównego projektu. Prezentuje to schemat z rysunku 17. Umieszczamy go równolegle z generatorem kwadratów. Oba przyjmują to samo wejście z generatora sygnału synchronizacyjnego. Następnie za blokami umieszczamy multiplekser. Będziemy nim sterować z przełącznika dip-switch, który znajduje się na płytce. Dzięki temu będziemy mogli przełączać się pomiędzy dwoma obrazami.

Listing 5. Implementacja multipleksera (15_VGA/vga_top1.sv)

72 always_ff @(posedge clk_vga) begin
73 hsync <= s ? hsync_s : hsync_f;
74 vsync <= s ? vsync_s : vsync_f;
75 red <= s ? red_s : red_f;
76 green <= s ? green_s : green_f;
77 blue <= s ? blue_s : blue_f;
78 end

Implementacja multipleksera jest bardzo prosta. Jak widzimy na listingu 5 została zrealizowana za pomocą operatora ?:. Test do nowego modułu głównego znajduje się w pliku 15_VGA/vga_top1_tb.sv. Uruchamiamy go poleceniem: ./vga_top1.do

Rysunek 18. Zmiana głównego modułu projektu

Wynik jest podobny do tego z poprzedniej symulacji, więc nie będę go tu zamieszczał. Za to przejdziemy do testów ze sprzętem. Najpierw w środowisku Quartus musimy zmienić moduł główny na vga_top1.sv (rysunek 18). W tym celu w panelu Project Navigator przechodzimy do zakładki Files. Klikamy prawym przyciskiem myszy na vga_top1.sv i z menu kontekstowego wybieramy Set as Top-Level Entity.

Fotografia 4. Obrazek wyświetlony na monitorze za pomocą płytki Rysino

Teraz możemy zbudować projekt i zaprogramować płytkę. Na ekranie powinniśmy zobaczyć obraz podobny do tego z fotografii 4. A po zmianie położenia przełącznika wrócimy z powrotem do znanych nam już kwadratów.

Podsumowanie

W tym odcinku zapoznaliśmy się z działaniem interfejsu VGA. Następnie użyliśmy go do wyświetlenia obrazu testowego i prostej grafiki. W kolejnej części spróbujemy zrobić coś bardziej interaktywnego.

Rafał Kozik
rafkozik@gmail.com

Bibliografia:
[1] https://bit.ly/3fveHcT
[2] https://bit.ly/3u6kCci

Artykuł ukazał się w
Elektronika Praktyczna
kwiecień 2021

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik lipiec 2021

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio lipiec - sierpień 2021

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka Podzespoły Aplikacje lipiec 2021

Automatyka Podzespoły Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna lipiec 2021

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich sierpień 2021

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów