Cómo hacer un AXI FIFO en RAM de bloque usando el apretón de manos listo/válido
Estaba un poco molesto por las peculiaridades de la interfaz AXI la primera vez que tuve que crear lógica para interconectar un módulo AXI. En lugar de las señales de control habituales ocupado/válido, completo/válido o vacío/válido, la interfaz AXI utiliza dos señales de control denominadas "listo" y "válido". Mi frustración pronto se transformó en asombro.
La interfaz AXI tiene control de flujo incorporado sin usar señales de control adicionales. Las reglas son bastante fáciles de entender, pero hay algunas trampas que se deben tener en cuenta al implementar la interfaz AXI en una FPGA. Este artículo le muestra cómo crear un AXI FIFO en VHDL.
AXI resuelve el problema del retraso de un ciclo
Evitar la sobrelectura y la sobrescritura es un problema común al crear interfaces de flujo de datos. El problema es que cuando dos módulos lógicos sincronizados se comunican, cada módulo solo podrá leer las salidas de su contraparte con un retraso de ciclo de reloj.
La imagen de arriba muestra el diagrama de tiempo de un módulo secuencial que escribe en un FIFO que usa habilitar escritura/completa esquema de señalización. Un módulo de interfaz escribe datos en el FIFO afirmando el wr_en
señal. El FIFO afirmará el full
señal cuando no hay espacio para otro elemento de datos, lo que hace que la fuente de datos deje de escribir.
Desafortunadamente, el módulo de interfaz no tiene forma de detenerse a tiempo siempre que use solo lógica sincronizada. El FIFO sube el full
bandera exactamente en el flanco ascendente del reloj. Simultáneamente, el módulo de interfaz intenta escribir el siguiente elemento de datos. No puede muestrear y reaccionar al full
señal antes de que sea demasiado tarde.
Una solución es incluir un almost_empty
extra señal, hicimos esto en el tutorial Cómo crear un FIFO de búfer de anillo en VHDL. La señal adicional precede al empty
señal, lo que le da al módulo de interfaz tiempo para reaccionar.
El apretón de manos listo/válido
El protocolo AXI implementa el control de flujo usando solo dos señales de control en cada dirección, una llamada ready
y el otro valid
. El ready
la señal es controlada por el receptor, un '1'
lógico valor en esta señal significa que el receptor está listo para aceptar un nuevo elemento de datos. El valid
la señal, por otro lado, es controlada por el emisor. El remitente establecerá valid
a '1'
cuando los datos presentados en el bus de datos son válidos para el muestreo.
Aquí viene la parte importante: la transferencia de datos solo ocurre cuando ready
y valid
son '1'
en el mismo ciclo de reloj. El receptor informa cuando está listo para aceptar datos, y el remitente simplemente envía los datos cuando tiene algo que transmitir. La transferencia ocurre cuando ambos están de acuerdo, cuando el remitente está listo para enviar y el receptor está listo para recibir.
La forma de onda anterior muestra una transacción de ejemplo de un elemento de datos. El muestreo se produce en el flanco ascendente del reloj, como suele ser el caso con la lógica sincronizada.
Implementación
Hay muchas formas de implementar un AXI FIFO en VHDL. Podría ser un registro de desplazamiento, pero usaremos una estructura de búfer de anillo porque es la forma más sencilla de crear un FIFO en RAM de bloque. Puede crearlo todo en un proceso gigante usando variables y señales, o puede dividir la funcionalidad en múltiples procesos.
Esta implementación utiliza procesos separados para la mayoría de las señales que deben actualizarse. Solo los procesos que necesitan ser sincrónicos son sensibles al reloj, los demás usan lógica combinacional.
La entidad
La declaración de entidad incluye un puerto genérico que se utiliza para configurar el ancho de las palabras de entrada y salida, así como el número de ranuras para reservar espacio en la RAM. La capacidad del FIFO es igual a la profundidad de RAM menos uno. Una ranura siempre se mantiene vacía para distinguir entre un FIFO lleno y uno vacío.
entity axi_fifo is generic ( ram_width : natural; ram_depth : natural ); port ( clk : in std_logic; rst : in std_logic; -- AXI input interface in_ready : out std_logic; in_valid : in std_logic; in_data : in std_logic_vector(ram_width - 1 downto 0); -- AXI output interface out_ready : in std_logic; out_valid : out std_logic; out_data : out std_logic_vector(ram_width - 1 downto 0) ); end axi_fifo;
Las dos primeras señales en la declaración del puerto son las entradas de reloj y reinicio. Esta implementación utiliza un reinicio síncrono y es sensible al flanco ascendente del reloj.
Hay una interfaz de entrada de estilo AXI que usa las señales de control listas/válidas y una señal de datos de entrada de ancho genérico. Finalmente viene la interfaz de salida AXI con señales similares a las de entrada, solo que con direcciones invertidas. Las señales que pertenecen a la interfaz de entrada y salida tienen el prefijo in_
o out_
.
La salida de un AXI FIFO podría conectarse directamente a la entrada de otro, las interfaces encajan perfectamente entre sí. Aunque, una mejor solución que apilarlos sería aumentar el ram_depth
genérico si desea un FIFO más grande.
Declaraciones de señales
Las dos primeras declaraciones en la región declarativa del archivo VHDL declaran el tipo de RAM y su señal. La RAM se dimensiona dinámicamente a partir de las entradas genéricas.
-- The FIFO is full when the RAM contains ram_depth - 1 elements type ram_type is array (0 to ram_depth - 1) of std_logic_vector(in_data'range); signal ram : ram_type;
El segundo bloque de código declara un nuevo subtipo entero y cuatro señales del mismo. El index_type
está dimensionado para representar exactamente la profundidad de la RAM. El head
La señal siempre indica la ranura de RAM que se utilizará en la siguiente operación de escritura. El tail
la señal apunta a la ranura a la que se accederá en la siguiente operación de lectura. El valor del count
la señal siempre es igual al número de elementos actualmente almacenados en el FIFO, y count_p1
es una copia de la misma señal retrasada por un ciclo de reloj.
-- Newest element at head, oldest element at tail subtype index_type is natural range ram_type'range; signal head : index_type; signal tail : index_type; signal count : index_type; signal count_p1 : index_type;
Luego vienen dos señales llamadas in_ready_i
y out_valid_i
. Estas son simplemente copias de las salidas de la entidad in_ready
y out_valid
. El _i
sufijo solo significa interno , es parte de mi estilo de codificación.
-- Internal versions of entity signals with mode "out" signal in_ready_i : std_logic; signal out_valid_i : std_logic;
Finalmente, declaramos una señal que se utilizará para indicar una lectura y escritura simultáneas. Explicaré su propósito más adelante en este artículo.
-- True the clock cycle after a simultaneous read and write signal read_while_write_p1 : std_logic;
Subprogramas
Después de las señales, declaramos una función para incrementar nuestro index_type
personalizado . El next_index
función mira el read
y valid
parámetros para determinar si hay una transacción de lectura o lectura/escritura en curso. Si ese es el caso, el índice se incrementará o ajustará. De lo contrario, se devuelve el valor del índice sin cambios.
function next_index( index : index_type; ready : std_logic; valid : std_logic) return index_type is begin if ready = '1' and valid = '1' then if index = index_type'high then return index_type'low; else return index + 1; end if; end if; return index; end function;
Para salvarnos de la escritura repetitiva, creamos la lógica para actualizar el head
y tail
señales en un procedimiento, en lugar de dos procesos idénticos. El update_index
El procedimiento toma el reloj y reinicia las señales, una señal de index_type
, un ready
señal y un valid
señal como entradas.
procedure index_proc( signal clk : in std_logic; signal rst : in std_logic; signal index : inout index_type; signal ready : in std_logic; signal valid : in std_logic) is begin if rising_edge(clk) then if rst = '1' then index <= index_type'low; else index <= next_index(index, ready, valid); end if; end if; end procedure;
Este proceso totalmente síncrono utiliza el next_index
función para actualizar el index
señal cuando el módulo está fuera de reinicio. Cuando se reinicia, el index
la señal se establecerá en el valor más bajo que pueda representar, que siempre es 0 debido a cómo index_type
y ram_type
se declara. Podríamos haber usado 0 como el valor de reinicio, pero hago todo lo posible para evitar la codificación.
Copia señales internas a la salida
Estas dos sentencias simultáneas copian las versiones internas de las señales de salida en las salidas reales. Necesitamos operar en copias internas porque VHDL no nos permite leer señales de entidad con el modo out
dentro del módulo. Una alternativa hubiera sido declarar in_ready
y out_valid
con modo inout
, pero la mayoría de los estándares de codificación de empresas restringen el uso de inout
señales de entidad.
in_ready <= in_ready_i; out_valid <= out_valid_i;
Actualiza la cabeza y la cola
Ya hemos discutido el index_proc
procedimiento que se utiliza para actualizar el head
y tail
señales Al asignar las señales apropiadas a los parámetros de este subprograma, obtenemos el equivalente de dos procesos idénticos, uno para controlar la entrada FIFO y otro para la salida.
-- Update head index on write PROC_HEAD : index_proc(clk, rst, head, in_ready_i, in_valid); -- Update tail index on read PROC_TAIL : index_proc(clk, rst, tail, out_ready, out_valid_i);
Dado que tanto el head
y el tail
se establecen en el mismo valor por la lógica de reinicio, el FIFO estará vacío inicialmente. Así es como funciona este búfer circular, cuando ambos apuntan al mismo índice significa que el FIFO está vacío.
Inferir RAM de bloque
En la mayoría de las arquitecturas FPGA, las primitivas de RAM de bloque son componentes totalmente síncronos. Esto significa que si queremos que la herramienta de síntesis infiera bloques de RAM de nuestro código VHDL, debemos colocar los puertos de lectura y escritura dentro de un proceso cronometrado. Además, no puede haber valores de reinicio asociados con el bloque de RAM.
PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= in_data; out_data <= ram(next_index(tail, out_ready, out_valid_i)); end if; end process;
No hay habilitación de lectura o habilitar escritura aquí, eso sería demasiado lento para AXI. En cambio, estamos escribiendo continuamente en la ranura de RAM a la que apunta el head
índice. Luego, cuando determinamos que se ha producido una transacción de escritura, simplemente avanzamos el head
para bloquear el valor escrito.
Asimismo, out_data
se actualiza en cada ciclo de reloj. El tail
el puntero simplemente se mueve a la siguiente ranura cuando ocurre una lectura. Tenga en cuenta que el next_index
La función se utiliza para calcular la dirección del puerto de lectura. Tenemos que hacer esto para asegurarnos de que la RAM reaccione lo suficientemente rápido después de una lectura y comience a generar el siguiente valor.
Cuenta el número de elementos en el FIFO
Contar el número de elementos en la RAM es simplemente cuestión de restar el head
del tail
. Si el head
ha terminado, tenemos que compensarlo por el número total de ranuras en la RAM. Tenemos acceso a esta información a través del ram_depth
constante de la entrada genérica.
PROC_COUNT : process(head, tail) begin if head < tail then count <= head - tail + ram_depth; else count <= head - tail; end if; end process;
También necesitamos realizar un seguimiento del valor anterior del count
señal. El proceso a continuación crea una versión que se retrasa un ciclo de reloj. El _p1
postfix es una convención de nomenclatura para indicar esto.
PROC_COUNT_P1 : process(clk) begin if rising_edge(clk) then if rst = '1' then count_p1 <= 0; else count_p1 <= count; end if; end if; end process;
Actualiza el listo salida
El in_ready
la señal será '1'
cuando este módulo esté listo para aceptar otro elemento de datos. Este debería ser el caso siempre que FIFO no esté lleno, y eso es exactamente lo que dice la lógica de este proceso.
PROC_IN_READY : process(count) begin if count < ram_depth - 1 then in_ready_i <= '1'; else in_ready_i <= '0'; end if; end process;
Detectar lectura y escritura simultánea
Debido a un caso de esquina que explicaré en la siguiente sección, necesitamos poder identificar operaciones simultáneas de lectura y escritura. Cada vez que haya transacciones válidas de lectura y escritura durante el mismo ciclo de reloj, este proceso establecerá el read_while_write_p1
señal a '1'
en el siguiente ciclo de reloj.
PROC_READ_WHILE_WRITE_P1: process(clk) begin if rising_edge(clk) then if rst = '1' then read_while_write_p1 <= '0'; else read_while_write_p1 <= '0'; if in_ready_i = '1' and in_valid = '1' and out_ready = '1' and out_valid_i = '1' then read_while_write_p1 <= '1'; end if; end if; end if; end process;
Actualizar el válido salida
El out_valid
señal indica a los módulos aguas abajo que los datos presentados en out_data
es válido y puede ser muestreado en cualquier momento. El out_data
la señal proviene directamente de la salida de RAM. Implementando el out_valid
la señal es un poco complicada debido a la demora adicional del ciclo de reloj entre la entrada y la salida de RAM del bloque.
La lógica se implementa en un proceso combinacional para que pueda reaccionar sin demora a la señal de entrada cambiante. La primera línea del proceso es un valor predeterminado que establece el out_valid
señal a '1'
. Este será el valor prevaleciente si no se activa ninguna de las dos declaraciones If posteriores.
PROC_OUT_VALID : process(count, count_p1, read_while_write_p1) begin out_valid_i <= '1'; -- If the RAM is empty or was empty in the prev cycle if count = 0 or count_p1 = 0 then out_valid_i <= '0'; end if; -- If simultaneous read and write when almost empty if count = 1 and read_while_write_p1 = '1' then out_valid_i <= '0'; end if; end process;
La primera instrucción If verifica si el FIFO está vacío o estuvo vacío en el ciclo de reloj anterior. Obviamente, el FIFO está vacío cuando tiene 0 elementos, pero también debemos examinar el nivel de llenado del FIFO en el ciclo de reloj anterior.
Considere la siguiente forma de onda. Inicialmente, el FIFO está vacío, como lo indica el count
la señal es 0
. Luego, se produce una escritura en el tercer ciclo de reloj. La ranura 0 de RAM se actualiza en el siguiente ciclo de reloj, pero se necesita un ciclo adicional antes de que los datos aparezcan en el out_data
producción. El propósito del or count_p1 = 0
declaración es asegurarse de que out_valid
permanece '0'
(encerrado en un círculo rojo) mientras el valor se propaga a través de la RAM.
La última sentencia If protege contra otro caso de esquina. Acabamos de hablar sobre cómo manejar el caso especial de escritura en vacío al verificar los niveles de llenado FIFO actuales y anteriores. Pero, ¿qué sucede si y realizamos una lectura y escritura simultáneas cuando count
ya es 1
?
La siguiente forma de onda muestra tal situación. Inicialmente, hay un elemento de datos D0 presente en el FIFO. Ha estado allí por un tiempo, así que tanto count
y count_p1
son 0
. Luego aparece una lectura y escritura simultáneas en el tercer ciclo de reloj. Un elemento sale del FIFO y entra uno nuevo, dejando los contadores sin cambios.
En el momento de leer y escribir, no hay ningún valor siguiente en la RAM listo para ser emitido, como lo habría sido si el nivel de llenado fuera superior a uno. Tenemos que esperar dos ciclos de reloj antes de que el valor de entrada aparezca en la salida. Sin ninguna información adicional, sería imposible detectar este caso de esquina y el valor de out_valid
en el siguiente ciclo de reloj (marcado en rojo sólido) se establecería erróneamente en '1'
.
Por eso necesitamos el read_while_write_p1
señal. Detecta que ha habido una lectura y escritura simultáneas, y podemos tener esto en cuenta configurando out_valid
al '0'
en ese ciclo de reloj.
Sintetizar en Vivado
Para implementar el diseño como un módulo independiente en Xilinx Vivado, primero debemos dar valores a las entradas genéricas. Esto se puede lograr en Vivado usando la Configuración → Generales → Genéricos/Parámetros menú, como se muestra en la imagen de abajo.
Los valores genéricos se eligieron para que coincidan con la primitiva RAMB36E1 en la arquitectura Xilinx Zynq, que es el dispositivo de destino. El uso de recursos posterior a la implementación se muestra en la siguiente imagen. El AXI FIFO utiliza un bloque de RAM y una pequeña cantidad de LUT y flip-flops.
AXI está más que listo/válido
AXI significa Interfaz extensible avanzada, es parte del estándar de arquitectura de bus de microcontrolador avanzado (AMBA) de ARM. El estándar AXI es mucho más que el apretón de manos leído/válido. Si quieres saber más sobre AXI, te recomiendo estos recursos para leer más:
- Wikipedia:AXI
- Introducción ARM AXI
- Introducción a Xilinx AXI
- Especificación AXI4
VHDL
- La nube y cómo está cambiando el mundo de las TI
- Cómo aprovechar al máximo sus datos
- Cómo inicializar RAM desde un archivo usando TEXTIO
- Cómo prepararse para la IA utilizando IoT
- Cómo la Internet industrial está cambiando la gestión de activos
- Mejores prácticas de seguimiento de activos:cómo aprovechar al máximo los datos de activos que tanto le costó ganar
- ¿Cómo podemos obtener una mejor imagen del IoT?
- Cómo aprovechar al máximo IoT en el negocio de los restaurantes
- Cómo los datos están habilitando la cadena de suministro del futuro
- Cómo hacer que los datos de la cadena de suministro sean confiables
- Cómo la IA aborda el problema de los datos "sucios"