La primera FPGA con soporte Open-Source - Parte 3


Sin embargo, aún no hemos revisado a detalle: ¿Qué hace o cómo funciona el código de ejemplo? ¡Y justo eso es lo que haremos en esta entrada!

Si han estado atentos, en el paso anterior ejecutamos las tres etapas de preparación que incluyen la síntesis, el ruteo y la generación del bitstream de forma automática.

En esta entrada vamos a seguir el diagrama completo del flujo de desarrollo con FPGA que mostrabamos en la primera entrada para entender mejor el funcionamiento de estas herramientas de desarrollo electrónico.

¡Comencemos!


Paso 1: Diseño electrónico

En esta parte utilizamos nuestros conocimientos en diseño de circuitos para construir el esquema o diagráma de las funciones que queremos que realice nuestra FPGA.

Utilizando una técnica inspirada en Dave-CAD decidimos un post-it y lapicero para generar nuestro diseño obteniendo el siguiente resultado:

(Creo que ahora comprenderán que no les mentía cuando les comentaba que las herramientas más comunes para diseño en el Hackerspace son la pizarra, papel y lápiz)

Comprendamos un poco que pasa dentro de este circuito:

En nuestro diseño nuestro módulo se llamará "TOP" y tendrá una sola entrada correspondiente a una señal de reloj (IN) y ocho salidas correspondientes a cada uno de los LEDs (OUT).

Para los que no están familiarizados con la nomenclatura de circuitos pueden observar que en la parte superior hay un símbolo similar a un ">" que representa un sumador. Un sumador hace lo que su nombre dice, toma dos buses (en este caso dos de 30 bits), suma los bits y coloca el resultado en un bus del mismo tamaño que sus operandos (30 bits) a la salida.

Notarán que una de las entradas del sumador tiene un valor fijo de "1", esto es porque esencialmente queremos que se sume siempre un 1 a los datos que guardamos en nuestra memoria.

La forma más básica en que una FPGA define memoria es a través de "latch tipo D" de tamaño arbitrario. Como la entrada del latch está conectada a su salida, entonces sinfica que con cada ciclo de reloj el valor a la salida del latch será incrementado en 1. Es decir, esta configuración es un contador de unidades.

Nuestra entrada de reloj, cuenta con un "detector de flanco positivo" (@posedge), esto nos garantizará que nuestras memorias solo guarden su estado cuando se detecte un flanco positivo en la señal del reloj.

La salida de la suma de la parte superior, es pasada a otro latch (resaltado en rojo), pero antes de conectarlo desplazamos 22 bits a la derecha, de tal manera que nos quedamos con un bus mas pequeño a la salida de únicamente 8 bits. Esto lo hacemos de esta manera porque el reloj corre a 12MHz y a esa velocidad sería imposible visualizar esto a la salida de los LEDs, así que solo utilizamos los 8 bits más significativos para poder apreciar el cambio ya que estos cambiarán a una velocidad humanamente perceptible al momento de funcionar.

Por último la salida de esta otra memoria la llevamos a una compuerta XOR (el circulo con el +) y la combinamos con la misma salida, pero desplazando un bit a la derecha, esta configuración tiene el efecto que solo un bit cambia a la salida por cada cambio de datos en la entrada.

Por último podemos ver como está conectada la salida de la compuerta XOR a cada uno de los LEDs de la salida.

El funcionamiento esperado de este circuito será  que los LEDs a la salida enciendan en una secuencia que solo ún LED cambie de encendido/apagado a la vez. Esta configuración a la salida esencialmente permite generar un código GREY a la salida en tanto la secuencia de bits en la entrada siempre sea incremental.

Paso 2: Generación de HDL

En esta ocasión no vamos a escribir el HDL en Verilog desde cero ya que IceStorm incluye el código de ejemplo.

En la carpeta de IceStorm busca el sub-directorio "examples", luego abre la carpeta "iceblink" y con tu editor favorito abre el archivo "example.v"
Este archivo contiene el código Verilog del ejemplo y el contenido debería ser similar al siguiente (Sin los comentarios):
// 1. Los módulos en Verilog permiten definir
// comportamientos específicos.
module top (
        // 2. Definimos las señales de entrada y salida
         input  clk,
        output LED0,
        output LED1,
        output LED2,
        output LED3,
        output LED4,
        output LED5,
        output LED6,
        output LED7
);
        // 3. Definimos parámetros de funcionamiento
        localparam BITS = 8;
        localparam LOG2DELAY = 22;

        // 4. Definimos "registros" de memoria
        reg [BITS+LOG2DELAY-1:0] counter = 0;
        reg [BITS-1:0] outcnt;

        // 5. Definimos el comportamiento cuando identificamos cambios de línea
        always @(posedge clk) begin
                counter <= counter + 1;
                outcnt <= counter >> LOG2DELAY;
        end

        // 6. Asignamos el valor de salida
        assign {LED0, LED1, LED2, LED3, LED4, LED5, LED6, LED7} = outcnt ^ (outcnt >> 1);
endmodule
Vamos a explicar brevemente lo que hace este código:
  1. En Verilog utilizamos las palabras clave "module" y "endmodule" para definir nuestro módulo funcional. Llamaremos a nuestro módulo "top".
  2. Para cada módulo definimos las "señales" de entrada y salida. Pueden fácilmente se pueden identificar las entradas y salidas de nuestro diagrama en el código VHDL. Identificamos a las señales que entran a nuestro módulo con la palabra clave "input" y las que salen con la palabra clave "output".
  3. Si deseamos definir valores como constantes o parámetros de configuración nos podemos ayudar de la palabra clave "parameter" que nos permite definir valores. Es necesario tomar en consideración que estos parámetros se establecen como constantes al momento de síntesis y no pueden ser modificados. Los tamaños de BUS (8 bits y 22 bits) resultan candidatos ideales ya que no cambiarán su valor en todo el diseño). Definir parámetros locales también nos facilitará más adelante simular el circuito en nuestro banco de pruebas.
  4. Para guardar nuestros datos, o definir estas memorias de tamaño arbitrario utilizamos la palabra clave "reg" o registro. Los registros son esencialmente arreglos de bits y para definirlos tenemos que especificar su tamaño y el orden en que accederemos a los bits. En este ejemplo se definen dos registros: counter que tiene un total de 30 bits (BITS + LOG2DELAY) y outcnt que es el registro que utilizaremos para guardar el estado de las líneas de salida.
  5. La palabra clave "always" nos permite definir que ocurre cuando cambia el valor de en una de nuestras señales (Transiciones de 0 a 1, de 1 a 0 o ambas). Para este ejemplo utilizamos  "@(posedge clk)" para indicar que el bloque solo modificará el estado de los registros especificados en el código en el flanco positivo de la señal clk. El algoritmo esencialmente incrementa en 1 cada ciclo de reloj sobre el registro counter y develve los 8 bits más significativos que son almacenados en outcnt. Noten que las operaciones que ocurren con este flanco se incluyen en el bloque "always".
  6. Por último "assign" nos permite conectar los registros o señales internas a señales de salida, en este caso un cambio del registro outcnt. Noten que también podemos realizar operaciones en la sección assign, en este caso nuestra operación XOR y nuestro desplazamiento de bits..

Definiendo las conexiones a los pines físicos

Habrán notado que en nuestro código en ningún momento definimos qué pines "físicos" utilizar de nuestra FPGA. La forma en que definimos los pines es a través de un archivo "pcf". En la misma carpeta del proyecto puedes abrir el archivo "hk8Xboard.pcf".

El archivo mostrará un contenido como el siguiente:
set_io LED0 B5
set_io LED1 B4
set_io LED2 A2
set_io LED3 A1
set_io LED4 C5
set_io LED5 C4
set_io LED6 B3
set_io LED7 C3
set_io clk J3
La iCE40-HK8X tiene una gran cantidad de IOs y el archivo PCF nos sirve para asociar nombres de señales a pines fisicos. Sin embargo, los pines anteriores no están disponibles en los pines de la tableta de desarrollo y es necesario consultar la hoja técnica. El pin clk es "especial" ya que está conectado a un oscilador de 12MHz incluído den la tarjeta en el pin J3.

Paso 3: Simular

Para simular debemos crear un "Banco de Pruebas" o en ingles Test Bench. En un test-bench vamos a simular que tenemos nuestro módulo "top" conectado y vamos a generar un archivo de tiempos (Wavefile) que nos servirá para graficar las diferentes señales. Si tratamos de diagramar nuestro banco de prueba se vería más o menos de la siguiente manera:

El banco de pruebas se escribe también en Verilog, así que crearemos un nuevo archivo llamado "example_tb.v" y vamos a pegar el siguiente código:

// 1. Comenzamos definiendo la escala de tiempo a simular
`timescale 1ms / 1ms

// 2. Generamos un módulo nuevo
module example_tb();

  // 3. Creamos un registro que funcionará como reloj
  reg clk = 0
  always #1 clk = ~clk;

  // 4. Utilizamos wire para definir nuestras señales de salida
  wire LED0,
      LED1,
      LED2,
      LED3,
      LED4,
      LED5,
      LED6,
      LED7;

  // 5. Inicializamos nuestro módulo y cambiamos los parámetros
  top #(.BITS(8),.LOG2DELAY(1)) DUT(
    .clk(clk),
    .LED0(LED0),
    .LED1(LED1),
    .LED2(LED2),
    .LED3(LED3),
    .LED4(LED4),
    .LED5(LED5),
    .LED6(LED6),
    .LED7(LED7)
  );

  // 6. En "initial begin" especificamos el comportamiento de
  // la simulación desde su inicio hasta su final
  initial begin
    $dumpfile("example_tb.vcd");
    $dumpvars(0, example_tb);
    # 1000 $finish;
  end
endmodule
Vamos a explicar un poco que hace el código de "Test Bench":
  1. Definimos la escala de tiempo y su precisión. Para este ejemplo la escala de tiempo es de 1 milisegundo y la precisión es de 1 milisegundo. En las simulaciones nosotros podemos incorporar "retardos", al ser la presición de 1 milisegundo significa que solo podremos crear retardos de multiplos de 1 milisegundo y no podremos simular retardos de menos de eso.
  2. De la misma manera que definimos un código en Verilog para definir nuestro bloque funcional, en esta sección definimos un módulo pero que contendrá a nuestro bloque "en prueba". Por convenciencia agregamos la terminación "_tb" al nombre de nuestro ejemplo.
  3. Necesitamos generar una señal de "reloj", en este caso definimos un registro (de 1 bit) que se llama "clk" y la siguiente orden especifica que "cada vez que ocurra un retardo de 1 ms" el valor de "clk" será igual a su valor negado. Es decir, este reloj cambiará su estado cada milisegundo.
  4. Con wire definimos conexiones "nombradas", esto es necesario ya que necesitamos conectar nuestro módulo a señales externas.
  5. Nuestro módulo a probarse se inicializa llamando primero su nombre "top", luego entre "#()" colocamos los valores de parámetros que deseamos cambiar. Notarán que podemos colocar el nómbre que deseemos a nuestro módulo, pero por convención se usa "DUT" del inglés Device Under Test (Dispositivo en Prueba). En el diseño original usamos un contador bastante grande de 30 bits, como simular un contador tan grande nos podría tomar una eternidad, bajamos el parámetro a únciamente 1 bit. Nota como conectamos las señales del módulo de la siguiente manera ".<Señal_módulo>(<Señal_prueba>)"
  6. Por último la instrucción "initial begin" establece que son las acciones que se realizarán una sola vez. En este caso, utilizamos la funión "dumpfile" para indicar el archivo donde se guardará la simulación y con "dumpvars" le indicamos el módulo, por último especificamos que luego de 1000 milisegundos terminaremos la simulación.
Para correr la simulación ejecutamos los siguientes comandos:

iverilog -o example_tb.vvp example_tb.v example.v

Este comando toma los archivos "example_tb.v" y "example.v" y genera un archivo "compilado" de verilog bajo el nombre "example_tb.vvp". Este archivo compilado contiene instrucciones sobre cómo correr la simulación del circuito.

Luego ejecutamos el comando:

vvp example_tb.vvp

Este segundo comando se encarga de "simular" el archivo provisto y escribir los valores de las señales y sus respectivos tiempos en el archivo "example_tb.vcd" que especificamos dentro de nuestro test-bench.

Y para visualizar las señales generadas:

gtkwave example_tb.vcd

En la ventana de GTKWave, aparece una lista a la izquierda con el nombre "example_tb". La expandimos y hacemos clic en "DUT". Podrán notar como aparecen todas las señales que definimos en nuestro módulo. Para explorarlas las seleccionamos y hacemos click derecho, luego seleccionamos "recurse import" y luego "append".

Si todo sale bien, veremos las señales simuladas en la ventana de GTKWave como se muestra  a continuación:

Paso 4: Síntesis

La mayor parte del tiempo te encontrarás repitiendo los pasos 1, 2 y 3. Una vez estás "feliz" con una simulación es hora de preparar tu circuito. El primer paso es la síntesis, esta la realizamos con la herramienta yosys.

Ejecutamos el comando:

yosys -q -p "synth_ice40 -blif example.blif" example.v

Yosys se encarga de hacer la síntesis y generar un archivo de intercambio de lógica que luego será utilizado por la herramienta de ruteo.

Paso 5: Ruteo

El ruteo se realiza con la herramienta arachne-pnr ejecutando el siguiente comando:

arachne-pnr -d 8k -p hk8Xboard.pcf example.blif -o example.txt

Como arachne intentará definir las mejores conexiones dentro de la FPGA requiere que especifiquemos el dispositivo "-d 8k" y el archivo de definición de pines "-p hk8XBoard.pcf", requiere también el archivo de lógica generado como resultado de la síntesis "example.blif" y guardará el resultado de las conexiones en el archivo "example.txt".

Paso 6: Generación del bitstream

Para generar el bitstream utilizaremos la herramienta "icepack" de "icestorm" con el siguiente comando:

icepack example.txt example.bin

La herramienta genera un archivo de configuración binario en el archivo "example.bin" que ahora estamos listos para configurar nuestra FPGA con el programa iceprog, con el siguiente comando:

iceprog example.bin

Ahora: Un poco de ayuda para no tener que memorizar tantos comandos

Descarga el archivo Makefile de la siguiente dirección:


Colócalo en el directorio de tu proyecto.

Edita la siguiente línea y cambia "led_driver" por el nombre de tu proyecto.
PROJ_NAME=led_driver
Solo recuerda seguir la siguiente nomenclatura:
  • "<nombre_projecto>.v": Archivo principal en Verilog de tu proyecto.
  • "<nombre_proyecto>_tb.v": Archivo de banco de pruebas en Verilog de tu proyecto.
  • "<nombre_proyecto>.pcf": Archivo de definición de pines de tu proyecto.
Luego de esto podrás ahorrarte tener que aprenderte de memoria todos los comandos anteriores y podrás ejecutar el proceso de forma semi-automática con la ayuda de make como explicamos a continuación:
  • Correr simulación y abrir el resultado en GTKWave
make run_simulation
  • Sintetizar, rutear y generar bitstream
make
  • Programar nuestra tarjeta de desarrollo FPGA
make flash_bitstream

Concluyendo

Desarrollar sobre FPGAs no es un proceso "sencillo" ya que los lenguajes de HDL al no ser "lenguajes que describen algorítmos" sino más bien "lenguajes que definen conexiones" nos obligan a tener al menos una imagen mental de los circuitos que deseamos constuir y cómo estos interactuan con otros módulos.

Sin embargo, la flexibilidad de estos lenguajes nos permite literalmente "crear chips" a la medida de nuestras necesidades. Las FPGA son utilizadas frecuentemente en el ámbito del procesamiento digital de señales debido a las grandes velocidades que pueden obtener y a la posibilidad de procesar datos con un gran nivel de paralelismo.

Las FPGA son utilizadas fuertemente en el ámbito de diseño de chips microcontroladores y microprocesadores ya que permiten simular "chips" sin necesidad de tener que fabricarlos a la medida.

Muy recientemente plataformas como el RISC V se han comenzado a popularizar como "núcleos" de microcontrolador completamente Open-Source y la forma de utilizarlos es justamente trabajar con lenguajes de abstracción de hardware como VHDL o Verilog.

La iCE40 si bien no es la FPGA más avanzada en el mercado, es la primera que nos permite programarla utilizando exclusivamente herramientas Open-Source y esto nos permite acercarnos cada día más al sueño de poder tener una computadora 100% Open-Source desde el diseño de los chips que la componen hasta el software que corre sobre ella.

Esperamos subir muy pronto algunos proyectos con FPGA y videotutoriales introductorios... Pero mientras tanto solo nos queda decir:

¡Hasta la Próxima!

Comentarios

Entradas populares de este blog

Reactivando el Blog

Emulando el Raspberry Pi OS con QEMU en Linux

Escribiendo Mensajes al Blockchain de Bitcoin