Kurs FPGA Lattice (10). Klawiatura matrycowa i maszyna stanów

Kurs FPGA Lattice (10). Klawiatura matrycowa i maszyna stanów

Klawiatura matrycowa to prosty sposób, by wykorzystać dużą liczbę przycisków przy małej liczbie połączeń. Przygotujemy prosty do zrozumienia, ale mało efektywny moduł, obsługujący taką klawiaturę. W dalszej części poznamy, co to jest maszyna stanów i jak można ją wykorzystać, by zoptymalizować modułu klawiatury.

Schemat klawiatury matrycowej pokazano na rysunku 1. Klawiatura powstała przez rozbudowę wyświetlacza matrycowego, który omawialiśmy w poprzednim odcinku kursu. Korzystając z zaledwie czterech dodatkowych sygnałów Rows[3:0], mamy możliwość podłączenia aż 32 przycisków!

Rysunek 1. Schemat wyświetlacza multipleksowanego z klawiaturą matrycową

Wyświetlaczem steruje moduł DisplayMultiplex z poprzedniego odcinka kursu. Steruje on katodami wyświetlaczy Cathodes[7:0], uaktywniając każdą z nich na pewien krótki okres czasu. Stan wysoki powoduje otwarcie tranzystora i zaświecenie tych segmentów, do których doprowadzono stan wysoki poprzez sygnały Segments[A-P]. Moduł aktywuje po kolei wszystkie cyfry wyświetlacza, zaczynając od zerowego i kończąc na siódmym. Ilustruje to rysunek 2.

Rysunek 2. Przebiegi na wyjściach Cathodes[7:0] i wejściach Rows[3:0], kiedy wciśnięty jest przycisk K10

Wejścia Rows[3:0] dostarczają układowi FPGA informacji o tym, który przycisk jest wciśnięty. Normalnie są w stanie niskim. Ten stan jest wymuszony przez rezystory R30...R33. W przypadku MachXO2 można te rezystory pominąć i użyć wewnętrznych rezystorów pull-down dostępnych wewnątrz FPGA (można je uaktywnić w narzędziu Spreadsheet). Wciśnięcie przycisku spowoduje przejście odpowiadającego mu sygnału Rows[3:0] w stan wysoki, ale tylko wtedy, kiedy stan wysoki jest również na odpowiadającej mu katodzie wyświetlacza. Na rysunku 2 pokazano jak zmieniają się sygnały na wyjściach Cathodes[7:0] i wejściach Rows[3:0] w sytuacji, kiedy wciśnięty jest przycisk K10, leżący na skrzyżowaniu sygnału Cathode[2] i Row[2].

Musimy opracować moduł, który będzie obserwował sygnały Cathodes[7:0] oraz Rows[3:0] i w rezultacie będzie informował o tym, że jakiś przycisk został wciśnięty oraz jaki jest numer wciśniętego przycisku (w formacie szesnastkowym).

Tworząc nowy projekt w programie Diamond, utwórz implementację o nazwie Combo. Dodaj do niej następujące pliki:

  1. top.v
  2. display_multiplex.v
  3. decoder_7seg.v
  4. strobe_generator.v
  5. matrix_keyboard.v

Moduły DisplayMultiplex, Decoder7seg i StrobeGenerator omawialiśmy szczegółowo w poprzednich odcinkach kursu i nie będziemy ich tutaj omawiać ponownie. Po tym, jak napiszemy i przetestujemy moduły top oraz MatrixKeyboard, skopiujemy całość i utworzymy nową implementację o nazwie StateMachine. Druga implementacja będzie różnić się zawartością pliku matrix_keyboard.v. Na końcu porównamy maksymalną częstotliwość sygnału zegarowego dla obu implementacji oraz jakie mają zapotrzebowanie na zasoby sprzętowe.

MatrixKeyboard

Moduł MatrixKeyboard obsługuje 32 przyciski, zorganizowane w matrycę 8 kolumn i 4 rzędów. Można by się pokusić o napisanie tego modułu w sposób sparametryzowany, aby obsługiwać dowolną liczbę rzędów i kolumn przycisków. Stwierdziłem jednak, że kod będzie prostszy do zrozumienia, jeżeli liczba kolumn i rzędów zostanie ustawiona na sztywno.

Listing 1. Kod modułu MatrixKeyboard

// Plik matrix_keyboard.v
module MatrixKeyboard(
input Clock,
input Reset,
input [7:0] Cathodes,
input [3:0] Rows,
output KeyStrobe,
output reg KeyPressed,
output reg [4:0] KeyCode
);

// Bufor wszystkich przycisków
reg [31:0] KeyBuffer; // #1

// Kopiowanie wejścia Rows do odpowiedniej pozycji w buforze
always @(posedge Clock, negedge Reset) begin
if(!Reset)
KeyBuffer <= 0;
else begin
if(Cathodes[0]) KeyBuffer[ 3: 0] <= Rows[3:0]; // #2
if(Cathodes[1]) KeyBuffer[ 7: 4] <= Rows[3:0];
if(Cathodes[2]) KeyBuffer[11: 8] <= Rows[3:0];
if(Cathodes[3]) KeyBuffer[15:12] <= Rows[3:0];
if(Cathodes[4]) KeyBuffer[19:16] <= Rows[3:0];
if(Cathodes[5]) KeyBuffer[23:20] <= Rows[3:0];
if(Cathodes[6]) KeyBuffer[27:24] <= Rows[3:0];
if(Cathodes[7]) KeyBuffer[31:28] <= Rows[3:0];
end
end

// Analizowanie bufora
// Ustalanie czy wciśnięto któryś przycisk i jaki jest jego kod
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
KeyCode <= 0;
KeyPressed <= 0;
end else begin
if(KeyBuffer[0]) begin KeyCode <= 5’d0; KeyPressed <= 1’b1; end // #3
else if(KeyBuffer[1]) begin KeyCode <= 5’d1; KeyPressed <= 1’b1; end
else if(KeyBuffer[2]) begin KeyCode <= 5’d2; KeyPressed <= 1’b1; end
else if(KeyBuffer[3]) begin KeyCode <= 5’d3; KeyPressed <= 1’b1; end
else if(KeyBuffer[4]) begin KeyCode <= 5’d4; KeyPressed <= 1’b1; end
else if(KeyBuffer[5]) begin KeyCode <= 5’d5; KeyPressed <= 1’b1; end
else if(KeyBuffer[6]) begin KeyCode <= 5’d6; KeyPressed <= 1’b1; end
else if(KeyBuffer[7]) begin KeyCode <= 5’d7; KeyPressed <= 1’b1; end
else if(KeyBuffer[8]) begin KeyCode <= 5’d8; KeyPressed <= 1’b1; end
else if(KeyBuffer[9]) begin KeyCode <= 5’d9; KeyPressed <= 1’b1; end
else if(KeyBuffer[10]) begin KeyCode <= 5’d10; KeyPressed <= 1’b1; end
else if(KeyBuffer[11]) begin KeyCode <= 5’d11; KeyPressed <= 1’b1; end
else if(KeyBuffer[12]) begin KeyCode <= 5’d12; KeyPressed <= 1’b1; end
else if(KeyBuffer[13]) begin KeyCode <= 5’d13; KeyPressed <= 1’b1; end
else if(KeyBuffer[14]) begin KeyCode <= 5’d14; KeyPressed <= 1’b1; end
else if(KeyBuffer[15]) begin KeyCode <= 5’d15; KeyPressed <= 1’b1; end
else if(KeyBuffer[16]) begin KeyCode <= 5’d16; KeyPressed <= 1’b1; end
else if(KeyBuffer[17]) begin KeyCode <= 5’d17; KeyPressed <= 1’b1; end
else if(KeyBuffer[18]) begin KeyCode <= 5’d18; KeyPressed <= 1’b1; end
else if(KeyBuffer[19]) begin KeyCode <= 5’d19; KeyPressed <= 1’b1; end
else if(KeyBuffer[20]) begin KeyCode <= 5’d20; KeyPressed <= 1’b1; end
else if(KeyBuffer[21]) begin KeyCode <= 5’d21; KeyPressed <= 1’b1; end
else if(KeyBuffer[22]) begin KeyCode <= 5’d22; KeyPressed <= 1’b1; end
else if(KeyBuffer[23]) begin KeyCode <= 5’d23; KeyPressed <= 1’b1; end
else if(KeyBuffer[24]) begin KeyCode <= 5’d24; KeyPressed <= 1’b1; end
else if(KeyBuffer[25]) begin KeyCode <= 5’d25; KeyPressed <= 1’b1; end
else if(KeyBuffer[26]) begin KeyCode <= 5’d26; KeyPressed <= 1’b1; end
else if(KeyBuffer[27]) begin KeyCode <= 5’d27; KeyPressed <= 1’b1; end
else if(KeyBuffer[28]) begin KeyCode <= 5’d28; KeyPressed <= 1’b1; end
else if(KeyBuffer[29]) begin KeyCode <= 5’d29; KeyPressed <= 1’b1; end
else if(KeyBuffer[30]) begin KeyCode <= 5’d30; KeyPressed <= 1’b1; end
else if(KeyBuffer[31]) begin KeyCode <= 5’d31; KeyPressed <= 1’b1; end
else KeyPressed <= 1’b0; // #4
end
end

// Wykrywanie zbocza rosnącego na KeyPressed
reg KeyPressedPrevious; // #5
always @(posedge Clock, negedge Reset) begin
if(!Reset)
KeyPressedPrevious <= 0;
else
KeyPressedPrevious <= KeyPressed; // #6
end

assign KeyStrobe = !KeyPressedPrevious && KeyPressed; // #7

endmodule

Analizę listingu 1 zaczniemy od zapoznania się z portami wejściowymi i wyjściowymi:

  • Clock – wejście sygnału zegarowego,
  • Reset – wejście sygnału resetującego, aktywne w stanie niskim,
  • Cathodes[7:0] – wejście sygnałów sterujących katodami wyświetlacza multipleksowanego, które jednocześnie służą do aktywowania kolumn przycisków,
  • Rows[3:0] – wejście rzędów przycisków,
  • KeyStrobe – wyjście sygnalizujące, że został wciśnięty jakiś przycisk. Zostaje ustawione w stan wysoki tylko na jeden takt sygnału zegarowego,
  • KeyPressed – wyjście sygnalizujące, że został wciśnięty jakiś przycisk. Pozostaje w stanie wysokim tak długo, jak przycisk jest wciśnięty. Stan niski oznacza, że żaden przycisk nie jest wciśnięty,
  • KeyCode[4:0] – wyjście podające numer ostatnio wciśniętego przycisku. Szerokość tego wyjścia jest 5-bitowa, ponieważ przycisków są 32 sztuki.

Kod rozpoczynamy od utworzenia 32-bitowego rejestru KeyBuffer (linia #1). Składa się on z 32 przerzutników D, a stan każdego z nich odzwierciedla stan każdego przycisku. W pierwszym bloku always, kopiujemy stan sygnałów wejściowych Rows[3:0] do odpowiednich bitów KeyBuffer[31:0]. Decyduje o tym, która katoda w danej chwili ma stan wysoki (linia #2).

W drugim bloku always analizujemy stan bufora (linia #3). Jest to dość długie drzewo decyzyjne if-else, badające po kolei wszystkie bity w buforze. Jeżeli któryś z nich ma stan wysoki, to znaczy, że przycisk jest wciśnięty. W takiej sytuacji do rejestru wyjściowego KeyCode wpisujemy numer wciśniętego przycisku oraz ustawiamy KeyPressed w stan wysoki. Jeżeli wszystkie bity w buforze mają stan niski, oznacza to, że żaden przycisk nie jest wciśnięty. Wtedy zmienna KeyPressed jest ustawiana w stan niski, a stan KeyCode pozostaje niezmieniony (linia #4).

Zwróć uwagę, że zastosowana logika prowadzi do syntezy enkodera priorytetowego. Oznacza to, że przyciski o niższym numerze są ważniejsze niż te, o numerze wyższym. Inaczej mówiąc, jeżeli zostaną wciśnięte dwa przyciski jednocześnie, to do zmiennej KeyCode zostanie wpisany kod przycisku o niższym numerze. Dzieje się tak dlatego, że w pierwszej kolejności sprawdzany jest bit zerowy bufora, a jeżeli jest on w stanie niskim, to wtedy instrukcjami else if sprawdzane są kolejne bity. Można zamienić kierunek priorytetu, aby sprawdzać bity od najstarszego do najmłodszego, jeżeli byłaby taka potrzeba.

Spróbuj zmodyfikować kod w taki sposób, aby zamiast długiej listy warunków if-else zastosować pętlę for. Wykorzystaj fakt, że iterator pętli jest jednocześnie numerem bitu w rejestrze KeyBuffer i jest tą samą liczbą, jaka jest wpisywana do zmiennej KeyCode.

Aby uzyskać sygnał KeyStrobe, który jest ustawiany w stan wysoki na jeden cykl zegara po wciśnięciu przycisku, musimy zaimplementować konstrukcję nazywaną wykrywaczem zbocza (edge detector). W tym celu musimy utworzyć przerzutnik KeyPressedPrevious, który będzie przechowywać stan sygnału KeyPressed, jaki był w poprzednim takcie zegara (linia #5).

W trzecim, bardzo prostym bloku always, po prostu kopiujemy stan KeyPressed do zmiennej KeyPressedPrevious. Rozpoznawanie przejścia sygnału KeyPressed w stan wysoki sprawdzane jest w linii #7. Jeżeli w poprzednim cyklu zegara zmienna KeyPressedPrevious miała stan niski i jednocześnie KeyPressed ma stan wysoki to warunek z linii #7 zostanie spełniony i na wyjściu KeyStrobe pojawi się stan wysoki tylko na jeden cykl zegara. W kolejnym cyklu do KeyPressedPrevious zostanie wpisany stan wysoki, co sprawi, że warunek już przestanie być prawdziwy, więc KeyStrobe zostanie ustawiony w stan niski.

W zrozumieniu kodu z listingu 1 mogą być przydatne przebiegi zaprezentowane na rysunku 3. Jest to sytuacja z wciśniętym przyciskiem numer 6.

Rysunek 3. Przebiegi wybranych sygnałów dla modułu klawiatury w wersji kombinacyjnej, kiedy wciśnięty jest przycisk numer 6

Moduł top

Moduł top zawiera instancję modułu wyświetlacza multipleksowanego, który opracowaliśmy w poprzednim odcinku kursu oraz instancję modułu MatrixKeyboard. Oprócz tego zawiera kilka prostych operacji, które pozwolą pokazywać na wyświetlaczu kody ostatnich czterech wciśniętych przycisków.

Listing 2. Kod modułu top

// Plik top.v
module top(
input Reset,
input [3:0] KeyRows,
output [7:0] Cathodes,
output [7:0] Segments,
output LedKeyPressed
);

// Generator sygnału zegarowego
parameter FREQUENCY_HZ = 14_000_000;
wire Clock;
OSCH #( // #1
.NOM_FREQ(“14.00”)
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock),
.SEDSTDBY()
);

// Rejestr Data przechowuje 4 ostatnio zarejestrowane kody przycisków
reg [19:0] Data; // #2
wire [ 4:0] KeyCode; // #3
wire KeyStrobe; // #4
wire SwitchCathode; // #5
always @(posedge Clock, negedge Reset)
if(!Reset)
Data <= 0;
else if(KeyStrobe) // #6
Data <= {Data[14:0], KeyCode}; // #7

// Glue logic
wire [31:0] DataToDisplay = { // #8
3’b000, Data[19:15], // Cyfry 7 i 6
3’b000, Data[14:10], // Cyfry 5 i 4
3’b000, Data[ 9: 5], // Cyfry 3 i 2
3’b000, Data[ 4: 0] // Cyfry 1 i 0
};

// Przecinki oddzielające poszczególne kody przycisków
wire [7:0] DecimalPoints = 8’b01010101; // #9

// Instancja sterownika wyświetlacza multipleksowanego
DisplayMultiplex #( // #10
.FREQUENCY_HZ(FREQUENCY_HZ),
.SWITCH_PERIOD_US(2000),
.DIGITS(8)
) DisplayMultiplex0(
.Clock(Clock),
.Reset(Reset),
.Data(DataToDisplay), // #11
.DecimalPoints(DecimalPoints),
.Cathodes(Cathodes),
.Segments(Segments),
.SwitchCathode(SwitchCathode) // #12
);

// Instancja sterownika klawiatury matrycowej
MatrixKeyboard MatrixKeyboard0( // #13
.Clock(Clock),
.Reset(Reset),
.Cathodes(Cathodes),
.Rows(KeyRows),
.SwitchCathode(SwitchCathode), // #14
.KeyCode(KeyCode), // #15
.KeyStrobe(KeyStrobe), // #16
.KeyPressed(LedKeyPressed)
);

endmodule

Przeanalizujmy listing 2. Pomocny może być schemat z rysunku 4, który powstał po syntezie kodu z tego listingu.

Rysunek 4. Schemat modułu top

Moduł top ma następujące porty:

  • Reset – wejście sygnału resetującego, aktywne w stanie niskim,
  • KeyRows[3:0] – wejście rzędów przycisków,
  • Cathodes[7:0] – wyjście sterujące katodami wyświetlaczy i kolumnami przycisków,
  • Segments[7:0] – wyjście sterujące segmentami wyświetlaczy,
  • LedKeyPressed – wyjście sterujące diodą, która świeci się, jeżeli jakiś przycisk jest wciśnięty.

Standardowo, jak w poprzednich częściach kursu, rozpoczynamy moduł top od utworzenia instancji generatora OSCH, który zapewni nam sygnał zegarowy o częstotliwości 14 MHz (linia #1). Następnie tworzymy kilka zmiennych. Pierwszą z nich jest rejestr Data (linia #2), który przechowuje kody czterech ostatnio naciśniętych przycisków. Kody są 5-bitowe, zatem rejestr Data musi mieć 20 bitów.

Następnie mamy 5-bitową zmienną KeyCode (linia #3) typu wire. Służy ona do tego, aby połączyć wyjście KeyCode modułu MatrixKeyboard (linia #15) do bloku always, gdzie kod przycisku jest zapisywany do rejestru Data. Dokładnie w tym samym celu służy sygnał KeyStrobe (linia #4), która wyprowadza sygnał z wyjścia modułu MatrixKeyboard o tej samej nazwie.

Poniżej, wewnątrz bloku always, reagującym na zbocze rosnące sygnału Clock i zbocze malejące sygnału Reset, sprawdzamy czy sygnał KeyStrobe jest w stanie wysoki (linia #6). Jeżeli tak jest, to wtedy aktualizujemy 20-bitowy rejestr Data (linia #7). Nowa wartość zmiennej Data otrzymywana jest przy pomocy operatora konkatenacji {}, czyli sklejania. Sklejamy ze sobą 15 najmłodszych bitów obecnej wartości tego rejestru i doklejamy do nich 5 bitów uzyskanych ze zmiennej KeyCode. W rezultacie dostajemy nową 20-bitową wartość. Efekt tej operacji jest taki, że dotychczas przechowywane kody przycisków są przesuwane o 5 bitów w lewo, a kod aktualnie wciśniętego przycisku wskakuje w miejsce 5 najmłodszych bitów.

Zmienna SwitchCathode (linia #5) dostarcza informację pochodzącą z modułu wyświetlacza (linia #12) do modułu klawiatury (linia #14) – jednak ta informacja będzie potrzebna tylko dla maszyny stanów, która będzie omawiana w dalszej części artykułu. W przypadku kodu napisanego tradycyjnie te linie należy zakomentować.

W linii #8 tworzymy 32-bitową zmienną DataToDisplay, której zadaniem jest przekształcić dane z rejestru Data w taki sposób, aby były zdatne do wyświetlenia przez moduł DisplayMultiplex. Moduł obsługuje osiem wyświetlaczy cyfrowych, a każda cyfra określana jest przez cztery bity. Najmłodsze cztery bity decydują, jaka cyfra zostanie pokazana na zerowym wyświetlaczu, kolejne cztery określają pierwszy wyświetlacz, itd. Każdy wyświetlaczy może pokazywać cyfry od 0 do 9 i od A do F.

Kody przycisków są 5-bitowe, zatem nie zmieszczą się na jednej cyfrze. Potrzebujemy dwóch cyfr, ale dwie cyfry to osiem bitów. Z tego powodu do każdego fragmentu rejestru Data, zawierającego poszczególne kody przycisków, musimy dokleić trzy zera. Przy pomocy operatora konkatenacji {} przekształcamy 20-bitową zmienną Data na 32-bitową zmienną DataToDisplay.

Pozostaje już tylko powołać do życia dwa kluczowe moduły. W linii #10 tworzymy instancję modułu DisplayMultiplex, a w linii #13 tworzymy MatrixKeyboard.

Syntezujemy, przypisujemy numery pinów w Spreadsheet, generujemy bitstream, wgrywamy do FPGA. Na wyświetlaczu powinna pojawić się cyfra 0. Po naciśnięciu dowolnego przycisku, pojawi się jego numer (w formacie heksadecymalnym) po prawej stronie wyświetlacza. Po naciśnięciu kolejnego przycisku, dotychczasowo zarejestrowany kod przycisku przesunie się o dwie pozycje w lewo, a w jego miejsce pojawi się nowy numer. W ten sposób możemy przetestować wszystkie przyciski. Ponadto, będzie świecić się dioda LED podłączona do wejścia LedKeyPressed, kiedy wciśnięty jest dowolny przycisk.

Wstęp do statycznej analizy czasowej

Producenci mikrokontrolerów przyzwyczaili nas do tego, że maksymalna częstotliwość zegara jest podana na pierwszej stronie datasheetu jako jeden z kluczowych parametrów mikrokontrolera, obok pojemności pamięci ROM i RAM. Wybierając procesor od razu wiemy z jaką częstotliwością może pracować, nawet zanim jeszcze zaczniemy pisać program. W FPGA niestety nie jest tak łatwo. Najpierw musimy utworzyć plik wymagań czasowych. Zawiera on oczekiwania, jakie mamy wobec różnych sygnałów w układzie FPGA. Aby to zrobić, w drzewku projektowym klikamy prawym przyciskiem pozycję Synthesis Constraint Files. Następnie wybieramy Add i New File. Pojawia się okienko dodawania nowych plików. Wybieramy LDC File i nazywamy plik timing.ldc. Nazwa pliku nie jest istotna. Ważne tylko, by miał rozszerzenie *.ldc.

Pojawia się tabelka, wyglądająca podobnie jak znany nam arkusz Spreadsheet, gdzie abstrakcyjne nazwy wejść i wyjść modułu top łączymy z fizycznymi pinami układu FPGA. W tym miejscu definiujemy nasze oczekiwania wobec zależności czasowych. Klikamy dwukrotnie na pustą komórkę w pierwszej linii i w kolumnie Source. Pojawia nam się lista dostępnych sygnałów. Klikamy Clock net i wybieramy jedyny dostępny sygnał zegarowy, który mamy w naszym projekcie, czyli Clock. Następnie w kolumnie Period(ns) musimy podać okres sygnału w nanosekundach (szkoda, że nie da się podać częstotliwości w megahercach). Nasz zegar ma częstotliwość 14 MHz, zatem musimy obliczyć odwrotność z 14000000, a następnie wynik mnożymy przez 10^9, aby otrzymać okres w nanosekundach. Wynik to 71,428574 – wpisujemy do komórki w kolumnie Period(ns) pamiętając, że separatorem dziesiętnym jest kropka, a nie przecinek. Powinieneś widzieć tabelę, jaką pokazano na rysunku 5. Zapisujemy plik.

Rysunek 5. Konfiguracja wymagań czasowych

Przechodzimy do okienka procesów i uruchamiamy proces Place & Route Trace. Zostanie ponownie przeprowadzona cała synteza, rozmieszczanie elementów w strukturze FPGA i łączenie ich labiryntem połączeń. Diamond postara się tak rozmieścić wszystkie elementy w taki sposób, aby sprostać wymaganiom, albo wyświetli komunikat, że jest to niemożliwe.

Otwórz okno raportów, a następnie wybierz raport Place & Route Trace. Znajdziemy w nim informację o następującej treści:

Preference Summary FREQUENCY NET "Clock" 14.000112 MHz (0 errors) 1160 items scored, 0 timing errors detected. Report: 75.307MHz is the maximum frequency for this preference.

Oznacza to, że nasze życzenie, by zegar pracował z częstotliwością 14 MHz zostało spełnione bez żadnych przeszkód. Jednocześnie dostajemy informację, że możemy zwiększyć częstotliwość zegara do 75,307 MHz (dotyczy układu FPGA LCMXO2-1200HZ-6TG100C. Jest to układ ze speed grade 6, czyli najszybszy z możliwych. W przypadku innego układu ten sam kod będzie mógł pracować z inną częstotliwością maksymalną).

Czy to dużo? Czy może mało? W rozdziale 3.21 dokumentacji MachXO2 Family Datasheet jest napisane, że układy speed grade 6 mogą mieć sygnał zegarowy o częstotliwości 388 MHz. Coś sprawia, że nasz układ działa dużo wolniej, niż by mógł.

W tym momencie powinniśmy uruchomić narzędzie Timing Analysis, które przeanalizuje wszystkie zależności czasowe, jednak będzie ona tematem kolejnego odcinka. Zatem posłużymy się już znanym Netlist Analyzer. Należy kliknąć Tool i wybrać Netlist Analyzer. Pojawi się okno ze schematem uzyskanym w wyniku syntezy kodu. Teraz klikamy przycisk Push down/Pop up w pionowym pasku narzędzi po lewej stronie okna schematu (ikona zielonej i niebieskiej strzałki w górę i w dół, zobacz rysunek 6), a następnie kliknij moduł MatrixKeyboard.

Okaże się, że schemat modułu MatrixKeyboard jest tak wielki, że nie mieści się na schemacie! Kliknij Tools, a następnie Options, by wejść do ustawień programu Diamond. W drzewku ustawień znajdź Netlist Analyzer, a następnie kliknij pozycję Sheet. W tym miejscu znajdują się wymiary arkusza. Domyślnie arkusz ma wymiary kartki A4 i jeżeli nie mieści się na jednej kartce to zostanie podzielony na kilka kartek, co sprawa, że schemat staje się nieczytelny. W polach tekstowych Sheet Width oraz Sheet Height wpisz 0, aby wyłączyć dzielenie schematów na kartki, dzięki czemu będziemy mogli zobaczyć całość na ekranie. Powinien pokazać się schemat, jaki zaprezentowano na rysunku 6.

Rysunek 6. Schemat modułu MatrixKeyboard

Trzymając wciśnięty przycisk CTRL pokręć kółkiem myszki, aby powiększyć schemat. Odnajdź rejestr KeyBuffer, który znajduje się w lewym dolnym rogu schematu. Następnie przesuń schemat w prawo, aż zobaczysz rejestr KeyCode. Pomiędzy nimi znajduje się strasznie długi łańcuch multiplekserów. Kliknij dowolny z multiplekserów prawym przyciskiem myszy i z menu, które się pojawi, wybierz opcję Jump to… i dalej Jump to HLD file. Zostaniesz przeniesiony do pliku matrix_keyboard.v, w którym zostanie zaznaczony jakiś fragment pomiędzy linią #3 i #4 w listingu 1. Okazuje się, że duży blok instrukcji if-else został przekształcony w szereg multiplekserów. Co gorsza, wyjście jednego jest połączone z wejściem kolejnego. W ten sposób multipleksery przekazują sobie sygnały niczym dzieci bawiące się w głuchy telefon. Każdy z nich wprowadza jakieś opóźnienie, a ponieważ jest ich bardzo dużo, to opóźnienie sumarycznie robi się bardzo duże. Częstotliwość sygnału zegarowego musi być na tyle mała, by sygnały z KeyBuffer miały czas przedostać się przez łańcuch multiplekserów, aż trafią do rejestru KeyCode. To jest źródło naszego problemu.

Maszyna stanów

Układ co prawda działa poprawnie, ale wolno. Spróbujemy napisać kod inaczej, by działał tak samo, ale szybciej. W tym celu duży blok kombinacyjny zamienimy na maszynę stanów. Jest to układ, który wykonuje różne operacje w zależności od tego, w jakim stanie się znajduje. Stan maszyny określa, co ona ma robić w momencie kolejnego zbocza sygnału zegarowego. Stan może zmienić się na inny, jeżeli zostanie spełniony jakiś warunek, a warunki mogą być inne dla każdego ze stanów. Maszyna pozostaje w nowym stanie przez co najmniej jeden takt zegara.

Maszyna stanów nie jest żadnym peryferium sprzętowym, takim jak pamięci, generatory, timery czy dzielniki, które omawialiśmy w poprzednich odcinkach kursu. Jest to koncepcja algorytmu i sposób pisania kodu, aby ułatwić sekwencyjne wykonywanie różnych czynności. W tym momencie warto wspomnieć o maszynach stanów Moore’a i Mealy’ego – ale nie będę tutaj przepisywał podręczników. W internecie znajdziesz wystarczająco dużo informacji na ten temat, a także wiele implementacji tych rozwiązań w Verilogu.

Dzięki maszynie stanów możemy duży blok logiki kombinacyjnej podzielić na mniejsze operacje, wykonywane sekwencyjnie. W naszym rozwiązaniu, maszyna będzie miała tylko trzy stany:

  1. WAIT – Jest to stan, w którym maszyna czeka aż nastąpi zbocze rosnące sygnału SwitchCathode, pochodzącego z modułu DisplayMultiplex. W tym momencie zmieniana jest katoda wyświetlacza, a jednocześnie kolumna przycisków. Jeżeli któryś z nich jest wciśnięty, wtedy pojawi się stan 1 na którymś z bitów KeyRows. Stan maszyny zmienia się na READ.
  2. READ – W tym stanie odczytujemy sygnały na wejściu KeyRows[3:0] i kopiujemy je do odpowiedniego miejsca w KeyBuffer[31:0]. Można by spytać, dlaczego nie odczytujemy wejścia KeyRows od razu, kiedy zmieni się stan wyjść, sterujących katodami? Ponieważ nie możemy w tym samym takcie zegara zmieniać wyjść i odczytywać wejść, które są przez te wyjścia sterowane. Stan wejścia nie zdążyłby się ustalić, więc w rezultacie byśmy odczytali niepoprawne dane. Z tego powodu pomiędzy ustawieniem katod, a odczytaniem przycisków, musi minąć jeden takt zegara. Stan maszyny zmienia się na ANALYZE.
  3. ANALYZE – W tym stanie sprawdzamy wszystkie bity bufora. Zastosujemy licznik, który będzie liczył od 0 do 31, a wartość licznika będzie wskazywać, który bit bufora ma zostać sprawdzony w kolejnym takcie zegara. Jeżeli w trakcie liczenia zostanie wykryty bit ustawiony w stan wysoki, wówczas stan licznika jest kopiowany do KeyBuffer i zmienna KeyPressed jest ustawiana na 1. Jeżeli licznik doliczy do ostatniego bitu rejestru i każdy z tych bitów będzie w stanie niskim, wtedy KeyPressed ustawiane jest w stan 0. Obojętnie która z tych opcji się wydarzy, następnie maszyna przechodzi w stan WAIT, gdzie czeka na kolejną zmianę katod wyświetlacza.

Na rysunku 7 pokazano przebiegi sygnałów w module klawiatury, kiedy wciśnięty jest przycisk numer 6.

Rysunek 7. Przebiegi wybranych sygnałów dla modułu klawiatury w wersji z maszyną stanów, kiedy wciśnięty jest przycisk numer 6

Skoro znamy już działanie maszyny stanów od strony teoretycznej, przejdźmy do praktyki. Prześledźmy kod z listingu 3. Stan maszyny jest przechowywany w 3-bitowej zmiennej typu reg o nazwie State (linia #2). Możliwe stany mamy zdefiniowane w trzech kolejnych linijkach przy pomocy instrukcji localparam. Każdy ze stanów musimy zdefiniować jako osobny parametr lokalny i przypisać mu jakąś wartość (warto wspomnieć, że typ wyliczeniowy enum, znany z C i C++, został dodany dopiero w standardzie SystemVerilog).

Listing 3. Kod modułu MatrixKeyboard w wersji z maszyną stanów

// Plik matrix_keyboard.v
module MatrixKeyboard(
input Clock,
input Reset,
input SwitchCathode,
input [3:0] Rows,
input [7:0] Cathodes,
output reg KeyStrobe,
output reg KeyPressed,
output reg [4:0] KeyCode
);

// Zmienne pomocnicze
reg [ 4:0] KeyCounter; // #1
reg [31:0] KeyBuffer;

// Maszyna stanów
reg [2:0] State; // #2
localparam [2:0] WAIT = 3’b001;
localparam [2:0] READ = 3’b010;
localparam [2:0] ANALYZE = 3’b100;

always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
State <= WAIT;
KeyCounter <= 0;
KeyPressed <= 0;
KeyCode <= 0;
KeyBuffer <= 0;
end else begin
case(State) // #3

// Stan oczekiwania na zmianę katody
WAIT: begin // #4
if(SwitchCathode) // #5
State <= READ; // #6
end

// Odczytywanie wejść Rows i kopiowanie ich stanu
// do różnych miejsc bufora w zależności od tego,
// która katoda jest aktywna
READ: begin // #7
if(Cathodes[0]) KeyBuffer[ 3: 0] <= Rows[3:0];
if(Cathodes[1]) KeyBuffer[ 7: 4] <= Rows[3:0];
if(Cathodes[2]) KeyBuffer[11: 8] <= Rows[3:0];
if(Cathodes[3]) KeyBuffer[15:12] <= Rows[3:0];
if(Cathodes[4]) KeyBuffer[19:16] <= Rows[3:0];
if(Cathodes[5]) KeyBuffer[23:20] <= Rows[3:0];
if(Cathodes[6]) KeyBuffer[27:24] <= Rows[3:0];
if(Cathodes[7]) KeyBuffer[31:28] <= Rows[3:0];
KeyCounter <= 0; // #8
State <= ANALYZE; // #9
end

// Testowanie wszystkich bitów bufora od zerowego do ostatniego
// W momencie napotkania 1 w buforze, wtedy kopiujemy stan
// licznika do wyjścia KeyCode i ustawiamy KeyPressed na 1.
ANALYZE: begin
if(KeyBuffer[KeyCounter]) begin // #10
KeyCode <= KeyCounter;
KeyPressed <= 1’b1;
State <= WAIT;
end else if(&KeyCounter && !KeyBuffer[31]) begin // #11
KeyPressed <= 1’b0;
State <= WAIT;
end else begin // #12
KeyCounter <= KeyCounter + 1’b1;
end
end

// Tylko w przypadku błędu - zresetuj wszystkie rejestry
default: begin // #13
State <= WAIT;
end
endcase
end
end

// Wykrywanie zbocza rosnącego na KeyPressed
reg KeyPressedPrevious; // #14
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
KeyStrobe <= 1’b0;
KeyPressedPrevious <= 1’b0;
end else begin
KeyPressedPrevious <= KeyPressed; // #15
if(!KeyPressedPrevious && KeyPressed) // #16
KeyStrobe <= 1’b1;
else
KeyStrobe <= 1’b0;
end
end

endmodule

Kiedy maszyna jest w stanie WAIT, to zmienna State będzie mieć wartość 001. Jeżeli stan zmieni się na READ, to do zmiennej zostanie wpisane 010, a w przypadku ANALYZE będzie to 100. Jest to tzw. kodowanie one-hot. W takim kodowaniu możliwe jest, by tylko jeden z bitów był w stanie wysokim, a wszystkie pozostałe muszą być w stanie niskim.

Można by zapytać – jaki w tym sens? Przecież dla trzech kombinacji w zupełności by wystarczyła zmienna 2-bitowa. Na dwóch bitach moglibyśmy zapisać nawet cztery stany! Okazuje się, że kodowanie one-hot działa dużo szybciej. Sprawdź sam! Zamień 3-bitowe wartości na 2-bitowe i zobacz, jaka będzie różnica w maksymalnej częstotliwości zegara. W moim przypadku, zastosowanie kodowania one-hot zwiększa dopuszczalną częstotliwość zegara o 15 MHz.

Maszynę stanów opisujemy przy pomocy instrukcji case, która jest instrukcją sterującą, działającą podobnie jak switch z C/C++ – z tą różnicą, że na końcu każdej opcji, nie trzeba pisać break.

W linii #3 rozpoczynamy opis maszyny stanów instrukcją case(State), a następnie definiujemy, co ma się dziać we wszystkich możliwych stanach. Stan oczekiwania WAIT (linia #4) jest bardzo prosty. Przy każdym takcie zegara sprawdzamy, czy zmieniła się aktywna katoda wyświetlaczy, a tym samym aktywna kolumna przycisków (linia #5). W takiej sytuacji stan maszyny jest zmieniany na READ (linia #6). Po zmianie stanu na READ, w kolejnym zboczu rosnącym sygnału zegarowego, wykonywane jest kilka czynności (linia #7). Po pierwsze, stan wejścia Rows[3:0], do którego podłączone są rzędy przycisków, jest odczytywany i następnie przepisywany do odpowiednich bitów bufora. Działa to dokładnie tak samo, jak opisano w linii #2 na listingu 1. Kolejną czynnością jest wyzerowanie licznika KeyCounter (linia #8) i zmiana stanu maszyny na ANALYZE (linia #9). Zwróć uwagę, że jedynym warunkiem ustawienia stanu ANALYZE, jest jedynie wcześniejsze ustawienie stanu READ i nic więcej. To znaczy, że stan READ będzie trwał tylko przez jeden cykl zegara.

W stanie ANALYZE korzystamy z licznika KeyCounter, który liczy od 0 do 31, aby sprawdzić wszystkie bity KeyBuffer. Czynności wykonywane w tym stanie zależą od dodatkowych warunków, sprawdzanych w drzewku decyzyjnym if-else. Możliwe są trzy akcje:

  1. Jeżeli bit rejestru KeyBuffer, który jest aktualnie wskazywany przez KeyCounter, znajduje się w stanie wysokim (linia #10) to znaczy, że odpowiadający mu przycisk został wciśnięty. Wtedy kopiujemy aktualny stan licznika KeyCounter do rejestru wyjściowego KeyCode, ustawiamy KeyPressed w stan wysoki i wracamy do stanu WAIT.
  2. Jeżeli licznik KeyCounter osiągnął swoją maksymalną wartość 31 i stan ostatniego bitu KeyBuffer jest niski (linia #11), to znaczy, że żaden przycisk nie został wciśnięty. Pierwszy z warunków można by sprawdzić na dwa sposoby. Pierwszy i bardziej intuicyjny sposób to zwyczajne porównanie KeyCounter == 5’d31. Drugi sposób wykorzystuje operator redukcji and &KeyCounter, co w rezultacie daje 5-bitową bramkę AND. Wyjście bramki AND ma stan wysoki wtedy, kiedy wszystkie jej wejścia są w stanie wysokim. Korzystamy tu z faktu, że 31 w systemie binarnym to 11111. Operator redukcji ma tę zaletę, że nie musimy znać wartości maksymalnej ani liczby bitów badanej zmiennej. Jeżeli te dwa warunki z linii #11 zostaną spełnione, to zmienna KeyPressed ustawiana jest na 0 i maszyna wraca do stanu WAIT.
  3. Jeżeli nie zostały spełnione warunki opisane w punktach 1 i 2, to znaczy, że jesteśmy jeszcze w trakcie analizowania bitów rejestru KeyBuffer. Zatem inkrementujemy licznik KeyCounter, aby w kolejnym takcie zegara sprawdzić kolejny bit tego rejestru.

W linii #13 opisujemy, co ma się wydarzyć, jeżeli z jakiegoś powodu zmienna State wypadnie poza określone przez nas trzy możliwości – tzn. wrócić do stanu WAIT. Teoretycznie mogłoby się tak zdarzyć na skutek zakłóceń na liniach zasilających lub pod wpływem silnego promieniowania któryś z przerzutników mógłby się przełączyć. W przypadku normalnie pracującego urządzenia taka sytuacja jest niemożliwa. Co ciekawe, usunięcie instrukcji default nie powoduje żadnej zmiany w zapotrzebowaniu na zasoby.

Pozostaje już tylko wykrywanie zbocza rosnącego sygnału KeyPressed. W tym celu musimy utworzyć zmienną KeyPressedPrevious. Ta zmienna zostanie zsyntezowana jako przerzutnik D, przechowujący stan KeyPressed, jaki był w poprzednim takcie zegara. Aktualizacja tej zmiennej odbywa się w linii #15.

Jeżeli aktualny stan KeyPressed jest wysoki i jednocześnie w poprzednim takcie zegara ten stan był niski, to ustawiamy zmienną KeyStrobe w stan wysoki (linia #16). Można by się pokusić o usunięcie instrukcji if-else i zastąpienie jej kodem, mieszczącym się w jednej linijce, który by wyglądał następująco:

KeyStrobe <= !KeyPressedPrevious && KeyPressed;

Zwróć uwagę na to, że linie #15 i #16 nie wykonują się jedna po drugiej, jakby to miało miejsce w przypadku normalnego języka programowania. Te linie wykonują się jednocześnie, w tym samym czasie, w momencie wystąpienia tego samego zbocza sygnału zegarowego. Można by zamienić ich kolejność, a wszystko działałoby dokładnie tak samo.

Czas przeprowadzić syntezę. Otwórz raporty i wejdź do raportu Lattice LSE. Znajduje się tam mnóstwo informacji na temat syntezy, a między innymi powinien się znaleźć taki zapis:

INFO – synthesis: Extracted state machine for register ‘\MatrixKeyboard0/State’ with one-hot encoding

Oznacza to, że syntezator poprawnie rozpoznał maszynę stanów, wykorzystującą kodowanie one-hot.

Otwórz raport Place & Route Trace. Tym razem maksymalna częstotliwość sygnału zegarowego to 150 MHz! To dwukrotnie więcej niż wcześniej. Zobacz także raport Map, gdzie znajduje się spis wszystkich wykorzystywanych zasobów sprzętowych. Porównanie obu implementacji zestawiono w tabeli 1.

Okazuje się, że oprócz zwiększenia częstotliwości zegara, zaoszczędziliśmy także trochę Slice’ów i LUT’ów. Kosztem tego usprawnienia jest poświęcenie zaledwie dziewięciu dodatkowych przerzutników.

Podsumowanie

Wnikliwy czytelnik mógłby zadać pytanie – czy faktycznie przyspieszyliśmy działanie układu? Przecież w wersji kombinacyjnej, pomiędzy zmianą katody a zmianą sygnału KeyStrobe mijały tylko 4 takty zegara, co przy częstotliwości 75 MHz zajmuje 53 ns. W kodzie z maszyną stanów, ten czas jest dłuższy, a ponadto jest zależny od tego, który przycisk został wciśnięty. W skrajnym przypadku, kiedy został wciśnięty ostatni przycisk, pomiędzy zmianą katody, a zmianą KeyStrobe mija aż 36 cykli zegarowych, co mimo większej częstotliwości 150 MHz trwa 240 ns – czyli pięciokrotnie dłużej! Czy wprowadzając maszynę stanów zepsuliśmy nasz projekt?

I tak, i nie. Gdyby głównym celem układu FPGA była obsługa klawiatury matrycowej, to faktycznie lepszy byłby kod w wersji kombinacyjnej. Jednak układy FPGA nie powstały po to, by obsługiwać klawiatury. Takim zadaniem mogą zajmować się co najwyżej przy okazji realizacji trochę ambitniejszych zadań, jak układy akwizycji danych, przetwarzanie sygnałów, obsługa pamięci, generatory DDS, itp.

Układy FPGA są tak szybkie, jak ich najwolniejszy moduł. Byłoby bardzo niedobrze, gdyby częstotliwość próbkowania oscyloskopu była ograniczona modułem obsługującym klawiaturę. Z tego powodu istotne jest, by wąskim gardłem był ten moduł, który jest najbardziej istotny w naszym urządzeniu. Nie możemy dopuścić do sytuacji, żeby jakiś mało istotny moduł, jak sterownik klawiatury, spowalniał pracę istotnych modułów. Jeżeli tak się stanie, to musimy jakoś go przyspieszyć. Jednym z rozwiązań tego problemu jest podzielenie dużych bloków kombinacyjnych na mniejsze. Dzięki temu sygnał zegarowy taktujący wszystkie moduły będzie mógł być szybszy.

Żeby lepiej zrozumieć, jakie parametry wpływają na szybkość układu FPGA, musimy poznać co to jest hold time, setup time, slack i skew, a następnie zagłębić się w tajniki statycznej analizy czasowej. O tym będzie w kolejnym odcinku.

Tymczasem gorąco zachęcam, by samodzielnie skonfigurować analizator Reveal, aby uzyskać takie przebiegi jakie pokazano na rysunkach 3 i 7. Ponadto, spróbuj przerobić kod w taki sposób, aby sparametryzować liczbę przycisków. Pomocny będzie blok generate i pętla for, opisywane przy okazji parametryzacji wyświetlacza multipleksowanego.

Dominik Bieczyński
leonow32@gmail.com

Repozytorium z modułami omawianymi w kursie: https://github.com/leonow32/verilog-fpga

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