Kurs FPGA Lattice (30). Zakończenie

Kurs FPGA Lattice (30). Zakończenie

Jest to ostatni odcinek kursu FPGA Lattice. Dokończymy terminal VGA i zastanowimy się… co dalej? Jest to drugi najdłuższy kurs w 32-letniej historii „Elektroniki Praktycznej”. Pomimo to niniejszy materiał należy traktować jako wstęp do tematyki układów FPGA oraz języka Verilog, pozostaje bowiem wiele tematów, o których nawet nie wspomniałem. W ostatnim odcinku kursu postaram się chociaż skrótowo opowiedzieć o funkcjonalnościach, których na łamach EP nie prezentujemy.

Moduł Memory

Moduł Memory jest najdłuższym i najbardziej zawiłym modułem w tym projekcie. Długo zastanawiałem się, czy nie byłoby lepiej podzielić go na pamięć czcionki i pamięć danych do wyświetlenia, lecz ostatecznie stwierdziłem, że te wszystkie obszary danych będą w jednym miejscu.

Moduł składa się z dwóch funkcjonalnych części, tworzących jedną logiczną całość. Pierwszą z nich jest odbieranie bajtów, pochodzących z interfejsu UART. Bajty te mogą być znakami sterującymi kursorem – enter, backspace i escape (który umieszcza kursor na pozycji (0,0), czyli w lewym górnym rogu). Mogą być też kodem ASCII znaku lub informacjami o kolorach. Druga część to obsługiwanie zapytań z VGA, co wiąże się z odczytywaniem danych z pamięci obrazu i czcionki.

W tym momencie musimy przyjrzeć się dokładniej pamięci RAM, w której zapisywany jest tekst oraz kolory. Musimy w niej przechowywać 2400 bajtów tekstu i 2400 bajtów informacji o kolorach, czyli razem 4800 bajtów. Wygodnie byłoby mieć osobne pamięci na tekst i kolory. Wtedy bajt tekstu i bajt koloru w obu pamięciach miałyby te same adresy, a ponadto operacje odczytu/zapisu z/do obu pamięci mogłyby wykonywać się jednocześnie. Potrzebowalibyśmy trzech bloków EBR na tekst i kolejnych trzech na kolor. Problem tylko w tym, że mamy do dyspozycji 5 bloków EBR o pojemności 1024 bajty każdy.

Zatem musimy scalić wszystko w jedną pamięć, aby pomieścić się w pięciu blokach EBR. Kod znaku i kolory umieścimy w następujących po sobie bajtach. Tworzy to ciekawą właściwość – jeżeli najmłodszy bit adresu ma wartość 0 to adres wskazuje na bajt tekstu, a jeżeli 1 to na bajt koloru (rysunek 8). Ta cecha jest wspólna dla wszystkich znaków w pamięci. Pozostałą część adresu można łatwo obliczyć znając numer kolumny i wiersza. Wystarczy numer wiersza przemnożyć przez 80 i dodać numer kolumny – a te właśnie informacje kontroler pamięci otrzymuje od modułu VGA.

Rysunek 8. Adresowanie pamięci obrazu

Aby zaadresować 4800 bajtów potrzebujemy 13-bitowego adresu. I tutaj pojawia się problem, bo Lattice Synthesis Engine, mając 13-bitowy adres, syntezuje pamięć o rozmiarze, jaki maksymalnie można zaadresować wykorzystując adres o takiej szerokości, tzn 8192 bajty. Na etapie mapowania dostaniemy błąd, bo taka pamięć potrzebuje 8 bloków EBR, a mamy tylko 5. Poza tym nawet, gdybyśmy mieli jeszcze trzy wolne bloki EBR do użytku, to zostałyby one bezsensownie zmarnowane.

Rozwiązaniem tego problemu jest skorzystanie z dodatku IP Express lub rozbicie pamięci na osobne bloki i scalenie ich przy pomocy własnego dekodera adresu. Wybrałem tę drugą opcję aby pokazać, jak można to zrobić.

Spójrz jeszcze raz na rysunek 8. Wejście adresowe bloku EBR jest 10-bitowe, ponieważ w pojemność bloku EBR to 1024 bajty (210=1024). Skoro potrzebujemy pięciu takich bloków, to musimy wstawić dodatkową zmienną 3-bitową, aby wskazywać który blok EBR chcemy odczytać lub zapisać. W ten sposób tworzymy 13-bitowy adres, który składa się z numeru bloku EBR i adresu wewnątrz EBR.

Przejdźmy wreszcie do kodu pokazanego na listingu 3.

// Plik memory.v

`default_nettype none

module Memory(
input wire Clock,
input wire Reset,

// Żądanie zapisu danych z UART do pamięci obrazu RAM
input wire AnalyzeRequest_i,
input wire [7:0] DataFromUART_i,

// Żądanie odczytu z modułu VGA
input wire GetImageRequest_i,
input wire [6:0] Column_i, // Zakres 0..79
input wire [4:0] Row_i, // Zakres 0..29
input wire [3:0] Line_i, // Zakres 0..15 (każdy znak składa się z 16 linii)

// Wyjście z kontrolera pamięci do modułu VGA
output reg [7:0] Pixels_o,
output reg [2:0] ColorForeground_o,
output reg [2:0] ColorBackground_o
);

// Zmienne do obsługi zapisu danych do pamięci obrazu
reg WriteStep1; // 1
reg WriteStep2;
reg WriteRequest;
reg [ 7:0] WriteBuffer;
reg [12:0] WriteAddress;
reg [ 7:0] ColorBuffer;

// Współrzędne znaku, który ma zostać zapisany jako kolejny
reg [ 6:0] CursorX; // Zakres 0..79
reg [ 4:0] CursorY; // Zakres 0..29
wire [31:0] WriteCharNum = CursorY * 80 + CursorX; // Zakres 0..2399 // 2

// Maszyna stanów do analizowania danych odebranych przez UART
// i do zapisywanie ich w pamięci obrazu
always @(posedge Clock, negedge Reset) begin // 3
if(!Reset) begin
WriteStep1 <= 0;
WriteStep2 <= 0;
WriteRequest <= 0;
WriteBuffer <= 0;
WriteAddress <= 0;
ColorBuffer <= 8’b1_111_0_000; // Biały tekst na czarnym tle // 4
CursorX <= 0;
CursorY <= 0;
end

// Jeżeli odebrano bajt danych przez UART
// to analizujemy, co dalej robić z tym bajtem
else if(AnalyzeRequest_i) begin // 5
casex(DataFromUART_i) // 6

// Przejście do nowej linii
8’h0D: begin
CursorX <= 0;

if(CursorY != 29)
CursorY <= CursorY + 1’b1;
else
CursorY <= 0;
end

// Backspace
8’h7F: begin
if(CursorX != 0)
CursorX <= CursorX – 1’b1;
else begin
CursorX <= 79;
if(CursorY != 0)
CursorY <= CursorY – 1’b1;
else
CursorY <= 29;
end
end

// Przesuń kursor na początek ekranu
8’h1B: begin
CursorX <= 0;
CursorY <= 0;
end

// Ustaw kolor
8’b1XXXXXXX: begin // 7
ColorBuffer <= DataFromUART_i;
end

// Zapisz tekst
8’b0XXXXXXX: begin // 8
// Kod ASCII znaku zostanie zapisany w kolejnym takcie zegara
WriteStep1 <= 1; // 9
WriteRequest <= 1;
WriteBuffer <= DataFromUART_i;
WriteAddress <= {WriteCharNum[11:0], 1’b0};
end
endcase
end

// W poprzednim cyklu, kod znaku ASCII został zapisany pod adresem XXXXXXXXXXXX0
// Teraz chcemy zapisać ColorBuffer pod adresem XXXXXXXXXXXX1
else if(WriteStep1) begin // 10
WriteStep1 <= 0;
WriteStep2 <= 1;
WriteRequest <= 1;
WriteBuffer <= ColorBuffer;
WriteAddress <= {WriteCharNum[11:0], 1’b1};
end

// W poprzednim cyklu, najt koloru zapisany pod adresem XXXXXXXXXXXX0
// Teraz chcemy skasować żądanie zapisu i przesuwamy kursor na kolejną pozycję
else if(WriteStep2) begin // 11
WriteStep2 <= 0;
WriteRequest <= 0;

if(CursorX != 79) begin
CursorX <= CursorX + 1’b1;
end else begin
CursorX <= 0;
if(CursorY != 29)
CursorY <= CursorY + 1’b1;
else
CursorY <= 0;
end
end
end

// Odczytywanie danych tekstu i koloru z pamięci RAM
reg [ 2:0] ReadState; // 12
reg [12:0] ReadAddress;
reg [10:0] FontAddress;
wire [31:0] ReadCharNum = Row_i * 80 + Column_i; // Zakres 0..2399

always @(posedge Clock, negedge Reset) begin // 13
if(!Reset) begin
ReadState <= 0;
ReadAddress <= 0;
FontAddress <= 0;
Pixels_o <= 0;
ColorForeground_o <= 0;
ColorBackground_o <= 0;
end

else case(ReadState) // 14

// Odczytaj kod ASCII znaku z pamięci obrazu RAM
0: begin
if(GetImageRequest_i) begin
ReadAddress <= {ReadCharNum[11:0], 1’b0};
ReadState <= ReadState + 1’b1;
end
end

// Odczytaj bajt koloru z pamięci obrazu RAM
1: begin
ReadAddress <= {ReadCharNum[11:0], 1’b1};
ReadState <= ReadState + 1’b1;
end

// Odczytaj bitmapę czcionki. DataFromImageRAM to kod ASCII znaku,
// żądanego dwa cykle zegarowe wcześniej
2: begin
FontAddress <= {DataFromImageRAM[6:0], Line_i[3:0]};
ReadState <= ReadState + 1’b1;
end

// Nic nie rób, czekamy aż FontROM wystawi odpowiedź na swoje wyjście
3: begin
ReadState <= ReadState + 1’b1;
end

// Skopiowanie wszystkich odczytanych danych na wyjścia modułu
4: begin
Pixels_o <= DataFromFontROM[7:0];
ColorForeground_o <= DataFromImageRAM[6:4];
ColorBackground_o <= DataFromImageRAM[2:0];
ReadState <= 0;
end

endcase
end

// Pamięć obrazu – tekst i kolor
// Każdy blok EBR ma pojemność 1024*8bit
wire [7:0] DataFromImageRAM_[0:4];

wire [7:0] DataFromImageRAM = (ReadAddress[12:10] == 3’d0) ? DataFromImageRAM_[0] :
(ReadAddress[12:10] == 3’d1) ? DataFromImageRAM_[1] :
(ReadAddress[12:10] == 3’d2) ? DataFromImageRAM_[2] :
(ReadAddress[12:10] == 3’d3) ? DataFromImageRAM_[3] :
DataFromImageRAM_[4];

PseudoDualPortRAM #(
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1024)
) ImageRAM_0(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadAddress[12:10] == 3’d0),
.WriteEnable_i(WriteRequest && (WriteAddress[12:10] == 3’d0)),
.ReadAddress_i(ReadAddress[9:0]),
.WriteAddress_i(WriteAddress[9:0]),
.Data_i(WriteBuffer),
.Data_o(DataFromImageRAM_[0])
);

PseudoDualPortRAM #(
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1024)
) ImageRAM_1(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadAddress[12:10] == 3’d1),
.WriteEnable_i(WriteRequest && (WriteAddress[12:10] == 3’d1)),
.ReadAddress_i(ReadAddress[9:0]),
.WriteAddress_i(WriteAddress[9:0]),
.Data_i(WriteBuffer),
.Data_o(DataFromImageRAM_[1])
);

PseudoDualPortRAM #(
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1024)
) ImageRAM_2(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadAddress[12:10] == 3’d2),
.WriteEnable_i(WriteRequest && (WriteAddress[12:10] == 3’d2)),
.ReadAddress_i(ReadAddress[9:0]),
.WriteAddress_i(WriteAddress[9:0]),
.Data_i(WriteBuffer),
.Data_o(DataFromImageRAM_[2])
);

PseudoDualPortRAM #(
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1024)
) ImageRAM_3(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadAddress[12:10] == 3’d3),
.WriteEnable_i(WriteRequest && (WriteAddress[12:10] == 3’d3)),
.ReadAddress_i(ReadAddress[9:0]),
.WriteAddress_i(WriteAddress[9:0]),
.Data_i(WriteBuffer),
.Data_o(DataFromImageRAM_[3])
);

PseudoDualPortRAM #(
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(704)
) ImageRAM_4(
.ReadClock(Clock),
.WriteClock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadAddress[12:10] == 3’d4),
.WriteEnable_i(WriteRequest && (WriteAddress[12:10] == 3’d4)),
.ReadAddress_i(ReadAddress[9:0]),
.WriteAddress_i(WriteAddress[9:0]),
.Data_i(WriteBuffer),
.Data_o(DataFromImageRAM_[4])
);

// Pamięć czcionki
// Obsługuje znaki o kodach ASCII od 0 do 127
// Rozmiar każdego znaku to 16x8 pikseli, czyli 16 B na każdy znak.
// Cała pamięć ma pojemność 2048 bajtów.
wire [7:0] DataFromFontROM;

ROM #(
.ADDRESS_WIDTH(11),
.DATA_WIDTH(8),
.MEMORY_DEPTH(2048),
.MEMORY_FILE(„font_0_127.mem”)
) FontROM(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(1’b1),
.Address_i(FontAddress),
.Data_o(DataFromFontROM)
);

endmodule

`default_nettype wire
Listing 3. Kod pliku memory.v

Pierwsza część kodu odpowiedzialna jest za odbieranie znaków z UART i ich analizowanie. Na potrzeby tego zadania musimy utworzyć kilka zmiennych pomocniczych, zaczynając od linii 1.

  • WriteStep1 i WriteStep2 to rejestry ułatwiające zapisywanie kolejno po sobie bajtów koloru i tekstu. Omówimy je dokładniej później.
  • WriteRequest to rejestr sterujący wejściami WriteEnable_i wszystkich pamięci RAM. Ustawienie tego rejestru w stan wysoki spowoduje, że przy najbliższym zboczu rosnącym pamięć zapisze dane z wejścia Data_i pod adresem, wskazywanym przez wartość znajdującą się na wejściu WriteAddress_i.
  • WriteBuffer – 8-bitowy rejestr przechowujący dane do zapisania w pamięci RAM. W zależności od fazy zapisu, jest to bajt koloru lub bajt tekstu.
  • ColorBuffer to 8-bitowy rejestr przechowujący ostatnio odebrany bajt koloru, domyślnie ustawiony jest w taki sposób, aby wyświetlać biały tekst na czarnym tle. Po odebraniu bajtu koloru ten rejestr zostanie zaktualizowany, a następnie wszystkie znaki odebrane później będę pokolorowane tak samo do czasu, aż zostanie odebrany inny bajt koloru.
  • WriteAddress – adres pod którym ma zostać zapisany bajt tekstu lub koloru.
  • CursorX i CursorY – współrzędne znaku, do którego ma zostać zapisany następny bajt tekstu, odebrany przez UART.
  • WriteCharNum – numer znaku, obliczony metodą z linii 2 (a zarazem bity [12:1] adresu do zapisania).

W linii 3 rozpoczyna się blok always, którego celem jest analizowanie danych z UART i zapisywanie ich we właściwym miejscu pamięci RAM. Podczas resetu zerujemy wszystkie wymienione wcześniej zmienne, za wyjątkiem ColorBuffer, bo chcemy, aby domyślnie tekst miał kolor biały na czarnym tle (linia 4). Gdybyśmy wyzerowali też ColorBuffer, to wyświetlany byłby czarny tekst na czarnym tle.

Niniejszy blok always to drzewko decyzyjne if-else, które sprawdza czy zmienne AnalyzeRequest_i, WriteStep1 lub WriteStep2 są ustawione w stan wysoki. Jeżeli wszystkie te zmienne mają stan niski, to nic się nie dzieje.

Wejście AnalyzeRequest_i (linia 5) jest ustawiane przez odbiornik UART i sygnalizuje, że pojawiły się nowe dane na wejściu DataFromUART_i. Analizę tych danych wykonujemy przy pomocy instrukcji casex (linia 6). Różni się to od zwykłego case tym, że możemy wskazać bity nieistotne i oznaczyć je jako X. Mogą przyjmować stan 0 lub 1. Pierwsze trzy akcje, jakie mogą się wykonać, to operacje przesuwania kursora. Jeżeli odebrany został bajt 0D, to przechodzimy do kolejnej linii, 7F to backspace, a 1B top escape, który ustawia kursor w zerowym wierszu i zerowej kolumnie. Kod tych operacji jest prosty i nie wymaga komentarza.

W linii 7 sprawdzamy, czy odebrany bajt to 8’b1XXXXXXX – tak sformułowany zapis oznacza, że najstarszy bit musi być jedyną, a pozostałe bity mogą mieć wartość dowolną. Zgodnie z rysunkiem 5 jest to bajt koloru, zatem linijkę niżej zapisujemy ów bajt do rejestru ColorBuffer.

W linii 8 mamy podobną sytuację. Jeżeli najstarszy bit jest zerem, to traktujemy go jako tekst (za wyjątkiem trzech specjalnych bajtów do sterowania kursorem, opisanych wcześniej). W takiej sytuacji modyfikujemy kilka zmiennych, aby zrealizować zapis do pamięci.

Ustawiamy WriteRequest w stan wysoki, do bufora WriteBuffer kopiujemy bajt z UART, a adres do zapisu tworzymy, biorąc WriteCharNum[11:0] i doklejając zero na najmłodszym bicie. I wreszcie, ustawiamy WriteStep1 w stan wysoki.

Przechodzimy teraz do linii 10, gdzie wykonują się różne operacje, kiedy WriteStep1 jest w stanie wysokim. Pamiętaj, że równolegle, w tym samym czasie, pamięć RAM zapisuje bajt tekstu w taki sposób, jak to przed chwilą zostało opisane. Musimy teraz skonfigurować zapis koloru. Tym razem do WriteBuffer wpisujemy informacje o kolorach, które przechowywane są w ColorBuffer, a adres do zapisu tworzymy, biorąc WriteCharNum[11:0] i doklejając jedynkę na najmłodszym bicie. Zerujemy WriteStep1 i ustawiamy WriteStep2 w stan wysoki.

W linii 11 mamy operacje wykonywane wtedy, gdy ustawiony jest WriteStep2. Pamięć równolegle zapisuje bajt koloru – w tym samym czasie, w którym wykonywane są operacje z tego bloku. Pozostaje już tylko posprzątać po procedurze zapisu – zerujemy zatem WriteStep2 oraz WriteRequest, by w kolejnym cyklu zegarowym już nic do pamięci się nie zapisywało. Ponadto musimy przesunąć kursor na kolejną pozycję w prawo lub na początek kolejnej linii.

Nauczyliśmy się zapisywać dane tekstu i kolory do pamięci RAM, a teraz zobaczymy, jak je odczytywać. Przypominam, że pamięć obrazu to pseudo-dwuportowa pamięć RAM, zatem nie musimy się przejmować problemami wynikającymi z jednoczesnego zapisu i odczytu.

W linii 12 tworzymy kilka zmiennych na potrzeby odczytu. ReadState to rejestr maszyny stanów – będzie on przyjmować jedną z pięciu wartości. Nie będziemy ich jakoś konkretnie nazywać przy pomocy definicji. Będą to liczby od 0 do 4 – każdy z tych stanów następuje kolejno i trwa dokładnie przez czas jednego taktu zegarowego. ReadAddress to adres danych odczytywanych z pamięci obrazu, a FontAddress jest używany przez pamięć ROM czcionki. Ponadto mamy numer odczytywanego znaku ReadCharNum, obliczany w taki sam sposób, jak WriteCharNum.

Blok always, odpowiedzialny za odczyt danych, rozpoczyna się w linii 13. Składa się on tylko z inicjalizacji zmiennych podczas resetu i instrukcji case, która wykonuje akcje na podstawie wartości zmiennej ReadState i obsługuje wszystkie możliwe fazy odczytywania (linia 14).

W fazie 0 sprawdzamy, czy na wejściu GetImage Request_uri pojawił się stan wysoki. To wejście jest ustawiane przez moduł VGA i rozpoczyna cały proces odczytu. Do rejestru ReadAddress wpisujemy numer znaku do odczytu z doklejonym zerem na najmłodszym bicie, po czym inkrementujemy zmienną ReadState.

W kolejnym takcie zegarowym, kiedy ReadState jest równe 1, pamięć RAM odczytuje żądanie, jakie wystosowaliśmy w powyższym akapicie, a odpowiedź pojawi się na wyjściach pamięci w kolejnym takcie. Wydaje się, że mamy tutaj dwa cykle zegarowe zmarnowane. Nie do końca. W czasie, kiedy pamięć odczytuje dane, możemy na jej wejściach przygotować adres kolejnego bajtu do odczytu. Zatem do zmiennej ReadAddress wpisujemy ponownie numer znaku do odczytu z doklejoną jedynką na końcu. Inkrementujemy ReadState.

Mamy teraz fazę 2. Pamięć RAM wystawiła na swoje wyjście jakieś dane (a konkretnie – kod ASCII znaku). Odczytujemy ten kod, doklejając do niego numer jednej z szesnastu linii, która ma zostać wyświetlona (zobacz rysunek 4 z poprzedniego odcinka kursu). Zapisując wartość do zmiennej FontAddress spowodujemy, że w kolejnym cyklu pamięć czcionki zacznie odczytywać bitmapę znaku do wyświetlenia. Jednocześnie pamięć RAM odczytuje bajt koloru. Ponownie inkrementujemy ReadState.

W fazie 3 pamięć RAM wystawia na swoje wyjście bajt koloru. Jednak nic z nim nie robimy, bo czekamy, aż pamięć czcionki skończy odczyt. Inkrementujemy ReadState i nic więcej.

Teraz dochodzimy do fazy 4, która kończy procedurę odczytu danych z pamięci. Wszystkie pamięci już udostępniły potrzebne informacje. Musimy jedynie skopiować wyniki na wyjścia kontrolera pamięci, aby moduł VGA mógł je wyświetlić. Zatem kopiujemy wyjście z pamięci czcionki DataFromFontROM na wyjście Pixels_o, a do ColorForeground_o i ColorBackground_o kopiujemy odpowiednie trzy bity uzyskane z pamięci obrazu.

Zdaję sobie sprawę z tego, że to co opisałem może wydawać się strasznie zagmatwane. Dane „wyplute” przez jedną pamięć stają się adresem odczytu drugiej pamięci, a ta pierwsza równolegle coś jeszcze odczytuje… Możliwość jednoczesnego działania wielu elementów w tym samym czasie sprawia, że układy FPGA mogą mieć fenomenalną wydajność, ale taki sposób pracy bywa trudny do zrozumienia, ponieważ jesteśmy przyzwyczajeni, że program wykonuje się linia po linii. Sposób pracy FPGA to coś, do czego najtrudniej jest się przyzwyczaić programistom.

W dalszej części kodu tworzymy poszczególne pamięci i przypisujemy do nich sygnały, jakie już omówiliśmy.

Zobaczmy jak wygląda plik czcionki font_0_127.mem, którym inicjalizujemy pamięć ROM. Składa się on z bitmap wszystkich znaków zapisanych w postaci szesnastkowej oraz z komentarzy, aby zawartość tego pliku była zrozumiała dla człowieka. Porównaj listing 4 z rysunkiem 3. Plik ten został wygenerowany automatycznie.

// Char 70
00 // ........
00 // ........
FE // #######.
66 // .##..##.
62 // .##...#.
68 // .##.#...
78 // .####...
68 // .##.#...
60 // .##.....
60 // .##.....
60 // .##.....
F0 // ####....
00 // ........
00 // ........
00 // ........
00 // ........

Listing 4. Fragment pliku font_0_127.mem

W tym miejscu powinniśmy omówić testbenche do wszystkich modułów, lecz niniejszy odcinek nawet bez nich jest bardzo długi, a testbenche do VGA są... okropnie nudne. Nie ma w nich nic ciekawego, a testowanie polega tylko na ręcznym sprawdzaniu, co robią poszczególne elementy w kolejnych cyklach zegara i czy timing sygnałów jest właściwy. Jeżeli chcesz zobaczyć testbenche i wyniki symulacji, to zajrzyj do repozytorium na GitHubie, dostępnego pod adresem [1].

Testujemy na żywo

Przebrnęliśmy przez całą teorię, więc czas wreszcie zobaczyć efekty naszej pracy. Uruchom Lattice Diamond i utwórz w nim nowy projekt, do którego dodasz pliki, które pokazano na rysunku 9.

Rysunek 9. Drzewko projektu

Nie wymaga to żadnego komentarza. Przeprowadź syntezę, a następnie otwórz Spreadsheet i skonfiguruj piny układu FPGA w taki sposób, jak na rysunku 9. Pamiętaj, by w Spreadsheet wybrać zakładkę Timing Preferences i skonfigurować częstotliwość sygnału zegarowego, dostarczonego do wejścia Clock (rysunek 10).

Rysunek 10. Konfiguracja pinów w Spreadsheet

Syntezujemy, wgrywamy bitstream do FPGA, podłączamy monitor i… nic. Ale tak właśnie miało być. Musimy jeszcze przesłać jakieś dane, które nasz terminal ma wyświetlić. W tym celu posłużymy się mikrokontrolerem ESP32, który można umieścić na płytce User Interface Board.

Rysunek 11. Konfiguracja zegara w Spreadsheet

Na listingu 5 pokazano prosty skrypt w MicroPythonie. Należy go uruchomić w edytorze Thonny lub innym współpracującym ze środowiskiem MicroPython na ESP32. Kod jest na tyle prosty, że do jego zrozumienia wystarczą komentarze zawarte w pliku. Aby uruchomić kod w Thonny należy nacisnąć przycisk F5. Natychmiast na monitorze powinien pojawić się taki obraz, jak na zdjęciu tytułowym. Obraz powinien być ostry i stabilny, a kolory powinny być bardzo intensywne – takie, jak we wczesnych komputerach z lat 90.

# Plik demo.py

# Zaimportowanie modułu UART
from machine import UART

# Tworzenie i konfiguracja portu UART
# Port 1, szybkość 115200 bit/s, numer pinu nadajnika Tx, numer pinu odbiornika Rx
uart = UART(1, baudrate=115200, tx=16, rx=17)

uart.write(bytearray([0x1B])) # Escape, powrót kursora na pozycję 0,0
uart.write(bytearray([0xF0])) # Białe znaki, czarne tło
uart.write(„================================================================================”)
uart.write(„ Test terminala UART-VGA „)
uart.write(„================================================================================”)

uart.write(bytearray([0x0D])) # Nowa linia

uart.write(bytearray([0b11110000])) # Białe znaki, czarne tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b11000000])) # Czerwone znaki, czarne tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b11100000])) # Żółte znaki, czarne tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10100000])) # Zielone znaki, czarne tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10110000])) # Cyan foreground, black background
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10010000])) # Blue foreground, black background
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b11010000])) # Magenta foreground, black background
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0x0D])) # Nowa linia

uart.write(bytearray([0b10000111])) # Czarne znaki, białe tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000100])) # Czarne znaki, czerwone tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000110])) # Czarne znaki, żółte tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000010])) # Czarne znaki, zielone tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000011])) # Czarne znaki, błękitne tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000001])) # Czarne znaki, niebieskie tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

uart.write(bytearray([0b10000101])) # Czarne znaki, fioletowe tło
uart.write(„ABCDEFGHIJKLMNOPQRTSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()-=<>”)

# Ikony
uart.write(bytearray([0x0D])) # Nowa linia
uart.write(bytearray([0xF0])) # Białe znaki, czarne tło

for char in range(0x00, 0x80): # Pętla od 0x00 do 0x7F
if char == 0x0D: # Jeżeli aktualny znak to 0x0D (nowa linia)
continue # Wykonaj kolejny obieg pętli
if char == 0x1B: # Jeżeli aktualny znak to 0x1B (powrót na początek)
continue; # Wykonaj kolejny obieg pętli
uart.write(bytearray([char])) # Prześlij znak przez UART

Listing 5. Kod pliku demo.py

Co dalej?

Pewnie większość Czytelników słyszała o tym, że w FPGA można zaimplementować procesor. Oczywiście jest to prawda i czasami takie rozwiązanie bywa lepsze niż „normalny” procesor, ale jest związany z tym szereg różnych problemów. Musimy sobie najpierw odpowiedzieć na szereg pytań związanym z własnym procesorem:

  • Jaki rdzeń CPU? Możemy zastosować gotowe rozwiązania, zarówno płatne jak i dostępne za darmo w Internecie. Niektórzy hobbyści stworzyli repliki rdzeni 8051, AVR, PIC, STM, które możemy obudować swoimi peryferiami. Możemy także spróbować sił i stworzyć własny rdzeń. Wbrew pozorom, wcale to nie jest takie trudne.
  • Jaka architektura? Istnieje szereg różnych metod łączenia CPU z pamięcią programu i pamięcią RAM oraz peryferiami. Dobrym pomysłem jest zapoznanie się z typowymi interfejsami stosowanymi w FPGA, takich jak Wishbone, AXI i Avalon.
  • Jakie peryferia? Aby procesor był użyteczny do czegokolwiek, musi mieć jakieś peryferia, takie jak porty czy różne interfejsy komunikacyjne. Tutaj zastosowanie FPGA stwarza wielkie możliwości, bo możemy sami sobie stworzyć peryferia takie, jakch potrzebujemy. W szczególności może to być bardzo istotne, jeżeli mamy bardzo nietypowe potrzeby i żaden mikrokontroler dostępny na rynku nie oferuje odpowiadających nam peryferiów.
  • Jaki język programowania? Każdy procesor można zaprogramować w assemblerze, ale bywa to uciążliwe, zwłaszcza w bardziej rozbudowanych systemach. Miło byłoby mieć kompilator C lub C++, ale skąd kompilator ma wiedzieć, jak działa nasz procesor? Tutaj pojawia się duży problem, bo o ile napisanie procesora w Verilogu jest stosunkowo proste, to opracowanie własnego kompilatora to już nie lada wyzwanie. Najlepiej byłoby wykorzystać jakiś gotowy kompilator, ale to ogranicza nas do takich procesorów, jakie ów kompilator obsługuje.

Z pomocą przychodzą wszyscy producenci FPGA, oferując rdzenie, peryferia i kompilatory. Lattice oferuje nam dwa rozwiązania.

Pierwszym jest LatticeMico System. Jest to kompletne środowisko, pozwalające wybrać i skonfigurować prosty procesor 8- lub 32-bitowy, pamięci i peryferia. Poszczególne elementy systemu łączy się przy pomocy interface’u Wishbone. W programie jest dostępny także kompilator języka C i programator. LatticeMico generuje wszystko to, co potrzebne jest do umieszczenia gotowego rozwiązania wewnątrz FPGA, jednak jest to rozwiązanie bardzo stare i już nie rozwijane, a ostatnia aktualizacja była opublikowana w 2010 roku.

Nowszym rozwiązaniem jest Lattice Propel, które bazuje na architekturze RISC-V, która staje się coraz popularniejsza w dzisiejszych czasach. Na przykład firma Espressif wszystkie nowe układy ESP32 opiera właśnie na rdzeniach RISC-V i zastępuje wcześniej stosowany Tensilica Xtensa. RISC-V jest procesorem open source, który każdy może zastosować bez ponoszenia żadnych opłat licencyjnych. Ponadto dostępne są także kompilatory C i C++, obsługujące współczesne standardy tych języków.

W tym miejscu kończymy kurs FPGA Lattice. W następnym wydaniu „Elektroniki Praktycznej” rozpoczniemy nowy kurs – będzie to MicroPython dla mikrokontrolerów ESP32!

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/eez/13720-kurs_fpga_lattice_29._terminal_tekstowy_vga.zip
Artykuł ukazał się w
Elektronika Praktyczna
kwiecień 2025
DO POBRANIA
Materiały dodatkowe
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik listopad 2025

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio listopad - grudzień 2025

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje listopad - grudzień 2025

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna listopad 2025

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich grudzień 2025

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów