Ein robuster VHDL-Ringpuffer-FIFO im Block-RAM entwerfen
Funktionsweise eines Ringpuffers
Ringpuffer sind beliebte Konstrukte zur Implementierung von Warteschlangen (FIFOs) in sequenziellen Programmiersprachen, lassen sich aber auch in Hardware realisieren. In diesem Artikel erstellen wir einen Ringpuffer in VHDL, der einen FIFO in Block-RAM abbildet.
Bei der FIFO-Implementierung sind zahlreiche Entwurfsentscheidungen zu treffen: Welches Interface wird benötigt? Sind Ressourcen begrenzt? Muss der Puffer gegen Überlesen und Überschreiben geschützt sein? Ist Latenz akzeptabel? Diese Fragen stellen sich unweigerlich bei der Entwicklung eines FIFOs.
Es gibt viele freie FIFO-Implementierungen online sowie Generatoren wie Xilinx LogiCORE. Dennoch bevorzugen viele Ingenieure eine eigene Implementierung, da sich die Details – Timing, Ressourcenverbrauch, Robustheit – stark unterscheiden können, obwohl die Grundfunktionen (Enqueue/Dequeue) identisch sind.
Leerer Zustand
Zeigen Head- und Tail-Zeiger auf denselben Speicherplatz, ist der FIFO leer. Die Abbildung zeigt ein Beispiel mit acht Slots: Beide Zeiger stehen auf Index 0 – der initiale Zustand.
Wichtig: Der FIFO wäre auch leer, wenn beide Zeiger auf einem anderen Index (z. B. 3) stehen. Bei jedem Schreibzugriff rückt der Head-Zeiger um eine Position vor; bei jedem Lesezugriff der Tail-Zeiger. Erreicht ein Zeiger den höchsten Index, springt er beim nächsten Zugriff zurück auf den niedrigsten Index – das Prinzip des Ringpuffers: Die Daten bleiben an Ort und Stelle, nur die Zeiger bewegen sich.
Head führt vor Tail
Nach fünf Schreibzugriffen zeigt der Tail noch auf Slot 0, der Head auf Slot 5. Die belegten Slots sind hellblau markiert. Der Tail verweist auf das älteste Element, der Head auf den nächsten freien Slot.
Solange der Head-Index größer ist als der Tail-Index, ergibt die Differenz (Head − Tail) die Anzahl der gespeicherten Elemente – im Beispiel fünf.
Tail führt vor Head
Führt der Tail (Index 5) vor dem Head (Index 2), liefert die einfache Subtraktion ein negatives Ergebnis (−3). Die Lösung: Den Head-Index um die Gesamtzahl der Slots (hier 8) erhöhen: (2 + 8) − 5 = 5 – korrekt.
Der Tail „jagt“ dem Head永远 hinterher; die Daten liegen immer zwischen den beiden Zeigern (hellblau).
Voller Zustand
Ein voller Ringpuffer hat den Tail direkt hinter dem Head. Dadurch bleibt mindestens ein Slot ungenutzt (gelb markiert), um „voll“ von „leer“ unterscheiden zu können. Ein dediziertes Full/Empty-Flag würde alle Slots nutzbar machen, erfordert aber zusätzliche Register und LUTs. Wir wählen das „ein Slot frei lassen“-Verfahren, da es nur günstigen Block-RAM „verschwendet“.
Implementierung des Ringpuffer-FIFOs
Das Interface bestimmt die möglichen Implementierungen. Wir nutzen eine Variante des klassischen Read/Write-Enable-Interfaces mit Empty/Full/Valid-Signalen.
- Schreibseite: Dateneingang
wr_dataund Write-Enablewr_en. - Leseseite: Datenausgang
rd_data, Read-Validrd_valid(vom FIFO gesteuert) und Read-Enablerd_en(vom nachgeschalteten Modul). - Status-Flags:
empty,empty_next,full,full_next– alle vom FIFO getrieben.
Back-Pressure durch Vorab-Signale
Reagiert die externe Logik erst bei assertiertem empty oder full, ist es oft zu spät, da sequenzielle Logik taktsynchron arbeitet.
Lösung: empty_next und full_next signalisieren den Zustand einen Taktzyklus früher („almost empty/full“). Dies gibt der Außenlogik Zeit zu reagieren, auch bei kontinuierlichem Lesen/Schreiben.
Entity-Deklaration
Das Entity besitzt zwei Generics: RAM_WIDTH (Bitbreite pro Speicherplatz) und RAM_DEPTH (Anzahl der Slots). Da ein Slot für die Voll-Erkennung reserviert ist, beträgt die nutzbare Kapazität RAM_DEPTH − 1. RAM_DEPTH sollte an die Block-RAM-Tiefe des Ziel-FPGAs angepasst werden, um ungenutzten Speicher zu vermeiden.
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;
-- Fill level
fill_count : out integer range RAM_DEPTH - 1 downto 0
);
end ring_buffer;
rd_valid wird einen Taktzyklus nach rd_en asserted, sofern der FIFO nicht leer war. Die Flags: empty_next bei ≤1 Element, empty bei 0; full_next bei ≥ RAM_DEPTH−1 Elementen, full bei ≥ RAM_DEPTH−2. fill_count spiegelt die aktuelle Belegung wider und ist intern ohnehin nötig – nach außen geführt ist es „kostenlos“.
Deklarativer Bereich
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;
-- Inkrement mit Wrap-Around
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
ram_type modelliert den Speicher als Array von Vektoren. index_type begrenzt die Zeiger auf den gültigen Adressbereich. Interne Signale (_i) vermeiden inout-Ports. Die Prozedur incr handhabt das Wrap-Around der ganzzahligen Zeiger.
Concurrent Statements (Nebenläufige Zuweisungen)
-- Intern nach außen durchreichen empty <= empty_i; full <= full_i; fill_count <= fill_count_i; -- Flag-Logik basierend auf fill_count_i 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';
Diese Zuweisungen sind delta-zyklus-verzögert in der Simulation, aber synthetisierbar. Die Flags ändern sich nur bei Änderung von fill_count_i.
Head-Zeiger aktualisieren
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;
Der Schutz full_i = '0' verhindert Überschreiben: Würde der Head bei vollem FIFO inkrementiert, zeigte er auf den Tail – der FIFO würde fälschlicherweise als leer erscheinen. So geht nur das überschriebene Datum verloren.
Tail-Zeiger aktualisieren
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;
Analog zum Head wird empty_i geprüft. rd_valid pulsiert bei jedem gültigen Lesezugriff – ein Takt nach rd_en. Unbenutzt wird es in der Synthese optimiert.
Block-RAM-Inferenz
PROC_RAM : process(clk)
begin
if rising_edge(clk) then
ram(head) <= wr_data;
rd_data <= ram(tail);
end if;
end process;
Synchroner Lese-/Schreibzugriff ohne Reset lässt das Synthesis-Tool Block-RAM inferieren. Kontinuierliches Schreiben „verriegelt“ den Wert beim Inkrementieren von head.
Füllstandszähler
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;
Kombinatorischer Prozess, sensitiv auf head und tail. Da diese nur an Taktflanken wechseln, aktualisiert sich fill_count_i unmittelbar nach der Flanke.
Vollständiger VHDL-Code
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;
wr_en : in std_logic;
wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0);
rd_en : in std_logic;
rd_valid : out std_logic;
rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0);
empty : out std_logic;
empty_next : out std_logic;
full : out std_logic;
full_next : out std_logic;
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;
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
empty <= empty_i;
full <= full_i;
fill_count <= fill_count_i;
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';
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;
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;
PROC_RAM : process(clk)
begin
if rising_edge(clk) then
ram(head) <= wr_data;
rd_data <= ram(tail);
end if;
end process;
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;
Testbench
Die Testbench instanziiert den FIFO mit RAM_WIDTH = 16, RAM_DEPTH = 256. Nach Reset werden Werte 1–255 geschrieben bis full asserted, dann wieder gelesen. Das Wellenformbild (Analog-Darstellung von fill_count) zeigt den Füllstandverlauf.

Am Ende der Simulation zeigen Head und Tail beide 255, fill_count = 0. Die Testbench dient nur der Demonstration; sie enthält keine Selbstprüfung.
Synthese in Vivado
Synthese für Xilinx Zynq (RAMB36E1, max. Tiefe 2048). RAM_DEPTH = 2048 nutzt genau einen Block-RAM. Ressourcenbericht bestätigt: 1 Block RAM, wenige LUTs/FFs.


Verzicht auf das Valid-Signal?
Das rd_valid-Signal verzögert die Daten um einen Takt. Block-RAM ist vollsynchron: Lesen und Schreiben benötigen Taktflanken. Der Ausgabe-Register sitzt im Block-RAM-Primitive; dazwischen darf keine Logik liegen.
Drei Takte von wr_data bis rd_data, plus ein weiterer für das nachgeschaltete Modul.
Latenzreduktion?
Kombinatorisches Lesen (rd_data <= ram(tail)) spart einen Takt, verhindert aber Block-RAM-Inferenz → LUTRAM (verteilter RAM). Vivado zeigt: LUTs steigen von 37 auf 947. Block-RAM ist effizienter; für die meisten Anwendungen reicht der vorgestellte Ringpuffer.

Weiterführend: AXI Ready/Valid-Handshake für Ringpuffer-FIFO im Block-RAM. Nächster Artikel: Constrained Random Verification für bessere Testbenches.
VHDL
- String-Listen in VHDL erstellen: Best Practices & Beispiel
- Tcl-gesteuerter Testbench für ein VHDL Code-Lock-Modul erstellen
- VHDL-Testbench-Simulation sauber beenden
- PWM-Controller in VHDL implementieren: Design, Simulation & FPGA-Demo
- Zufallszahlen in VHDL generieren: Von Uniform zu OSVVM
- Selbstprüfenden VHDL-Testbench erstellen – Praxisleitfaden
- Dynamische verkettete Liste in VHDL mit Protected Types und Access-Pointern
- In-Prozess-Prozeduren für saubere VHDL-FSM-Entwürfe nutzen
- Impure Functions in VHDL nutzen: FSM-Lesbarkeit und Wartbarkeit steigern
- VHDL-Funktionen meistern: Praktischer Leitfaden für effizientes Design