Kurs FPGA Lattice (18). Nadajnik UART

Kurs FPGA Lattice (18). Nadajnik UART

Interfejs UART, czyli Universal Asynchronous Receiver-Transmitter, jest jednym z podstawowych narzędzi do komunikacji między urządzeniami. Standard ten ma już kilkadziesiąt lat, ale mimo to wciąż jest szeroko stosowany. Nawet najprostsze mikrokontrolery mają wbudowany UART, a te bardziej rozbudowane mają nawet po kilka ich instancji. W najnowszym odcinku kursu FPGA nauczymy się, jak zrobić nadajnik UART, a w kolejnym odcinku opracujemy odbiornik.

Trochę teorii

Zacznijmy od omówienia, jak w ogóle działa interfejs UART. Daje on możliwość połączenia ze sobą dwóch urządzeń, tak by były równorzędne. Oba urządzenia wyposażone są w nadajnik i odbiornik. Pin nadajnika zwyczajowo oznacza się symbolem Tx, natomiast pin odbiornika nazywa się Rx. Spójrz na rysunek 1, na którym pokazano najczęściej stosowany schemat połączeń dwóch układów, komunikujących się poprzez UART. Wyjście Tx jednego układu połączone jest z wejściem Rx drugiego, a więc w standardzie UART linie transmisyjne krzyżują się w charakterystyczny sposób. Jeżeli komunikacja ma być jednokierunkowa, wystarczy tylko jedna linia transmisyjna. Oba urządzenia muszą mieć wspólną masę.

Rysunek 1. Schemat połączenia dwóch urządzeń z interfejsem UART

Istnieje możliwość dodania jeszcze kolejnych sygnałów odpowiedzialnych za tzw. hardware handshake, czyli informacje o tym, że nadajnik ma jakieś dane do wysłania, że odbiornik może je odebrać itp. Jednak nie będziemy wchodzić w szczegóły tej funkcjonalności, bo współcześnie są one już tylko zaszłością historyczną.

W omawianym interfejsie nie ma podziału na master i slave, z którym spotkamy się w przypadku I²C czy SPI. Każde urządzenie może rozpocząć nadawanie w dowolnej chwili, a odbiornik w drugim urządzeniu musi te dane odebrać i ewentualnie zapisać do bufora. Komunikacja w obie strony może zachodzić niezależnie od siebie. Możliwa jest także transmisja w obu kierunkach w tym samym czasie, czyli komunikacja full-duplex.

Interfejs UART, jak sama nazwa wskazuje, jest asynchroniczny. Oznacza to, że pomiędzy urządzeniami nie ma żadnego sygnału zegarowego. Zarówno nadajnik, jak i odbiornik muszą mieć swoje własne zegary, które synchronizują ze sobą na początku każdej ramki transmisyjnej. Ponadto muszą być one odpowiednio dokładne, aby transmisja przebiegała prawidłowo.

Przykład transmisji jednego bajtu przez UART przedstawiono na rysunku 2. W stanie spoczynkowym linie transmisyjne pozostają w stanie wysokim. Ramka danych rozpoczyna się bitem startu, który zawsze jest reprezentowany przez stan niski - to sygnał dla odbiornika, że ma uruchomić swój zegar i rozpocząć próbkowanie linii transmisyjnej.

Rysunek 2. Pojedyncza 8-bitowa ramka transmisji

Następnie nadawane są bity danych, w kolejności od najmłodszego do najstarszego. Najczęściej mamy do czynienia z transmisją 8-bitową. Czasami wykorzystuje się ramki 9-bitowe. Standard przewiduje także możliwość 7-, 6- czy nawet 5-bitowych, ale możliwość ta raczej nie ma praktycznego zastosowania.

Dane mogą mieć dodatkowo bit parzystości lub nieparzystości, którego celem jest weryfikacja poprawności transmisji. Często jednak pomija się go, ponieważ taka metoda kontroli jest mało skuteczna, a obecność dodatkowego bitu sprawia, że transmisja zajmuje więcej czasu.

Każda ramka kończy się bitem stopu, który zawsze ma stan wysoki. Opcjonalnie można ustawić dwa bity stopu (jeszcze nigdy nie spotkałem się jednak z sytuacją, w której ktoś faktycznie stosowałby takie rozwiązanie). Jeżeli jest to ostatnia ramka, wówczas linia pozostaje w stanie wysokim, a jeżeli nie, to następuje zbocze opadające i mamy kolejny bit startu (stan niski). Zwróć uwagę, że przesyłając dane 8-bitowe, musimy w rzeczywistości przesłać 10 bitów.

Istnieje dość sporo standardowych prędkości transmisji (baud rate), wyrażonych w bitach na sekundę, co pokazano w tabeli 1. Niestety, wartości te są „dziwne”. W rezultacie czas trwania pojedynczego bitu jest niewymierny - nie sposób osiągnąć dokładnie takich przedziałów czasu, dzieląc jakąś typową częstotliwość kwarcu, jak np. 10 MHz, 20 MHz, 25 MHz, przez liczbę całkowitą. Trzeba zastosować dzielnik frakcjonalny, kwarc o nietypowej częstotliwości lub... pogodzić się z faktem, że uzyskany timing będzie trochę niedokładny. W naszym przykładzie zadowolimy się trzecią opcją.

Podczas ćwiczeń z tego odcinka kursu opracujemy moduł, który - po naciśnięciu przycisku - wyśle przez UART prosty komunikat tekstowy „Hello”. Moduł nadawczy będzie miał możliwość konfiguracji jedynie prędkości transmisji. Długość ramki danych ustawimy na 8 bitów, bez możliwości zmiany. Nie będziemy stosować bitów parzystości, a na końcu ramki transmisyjnej znajdzie się jeden bit stopu. Jest to najczęściej stosowana konfiguracja.

Moduł StrobeGeneratorTicks

Do realizacji nadajnika i odbiornika potrzebujemy modułu, który będzie precyzyjnie wyznaczał odstępy czasu. Wielokrotnie w tym kursie stosowaliśmy moduł StrobeGenerator, który ustawiał stan wysoki na swoim wyjściu co pewien czas, określony w mikrosekundach. Taka rozdzielczość okazuje się jednak niewystarczająca na potrzeby komunikacji przez UART.

Zapewne większość Czytelników pomyśli, że aby rozwiązać ten problem, należy zmienić rozdzielczość z mikrosekund na nanosekundy. Owszem, jest to prawidłowe rozwiązanie, jednak wywołuje dość nieoczekiwany problem w Lattice Synthesis Engine. W jednej sekundzie jest miliard nanosekund, częstotliwość zegara wyrażona jest w milionach herców, a żądane odstępy czasu mogą być bardzo małe lub bardzo duże. Lattice Synthesis Engine obsługuje obliczenia na liczbach 32-bitowych ze znakiem, co oznacza, że największa liczba, jaką jest w stanie przetworzyć, to 2.147.483.647. Głównym problemem okazuje się fakt, że w przypadku przekroczenia maksymalnej wartości syntezator nie zgłosi żadnego błędu! Nadmiarowa część liczby zostaje obcięta i wynik traktowany jest nadal jako zwykła zmienna 32-bitowa, co prowadzi do nieprawidłowego działania układu. Trzeba przyznać, że jest to dość poważny błąd w Lattice Diamond… podczas gdy w Icarus Verilog obliczenia na dużych liczbach działają prawidłowo.

Obejdziemy problem dookoła, tworząc moduł podobny do StrobeGenerator, który już znamy. Różnić będzie się tylko tym, że zamiast czasu w mikrosekundach, podawać będziemy liczbę taktów zegarowych, jaka ma mijać między impulsami stanu wysokiego na wyjściu Strobe_o. Przeanalizujmy kod pokazany na listingu 1.

// Plik strobe_generator_ticks.v

`default_nettype none
module StrobeGeneratorTicks #(
parameter TICKS = 10 // 1
)(
input wire Clock,
input wire Reset,
input wire Enable_i,
output reg Strobe_o
);

localparam MAXCOUNT = TICKS - 1; // 2
localparam WIDTH = $clog2(MAXCOUNT + 1); // 3
reg [WIDTH-1:0] Counter; // 4

always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Counter <= MAXCOUNT; // 5
Strobe_o <= 1’b0;
end else if(Enable_i) begin // 6
if(!Counter) begin // 7
Counter <= MAXCOUNT; // 8
Strobe_o <= 1’b1; // 9
end else begin
Counter <= Counter - 1’b1; // 10
Strobe_o <= 1’b0; // 11
end
end else begin
Counter <= MAXCOUNT; // 12
end
end
endmodule
`default_nettype wire

Listing 1. Kod pliku strobe_generator_ticks.v

W linii 1 tworzymy jedyny parametr modułu, który definiuje, co ile cykli zegarowych ma zostać wygenerowany stan wysoki na wyjściu modułu. Domyślna wartość 10 spowoduje, że przez 9 taktów zegarowych wyjście Strobe_o będzie w stanie niskim, a przez 1 takt - w stanie wysokim. Operacja ta będzie powtarzać się cyklicznie przez cały czas trwania stanu wysokiego na wejściu Enable_i.

Aby zrealizować tę operację, potrzebujemy licznika, który zostanie załadowany jakąś wartością na początku cyklu i z każdym taktem zegara będzie zmniejszany do zera. W linii 2 tworzymy parametr lokalny MAXCOUNT określający maksymalną wartość licznika, która równa jest żądanej liczbie taktów zegarowych pomniejszonej o jeden.

Następnie w linii 3 tworzymy parametr lokalny WIDTH, dzięki któremu określimy liczbę bitów niezbędną, by pomieścić maksymalną wartość licznika. Licznik Counter tworzymy w linii 4.

Przejdźmy do jedynego bloku always w tym module. W momencie resetu układu ładujemy licznik Counter jego wartością maksymalną (linia 5), a wyjście Strobe_o, które jest typu reg, ustawiamy w stan niski.

Dalsza praca układu warunkowana jest wejściem Enable_i, sprawdzanym w linii 6. Jeżeli pozostaje ono w stanie niskim, to moduł nie pracuje i licznik Counter ładowany jest wartością początkową (linia 12). Jeżeli natomiast to wejście jest w stanie wysokim, to moduł pracuje.

W linii 7 sprawdzamy, czy licznik Counter osiągnął już wartość zerową. Używamy w tym celu operatora negacji logicznej. Jeśli wartość zerowa została osiągnięta, ponownie ładujemy licznik wartością maksymalną, aby rozpocząć kolejny cykl pracy, a wyjście Strobe_o ustawiamy w stan wysoki. Jeżeli natomiast Counter nie ma wartości zerowej, wykonywane są linie 10 i 11, tzn. pomniejszamy stan licznika o jeden i ustawiamy wyjście Strobe_o w stan niski.

Przeprowadźmy na szybko symulację modułu StrobeGeneratorTicks. Nie będziemy tutaj prezentować kodu testbenchu, ponieważ wygląda banalnie (jest dostępny w linku 1). Przebiegi sygnałów uzyskane podczas symulacji przedstawiono na rysunku 3. Widzimy, że po zmianie stanu wejścia Enable_i z 0 na 1 licznik pomniejsza swoją wartość wraz z każdym taktem sygnału zegarowego. Na wyjściu Strobe_o widzimy krótkie szpilki stanu wysokiego, które pojawiają się co 10 taktów zegara.

Rysunek 3. Symulacja modułu StrobeGeneratorTicks

Moduł UartTx

Istnieje bardzo wiele sposobów na implementację nadajnika UART w języku Verilog. W internecie można znaleźć mnóstwo rozwiązań tego problemu, od banalnych po bardzo zaawansowane. Starałem się, by rozwiązanie zaproponowane w tym odcinku kursu było proste do zrozumienia, ale także, by moduł był jak najbardziej użyteczny.

Koncepcja działania nadajnika UART jest następująca: po wystąpieniu żądania startu nadajnik ma przesłać 10 bitów (bit startu, osiem bitów danych oraz bit stopu). Każdy z tych bitów ma być wyprowadzany na wyjście Tx_o przez ściśle określony czas. Bity przeznaczone do transmisji zgrupujemy w 10-bitową zmienną typu wire. O tym, który z tych bitów będzie aktualnie nadawany, decydować będzie licznik inkrementowany od 0 do 9. Zatem w naszej konstrukcji pojawi się multiplekser, który ma 10 wejść danych i 4-bitowe wejście adresowe, sterowane przez wspomniany licznik. Zwiększanie stanu licznika będzie następowało co ściśle określony czas, zależny od żądanej szybkości transmisji. To zadanie powierzymy modułowi StrobeGeneratorTicks, który opracowaliśmy przed chwilą.

Przejdźmy teraz do analizy modułu nadajnika UART, którego kod pokazano na listingu 2.

// Plik uart_tx.v

`default_nettype none
module UartTx #(
parameter CLOCK_HZ = 10_000_000,
parameter BAUD = 115200 // 1
)(
input wire Clock,
input wire Reset,
input wire Start_i
input wire [7:0] Data_i,
output wire Busy_o,
output wire Done_o,
output wire Tx_o
);

// Timing
wire NextBit; // 2
localparam TICKS_PER_BIT = CLOCK_HZ / BAUD; // 3

StrobeGeneratorTicks #( // 4
.TICKS(TICKS_PER_BIT) // 5
) StrobeGeneratorTicks_inst(
.Clock(Clock),
.Reset(Reset),
.Enable_i(Busy || Start_i), // 6
.Strobe_o(NextBit) // 7
);

// Wyznaczanie aktualnie transmitowanego bitu
// oraz sygnałów zajętości
reg Busy;
reg [3:0] Pointer /* synthesis syn_encoding = "sequential" */;
// 8
reg [7:0] ByteCopy; // 9

always @(posedge Clock, negedge Reset) begin
if(!Reset) begin // 10
ByteCopy <= 0;
Busy <= 0;
Pointer <= 0;
end else if(Start_i) begin // 11
ByteCopy <= Data_i;
Busy <= 1’b1;
Pointer <= 0;
end else if(NextBit) begin // 12
if(Pointer == 4’d9) begin // 13
Busy <= 1’b0;
Pointer <= 4’d0;
end else begin
Pointer <= Pointer + 1’b1; // 14
end
end
end

wire [9:0] DataToSend; // 15
assign DataToSend = {1’b1, ByteCopy, 1’b0}; // 16

// Przypisanie wyjść
assign Tx_o = Busy ? DataToSend[Pointer] : 1’b1; // 17
assign Busy_o = Busy; // 18
assign Done_o = NextBit && (Pointer == 4’d9); // 19

endmodule
`default_nettype wire

Listing 2. Kod pliku uart_tx.v

Na liście parametrów mamy, jak zawsze, częstotliwość sygnału zegarowego CLOCK_HZ, a oprócz tego - żądaną szybkość transmisji BAUD w bitach na sekundę (linia 1). Domyślną wartość tego parametru ustawiamy na 115200 bit/s, ponieważ jest to jedna z najczęściej wybieranych prędkości transmisji przez UART. Lista portów modułu prezentuje się następująco:

  • Clock - wejście sygnału zegarowego,
  • Reset - wejście resetujące (aktywne w stanie niskim),
  • Start_i - wejście informujące, że moduł ma zacząć transmitować dane doprowadzone do wejścia Data_i,
  • Data_i - 8-bitowy port danych do wysłania,
  • Busy_i - wyjście informujące o tym, że trwa wysyłanie danych; stan wysoki oznacza pracę,
  • Done_o - na tym wyjściu pojawia się stan wysoki na jeden cykl zegarowy po zakończeniu wysyłania danych,
  • Tx_o - wyjście nadajnika UART, należy je połączyć z dowolnym pinem układu FPGA.

W pierwszej kolejności musimy zająć się generatorem impulsów wyznaczających zmianę nadawanego bitu. Na początku musimy ustalić, ile taktów zegarowych zajmuje wysłanie pojedynczego bitu. Obliczamy to w linii 3. Dzielimy częstotliwość zegara, która wyrażona jest liczbą taktów na sekundę przez liczbę bitów na sekundę. W rezultacie otrzymujemy liczbę taktów zegarowych na bit i wynik tego obliczenia zapisujemy do parametru lokalnego TICKS_PER_BIT. Należy mieć na uwadze, że tak obliczony wynik może trochę odbiegać od stricte matematycznego rozwiązania, ponieważ działamy tutaj na liczbach całkowitych, a nie rzeczywistych.

W linii 4 tworzymy instancję modułu typu StrobeGenerator Ticks o nazwie StrobeGeneratorTicks_inst. W linii 5 przekazujemy liczbę cykli zegarowych na bit, zapisaną w parametrze TICKS_PER_BIT.

Moduł StrobeGeneratorTicks ma działać tylko wtedy, kiedy nadajnik pracuje. Z tego powodu w linii 6 do wejścia Enable_i podajemy dwa sygnały, połączone operatorem OR - sygnał Start_i, pochodzący z wejścia modułu oraz Busy, który ustawiany jest w stan wysoki wtedy, kiedy moduł pracuje. Konstrukcja ta może na początku trochę dziwić, jednak ma ona swoje uzasadnienie. Chodzi o fakt, że zmienna Busy jest ustawiana dopiero w kolejnym cyklu zegara po wystąpieniu stanu wysokiego na wejściu Start_i. Gdybyśmy timer włączyli dopiero wtedy, kiedy Busy jest w stanie wysokim, to pierwszy nadawany bit byłby dłuższy, niż powinien o jeden takt zegarowy. Może ten jeden takt zegarowy nie jest bardzo istotny, ale zbudujemy nasz moduł w taki sposób, aby wszystkie bity miały dokładnie taki sam czas trwania.

Wyjście Strobe_o tego modułu (linia 7) połączone jest ze zmienną wire NextBit, która została zdefiniowana w linii 2. Zmienna ta odczytywana jest w bloku always w dalszej części kodu.

W linii 8 tworzymy 4-bitowy licznik Pointer wskazujący, który bit ramki transmisyjnej jest aktualnie udostępniony na wyjściu Tx_o. Przeskoczmy teraz do linii 15, gdzie tworzymy 10-bitową zmienną wire DataToSend, zawierającą wszystkie dziesięć bitów, które mają zostać wysłane. Wartość tej zmiennej przypisujemy, korzystając z operatora konkatenacji (linia 16), w którym scalamy bit startu, osiem bitów danych i bit stopu. Zwróć uwagę, że wykorzystujemy tutaj kopię danych do wysłania ze zmiennej reg ByteCopy, która zadeklarowana została w linii 9. Celem utrzymywania kopii danych wejściowych jest umożliwienie zmiany danych na wejściu Data_i w trakcie transmisji. Dzięki temu moduł będzie mógł mieć na swoim wejściu kolejny bajt przygotowany do wysyłki w następnej ramce.

Licznik Pointer jest traktowany przez Lattice Synthesis Engine jako rejestr maszyny stanów. Syntezator próbuje zoptymalizować algorytm, stosując kodowanie one-hot, czyli przetwarza 4-bitowy licznik na 16-bitowy rejestr, w którym tylko jeden z szesnastu bitów może być w stanie wysokim. W założeniu ma to przyspieszyć pracę układu i zwiększyć częstotliwość zegara, jednak w tym przypadku zwiększa jedynie (i to aż czterokrotnie) zapotrzebowanie na przerzutniki, a uzysk czasowy okazuje się niewielki. Z tego powodu w linii 8 umieszczamy dyrektywę syntezatora mówiącą, że rejestr maszyny stanu ma być sekwencyjny, tzn. ma to być standardowy licznik, tak jak to opiszemy dalej w kodzie.

Przejdźmy do bloku always, zawierającego całą logikę sekwencyjną transmitera UART. W stanie resetu zerujemy wszystkie zmienne typu reg (linia 10). Następnie podejmujemy działania w zależności od tego, czy spełniony jest jeden z dwóch poniższych warunków:

  1. Jeżeli wejście Start_i jest w stanie wysokim (linia 11), to przepisujemy wejście Data_i do kopii ByteCopy, ustawiamy rejestr Busy w stan wysoki i zerujemy licznik wskazujący, który bit jest obecnie transmitowany.
  2. Jeżeli sygnał NextBit jest w stanie wysokim (linia 12), to inkrementujemy licznik Pointer (linia 14) lub - jeżeli aktualnie ten licznik ma wartość 9 (linia 13), czyli wysyłany jest ostatni bit z ramki transmisyjnej - kończymy transmitować ostatni bit. Zatem ustawiamy Busy w stan niski, aby poinformować, że transmisja została zakończona.

Jeżeli żaden z opisanych warunków nie został spełniony, to w bloku always nic się nie dzieje. Równolegle pracuje moduł StrobeGeneratorTicks, który kontroluje, jak długo obecny bit ma być dostępny na wyjściu, zgodnie z oczekiwaną prędkością transmisji.

W linii 17 przypisujemy stan wyjścia Tx_o za pomocą operatora logicznego :? który znamy z C i C++. Jeżeli stan zmiennej Busy jest prawdziwy, to wtedy Tx_o jest łączone z bitem zmiennej DataToSend wskazywanym przez aktualny stan licznika Pointer, a jeżeli nie, to Tx_o ustawiamy na sztywno w stan wysoki.

Informację o zakończeniu pracy, dostępną na wyjściu Done_o, czerpiemy z sygnału NextBit (ustawianego w stan wysoki przy każdej zmianie transmitowanego bitu) oraz porównania zmiennej Pointer z liczbą 9, czyli maksymalną wartością tego licznika. Warunki te łączymy ze sobą operatorem AND, czyli muszą być one spełnione jednocześnie w tym samym takcie zegarowym.

Testbench modułu UartTx

Zgodnie z naszym zwyczajem, praktykowanym w wielu poprzednich odcinkach, opracujemy testbench, aby przeprowadzić symulację modułu, zbudowanego w tym odcinku kursu. Testbench nadajnika UART będzie wysyłał prosty komunikat tekstowy, zapisany w prostej pamięci ROM, jednak w celu zachowania maksymalnej prostoty nie będziemy tutaj korzystać z bloków EBR ani modułów opracowanych w 15 odcinku - pamięć z komunikatem tekstowym ma pojawić się tylko w testbenchu na potrzeby symulacji, a nie w kodzie syntezowanym do wykonywania przez układ FPGA.

Przeanalizujmy kod z listingu 3. Testbench zaczyna się standardowo, więc przeskoczymy od razu do linii 1. Deklarujemy tam 8-bitową pamięć Memory, która składa się tylko z ośmiu elementów, ponumerowanych od 0 do 7. Następnie, w bloku initial, który znajduje się poniżej, inicjalizujemy wszystkie osiem elementów pamięci. Pierwsze pięć bajtów utworzy napis „Hello”, a pozostałym trzem przypisujemy wartość zerową.

// Plik uart_tx_tb.v

`timescale 1ns/1ns
`default_nettype none
module UartTx_tb();

parameter CLOCK_HZ = 1_000_000;
parameter real HALF_PERIOD_NS = 1_000_000_000.0 / (2 * CLOCK_HZ);

// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#HALF_PERIOD_NS;
Clock = !Clock;
end

// Pamięć z wiadomością do wysłania
reg [7:0] Memory [0:7]; // 1
initial begin
Memory[0] = "H";
Memory[1] = "e";
Memory[2] = "l";
Memory[3] = "l";
Memory[4] = "o";
Memory[5] = 8’d0;
Memory[6] = 8’d0;
Memory[7] = 8’d0;
end

// Zmienne
wire ByteTransmitBusy; // 2
wire ByteTransmitDone; // 3
reg Reset = 1’b0;
reg ManualRequest = 1’b0; // 4

// Wskaźnik do bajtu pamięci, który ma zostać
// wysłany w następnej kolejności
reg [2:0] Pointer; // 5
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Pointer <= 0;
end else if(ManualRequest || ByteTransmitDone) begin // 6
Pointer <= Pointer + 1’b1; // 7
end else if(!ByteTransmitBusy) begin // 8
Pointer <= 0; // 9
end
end

wire ByteTransmitRequest = ManualRequest || // 10
(ByteTransmitDone && (Memory[Pointer] != 8’d0));

// Instancja testowanego modułu
UartTx #( // 11
.CLOCK_HZ(CLOCK_HZ),
.BAUD(100_000) // 12
) UartTx_inst(
.Clock(Clock),
.Reset(Reset),
.Start_i(ByteTransmitRequest), // 13
.Data_i(Memory[Pointer]), // 14
.Busy_o(ByteTransmitBusy), // 15
.Done_o(ByteTransmitDone), // 16
.Tx_o()
);

// Eksport wyników symulacji
initial begin
$dumpfile("uart_tx.vcd");
$dumpvars(0, UartTx_tb);
end

// Sekwencja testowa
integer i;
initial begin
$timeformat(-6, 3, "us", 12);
$display("===== START =====");
$display("Clock: %9d", CLOCK_HZ);
$display("Baud rate: %9d", DUT.BAUD);
$display("Ticks per bit:%9d",
DUT.StrobeGeneratorTicks_inst.TICKS);

@(posedge Clock);
Reset <= 1’b1;

repeat(99) @(posedge Clock); // 17
ManualRequest <= 1’b1;
@(posedge Clock);
ManualRequest <= 1’b0;

wait(Memory[Pointer] == 8’d0); // 18
@(posedge ByteTransmitDone);
repeat(100) @(posedge Clock);

$display("====== END ======");
$finish;
end

// Display transmitted bytes
always begin // 19
@(posedge ByteTransmitRequest) // 20
$display("%t Transmitting byte%d:%s", // 21
$realtime,
Pointer,
Memory[Pointer]
);
end

endmodule
`default_nettype wire

Listing 3. Kod pliku uart_tx_tb.v

Dalej tworzymy kilka zmiennych: w liniiach 2 i 3 będą to zmienne ByteTransmitBusy i ByteTransmitDone, łączące wyjście testowanego modułu nadajnika UART (linie 15 i 16). Posłużą do sterowania logiką testbencha. Zmienna ManualRequest typu reg (linia 4) jest doprowadzona do wejścia Start_i testowanego modułu (linia 13) i służy do uruchomienia transmisji pierwszego bajtu.

Musimy zaimplementować prosty układ sekwencyjny, który będzie podawał do nadajnika UART kolejne bajty pamięci. Zastosujemy algorytm podobny do tego, który w module UartTx podawał kolejne bity na wyjście nadajnika. W tym celu tworzymy 3-bitowy licznik Pointer (linia 5). 3 bity wystarczą, by zaadresować pamięć mającą tylko osiem elementów.

Poniżej rozpoczyna się blok always, który wykonuje się równolegle do sekwencji testowej. W bloku always sprawdzane są tylko trzy proste warunki:

  1. Jeżeli aktywny jest sygnał resetujący, to licznik Pointer jest zerowany.
  2. Jeżeli w stanie wysokim są sygnały ManualRequest (rozpoczynający nadawanie bajtów z pamięci) lub ByteTransmitDone (informujący, że zakończono transmitowanie bajtu), to licznik Pointer zwiększany jest o jeden (linia 7).
  3. Jeżeli nie jest prawdziwa zmienna ByteTransmitBusy (informująca, że nadajnik właśnie pracuje), to zerujemy licznik Pointer (linia 9).

Jeżeli żaden z tych warunków nie jest spełniony, to Pointer się nie zmienia. Jednocześnie pracuje moduł nadajnika.

W linii 10 tworzymy zmienną ByteTransmitRequest typu wire, która ma informować nadajnik, żeby rozpoczął nadawanie kolejnego bajtu danych. Została ona doprowadzona do wejścia Start_i nadajnika (linia 13). Zmienna ta jest ustawiana za pomocą operatora OR, łączącego ze sobą dwa warunki:

  • Zmienna ManualRequest jest w stanie wysokim, czyli rozpoczynamy wysłanie pierwszego bajtu z pamięci.
  • Zmienna ByteTransmitDone jest w stanie wysokim, czyli zakończyło się wysyłanie bajtu i można wysyłać kolejny, ale bajt pamięci aktualnie wskazywany przez Pointer nie może mieć wartości zerowej. Chodzi tu o zatrzymanie nadawania kolejnych bajtów z pamięci, kiedy zostanie wysłany ostatni znak, a pozostałe są zerami.

Dochodzimy wreszcie do instancji testowanego modułu w linii 11. Moduł konfigurujemy w taki sposób, aby prędkość transmisji wynosiła 100000 bitów na sekundę - celowo nie użyłem tutaj typowych „dziwnych” prędkości, jakie stosuje się w UART, aby ułatwić analizowanie wykresów symulacji. Ustawienie prędkości na 100000 bps sprawi, że wysyłanie jednego bajtu danych będzie trwało dokładnie 100 μs, co przy symulowanym zegarze o częstotliwości 1 MHz będzie trwało 100 taktów zegarowych.

Na początku sekwencji testowej printujemy na konsoli kilka komunikatów. Następnie czekamy przez 100 cykli zegarowych, co trwa łącznie 100 μs (linia 17). Po tym ustawiamy zmienną ManualRequest w stan wysoki na jeden cykl sygnału zegarowego. Powoduje to rozpoczęcie pracy nadajnika UART.

W linii 18 zawieszamy wykonywanie sekwencji testowej za pomocą instrukcji wait(). Sprawia ona, że układ czeka tak długo, aż warunek podany w nawiasach zostanie spełniony. W tym przypadku - oczekujemy, aż komórka pamięci wskazywana przez Pointer będzie mieć wartość zerową, co oznacza zakończenie ciągu znaków.

W linii 19 mamy jeszcze jeden blok always. Jego celem jest tylko wyświetlanie komunikatów na konsoli w momencie rozpoczęcia transmisji bajtu danych. Blok jest wykonywany w pętli nieskończonej i składa się jedynie z dwóch instrukcji. Pierwszą z nich jest oczekiwanie na zbocze rosnące sygnału ByteTransmitRequest (linia 20), które powoduje rozpoczęcie transmisji danych. Druga to instrukcja $display, wyświetlająca w konsoli symulatora informacje o aktualnym czasie, zawartości licznika Pointer oraz zawartości komórki pamięci wskazywanej przez ten licznik.

@echo off
iverilog -o uart_tx.o uart_tx.v uart_tx_tb.v strobe_generator_ticks.v
vvp uart_tx.o
del uart_tx.o

Listing 4. Kod skryptu uart_tx.bat

Listing 4 prezentuje skrypt wykonujący symulację w Icarus Verilog. Po zakończeniu symulacji powinniśmy zobaczyć komunikaty takie, jak na listingu 5.

VCD info: dumpfile uart_tx.vcd opened for output.
===== START =====
Clock: 1000000
Baud rate: 100000
Ticks per bit: 10
100.000us Transmitting byte 0: H
200.000us Transmitting byte 1: e
300.000us Transmitting byte 2: l
400.000us Transmitting byte 3: l
500.000us Transmitting byte 4: o
====== END ======
uart_tx_tb.v:94: $finish called at 700000 (1ns)

Listing 5. Wynik symulacji na konsoli

Czas przeanalizować rezultaty symulacji. Otwórz plik wynikowy uart_tx.vcd w przeglądarce GTKWave i skonfiguruj ją tak, aby otrzymać obraz widoczny na rysunku 4.

Rysunek 4. Przebiegi uzyskane podczas symulacji

Praca układu zaczyna się od ustawienia w stan wysoki sygnału ManualRequest w setnej mikrosekundzie. Wysyłanych jest pięć bajtów, a początek każdego z nich wyznacza krótka szpilka stanu wysokiego na sygnale ByteTransmitRequest, który jest tym samym, czym był Start_i w module UartTx. Dla zwiększenia czytelności zaznaczono te przebiegi kolorem żółtym.

Zwróć uwagę na zmienne Pointer testbencha oraz Data_i modułu UartTx. Widzimy, jak kolejne bajty komunikatu „Hello” odczytywane są z pamięci i trafiają na wejście danych.

Przyjrzyjmy się bliżej procesowi wysyłania pojedynczej ramki. Wyjście Tx zostało zaznaczone kolorem pomarańczowym. Zwróć uwagę na sygnał NextBit modułu UartTx. Ustawienie go w stan wysoki powoduje inkrementację zmiennej Pointer w UartTx, a to pociąga za sobą zmianę bitu na wyjściu Tx_o.

Rysunek 5. Zbliżenie na symulację przesyłania pierwszego bajtu

Moduł top

Czas na ćwiczenia z prawdziwym FPGA. Utworzymy prosty projekt przy użyciu płytki MachXO2 Mega oraz User Interface Board. Wykorzystamy przycisk enkodera obrotowego E41. Po jego wciśnięciu zostanie uruchomiony komunikat „Hello” poprzez nadajnik UART, a jego wyjście wyprowadzimy na jeden z pinów złącza goldpin, dostępnego na płytce. Należy ten pin podłączyć z dowolnym konwerterem UART/USB opartym np. na układzie FT232RL, CP2102, CH340 lub innym. Zakładam, że Czytelnik ma w zanadrzu jakiś konwerter tego typu. Ponadto wyprowadzimy sygnał Busy na inny pin złącza goldpin, aby obserwować go na oscyloskopie.

Po utworzeniu nowego projektu dodaj do niego pliki widoczne na rysunku 6. Wszystkie pliki z modułami, z wyjątkiem top, omawiane były już wcześniej. Możesz pobrać je z linków 1 i 2 na końcu artykułu.

Rysunek 6. Lista plików projektu

Kod modułu top pokazano na listingu 6. W gruncie rzeczy jest on bardzo podobny do kodu testbencha. Istnieje jednak pewna spora różnica między tym modułem top a opracowanymi w poprzednich odcinkach kursu. Tym razem, zamiast generatora RC wbudowanego w strukturę FPGA, korzystać będziemy z zewnętrznego generatora kwarcowego. Jego wyjście doprowadzone jest do pinu 20 układu FPGA.

// Plik top.v

`default_nettype none
module top(
input wire Clock, // Pin 20 (Zegar 25MHz)
input wire Reset, // Pin 17 (Przycisk K0)
input wire EncoderA_i, // Pin 66 (Przycisk enkodera E41)
output wire Tx_o, // Pin 74 (Oznaczenie Tx na złączu)
output wire Busy_o // Pin 27 (Oznaczenie SPI-CS)
);

parameter CLOCK_HZ = 25_000_000; // 1

// Wykrywanie zbocza przycisku
wire ManualRequest;
Encoder EncoderA(
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(1’b1),
.AsyncB_i(1’b1),
.AsyncS_i(EncoderA_i),
.Increment_o(),
.Decrement_o(),
.ButtonPress_o(ManualRequest),
.ButtonRelease_o(),
.ButtonState_o()
);

// Wiadomość do wysłanie
reg [7:0] Memory [0:7];
initial begin
Memory[0] = "H";
Memory[1] = "e";
Memory[2] = "l";
Memory[3] = "l";
Memory[4] = "o";
Memory[5] = " ";
Memory[6] = 8’d0;
Memory[7] = 8’d0;
end

// Zmienne
wire ByteTransmitBusy;
wire ByteTransmitDone;

// Wskaźnik do bajtu pamięci, który ma zostać
// wysłany w następnej kolejności
reg [2:0] Pointer;
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Pointer <= 0;
end else if(ManualRequest || ByteTransmitDone) begin
Pointer <= Pointer + 1’b1;
end else if(!ByteTransmitBusy) begin
Pointer <= 0;
end
end

wire ByteTransmitRequest = ManualRequest ||
(ByteTransmitDone && (Memory[Pointer] != 8’d0));

// Instancja nadajnika UART
UartTx #(
.CLOCK_HZ(CLOCK_HZ),
.BAUD(115_200) // 2
) UartTx_inst(
.Clock(Clock),
.Reset(Reset),
.Start_i(ByteTransmitRequest),
.Data_i(Memory[Pointer]),
.Busy_o(ByteTransmitBusy),
.Done_o(ByteTransmitDone),
.Tx_o(Tx_o)
);

assign Busy_o = ByteTransmitBusy;

endmodule

`default_nettype wire

Listing 6. Kod pliku top.v

Zmiana ta podyktowana jest koniecznością użycia precyzyjnego źródła sygnału zegarowego podczas użytkowania UART. Zgodnie z dokumentacją MachXO2 Family Data Sheet wbudowany generator ma tolerancję częstotliwości ±6,5%, a wypełnienie sygnału prostokątnego może się zmieniać od 43% do 57%.

To bardzo słabe parametry. Takiego generatora można użyć do multipleksowania wyświetlacza LED, a nie do taktowania nadajnika UART. Z tego powodu na płytce MachXO2 Mega dostępny jest precyzyjny generator kwarcowy o częstotliwości 25 MHz. Pamiętaj, by parametr CLOCK_HZ
ustawić na 25_000_000, a nie 14_000_000, jak w poprzednich odcinkach kursu (linia 1).

Kolejna ważna różnica między modułem top a testbenchem to prędkość transmisji nadajnika. W linii 2 ustawiamy ją na 115200 bitów na sekundę.

Uruchamiamy syntezator, a następnie otwieramy narzędzie Spreadsheet. Konfigurujemy piny układu FPGA w sposób zaprezentowany na rysunku 7.

Rysunek 7. Konfiguracja pinów układu FPGA

Teraz pojawi się nowość. Używamy zewnętrznego źródła zegara, więc powinniśmy poinformować Lattice Diamond, jaka jest częstotliwość sygnału zegarowego dostarczonego do pinu wejściowego. Na dole okna Spreadsheet znajdują się różne zakładki. Wybieramy zakładkę Timing Preferences. Klikamy przycisk PERIOD/FREQUENCY Preference, który znajduje się na drugiej pozycji od góry w pionowym pasku narzędzi po lewej stronie (zaznaczony strzałką na rysunku 8). Pojawi się okienko konfiguracji wejścia zegarowego. Wszystkie opcje należy ustawić w taki sposób, jak to pokazano na rysunku 8.

Rysunek 8. Konfiguracja wejścia zegarowego

Po kliknięciu OK wracamy do Spreadsheet i powinniśmy zobaczyć widok identyczny z tym z rysunku 9.

Rysunek 9. Timing Preferences w Spreadsheet

Zapisujemy, generujemy bitstream i wgrywamy do FPGA. Otwieramy dowolny terminal, np. Putty, RealTerm lub jakikolwiek inny. Port szeregowy konfigurujemy na prędkość 115200, 8 bitów danych, brak bitu parzystości i 1 bit stopu. Po wciśnięciu enkodera powinien pojawić się napis „Hello” - tak jak to pokazano na rysunku 10.

Rysunek 10. Demonstracja działania układu z wykorzystaniem programu RealTerm

Na zakończenie podłączmy jeszcze oscyloskop do pinów Tx oraz Busy, aby zobaczyć, jak w rzeczywistości wyglądają te sygnały. Obraz z oscyloskopu prezentuje rysunek 11. Przypatrz się końcówce zarejestrowanych sygnałów. Sygnał Tx przechodzi ze stanu niskiego w wysoki i pozostaje w nim do końca, a sygnał Busy zmienia swój stan dopiero chwilę później. Czy potrafisz wytłumaczyć to zjawisko?

Rysunek 11. Zrzut ekranu z oscyloskopu

W tym odcinku nauczyliśmy się wysyłać dane przez UART. W kolejnym zobaczymy, jak zbudować odbiornik. Przyda się nam on jeszcze wielokrotnie w nadchodzących odcinkach!

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
kwiecień 2024
DO POBRANIA
Materiały dodatkowe
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 grudzień 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów