Esta página es la última de una serie de tres páginas sobre dominios de reloj.
Cuando un bit no basta
Con bastante frecuencia, la señal que debe cruzar entre dominios de reloj (clock domain crossing) es una palabra de datos, y no un solo bit. La solución más directa en este caso es la FIFO de doble reloj (dual-clock FIFO) que suministra el fabricante de la FPGA, como ya se sugirió. Pero a veces esto no es una opción. Y además, alguien tuvo que implementar esa FIFO en el pasado.
Así que el objetivo es conseguir que una señal vectorial aparezca correctamente en otro dominio de reloj. Empecemos con un ejemplo incorrecto e ingenuo de cruce de dominios de reloj (clock domain crossing), solo para explicar por qué no es tan fácil:
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
Esto es exactamente igual que el ejemplo sencillo de protector de metaestabilidad (metastability guard) de la página anterior, pero los registros son vectores de 8 bits, y @foo es un contador en lugar de cambiar entre '0' y '1'.
¿Por qué está mal? El problema son las diferencias entre los retardos de enrutado de los caminos (paths) que van de @foo a @bar_metaguard: cada uno de los ocho bits tiene un retardo distinto. Cuando @foo cambia, algunos de esos cambios pueden llegar con una temporización válida a los flip-flops de @bar_metaguard, y otros no llegarán a tiempo.
Así que, aunque no haya metaestabilidad (metastability) en ninguno de los 8 flip-flops que forman @bar_metaguard, puede darse una situación en la que @foo cambie, y solo algunos de los bits de @bar_metaguard tomen el nuevo valor, mientras que otros se queden con el antiguo. Si @foo cambia de 0xff a 0x00, el siguiente valor de @bar_metaguard podría ser cualquier cosa. Eso se debe a que algunos bits capturaron el valor de @foo antes del cambio, y otros después. Ese valor incorrecto será visible en @bar después de un ciclo de @clk2.
Para resolver este problema, primero hay que definir la necesidad: ¿se exige que @bar contenga un valor válido en todo momento, o se pretende que pase información de un dominio de reloj a otro de vez en cuando? Voy a tratar estas dos opciones por separado.
Opción n.º 1: Muestreo continuo
Si se exige que la palabra de destino (@bar en el ejemplo) realice un muestreo continuo (sampling) de la palabra del otro dominio de reloj (@foo), y que contenga siempre un valor válido y con sentido, solo hay una manera de garantizarlo: usar el método ingenuo de cruce de dominios de reloj mostrado antes, pero asegurándose de que en cada ciclo de @clk1 cambie solamente uno de los bits de @foo (o ninguno). En otras palabras, usar un protector de metaestabilidad con la señal vectorial, pero evitando el problema de que cambien varios bits en el mismo ciclo de reloj.
Como solo puede cambiar un bit en cada ciclo de reloj, cada cambio o bien es muestreado o bien se pierde en @bar_metaguard; en cualquier caso, refleja uno de los valores que tuvo @foo.
Así pues, si @foo, en el ejemplo anterior, fuera un contador que utilizara código Gray en lugar del código binario ordinario, funcionaría perfectamente: la esencia del código Gray es que solo cambia un bit cada vez que la palabra incrementa, de modo que @bar tendría garantizado llevar siempre un valor con sentido.
Pero un momento, ¿qué pasa si @clk1 tiene una frecuencia mayor que @clk2? Eso no importa si está bien que @bar se salte algunos de los valores de @foo. Por ejemplo, si @foo es un contador en formato de código Gray, puede que al mirar @bar se salten algunos números. Aun así, todos los valores que se ven en @bar son correctos, en el sentido de que realmente aparecieron en @foo en algún momento.
El uso más habitual de este método se da dentro de las FIFOs de doble reloj, donde se emplea código Gray para transferir la dirección de la RAM de la FIFO entre los dos dominios de reloj: el lado de escritura de la FIFO codifica en código Gray la dirección de la última palabra que ha escrito en la RAM. La palabra codificada se transfiere al lado de lectura, que está en otro dominio de reloj, aplicando protectores de metaestabilidad sobre esa palabra. El mismo método se usa en la otra dirección.
Como cada lado conoce la dirección actualizada del otro lado (después del retardo de los protectores de metaestabilidad), cada lado también puede calcular cuántos elementos hay en la FIFO y, por tanto, producir señales como empty (vacío) y full (lleno).
Transmisión de eventos a mayor ritmo
Recuerda, de la página anterior, que el protector de metaestabilidad con un solo bit tiene una limitación en cuanto a la frecuencia del reloj del dominio de origen. Si cada cambio de ese bit sirve para comunicar un evento al otro lado, existe el riesgo de que el receptor pierda eventos si estos ocurren con demasiada frecuencia.
La solución consiste en transmitir entre los dominios de reloj un contador codificado en código Gray. De este modo, el receptor sabe cuántos eventos han ocurrido y, por tanto, no pierde información. El número de bits de ese contador se elige de manera que, incluso si los eventos ocurren en cada ciclo de @clk1, el receptor pueda deducir cuántos eventos han tenido lugar.
Así que, en este sentido, puede resultar más fácil trabajar con un vector que con un solo bit: si un único bit cambia y ese hecho se pierde porque el reloj del destino es más lento, el resultado es que no nos enteramos de que ha pasado nada. Pero con una palabra que se transfiere correctamente entre los dominios de reloj (por ejemplo, con código Gray), no se pierde información.
Y si un solo bit basta para este propósito, entonces el contador en código Gray es simplemente un bit que cambia su valor en cada evento. En otras palabras, con un solo bit, la solución del contador en código Gray es exactamente la misma que el sencillo protector de metaestabilidad.
Un comentario puntilloso sobre la temporización de los caminos
Como sugiere el título de esta sección, puede ser una buena idea saltar a la siguiente.
Hay una suposición subyacente en cuanto al protector de metaestabilidad para señales vectoriales: que la diferencia entre los retardos de esos caminos (paths) no supera un ciclo de reloj del reloj de origen.
Esta suposición se cumple casi con total seguridad sin hacer nada especial, pero aun así, consideremos este ejemplo teórico: supongamos que @clk1 tiene una frecuencia de 500 MHz, y que uno de los caminos de @foo hacia @bar_metaguard tiene un retardo de enrutado de 1 ns. Supongamos también que otro camino tiene un retardo de enrutado de 4 ns, lo cual es, desde luego, extremadamente improbable, pero veamos qué puede ocurrir:
Uno de los bits cambia de valor, y ese cambio comienza un viaje que tarda 4 ns. En el siguiente ciclo de reloj, 2 ns después, el otro bit cambia y llega a @bar_metaguard tras 1 ns. Pero eso es 1 ns antes de que llegue el primer bit. Por tanto, @bar_metaguard puede muestrear la palabra completa con un valor que @foo nunca tuvo.
Como los retardos de enrutado suelen ser mucho más cortos que en este ejemplo, no se espera que esto ocurra en la práctica. No obstante, cualquier retardo de enrutado es teóricamente posible. Para eliminar esta posibilidad por completo, se puede usar una restricción de temporización (timing constraint) como la siguiente (en formato Vivado):
set_max_delay -datapath_only -from [ get_pins -hier -filter {name=~*/C} ] -to [ get_pins -hier -filter {name=~*_metaguard*/D} ] 1.5
Esta restricción se parece a la de set_max_delay que se mostró en la página anterior. Sin embargo, observa que en la página anterior el protector de metaestabilidad está en la parte «-from», y aquí está en la parte «-to». Así que las restricciones no se aplican a los mismos caminos. La finalidad de la restricción de la página anterior era dar al protector de metaestabilidad algo de tiempo para recuperarse de la metaestabilidad. Por tanto, esa restricción se aplica a caminos dentro del mismo reloj. En cambio, la restricción anterior tiene que ver con el propio cruce de dominios de reloj (clock domain crossing).
Por tanto, esta restricción está escrita de otra forma: como los caminos relevantes conectan dominios de reloj de relojes no relacionados (unrelated clocks), no tiene sentido tener en cuenta las desviaciones de estos relojes (clock skew) ni sus jitters. Eso es lo que dice la parte «-datapath_only»: no importa el tiempo que tardan los relojes en llegar a los flip-flops; solo hay que medir el camino.
Lo que hace confusa esta restricción es que el camino empieza en el pin de reloj del flip-flop de origen, y termina en el pin de datos (D) del destino. El cronómetro, por tanto, se pone en marcha cuando al flip-flop de origen le llega su reloj, y se detiene cuando la señal actualizada llega al destino; y se exige que cumpla su tiempo de setup. Por tanto, este camino incluye los requisitos de temporización de ambos lados.
Al limitar todos estos caminos a 1,5 ns, como hace esta restricción de temporización, ningún camino puede superar ese límite, y por tanto la desviación entre los retardos de los caminos queda también limitada a esa cifra. Así que, aunque @clk1 tenga un periodo de 2 ns, es imposible que los caminos lleguen en el orden equivocado. Lo cual, una vez más, es de todos modos extremadamente improbable, pero así es como se garantiza.
Ten en cuenta que si el camino hacia el protector de metaestabilidad se ve afectado por una restricción de camino falso (false path) (por ejemplo, set_false_paths o set_clock_groups), es probable que set_max_delay no tenga efecto: la restricción de camino falso suele tener prioridad. Así que revisa siempre los caminos en el informe de temporización, para verificar que las herramientas interpretan las restricciones como deseas. Una página distinta sobre temporización trata este tema.
Opción n.º 2: Actualización ocasional del registro
Para el siguiente ejemplo, supongamos que @do_update se activa (es decir, vale '1') solo una vez cada varios ciclos de reloj. Supongamos también que este registro se utiliza para indicar que el valor de @foo debe actualizarse con @new_value:
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
Por ahora, ignora @new_bar. Volveré a ello más adelante.
Así es como funciona: @foo solo se actualiza cuando @do_update está activo. Cuando eso ocurre, @toggle cambia a su valor opuesto en el mismo ciclo de reloj.
En el dominio de reloj de @clk2, @toggle_metaguard toma el valor de @toggle como protector de metaestabilidad. En el siguiente ciclo de reloj, ese valor se copia en @toggle_a. En el ciclo posterior, el valor de @foo se copia directamente en @bar. Esto es así porque @toggle_a y @toggle_b tienen valores distintos durante exactamente un ciclo de reloj.
El hecho de que @bar y @foo estén en dominios de reloj distintos no tiene importancia, porque @foo ha permanecido estable durante muchísimo más tiempo del necesario para cumplir los requisitos de temporización.
¿Por qué estoy tan seguro? Esta vez tengo un buen motivo, y es el siguiente: todo el procedimiento empieza cuando @toggle_metaguard cambia de valor porque lo hizo @toggle. Si @bar hubiera muestreado @foo en ese mismo ciclo de @clk2, habría sido arriesgado, pero con un poco de suerte quizá habría salido bien. Pero luego hace falta otro ciclo de @clk2 hasta que el nuevo valor de @toggle_metaguard llegue a @toggle_a. Y @bar tampoco se actualiza entonces, sino solo en el siguiente ciclo de @clk2.
Así que desde el momento en que @foo cambia hasta que @bar lo muestrea, transcurre un periodo de tiempo equivalente al menos a dos ciclos de @clk2. Comparado con el tiempo de setup de cualquier flip-flop, eso es una eternidad. Dicho esto, tiene sentido aplicar a @toggle_metaguard un set_max_delay como el que se muestra en la página anterior. Lo mismo puede hacerse con los caminos (paths) hacia @bar, aunque es muy poco probable que sea necesario, debido a la eternidad que acabo de mencionar.
El talón de Aquiles de este método es que @do_update debe activarse con la poca frecuencia suficiente para garantizar que @foo permanezca estable cuando @bar lo muestree. Un tiempo mínimo razonable entre estas actualizaciones es el equivalente a cuatro ciclos de @clk2. Así que el cálculo consiste en ver a cuántos ciclos de @clk1 corresponden cuatro ciclos de @clk2, y redondear hacia arriba al entero más próximo. Si @clk1 es cuatro veces más lento que @clk2 (o más), eso no supone ninguna restricción. En caso contrario, debe existir algún mecanismo en la lógica que garantice que @do_update no se active con más frecuencia de la permitida.
La verdad es que, en diseños reales, cuando la tasa de actualización es muy baja, a veces se cruzan los dominios de reloj con descuido, sin ninguna protección del estilo de la que ofrece @toggle. Cuando se hace así, @foo se copia continuamente en @bar. Cuando @foo cambia de vez en cuando, @bar puede contener un valor incorrecto durante un ciclo de reloj, pero ¿a quién le importa? La mayoría de las veces, este error es resultado de ignorar por completo el asunto de los dominios de reloj, porque, oye, funciona. Hasta que de vez en cuando, no funciona.
Hablando de ser descuidados, observa que ni @toggle ni ninguno de sus registros asociados se restablecen ni se les asigna un valor inicial en el ejemplo anterior. Eso suele estar bien, porque lo más probable es que el sintetizador (synthesizer) les asigne a todos un valor inicial de 0. E incluso si estos registros no tienen el mismo valor al principio, el resultado es un muestreo (sampling) innecesario de @foo, y nada más. Puede ser una buena idea, no obstante, restablecer estos registros.
Variantes más avanzadas
Hasta ahora he presentado tres ejemplos sencillos:
- Cruzar entre dominios de reloj con un solo bit, mediante un protector de metaestabilidad (en la página anterior).
- Lo mismo con varios bits, pero con la restricción de que solo un bit cambie en cada ciclo de reloj.
- Cruzar entre dominios de reloj sin restricciones sobre la palabra, aunque con la limitación de cuántas veces puede cambiar esa palabra. Se usó un bit conmutado (toggle) para garantizar que la palabra solo se muestree cuando esté estable.
Estos ejemplos sencillos son la base de otros varios mecanismos.
En primer lugar, prometí decir algo sobre @new_bar en el ejemplo anterior. Pues bien, es simplemente un registro que está a nivel alto durante un ciclo de reloj, cuando @bar tiene un valor nuevo. No tiene nada de especial, pero observa que @bar y @new_bar reflejan @foo y @do_update en el otro dominio de reloj. Así que esta es una manera de pasar comandos y mensajes de estado a través de un dominio de reloj (¿he mencionado que convendría usar una FIFO en su lugar, cuando sea posible?).
Otra expansión interesante del último ejemplo es: poner una RAM de doble puerto en lugar del par de registros @foo y @bar. Este es un método para transportar búferes de datos entre dominios de reloj: supongamos que la lógica del dominio de @clk1 escribe datos en la RAM y, al cabo de un tiempo, llena una mitad de esa RAM. Cuando la lógica pasa a llenar la segunda mitad de la RAM, cambia el valor de @toggle. Ese registro se copia al dominio de reloj de @clk2, exactamente como se ha mostrado antes. Pero en lugar de actualizar @bar como en el ejemplo, la lógica consume los datos de la primera mitad de la RAM.
Así es como este sencillo registro puede sincronizar un mecanismo de doble búfer (double buffer), en el que un lado escribe datos en la RAM y el otro lado los lee. De hecho, el papel de @toggle no es solo cambiar de valor, sino también informar al otro lado de qué mitad de la RAM se está escribiendo en ese momento.
Y aun así, lo mejor es usar una FIFO cuando sea posible. Por muy tentador que pueda parecer este mecanismo de doble búfer, solo debería usarse cuando no haya una alternativa mejor. Por ejemplo, cuando los datos de la RAM se leen en un orden distinto del orden en que se escriben.
Resumen
Al final, todo se reduce a esto: en la transición entre dominios de reloj de relojes no relacionados (unrelated clocks), siempre hay lógica de resincronización de por medio. La palabra de datos que atraviesa esa resincronización está limitada, de modo que solo un bit puede cambiar de valor en cada ciclo de reloj del reloj de origen (@clk1 en los ejemplos). De lo contrario, pueden llegar datos ilegales al destino.
En algunas aplicaciones, esto es suficiente, pero cuando esa limitación resulta demasiado restrictiva, los datos pueden moverse entre los dominios de reloj mediante un registro vectorial o a través de una RAM, sin aplicar lógica de resincronización a los propios datos. Esto funciona gracias a una lógica que mantiene un intervalo de tiempo mínimo entre la operación de escritura y la operación de lectura de los datos. Ese intervalo garantiza que la palabra de datos sea estable cuando se muestrea en el destino. Esa lógica se basa, no obstante, en la misma técnica de cruce de dominios de reloj (clock domain crossing). En consecuencia, esta solución implica una lógica de resincronización que se limita a cambiar un bit cada vez, posiblemente usando código Gray.
Así pues, cuando hay relojes no relacionados de por medio, la lógica de resincronización y esa regla de un solo bit están siempre presentes. Solo es cuestión de cómo se aplican.