Verilator

8 minuto(s) de lectura

Esta entrada forma parte de una serie de entradas que quiero escribir relacionadas con las FPGAs. Como uno de los pasos a la hora de diseñar circuitos lógicos es la simulación, he decidido empezar hablando de Verilator, uno de los varios simuladores que existen en el mercado. Elijo este principalmente por que es de código abierto y gratuito. Además, es una herramienta cada vez más adoptada en la industria debido a sus bondades.

¿Qué es Verilator?

Verilator es un software que convierte diseños Verilog y SystemVerilog en modelos C++ o SystemC que pueden ser ejecutados con posterioridad. Por lo tanto, Verilator se podría ver también más como un compilador que un simulador.

El flujo de trabajo de esta herramienta se divide en una serie de pasos que he denominado de la siguiente manera:

  1. Verilar. Se invoca Verilator como se harı́a con herramientas como GCC. Verilator entonces lee el código en SystemVerilog y lo compila en un modelo C++ (SystemC). Este proceso se conoce como “Verilar” y la salida se denomina modelo “Verilado”.
  2. Envolver. De cara a simular el modelo Verilado, hay que envolverlo en un wrapper escrito en C++. Dicho wrapper incluye una función main que instancia el modelo verilado e “inyecta” diferentes valores en las entradas para testear el diseño.
  3. Compilar. Se compila el wrapper con el compilador de C++ para generar el ejecutable de la simulación.
  4. Simular. Se ejecuta el ejecutable para llevar a cabo la simulación.
  5. Depurar (opcional). Si se habilita la generación de trazas en el wrapper, se genera un fichero que se puede utilizar para visualizar las formas de onda del diseño durante la simulación.

Instalación

Verilator ha sido desarrollado y testeado en Ubuntu, por lo que se recomienda este Sistema Operativo para su instalación. Existen dos modos de instalar Verilator:

  • Binario. Consiste en instalar el ejecutable a través del gestor de paquetes\footnote{Las instrucciones son para Ubuntu.
sudo apt install verilator

Este modo de instalación es menos flexible que compilar el código fuente y seguramente la versión instalada no sea la última disponible, pero es más sencillo y directo.

  • Código fuente. Consiste en compilar el código fuente a partir del repositorio de git.
# Dependencias:
sudo apt-get install git help2man perl python3 make autoconf g++ flex bison ccache
sudo apt-get install libgoogle-perftools-dev numactl perl-doc
sudo apt-get install libfl2 # Ubuntu only (ignore if gives error)
sudo apt-get install libfl-dev # Ubuntu only (ignore if gives error)
sudo apt-get install zlibc zlib1g zlib1g-dev # Ubuntu only (ignore if gives error)

# Clonacion del repositorio
git clone https://github.com/verilator/verilator # Only first time
        
# Cada vez que haya que hacer el build:
unsetenv VERILATOR_ROOT # For csh; ignore error if on bash
unset VERILATOR_ROOT # For bash
cd verilator
git pull                 # Actualizar el repositorio de git
git tag                  # Listar las versiones
#git checkout master     # Use development branch (e.g. recent bug fixes)
#git checkout stable     # Use most recent stable release
#git checkout v{version} # Cambiar a una version concreta
autoconf                 # Crear el fichero ./configure
./configure              # Configurar y crear el Makefile
make -j `nproc`          # Construir Verilator (si da error, intentar solo 'make')
sudo make install

Este modo de instalación es más flexible, ya que permite instalar diferentes versiones del software que se adapten mejor a entornos concretos, pero es más laborioso. Para instrucciones más detalladas acerca de este modo de instalación véase este enlace.

Ejemplo básico de uso

Para entender mejor el uso de Verilator, lo mejor es hacerlo a través de un sencillo ejemplo. Para ello, vamos a diseñar un módulo con una interfaz muy simple consistente en una entrada y una salida. Lo que hará dicho módulo por dentro será negar la entrada de un bit y conectarla a la salida de un bit. El código, que llamaremos top.sv, se muestra a continuación.

/*
*       Módulo basico de ejemplo
*       Descripcion: conexion de la entrada negada a la salida
*       Dependencias:
*/
module top (
            input   wire    clk,
            input   wire    rst,
            input   wire    a,
            output  wire    b
           );
  reg aux;

  always @(posedge clk) begin
  if(rst)
    aux <= 0;
  else
    aux <= ~a;
  end

  assign b = aux;

endmodule

Partiendo de este módulo, vamos a llevar a cabo los diferentes pasos mencionados antes.

1. Verilar

El primer paso es verilar el modelo. Para ello, ejecutamos la siguiente instrucción en la raíz del proyecto.

verilator -Wall -cc inversor.sv

Como se puede ver, se han añadido algunas opciones a la instrucción:

-Wall: Activa todos los warnings de sintaxis. -cc: Se verila el diseño en C++ en vez de en SystemC.

Tras esto, hay que hacer un make para generar los ficheros objetos asociados al modelo verilado y que luego habrá que enlazar durante la fase de compilación del banco de pruebas. La instrucción es la mostrada a continuación. Con la opción -C indicamos a la instrucción make que cambie de directorio durante su ejecución, mientras que con la opción -f le indicamos el fichero que tiene que utilizar dentro de dicha ubicación.

make -C obj_dir -f Vtop.mk

2. Envolver

El siguiente paso es escribir un banco de pruebas (testbench en inglés) en C++ que instancie el modelo verilado y lo inyecte las señales de entrada para verificar que el diseño funciona como debe. Dicho banco de pruebas se suele llamar igual que el fichero fuente del módulo principal, pero añadiéndole el prefijo tb_. Por lo tanto, a nuestro banco de pruebas lo llamaremos tb_inversor.cpp, y es el que se muestra a continuación. En este código se pueden destacar una serie de puntos. El primero es la inclusión de los ficheros de cabecera que contendrán los objetos y funciones necesarios para llevar a cabo la simulación. Estos son verilated.h y Vtop.h. Este último es uno de los ficheros generados al verilar el modelo en el paso anterior y contiene la declaración de las variables y funciones del modelo verilado (que en C++ pasará a ser una clase), por lo que su nombre dependerá de aquel que le hayamos dado al fichero fuente. El siguiente punto importante es la instrucción const std::unique_ptr<VerilatedContext> contextp{new VerilatedContext};, punto en el cual se inicia el contexto de la simulación. Sin entrar mucho en detalle, el contexto se encargará de contener toda la información importante relativa a la simulación, como el tiempo de simulación, entre otras cosas. Además, después se instancia de igual manera un objeto del modelo verilado. Con el bucle while comienza la lógica de la simulación. Esta consiste, en este caso, de un bucle que se ejecutará un número limitado de veces y dentro del cual estimularemos las señales de entrada del modelo verilado con diferentes valoresa lo largo de las diferentes iteraciones del bucle. Asimismo, tras estimular las señales de entrada, llevaremos a cabo una evaluación del sistema en dicho punto (función eval()), es decir, el modelo actualizará los valores de las señales de salida conforme a los de entrada. Tras la evaluación, se muestran los valores de las diferentes señales con la función VL_PRINTF. Por último, y tras terminar el bucle, liberamos el objeto del modelo verilado.

/*
 * INCLUDES
 */
#include <memory>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <verilated.h>
#include "Vtop.h"

/*
 * MAIN
 */
int main(int argc, char** argv){

  // iniciamos el contexto
  const std::unique_ptr<VerilatedContext> contextp{new VerilatedContext};

  // instanciamos el modelo verilado
  const std::unique_ptr<Vtop> top{new Vtop{contextp.get(), "TOP"}};

  // inicializamos las señales de reloj y reseteo
  top->clk = 0;
  top->rst = 1;

  // simulamos 20 pasos
  while(contextp->time() < 20){

    // incrementamos el tiempo de simulación en una unidad
    contextp->timeInc(1);

    // alternamos la señal de reloj
    top->clk = !top->clk;

    // en los primeros 10 pasos de simulación activamos el reset
    // después, alternamos la señal a
    if(!top->clk){
      if(contextp->time() > 1 && contextp->time() < 10){
        top->rst = 1;
      } else{
        top->rst = 0;
      }
        top->a = !top->a;
    }

    // evaluamos el modelo
    top->eval();

    // mostramos las salidas
    VL_PRINTF("[%" PRId64 "] clk=%x rst=%x a=%x -> b=%x\n", contextp->time(), top->clk, top->rst, top->a, top->b);
  }

  top->final();

  return 0;
}

3. Compilar

Una vez escrito el banco de prueba hay que compilarlo. Durante la compilación es necesario incluir una serie de directorios que contienen los ficheros necesarios para que la simulación funcione. Por ejemplo, el fichero de cabecera verilated.h que incluimos en el banco de pruebas no se encuentra en el directorio estándar de bibliotecas de C, así que hay que incluirlo mediante la opción -I /usr/share/verilator/include/. Lo mismo ocurre con el directorio obj_dir. Por otro lado, el fichero correspondiente al banco de pruebas no va a ser el único fichero fuente que vamos a compilar. Por ejemplo, las funciones declaradas en el fichero de cabecera verilated.h están definidas en el fichero verilated.cpp, localizado en el mismo directorio que aquel, y que no está compilado por defecto. Por eso es por lo que hay que compilar este y el fichero verilated_threads.cpp también. La instrucción resultante con la que compilaríamos el banco de pruebas es la siguiente.

g++ -I /usr/share/verilator/include/ -I obj_dir/ /usr/share/verilator/include/verilated_threads.cpp /usr/share/verilator/include/verilated.cpp tb_top.cpp obj_dir/Vtop__ALL.a -o Vtop

4. Simular

Tras compilar se genera un fichero ejecutable que se puede ejecutar con la orden ./Vtop. Dicha ejecución crea una salida en la que se muestran los valores de las diferentes señales para cada evaluación del modelo. También podemos redirigir la salida de la simulación a un fichero para su visualización más detenida, en caso de que se quiera ejecutar la simulación durante una cantidad de pasos demasiado alta. La ejecuión del programa mostraría algo como lo siguiente.

[1] clk=1 rst=1 a=0 -> b=0
[2] clk=0 rst=1 a=1 -> b=0
[3] clk=1 rst=1 a=1 -> b=0
[4] clk=0 rst=1 a=0 -> b=0
[5] clk=1 rst=1 a=0 -> b=0
[6] clk=0 rst=1 a=1 -> b=0
[7] clk=1 rst=1 a=1 -> b=0
[8] clk=0 rst=1 a=0 -> b=0
[9] clk=1 rst=1 a=0 -> b=0
[10] clk=0 rst=0 a=1 -> b=0
[11] clk=1 rst=0 a=1 -> b=0
[12] clk=0 rst=0 a=0 -> b=0
[13] clk=1 rst=0 a=0 -> b=1
[14] clk=0 rst=0 a=1 -> b=1
[15] clk=1 rst=0 a=1 -> b=0
[16] clk=0 rst=0 a=0 -> b=0
[17] clk=1 rst=0 a=0 -> b=1
[18] clk=0 rst=0 a=1 -> b=1
[19] clk=1 rst=0 a=1 -> b=0
[20] clk=0 rst=0 a=0 -> b=0

En la salida se puede observar que el diseño funciona como debe. En cada flanco de subida (clk=1), la salida b es la negación de la entrada a.

5. Depurar

En este ejemplo sencillo se puede comprobar el correcto funcionamiento del diseño simplemente observando los valores de las señales en la salida. Por lo tanto, no es necesario generar trazas para depurar las señales. Se verá cómo hacer esto en ejemplos posteriores.

Actualizado: