01signal.com

FPGA-FIFOs: Varianten und Zusatzfunktionen

Themenbereich

Diese Seite ist die zweite in einer fünfteiligen Serie über FIFOs. Nachdem die vorherige Seite die Grundlagen eines FIFO vorgestellt hat, ist es an der Zeit, gängige Varianten und Zusatzfunktionen zu besprechen. FIFOs werden häufig als Kombination der unten beschriebenen Optionen konfiguriert.

Single-Clock-FIFOs

Obwohl der „Basis-FIFO“ zwei Eingänge für nicht miteinander verwandte Takte (unrelated clocks) besitzt, sind die Signale auf beiden Seiten oft mit demselben Takt synchron. Es ist völlig in Ordnung, diesen Takt sowohl an @wr_clk als auch an @rd_clk anzuschließen. Da an beiden Takteingängen aber derselbe Takt anliegt, ist kein Taktdomänenwechsel (clock domain crossing) nötig – der FIFO enthält also unnötige Logik. Mehr über Taktdomänen finden Sie hier.

Jeder FPGA-Hersteller bietet daher zwei FIFO-Kategorien an: Dual-Clock-FIFOs und Single-Clock-FIFOs. Häufig werden auch andere Bezeichnungen verwendet: Independent-Clock-FIFO und Common-Clock-FIFO oder asynchrones FIFO und synchrones FIFO. Der „Basis-FIFO“, der auf der vorherigen Seite vorgestellt wurde, ist ein Dual-Clock-FIFO.

Single-Clock-FIFOs benötigen keine Synchronisationslogik, weil die gesamte Logik mit demselben Takt synchron ist. Der Reset-Eingang muss daher mit demselben Takt synchron sein wie die übrigen Ports.

Neben der Ersparnis an FPGA-Logik spricht auch die Klarheit für den Einsatz von Single-Clock-FIFOs: Man signalisiert damit unmissverständlich, dass kein zweiter Takt vorgesehen ist.

Kurz gesagt: Wenn der FIFO nicht zwischen zwei Taktdomänen (clock domains) vermittelt, verwenden Sie ein Single-Clock-FIFO.

FWFT-FIFOs

Wie auf der vorherigen Seite betont, besteht das Lesen aus einem „Basis-FIFO“ darin, @rd_en auf high zu setzen und den Wert an @dout erst im nächsten Taktzyklus zu erhalten. Das wirkt etwas kontraintuitiv: Wenn die Daten bereits im FIFO liegen, warum muss ich danach fragen? Warum kann der FIFO sie nicht einfach an @dout legen und mir mitteilen, dass ich sie verwenden darf?

Es gibt also eine verbreitete Variante, die genau das tut; sie heißt First-Word-Fall-Through-FIFO (FWFT, manchmal auch Read-Ahead-, Show-Ahead- oder Look-Ahead-FIFO). Das Gegenteil eines FWFT-FIFO wird oft als „Standard-FIFO“ bezeichnet (kann mir jemand die Norm zeigen?).

Die Idee ist einfach: Wenn ein FWFT-FIFO nicht mehr leer ist (weil Daten hineingeschrieben wurden), legt er das erste Wort an @dout. Die Anwendungslogik liest dann Wörter, indem sie @rd_en high hält. Der Unterschied betrifft also nur das erste Wort.

Leichter versteht man ein FWFT-FIFO jedoch, wenn man erkennt, dass sich die Bedeutung zweier Ports geändert hat: @rd_en bedeutet bei einem FWFT-FIFO eigentlich „Ich habe die Daten an @dout gerade übernommen; es kann das nächste Wort kommen“, und @empty (leer) bedeutet eigentlich „@dout ist nicht gültig“.

Unverändert gilt: @rd_en sollte nicht high sein, wenn @empty (leer) high ist. Man kann ja nicht behaupten, ungültige Daten übernommen zu haben. Die Regel bleibt also dieselbe – nur aus einem anderen Grund.

Die folgende Wellenform zeigt, wie das Lesen aus einem FWFT-FIFO aussehen kann:

Example waveform for reading words from a FWFT FIFO

Beachten Sie, dass der erste gültige Wert an @dout erscheint, während @rd_en low ist, und dass @empty (leer) gleichzeitig mit dem gültigen Wert auf low wechselt. Wie schon erwähnt, bedeutet @empty bei einem FWFT-FIFO „@dout nicht gültig“, und die Wellenform verdeutlicht das.

Beachten Sie außerdem, dass der erste Impuls an @rd_en keinen neuen Wert aus dem FIFO gelesen, sondern nur bewirkt hat, dass @empty (leer) wieder high wurde. Dementsprechend wurde der Wert von @dout im selben Moment unbekannt. In der Praxis ändert sich @dout meistens nicht, wenn @empty high wird, aber verlassen kann man sich darauf nicht.

Danach legt der FIFO erneut einen Wert an @dout und setzt @empty (leer) auf low. Die Anwendungslogik liest drei Wörter und setzt @rd_en dann auf low. Insgesamt hat die Anwendungslogik also vier oder fünf Wörter aus dem FIFO entnommen.

Beachten Sie, dass die Wellenform nicht verrät, ob die Anwendungslogik auch den Wert von D4 verwendet hat. Sie könnte das fünfte Wort ignoriert haben, was bedeuten würde, dass sie nur vier Wörter entnommen hat. Oder sie könnte den fünften Wert verwendet haben. Das einzig Klare, was die Wellenform zeigt, ist, dass die Anwendungslogik @rd_en nach vier Taktzyklen low gehalten hat und dem FIFO so nicht erlaubt hat, @dout weiter zu aktualisieren.

Ein weiterer Punkt: Wir wissen nicht, ob im Speicher des FIFO noch weitere Daten liegen. Dass @empty (leer) am Ende dieser Wellenform low ist, bedeutet nur, dass der Wert an @dout gültig ist.

Nun ändern wir das Verilog-Beispiel von der vorherigen Seite. Auch dieser Code berechnet die laufende Summe all dessen, was den FIFO verlässt:

assign rd_en = !empty; // If @dout's value is valid, it's consumed.

always @(posedge rd_clk)
  if (!empty) // FIFO is FWFT, so !empty means @dout contains valid data
    sum <= sum + dout; // Don't try this at home: @sum is never reset.

Anders als im vorherigen Beispiel wird hier ein FWFT-FIFO verwendet, sodass kein Register für den Wert von @rd_en aus dem vorherigen Taktzyklus erforderlich ist. Stattdessen kann @dout übernommen werden, wenn @empty (leer) low ist. Diese einfache Regel funktioniert, weil @rd_en high ist, wenn @empty low ist; damit liegt jedes Wort aus dem FIFO genau einen Taktzyklus lang gültig an @dout.

Ich möchte das Thema FWFT mit einem etwas abseitigen Punkt abschließen. Der Unterschied zwischen „Standard-FIFO“ und FWFT-FIFO spiegelt eine grundlegende Frage des Datenflusses zwischen zwei Logikmodulen wider: Muss die empfangende Seite nach den Daten fragen – oder legt die sendende Seite die Daten so schnell wie möglich vor, und die empfangende Seite bestätigt nur, dass es weitergehen kann? Stellen Sie sich diese Frage immer, wenn ein Modul Daten an ein anderes übergibt; fragen Sie insbesondere, ob die beiden Module sich darüber einig sind.

Asymmetrische FIFOs

Meistens darf der FIFO mit unterschiedlichen Breiten für @din und @dout konfiguriert werden. Das ist zum Beispiel dann nützlich, wenn Daten als 32-Bit-Wörter in den FPGA gelangen, die Anwendungslogik sie aber als Bytes, also 8-Bit-Wörter, verarbeitet. In diesem Fall wird die Schreibseite auf 32 Bit und die Leseseite auf 8 Bit Breite eingestellt. Beide Seiten verhalten sich wie gewohnt, außer dass man vier Lesezyklen braucht, um ein Wort zu verbrauchen, das mit einem einzigen Schreibzyklus eingefügt wurde.

Wenn die Leseseite breiter als die Schreibseite ist, verhält es sich wie zu erwarten: Die im FIFO gespeicherten Daten können erst dann an der Leseseite genutzt werden, wenn die geschriebenen Daten ein Wort von der Breite der Leseseite gefüllt haben.

Was die Reihenfolge des Zusammenpackens betrifft, scheinen alle FIFOs Little Endian zu verwenden. Packt ein FIFO zum Beispiel 32-Bit-Wörter in 8-Bit-Wörter, enthält das erste gelesene Wort die Bits [7:0] des ursprünglichen 32-Bit-Worts, danach [15:8], [23:16] und [31:24].

Wenn Sie diese Funktion nutzen möchten, prüfen Sie aber auf jeden Fall die Dokumentation.

Zum Zusammenspiel mit kombinatorischer Logik bei @empty und @full

Die Ports @empty (leer) und @full (voll) haben einen gemeinsamen Nachteil: Die Anwendungslogik muss im selben Taktzyklus auf sie reagieren. Anders gesagt muss @rd_en in kombinatorischer Logik (combinatorial logic) aus @empty (leer) gebildet werden, damit diese beiden Signale nie gleichzeitig high sind (das ist, wie bereits erwähnt, nicht zulässig). In gleicher Weise muss @wr_en in kombinatorischer Logik aus @full (voll) gebildet werden.

Die Verwendung kombinatorischer Logik (combinatorial logic) kann sich als Hindernis bei der Einhaltung der Timing-Constraints (timing constraints) erweisen. Das kann vor allem dann zum Problem werden, wenn die Taktfrequenz hoch ist (gemessen an den Spezifikationen des FPGA) und die Verknüpfung kompliziert. Der Hauptgrund für Schwierigkeiten liegt darin, dass @rd_en und @wr_en häufig auch in der Logik verwendet werden, die Daten erzeugt oder verbraucht. Insbesondere die Logikfunktion, die die Taktfreigabe (clock enable) für viele Elemente berechnet, kann von diesen Signalen abhängen. Wenn beispielsweise eine lange Pipeline (pipeline) Daten aus einem FIFO verarbeitet, muss die gesamte Pipeline-Logik angehalten werden, sobald der Datenstrom aus dem FIFO kurzzeitig stockt.

Um ganz präzise zu sein: Es gibt eine Möglichkeit, diese kombinatorische Verknüpfung zu vermeiden. Angenommen, @wr_en wird als Register deklariert und @want_to_write ist ein Signal, das den Schreibwunsch der Anwendungslogik zu einem bestimmten Zeitpunkt darstellt. Dann ist Folgendes möglich:

always @(posedge wr_clk)
  wr_en <= want_to_write && !wr_en && !full;

Dadurch ist sichergestellt, dass @wr_en und @full (voll) nie im selben Taktzyklus high sind, denn @full (voll) kann erst in dem Taktzyklus high werden, der auf einen Zyklus mit @wr_en = high folgt. Der Ausdrucksteil !wr_en sorgt dafür, dass @wr_en nie zwei aufeinanderfolgende Taktzyklen lang high ist. Wenn @full (voll) also high wird, ist @wr_en wegen !wr_en im ersten darauffolgenden Taktzyklus low. @wr_en bleibt danach wegen @full selbst low.

Bei dieser Lösung muss @wr_en die halbe Zeit low sein; dadurch wird nur 50 % der FIFO-Datenrate genutzt. Das ist normalerweise nicht akzeptabel.

Dieselbe Lösung ist für @rd_en möglich, und sie hat dasselbe Problem: Es wird nur die halbe Datenrate genutzt.

Diese Überlegungen führen direkt zum nächsten Abschnitt: den „Almost-Full“-/„Almost-Empty“-Ports.

Almost full, almost empty und ähnliche Ports

Ein FIFO kann um zwei optionale Ports erweitert werden: einen @almost_full-Ausgang und/oder einen @almost_empty-Ausgang.

@almost_empty (deutsch: „fast leer“) ist mit @rd_clk synchron und funktioniert ähnlich wie @empty (leer), allerdings mit einem kleinen Unterschied: @almost_empty ist high, wenn der FIFO empty (leer) ist, aber auch, wenn genau noch ein Wort im FIFO auf das Lesen wartet.

@almost_full (deutsch: „fast voll“) ist entsprechend mit @wr_clk synchron und ist high, wenn der FIFO full (voll) ist, aber auch, wenn noch genau ein Wort geschrieben werden kann.

Die Namen dieser beiden Ausgangsports hängen vom FPGA-Hersteller und seiner Software ab, aber eine Möglichkeit, Ports mit derselben Funktion hinzuzufügen, gibt es immer. Nur bei bestimmten FIFO-Varianten sind diese Ports gelegentlich nicht verfügbar.

Wie helfen diese Ports? Nun, weil das hier einwandfrei funktioniert:

always @(posedge wr_clk)
  wr_en <= want_to_write && !almost_full;

Keine kombinatorische Logik und kein Weglassen der halben Schreibzyklen. Wenn @almost_full (deutsch: „fast voll“) high wird, muss @wr_en nicht im selben Taktzyklus auf low gehen, sondern erst im nächsten. Dadurch kann nach dem High-Wechsel von @almost_full noch genau ein Schreibvorgang stattfinden. Das ist in Ordnung, denn für genau ein Wort ist noch Platz.

Beachten Sie, dass bei dauerhaft auf high gehaltenem @want_to_write der letzte Schreibvorgang den FIFO vollständig füllt. Andernfalls kann es passieren, dass der FIFO am Ende nur fast voll ist: Ist @wr_en gerade wegen @want_to_write low und wird der FIFO deshalb nicht ganz voll, gibt es keine zweite Chance. @almost_full wechselt erst dann auf low, wenn die andere Seite Daten aus dem FIFO liest; es ist dann Platz für zwei oder mehr Wörter im FIFO.

Das spielt selten eine Rolle, aber der Vollständigkeit halber: Dieser Code stellt sicher, dass auch das letzte Wort verwendet wird:

always @(posedge wr_clk)
  wr_en <= want_to_write && (!almost_full || (!full && !wr_en));

Dieser Ausdruck für @wr_en verlässt sich meist auf @almost_full, außer wenn genau noch ein Wort geschrieben werden kann. Nur dann hängt @wr_en von @full (voll) und @wr_en ab, ähnlich wie im weiter oben gezeigten Ausdruck mit !wr_en.

Ob dieser letzte Ausdruck für @wr_en allerdings praktischen Nutzen hat, bezweifle ich ernsthaft.

Bei @almost_empty ist die Geschichte ähnlich; Folgendes ist also zulässig (aber übernehmen Sie das nicht in Ihren Code):

always @(posedge rd_clk)
  rd_en <= want_to_read && !almost_empty;

Wie bei @almost_full gibt es eine Sache mit dem letzten Wort: Wenn @rd_en wegen @want_to_read low ist, verpasst die Logik ihre Chance, bis der FIFO wieder mit Daten gefüllt wird. Anders als bei @almost_full kann das in manchen Szenarien durchaus ein Problem sein: Ist @almost_empty high, der FIFO aber nicht empty (leer), liegen im FIFO Daten, die zum Lesen vorgesehen waren, aber im FIFO hängen bleiben.

Der sichere Weg ist also:

always @(posedge rd_clk)
  rd_en <= want_to_read && (!almost_empty || (!empty && !rd_en));

Füllstandszähler

Die Anwendungslogik arbeitet häufig mit Datenblöcken. Beispielsweise liest eine Logik Pakete fester Länge aus dem FIFO und sendet sie über ein physikalisches Medium. Da die Daten in einem FIFO gespeichert sind, muss die Anwendungslogik wissen, dass zum Füllen eines Pakets genügend Daten im FIFO liegen, bevor sie mit dem Lesen beginnt.

Ebenso erzeugt die Anwendungslogik häufig eine feste Datenmenge, die in einen FIFO geschrieben werden soll, z. B. beim Lesen eines Bursts aus einem externen Speicher. Der Vorgang sollte erst beginnen, wenn im FIFO genügend Platz für den kompletten Burst ist.

Zu diesem Zweck unterstützen FIFOs normalerweise Füllstandszähler (fill counters), einen programmierbaren Empty-Port (leer) und einen programmierbaren Full-Port (voll). Die Füllstandszähler (manchmal auch Datenzähler genannt) gibt es in unterschiedlichen Formen und Ausführungen, was stark vom FPGA-Hersteller abhängt; lesen Sie die FIFO-Dokumentation also sorgfältig. Dabei sind drei Hauptpunkte zu beachten:

Darüber hinaus gibt es programmierbares Empty und programmierbares Full – also erweiterte Versionen von @almost_empty (deutsch: „fast leer“) und @almost_full (deutsch: „fast voll“). Die Idee ist: Da die Verwendung eines Füllstandszählers mit ziemlicher Sicherheit so aussähe:

assign dont_start_reading = (rd_data_count < 64);

Warum dieses Signal nicht direkt anbieten und es prog_empty nennen? Auch hier gilt: Ein genauer Blick in die FIFO-Dokumentation ist wichtig.

Und wenn es darauf ankommt, auch das letzte Wort im FIFO zu lesen, fragen Sie sich auf jeden Fall, ob Ihre Logik das wirklich tut. Diese Frage ähnelt der weiter oben geführten Diskussion über @almost_empty.

Es versteht sich fast von selbst, dass Sie diese zusätzlichen Ports bei der FIFO-Konfiguration anfordern müssen, wenn Sie sie nutzen möchten.

AXI-Schnittstelle

Dieses Thema hängt nicht direkt mit dem bisherigen zusammen, sollte aber erwähnt werden, um Verwirrung zu vermeiden, denn der Begriff taucht im Zusammenhang mit FIFOs häufig auf.

AXI ist eine Sammlung von Schnittstellen, die im AMBA-Standard definiert sind, welcher von ARM eingeführt wurde. Wie zu erwarten, sind FIFOs mit AXI-Schnittstelle meistens dafür gedacht, als Peripherie eines Prozessors zu arbeiten.

Die Schnittstelle des „Basis-FIFO“ wird oft als „native“ Schnittstelle bezeichnet – im Gegensatz zu einer AXI-Schnittstelle.

Es gibt zwei Haupttypen von AXI-Schnittstellen: das „normale“ AXI (üblicherweise AXI3, AXI4 oder AXI-Lite), das ein Bus mit Adress- und Datenleitungen ist. Der zweite Typ, AXI-S (Streaming-AXI), ist für Datenströme gedacht (möglicherweise in Pakete aufgeteilt).

Wenn ein FIFO als AXI3/AXI4 oder AXI-Lite konfiguriert wird, wird zusätzliche Logik hinzugefügt, sodass er als adressierbare Peripherie über diese Schnittstelle an eine CPU angeschlossen werden kann. Darauf werde ich nicht weiter eingehen, denn das ist ein völlig anderes Thema.

Weil aber die Streaming-Schnittstelle dem Verhalten eines FIFO etwas ähnelt, kann man die Handshake-Signale der AXI-S-Schnittstelle in eine „native“ Schnittstelle umsetzen. Beachten Sie, dass AXI-S häufig weitere Signale umfasst, die ebenfalls beachtet werden müssen.

Wenn also die AXI-S-Signale zum Schreiben in den FIFO @axi_w_valid, @axi_w_ready und @axi_w_data sind, kann man sie folgendermaßen mit den Ports eines „Standard“-FIFO verbinden:

assign axi_w_ready = !full;
assign wr_en = axi_w_valid && axi_w_ready;
assign din = axi_w_data;

Entsprechend können die AXI-S-Signale zum Lesen aus dem FIFO, @axi_r_valid, @axi_r_ready und @axi_r_data, mit den Ports eines FWFT-FIFO verbunden werden:

assign axi_r_valid = !empty; // Non-empty means valid with FWFT FIFOs
assign rd_en = axi_r_valid && axi_r_ready;
assign axi_r_data = dout;

Auch hier gilt: Damit das funktioniert, muss der FIFO eine FWFT-Variante sein.

Damit ist die zweite Seite in dieser Serie über FIFOs abgeschlossen. Die nächste Seite zeigt, wie ein Single-Clock-FIFO in Verilog implementiert wird.

Diese Seite wurde maschinell aus dem Englischen übersetzt. Im Zweifelsfall siehe den Originaltext.
Copyright © 2021-2026. All rights reserved. (dcc38493)