-
Notifications
You must be signed in to change notification settings - Fork 14
CT.9: Memorias
Las memorias son importantísimas. Es el lugar donde almacenamos grandes cantidades de información y datos para que otros circuitos, como los procesadores, los puedan usar, y crear nuevos datos a partir de ellos. Estudiaremos las memorias empezando desde lo más básico: los biestables D que almacenan 1 bit. Terminaremos mostrando cómo implementar mapas de memoria, con diferentes recursos mapeados
- 2020-Sep-24: Version inicial del cuaderno técnico
- 2024-Mayo-13: Ejemplos adaptados a la nueva toolchain: apio-0.9.4. Eliminado el error en la verificación. Probados con icestudio 0.12. Los pantallazos de los ejemplos no se han actualizado todavía
- Fecha: 03-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
- Fecha: 04-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
- Fecha: 05-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
- Fecha: 05-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
- Fecha: 05-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
- Fecha: 05-Nov-2020
Haz click en la imagen para ver el vídeo en Youtube
Todos los ejemplos se han probado con Icestudio 0.12. Usa esta versión o superior
- iceMem-Collection-0.2.0.zip: Colección para este cuaderno técnico. Descargar e instalar. Usa esta versión o superior
Todos los ejemplos de este cuaderno técnico están accesibles desde el menú Archivo/Ejemplos/CT9-Examples de Icestudio, una vez instalada y selecciona la colección iceMem
También los podrás encontrar en el repositorio de este cuaderno técnico
- Introducción
- Panel de pruebas
- Del bit al registro
- Banco de registros
-
Memorias síncronas
- Bloques RAM (BRAMs) en las FPGAs ice40
- Ejemplo 16: Probando una memoria síncrona de 16B con LEDs y pulsadores
- Ejemplo 17: Probando una memoria síncrona de 16B con el panel web
- Número de bloques RAM (BRAM) usados
- Implementación de la memoria síncrona
- Cronogramas
- Ejemplo 21: Memoria síncrona de 1KiB
- Ejemplo 22: Inicialización de la memoria desde fichero
- Circuitos de aplicación
- Mapeo de recursos en memoria
- Conclusiones
- Autor
- Licencia
- Créditos y agradecimientos
- Enlaces
Empezaremos nuestro viaje desde lo más básico: el biestable D. Ya lo conocemos y sabemos usar, pero lo utilizaremos como punto de partida para ir construyendo los registros y las memorias a partir de él. Al fin y al cabo, las memorias no son más que elementos que nos permite almacenar bits de forma organizada. Y la unidad mínima de almacenamiento es el Biestable D, que guarda un bit de información
Aprenderemos a usar las señales de interfaz de las memorias: dirección, dato de entrada, señal de escritura y dato de salida. Esta interfaz es muy importante. No sólo sirve para almacenar datos en las memorias y luego recuperarlos, sino que se ha convertido en una manera estándar de comunicar unos circuitos con otros. Por ejemplo, la comunicación entre un procesador y sus periféricos se realiza normalmente mediante el mapeado de recursos, que usa esta misma interfaz de acceso a memoria
Este cuaderno técnico es muy práctico. Haremos circuitos desde el principio para comprener el funcionamiento de los elementos presentados, e ir asimilando de forma práctica los conceptos. Para trabajar más fácilmente con las memorias, utilizaremos un panel web con componentes virtuales
En muchos de los ejemplos de este cuaderno ténico utilizaremos este panel web de prueba para aprender el funcionamiento de las memorias:
El uso de los paneles web y los componetes virtuales está descrito en el Cuaderno técnico 8: Entrada y Salida de bits con componentes virtuales
Este es el panel que usaremos:
Tiene 8 interruptores para introducir direcciones de memoria, otros 8 para introducir los datos a grabar y 4 interruptores auxiliares, para otros usos, según el ejemplo. Además hay 4 pulsadores genéricos, usados para realizar diferentes acciones según el ejemplo. Y finalmente hay 16 LEDs virtuales para mostrar las salidas. Ocho están pensado para mostrar las direcciones, aunque su uso depende del circuito de ejemplo y 8 para mostrar los datos de salida de la memoria
En cada ejemplo se detalla el uso de los interruptores, pulsadores y LEDs
Los circuitos digitales, por muy complejos que sean, se construyen a patir de tres tipos de elementos: cables, puertas lógicas y biestables. Los cables son los que conectan todos los elementos entre sí, y permiten que los bits circulen de unos a otros. Las puertas lógicas son las que modifican los bits y los combinan para obtener otros nuevos como resultado. Los biestables son los elementos que almacenan bits, de manera que podemos recordar qué ocurrió en el pasado
El biestable más básico es el biestable D. Es el ladrillo con el que se pueden construir el resto de elementos de almacenamiento: registros, bancos y memorias. Ya lo conocemos del Tutorial 28: Biestables de datos y cambio. En la colección Jedi lo reconocemos por el símbolo de la chincheta:
También se denomina biestable D del sistema, porque captura los datos usando el reloj del sistema. Cada vez que llega un ciclo nuevo del reloj del sistema, el bit en la entrada se captura y se almacena en el biestable, obteniéndose su valor en la salida. Este Biestable está TODO el rato capturando lo que hay en su entrada, mientras funcione el reloj del sistema
Su principal utilidad es retrasar las señales un ciclo de reloj, para sincronizarse con otras partes de nuestro circuito. Sin embargo, si necesitamos capturar un bit en un determinado instante (y no todo el rato), ¿cómo lo hacemos?
Para capturar un bit en un momento determinado, usamos un Biestable D con entrada de carga (load), o también conocido como registro de 1 bit. El símbolo que usamos es igual al anterior, pero con la entrada adicional de load
Este biestable ya lo conocemos de sobra. En el Tutorial 28 hay disponibles ejemplos y ejercicios para practicar con él
En la colección iceMem podemos encontrar este componente, junto al resto de registros y memorias que utilizaremos en este cuaderno técnico. Está en el menú Regs/01-bit
Como recordatorio y para familiarizarnos con el almacenamiento de bits veremos algunos ejemplos. El escenario es el mostrado en esta figura. Sólo necesitamos la tarjeta Alhambra II, y usaremos sus pulsadores y LEDs
Es un ejemplo del funcionamiento de la captura de datos. La salida del registro se muestra en el LED0. Con el pulsador 1 establecemos el valor de entrada: 0 ó 1. Este valor se muestra en el LED7. Cuando se aprieta el botón 2 se captura el dato en el registro, mostrándose en el LED0
En este pantallazo vemos el estado de los LEDs cuando se captura un 1 en la entrada. El LED7 está encendido porque hemos seleccionado el valor 1 para capturar. El LED0 está encendido porque acabamos de capturar el bit 1 de la entrada
En este vídeo se muestra el funcionamiento. Todo lo que se muestra en el LED7 (superior) pasa al inferior (registro) cada vez que realizamos una captura
¿Cómo construimos este Registro de 1 bit a partir del Biestable D del sistema, que es nuestro ladrillo básico? Si nos metemos dentro del bloque Reg-01 del ejemplo anterior lo veremos. En la colección iceMem encontramos dos implementaciones equivalentes de este registro. Una es en lenguaje Verilog, y la otra usando bloques de icestudio. Pero ambos ocupan exactamente los mismos recursos en la FPGA y son totalmente equivalentes
Se implementa a partir de un Biestable D y un multiplexor de 2 a 1. El Biestable-D está constantemente capturando los datos, con cada ciclo de reloj del sistema. Mediante el multiplexor seleccionamos lo que queremos que capture. Así, siempre que la señal load esté a 0, el registro captura su propio valor (curr_bit), y permanece en el mismo estado
Cuando load se pone a 1, se selecciona el bit data para su captura al llegar el siguiente ciclo de reloj
Es el mismo circuito del ejemplo 1: un registro de 1 bit que captura el dato que le llega por la entrada. Pero ahora se utilizan elementos del panel web de prueba. El interruptor "q" selecciona el dato a cargar y el pulsador "Q" realiza la captura. La salida del registro se muestra por el LED "h", y su entrada por el LED "a"
En este pantallazo se muestra el estado del panel y de la placa cuando se acaba de capturar un 1 en el registro
Y en este vídeo se muestra en acción
Colocando varios Registros de 1 bit en paralelo construirmos registros de N bits, capaces de almacenar un número de N bits cuando se activa la señal load. En este apartado haremos ejemplos con registros de 2 bits, pero son totalmente generalizables a registros de N bits. Los registros ya los vimos en el Tutorial 29: Registros y comparadores, pero mostraremos algunos ejemplos aquí como recordatorio
Las señales de manejo son las mismas que para el registro de 1 bit: el dato de 2 bits llega por d, la señal load se usa para su captura, y q es la salida del dato de 2 bits
Para comprobar el funcionamiento del registro de 2 bits utilizamos el circuito mostrado a continuación. Con el pulsador 1 se incrementa un contador que contiene el dato a guardar en el registro. Con el pulsador 2 se captura este dato en el registro, y se ve en los LEDs 1 y 0
En este pantallazo se muestra la placa cuando se ha seleccionado el número 3 y se ha capturado en el registro. Hay en total 4 LEDs encendidos: 7 y 6, y 1 y 0 (ya que 3 en binario es 11)
Y en este vídeo se muestra en funcionamiento
El registro de 2 bits se construye situando dos registros de 1 bit en paralelo, usando la misma señal de load para ambos, para cargar los bits a la vez. Uno almacena el bit de mayor peso (bit1) y el otro el de menor (bit0). La salida de ambos registros se combina en un bus de 2 bits
Su implementación se puede ver haciendo doble click en el registro de 2 bits del ejemplo 3. También se puede cargar directamente el bloque desde el menú Regs/02-bits/Blocks/Reg de la colección iceMem
Este circuito refleja la estructura interna real del registro de 2-bits. Y así es como se crea en la FPGA. Sin embargo, también es posible describir este registro de forma más compacta usando el lenguaje de descripción hardware Verilog. Esta descripción se encuentra en el bloque situado en Regs/02-bits/Reg
Ambas implementaciones, mediante bloque o mdiante Verilog, son exactamente iguales. Ambas ocupan los mismos recursos en la FPGA. La implementación de bloques describe lo que ocurre realmente, mientras que la de Verilog es más compacta y más escalable (¿Te imaginas cómo hacer un registro de 8 bits con bloques?)
Es el mismo circuito del ejemplo 3: un registro de 2 bits que captura el dato que le llega por la entrada. Pero ahora se utilizan elementos del panel web de prueba. Por los interruptores "A" y "B" se introduce el número de 2 bits, y con el pulsador "Q" se realiza la captura. La salida del registro se muestra por los LEDs virtuales "G" y "H", y su entrada por los LEDs "A" y "B"
En este pantallazo vemos su aspecto cuando por la entrada del registro se ha introducido el número 3 (11 en binario) y se ha realizado su captura
Y en este vídeo lo vemos en acción: se están capturando los valores 1, 2, 3 y 0
Los registros de N bits funcionan exactamente igual que el registro de 2 bits, con la diferencia del tamaño del dato de entrada y salida, que en vez de 2 bits es de N bits. En este dibujo se muestran registros de 1, 2, 3, 4 y 8 bits que están en la colección iceMem
Para crearlos no hay más que colocar N registros de 1 bit en paralelo, con la señal load común, y haciendo que cada registro almacene uno de los bits que llegan por la entrada. Los bits de salida de cada registro se agrupan por último en un bus de N bits para sacarlo al exterior. También se pueden construir a partir de otros registros colocándolos en paralelo. Por ejemplo, uno de 16-bits lo creamos a partir de dos de 8 bits
No haremos ejemplos específicos de registros de N bits. Pero los usaremos en los ejemplos venideros
Los registros los podemos agrupar en otra organización conocida como banco, a la que también se llama memoria. Cuando hablamos de banco nos estamos refiriendo a pocos registros. Un banco de 8, 16 ó 32 registros, por ejemplo. Al hablar de memoria normalmente pensamos en cantidades mayores de datos: 256 Bytes, 1K, 8K, 32K, etc... Pero conceptualmente son equivalentes
Dentro de un banco, a cada registro se le asigna un número, comenzando por 0. Este número es su dirección. Para leer el valor de un registro del banco, simplemente especificamos su dirección y en la salida obtendremos su valor. Para escribir en un registro indicamos el dato a guardar y la dirección del registro donde guardarlo
Este es el aspecto que tienen los bloques Banco de registros, que se encuentran en el menú Banks de la colección iceMem. Aquí se representa uno genérico, pero las entradas de datos y direcciones tendrán un número de bits concreto
Empezamos con el banco más sencillo posible: un banco de 2 registros de 1 bit. Un registro se encuentra en la dirección 0, al que llamaremos registro 0, y el otro en la dirección 1 (registro 1). Como son registros de 1 bit, tanto la entrada de datos (data) como la salida del banco son de 1 bit.
Como sólo tenemos 2 registros, el 0 y el 1, solo necesitamos 1 bit para la dirección de los registros. Por eso la entrada addr es de sólo 1 bit. En este esquema se resume gráficamente este banco
Vamos a comprobar de manera práctica el funcionamiento del banco 2x1. Con el pulsador 1 seleccionamos el dato que queremos guardar en el banco (0/1). Este valor se muestra en el LED5. Con el pulsador 2 seleccionamos el registro que queremos leer/escribir (0/1). Esta dirección la vemos en el LED7. Al hacer una pulsación larga en el pulsador 1, se escribe el dato en el registro seleccionado. La salida del banco se muestra en el LED0
Este es el circuito: (05-Bank-2x1-01.ice)
En este pantallazo se muestra la placa cuando se ha escrito el dato 1 (LED5) en la dirección 1 (LED7). Es decir, que ahora el registro 1 vale 1, tal y como se muestra en el LED0
En este vídeo se muestra el funcionamiento. Primero se usa el pulsador 2 para enseñar el contenido de los registro 0 y 1: están ambos a 0. Luego se usa el pulsador 1 para poner el dato a 1, y también para grabarlo en el registro 0. Ahora vemos que Reg0 es 1 y Reg1 es 0. Nos vamos a Reg1 y lo ponemos a 1, por lo que ambos registros están ahora a 1. Finalmente nos movemos a Reg0 y lo ponemos a 0. Ahora Reg0 es 0 y Reg1 es 1
El banco de 2 registros de 1 bit, disponible en Banks/01-bit-regs/Blocks/Bank-2x1, está implementado mediante bloques de icestudio. Está formado por 2 registros de 1 bit, cuyas salidas se introducen en un multiplexor 2-1 para sacar sólo el registro actualmente seleccionado mediante la dirección (addr)
La señal de write que reailza la escritura se envía a un registro u otro mediante un demultiplexor 2-1. Según la dirección, la orden de escritura se envía al registro 0 ó al registro 1
La otra posible implementación es en verilog, que es más compacta, pero es TOTALMENTE EQUIVALENTE a la anterior. Consume los mismos recursos porque decribe el mismo hardware. Se encuentra en Banks/01-bit-regs/Bank-2x1. La única diferencia es que en verilog podemos incluir un parámetro adicional para introducir los valores iniciales de los registros del banco
Haremos el mismo ejemplo 5, pero ahora usando el panel de prueba en vez de los pulsadores. Cuando trabajemos con bancos y memorias de mayor tamaño ya usaremos siempre los paneles web, que tienen un mayor número de pulsadores y LEDs
El interruptor "h" se usa para introducir la dirección del registro (0/1), con el interruptor "H" el dato que queremos almacenar en ese registro (0/1) y por último, con el pulsador "Q" se graba el dato en el registro actual.
Por los LEDs virtuales "h" y "H" se muestra la dirección actual y el dato almacenado en el registro direccionado
En este pantallazo se muestra el contenido del registro 1, en el que previamente se le ha grabado el dato 1
Y en este vídeo lo vemos en acción. Primero se muestra el contenido de los dos registros, que es 0. Después se graba el dato 1 en el registro 0. A continuación se guarda 1 en el registro 1. Y por último se guarda 0 en el registro 0 y otro 0 en el registro 1
Cuando tenemos un banco de registros de pocos bits, como por ejemplo el banco de dos registros de 1 bit, las salidas de TODOS los registros del banco se pueden sacar en paralelo. Esto es muy útil para hacer pruebas, ya que podemos mostrar las salidas de todos los registros del banco a la vez en los LEDs
El banco de dos registros de 1 bit con salida en paralelo es exactmente igual al que ya conocemos, pero tiene una salida de bus adicional donde se agrupan las salidas de los dos registros. Así, por parall[0] tenemos el registro 0 y por parall[1] el registro 1. Además por la salida habitual obtenemos el registro que está seleccionado por la dirección
Este bloque se encuentra en el menú Banks/01-bit-regs/Bank-2x1-paralell. Su implementación es directa: sólo hay que agrupar las salidas de los registros 0 y 1 en un bus de dos bits
Para comprobar su funcionamiento usamos el mismo ejemplo anterior (ejemplo 6) pero con el Banco 2x1 con salidas en paralelo. Estas salidas se muestran por los LEDs virtuales "A" y "B" así como por los LEDs reales 3 y 4
(07-Bank-2x1-paralell-panel.ice)
En este pantallazo se muestra el panel y la placa cuando se han puesto a 1 tanto el registro 0 como el 1. Por ello vemos que los LEDs virtuales "A" y "B" están encendidos, así como los LEDs reales 3 y 4 en la placa
Y en este vídeo se muestra en funcionamiento. Se graba el valor 1 en los registros 1 y 0, y luego se graba el valor 0 en ambos
Para acceder a 8 registros las direcciones son de 3 bits. Así, tenemos acceso desde el registro 0 (dirección 000) hasta el registro 7 (dirección 111). Como son registros de 1 bit, tanto el dato de entrada como el de salida son de 1 bit. Este banco tiene 3 bits para las direcciones y 1 bit para los datos
Veremos algunos ejemplos para familiarizarnos. Son muy parecidos a los anteriores, pero con una mayor anchura del bus de direcciones
Utilizaremos el pulsador 2 para incrementar la dirección, que además se muestra por los LEDS del 7 al 5. Por el LED0 se muestra el contenido del registro actual. Con el pulsador 1 cambiamos de estado el bit almacenado en el registro actual: si había 0 se guarada 1 y si había 1 se guarda cero. Así son más cómodas las pruebas
En este video se muestra un ejemplo de funcionamiento. Primero se usa el pulsador 2 para mostrar que los 8 registros están a 0. Luego se graba un 1 en los registros 0 y 1. Se muestra que el resto de registros siguen a 0, pero que los dos primeros están a 1. Luego se ponen a 1 los registros 5, 6 y 7. Finalmente se ponen todos a cero de nuevo
El banco de 8 registros de 1 bit disponible en Banks/01-bit-regs/Blocks/Bank-8x1 está implementado mediante bloques de icestudio. Existen múltiples formas de implementarlo. Una sería utilizar 8 registros de 1 bit, en paralelo, y utilizar un multiplexor 8-1 y un demultiplexor de 1-8. Pero también se puede implementar utilizando 2 Bancos de 4x1:
La dirección recibida (addr) tiene 3 bits. El bit de mayor peso se usa para seleccionar cuál de los dos bancos (0/1) se usará, y los dos bits de menor peso para seleccionar el registro (0-3) dentro del banco 4x1. La señal de write que reailza la escritura se envía a un banco u otro según el valor de este bit de mayor peso
La otra posible implementación es en verilog, que es más compacta, pero es TOTALMENTE EQUIVALENTE a la anterior. Consume los mismos recursos porque decribe el mismo hardware. Se encuentra en Banks/01-bit-regs/Bank-8x1. La única diferencia es que en verilog podemos incluir un parámetro adicional para introducir los valores iniciales de los registros del banco
Para probar el banco 8x1 con el panel web utilizamos los conmutadores "f", "g" y "h" para establecer la dirección de 3 bits, y con el switch "H" el dato. Con el pulsador "Q" se realiza la escritura. La dirección actual se muestra tanto en los LEDS virtuales "f", "g" y "h" como en los LEDs reales 7,6 y 5. El dato almacenado en el registro actual se muestra por el LED virtual "H" y el LED real 0
En este pantallazo se muestra el panel y la placa cuando se acaba de guardar el valor 1 en el registro 7
En este vídeo lo vemos en acción. Primero se muestran algunos registros, que están a 0. Luego se guarda 1 en los registros 7, 5 y 1. Por último se vuelven a poner a 0:
Cuando se está usando el banco 8x1 para implementar algún otro circuito con él, resulta muy útil utilizar su versión con salida en paralelo, ya que si la conectamos por ejemplo a los LEDs nos permite ver de un sólo golpe de vista el contenido de todos los registros
La implementación en bloques es muy sencilla: sólo hay que agrupar las salidas en paralelo de los 2 bancos de 4 registros
Probaremos el banco 8x1 con salida en paralelo con los pulsadores virtuales "Q" y "R". Con "R" se incrementa la dirección del registro actual, y con "Q" se cambia de estado (toggle). En los LEDs virtuales "A"-"H" se muestra el banco de registros al completo (salida en paralelo) y también en los LEDs reales 7-0
(10-Bank-8x1-paralell-panel.ice)
En este pantallazo se muestra el estado del banco de registros cuando los registros 7, 4 y 0 están a 1, y el resto a cero
Y en este vídeo se puede ver en funcionamiento. Los registros 0, 4 y 7 se ponen a 1, por lo que se ven en los LEDs virtuales y reales correspondientes. Luego se vuelven a poner a 0
Como último ejemplo de bancos de registros vamos a probar el banco de 8 registros de 8 bits, o lo que es lo mismo, un banco de 8 registros de un byte. Al haber 8 registros (del 0 al 7), necesitamos 3 bits para la entrada de direcciones (addr). En cada registro se almacena 1 byte (8 bits) por lo que la entrada de datos es de 8 bits, y la salida del banco también de 8 bits
Para comprobar experimentalmente el funcionamiento del banco 8x8 utilizaremos este circuito de ejemplo que nos permite ver el contenido de los 8 registros en los LEDs. Inicialmente se muestra el registro 0, que tiene el valor 0 (por lo que los LEDs estarán apagados). Con el pulsador 2 se incrementa la dirección, por lo que vemos el siguiente registro. Al llegar al registro 7 y apretar SW2, se comienza de nuevo por el registro 0
Además, con el pulsador 1 incrementamos en una unidad el registro actual. Si realizamos una pulsación larga de SW1, lo incrementamos en 64 unidades. Así es más fácil hacer pruebas con números mayores
La dirección del registro actual NO se muestra en ningún LED, ya que sólo tenemos 8 disponibles y los estamos usando para visualizar los datos. Por ello en los siguientes ejemplos usaremos los LEDs virtuales del panel web
En este vídeo se muestra en funcionamiento. Primero se asigna el valor 3 (11 en binario) al registro 0, pulsando 3 veces el pulsador 1. Luego se pasa al siguiente registro mediante el pulsador 2 y los LEDs se apagan porque R1 tiene 0 almacenado. Se hacen 3 pulsaciones largas de SW1 para almacenar el valor 11000000. En los siguientes registros se almacenan consecutivamente los valores 00000001, 00000010, 00000100, 00001000, 00001001 y 00001011. Por último se recorren los 8 registros, y observamos los valores previamente guardados
Una posible implementación del banco 8x8 usando bloques es la que se muestra en este circuito:
Se han colocado dos bancos de 4x8 registros en paralelo. Es igual a la implementación del banco 8x1 pero ahora el multiplexor de salida es de 8-bits en vez de 1 bit
Y por supuesto también está la implementación en verilog, que es TOTALMENTE EQUIVALENTE a la anterior:
Para probar el banco 8x8 con el panel web utilizaremos 4 pulsadores y 11 LEDs, todos virtuales. Con los botones "T" y "S" se incrementa y se decrementa la dirección. Con ellos podemos recorrer todo el banco. Con "Q" se incrementa el registro actual en una unidad y con "R" en 64 unidades. En los LEDs virtuales "f", "g" y "h" se muestra la dirección actual, y en los LEDs "A"-"H" el dato almacenado en ese registro. Este dato se muestra también en los LEDs reales
En este pantallazo vemos el panel y la placa en el momento de haber guardado el valor 11000101 en el registro 5
En este vídeo lo vemos en funcionamiento. Primero se recorre el banco comprobando que todos los registros están a cero. Luego se almacenan los siguientes valores en los registros consecutivamente: 00000011, 11000000, 00000001, 00000010, 00000100, 00001000, 0001001 y 0001011
Las memorias tienen dos modos de funcionamiento, según el valor de la señal de control write. Cuando write es 0, la memoria está en modo de lectura. Decimos que se realiza un ciclo de lectura. Cuando write es 1, la memoria está en modo de escritura, y decimos que realiza un ciclo de escritura
En el caso de los bancos de registros, las lecturas son combinacionales. Esto significa que todo se realiza dentro del mismo ciclo de reloj: en cuanto se coloca la dirección en addr, la memoria devuelve el dato contenido en ella sin esperar a que llegue un nuevo ciclo de reloj. Sin embargo, las escrituras son síncronas: sólo se realiza esta escritura cuando llegue un flanco de subida del reloj. Hasta que no llegue no hay captura. Por eso los ciclos de escritura tardan 1 ciclo de reloj del sistema en realizarse
Para entender mejor esta información sobre la duración y la temporización, utilizamos unas gráficas llamadas cronogramas.
Este es un fragmento del circuito de pruebas que usaremos para generar su cronograma. Primero analizamos los ciclos de lectura, por lo que la señal write la dejamos a 0 todo el tiempo. Usamos un banco de 8 registros de 1 byte, inicializado con los valores desde 8 hasta 15 (8-F en hexadecimal)
(Ejemplo completo: 13-Bank-8x8-ciclo-lectura.ice)
Se usa un contador de 3 bits para seleccionar el registro (0-7). Al apretar el pulsador 1 el contador se pone a 0 y comenzamos la captura. Aparece una dirección nueva en cada ciclo de reloj. Sólo nos quedamos con los 4 bits de menor peso del dato de salida de la memoria, ya que en el sistema de captura de datos sólo disponemos de 8 canales
Este es el cronograma donde vemos la evolución de las 3 señales de interés: write, addr y data:
La señal clk es el reloj del sistema. En total se han representado 5 ciclos. Se ha marcado con una flecha apuntando hacia arriba los flancos de subida. Es en esos instantes cuando se producen los cambios, por eso se han colocado además unas marcas verticales
En el primer ciclo no nos interesa lo que ocurre, por eso los valores de las señales tienen asignado el valor "XXX", que significa: "No nos importa". Puede ser el valor que sea, da igual. En algún momento de este primer ciclo la señal de rst se pone 1, porque el usuario ha apretado el pulsador. A partir de aquí comenzamos con la captura de los datos, y los siguientes ciclos serán los que nos interesen
Las flechas curvas en fuxia nos indican una relación de causalidad. Así, al llegar el segundo flanco de subida de clk, como la señal rst está a 1, causa que el contador se ponga a 0 y por tanto la señal Addr tiene el valor 0. Además la señal write también es 0 por lo que se inicia el primer ciclo de lectura. Esto provoca que aparezca en Data el valor de la posición de memoria 0, que es 8. El ciclo de lectura es combinacional por lo que en cuanto hay una dirección nueva, automáticamente se devuelve su valor, sin esperar a que venga el siguiente ciclo de reloj
Cuando llega el tercer flanco de reloj, como rst es 0 el contador se incrementa en una unidad, apuntando a la siguiente dirección de memoria: la 1. Esto provoca que la memoria devuelva el valor de dicha posición: 9, ya que como la señal de write es 0, se trata de un ciclo de lectura
En los sucesivos ciclos de reloj ocurre lo mismo. Al ser write=0 son ciclos de lectura. La señal Addr se incrementa con cada ciclo: 2,3,4... y la memoria devuelve los datos de esas posiciones: A, B, ...
Para generar los cronogramas del ciclo de escritura utilizaremos un banco de 4x8, con todos sus registros inicializados a 0. Al comenzar la captura, el circuito recorre la memoria escribiendo el valor addr + 1, donde addr es la dirección actual. En los primeros cuatro ciclos de reloj se escriben los valores 1,2,3 y 0. En los siguientes 4 ciclos de reloj se lee la memoria secuencialmente para comprobar que efectivamente los valores anteriores se habían escrito
El circuito completo que realiza la captura es el siguiente:
(14-Bank-4x8-ciclo-escritura.ice)
Este es el cronograma obtenido a partir de las mediciones:
Al apretar el pulsador para empezar la captura, la señal Trig se pone a 1 durante un ciclo de reloj (es un tic). Al final del primer ciclo, Trig es 1. Al llegar un flanco de subida del reloj del sistema se coloca 0 en Addr y 0+1 en Din. La señal write también se pone a 1 para iniciar un ciclo de escritura. Por la salida de la memoria, Dout, se muestra el valor actual de la dirección 0, que es 0
En este cronograma se muestran los contenidos del banco de registros: M0 - M3, que inicialmente están a 0. Al finalizar el segundo ciclo de reloj del cronograma, se escribe en M0 el valor 1, se coloca la nueva dirección: Addr=1 y el nuevo dato a grabar: din = 1+1
Al llegar el tercer ciclo de reloj se repite el proceso: ahora se guarda 2 en M1, se coloca la nueva dirección: Addr=2 y el nuevo dato de entrada: din = 2+1. Durante estos cuatro primeros ciclos de escritura la señal write permance siempre a 1
Al final del último ciclo de escritura, la señal write se pone a 0 para comenzar los ciclos de lectura. En Addr se sitúa la primera dirección: 0 y en din 0+1 (pero como ahora es una lectura, el valor de din nos da igual). Al ser una lectura combinacional, por dout se obtiene lo que hay en la dirección 0, que es 1 (el valor que habíamos guardado previamente)
Si nos fijamos en la evolución de dout durante los ciclos de lectura, obtenemos los valores 1,2,3 y 0, que justamente son los valores dados a din durante los ciclos de escritura
Los cronogramas contienen mucha información, de manera muy compacta. Por ello entender todo lo que ocurre en ellos lleva un tiempo
En este ejemplo haremos ciclos de escritura y lectura combinados. Primero una escritura y luego la lectura, en las 4 direcciones de memoria de un banco de 4x8. La memoria está inicializada a 0
El circuito que realiza la medición es casi idéntico al usado en el ejemplo de los ciclos de escritura. Pero ahora se usa el bit menos significativo del contador como señal de write, y los 2 bits más significativos para la dirección. De esta forma para cada una de las 4 direcciones (00, 01, 10, 11) se hace un ciclo de escritura (write=1) seguido de otro de lectura (write=0)
(15-Bank-4x8-ciclo-escritura-lectura.ice)
El cronograma que obtenemos es el siguiente:
Durante el primer ciclo de escritura, se está escribiendo el valor 1 (din=1) en la dirección 0 (addr=0). En ese momento, la salida de la memoria es el valor que hay en M0, que todavía es 0. Al llegar el flanco de reloj se hace la escritura, por lo que M0 es ahora 1, y se cambia la señal de write a 0 para indicar que ahora queremos leer de la memoria. Al ser la lectura combinacional, inmediatamente se obtiene por dout el valor de la dirección 0, que es 1 (dout=1)
Al comenzar el segundo ciclo de escritura, addr se cambia a 1 por lo que por dout sale lo que hay en esa dirección de memoria, que es 0 (dout = 0). Por din se sitúa el nuevo valor a grabar: din=2. Al llegar el flanco de reloj, se hace la escritura, por lo que M1 = 2. Se pasa a lectura (write=0) y por dout sale el contenido de la dirección 1, que ahora es 2 (dout = 2)
Las memorias síncronas son prácticamente iguales a los bancos de registros. Tienen las mismas señales de entrada y salida, así como la misma organización: grupos de bits referenciados mediante su dirección de memoria. Esta es la pinta que tienen los bloques de memoria síncrona de la colección iceMem:
En las memorias síncronas, tanto la escritura como la lectura se realizan al llegar un flanco de subida del reloj del sistema. Por tanto, ambas acciones tardan 1 ciclo de reloj. En los bancos de registros la lectura es combinacional, y no hay que esperar el flanco del reloj
Las memorias síncronas se comportan como un banco de registros al que se ha añadido un registro a su salida, que captura el dato leído de la memoria en cada ciclo del reloj
Las FPGAs tienen en su interior unos bloques lógicos, llamados PLBs en la familia ice40 de Lattice, que contienen tablas para implementar circuitos combinacionales (LUTs=Look Up Tables) así como biestables D para almacenar información. Con estos elementos construimos los registros, los bancos de registros y también las memorias síncronas. Es lo que hemos hecho en los apartados anteriores de este cuaderno técnico
Sin embargo, como la memoria es algo que se usa mucho, en prácticamente todas las FPGAs podemos encontrar bloques específicos de memoria, que en el caso de la familia Ice40 de Lattice se llaman BRAMS (Block Rams). Cada BRAM contiene en su interior 4096 bits. La FPGA de la placa Alhambra II, la que estamos usando para los ejemplos, es el modelo ice40HX8K, que tiene un total de 32 BRAMs, lo que nos da una capacidad de almacenamiento de 32 * 4096 = 131072 bits
Las BRAMs son síncronas, y nos permiten construir memorias síncronas de mayor capacidad. Si lo que estamos usando son bytes, la memoria síncrona de mayor capacidad posible será de 131072 / 8 = 16384 Bytes = 16KiB
En este dibujo se muestran algunos de las memorias síncronas disponibles en el menú Smemory de la colección iceMem. Están implementadas utilizando BRAMs. Las memorias de 16B hasta 256B ocupan una BRAM, pero NO completa. Desde la de 512B hasta la de 16KiB ocupan BRAMs completas, desde 1 hasta las 32 disponibles en las FPGAs iCE40Hx8K (la que lleva la Alhambra II)
Probaremos el funcionamiento de una memoria síncrona de 16 posiciones de 1 byte cada una. Las direcciones van desde la 0 hasta la 0xF (15). Se necesitan 4 bits. La memoria está inicializada con valores correspondientes a su dirección. Así, en la dirección 0 hay un 0, en la 1 un 1... y en la 15 un 15
Se navega por la memoria mediante los pulsadores 1 y 2. Con el segundo se avanza a la siguiente posición, y con el primero se retroce una posición. El contenido de cada posición se muestra en los LEDs
Mediante pulsaciones prolongadas en SW1 y SW2, modificamos el valor del dato de la posición actual. Con SW1 se incrementa en 1 unidad, y con SW2 en 64
En este vídeo lo vemos en funcionamiento. Primero se muestra en los LEDs las posiciones desde la 0 hasta la 3, y se vuelve a la 0 (todos los LEDs apagados). Usando una pulsación larga en SW2 se suma 64 a las posiciones 0 a 3. Se vuelve a 0. Finalmente se recorren las 16 posiciones y se llega a 0 nuevamente
Este es un ejemplo similar al 16, pero usando el panel web. Con los botones 'T' y 'S' se incrementa/decrementa la dirección actual. En cada posición de memoria se tiene grabado un dato igual a su dirección. Con los botones 'R' y 'Q' se suma al dato actual un 1 ó un 64. El dato actual se muestra tanto en los LEDs físicos como en los LEDs virtuales "A"-"H"
El funcionamiento en modo manual es igual que con el banco de registros. Depositamos la dirección y obtenemos el contenido que está grabado en ella. En este vídeo puedes ver una demostración. Inicialmente el dato guardado en cada posición de memoria es igual a su dirección
Desde Icestudio tenemos la opción de ver qué recursos de la FPGA se están usando para saber cuánto nos queda disponible, y tener una idea del tamaño que ocupa nuestro circuito. Esto lo activamos desde la opción Ver/Recursos de la FPGA
Al hacerlo, veremos en la parte inferior de icestudio una barra nueva con los recursos de la FPGA:
Cuando le damos a cargar el circuito (o simplemente al sintetizarlo) entonces ya sí que veremos los recusos empleados. En el caso del ejemplo 17, nos fijamos en los dos primeros recursos: LC y RAM. El primero son los bloques lógicos. Este circuito emplea 375 bloques lógicos (LC) de un total de 7680. Es decir, el 4%. Y sólo usa un bloque de RAM, de los 32 disponibles (3%)
El sintetizador es la herramienta que lee nuestros diseños "en humano" y a partir de ellos infiere cuál es el hardware real a implementar. Luego implementa este hardware con los recursos internos de la FPGA. En el caso de las FPGAs de la familia iCE40, para que se infiera memoria síncrona y se implemente en los bloques BRAM la descripción se hace con este código en verilog (Ejemplo de una memoria síncrona de 16B):
//-- Address width
localparam ADDR_WIDTH = 4;
//-- Data width
localparam DATA_WIDTH = 8;
//-- Size of the memory
localparam SIZE = 1 << ADDR_WIDTH;
//-- Memory itself
reg [DATA_WIDTH-1:0] mem[0:SIZE-1];
//-- The data_out is a registered output (not a wire)
reg data_out;
//-- Reading port: Synchronous
always @(posedge clk)
begin
data_out <= mem[addr];
end
//-- Writing port: Synchronous
always @(posedge clk)
begin
if (wr) mem[addr] <= data_in;
end
//-- Init the memory
initial begin
if (ROMF)
$readmemh(ROMF, mem, 0, SIZE-1);
end
Cambiando las constantes ADDR_WIDTH y DATA_WIDTH implementamos las memorias de todos los tamaños posibles. En este pantallazo se muestra el interior de un bloque de memoria síncrona de 16B:
Si la memoria síncrona se implementa directamente con bloques de icestudio (banco de registros + registro en la salida) el sintetizador NO inferirá que se trata de una memoria síncrona y NO usará los bloques BRAM. Será una memoria síncrona totalmente funcional, pero se usarán los bloques lógicos en vez los bloques RAM
En las memorias síncronas, tanto las lecturas (wr=0) como las escrituras (wr=1) tardan un ciclo de reloj. Su funcionamiento queda perfectamente detallado en los cronogramas. Sacaremos los mismos tres cronogramas que usamos con los Bancos de registros pero aplicados a una memoria síncrona de 16 Bytes
La señal write se pone 0 duranto todo el tiempo para realizar un total de 16 ciclos de lectura. La memoria está inicializada con los valores 0x08 - 0x17, correspondientes a las direcciones 0 - 0xF
El contador de 4 bits de las direcciones está funcionando todo el tiempo. Al apretar el pulsador 1, se inicializa a 0 y comienza la captura. En el cronograma sólo se representan 5 ciclos de reloj
En la transición entre el ciclo 1 y 2, en el flanco de subida del reloj la dirección (Addr) vale 0. Pero ahora, como las lecturas NO SON COMBINACIONALES, por la salida de la memoria NO tendremos el contenido de la dirección 0 (que es 8), sino otro valor que no nos interesa (se ha marcado como xx)
(18-Smem-16B-ciclo-lectura.ice)
El contenido de la dirección 0 aparece en el siguiente ciclo de reloj (ciclo 3). En la transición entre el ciclo 2 y 3 aparece el siguiente valor de la dirección, 1, pero en la salida aparece el dato de la dirección anterior. Debido a que es una memoria síncrona y las lecturas tardan un ciclo de reloj, todas las salidas de la memoria están retrasadas un ciclo de reloj. A partir del ciclo 3 obtenemos las salidas de la memoria: 8, 9, A...
En este ejemplo capturamos el resultado de hacer 4 ciclos de escritura y luego 4 ciclos de lectura. La memoria de 16B tiene inicialmente todo su contenido a 0. En el ciclo de escritura se escriben los valores 1,2,3 y 0 (En la posición Addr se escribe el valor Addr+1)
En el cronograma se ha incluido el contenido de las posiciones de memoria: M0-M3. En la transición entre el ciclo 2 y 3 se guarda el primer valor, M0 = 1. Y en los siguientes ciclos el resto de valores: M1=2, M2=3 y M3 = 0
(19-Smem-16B-ciclo-escritura.ice)
En la transición entre el ciclo 5 y 6 la señal write se pone a 0 para iniciar los ciclos de lectura. En el ciclo 6 la dirección es 0, pero la salida de la memoria NO contiene todavía el valor de esa dirección. En el siguiente ciclo, 7 ya sí que aparece. Si se compara este cronograma con el correspondiente del banco de registro se aprecia que es exactamente el mismo, salvo en los ciclos de lectura, que están retrasados un ciclo
En este ejemplo se generan ciclos de escritura y lectura intercalados. Se parte de una memoria síncrona de 16 Bytes inicializada a 0. En cada posición Addr se graba el valor Addr+1 y a continuación se lee: ciclo de escritura seguido de uno de lectura
En la transición del ciclo 2 al 3 se hace la escritura del valor 1 en la posición 0 (M0=1), y se cambia write a 0 para iniciar el ciclo de lectura. En la transición del ciclo 3 al 4 es cuando se hace la lectura, devolviendo 1 por Dout
(20-Smem-16B-ciclo-escritura-lectura.ice)
Desde que se escribe un valor en una posición hasta que se lee pasan 2 ciclos de reloj: uno para realizar la grabación, y otro para la lectura
En este ejemplo se usa una memoria de 1KiB (1024 bytes) desde el panel web. Las direcciones son de 10 bits. Desde el panel se usan los switches "s" y "t" para los 2 bits de mayor peso y los switches "a"-"h" para los 8 bits restantes. Con los switches "A"-"H" se introduce el dato de 8 bits y con pulsador "Q" se graba ese dato en la dirección actual
Las direcciones accesibles son desde la 0x000 hasta la 0x3FF. Para hacer pruebas, la memoria tiene dos posiciones con valores grabados: la dirección 0x100 contiene el valor 0xAA y la 0x300 el 0x55. Esto se especifica en la caja de contenido de la memoria, en su parte superior, colocando la dirección en hexadecimal con el prefijo @ y a continuación el valor de esa posición (También en hexadecimal):
@100 AA
@300 55
En este pantallazo se muestra el panel de control en el que se ha establecido la dirección 0x300, y en los LEDS se comprueba que el valor ahí almacenado es el 0x55
En este vídeo se muestra el ejemplo en acción. Primero se accede a la dirección 0x100, y su contenido, 0xAA, aparece en los leds. Luego se cambia a la dirección 0x300, y vemos 0x55. Después se va a la dirección 1 y se graba el valor 0xFF. Si accedes a otras posiciones de memoria y luego volvemos a la 1, veremos que efectivamente ese valor ha quedado guardado
Los valores a situar en la memoria se pueden establecer usando dos mecanismos diferentes. Uno es el que hemos usado hasta ahora: mediante el componente de Icestudio situado en el menú Basico/Memorias. En esa caja escribimos los valores en hexadecimal que se sitúan en la memoria, de forma consecutiva
También es posible tener los datos en un fichero de texto externo, con el mismo formato usando en la caja de memoria. La carga de la memoria se realiza duante el proceso de síntesis. Hay que colocar el elemento de icestudio Basico/Constante con el nombre del fichero entre comillas. El fichero debe tener la extensión .list para que icestudio lo pueda reconocer
Como ejemplo, usaremos el mismo circuito del ejemplo 21: una memoria de 1KiB accesible desde el panel web. El contenido de la memoria se inicializa desde el fichero EX22.list
(22-Mem1KiB-panel-fichero.ice) (EX22.list)
En este pantallazo se muestra el fragmento del circuito de ejemplo donde está la memoria pasándole como parámetro el nombre del fichero
El fichero con el contenido de la memoria debe estar situado en el mismo directorio donde está nuestro proyecto .ice de icestudio. Si está situado en otro directorio, o estamos usando un nombre de fichero incorrecto, al sintetizar/cargar nos aparecerá una notificación en rojo en la parte inferior derecha con el mensaje: "El archivo EX22.list no existe"
Este es el contenido del fichero EX22.list usado en este ejemplo:
//-- Direcion 0
10
11
12
13
14
15
16
17
18
19
1a
1b
1c
1d
1e
1f
@80 //-- Direccion 0x80
F0
F1
@100 //-- Direccion 0x100
C0
C1
C2
@300 //-- Direccion 0x300
70
71
72
Todos los números están en hexadecimal. El prefijo @ se usa para establecer la nueva dirección, a partir de la cual se situarán los siguientes datos. Los caracteres // se utilizan para insertar comentarios
Veremos dos circuitos de ejemplo que utilizan memorias síncronas de 16 bytes. El primero reproduce una secuencia de 16 estados en los LEDs. Esta secuencia se reprograma enviando los datos desde el PC. El segundo ejemplo nos permite modificar los datos almacenados en la memoria y realizar un volcado al PC para ver su contenido
En este primer ejemplo se reproduce una secuencia de 16 estados en los LEDs. Al arrancar el circuito, se recorre la memoria enviando los datos a los LEDs, con un tiempo de 100ms (es un parámetro que se puede modificar). Esta secuencia se reproduce todo el rato. Cuando se aprieta el botón 1 se entra en el modo de carga. Los 16 bytes que se reciban por el puerto serie se almacenan secuencialmente en la memoria. Una vez realizada la carga se pasa automáticamente al modo reproducción, donde se verá la nueva secuencia en los LEDs
Este es el circuito completo:
(23-Secuencia-programable-leds.ice)
Las acciones de reproducir la secuencia (modo secuencia) y de carga (modo upload) se implementan mediante dos máquinas de contar: la máquina 0 y la máquina 1. Según el modo, la dirección que le llega a la memoria proviene de la máquina 0 ó de la 1. Esto lo determina la señal upload_on, que se activa cuando estamos en modo de carga. En caso contrario se está en modo de reproducción:
La entrada de datos a la memoria, din, llega del puerto serie. La señal write se activa cada vez que llega un dato y estamos en modo de carga (el usuario ha pulsado el botón 1). En caso contrario, todo lo que llegue por el puerto serie se ignora
La máquina de reproducción (máquina 0) se activa al encenderse el circuito. Empieza a contar desde 0 hasta 15 enviando la dirección (addr0) a la memoria. Por los LEDs se muestra el contenido actual. Por cada dirección de memoria, la máquina activa el pulso exec que enciende un temporizador de 100ms. Al cumplirse el tiempo, se emite un pulso para que la máquina pase a la siguiente dirección
La máquina se pone en bucle infinito, de forma que al terminar emite una señal por done, que hace que la máquina vuelva a arrancar, comenzando un nuevo ciclo de reproducción
La máquina de carga (máquina 1) está apagada inicialmente (upload_on es 0). Cuando se aprieta el pulsador 1 arranca, y upload_on se pone a 1. En este estado, al recibirse un dato por el puerto serie aparece un pulso en write que hace que ese dato se guarde en la primera dirección (indicada por addr1). Un ciclo después se pasa a la siguiente dirección (addr1 se incrementa en 1)
Durante toda la carga, la señal upload_on está a 1, por lo que el multiplexor de la memoria usa la dirección que llega de la máquina 1 (addr1) y habilita la señal de write para que se puedan realizar las escrituras
Cuando el circuito está en modo carga, los bytes se pueden enviar desde cualquier terminal de comunicaciones serie. Para poder hacer secuencias más interesantes, es mejor enviar los datos en hexadecimal. Podemos utilizar por ejemplo el ScriptCommunicator
Este es un pantallazo del Scriptcommunicator en el que se ha cargado una secuencia nueva en la memoria (la que se muestra en el vídeo de ejemplo a continuación)
Y en este vídeo lo vemos en funcionamiento. Primero se está reproduciendo la secuencia inicial, en la que hay un LED que se desplaza hacia arriba y hacia abajo. Al apretar el botón 1 esta secuencia se para: se ha entrado en modo de carga. Desde el Script Communicator se envía la nueva secuencia de 16 Bytes pulsando el botón de send. La nueva secuencia se reproduce en los LEDs
La secuencia la podemos cargar desde nuestros propios programas, en vez desde el programa de comunicaciones serie. Este es un ejemplo de un programa en python que envía una secuencia prefijada
#!/usr/bin/env python3
# -*- coding: iso-8859-15 -*-
from serial import Serial
import time
SERIAL = "/dev/ttyUSB1"
SEQ = b"\x03\x0c\x30\xc0\xc0\x30\x0c\x03\x00\x00\x00\x00\x00\x00\x00\x00"
# ------------------ Main
if __name__ == "__main__":
# -- Open the serial port
sp = Serial(SERIAL, 115200)
time.sleep(0.2)
print("Uploading the sequence to the FPGA..")
sp.write(SEQ)
time.sleep(0.5)
sp.close()
Deberás cambiar el nombre del dispositivo serie (SERIAL) para poner el que tengas en tu sistema opererativo
En este Vídeo lo vemos en funcionamiento. Se cargan dos secuencias diferentes, arrancando dos programas en python desde la consola.
En este ejemplo se realiza un volcado de la memoria de 16 bytes al PC cada vez que se aprieta el pulsador 1. Los datos recibidos se pueden ver usando un programa de comunicaciones serie que permita la visualización en hexadecimal. Mediante el pulsador 2 se incrementa el dato apuntado por un contador, que inicialmente tiene la dirección 0. Además, este contador se incrementa para apuntar a la siguiente dirección. De esta forma modificamos el contenido de la memoria y podemos comprobar los resultados en el volcado
Este es el circuito completo:
El volcado se controla mediante una máquina de contar de 4 bits, que direcciona la memoria empezando desde la dirección 0 y terminando en la 15 (0x0F). La máquina se activa con el pulsador 1. Al activarse, la señal dump_on se pone a 1. Por cada dirección se emite un pulso por txmit para realizar la transmisión serie del dato
El dato se envía en el siguiente ciclo de reloj, que es lo que tarda la memoria en devolverlo, por eso se coloca el biestable D entre la etiqueta txmit y la entrada txmit. Cuando se ha terminado de enviar el dato, se emite un pulso por next y la máquina de contar se incrementa en una unidad para apuntar a la siguiente dirección a volcar
Con el pulsador 2 se graba en la posición actual (Addr0) el dato incrementado en una unidad. Además se incrementa otro contador de 4 bits, para apuntar a la siguiente posición
La memoria se direcciona mediante la dirección proveniente de la máquina 1 (addr1, en el modo de volcado) o bien de la dirección actual de incremento. Se hace mediante un multiplexor de 2 a 1 cuyo selector es el estado de la máquina 1. En el modo de volcado se accede mediante addr1, en el resto de casos mediante addr0
La señal write y el dato a grabar (din) sólo se usan en el modo de incremento, por lo que no hay multiplexor. En la salida de la memoria hay un demultiplexor de 1 a 2 para enviar el dato bien al puerto serie, o bien al sumador para su incremento, según el estado de la máquina de volcar. Si está activa se envía al puerto serie. De lo contrario se envía al incrementador.
Cargamos el circuito y lo probamos. Al apretar el pulsador 1, se hace un volcado de la memoria en su estado inicial. Desde el programa de comunicaciones serie (Por ejemplo el Script Communicator) podemos ver los bytes recibidos
Efectivamente se corresponden con los que se había situado inicialmente en la memoria. Si apretamos una vez el pulsador 2, se incrementará el valor de la posición 0 (que estaba a 0, por lo que pasa a valer 1). Realizamos un nuevo volcado apretando SW1 para comprobarlo
Si nos fijamos en la primera columna vemos cómo efectivamente del primer al segundo volcado ha cambiado. El resto de la memoria permanece igual. Ahora apretamos el pulsador 2 varias veces más, para incrementar las siguientes posiciones de memoria. Esto es lo que veremos en el terminal si apretamos, por ejemplo, 3 veces más:
Las posiciones de memoria 1,2 y 3 (columnas 2, 3 y 4) se han incrementado con respecto al volcado anterior. Pero el resto han permanecido igual
En este vídeo se muestra en funcionamiento
Los bytes provenientes del circuito del volcado los podemos obtener desde nuestros propios programas en vez de usar un terminal de comunicaciones serie. Este es un ejemplo en python que recibe los 16 bytes, los imprime en la pantalla y los guarda en el fichero data.raw (en binario). Finaliza cuando se pulsa ctrl-c
#!/usr/bin/env python3
# -*- coding: iso-8859-15 -*-
from serial import Serial
import time
from pathlib import Path
SERIAL = "/dev/ttyUSB1"
TIMEOUT = 100
FILENAME = "data.raw"
BYTES = 16
# ------------------ Main
if __name__ == "__main__":
# -- Open the serial port
serial_p = Serial(SERIAL, 115200)
time.sleep(0.2)
while True:
print("Waiting for the Data from the FPGA...")
try:
data = serial_p.read(BYTES)
except KeyboardInterrupt:
print("\nABORT...")
break
print("")
data_hex = [hex(d) for d in data]
print("Data received: ")
print(f'{data_hex}')
# Write the data into the file
p = Path('.')
f_data = p / FILENAME
f_data.write_bytes(data)
print(f"FILE: {f_data.name}\n")
serial_p.close()
Al ejecutarlo se queda esperando a recibir el volcado. Si apretamos el pulsador 1 obtenemos lo siguiente:
El fichero data.raw se puede ver con un visualizador hexadecimal. Desde la consola de Linux se puede ver muy fácilmente con el comando hd (hexdump):
En este vídeo se muestra en funcionamiento
Hemos visto que una memoria es en realidad un conjunto de registros a los que se les asigna una dirección: sus posiciones de memoria. Para acceder a estos registros tenemos unas señales de interfaz: la dirección (addr), el dato a grabar (din), la señal de escritura (write) y el dato de salida (dout)
Esta interfaz se usa muchísimo para intercambiar información entre dos circuitos. Principalmente entre un procesador (cpu) y un periférico. En vez de trabajar con memoria "normal", algunas posiciones de memoria se usan para enviar datos a un periférico o leer información de él
El esquema de organización de todas las direcciones usadas para acceder tanto a memoria real como a los periféricos se denomina mapa de memoria. En esta figura se muestra la idea. Se dispone de un mapa de memoria genérico, en el que hay memoria ROM (de sólo lectura), memoria RAM (lectura y escritura) y varios periféricos
Hay un circuito principal que accede a este mapa de memoria utilizando la interfaz estándar de la memoria: Dirección, dato a escribir y señal de write. Esta interfaz se agrupa en lo que se denominal el bus de direcciones (que llevan la dirección a la que se quiere acceder), el bus de datos de entrada (los datos a escribir) y el bus de control con las señales de control necesarias. En nuestras memorias sólo hemos usado la señal write, por lo que nuestro bus de control es de sólo una señal
Los datos leidos de este mapa de memoria se devuelven por el Bus de datos de salida, y son procesados por el circuito principal. De cara al circuito principal, el acceso es a una memoria normal. Sin embargo, dependiendo de la dirección usada, se accede a un tipo diferente de elemento. En este dibujo tenemos memoria ROM, memoria RAM y varios periféricos
Para concretar las ideas vamos a utilizar un mapa de memoria de ejemplo. Utilizaremos direcciones de 6 bits, que van desde la 0x00 hasta la 0x3F (en hexadecimal). En binario van desde la 000000 hasta la 111111. El espacio total direccionable es de 64 bytes. Pero dentro de este espacio no todas las posiciones se utilizan. Este es el esquema del mapa de memoria:
Las direcciones desde la 0x00 hasta la 0x0F están ocupadas por una memoria ROM de 16 Bytes. Es decir, es una memoria de sólo lectura. Las escrituras de datos en esa zona no tendrán efecto. A continuación hay una memoria RAM de 8 Bytes, que ocupa las direcciones 0x10 hasta la 0x17. En esta memoria podemos leer y escribir datos
En la dirección 0x20 hay un puerto de salida. Es una posición de memoria especial, cuyo efecto es sacar hacia el exterior de la FPGA el dato que se haya escrito en ella. En nuestro ejemplo este puerto de salida lo conectaremos a los 8 LEDs de la Alhambra II. Se trata de un puerto de lectura y escritura. Todo lo escrito en él se sacará por lo LEDs.Y todo lo que se haya escrito se podrá leer en cualquier momento
Finalmente, en la dirección 0x21 hay un puerto de entrada. Al leer de esta posición lo que estamos leyendo en realidad es información que llega desde el exterior de la FPGA. Como ejemplo, conectaremos los pulsadores 1 y 2 de la Alhambra II a este puerto. Cualquier lectura nos devolverá el estado de los pulsadores. Se trata de un puerto de sólo lectura: Si escribimos un valor en él, simplemente se ignora (¡¡no se puede escribir nada en los pulsadores!!)
En este mapa de memoria vemos que hay zonas NO usadas. Esto suele ser muy típico. Se dejan reservadas para futuros usos: mapeo de periféricos nuevos, o ampliación de memoria. Como en esas posiciones no hay nada, tanto las escrituras como las lecturas no tendrán ningún efecto
Este es un ejemplo de circuito que nos permite acceder al mapa de memoria anterior (direcciones de 6 bits) desde el panel web, y así probar y comprender cómo funciona. Primero lo probaremos y luego veremos cómo está implementado
El bloque del panel web genera la dirección a la que acceder, introducida por el usuario, así como el dato a guardar y la señal de write en caso de escritura. El dato leído del mapa de memoria se introduce en este bloque para sacarlo por los LEDs virtuales
El bloque del mapa de memoria tiene una interfaz muy parecida a la de las memorias. Recibe la dirección, el dato a escribir y la señal de escritura (write) y devuelve el dato leído (en caso de lectura). Además, tiene la entrada inp que es lo que se leerá por el puerto de entrada. Está conectada a los dos pulsadores 1 y 2, que se lee en los bits 1 y 0 del puerto de entrada
Tiene también la salida outp con el valor escrito en el puerto de salida. Esta salida está conectada a los LEDs
En este pantallazo se muestra el estado de la placa una vez que se ha escrito el valor 0xA8 en el puerto de salida (0x20). Vemos que en los LEDs se puede ver el valor escrito. En los interruptores de la dirección está la dirección 0x20 (100000). En los LEDs virtuales vemos también el valor escrito: 0xA8, ya que es un puerto de lectura/escritura
En este otro pantallazo se está accediendo al puerto de entrada, en la dirección 0x21. Como se está apretando el pulsador 1 el valor leído es 0x02. Se puede ver que el bit 1 (LED virtual "G") del panel está activado, y el resto a 0
En este vídeo lo vemos en funcionamiento. Primero se accede a las posiciones de la ROM: 0x00, 0x01, 0x02, 0x03 y 0x04. Se intenta escribir un valor, pero no se puede. Luego se muestran las direcciones 0x10 y 0x11 que se corresponden con la RAM. En la dirección se guarda el valor 0x07 para mostrar que sí se puede escribir. Luego se escribe el valor 0xC3 en el puerto de salida, en la dirección 0x20, por lo que aparece en los LEDs. Finalmente se accede al puerto de entrada de la dirección 0x21. Al apretar los pulsadores se puede ver su valor en los bits 1 y 0 de los LEDs virtuales
Para implementar el mapa de memoria se utilizan comparadores, codificadores, multiplexores, registros y las memorias. Los comparadores se usan para detectar a qué zona del mapa de memoria se está accediendo: ROM, RAM, puerto de salida o puerto de entrada. El codificador convierte esta información en un número que indica la zona: 0 es la ROM, 1 es la RAM, 2 es el puerto de salida y 3 es el puerto de entrada
El multiplexor se usa para seleccionar de dónde viene el dato leido y devolverlo por el bus de datos de salida. Los registros de 8 bits los empleamos para implementar los puertos, tanto de entrada como de salida
El circuito completo, que se encuentra dentro del bloque del mapa de memoria del ejemplo 25, es este:
En las siguientes secciones se explica todo con más detalle, implementando el mapa de memoria incrementalmente
Comenzamos añadiendo al mapa de memoria sólo la memoria ROM. El mapa de memoria completo tiene direcciones de 6 bits. Es decir, un tamaño total de 64 bytes (2 elevado a 6). La ROM tiene un tamaño de 16 Bytes: utiliza direcciones de 4 bits. Los dos bits de mayor peso, hasta llegar a 6, nos indican el bloque de 16 bytes en el que se situará la ROM
En total, el mapa de memoria lo podemos descomponer en 4 bloques de 16 bytes (4 * 16 = 64). Cada bloque tiene un número igual a sus dos bits de mayor peso: bloque 00, 01, 10 y 11. Queremos situar la ROM en el bloque 0, para que se encuentre mapeado entre las direcciones 0x00 - 0x0F (000000 - 001111 en binario). Es el bloque 0. Es decir, que cuando los dos bits de mayor peso son 0 significa que estamos accediendo al bloque de la ROM. Esa será la condición que utilizaremos para saber si estamos accediendo a la ROM
Este es el circuito:
(25-1-Mapa-memoria-6bits-ROM.ice)
Mediante un comparador de 2 bits se comprueba que el número del bloque al que se accede es el 0. En ese caso se activa la señal cs_rom (chip select rom) para indicar que se está accediendo a la ROM. Los 4 bits de menor peso de la dirección se llevan a la entrada de dirección de la memoria. Como queremos que sea de sólo lectura, conectamos un 0 a su entrada write: será imposible escribir nada
El dato que sale del mapa de memoria sólo es válido si estamos accediendo a la ROM. En caso contario se saca un 0. Esto se hace con una puerta AND de habilitación, usando la señal cs_rom
Si quisiéramos que la ROM estuviese mapeada en otra dirección, por ejemplo en las direcciones 0x20 - 0x2F, utilizaríamos el valor 2'b10 en el comparador (bloque 2)
Añadiremos al mapa anterior la memoria RAM de 8 Bytes, situada en las direcciones 0x10 - 0x17 (010000 - 010111). El circuito completo está en este archivo: 25-2-Mapa-memoria-6bits-ROM-RAM.ice, pero mostraremos aquí sólo los cambios que hay que hacer en el circuito anterior
La memoria a colocar en el mapa es de 8 bytes (tiene 3 bits de direcciones), pero las direcciones del mapa de memoria son de 6 bits. Los 3 bits de mayor peso nos dividen los 64 bytes del mapa de memoria completo en 8 bloques de 8 bytes (8 * 8 = 64). Desde el bloque 0 hasta el bloque 7. Nosotros queremos colocar la RAM a partir de la dirección 0x10, que es el bloque 2 (010). Por tanto, se divide la dirección en dos campos de 3 bits, uno para indicar el bloque 2, y otro con la dirección a la que acceder dentro de ese bloque
Utilizamos un comparador de 3 bits para comprobar si el bloque es el 2, y activar la señal cs_ram. Esta señal nos indica que se está accediendo a la zona de la RAM, y permanece a 0 en caso contrario
La RAM es de lectura/escritura. La escritura sólo se realiza si llega un pulso por la señal write Y la señal cs_ram está activada. En ese caso se almacena en la dirección indicada por addr[2:0] el dato recibido por din
Al acceder a la RAM se activa la señal cs_ram, y al acceder a la ROM se activa cs_rom. Estas señales son mutuamente excluyentes: si hay una activada la otra no puede estar (la ram y la rom están en direcciones diferentes del mapa). Utilizando un codificador de 2 a 1 obtenemos un número de 1 bit que nos indica a qué zona estamos accediendo (sel). Si sel es 0, estamos accediendo a la ROM, y si sel es 1 a la RAM. Este número sólo es válido si la señal valid está a 1. En caso contrario, si valid es 0, significa que se accede a otra zona diferente de la ROM y la RAM (y por tanto la señal sel se ignora)
Por último, usamos un multiplexor de 2 a 1 para devolver por el bus de salida (dout) el dato leido de la zona correspondiente: ROM ó RAM. Este dato sólo es válido si la señal valid es 1. Se utiliza una puerta AND de habilitación para que sea 0 siempre que se esté accediendo a otras zonas diferenets de la ROM o la RAM
Añadiremos un puerto de salida de 1 byte a nuestro mapa, y lo situaremos por ejemplo en la dirección 0x20. La salida de este puerto la conectamos a los LEDs para comprobar que efectivamente estamos enviando información hacia el exterior. El circuito completo está en este fichero: 25-3-Mapa-memoria-6bits-ROM-RAM-OUTP.ice. Comentaremos las partes nuevas que hay que añadir a nuestro ejemplo anterior para disponer del puerto de salida
Como el puerto es de 1 byte, puede estar situado en cualquiera de las 64 posiciones (libres) del mapa. Para detectar si hay un acceso al puerto hay que utilizar un comparador de 6 bits. Si la dirección es igual a la del puerto (0x20) entonces se activa la señal cs_outp
El puerto en sí se implementa mediante un registro de 8 bits. La escritura en el puerto sólo se realiza si llega un pulso por la señal de write y la señal cs_outp está activa. En ese caso el dato se guarda en el registro. Y además se saca por los LEDs para comprobar que la escritura es correcta
Como en este nuevo mapa tenemos 3 zonas, hay que utilizar un codificador de 4 a 2, que devuelve un número de 2 bits por Sel con el número de la zona accedida: 0 para la ROM, 1 para la RAM y 2 para el puerto de salida. La cuarta entrada del codificador se pone a 0. La señal Sel sólo es válida si valid es 1. En caso contrario, cuando valid es 0, significa que se está accediendo a una zona fuera del mapa de memoria
El volcado del dato por el bus de salida (dout) se hace a través de un multiplexor de 4 a 1, utilizando una puerta AND de validación que devuelve 0 cuando se accede a una zona fuera del mapa
Por último añadimos un puerto de entrada de 8 bits, situado en la dirección 0x21, justo a continuación del puerto de lectura. El ejemplo completo está en este fichero: 25-4-Mapa-memoria-6bits-ROM-RAM-OUTP-INP.ice
Al ser un elemento de 1 byte, necesitamos usar los 6 bits para saber si se está accediendo a él o no. Utilizamos un comparador de 6 bits para comparar la dirección de acceso con la dirección del puerto (0x21). Si se accede a esa dirección, se activa la señal cs_inp. El puerto se implementa mediante un registro de 8 bits que captura los datos de entrada cuando se activa la señal cs_imp
Los datos de entrada son genéricos. En este ejemplo lo que se lee son los dos pulsadores de la placa Alhambra II, y se sitúan en los bits 0 y 1 del puerto. El resto de bits del puerto se dejan a 0 (todas las lecturas tendrán 0 en los bits 7 - 2)
Como en esta mapa final tenemos 4 zonas, en el codificador asignamos el número 3 a este puerto. Tenemos así las siguientes asignaciones: 0 ROM, 1 RAM, 2 Puerto de salida y 3 para el puerto de entrada. Este número de 2 bits está en la señal Sel
Y lo mismo para el multiplexor de acceso al bus de salida: Simplemente añadimos a la entrada i3 la salida del registro del puerto de entrada
Las memorias no sólo son uno de los elementos fundamentales de los computadores, sino que su interfaz se ha extendido para implementar la comunicación con periféricos. De esta forma, los circuitos ven al resto de circuitos como posiciones de memoria, e intercambian información como si lo hicieron con una memoria
Hemos hecho un recorrido desde el elemento mínimo de memoria: un biestable, hasta la construcción de un mapa de memoria que incluye distintos tipos de recursos: ROM, RAM y periféricos. Por el camino hemos pasado por los registros, bancos de registros y memorias síncronas
Los componentes de memoria para Icestudio están disponibles en la colección icemem, así como ejemplos de uso
Todo esto lo usaremos en cuadernos técnicos venideros para implementar nuestro propio computador
- Juan González-Gómez (Obijuan)
- Carlos Venegas. Muchísimas gracias por toda tu ayuda y tus aportaciones 😄
- Nand2Tetris. Muchas gracias al proyecto Nand2Tetris, que es toda una inspiración
- Colección iceMem. Colección de Icestudio con registros y memorias
- Icestudio, Nigthly builds
- Tutorial de Electrónica digital para makers con FPGAs libres
- Cuaderno ténico 8: Entrada y Salida de bits con componentes virtuales
- LOVE-FPGA: Paneles y componentes virtuales
- Colección LOVE-FPGA
- Tutorial 28: Biestables de datos y cambio
- Tutorial 29: Registros y comparadores
- Nand2Tetris
- Jedi Collection
- STDIO Collection
- Vídeoblog: Píldoras de conocimiento
- Unidad de PWM de frecuencia aproximada
- VGA Retro: Puesta en marcha. MonsterLED
- Pines de Entrada/Salida
- Control de LEDs
- SPI esclavo
- SPI Maestro
- Display SPI de 4 dígitos de 7 segmentos
- Entrada y Salida de Bits con Componentes Virtuales
- Memorias
- Entradas y pulsadores
- Señales del sistema. Medición con el LEDOscopio
- Controlador LCD 16x2
- Señales periódicas y temporización
- Buses: Medio compartido
- Memoria Flash SPI
- Conexión de LEDs en la Alhambra II. Placa AP‐LED8‐THT
- Periféricos PMOD
- Fundamentos. Sistema Unario
- Autómatas
- Pantallas de vídeo. Fundamentos. Display de 1x4 LEDs
- Pantallas de vídeo. Fundamentos. Matriz de 4x4 LEDs