01signal.com

Taktdomänenwechsel mit Daten

Diese Seite ist die letzte in einer Serie von drei Seiten über Taktdomänen.

Wenn ein einzelnes Bit nicht ausreicht

Sehr häufig ist das Signal, das eine Taktdomäne (clock domain) wechseln soll, ein Datenwort und kein einzelnes Bit. Die naheliegende Lösung ist in diesem Fall der Dual-Clock-FIFO des FPGA-Herstellers, wie bereits empfohlen. Manchmal ist das aber keine Option. Außerdem musste schließlich jemand diesen FIFO einmal implementieren.

Das Ziel ist es also, ein Vektorsignal in einer anderen Taktdomäne korrekt erscheinen zu lassen. Beginnen wir mit einem naiven und falschen Beispiel für einen Taktdomänenwechsel (clock domain crossing), um zu erklären, warum das nicht so einfach ist:

reg [7:0] foo, bar, bar_metaguard;

always @(posedge clk1)
  foo <= foo + 1;

always @(posedge clk2)
  begin
    bar_metaguard <= foo; // This will fail sometimes!
    bar <= bar_metaguard;
  end

Das ist genau wie das einfache Metastabilitäts-Guard-Beispiel auf der vorherigen Seite, nur dass die Register 8-Bit-Vektoren sind und @foo ein Zähler ist, statt zwischen „0“ und „1“ zu wechseln.

Warum ist das falsch? Das Problem sind die unterschiedlichen Routing-Verzögerungen auf den Pfaden (paths) von @foo zu @bar_metaguard: Jedes der acht Bits hat eine andere Verzögerung. Wenn sich @foo ändert, können einige dieser Bitänderungen mit korrektem Timing an den Flip-Flops von @bar_metaguard ankommen, andere nicht rechtzeitig.

Auch wenn in keinem der acht Flip-Flops, aus denen @bar_metaguard besteht, Metastabilität auftritt, kann es also vorkommen, dass sich @foo ändert und nur einige Bits von @bar_metaguard den neuen Wert übernehmen, während andere den alten behalten. Wechselt @foo zum Beispiel von 0xff auf 0x00, kann der nächste Wert von @bar_metaguard irgendetwas sein. Das liegt daran, dass einige Bits den Wert von @foo vor der Änderung übernommen haben und andere erst nach der Änderung. Dieser falsche Wert wird nach einem Takt von @clk2 auf @bar sichtbar.

Um das Problem zu lösen, muss man zunächst klären, was eigentlich gebraucht wird: Soll @bar zu jedem Zeitpunkt einen gültigen Wert enthalten, oder soll gelegentlich eine Information von einer Taktdomäne in die andere übertragen werden? Ich bespreche diese zwei Möglichkeiten getrennt.

Option Nr. 1: Kontinuierliches Abtasten

Wenn das Zielwort (@bar im Beispiel) das Wort der anderen Taktdomäne (@foo) kontinuierlich abtasten (sampling) und immer einen gültigen und sinnvollen Wert enthalten muss, gibt es nur einen Weg, das sicherzustellen: Man verwendet die naive Methode von oben, stellt aber sicher, dass sich in jedem Taktzyklus von @clk1 nur eines der Bits von @foo ändert (oder keines). Anders gesagt: Verwenden Sie einen Metastabilitäts-Guard für das Vektorsignal, aber vermeiden Sie das Problem, dass sich mehrere Bits im selben Taktzyklus ändern.

Da sich pro Taktzyklus nur ein Bit ändern kann, wird jede Änderung von @bar_metaguard entweder abgetastet oder verpasst. In beiden Fällen entspricht der Wert einem Wert, den @foo tatsächlich hatte.

Wenn @foo im obigen Beispiel also ein Zähler wäre, der statt des üblichen Binärcodes einen Gray-Code verwendet, würde das einwandfrei funktionieren: Das Wesen des Gray-Codes besteht darin, dass sich bei jedem Hochzählen des Worts nur ein Bit ändert. @bar wäre also garantiert immer sinnvoll.

Aber was ist, wenn @clk1 eine höhere Frequenz als @clk2 hat? Das spielt keine Rolle, solange es in Ordnung ist, dass @bar einige Werte von @foo überspringt. Wenn @foo zum Beispiel ein Zähler im Gray-Code-Format ist, können beim Betrachten von @bar einige Zählwerte fehlen. Trotzdem sind alle Werte, die auf @bar erscheinen, in dem Sinne korrekt, dass sie zu irgendeinem Zeitpunkt tatsächlich in @foo vorkamen.

Die häufigste Anwendung dieser Methode findet sich in Dual-Clock-FIFOs: Dort wird der Gray-Code verwendet, um die Adresse im RAM des FIFO zwischen den beiden Taktdomänen zu übertragen. Die Schreibseite des FIFO codiert die Adresse des letzten Worts, das sie in den RAM geschrieben hat, in Gray-Code. Das codierte Wort wird mithilfe von Metastabilitäts-Guards auf die Leseseite übertragen, die in einer anderen Taktdomäne liegt. In der anderen Richtung wird genauso verfahren.

Da jede Seite die aktualisierte Adresse der anderen Seite kennt (nach der Verzögerung durch die Metastabilitäts-Guards), kann jede Seite auch berechnen, wie viele Elemente sich im FIFO befinden, und daraus Signale wie „empty“ (leer) und „full“ (voll) erzeugen.

Ereignisse mit höherer Rate übertragen

Wie auf der vorherigen Seite erwähnt, hat der Metastabilitäts-Guard mit einem einzelnen Bit eine Einschränkung bezüglich der Frequenz des Takts in der Taktdomäne der Quelle. Wenn jede Änderung dieses Bits ein Ereignis an die andere Seite meldet, besteht die Gefahr, dass die empfangende Seite Ereignisse verpasst, wenn diese zu häufig auftreten.

Die Lösung besteht darin, einen im Gray-Code codierten Zähler über die Taktdomänen zu übertragen. Auf diese Weise weiß die empfangende Seite, wie viele Ereignisse aufgetreten sind, und verliert keine Informationen. Die Bitbreite dieses Zählers wird so gewählt, dass die empfangende Seite auch dann noch ableiten kann, wie viele Ereignisse stattgefunden haben, wenn in jedem Taktzyklus von @clk1 ein Ereignis auftritt.

In diesem Sinne kann es also einfacher sein, mit einem Vektor zu arbeiten als mit einem einzelnen Bit: Wenn sich ein einzelnes Bit ändert und diese Änderung verpasst wird, weil der Takt des Ziels langsamer ist, hat man zur Folge, dass man überhaupt nicht bemerkt, dass etwas passiert ist. Wird dagegen ein Wort korrekt über die Taktdomänen übertragen (z. B. mit Gray-Code), geht keine Information verloren.

Und wenn für diesen Zweck ein einziges Bit ausreicht, ist der Gray-Code-Zähler eben ein einzelnes Bit, das bei jedem Ereignis seinen Wert wechselt. Mit anderen Worten: Bei einem einzelnen Bit ist die Lösung mit dem Gray-Code-Zähler genau dasselbe wie der einfache Metastabilitäts-Guard.

Eine pedantische Anmerkung zum Timing der Pfade

Wie der Titel schon andeutet, kann man diesen Abschnitt getrost überspringen.

Den Metastabilitäts-Guard für Vektorsignale betrifft eine stillschweigende Annahme: dass der Unterschied zwischen den Verzögerungen dieser Pfade eine Taktperiode des Quelltakts nicht überschreitet.

Diese Annahme ist mit an Sicherheit grenzender Wahrscheinlichkeit erfüllt, ohne dass man etwas Besonderes dafür tun muss. Dennoch wollen wir dieses theoretische Beispiel betrachten: Angenommen, @clk1 hat 500 MHz und einer der Pfade von @foo zu @bar_metaguard hat eine Routing-Verzögerung von 1 ns. Ein weiterer Pfad habe eine Routing-Verzögerung von 4 ns, was natürlich äußerst unwahrscheinlich ist – aber schauen wir, was passieren kann:

Eines der Bits ändert seinen Wert, und die Änderung beginnt eine Reise, die 4 ns dauert. Im folgenden Taktzyklus, 2 ns später, ändert sich das andere Bit und erreicht @bar_metaguard nach 1 ns – also 1 ns früher als das erste Bit ankommt. Dadurch kann @bar_metaguard ein gesamtes Wort abtasten, das @foo nie hatte.

Da Routing-Verzögerungen in der Regel viel kürzer sind als in diesem Beispiel, ist so etwas in der Praxis nicht zu erwarten. Trotzdem ist jede Routing-Verzögerung theoretisch möglich. Um diese Möglichkeit ganz auszuschließen, kann man folgende Timing-Constraint verwenden (im Vivado-Format):

set_max_delay -datapath_only -from [ get_pins -hier -filter {name=~*/C} ] -to [ get_pins -hier -filter {name=~*_metaguard*/D} ] 1.5

Diese Constraint ähnelt der set_max_delay-Constraint auf der vorherigen Seite. Beachten Sie jedoch, dass auf der vorherigen Seite der Metastabilitäts-Guard im Teil „-from“ steht und hier im Teil „-to“. Die Constraints betreffen also nicht dieselben Pfade. Der Zweck der Constraint auf der vorherigen Seite war es, dem Metastabilitäts-Guard etwas Zeit zur Erholung von der Metastabilität zu geben. Dementsprechend gilt diese Constraint für Pfade innerhalb desselben Takts. Die Constraint hier dagegen bezieht sich auf den Taktdomänenwechsel selbst.

Deshalb ist diese Constraint anders formuliert: Da die betreffenden Pfade zwischen Taktdomänen nicht verwandter Takte verlaufen, ist es sinnlos, den Taktversatz (clock skew) und den Jitter (jitter) dieser Takte zu berücksichtigen. Genau das sagt der Teil -datapath_only: Ignoriere die Zeit, die die Takte zu den Flip-Flops brauchen. Miss einfach den Pfad.

Das Verwirrende an dieser Constraint ist, dass der Pfad am Takteingang des Flip-Flops der Quelle beginnt und am Dateneingang (D) des Ziels endet. Die Stoppuhr startet also, wenn das Flip-Flop der Quelle seinen Takt erhält, und stoppt, wenn das aktualisierte Signal am Ziel ankommt; dieses Signal muss dessen Setup-Zeit erfüllen. Der Pfad umfasst daher die Timing-Anforderungen beider Seiten.

Indem man alle diese Pfade wie in dieser Timing-Constraint auf 1,5 ns begrenzt, kann kein Pfad diese Zeitschranke überschreiten; damit ist auch die Streuung zwischen den Pfadverzögerungen auf diesen Wert begrenzt. Selbst wenn @clk1 also eine Taktperiode von 2 ns hat, können die Pfade nicht in falscher Reihenfolge ankommen. Das ist, wie gesagt, ohnehin äußerst unwahrscheinlich, aber so stellt man es sicher.

Beachten Sie, dass set_max_delay wahrscheinlich keine Wirkung hat, wenn der Pfad zum Metastabilitäts-Guard von einer Constraint für falsche Pfade (false path) betroffen ist (z. B. set_false_paths oder set_clock_groups): Die False-Path-Constraint hat vermutlich Vorrang. Prüfen Sie deshalb immer die Pfade im Timing-Bericht, um sicherzustellen, dass die Tools die Constraints wie gewünscht interpretieren. Eine andere Seite über Timing behandelt das.

Option Nr. 2: Gelegentliches Aktualisieren des Registers

Die Einschränkung, dass sich pro Taktzyklus nur ein Bit ändern darf, ist oft zu streng. Wenn die Daten nur gelegentlich aktualisiert werden, kann man eine andere Technik verwenden. Nehmen wir für das folgende Beispiel an, dass @do_update nur einmal in mehreren Taktzyklen aktiv ist (also den Wert „1“ hat). Nehmen wir außerdem an, dass dieses Signal anzeigt, dass der Wert von @foo mit @new_value aktualisiert werden soll:

reg [7:0] foo, bar;
reg       toggle, toggle_metaguard, toggle_a, toggle_b;
reg       new_bar;

always @(posedge clk1)
  if (do_update)
    begin
      foo <= new_value;
      toggle <= !toggle;
    end

always @(posedge clk2)
  begin
    toggle_metaguard <= toggle;
    toggle_a <= toggle_metaguard;
    toggle_b <= toggle_a;

    if (toggle_a != toggle_b)
      bar <= foo; // No metastability guard, because foo is stable
    new_bar <= (toggle_a != toggle_b); // Not necessary, just side info
  end

@new_bar ignorieren Sie vorerst; darauf komme ich später zurück.

So funktioniert das: @foo wird nur aktualisiert, wenn @do_update aktiv ist. Wenn das passiert, wechselt @toggle im selben Taktzyklus auf seinen entgegengesetzten Wert.

In der Taktdomäne von @clk2 übernimmt @toggle_metaguard den Wert von @toggle als Metastabilitäts-Guard. Im nächsten Taktzyklus wird dieser Wert nach @toggle_a kopiert. Im übernächsten Taktzyklus wird der Wert von @foo direkt nach @bar kopiert. Das liegt daran, dass @toggle_a und @toggle_b während genau eines Taktzyklus unterschiedliche Werte haben.

Dass @bar und @foo in verschiedenen Taktdomänen liegen, spielt keine Rolle, denn @foo ist seit Langem stabil – mehr als genug Zeit, um die Timing-Anforderungen zu erfüllen.

Warum bin ich mir da so sicher? Diesmal habe ich einen guten Grund, und der lautet: Der gesamte Vorgang beginnt, wenn @toggle_metaguard seinen Wert ändert, weil @toggle das getan hat. Hätte @bar @foo im selben @clk2-Zyklus abgetastet, wäre das unsicher gewesen, vielleicht aber mit etwas Glück noch in Ordnung. Aber bis der neue Wert von @toggle_metaguard bei @toggle_a ankommt, vergeht noch ein weiterer Taktzyklus von @clk2. Und @bar wird nicht einmal dann aktualisiert, sondern erst im darauffolgenden Taktzyklus von @clk2.

Vom Moment der Änderung von @foo bis zum Abtasten von @foo durch @bar vergeht also eine Zeitspanne von mindestens zwei Taktzyklen von @clk2. Verglichen mit der Setup-Zeit eines Flip-Flops ist das eine Ewigkeit. Dennoch ist es sinnvoll, set_max_delay wie auf der vorherigen Seite gezeigt auf @toggle_metaguard anzuwenden. Dasselbe kann man mit den Pfaden zu @bar tun, auch wenn es wegen der eben erwähnten Ewigkeit sehr unwahrscheinlich ist, dass das nötig ist.

Die Achillesferse dieser Methode ist, dass @do_update selten genug aktiv sein muss, damit @foo stabil bleibt, wenn es von @bar abgetastet wird. Ein vernünftiger Mindestabstand zwischen solchen Aktualisierungen entspricht vier Taktzyklen von @clk2. Man rechnet also aus, wie viele Taktzyklen von @clk1 vier Taktzyklen von @clk2 entsprechen, und rundet auf die nächste ganze Zahl auf. Wenn @clk1 viermal langsamer als @clk2 ist (oder noch langsamer), ist das überhaupt keine Einschränkung. Andernfalls muss die Logik einen Mechanismus enthalten, der sicherstellt, dass @do_update nicht häufiger aktiv wird als erlaubt.

Die Wahrheit ist, dass in realen Entwürfen bei sehr langsamen Aktualisierungsraten Taktdomänen manchmal sorglos gewechselt werden, ohne den Schutz, den @toggle bietet. Wenn man es so macht, wird @foo kontinuierlich nach @bar kopiert. Wenn sich @foo dann einmal in langer Zeit ändert, kann @bar während eines Taktzyklus einen falschen Wert enthalten – aber wen kümmert das? In den meisten Fällen ist dieser Fehler die Folge davon, dass man das ganze Thema Taktdomänen vernachlässigt, denn hey, es funktioniert ja. Bis es gelegentlich eben doch nicht funktioniert.

Da wir gerade beim Thema Nachlässigkeit sind: Beachten Sie, dass weder @toggle noch eines der zugehörigen Register im obigen Beispiel zurückgesetzt oder mit einem Anfangswert belegt wird. Das ist meist in Ordnung, denn der Synthesizer (synthesizer) weist ihnen mit hoher Wahrscheinlichkeit alle den Anfangswert 0 zu. Selbst wenn diese Register anfangs nicht denselben Wert haben, führt das nur zu einem unnötigen Abtasten von @foo, mehr nicht. Es kann trotzdem eine gute Idee sein, diese Register zurückzusetzen.

Fortgeschrittenere Varianten

Bisher habe ich drei einfache Beispiele vorgestellt:

Diese einfachen Beispiele sind die Grundlage für verschiedene andere Mechanismen.

Zunächst habe ich versprochen, etwas zu @new_bar im obigen Beispiel zu sagen. @new_bar ist also nur ein Register, das während eines Taktzyklus hoch ist, wenn @bar einen neuen Wert hat. Besonders ist das nicht. Beachten Sie aber, dass @bar und @new_bar in der anderen Taktdomäne @foo und @do_update widerspiegeln. Das ist also eine Möglichkeit, Befehle und Statusmeldungen über eine Taktdomäne zu übertragen (habe ich erwähnt, dass man stattdessen möglichst einen FIFO verwenden sollte?).

Eine weitere interessante Erweiterung des letzten Beispiels besteht darin, anstelle des Registerpaars @foo und @bar einen Dual-Port-RAM zu verwenden. Damit lassen sich Datenpuffer über Taktdomänen übertragen: Angenommen, die Logik in der Taktdomäne von @clk1 schreibt Daten in den RAM und füllt nach einiger Zeit eine Hälfte dieses RAM. Während die Logik damit fortfährt, die zweite Hälfte des RAM zu füllen, ändert sie den Wert von @toggle. Dieses Register wird genau wie oben gezeigt in die Taktdomäne von @clk2 kopiert. Statt aber @bar zu aktualisieren, verarbeitet die Logik die Daten in der ersten Hälfte des RAM.

So kann dieses einfache Register einen Doppelpuffer-Mechanismus synchronisieren, bei dem eine Seite Daten in den RAM schreibt und die andere Seite aus diesem RAM liest. Die Rolle von @toggle besteht übrigens nicht nur darin, seinen Wert zu ändern; es teilt der anderen Seite auch mit, welche Hälfte des RAM gerade beschrieben wird.

Und dennoch ist es am besten, wenn möglich einen FIFO zu verwenden. Auch wenn dieser Doppelpuffer-Mechanismus verlockend klingen mag, sollte man ihn nur einsetzen, wenn es keine bessere Alternative gibt. Zum Beispiel, wenn die Daten aus dem RAM in anderer Reihenfolge gelesen werden, als sie geschrieben wurden.

Zusammenfassung

Letztendlich läuft alles darauf hinaus: Beim Übergang zwischen Taktdomänen nicht verwandter Takte ist immer Resynchronisationslogik im Spiel. Das Datenwort, das diese Resynchronisation durchläuft, ist eingeschränkt: Pro Taktzyklus des Quelltakts (@clk1 in den Beispielen) darf sich nur ein Bit ändern. Andernfalls können ungültige Daten am Ziel ankommen.

In manchen Anwendungen reicht das aus. Wenn die Einschränkung jedoch zu streng ist, können die Daten stattdessen über ein Vektorregister oder durch einen RAM zwischen den Taktdomänen bewegt werden, ohne dass auf den Daten selbst Resynchronisationslogik angewendet wird. Das funktioniert dank einer Logik, die einen minimalen zeitlichen Abstand zwischen dem Schreibvorgang und dem Lesevorgang der Daten einhält. Diese Zeitspanne stellt sicher, dass das Datenwort beim Abtasten am Ziel stabil ist. Diese Logik beruht allerdings auf derselben Technik für den Taktdomänenwechsel. Entsprechend enthält diese Lösung Resynchronisationslogik, die darauf beschränkt ist, jeweils nur ein Bit zu ändern – möglicherweise mit Gray-Code.

Resynchronisationslogik und die Regel mit dem einen Bit sind also immer vorhanden, wenn nicht verwandte Takte im Spiel sind. Es kommt nur darauf an, wie sie angewendet werden.

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