Zacznijmy od omówienia sygnałów, jakie znajdują się w 15-pinowym konektorze D-Sub, używanym w interfejsie VGA. Zarówno monitor, jak i generator obrazu (tzn. karta graficzna komputera lub dowolne inne urządzenie dostarczające obrazy graficzne) wyposażone są w złącze żeńskie, a po obu stronach kabla powinny znajdować się wtyczki w wersji męskiej.
Układ wyprowadzeń złącza pokazano na rysunku 1, a opis sygnałów zamieszczono w tabeli 1.
Linie te możemy podzielić na trzy grupy: sygnały analogowe odpowiedzialne za generowanie obrazu (piny 1, 2, 3, 13, 14), cyfrowa szyna danych służąca do identyfikacji monitora i jego możliwości oraz różne masy, które w praktyce można połączyć razem ze sobą.
Na przestrzeni lat zmieniały się standardy identyfikacji monitorów. W latach 90. wprowadzono standard EDID, bazujący na danych zapisanych w prostej pamięci EEPROM typu 24C z interfejsem I²C, która znajduje się wewnątrz monitora. Linie danych pamięci, tzn. SDA i SCL, wyprowadzone są wprost na złącze VGA. Pamięć ta działa nawet wtedy, gdy monitor nie jest podłączony do zasilania – w takiej sytuacji pamięć zasila się z komputera za pośrednictwem pinu 9, który dostarcza napięcie 5 V. Aby odczytać dane z pamięci, musimy wywołać ją na magistrali I²C spod adresu 0x50, a dalej postępować tak samo, jak w przypadku zwyczajnych pamięci szeregowych. W ten sposób można odczytać informacje o modelu monitora, producencie, obsługiwanych rozdzielczościach, kolorach, częstotliwości odświeżania obrazu, itp. Warto dodać, że w taki sam sposób rozpoznawane są monitory ze złączami DVI i HDMI. W tym odcinku kursu nie będziemy jednak szczegółowo omawiać tego tematu.
Piny 1, 2, 3 dostarczają informacje na temat składowych kolorystycznych: czerwonej, zielonej i niebieskiej aktualnie wyświetlanego piksela. Są to sterowane napięciowo wejścia analogowe. Zakres dopuszczalnych napięć może zmieniać od 0 do 0,7 V – im wyższe napięcie, tym jaśniejszy kolor piksela. Każde z tych wejść połączone jest do masy poprzez rezystor 75 Ω. Nasz układ FPGA pracuje przy napięciu 3,3 V, zatem pomiędzy nim a wyjściem VGA musimy umieścić dodatkowe rezystory, które wraz z rezystorami wewnątrz monitora będą tworzyć dzielnik napięcia. Z tego powodu na płytce User Interface Board zastosowano rezystory 270 Ω. Dzielnik napięcia zasilany z 3,3 V, składający się z rezystorów 270 Ω i 75 Ω, da bowiem na wyjściu napięcie 0,7 V. Proste i skuteczne.
Sygnały HSYNC i VSYNC służą do synchronizacji monitora z urządzeniem nadającym sygnał. Sposób synchronizacji jest bardzo staroświecki i wynika ze sposobu działania lamp elektronopromieniowych, zwanych w skrócie CRT. Budowę takiej lampy pokazano na rysunku 2. Składa się ona z działa elektronowego, które emituje wiązkę elektronów oraz z ekranu pokrytego luminoforem. Warstwa luminoforu świeci tym jaśniej, im więcej elektronów ją bombarduje. Ruchem elektronów sterują dwie pary elektrod kierujących, które – w zależności od przyłożonego napięcia – mogą wiązkę elektronów odchylać pionowo i poziomo. W ten sposób wiązka przemiata cały ekran, linia po linii, zaczynając od lewego górnego rogu i kończąc na prawym dolnym.
Sygnał synchronizacji pionowej mówi nam, że właśnie rozpoczyna się transmisja nowej klatki obrazu, a sygnał synchronizacji poziomej oznacza początek każdej kolejnej linii. Sygnały te nazywane są sync pulse. Jednak elektrody odchylania poziomego i pionowego potrzebują trochę czasu, aby cofnąć wiązkę na pozycję początkową, czyli przeładować napięcie, jakie na nich występuje. Z tego powodu potrzebujemy dodatkowych dwóch faz cyklu, nazywanych front porch i back porch. W tym czasie obraz nie jest wyświetlany, a układ sterujący lampą CRT wytwarza odpowiednie napięcia, potrzebne do wyświetlenia kolejnej linii od początku.
W tabelach 2 i 3 zebrano czasy wszystkich czterech części cyklu pionowego i poziomego, obliczone dla rozdzielczości 640×480 pikseli i odświeżania 60 Hz. Jeżeli czas ten przeliczymy na piksele, wówczas okazuje się, że do monitora musimy dostarczyć obraz o rozdzielczości 800×525 pikseli. W tym nadmiarowym czasie sygnały odpowiedzialne za kolory czerwony, zielony i niebieski mają napięcie równe 0 V, a pracują jedynie linie synchronizacji pionowej i poziomej.
Policzmy pewną rzecz. Jeżeli zgodnie z tabelą 2 mamy wyświetlić 640 pikseli w czasie 25,422 μs, to czas wyświetlania jednego piksela powinien wynosić 39,722 ns. Jeżeli obliczymy odwrotność wyniku, to uzyskamy 25,175 MHz. Jest to częstotliwość, jaką najwygodniej będzie taktować układ FPGA, aby każdy takt zegarowy odpowiadał za wyświetlanie jednego piksela. Na szczęście monitory są tolerancyjne i dopuszczają dość duże odchyłki w częstotliwości taktowania sygnałów, zatem bez problemu można zastosować zegar o częstotliwości 25 MHz, bo jest popularniejszy i łatwiej dostępny.
Tabele z timingami dla innych rozdzielczości i częstotliwości odświeżania znajdziesz na stronie pod adresem [4].
Na rysunku 3 zaprezentowano prosty schemat połączenia gniazda VGA z układem FPGA. Układ ten można zastosować także w dowolnym innym systemie pracującym z napięciem 3,3 V. Sygnały kolorów połączone są poprzez rezystory o rezystancji 270 Ω, a sygnały synchronizacji – poprzez 75 Ω. Tranzystory T50 i T51 pracują jako translatory napięć. I²C po stronie monitora pracuje przy napięciu 5 V, a FPGA może działać z napięciem maksymalnie 3,3 V. Rezystory R54 i R55 podciągają linie I²C do zasilania o napięciu 5 V. Istnieje możliwość, by odłączyć interfejs I²C od układu FPGA, jeżeli zajdzie taka konieczność. W tym celu należy wylutować rezystory R53, R56 i R59 o rezystancji 0 Ω.
Moduł VGA
W tym odcinku opracujemy prosty kod, który wygeneruje na monitorze kolorowy wzorek pokazany na fotografii 1. Obraz wytwarzany przez FPGA ma mieć rozdzielczość 640×480 pikseli i częstotliwość odświeżania ekranu równą 60 Hz. Wyświetlimy mozaikę kwadratów o rozmiarze 16×16 pikseli w ośmiu podstawowych kolorach. Wzorek ten będzie nieruchomy i zakodowany na stałe, bez możliwości zmiany podczas pracy FPGA. Celem ćwiczenia jest zapoznanie się z techniką generowania poszczególnych sygnałów. Użyteczne obrazy będziemy wyświetlać w kolejnych odcinkach kursu.
Przejdźmy do analizy kodu zaprezentowanego na listingu 1. Moduł VGA ma jedynie dwa wejścia – Clock i Reset. Generowany obraz ma być zawsze taki sam, więc nie ma potrzeby, aby moduł miał jakiekolwiek inne wejścia. Trzeba podkreślić, że tym razem do wejścia Clock należy doprowadzić sygnał o ściśle określonej częstotliwości 25 MHz, a jeszcze lepiej 25,175 MHz. We wcześniejszych ćwiczeniach stosowaliśmy parametr CLOCK_HZ i moduły na ogół same sobie wyliczały różne dzielniki, ale tym razem będziemy pracować na pełnej częstotliwości sygnału zegarowego bez żadnych spowalniaczy. Projektując płytkę MachXO2 Mega, umieściłem na niej generator kwarcowy o częstotliwości 25 MHz – właśnie na potrzeby dzisiejszego odcinka.
`default_nettype none
module VGA(
input wire Clock, // Pin 25, musi być 25 MHz lub 25,175 MHz
input wire Reset, // Pin 17
output reg Red_o, // Pin 78
output reg Green_o, // Pin 10
output reg Blue_o, // Pin 9
output reg HSync_o, // Pin 1
output reg VSync_o // Pin 8
);
// Liczniki
reg [9:0] HCounter; // 1
reg [9:0] VCounter;
// Maszyny stanów
reg [1:0] HState; // 2
reg [1:0] VState;
// Stany
localparam ACTIVE = 0; // 3
localparam FRONT = 1;
localparam SYNC = 2;
localparam BACK = 3;
// Logika licznika poziomego i pionowego
always @(posedge Clock, negedge Reset) begin // 4
if(!Reset) begin
HCounter <= 0;
VCounter <= 0;
end else begin
if(HCounter != 799) // 5
HCounter <= HCounter + 1’b1; // 6
else begin
HCounter <= 0; // 7
if(VCounter != 524) // 8
VCounter <= VCounter + 1’b1; // 9
else
VCounter <= 0; // 10
end
end
end
// Generowanie testowego kolorowego obrazu
wire [6:0] Sum = HCounter[9:4] + VCounter[9:4]; // 11
wire [2:0] ColorSelector = Sum[2:0]; // 12
// Pozioma maszyna stanów
always @(posedge Clock, negedge Reset) begin // 13
if(!Reset) begin
Red_o <= 0;
Green_o <= 0;
Blue_o <= 0;
HSync_o <= 1; // 14
HState <= ACTIVE;
end
else begin
case(HState) // 15
ACTIVE: begin
if(VState != ACTIVE) begin // 16
{Red_o, Green_o, Blue_o} <= 3’b000;
end else begin
case(ColorSelector) // 17
3’d0: {Red_o, Green_o, Blue_o} <= 3’b100; // czerwony
3’d1: {Red_o, Green_o, Blue_o} <= 3’b110; // żółty
3’d2: {Red_o, Green_o, Blue_o} <= 3’b010; // zielony
3’d3: {Red_o, Green_o, Blue_o} <= 3’b011; // błękitny
3’d4: {Red_o, Green_o, Blue_o} <= 3’b001; // niebieski
3’d5: {Red_o, Green_o, Blue_o} <= 3’b101; // fioletowy
3’d6: {Red_o, Green_o, Blue_o} <= 3’b000; // czarny
3’d7: {Red_o, Green_o, Blue_o} <= 3’b111; // biały
endcase
end
HSync_o <= 1;
if(HCounter == 639) // 18
HState <= FRONT;
end
FRONT: begin // 19
Red_o <= 0;
Green_o <= 0;
Blue_o <= 0;
HSync_o <= 1;
if(HCounter == 655)
HState <= SYNC;
end
SYNC: begin // 20
Red_o <= 0;
Green_o <= 0;
Blue_o <= 0;
HSync_o <= 0;
if(HCounter == 751)
HState <= BACK;
end
BACK: begin // 21
Red_o <= 0;
Green_o <= 0;
Blue_o <= 0;
HSync_o <= 1;
if(HCounter == 799)
HState <= ACTIVE;
end
endcase
end
end
// Pionowa maszyna stanów
always @(posedge Clock, negedge Reset) begin // 22
if(!Reset) begin
VSync_o <= 1; // 23
VState <= ACTIVE;
end
else if(HCounter == 799) begin // 24
case(VState)
ACTIVE: begin
VSync_o <= 1;
if(VCounter == 479)
VState <= FRONT;
end
FRONT: begin
VSync_o <= 1;
if(VCounter == 489)
VState <= SYNC;
end
SYNC: begin
VSync_o <= 0;
if(VCounter == 491)
VState <= BACK;
end
BACK: begin
VSync_o <= 1;
if(VCounter == 524)
VState <= ACTIVE;
end
endcase
end
end
endmodule
`default_nettype wire
Listing 1. Kod pliku vga.v
Kod modułu rozpoczynamy od zadeklarowania dwóch liczników HCounter i VCounter (linia 1). Ich zadaniem będzie wyznaczanie współrzędnych aktualnie wyświetlanego piksela. Licznik HCounter (H to skrót od horizontal) wyznacza, w której kolumnie znajduje się aktualnie wyświetlany pikseli, a VCounter (vertical) określa numer wiersza. Licznik poziomy ma liczyć w zakresie od 0 do 799, a pionowy od 0 do 524. W obu przypadkach musimy użyć 10 bitów, aby pomieścić te liczby. Zwróć uwagę, że zliczać będziemy piksele leżące poza obszarem aktywnym, czyli liczniki wyznaczać będą także sygnały synchronizacji pionowej i poziomej.
W linii 2 i kolejnej tworzymy dwa rejestry maszyn stanów HState oraz VState, odpowiednio dla osi poziomej i pionowej. W obu przypadkach mamy tylko cztery fazy, zatem wystarczy, aby te rejestry były 2-bitowe. Nazwy stanów definiujemy w linii 3 i kolejnych.
W dalszej części kodu mamy trzy bloki always. Pierwszy z nich odpowiada za inkrementację liczników, drugi za oś poziomą, a trzeci – za pionową. Przejdźmy do omówienia pierwszego z nich (linia 4), którego logika jest bardzo prosta. Jeżeli licznik HCounter jest mniejszy od wartości maksymalnej, czyli 799 (linia 5), to inkrementujemy go (linia 6). Jeśli jednak obecna wartość licznika równa jest wartości maksymalnej, to zerujemy ów blok (linia 7) i wykonujemy analogiczną operację na liczniku VCounter. VCounter zwiększa swoją wartość (linia 9) dopiero wtedy, gdy licznik HCounter przepełnia się. Kiedy licznik VCounter osiągnie wartość maksymalną, czyli 524 (linia 8) i jednocześnie HCounter też osiąga maksimum, wówczas VCounter jest zerowany (linia 10).
Przeskoczmy teraz do kolejnego bloku always, który zaczyna się w linii 13. Jest on odpowiedzialny za generowanie sygnałów: czerwonego, zielonego, niebieskiego oraz synchronizacji poziomej HSYNC. Zwróć uwagę, że sygnał synchronizacji podczas spoczynku ma stan wysoki. Dlatego podczas resetu ustawiamy go na 1. Uważaj, by z przyzwyczajenia nie ustawić go na 0, tak jak w przypadku wszystkich innych sygnałów.
Omówimy najpierw sposób pracy maszyny stanów i generowanie sygnału HSYNC. Jest zrealizowany w sposób typowy – składa się z instrukcji case sprawdzającej wszystkie możliwe stany zmiennej HState (linia 15).
Po zresetowaniu układu rejestr stanów maszyny HState inicjalizowany jest wartością ACTIVE, czyli zerem. W takiej sytuacji zajmujemy się generowaniem kolorów, ale tylko wtedy, kiedy pionowa maszyna stanów też pozostaje w fazie ACTIVE. Sprawdzamy to w linii 16. Jeżeli nie, to sygnały kolorów czerwonego, zielonego i niebieskiego powinny być w stanie niskim (sposób generowania barw omówimy kilka akapitów niżej). Niezależnie od tego wyjście HSync_o ma być w stanie wysokim. W taki sposób obsługujemy sygnały pierwszych 640 pikseli, tzn. od 0 do 639. W linii 18 sprawdzamy, czy właśnie teraz generowany jest ostatni piksel z tego zakresu i jeżeli tak, to wtedy do rejestru maszyny stanów wpisujemy wartość FRONT.
Następuje faza front porch (linia 19), w której sygnały kolorów są w stanie niskim, a sygnał synchronizacji cały czas pozostaje w stanie wysokim. Dzieje się to przez czas odpowiadający wyświetlaniu 16 pikseli. W tym czasie licznik HCounter cały czas zlicza takty zegarowe, zatem oczekujemy, aż doliczy do wartości 655 i wtedy do HState wpisujemy SYNC.
Faza synchronizacji jest bardzo podobna do poprzedniej, ale wyjście HSync_o przechodzi w stan niski, co stanowi właściwy sygnał synchronizacji poziomej. Trwa to tak długo, aż licznik HCounter osiągnie wartość 751 – wtedy przechodzimy do fazy back porch (linia 21). Tutaj również nie ma nic zaskakującego: wyjście HSync_o wraca w stan wysoki. Kiedy licznik HCounter osiągnie 799, zaczynamy wszystko od początku (licznik HCounter jest zerowany w poprzednim bloku always).
Zastanówmy się, w jaki sposób wygenerować sygnały odpowiedzialne za powstanie kolorowych kwadratów na monitorze. Chcemy, by kwadraty miały boki o długości 16 pikseli i były w ośmiu podstawowych kolorach. Tak się dobrze składa, że rozdzielczość ekranu, tzn 640×480 px, dzieli się przez 16. Zatem nasza mozaika składać się będzie z 40×30 kwadratów. Liczba 16 została wybrana nieprzypadkowo, ponieważ jest ona czwartą potęgą dwójki (24=16). Dzielenie przez liczbę będącą potęgą dwójki jest bardzo proste. Wystarczy „skreślić” tyle najmłodszych bitów dzielonej liczby, ile jest w wykładniku potęgi. Zatem aby podzielić liczbę przez 16, musimy usunąć cztery najmłodsze bity. Dzielić będziemy wartości liczników HCounter i VCounter (linia 11). Z tych zmiennych operatorem [] wybieramy wszystkie bity z wyjątkiem czterech najmłodszych, po czym dodajemy je do siebie. Dostajemy wynik, który zmienia się w zakresie dużo większym, niż jest nam potrzebny. Dlatego w linii 12 tworzymy 3-bitową zmienną ColorSelector, do której przypisujemy tylko trzy najmłodsze bity sumy. W ten sposób otrzymujemy liczbę zwiększaną co 16 pikseli i zmieniającą się w zakresie od 0 do 7. Ponadto w pierwszej linii kwadratów zaczynamy od 0, a w drugiej linii od 1, w trzeciej od 2 i tak dalej.
Jak zapewne się domyślasz, zawartość zmiennej ColorSelector ma decydować o tym, który kolor jest obecnie wyświetlany. Zobacz linię 17, zawierającą kolejną instrukcję case. Wykonuje się ona tylko wtedy, gdy obie maszyny stanów są w fazie aktywnej. Na podstawie wartości zmiennej ColorSelector następuje przypisanie wyjść Red_o, Green_o, Blue_o. Aby kod był bardziej zwięzły, przypisujemy wartość wszystkim trzem zmiennym jednocześnie za pomocą operatora konkatenacji {}.
Do omówienia pozostaje już tylko trzeci blok always (linia 22), odpowiedzialny za pionową maszynę stanów i sygnał synchronizacji pionowej VSYNC. Sygnał ten jest w stanie wysokim i dlatego podczas resetu wpisujemy do niego 1 (linia 23). Dalej mamy maszynę stanów, jednak jest ona przełączana dopiero wtedy, gdy licznik HCounter osiąga wartość maksymalną (linia 24). Konstrukcja pionowej maszyny stanów jest bardzo podobna do maszyny poziomej, więc nie będziemy jej dokładnie omawiać.
Testbench modułu VGA
Testbench naszego modułu VGA będzie nieskomplikowany. Jego kod pokazano na listingu 2 – ogranicza się do utworzenia instancji testowanego modułu, puszczeniu go w ruch i czekaniu, aż wygeneruje całą klatkę obrazu. Koniec klatki rozpoznajemy w linii 1, gdzie sprawdzamy, czy oba liczniki jednocześnie mają wartość maksymalną.
`timescale 1ns/1ps
`default_nettype none
module VGA_tb();
parameter CLOCK_HZ = 25_175_000;
// 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 = 1’b0;
// Instancja testowanego modułu
VGA DUT(
.Clock(Clock),
.Reset(Reset),
.HSync_o(),
.VSync_o(),
.Red_o(),
.Green_o(),
.Blue_o()
);
// Eksport wyników symulacji
initial begin
$dumpfile(”vga.vcd”);
$dumpvars(0, VGA_tb);
end
// Sekwencja testowa
initial begin
$timeformat(-6, 3, ”us”, 12);
$display(”===== START =====”);
@(posedge Clock);
Reset <= 1’b1;
wait(DUT.VCounter == 524 && DUT.HCounter == 799); // 1
wait(DUT.VCounter == 10); // 2
$display(”===== END =====”);
$finish;
end
endmodule
`default_nettype wire
Listing 2. Kod pliku vga_tb.v
Dalej, w linii 2 czekamy jeszcze trochę czasu, aby ponownie zobaczyć początek klatki.
Aby uruchomić symulację w Icarus Verilog, należy napisać prosty skrypt, którego kod zaprezentowano na listingu 3.
iverilog -o vga.o ^
vga.v ^
vga_tb.v
vvp vga.o
del vga.o
Listing 3. Kod pliku vga.bat
Otwórz plik vga.vcd w przeglądarce GTKWave i skonfiguruj ją tak, by uzyskać efekt pokazany na rysunkach 4 i 5.
Pierwszy z nich reprezentuje zbliżenie na symulację jednej linii obrazu. Widzimy, jak pracuje pozioma maszyna stanów, korzystająca ze zmiennej HState oraz w jaki sposób generowane są sygnały na wyjściach Red_o, Green_o i Blue_o. Ponadto widzimy, w jaki sposób zmienia się sygnał synchronizacji poziomej. Rysunek 5 pokazuje natomiast symulację całej klatki obrazu o rozdzielczości 640×480 pikseli – tutaj możemy analizować pracę pionowej maszyny stanów ze zmienną VState oraz sposób, w jaki zmienia się sygnał synchronizacji pionowej. Za pomocą kursorów można zmierzyć czas trwania wszystkich sygnałów, aby zweryfikować, czy są one zgodne z tym, co podano wcześniej w tabelach 2 i 3.
Testy w FPGA
W tym odcinku nie będzie modułu top. Nasz projekt jest na tyle prosty, że moduł VGA może bezpośrednio pełnić funkcję modułu nadrzędnego. Z tego powodu drzewko projektu w Lattice Diamond powinno wyglądać tak, jak pokazano na rysunku 6.
Przeprowadź syntezę, a następnie otwórz narzędzie Spreadsheet i skonfiguruj piny układu FPGA w sposób pokazany na rysunku 7.
Pamiętaj, że używamy zewnętrznego generatora sygnału zegarowego, więc powinniśmy także podać jego częstotliwość w Timing Preferences (zakładki na dole Spreadsheet), co pokazuje rysunek 8.
Po wgraniu bitstreamu do FPGA i podłączeniu monitora na ekranie powinien pokazać się obraz podobny do tego z fotografii 1. Grafika powinna być ostra i nieruchoma. Kiedy wejdziemy w ustawiania monitora zobaczymy, że obraz ma rozdzielczość 640×480 px, a częstotliwość odświeżania to 60 Hz, czyli wszystko działa zgodnie z planem (fotografia 2).
W ostatnich czasach popularność zyskują proste graficzne wyświetlacze OLED ze sterownikami SSD1309, SSD1306 czy SH1106. Są niedrogie i łatwe w obsłudze. Kontrolery te odbierają dane z mikrokontrolera przez interfejs SPI i wyświetlają ładną grafikę. W następnym odcinku kursu zastosujemy opracowane wcześniej interfejsy SPI oraz VGA, aby stworzyć replikę sterownika OLED. Z punktu widzenia procesora jego obsługa nie będzie różniła się niczym od obsługi zwykłego wyświetlacza OLED, ale nasz sterownik będzie pokazywał obraz nie na małej matrycy OLED, lecz na „pełnowymiarowym” monitorze VGA.
Dominik Bieczyński
leonow32@gmail.com
Repozytorium modułów wykorzystywanych w kursie https://github.com/leonow32/verilog-fpga
Projekt w programie Diamond https://ep.com.pl/files/bmh/13709-kurs_fpga_lattice_27._obsluga_monitora_vga.zip
Budowa lampy elektronopromieniowej CRT https://www.physics-and-radio-electronics.com/blog/cathode-ray-tube-crt
Timing sygnałów VGA http://tinyvga.com/vga-timing