Verilator est sans conteste le simulateur HDLopen source le plus rapide du « marché ». Il permet de simuler des porte‐grammes écrits en Verilog synthétisable.
Le «défi TapTempo» a été lancé sur LinuxFr il y a quelques semaines. L’objectif est de réaliser la mesure du tempo de l’appui sur une touche et de l’afficher simplement dans la console le résultat. La mesure du tempo s’effectue par défaut sur 5 appuis consécutif et affiche une moyenne en bpm (Beats Per Minute). L’idée est de réaliser la fonction dans divers langages informatiques pour que chacun puisse promouvoir son langage favoris. Beaucoup de langages ont été représenté jusqu’à présent, mais aucun langages de description matériel n’avait encore été proposé.
Pour palier ce gros manquement dans les langages représenté je vous propose ici de réaliser TapTempo en Chisel (version 3).
Architecture générale
L’idée ici n’est donc plus d’écrire un programme pour calculer le tempo mais de décrire l’architecture d’un composant matériel permettant de réaliser la fonction. Le matériel visé sera un FPGA, nous laissons de coté le développement sur ASIC. Même si une fois terminé il ne devrait pas y avoir de problème pour être porté sur un ASIC, si quelqu’un a suffisamment d’argent pour le claquer dans ce genre d’ânerie 😉
Le bloc fonctionnel de notre composant sera donc constitué d’une entrée button recevant le signal de l’appui sur un bouton permettant de faire le tempo. Dans un premier temps nous laisserons de coté les problèmes de métastabilité ainsi que de rebond. L’implémentation réel dans un FPGA nécessitera obligatoirement l’ajout d’un étage de synchronisation du signal d’entrée avec l’horloge ainsi que d’un bloc «anti-rebond», aucun bouton réel n’étant capable de faire un signal vraiment propre.
La sortie du bloc sera constitué d’un entier non signé bpm dont nous allons discuter la taille ci-dessous.
Et comme nous somme dans un FPGA il est indispensable de concevoir notre fonction synchrone d’une horloge, et souhaitable d’avoir un reset général.
Structure interne
La structure interne de TapTempo est donnée ci-dessous:
L’idée est de compter des ticks générés par timepulse au moyen du compteur count. Quand un appui sur le bouton est détecté, le compteur se remet à zéro et la valeur est enregistrée dans le tableau countx. À chaque coup d’horloge l’addition des 4 valeurs count est réalisée puis on divise par 4. La division par 4 est réalisable dans un FPGA au moyen d’un simple décalage à droite de 2. Vient la partie la plus compliqué : se servir de cette période moyenne pour diviser TMINUTE et obtenir la valeur du tempo en bmp.
Un peu de dimensionnement
On ne fonctionne pas dans un FPGA comme on fonctionne avec un pc, quand on fait des opérations sur des nombres il faut les dimensionner. Et il est fortement recommander d’utiliser des entiers, car le calcul flottant nécessite tout de suite une quantité de ressources phénoménale.
Notre objectif est de mesurer une cadence musicale en bpm que l’on puisse «taper à la main», si l’on regarde l’article wikipedia consacré au Tempo, on se rend vite compte qu’attendre les 200bpm est déjà pas mal. Disons que pour prendre une très large marge nous mettons la marge supérieur à 270bpm. Nous aurons donc en sortie une variable entière sur 9bits ( int(ln2(270))+1).
À 270bpm, le temps entre deux tempos est de ~222ms ce qui nous donnerais une fréquence de pulse de 4.5Hz. Cependant, si nous voulons une précision de 1bpm il va falloir augmenter cette fréquence , pour avoir un chiffre rond nous prendrons un temps de 1ms, soit une fréquence de 1kHz. Ce qui est un peu juste pour 270bpm, mais conviendra à la démonstration.
Décomposons le code
Le code se trouve sur le dépôt github suivant. Nous allons décrire la description du module à proprement parlé qui se trouve ici.
Dans l’entête du module nous allons retrouver le port d’entrée button ainsi que le port de sortie bpm. Point d’horloge ni de reset ici puisqu’en Chisel ces signaux sont implicite.
// default clock 100Mhz -> T = 10ns
class TapTempo(tclk_ns: Int, bpm_max: Int = 270) extends Module {
val io = IO(new Bundle {
// val bpm = Output(UInt(8.W))
val bpm = Output(UInt(9.W))
val button = Input(Bool())
})
Quelques constantes qui nous servirons ensuite :
/* Constant parameters */
val MINUTE_NS = 60*1000*1000*1000L
val PULSE_NS = 1000*1000
val TCLK_NS = tclk_ns
val BPM_MAX = bpm_max
/* usefull function */
def risingedge(x: Bool) = x && !RegNext(x)
Pour notre générateur de pulses il suffit d’utiliser une classe présente dans la bibliothèque «util» de chisel : Counter. Qui comme son nom l’indique … compte !
import chisel3.util.Counter
[...]
val (pulsecount, timepulse) = Counter(true.B, PULSE_NS/tclk_ns)
[...]
Ce compteur prend en paramètre un signal de comptage (ici true.B -> compte tout le temps) ainsi que la valeur max à atteindre.
L’instanciation de l’objet retourne un compteur ainsi qu’un signal qui passe à ‘1’ quand le compteur se remet à zero (quand il dépasse la valeur max).
Ce signal timepulse sera ensuite utilisé par un deuxième compteur 16 bits tp_count que nous allons écrire «à la main» cette fois.
On défini d’abord le registre de 16bits que l’ont initialise à 0 (0.asUInt(16.W))
val tp_count = RegInit(0.asUInt(16.W))
Puis on écrit le code décrivant le «comptage»:
when(timepulse) {
tp_count := tp_count + 1.U
}
when(risingedge(io.button)){
// enregistrement de la valeur du compteur.
countx(count_mux) := tp_count
count_mux := Mux(count_mux === 3.U, 0.U, count_mux + 1.U)
// remise à zéro du compteur
tp_count := 0.U
}
Ce deuxième compteur compte les pulse «timepulse» et se remet à 0 lorsqu’un front montant est détecté sur le bouton (quand on appuis sur le bouton).
Pour stocker les 4 valeurs permettant de réaliser une valeurs nous déclarons un vecteur de registres de 19 bits (pour gérer les retenues quand nous ferons l’addition):
val countx = RegInit(Vec(Seq.fill(4)(0.asUInt(19.W))))
Ainsi que le «pointeur» :
val count_mux = RegInit(0.asUInt(2.W))
La gestion de l’incrémentation du pointeur ainsi que de l’enregistrement du compteur se trouve dans le code du «compteur de pulse» que nous avons vu plus haut:
// enregistrement de la valeur du compteur.
countx(count_mux) := tp_count
count_mux := Mux(count_mux === 3.U, 0.U, count_mux + 1.U)
Pour faire la somme rien de plus simple, il suffit de faire ‘+’ :
val sum = Wire(UInt(19.W))
[...]
sum := countx(0) + countx(1) + countx(2) + countx(3)
Et la division par 4 se résume à un décalage:
val sum_by_4 = sum(18, 2)
Et nous arrivons à la partie la plus compliquée du design : diviser. Diviser est quelque chose de très compliqué dans un FPGA, on peut réaliser un design permettant d’effectuer une divisions en plusieurs cycles d’horloge, mais c’est tout de suite une très grosse usine à gaz.
Pour la division permettant de faire la moyenne des échantillons nous nous en étions sortie en faisant une division par une puissance de 2, qui se résume de fait à un simple décalage. Mais cette fois on ne pourra pas s’en sortir avec ce genre de pirouette. Car notre division à réaliser est la suivante :
Nombre de pulse dans une minute
------------------------------------- = valeur en bpm
moyenne des pulses mesuré (sum_by_4)
Pour nous en sortir la première idée serait de faire une table de valeurs pré-calculée pour chaque valeurs de sum_by_4. Ce qui se fait très simplement en scala avec une séquence :
val x = Seq.tabulate(pow(2,16).toInt-1)(n => ((MINUTE_NS/PULSE_NS)/(n+1)).U)
Sauf que le nombre de valeurs est particulièrement grand (2^16) et qu’on pourrait certainement factoriser un peut tout ça.
Pour réduire la taille ne notre tableau il faut prendre le problème à l’envers: combien de valeurs possible puis-je avoir en sortie ?
La réponse est simplement 269 puisque je peux aller de 1 à 270bpm. Nous allons donc réaliser un vecteur de 270 valeurs contenant la valeurs minimum du compteurs permettant d’atteindre notre résultat. Et nous mettrons ces valeurs dans 270 registres.
val bpm_calc = RegInit(Vec(x(0) +: Seq.tabulate(bpm_max)(n => x(n))))
Pour obtenir la valeur finale il faut ensuite générer 270 inéquations ayant pour résultat un vecteur de 270 bits :
Le résultat correspondra ensuite à l’index du dernier bit à 1 dans ce vecteur.
Pour récupérer cet index, une fonction très utile est fournie dans la bibliothèque «util» de chisel : le PriorityEncoder. Qui permet d’obtenir l’index du plus petit bit à 1 dans un vecteur… sauf que nous on veut le plus grand !
Mais ce n’est pas très grave, il suffit de retourner le vecteur avec Reverse puis de faire une soustraction pour avoir le résultat :
Pour être vraiment complet il faudrait ajouter la gestion des rebonds ainsi que la synchronisation du signal d’entrée puis tester la synthèse. Mais ce sont de nouvelles aventure qui continuerons peut-être dans un prochaine épisode 🙂 .
Plusieurs solutions de simulations s’offrent à nous quand on développe un composant en Verilog. On peut écrire le testbench en Verilog, de manière à être compatible avec la plupart des simulateurs du marché. Dans ce cas, le simulateur libre le plus célèbre est Icarus.
Mais la solution du «tout Verilog» est relativement lente en temps de simulation, et l’utilisation du langage Verilog est restrictive, en effet il n’est pas facile de s’approprier toutes les subtilités du langage.
C’est là que la solution de Verilator devient très intéressante. Verilator permet de convertir un modèle de composant écrit en Verilog synthétisable en un modèle C++. De cette manière, écrire le code du testbench revient à instancier notre composant dans un main() en C++ et à décrire nos tests avec toute la liberté qu’offre ce langage.
Mieux encore, il est possible d’écrire notre testbench en SystemC, et de profiter ainsi de cette librairie conçue pour la simulation de circuit numérique.
L’idée va être ici de mesurer le temps effectif de simulation de chacune de ses deux solutions. On se servira pour cela du composant d’antirebond du projet «blinking led project» (blp) disponible sur github.
Le composant synthétisable
Le composant button_deb commute sa sortie button_valid à chaque front montant du signal d’entrée button_in. Pour que le fonctionnement soit un peu plus complexe qu’une simple commutation, et surtout pour bien coller au fonctionnement réel, le composant est muni d’un antirebond de 20 ms (par défaut). Quand le premier front survient, un compteur est déclenché et aucun autre front n’est pris en compte pendant 20 ms.
Tout comme en VHDL, un testbench en Verilog se présente sous la forme d’un module sans entrée/sortie.
La première chose à définir est le temps, avec la directive `timescale:
`timescale 1ns/100ps;
Le premier chiffre indique le pas de simulation, cela correspondra au temps d’attente que l’on retrouvera tout au long du code avec l’«instruction» ‘#’. Le deuxième nombre indique la précision maximum.
Par exemple, pour simuler l’horloge à 95Mhz on écrira :
/* Make a regular pulsing clock. */
always
#5.263158 clk = !clk;
Mais comme la précision indiquée en début de code est de 100ps, seule le 5.2 sera pris en compte pour la simulation, ce qui dans notre cas est tout à fait suffisant.
La simulation de l’appui sur le bouton avec rebond se fait ensuite dans un process que l’on appel souvent «stimulis» par convention :
/* Stimulis */
initial begin
$display("begin stimulis");
$dumpfile("simu/button_deb_tb.vcd");
$dumpvars(1, clk, rst, button_in, button_valid, button);
$monitor("At time %t, value = %h (%0d)",
$time, button_in, button_in);
[...]
end
Le Verilog fourni tout un tas de primitive permettant de simplifier le debuggage. Notamment $display() et $monitor(), pour afficher du texte pendant la simulation. $display() ne fait qu’afficher du texte au moment où la fonction est appelée, alors que monitor va afficher le texte à chaque changement d’état de ses paramètres.
Les functions $dump* permettent de définir le format de fichiers des traces ainsi que les signaux a dumper. Dans ce cas précis on choisira le format vcd qui est un format non compressé, de manière à améliorer la comparaison avec verilator qui lui ne sait faire que du vcd. Mais cela génère vite de très gros fichiers, il sera préférable d’utiliser le format compressé lxt2, fst ou fsdb pour des simulations plus longues.
Pour factoriser un peu de code d’attente on décrit des tâches d’attentes wait_ms et wait_us :
/* some usefull functions */
task wait_us;
input integer another_time;
begin
repeat(another_time) begin
# 1_000;
end
end
endtask
task wait_ms;
[...]
Tâches qui seront appelées lors de la simulation des rebonds :
Icarus va ainsi créer un binaire exécutable nommé simu/button_deb que l’on lancera avec les arguments de dumps:
vvp simu/button_deb -lvcd
Le fichier de traces (VCD) généré fait une taille vénérable de 752Mo et peut être visualisé avec gtkwave en l’indiquant simplement en paramètre de la commande :
gtkwave simu/button_deb_tb.vcd
Sur un Lenovo T430, la simulation prend 1 minute et 17 secondes, ce qui est tout de même relativement lent pour une simulation d’appui sur un boutton 😉
Voyons maintenant ce que nous donne Verilator.
Le testbench C++
Un testbench Verilator se présente sous la forme d’un main() C++. Dans le main() du testbench nous instancierons l’objet correspondant au model verilog transformé par verilator.
Pour cela nous devons donc convertir notre bouton_deb.v en C++ au moyen de la commande Verilator suivante :
Cette commande va nous créer un projet avec le code source du modèle ainsi que le makefile pour compiler. Il suffira donc de se rendre dans le répertoire obj_dir et de faire «make» pour compiler le modèle.
Il ne nous restera plus qu’à instancier notre bouton dans notre testbench test_button_deb.cpp :
Vbutton_deb* top = new Vbutton_deb;
Ainsi que l’objet tfp pour les dump VCD:
VerilatedVcdC* tfp = new VerilatedVcdC;
Pour simuler notre objet «top» il faut assigner des valeurs aux signaux d’entrées :
top->rst = 1;
top->button_in = 0;
top->clk = 0;
Puis évaluer les sorties avec la méthode eval() :
top->eval();
À chaque front d’horloge il faut donc changer la valeur de clk et évaluer :
top->clk = !top->clk;
top->eval();
Dans tout ce que nous venons de voir le temps n’intervient pas. En réalité, le temps n’est tout simplement pas géré dans les modèles Verilator, le modèle ne fait qu’évaluer les sorties en fonction des entrées, c’est à nous de gérer le temps comme nous le souhaitons.
Nous allons donc gérer le temps au moment du dump des signaux en lui indiquant le temps en argument:
tfp->dump(10);
Pour éviter d’avoir à taper tout ça à chaque fois on pourra créer une fonction «time_pass» qui fait passer le temps, avec un compteur global pour incrémenter le temps:
#define BASE_TIME_NS ((1000*1000)/(CLK_FREQ*2))
int base_time = 0;
/* the time is passing */
void time_pass(VerilatedVcdC *tfp, Vbutton_deb *top) {
top->clk = !top->clk;
tfp->dump(base_time*BASE_TIME_NS);
top->eval();
base_time++;
}
À l’image du testbench verilog, on pourra aussi créer des fonctions d’attente wait_ms et wait_us:
/* wait for us */
void wait_us(VerilatedVcdC *tfp, Vbutton_deb *top, int timeus) {
int wait_time = 0;
while((wait_time * BASE_TIME_NS) < (timeus * 1000)) {
wait_time++;
time_pass(tfp, top);
}
}
/* wait for ms */
void wait_ms(VerilatedVcdC *tfp, Vbutton_deb *top, int timems) {
int wait_time = 0;
while((wait_time * BASE_TIME_NS) < (timems * 1000 * 1000)) {
wait_time++;
time_pass(tfp, top);
}
}
Le code simulant les rebonds du bouton ressemble ainsi à s’y méprendre au code Verilog:
Pour lancer la simulation on va d’abord compiler le tout :
make -C obj_dir/ -j -f Vbutton_deb.mk Vbutton_deb
Puis le lancer simplement comme un vulgaire binaire exécutable :
./Vbutton_deb
La simulation crée un fichier VCD de 654Mo que nous pouvons visualiser avec gtkwave.
Sur le même Lenovo T430 la simulation ne dure cette fois que 17 secondes, ce qui est très nettement plus rapide qu’Icarus !
Mieux, si on optimise le code à la compilation avec l’option -O3 :
La rapidité d’exécution est telle que certain réussissent à faire «tourner» un soft-core avec son programme, à des fréquences allant jusqu’à la centaine de kilohertz.
Formidable !
C’est un peu l’expression que l’on a lors des premiers tests de verilator, pouvoir faire de la simulation 20 fois plus rapidement qu’Icarus semble formidable. Surtout quand cela passe par de la simplicité d’écriture du testbench en C++ ou SystemC.
Néanmoins il faut relativiser un peu notre ferveur, Verilator a encore un gros point noir: il ne gère pas le temps. Verilator n’est capable que de gérer des modèle Verilog synthétisable. Mais si ce code synthétisable inclu des primitives du constructeurs : RAM, Multiplieur, … et surtout PLL. Verilator ne sera pas capable de les simuler.
En ce qui concerne les Ram ou les multiplieurs cela ne pose pas trop de problème dans la mesure ou il est assez simple de les inférer. Mais pour les PLL cela devient bien plus compliqué, et je ne parle même pas des composants spécifique au constructeur (Bus serie, transceiver, core, …).
Ce «bug» a été répertorié il y 5 ans déjà mais pour l’instant personne ne semble s’être attelé à la tâche. D’après un des auteur de Verilator ajouter cette fonctionnalité devrait prendre quelques mois de programmation et de test. Mais cela vaudrait quand même le coup !