Kurs FPGA Lattice (9). Wyświetlacz multipleksowany

Kurs FPGA Lattice (9). Wyświetlacz multipleksowany

Wyświetlacze 7-segmentowe są bardzo prostym sposobem na przekazywanie informacji pomiędzy urządzeniem a użytkownikiem. W tym odcinku kursu napiszemy moduł, który obsługuje wyświetlacz 8-cyfrowy. Następnie sparametryzujemy moduł wyświetlacza w taki sposób, aby dało się zmienić liczbę cyfr, zmieniając tylko jeden parametr. Oprócz tego nauczymy się, jak wyzwalać różne zdarzenia w różnych modułach, pomimo że wszystkie moduły są taktowane tym samym sygnałem zegarowym.

Zastosujemy wyświetlacze ze wspólną katodą. Oznacza to, że aby zaświecić segment, do odpowiadającego mu pinu należy przyłożyć napięcie dodatnie (poprzez rezystor ograniczający prąd!), a elektrodę wspólną wyświetlacza trzeba połączyć z masą.

Schemat 8-cyfrowego wyświetlacza multipleksowanego pokazano na rysunku 1. Wszystkie odpowiadające sobie elektrody segmentów są połączone razem i mają wspólne rezystory ograniczające prąd o rezystancji 100 Ω. Tak utworzone sygnały SegmentX należy podłączyć do pinów układu FPGA. Elektrody wspólne wyświetlaczy są sterowane poprzez tranzystory NPN. Kiedy na sygnale CathodeX pojawi się logiczna jedynka, przez bazę tranzystora płynie niewielki prąd, a tranzystor otworzy się, zwierając elektrodę wspólną wyświetlacza z masą. Kiedy do bazy doprowadzony jest stan niski, nie płynie przez nią prąd, tranzystor jest zamknięty, a w rezultacie żaden segment wyświetlacza nie będzie mógł się zaświecić – niezależnie od tego jakie stany są ustawione na liniach SegmentX.

Rysunek 1. Schemat 8-cyfrowego wyświetlacza multipleksowanego

Jest to jedna z możliwych implementacji, właściwa dla średniej wielkości wyświetlaczy. W przypadku mniejszych wyświetlaczy, ciemniejszych, bardziej energooszczędnych lub o mniejszej liczbie cyfr, można by się pokusić o wyeliminowanie tranzystorów i połączenie elektrod wspólnych prosto do wyprowadzeń układu FPGA. Natomiast jeżeli chcemy sterować dużym, jasnym wyświetlaczem, złożonym z wielu cyfr, wtedy należałoby dodać tranzystory także na linie segmentów.

Musimy pamiętać, że maksymalny prąd jaki możemy pobrać z pinu układu MachXO2 to 24 mA, a sumaryczny prąd pobierany ze wszystkich pinów nie powinien przekraczać 8 mA×n, gdzie n to liczba pinów dostępnych w banku. Układy MachXO2 mają od czterech do sześciu banków pinów, a każdy z nich może być zasilany innym napięciem poprzez wyprowadzenie VCCIOx. Po szczegóły odsyłam do dokumentacji MachXO2 Family Datasheet, dostępnej pod adresem [1].

Multipleksacja polega na tym, że w danej chwili tylko jeden z dostępnych wyświetlaczy pokazuje cyfrę, zaświecając odpowiednie segmenty, a wszystkie pozostałe wyświetlacze pozostają wygaszone. Po upływie pewnego krótkiego czasu, następuje wyłączenie dotychczas pracującego wyświetlacza, poprzez zamknięcie tranzystora, sterującego jego elektrodą wspólną. Zmieniają się wygnały sterujące segmentami, a następnie włączany jest kolejny wyświetlacz na pewien czas. Czas ten musi być na tyle krótki, aby ludzkie oko nie zauważyło przełączania wyświetlaczy. W naszym przykładzie będą to dwie milisekundy.

Wynika z tego ważny wniosek – zawsze aktywny jest tylko jeden wyświetlacz, a skoro mamy 8 cyfr w wyświetlaczu to każda cyfra pracuje tylko przez ⅛ dostępnego czasu. Skutkiem tego jest zmniejszenie jasności do ⅛ wartości, jaka jest dostępna przy świeceniu ciągłym. Z tego powodu, projektując wyświetlacze multipleksowane, należy wybierać komponenty o możliwie jak największej jasności. Efekt przyciemnienia można zniwelować, sterując wyświetlacz większym prądem, niż jest to dopuszczalne przy pracy ciągłej. Informacji o maksymalnych prądach należy szukać w dokumentacji wyświetlacza pod hasłem continuous current oraz peak current.

W naszym przykładzie będziemy zaświecać wyświetlacze zaczynając od zerowego (Cathode0) do siódmego (Cathode7). Nic nie stoi na przeszkodzie, by kierunek multipleksacji był odwrotny – nie ma to w praktyce żadnego znaczenia.

Podczas tego kursu utworzymy sterownik wyświetlacza, a żeby go przetestować, opracujemy prosty, 8-cyfrowy licznik. Będzie on zwiększał swoją wartość co 100 milisekund. Ponadto, zapalone będą dwie kropki, które będą przesuwać się od prawej do lewej. Przesunięcie kropek ma następować co jedną sekundę. Kod napiszemy w taki sposób, aby czasy opóźnień dało się łatwo modyfikować. W tym celu zastosujemy strobe generator.

Moduł StrobeGenerator

Aby zrozumieć sens tego modułu, musimy dowiedzieć się, jak działa system zegarowy w FPGA. Każdy sygnał w FPGA obarczony jest niewielkim, ale istotnym, czasem propagacji. Jest to czas pomiędzy ustaleniem się stanu sygnału na wyjściu nadajnika i ustaleniem się tego sygnału na wejściu odbiornika. Czas ten może być bardzo różny. Czas propagacji sygnałów pomiędzy sąsiadującymi ze sobą blokami jest mały, ale jeżeli sygnał ma przedostać się z jednego końca struktury krzemowej na drugi koniec, wówczas ten czas będzie dużo dłuższy.

Problem staje się jeszcze większy, kiedy jeden nadajnik steruje wieloma odbiornikami, umieszczonymi w różnych miejscach struktury krzemowej. Odbiorniki umieszczone bliżej nadajnika zareagują szybciej niż te, które są umieszczone dalej. Może to prowadzić do trudno wykrywalnych błędów.

Aby przeciwdziałać temu problemowi, projektanci układów FPGA umieszczają w nich globalne linie zegarowe – w MachXO2 nazywają się Global Primary Clock Networks. Są to specjalne ścieżki zaprojektowane w taki sposób, aby zminimalizować czas propagacji. Nadajnikiem w globalnych liniach zegarowych mogą być wyprowadzenia PCLKxx, do których można podłączyć zewnętrzne źródło sygnału zegarowego, wbudowany generator OSCH, układ PLL, dzielniki CLKDIVC i kilka innych peryferiów.

Globalnych linii zegarowych w MachXO2 jest osiem. Każda z nich może pracować z sygnałem zegarowym o innej częstotliwości i fazie. Jednak pojawia się tu kolejny problem – moduły pracujące w różnych domenach zegarowych (tzn. taktowane różnymi zegarami) nie mogą komunikować się ze sobą bezpośrednio, ponieważ wpadniemy w pułapkę metastabilności (jest to temat na osobny odcinek kursu). Pomiędzy modułami, taktowanymi różnymi zegarami, należy umieścić układy synchronizujące.

Na szczęście użycie wielu różnych zegarów najczęściej wcale nie jest potrzebne. Idealnie jest zastosować tylko jeden zegar, który steruje wszystkimi modułami. Natomiast to, czy moduł ma wykonać jakąś operację czy ma czekać, można sterować dodatkowym wejściem tego modułu, które często nazywane jest Enable. Kiedy sygnał na wejściu Enable ma wartość 1, wówczas moduł coś robi w kolejnym takcie zegara, a kiedy Enable ma stan 0, to moduł ignoruje sygnał zegarowy.

Sygnał Enable powinien mieć stan wysoki tylko na jeden takt sygnału zegarowego. Ponadto stan wysoki może pojawiać się w stałych odstępach czasu. Krótkie impulsy stanu wysokiego, o szerokości jednego taktu zegara, będziemy nazywać Strobe. Sytuację taką ilustruje rysunek 2. Zwróć uwagę, że okres sygnału zegarowego to 1 ns, natomiast okres sygnału Strobe to 10 ns – w ten sposób uzyskaliśmy efekt, który dałby dzielnik częstotliwości przez 10.

Rysunek 2. Przebiegi generatora strobów, trwających jeden takt zegara, co 10 cykli zegarowych

Sygnał Strobe prowadzony jest z użyciem zwykłych ścieżek pomiędzy blokami logicznymi w FPGA. Jednak jego czas propagacji nie stanowi problemu. Zwróć uwagę, że sygnał Strobe przybiera stan wysoki w chwili wystąpienia zbocza rosnącego zegara. Ten sygnał wydostaje się z generatora, a następnie podróżuje przez strukturę krzemową i trafia do wejścia Enable sterowanego układu. W tym miejscu czeka aż wystąpi kolejne zbocze rosnące sygnału zegarowego. W ten sposób opóźniamy reakcję odbiornika o 1 cykl zegarowy, ale omijamy litanię problemów, o których pisałem wcześniej.

Przeanalizujmy listing 1. Jest to moduł generujący sygnały Strobe, które mają cyklicznie, co jakiś stały czas, uaktywniać inne moduły, które taktowane są tym samym sygnałem zegarowym.

Listing 1. Kod pliku strobe_generator.v

// Plik strobe_generator.v
module StrobeGenerator #(
parameter FREQUENCY_HZ = 10_000_000, // #1
parameter PERIOD_US = 100 // #2
)(
input Clock,
input Reset,
output reg Strobe
);

localparam DELAY = (FREQUENCY_HZ / 1_000_000) * PERIOD_US - 1; // #3
localparam WIDTH = $clog2(DELAY + 1); // #4

reg [WIDTH-1:0] Counter; // #5

always @(posedge Clock, negedge Reset) begin
if(!Reset) begin // #6
Counter <= DELAY;
Strobe <= 1’b0;
end else if(!Counter) begin // #7
Counter <= DELAY;
Strobe <= 1’b1;
end else begin // #8
Counter <= Counter - 1’b1;
Strobe <= 1’b0;
end
end

endmodule

Sygnał Strobe generowany jest poprzez zliczanie odpowiedniej liczby taktów sygnału zegarowego. Aby ułatwić sobie konfigurowanie modułu, częstotliwość zegara oraz żądany okres sygnału Strobe, przekażemy do modułu przy pomocy parametrów podanych w liniach #1 oraz #2. Parametry te mają zdefiniowane wartości 10000000 Hz i 100 μs. Są to wartości domyślne, które będziemy nadpisywać przy tworzeniu instancji modułu StrobeGenerator.

W linii #3 tworzymy parametr lokalny DELAY, informujący, ile cykli zegarowych trzeba zliczyć, aby uzyskać żądany czas opóźnienia. Ta liczba jest początkową wartością licznika Counter, który tworzymy w linii #5. Musimy jeszcze określić, ile bitów musi mieć ten licznik. Wykorzystujemy funkcję $clog2(x+1), która zwraca nam liczbę bitów potrzebnych, by „zmieścić” liczbę x (linia #4).

Następnie mamy blok always, który standardowo reaguje na zbocze rosnące sygnału zegarowego posedge Clock i zbocze opadające sygnału resetującego negedge Reset. Kiedy Reset jest aktywny, wówczas licznik Counter jest ładowany wartością początkową, określoną poprzez parametr lokalny DELAY, a przerzutnik Strobe jest zerowany (linia #6).

Licznik liczy w dół przy każdym takcie zegara, a przerzutnik Strobe pozostaje przez cały czas w stanie niskim (linia #8). Kiedy licznik osiągnie wartość zero, wtedy spełniony zostaje warunek z linii #7, a następnie Strobe zostaje ustawiony w stan wysoki, a licznik ponownie ładowany jest wartością początkową.

Można zadać pytanie dlaczego licznik liczy w dół, a nie w górę? Obojętnie jaki byłby kierunek liczenia, czas na zliczenie określonej liczby taktów byłby taki sam. Jednak jest duża różnica w zasobach sprzętowych, jakie potrzebne są do realizacji takiego algorytmu. Liczenie w górę oznaczałoby, że wartość licznika należy porównywać z wartością maksymalną przy pomocy wielobitowego komparatora. Natomiast liczebnie w dół wykorzystuje tylko porównywanie do zera. Porównanie z zerem jest zdecydowanie prostsze i szybsze niż porównywanie z liczbą niezerową. Okazuje się, że StrobeGenerator porównujący licznik liczący do zera może działać z szybszym zegarem niż alternatywna wersja, porównująca licznik do wartości niezerowej.

Wróćmy jeszcze raz do linii #7, gdzie jest warunek if(!Counter). Jaka byłaby różnica, gdybyśmy napisali if(Counter == 0)? Drugi sposób spowoduje wygenerowanie komparatora cyfrowego, który porównuje Counter do zera. Wyjście tego komparatora jest w stanie 1, kiedy Counter równa się zero. Pierwszy sposób prowadzi do wygenerowania wielowejściowej bramki NOR, do której doprowadzone są wyjścia ze wszystkich przerzutników licznika Counter. Bramka NOR daje na wyjściu stan 1, kiedy jej wszystkie wejścia są w stanie 0. Okazuje się, że pierwszy sposób ma mniejsze zapotrzebowanie na elementy Slice.

Moduł top

Moduł top zawiera instancję modułu wyświetlacza oraz kilka elementów utworzonych tylko po to, by generować jakieś testowe, zmieniające się dane, które wyświetlacz ma pokazywać. Przeanalizujmy listing 2.

Listing 2. Kod pliku top.v

// Plik top.v
module top(
input Reset,
output [7:0] Cathodes, // #1
output [7:0] Segments // #2
);

// Generator sygnału zegarowego
wire Clock14MHz; // #3
OSCH #( // #4
.NOM_FREQ(“14.00”)
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock14MHz),
.SEDSTDBY()
);

// Licznik Data ma być zwiększany co 0,1s
wire CountEnable; // #4
StrobeGenerator #( // #5
.FREQUENCY_HZ(14_000_000),
.PERIOD_US(100_000)
) CountEnableStrobeGenerator(
.Clock(Clock14MHz),
.Reset(Reset),
.Strobe(CountEnable)
);

// Stan tego licznika zostanie pokazany na wyświetlaczu
reg [31:0] Data; // #6
always @(posedge Clock14MHz, negedge Reset)
if(!Reset)
Data = 32’hFF; // #7
else if(CountEnable) // #8
Data <= Data + 1’b1;

// Przecinki mają być przesuwane co 1s
wire DecimalPointMoveEnable; // #9
StrobeGenerator #( // #10
.FREQUENCY_HZ(14_000_000),
.PERIOD_US(1_000_000)
) DecimalStrobeGenerator(
.Clock(Clock14MHz),
.Reset(Reset),
.Strobe(DecimalPointMoveEnable)
);

// Kod odpowiedzialny za przesuwanie przecinków
reg [7:0] DecimalPoints; // #11
always @(posedge Clock14MHz, negedge Reset)
if(!Reset)
DecimalPoints = 8’b00000011; // #12
else if(DecimalPointMoveEnable) // #13
DecimalPoints <= {DecimalPoints[6:0], DecimalPoints[7]}; // #14

// Instancja wyświetlacza multipleksowanego
DisplayMultiplex #( // #15
.FREQUENCY_HZ(14_000_000),
//.DIGITS(8),
.SWITCH_PERIOD_US(2_000)
) DisplayMultiplex0(
.Clock(Clock14MHz),
.Reset(Reset),
.Data(Data),
.DecimalPoints(DecimalPoints),
.Cathodes(Cathodes),
.Segments(Segments)
);

endmodule

Moduł top ma tylko trzy porty. Są to standardowy sygnał Reset, aktywny w stanie niskim oraz Cathodes i Segments (linie #1 i #2). Katody należy podłączyć tak, jak jest to przedstawione na rysunku 1. Segmenty zwyczajowo oznacza się literami, a w Verilogu poszczególne linie w magistralach oznacza się cyframi. Przyjąłem, że Segment[0] będzie sterować segmentami A, Segment[1] to segment B, itd, a Segment[7] oznacza segment P, czyli kropkę. W linii #3 tworzymy sygnał Clock14MHz, który rozprowadza sygnał zegarowy do wszystkich modułów. Źródłem sygnału zegarowego jest instancja modułu OSCH, utworzona w linii #4. Wykorzystujemy dokładnie to samo źródło zegara, co w poprzednich odcinkach kursu, więc nie będę tutaj go opisywał ponownie.

Przeskoczmy do linii #15. Znajdziemy tam instancję modułu DisplayMultiplex. Moduł konfigurujemy przy pomocy następujących parametrów:

  • FREQUENCY_HZ – jest to częstotliwość zegara doprowadzonego do wejścia Clock,
  • DIGITS – liczba cyfr w wyświetlaczu. Parametr ten jest potrzebny tylko do wersji modułu ze zmienną liczbą cyfr, który będziemy omawiać w dalszej części artykułu. W wersji ze stałą liczbą cyfr ten parametr jest niepotrzebny i dlatego został zakomentowany,
  • SWITCH_PERIOD_US – jest to czas w mikrosekundach, po upływie którego będą przełączanie cyfry na wyświetlaczu. Polecam poeksperymentować z tym parametrem. Ustawienie zbyt dużego czasu sprawi, że efekt multipleksacji stanie się widoczny, a cyfry zaczną migać od prawej do lewej. Ustawienie zbyt krótkiego czasu spowoduje, że sąsiednie cyfry zaczną się zlewać.

Następnie musimy połączyć kilka portów:

  • Clock – wejście sygnału zegarowego;
  • Reset – wejście resetujące, aktywne w stanie niskim;
  • Data – 32-bitowe wejście danych, które mają być wyświetlane. Wejście danych zorganizowane jest w taki sposób, że na każdą cyfrę wyświetlacza przypadają 4 bity. Skoro cyfr jest osiem, to szerokość wejścia danych jest 32-bitowa. Najmłodsze cztery bity określają cyfrę pokazywaną na zerowym wyświetlaczu, kolejne cztery bity odpowiadają za pierwszy wyświetlacz, i tak dalej, a najstarsze cztery bity określają, co pokazywane jest na wyświetlaczu siódmym;
  • DecimalPoints – 8-bitowe wejście sterujące przecinkami. Każda cyfra ma swój przecinek. Ustawienie stanu wysokiego na wybranym bicie spowoduje zaświecenie się przecinka na odpowiadającym mu wyświetlaczu;
  • Cathodes – 8-bitowe wyjście sterujące katodami, gdzie 1 uaktywnia wyświetlacz, a 0 go wygasza; nie jest możliwe ustawienie stanu 1 dla więcej niż jednego wyświetlacza jednocześnie;
  • Segments – 8-bitowe wyjście sterujące segmentami aktualnie wyświetlanej cyfry.

Co kryje się wewnątrz modułu DisplayMultiplex zostawimy na deser, a najpierw zobaczmy, w jaki sposób generowane są dane, które wyświetlacz ma pokazywać.

W linii #6 tworzymy 32-bitowy licznik Data. Jego początkowa wartość to 32’hFF (linia #7). Nie ma to żadnego specjalnego powodu – chodziło tylko o to by pokazać, że rejestry nie zawsze muszą być inicjalizowane samymi zerami.

Blok always tworzący logikę licznika reaguje standardowo na zegar o częstotliwości 14 MHz oraz na sygnał resetujący. W linii #8 mamy warunek if(CountEnable) – jest to sprawdzanie, czy sygnał strobujący pozwala na inkrementację licznika w bieżącym takcie zegara.

Sygnał CountEnable został utworzony w linii #4. Ten sygnał jest sterowany przez instancję modułu StrobeGenerator o nazwie CountEnableStrobeGenerator. Ten moduł już znamy i omawialiśmy go, analizując listing 1. Wykorzystując parametr PERIOD_US konfigurujemy go w taki sposób, aby generował sygnał strobe co 100000 mikrosekund, czyli 0,1 sekundy.

W linii #11 widzimy rejestr DecimalPoints, który steruje przecinkami. Zainicjalizowany jest wartością 8’b00000011, co spowoduje zaświecenie przecinków w wyświetlaczach pierwszymi i zerowym (linia #12). Podobnie jak wszystkie inne bloki always, logika reaguje na zbocze rosnące sygnału zegarowego Clock14MHz oraz na zbocze opadające sygnału Reset. Tutaj sygnałem strobującym jest DecimalPointMoveEnable, który sprawdzany jest w linii #13.

Przesuwanie przecinków zostało zrealizowane przy pomocy operatora konkatenacji, czyli nawiasów klamrowych {}. Jest to operator, który skleja w jedną całość składniki podane w nawiasach. W linii #14 pierwszym składnikiem są bity [6:0] rejestru DecimalPoints, a drugim składnikiem jest jego bit [7]. W ten sposób najstarszy bit trafia w miejsce najmłodszego, a cała reszta jest przesuwana w lewo.

Sygnał strobujący DecimalPointMoveEnable został utworzony w linii #9 i steruje nim instancja modułu StrobeGenerator o nazwie DecimalStrobeGenerator. Jest to kolejna instancja tego samego modułu, ale skonfigurowana trochę inaczej. Okres strobów został ustawiony na 1000000 mikrosekund, czyli sygnały strobe będą pojawiać się co jedną sekundę.

Moduł DisplayMultiplex ze stałą liczbą cyfr

Dotarliśmy do najważniejszego modułu, jaki w tym odcinku kursu będziemy omawiać. Kod tego modułu pokazano na listingu 3. Sercem modułu jest 3-bitowy licznik Selector (linia #7), który liczy od 0 do 7. Ten licznik wyznacza, który wyświetlacz w danej chwili ma się świecić, a także określa, które dane mają trafiać do dekodera 7-segmentowego, sterującego aktywną cyfrą wyświetlacza. Dekoder jest tylko jeden na wszystkie cyfry, ponieważ świeci się tylko jedna cyfra w danej chwili, więc nie potrzebujemy więcej dekoderów. Licznik Selector służy także do wyznaczania, który przecinek w ma zostać zaświecony.

Listing 3. Kod pliku display_multiplex.v w wersji ze stałą liczbą cyfr wyświetlacza

// Plik display_multiplex.v w wersji ze stałą liczbą cyfr wyświetlacza
module DisplayMultiplex #(
parameter FREQUENCY_HZ = 10_000_000,
parameter SWITCH_PERIOD_US = 1000
)(
input Clock,
input Reset,
input [31:0] Data,
input [ 7:0] DecimalPoints,
output [ 7:0] Cathodes,
output [ 7:0] Segments
);

// Wygaszanie nieistotnych zer
wire [7:0] Visible; // #1
assign Visible[7] = |Data[31:28]; // #2
assign Visible[6] = |Data[27:24] || Visible[7]; // #3
assign Visible[5] = |Data[23:20] || Visible[6];
assign Visible[4] = |Data[19:16] || Visible[5];
assign Visible[3] = |Data[15:12] || Visible[4];
assign Visible[2] = |Data[11: 8] || Visible[3];
assign Visible[1] = |Data[ 7: 4] || Visible[2];
assign Visible[0] = 1’b1; // #4

// Generator sygnału przełączającego cyfry wyświetlacza
wire SwitchCathode; // #5
StrobeGenerator #( // #6
.FREQUENCY_HZ(FREQUENCY_HZ),
.PERIOD_US(SWITCH_PERIOD_US)
) StrobeGenerator0(
.Clock(Clock),
.Reset(Reset),
.Strobe(SwitchCathode)
);

// Licznik wybierający aktywną cyfrę do wyświetlenia
reg [2:0] Selector; // #7
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Selector <= 0;
else if(SwitchCathode) begin // #8
if(Selector == 7) // #9
Selector <= 0;
else
Selector <= Selector + 1’b1; // #10
end
end

// Wybór aktywnej katody
assign Cathodes = (1’b1 << Selector); // #11

// Wybór danych do wyświetlenia
wire [3:0] TempData = Data[(Selector*4+3)-:4]; // #12

// Sprawdź czy aktywna cyfra ma być widoczna czy wygaszona
wire Enable = Visible[Selector]; // #13

// Włączenie lub wyłączenie przecinka dla aktywnej cyfry
assign Segments[7] = DecimalPoints[Selector]; // #14

// Instancja dekodera 7-segmentowego
Decoder7seg #( // #15
.COMMON_CATHODE(1)
) Dekoder(
.Enable(Enable),
.Data(TempData), // #16
.Segments(Segments[6:0]) // #17
);

endmodule

Przejdźmy najpierw do linii #5, gdzie utworzona została zmienna SwitchCathode typu wire. Ten sygnał sterowany jest przez StrobeGenerator (linia #6), który wyznacza czas przełączania cyfr wyświetlacza. Czas ten zdefiniowany jest parametrem SWITCH_PERIOD_US. Podobnie jak w przypadku generatorów strobów z modułu top, podaje on sygnał, który przybiera stan wysoki tylko na jeden takt sygnału zegarowego.

Stan sygnału SwitchCathode sprawdzany jest w linii #8 i jeżeli on jest prawdziwy to następuje inkrementacja licznika Selector (linia #10), wskazującego aktywną cyfrę. Warunek sprawdzany w linii #9 służy do zerowania licznika w następnej linijce, kiedy jego wartość osiągnęła maksimum. Licznik 3-bitowy może liczyć od 0 do 7, co odpowiada wszystkim ośmiu wykorzystywanym cyfrom. Inkrementując wartość maksymalną dostalibyśmy z powrotem zero, zatem w przypadku wyświetlacza ośmiocyfrowego, ten warunek nie jest potrzebny i można by uprościć kod o te dwie linijki. Jednak gdybyśmy wykorzystywali wyświetlacz np. sześciocyfrowy to w linii #9 musielibyśmy zmienić liczbę z 7 na 5 (bo w przypadku wyświetlacza 6-cyfrowego numerujemy cyfry od 0 do 5).

Mając już licznik, wskazujący aktywny wyświetlacz, musimy otworzyć jeden z tranzystorów, sterowanych wyjściami Cathodes[7:0]. Najprościej to zrobić w sposób przedstawiony w linii #11. Operator << przesuwa daną wartość o liczbę bitów, jaka została określona po prawej stronie operatora. Działa to dokładnie tak samo jak operator przesunięcia bitowego, znany z C i C++. Konstrukcja z linii #11 może wydawać się zaskakująca, ponieważ przesuwanie jedynki o liczbę bitów daną przy pomocy zmiennej, może kojarzyć się z pętlą, wykonującą się sekwencyjnie kilka razy, to jednak z Verilogu taki zapis jest całkowicie poprawny. Układ otrzymany po syntezie będzie układem czysto kombinacyjnym.

Przeskoczmy teraz do linii #14. Sygnał Segments[7] decyduje czy przecinek aktywnej cyfry ma być zaświecony czy wygaszony. Informacja o tym, które przecinki mają być zapalone, pobierana jest z 8-bitowego wejścia DecimalPoints. Każdy bit tego wejścia odpowiada za przecinki przy kolejnych cyfrach.

Mogą się świecić wszystkie przecinki, żaden, jeden z nich lub dowolna kombinacja. W linii #14, sygnał Segments[7] łączymy z tym bitem wejścia DecimalPoints, który akurat jest wskazywany przez licznik Selector. Operator [] może przyjmować zmienną, podobnie jak w C++ można w taki sposób odczytywać różne elementy tablicy. Logika z linii #14 zostanie zsyntezowana jako multiplekser mający osiem wejść, jedno wyjście i trzy wejścia wybierające.

W linii #12 tworzymy 4-bitową zmienną TempData typu wire. Ta zmienna będzie łączyć fragment 32-bitowego wejścia Data, odpowiadający za aktualnie wyświetlaną cyfrę i będzie te dane dostarczać do dekodera 7-segmentowego (linia #16). Zobacz tabelę 1, która tłumaczy znaczenia bitów wejścia Data.

Moglibyśmy tutaj zastosować instrukcję case(Selector) i następnie dla wszystkich możliwych wartości licznika Selector podać, jaki fragment danych ma zostać załadowany do zmiennej TempData. Możliwe, że wielu czytelników stwierdzi, że takie rozwiązanie jest bardziej czytelne.

Rozwiązanie prezentowane w linii #12 jest bardziej zwarte, dzięki czemu kod jest krótszy. Należy jedynie wyjaśnić, dlaczego napisałem TempData = Data[(Selector*4+3)-:4] a nie TempData = Data[(Selector*4+3):(Selector*4)]. Otóż wybierając pewną liczbę bitów musimy to zrobić w taki sposób, aby zawsze liczba wybranych bitów była taka sama, niezależnie od wartości zmiennej. Z tego powodu nie możemy użyć operatora : lecz powinniśmy skorzystać z -: który pozwala wybrać zakres bitów, podając numer najbardziej znaczącego bitu oraz ile bitów w dół ma zostać objętych. Wyrażenie Selector*4+3 wyznacza numer najbardziej znaczącego bitu danych dla każdej cyfry, wskazywanej przez licznik Selector. Następnie, od tak obliczonej liczby odejmowana jest liczba 4 i w ten sposób określone zostają bity, które należy wyekstrahować z wejścia Data i dostarczyć do dekodera 7-segmentowego. Układ powstały po syntezie takiego kodu jest w całości układem kombinacyjnym.

Moduł DisplayMultiplex umożliwia opcję wygaszania nieistotnych zer. Dzięki temu, zamiast 00000123 zostanie wyświetlone po prostu 123. Zastanówmy się, jaka logika powinna sterować tą funkcjonalnością:

  1. Wyświetlacz pokazujący najmniej znaczącą cyfrę powinien być zawsze aktywny. Jeżeli na wejściu Data mamy same zera, to wyświetlacz najmniej znaczącej cyfry powinien pokazać 0, a wszystkie pozostałe wyświetlacze powinny być wygaszone.
  2. Jeżeli wyświetlacz najbardziej znaczącej cyfry pokazuje zero, to powinien być wygaszony bez względu na resztę cyfr.
  3. Wyświetlacze w środku powinny być wygaszone tylko wtedy, gdy mają wyświetlać zero i jednocześnie został spełniony warunek wygaszenia bardziej istotnego wyświetlacza. Ten warunek jest konieczny, aby prawidłowo wyświetlać liczby takie jak 00123400 – w rezultacie tylko pierwsze dwa zera zostaną wygaszone, a wyświetlona zostanie liczba 1234 00.

Spróbujmy zaimplementować tę logikę w Verilogu. Jest to logika czysto kombinacyjna. Nie ma potrzeby stosować tutaj żadnych przerzutników, ponieważ interesuje nas tylko aktualny stan wejścia Data. W linii #1 tworzymy 8-bitową zmienną Visible typu wire. Każdy bit tej zmiennej decyduje, czy odpowiadająca mu cyfra ma być zaświecona. Stan wysoki oznaczać będzie zaświecenie cyfry, a stan niski będzie powodować wygaszenie. Najprościej będzie zrealizować warunek pierwszy. Skoro najmniej znacząca cyfra ma zawsze się świecić, to do zerowego bitu sygnału Visible[0] wpisujemy na stałe stan wysoki (linia #4).

Warunek drugi możemy zaimplementować tak, jak pokazano to w linii #2. Testujemy cztery najstarsze bity sygnału Data[31:28], które sterują najbardziej istotną cyfrą. Możemy to zrobić na dwa sposoby. Pierwszy (i zapewne bardziej intuicyjny) to wykorzystanie operatora nierówności Data[31:28] != 4’d0.

Drugi sposób polega na zastosowaniu operatora redukcji OR. Tworzy on wielowejściową bramkę OR, do której podłączone są wszystkie bity testowanego sygnału. Bramka OR daje na wyjściu stan wysoki, jeżeli chociażby jeden z badanych bitów ma stan wysoki. Obojętnie który sposób zastosujemy, w rezultacie dostaniemy logikę działającą dokładnie tak samo.

Trzeci warunek musimy zaimplementować dla wszystkich cyfr pomiędzy najbardziej i najmniej znaczącą. Przykład realizacji pokazano w linii #3. W ten sposób przygotowaliśmy informacje o tym, które cyfry mogą się świecić. Spośród ośmiu bitów musimy przekazać do dekodera 7-segmentowego tylko ten jeden bit, który odpowiedzialny jest za aktualnie wyświetlaną cyfrę, wskazaną przez licznik Selector. Aby to uczynić, tworzymy pomocniczy sygnał Enable w linii #13, który przyjmuje wartość 0 lub 1 w zależności od tego, co zostało określone w wektorze Visible[7:0] i aktualnego stanu licznika Selector. Sygnał ten łączymy z dekoderem wyświetlacza 7-segmentowego w linii #15.

Moduł DisplayMultiplex ze zmienną liczbą cyfr

Parametryzacja kodu sprawia, że staje się on bardziej elastyczny i łatwiej go wykorzystywać w różnych projektach. Z tego powodu przerobimy kod z listingu 3 w taki sposób, aby można było go wykorzystywać w wielu projektach, gdzie wykorzystywane są wyświetlacze multipleksowane, różniące się liczbą cyfr. Przeanalizujmy listing 4, który pod względem funkcjonalności jest identyczny jak kod z listingu 3. Omówimy tylko istotne różnice między tymi dwoma listingami.

Listing 4. Kod pliku display_multiplex.v w wersji ze zmienną liczbą cyfr wyświetlacza

// Plik display_multiplex.v w wersji ze zmienną liczbą cyfr wyświetlacza
module DisplayMultiplex #(
parameter FREQUENCY_HZ = 10_000_000,
parameter SWITCH_PERIOD_US = 1000,
parameter DIGITS = 8 // #1
)(
input Clock,
input Reset,
input [DIGITS*4-1:0] Data, // #2
input [ DIGITS-1:0] DecimalPoints, // #3
output [ DIGITS-1:0] Cathodes, // #4
output [ 7:0] Segments
);

// Wygaszanie nieistotnych zer
wire [DIGITS-1:0] Visible; // #5
generate // #6
genvar i; // #7
for(i=0; i<DIGITS; i=i+1) begin // #8
case(i) // #9
0: assign Visible[i] = 1’b1;
DIGITS-1: assign Visible[i] = |Data[i*4+3:i*4];
default: assign Visible[i] = |Data[i*4+3:i*4] || Visible[i+1];
endcase
end
endgenerate

// Generator sygnału przełączającego cyfry wyświetlacza
wire SwitchCathode;
StrobeGenerator #(
.FREQUENCY_HZ(FREQUENCY_HZ),
.PERIOD_US(SWITCH_PERIOD_US)
) StrobeGenerator0(
.Clock(Clock),
.Reset(Reset),
.Strobe(SwitchCathode)
);

// Licznik wybierający aktywną cyfrę do wyświetlenia
localparam CNT_WIDTH = $clog2(DIGITS); // #10
reg [CNT_WIDTH-1:0] Selector; // #11
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Selector <= 0;
else if(SwitchCathode) begin
if(Selector == DIGITS - 1)
Selector <= 0;
else
Selector <= Selector + 1’b1;
end
end

// Wybór aktywnej katody
assign Cathodes = (1’b1 << Selector);

// Wybór danych do wyświetlenia
wire [3:0] TempData = Data[(Selector*4+3)-:4];

// Sprawdź czy aktywna cyfra ma być widoczna czy wygaszona
wire Enable = Visible[Selector];

// Włączenie lub wyłączenie przecinka dla aktywnej cyfry
assign Segments[7] = DecimalPoints[Selector];

// Instancja dekodera 7-segmentowego
Decoder7seg #(
.COMMON_CATHODE(1)
) DUT(
.Enable(Enable),
.Data(TempData),
.Segments(Segments[6:0])
);

endmodule

Na liście parametrów modułu DisplayMultiplex pojawił się parametr DIGITS, który jak można się domyślać, określa liczbę cyfr obsługiwanych przez wyświetlacz. Ten parametr jest wykorzystywany w bardzo wielu miejscach w dalszej części kodu. W liniach #2, #3 i #4 przy pomocy parametru DIGITS definiujemy szerokość wejść i wyjść. Przy określaniu szerokości wejścia Data, parametr mnożymy przez cztery, ponieważ na każdą cyfrę przypadają cztery bity magistrali danych.

Przejdźmy do linii #11, gdzie tworzony jest licznik Selector. Ponieważ liczba cyfr wyświetlacza może się zmieniać, to liczba bitów tego licznika również musi być regulowana w zależności od potrzeb. Szerokość licznika jest ustalana przez parametr lokalny CNT_WIDTH, który zdefiniowany jest w linii #10.

Zastosowana w tej linijce funkcja $clog2(x) oblicza wartość logarytmu przy podstawie 2 i wynik zaokrągla w górę. W taki sposób możemy łatwo wyznaczyć liczbę bitów, jakie potrzebne są w liczniku, liczącym od zera do wartości zależnej od zmieniającego się parametru.

Przejdźmy teraz do linii #5, gdzie podobnie jak w listingu 3, tworzymy wektor Visible typu wire wyznaczający, które cyfry mają być zaświecone, a które wygaszone, aby ukryć nieistotne zera. Musimy zaimplementować trzy różne warunki tak, jak to zostało opisane powyżej.

Z pomocą przychodzi blok generate. Należy go traktować jako makro, które jeszcze przed syntezą wygeneruje nam „linie kodu” zgodnie z opisanym przez nas szablonem. Można w ten sposób utworzyć zbiór różnych instancji, bloków always/initial, zmiennych i różnych przypisań.

W bloku generate musimy wykorzystać chociażby jedną zmienną typu genvar, która będzie w jakiś sposób odróżniać generowane obiekty. Zmienną genvar możemy utworzyć wewnątrz lub na zewnątrz bloku generate. Może być ona stosowana jako iterator pętli for, warunek instrukcji if-else, a także argument instrukcji wyboru case. W linii #7 tworzymy jedną zmienną genvar o nazwie i.

W linii #8 mamy pętlę for. Podobnie jak w językach C i C++, pętla ma trzy wyrażenia – inicjalizację iteratora, warunek wykonywania pętli oraz inkrementację iteratora. Pętla z linii #8 wykona się tyle razy, ile mamy wyświetlaczy, co określone jest poprzez parametr DIGITS.

Gdyby logika sterująca wygaszaniem nieistotnych zer była taka sama dla wszystkich cyfr, moglibyśmy ją umieścić od razu wewnątrz pętli for. Niestety jednak warunki wygaszenia są inne dla pierwszej cyfry, ostatniej oraz środkowych, co dokładniej opisałem wcześniej przy okazji omawiania zmiennej Visible[7:0] w listingu 3.

Kiedy generowane obiekty mają być tworzone według różnych szablonów, przyjade się instrukcja case, której argumentem jest zmienna typu genvar. W zależności od niej, wybierany jest szablon, który zostanie wykorzystany. Zobacz linię #9. Zostały tam zdefiniowane trzy różne szablony – dla zerowej cyfry wyświetlacza, ostatniej cyfry i wszystkich cyfr w środku wyświetlacza.

Kod wygenerowany przez blok generate będzie identyczny z tym, który w listingu 3 przedstawiają linie od #1 do #4.

Moduł Decoder7seg

Pozostał już tylko moduł dekodera wyświetlacza 7-segmentowego. Jest to prostu układ kombinacyjny, który tylko przekształca 4-bitową wartość, otrzymają na wejście Data, na kombinację sygnałów, które zaświecają odpowiednie segmenty wyświetlacza, wyprowadzone na wyjście Segments.

Listing 5. Kod pliku decoder_7seg.v

// Plik decoder_7seg.v
module Decoder7seg #(
parameter COMMON_CATHODE = 1 // 1 - wspólna katoda, 0 - wspólna anoda
)(
input Enable, // 1 - on, 0 - off
input [3:0] Data, // Wejście danych do wyświetlenia
output [6:0] Segments // Wyjście segmentów
);

reg [6:0] Temp;

always @(*) begin
if(Enable)
case(Data) // gfedcba
4’h0: Temp = 7’b0111111;
4’h1: Temp = 7’b0000110;
4’h2: Temp = 7’b1011011;
4’h3: Temp = 7’b1001111;
4’h4: Temp = 7’b1100110;
4’h5: Temp = 7’b1101101;
4’h6: Temp = 7’b1111101;
4’h7: Temp = 7’b0000111;
4’h8: Temp = 7’b1111111;
4’h9: Temp = 7’b1101111;
4’hA: Temp = 7’b1110111;
4’hB: Temp = 7’b1111100;
4’hC: Temp = 7’b0111001;
4’hD: Temp = 7’b1011110;
4’hE: Temp = 7’b1111001;
4’hF: Temp = 7’b1110001;
default: Temp = 7’b0000000;
endcase
else
Temp = 7’b0000000;
end

// W przypadku wyświetlacza ze wspólną anodą zaneguj rejestr Temp
assign Segments = COMMON_CATHODE ? Temp : ~Temp;

endmodule

Zobaczmy listing 5. Kod ten jest podobny do kodu, który zastosowaliśmy w 6 odcinku kursu i dlatego nie będziemy go szczegółowo omawiać. Różnica polega na dodaniu wejścia Enable, którego celem jest umożliwienie wygaszenia wyświetlacza. Kiedy Enable jest w stanie wysokim to dekoder pracuje normalnie, natomiast kiedy jest w stanie niskim to wszystkie segmenty są wygaszone, niezależnie od tego, co jest na wejściu Data.

Na koniec zastanówmy się, jakie części naszego designu są układami sekwencyjnymi, a które to układy kombinacyjne. W module DisplayMultiplex, układami sekwencyjnymi jest tylko StrobeGenerator oraz licznik Selector. Każda inkrementacja licznika Selector pociąga za sobą lawinę zdarzeń, takich jak przełączanie aktywnego wyświetlacza, wybór przecinka, wybór nowych danych, które są podawane do dekodera 7-segmentowego, a sam dekoder również jest układem czysto kombinacyjnym. Każdy z tych układów kombinacyjnych ma oczywiście inny czas propagacji, a dane na wyjściach tych układów ustalają się w różnych odstępach czasu.

Ta gmatwanina sygnałów steruje wyświetlaczem bez żadnego buforowania i synchronizowania z sygnałem zegarowym. jednak nie stanowi to problemu, ponieważ czasy propagacji tych układów są tak małe, że nie da się ich zauważyć ludzkim okien, ani nawet analizatorem Reveal samplującym z częstotliwością 133 MHz.

Gorąco zachęcam, by pobawić się analizatorem logicznym Reveal, opisanym w 7 odcinku kursu. Spróbuj tak skonfigurować analizator, by obserwować sygnały wewnątrz modułu DisplayMultiplex, jak to pokazano na rysunku 3.

Rysunek 3. Przebiegi moduły DisplayMultiplex zarejestrowane przez Reveal Analyzer
Kody z tego odcinka kursu możesz zasymulować w EDA Playground:
• Strobe Generator – https://www.edaplayground.com/x/P4Bt
• Display Multiplex ze stałą liczbą cyfr – https://www.edaplayground.com/x/M46N
• Display Multiplex ze zmienną liczbą cyfr – https://www.edaplayground.com/x/9fsF
• Dekoder 7-segmentowy – https://www.edaplayground.com/x/UvUG
Cały projekt w programie Diamond możesz pobrać klikając w przycisk "Materiały dodatkowe" po prawej stronie tytułu artykułu.

Podsumowanie

W tym odcinku kursu nauczyliśmy się wywoływać różne zadania z różną częstotliwością, pomimo że wszystkie moduły wykorzystują jeden i ten sam sygnał zegarowy. Jest to bardzo ważna koncepcja, której zrozumienie jest kluczowe przed przystąpieniem do budowy bardziej skomplikowanych układów. Sterownik wyświetlacza przygotowany w tym odcinku będzie wykorzystywany w przyszłości jeszcze nie raz.

W następnym odcinku rozbudujemy nasz wyświetlacz o klawiaturę matrycową. Jest to prosty i wygodny sposób, aby niewielkim nakładem sprzętowym umożliwić wykorzystanie dużej liczby przycisków. Ponadto, część sygnałów może być współdzielona przez wyświetlacz multipleksowany oraz przez klawiaturę matrycową.

Dominik Bieczyński
leonow32@gmail.com

Dodatkowe informacje:

  1. MachXO2 Family Datasheet – https://www.latticesemi.com/view_document?document_id=38834
Artykuł ukazał się w
Elektronika Praktyczna
lipiec 2023
DO POBRANIA
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik kwiecień 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio maj - czerwiec 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje kwiecień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna kwiecień 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich maj 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów