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.
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.
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.
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.
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.
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.
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:
Uzyskany wynik został zaprezentowany na rysunku 5.
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.
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.
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.
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.
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.
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.
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.
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 .
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.
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:
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.
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.
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