Alcance
Esta página es la segunda de una serie de cinco páginas dedicadas a las FIFO. Después de presentar los conceptos básicos de una FIFO en la página anterior, toca hablar de las variantes habituales y de las funciones adicionales. Las FIFO se configuran a menudo como una combinación de las opciones que se describen a continuación.
FIFO de un solo reloj
Aunque la «FIFO básica» tiene dos entradas para relojes no relacionados (unrelated clocks), no es raro que las señales de ambos lados sean síncronas con el mismo reloj. En ese caso, se puede conectar perfectamente ese mismo reloj a @wr_clk y a @rd_clk. Pero, al ser el mismo reloj en ambas entradas, no hay necesidad de cruzar el dominio de reloj (clock domain crossing), así que la FIFO contiene lógica innecesaria. Puedes encontrar más información sobre dominios de reloj aquí.
Por eso, todos los fabricantes de FPGA ofrecen dos categorías de FIFO: la FIFO de doble reloj (dual-clock FIFO) y la FIFO de reloj único (single-clock FIFO). También se usan otros nombres: FIFO de relojes independientes (Independent Clock FIFO) frente a FIFO de reloj común (Common Clock FIFO), o FIFO asíncrona (asynchronous FIFO) frente a FIFO síncrona (synchronous FIFO). La «FIFO básica» que se presentó en la página anterior es una FIFO de doble reloj.
Las FIFO de reloj único no tienen ninguna lógica de sincronización, porque toda la lógica es síncrona con el mismo reloj. Por tanto, la entrada de reset debe ser síncrona con el mismo reloj que los demás puertos.
Además de no desperdiciar lógica de la FPGA, la otra buena razón para usar FIFO de reloj único es la claridad. Es una forma de decir alto y claro que no se pretende que intervengan dos relojes.
En resumen: si la FIFO no sirve de puente entre dos dominios de reloj, elige una FIFO de reloj único.
FIFO FWFT
Como se insistía en la página anterior, el procedimiento para leer datos de una «FIFO básica» consiste en poner @rd_en en alto y obtener el valor en la salida @dout de la FIFO en el ciclo de reloj siguiente. Eso resulta un poco contraintuitivo: si el dato ya está dentro de la FIFO, ¿por qué tengo que pedirlo? ¿Por qué la FIFO no lo pone simplemente en el puerto @dout y me dice que puedo usarlo?
Pues bien, existe una variante habitual que hace exactamente eso, y se llama FIFO de primera palabra visible (First Word Fall Through, FWFT; en inglés se la llama a veces también read-ahead, show-ahead o look-ahead). Lo contrario de una FIFO FWFT se suele denominar «FIFO estándar» (Standard FIFO). ¿Alguien me enseña el estándar?
La idea es sencilla: cuando una FIFO FWFT deja de estar vacía, porque se le han escrito datos, presenta la primera palabra en @dout. La lógica de la aplicación lee entonces palabras manteniendo @rd_en en alto. La diferencia es, por tanto, solo con respecto a la primera palabra.
Sin embargo, es más fácil entender una FIFO FWFT si te das cuenta de que el significado de dos de sus puertos ha cambiado: @rd_en en una FIFO FWFT significa en realidad «acabo de consumir el dato de @dout; puedes traer el siguiente», y @empty (vacía) significa en realidad «@dout no es válido».
Lo que no ha cambiado es que @rd_en no debe estar en alto si @empty está en alto. No puedes decir que has consumido un dato no válido. Así que la regla sigue siendo la misma, aunque por un motivo distinto.
La siguiente forma de onda muestra cómo puede ser una lectura de una FIFO FWFT:
Observa que el primer valor válido en @dout aparece mientras @rd_en está en bajo, y que @empty se pone en bajo al mismo tiempo que aparece el valor válido. Como acabo de mencionar, en una FIFO FWFT @empty significa «@dout no es válido», y la forma de onda lo refleja.
Fíjate también en que el primer pulso de @rd_en no leyó un valor nuevo de la FIFO, sino que hizo que @empty volviera a ponerse en alto. En consecuencia, el valor de @dout pasó a ser desconocido al mismo tiempo. En realidad, @dout no suele cambiar cuando @empty se pone en alto, pero no puedes fiarte de eso.
Después, la FIFO vuelve a poner un valor en @dout y pone @empty en bajo. La lógica de la aplicación lee tres palabras y luego pone @rd_en en bajo. En total, la lógica de la aplicación ha consumido cuatro o cinco palabras de la FIFO.
Observa que la forma de onda no nos dice si la lógica de la aplicación utilizó también el valor de D4. Puede haber ignorado la quinta palabra, lo que significaría que solo consumió cuatro. O puede haber usado el valor de la quinta palabra. Lo único que queda claro en la forma de onda es que la lógica de la aplicación mantuvo @rd_en en bajo después de cuatro ciclos de reloj, de modo que no permitió que la FIFO siguiera actualizando @dout.
Otra cosa a tener en cuenta es que no sabemos si hay más datos en la memoria de la FIFO. El hecho de que @empty esté en bajo al final de esta forma de onda solo significa que @dout es válido.
Ahora vamos a modificar el ejemplo en Verilog de la página anterior. Una vez más, este código calcula la suma acumulada de todo lo que sale de la FIFO:
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.
A diferencia del ejemplo anterior, este usa una FIFO FWFT, así que no hace falta ningún registro que guarde el valor de @rd_en del ciclo de reloj anterior. En su lugar, @dout se puede consumir cuando @empty está en bajo. Esta regla tan sencilla funciona porque @rd_en está en alto cuando @empty está en bajo, de modo que cada palabra de la FIFO es válida en @dout durante exactamente un ciclo de reloj.
Me gustaría cerrar el tema de las FWFT con una cuestión algo tangencial. La diferencia entre una FIFO «estándar» y una FIFO FWFT refleja una cuestión fundamental sobre el flujo de datos entre dos módulos lógicos cualesquiera: ¿tiene el receptor que pedir los datos? ¿O el emisor presenta los datos en cuanto puede y el receptor solo confirma que se puede seguir? Pregúntatelo siempre que un módulo pase datos a otro y, en particular, pregúntate si ambos módulos están de acuerdo en este punto.
FIFO asimétricas
Es habitual que se permita definir la FIFO con anchos distintos para @din y @dout. Esto resulta útil, por ejemplo, si los datos llegan a la FPGA en palabras de 32 bits, pero la lógica de la aplicación los procesa como bytes, es decir, palabras de 8 bits. En ese caso, configura el ancho del lado de escritura a 32 bits y el del lado de lectura a 8 bits. Ambos lados se comportan como siempre, salvo que se necesitan cuatro ciclos de lectura para consumir una palabra que se insertó con un único ciclo de escritura.
Cuando el lado de lectura es más ancho que el de escritura, el comportamiento es el esperado: los datos que se escriben en la FIFO no están disponibles en el lado de lectura hasta que los datos escritos han completado una palabra del tamaño del lado de lectura.
En cuanto al orden en que se empaquetan las palabras, parece que todas las FIFO utilizan formato little endian. Por ejemplo, una FIFO empaqueta palabras de 32 bits en palabras de 8 bits así: el rango de bits de la primera palabra que se lee de la FIFO es [7:0], y luego [15:8], [23:16] y [31:24].
Pero si quieres usar esta función, consulta siempre la documentación.
Dependencia de la lógica combinacional con @empty y @full
Los puertos @empty (vacía) y @full (llena) tienen un inconveniente común: la lógica de la aplicación tiene que responder a ellos en el mismo ciclo de reloj. En otras palabras, @rd_en debe ser una función combinacional (combinatorial logic) que dependa de @empty para garantizar que estas dos señales no estén en alto en el mismo ciclo de reloj (eso está prohibido, como ya se ha mencionado). Por la misma razón, @wr_en debe ser una función combinacional que dependa de @full.
El uso de funciones combinacionales puede convertirse en un obstáculo para cumplir las restricciones de temporización (timing constraints). Esto puede convertirse en un problema cuando la frecuencia del reloj es alta, en relación con las especificaciones de la FPGA, y cuando la función lógica es complicada. El motivo principal de los problemas es que tanto @rd_en como @wr_en se usan a menudo en la lógica que produce o consume los datos. En particular, la función lógica que calcula la habilitación de reloj (clock enable) para un montón de lógica puede depender de estas señales. Por ejemplo, si hay un pipeline largo que procesa datos procedentes de una FIFO, toda la lógica del pipeline debe congelarse cuando el flujo de datos de la FIFO se detiene momentáneamente.
Bueno, para ser totalmente exactos, hay una forma de evitar esa función combinacional. Por ejemplo, supongamos que @wr_en se declara como un registro y que @want_to_write es una señal que representa la necesidad de escribir que tiene la lógica de la aplicación en un momento dado. Se puede hacer así:
always @(posedge wr_clk)
wr_en <= want_to_write && !wr_en && !full;
Esto garantiza que @wr_en y @full nunca estén en alto en el mismo ciclo de reloj, porque @full solo puede ponerse en alto en el ciclo posterior a que @wr_en haya estado en alto. La parte !wr_en de la expresión garantiza que @wr_en nunca esté en alto durante dos ciclos consecutivos. Así, si @full se pone en alto, @wr_en estará en bajo debido a !wr_en en el primer ciclo. Después @wr_en permanece en bajo debido al propio @full.
Pero con esta solución, @wr_en debe estar en bajo durante la mitad del tiempo. Como resultado, solo se utiliza el 50% de la tasa de datos de la FIFO. Esto suele ser inaceptable.
La misma solución es posible para @rd_en, y tiene el mismo problema de usar la mitad de la tasa de datos.
Toda esta discusión pretendía conducir a la siguiente sección: los puertos «almost» (de casi lleno y casi vacío).
Puertos almost full, almost empty y similares
Es posible añadir dos puertos opcionales a una FIFO: un puerto @almost_full y/o un puerto @almost_empty.
La señal @almost_empty (casi vacía) es síncrona con @rd_clk y se parece a @empty (vacía), con una pequeña diferencia: @almost_empty está en alto cuando la FIFO está vacía, pero también cuando queda exactamente una palabra por leer en la FIFO.
Del mismo modo, la señal @almost_full (casi llena) es síncrona con @wr_clk y está en alto cuando la FIFO está llena, pero también cuando se puede escribir exactamente una palabra en la FIFO.
Los nombres de estos dos puertos de salida dependen del fabricante de la FPGA y del software que suministra, pero siempre existe la posibilidad de añadir puertos con la misma funcionalidad. Solo en algunos casos una variante concreta de FIFO puede no soportar estos puertos.
¿En qué ayudan estos puertos? Pues en que esto funciona perfectamente:
always @(posedge wr_clk)
wr_en <= want_to_write && !almost_full;
Sin lógica combinacional y sin necesidad de saltarse la mitad de los ciclos de escritura. Cuando @almost_full está en alto, puede que @wr_en no pase a bajo en el mismo ciclo de reloj, sino solo en el siguiente. Como resultado, puede haber una operación de escritura después de que @almost_full pase a alto. Pero no pasa nada, porque hay sitio para una palabra.
Observa que si @want_to_write se mantiene en alto continuamente mientras se llena la FIFO, la última operación de escritura deja la FIFO completamente llena. De lo contrario, es posible que la FIFO acabe casi llena: si @wr_en está en bajo por culpa de @want_to_write y, por tanto, la FIFO no llega a llenarse del todo, no habrá una segunda oportunidad. @almost_full solo pasará a bajo cuando el otro lado lea datos de la FIFO, de modo que habrá espacio para dos o más palabras en la FIFO.
Eso rara vez importa, pero para la discusión, esto asegura que se aproveche la última palabra:
always @(posedge wr_clk)
wr_en <= want_to_write && (!almost_full || (!full && !wr_en));
Esta expresión para @wr_en se basa en @almost_full la mayor parte del tiempo, salvo cuando se puede escribir exactamente una palabra. Solo entonces @wr_en depende de @full y de @wr_en, de forma parecida a la expresión anterior que usaba !wr_en.
Sin embargo, dudo seriamente que esta última expresión de @wr_en sea útil.
La historia con @almost_empty es parecida, así que esto es válido (pero no copies esto en tu código):
always @(posedge rd_clk)
rd_en <= want_to_read && !almost_empty;
Igual que con @almost_full, hay un problema con la última palabra: si @rd_en está en bajo por culpa de @want_to_read, se pierde la oportunidad hasta que se escriban más datos en la FIFO. A diferencia de lo que ocurría con @almost_full, esto puede ser sin duda un problema en algunos escenarios: si @almost_empty está en alto pero la FIFO no está vacía, significa que hay datos en la FIFO que estaban destinados a ser leídos, pero esos datos se quedan atascados en la FIFO.
Así que esta es la forma segura de hacerlo:
always @(posedge rd_clk)
rd_en <= want_to_read && (!almost_empty || (!empty && !rd_en));
Contadores de llenado
La lógica de la aplicación suele realizar operaciones por bloques. Por ejemplo, una lógica que lee de la FIFO paquetes de datos de longitud constante y los transmite a través de algún medio físico. Como los datos están almacenados en una FIFO, la lógica de la aplicación necesita saber que hay suficientes datos para completar un paquete antes de empezar a leer.
Del mismo modo, la lógica de la aplicación suele producir una cantidad fija de datos para almacenarlos en una FIFO, por ejemplo al leer una ráfaga (burst) de datos de una memoria externa. La operación no debería comenzar si no hay sitio suficiente en la FIFO para completar la ráfaga.
Para estos fines, las FIFO suelen incorporar contadores de llenado (fill counters), un puerto de empty programable (programmable empty) y un puerto de full programable (programmable full). Los contadores de llenado, a veces llamados contadores de datos (data counters), se presentan en formas muy diversas, dependiendo mucho del fabricante de la FPGA, así que lee la documentación de la FIFO con atención. Hay tres cuestiones principales a las que prestar atención:
- ¿Con qué reloj es síncrono el contador? Casi sobra decirlo: tiene que ser el mismo reloj que usa la lógica que emplea el contador.
- ¿Qué nos dice el contador? Los «contadores de lectura» (read counters) suelen indicar el número de palabras almacenadas en la FIFO, pero ¿y los «contadores de escritura» (write counters)? ¿Indican también el número de palabras almacenadas, o el número de palabras que se pueden escribir antes de que la FIFO se llene?
- ¿Qué garantiza el contador? Los contadores de llenado suelen ser pesimistas en relación con su propósito previsto. Por ejemplo, es habitual que los «contadores de lectura» puedan dar temporalmente un número inferior al número real de palabras de la FIFO. Esto ocurre mientras se escriben palabras en la FIFO, porque estos contadores incrementan su valor tarde como respuesta a las operaciones de escritura, pero lo decrementan pronto como respuesta a las operaciones de lectura. Esto tiene sentido cuando se usan con la lógica que controla @rd_en; sin embargo, si un contador de este tipo lo usa la lógica que controla @wr_en, podrías provocar un desbordamiento (overflow). Para eso están los «contadores de escritura». Con todo, conviene leer la letra pequeña de la documentación.
Además, están las variantes programables de las señales empty y full (programmable empty y programmable full), que son versiones ampliadas de @almost_empty y @almost_full. La idea es que, como el uso de los contadores de llenado será casi con toda seguridad algo así:
assign dont_start_reading = (rd_data_count < 64);
¿por qué no ofrecer esa señal directamente y llamarla prog_empty? Una vez más, lee con atención la documentación de la FIFO.
Y, una vez más, cuando sea importante leer la última palabra de la FIFO, asegúrate de preguntarte si tu lógica lo hará de verdad. Esta pregunta es parecida a la discusión anterior sobre @almost_empty.
Casi sobra decirlo: si quieres estos puertos adicionales, tendrás que solicitarlos al configurar la FIFO.
La interfaz AXI
Este tema no está directamente relacionado, pero conviene mencionarlo para evitar confusiones, porque este término aparece a menudo en el contexto de las FIFO.
AXI es un conjunto de interfaces definidas en el estándar AMBA, introducido por ARM. Como cabría esperar, las FIFO con interfaz AXI suelen estar pensadas para funcionar como periférico de una CPU.
La interfaz de la «FIFO básica» se denomina a menudo interfaz «nativa» (native interface), en contraposición a una interfaz AXI.
Hay dos tipos principales de interfaces AXI: la AXI «normal» (normalmente AXI3, AXI4 o AXI Lite), que es un bus con dirección y datos, y la segunda, la AXI-S (streamed AXI, o AXI de flujo continuo), pensada para flujos de datos, posiblemente divididos en paquetes.
Cuando una FIFO se configura como AXI3/AXI4 o AXI Lite, se le añade lógica adicional para que pueda conectarse como periférico con dirección a una CPU a través de esta interfaz. No voy a profundizar más en esto, porque es un tema completamente distinto.
Pero, como la interfaz de flujo (streaming) se parece un poco al comportamiento de una FIFO, es posible convertir las señales de protocolo (handshake) de la interfaz AXI-S en una interfaz «nativa». Ten en cuenta que AXI-S suele incluir otras señales que también requieren atención.
Así, dadas las señales AXI-S de escritura en la FIFO, @axi_w_valid, @axi_w_ready y @axi_w_data, se pueden conectar a los puertos de una FIFO «estándar» de la siguiente manera:
assign axi_w_ready = !full;
assign wr_en = axi_w_valid && axi_w_ready;
assign din = axi_w_data;
Del mismo modo, las señales AXI-S de lectura de la FIFO, @axi_r_valid, @axi_r_ready y @axi_r_data, pueden conectarse a los puertos de una FIFO FWFT así:
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;
Una vez más, ten en cuenta que para que esto funcione, la FIFO debe ser una variante FWFT.
Con esto concluye la segunda página de esta serie sobre las FIFO. La siguiente página muestra cómo se implementa una FIFO de reloj único en Verilog.
