Eksperymenty z FPGA (22). Terminal alfanumeryczny

Eksperymenty z FPGA (22). Terminal alfanumeryczny

W poprzednim odcinku uruchomiliśmy generator obrazu dla alfanumerycznego terminalu znakowego. W tym odcinku przygotujemy maszynę stanów odpowiedzialną za odbieranie znaków z portu szeregowego. Obliczy ona aktualną pozycję kursora oraz wpisze dane do pamięci.
Tak jak poprzednio przed przystąpieniem do wykonywania eksperymentów zachęcam do aktualizacji repozytorium z przykładami (na przykład poprzez wywołanie polecenia git pull).

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ą.

Rysunek 1. Schemat blokowy terminalu alfanumerycznego

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.
Rysunek 2. Schemat modułu terminal

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.

Listing 1. Wejścia i wyjścia modułu terminal (17_Terminal/terminal.sv)

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.

Listing 2. Implementacja interfejsu w języku SystemVerilog (05_UART/uart_pkg.sv)

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.

Listing 3. Zmiana pozycji kursora po odebraniu strzałki w górę (17_Terminal/terminal.sv)

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.

Listing 4. Zmiana pozycji kursora dla znaku drukowalnego (17_Terminal/terminal.sv)

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.

Listing 5. Zmiana pozycji kursora przy kasowaniu (17_Terminal/terminal.sv)

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.

Listing 6. Zmiana pozycji kursora przy przejściu do nowej linii (17_Terminal/terminal.sv)

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.

Listing 7. Pozycja kursora przy czyszczeniu ekranu (17_Terminal/terminal.sv)

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.

Listing 8. Obsługa klawisza ESC (17_Terminal/terminal.sv)

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.

Listing 9. Czyszczenie ekranu(17_Terminal/terminal.sv)

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!

Listing 10. Wpisywanie znaków do pamięci (17_Terminal/terminal.sv)

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.

Listing 11. Funkcje sprawdzające położenie kursora oraz zawartość pamięci (17_Terminal/terminal_tb.sv)

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.

Listing 12. Test wpisania znaku drukowalnego (17_Terminal/terminal_tb.sv)

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.

Listing 13. Wynik symulacji

# 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.

Rysunek 3. Przebiegi wygenerowane w symulacji

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.

Rysunek 4. Okno startowe programu PuTTY

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

Artykuł ukazał się w
Elektronika Praktyczna
wrzesień 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