Dla przypomnienia popatrzmy na rysunek 1, który pokazuje schemat blokowy naszego terminalu alfanumerycznego. Zajmiemy się implementacją bloku odczytującego kolejne symbole z UART i obsługującego kursor oraz pamięć z tekstem. Został on zaznaczony czerwoną ramką.
Dane wejściowe
Najpierw musimy ustalić, jaki efekt chcemy uzyskać. Do obsługi portu szeregowego ze strony komputera PC użyjemy programu PuTTY. Emuluje on terminal VT100. Jego dokładny opis znajdziemy w [2]. Jeśli otworzymy dokumentację, to zobaczymy, że sam interfejs jest dość złożony. My jednak użyjemy tylko kilku podstawowych opcji. Zostały one zebrane w tabeli 1.
Pierwsza funkcja, którą będziemy obsługiwać, to po prostu pisanie. Naciśnięcie klawiszy cyfr oraz liter powoduje wysłanie odpowiadających im kodów ASCII. Są to wszystkie kolejne liczby od 32 (spacja) po 126 (tylda). Wpisanie znaku spowoduje zastąpienie litery w obecnej komórce i przesunięcie kursora w prawo o jedną pozycję. Dla ruchu kursora przyjmiemy założenie, że gdy znajduje się na ostatnim znaku w wierszu, zostanie przeniesiony na pierwszy znak w kolejnym wierszu. Jeżeli to będzie ostatni wiersz, nastąpi powrót na samą górę ekranu.
Kolejna funkcja to przejście do nowej linii. Zwykle wymusza ją znak Line Feed (10). Ale przy VT100 naciśnięcie klawisza ENTER powoduje wysłanie kodu Return (13). Aby wysłać LF, możemy użyć kombinacji klawiszy CTRL + j. W projekcie założymy, że oba te znaki będą powodowały przejście kursora na początek nowego wiersza. Sam tekst nie ulegnie zmianie.
Kolejna funkcjonalność to usuwanie pojedynczego znaku. Tutaj także czeka nas niespodzianka. Domyślne naciśnięcie klawisza BACKSPACE spowoduje wysłanie kodu 127 (czyli DELETE). Dopiero zmiana ustawień spowoduje, że wykorzystany będzie kod 8, czyli BACKSPACE. Przyjmiemy, że oba będą miały tę samą funkcjonalność: najpierw nastąpi przesunięcie kursora w lewo, a następnie komórka pamięci zostanie wyzerowana.
Tutaj kończymy pojedyncze znaki i przechodzimy do sekwencji. Każda z nich zaczyna się od klawisza ESC (kod 27). Pierwsza z nich posłuży do czyszczenia całej pamięci. Aby ją wysłać, naciskamy najpierw klawisz ESC, a później c. Ostatnie cztery kombinacje odpowiadają naciśnięciu strzałek na klawiaturze. Ich funkcje będą intuicyjne: spowodują przemieszczenie kursora w odpowiadającym im kierunku.
Terminal
Schemat blokowy modułu został zaprezentowany na rysunku 2. Parsowanie danych odbieranych z portu szeregowego podzielimy na trzy części:
- obsługę kursora,
- obsługę klawisza ESC,
- obsługę pamięci.
Pierwszy blok na podstawie poprzedniej pozycji kursora oraz odebranego znaku ustala kolejną pozycję. Drugi wykrywa klawisz ESC i sprawdza, czy tryb poleceń jest nadal aktywny. Ostatni z nich generuje sygnały dla pamięci: dane, adres oraz we (write enable – zapisz).
Zaczniemy od wejść i wyjść modułu. Jego kod został pokazany na listingu 1. Na początku (wiersze 11...16) znajdziemy definicje stałych. Mamy tu liczbę znaków w wierszu (LETTERS_X), liczbę wierszy (LETTERS_Y) oraz liczbę bitów zajmowanych przez znak (LETTER_BIT). Za pomocą polecenia $clog2 obliczamy także liczbę bitów potrzebnych do przechowywania poszczególnych wartości.
10 module terminal #(
11 parameter LETTERS_X = 80,
12 parameter LETTERS_X_BIT = $clog2(LETTERS_X),
13 parameter LETTERS_Y = 25,
14 parameter LETTERS_Y_BIT = $clog2(LETTERS_Y),
15 parameter LETTER_BIT = 8,
16 parameter CHAR_NUM_BIT = $clog2(LETTERS_X) + $clog2(LETTERS_Y)
17 ) (
18 StreamBus.Slave uart,
19 output logic [LETTERS_X_BIT-1:0]cursor_x,
20 output logic [LETTERS_Y_BIT-1:0]cursor_y,
21 output logic we,
22 output logic [LETTER_BIT-1:0]wdata,
23 output logic [CHAR_NUM_BIT-1:0]waddr
24 );
Dalej znajdziemy wejścia. Skorzystamy tu z naszego interfejsu przygotowanego dla modułu portu szeregowego. Dla przypomnienia – jest on pokazany na listingu 2. Sygnały clk i rst są wejściami dla obu stron połączenia. Pozostałe sygnały: data, valid i ready, służą do komunikacji między modułami. Ich kierunki definiujemy w modportach.
10 interface StreamBus #(
11 parameter N = 8
12 ) (
13 input wire clk,
14 input wire rst
15 );
16 logic [N-1:0] data;
17 logic valid;
18 logic ready;
19
20 modport Master (
21 input clk,
22 input rst,
23 input ready,
24 output data,
25 output valid
26 );
27
28 modport Slave (
29 input clk,
30 input rst,
31 input data,
32 input valid,
33 output ready
34 );
35
36 endinterface
U nas odbiornik portu szeregowego jest oznaczony jako Master, natomiast terminal będzie pracował jako Slave. Widzimy to w definicji portu, w linii 18 (listing 1). Dalej znajdziemy wyjścia: położenie kursora w osi x i y (19...20) oraz interfejs dla pamięci z danymi (21...23).
Obsługa kursora
Przejdźmy teraz do pierwszej części, czyli obsługi kursora. Większość zmian (poza czyszczeniem, ale to później) następuje, gdy dociera nowy znak. Sprawdzenie to widzimy na listingu 3 w linii 50. Pierwszą częścią jest obsługa strzałek. Tutaj sprawdzamy, czy jesteśmy w trybie esc (czyli, czy użytkownik wysłał znak o kodzie 27). Jeżeli tak, sprawdzamy, czy odebraliśmy któryś z kodów odpowiadających strzałkom. Ich listę mamy w tabeli 1. W wierszach 53...57 widzimy obsługę strzałki w górę. Zmianie ulega jedynie położenie kursora w osi y. Jeżeli znajdował się on w linii 0, jest przenoszony na dół ekranu. W przeciwnym przypadku jest przesuwany do góry, czyli odejmujemy 1 (wiersze numerujemy od góry ku dołowi ekranu). Obsługa pozostałych strzałek jest analogiczna. Można ją sprawdzić w repozytorium.
47 always_ff @(posedge uart.clk)
48 if (!uart.rst)
49 {cursor_x, cursor_y} <= ‘0;
50 else if (uart.valid) begin
51 if (esc) begin
52 // Arrows
53 if (uart.data == "A") begin
54 if (cursor_y == 0)
55 cursor_y <= LETTERS_Y-1;
56 else
57 cursor_y <= cursor_y - 1’d1;
Kolejna możliwość to wysłanie znaku drukowalnego. Powoduje on przesunięcie kursora w prawo. Odpowiedzialny za to fragment kodu znajdziemy na listingu 4. Tutaj najpierw przesuwamy kursor w osi x. Jeżeli dojdziemy do brzegu, przenosimy go na początek kolejnej linii, co pociąga za sobą sprawdzenie osi y.
83 end else if (uart.data >= 32 && uart.data <= 126) begin
84 if (cursor_x < LETTERS_X-1)
85 cursor_x <= cursor_x + 1’d1;
86 else begin
87 cursor_x <= ‘0;
88 if (cursor_y < LETTERS_Y-1)
89 cursor_y <= cursor_y + 1’d1;
90 else
91 cursor_y <= ‘0;
92 end
Następną specjalną operacją jest kasowanie. Obsługujemy tylko funkcję klawisza BACKSPACE. Jak widzimy na listingu 5, mamy tu operację odwrotną do tej z listingu 4. Cofamy kursor o jedną pozycję w lewo. W razie potrzeby przenosimy go do linii wyżej.
094 end else if (uart.data == BACKSPACE || uart.data == DEL) begin
095 if (cursor_x != ‘0)
096 cursor_x <= cursor_x - 1’d1;
097 else begin
098 cursor_x <= LETTERS_X - 2’d1;
099 if (cursor_y != ‘0)
100 cursor_y <= cursor_y - 1’d1;
101 else
102 cursor_y <= LETTERS_Y - 2’d1;
103 end
Następną możliwością jest przejście do nowej linii, czyli wciśnięcie ENTER. Widzimy kod na listingu 6. Tutaj przesuwamy pozycję w osi x na początek i zmieniamy położenie w osi y na linijkę poniżej.
105 end else if (uart.data == NEW_LINE || uart.data == RET) begin
106 cursor_x <= ‘0;
107 cursor_y <= (cursor_y < LETTERS_Y-1) ? cursor_y + 1’d1 : ‘0;
108 end
Ostatni element prezentuje listing 7. Jest on wykonywany, gdy aktywne jest czyszczenie. Odbywa się on niezależnie od stanu sygnału valid. Jest to najprostsza z pokazanych operacji: po prostu przesuwamy kursor w lewy górny róg ekranu.
110 end else if (clearing) begin
111 cursor_x <= ‘0;
112 cursor_y <= ‘0;
113 end
Obsługa ESC
Drugi blok obsługuje komendy rozpoczynające się klawiszem ESC. Został on pokazany na listingu 8. Gdy zostanie wykryty znak o kodzie 27, następuje ustawienie bitu esc. Jest on aktywny, dopóki nie zostanie odebrana któraś z komend (linia 134): A, B, C, D lub c. Dopóki to nie nastąpi, żaden odebrany znak nie zostanie przetworzony.
127 always_ff @(posedge uart.clk)
128 if (!uart.rst)
129 esc <= 1’d0;
130 else begin
131 if (uart.data == 27)
131 esc <= 1’d1;
132 else if (esc)
133 case (data_r)
134 "A", "B", "C", "D", "c": esc <= 1’d0;
135 default: esc <= 1’d1;
136 endcase
137 end
Obsługa pamięci danych
Został nam ostatni fragment – obsługa pamięci znaków. Tym razem na pierwszy ogień weźmiemy czyszczenie (listing 9). Następuje ono, gdy aktywna jest flaga clearing. Sama flaga jest ustawiana, gdy w trybie esc nastąpi odebranie znaku c (wiersze 163...165). Najpierw samo sprzątanie. Mamy tutaj dwa liczniki: clearing_x liczy pozycję znaku w wierszu, a clearing_y to numer wiersza. W kolejnych taktach zegara przechodzimy po wszystkich znakach (wiersze 148...158). Dla każdego nastąpi wpisanie zera do pamięci (linie 159...161). Gdy przeiterujemy po całej pamięci, flaga clearing zostanie wyzerowana.
147 end else if (clearing) begin
148 if (clearing_x < LETTERS_X-1)
149 clearing_x <= clearing_x + 1’d1;
150 else begin
151 clearing_x <= ‘0;
152 if (clearing_y < LETTERS_Y-1)
153 clearing_y <= clearing_y + 1’d1;
154 else begin
155 clearing_y <= ‘0;
156 clearing <= ‘0;
157 end
158 end
159 we <= 1’d1;
160 wdata <= ‘0;
161 waddr <= {clearing_x, clearing_y};
162 end else if (valid_r) begin
163 if (esc) begin
164 if (data_r == "c")
165 clearing <= 1’d1;
Widzimy, że jest to czasochłonna operacja. Wymaga po jednym cyklu zegara dla każdego znaku, czyli w naszym przypadku 2000 cykli. Mamy jednak na tyle szybki zegar, że zadanie zostanie wykonane, zanim nadejdzie kolejny znak. Częstotliwość taktowania wynosi 25200 kHz, natomiast prędkość transmisji to 115200 bodów. Transmisja jednego znaku wymaga przesłania 10 symboli (bit startu, stopu i 8 bitów danych). Oznacza to, że odstęp pomiędzy dwoma znakami wynosi minimum 2180 cykli. Czyli zdążymy!
166 end else if (data_r >= 32 && data_r <= 126) begin
167 wdata <= data_r;
168 waddr <= {cursor_x_prev, cursor_y_prev};
169 we <= 1’d1;
170 end else if (data_r == BACKSPACE || data_r == DEL) begin
171 wdata <= ‘0;
172 waddr <= {cursor_x, cursor_y};
173 we <= 1’d1;
174 end else begin
175 we <= ‘0;
176 end
177 end else
178 we <= ‘0;
Pozostała część obsługi pamięci widoczna jest na listingu 10. W wierszach 166...169 obsługujemy znaki drukowalne. Po prostu wpisujemy odebrany znak pod adres wskazywany poprzednio przez kursor. Dalej znajdziemy jeszcze kasowanie. Tym razem wpisujemy zero pod nowe położenie kursora. Jeżeli nie mamy nic do wpisania, upewniamy się, że flaga we jest wyłączona.
Testy
Dla tego modułu do symulacji dodamy także testy, które automatycznie sprawdzają poprawność. Pomogą nam w tym dwie funkcje z listingu 11. Pierwsza z nich (wiersze 28...33) to check_ram. Przyjmuje ona dwa parametry: adres w pamięci addr oraz pożądaną wartość val. W wierszu 30 za pomocą instrukcji assert wykonujemy sprawdzenie. Jeżeli odczytana wartość jest błędna, zostanie wyświetlony stosowny komunikat.
28 function automatic void check_ram
29 (logic [CHAR_NUM_BIT-1:0]addr, logic [LETTER_BIT-1:0]val);
30 assert (data.ram[addr] == val)
31 else $display("data[%d] is not %d, (%d, %c)",
32 addr, val, data.ram[addr], data.ram[addr]);
33 endfunction
34
35 function automatic void check_cursor
36 (logic [LETTERS_X_BIT-1:0]x, logic [LETTERS_Y_BIT-1:0]y);
37 assert (cursor_x == x && cursor_y == y)
38 else $display("Cursor is not (%d, %d), but (%d, %d)",
39 x, y, cursor_x, cursor_y);
40 endfunction
Druga funkcja check_cursor sprawdza położenie kursora. Jeżeli pozycja jest nieodpowiednia, zostanie wyświetlona informacja.
64 bus_uart.data = 50;
65 bus_uart.valid = 1;
66 @(posedge clk);
67 bus_uart.valid = 0;
68
69 repeat(5) @(posedge clk);
70
71 $display("WRITE");
72 check_cursor(1, 0);
73 check_ram(0, 50);
Przykładowe ich użycie prezentuje listing 12. Najpierw w wierszach 64...67 wpisujemy dane i odczekujemy jeden cykl zegara, aby wyłączyć sygnał valid. Następnie czekamy 5 cykli zegara, wyświetlamy nazwę testu oraz wywołujemy funkcje sprawdzające. Sam moduł był implementowany w następującej kolejności: najpierw dodawany był test dla kolejnej funkcjonalności (nowa linia, kasuj, itp.), a następnie implementowana była sama funkcja. Po jej napisaniu następowało sprawdzenie, czy wszystkie dotychczasowe testy nadal kończą się pomyślnie.
# RESET
# WRITE
# WRITE MULTIPLE
# BACKSPACE
# BACKSPACE MULTIPLE
# BACKSPACE MULTIPLE 2
# NEW LINE
# CLEAR
# ARROW DOWN
# ARROW UP
# ARROW RIGHT
# ARROW LEFT
# ** Note: $stop : terminal_tb.sv(202)
# Time: 273437500 ps Iteration: 1 Instance: /terminal_tb
# Break in Module terminal_tb at terminal_tb.sv line 202
# 0 ps
# 287109380 ps
# Coverage Report Summary Data by instance
#
# =================================================================================
# === Instance: /terminal_tb
# === Design Unit: work.terminal_tb
# =================================================================================
# Enabled Coverage Bins Hits Misses Coverage
# ---------------- ---- ---- ------ --------
# Assertions 3 3 0 100.00%
#
#
# TOTAL ASSERTION COVERAGE: 100.00% ASSERTIONS: 3
#
# Total Coverage By Instance (filtered view): 100.00%
Uzyskany wynik prezentuje listing 13. Najpierw widzimy nazwy dwunastu testów, które zostały wykonane. Nie mamy tu żadnych informacji o błędach. Dalej znajdziemy informację o linii, z której został wywołany stop. Na końcu znajdujemy podsumowanie asercji. Wszystkie trzy przeszły pozytywnie. Mamy tylko trzy asercje, ponieważ te w funkcjach liczą się pojedynczo, nawet jeżeli funkcja zostanie wywołana kilkakrotnie. Choćby pojedyncze jej niespełnienie spowoduje pojawienie się komunikatu o błędzie.
Przebiegi wygenerowane w czasie symulacji prezentuje rysunek 3. Dwa pierwsze to sygnał reset oraz zegar. W sekcji input znajdziemy dane wejściowe i sygnał valid. W sekcji inside znajdziemy wewnętrzne dane modułu: liczniki do czyszczenia oraz flagi clearing i esc. Na samym końcu znajdziemy wyjścia: położenie kursora w obu osiach oraz interfejs do pamięci z tekstem.
Sprzęt
Integracja poszczególnych bloków jest zrealizowana w module terminal_top.sv. Dodajemy go do projektu terminal.qpf i ustawiamy jako moduł top. Budujemy projekt i programujemy płytkę. Do komunikacji przez port szeregowy użyjemy programu PuTTY. Jego okno startowe prezentuje rysunek 4.
Wybieramy typ Serial. W okienku Speed ustawiamy wartość 115200, a w pole Serial line podajemy nazwę portu szeregowego (możemy ją znaleźć w managerze urządzeń). Naciskamy Open. Teraz możemy pisać na klawiaturze, a tekst będzie widoczny na ekranie monitora (konsola natomiast pozostanie pusta). Po zaprogramowaniu Rysino najpierw zobaczymy nasz ekran testowy z poprzedniego odcinka. Możemy go wyczyścić, naciskając klawisze ESC, a następnie c. Uzyskany efekt prezentuje fotografia tytułowa oraz film [3]. Za pomocą przełącznika można włączać i wyłączać kursor.
Jeżeli pojawi się problem z komunikacją z programatorem, należy odłączyć i podłączyć jeszcze raz kabel USB obsługujący port szeregowy (i zasilający płytkę).
Podsumowanie
W tym odcinku uruchomiliśmy cały terminal znakowy. Bazuje on (dość luźno) na standardzie VT100. Zachęcam do samodzielnego wykonania eksperymentów oraz dodania dodatkowych funkcji. Może ktoś się pokusi na przykład o obsługę kolorów albo migania tekstu?
Rafał Kozik
rafkozik@gmail.com
[1] Repozytorium z przykładami https://bit.ly/3l2rK8h
[2] Dokumentacja VT100 https://bit.ly/3sL51iQ
[3] Film z demonstracją działania https://bit.ly/3mxunjf