Eksperymenty z FPGA (21). Monitor jako wyświetlacz alfanumeryczny

Eksperymenty z FPGA (21). Monitor jako wyświetlacz alfanumeryczny

Zgodnie z obietnicą, w tym odcinku zmienimy monitor w wyświetlacz alfanumeryczny, i to nie byle jaki, bo mieszczący dokładnie 2000 znaków. W tym odcinku zajmiemy się samym renderowaniem obrazu. Czas na modyfikację tekstu przyjdzie w kolejnym odcinku.

Tak jak zawsze, przed przystąpieniem do wykonywania eksperymentów zachęcam do aktualizacji repozytorium z przykładami [1], na przykład poprzez wywołanie polecenia git pull.

Wyświetlamy znaki

Koncepcja terminala znakowego została pokazana na rysunku 1. Składa się on z czterech bloków. Dwa z nich już znamy: moduły obsługi portu szeregowego oraz generator sygnału VGA.

Rysunek 1. Koncepcja terminala

Do implementacji zostały nam pozostałe dwa. W tej części przygotujemy generowanie obrazu, a w przyszłym miesiącu zajmiemy się zarządzaniem tekstem. Rozdzielenie tych dwóch zadań uprości implementację oraz pozwoli na ich osobne przetestowanie.

Rysunek 2. Generowanie obrazu

Uproszczony schemat modułu generowania obrazu został pokazany na rysunku 2. Składa się on z dwóch pamięci. Pierwsza z nich, dwuportowa pamięć RAM przechowuje tekst, a druga – pamięć ROM, przechowuje czcionkę (font). Na końcu znajdziemy jeszcze moduł odpowiedzialny za wyświetlenie kursora.

Do dyspozycji mamy ekran o rozdzielczości 640×480 pikseli. Dla uproszczenia zdecydujemy się na font o stałej szerokości znaków. Jest kilka używanych wymiarów znaku. Na przykład popularne kontrolery alfanumerycznych wyświetlaczy LCD HD44780 używają liter o rozmiarze 7 na 5 pikseli. Jednak w przypadku wyświetlania tekstów na monitorach VGA popularny jest font o wymiarach 19 wierszy na 8 kolumn.

Rysunek 3. Organizacja ekranu

Jak widzimy na rysunku 3 pozwoli to na wyświetlenie dwudziestu wierszy po 80 znaków w każdym. Łącznie zmieścimy więc dwa tysiące liter. Zostanie nam także niewykorzystany pasek o wysokości pięciu pikseli.

Font

Musimy jeszcze znaleźć czcionkę. Moglibyśmy sami ją zaprojektować, jednak jest to zajęcie pracochłonne i dodatkowo wymagające zmysłu estetycznego. Prostszym rozwiązaniem jest znalezienie gotowego projektu, najlepiej na którejś z przyjaznych licencji. Z pomocą przyszedł nam projekt UEFI – Unified Extensible Firmware Interface. Jest to interfejs pomiędzy systemem operacyjnym a firmware, następca dobrze znanego BIOS-u. Jest on dostępny na licencji BSD. A ponieważ pracuje on przed startem systemu operacyjnego potrzebuje także własnego zestawu czcionek.

Interesujący kod znajdziemy w pliku [2]. Jego kopię, po drobnych zmianach tłumaczących kod z języka C na Python znajdziemy w pliku font/font_uefi.py. Jego fragment został zaprezentowany na listingu 1.

Listing 1. Opis czcionki (17_terminal/font/font_uefi.py)

05 font = [
06 [ 0x0020, 0x00, [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00]],
07 [ 0x0021, 0x00, [0x00,0x00,0x00,0x18,0x3C,0x3C,0x3C,0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x18,0x00,0x00,0x00,0x00]],

42 [ 0x0042, 0x00, [0x00,0x00,0x00,0xFC,0x66,0x66,0x66,0x66,0x7C,0x66,0x66,0x66,0x66,0x66,0xFC,0x00,0x00,0x00,0x00]],

Pojedynczy wiersz tablicy font opisuje jeden znak. Jest on listą, gdzie pierwszy element oznacza kod znaku w standardzie ASCII, a zapisana na końcu tablica 19 zmiennych 1-bajtowych opisuje kolejne wiersze. W zrozumieniu co one dokładnie oznaczają pomoże nam rysunek 4.

Rysunek 4. Wygląd litery „B”

Obrazuje on znak o kodzie ASCII 0x42 (66 dziesiętne) czyli dużą literę „B”. Kolejne bajty opisują wiersze od góry do dołu. Natomiast w każdym wierszu po lewej stronie znajdują się piksele zakodowane przez najbardziej znaczący bit.

Listing 2. Zapisanie fontu do pliku graficznego (17_terminal/font/parse_font.py)

05 CHARS = 128
06 HIGHT = 19

33 img = Image.new(‘RGB’, (WIDTH*16, HIGHT*8), color = ‘black’)
34
35 char = 0
36 for y in range(8):
37   for x in range(16):
38     c = y*16+x
39     if char < len(font) and c == font[char][0]:
40       for a in range(HIGHT):
41         for b in range(WIDTH):
42           if font[char][2][a] & (1 << b) != 0:
43             img.putpixel((WIDTH*(x+1)-b, HIGHT*y+a), (255, 255, 255))
44       char += 1
45
46 img.save("font.png")

Aby zobaczyć jak wyglądają pozostałe znaki posłużymy się krótkim skryptem w języku Python. Został pokazany na listingu 2. Na początku, w linii 33 tworzymy pusty obraz. Będzie się on składał z ośmiu wierszy po szesnaście znaków. Następnie w pętlach z wierszy 36 i 37 przejdziemy przez wszystkie znaki. Za pomocą zmiennej pomocniczej c obliczmy wartość wybranego znaku. Ponieważ nie wszystkim kodom ASCII odpowiadają znaki drukowalne, musimy sprawdzić czy obecny znak znajduje się w tablicy. Na końcu w wewnętrznych pętlach (linie 40 i 41) piksel po pikselu rysujemy kolejne znaki. Wynik zostanie zapisany w pliku font.png (linia 46).

Aby uruchomić powyższy program musimy mieć zainstalowaną bibliotekę Pillow. Możemy ją pozyskać poleceniem:

sudo pip3 install Pillow

Uzyskany wynik został zaprezentowany na rysunku 5.

Rysunek 5. Wszystkie znaki dostępne w naszym foncie

Organizacja pamięci

Znaleźliśmy font, teraz musimy umieścić go w pamięci ROM. Naszym celem jest łatwy dostęp do interesującego nas piksela. Nawet kosztem zmarnowania części pamięci. Organizacja danych została pokazana na rysunku 6.

Rysunek 6. Organizacja pamięci fontu

Słowo w pamięci ma długość 8 bitów i odpowiada pojedynczemu wierszowi. Adres składa się z dwóch części:

  • starsze 7 bitów to kod ASCII znaku,
  • młodsze 5 bitów to numer wiersza.

Jak widzimy kosztem takiej adresacji jest strata 13 z każdych 32 słów (czyli nieco ponad 40%). Jednak należy mieć na uwadze, że poświęcamy ją dla uzyskania prostoty rozwiązania. A ponadto dzięki uproszczeniu logiki zużyjemy mniej elementów logicznych. W zależności jakich zasobów brakuje w naszym projekcie, możemy próbować różnych sposobów na optymalizację. Łączny rozmiar pamięci to 4096 bajtów.

Listing 3. Przygotowanie wsadu dla pamięci fontu (17_terminal/font/parse_font.py)

05 CHARS = 128
06 HIGHT = 19
07 WIDTH = 8
08 MEM_HIGHT = 32
09
10 data = ""
11 char = 0
12
13 str_format = "{{0:0{}b}}\r\n".format(WIDTH)
14 str_format_nel = "{{0:0{}b}}".format(WIDTH)
15
16 for i in range(CHARS):
17   if char >= len(font) or i < font[char][0]:
18     for j in range(MEM_HIGHT):
19         data += str_format.format(0)
20   else:
21     for j in range(HIGHT):
22       c = str_format_nel.format(font[char][2][j])
23       data += c[::-1]
24       data += "\r\n"
25     for j in range(MEM_HIGHT-HIGHT):
26       data += str_format.format(0)
27     char += 1
28
29 f = open("font.mem", "w")
30 f.write(data)
31 f.close()

Możemy więc już przygotować skrypt, który przygotuje wsad dla naszej pamięci. Jest on pokazany na listingu 3. Na początku definiujemy pomocnicze stałe: liczbę znaków, jego rozmiar oraz liczbę słów w pamięci, która na niego przypada. W wierszach 13 i 14 tworzymy ciągi znaków reprezentujące linie danych oraz „pustą” (wpisywaną do nieużywanych fragmentów pamięci). Następnie w pętli iterujemy przez wszystkie znaki. Jeżeli znak o danym numerze znajduje się w tablicy, jest on dopisywany do pamięci. Następnie nieużywane pola są wypełniane zerami (25...26). Na zakończenie wygenerowany tekst jest zapisywany do pliku (linie 29...31). Wynik znajdziemy w pliku 17_terminal/font/font.mem.

Rysunek 7. Organizacja pamięci danych

Druga pamięć służy do przechowywania danych. Musimy w niej zmieścić 2000 znaków, które mają być wyświetlane na monitorze. Pojedyncze słowo w pamięci ma także długość ośmiu bitów i przechowuje kod ASCII znaku. Sposób adresowania prezentuje rysunek 7:

  • starsze 7 bitów to numer kolumny,
  • młodsze 5 bitów to numer wiersza.

Aby równocześnie mogła być ona używana do generowania obrazu, oraz edytowana przez użytkownika użyjemy dwu-portowej pamięci RAM. Jednak na początku chcemy tylko wyświetlać, dlatego przygotujemy tekst testowy.

Wsad do pamięci także wygenerujemy za pomocą skryptu. Na początku definiujemy stałe (wiersze 1...6). Następnie w wierszu 8 tworzymy szablon pojedynczej linii. Pierwszy i ostatni wiersz zawiera tylko znaki drukowalne, natomiast do reszty wpisywane są po kolei wszystkie 128 możliwości. Gotowy wsad znajdziemy w pliku 17_terminal/test_page.mem.

Adresowanie pamięci

Wiemy już jak rozlokować dane w pamięci. Pozostało nam ją jeszcze odpowiednio zaadresować. Rozwiązanie zostało zaprezentowane na rysunku 8.

Rysunek 8. Odczyt wartości aktualnego piksela

Wyjściem naszego modułu VGA jest pozycja aktualnego piksela: x i y. W przypadku osi poziomej rozdział na numer znaku oraz położenie wewnątrz znaku jest proste: dzielenie przez 8 oraz modulo 8 sprowadza się do podzielenia wektora: trzy młodsze bity to numer piksela w znaku, a 6 starszych to numer znaku.

Sprawa ma się gorzej dla osi pionowej. Wysokość jednego znaku to 19 pikseli. Operacja dzielenia przez stałą jest syntezowalna, ale spróbujemy prostszego rozwiązania: zrobimy zestaw liczników zliczających kolejne wiersze: pierwszy z nich liczący modulo 19 pokazuje pozycję w znaku. Drugi wyzwalany przepełnieniem poprzedniego liczy wiersze tekstu. Pierwszym krokiem jest oczytanie odpowiedniego znaku z pamięci danych. Składamy więc adres korzystając z starszych części pozycji x i y tworząc wektor: [x2,y2]. Pozwoli on odczytać kod ASCII znaku. Pamięć (oraz cały nasz moduł) działają przepływowo. Wynik będzie dostępny na wyjściu w następnym cyklu zegara. Razem z numerem kolumny y1 posłuży on do zaadresowania pamięci fontu. Jednak aby dane były poprawne musimy wyrównać jego opóźnienie dodając rejestr. Ostatnim krokiem jest wybranie piksela z odczytanego wiersza. Tutaj użyjemy multipleksera sterowanego trzema młodszymi bitami położenia w poziomie: x1.

Listing 4. Generowanie testowego wsadu dla pamięci danych (17_terminal/gen_init_mem.py)

01 W = 80
02 H = 25
03 MEM_W = 128
04 MEM_H = 32
05 C_MAX = 128
06 WIDTH = 8
07
08 str_format = "{{0:0{}b}}\r\n".format(WIDTH)
09
10 f = open("test_page.mem", "w")
11
12 for w in range(MEM_W):
13   for h in range(MEM_H):
14     if (h == 0 or h == H-1) and w < W:
15       f.write(str_format.format(ord(‘!’) + w))
16     elif w < W and h < H:
17       c = W * (h % 2) + w
18       f.write(str_format.format(c if c < C_MAX else 0))
19     else:
20       f.write(str_format.format(0))
21
22 f.close()

On także musi zostać opóźniony o jeden cykl zegara. Na wyjściu dostajemy informację, czy obecny piksel ma być zaświecony, czy wygaszony.

Listing 5. Odczytywanie informacji o aktualnym pikselu (17_terminal/terminal_show.sv)

066 always_ff @(posedge clk)
067   {hcnt_r, vcnt_r} <= {hcnt, vcnt};
068
069 always_ff @(posedge clk)
070   {hsync_r, vsync_r} <= {hsync, vsync};

072 assign cnt_letter_v_rst = (vcnt_r == V_MAX - 1);
073 assign cnt_letter_v_ce = !hsync && hsync_r;

080 counter #(.N(LETTER_HIGH)) cnt_letter_v (
081   .clk(clk),
082   .rst(!cnt_letter_v_rst),
083   .ce(cnt_letter_v_ce),
084   .q(letter_v),
085   .ov(ov_letter_v));
086
087 counter #(.N(LETTERS_Y + 1)) cnt_char_h (
088   .clk(clk),
089   .rst(!cnt_letter_v_rst),
090   .ce(ov_letter_v & cnt_letter_v_ce),
091   .q(char_v),
092   .ov());

107 assign data_addr = {hcnt_r[H_BIT-1:3], char_v};
108
109 simple_dual_port_ram #(
110   .DATA_WIDTH(LETTER_BIT),
111   .ADDR_WIDTH(CHAR_NUM_BIT),
112   .INIT(1),
113   .DATA("test_page.mem")
114 ) data (
115   .clk(clk),
116   .waddr(waddr),
117   .wdata(wdata),
118   .we(we),
119   .raddr(data_addr),
120   .q(current_letter)); 
121
122 always_ff @(posedge clk)
123   letter_v_d <= letter_v;
124
125 assign font_addr = {current_letter, letter_v_d};
126
127 rom #(
128   .DATA_WIDTH(LETTER_WIDTH),
129   .ADDR_WIDTH(FONT_ADDR_WIDTH),
130   .DATA("font/font.mem")
131 ) font (
132   .clk(clk),
133   .addr(font_addr),
134   .q(letter_row));

Implementację znajdziemy na listingu 5. Na początku (wiersze 66...70) zatrzaskujemy sygnały wejściowe w rejestrach. Następnie w linii 72 wykrywamy pojawienie się nowej ramki, a w linii 74 nowego wiersza. Sygnały te sterują licznikiem cnt_letter_v (linie 80...85) zliczającym wiersza w znaku. Jego przepełnienie powoduje inkrementację licznika cnt_char_h. W wierszu 107 tworzymy adres, który służy do odczytania z pamięci RAM obecnego znaku. Na jego podstawie w linii 125 tworzymy kolejny adres, tym razem dla pamięci ROM z fontem. Opóźnienie drugiej części adresu znajdziemy w wierszach 122...123. Na samym końcu (127...134) znajdziemy samą pamięć ROM.

Kursor

Został nam jeszcze jeden element: kursor. Jak pokazano na rysunku 9 rozważałem dwie wersje: miganie całego pola na biało albo „negatyw” znaku.

Rysunek. 9. Dwa pomysły na prezentację kursora

W ankiecie na instagramowym koncie Rysino wygrała ta druga opcja. Implementację tej pierwszej zostawiam zainteresowanym Czytelnikom jako ćwiczenie. A jeżeli ktoś ma inne pomysły na kursor, chętnie przyjmę kolejne propozycje wink.

Listing 6. Implementacja kursora (17_terminal/terminal_show.sv)

074 assign new_frame = !vsync && vsync_r;
075
076 always_ff @(posedge clk)
077   if (new_frame)
078     {cursor_x_r, cursor_y_r, cursor_on_r} <= {cursor_x, cursor_y, cursor_on};

094 counter #(.N(CURSOR_BLINK_RATE)) cnt_cursor_blink (
095  .clk(clk),
096  .rst(rst),
097  .ce(new_frame),
098  .q(),
099  .ov(cnt_cursor_blink_ov));
100
101 always_ff @(posedge clk)
102  if (!rst)
103     cursor_blink <= ‘0;
104   else if (new_frame && cnt_cursor_blink_ov)
105     cursor_blink <= !cursor_blink;

156 delay #(
157   .N(2),
158   .L(1)
159 ) d_cursor (
160   .clk(clk),
161   .rst(rst),
162   .ce(‘1),
163   .in((hcnt_r[H_BIT-1:3] == cursor_x_r) && (char_v == cursor_y_r) && cursor_on_r),
164   .out(is_cursor));
165 
166 always_ff @(posedge clk)
167   if (valid_r && v_valid) begin
168     if (is_cursor && cursor_blink) begin
169       if (letter_row[hcnt_rr[2:0]])
160         {vga.red, vga.green, vga.blue} <= ‘0;
171       else
172         {vga.red, vga.green, vga.blue} <= COLOR;
173     end else begin
174       if (letter_row[hcnt_rr[2:0]])
175         {vga.red, vga.green, vga.blue} <= COLOR;
176       else
177         {vga.red, vga.green, vga.blue} <= ‘0;
178     end
179   end else begin
180     {vga.red, vga.green, vga.blue} <= ‘0;
181   end

Samą implementację widzimy na listingu 6. Najpierw (wiersz 74) wykrywamy początek ramki i używamy go do zatrzaśnięcia obecnej konfiguracji kursora: położenia oraz informacji czy ma zostać wyświetlony (cursor_on). Dalej znajdziemy licznik odpowiedzialny za „mruganie”. Powoduje on zmianę stanu zmiennej cursor_blink na przeciwny co CURSOR_BLINK_RATE ramek. W przykładzie ustawiłem ją na 30, więc okres animacji wynosi 2 sekundy. Teraz musimy wyrównać latencję sygnałów sterujących kursorem do pozostałych danych (linie 156...164). Na samym końcu, na podstawie stanu piksela oraz kursora podejmujemy decyzję o wyświetlanym kolorze.

Rysunek 10. Symulacja głównego modułu

Testujemy

Zostaje nam połączenie nowego modułu z generatorem sygnału VGA. Znajdziemy je w pliku 17_terminal/terminal_show_top.sv. Najpierw sprawdzimy jego działanie w symulacji. Użyjemy naszego monitora portu VGA. Uruchamiamy program Questa i wywołujemy polecenie:

do ./terminal_show_top.do

Symulacja jest dość długa, może zająć kilkanaście minut. W jej wyniku otrzymamy przebiegi zaprezentowane na rysunku 10 oraz obraz ekranu pokazany na rysunku 11.

Rysunek 11. Obraz wygenerowany podczas symulacji

Możemy przejść do ostatniego kroku. Uruchamiamy środowisko Quartus i budujemy projekt 17_terminal/terminal.qpf. Warto przyglądnąć się zużyciu zasobów, zaprezentowanemu na rysunku 12. Widzimy, że zajętych jest 61440 bitów pamięci RAM, choć w projekcie mamy dwie pamięci składające się z 4096 ośmiu bitowych słów.

Rysunek 12. Zużycie zasobów

Powinno to dać razem 65536 bitów. Jednak w czasie budowy środowisko wykryło, że kod ASCII składa się tylko z 7 bitów i usunęło jeden dodatkowy. Mamy więc jedną pamięć gdzie słowo ma 8, a drugą gdzie ma 7 bitów. Co właśnie daje uzyskaną wartość.

Działanie projektu możemy zobaczyć na fotografii tytułowej oraz filmie [3]. Kursor możemy włączać i wyłączać za pomocą przełącznika DIP switch.

Podsumowanie

W tym odcinku uruchomiliśmy projekt wyświetlający na monitorze tekst zawarty w pamięci RAM. W kolejnym odcinku podłączymy port szeregowy i użyjemy drugi port pamięci do zmiany jej zawartości.

Rafał Kozik
rafkozik@gmail.com

[1] Repozytorium z przykładami https://bit.ly/3l2rK8h
[2] Font z UEFI https://bit.ly/37bl2od
[3] Film demonstrujący pracę projektu https://bit.ly/3BNyTiY

Artykuł ukazał się w
Elektronika Praktyczna
sierpień 2021
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik grudzień 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio listopad - grudzień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

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

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna grudzień 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich styczeń 2025

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów