Archives par mot-clé : taptempo

Portage de TapTempo en VHDL

[Dépêche publiée initialement sur LinuxFr]

Ayant préparé tout le matériel pour faire du TapTempo en Verilog, il était trop tentant de réaliser la même chose en VHDL. L’occasion de se plonger dans ce langage de description matériel concurrent du Verilog.
L’occasion également de parler des avancées de GHDL, un simulateur libre du VHDL, et désormais également capable de faire la synthèse en conjonction avec Yosys.

Pour comprendre TapTempo dans la culture moulesque de LinuxFr.org, il est conseillé d’aller faire un petit tour sur la page wiki homonyme.

Sommaire

Comme l’indique le titre de cette dépêche, nous allons effectuer un « portage » du projet TapTempo, qui était écrit en Verilog, vers le language VHDL. Nous aurons donc l’avantage de profiter d’une architecture déjà conçue. Et comme une partie des stimulus de simulation (banc de test) ont été écrit en Python avec CocoTB, nous allons pouvoir les reprendre (presque) tels quels pour simuler notre version VHDL.

Vue d’ensemble du matériel pour TapTempo

Le VHDL

Le VHDL – pour VHSIC Hardware Description Language – est un langage de description matérielle issu d’une commande du ministère de la Défense américaine, c’est donc en toute logique qu’il soit surtout populaire… en Europe !

Le VHDL s’inspire fortement du langage ADA pour décrire le comportement des circuits intégrés numériques. Cela permet de décrire un modèle que l’on peut ensuite simuler au moyen d’un simulateur.
Le simulateur VHDL OpenSource le plus connu est GHDL, initialement développé par le français Tristan Gringold comme une surcouche à GCC.
Il existe également un simulateur opensource nommé nvc, mais il est moins mature que GHDL. Les autres simulateurs OpenSource, comme FreeHDL ou VerilatorVHDL, sont plus anecdotiques.

À partir d’une source VHDL il est également possible de générer un schéma de portes et de bascules logiques au moyen d’un logiciel de synthèse. Pendant longtemps, les seuls logiciels libres de synthèse HDL ciblaient le Verilog (OdinII et Yosys). Mais depuis cette année une extension GHDL est apparue pour Yosys. Si cette extension n’est pas encore vraiment stabilisée, elle n’en reste pas moins parfaitement utilisable comme nous allons le voir dans cet exemple.

Le VHDL est très hiérarchique, on décrit des modules avec leurs entrées-sorties que l’on assemble ensuite à la manière d’un schéma bloc dans un composant «top».

Dans le cas de TapTempo, l’interface du module «top» est déclarée dans une entité comme ceci :

entity taptempo is
    port (
        clk_i : in std_logic;
        btn_i : in std_logic;
        pwm_o : out std_logic
    );
end entity taptempo;

VHDL n’est pas sensible à la casse ! C’est quelque chose qui est très perturbant mais dans l’exemple ci-dessus Entity est exactement identique à entity ou ENTITY. En pratique, le simulateur mettra tout en minuscule.

Le module possède deux entrées : l’horloge (clk_i) et le bouton (btn_i) ainsi qu’une sortie pwm (pwm_o) pour l’affichage. Il est possible de définir des paramètres pour la génération du module en utilisant le mot clef generic de la même manière que le port. Pour plus de simplicité nous choisirons plutôt de mettre tous les paramètres dans un package séparé (nommé taptempo_pkg.vhd), à la manière des include du C/C++.

Le type std_logic est utilisé pour représenter les états que peut prendre un signal. En simulation, il peut prendre 9 valeurs différentes :
0 : 0 logique;
1 : 1 logique;
Z : Haute impédence;
X : inconnue, plusieurs pilotes en conflit -> erreur;
L : 0 faible, peut-être vu comme une résistance de tirage à la masse (pull-down);
H : 1 faible, peut-être vu comme une résistance de tirage à Vcc (pull-up);
W : signal faible inconnu, quand il y a un conflit entre L et H mais qu’un troisième pilote fort (0 ou 1) pourrait tout de même mettre le signal à une valeur déterminable;
- : pas d’importance;
U : non initialisé.

Dans la pratique on évitera d’utiliser toutes ces valeurs si on veut limiter les problèmes à la synthèse. Par exemple les valeurs ‘L’ et ‘H’ ne peuvent être synthétisées « à l’intérieur » d’un FPGA, les résistances de tirage ne sont disponibles que sur les entrées sorties.
Un bus de données bidirectionnel à l’extérieur du FPGA devra être séparé en deux bus dans le FPGA : un pour chaque direction.

En réalité on se limite à ‘0’ et ‘1’. Les valeurs ‘U’ et ‘X’ apparaissent dans les traces de simulation et nous avertissent d’un problème avec notre code (simulation ou synthèse).

Le type std_logic peut être étendu en tableau avec std_logic_vector très pratique pour représenter des bus.

Architecture de TapTempo

L’architecture global de TapTempo qui est exactement la même que celle de la version Verilog est présenté dans le schéma ci-dessous:

schema blocs taptempo

Le tempo en entrée est donné par une touche de morse

Schéma de la touche morse

qui peut aisément être remplacé par un simple bouton poussoir (c’est d’ailleurs monté en parallèle du bouton de la colorlight). Cette entrée est synchronisée avec l’horloge du FPGA au moyen d’une double bascule en série pour éviter la métastabilité :

    -- Synchronize btn
    btn_sync_p: process (clk_i, rst)
    begin
        if rst = '1' then
            btn_s <= '0';
            btn_old <= '0';
        elsif rising_edge(clk_i) then
            btn_s <= btn_old;
            btn_old <= btn_i;
        end if;
    end process btn_sync_p;

Le module debounce se charge ensuite de filtrer les rebonds et transfert le signal au module de mesure de la période d’appuis.

Cette période divise ensuite une constante de temps représentant le nombre de cycle de timepulse pour donner la fréquence en battement par minute (bpm).

Pour être « affichée », cette valeur est transformée en un signal périodique avec un rapport cyclique proportionnel au battement bpm.

Ce signal cyclique s’affiche ensuite très bien au moyen d’un voltmètre à aiguille comme celui-ci :

Photo du voltmètre à aiguille

La graduation qui nous intéresse ici est celle qui va de 0 à 250.

Les paramètres dans un package : taptempo_pkg

Pour configurer les différentes constantes et définir des fonctions utiles, on utilise un « package » nommé taptempo_pkg.vhd qui sera inclus dans tous les modules du projet :

use work.taptempo_pkg.all;

La déclaration d’un package en VHDL s’effectue en deux temps :

  • on déclare d’abord les «objets» que l’on va utiliser dans le package :
-- La déclaration de ce que le package rend visible
package taptempo_pkg is

    constant CLK_PER_NS : natural;
    constant BPM_MAX : natural;
    constant BPM_SIZE : natural;
    constant TP_CYCLE : natural;
    constant BTN_PER_MAX : natural;
    constant BTN_PER_SIZE : natural;
    constant BTN_PER_MIN : natural;
    constant MIN_US : natural;
    constant MAX_COUNT : natural;
    constant DEBOUNCE_PER_US: natural;
    constant DEB_MAX_COUNT : natural;
    constant DEB_MAX_COUNT_SIZE : natural;

    constant ZEROS : std_logic_vector(31 downto 0);

    -- Usefull function for register size
    function log2ceil(m : integer) return integer;

end package taptempo_pkg;
  • Puis on définit leurs valeurs et comportement dans le corps (body) :
package body taptempo_pkg is

    constant CLK_PER_NS : natural := 40;
    constant BPM_MAX : natural := 250;

    -- period of tp in ns
    constant TP_CYCLE : natural := 5120;

    -- Debounce period in us
    constant DEBOUNCE_PER_US: natural := 50_000;
    constant DEB_MAX_COUNT : natural := (1000 * (DEBOUNCE_PER_US / TP_CYCLE));
    constant DEB_MAX_COUNT_SIZE : natural := log2ceil(DEB_MAX_COUNT);

    -- constant MIN_NS : natural := 60000000000;
    constant MIN_US : natural := 60_000_000;

    constant BPM_SIZE : natural := log2ceil(BPM_MAX + 1);

    constant BTN_PER_MAX : natural := 1000 * (MIN_US / TP_CYCLE);
    constant BTN_PER_SIZE : natural := log2ceil(BTN_PER_MAX + 1);
    constant BTN_PER_MIN : natural := 1000 * (MIN_US / TP_CYCLE) / BPM_MAX;
    constant MAX_COUNT : natural := TP_CYCLE / CLK_PER_NS;

    constant ZEROS : std_logic_vector(31 downto 0) := x"00000000";

    function log2ceil(m : integer) return integer is
    begin
      for i in 0 to integer'high loop
            if 2 ** i >= m then
                return i;
            end if;
        end loop;
    end function log2ceil;

end package body;

Vous noterez la lourdeur d’avoir à déclarer le type de la constante dans le package avant de donner sa valeur dans le body. Les mauvaises langues diront que ça n’est pas beaucoup plus lourd que le C++ et ses doubles fichier (*.h et *.cpp) pour définir une classe.

Un cadenceur (timer) pour tout le système : Timepulse

Nous allons avoir besoin de compter le temps dans plusieurs blocs du projet. L’horloge câblée sur la colorlight est de 25Mhz. Comme nous n’avons pas besoin de précisions à la quarantaine de nanoseconde nous allons utiliser un compteur global pour générer des « pulses » toutes les 5,12µs. Ces pulses seront utilisés en entrée des blocs ayant besoin de compter le temps. Cela réduira la taille des compteurs puisqu’ils n’auront pas à repartir de l’horloge globale.

Le code est suffisamment concis pour que nous puissions le reproduire ici dans son intégralité.

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

use work.taptempo_pkg.all;

entity timepulse is
    port (
        -- clock and reset
        clk_i : in std_logic;
        rst_i : in std_logic;
        -- timepulse output
        tp_o : out std_logic
    );
end entity timepulse;

architecture RTL of timepulse is
    signal counter : natural range 0 to MAX_COUNT + 1;
begin
    tp_o <= '1' when (counter = 0) else '0';

    counter_p: process (clk_i, rst_i)
    begin
        if rst_i = '1' then
            counter <= 0;
        elsif rising_edge(clk_i) then
            if counter < MAX_COUNT then
                counter <= counter + 1;
            else
                counter <= 0;
            end if;
        end if;
    end process counter_p;
end architecture RTL;

Tout module VHDL est constitué d’une partie entité entity pour décrire les interfaces d’entrées-sorties. Ici nous avons les deux entrées horloge clk_i et reset rst_i caractéristiques d’un système synchrone. L’unique sortie est le pulse généré toutes les 5,12µs.

La description du fonctionnement du module est donnée ensuite dans le bloc « architecture ». En plus des signaux d’entrées-sorties, il est possible de déclarer des signaux interne au bloc. Nous avons déclaré ici le signal counter qui est un entier naturel borné de 0 à MAX_COUNT + 1. La possibilité de borner les signaux que l’on déclare en integer permet d’aider l’outil de synthèse à générer un bus de taille adaptée quand on ne souhaite pas déclarer un std_logic_vector avec une taille explicite.

Attention, un signal n’a pas la même signification qu’une variable dans un programme procédural. Une variable prend une seule valeur qui changera au cours de l’exécution du programme. Un signal doit être vu comme une liste des différentes valeurs que prendra le signal au cours de la simulation. La première valeur de la liste sera son état initial, et à chaque événement sur le signal on ajoutera une valeur à la liste jusqu’à la fin de la simulation.

Nous aurions également pu déclarer des constantes à cet endroit, mais comme dit dans l’introduction, une constante étant un paramètre modifiable nous avons préféré le mettre dans le package taptempo_pkg.

Le corps de l’architecture de timepulse possède deux « process » concurrents qui s’exécutent en parallèle. Le premier est une assignation continue :

    tp_o <= '1' when (counter = 0) else '0';

Il fait passer tp_o à ‘1’ quand le compteur counter atteint la valeur 0. Ce « process » est activé à chaque évenement/changement sur le signal counter.

Le second process est plus étoffé :

    counter_p: process (clk_i, rst_i)
    begin
    --...
    end process counter_p;

Ce process est exécuté à chaque événement surgissant sur les signaux déclarés dans la liste de sensibilité (ici clk_i et rst_i). Le contenu du process est quant à lui exécuté de manière séquentielle.

        if rst_i = '1' then
            --...
        elsif rising_edge(clk_i) then
            --...
        end if;

Cette structure décrit une bascule D qui sera bien reconnue comme telle par le logiciel de synthèse.
Un reset asynchrone ré-initialise le compteur à 0:

            counter <= 0;

Et sur un front montant de l’horloge

        elsif rising_edge(clk_i) then

on incrémente le compteur jusqu’à son maximum.

            if counter < MAX_COUNT then
                counter <= counter + 1;
            else
                counter <= 0;
            end if;

Notez l’utilisation de l’opérateur d’affectation non bloquant <= qui n’affectera la valeur qu’à la fin de l’exécution du process.

Si par exemple nous avons le code suivant dans un process :

    counter <= 10;
    counter <= 5;

La valeur effective de counter à la fin de l’exécution de process sera 5. Et à aucun moment la valeur 10 n’apparaîtra dans counter.

Pour visualiser les signaux de sortie du module on pourra se rendre dans le répertoire cocotb/test_timepulse du projet et lancer le test en donnant ghdl comme simulateur:

$ cd test_timepulse
$ SIM=ghdl make

Le même principe pourra être appliqué pour simuler tous les autres modules de TapTempo.

La simulation consiste à laisser passer le temps pendant une miliseconde :

@cocotb.test()
async def double_push_test(dut):
    trg = TestTimePulse(dut)
    trg.display_config()
    trg.log.info("Running test!")
    await trg.reset()
    await Timer(1, units="ms")

La simulation génère un fichier nommé timepulse.fst visible avec gtkwave :

$ gtkwave timepulse.fst

Comme visible dans l’image ci-dessous.

Visualisation de la simulation timepulse avec gtkwave

Filtrage des rebonds : debounce

Le manipulateur morse génère des rebonds, le filtrage avait initialement été réglé à 20 ms sur la version Verilog. Pour être vraiment tranquille nous le monterons à 50 ms, mais c’est particulièrement long comme période. Comme pour le reste, cette configuration se trouve donc dans le package taptempo_pkg.vhd.

Le module debounce va se charger de filtrer les rebonds au moyen d’une machine à états et d’un compteur.

entity debounce is
    port (
        -- clock and reset
        clk_i : in std_logic;
        rst_i : in std_logic;
        -- inputs
        tp_i  : in std_logic;
        btn_i : in std_logic;
        -- outputs
        btn_o : out std_logic
    );
end entity;

Le comptage des pulsations du module timepulse s’effectue dans un process :

counter_p: process (clk_i, rst_i)
begin
    if rst_i = '1' then
        counter <= 0;
    elsif rising_edge(clk_i) then
        if tp_i = '1' then
            if (state_reg = s_cnt_high) or (state_reg = s_cnt_low) then
                counter <= counter + 1;
            else
                counter <= 0;
            end if;
        end if;
    end if;
end process counter_p;

La machine à états est constituée de 4 états :

    type t_state is (s_wait_low, s_wait_high, s_cnt_high, s_cnt_low);
    signal state_reg : t_state;

Deux états d’attentes et deux états de comptages. Dans un état d’attente s_wait_ on attend un front du bouton. Quand un front survient on passe dans un état de comptage s_cnt_.

Dans un état de comptage, le compteur s’incrémente et passe à l’état d’attente lorsqu’il arrive à son maximum.

De cette manière, seuls les fronts intervenant dans un état d’attente sont pris en compte. Une fois la machine à états dans un état de comptage, plus aucun front du bouton n’est « écouté ».

Mesure de la période d’appuis : percount

Le module percount (period counter) va compter le temps d’une période entre deux appuis sur le bouton.

Si on passe sur les signaux désormais bien connus clk_i, rst_i et tp_i; l’entrée du module est btn_i qui représente la valeur du bouton (appuyé ou non) sans rebond.

Les deux signaux de sortie sont :
– Un vecteur btn_per_o représentant la valeur de la période mesurée
– Un signal btn_per_valid donnant la validité de la valeur précédente. La valeur de btn_per_o est considérée comme bonne uniquement si btn_per_valid est à ‘1’.

Dans un premier process détecte les fronts descendants du bouton :

    btn_fall <= btn_old and (not btn_i);

    btn_p: process (clk_i, rst_i)
    begin
        if rst_i = '1' then
            btn_old <= '0';
        elsif rising_edge(clk_i) then
            btn_old <= btn_i;
        end if;
    end process btn_p;

Puis dans un second process on compte le temps et on remet à 0 le compteur quand un front descendant du bouton surgit :

        --...
        elsif rising_edge(clk_i) then
            if btn_fall = '1' then
                counter_valid <= '1';
            elsif counter_valid = '1' then
                counter <= 0;
                counter_valid <= '0';
            elsif (tp_i = '1') and (counter < BTN_PER_MAX) then
                counter <= counter + 1;
            end if;
        end if;
        -- ...

Conversion période/fréquence (division) : per2bpm

C’est la partie « dure » du système, il va falloir faire une division d’une constante par la période mesurée.

Pour éviter d’avoir à utiliser les blocs multiplieurs dédiés de l’ECP5 et rester « portable » nous allons poser cette division comme on le ferait à la main.

Pour ce faire, on va mettre le dividende dans un registre nommé remainder (pour le reste) :

remainder <= std_logic_vector(to_unsigned((MIN_US / TP_CYCLE) * 1000, remainder'length));

Et le diviseur dans un registre divisor:

if to_integer(unsigned(btn_per_i)) < BTN_PER_MIN then
    divisor <= std_logic_vector(to_unsigned(BTN_PER_MIN, BTN_PER_SIZE)) & ZEROS(DIVIDENTWIDTH-1 downto 0);
else
    divisor <= btn_per_i & ZEROS(DIVIDENTWIDTH-1 downto 0);
end if;

Notez la la valeur minimum de btn_per_i qui nous évitera les problèmes de division par 0 ainsi que des valeurs de bpm trop élevées.

La division est cadencée par une machine à états de 3 états :

type t_state is (s_init, s_compute, s_result);
signal state_reg : t_state;

Un état initial pour démarrer, un état de calcul et un état durant lequel la valeur de sortie est considérée comme bonne.

L’algorithme de division consiste en une série de décalage à droite du diviseur :

divisor <= "0" & divisor(REGWIDTH-1 downto 1);

Et de décalages à gauche du quotient :

if (unsigned(divisor) <= unsigned(remainder)) then
    remainder <= std_logic_vector(unsigned(remainder) - unsigned(divisor));
    quotient <= quotient(REGWIDTH-2 downto 0) & "1";
else
    quotient <= quotient(REGWIDTH-2 downto 0) & "0";
end if;

Avec ajout d’un ‘1’ ou d’un ‘0’ en fonction de la valeur du diviseur et du reste. Lorsque le reste est supérieur au diviseur, on le soustrait du diviseur et on ajoute un bit ‘1’ au moment du décalage du quotient. Sinon on ajoute le bit ‘0’ sans toucher au registre de reste (remainder).

Les registres remainder, divisor et quotient sont des std_logic_vector qui ne représente rien d’autre qu’un « paquet de bits » pour le langage. Si l’on souhaite effectuer des opérations de comparaison ou des additions soustraction dessus il faut donc dire au VHDL que ses bits représentent un nombre (ici non signé) en les « castant » avec unsigned(). Et si l’on souhaite assigner le résultat à un std_logic_vector il faut « caster » à nouveau avec std_logic_vector. Ces multiples conversion de type peuvent vite devenir un casse-tête quand on fait du VHDL.

L’esperluette & est ici un opérateur de concaténation de deux vecteurs std_logic_vector.

Le résultat de la division est donné en continu par les bits de poids faible du registre divisor :

bpm_o <= quotient(BPM_SIZE-1 downto 0);
bpm_valid <= '1' when state_reg = s_result else '0';

bpm_o est considéré comme valide lorsque l’état de la machine à états est s_result.

Sortie «affichage» en rapport cyclique : pwmgen

La sortie « d’affichage » de la valeur consiste à générer un signal périodique pwm_o dont le rapport cyclique est proportionnel à la valeur de bpm_o.

On va pour cela compter de 0 à BPM_MAX :

-- count
count_p: process (clk_i, rst_i)
begin
    if rst_i = '1' then
        count <= BPM_MAX;
    elsif rising_edge(clk_i) then
        if tp_i = '1' then
            if count = 0 then
                count <= BPM_MAX;
            else
                count <= count - 1;
            end if ;
        end if ;
    end if;
end process count_p;

Tant que la valeur du compteur se trouvera en dessous d’un seuil pwmthreshold elle sera à ‘1’, une fois le seuil dépassé elle passera à ‘0’:

-- pwm output
pwm_o <= '1' when (count < pwmthreshold) else '0';

Ce seuil ne doit pas changer de valeur pendant le comptage, on va donc devoir utiliser un registre intermédiaire bpm_reg pour stocker la valeur de bpm_i d’entrée :

-- Latching bpm_i on bpm_valid
bpm_register_p: process (clk_i, rst_i)
begin
    if rst_i = '1' then
        bpm_reg <= BPM_RESET;
        pwmthreshold <= BPM_RESET;
    elsif rising_edge(clk_i) then
        if bpm_valid = '1' then
            bpm_reg <= to_integer(unsigned(bpm_i));
        end if;
        if (count = BPM_MAX) then
            pwmthreshold <= bpm_reg;
        end if;
    end if;
end process bpm_register_p;

Notez, à nouveau, la conversion d’un std_logic_vector (bpm_i) vers un entier naturel (bpm_reg) par le double « cast » to_integer(unsigned())

Synthèse, placement-routage et configuration

Nous voici arrivé à la véritable information de cette dépêche : il est désormais possible de faire de la synthèse VHDL avec un logiciel libre.
Le VHDL est le langage avec lequel les étudiants français abordent le monde du FPGA en général. Je l’ai personnellement beaucoup pratiqué en utilisant ghdl pour la simulation et gtkwave pour visualiser les chronogrammes. Mais jusqu’à cette année, j’ai toujours dû passer sur les monstres propriétaires fournis gratuitement par les fabricants de FPGA pour la partie synthèse. Monstres de plusieurs dizaines de Giga-octets à télécharger, souvent précompilé pour une architecture 32 ou 64 bits mais pas les deux, incluant des machines virtuelles java et autres librairies graphique bugués quand on change la langue du système (coucou le MIG de Vivado). Quand ils ne sont tout simplement pas compatibles Linux (de plus en plus rare cependant). Bref, la synthèse VHDL n’était pas une sinécure.

La simulation n’était pas en reste non plus, car si GHDL fonctionnait plutôt bien, il n’était pas des plus rapides à l’époque. Et impossible de faire de la simulation mixte en mélangeant du VHDL et du Verilog.

Impossible également d’utiliser la formule 1 de la simulation qu’est Verilator, un simulateur un peu spécial qui converti son design en un objet C++, le testbench étant ensuite écrit comme du C++ «normal». Le rapport de performance est d’au moins 100 fois plus rapide.

Pour être honnête, signalons qu’il existe bien le logiciel français Alliance, mais c’est très orienté ASIC, et je n’ai jamais réussi à le faire fonctionner correctement. Peut-être que si des développeurs d’Alliance traînent sur LinuxFR, ils pourront nous en parler.

Bref, la lumière est arrivée cette année avec le développement du greffon ghdl pour yosys ainsi que les avancées à toute vapeur de la partie «synthèse» de ghdl. Comme nous allons le voir dans la section suivante.

GHDL + Yosys, la lune de miel

Pour pouvoir synthétiser TapTempo il va falloir compiler et installer les trois projet suivant :

  • GHDL : Le logiciel de simulation ainsi que de synthèse (ne pas oublier d’option de synthèse dans la configuration avant compilation) ;
  • Yosys : le logiciel de synthèse Verilog, véritable couteau suisse pour le FPGA et plus si affinité ;
  • ghdl-yosys-plugin : le greffon qui permet de compiler une librairie ghdl.so à copier dans le répertoire YOSYS_PREFIX/share/yosys/plugins/ de yosys.

Il est fortement conseillé de prendre les dernières versions du code des projets ci-dessus et de les compiler « à la main » car ces projets avancent vite et bougent beaucoup, les paquets des différentes distributions linux sont déjà largement obsolètes.

Une fois installé on peut lancer yosys avec le greffon ghdl comme ceci :

$ yosys -m ghdl

[...]

 Yosys 0.9+3686 (git sha1 bc085761, clang 8.0.0-svn345496-1~exp1+0~20181029105533.852~1.gbpf10f36 -fPIC -Os)

yosys>

Puis charger nos sources VHDL avec la commande ghdl, en précisant bien le nom du « top » que l’on souhaite élaborer avec l’option -e.

yosys> ghdl --std=08 debounce.vhd  per2bpm.vhd  percount.vhd  pwmgen.vhd  rstgen.vhd  taptempo_pkg.vhd  taptempo.vhd  timepulse.vhd -e taptempo
1. Executing GHDL.
Importing module taptempo.
Importing module rstgen.
Importing module timepulse.
Importing module debounce.
Importing module percount.
Importing module per2bpm.
Importing module pwmgen_250.

Et c’est tout ! Maintenant on peut reprendre les commandes et la procédure que l’on avait utilisée dans le cas de TapTempo en Verilog pour faire la synthèse, le placement routage et le chargement.

Notez que comme les sources ont été chargées et parsé dans yosys, il est parfaitement possible de le convertir en Verilog grâce à la commande write_verilog:

yosys> write_verilog taptempo_converted.v

2. Executing Verilog backend.
Dumping module `\debounce'.
Dumping module `\per2bpm'.
Dumping module `\percount'.
Dumping module `\pwmgen_250'.
Dumping module `\rstgen'.
Dumping module `\taptempo'.
Dumping module `\timepulse'.

La version Verilog ainsi générée pourra être simulée avec verilator ou icarus par exemple pour faire de la simulation mixte, ou tout simplement pour accélérer la simulation dans le cas de verilator.

Pour synthétiser sur la colorlight qui est munie d’un FPGA ECP5 de chez Lattice, on utilisera la commande synth_ecp5 avec une sortie netlist au format json.

yosys>  synth_ecp5 -json taptempo.json
[...]
=== taptempo ===

   Number of wires:                540
   Number of wire bits:           1515
   Number of public wires:         540
   Number of public wire bits:    1515
   Number of memories:               0
   Number of memory bits:            0
   Number of processes:              0
   Number of cells:                785
     CCU2C                         109
     L6MUX21                         9
     LUT4                          452
     PFUMX                          25
     TRELLIS_FF                    190

2.50. Executing CHECK pass (checking for obvious problems).
Checking module taptempo...
Found and reported 0 problems.

2.51. Executing JSON backend.

Arrivé à cette étape il peut être intéressant de comparer avec la version Verilog les ressources utilisées par le même projet :

$ yosys
yosys> read_verilog debounce.v  per2bpm.v  percount.v  pwmgen.v  rstgen.v  taptempo.v  timepulse.v
yosys> synth_ecp5 -json taptempo.json
...
=== taptempo ===

   Number of wires:                487
   Number of wire bits:           1435
   Number of public wires:         487
   Number of public wire bits:    1435
   Number of memories:               0
   Number of memory bits:            0
   Number of processes:              0
   Number of cells:                751
     CCU2C                         105
     L6MUX21                         1
     LUT4                          441
     PFUMX                          13
     TRELLIS_FF                    191

8.50. Executing CHECK pass (checking for obvious problems).
Checking module taptempo...
Found and reported 0 problems.

8.51. Executing JSON backend.

Nous obtenons un design légèrement plus petit avec la version Verilog, mais les ordres de grandeurs sont tout de même respecté.

Placement routage avec NextPnR

NextPnR est un logiciel de placement routage qui prend le schéma (netlist) de cellules «primitives» généré par le logiciel de synthèse et associe chaque cellule à une cellule disponible dans le FPGA. NextPnR effectue également le routage qui consiste à établir les connexions entre les différentes cellules.

C’est également à cette étape que l’on va préciser la configuration du FPGA (taille, IO…). En plus du fichier json de synthèse nous donnerons un second fichier de configuration des IO nommé taptempo.lpf décrivant nos trois signaux d’entrées-sortie :

LOCATE COMP "clk_i" SITE "P6";
IOBUF PORT "clk_i" IO_TYPE=LVCMOS33;
FREQUENCY PORT "clk_i" 25 MHZ;

LOCATE COMP "btn_i" SITE "M13";
IOBUF PORT "btn_i" IO_TYPE=LVCMOS33;

LOCATE COMP "pwm_o" SITE "P4";
IOBUF PORT "pwm_o" IO_TYPE=LVCMOS33;

Toutes les commandes de synthèse données ici sont bien évidemment disponibles dans un Makefile sur le dépot. Pour faire le placement routage nous pourrions taper la commande suivante :

$ nextpnr-ecp5 --25k --package CABGA256 --speed 6 --json taptempo.json --textcfg taptempo_out.config --lpf taptempo.lpf --freq 25

Qui ne donnera pas le fichier de configuration nommé bitstream permettant de configurer le FPGA !

Car les spécifications des FPGA sont gardées jalousement par les constructeurs, et il faut des heures et des heures d’ingénieries inverses pour venir à bout de ces informations. Travail qui a été effectué via le projet Trellis et qui nous permet de convertir la sortie texte précédente taptempo_out.config en un bitstream reconnu par l’EPC5 :

$ ecppack taptempo_out.config taptempo.bit

Et l’on décroche enfin le Saint Grââl permettant de configurer la colorlight : le bitstream taptempo.bit.

En avant la musique avec openFPGALoader

Arrivé à cette étape il serait vraiment dommage d’être contraint de relancer l’ide proprio du constructeur juste pour télécharger le bitstream dans le FPGA via une sonde USB-Jtag !

C’est là que l’on peut dégainer le projet openFPGALoader qui a pour ambition de permettre la configuration de tous les FPGA existant avec toutes les sondes disponibles sur le marché.

$ openFPGALoader taptempo.bit 
Open file taptempo.bit DONE
Parse file DONE
Enable configuration: DONE
SRAM erase: DONE
Loading: [==================================================] 100.000000%
Done
Disable configuration: DONE

Et voila, on peut maintenant taper taper taper jusqu’au bout de la nuit.

Conclusion

Le VHDL est très verbeux, les évolutions du langage ont tenté de corriger un peu le tir mais cela reste tout de même verbeux. Certaine caractéristiques comme l’insensibilité à la casse font un peu penser à un langage d’un autre âge. Cependant, l’héritage du langage Ada fait de VHDL un langage très strict et déterministe de part sa conception contrairement au Verilog.

Le typage fort peut-être considéré à première vu comme un défaut ralentissant l’écriture du code. Mais il n’en est rien, après avoir souffert de « compiler » votre porte-gramme pour la simulation, vous aurez l’agréable surprise de voir votre système fonctionner parfaitement sur le FPGA du (presque) premier coup.

Le vocabulaire VHDL est très vaste et on se contente en général des structures de code connue dont on sait qu’elles « synthétiseront » correctement, ce qui donne une impression de ne jamais pouvoir atteindre la maîtrise du langage.

Il y a quelques années je m’étais posé la question de la popularité du VHDL par rapport au Verilog. En effet, même si le VHDL est presque aussi bien supporté que le Verilog par les outils des constructeurs, ça n’était pas le cas des logiciels libres. C’est encore largement le cas aujourd’hui, même certain logiciels non libre supportent en priorité le Verilog. Le constructeur Gowin par exemple ne permettait que la synthèse Verilog avec son outil maison. Il fallait installer le logiciel tier synplify de synopsis pour pouvoir accéder à la synthèse VHDL.

Cette extension de ghdl pour Yosys change la donne. Car, comme nous l’avons vu, il est possible de l’utiliser pour convertir son projet en Verilog et avoir accès à tous l’écosystème libre Verilog. Il est également possible de faire de la vérification formelle pour le VHDL.

Avoir la compétence VHDL dans son CV est une assez bonne idée car c’est souvent par ce mot que l’on résume le développement ASIC/FPGA/SoC. En Europe, le VHDL est très apprécié de l’industrie et particulièrement de l’industrie de défense.

Mais si c’est juste pour mesurer le tempo, ce n’est peut-être pas la voie la plus rapide et la plus simple 😉

Aller plus loin

TapTempo en Verilog

Il y a plus de deux ans et demi maintenant, mzf publiait un journal sur le site LinuxFR parlant de son projet «TapTempo». L’objectif de son programme était simplement de mesurer la cadence d’une musique en tapant sur une touche de son clavier, le résultat s’affichant simplement dans la console.

Ce journal fut le point de départ d’une série de «projets TapTempo» proposé par les lecteurs du site dans à peu prêt tous les langages informatique possible… Mais pas le Verilog.

Voici donc la lacune comblée avec TapTempo en Verilog.

[Dépêche parue initialement sur LinuxFR]

Le projet TapTempo semble faiblir depuis quelques mois maintenant. En panne de langage informatique pour en faire une dépêche ?
Laissez‑moi vous présenter un langage assez particulier puisqu’il ne sert pas à faire de la programmation. Ce langage permet de décrire le comportement numérique d’un composant électronique (on parle alors de langage de description de matériel — HDL) : le Verilog.

C’est aussi un langage utilisé pour faire de la synthèse numérique sur les circuits logiques programmables (FPGA). Dans cet exemple, nous utiliserons la carte de développement à bas coût ColorLight 5A‑75B.

Vue d'ensemble du montage TapTempo

Sommaire

Le Verilog

Le Verilog est un langage conçu à l’origine pour rédiger des spécifications de circuits logiques en électronique numérique. Le langage permet de décrire le comportement de sortie par rapport à des entrées logiques.

Un peu comme les logiciels de saisie de schéma électronique, le Verilog est très hiérarchique, on décrit des modules avec leurs entrées-sorties. Que l’on assemble ensuite dans d’autres modules pour finir dans un module « top » qui décrit le composant final.

Dans le cas de TapTempo, le module « top » est déclaré comme ceci :

module taptempo #(
    parameter CLK_PER_NS = 40, // 25Mhz clock (ns)
    parameter TP_CYCLE = 5120, // timepulse cycle period (ns)
    parameter BPM_MAX = 250 // BPM max (bpm)
)(
    input clk_i,
    input btn_i,
    output pwm_o
);
//corps du module
endmodule

Le module possède deux entrées : l’horloge (clk_i) et le bouton (btn_i) ainsi qu’une sortie pwm (pwm_o) pour l’affichage. Les paramètres seront vus comme des constantes au moment de la simulation, ils permettent de configurer les composants en fonction de la cible.

Le changement de valeur des signaux se fait dans des processus qui sont déclenchés sur événement. Ces processus sont décrits au moyen du mot clef always@() en Verilog.
Par exemple, dans le code suivant:

/* Detect rising edge*/
reg btn_old, btn_rise;
always@(posedge clk_i)
begin
    btn_old <= btn_i;
    if(btn_old == 0 && btn_i == 1)
        btn_rise <= 1;
    else
        btn_rise <= 0;
end

L’événement déclencheur du process est le front montant de l’horloge clk_i. À chaque fois qu’un front montant d’horloge se présente, le processus est exécuté de manière séquentielle.

L’opérateur <= est l’opérateur d’affectation dit « non bloquant ». Cela signifie que la valeur ne sera effectivement appliquée qu’a la fin de l’exécution du process. Donc la valeur du signal btn_old ne sera pas nécessairement égale à btn_i à la ligne du if() comme on aurait pu instinctivement le croire.

Le langage Verilog a beaucoup de succès dans le monde du logiciel libre. En effet il est relativement peu verbeux et ressemble au C pour de nombreux aspects.

Il est par exemple possible de décrire des macros de la même manière qu’en C, il suffit de remplacer le symbole # par ` pour créer des constantes qui seront remplacées par le pré-processeur.

/* count tap period */
`define MIN_NS 60_000_000_000
`define BTN_PER_MAX (`MIN_NS/TP_CYCLE)
`define BTN_PER_SIZE ($clog2(1 + `BTN_PER_MAX))

Le Verilog reprend également les opérateurs booléen et binaire &,&&, |,||, etc. du C.

C’est le langage HDL le mieux supporté par les différents logiciels libres. Si l’on souhaite se lancer dans le domaine des FPGA et/ou des ASIC, il est préférable de commencer par lui. C’est également le langage « de sortie » de quasiment tous les générateurs de code HDL.

Architecture de TapTempo

L’outil indispensable pour commencer un projet en Verilog est… le papier et le crayon. Il est en effet indispensable d’avoir une vue d’ensemble assez claire de ce que l’on souhaite réaliser avant de se lancer dans le code.

Voici donc l’architecture générale du composant TapTempo :

Schema TapTempo au Crayon

Même si l’on doit revenir plusieurs fois (ce qui est le cas ici puisque les constantes ne sont pas à jour) sur ce schéma général en cours de développement, cette partie est très importante. Si elle est bien pensée, le reste coule de source.

Le composant va nécessiter quelques compteurs, mais l’horloge utilisée ici étant très rapide nous allons d’abord factoriser le comptage au moyen du module nommé timepulse, ce module va distribuer un pulse qui servira de base aux autres compteurs pour leur fonctionnement interne.

L’entrée utilisateur se compose d’un bouton (touche télégraphique « morse »). Les fronts montant et descendant de cette entrée n’étant pas synchronisés sur l’horloge du système nous allons devoir le faire au moyen de deux bascules en série pour éviter la métastabilité.

/* Synchronize btn_i to avoid metastability*/
reg btn_old, btn_s;
always@(posedge clk_i or posedge rst)
begin
    if(rst) begin
        btn_old <= 1'b0;
        btn_s <= 1'b0;
    end else begin
        btn_old <= btn_i;
        btn_s <= btn_old;
    end
end

Le second problème que pose notre entrée est que l’appui sur le bouton ne génère pas des changements francs de son état. Chaque « appui et relâche » génère une série de rebonds et donc une série de 0 et de 1 avant de se stabiliser. Pour lisser le signal il va donc falloir faire passer le signal dans le bloc « anti-rebond » debounce.

Le bloc percount va ensuite se charger de mesurer le temps entre deux appuis sur le bouton. Cette période va devoir être transformée en fréquence « BPM » (Beat Per Minute) via le module per2bpm puis en une valeur pseudo-analogique (PWM) grâce au module pwmgen.

La carte cible ne possédant pas de bouton « reset », il va falloir le générer grâce au module rstgen de manière à s’assurer de l’état de départ de notre système au démarrage.

Entrée sortie

La plupart des programmes TapTempo proposés jusqu’ici supposaient – en plus d’un CPU – la présence d’un clavier et d’une console texte de sortie (avec toute la pile de pilotes et de système d’exploitation associés). Ici, nous allons devoir tout définir dans le « portegramme » – Dans l’industrie on va parler d’IP pour Intellectual Property, quel horrible nom –.

L’idée est donc de simplifier au maximum l’entrée « clavier » et la sortie histoire de pouvoir les décrire simplement.

Pour l’entrée nous allons nous contenter d’un contact de type bouton, ou d’une touche de type télégraphe « morse ».

TapTempoEntreeMorse

Comme on peut le voir dans le schéma ci-dessus, quand la touche est appuyée, l’entrée « bouton » est mise à la masse et donne un niveau logique à 0 sur notre système. Lorsque l’on relâche le bouton, la résistance de tirage ramène le niveau de tension à Vcc pour avoir un niveau 1 sur l’entrée.

Pour la sortie, l’idée de mettre un écran complexifie énormément le système. En effet, il est nécessaire de faire une machine d’état assez complexe pour initialiser l’écran puis rafraîchir l’affichage. Il est souvent nécessaire d’ajouter un processeur « soft » rien que pour ça d’ailleurs. (Bon il est vrai que le VGA n’est pas si compliqué, mais il reste plus complexe que la solution proposée ici).

Non, l’idée ici est d’utiliser les graduations de l’antique voltmètre à aiguille trouvé dans une cave et qui gradue de 0 à 300 comme on peut le voir sur la photo :

vuemetretaptempo

Et comme un système numérique ne sort que des 0 et des 1 sur ses broches, on va « simuler » une valeur analogique au moyen d’une PWM (Pulse With Modulation). Il suffit de changer le rapport cyclique entre le temps haut et le temps bas de notre signal pour faire varier la tension moyenne qui sera vue par le voltmètre. Si on l’ajuste correctement avec une résistance en série, il est relativement facile de forcer la valeur maximale (5V) à 250.

pwm

La période de la pwm sera configurée suffisamment rapide pour que l’aiguille n’oscille pas.

Pulsation de temporisation (timepulse)

Le module ne prend pas de valeur d’entrée hormis l’horloge et le reset qui sont de rigueur dans tout le projet. Son signal de sortie tp_o est une pulsation de l’horloge émise toutes les 5120 ns :

module timepulse #(
    parameter CLK_PER_NS = 40,
    parameter PULSE_PER_NS = 5120
)(
    /* clock and reset */
    input clk_i,
    input rst_i,
    /* output */
    output tp_o);

Pour pouvoir compter des périodes de 5120ns on définit un registre de comptage :

`define MAX_COUNT (PULSE_PER_NS/CLK_PER_NS)
`define MAX_COUNT_SIZE ($clog2(`MAX_COUNT))

reg [`MAX_COUNT_SIZE-1:0] counter = 0;

Puis on compte de manière synchronisée avec l’horloge :

always@(posedge clk_i or posedge rst_i)
begin
    if(rst_i)
    begin
        counter <= 0;
    end else begin
        if (counter < `MAX_COUNT)
        begin
            counter <= counter + 1'b1;
        end else begin
            counter <= 0;
        end
    end
end

La pulsation est émise lorsque le compteur passe par 0 :

assign tp_o = (counter == 0);

Gestion des rebonds (debounce)

L’entrée de ce module est le signal de bouton préalablement synchronisé avec l’horloge du système btn_s. Le compteur utilisera la pulsation tp_i généré par le module timepulse décrit ci-avant.

La sortie du module est un signal btn_o proprement lissé. La période de temporisation de 20 ms est donné ici en paramètre DEBOUNCE_PER_NS.

module debounce #(
    parameter PULSE_PER_NS = 5120,
    parameter DEBOUNCE_PER_NS = 20_971_520
)(
    /* clock and reset */
    input clk_i,
    input rst_i,
    /* inputs */
    input tp_i,
    input btn_i,
    /* output */
    output btn_o
);

La gestion des rebonds est réalisée au moyen d’un compteur utilisé pour temporiser.

`define MAX_COUNT ((DEBOUNCE_PER_NS/PULSE_PER_NS)-1'b1)
`define MAX_COUNT_SIZE ($clog2(`MAX_COUNT))

/* Counter */
reg [`MAX_COUNT_SIZE-1:0] counter = 0;

Ainsi que d’une machine d’états à 4 états :

/* State machine */
localparam [1:0] s_wait_low  = 2'h0,
                 s_wait_high = 2'h1,
                 s_cnt_high  = 2'h2,
                 s_cnt_low   = 2'h3;

reg [1:0] state_reg, state_next;

Les transitions de la machine d’états sont données dans le code ci-dessous dans un processus dit « combinatoire » (always@*) par opposition à un processus « synchrone ».

always@*
begin
    case(state_reg)
        s_wait_low:
            if(btn_i)
                state_next = s_cnt_high;
            else
                state_next = s_wait_low;
        s_wait_high:
            if(!btn_i)
                state_next = s_cnt_low;
            else
                state_next = s_wait_high;
        s_cnt_high:
            /* verilator lint_off WIDTH */
            if(counter == `MAX_COUNT)
            /* verilator lint_on WIDTH */
                state_next = s_wait_high;
            else
                state_next = s_cnt_high;
        s_cnt_low:
            /* verilator lint_off WIDTH */
            if(counter == `MAX_COUNT)
            /* verilator lint_on WIDTH */
                state_next = s_wait_low;
            else
                state_next = s_cnt_low;
    endcase;
end

L’état de la machine est tout de même synchronisé dans un second processus :

always@(posedge clk_i or posedge rst_i)
    if(rst_i)
        state_reg <= s_wait_low;
    else
        state_reg <= state_next;

Le principe de « lissage » des rebonds est donc le suivant : Dans l’état initial s_wait_low on attend que le bouton passe à la valeur 1. Lorsque le signal passe à 1, on change d’état pour s_cnt_high.

Le passage dans l’état s_cnt_high a pour effet de faire passer le signal de sortie à 1 et déclencher le compteur. Tant que le compteur compte et n’a pas atteint la valeur MAX_COUNT, on reste dans cet état quelles que soient les variations du signal d’entrée.
Lorsque le compteur atteint la valeur maximale, la machine d’état passe dans l’état s_wait_high (en attente de valeurs hautes).

Dans l’état s_wait_high on surveille la valeur du bouton d’entrée, si elle passe à 0 on change d’état pour s_cnt_low.

De manière symétrique à s_cnt_high on déclenche donc le compteur en ignorant la valeur d’entrée. Et, lorsqu’elle atteint son maximum on passe à l’état initial s_wait_low.

La valeur « lissée » du bouton en sortie est donnée par l’état de la machine d’état :

assign btn_o = (state_reg == s_cnt_high) || (state_reg == s_wait_high);

Mesure de la période de tempo (percount)

L’interface du module percount se compose des entrées habituelles d’horloge clk_i, de reset rst_i ainsi que de la pulsation tp_i.

Le signal de mesure en entrée est btn_i et la sortie est un vecteur btn_per_o donnant la valeur mesurée. La valeur est considérée comme valide uniquement lorsque la sortie btn_per_valid est à 1. Cette astuce permet d’économiser un registre si la sauvegarde de la valeur mesurée est inutile comme c’est le cas ici.

`define MIN_NS 60_000_000_000
`define BTN_PER_MAX (`MIN_NS/TP_CYCLE)
`define BTN_PER_SIZE ($clog2(1 + `BTN_PER_MAX))

module percount #(
    parameter CLK_PER_NS = 40,
    parameter TP_CYCLE = 5120,
    parameter PULSE_PER_NS = 5120,
)(
    /* clock and reset */
    input clk_i,
    input rst_i,
    /* time pulse */
    input tp_i,
    /* input button */
    input btn_i,
    /* output period */
    output [(`BTN_PER_SIZE-1):0] btn_per_o,
    output btn_per_valid);

Maintenant que nous avons un signal de bouton btn_b propre et lissé, nous pouvons entamer la mesure de la période entre deux appuis au moyen de… devinez quoi ? D’un compteur pardi !

reg [($clog2(`BTN_PER_MAX+1)-1):0] counter = 0;
reg counter_valid = 0;

assign btn_per_valid = counter_valid;
assign btn_per_o = counter;

Il nous faut tout d’abord détecter le front descendant du bouton  :

reg btn_old;
wire btn_fall = btn_old & (!btn_i);

always@(posedge clk_i or posedge rst_i)
begin
    if(rst_i)
        btn_old <= 1'b0;
    else
        btn_old <= btn_i;     
end

Le signal btn_fall sert de remise à zéro du compteur ainsi que de validation de la valeur de sortie :

always@(posedge clk_i or posedge rst_i)
begin
    if(rst_i)
    begin
        counter <= 0;
    end else begin
        if(btn_fall) begin
            counter_valid <= 1'b1;
        end else if(counter_valid) begin
            counter <= 0;
            counter_valid <= 1'b0;
        end else begin
            /* stop counting if max, count tp_i */
            if(tp_i && counter < `BTN_PER_MAX)
                counter <= counter + 1'b1;
        end
    end
end

Le compteur compte le nombre de pulsations de tp_i jusqu’à atteindre la saturation BTN_PER_MAX. Si un front montant du bouton se présente avec btn_fall, on valide le compteur avec counter_valid. Et si le signal de validation passe à 1 (donc le coup d’horloge suivant) on remet le compteur à zéro et on recommence à compter.

Calcul de la fréquence en Beat Per Minute (per2bpm)

Avec le module per2bpm on arrive dans la partie critique du projet, car il va nous falloir faire une division. On entre une période dans le module :

    /* inputs */
    input [(`BTN_PER_SIZE-1):0] btn_per_i,
    input btn_per_valid,

Et on doit en ressortir une fréquence (BPM) :

    /* outputs */
    output [`BPM_SIZE - 1:0] bpm_o,
    output bpm_valid

Suivant la formule :

Il faut donc diviser la constante

par la variable btn\_per\_i

La division (tout comme la multiplication) est un point sensible en Verilog. En effet, l’opérateur de division existe bien dans le langage et il se peut que cela simule parfaitement.

C’est lorsque arrivera l’étape de la synthèse que l’on risque d’avoir quelques surprises. Il est possible que certains logiciels de synthèse réussiront à faire quelque chose en un coup d’horloge. Mais il est certain que cela se fera au prix de très mauvaises performances en matière de ressources utilisées et de fréquence d’horloge. Il est surtout probable que votre logiciel de synthèse jette l’éponge.

Pour réaliser cette division, nous allons donc en revenir aux fondamentaux appris au primaire et poser la division. Une division, c’est la recherche du Quotient et du Reste de l’équation suivante :

Dividend = Quotient \times Divisor + Remainder
reg [(`REGWIDTH-1):0] divisor;
reg [(`REGWIDTH-1):0] remainder;
reg [(`REGWIDTH-1):0] quotient;

La taille des registres sera celle de la période en entrée BTN_PER_SIZE additionné à la constante à diviser.

`define DIVIDENTWITH ($clog2(1 + `MIN_NS/(TP_CYCLE)))
`define REGWIDTH (`BTN_PER_SIZE + `DIVIDENTWITH)

La division s’effectue avec une série de soustraction du reste (remainder) et de décalage du diviseur.

À l’étape initiale, on place le diviseur à gauche du registre divisor et le dividende dans le reste remainder :

     divisor <= {btn_per_i, (`DIVIDENTWITH)'h0};
     remainder <= `MIN_NS/TP_CYCLE;
     // le résultat est initialisé à 0:
     quotient <= 0;

Puis on effectue une série de comparaison-soustraction-décalage avec l’algorithme comme décrit ci-dessous :

  • si le diviseur (divisor) inférieur ou égal au reste (remainder), on soustrait le reste avec le diviseur et on décale le quotient à gauche en ajoutant 1 :
            if(divisor <= remainder)
            begin
                remainder <= remainder - divisor;
                quotient <= {quotient[(`DIVIDENTWITH-2):0], 1'b1};
  • si le diviseur (divisor) est supérieur au reste, on décale le quotient à gauche en ajoutant 0. On ne touche pas au reste :
                quotient <= {quotient[(`DIVIDENTWITH-2):0], 1'b0};
  • dans tous les cas, on décale le diviseur à droite.
            divisor <= {1'b0, divisor[(`REGWIDTH-1):1]};

La division est orchestrée par une machine à trois états :

localparam [1:0] s_init    = 2'h0,
                 s_compute = 2'h1,
                 s_result  = 2'h2;

reg [1:0] state_reg, state_next;

Et le résultat est disponible en sortie quand state_reg est dans l’état s_result:

assign bpm_o = quotient[(`BPM_SIZE-1):0];
assign bpm_valid = (state_reg == s_result);

Génération de la tension de sortie (pwmgen)

La génération du signal pseudo analogique décrite en introduction est presque la partie la plus simple.

On compte (oui encore) de 0 à 250 (BPM_MAX) :

/* count */
always@(posedge clk_i or posedge rst_i)
begin
    if(rst_i)
        count <= BPM_MAX;
    else begin
        if(tp_i)
        begin
            if (count == 0)
                count <= BPM_MAX;
            else
                count <= count - 1'b1;
        end
    end
end

Et on passe le signal de sortie pwm_o à 1 lorsque le compteur est inférieur à la fréquence demandée :

assign pwm_o = (count <= pwmthreshold);

Il y a juste une subtilité consistant à sauvegarder la valeur de la fréquence donnée en entrée dans deux registres pwmthreshold et bpm_reg :

reg [($clog2(BPM_MAX+1)-1):0] bpm_reg;
reg [($clog2(BPM_MAX+1)-1):0] pwmthreshold;

/* Latching bpm_i on bpm_valid */
always@(posedge clk_i or posedge rst_i)
begin
    if(rst_i)
    begin
        bpm_reg <= 0;
        pwmthreshold <= 0;
    end else begin
        if(bpm_valid)
            bpm_reg <= bpm_i;
        if(count == BPM_MAX)
            pwmthreshold <= bpm_reg;
    end
end

Le premier registre bpm_reg est mis à jour lorsque le signal d’entrée bpm_valid est à 1. Pour mémoriser la valeur d’entrée et pouvoir l’utiliser au moment où l’on en a besoin.
Et le second pwmthreshold est rafraîchi en fin de cycle d’une période de la pwm. Pour éviter d’avoir un changement de valeur en cours de période, et donc un rapport cyclique faux.

Simulation de l’ensemble avec Cocotb

Jusqu’ici nous avons décrit le comportement du composant final en Verilog. Toutes les développeuses ou développeurs HDL le savent très bien, il est impossible de réaliser un projet Verilog (ou autre HDL) sans faire un minimum de simulation.

Pour simuler le composant, il est nécessaire de décrire les stimuli en entrée du composant et de lire/valider les sorties. On va généralement créer un composant hiérarchiquement au-dessus du top de notre composant appelé « testbench » dans lequel nous décrirons les changements de valeurs des entrées au cours du temps. Cette partie peut tout à fait se faire en Verilog.

Cependant, l’idée de mélanger la partie banc de test et composant « synthétisable » n’est pas terrible. En effet on va très vite confondre les deux parties et mélanger les codes. L’exemple de la division est criant : l’opérateur diviser « / » fonctionne très bien dans la partie testbench mais elle pose de gros problèmes dans la partie « synthétisable ».

Pour éviter ce mélange des genres, une solution radicale consiste à utiliser un autre langage pour la partie banc de test. Le C++ et le SystemC sont utilisés depuis longtemps pour cela. S’ils sont utilisés en conjonction avec Verilator ils permettent d’atteindre des puissance/rapidité de simulation inégalées par les simulateurs « propriétaires ».

Une autre méthode consiste à piloter le simulateur Verilog avec un autre programme, on parle alors de cosimulation. C’est le cœur du fonctionnement du module python CocoTB. L’idée ici est d’écrire son banc de test en python, ce qui est nettement plus confortable que du Verilog ou même du C++ (SystemC est une librairie C++ également).

Le testbench pour simuler l’ensemble du projet taptempo se trouve dans le répertoire cocotb/test_taptempo. Pour le simuler il suffit de s’y rendre et d’y exécuter un make.
À condition cependant d’avoir installé cocotb (en python3) et Icarus pour la partie simulateur (On laissera l’appréciation de l’installation au lecteur en fonction de ses affinités linuxdistributive).

La simulation consiste à tester trois appuis sur le bouton à des intervalles différents :

@cocotb.test()
async def debounce_upanddown(dut):
    td = TestTapTempo(dut)
    td.log.info("Running test!")
    await td.reset()
    td.log.info("System reseted!")
    await Timer(1000, units="us")
    td.log.info("up")
    await td.bounce_up(10, bounce_per=(10000, "ns"))
    await Timer(24, units="ms")
    td.log.info("down")
    await td.bounce_down(10, bounce_per=(10000, "ns"))
    await Timer(300, units="ms")
    td.log.info("up")
    await td.bounce_up(10, bounce_per=(10000, "ns"))
    await Timer(30, units="ms")
    td.log.info("down")
    await td.bounce_down(10, bounce_per=(10000, "ns"))
    await Timer(800, units="ms")
    td.log.info("up")
    await td.bounce_up(10, bounce_per=(10000, "ns"))
    await Timer(30, units="ms")

    td.log.info("Wait stable")
    await Timer(1000, units="us")

Cela génère un fichier de « traces » au format VCD particulièrement volumineux de 2,3 Go (qui se compresse à 70 Mo avec xz !) permettant de visionner les signaux au cours du temps grâce à gtkwave:

$ gtkwave -g taptempo.vcd

Et donne la trace suivante :
simulation_taptempo_full

Cette simulation est particulièrement longue (il m’a fallu environ une heure et demie sur mon vieux T430) et génère un fichier de trace monstrueux. En phase de développement on va généralement lancer de petites simulations par modules comme on peut le voir pour le module debounce dans le répertoire cocotb/test_debounce. On changera également certaines constantes de temps pour limiter les «  pas » de simulation consommant inutilement du calcul processeur.

Il est également possible de laisser l’ordinateur écrire les stimuli grâce à la méthode de preuve formelle. C’est la méthode qui a été utilisée ici pour les modules. Les fichiers de configuration se trouvent dans le répertoire formal/*.

Synthèse sur ColorLight

La Colorlight n’est pas initialement une carte de développement pour les FPGA. C’est une carte permettant de piloter des panneaux de LED qui nous agressent un peu partout dans les rues commerçantes. Cependant, un petit malin s’est rendu compte qu’elle était munie d’un FPGA de chez Lattice : l’ECP5.

Ce FPGA possède deux gros avantages :

  • il est relativement gros, suffisamment pour posséder des multiplieurs câblés, des sérialiseurs-désérialiseurs…
  • on peut développer dessus avec une chaîne de développement intégralement opensource !

Jusqu’à la colorlight, les kits de développement ECP5 n’étaient pas donnés puisque les premières cartes débutaient à 100 $ minimum. Mais avec la colorlight, on tombe à 15 $, ce qui en fait un kit de développement ultra bon marché pour se faire la main avec des FPGA.

Et comme tout est opensource, il est aisé d’aller installer les logiciels permettant de synthétiser TapTempo sur sa distribution Linux préférée.
L’explication de l’installation des outils est hors de propos de cet article (un article détaillé sur la colorlight est disponible dans le Hackable 35), mais une fois les outils installés, il suffit de se rendre dans le répertoire synthesis/colorlight du projet et de faire make :

$ make
[...]
Info: Device utilisation:
Info:          TRELLIS_SLICE:   328/12144     2%
Info:             TRELLIS_IO:     3/  197     1%
Info:                   DCCA:     1/   56     1%
Info:                 DP16KD:     0/   56     0%
Info:             MULT18X18D:     0/   28     0%
Info:                 ALU54B:     0/   14     0%
Info:                EHXPLLL:     0/    2     0%
Info:                EXTREFB:     0/    1     0%
Info:                   DCUA:     0/    1     0%
Info:              PCSCLKDIV:     0/    2     0%
Info:                IOLOGIC:     0/  128     0%
Info:               SIOLOGIC:     0/   69     0%
Info:                    GSR:     0/    1     0%
Info:                  JTAGG:     0/    1     0%
Info:                   OSCG:     0/    1     0%
Info:                  SEDGA:     0/    1     0%
Info:                    DTR:     0/    1     0%
Info:                USRMCLK:     0/    1     0%
Info:                CLKDIVF:     0/    4     0%
Info:              ECLKSYNCB:     0/   10     0%
Info:                DLLDELD:     0/    8     0%
Info:                 DDRDLL:     0/    4     0%
Info:                DQSBUFM:     0/    8     0%
Info:        TRELLIS_ECLKBUF:     0/    8     0%
Info:           ECLKBRIDGECS:     0/    2     0%
[...]
ecppack --svf taptempo.svf taptempo_out.config taptempo.bit

On voit ici que les ressources utilisées pour TapTempo sont ridicules par rapport au FPGA utilisé. La curieuse ou le curieux qui voudra « voir » le placement routage dans le FPGA utilisera l’option --gui dans la commande NextPnR pour avoir l’interface graphique :

$ nextpnr-ecp5 --25k --package CABGA256 --speed 6 --json taptempo.json --textcfg taptempo_out.config --lpf taptempo.lpf --freq 25 --gui

Ce qui donne un autre aperçu du remplissage du FPGA.

taptempo routage

Pour télécharger le bitstream dans le FPGA, on pourra utiliser openFPGALoader en donnant simplement le nom du bitstream :

$ openFPGALoader taptempo.bit

Exercices de travaux pratiques

Pour celles et ceux qui ont suivi jusqu’ici et qui voudraient se faire la main avec ce projet, voici quelques propositions de « sujet de TP » :) :

  • utilisation d’un multiplieur câblé de l’ECP5 pour faire la division dans per2bpm ;
  • ajout un module de moyennage sur cinq échantillons pour coller à la spécification initiale de TapTempo ;
  • utilisation d’autres plates‑formes FPGA à bas coût : QuickFeather, FireAnt, Tang Nano

N’hésitez pas à me proposer des demandes d’intégration Git pour améliorer le projet.

Conclusion

On voit que dès que l’on passe dans le domaine de l’embarqué les choses se compliquent et prennent plus de temps. Alors que sur un PC on aurait pu faire ça en une ligne de code, quand on embarque ça dans un microcontrôleur, c’est déjà plus compliqué. Mais si l’on passe dans le monde des FPGA et des ASIC, le projet prend une toute autre dimension. C’est la raison pour laquelle il faut toujours se demander si un FPGA est bien à propos pour notre projet, non seulement cela coûtera plus cher en composant qu’une solution sur étagère, mais en plus le temps de développement (et donc le coût) sera nettement supérieur.

L’idée d’utiliser une touche de télégraphe pour mesurer le tempo n’était peut‑être pas la meilleure, compte tenu des rebonds qui sont relativement violents. Même avec le module lisseur de rebond (debounce), il subsiste quelques rebonds trop longs. Un tempo maximum à 250 n’est pas si rapide et l’on est vite frustré de l’atteindre alors qu’on pourrait mesurer des tempos de musiques plus… rythmées. On peut facilement passer à 300, mais ça reste lent. Si l’on veut un tempo plus rapide, il faut tout d’abord changer la graduation sur le voltmètre, puis modifier le paramètre BPM_MAX dans le code.

On a ici un modèle de projet qui est facile à synthétiser sur n’importe quel petit FPGA. C’est un projet qui peut être intéressant si l’on souhaite se sortir un peu les doigts des LED qui clignotent. La démonstration étant faite du fonctionnement de l’architecture globale, il est aisé de s’en servir pour la réécrire dans d’autres langages de description de matériel comme le VHDL, Chisel (même s’il y en a déjà une pour taptempo), Migen/Litex, MyHDL, Clash (en plus, ça permettrait de débloquer la dépêche LinuxFr.org sur le sujet)…

Pour le curieux, ou la curieuse, qui sera allé voir le code sur le projet GitHub, ce projet a été développé avec une dose de preuves formelles grâce au logiciel libre Yosys-SMTBMC.