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.

generador memoria de bloque

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.

block ram

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.