Celem ćwiczenia z dzisiejszego odcinka kursu będzie opracowanie generatora DDS, zdolnego do wytworzenia sygnału o dowolnym kształcie, z regulowaną częstotliwością i amplitudą sygnału. Zanim zaczniemy analizować kody w języku Verilog, musimy zapoznać się z teorią funkcjonowania układów DDS. Schemat najprostszego takiego rozwiązania pokazano na rysunku 1.
Składa się on z zaledwie trzech podzespołów: licznika i pamięci ROM – które można umieścić wewnątrz struktury FPGA – oraz przetwornika cyfrowo-analogowego (DAC), umieszczanego najczęściej poza FPGA (układy FPGA, w przeciwieństwie do mikrokontrolerów, na ogół nie mają wbudowanych żadnych peryferiów analogowych).
Przetwornik cyfrowo-analogowy
Zacznijmy od końca, czyli od przetwornika cyfrowo-analogowego. Jest to układ, który przetwarza wielobitowy sygnał cyfrowy na sygnał analogowy, czyli najczęściej napięcie elektryczne. Istnieje wiele różnych metod takiej konwersji, lecz skupimy się tylko na przetworniku R-2R, ponieważ taki znajduje się na płytce User Interface Board (została ona zaprezentowana w EP 09/2023).
Schemat opisanego przetwornika pokazano na rysunku 2.
Jest on bardzo prosty: składa się z drabinki rezystorów „poziomych” i „pionowych”. Elementy poziome podłączone zostają do wyjść poszczególnych bitów sygnału cyfrowego i mają dwa razy większą rezystancję niż rezystory pionowe. Wyjątek stanowi ostatni element pionowy, który ma dwukrotnie większą rezystancję od pozostałych oporników o tej samej „orientacji”. Na płytce User Interface Board zastosowano rezystory 1 kΩ i 2 kΩ. Napięcie wyjściowe z drabinki rezystorowej przechodzi następnie poprzez wzmacniacz operacyjny, pracujący jako wtórnik napięciowy, aby zwiększyć obciążalność prądową przetwornika. Ważne jest, by zastosować wzmacniacz typu rail-to-rail (czyli aby napięcie na jego wejściu i wyjściu mogło zmieniać się w całym zakresie od masy do napięcia zasilającego) – popularny LM358 nie nadaje się do tego zastosowania.
Jak działa taki przetwornik? Jest on sterowany wyjściami cyfrowymi typu push-pull. To znaczy, że wyjście w stanie niskim de facto oznacza połączenie z masą, a stan wysoki – połączenie z szyną zasilającą. Zatem rezystory poziome z jednej strony połączone są z łańcuszkiem innych rezystorów, a z drugiej – połączone są z masą lub zasilaniem. W taki sposób tworzy się dość zagmatwany dzielnik napięcia, jednak obliczenie napięcia wyjściowego pozostaje bardzo proste. Wystarczy zastosować wzór:
gdzie
- VCC – napięcie zasilające,
- INPUT – wartość cyfrowa na wejściu przetwornika,
- BITS – liczba bitów przetwornika.
W naszym przypadku mamy przetwornik 8-bitowy. Maksymalna liczba, jaką jesteśmy w stanie zapisać na 8 bitach, to 255, a mianownik tego ułamka wynosić będzie 256. Przykładowo: jeżeli napięcie zasilania wynosi 3,3 V, wówczas maksymalne napięcie, jakie możemy uzyskać z 8-bitowego przetwornika typu R-2R, wynosić będzie 3,3∙255/256=3,287 V.
Zaletą drabinki R-2R jest prostota, a także czas reakcji. Jedyne opóźnienie wynika tylko z czasu reakcji wzmacniacza operacyjnego oraz pojemności pasożytniczej elementów i ścieżek. Natomiast wadę stanowi jego dokładność – wynika ona z tolerancji rezystorów. Nie ma możliwości, aby zastosować jakieś precyzyjne źródło napięcia odniesienia. Tutaj odniesieniem jest napięcie zasilania, które bywa mocno zaszumione, a to negatywnie wpływa na wynik konwersji.
Pamięć z próbkami
Skoro mamy już przetwornik cyfrowo-analogowy, musimy skądś wziąć dane cyfrowe, które będzie on przetwarzał. W przypadku sygnału sinusoidalnego najczęściej stosowane są dwa rozwiązania:
- CORDIC – algorytm umożliwiający wykonanie różnych funkcji matematycznych, w tym trygonometrycznych. Algorytm ten może być zaimplementowany w sprzęcie, przez co znajduje zastosowanie w układach FPGA, a także w różnych procesorach jako akcelerator obliczeń.
- Lookup table – próbki sygnału zapisane są w pamięci ROM. Wystarczy co pewien czas odczytywać kolejne próbki z pamięci i przekazywać je do przetwornika DAC.
Podczas realizacji zadań z kursu użyjemy metody z pamięcią. Gdybyś chciał poeksperymentować z algorytmem CORDIC, znajdziesz gotowe moduły w bibliotece IP Express (która została omówiona w 5 odcinku kursu, opublikowanym w EP 03/2023).
Bloki pamięci EBR już potrafimy zastosować. Poznaliśmy je w 15 odcinku kursu (EP 01/2024) i opracowaliśmy wówczas moduł, który można konfigurować w bardzo szerokim zakresie. Teraz musimy tylko ustalić, jak ten moduł skonfigurować i skąd wziąć zawartość pamięci.
Załóżmy, że na próbki sygnału chcemy przeznaczyć jeden blok pamięci EBR. Wyjście pamięci powinno być 8-bitowe, ponieważ tyle bitów ma przetwornik R-2R na płytce User Interface Board. Jeden blok EBR z wyjściem 8-bitowym może pomieścić 1024 słowa, czyli w tym przypadku 1024 bajty. Tyle można zaadresować za pomocą wejścia adresowego o szerokości 10 bitów.
Potrzebujemy sposobu na wygenerowanie pliku z wsadem do pamięci, który zostanie zaimportowany za pomocą instrukcji $readmemh(). Plik ma zawierać 1024 8-bitowych wpisów, oddzielonych spacjami lub enterami. Musimy zatem wygenerować tyle wartości funkcji sinus, lecz powinniśmy trochę je poprzekształcać. Interesuje nas fragment funkcji od 0 do 2π, czyli cały jeden okres sinusa. Nasze zadanie polega na przeskalowaniu tego fragmentu na zakres od 0 do 1023, bo takie mamy adresy w pamięci. Ponadto funkcja sinus zwraca wartości od –1 do +1, a nam potrzebne są wartości od 0 do 255, ponieważ wyniki chcemy zapisać w zmiennych 8-bitowych (koniecznie używając do tego celu formatu szesnastkowego).
Plik z wsadem do pamięci możemy wygenerować w dowolny sposób. Ja do tego celu użyłem Excela. Arkusz obliczeniowy oraz gotowy plik z wsadem do pamięci znajdziesz w materiałach dołączonych do niniejszego odcinka.
Licznik z akumulatorem fazy
Przypatrzmy się jeszcze raz schematowi z rysunku 1. Adres pamięci jest wyznaczany przez 10-bitowy licznik inkrementowany z każdym taktem zegara. Taki sposób pracy umożliwia odczytanie wszystkich danych z pamięci i przekazanie ich do przetwornika cyfrowo-analogowego. Jednak nie mamy możliwości regulowania częstotliwości wygenerowanego sygnału.
Aby rozwiązać ten problem, moglibyśmy zastosować rozwiązanie podobne do tego, którego użyliśmy w odtwarzaczu melodyjek, do ustalania długości odtwarzanych nut melodii (odcinek 17 opublikowany w EP 03/2024), ale lepiej będzie poznać coś nowego – licznik z akumulatorem fazy.
Zobacz schemat na rysunku 3.
Prosty licznik został zastąpiony przez rejestr 16 przerzutników D (który nazywać będziemy akumulatorem) oraz 16-bitowy sumator bez przeniesienia, który sumuje aktualną wartość rejestru akumulatora z wartością wejścia TuningWord. Wynik z sumatora w każdym cyklu zegarowym jest przepisywany do rejestru i proces ten powtarza się w nieskończoność. Jeżeli wynik dodawania nie mieści się w 16-bitowym rejestrze, to licznik „przekręca się” i zaczyna liczyć od początku.
Normalny licznik w FPGA zbudowany jest dokładnie tak samo, lecz zamiast zmiennej TuningWord do sumatora na stałe doprowadzona jest liczba 1. Dodawanie zmiennej TuningWord sprawi, że z każdym cyklem zegara licznik będzie przeskakiwał o jakąś liczbę jednostek. W pierwszej chwili może wydawać się, że chodzi tutaj o przeskakiwanie próbek z pamięci, aby zwiększyć częstotliwość sygnału. Jednak jest w tym jeszcze inny sens.
Zwróć uwagę, że akumulator jest 16-bitowy, a wejście adresowe pamięci jest 10-bitowe. Oznacza to, iż korzystamy z 10 najstarszych bitów licznika, a 6 najmłodszych zostaje odrzuconych.
Częstotliwość sygnału, uzyskanego przez generator DDS, możemy obliczyć za pomocą wzoru:
gdzie:
- fSIG – częstotliwość sygnału na wyjściu,
- fCLOCK – częstotliwość sygnału zegarowego,
- TuningWord – wartość z wejścia regulującego częstotliwość,
- UsedBits – ile bitów licznika jest używanych przez przetwornik,
- DiscardedBits – ile bitów licznika jest odrzucanych.
W naszym przypadku, kiedy częstotliwość zegara wynosi 25 MHz, korzystamy z 10 bitów, a odrzucamy 6 bitów, wzór przyjmuje postać:
Okazuje się, że częstotliwość sygnału sinus, uzyskanego z generatora DDS, będzie wprost proporcjonalna do zmiennej na wejściu TuningWord i będzie ona zawsze wielokrotnością 381,47 Hz. Jeśli chcielibyśmy precyzyjniej ustawiać częstotliwości, powinniśmy zwiększyć liczbę odrzucanych bitów.
Regulacja amplitudy
Zobaczmy schemat na rysunku 4.
Pomiędzy pamięcią ROM i przetwornikiem DAC pojawił się układ mnożący. Jego zadaniem jest mnożenie próbki sygnału, odczytanej z pamięci, przez wartość, która zostaje doprowadzona do wejścia Amplitude.
Ogólnie, jeżeli mnożymy liczbę A-bitową przez B-bitową, to wynik musimy zapisać w zmiennej A+B-bitowej. Oba wejścia układu mnożącego są 8-bitowe, lecz wynik mnożenia takich liczb jest 16-bitowy. Nasz przetwornik DAC jest 8-bitowy, zatem musimy użyć 8 najstarszych bitów, czyli [15:8], a 8 najmłodszych odrzucamy. Można powiedzieć, że jest to równoznaczne z podzieleniem wyniku mnożarki przez 256.
W przypadku normalnych języków programowania operacja mnożenia jest czymś oczywistym. Jednak w świecie FPGA mnożenie da się zrealizować na wiele różnych sposobów. Może to być układ kombinacyjny, składający się z sieci różnych bramek i sumatorów. Da się także tę sieć przerobić na układ sekwencyjny, można do niego dodać również pipelining. W grę wchodzi też sięgnięcie po gotowe układy mnożące DSP, jednak FPGA MachXO2 takich nie mają.
Moduł DDS
Znamy już teorię funkcjonowania generatora DDS – czas na praktykę! Przeanalizujmy kod pokazany na listingu 1.
`default_nettype none
module DDS (
input wire Clock,
input wire Reset,
input wire [7:0] TuningWord_i,
input wire [7:0] Amplitude_i,
output wire [7:0] Signal_o,
output wire Overflow_o
);
// Akumulator fazy
reg [15:0] Accumulator; // 1
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Accumulator <= 0;
else
Accumulator <= Accumulator + TuningWord_i; // 2
end
// Pamięć ROM z próbkami sygnału sinusoidalnego
wire [7:0] DataFromROM; // 3
ROM #( // 4
.ADDRESS_WIDTH(10),
.DATA_WIDTH(8),
.MEMORY_DEPTH(1024),
.MEMORY_FILE(”sin.mem”)
) ROM_inst(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(1’b1),
.Address_i(Accumulator[15:6]), // 5
.Data_o(DataFromROM) // 6
);
// Regulacja amplitudy
reg [15:0] Temp; // 7
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Temp <= 0;
else
Temp <= DataFromROM * Amplitude_i; // 8
end
// Przypisanie wyjścia
assign Signal_o = Temp[15:8]; // 9
// Wykrywanie przepełnienie licznika
reg [9:0] Previous; // 10
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Previous <= 0;
else
Previous <= Accumulator[15:6]; // 11
end
assign Overflow_o = (Previous > Accumulator[15:6]); // 12
endmodule
`default_nettype wire
Listing 1. Kod pliku dds.v
Zadaniem tego modułu jest odwzorowanie schematu z rysunku 4 – na podstawie dwóch 8-bitowych wejść TuningWord_i oraz Amplitude_i moduł będzie generował próbki sygnału sinusoidalnego, które dostępne będą na 8-bitowym wyjściu Signal_o. Dodatkowo moduł wyposażony będzie także w wyjście Overflow_o, które ma informować o przepełnieniu licznika. Informacja o tym zdarzeniu będzie przekazywana poprzez ustawienie wspomnianego wyjścia w stan wysoki na jeden takt zegarowy.
W linii 1 tworzymy 16-bitowy rejestr akumulatora. Następnie mamy blok always, w którym w każdym takcie zegarowym inkrementujemy akumulator o wartość, jaka znajduje się na wejściu TuningWord_i (linia 2).
W linii 4 tworzymy instancję pamięci ROM, zawierającej 1024 dane 8-bitowe, której zawartość inicjalizujemy plikiem sin.mem z próbkami sygnału sinusoidalnego. W roli adresu używamy 10 najstarszych bitów akumulatora (linia 5). Wyjście z pamięci łączymy do zmiennej DataFromROM typu wire (linia 6), która została utworzona w linii 3.
Przejdźmy teraz do regulacji amplitudy. W linii 7 tworzymy 16-bitową zmienną Temp typu reg, która ma służyć do przechowywania wyniku mnożenia. Aby pomnożyć dwie liczby, zastosujemy kolejny sekwencyjny blok always. Właściwe mnożenie odbywa się w linii 8, gdzie mnożymy wyjście z pamięci DataFromROM przez wejście Amplitude_i i wynik zapisujemy do Temp. Przetwornik DAC na płytce User Interface Board jest 8-bitowy, zatem skorzystamy tylko z 8 najbardziej istotnych bitów wyniku mnożenia. Aby to uczynić, do 8-bitowego wyjścia Signal_o przypisujemy fragment zmiennej Temp, wybierając z niej bity od 15 do 8 (linia 9).
Ostatnia funkcjonalność, którą zamierzamy dodać, jest opcjonalna. Mowa o module mierzącym i wyświetlającym częstotliwość sygnału. Opracowaliśmy go w poprzednim odcinku (EP 10/2024). Pomiar częstotliwości polega na zliczaniu, ile razy jakieś zjawisko wystąpi w ciągu sekundy. W tym przypadku zjawiskiem będzie „przekręcenie się” licznika akumulatora – właściwie interesuje nas tylko jego 10 najstarszych bitów, które doprowadzone są do wejścia adresowego pamięci ROM.
Musimy zbadać, czy w poprzednim cyklu zegarowym wartość akumulatora była większa niż w obecnym cyklu. Dlaczego tak dziwnie? Dlaczego nie możemy np. wykrywać zera albo wartości maksymalnej? W zależności od wartości TuningWord_i, licznik akumulatora w każdym cyklu zegarowym może inkrementować się o jakąś liczbę jednostek. Na przykład, jeżeli w 16-bitowym akumulatorze mamy wartość 65530, a TuningWord_i jest równe 10, to w kolejnym cyklu zegarowym akumulator przepełni się i będzie miał wartość 4. Z tego powodu porównywanie stanu akumulatora do jakiejś konkretnej wartości nie będzie zdawać egzaminu, bo ta wartość może zostać przeskoczona, kiedy TuningWord jest większe od 64.
W linii 10 tworzymy 10-bitowy rejestr Previous. Poniżej niego mamy kolejny sekwencyjny blok always, w którym do wspomnianego rejestru kopiujemy aktualną wartość 10 najstarszych bitów akumulatora (linia 11). Porównanie poprzedniej i aktualnej wartości zostało zrealizowane jako logika kombinacyjna za pomocą instrukcji assign (linia 12).
Testbench modułu DDS
Omówimy teraz moduł testujący generatora DDS. Kod tego modułu pokazano na listingu 2.
`timescale 1ns/1ps
`default_nettype none
module DDS_tb();
// Konfiguracja
parameter CLOCK_HZ = 25_000_000;
reg [7:0] TuningWord = 100; // 1
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#(1_000_000_000.0 / (2 * CLOCK_HZ));
Clock = !Clock;
end
// Zmienne
reg Reset = 0;
// Eksport wyników symulacji
initial begin
$dumpfile(”dds.vcd”);
$dumpvars(0, DDS_tb);
end
// Instancja testowanego modułu
DDS DUT( // 2
.Clock(Clock),
.Reset(Reset),
.TuningWord_i(TuningWord), // 3
.Amplitude_i(8’hFF), // 4
.Signal_o(),
.Overflow_o()
);
// Pomiar częstotliwości sygnału
always @(posedge DUT.Overflow_o) begin: MeasureFreq // 5
real TimePrevious; // 6
real Freq; // 7
if(TimePrevious == 0) begin // 8
TimePrevious = $realtime;
end else begin
Freq = 1_000_000_000.0 / ($realtime – TimePrevious); // 9
$display(”%t %10.3f Hz”, $realtime, Freq); // 10
TimePrevious = $realtime; // 11
end
end
// Sekwencja testowa
initial begin
$timeformat(-3, 3, ”ms”, 10);
$display(”===== START =====”);
@(posedge Clock);
Reset <= 1’b1; // 12
repeat(10) @(posedge DUT.Overflow_o); // 13
$display(”====== END ======”);
$finish;
end
endmodule
Listing 2. Kod pliku dds_tb.v
Zadanie testbencha jest bardzo proste – tworzymy w nim instancję testowanego modułu generatora DDS, uruchamiamy go zgodnie z oczekiwaną konfiguracją i odczekujemy pewien czas, obserwując jego pracę.
Konfigurację ustawiamy na początku testbencha. Oczywiście, oprócz częstotliwości sygnału zegarowego, ustawić musimy wartość wejścia TuningWord (linia 1). Przez cały czas symulacji ta wartość będzie pozostawać stała. Na kolejnych liniach mamy elementy typowe dla wszystkich dotychczasowych testbenchy – generator sygnału zegarowego, deklarację zmiennych i eksport wyników symulacji do pliku.
Przejdźmy do linii 2, w której znajduje się instancja testowanego modułu generatora DDS. Zmienną TuningWord, którą utworzyliśmy w linii 1, doprowadzamy do wejścia TuningWord_i (linia 3), a do wejścia Amplitude_i przypisujemy na stałe wartość 8’hFF, co pozwoli uzyskać maksymalną możliwą amplitudę sygnału (linia 4).
Sekwencja testowa jest bardzo prosta. Po zwolnieniu sygnału Reset (linia 12) moduł rozpoczyna pracę, a my czekamy, aż 10 razy wystąpi zbocze rosnące na wyjściu Overflow_o (linia 13), sygnalizujące rozpoczęcie kolejnego cyklu ładowania próbek sygnału z pamięci ROM do przetwornika DAC.
Kolejnym zadaniem testbencha będzie zmierzenie częstotliwości wygenerowanego sygnału. Najprościej byłoby użyć modułu FrequencyMeter, który opracowaliśmy w poprzednim odcinku kursu. Wszak zamierzamy go umieścić w module top i w ten sposób mierzyć częstotliwość w rzeczywistym układzie FPGA.
Metoda pomiaru zastosowana w tym module polega na zliczaniu impulsów w ciągu jednej sekundy. Oznacza to, że cała symulacja musiałaby trwać przez taki czas. Wykonanie tego typu symulacji jest możliwe, ale jednocześnie bardzo czasochłonne, a plik wynikowy zawierałby wiele gigabajtów danych.
Musimy wymyślić coś innego.
Skorzystamy z innej metody pomiaru. Będziemy zapisywać, kiedy wystąpiły zbocza rosnące sygnału Overflow_o. Mając tę wiedzę, obliczymy okres sygnału – wszak jego częstotliwość to nic innego, jak odwrotność okresu.
W linii 5 rozpoczynamy blok always, który reagować ma tylko na zbocze rosnące sygnału Overflow_o, pochodzącego z modułu DUT. Wewnątrz tego bloku tworzymy zmienne, a więc blok begin-end nie może być anonimowy i musimy go jakoś nazwać. Nazwa nie ma większego znaczenia, bo nigdzie nie będziemy jej stosować, ale jakąś trzeba wymyślić. W naszym przykładzie zdecydowaliśmy się na MeasureFreq.
W linii 6 tworzymy zmienną TimePrevious typu real – jest to zatem liczba zmiennoprzecinkowa. Ta zmienna będzie przechowywać informację o tym, kiedy wystąpiło poprzednie zbocze Overflow_o. Następnie tworzymy zmienną Freq, również zmiennoprzecinkową, w której będzie zapisywany wynik obliczenia częstotliwości (linia 7). Tak utworzone zmienne domyślnie są inicjalizowane zerami.
Korzystamy z tego faktu w linii 8, aby zarejestrować czas wystąpienia pierwszego takiego zbocza w naszej symulacji. Wtedy do zmiennej TimePrevious wpisujemy timestamp pobrany za pomocą instrukcji $realtime. Instrukcja ta zwraca nam aktualny czas jako liczbę nanosekund, które upłynęły od startu symulacji (uwaga! na początku kodu, stosując instrukcję $timescale, ustawiliśmy podstawową jednostkę czasu symulacji jako jedną nanosekundę).
W przypadku wystąpienia kolejnych zboczy, zmienna TimePrevious jest różna od zera, zatem wykonuje się linia 9. Od aktualnego czasu odejmujemy czas zarejestrowania poprzedniego zbocza. Otrzymujemy tym sposobem okres sygnału wyrażony w nanosekundach. Aby przeliczyć go na częstotliwość w hercach, musimy 1_000_000_000 podzielić przez okres. Skąd taka liczba? Dokładnie tyle nanosekund jest w jednej sekundzie. Następnie wyświetlamy wynik na konsoli za pomocą funkcji $display i aktualizujemy zmienną TimePrevious o aktualny czas symulacji, aby powtórzyć cały ten proces przy wykryciu kolejnego zbocza.
W celu przeprowadzenia symulacji w Icarus Verilog uruchom skrypt, którego kod pokazano na listingu 3.
iverilog -o dds.o ^
dds.v ^
dds_tb.v ^
rom.v
vvp dds.o
del dds.o
Listing. 3. Kod pliku dds.bat
Powinieneś zobaczyć komunikaty podobne do zaprezentowanych na listingu 4.
===== START =====
0.052ms 38167.939 Hz
0.079ms 38109.756 Hz
0.105ms 38167.939 Hz
0.131ms 38167.939 Hz
0.157ms 38109.756 Hz
0.184ms 38167.939 Hz
0.210ms 38167.939 Hz
0.236ms 38109.756 Hz
====== END ======
dds_tb.v:63: $finish called at 262200000 (1ps)
Listing 4. Log z symulacji
Otwórz plik dds.vcd w przeglądarce GTKWave i skonfiguruj ją tak, by uzyskać efekt widoczny na rysunku 5.
Wejście adresowe pamięci ROM zostało zaprezentowane w postaci analogowej, aby łatwiej było zaobserwować, w jaki sposób się ono zmienia – przypomina to sygnał piłokształtny. Adres rośnie od zera do maksimum w sposób liniowy, a następnie licznik akumulatora przepełnia się i zaczyna zliczanie od początku (choć wcale nie od zera – zależy to od TuningWord).
W momencie przekręcenia się licznika widzimy krótką szpilkę sygnału Overflow_o, dla większej czytelności zaznaczonego kolorem żółtym.
Na wyjściu Signal_o widzimy piękny 8-bitowy sygnał sinusoidalny. Wystarczy doprowadzić go do przetwornika DAC.
Moduł top
Moduł top będzie pełnił funkcję interfejsu użytkownika i umożliwi zmianę ustawień generatora DDS oraz obserwację jego pracy. Za pomocą dwóch enkoderów obrotowych będziemy regulować częstotliwość oraz amplitudę generowanego sygnału. Film demonstrujący działanie projektu można obejrzeć pod adresem [3].
Kod pliku top.v przedstawiono na listingu 5.
`default_nettype none
module top #(
parameter CLOCK_HZ = 25_000_000
)(
input wire Clock, // Pin 20
input wire Reset, // Pin 17
input wire EncoderFreqA_i, // Pin 68
input wire EncoderFreqB_i, // Pin 67
input wire EncoderAmplA_i, // Pin 71
input wire EncoderAmplB_i, // Pin 70
output wire [7:0] Signal_o, // Pin 2 3 4 7 82 81 77 76
output wire [7:0] Cathodes_o, // Pin 40 41 42 43 45 47 51 25
output wire [7:0] Segments_o // Pin 39 38 37 36 35 34 30 29
);
// Enkoder do regulacji częstotliwości
wire IncrementFreq; // 1
wire DecrementFreq; // 2
Encoder EncoderFreq_inst( // 3
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(EncoderFreqA_i), // 4
.AsyncB_i(EncoderFreqB_i), // 5
.AsyncS_i(1’b1),
.Increment_o(IncrementFreq), // 6
.Decrement_o(DecrementFreq), // 7
.ButtonPress_o(),
.ButtonRelease_o(),
.ButtonState_o()
);
// Ustawianie rejestru TuningWord
reg [7:0] TuningWord; // 8
always @(posedge Clock, negedge Reset) begin
if(!Reset)
TuningWord <= 0;
else if(IncrementFreq) // 9
TuningWord <= TuningWord + 1’b1;
else if(DecrementFreq) // 10
TuningWord <= TuningWord – 1’b1;
end
// Enkoder do regulacji amplitudy
wire IncrementAmpl;
wire DecrementAmpl;
Encoder EncoderAmpl_inst(
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(EncoderAmplA_i),
.AsyncB_i(EncoderAmplB_i),
.AsyncS_i(1’b1),
.Increment_o(IncrementAmpl),
.Decrement_o(DecrementAmpl),
.ButtonPress_o(),
.ButtonRelease_o(),
.ButtonState_o()
);
// Ustawianie mnożnika amplitudy
reg [7:0] Amplitude;
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Amplitude <= 8’hFF;
else if(IncrementAmpl)
Amplitude <= Amplitude + 1’b1;
else if(DecrementAmpl)
Amplitude <= Amplitude – 1’b1;
end
// Instancja modułu generatora DDS
wire Overflow; // 11
DDS DDS_inst( // 12
.Clock(Clock),
.Reset(Reset),
.TuningWord_i(TuningWord),
.Amplitude_i(Amplitude),
.Signal_o(Signal_o),
.Overflow_o(Overflow)
);
// Instancja miernika częstotliwości
FrequencyMeter #( // 13
.CLOCK_HZ(CLOCK_HZ)
) FrequencyMeter_inst(
.Clock(Clock),
.Reset(Reset),
.SignalAsync_i(Overflow),
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o)
);
endmodule
`default_nettype wire
Listing 5. Kod pliku top.v
Na liście portów modułu znajdziemy wejścia A i B (sterowane przez dwa enkodery obrotowe), 8-bitowe wyjście sygnału oraz dwa 8-bitowe wyjścia sterujące segmentami i katodami wyświetlacza LED.
Omówimy tylko sposób obsługi enkodera regulującego częstotliwość, a ustawianie amplitudy pominiemy, ponieważ działa bardzo podobnie. Do obsługi enkodera wykorzystamy moduł Encoder (linia 3), który opracowaliśmy w 14 odcinku kursu (opublikowanym w EP 12/2023). Do jego wejść doprowadzimy sygnały EncoderFreqA_i oraz EncoderFreqB_i, pochodzące z wejścia modułu top (linie 4 i 5). Wyjścia enkodera, które informują o wykryciu obrotu w prawo lub w lewo (linie 6 i 7), łączymy ze zmiennymi IncrementFreq oraz DecrementFreq, które zostały utworzone w liniach 1 i 2.
W linii 8 tworzymy 8-bitowy rejestr TuningWord, którego zawartość jest modyfikowana w bloku always poniżej. Po zresetowaniu układu ma on wartość zerową. Kiedy sygnał IncrementFreq jest w stanie wysokim, rejestr zwiększamy o jeden (linia 9), a kiedy DecrementFreq jest w stanie wysokim – zmniejszamy o jeden (linia 10). Regulacja amplitudy zrealizowana jest w analogiczny sposób, więc nie będziemy jej szerzej omawiać.
Instancję generatora DDS tworzymy w linii 12. Do jego wejść doprowadzamy utworzone wcześniej rejestry TunungWord oraz Amplitude, a wyjście łączymy z portem wyjściowym Signal_o.
Na potrzeby pomiaru częstotliwości tworzymy instancję modułu FrequencyMeter (linia 13). Jest on połączony z modułem generatora DDS za pośrednictwem zmiennej wire Overflow, zadeklarowanej w linii 11.
Testbench modułu top
W dotychczasowych odcinkach nie robiliśmy testbencha modułu top, lecz tym razem go zbudujemy. Celem testbencha będzie symulowanie obrotu enkoderów obrotowych w taki sposób, aby:
- Zwiększać TuningWord od 0 do 20 (czyli aż TuningWord osiągnie wartość równą parametrowi TuningWordRequested, który definiujemy na początku testbencha), co powinno skutkować zwiększeniem częstotliwości generowanego sygnału sinusoidalnego.
- Zmniejszać amplitudę od maksymalnej do zera.
- Zwiększać amplitudę od zera do maksymalnej.
- Zmniejszać TuningWord do zera, co w rezultacie obniży częstotliwość sygnału, aż ostatecznie przestanie się on zmieniać.
Kod testbenacha modułu top pokazano na listingu 6, a skrypt uruchamiający symulator znajduje się na listingu 7.
`timescale 1ns/1ps
`default_nettype none
module top_tb();
// Konfiguracja
parameter CLOCK_HZ = 25_000_000;
parameter TuningWordRequested = 20;
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#(1_000_000_000.0 / (2 * CLOCK_HZ));
Clock = !Clock;
end
// Zmienne
reg Reset = 0;
reg AsyncFreqA = 1;
reg AsyncFreqB = 1;
reg AsyncAmplA = 1;
reg AsyncAmplB = 1;
// Eksport wyników symulacji
initial begin
$dumpfile(”top.vcd”);
$dumpvars(0, top_tb);
end
// Instancja testowanego modułu
top DUT(
.Clock(Clock),
.Reset(Reset),
.Encoder Freq A_i(AsyncFreqA),
.EncoderFreqB_i(AsyncFreqB),
.EncoderAmplA_i(AsyncAmplA),
.EncoderAmplB_i(AsyncAmplB),
.Signal_o(),
.Cathodes_o(),
.Segments_o()
);
// Sekwencja testowa
initial begin
$timeformat(-9, 3, ”ns”, 10);
$display(”===== START =====”);
@(posedge Clock);
Reset = 1’b1;
repeat(5000)
@(posedge Clock);
// Zwiększanie TuningWord
repeat(TuningWordRequested) begin
#10000 AsyncFreqA = 1’b0;
#10000 AsyncFreqB = 1’b0;
#10000 AsyncFreqA = 1’b1;
#10000 AsyncFreqB = 1’b1;
#20000;
end
// Zmniejszanie amplitudy od maksimum do zera
repeat(255) begin
#2000 AsyncAmplB = 1’b0;
#2000 AsyncAmplA = 1’b0;
#2000 AsyncAmplB = 1’b1;
#2000 AsyncAmplA = 1’b1;
#5000;
end
// Zwiększanie amplitudy od zera do maksimum
repeat(255) begin
#2000 AsyncAmplA = 1’b0;
#2000 AsyncAmplB = 1’b0;
#2000 AsyncAmplA = 1’b1;
#2000 AsyncAmplB = 1’b1;
#5000;
end
// Zmniejszanie TuningWord
repeat(TuningWordRequested) begin
#10000 AsyncFreqB = 1’b0;
#10000 AsyncFreqA = 1’b0;
#10000 AsyncFreqB = 1’b1;
#10000 AsyncFreqA = 1’b1;
#20000;
end
repeat(5000)
@(posedge Clock);
$display(”====== END ======”);
$finish;
end
endmodule
Listing 6. Kod pliku top_tb.v
iverilog -o top.o ^
top.v ^
top_tb.v ^
dds.v ^
decoder_7seg.v ^
display_multiplex.v ^
double_dabble.v ^
edge_detector.v ^
frequency_meter.v ^
rom.v ^
encoder.v ^
strobe_generator.v ^
synchronizer.v
vvp top.o
del top.o
Listing 7. Kod pliku top.bat
Nie będziemy go szczegółowo omawiać. Metody symulacji enkoderów obrotowych oraz rola ich sygnałów A i B była już omówiona w 14 odcinku kursu, opublikowanym w EP 12/2023. Przejdziemy od razu do omówienia wyników symulacji.
Otwórzmy plik VCD w przeglądarce GTKWave i skonfigurujmy ją tak, by uzyskać efekt pokazany na rysunku 6.
Sygnały z modułów kontrolujących enkodery, tzn. IncrementFreq, DecrementFreq, IncrementAmpl oraz DecrementAmpl zaznaczyłem na żółto w celu zwiększenia czytelności. Sygnał DataFromROM pochodzi bezpośrednio z wyjścia pamięci. Widzimy, że w pierwszej fazie symulacji częstotliwość tego sygnału zwiększa się, kiedy występują szpilki na IncrementFreq. Podobnie w końcowej części symulacji występują impulsy na sygnale DecrementFreq, a częstotliwość przebiegu sinusoidalnego zmniejsza się, aż finalnie sygnał ten przyjmuje wartość stałą.
Przebieg na wyjściu Signal_o jest mierzony po przejściu sygnału przez mnożnik amplitudy. Widzimy, że gdy pojawiają się impulsy sygnału DecrementAmpl, to amplituda zmniejsza się, ale kształt i częstotliwość sygnału pozostają zachowane. Obecność szpilek na IncrementAmpl powoduje efekt odwrotny – amplituda rośnie do pierwotnego poziomu.
Testy na żywo
Wiemy już, że moduł top działa w symulatorze. Czas na test w prawdziwym FPGA. Utwórz nowy projekt w Lattice Diamond i dodaj do niego pliki widoczne na rysunku 7.
Przeprowadź syntezę, a następnie otwórz Spreadsheet i skonfiguruj piny wejścia-wyjścia w taki sposób, jak to zaprezentowano na rysunku 8.
Wygeneruj bitstream i wgraj go do FPGA. Podłącz sondę oscyloskopu do pinu Analog na płytce User Interface Board i obracaj enkoderami, aby zobaczyć efekt. Film z demonstracją działania układu możesz zobaczyć pod adresem [3].
Statyczna analiza czasowa
Normalnie zakończylibyśmy odcinek kursu po wgraniu bit- streamu do FPGA. Tym razem jednak zastanowimy się nad pewną kwestią: jaka jest maksymalna częstotliwość sygnału zegarowego? Aby się dowiedzieć, należy przeprowadzić statyczną analizę czasową. W okienku procesów klikamy Place & Route Trace, a następnie w oknie raportów wybieramy pozycję o tej samej nazwie. Na rysunku 9 zaznaczono je czerwonymi strzałkami (temat statycznej analizy czasowej został dokładnie omówiony w 11 odcinku kursu, opublikowanym w EP 09/2023).
Okazuje się, że największa częstotliwość zegara, jakiej możemy użyć, to 62,131 MHz. Nie jest to mało, ale mogłoby być więcej. Aby ustalić, co jest wąskim gardłem w naszym projekcie, musimy wybrać menu Tools, a następnie Timing Analysis View. Pozycję Worst case paths (lewa górna część okna) zmieniamy z 10 na 200, aby program pokazał więcej przeanalizowanych ścieżek. Z okienka poniżej wybieramy pozycję FREQUENCY NET “Clock” 25.000000 MHz – setup.
W okienku po prawej, górnej stronie pokazały się najdłuższe ścieżki. W uproszczeniu: program obliczył czas propagacji elementów kombinacyjnych. Jest to czas, jaki mija od ustalenia się stanu wyjścia przerzutnika D nadającego (source) do ustalenia sygnału na wejściu przerzutnika D odbierającego (destination) – pomiędzy nimi znajdują się różne bramki, multipleksery, sumatory i inne elementy kombinacyjne. Wszystkie przerzutniki D taktowane są sygnałem zegarowym o tej samej częstotliwości, która nie może być zbyt wysoka, ponieważ wtedy przerzutnik odbierający odczytywałby dane na swoim wejściu wcześniej, niż sygnał zdążyłby do niego dotrzeć. Czas propagacji poszczególnych ścieżek kombinacyjnych jest wykazany w kolumnie Arrival i wyrażony w nanosekundach. W przykładzie na rysunku 10 najwolniejsza ścieżka ma czas propagacji równy 16,095 ns – jeżeli obliczymy odwrotność tej liczby, to dostaniemy maksymalną częstotliwość zegara, czyli 62,131 MHz.
Narzędzie do statycznej analizy czasowej niestety nie wskaże nam fragmentu kodu, który trzeba poprawić. Jednak w tym przypadku jest to dość łatwe. Wystarczy tylko przewinąć listę ścieżek, by stwierdzić, że wszystkie bez wyjątku wychodzą z pamięci ROM wewnątrz instancji DDS_inst i wszystkie idą do rejestru Temp, znajdującego się również wewnątrz DDS_inst. Trzeba teraz prześledzić kod modułu DDS, pokazany na listingu 1. Winną okazuje się operacja mnożenia (linia 8). Dane z wyjścia pamięci ROM są mnożone przez dane na wejściu Amplitude_i, a następnie wynik zapisywany jest do rejestru Temp. Operacja mnożenia, wykonywana w jednym takcie zegarowym, wymaga dość rozbudowanej gmatwaniny różnych sumatorów i multiplekserów, co w rezultacie prowadzi do relatywnie długiego czasu propagacji, a to w rezultacie powoduje, że częstotliwość sygnału zegarowego musi być odpowiednio niska.
Jak to naprawić? Często w FPGA umieszczane są bloki DSP, czyli peryferia, które można zastosować podobnie jak bloki pamięci EBR, dzielniki częstotliwości CLKDIV, generatory OSCH czy PLL. Bloki DSP potrafią bardzo szybko wykonywać operacje dodawania i mnożenia. Niestety w FPGA MachXO2 nie ma takich peryferiów.
Możemy skorzystać z dodatku IP Express, w którym firma Lattice Semiconductor udostępnia różne, bardzo dobrze zoptymalizowane moduły. Z menu Tools wybieramy IP Express, a następnie Arithmetic Modules i Multiplier (zaznaczone czerwoną strzałką na rysunku 11). Pozostałe pola konfigurujemy tak, jak to pokazano na rysunku 11, po czym klikamy Customize.
Otwiera się kreator modułu mnożącego, widoczny na rysunku 12. Nas interesuje, by stworzyć moduł mnożący dwie zmienne 8-bitowe bez znaku.
Bardzo ciekawą funkcjonalność możemy skonfigurować za pomocą opcji Specify the Number of Pipeline Stages. W odcinku o statycznej analizie czasowej pisaliśmy, że jedną z metod zwiększania częstotliwości sygnału zegarowego jest podzielenie dużych bloków kombinacyjnych na mniejsze sekwencyjne i umieszczenie przerzutników D pomiędzy tymi blokami. W ten sposób wykonanie jakiejś operacji będzie trwało kilka taktów zegarowych, ale zegar może mieć wyższą częstotliwość. Ponadto do takiego układu z każdym cyklem zegarowym możemy podawać kolejne dane, a wynik ich pracy otrzymamy kilka taktów zegarowych później. Takie rozwiązanie to właśnie pipeling, czyli przetwarzanie potokowe. W przypadku mnożenia liczb 8-bitowych możemy zastosować pipelining 8-stopniowy.
Nie zapomnij zaznaczyć opcji Import IPX to Diamond project – bez tego program wygeneruje plik modułu, ale nie doda go do projektu. Następnie kliknij Generate i zamknij kreator.
Teraz musimy odrobinę zmodyfikować plik dds.v. Blok always, w którym wykonywaliśmy mnożenie, należy usunąć lub zakomentować i zastąpić go kodem znajdującym się na listingu 8.
wire [15:0] Temp;
Multiplier multiplier_inst(
.Clock(Clock),
.ClkEn(1’b1),
.Aclr(1’b0),
.DataA(DataFromROM),
.DataB(Amplitude_i),
.Result(Temp)
);
// Przypisanie wyjścia
assign Signal_o = Temp[15:8];
Listing 8. Instancja modułu mnożącego, wygenerowanego przez IP Express
Moduł mnożarki ma dwa wejścia, których znaczenie nie jest oczywiste. Wejście ClkEn uaktywnia moduł (to skrót od Clock Enable). Stan niski powoduje uśpienie modułu, a stan wysoki zezwala na pracę. Wejście Aclr służy do zerowania przerzutników D na wszystkich stopniach pipeliningu.
Ponownie przeprowadźmy syntezę i statyczną analizę czasową. Po otwarciu raportu Place & Route Trace (listing 9) widzimy, że maksymalna częstotliwość sygnału zegarowego wzrosła aż do 133,923 MHz! Dzięki zastosowaniu pipeliningu uzyskaliśmy ponad dwukrotne przyspieszenie. Kosztem tego rozwiązania jest większe zapotrzebowanie na zasoby logiczne w FPGA, ale nie jest to dla nas problemem, bo i tak wykorzystujemy zaledwie 30% zasobów dostępnych w MachXO2-1200. Jeżeli ponownie otworzymy Timing Analysis View, to okaże się, że wąskim gardłem stał się moduł mierzący i wyświetlający częstotliwość.
3665 items scored, 0 timing errors detected.
Report: 133.923MHz is the maximum frequency for this preference.
Listing 9. Fragment raportu Place & Route Trace
Podsumowanie
W tym odcinku przygotowaliśmy generator sygnału sinusoidalnego. Nic nie stoi na przeszkodzie, by generować nim jakikolwiek inny sygnał – wystarczy zmienić plik z próbkami, który zapisany jest w pamięci ROM. W następnym odcinku poznamy, w jaki sposób działa interfejs SPI – będzie to bardzo przydatne, aby sterować układem FPGA z poziomu mikrokontrolera.
Dominik Bieczyński
leonow32@gmail.com