Pamięć można pokazać jako tablicę stałych lub zmiennych, w której wszystkie dane mają swój unikalny adres i każdy wpis ma tę samą liczbę bitów. W FPGA najczęściej stosujemy pamięci równoległe. To znaczy, że w jednym takcie zegarowym dokonujemy odczytu lub zapisu całej danej z pamięci, wskazanej przez wejście adresowe. Dla kontrastu, w przypadku pamięci szeregowych dane odczytujemy lub zapisujemy do pamięci bit po bicie.
Zwykle mamy do czynienia z pamięciami, gdzie dane mają długość 8, 16 lub 32 bitów. Dane wchodzą i wychodzą z pamięci w sposób równoległy, zatem wejście i wyjście danych musi mieć tyle bitów, ile ma pojedynczy wpis. Najczęściej w równoległych pamięciach RAM, Flash czy EPROM, dostępnych w formie samodzielnych układów scalonych (np. 62256, 39Fxxx, 27Cxxx), wejście i wyjście realizowane jest w formie dwukierunkowego portu wyposażonego w bufor trójstanowy. Takie rozwiązanie ma na celu zmniejszenie liczby wyprowadzeń układu scalonego. Natomiast wewnątrz FPGA nie musimy przejmować się takimi ograniczeniami, więc stosuje się osobny port dla wejścia danych i osobny na wyjście.
Wejście adresowe wskazuje nam, którą daną chcemy odczytać lub zapisać. Liczba bitów tego wejścia jest ściśle powiązana z pojemnością pamięci. Mając N-bitowe wejście adresowe możemy zaadresować 2N danych (obojętnie ile bitów mają dane), czyli przykładowo 8-bitowa magistrala adresowa może obsłużyć maksymalnie 256 danych.
Pojemność pamięci to iloczyn liczby bitów danych oraz liczby wszystkich danych w pamięci. Przykładowo, pamięć obsługująca 1024 danych 8-bitowych ma pojemność 8 kilobitów. Pamięć 512 danych 16-bitowych również ma pojemność 8 kilobitów, ale to przecież nie jest taka sama pamięć! Podawanie pojemności pamięci bez informacji o jej organizacji stwarza możliwość nieporozumień. Z tego powodu dobrze jest podawać pojemność pamięci w formie 1024×8 lub 512×16.
W FPGA mamy dwa typy pamięci pod względem rodzaju używanych zasobów. Są to pamięć rozproszona (distributed memory) i blokowa (block memory). Pamięć rozproszona powstaje z uniwersalnych zasobów logicznych. Dzięki temu pamięć może mieć najróżniejszą architekturę oraz nietypowe funkcjonalności. Jednak jej wadą jest to, że pamięć rozproszona jest bardzo zasobochłonna i może się okazać, że nawet mała i nieskomplikowana pamięć rozproszona wymaga zastosowania dużego i drogiego układu FPGA.
W praktyce pamięć rozproszona jest stosowana tylko do prostych tablic lub bardzo nietypowych zastosowań. W naszym kursie już raz spotkaliśmy się z pamięcią rozproszoną, choć nie używałem wtedy takiej nazwy. Mowa o module Decoder7seg, którego używaliśmy do sterowania 7-segmentowym wyświetlaczem LED. Moduł ten zawierał tablicę (co prawda opisaną przy pomocy instrukcji case) z szesnastoma 7-bitowymi stałymi. Każdej 4-bitowej liczbie binarnej był przypisany 7-bitowy kod, sterujący segmentami wyświetlacza. W gruncie rzeczy jest to asynchroniczna pamięć ROM. Wejście liczby do pokazania na wyświetlaczu można by nazwać 4-bitowym wejściem adresowym, a wyjście segmentów byłoby 7-bitowym wyjściem danych z pamięci.
Pamięć jest peryferium na tyle często stosowanym, że większość układów FPGA ma w swojej strukturze umieszczone gotowe bloki pamięci. Możemy je konfigurować na wiele sposobów oraz łączyć, aby uzyskać większe bloki pamięci. W MachXO2 nazywają się EBR, czyli Embedded Block RAM, choć te bloki mogą funkcjonować także jako ROM. Układy MachXO2 mają także osobny blok pamięci Flash, ale nie będziemy omawiać go w tej części kursu.
Pamięć EBR
Pamięć EBR została dokładnie omówiona w rozdziale 2.5 dokumentacji MachXO2 Family Datasheet [2], a także w Memory Usage Guide for MachXO2 Devices [3]. Blok EBR może pracować w jednym z pięciu możliwych trybów. Ich symbole wraz z wejściami i wyjściami pokazano na rysunku 1.
- Single Port RAM – Jest to najprostsza i najczęściej stosowana konfiguracja. Pamięć ma 13-bitowe wejście adresowe AD, 9-bitowe wejście danych DI, 9-bitowe wyjście danych DO, wejście zegarowe oraz kilka sygnałów sterujących.
- Pseudo Dual Port RAM – Pamięć ma dwie osobne magistrale adresowe oznaczone jako ADW (do zapisu) oraz ADR (do odczytu). Celem tego jest możliwość rozdzielenia operacji zapisywania i odczytywania, a co bardzo ważne, zegar i sygnały sterujące dla odczytu i zapisu mogą być w zupełnie różnych domenach zegarowych. Najczęściej takie rozwiązanie wykorzystuje się w roli bufora, gdzie jeden moduł zapisuje pamięć jakimiś danymi, a inny moduł te dane odczytuje. Dzięki rozdzieleniu portów, moduł zapisujący i odczytujący mogą mieć swobodny dostęp do pamięci w dowolnym momencie, bez konieczności stosowania jakichś systemów kontroli dostępu do pamięci.
- True Dual Port RAM – Jest to rozwinięcie powyższej pamięci w taki sposób, aby oba porty miały możliwość zapisu i odczytu po obu stronach pamięci. Porty oznaczane są literami A i B.
- FIFO – Jest to konfiguracja podobna do Pseudo Dual Port RAM, ale nie zawiera wejść adresowych. Pamięć tego typu służy do buforowania danych. Z jednej strony są zapisywane przez jakiś moduł, a z drugiej są odczytywane przez inny moduł. Idea polega na tym, że dane odczytywane są w takiej kolejności, w jakiej zostały zapisane (First In First Out).
- ROM – Działa tak samo jak Single Port RAM, ale nie ma możliwości zapisywania danych w trakcie pracy. Dane muszą być zapisane w osobnym pliku, który syntezuje się równolegle z innymi modułami w języku Verilog.
Pamięć EBR ma sumarycznie 9216 bitów, które można zorganizować na wiele sposobów, w zależności od wybranego trybu pracy. Ta „dziwna” liczba wynika z możliwości zapisywania danych 9-bitowych, których można zapisać 1024 sztuki. Jednak najczęściej operujemy danymi 8-bitowymi, czyli bajtami. W takim przypadku dziewiąty bit danych pozostaje nieużywany i w pojedynczym bloku EBR możemy zgromadzić 1024 bajty. Wszystkie możliwe konfiguracje pokazano na rysunku 2.
Istnieje jedna zasadnicza różnica między klasycznymi pamięciami RAM, a pamięciami EBR w FPGA. Klasyczne pamięci nie mają wejścia zegarowego. W celu odczytania danych wystarczyło ustawić żądany adres na wejściu adresowym, a dane na wyjściu pojawiały się po upływie czasu propagacji pamięci. W FPGA musimy dodatkowo poczekać na zbocze rosnące sygnału zegarowego i dopiero wtedy możemy odczytywać dane na wyjściu.
Moduł ROM
Zanim poznamy, jak utworzyć moduł obsługujący pamięć, musimy nauczyć się tworzyć obiekty pamięciowe w języku Verilog. Zobaczmy listing 1, w którym pokazano przykłady deklaracji różnych elementów przechowujących dane. W linii 1 widzimy coś, co już dobrze znamy. Jest to 1-bitowy rejestr, mogący przechowywać stan wysoki lub niski, który zostanie zaimplementowany w sprzęcie jako przerzutnik D. Linia 2 również niczym nowym nie zaskakuje. Jest to rejestr 8-bitowy, gdzie bity ponumerowane są od 7 do 0. Taka konstrukcja spowoduje syntezowanie ośmiu przerzutników D. Deklaracja w linii 3, choć podobna do linii 2, tworzy pamięć złożoną z 16 danych 1-bitowych, które oznaczone są indeksami od 0 do 15. Pamięci, w których dane są 1-bitowe w gruncie rzeczy są dość podobne do rejestrów wielobitowych.
reg Memory; // 1
reg [7:0] Memory; // 2
reg Memory [0:15]; // 3
reg [7:0] Memory [0:15]; // 4
reg [7:0] Memory [15:0]; // 5
reg [7:0] Memory [16]; // 6
reg [7:0] Memory [0:15][0:255]; // 7
Najczęściej mamy do czynienia z pamięciami, które przechowują dane 8-bitowe. Przykład deklaracji pamięci, która przechowuje 16 danych 8-bitowych widzimy w linii 4. Dane w tej pamięci ponumerowane są od 0 do 15. Linia 5 jest bardzo podobna do linii 4 z tą różnicą, że dane ponumerowane są w odwrotnej kolejności. Nie ma to praktycznego znaczenia. Większość deweloperów stosuje sposób pokazany w linii 4, ale niektórzy wolą metodę z linii 5. Jest to kwestia indywidualnych upodobań.
W linii 6 widzimy deklarację takiej samej pamięci, co w linii 4 i 5 jednak zamiast numerować elementy „od do” podajemy po prostu liczbę danych w pamięci. Jednak należy podkreślić, że taki zapis został wprowadzony dopiero ze standardem SystemVerilog. Niektóre syntezatory i symulatory języka Verilog również wspierają taki zapis, ale nie wszystkie.
Następnie w linii 7 widzimy przykład utworzenia pamięci dwuwymiarowej, składającej się z tablicy 16 rzędów i 256 kolumn danych 8-bitowych. Dostęp do tak utworzonych pamięci jest podobny do odczytywania danych w C i C++. W nawiasach kwadratowych podajemy, który elementy pamięci nas interesuje.
Wyrażenie Memory[n] zwraca n-ty element pamięci, gdzie n może być zarówno stałą jak i zmienną.
Nadszedł czas, aby pokazać jak w języku Verilog opisać moduł pamięci tylko do odczytu. Przeanalizujmy kod z listingu 2.
// Plik rom.v
`default_nettype none
module ROM #(
parameter ADDRESS_WIDTH = 16, // 1
parameter DATA_WIDTH = 8, // 2
parameter MEMORY_DEPTH = 2**ADDRESS_WIDTH, // 3
parameter MEMORY_FILE = “data.mem” // 4
)(
input wire Clock,
input wire Reset,
input wire ReadEnable_i, // 5
input wire [ADDRESS_WIDTH-1:0] Address_i, // 6
output reg [ DATA_WIDTH-1:0] Data_o // 7
);
/ Deklaracja pamięci
/* synthesis syn_romstyle = “EBR” */
reg [DATA_WIDTH-1:0] Memory [0:MEMORY_DEPTH-1]; // 8
// Kontrola czy pamięć mieści się w przestrzeni adresowej
initial begin // 9
if(MEMORY_DEPTH > 2**ADDRESS_WIDTH)
$fatal(0, “Required memory depth is larger than address space”);
end
// Inicjalizacja pamięci plikiem
initial begin // 10
$readmemh(MEMORY_FILE, Memory);
end
// Inicjalizacja pamięci poprzez przypisania
initial begin // 11
Memory[0] = 8’h00;
Memory[1] = 8’h35;
Memory[2] = 8’h7A;
Memory[3] = 8’hFF;
// ...i tak dalej
end
// Inicjalizacja pamięci poprzez przypisania w pętli
integer i;
initial begin // 12
for(i=0; i<MEMORY_DEPTH; i=i+1) begin
Memory[i] = 0;
end
end
// Logika pamięci
always @(posedge Clock, negedge Reset) begin
if(!Reset) // 13
Data_o <= 0;
else if(ReadEnable_i) // 14
Data_o <= Memory[Address_i];
end
endmodule
`default_nettype wire
Zaprezentowany kod modułu pamięci jest napisany w taki sposób, aby był jak najbardziej uniwersalny. Moduł jest konfigurowalny przy pomocy trzech lub czterech parametrów w liniach od 1 do 4. Są to:
- ADDRESS_WIDTH – Szerokość wejścia adresowego; wynika z niej maksymalna możliwa pojemność pamięci;
- DATA_WIDTH – Szerokość danych zapisanych w pamięci, najczęściej jest to 8, 16 lub 32 bity;
- MEMORY_DEPTH – Liczba danych, jaką pamięć ma przechowywać. Jest to parametr opcjonalny i ma zastosowanie tylko wtedy, kiedy pamięć ma mieć mniejszą pojemność niż to wynika z przestrzeni adresowej. Jeżeli parametr ten zostanie pominięty podczas tworzenia instancji modułu, to zostanie wykorzystana jego domyślna wartość, obliczana w linii 3 na podstawie parametru ADDRESS_WIDTH. Przykładowo, jeżeli wejście adresowe ma 16 bitów, to przestrzeń adresowa pozwala zaadresować 65536 danych, ale pamięć można skonfigurować tak, by jej pojemność była mniejsza;
- MEMORY_FILE – Nazwa pliku z danymi służącymi do inicjalizacji pamięci. Nazwę pliku należy objąć w cudzysłów.
W liniach 5, 6 i mamy porty modułu. Wejście zegarowe i resetujące działa tak samo jak we wszystkich dotychczas omawianych modułach, więc nie będę ich opisywał po raz kolejny.
- ReadEnable_i – Wejście uruchamiające odczyt z pamięci. Jeżeli jest w stanie niskim to pamięć ignoruje wejście adresowe i zegarowe. Ta funkcjonalność jest zaimplementowana na poziomie bloku EBR. Usunięcie tego wyjścia nie spowoduje żadnej oszczędności w zasobach FPGA. Jeżeli nie interesuje nas taka funkcjonalność, można do tego wejścia na stałe podać 1’b1;
- Address_i – Wejście na którym podajemy adres danej, którą chcemy odczytać w następnym takcie zegarowym;
- Data_o – Wyjście danych z pamięci.
Następnie w linii 8 deklarujemy pamięć, którą chcemy powołać do życia. Szerokość danych oraz ich liczbę podajemy przy pomocy zdefiniowanych wcześniej parametrów. Niespodzianką jest komentarz /* synthesis syn_romstyle = "EBR" */. Jest to dyrektywa syntezatora Lattice Synthesis Engine informująca, w jaki sposób pamięć ma zostać zaimplementowana w strukturze FPGA. Możliwe są następujące opcje:
- auto – Syntezator podejmie decyzję automatycznie na podstawie rozmiaru pamięci, dostępnych zasobów w FPGA, a także wybranej strategii syntezy;
- logic – Pamięć zostanie zaimplementowana z wykorzystaniem uniwersalnych zasobów logicznych;
- EBR – Pamięć zostanie zaimplementowana z wykorzystaniem bloków pamięci EBR.
Brak takiego komentarza jest równoznaczny z wybraniem opcji auto. Więcej na ten temat znajdziesz w instrukcji Lattice Synthesis Engine for Diamond User Guide [link 4].
W dalszej części mamy kilka bloków initial. Są to bloki, które wykonują się jednorazowo na początku syntezy lub symulacji i definiują warunki startowe układu po uruchomieniu aplikacji. Pierwszy z nich (linia 9) stanowi proste zabezpieczenie na wypadek błędnej konfiguracji pamięci, kiedy użytkownik chce utworzyć pamięć o większym rozmiarze, niż pozwala na to przestrzeń adresowa. Maksymalna liczba elementów pamięci to 2ADDRESS_WIDTH, zatem, żądany rozmiar pamięci, określony parametrem MEMORY_DEPTH nie może być większy. Jeżeli taka sytuacja wystąpi, to wtedy wykonujemy instrukcję $fatal, która wyświetla stosowny komunikat na konsoli i natychmiast przerywa dalsze operacje.
Istnieje kilka sposobów, aby zainicjalizować pamięć. W przypadku pamięci ROM wręcz musimy ją zainicjalizować jakimiś konkretnymi danymi, które będą wykorzystywane w naszej aplikacji. Najpopularniejszy i najbardziej uniwersalny sposób przedstawiono w linii 10. W bloku initial wykorzystujemy instrukcję $readmemh, która otwiera plik podany w pierwszym argumencie i ładuje jego zawartość do pamięci, wskazanej w argumencie drugim. Plik otwierany przy pomocy tego polecenia powinien zawierać dane zapisane w notacji szesnastkowej. Dane mogą być oddzielone spacjami, enterami i tabulatorami, a ponadto w pliku mogą znajdować się także komentarze. Przykład pliku zdatnego do załadowania poprzez $readmemh przedstawiono na listingu 3. Innym sposobem jest wykorzystanie $readmemb, która różni się tym, że dane zapisane są w formie binarnej.
// Plik data.mem
0F 1E 2D 3C 4B // Komentarz a
5A 69 78 87 96 // Komentarz b
A5 B4 C3 D2 E1 // Komentarz c
F0
Taki sposób inicjalizacji pamięci jest bardzo praktyczny, ponieważ zawartość pamięci oddzielamy od modułu pamięci. Ponadto, nazwę pliku z zawartością pamięci możemy przekazać w instancji przy pomocy parametru, co zobaczymy za chwilę analizując testbench. Często zdarza się tak, że plik z zawartością pamięci jest generowany przez jakiś inny program, skrypt w Pythonie lub chociażby arkusz w Excelu – oddzielenie plików z uniwersalnym modułem pamięci i jej zawartością jest po prostu wygodne. W ten sposób można utworzyć wiele instancji tego samego modułu pamięci, a każdą z nich można zainicjalizować innym plikiem z danymi.
W linii 11 przedstawiono inny sposób, najprostszy z możliwych. W bloku initial wszystkim danym w pamięci przypisujemy jakieś wartości ręcznie, wpisując je przy pomocy operatora =, tak jak byśmy je wpisywali do normalnych zmiennych. Taki sposób może być przydatny tylko, kiedy pamięć jest bardzo mała.
Ponadto, tak zainicjalizowana pamięć przestaje być uniwersalna, bo każda instancja tego modułu posiada taką samą zawartość pamięci.
Sposób przedstawiony w linii 12 ma większe zastosowanie dla pamięci RAM niż ROM. Wykorzystuje on pętlę for, która każdemu elementowi pamięci przypisuje wartość początkową. Najczęściej przypisuje się 0 lub FF, czyli wszystkie bity ustawiane są w stan niski lub wysoki. Można także pokusić się o zastosowanie jakiegoś wzoru, który przypisze elementom tablicy wartość na podstawie ich adresu lub jeszcze jakichś innych zmiennych.
Z tych trzech wymienionych sposobów wybieramy tylko jeden. W dalszej części kursu i we wszystkich kolejnych odcinkach moduł pamięci ROM będzie wykorzystywał inicjalizację poprzez odczytanie pliku z danymi, natomiast moduły pamięci RAM będą inicjalizowane zerami.
Przejdźmy wreszcie do jedynego bloku always w tym module, gdzie zawarta jest właściwa logika pamięci ROM. Blok ten reaguje na zbocze rosnące sygnału zegarowego i zbocze opadające sygnału resetującego. Jeżeli Reset jest w stanie niskim, to w linii 13 resetujemy rejestr wyjściowy Data_o i tylko tyle. W żaden sposób nie resetujemy zawartości pamięci. Jeżeli nie, to sprawdzamy czy wejście ReadEnable_i jest w stanie wysokim (linia 14) i jeżeli tak, to do rejestru wyjściowego Data_o wpisujemy wpis z pamięci, wskazywany przez aktualny stan wejścia Address_i.
I to cała logika pamięci ROM! Całkiem proste, prawda?
Testbench pamięci ROM
Opracujemy teraz testbench, aby sprawdzić w symulatorze, jak działa moduł pamięci ROM. Jego kod widzimy na listingu 4. Zadanie testbencha będzie bardzo proste – ma on jedynie odczytać wszystkie dane z pamięci.
// Plik rom_tb.v
`timescale 1ns/1ns
`default_nettype none
module ROM_tb();
parameter CLOCK_HZ = 10_000_000;
parameter real HALF_PERIOD_NS = 1_000_000_000.0 / (2 * CLOCK_HZ);
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#HALF_PERIOD_NS;
Clock = !Clock;
end
// Zmienne
reg Reset = 1’b0; // 1
reg ReadEnable = 1’b0;
reg [3:0] Address;
wire [7:0] Data;
// Instancja testowanego modułu
ROM #( // 2
.ADDRESS_WIDTH(4),
.DATA_WIDTH(8),
.MEMORY_DEPTH(16), // 3
.MEMORY_FILE(“data.mem”)
) DUT(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadEnable), // 4
.Address_i(Address),
.Data_o(Data)
);
// Eksport wyników symulacji
initial begin
$dumpfile(“rom.vcd”);
$dumpvars(0, ROM_tb);
end
// Sekwencja testowa
integer i; // 5
initial begin
$timeformat(-6, 3, “us”, 12);
$display(“===== START =====”);
$display(“MEMORY_DEPTH: %0d”, DUT.MEMORY_DEPTH); // 6
// Wyświetl zawartość całej pamięci na konsoli
for(i=0; i<DUT.MEMORY_DEPTH; i=i+1) begin // 7
$display(“Memory[%d] = %h”, i, DUT.Memory[i]);
end
@(posedge Clock);
Reset <= 1’b1; // 8
@(posedge Clock); // 9
// Odczytaj wszystkie dane z pamięci
ReadEnable <= 1’b1; // 10
for(i=0; i<DUT.MEMORY_DEPTH; i=i+1) begin // 11
Address <= i;
@(posedge Clock);
end
ReadEnable <= 1’b0; // 12
repeat(2) @(posedge Clock);
$display(“===== END =====”);
$finish;
end
endmodule
`default_nettype wire
Standardowo rozpoczynamy od utworzenia generatora sygnału zegarowego, po czym deklarujemy zmienne (linia 1). W linii 2 tworzymy instancję testowanego modułu pamięci. Konfigurujemy go w taki sposób, aby przechowywał dane 8-bitowe, a liczbę bitów wejścia adresowego ustawiamy na 4 bity. Oznacza to, że w przestrzeni adresowej będziemy mogli pomieścić tylko 16 danych i będziemy wykorzystywać całą pamięć. W takiej sytuacji można by pominąć linię 3. Podczas symulacji będziemy zmieniać stan wejścia ReadEnable_i, ale to wejście można by ustawić na stałe w stan wysoki (linia 4).
Zanim rozpoczniemy sekwencję testową, musimy jeszcze utworzyć zmienną i, która jest iteratorem pętli for (linia 5). Niestety w Verilogu nie ma możliwości, by utworzyć iterator pętli w pierwszym argumencie pętli for, tak jak w C++, więc trzeba to zrobić przed rozpoczęciem bloku always lub initial.
Sekwencję testową rozpoczynamy od wyświetlenia kilku komunikatów przy pomocy instrukcji $display. Między innymi wyświetlamy liczbę elementów pamięci określoną parametrem MEMORY_DEPTH (linia 6).
W linii 7 tworzymy pętlę for, której zadaniem będzie wyświetlenie na konsoli wszystkich danych z pamięci. Składnia pętli for jest taka sama jak w języku C za wyjątkiem tego, że iterator pętli inkrementujemy instrukcją i=i+1, a nie i++, ponieważ operator ++ w Verilogu nie istnieje (a szkoda, bo by się bardzo przydał). Elementy pamięci wyświetlamy przy pomocy funkcji $display. Warto podkreślić, że cała ta operacja jest wykonywana przez testbench w zerowym czasie, a nie przez testowany moduł pamięci. Dopiero w linii 8 ustawiany stan zmiennej Reset na wysoki, co uruchamia testowany moduł i rozpoczyna właściwą symulację.
Czekamy na kolejne zbocze rosnące sygnału zegarowego (linia 9) po czym ustawiamy ReadEnable w stan wysoki, aby moduł pamięci reagował na sygnał zegarowy i wejście adresowe (linia 10).
W linii 11 mamy pętlę iterującą po wszystkich elementach pamięci, podobnej do pętli, którą widzieliśmy przed chwilą. Jest jednak zasadnicza różnica między tymi dwoma pętlami. Ta druga modyfikuje zmienną Address, która połączona jest z wejściem adresowym pamięci. Ponadto, po każdej zmianie, czekamy na zbocze rosnące sygnału zegarowego. W ten sposób nie tylko odczytujemy pamięć jako taką, ale badamy reakcje testowanego modułu na zmiany sygnałów wejściowych, co będziemy obserwować na wykresie (będzie o tym kilka akapitów dalej). Pozostaje już tylko ustawić ReadEnable w stan niski (linia 12) i poczekać dwa takty zegarowe przed zakończeniem symulacji, aby wykres był bardziej czytelny.
@echo off
iverilog -o rom.o rom.v rom_tb.v
vvp rom.o
del rom.o
Listing 5 to zawartość skryptu rom.bat, który służy do wykonania symulacji programem Icarus Verilog, który omawialiśmy w 12 odcinku kursu. Po przeprowadzeniu symulacji powinniśmy zobaczyć komunikaty, jakie pokazano na listingu 6.
VCD info: dumpfile rom.vcd opened for output.
===== START =====
MEMORY_DEPTH: 16
Memory[ 0] = 0f
Memory[ 1] = 1e
Memory[ 2] = 2d
Memory[ 3] = 3c
Memory[ 4] = 4b
Memory[ 5] = 5a
Memory[ 6] = 69
Memory[ 7] = 78
Memory[ 8] = 87
Memory[ 9] = 96
Memory[ 10] = a5
Memory[ 11] = b4
Memory[ 12] = c3
Memory[ 13] = d2
Memory[ 14] = e1
Memory[ 15] = f0
===== END =====
rom_tb.v:71: $finish called at 400 (1ns)
Musimy teraz otworzyć plik rom.vdc, który zawiera informacje o zmianach wszystkich symulowanych sygnałów. Otwórz ten plik przy pomocy GTKWave i skonfiguruj program, by otrzymać rezultat taki, jak pokazano na rysunku 3. Widzimy, że zmienna Address w każdym cyklu zegarowym zmienia swój stan, odliczając od 0 do F szesnastkowo, czyli do 15, dziesiętnie. Zwróć uwagę, że dane odpowiadające żądanemu adresowi pojawiają się dopiero w następnym takcie zegarowym. Dzieje się tak dlatego, że bloki pamięci EBR są zsynchronizowane z zegarem – w związku z tym, przed odczytaniem danych z pamięci musimy mieć na uwadze to opóźnienie. Powinny szczególnie o tym pamiętać osoby przyzwyczajone do klasycznych pamięci równoległych, które funkcjonują asynchronicznie i wyrzucają dane na wyjście od razu po zmianie wejścia adresowego (mówię tutaj także o sobie samym – przyzwyczajenie się do takiego sposobu działania pamięci było dla mnie mocno irytujące).
Pamięć RAM
Pamięć RAM jest bardzo podobna do pamięci ROM. Z tego powodu analizując kod z listingu 7 przedstawimy tylko istotne różnice. Pod względem funkcjonalnym ROM od RAM różni się tylko tym, że można do niej zapisywać dane podczas pracy.
// Plik ram.v
`default_nettype none
module RAM #(
parameter ADDRESS_WIDTH = 16,
parameter DATA_WIDTH = 8,
parameter MEMORY_DEPTH = 2**ADDRESS_WIDTH
)(
input wire Clock,
input wire Reset,
input wire ReadEnable_i,
input wire WriteEnable_i, // 1
input wire [ADDRESS_WIDTH-1:0] Address_i,
input wire [ DATA_WIDTH-1:0] Data_i, // 2
output reg [ DATA_WIDTH-1:0] Data_o
);
/ Deklaracja pamięci
/* synthesis syn_ramstyle = “block_ram” */
reg [DATA_WIDTH-1:0] Memory [0:MEMORY_DEPTH-1]; // 3
// Kontrola czy pamięć mieści się w przestrzeni adresowej
initial begin
if(MEMORY_DEPTH > 2**ADDRESS_WIDTH)
$fatal(0, “Required memory depth is larger than address space”);
end
// Inicjalizacja pamięci zerami
integer i;
initial begin // 4
for(i=0; i<2**ADDRESS_WIDTH; i=i+1) begin
Memory[i] = 0;
end
end
// Logika pamięci
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Data_o <= 0;
end else begin
if(ReadEnable_i)
Data_o <= Memory[Address_i];
if(WriteEnable_i) // 5
Memory[Address_i] <= Data_i;
end
end
endmodule
`default_nettype wire
Pierwszą zmianę widzimy już na liście portów modułu. W linii 1 pojawiło się wejście WriteEnable_i. Stan wysoki na tym wejściu będzie powodować, że pamięć zapisze pod wskazanym adresem dane, które zostały doprowadzone do wejścia Data_i (linia 2).
W linii 3 deklarujemy pamięć. Składnia języka Verilog jest w tym przypadku identyczna. Zmienia się tylko instrukcja dla syntezatora. Parametr syn_ramstyle może przyjmować następujące wartości:
- block_ram – implementacja pamięci w blokach EBR (uwaga, aby uzyskać taki sam efekt dla pamięci ROM trzeba było wpisać „EBR”);
- distributed – implementacja w postaci pamięci rozproszonej;
- registers – implementacja w przerzutnikach;
- no_rw_check – ta opcja ma zastosowanie tylko do dwuportowych pamięci RAM i pozwala wyeliminować logiką kontrolującą jednoczesny zapis do tej samej komórki pamięci z obu portów. Jeżeli nie ma ryzyka, że taka sytuacja wystąpi to można tę logikę usunąć, aby zaoszczędzić trochę zasobów. Należy tę opcję dodać po przecinku do jednej z trzech powyższych opcji.
W linii 4 mamy blok initial, w których inicjalizujemy stan początkowy pamięci. Wykorzystując pętlę for, każdemu elementowi pamięci przypisujemy wartość zerową. Możliwe jest także zainicjalizowanie pamięci RAM wartościami odczytanymi z pliku, tak jak to robiliśmy w przypadku pamięci ROM. Jednak w praktycznych rozwiązaniach najczęściej zależy nam, aby po starcie aplikacji pamięć RAM była czysta, a nie zapełniona jakimiś danymi.
Dalej mamy blok always z logiką pamięci. Jedyne, co zostało dodane to linia 5, w której sprawdzamy, czy wejście WriteEnable_i jest w stanie wysokim. Jeżeli tak, to do komórki pamięci wskazywanej przez wejście adresowe wpisywane są dane z wejścia Data_i.
Testbench pamięci RAM
Opracujmy testbench, który zapisze do wszystkich komórek pamięci losowo wybrane wartości, a następnie wszystkie je odczyta. Kod testbencha modułu pamięci RAM pokazano na listingu 8. Jest on podobny do testbencha pamięci ROM, więc omówimy tylko istotne różnice.
// Plik ram_tb.v
`timescale 1ns/1ns
`default_nettype none
module RAM_tb();
parameter CLOCK_HZ = 10_000_000;
parameter real HALF_PERIOD_NS = 1_000_000_000.0 / (2 * CLOCK_HZ);
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#HALF_PERIOD_NS;
Clock = !Clock;
end
// Zmienne
reg Reset = 1’b0;
reg ReadEnable = 1’b0;
reg WriteEnable = 1’b0; // 1
reg [3:0] Address; // 2
reg [7:0] DataIn; // 3
wire [7:0] DataOut;
integer i;
// Instancja testowanego modułu
RAM #(
.ADDRESS_WIDTH(4),
.DATA_WIDTH(8)
) DUT(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(ReadEnable),
.WriteEnable_i(WriteEnable),
.Address_i(Address),
.Data_i(DataIn),
.Data_o(DataOut)
);
// Eksport wyników symulacji
initial begin
$dumpfile(“ram.vcd”);
$dumpvars(0, RAM_tb); // 4
// Eksportuj także wszystkie dane z pamięci
for(i=0; i<DUT.MEMORY_DEPTH; i=i+1) begin // 5
$dumpvars(2, DUT.Memory[i]); // 6
end
end
// Sekwencja testowa
initial begin
$timeformat(-6, 3, “us”, 12);
$display(“===== START =====”);
$display(“MEMORY_DEPTH: %d”, DUT.MEMORY_DEPTH);
$display(“ Time Address DataIn DataOut”);
$monitor(“%t %H %H %H”,
$realtime,
Address,
DataIn,
DataOut
);
@(posedge Clock);
Reset <= 1’b1;
@(posedge Clock);
// Zapis losowych danych do pamięci
for(i=0; i<=15; i=i+1) begin // 7
WriteData(i, $urandom_range(8’h00, 8’hFF)); // 8
end
WriteEnd(); // 9
// Odczytaj wszystkie dane z pamięci
ReadEnable <= 1’b1; // 10
for(i=0; i<=15; i=i+1) begin
Address <= i;
@(posedge Clock);
end
ReadEnable <= 1’b0;
repeat(2) @(posedge Clock);
$display(“===== END =====”);
$finish;
end
// Task zapisujący dane do pamięci
task WriteData(input [3:0] Adr, input [7:0] Dat); // 11
begin
Address <= Adr;
DataIn <= Dat;
WriteEnable <= 1’b1;
@(posedge Clock);
end
endtask
// Task kończący zapis danych
task WriteEnd(); // 12
begin
Address <= 4’dX;
DataIn <= 8’dX;
WriteEnable <= 1’b0;
@(posedge Clock);
end
endtask
endmodule
`default_nettype wire
Dotychczas do eksportowania wyników symulacji stosowaliśmy taką instrukcję, jaką przedstawiono w linii 4. Funkcja $dumpvars(), której pierwszym argumentem jest 0, eksportuje wszystkie zmienne z modułu podanego w drugim argumencie oraz jego modułach podrzędnych. Niestety jednak ta funkcja nie eksportuje tablic pamięci. Aby mieć możliwość zaglądania do środka pamięci i obserwowania zmian poszczególnych danych wewnątrz pamięci, musimy wyeksportować każdą z interesujących nas komórek pamięci. Z tego powodu w linii 5 tworzymy pętlę for, która wywołuje funkcję $dumpvars() dla każdego elementu tablicy (linia 6).
Przeskoczmy teraz do linii 11. Znajduje się tam coś, czego w tym kursie jeszcze nie widzieliśmy – jest to task. W języku Verilog mamy możliwość grupowania często wykorzystywanego kodu przy pomocy funkcji i tasków, podobnie jak to się dzieje w różnych językach programowania. Taskom i funkcjom poświęcimy osobny odcinek, a w tym odcinku poznamy tylko elementarne podstawy tasków.
Największa różnica między taskiem a funkcją jest taka, że funkcja w założeniu ma służyć do obliczenia czegoś, a task ma wykonywać jakąś czynność. Funkcja pobiera co najmniej jeden argument i zwraca jakąś wartość, a co najważniejsze, musi wykonać się w zerowym czasie. Funkcja nie może zawierać żadnych opóźnień ani wywoływać jakichkolwiek innych operacji, które zawierają opóźnienia. Task służy do wykonywania jakichś zadań, które mogą być natychmiastowe, ale także mogą trwać przez jakiś czas. Może mieć jakieś wejścia, ale nie musi. Nie zwraca żadnych wartości tak, jak to robi funkcja, chociaż task może mieć wyjścia, tak jak moduły mają porty wejściowe i wyjściowe. Task może modyfikować różne zmienne wewnątrz modułu, w którym on się znajduje.
W linii 8 tworzymy task, który zapisuje bajt danych do pamięci RAM. Posiada on dwa porty wejściowe: Adr, który określa adres modyfikowanej komórki pamięci oraz Dat, czyli dane do zapisania. Ponieważ task może modyfikować zmienne modułu, dobrze jest, aby jego wejścia i wyjścia nazywały się inaczej niż inne zmienne wykorzystywane w kodzie. Deklaracja portów tasku jest bardzo podobna do portów modułu.
Jeżeli task ma zawierać więcej niż jedną instrukcję, to musimy je objąć blokiem begin… end pomimo tego, że wszystkie taski zaczynamy słowem kluczowym task i kończymy endtask. Wewnątrz tasku jedynie przepisujemy wejścia Adr i Dat do rejestrów Address oraz DataIn, utworzonych w w liniach 2 i 3, które doprowadzone są do wejść testowanej pamięci. Aby pamięć zapisała dane, musimy jeszcze tylko ustawić sygnał WriteEnable w stan wysoki (linia 1). Pozostaje już tylko poczekać na zbocze rosnące sygnału zegarowego i można opuścić task.
Wróćmy do linii 7. Rozpoczynamy w tym miejscu pętlę, która iterując po wszystkich elementach pamięci będzie przypisywać im losowo wybrane wartości. W linii 8 widzimy wywołanie tasku WriteData, który przyjmuje dwa argumenty. Pierwszym z nich jest iterator pętli i, zmieniający się od 0 do 15. Drugim jest wartość zwracana przez funkcję $urandom_range(x, y). Ta funkcja zwraca nam losowo wybraną liczbę całkowitą bez znaku, znajdującą się w zakresie od x do y.
Po dokonaniu zapisu do wszystkich szesnastu elementów pamięci, musimy pamiętać o sprowadzeniu linii WriteEnable w stan niski. Bez tego w każdym cyklu zegarowym pamięć by zapisywała dane, jakie aktualnie znajdują się na wejściu Data_i pod adres, jaki jest wskazywany przez Address_i. W tym celu utworzymy podobny task do tego, który omawialiśmy przed chwilą. W linii 12 rozpoczyna się task WriteEnd, który zmiennej WriteEnable przypisuje stan niski, a zmiennym Address oraz DataIn przypisujemy stan nieokreślony X, aby podkreślić to, że zmienne te nie mają znaczenia, kiedy WriteEnable jest w stanie niskim (Address jest używany także do odczytu pamięci, ale wtedy ReadEnable musi być w stanie wysokim).
@echo off
iverilog -o ram.o ram.v ram_tb.v
vvp ram.o
del ram.o
Testbench gotowy. Na listingu 9 pokazano skrypt BAT, który umożliwi przeprowadzenie symulacji w symulatorze Icarus Verilog. W wyniku symulacji powstaje plik ram.vcd, który otwieramy przeglądarką GTKWave. Skonfigurują jak tak, by uzyskać przebiegi widoczne na rysunku 4. Widzimy, że w pierwszej części symulacji na wejściu Address_i pojawiają się adresy kolejno od 0 do F (lub dziesiętnie: 15), a na wejściu Data_i są losowo wybrane dane (uwaga, przy ponownym uruchomieniu symulacji zostaną wylosowane dokładnie takie same liczby!). Dane te pojawiają się w pamięci dopiero po wystąpieniu zbocza rosnącego sygnału zegarowego. W drugiej części symulacji dokonujemy odczytu danych z pamięci w taki sam sposób, jak to robiliśmy w pamięci ROM.
Pamięć Pseudo Dual Port RAM
Pamięć pseudo dwuportowa to taka pamięć, gdzie dostępne są dwa porty adresowe. Jeden wskazuje adres danych, które chcemy odczytać, a drugie wskazuje adres danych do zapisu. Ponadto, operacje zapisu i odczytu mogą być taktowane zupełnie różnymi zegarami. Z tego powodu pamięć tego typu świetnie nadaje się jako bufor pomiędzy dwiema domenami zegarowymi.
Z tego powodu mamy kilka zmian na liście portów modułu. Na listingu 10 w liniach 1 i 2 widzimy dwa osobne wejścia zegarowe – jedno do operacji odczytu danych z pamięci, drugie do zapisu. Analogicznie, w liniach 3 i 4 tworzymy dwa osobne porty adresowe.
// Plik ram_pdp.v
`default_nettype none
module PseudoDualPortRAM #(
parameter ADDRESS_WIDTH = 16,
parameter DATA_WIDTH = 8,
parameter MEMORY_DEPTH = 2**ADDRESS_WIDTH
)(
input wire ReadClock, // 1
input wire WriteClock, // 2
input wire Reset,
input wire ReadEnable_i,
input wire WriteEnable_i,
input wire [ADDRESS_WIDTH-1:0] ReadAddress_i, // 3
input wire [ADDRESS_WIDTH-1:0] WriteAddress_i, // 4
input wire [ DATA_WIDTH-1:0] Data_i,
output reg [ DATA_WIDTH-1:0] Data_o
);
// Deklaracja pamięci
reg [DATA_WIDTH-1:0] Memory [0:2**ADDRESS_WIDTH-1];
// Kontrola czy pamięć mieści się w przestrzeni adresowej
initial begin
if(MEMORY_DEPTH > 2**ADDRESS_WIDTH)
$fatal(0, “Required memory depth is larger than address space”);
end
// Inicjalizacja pamięci zerami
integer i;
initial begin
for(i=0; i<2**ADDRESS_WIDTH; i=i+1) begin
Memory[i] = 0;
end
end
// Operacja odczytu
always @(posedge ReadClock, negedge Reset) begin // 5
if(!Reset)
Data_o <= 0;
else if(ReadEnable_i)
Data_o <= Memory[ReadAddress_i];
end
// Operacja zapisu
always @(posedge WriteClock) begin // 6
if(WriteEnable_i)
Memory[WriteAddress_i] <= Data_i;
end
endmodule
`default_nettype wire
W przeciwieństwie do omawianych wcześniej pamięci ROM i RAM, w przypadku pamięci dwuportowych mamy dwa aloki always. Ten, który zaczyna się z linii 5 obsługuje operację odczytu, a ten z linii 6 odpowiedzialny jest za zapis. Zwróć uwagę na listę wrażliwości instrukcji always. Mamy tam dwa różne sygnały zegarowe. Ponadto, blok odpowiedzialny za zapis nie reaguje na sygnał zegarowy. Nie ma takiego powodu – rejestr wyjściowy pamięci Data_o służy tylko do odczytywania danych i zerowany jest przez blok always związany z odczytem.
I to wszystko, jeżeli chodzi o pamięć pseudo dwuportową. Okazuje się to bardzo proste. Możemy także utworzyć pamięć prawdziwie dwuportową (true dual port RAM), który ma dwa porty zdolne do zapisu i odczytu. Z uwagi długość tego artykułu nie będziemy omawiać pamięci prawdziwie dwuportowej, ale powinieneś być w stanie opracować ją samodzielnie, łącząc w jedną całość kody pamięci RAM oraz pseudo dwuportowej pamięci RAM.
W tym odcinku kursu nie będziemy omawiać zastosowania pamięci EBR skonfigurowanej jako FIFO.
Testbench pamięci Pseudo Dual Port RAM
Testbench pamięci dwuportowej, jak można się spodziewać, jednym portem będzie zapisywał losowe dane, a drugim będzie je odczytywać w tym samym czasie. Kod testbencha przedstawiono na listingu 11.
// Plik ram_pdp_tb.v
`timescale 1ns/1ns
`default_nettype none
module PseudoDualPortRAM_tb();
parameter CLOCK_READ_HZ = 10_000_000; // 1
parameter real HALF_PERIOD_READ_NS = 1_000_000_000.0 / (2 * CLOCK_READ_HZ);
parameter CLOCK_WRITE_HZ = 15_000_000; // 2
parameter real HALF_PERIOD_WRITE_NS = 1_000_000_000.0 / (2 * CLOCK_WRITE_HZ);
// Generator sygnału zegarowego dla odczytu
reg ReadClock = 1’b1; // 3
always begin
#HALF_PERIOD_READ_NS;
ReadClock = !ReadClock;
end
// Generator sygnału zegarowego dla zapisu
reg WriteClock = 1’b1; // 4
always begin
#HALF_PERIOD_WRITE_NS;
WriteClock = !WriteClock;
end
// Zmienne
reg Reset = 1’b0;
reg ReadEnable = 1’b0;
reg WriteEnable = 1’b0;
reg [3:0] ReadAddress; // 5
reg [3:0] WriteAddress; // 6
reg [7:0] DataIn;
wire [7:0] DataOut;
integer r; // 7
integer w; // 8
// Instancja testowanego modułu
PseudoDualPortRAM #(
.ADDRESS_WIDTH(4),
.DATA_WIDTH(8)
) DUT(
.ReadClock(ReadClock),
.WriteClock(WriteClock),
.Reset(Reset),
.ReadEnable_i(ReadEnable),
.WriteEnable_i(WriteEnable),
.ReadAddress_i(ReadAddress),
.WriteAddress_i(WriteAddress),
.Data_i(DataIn),
.Data_o(DataOut)
);
// Eksport wyników symulacji
initial begin
$dumpfile(“ram_pdp.vcd”);
$dumpvars(0, PseudoDualPortRAM_tb);
// Eksportuj także wszystkie dane z pamięci
for(r=0; r<=15; r=r+1) begin
$dumpvars(2, DUT.Memory[r]);
end
end
// Sekwencja testowa zapisu
initial begin // 9
$timeformat(-6, 3, “us”, 12);
$display(“===== START =====”);
$display(“ Time WriteAddress DataIn ReadAddress DataOut”);
$monitor(“%t %H %H %H %H”,
$realtime,
WriteAddress,
DataIn,
ReadAddress,
DataOut
);
@(posedge WriteClock);
Reset <= 1’b1;
@(posedge WriteClock);
// Zapis losowych danych do pamięci
for(w=0; w<=15; w=w+1) begin // 10
WriteData(w, $urandom_range(8’h00, 8’hFF));
end
WriteEnd();
end
// Sekwencja testowa odczytu
initial begin // 11
repeat(5) @(posedge ReadClock); // 12
// Odczytaj wszystkie dane z pamięci
ReadEnable <= 1’b1;
for(r=0; r<=15; r=r+1) begin // 13
ReadAddress <= r;
@(posedge ReadClock);
end
ReadAddress <= 4’dX;
ReadEnable <= 1’b0;
// Pauza
repeat(2) @(posedge ReadClock);
$display(“===== END =====”);
$finish;
end
// Task zapisujący dane do pamięci
task WriteData(input [3:0] Adr, input [7:0] Dat);
begin
WriteAddress <= Adr;
DataIn <= Dat;
WriteEnable <= 1’b1;
@(posedge WriteClock);
end
endtask
// Task kończący zapis danych
task WriteEnd();
begin
WriteAddress <= 4’dX;
DataIn <= 8’dX;
WriteEnable <= 1’b0;
@(posedge WriteClock);
end
endtask
endmodule
`default_nettype wire
W naszej symulacji nowością będą dwa różne sygnały zegarowe. W liniach 1 i 2 definiujemy częstotliwość CLOCK_READ_HZ oraz CLOCK_WRITE_HZ na odpowiednio 10 MHz i 15 MHz. Następnie tworzymy generatory sygnałów zegarowych dla odczytu i zapisu pamięci RAM w liniach 3 i 4. W taki sposób powstają dwa niezależne od siebie sygnały zegarowe ReadClock oraz WriteClock. Następnie tworzymy różne zmienne, które wykorzystywane będą w sekwencji testowej. W szczególności uwagę należy zwrócić na osobne zmienne sterujące wejściami adresowymi ReadAddress (linia 5) oraz WriteAddress (linia 6). W linii 7 i 8 tworzymy zmienne r i w typu integer. Są to iteratory pętli for, odpowiednio do odczytu i zapisu. W poprzednich testbenchach używaliśmy do obu operacji zmiennej o nazwie i, ponieważ tak się przyjęło. W tym testbenchu będziemy jednocześnie zapisywać i odczytywać, a więc potrzebujemy dwóch różnych zmiennych sterujących pętlami for.
W liniach 9 oraz 11 rozpoczynamy dwie sekwencje testowe, które wykonują się równolegle, w tym samym czasie. W linii 9 rozpoczyna się blok always podobny do tego, jaki wykorzystywaliśmy do testowania zwykłej pamięci RAM. W linii 10 mamy pętlę for, która zapełnia pamięć losowo wybranymi danymi.
Równolegle wykonuje się sekwencja z bloku initial, zaczynającego się w linii 11, której celem jest odczytanie danych z pamięci. W linii 12 mamy opóźnienie o kilka taktów zegarowych. Celem tego jest zawieszenie wykonywania tego bloku do czasu, aż w pamięci zostanie zapisane pierwsze kilka danych. Potem będziemy jednocześnie zapisywać kolejne dane i odczytywać te, które zostały zapisane chwile wcześniej. Odczytywania dokonujemy w pętli for w taki sam sposób, jak w poprzednich testbenchach.
Uruchamiamy symulację plikiem ram_pdp.bat, którego kod przedstawiono na listingu 12, a plik ram_pdp.vcd, powstający po zakończeniu symulacji, otwieramy przeglądarką GTKWave.
@echo off
iverilog -o ram_pdp.o ram_pdp.v ram_pdp_tb.v
vvp ram_pdp.o
del ram_pdp.o
Przebiegi sygnałów uzyskane podczas symulacji przedstawiono na rysunku 5. Dla zwiększenia czytelności sygnały zegarowe zaznaczono żółtym kolorem. Porównaj ten screenshot z rysunkiem 4. W tym przypadku widzimy wyraźnie, że operacja zapisu i odczytu wykonywana jest równolegle.
Moduł top
Czas na ćwiczenie praktyczne, aby przetestować jak działa zwykła pamięć RAM o organizacji 256x8bit, czyli komórki pamięci będą miały adresy od 0x00 do 0xFF i będzie można w nich przechowywać wartości również w takim zakresie. Zobacz rysunek 6. Zrobimy prostą aplikacją, gdzie przy pomocy jednego enkodera będziemy wybierać interesujący nas adres pamięci. Będzie on wyświetlany na dwóch cyfrach wyświetlacza w formacie szesnastkowym – adres na wyświetlaczu zaznaczono kolorem niebieskim. Natychmiast po zmianie adresu, będzie wyświetlana zawartość wybranej komórki pamięci na dwóch cyfrach zaznaczonych kolorem zielonym.
Będziemy mogli zapisać do pamięci jakąś wartość pod aktualnie wskazywany adres. W tym celu służy drugi enkoder, którym będziemy mogli modyfikować wartość na wyświetlaczu zaznaczoną na żółto. Wartość ta zostanie zapisana do pamięci dopiero wtedy, kiedy zostanie wciśnięty enkoder wybierający adres. Po zapisaniu, zielone cyfry wyświetlacza powinny natychmiast pokazać nową wartość odczytaną z pamięci.
Utwórz nowy projekt w Diamond i dodaj do niego pliki, które widoczne są na rysunku 7. Są to moduły, które omawialiśmy już w poprzednich odcinkach.
Kod pliku top.v pokazano na listingu 13. Na liście portów modułu top znajduje się tylko sygnały związane z enkoderem, wyświetlaczem i sygnał resetujący. Logika odpowiedzialna za inkrementację i dekrementację adresu oraz danych do zapisu jest bardzo podobna, więc z tego powodu omówimy tylko obsługę jednego enkodera. W linii 4 widzimy instancję modułu obsługującego enkoder obrotowy, który opracowaliśmy w 14 odcinku kursu. Jego wyjścia Increment_o, Decrement_o i ButtonPress_o (linie 5, 6, 7) informują o wykryciu obrotu gałki enkodera i wciśnięcia jej, poprzez ustawienie stanu wysokiego na czas jednego cyklu zegarowego. Do tych wyjść doprowadzono sygnały wire utworzone w liniach 1, 2 i 3.
/ Plik top.v
`default_nettype none
module top(
input wire Reset, // Pin 17
input wire Encoder1A_i, // Pin 68
input wire Encoder1B_i, // Pin 67
input wire Encoder1S_i, // Pin 66
input wire Encoder2A_i, // Pin 71
input wire Encoder2B_i, // Pin 70
output wire [7:0] Cathodes_o,
output wire [7:0] Segments_o
);
// Generator sygnału zegarowego
parameter CLOCK_HZ = 14_000_000;
wire Clock;
OSCH #(
.NOM_FREQ("14.00")
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock),
.SEDSTDBY()
);
// Enkoder 1 do ustawiania adresu
wire AddressIncrement; // 1
wire AddressDecrement; // 2
wire WriteExecute; // 3
Encoder Encoder1( // 4
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(Encoder1A_i),
.AsyncB_i(Encoder1B_i),
.AsyncS_i(Encoder1S_i),
.Increment_o(AddressIncrement), // 5
.Decrement_o(AddressDecrement), // 6
.ButtonPress_o(WriteExecute), // 7
.ButtonRelease_o(),
.ButtonState_o()
);
// Enkoder 2 do ustawiania zapisywanego bajtu danych
wire DataIncrement;
wire DaraDecrement;
Encoder Encoder2(
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(Encoder2A_i),
.AsyncB_i(Encoder2B_i),
.AsyncS_i(1’b0),
.Increment_o(DataIncrement),
.Decrement_o(DaraDecrement),
.ButtonPress_o(),
.ButtonRelease_o(),
.ButtonState_o()
);
// Rejestr wskazujący aktualnie wybrany adres pamięci
reg [7:0] Address; // 8
// Logika inkrementacji/dekrementacji wybranego adresu
always @(posedge Clock, negedge Reset) begin // 9
if(!Reset)
Address <= 0;
else if(AddressIncrement)
Address <= Address + 1’b1;
else if(AddressDecrement)
Address <= Address - 1’b1;
end
// Dane do zapisu
reg [7:0] DataToWrite;
// Logika inkrementacji/dekrementacji bajtu danych do zapisu
always @(posedge Clock, negedge Reset) begin
if(!Reset)
DataToWrite <= 0;
else if(DataIncrement)
DataToWrite <= DataToWrite + 1’b1;
else if(DaraDecrement)
DataToWrite <= DataToWrite - 1’b1;
end
// Instancja pamięci RAM
wire [7:0] DataReadFromMemory; // 10
RAM #( // 11
.ADDRESS_WIDTH(8),
.DATA_WIDTH(8)
) RAM_inst(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(1’b1), // 12
.WriteEnable_i(WriteExecute), // 13
.Address_i(Address), // 14
.Data_i(DataToWrite), // 15
.Data_o(DataReadFromMemory) // 16
);
// Instancja sterownika wyświetlacza
DisplayMultiplex #( // 17
.CLOCK_HZ(CLOCK_HZ),
.SWITCH_PERIOD_US(1000),
.DIGITS(8)
) DisplayMultiplex_inst(
.Clock(Clock),
.Reset(Reset),
.Data_i({8’d0, Address, DataToWrite, DataReadFromMemory}), // 18
.DecimalPoints_i(8’b00010000),
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o),
.SwitchCathode_o()
);
endmodule
`default_nettype wire
Sygnały te sprawdzane są w bloku always, który rozpoczyna się z linii 9. Jeżeli rozpoznano obrót pokrętła enkodera to wtedy jest zwiększany lub zmniejszany stan rejestru Address, który utworzono w linii 8.
W linii 11 tworzymy instancję pamięci RAM. Konfigurujemy szerokość wejścia adresowego oraz wejścia danych na 8 bitów. Bajt wskazany przez wejście adresowe ma być odczytywany w każdych warunkach, więc dlatego na wejściu ReadEnable_i ustawiony jest na sztywno stan wysoki (linia 12). Zapis do pamięci odbywa się wtedy, kiedy doprowadzony do wejścia WriteEnable_i sygnał WriteExecute jest w stanie wysokim – ten sygnał pochodzi z wyjścia ButtonPress_o sterownika enkodera (linia 7).
Dane odczytane z pamięci dostępne są na jej wyjściu Data_o (linia 16) i przekazywane są do sterownika wyświetlacza przy pomocy sygnału wire DataReadFromMemory (linia 10).
Instancję sterownika 8-cyfrowego wyświetlacza 7-segmentowego LED widzimy w linii 17. Najbardziej nas interesuje jego wejście Data_i, które jest wejściem 32-bitowym. Każdy z czterech bitów tego wejścia steruje pojedynczą cyfrą wyświetlacza, która może wyświetlać cyfry od 0 do 9 i znaki od A do F, aby wyświetlać dane w formacie szesnastkowym. W naszej testowej aplikacji mamy trzy zmienne 8-bitowe do wyświetlenia. Są to Address, DataToWrite i DataReadFromMemory. Te trzy zmienne zajmują 24 bity, więc pozostałe 8-bitów zerujemy na stałe przy pomocy wyrażenia 8’d0. Wszystkie te cztery 8-bitowe wyrażenia sklejamy w jedną 32-bitową całość przy pomocy operatora konkatenacji {}.
Syntezujemy, po czym otwieramy narzędzie Spreadsheet i konfigurujemy piny układu FPGA tak, jak to pokazano na rysunku 8.
Pozostaje już tylko wygenerować bitstream i wgrać go do FPGA. Miłej zabawy! Pamiętaj, że moduł wyświetlacza wygasza zera z lewej strony wyświetlacza, czyli adres 0x00 i dane do zapisu 0x00 będą niewidoczne.
W następnej części kursu dowiemy się, jak przy pomocy FPGA można generować dźwięki, a potem użyjemy pamięci, aby wykonać odtwarzacz melodyjek.
Dominik Bieczyński
leonow32@gmail.com
Zobacz więcej:
- Repozytorium modułów wykorzystywanych w kursie https://github.com/leonow32/verilog-fpga
- MachXO2 Family Datasheet https://www.latticesemi.com/view_document?document_id=38834
- Memory Usage Guide for MachXO2 Devices https://www.latticesemi.com/view_document?document_id=39082
- Lattice Synthesis Engine for Diamond User Guide https://www.latticesemi.com/view_document?document_id=51556