Kurs FPGA Lattice (21). Terminal UART z 14-segmentowym wyświetlaczem LCD

Kurs FPGA Lattice (21). Terminal UART z 14-segmentowym wyświetlaczem LCD

W poprzednim odcinku kursu opracowaliśmy moduł, który steruje 14-segmentowym wyświetlaczem typu VIM828, zdolnym do wyświetlania 8 znaków. W tym odcinku przygotujemy moduł konwertujący kody znaków ASCII oraz dodamy obsługę UART. W ten sposób zyskamy możliwość wyświetlenia dowolnego tekstu na wyświetlaczu, używając terminala na komputerze.

Historia standardu ASCII sięga jeszcze lat 60. Powstał on w celu usprawnienia komunikacji przez dalekopisy, czyli zdalnie sterowane maszyny do pisania. Wszystkim małym i wielkim literom alfabetu łacińskiego, cyfrom, znakom interpunkcyjnym, nawiasom itp. przyporządkowano liczby od 0 do 127. Liczby te dało się zapisywać na magistrali 7-bitowej. Niedługo później dodano ósmy bit, ponieważ większość komputerów w latach 80. była 8-bitowa.

Stworzyło to możliwość wyświetlania 256 różnych znaków, a każdy z nich zajmował dokładnie 1 bajt pamięci. Powstało wiele różnych tablic znaków ASCII – jedną z nich widzimy na rysunku 1. Różnią się one znakami diakrytycznymi, charakterystycznymi dla różnych języków, a także znakami matematycznymi czy różnego rodzaju kreskami, z których można budować tabele. Warto dodać, że pierwsze 32 znaki często miały specjalne znaczenie i służyły do kontroli transmisji, przejścia do kolejnej linii, tabulacji itp.

Rysunek 1. Lista kodów ASCII od 0 do 255

Moduł Decoder14seg

Moduł jest bardzo prosty, a jego kod pokazano na listingu 1. Na wejściu Data_i (linia 2) dostarczamy 8-bitowy kod ASCII znaku, który chcemy wyświetlić. Na wyjściu Segments_o (linia 3) w następnym takcie zegarowym pojawia się 14-bitowa bitmapa, którą można przekazać bezpośrednio do sterownika wyświetlacza. Moduł pracuje, kiedy wejście Enable_i znajduje się w stanie wysokim, natomiast kiedy jest ono w stanie niskim, wówczas wyjście Segments_o pozostaje bez zmian, niezależnie od tego, co się dzieje na wejściu danych oraz Clock (wejście Reset ma zawsze priorytet).

// Plik decoder_14seg.v


`default_nettype none
module Decoder14seg(
    input wire Clock,
    input wire Reset,
    input wire Enable_i,                                    // 1
   
input wire [ 7:0] Data_i,                              // 2
   
output reg [13:0] Segments_o                           // 3
);
   
   
always @(posedge Clock, negedge Reset) begin
       
if(!Reset) begin
           
Segments_o <= 14’b00000000000000;
        end else if(Enable_i) begin                       
           
case(Data_i)                                   // 4
                                      
//  NMLKJIHGFEDCBA
             
  8’h00:   Segments_o <= 14’b00000000000000;
                " ":     Segments_o <= 14’b00000000000000;
                "\"":    Segments_o <= 14’b00000000100010;
                "’":     Segments_o <= 14’b00000100000000;
                "(":     Segments_o <= 14’b10001000000000;
                ")":     Segments_o <= 14’b00100010000000;
                "*":     Segments_o <= 14’b11111111000000;
                "+":     Segments_o <= 14’b01010101000000;
                ",":     Segments_o <= 14’b00100000000000;
                "-":     Segments_o <= 14’b00010001000000;
                ".":     Segments_o <= 14’b01000000000000;
                "/":     Segments_o <= 14’b00101000000000;
                "0":     Segments_o <= 14’b00000000111111;
                "1":     Segments_o <= 14’b00001000000110;
                "2":     Segments_o <= 14’b00010001011011;
                "3":     Segments_o <= 14’b00010000001111;
                "4":     Segments_o <= 14’b00010001100110;
                "5":     Segments_o <= 14’b00010001101101;
                "6":     Segments_o <= 14’b00010001111101;
                "7":     Segments_o <= 14’b01001000000001;
                "8":     Segments_o <= 14’b00010001111111;
                "9":     Segments_o <= 14’b00010001101111;
                ":":     Segments_o <= 14’b11111111111111;
                ";":     Segments_o <= 14’b11111111111111;
                "<":     Segments_o <= 14’b10001000000000;
                "=":     Segments_o <= 14’b00010001001000;
                ">":     Segments_o <= 14’b00100010000000;
                "A":     Segments_o <= 14’b00010001110111;
                "B":     Segments_o <= 14’b01010100001111;
                "C":     Segments_o <= 14’b00000000111001;
                "D":     Segments_o <= 14’b01000100001111;
                "E":     Segments_o <= 14’b00000001111001;
                "F"
:     Segments_o <= 14’b00000001110001;
                "G":     Segments_o <= 14’b00010000111101;
                "H":     Segments_o <= 14’b00010001110110;
                "I":     Segments_o <= 14’b01000100001001;
                "J"
:     Segments_o <= 14’b00000000011110;
                "K":     Segments_o <= 14’b10001001110000;
                "L":     Segments_o <= 14’b00000000111000;
                "M":     Segments_o <= 14’b00001010110110;
                "N":     Segments_o <= 14’b10000010110110;
                "O":     Segments_o <= 14’b00000000111111;
                "P":     Segments_o <= 14’b00010001110011;
                "Q":     Segments_o <= 14’b10000000111111;
                "R":     Segments_o <= 14’b10010001110011;
                "S":     Segments_o <= 14’b00010001101101;
                "T":     Segments_o <= 14’b01000100000001;
                "U":     Segments_o <= 14’b00000000111110;
                "V":     Segments_o <= 14’b00101000110000;
                "W":     Segments_o <= 14’b10100000110110;
                "X":     Segments_o <= 14’b10101010000000;
                "Y":     Segments_o <= 14’b01001010000000;
                "Z":     Segments_o <= 14’b00101000001001;
                default: Segments_o <= 14’b11111111111111; // 5
           
endcase
       
end
   
end
   
endmodule
`default_nettype wire

Listing 1. Kod pliku decoder_14seg.v

Właściwie cały moduł sprowadza się do dość długiej instrukcji case, sprawdzającej wejście Data_i (linia 4). Instrukcja case w Verilogu działa podobnie, jak switch-case w C++, nie trzeba jednak pisać break po każdej opcji. Wszystkim wielkim literom, cyfrom oraz niektórym znakom interpunkcyjnym przyporządkowujemy odpowiednie ustawienie segmentów wyświetlacza. Zwróć uwagę, że każdy ze znaków objęty został podwójnym cudzysłowem. Wygląda to jak string w C++, lecz w Verilogu zapisane w ten sposób znaki oznaczają tylko stałe 8-bitowe. Pojedynczy znak objęty cudzysłowem konwertowany jest na odpowiadający mu kod w standardzie ASCII, na przykład „A” zamieniane jest na 8-bitową liczbę 65. Dla wszystkich niezdefiniowanych kombinacji ustalamy instrukcją default, że ma być widoczny pełen zestaw segmentów (linia 5).

Porównaj ten moduł z Decoder7seg, który omawialiśmy w 9 odcinku kursu. Wygląda on dość podobnie, ale istnieje jedna duża różnica w jego działaniu. W module Decoder7seg zastosowaliśmy kombinacyjny blok always – to znaczy, że wynik na wyjściu pojawiał się prawie natychmiast po zmianie danych na wejściu (prawie, bo między tymi zdarzeniami mija pewien czas propagacji).

W module Decoder14seg blok always jest sekwencyjny. Z tego powodu wynik na wyjściu pojawia się dopiero po wystąpieniu rosnącego zbocza sygnału zegarowego. Dlaczego tak dziwnie? Rozwiązanie ze sterownika wyświetlacza 7-segmentowego wydaje się prostsze i bardziej intuicyjne. Rzeczywiście tak jest, ale w tym przypadku stosujemy bardzo sprytną sztuczkę optymalizacyjną – tak skonstruowana instrukcja case zostanie bowiem w całości zaimplementowana w bloku pamięci EBR. Ponadto funkcjonalność wejścia Enable_i również zostanie zaimplementowana w EBR bez korzystania z uniwersalnych zasobów logicznych.

Jak to możliwe? Otóż instrukcja case, przypisująca wartość jednej zmiennej tylko na podstawie innej pojedynczej zmiennej, w gruncie rzeczy odpowiada... pamięci ROM. W takiej pamięci stan wyjścia danych ustalany jest tylko na podstawie stanu wejścia adresowego. 8-bitowe wejście Data_i stanowi w rzeczywistości wejście adresowe pamięci ROM, w której zapisanych zostało 256 stałych o długości 14 bitów. W ten sposób można zaoszczędzić mnóstwo uniwersalnych zasobów logicznych, zastępując je tylko jednym blokiem pamięci EBR. Trzeba spełnić tu tylko jeden warunek: blok always musi być sekwencyjny, bo pamięć EBR ma charakter synchroniczny i musi działać z sygnałem zegarowym (co zostało dokładniej omówione w 15 odcinku kursu).

Moduł TerminalVIM828

Opracujemy teraz moduł, którego zadaniem będzie odbieranie znaków ASCII przez interfejs UART, a następnie konwertowanie odebranych znaków na kod 14-segmentowy, zapisywanie ich w pamięci i dostarczanie do sterownika wyświetlacza VIM828. Moduł będzie przechowywał w swojej pamięci osiem ostatnio odebranych znaków. Po odebraniu nowego bajtu z interfejsu UART, bajt ten pojawi się na pierwszej pozycji z prawej strony wyświetlacza, a wszystkie znaki odebrane wcześniej zostaną przesunięte o jedną pozycję w lewo.

Na płytce Segment14 znajduje się konektor USB oraz przejściówka USB/UART typu FT230X, dołączona do FPGA. Płytka ta została zaprezentowana w EP 2024/06. Dzięki takiemu rozwiązaniu będziemy mogli łatwo i wygodnie przesyłać dane do płytki z komputera, używając dowolnego terminala.

Kod modułu TerminalVIM828 znajduje się na listingu 2. Jak można się spodziewać, moduł ma konfigurowalną prędkość transmisji interfejsu UART, którą ustawić można parametrem BAUD (linia 1). Domyślna prędkość wynosi 115200 bitów na sekundę.

// Plik terminal_vim828.v

`default_nettype none
module TerminalVIM828 #(
    parameter CLOCK_HZ      = 10_000_000,
    parameter BAUD          = 115200                 // 1
)(
    input wire Clock,
    input wire Reset,
    input wire Rx_i,                                 // 2
    output wire [36:1] Pin_o                         // 3
);
   
    // Zmienne dla odbiornika UART
    wire RxDone;                                     // 4
    wire [7:0] RxByte;                               // 5
    

    // Instancja odbiornika UART
    UartRx #(                                        // 6
        .CLOCK_HZ(CLOCK_HZ),
        .BAUD(BAUD)
    ) UartRx_Inst(
        .Clock(Clock),
        .Reset(Reset),
        .Rx_i(Rx_i),
        .Done_o(RxDone),
        .Data_o(RxByte)
  );
   
    // Zmienna dla konwertera kodu 14-segmentowego
    wire [13:0] DecodedByte;                         // 7

    // Instancja konwertera znaków ASCII na kod 14-segmentowy
   
Decoder14seg Decoder14seg_inst(                  // 8
       
.Clock(Clock),
       
.Reset(Reset),
        .Enable_i(1’b1),
        .Data_i(RxByte),
        .Segments_o(DecodedByte)
    );
   
   
// Rejestry do przechowywania zdekodowanych

    // znaków ASCII w kodzie 14-segmentowym
   
reg [13:0] Segments7;                            // 9
   
reg [13:0] Segments6;
    reg [13:0] Segments5;
    reg [13:0] Segments4;
    reg [13:0] Segments3;
    reg [13:0] Segments2;
    reg [13:0] Segments1;
    reg [13:0] Segments0;
    

    // Opóźnienie o 1 takt zegarowy
   
reg OneBitDelay;                                 // 10
   
   
// Prosty przerzutnik D do generowania opóźnień

    always @(posedge Clock, negedge Reset) begin     // 11
       
if(!Reset)
            OneBitDelay <= 1’b0;
        else
           
OneBitDelay <= RxDone;                   // 12
   
end
    

    // Przesuwanie znaków po odebraniu nowego znaku
   
always @(posedge Clock, negedge Reset) begin     // 13
       
if(!Reset) begin
           
Segments7 <= 0;
            Segments6 <= 0;
            Segments5 <= 0;
            Segments4 <= 0;
            Segments3 <= 0;
            Segments2 <= 0;
            Segments1 <= 0;
            Segments0 <= 0;
        end else if(OneBitDelay) begin               // 14
           
Segments0 <= DecodedByte;                // 15
            Segments1 <= Segments0;
            Segments2 <= Segments1;
            Segments3 <= Segments2;
            Segments4 <= Segments3;
            Segments5 <= Segments4;
            Segments6 <= Segments5;
            Segments7 <= Segments6;
        end
   
end

    // Instancja sterownika wyświetlacza
 
  VIM828 #(                                        // 16
       
.CLOCK_HZ(CLOCK_HZ),
        .CHANGE_COM_US(1000)
    ) VIM828_inst(
        .Clock(Clock),
        .Reset(Reset),
        .Segments7_i(Segments7),
        .Segments6_i(Segments6),
        .Segments5_i(Segments5),
        .Segments4_i(Segments4),
        .Segments3_i(Segments3),
        .Segments2_i(Segments2),
        .Segments1_i(Segments1),
        .Segments0_i(Segments0),
        .DecimalPoints_i(8’b00000001),               // 17
       
.Pin_o(Pin_o)
    );

   
endmodule
`default_nettype wire

Listing 2. Kod pliku terminal_vim828.v

Oprócz standardowych wejść zegara i resetu, moduł ma tylko jedno wejście. Znajduje się ono w linii 2, nosi nazwę Rx_i i służy do odbierania danych. Nie ma wyjścia Tx_o nadajnika UART, ponieważ moduł ma za zadanie wyłącznie odbierać dane. Jedyne wyjście to 36-bitowa magistrala, która steruje wszystkimi pinami wyświetlacza VIM828 (linia 3).

Przejdźmy do linii 4 i 5. Definiujemy tam dwie zmienne typu wire, sterowane przez odbiornik UART i odczytywane przez dalsze peryferia. 8-bitowa zmienna RxByte zawiera ostatnio odebrany bajt danych. Zmienna RxDone ustawiana jest w stan wysoki na jeden cykl zegarowy, po tym jak zostanie odebrany nowy bajt i będzie dostępny do odczytu w RxByte. W linii 6 widzimy instancję modułu odbiornika UART.

Następnie tworzymy 14-bitową zmienną DecodedByte typu wire (linia 7). Połączona jest ona do wyjścia Segments_o modułu Decoder14seg, który opisywaliśmy kilka akapitów wcześniej. Instancję tego modułu tworzymy w linii 8.

W linii 9 i kolejnych tworzymy osiem 14-bitowych rejestrów SegmentsX, służących do przechowywania informacji, które segmenty mają być zaczernione w każdym z ośmiu znaków wyświetlacza. Segments0 to rejestr odpowiedzialny za znak pierwszy z prawej, a Segments7 to pierwszy z lewej. W momencie, gdy nowy znak jest gotowy „wskakuje” on do rejestru Segments0, a to, co znajdowało się dotychczas w tym rejestrze, ulega przesunięciu do Segments1. Tak samo dzieje się z pozostałymi rejestrami, które łącznie tworzą 8-stopniowy rejestr przesuwny o szerokości 14-bitów.

Następnie musimy opracować prosty opóźniacz o jeden takt zegarowy. Dlaczego? Przyjrzyjmy się dokładniej, w jaki sposób dane przepływają przez kolejne moduły:

  1. Po zakończeniu odbierania, moduł odbiornika UART wystawia dane na wyjście RxByte i jednocześnie ustawia stan wysoki na RxDone przez jeden takt zegarowy.
  2. Sygnał RxByte jest doprowadzony bezpośrednio do wejścia Data_i dekodera 14-segmentowego. Dekoder potrzebuje 1 taktu zegarowego, by odpowiedź pojawiła się na jego wyjściu Segments_o.
  3. Informację o „nowych” segmentach możemy wpisać do rejestru Segments0 dopiero wtedy, gdy znajdzie się ona na wyjściu Segments_o dekodera. Wtedy jednocześnie poprzesuwamy kolejne rejestry SegmentX tak, jak to opisano wyżej.

Jednak skąd mamy wiedzieć, że pojawiły się nowe dane na wyjściu Segments_o dekodera 14-segmentowego, aby uruchomić przesuwanie rejestrów? W tym miejscu potrzebujemy jakiegoś wyzwalacza, tzn. zmiennej, która ustawi się w stan wysoki na jeden takt zegarowy dokładnie po tym, kiedy RxDone znajdzie się w stanie wysokim. Najprościej będzie uczynić to, budując zwyczajny przerzutnik D, do którego wejścia doprowadzony zostanie sygnał z RxDone.

W linii 10 tworzymy 1-bitową zmienną OneBitDelay typu reg. Na potrzeby tej zmiennej zastosujemy osobny blok always (linia 11). Jego jedynym celem jest powielanie stanu RxDone w kolejnym takcie zegarowym (linia 12). W ten sposób, zmienna OneBitDelay będzie w stanie wysokim tylko, jeżeli w poprzednim takcie zegara RxDone było w stanie wysokim.

W kolejnym bloku always (linia 13) zrealizujemy przesuwanie danych między kolejnymi rejestrami SegmentsX, które utworzyliśmy w linii 9. W tym bloku sprawdzamy, czy zmienna OneBitDelay znajduje się w stanie wysokim (linia 14) i jeśli tak, wówczas do Segments0 wpisujemy wartość DecodedByte (linia 15), pochodzącą z wyjścia dekodera, a rejestry od Segments1 do Segments6 przesuwamy do kolejnych rejestrów.

W linii 16 mamy wisienkę na torcie, czyli instancję sterownika wyświetlacza. To ona odczytuje wszystkie rejestry SegmentsX i generuje przebiegi PWM sterujące segmentami wyświetlacza. Dodatkowo w linii 17 ustawiamy, że ma być widoczny przecinek za pierwszym znakiem z prawej. Celem tego zabiegu jest pokazanie „czegoś” zaraz po uruchomieniu FPGA, ponieważ domyślnie wszystkie rejestry inicjalizowane są zerami, więc żaden znak nie będzie widoczny. Widząc zaczerniony przecinek będziemy widzieć, że układ FPGA wystartował.

Testbench modułu TerminalVIM828

Zadaniem testbencha będzie przetestowanie modułu terminala, poprzez wysłanie do niego liter „ABCDEFGH” za pomocą nadajnika UART omówionego w 18 odcinku kursu. Moduł wyświetlacza wygeneruje różne sygnały PWM, które są raczej trudne do zrozumienia, kiedy analizuje się je w przeglądarce.

Łatwiej będzie przetestować je, oglądając znaki na rzeczywistym wyświetlaczu. Główny cel symulacji to przetestowanie samej transmisji między nadajnikiem a odbiornikiem UART oraz weryfikacja, czy sterownik wyświetlacza otrzymuje dane przetworzone przez dekoder 14-segmentowy.

Kod testbencha pokazano na listingu 3. Zacznijmy od omówienia zmiennych stosowanych w testbenchu. Zmienna TxRequest typu reg (linia 1) służyć będzie do wyzwalania nadajnika UART. Ustawienie stanu wysokiego na jeden takt zegarowy spowoduje odczytanie danych, zapisanych w 8-bitowej zmiennej TxData (linia 2). Informacja o zakończeniu nadawania będzie przekazywana za pomocą zmiennej TxDone typu wire (linia 3), poprzez ustawienie stanu wysokiego na jeden takt zegarowy. Możliwe będzie wtedy załadowanie do nadajnika kolejnego bajtu do wysyłki. Wyjście nadajnika i wejście odbiornika połączone zostaną za pomocą zmiennej TxRxCommon typu wire (linia 4).

// Plik terminal_vim828_tb.v

`timescale 1ns/1ns
`default_nettype none

module TerminalVIM828_tb();
   
    parameter CLOCK_HZ       = 1_000_000;
    parameter HALF_PERIOD_NS = 1_000_000_000 / (2 * CLOCK_HZ);
   
    // Generator sygnału zegarowego
   
reg Clock = 1’b1;
    always begin
   
    #HALF_PERIOD_NS;
        Clock = !Clock;
    end
   
   
// Zmienne
   
reg Reset  = 1’b0;
    reg TxRequest = 1’b0;                              // 1
   
reg [7:0] TxData = 8’d0;                            // 2
   
wire TxDone;                                        // 3
   
wire TxRxCommon;                                    // 4
   
   
// Instancja nadajnika UART
 
  UartTx #(                                           // 5
       
.CLOCK_HZ(CLOCK_HZ),
        .BAUD(100_000)                                  // 6
 
  ) UartTx_Inst(
        .Clock(Clock),
        .Reset(Reset),
        .Start_i(TxRequest),
        .Data_i(TxData),
        .Busy_o(),
        .Done_o(TxDone),
        .Tx_o(TxRxCommon)
    );
   
    // Instancja  testowanego modułu
    TerminalVIM828 #(                                   // 7
       
.CLOCK_HZ(CLOCK_HZ),
        .BAUD(100_000)
    ) DUT(
        .Clock(Clock),
        .Reset(Reset),
        .Rx_i(TxRxCommon),
        .Pin_o()
    );
   
    // Task do wysyłania bajtu danych przez UART
   
task UartSend(input [7:0] Data);                   // 8
       
begin
           
TxData    <= Data;                          // 9
           
TxRequest <= 1’b1;                         // 10
           
@(posedge Clock);                          // 11
       
    TxData    <= 8’bxxxxxxxx;                  // 12
           
TxRequest <= 1’b0;                          // 13
           
@(posedge TxDone);                         // 14
       
end
   
endtask
   
   
// Eksport wyników symulacji do pliku
   
initial begin
       
$dumpfile("terminal_vim828.vcd");
        $dumpvars(0, TerminalVIM828_tb);
    end

   
// Sekwencja testowa
   
initial begin
       
$timeformat(-6, 3, "us", 10);
        $display("===== START =====");
        $display("CLOCK_HZ = %9d", CLOCK_HZ);

       
@(posedge Clock);
        Reset = 1’b1;
       
        repeat(99) @(posedge Clock);                    // 15
       
       
// Wysyłanie 8 znaków, które mają

        // być pokazane na wyświetlaczu
        UartSend("A");                                 // 16
        UartSend("B");
        UartSend("C");
        UartSend("D");
        UartSend("E");
        UartSend("F");
        UartSend("G");
        UartSend("H");
       
        // Czekaj przez wszystkie 8 stanów wyświetlacza VIM828
        repeat(8) begin
            @(posedge DUT.VIM828_inst.ChangeState);     // 17
        end
       
        $display("====== END ======");
        $finish;
    end

endmodule
`default_nettype wire

Listing 3. Kod pliku terminal_vim828_tb.v

W linii 5 widzimy instancję modułu nadajnika UART. Aby ułatwić sobie przeglądanie wyników symulacji, ustawiamy prędkość transmisji równą 100000 bitów na sekundę za pomocą parametru BAUD (linia 6). Dzięki temu każda ramka transmisyjna będzie trwała dokładnie 100 μs.

Następnie tworzymy instancję testowanego modułu terminala (linia 7). W linii 8 znajduje się task ułatwiający wysyłanie danych przez UART, ale omówimy go za chwilę. Przejdźmy teraz do sekwencji testowej. W linii 15 czekamy przez 99 taktów zegarowych, a chwilę wcześniej czekaliśmy 1 takt, by zmienić stan sygnału Reset. Przy częstotliwości zegara równej 1 MHz, taka liczba taktów trwa dokładnie 100 μs, czyli tyle samo, ile wysłanie bajtu przez UART z prędkością transmisji 100000 bps. Zatem początek transmisji pierwszego bajtu będzie miał miejsce w setnej mikrosekundzie symulacji.

W linii 16 i kolejnych widzimy wywołanie tasku UartSend. W ten sposób wysyłamy po kolei litery od A do H. Przejdźmy teraz do linii 8, w której utworzyliśmy ten task. Taski, podobnie jak moduły, mogą mieć porty wejściowe i wyjściowe. W tym przypadku mamy tylko jeden 8-bitowy port wejściowy Data, przez który przekazujemy bajt w celu wysłania interfejsem UART.

Praca tasku dzieli się na dwa etapy. Pierwszy to załadowanie danych do nadajnika. W linii 9 kopiujemy dane przekazane portem Data do zmiennej TxData, którą utworzyliśmy na początku testbencha. W tej samej chwili (operator <= to przypisanie nieblokujące!) ustawiamy zmienną TxRequest (linia 10).

Czekamy na zbocze rosnące sygnału zegarowego (linia 11). W chwili wystąpienia zbocza, nadajnik odczytuje bajt oraz żądanie wysyłki, po czym zaczyna pracę. Task działa nadal. Zmienna TxData nie jest już istotna, więc wpisujemy do niej stan nieokreślony X (linia 12). Koniecznie musimy wyzerować zmienną TxRequest (linia 13) – w przeciwnym razie, w kolejnym takcie nadajnik ponownie rozpoczynałby wysyłanie bajtu. Następnie task czeka, aż zakończy się transmisja, czyli oczekuje na wystąpienie zbocza rosnącego sygnału TxDone (linia 14). Po spełnieniu tego warunku task kończy swoje działanie.

Po zakończeniu wysyłania wszystkich ośmiu bajtów czekamy, aż wykonają się wszystkie stany maszyny stanów w sterowniku wyświetlacza (linia 17) i kończymy testbench.

Aby wykonać symulację, uruchom skrypt terminal_vim828.bat, którego kod pokazano na listingu 4.

@echo off

iverilog -o terminal_vim828.o ^

    terminal_vim828.v         ^

    terminal_vim828_tb.v      ^

    decoder_14seg.v           ^

    vim828_defines.v           ^

    vim828.v                  ^

    vim828_pwm.v              ^

    edge_detector.v           ^

    strobe_generator.v        ^

    strobe_generator_ticks.v  ^

    synchronizer.v            ^

    uart_rx.v                 ^

    uart_tx.v

vvp terminal_vim828.o

del terminal_vim828.o

Listing 4. Kod pliku terminal_vim828.bat

Zobaczmy wyniki symulacji na rysunku 2. Screenshot ukazuje zbliżenie na pierwszą milisekundę symulacji, podczas której przesyła się osiem bajtów danych. Widzimy, że w rejestrach SegmentsX pojawiają się jakieś dane – są to znaki odebrane przez UART i przekształcone na kod 14-segmentowy.

Rysunek 2. Symulacja transmisji 8 bajtów

Po odebraniu każdego bajtu danych, wszystkie poprzednio odebrane dane są przesuwane do kolejnych rejestrów. Kiedy będziemy ręcznie wpisywać dane z klawiatury na terminalu, stanie się to widoczne jako przesuwanie zawartości wyświetlacza w lewo.

Przybliżmy widok na pierwszą ramkę transmisyjną, co zaprezentowano na rysunku 3. Dla ułatwienia sygnały strobe zaznaczyłem na żółto. Przyjrzyj się krótkiej szpilce stanu wysokiego wyjścia Done_o z odbiornika UART (w module TerminalVIM828 to wyjście jest połączone do zmiennej RxDone typu wire).

Rysunek 3. Zbliżenie na transmisję pierwszego bajtu

Świadczy on o zakończeniu odbioru. Jednocześnie, na wyjściu Data_o (jest to zmienna RxData w TerminalVIM828) pojawia się odebrany bajt, aby można było go odczytać w kolejnym takcie zerowym.

Wyjście Data_o odbiornika UART jest podłączone wprost do wejścia Data_i dekodera 14-segmentowego za pośrednictwem zmiennej RxByte w module TerminalVIM828. Widzimy, że odpowiedź dekodera pojawia się na jego wyjściu Segments_o w kolejnym takcie zegarowym. Jednocześnie w tym samym takcie pojawia się stan wysoki zmiennej OneBitDelay.

W kolejnym cyklu zegara obserwujemy skopiowanie danych z wyjścia dekodera 14-segmentowego do rejestru Segments0. Wszystkie rejestry SegmentsX były wyzerowane na początku symulacji, więc na rysunku 3 nie zauważymy jeszcze efektu przesuwania danych pomiędzy kolejnymi rejestrami – widać to lepiej na rysunku 4, który obejmuje większy przedział czasowy.

Moduł top

Utwórz nowy projekt i dodaj do niego pliki, które pokazano na rysunku 4. Wszystkie moduły, z wyjątkiem top, były już omawiane w niniejszym kursie. Znajdziesz je w repozytorium na GitHubie, dostępnym pod adresem [1]. Pamiętaj, że w drzewku projektowym plik vim828_defines.v musi znajdować się przed plikiem vim828.v. W razie potrzeby, kolejność plików możesz zmieniać, klikając i przeciągając je myszą we właściwe miejsce.

Rysunek 4. Drzewo projektu

Moduł top jest wyjątkowo prosty. Zawiera tylko instancję modułu TerminalVIM828 i nic więcej. Kod tego modułu pokazano na listingu 5.

// Plik top.v

`default_nettype none

module top(
    input wire Clock,                   // Pin 20
    input wire Reset,                   // Pin 17
    input wire UartRx_i,                // Pin 48
    output wire [36:1] PinLCD_o
);

    parameter CLOCK_HZ = 25_000_000;
   
    TerminalVIM828 #(
        .CLOCK_HZ(CLOCK_HZ),
        .BAUD(115200)

    ) TerminalVIM828_inst(
        .Clock(Clock),
        .Reset(Reset),
        .Rx_i(UartRx_i),
        .Pin_o(PinLCD_o)
    );
   
endmodule

`default_nettype wire

Listing 5. Kod pliku top.v

Uruchom syntezę, a następnie otwórz Spreadsheet i skonfiguruj piny tak, jak to zaprezentowano na rysunku 5. Pamiętaj, że korzystamy z zewnętrznego źródła sygnału zegarowego w postaci generatora kwarcowego. W zakładce Timing Preferences musisz ustawić częstotliwość zegara na 25 MHz (zostało to dokładnie opisane w 18 odcinku kursu).

Rysunek 5. Konfiguracja pinów w Spreadsheet

Otwórz raporty i wybierz raport Map. Zobacz rysunek 6. W miejscu zaznaczonym czerwoną ramką widzimy, że został użyty jeden blok pamięci EBR. Pamięć ta została użyta przez moduł Decoder14seg.

Rysunek 6. Raport mapowania peryferiów

Po wgraniu bitstreamu do FPGA, podłącz płytkę Segment14 do komputera za pomocą kabla USB. Otwórz dowolny terminal i napisz cokolwiek na klawiaturze. Znaki powinny natychmiast pojawić się na wyświetlaczu.

W następnym odcinku zobaczymy, jak działa algorytm Double Dabble oraz jak go zaimplementować w wersji kombinacyjnej i sekwencyjnej. Użyjemy go do przekształcania liczb z formatu szesnastkowego na dziesiętny.

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
lipiec 2024
DO POBRANIA
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik październik 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio wrzesień - październik 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje wrzesień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna październik 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich październik 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów