So erstellen Sie einen Ringpuffer-FIFO in VHDL
Ringpuffer sind beliebte Konstrukte zum Erstellen von Warteschlangen in sequentiellen Programmiersprachen, sie können jedoch auch in Hardware implementiert werden. In diesem Artikel erstellen wir einen Ringpuffer in VHDL, um einen FIFO im Block-RAM zu implementieren.
Bei der Implementierung eines FIFO müssen Sie viele Entwurfsentscheidungen treffen. Welche Art von Schnittstelle benötigen Sie? Sind Sie durch Ressourcen begrenzt? Sollte es widerstandsfähig gegen Überlesen und Überschreiben sein? Ist die Latenz akzeptabel? Das sind einige der Fragen, die mir in den Sinn kommen, wenn ich gebeten werde, einen FIFO zu erstellen.
Es gibt online viele kostenlose FIFO-Implementierungen sowie FIFO-Generatoren wie Xilinx LogiCORE. Dennoch ziehen es viele Ingenieure vor, ihre eigenen FIFOs zu implementieren. Denn obwohl sie alle die gleichen grundlegenden Warteschlangen- und Dequeue-Aufgaben ausführen, können sie bei Berücksichtigung der Details sehr unterschiedlich sein.
Wie ein Ringpuffer funktioniert
Ein Ringpuffer ist eine FIFO-Implementierung, die zusammenhängenden Speicher zum Speichern der gepufferten Daten mit einem Minimum an Daten-Shuffling verwendet. Neue Elemente bleiben vom Zeitpunkt des Schreibens an am gleichen Speicherort, bis sie gelesen und aus dem FIFO entfernt werden.
Zwei Zähler werden verwendet, um den Ort und die Anzahl der Elemente im FIFO zu verfolgen. Diese Zähler beziehen sich auf einen Offset vom Beginn des Speicherplatzes, wo die Daten gespeichert sind. In VHDL ist dies ein Index für eine Array-Zelle. Für den Rest dieses Artikels beziehen wir uns darauf, dass diese Zähler Zeiger sind .
Diese beiden Zeiger sind der Kopf und Schwanz Zeiger. Der Kopf zeigt immer auf den Speicherplatz, der die nächsten geschriebenen Daten enthalten wird, während der Schwanz auf das nächste Element verweist, das aus dem FIFO gelesen wird. Es gibt noch andere Varianten, aber wir werden diese verwenden.
Leerer Zustand
Wenn Kopf und Ende auf dasselbe Element zeigen, bedeutet dies, dass der FIFO leer ist. Das obige Bild zeigt ein Beispiel-FIFO mit acht Slots. Sowohl der Kopf- als auch der Endzeiger zeigen auf Element 0, was anzeigt, dass der FIFO leer ist. Dies ist der Anfangszustand des Ringpuffers.
Beachten Sie, dass der FIFO immer noch leer wäre, wenn beide Zeiger auf einem anderen Index wären, beispielsweise 3. Für jeden Schreibvorgang bewegt sich der Kopfzeiger um eine Stelle vorwärts. Der Endzeiger wird jedes Mal inkrementiert, wenn der Benutzer des FIFO ein Element liest.
Wenn sich einer der Zeiger am höchsten Index befindet, bewirkt der nächste Schreib- oder Lesevorgang, dass sich der Zeiger zurück zum niedrigsten Index bewegt. Das ist das Schöne am Ringpuffer, die Daten bewegen sich nicht, nur die Zeiger.
Kopf führt Schwanz
Das obige Bild zeigt denselben Ringpuffer nach fünf Schreibvorgängen. Der Endzeiger befindet sich immer noch auf Steckplatznummer 0, aber der Kopfzeiger hat sich auf Steckplatznummer 5 bewegt. Die Steckplätze, die Daten enthalten, sind in der Abbildung hellblau gefärbt. Der Schwanzzeiger befindet sich auf dem ältesten Element, während der Kopf auf den nächsten freien Platz zeigt.
Wenn der Kopf einen höheren Index als der Schwanz hat, können wir die Anzahl der Elemente im Ringpuffer berechnen, indem wir den Schwanz vom Kopf subtrahieren. In der Abbildung oben ergibt das eine Anzahl von fünf Elementen.
Schwanz führt Kopf
Das Subtrahieren des Kopfes vom Schwanz funktioniert nur, wenn der Kopf dem Schwanz vorangeht. In der obigen Abbildung befindet sich der Kopf auf Index 2, während der Schwanz auf Index 5 liegt. Wenn wir also diese einfache Berechnung durchführen, erhalten wir 2 – 5 =-3, was keinen Sinn ergibt.
Die Lösung besteht darin, den Kopf mit der Gesamtzahl der Slots im FIFO, in diesem Fall 8, zu versetzen. Die Rechnung ergibt nun (2 + 8) – 5 =5, was die richtige Antwort ist.
Der Schwanz wird dem Kopf ewig nachjagen, so funktioniert ein Ringpuffer. Die Hälfte der Zeit hat der Schwanz einen höheren Index als der Kopf. Die Daten werden zwischen den beiden gespeichert, wie durch die hellblaue Farbe im Bild oben angezeigt wird.
Vollständiger Zustand
Bei einem vollen Ringpuffer zeigt der Schwanz direkt nach dem Kopf auf den Index. Eine Folge dieses Schemas ist, dass wir niemals alle Slots zum Speichern von Daten verwenden können, es muss mindestens ein freier Slot vorhanden sein. Das obige Bild zeigt eine Situation, in der der Ringpuffer voll ist. Der offene, aber unbenutzbare Steckplatz ist gelb gefärbt.
Ein dediziertes Leer/Voll-Signal könnte auch verwendet werden, um anzuzeigen, dass der Ringpuffer voll ist. Dies würde es allen Speichersteckplätzen ermöglichen, Daten zu speichern, erfordert jedoch zusätzliche Logik in Form von Registern und Nachschlagetabellen (LUTs). Daher verwenden wir die Option keep one open Schema für unsere Implementierung des Ringpuffer-FIFO, da dies nur billigeren Block-RAM verschwendet.
Die Ringpuffer-FIFO-Implementierung
Wie Sie die Schnittstellensignale zu und von Ihrem FIFO definieren, begrenzt die Anzahl möglicher Implementierungen Ihres Ringpuffers. In unserem Beispiel verwenden wir eine Variation der klassischen Read/Write Enable- und Empty/Full/Valid-Schnittstelle.
Es wird ein Daten schreiben geben Bus auf der Eingangsseite, der die Daten trägt, die zum FIFO geschoben werden sollen. Es wird auch eine Schreibfreigabe geben Signal, das, wenn es aktiviert wird, bewirkt, dass der FIFO die Eingangsdaten abtastet.
Die Ausgabeseite hat gelesene Daten und ein Lesen gültig vom FIFO gesteuertes Signal. Es wird auch eine Lesefreigabe haben Signal, das vom nachgeschalteten Benutzer des FIFO gesteuert wird.
Die leere und voll Steuersignale sind Teil der klassischen FIFO-Schnittstelle, wir werden sie auch verwenden. Sie werden vom FIFO gesteuert, und ihr Zweck ist es, den Zustand des FIFO an den Leser und Schreiber zu übermitteln.
Gegendruck
Das Problem beim Warten, bis der FIFO entweder leer oder voll ist, bevor Maßnahmen ergriffen werden, besteht darin, dass die Schnittstellenlogik keine Zeit zum Reagieren hat. Die sequentielle Logik arbeitet auf Taktzyklus-zu-Taktzyklus-Basis, die ansteigenden Flanken der Uhr trennen die Ereignisse in Ihrem Design effektiv in Zeitschritte.
Eine Lösung besteht darin,
In unserer Implementierung werden die vorhergehenden Signale empty_next
genannt und full_next
, einfach weil ich es vorziehe, Namen mit Postfixes zu versehen, anstatt ihnen voranzustellen.
Die Entität
Das Bild unten zeigt die Entität unseres Ringpuffer-FIFO. Zusätzlich zu den Eingangs- und Ausgangssignalen im Port hat er zwei generische Konstanten. Die RAM_WIDTH
Generic definiert die Anzahl der Bits in den Eingangs- und Ausgangswörtern, die Anzahl der Bits, die jeder Speicherplatz enthalten wird.
Die RAM_DEPTH
Generic definiert die Anzahl der Slots, die für den Ringpuffer reserviert werden. Da ein Schlitz reserviert ist, um anzuzeigen, dass der Ringpuffer voll ist, beträgt die Kapazität des FIFO RAM_DEPTH
– 1. Die RAM_DEPTH
Konstante sollte an die RAM-Tiefe auf dem Ziel-FPGA angepasst werden. Ungenutztes RAM innerhalb eines Block-RAM-Grundelements wird verschwendet, es kann nicht mit anderer Logik im FPGA geteilt werden.
entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Write port wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Read port rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Flags empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- The number of elements in the FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer;
Zusätzlich zur Uhr und zum Zurücksetzen enthält die Portdeklaration klassische Daten/Lese- und Schreibports aktivieren. Diese werden von den Upstream- und Downstream-Modulen verwendet, um neue Daten in den FIFO zu schieben und um das älteste Element daraus zu entnehmen.
Die rd_valid
Signal wird vom FIFO aktiviert, wenn rd_data
Port enthält gültige Daten. Dieses Ereignis wird um einen Taktzyklus nach einem Impuls auf rd_en
verzögert Signal. Wir werden am Ende dieses Artikels mehr darüber sprechen, warum das so sein muss.
Dann kommen die vom FIFO gesetzten Leer/Voll-Flags. Die empty_next
Das Signal wird aktiviert, wenn 1 oder 0 Elemente übrig sind, während empty
ist nur aktiv, wenn 0 Elemente im FIFO vorhanden sind. Ebenso die full_next
Signal zeigt an, dass Platz für 1 oder 0 weitere Elemente vorhanden ist, während full
wird nur bestätigt, wenn der FIFO kein weiteres Datenelement aufnehmen kann.
Schließlich gibt es noch einen fill_count
Ausgang. Dies ist eine ganze Zahl, die die Anzahl der aktuell im FIFO gespeicherten Elemente widerspiegelt. Ich habe dieses Ausgangssignal einfach eingefügt, weil wir es intern im Modul verwenden werden. Das Ausbrechen durch die Entität ist im Wesentlichen kostenlos, und der Benutzer kann dieses Signal beim Instanziieren dieses Moduls unverbunden lassen.
Die deklarative Region
Im deklarativen Bereich der VHDL-Datei deklarieren wir einen benutzerdefinierten Typ, einen Untertyp, eine Reihe von Signalen und eine Prozedur zur internen Verwendung im Ringpuffermodul.
type ram_type is array (0 to RAM_DEPTH - 1) of std_logic_vector(wr_data'range); signal ram : ram_type; subtype index_type is integer range ram_type'range; signal head : index_type; signal tail : index_type; signal empty_i : std_logic; signal full_i : std_logic; signal fill_count_i : integer range RAM_DEPTH - 1 downto 0; -- Increment and wrap procedure incr(signal index : inout index_type) is begin if index = index_type'high then index <= index_type'low; else index <= index + 1; end if; end procedure;
Zuerst deklarieren wir einen neuen Typ, um unseren RAM zu modellieren. Die ram_type
Typ ist ein Array von Vektoren, dessen Größe durch die generischen Eingaben bestimmt wird. Der neue Typ wird in der nächsten Zeile verwendet, um ram
zu deklarieren Signal, das die Daten im Ringpuffer hält.
Im nächsten Codeblock deklarieren wir index_type
, ein Untertyp von Integer. Seine Reichweite wird indirekt durch RAM_DEPTH
geregelt generisch. Unterhalb der Subtyp-Deklaration verwenden wir den index-Typ, um zwei neue Signale zu deklarieren, die Head- und Tail-Zeiger.
Dann folgt ein Block von Signaldeklarationen, die interne Kopien von Entitätssignalen sind. Sie haben die gleichen Basisnamen wie die Entity-Signale, sind aber mit _i
nachgestellt um anzuzeigen, dass sie für den internen Gebrauch bestimmt sind. Wir verwenden diesen Ansatz, weil es als schlechter Stil angesehen wird, inout
zu verwenden Modus auf Entity-Signale, obwohl dies den gleichen Effekt hätte.
Schließlich deklarieren wir eine Prozedur namens incr
was einen index_type
braucht Signal als Parameter. Dieses Unterprogramm wird verwendet, um die Kopf- und Endzeiger zu inkrementieren und sie auf 0 zurückzusetzen, wenn sie den höchsten Wert haben. Head und Tail sind Subtypen von Integer, die normalerweise kein Wrapping-Verhalten unterstützen. Wir werden das Verfahren verwenden, um dieses Problem zu umgehen.
Übereinstimmende Aussagen
An der Spitze der Architektur erklären wir unsere übereinstimmenden Aussagen. Ich ziehe es vor, diese einzeiligen Signalzuweisungen vor den normalen Prozessen zu sammeln, weil sie leicht übersehen werden. Eine gleichzeitige Anweisung ist eigentlich eine Form von Prozess, Sie können hier mehr über gleichzeitige Anweisungen lesen:
So erstellen Sie eine gleichzeitige Anweisung in VHDL
-- Copy internal signals to output empty <= empty_i; full <= full_i; fill_count <= fill_count_i; -- Set the flags empty_i <= '1' when fill_count_i = 0 else '0'; empty_next <= '1' when fill_count_i <= 1 else '0'; full_i <= '1' when fill_count_i >= RAM_DEPTH - 1 else '0'; full_next <= '1' when fill_count_i >= RAM_DEPTH - 2 else '0';
Im ersten Block gleichzeitiger Zuweisungen kopieren wir die internen Versionen der Entity-Signale zum Ausgang. Diese Leitungen stellen sicher, dass die Entity-Signale den internen Versionen genau zur gleichen Zeit folgen, jedoch mit einer Verzögerung von einem Delta-Zyklus in der Simulation.
Im zweiten und letzten Block gleichzeitiger Anweisungen weisen wir die Ausgangsflags zu, die den vollen/leeren Zustand des Ringpuffers signalisieren. Wir basieren die Berechnungen auf dem RAM_DEPTH
generisch und auf dem fill_count
Signal. Die RAM-Tiefe ist eine Konstante, die sich nicht ändert. Daher ändern sich die Flags nur als Ergebnis einer aktualisierten Füllzahl.
Aktualisierung des Kopfzeigers
Die Grundfunktion des Kopfzeigers besteht darin, immer dann zu inkrementieren, wenn das Schreibfreigabesignal von außerhalb dieses Moduls aktiviert wird. Wir tun dies, indem wir den head
übergeben Signal an den zuvor erwähnten incr
Verfahren.
PROC_HEAD : process(clk) begin if rising_edge(clk) then if rst = '1' then head <= 0; else if wr_en = '1' and full_i = '0' then incr(head); end if; end if; end if; end process;
Unser Code enthält einen zusätzlichen and full_i = '0'
Anweisung zum Schutz vor Überschreibungen. Diese Logik kann weggelassen werden, wenn Sie sicher sind, dass die Datenquelle niemals versuchen wird, in das FIFO zu schreiben, während es voll ist. Ohne diesen Schutz führt ein Überschreiben dazu, dass der Ringpuffer wieder leer wird.
Wenn der Kopfzeiger inkrementiert wird, während der Ringpuffer voll ist, zeigt der Kopf auf dasselbe Element wie der Schwanz. Somit „vergisst“ das Modul die enthaltenen Daten und die FIFO-Füllung scheint leer zu sein.
Durch Auswertung des full_i
Signal vor dem Inkrementieren des Kopfzeigers, vergisst es nur den überschriebenen Wert. Ich finde diese Lösung schöner. Aber so oder so, wenn es jemals zu Überschreibungen kommt, deutet dies auf eine Fehlfunktion außerhalb dieses Moduls hin.
Aktualisierung des Endzeigers
Der Endzeiger wird auf ähnliche Weise wie der Kopfzeiger inkrementiert, aber read_en
Eingang wird als Trigger verwendet. Genau wie beim Überschreiben schützen wir uns vor Überlesen, indem wir and empty_i = '0'
einfügen im booleschen Ausdruck.
PROC_TAIL : process(clk) begin if rising_edge(clk) then if rst = '1' then tail <= 0; rd_valid <= '0'; else rd_valid <= '0'; if rd_en = '1' and empty_i = '0' then incr(tail); rd_valid <= '1'; end if; end if; end if; end process;
Zusätzlich pulsieren wir den rd_valid
Signal bei jedem gültigen Lesevorgang. Die gelesenen Daten sind immer im Taktzyklus nach rd_en
gültig wurde geltend gemacht, wenn das FIFO nicht leer war. Mit diesem Wissen besteht für dieses Signal eigentlich keine Notwendigkeit, aber wir werden es der Einfachheit halber einbeziehen. Die rd_valid
Signal wird in der Synthese wegoptimiert, wenn es bei der Instanziierung des Moduls nicht verbunden bleibt.
Block-RAM ableiten
Damit das Synthesetool auf Block-RAM schließen kann, müssen wir die Lese- und Schreibports in einem synchronen Prozess ohne Reset deklarieren. Wir lesen und schreiben bei jedem Taktzyklus in den RAM und überlassen die Verwendung dieser Daten den Steuersignalen.
PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= wr_data; rd_data <= ram(tail); end if; end process;
Dieser Prozess weiß nicht, wann der nächste Schreibvorgang stattfinden wird, muss es aber auch nicht wissen. Stattdessen schreiben wir einfach kontinuierlich. Wenn der head
Signal als Ergebnis eines Schreibvorgangs inkrementiert wird, beginnen wir mit dem Schreiben in den nächsten Slot. Dadurch wird der geschriebene Wert effektiv gesperrt.
Aktualisierung des Füllzählers
Der fill_count
Das Signal wird zum Erzeugen der Voll- und Leersignale verwendet, die wiederum zum Verhindern des Überschreibens und Überlesens des FIFO verwendet werden. Der Füllzähler wird durch einen kombinatorischen Prozess aktualisiert, der für den Kopf- und Endzeiger empfindlich ist, aber diese Signale werden nur an der ansteigenden Flanke des Takts aktualisiert. Daher ändert sich auch die Füllzahl unmittelbar nach der Taktflanke.
PROC_COUNT : process(head, tail) begin if head < tail then fill_count_i <= head - tail + RAM_DEPTH; else fill_count_i <= head - tail; end if; end process;
Die Füllzahl wird einfach durch Subtrahieren des Schwanzes vom Kopf berechnet. Wenn der Endindex größer als der Kopf ist, müssen wir den Wert von RAM_DEPTH
hinzufügen konstant, um die korrekte Anzahl von Elementen zu erhalten, die sich derzeit im Ringpuffer befinden.
Der vollständige VHDL-Code für den Ringpuffer-FIFO
library ieee; use ieee.std_logic_1164.all; entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Write port wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Read port rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Flags empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- The number of elements in the FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer; architecture rtl of ring_buffer is type ram_type is array (0 to RAM_DEPTH - 1) of std_logic_vector(wr_data'range); signal ram : ram_type; subtype index_type is integer range ram_type'range; signal head : index_type; signal tail : index_type; signal empty_i : std_logic; signal full_i : std_logic; signal fill_count_i : integer range RAM_DEPTH - 1 downto 0; -- Increment and wrap procedure incr(signal index : inout index_type) is begin if index = index_type'high then index <= index_type'low; else index <= index + 1; end if; end procedure; begin -- Copy internal signals to output empty <= empty_i; full <= full_i; fill_count <= fill_count_i; -- Set the flags empty_i <= '1' when fill_count_i = 0 else '0'; empty_next <= '1' when fill_count_i <= 1 else '0'; full_i <= '1' when fill_count_i >= RAM_DEPTH - 1 else '0'; full_next <= '1' when fill_count_i >= RAM_DEPTH - 2 else '0'; -- Update the head pointer in write PROC_HEAD : process(clk) begin if rising_edge(clk) then if rst = '1' then head <= 0; else if wr_en = '1' and full_i = '0' then incr(head); end if; end if; end if; end process; -- Update the tail pointer on read and pulse valid PROC_TAIL : process(clk) begin if rising_edge(clk) then if rst = '1' then tail <= 0; rd_valid <= '0'; else rd_valid <= '0'; if rd_en = '1' and empty_i = '0' then incr(tail); rd_valid <= '1'; end if; end if; end if; end process; -- Write to and read from the RAM PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= wr_data; rd_data <= ram(tail); end if; end process; -- Update the fill count PROC_COUNT : process(head, tail) begin if head < tail then fill_count_i <= head - tail + RAM_DEPTH; else fill_count_i <= head - tail; end if; end process; end architecture;
Der obige Code zeigt den vollständigen Code für den Ringpuffer-FIFO. Sie können das folgende Formular ausfüllen, um die ModelSim-Projektdateien sowie die Testbench sofort per Post zu erhalten.
Die Prüfbank
Das FIFO wird in einer einfachen Testbench instanziiert, um zu demonstrieren, wie es funktioniert. Sie können den Quellcode für die Testbench zusammen mit dem ModelSim-Projekt herunterladen, indem Sie das folgende Formular verwenden.
Die generischen Eingaben wurden auf folgende Werte gesetzt:
- RAM_WIDTH:16
- RAM_DEPTH:256
Die Testbench setzt zuerst den FIFO zurück. Wenn der Reset freigegeben wird, schreibt die Testbench sequentielle Werte (1-255) in den FIFO, bis er voll ist. Schließlich wird der FIFO geleert, bevor der Test abgeschlossen ist.
Wir können die Wellenform für den vollständigen Lauf der Testbench im Bild unten sehen. Der fill_count
Signal als analoger Wert in der Wellenform dargestellt, um den Füllstand des FIFOs besser zu veranschaulichen.
Der Head-, der Tail- und der Fill-Count sind zu Beginn der Simulation 0. An der Stelle, wo der full
Signal gesetzt wird, hat der Kopf den Wert 255, ebenso der fill_count
Signal. Die Füllzahl geht nur bis 255, obwohl wir eine RAM-Tiefe von 256 haben. Das liegt daran, dass wir das keep one open verwenden Methode, um zwischen voll und leer zu unterscheiden, wie wir zuvor in diesem Artikel besprochen haben.
An dem Wendepunkt, an dem wir aufhören, in den FIFO zu schreiben und daraus zu lesen, friert der Head-Wert ein, während der Tail- und Fill-Zähler zu sinken beginnen. Schließlich haben am Ende der Simulation, wenn der FIFO leer ist, sowohl der Kopf als auch der Schwanz den Wert 255, während der Füllzähler 0 ist.
Diese Testbench sollte nur für Demonstrationszwecke als angemessen angesehen werden. Es hat kein selbstüberprüfendes Verhalten oder keine Logik, um zu überprüfen, ob die Ausgabe des FIFO überhaupt korrekt ist.
Wir werden dieses Modul im Artikel der nächsten Woche verwenden, wenn wir uns mit dem Thema eingeschränkte zufällige Verifizierung befassen . Dies ist eine andere Teststrategie als die häufiger verwendeten gerichteten Tests. Kurz gesagt, die Testbench führt zufällige Interaktionen mit dem DUT (Device Under Test) durch, und das Verhalten des DUT muss durch einen separaten Testbench-Prozess verifiziert werden. Schließlich ist der Test abgeschlossen, wenn eine Reihe von vordefinierten Ereignissen aufgetreten sind.
Klicken Sie hier, um den folgenden Blogpost zu lesen:
Eingeschränkte zufällige Überprüfung
Synthetisieren in Vivado
Ich habe den Ringpuffer in Xilinx Vivado synthetisiert, weil es das beliebteste FPGA-Implementierungstool ist. Es sollte jedoch auf allen FPGA-Architekturen mit Dual-Port-Block-RAM funktionieren.
Wir müssen den generischen Eingängen einige Werte zuweisen, um den Ringpuffer als eigenständiges Modul implementieren zu können. Dies geschieht in Vivado über die Einstellungen → Allgemein → Generika/Parameter Menü, wie im Bild unten gezeigt.
Der Wert für RAM_WIDTH
auf 16 gesetzt, was der gleiche ist wie in der Simulation. Aber ich habe den RAM_DEPTH
eingestellt bis 2048, da dies die maximale Tiefe des RAMB36E1-Primitives in der Xilinx Zynq-Architektur ist, die ich gewählt habe. Wir hätten einen niedrigeren Wert wählen können, aber es hätte immer noch die gleiche Anzahl von Block-RAMs verwendet. Ein höherer Wert hätte dazu geführt, dass mehr als ein Block-RAM verwendet wurde.
Das folgende Bild zeigt die Ressourcennutzung nach der Implementierung, wie von Vivado gemeldet. Unser Ringpuffer hat tatsächlich einen Block RAM und eine Handvoll LUTs und Flip-Flops verbraucht.
Ditching the valid signal
Sie fragen sich vielleicht, ob die Verzögerung von einem Taktzyklus zwischen rd_en
und die rd_valid
Signal ist eigentlich notwendig. Schließlich sind die Daten bereits auf rd_data
vorhanden wenn wir den rd_en
bestätigen Signal. Können wir diesen Wert nicht einfach verwenden und den Ringpuffer beim Lesen aus dem FIFO beim nächsten Taktzyklus zum nächsten Element springen lassen?
Genau genommen brauchen wir den valid
nicht Signal. Ich habe dieses Signal nur der Einfachheit halber eingefügt. Der entscheidende Teil ist, dass wir bis zum Taktzyklus warten müssen, nachdem wir rd_en
bestätigt haben Signal, sonst hat das RAM keine Zeit zu reagieren.
Block-RAM in FPGAs sind vollsynchrone Komponenten, sie benötigen eine Taktflanke sowohl zum Lesen als auch zum Schreiben von Daten. Lese- und Schreibtakt müssen nicht von derselben Taktquelle kommen, aber Taktflanken müssen vorhanden sein. Außerdem darf zwischen dem RAM-Ausgang und dem nächsten Register (Flip-Flops) keine Logik vorhanden sein. Dies liegt daran, dass sich das Register, das zum Takten des RAM-Ausgangs verwendet wird, innerhalb des Block-RAM-Grundelements befindet.
Das obige Bild zeigt ein Zeitdiagramm, wie sich ein Wert von wr_data
ausbreitet Eingabe in unseren Ringpuffer, durch das RAM und erscheint schließlich auf dem rd_data
Ausgang. Da jedes Signal an der ansteigenden Taktflanke abgetastet wird, dauert es drei Taktzyklen, bis wir beginnen, den Schreibport anzusteuern, bevor es am Leseport erscheint. Und ein zusätzlicher Taktzyklus vergeht, bevor das empfangende Modul diese Daten verwenden kann.
Reduktion der Latenz
Es gibt Möglichkeiten, dieses Problem zu entschärfen, aber es geht zu Lasten zusätzlicher Ressourcen, die im FPGA verwendet werden. Versuchen wir ein Experiment, um die Verzögerung eines Taktzyklus vom Leseport unseres Ringpuffers zu verringern. Im Code-Snippet unten haben wir den rd_data
geändert Ausgabe von einem synchronen Prozess an einen kombinatorischen Prozess, der für ram
empfindlich ist und tail
Signal.
PROC_READ : process(ram, tail) begin rd_data <= ram(tail); end process;
Leider kann dieser Code nicht dem Block-RAM zugeordnet werden, da es möglicherweise eine kombinatorische Logik zwischen dem RAM-Ausgang und dem ersten Downstream-Register auf dem rd_data
gibt Signal.
Das folgende Bild zeigt die von Vivado gemeldete Ressourcennutzung. Das Block-RAM wurde durch LUTRAM ersetzt; eine Form von verteiltem RAM, die in LUTs implementiert ist. Die LUT-Nutzung ist von 37 LUTs auf 947 sprunghaft angestiegen. Nachschlagetabellen und Flip-Flops sind teurer als Block-RAM, das ist der ganze Grund, warum wir überhaupt Block-RAM haben.
Es gibt viele Möglichkeiten, einen Ringpuffer-FIFO im Block-RAM zu implementieren. Sie können den zusätzlichen Taktzyklus möglicherweise sparen, indem Sie ein anderes Design verwenden, aber es kostet in Form von zusätzlicher unterstützender Logik. Für die meisten Anwendungen ist der in diesem Artikel vorgestellte Ringpuffer ausreichend.
Aktualisierung:
Erstellen eines Ringpuffer-FIFOs im Block-RAM mit dem AXI-Ready/Valid-Handshake
Im nächsten Blogbeitrag werden wir eine bessere Testbench für das Ringpuffermodul erstellen, indem wir eingeschränkte zufällige Verifizierung verwenden .
Klicken Sie hier, um den folgenden Blogpost zu lesen:
Eingeschränkte zufällige Überprüfung
VHDL
- So erstellen Sie eine Liste von Zeichenfolgen in VHDL
- So erstellen Sie eine Tcl-gesteuerte Testbench für ein VHDL-Code-Sperrmodul
- So stoppen Sie die Simulation in einer VHDL-Testbench
- So erstellen Sie einen PWM-Controller in VHDL
- So generieren Sie Zufallszahlen in VHDL
- So erstellen Sie eine selbstüberprüfende Testbench
- So erstellen Sie eine verknüpfte Liste in VHDL
- So verwenden Sie eine Prozedur in einem Prozess in VHDL
- So verwenden Sie eine unreine Funktion in VHDL
- So verwenden Sie eine Funktion in VHDL