Una memoria RAM es una memoria en la que se puede tanto leer como escribir datos. En muchas ocasiones nuestro diseño VHDL necesitará utilizar memorias RAM para almacenar datos de forma temporal.
Cuando un diseñador se enfrenta a sus primeros ejemplos en VHDL y debe utilizar una RAM siempre surgen las dudas de cómo debe describirla para que la herramienta de síntesis del fabricante sea capaz de entender el código.
Independientemente de si trabajamos con FPGA de Altera (Intel), Xilinx, o cualquier otro fabricante existen dos maneras de trabajar con memorias RAM.
- Memoria distribuida. En la que los elementos lógicos contenidos en la lógica configurable de la FPGA son conectados formando una memoria.
- Memoria de bloque. Todas las FPGA contienen bloques de memoria insertados entre la lógica de la FPGA, de tal manera que cuando los utilizamos no estamos consumiendo valioso recursos lógicos.
En cuanto a funcionamiento ambas son muy similares. Pueden crearse de uno o dos puertos de lectura y uno de escritura. La principal diferencia es la forma de leer los datos.
- En la memoria de bloque los datos se leen de forma síncrona. Es decir, ponemos la dirección que queremos leer en el puerto de lectura y debemos esperar al siguiente ciclo de reloj para leer el dato en la salida.
- La memoria distribuida puede utilizarse de forma síncrona o asíncrona, es decir, podemos leer el dato el mismo ciclo en el que ponemos la dirección o podemos hacer que se comporte la síncrona y leerlo en la siguiente. Es un parámetro configurable.
En este tutorial vamos a ver las diferentes formas que tenemos de trabajar con memorias dentro de una FPGA, para ello desarrollaremos nuestros ejemplos con una FPGA Artix-7 de Xilinx como la que utiliza la placa Nexys 4 que hemos utilizado en otros artículos. Si disponéis de otra FPGA la idea es muy similar, utilizando las herramientas propias de cada fabricante. En caso de duda preguntadnos a través de los comentarios o el formulario de contacto.
Utilizar Memoria de Bloque (Block RAM)
El uso de memoria de bloque en cualquier FPGA de Xilinx puede hacerse de varias maneras. Vamos a ver cada una de ellas.
Utilizar el IP Catalog de Vivado
Una de las formas más sencillas, pero también mas engorrosas, ya que si cambiamos de modelo de FPGA hay que volver a repetir el proceso, es ir al catalogo de IP de Vivado y generar una memoria de bloque. Lo encontraremos bajo la sección Basic Elements->Memory Elements->Block Memory.

En cada una de las pestañas podemos configurar cada uno de los aspectos de la memoria. Lo más importante es declaras si el tipo es de Single Port o Dual Port y si es una RAM o una ROM. En nuestro caso declararemos una memoria Single Port.
En la pestaña Port A podremos declarar la anchura en bits y la profundidad en palabras de la memoria. Cada bloque de la FPGA Artix-7 tiene bloques de 18Kb y de 36Kb, dependiendo de los parámetros escogidos se utilizará mas o menos bloques.
Una vez escogido todo, le daremos a generar y tendremos el IP añadido a Vivado. Para utilizarlo debemos ir al fichero VHDL que nos genera Vivado. Por ejemplo para una memoria simple de 16 palabras de 16 bit el componente que deberemos utilizar cuando queramos conectarla es:
ENTITY blk_mem_gen_0 IS PORT ( clka : IN STD_LOGIC; ena : IN STD_LOGIC; wea : IN STD_LOGIC_VECTOR(0 DOWNTO 0); addra : IN STD_LOGIC_VECTOR(3 DOWNTO 0); dina : IN STD_LOGIC_VECTOR(15 DOWNTO 0); douta : OUT STD_LOGIC_VECTOR(15 DOWNTO 0) ); END blk_mem_gen_0;
Al utilizar esta memoria se genera una arquitectura que contiene la instancia del bloque de memoria con toda su configuración, ocultándonosla y reduciendo la complejidad de su uso.
Usar primitivas de memoria de bloque
También podemos crear la instancia directamente nosotros, haciendo a mano el trabajo que hace el generador de memorias. Para utilizar las primitivas debemos ir al manual de nuestra familia de FPGA y mirar cuales soporta.
En el caso de la familia 7, el manual lo podéis encontrar en este enlace. En él podemos ver las diferentes primitivas soportadas, RAMB36E1 y RAMB18E1 que se corresponden con los bloques que hay dentro de la FPGA. Mediante genéricos podemos configurar todos los aspectos de las memorias para generar la configuración que deseemos, lo cual es bastante complejo.
Por suerte Xilinx proporciona las siguientes macros para hacer la instanciación de las memorias de bloque utilizando una sintaxis reducida.
- BRAM_SDP_MACRO Macro: Simple Dual Port RAM. Es una memoria con un puerto de lectura y uno de escritura con relojes separados para cada uno.
- BRAM_SINGLE_MACRO Macro: Single Port RAM. La más sencilla, un puerto de lectura y uno de escritura con un solo reloj.
- BRAM_TDP_MACRO Macro: True Dual Port RAM. Dos puertos de lectura y dos de escritura con relojes separados.
La plantilla proporcionada por Xilinx para la BRAM_SDP_MACRO es la siguiente. Hemos eliminado comentarios y la parte de inicialización de la memoria para que no quede muy larga la plantilla. Otras plantillas podéis encontrarlas en la página 108 de este documento.
Library UNISIM; use UNISIM.vcomponents.all; library UNIMACRO; use unimacro.Vcomponents.all; BRAM_SDP_MACRO_inst : BRAM_SDP_MACRO generic map ( BRAM_SIZE => "18Kb", -- Target BRAM, "18Kb" or "36Kb" DEVICE => "7SERIES", -- Target device: "VIRTEX5", "VIRTEX6", "7SERIES", "SPARTAN6" WRITE_WIDTH => 0, -- Valid values are 1-72 (37-72 only valid when BRAM_SIZE="36Kb") READ_WIDTH => 0, -- Valid values are 1-72 (37-72 only valid when BRAM_SIZE="36Kb") DO_REG => 0, -- Optional output register (0 or 1) INIT_FILE => "NONE", SIM_COLLISION_CHECK => "ALL", -- Collision check enable "ALL", "WARNING_ONLY", -- "GENERATE_X_ONLY" or "NONE" SRVAL => X"000000000000000000", -- Set/Reset value for port output WRITE_MODE => "WRITE_FIRST", -- Specify "READ_FIRST" for same clock or synchronous clocks -- Specify "WRITE_FIRST for asynchrononous clocks on ports INIT => X"000000000000000000", -- Initial values on output port -- The following INIT_xx declarations specify the initial contents of the RAM INIT_00 => X"0000000000000000000000000000000000000000000000000000000000000000", INIT_01 => X"0000000000000000000000000000000000000000000000000000000000000000", INIT_02 => X"0000000000000000000000000000000000000000000000000000000000000000", INIT_03 => X"0000000000000000000000000000000000000000000000000000000000000000", INIT_04 => X"0000000000000000000000000000000000000000000000000000000000000000", ... port map ( DO => DO, -- Output read data port, width defined by READ_WIDTH parameter DI => DI, -- Input write data port, width defined by WRITE_WIDTH parameter RDADDR => RDADDR, -- Input read address, width defined by read port depth RDCLK => RDCLK, -- 1-bit input read clock RDEN => RDEN, -- 1-bit input read port enable REGCE => REGCE, -- 1-bit input read output register enable RST => RST, -- 1-bit input reset WE => WE, -- Input write enable, width defined by write port depth WRADDR => WRADDR, -- Input write address, width defined by write port depth WRCLK => WRCLK, -- 1-bit input write clock WREN => WREN -- 1-bit input write port enable ); -- End of BRAM_SDP_MACRO_inst instantiation
Utilizar las memorias de esta manera es probablemente la más complicada, ya que tenemos que parametrizar todos aspectos de la memoria que generábamos con el IP Catalog pero mediante código VHDL. La única ventaja es que a la hora de distribuir nuestro diseño, este contendrá únicamente texto plano.
Crear un módulo VHDL sintetizable siguiendo una serie de restricciones
Los dos métodos presentados anteriormente tienen una ventaja y una gran desventaja.
- La ventaja es que estamos utilizando exactamente los bloques proporcionados por el fabricante, lo cual nos permite optimizar su comportamiento. Por ejemplo en la configuración podemos escoger si la conexión entre memorias para generar una más grande se hace con un método que genere menos área o menos consumo de potencia.
- La desventaja es que las primitivas cambian entre familias de FPGA de mismo fabricante y entre fabricantes, por lo que un diseño hecho para una Spartan 3, no funcionará en una Spartan 6. Es muy común descargar algún diseño de sitios como opencores.org y tener que readaptar el diseño de las memorias., ya que el diseño contiene primitivas de una familia diferente a la que vamos a utilizar. Recuerdo un diseño de un coprocesador RSA que hicimos y subimos a Opencores. Las memorias de las Virtex-4 y Virtex-5 se comportaban de forma diferente, ya que una de ellas tenía la salida registrada. Tuvimos que adaptar el diseño para que incluyera un ciclo de retraso y de esta manera soportara ambos tipos de memoria
La solución a este problema es crear nosotros un modelo de la memoria en VHDL y dejar que sea el sintetizador el que la identifique y seleccione el componente más adecuado. Para ello debemos seguir ciertas “plantillas” comunes. La ventaja de esto es que si esta bien hecho nuestro diseño funcionará en todos los fabricantes de FPGA sin apenas cambios. Otra gran ventaja es que podemos simular el diseño sin tener que añadir librerías externas proporcionadas por el fabricante.
Para crear una memoria de bloque simple en FPGA de Xilinx el código es el siguiente. Como se puede ver, la lectura del dato y su asignación al puerto de salida do se hace dentro de un proceso secuencial (con reloj), por tanto la salida está registrada y el sintetizador entenderá este código como una memoria de bloque.
-- Block RAM with Resettable Data Output -- File: rams_sp_rf_rst.vhd library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity rams_sp_rf_rst is port( clk : in std_logic; en : in std_logic; we : in std_logic; rst : in std_logic; addr : in std_logic_vector(9 downto 0); di : in std_logic_vector(15 downto 0); do : out std_logic_vector(15 downto 0) ); end rams_sp_rf_rst; architecture syn of rams_sp_rf_rst is type ram_type is array (1023 downto 0) of std_logic_vector(15 downto 0); signal ram : ram_type; begin process(clk) begin if clk'event and clk = '1' then if en = '1' then -- optional enable if we = '1' then -- write enable ram(conv_integer(addr)) <= di; end if; if rst = '1' then -- optional reset do <= (others => '0'); else do <= ram(conv_integer(addr)); end if; end if; end if; end process; end syn;
En la siguiente imagen podemos ver el resultado de la implementación de este código con Vivado y como se utiliza una Block RAM rodeada de la lógica de la FPGA.

Utilizar Memoria RAM distribuida
La memoria RAM distribuida utiliza los LUT internos de los elementos lógicos para crear una memoria. Su principal característica es que no hay que esperar al siguiente ciclo de reloj para obtener el resultado. Esto resulta muy util en ciertas aplicaciones, como un banco de registros de un procesador. La desventaja es que utilizamos elementos lógicos y recursos de interconexión.
Para implementar memorias distribuidas podemos hacer lo mismo que para las memorias de bloque. Podemos usar el generador del IP, que es el mismo que para la Memoria de Bloque pero seleccionando en el desplegable memoria distribuida y utilizar el componente que nos genera.
También podemos utilizar las primitivas y macros de la FPGA, que están contenidas en los documentos que os enlazamos más arriba. Por ejemplo una macro para una RAM distribuida de dos puertos es la XPM_MEMORY_DPDISTRAM, y su aspecto es este:
Library xpm; use xpm.vcomponents.all; xpm_memory_dpdistram_inst : xpm_memory_dpdistram generic map ( -- Common module generics MEMORY_SIZE => 2048, --positive integer CLOCKING_MODE => "common_clock",--string; "common_clock", "independent_clock" MEMORY_INIT_FILE => "none", --string; "none" or "<filename>.mem" MEMORY_INIT_PARAM => "", --string; USE_MEM_INIT => 1, --integer; 0,1 MESSAGE_CONTROL => 0, --integer; 0,1 -- Port A module generics WRITE_DATA_WIDTH_A => 32, --positive integer READ_DATA_WIDTH_A => 32, --positive integer BYTE_WRITE_WIDTH_A => 32, --integer; 8, 9, or WRITE_DATA_WIDTH_A value ADDR_WIDTH_A => 6, --positive integer READ_RESET_VALUE_A => "0", --string READ_LATENCY_A => 2, --non-negative integer -- Port B module generics READ_DATA_WIDTH_B => 32, --positive integer ADDR_WIDTH_B => 6, --positive integer READ_RESET_VALUE_B => "0", --string READ_LATENCY_B => 2 --non-negative integer ) port map ( -- Port A module ports clka => clka, rsta => rsta, ena => ena, regcea => '1', --do not change wea => wea, addra => addra, dina => dina, douta => douta, -- Port B module ports clkb => clkb, rstb => rstb, enb => enb, regceb => '1', --do not change addrb => addrb, doutb => doutb );
Y por último la opción más utilizada que es la inferencia. Para ello podemos utilizar este código que nos generará una memoria distribuida. Podéis tomarle y cambiar anchuras de datos y direcciones para adaptarle a vuestro diseño. Como se ve la diferencia con respecto a la memoria de bloque es que la lectura va fuera del proceso del reloj, por lo que se hace en cuanto la entrada de dirección cambia y no hay que esperar un ciclo por ella.
-- Single-Port RAM with Asynchronous Read (Distributed RAM) -- File: rams_dist.vhd library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity rams_dist is port( clk : in std_logic; we : in std_logic; a : in std_logic_vector(5 downto 0); di : in std_logic_vector(15 downto 0); do : out std_logic_vector(15 downto 0) ); end rams_dist; architecture syn of rams_dist is type ram_type is array (63 downto 0) of std_logic_vector(15 downto 0); signal RAM : ram_type; begin process(clk) begin if (clk'event and clk = '1') then if (we = '1') then RAM(conv_integer(a)) <= di; end if; end if; end process; do <= RAM(conv_integer(a)); end syn;
El resultado de la implementación sobre la Artix-7 es el siguiente, como se aprecia claramente se utilizan los LUT de los CLB para implementar la memoria.

Esto ha sido una introducción al uso de memorias RAM con FPGA y las diferentes alternativas que podemos utilizar. Todavía existe mucho más donde profundizar pero lo dejaremos para otros artículos. Si tenéis alguna duda contactadnos por email o en los comentarios y estaremos encantados de resolver vuestras cuestiones.
la señal de we quien la controla?
El que quiere escribir en la memoria o leer de ella. El mismo que controla la direccion a leer.