Częstotliwość, według definicji, jest to wielkość fizyczna określająca liczbę wystąpień jakiegoś zjawiska w jednostce czasu. Jednostką czasu w układzie SI jest sekunda, a zjawiskiem, jakie chcemy badać, jest zbocze rosnące mierzonego sygnału prostokątnego. Zatem: aby zmierzyć częstotliwość sygnału, musimy po prostu policzyć, ile występuje zboczy tego sygnału w ciągu jednej sekundy.
Na płytce User Interface Board nie mamy żadnych układów umożliwiających kondycjonowanie różnych sygnałów – badany przebieg doprowadzony będzie prosto do pinu układu FPGA. Wynika z tego bardzo ważne ograniczenie: sygnał pochodzący z zewnętrznego generatora musi mieścić się w przedziale od 0 do 3,3 V. Badany przebieg powinien mieć kształt prostokątny – pomiar fali sinusoidalnej, trójkątnej lub innej może dać niepoprawny wynik.
Przeanalizujmy schemat z rysunku 1, wygenerowany automatycznie przez narzędzie Netlist Analyzer na podstawie syntezy kodu źródłowego w Verilogu, aby lepiej zrozumieć ideę działania miernika częstotliwości.
Układ będzie miał trzy wejścia. Do wejścia zegarowego Clock podłączony zostanie generator kwarcowy o częstotliwości 25 MHz, umieszczony na płytce MachXO2 Mega, prezentowanej już w EP. Wejście Reset działa dokładnie tak samo, jak we wszystkich dotychczasowych projektach. Do wejścia SignalAsync_i doprowadzić należy sygnał, którego częstotliwość ma zostać zmierzona.
Badany przebieg oczywiście nie jest zsynchronizowany z domeną zegarową. Aby uniknąć problemu metastabilności, należy go najpierw zsynchronizować. W tym celu trzeba ów sygnał „przepuścić” przez moduł Synchronizer, widoczny po lewej stronie schematu. Temat ten został bardzo obszernie omówiony w 11 odcinku kursu poświęconym tematowi statycznej analizy czasowej (opublikowanym w EP 09/2023).
Synchronizator daje nam sygnał o takiej samej częstotliwości i wypełnieniu, jak sygnał wejściowy, ale my potrzebujemy wykrywać zbocza rosnące sygnału. W tym celu zastosujemy moduł EdgeDetector. Obserwuje on wejście Signal_i, a kiedy zmienia się jego stan, wówczas na wyjściach RisingEdge_o lub FallingEdge_o ustawiany jest – na jeden takt zegarowy – stan wysoki (w zależności od tego, czy moduł wykrył, odpowiednio, zobacze rosnące czy opadające). Nas interesuje tylko wykrywanie zbocza rosnącego i każde takie zdarzenie będziemy zliczać za pomocą licznika Counter.
Popatrzmy teraz na licznik Counter oraz jego wyjście q. Jest ono doprowadzone do wejścia a sumatora add_5 (nazwa wygenerowana została automatycznie), który do obecnego stanu licznika dodaje jedynkę, doprowadzoną „na sztywno” do wejścia b. Wyjście wspomnianego sumatora prowadzi do wejścia d1 multipleksera mux_6 (nazwa również wygenerowana automatycznie), a do wejścia d0 doprowadzany jest aktualny stan licznika, niepowiększony o 1. Zatem multiplekser przepuści dalej aktualny stan lub stan powiększony o 1 – decyduje o tym wejście cond multipleksera, sterowane przez wyjście RisingEdge_o detektora zbocza. Kiedy zostanie wykryte zbocze rosnące, wówczas stan tego wejścia jest wysoki przez czas jednego taktu zegarowego, a multiplekser przekazuje do swojego wyjścia stan wejścia d1, czyli stan licznika zwiększony o 1. W stanie spoczynku, kiedy nic nie jest wykrywane, multiplekser łączy wyjście z wejściem d0, czyli przekazuje dalej obecny stan licznika.
Miernik częstotliwości potrzebuje wzorca czasu jednej sekundy. Posłużymy się tutaj dobrze znanym i używanym chyba w każdym odcinku modułem StrobeGenerator. Za pomocą parametru PERIOD_US skonfigurujemy go tak, aby dokładnie co jedną sekundę ustawiał swoje wyjście Strobe_o w stan wysoki na jeden takt zegarowy.
Wyjście tego modułu pełni dwie funkcje. Pierwszą z nich jest sterowanie multiplekserem Counter_25__I_0 (automat nazwał multiplekser licznikiem…?!), którego wejście d0 połączone jest z wyjściem multipleksera mux_6, omawianego wcześniej, a wejście d1 połączone jest z masą, czyli z liczbą zero. Wyjście tego multipleksera doprowadzone jest do wejścia licznika Counter, który de facto stanowi zbiór 26 przerzutników D połączonych równolegle. Te przerzutniki, z każdym zboczem rosnącym sygnału zegarowego, wpisują do swojej pamięci: wartość licznika powiększoną o 1, aktualną wartość licznika (czyli nie zmieniają swojego stanu) lub same zera. Ostatnia wymieniona możliwość ma zastosowanie wtedy, kiedy kończy się czas pomiaru i cała operacja zaczyna się od nowa. Wróćmy do multipleksera i licznika: kiedy wyjście z modułu StrobeGenerator znajduje się w stanie wysokim, to w kolejnym takcie zegarowym licznik Counter zostanie zresetowany, ponieważ multiplekser Counter_25__I_O na wejście licznika poda same zera.
Drugie zastosowanie sygnału generowanego przez moduł StrobeGenerator obejmuje uruchamianie konwersji liczby binarnej z wyjścia licznika na format dziesiętny BCD, aby pokazać ją na wyświetlaczu LED w formacie zrozumiałym dla człowieka. Sygnał z wyjścia StrobeGenerator jest zatem doprowadzony do wejścia Start_i modułu DoubleDabble w wersji sekwencyjnej (poznaliśmy go w 22 odcinku kursu, opublikowanym w EP 08/2024).
Może trochę dziwić, że jeden sygnał powoduje jednocześnie zerowanie licznika i konwersję jego zawartości na format dziesiętny. Jednak nie ma w tym nic nieprawidłowego! Ustawienie stanu wysokiego na wyjściu modułu StrobeGenerator uruchamia dwie operacje (które wykonywane są jednocześnie w tym samym takcie zegarowym):
- Zerowanie licznika, tzn. po wystąpieniu zbocza rosnącego sygnału zegarowego, stan tego licznika zostanie ustawiony na zero.
- Uruchomienie konwersji stanu licznika na format BCD, tzn. po wystąpieniu zbocza rosnącego sygnału zegarowego, obecny stan licznika zostanie odczytany przez moduł DoubleDabble i skopiowany do jego wewnętrznego rejestru, a następnie rozpocznie się konwersja.
Dochodzimy do ostatniego modułu, czyli sterownika wyświetlacza DisplayMultiplex. Do jego wejścia Data_i doprowadzono wprost wynik z wyjścia BCD_o modułu DoubleDabble. Moduł ten nie potrzebuje żadnych sygnałów wyzwalających, więc zmiana na wejściu danych znajduje od razu odzwierciedlenie na wyjściach sterujących katodami i segmentami wyświetlacza multipleksowanego LED.
Moduł FrequencyMeter
Kod modułu FrequencyMeter pokazano na listingu 1. Synteza tego modułu daje w rezultacie schemat, którego działanie omówiliśmy powyżej.
`default_nettype none
module FrequencyMeter #(
parameter CLOCK_HZ = 25_000_000
)(
input wire Clock, // Pin 20
input wire Reset, // Pin 17
input wire SignalAsync_i, // Pin 75
output wire [7:0] Cathodes_o, // Pin 40 41 42 43 45 47 51 52
output wire [7:0] Segments_o // Pin 39 38 37 36 35 34 30 29
);
// Synchronizacja sygnału wejściowego z domeną zegarową
wire SignalSync;
Synchronizer #(
.WIDTH(1)
) Synchronizer_inst(
.Clock(Clock),
.Reset(Reset),
.Async_i(SignalAsync_i),
.Sync_o(SignalSync)
);
// Rozpoznawanie zbocza rosnącego badanego sygnału
wire SignalEdge;
EdgeDetector EdgeDetector_inst(
.Clock(Clock),
.Reset(Reset),
.Signal_i(SignalSync),
.RisingEdge_o(SignalEdge),
.FallingEdge_o()
);
// Generowanie okna pomiarowego o długości 1 sekundy
wire OneSecondStrobe;
StrobeGenerator #(
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(1_000_000)
) StrobeGenerator_inst(
.Clock(Clock),
.Reset(Reset),
.Enable_i(1’b1),
.Strobe_o(OneSecondStrobe)
);
// Licznik do zliczania zboczy rosnących mierzonego sygnału
// w oknie pomiarowym o długości 1 sekundy
reg [25:0] Counter; // 1
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Counter <= 0;
else if(OneSecondStrobe)
Counter <= 0;
else if(SignalEdge)
Counter <= Counter + 1’b1;
end
// Konwersja wyniku na format BCD
wire [31:0] Decimal; // 2
DoubleDabble #(
.INPUT_BITS(26), // 3
.OUTPUT_DIGITS(8) // 4
) DoubleDabble_inst(
.Clock(Clock),
.Reset(Reset),
.Start_i(OneSecondStrobe),
.Busy_o(),
.Done_o(),
.Binary_i(Counter),
.BCD_o(Decimal)
);
// Instancja sterownika wyświetlacza
DisplayMultiplex #(
.CLOCK_HZ(CLOCK_HZ),
.SWITCH_PERIOD_US(1000),
.DIGITS(8)
) DisplayMultiplex_inst(
.Clock(Clock),
.Reset(Reset),
.Data_i(Decimal),
.DecimalPoints_i(8’b00000000),
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o),
.SwitchCathode_o()
);
endmodule
`default_nettype wire
Listing 1. Kod pliku frequency_meter.v
Jedyna kwestia, jaka nie została dotychczas wyjaśniona, to powód, dla którego licznik Counter ma długość 26 bitów (linia 1). Wartość ta wynika z faktu, że wyświetlacz zamontowany na płytce User Interface Board jest 8-cyfrowy. Maksymalna liczba, którą możemy zapisać w tego typu liczniku, to 226–1, czyli 67108863, zatem taka właśnie liczba zmieści się na wyświetlaczu. Gdyby licznik był 27-bitowy, wówczas potrzebowalibyśmy wyświetlacza 9-cyfrowego. Teoretycznie 67,108863 MHz jest największą częstotliwością, jaką można zmierzyć opisywanym miernikiem. W rzeczywistości jest ona dużo mniejsza (około 10 MHz), bo ogranicza nas zegar w FPGA o częstotliwości 25 MHz oraz pojemność pasożytnicza ścieżek na płytce, które nie są przystosowane do przenoszenia sygnałów o wymienionej wcześniej częstotliwości.
Rzućmy okiem na konfigurację parametrów modułu DoubleDabble. W linii 3 informujemy moduł, że zmienna wejściowa ma 26 bitów. Następnie w linii 4 ustalamy, że na wyjściu chcemy mieć 8 cyfr BCD, a każda z nich ma 4 bity, więc do odczytania wyniku potrzebujemy 32-bitowej zmiennej typu wire (linia 2).
Testbench modułu FrequencyMeter
Omówimy teraz kod testera (listing 2), aby sprawdzić, czy miernik częstotliwości działa prawidłowo. Metodologia testbencha będzie niezwykle prosta. Utworzymy generator sygnału zegarowego, który zostanie doprowadzony na wejście miernika, a następnie zatrzymamy sekwencję testową, czekając, aż zostanie podany wynik pomiaru.
`timescale 1ns/1ns
`default_nettype none
module FrequencyMeter_tb();
// Konfiguracja
parameter CLOCK_HZ = 2_000_000;
parameter TEST_SIGNAL_HZ = 123_456; // 1
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#(1_000_000_000.0 / (2 * CLOCK_HZ));
Clock = !Clock;
end
// Generator testowego sygnału do pomiaru
reg TestSignal = 1’b0; // 2
always begin // 3
#(1_000_000_000.0 / (2 * TEST_SIGNAL_HZ));
TestSignal = !TestSignal;
end
// Eksport wyników symulacji do pliku
initial begin
$dumpfile("frequency_meter.vcd");
$dumpvars(0, FrequencyMeter_tb);
end
// Instancja testowanego modułu
FrequencyMeter #(
.CLOCK_HZ(CLOCK_HZ)
) DUT(
.Clock(Clock),
.Reset(Reset),
.SignalAsync_i(TestSignal), // 4
.Cathodes_o(), // 5
.Segments_o() // 6
);
reg Reset = 0;
// Sekwencja testowa
initial begin
$timeformat(-9, 3, "ns", 10);
$display("===== START =====");
$display("Test freq: %0d", TEST_SIGNAL_HZ); // 7
// Uruchomienie modułu
@(posedge Clock);
Reset = 1’b1; // 8
// Oczekiwanie na wynik
@(posedge DUT.DoubleDabble_inst.Done_o) // 9
repeat(100) @(posedge Clock); // 10
$display("Result: %0H", DUT.Decimal); // 11
$display("====== END ======");
$finish;
end
endmodule
Listing 2. Kod pliku frequency_meter_tb.v
Na początku kodu przeprowadzamy konfigurację częstotliwości sygnału zegarowego za pomocą parametru CLOCK_HZ, tak jak to robiliśmy w poprzednich odcinkach. Poniżej, w linii 1, ustalamy częstotliwość badanego sygnału za pomocą parametru TEST_SIGNAL_HZ.
Generatory sygnału zegarowego i testowego zrealizowane są bardzo podobnie. Musimy utworzyć zmienną TestSignal typu reg (linia 2), inicjalizując ją zerem lub jedynką, a następnie w bloku always (linia 3) odwracamy stan tej zmiennej co pewien czas, zależny od parametru TEST_SIGNAL_HZ.
Przejdźmy do instancji testowanego modułu, który zwyczajowo nazywamy DUT (device under test). Do jego wejścia SignalAsync_i podłączamy testowy sygnał TestSignal (linia 4). Zwróć uwagę, że wyjścia Cathodes_o oraz Segments_o nie zostały podłączone do niczego. Taka decyzja powodowana jest faktem, że zmiany sygnałów sterujących poszczególnymi segmentami wyświetlacza są trudne do przeanalizowania dla człowieka, więc nie będziemy się nimi interesować. Moduł wyświetlacza testowaliśmy już w 9 odcinku kursu i stąd wiemy, że działa. Wystarczy tylko, że będziemy obserwować wynik bezpośrednio z wyjścia miernika częstotliwości.
Zobaczmy sekwencję testową. W linii 7 printujemy na konsoli informację o częstotliwości testowego sygnału. Ta operacja posłuży do weryfikacji, czy moduł daje poprawny wynik. W tym odcinku nie będziemy przeprowadzać automatycznej weryfikacji w testbenchu, choć nie byłoby to trudne – spróbuj sam dodać taką funkcjonalność.
W linii 8 ustawiamy zmienną Reset w stan wysoki, co powoduje rozpoczęcie pracy modułu FrequencyCounter oraz wszystkich jego modułów podrzędnych. Moduł miernika częstotliwości pracuje, zliczając wszystkie zbocza testowanego sygnału. Musimy poczekać, aż skończy się okno pomiarowe i moduł zwróci wynik pomiaru. W linii 9 oczekujemy na zbocze rosnące wyjścia Done_o w module DoubleDabble_inst, który jest wewnętrznym modułem w DUT (linia 9). Za pomocą operatora kropki możemy sięgać do zmiennych leżących wewnątrz zagnieżdżonych modułów.
W linii 9 czekamy 100 cykli zegarowych (linia 10) – celem narysowania odstępu w przeglądarce GTKWave, aby lepiej było widać wynik. Na końcu, w linii 11, printujemy na konsoli wynik pomiaru. Zwróć uwagę na drobną różnicę między liniami 7 i 11. W linii 7 odczytujemy parametr, który zapisany jest w pamięci w postaci binarnej i chcemy go wyświetlić w formacie dziesiętnym – dlatego stosujemy operator %0d (działa on podobnie jak składnia funkcji printf() z C++). Natomiast w linii 11 odczytujemy wynik w formacie BCD i – chcąc wyświetlić go na konsoli – musimy posłużyć się operatorem %0H, który zazwyczaj służy do wyświetlania liczb w postaci szesnastkowej.
iverilog -o frequency_meter.o ^
frequency_meter.v ^
frequency_meter_tb.v ^
edge_detector.v ^
double_dabble.v ^
synchronizer.v ^
display_multiplex.v ^
decoder_7seg.v ^
strobe_generator.v
vvp frequency_meter.o
del frequency_meter.o
Listing 3. Kod pliku frequency_meter.bat
Kod skryptu do uruchomienia symulacji pokazano na listingu 3. Zapisy, które zobaczymy na konsoli po zakończeniu symulacji, zaprezentowano na listingu 4.
===== START =====
Test freq: 123456
Result: 123457
====== END ======
frequency_meter_tb.v:62: $finish called at 1000076500 (1ns)
Listing 4. Log symulacji na konsoli
Częstotliwość testowego sygnału i wynik pomiaru różnią się o jeden! Czy moduł działa nieprawidłowo? Nie do końca. Metoda pomiaru, którą stosujemy, z założenia dopuszcza możliwość różnicy o jeden. Dzieje się tak dlatego, że w oknie pomiarowym występuje jakaś całkowita liczba zboczy badanego sygnału. Może się zdarzyć (i bardzo często tak jest), że okno pomiarowe kończy się w trakcie okresu badanego sygnału. Zatem istnieje możliwość, że w kolejnych oknach pomiarowych policzymy jedno więcej lub jedno mniej zbocze badanego sygnału.
Testujemy na żywo
Czas na ćwiczenia praktyczne w prawdziwym układzie FPGA. Uruchom program Lattice Diamond, utwórz nowy projekt i dodaj do niego pliki, które pokazano na rysunku 2. Wszystkie pliki możesz ściągnąć z repozytorium na GitHubie https://github.com/leonow32/verilog-fpga.
A gdzie jest plik top.v? Nie ma! Tym razem stwierdziłem, że nie ma sensu go tworzyć, ponieważ znalazłaby się w nim wyłącznie instancja modułu FrequencyMeter i nic więcej. W takiej sytuacji Diamond rozpoznaje moduł nadrzędny po tym, że w żadnym innym pliku nie ma jego instancji. Nazwa pliku, który został rozpoznany jako nadrzędny, jest pogrubiona w drzewku projektowym.
Przeprowadź syntezę, a następnie otwórz Spreadsheet i skonfiguruj piny układu FPGA, tak jak to pokazano na rysunku 3.
W dzisiejszym odcinku nie używamy generatora RC typu OSCH, wbudowanego w FPGA, lecz używamy zewnętrznego generatora kwarcowego, ponieważ jest on dużo bardziej precyzyjny. W takiej sytuacji musimy także określić, jaka jest częstotliwość zegara. Klikamy zakładkę Timing Preferences na dole okna Spreadsheet, po czym ustawiamy wszystko tak, jak to widać na rysunku 4.
Generujemy bitstream i wgrywamy do FPGA. Na wyświetlaczu cyfrowym powinna pojawić się liczba 0, jeżeli żaden sygnał nie jest podłączony. Sygnał z dowolnego generatora należy podłączyć do wejścia Rx na złączu goldpin J5, a masę generatora należy podłączyć do wejścia GND. Pamiętaj, że napięcie mierzonego sygnału powinno się zmieniać w zakresie do 0 V do 3,3 V.
W następnym odcinku poznamy, jak za pomocą FPGA zbudować przetwornik cyfrowo-analogowy. Na kolejnym etapie użyjemy pamięci EBR, by cyklicznie odczytywać z niej wartości napięcia, które następnie będziemy przekazywać do przetwornika cyfrowo-analogowego. W taki sposób można wygenerować sygnał analogowy o zupełnie dowolnym kształcie. Poznamy także, w jaki sposób stworzyć zmienny dzielnik częstotliwości, aby móc regulować częstotliwość generowanego sygnału.
Dominik Bieczyński
leonow32@gmail.com