Kurs FPGA Lattice (3). Podstawy języka Verilog

Kurs FPGA Lattice (3). Podstawy języka Verilog

W tym odcinku kursu nauczymy się podstawowych instrukcji języka Verilog i opiszemy proste układy kombinacyjne oraz sekwencyjne. Poprzednie części kursu można znaleźć tu i tu.

Moduły i instancje modułów

Kod języka Verilog dzielimy na moduły, podobnie jak kod w językach programowania dzieli się na funkcje czy klasy. Każdy moduł komunikuje się z pozostałymi modułami przy pomocy portów – mogą one być wejściem input, wyjściem output lub sygnałem dwukierunkowym inout. Moduł nadrzędny, od którego zaczyna się cała aplikacja zwyczajowo nazywany jest top. Jest odpowiednikiem funkcji main() w C++. Każdy inny moduł należy opisać, a następnie powołać do życia tworząc co najmniej jedną instancję tego modułu.

Moduły wygodnie jest zapisywać w oddzielnych plikach, których nazwa jest taka sama jak nazwa modułu.

Zapamiętaj
Układ kombinacyjny to taki, którego stan wyjścia zależy tylko od obecnego stanu wejść.
Układ sekwencyjny to taki, którego stan wyjścia zależy od obecnego stanu wejść oraz od stanów, jakie były w przeszłości. Układ sekwencyjny ma jakiś element pamięciowy, a często sterowany jest sygnałem zegarowym.

Prześledźmy przykład zaprezentowany na listingu 1. W liniach oznaczonych jako #4 i #5 definiujemy moduły BramkaAND i BramkaOR. Każda z nich ma dwa wejścia x i y oraz wyjście result. Samo zdefiniowanie tych modułów jeszcze nie spowoduje wygenerowania ich w układzie FPGA. W module top w liniach oznaczonych #1 i #2 zostały utworzone dwie instancje modułu BramkaAND. W nawiasach podane są nazwy portów, jakie ma moduł top. W ten sposób wejścia i wyjścia modułu top, które są fizycznymi pinami układu FPGA, zostają połączone z wirtualnymi pinami modułów.

Listing 1. Przykład podziału kodu na moduły

// Plik top.v
module top(
input ButtonA,
input ButtonB,
input ButtonC,
output LED,
output Buzzer,
output Relay
);

// Instancje modułów typu BramkaAND o nazwie AND1 i AND2
BramkaAND AND1(ButtonA, ButtonB, LED); // #1
BramkaAND AND2(ButtonA, ButtonC, Buzzer); // #2

// Instancja modułu typu BramkaOR o nazwie OR1
BramkaOR OR1( // #3
.x(ButtonA),
.y(ButtonB),
.result(Relay)
);

endmodule

// Plik bramka_and.v
module BramkaAND( // #4
input x, y,
output result
);

assign result = x & y;

endmodule

// Plik bramka_or.v
module BramkaOR( // #5
input x, y,
output result
);

assign result = x | y;

endmodule

Należy zwrócić uwagę, że utworzyliśmy dwie instancje AND1 i AND2, które działają dokładnie tak samo, jednak połączone są z innymi sygnałami. Istnieją dwa sposoby tworzenia instancji i łączenia ich z sygnałami z innych modułów. Sposób pokazany w liniach #1 i #2 jest podobny do kodu C++, w którym tworzony jest obiekt jakiejś klasy, a jego konstruktor przyjmuje kilka argumentów. W tym przypadku kolejność argumentów ma znaczenie. Taki sposób jest przydatny dla bardzo prostych modułów, które mają niewiele portów. Drugi sposób został pokazany w linii #3. Każdy z portów modułu wymieniony jest po znaku kropki. Następnie w nawiasach podana jest nazwa sygnału, z którym dany port ma być połączony. Taki opis jest bardziej rozwlekły i czasochłonny, jednak ma kilka istotnych zalet.

Po pierwsze kod staje się bardziej czytelny, ponieważ widzimy nazwę portu wewnątrz modułu i jednocześnie sygnał, z którym jest połączony na zewnątrz. Dodatkowo każdy z nich możemy opatrzyć komentarzem. Dzięki temu, że każdy port jest zapisany w osobnej linii, w razie błędu syntezator powie nam, w której linii jest błąd – w przypadku pierwszego sposobu, gdzie wszystko opisujemy w jednej linijce, komunikatory syntezatora mogą być trudniejsze do zrozumienia. Ponadto, kolejność przypisań jest dowolna.

Zmienne

W języku Verilog stosuje się głównie dwa typy zmiennych (jest ich więcej, ale dwa są szczególnie ważne dla syntezy). Są to wire oraz reg. Oba dostarczają sygnały logiczne, ale reprezentują inne zasoby sprzętowe.

Reg jest elementem, który przechowuje stan logiczny tak długo, aż nie zostanie do niego przypisany inny stan. Najczęściej jest syntezowany jako przerzutnik D, który przepisuje stan swojego wejścia na wyjście w momencie wystąpienia określonego zbocza sygnału zegarowego. Zmienną typu reg może tworzyć także przerzutnik RS lub inny, a grupa takich zmiennych może tworzyć pamięć RAM. Jednak należy zaznaczyć, że zmienna typu reg wcale nie musi być syntezowana jako przerzutnik – przykład takiej sytuacji jest opisany w dalszej części artykułu. Reg może służyć do tworzenia układów zarówno kombinacyjnych jak i sekwencyjnych.

Wire to najprościej mówiąc przewód, który łączy jakieś dwa obiekty. Zmienną typu wire musimy przypisać do jakiegoś sygnału źródłowego. W pewnym sensie wire przypomina wskaźnik do zmiennej, jednak nie jest to żaden adres zmiennej z pamięci, lecz fizyczne połączenie do jakiegoś wejścia, wyjścia, przerzutnika, itp. Wire stosuje się do modelowania układów kombinacyjnych Porty wejściowe input, dwukierunkowe inout oraz wyjściowe output są sygnałami typu wire. Istnieje możliwość, by porty wyjściowe miały możliwość zapamiętywania stanu. Wtedy trzeba je zadeklarować jako output reg.

Oprócz wire i reg, istnieją także typy time, integer, real i wiele innych. Znajdują one zastosowanie głównie w symulacji układów. Wrócimy do nich w następnych odcinkach kursu, kiedy będziemy omawiać kwestie związane z symulacją układów cyfrowych.

Skalary i wektory

Zmienne typu reg i wire to tylko jeden bit, który może przyjmować wartość 1 lub 0 (w pewnych przypadkach także stan wysokiej impedancji Z lub stan nieokreślony X). Takie zmienne nazywane są skalarami. Kiedy zachodzi potrzeba wykonywania operacji na większej liczbie bitów jednocześnie, możemy utworzyć grupę takich zmiennych, nazywaną wektorem lub magistralą.

Zapoznajmy się z praktycznymi przykładami z listingu 2. W liście portów znajdziemy wejścia i wyjścia wielobitowe. Pierwsza cyfra w nawiasach kwadratowych oznacza number najstarszego bitu, a druga cyfra to numer najmłodszego bitu. W linii oznaczonej #1 widzimy przykład, jak przypisać jeden wybrany bit sygnału wielobitowego do sygnału 1-bitowego. W linii #2 przypisano sygnał 1-bitowy do zerowego bitu zmiennej 4-bitowej. Istnieje możliwość, by w jednym poleceniu połączyć kilka wybranych bitów. Taki przykład widzimy w linii #3. W linii #4 jest nowość – nawiasy klamrowe. Jest to operator konkatenacji czyli sklejania ze sobą różnych zmiennych w celu uzyskania jednej zmiennej o większej liczbie bitów. W przykładzie z linii #4 sklejamy fragment zmiennej C z całością zmiennej B i w rezultacie dostajemy sygnał 8-bitowy, którzy przypisujemy do Z. W linii #4 pominięto nawiasy kwadratowe przy zmiennej Z, pomimo że wpisujemy dane do zmiennej wielobitowej. Kiedy wpisujemy dane do całej zmiennej nie ma potrzeby, by wskazywać na wszystkie jej bity.

Listing 2. Przykłady operacji na skalarach i wektorach

module top(
input A, // Wejście 1-bitowe
input [3:0] B, // Wejście 4-bitowe
input [7:0] C, // Wejście 8-bitowe
output X, // Wyjście 1-bitowe
output [3:0] Y, // Wyjście 4-bitowe
output [7:0] Z // Wyjście 8-bitowe
);

assign X = B[3]; // #1
assign Y[0] = A; // #2
assign Y[3:1] = B[2:0]; // #3
assign Z = {C[7:4], B[3:0]}; // #4

endmodule

Programiści przyzwyczajeni do języka Python powinni zwrócić uwagę, że w Verilogu zapis zmienna[x:y] oznacza wybór wszystkich bitów pomiędzy x i y, włączając też y.

Notacja liczbowa

W języku Verilog stosuje się dość nietypową notację do zapisu liczb. Najpierw podajemy długość w bitach, następnie jest apostrof, potem litera określająca format liczby i na końcu właściwe dane. Wszystko stanie się jasne, kiedy przeanalizujmy przykłady pokazane na listingu 3. Mamy do wyboru jeden z czterech formatów liczbowych: b binarny, o ósemkowy, d dziesiętny i h szesnastkowy.

Listing 3. Przykłady zapisu danych liczbowych

assign A = 1’b0; // 1-bitowe binarne 0
assign B = 1’b1; // 1-bitowe binarne 1
assign C = 4’b1111; // 4-bitowe binarne 1111
// (czyli 15 dziesiętnie)
assign D = 4’hF; // 4-bitowe szesnastkowe F
// (czyli 15 dziesiętnie)
assign E = 4’d15; // 4-bitowe dziesiętne 15
assign F = 32’b00001111_11110000_11001100_10101010;
// Długa liczba
assign G = 1; // 32-bitowe dziesiętne 1
assign H = 1’bZ; // Stan wysokiej impedancji
assign I = 1’bX; // Stan nieokreślony
assign J = 4’b01XZ;

W przypadku zmiennych o długości 1 bitu (przykład A i B) format nie ma praktycznie znaczenia i może być dowolny.

Przykłady C, D i E to różne sposoby przypisania danych 4-bitowych, różniących się formatem, choć zapisywana jest ta sama liczba.

Przykład F pokazuje, że kiedy stosujemy zapis binarny pomocne może być dodanie separatora _ aby zwiększyć czytelność długiego szeregu zer i jedynek.

Przykład G wymaga szerszego komentarza. Na pierwszy rzut oka wydaje się on być taką samą operacją, jaka jest w przykładzie B czyli przypisanie jedynki do zmiennej. Zmienna liczbowa podana normalnie bez liczby bitów i formatu traktowana jest jako typ integer, który ma 32 bity i jest liczbą ze znakiem. Operacja w przykładzie G jest akceptowalna i zostanie wykonana prawidłowo, ale syntezator zgłosi warning, że musi wykonać rzutowanie.

Przykład H to przypisanie stanu wysokiej impedancji. Ma zastosowanie dla sygnałów inout wyprowadzonych na piny układu FPGA.

Przykład I będzie działać tylko w symulatorze i jest niesyntezowalny. Stan nieokreślony zobaczymy w symulatorze, kiedy nie zainicjujemy jakiejś zmiennej. Przydaje się także w instrukcjach sterujących if oraz case, aby wskazać bity, których stan jest dla nas nieistotny.

Układy sekwencyjne

Układy sekwencyjne mają jakiś rodzaj pamięci. Na ogół jest to przerzutnik D taktowany globalnym sygnałem zegarowym. Opcjonalnie może mieć wejście asynchronicznie resetujące i ustawiające. Przeanalizujmy listing 4. Jest to prosty licznik 4-bitowy liczący w górę z możliwością asynchronicznego zerowania. Deklaracja portów jest zgodna z tym, co już wcześniej poznaliśmy podczas kursu. Nowością jest instrukcja always, rozpoczynająca blok proceduralny, w którym opisany jest sposób działania licznika.

Listing 4. Kod prostego licznika 4-bitowego liczącego w górę z możliwością asynchronicznego zerowania

module Counter(
input Clock, // Wejście zegarowe
input Reset, // Wejście zerujące
output reg [3:0] Out // 4 przerzutniki D tworzące licznik
);

// Opis behawioralny licznika
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Out <= 4’d0; // #1
else
Out <= Out + 1’b1; // #2
end

endmodule

Blok always @ wykonuje się, kiedy zostanie spełniony jeden z warunków opisanych w nawiasach. Jest to tzw. lista wrażliwości (sensitivity list). Nasz licznik ma reagować na zbocze rosnące sygnału zegarowego (zmianę z 0 na 1), więc etykieta Clock poprzedzona jest instrukcją posedge. Natomiast sygnał resetujący ma działać w taki sposób, że kiedy ustawiony jest w stanie 1 to licznik ma działać normalnie, a kiedy przejdzie w stan 0, wówczas licznik ma się wyzerować i ma pozostawać wyzerowany tak długo, aż reset znów przyjmie stan 1. Wykrywanie zbocza opadającego sygnału zapewnia instrukcja negedge. W naszym przykładzie sygnały w liście wrażliwości rozdzielone są przecinkiem, ale można zamiast niego stosować operator or.

Następnie mamy instrukcję warunkową if-else, która działa dokładnie tak samo jak w C++. Warunkiem sprawdzanym w instrukcji if jest zanegowany sygnał resetujący. Negację zapewnia operator ! – tak samo jak w C++ – czyli kiedy sygnał Reset ma stan 0, wówczas warunek jest prawdziwy i wykonuje się linia #1, a w przeciwnym wypadku linia #2.

W liniach #1 i #2 widzimy jak można przypisać nową wartość do zmiennej typu reg. W odróżnieniu od zmiennych wire, zmienna reg przechowuje jakieś dane. W przypadku reg nie stosujemy instrukcji assign. Dane do zmiennych reg można zapisywać poprzez przypisanie blokujące = i przypisanie nieblokujące <=. W układach sekwencyjnych należy stosować <=, a w kombinacyjnych =. Jest to dobra praktyka.

W linii #1 zerujemy licznik, a w linii #2 zwiększamy jego wartość o 1. Licznik jest 4-bitowy, a więc liczy od 0 do 15. Kiedy przekroczy swoją maksymalną wartość, wtedy wyzeruje się i będzie liczył od początku.

W przeciwieństwie do C++, w języku Verilog nie ma operatora inkrementacji ++ ani dekrementacji -- (niestety!). Nie ma także operatorów += i -=. Kiedy chcemy zwiększyć lub zmniejszyć zmienną, niestety musimy posłużyć się taką konstrukcją jaką zastosowano w linii #2. Przydatne operatory inkrementacji i dekrementacji zostały dodane dopiero w standardzie SystemVerilog.

Jeżeli po instrukcjach takich jak if, else, always występuje więcej niż jedno polecenie, wówczas musimy te polecenia objąć słowami begin i end, które są odpowiednikiem nawiasów klamrowych z C++.

Kiedy przetestujemy nasz licznik w symulatorze, uzyskamy przebiegi takie, jak zaprezentowano na rysunku 1.

Rysunek 1. Symulacja licznika, którego działanie definiuje kod z listingu 4

Układy kombinacyjne

Układ kombinacyjny to taki, którego stan wyjść zależy tylko od obecnego stanu wejść. Oznacza to, że nie ma żadnej pamięci. Jego działanie jest teoretycznie natychmiastowe, ale w rzeczywistości jest niewielkie opóźnienie pomiędzy zmianą stanu wejść, a ustaleniem się stanu wyjść, nazywane czasem propagacji. Przykładem układu kombinacyjnego jest multiplekser. Zaprezentujemy trzy sposoby realizacji multipleksera, który ma cztery wejścia danych, dwa wejścia adresowe i jedno wyjście.

Przeanalizujmy teraz listing 5. Pierwszy sposób zawiera instrukcję assign oraz operator warunkowy ?:, który działa dokładnie tak samo jak w C++. Ma on postać warunek_logiczny ? wartość_jeżeli_prawda : wartość_jeżeli_fałsz. W ten sposób cała logika sprowadza się do jednej instrukcji assign, która przypisuje do wyjścia Out jedno z czterech wejść DataX. W pierwszej kolejności sprawdzane jest czy Address jest równy 2’d0. Jeśli tak, to zmienna Out (która jest typu wire) łączona jest z Data0 i na tym koniec. Jeżeli nie, wówczas sprawdzany jest kolejny warunek i tak dalej. Cała logika de facto ogranicza się do pojedynczej instrukcji, która tylko dla zachowania czytelności kodu została zapisana w czterech linijkach.

Listing 5. Multiplekser z użyciem operatora warunkowego ?:

module Mux1(
input Data3, Data2, Data1, Data0,
input [1:0] Address,
output Out
);

// assign zmienna = warunek ? wartość_jeśli_prawda : wartość_
// jeśli_fałsz

assign Out = (Address == 2’d0) ? Data0 : // tu dwukropek
(Address == 2’d1) ? Data1 : // tu dwukropek
(Address == 2’d2) ? Data2 : // tu dwukropek
Data3; // a tutaj
// średnik!

endmodule

Na listingu 6 zostało pokazane prostsze rozwiązanie, zawierające operator selekcji. Wejście Data zostało zrealizowane jako wektor 4-bitowy, a nie cztery niezależne sygnały. Dzięki temu możemy z niego wybrać jeden z sygnałów od 0 do 3 umieszczając go w nawiasach kwadratowych. Numer wybieranego sygnału może być podany poprzez zmienną. Działa to podobnie jak odczytanie zmiennej z tablicy w C++.

Listing 6. Multiplekser z zastosowaniem selekcji

module Mux2(
input [3:0] Data,
input [1:0] Address,
output Out
);

assign Out = Data[Address];

endmodule

Ostatni przykład z listingu 7 pokazuje, w jaki sposób można zastosować blok always w układzie kombinacyjnym. Na początku zwróć uwagę na to, że Out jest zadeklarowane jako output reg, jednak mimo to, nie zostanie zsyntezowany żaden przerzutnik! Układ kombinacyjny zależy od stanu wejść, dlatego w liście czułości bloku always są jedynie nazwy sygnałów, na jakie blok ma reagować, ale nie ma żadnych instrukcji posedge ani negedge. Układ ma reagować natychmiast na zmianę sygnału wejściowego Data i Address. Tak się składa, że w bloku always nie ma żadnych innych sygnałów wejściowych. W tej sytuacji w liście czułości można wstawić gwiazdkę * która oznacza: reaguj na wszystko. Przykład ten demonstruje użycie instrukcji case, która działa podobnie do switch-case z C++.

Listing 7. Multiplekser w postaci bloku always

module Mux3(
input [3:0] Data,
input [1:0] Address,
output reg Out // Zwróć uwagę na reg
);

always @(Address, Data) begin // Można też always @(*)
case(Address)
2’b00: Out = Data[0];
2’b01: Out = Data[1];
2’b10: Out = Data[2];
2’b11: Out = Data[3];
default:Out = 1’b0; // W tym przypadku
// niepotrzebne
endcase
end

endmodule

Różnica jest taka, że nie trzeba pisać break na końcu każdej możliwości. Podobnie jak w przypadku if-else, jeżeli ma zostać wykonana więcej niż jedna instrukcja, należy je objąć słowami begin-end. W tym przypadku wpisujemy tylko wartość do jednej zmiennej, więc begin-end pominięto.

W instrukcji case można łatwo wpaść w pułapkę i stworzyć układ sekwencyjny zamiast kombinacyjnego. Stanie się to wtedy, kiedy instrukcja case nie będzie obejmowała wszystkich możliwych kombinacji sygnału Address. Wówczas układ będzie musiał pamiętać swój ostatni stan na wypadek wystąpienia przypadku nieokreślonego i z tego powodu zostanie zsyntezowany przerzutnik. Aby uniknąć takiej sytuacji należy wypisać wszystkie możliwe kombinacje lub zastosować instrukcję default, która obejmuje wszystkie przypadki, jakie nie zostały opisane.

Podsumowanie

Wystarczy teorii na dziś! W tym odcinku zaprezentowałem absolutnie elementarne informacje o Verilogu. Zachęcam do dalszego zgłębiania wiedzy. Polecam strony chipverify.com i www.fpga4fun.com, a w kolejnym odcinku wykonamy coś praktycznego.

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
styczeń 2023

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