Découvrir le langage Lucid avec une carte de développement FPGA d’Alchitry

f-leb

Le but de ce tutoriel est de s’initier au développement sur FPGA en langage Lucid (un langage dérivé du langage Verilog) avec une carte FPGA d’Alchitry. Ce tutoriel suppose que vous ayez un peu d’expérience en programmation de microcontrôleurs (sur Arduino par exemple), et en électronique numérique en général (logique combinatoire et séquentielle).

Commentez

- Titre : Découvrir le langage Lucid avec une carte de développement FPGA d’Alchitry

- Auteur : f-leb

- Parution : 1 décembre 2020

- Mise à jour : 22 septembre 2021

- Licence : Le contenu de cet article est rédigé par f-leb et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- I. Introduction

- II. Présentation de la plateforme Alchitry

- III. Langage Lucid

- IV. Installation des outils

- V. Premiers pas

- VI. Constitution du module principal

- VI-A. Déclaration des ports

- VI-B. Bloc d’instanciation de signaux et modules

- VI-C. Bloc always

- VII. Exemples de démonstration

- VII-A. Clignotement de LED

- VII-A-1. La bascule D

- VII-A-2. Module blinker

- VII-B. Jouer avec la luminosité des LED avec un signal PWM

- VII-C. Piloter une LED avec un bouton-poussoir

- VII-D. Envoyer un message sur la liaison série

- VIII. Conclusion

- IX. Bibliographie et sitographie

I - Introduction

Les FPGA sont des puces à circuits intégrés constitués de réseaux de blocs logiques. Les cellules logiques disposées dans une matrice sont librement connectables et les circuits de la puce peuvent donc être reconfigurés même après sa fabrication.

Pour reconfigurer la puce, on passe habituellement par un langage de description de matériel (ou HDL pour Hardware Description Language) comme VHDL ou Verilog. Même si par abus de langage, on parle de « programmation » de FPGA (après tout, l’activité consiste toujours à écrire des lignes de code qui finiront en fichier binaire transféré dans la puce), il ne s’agit pas de programmer un traitement selon un algorithme comme on a l’habitude de le faire en Python, C/C++, Java, etc. Les lignes de code servent ici à décrire la structure et le comportement de votre circuit. Les outils fournis avec ces langages permettent de faire de la simulation et la synthèse logique jusqu’à l’implémentation physique optimale du circuit au cœur de la puce FPGA.

Du code source jusqu'à l'implémentation physique

II - Présentation de la plateforme Alchitry

La plateforme Alchitry propose à ce jour :

- deux cartes de développement FPGA : Alchitry Au et Alchitry Cu ;

- des cartes d’extensions (shields façon Arduino) pour l’expérimentation et le prototypage ;

- un environnement de développement intégré (EDI) : Alchitry Labs.

La carte Alchitry Au utilisée dans ce tutoriel est la plus performante des deux : puce FPGA Xilinx Artix 7 (33 280 cellules logiques, 256 Mo RAM DDR3, 102 entrées-sorties (3,3 V), horloge 100 MHz).

FPGA Alchitry Au - Image Sparkfun Licence C.C

La carte intègre en surface un jeu de 8 LED vertes, un bouton utilisateur (pour un Reset en général), 4 connecteurs femelles 50 broches pour les cartes d’extension, un connecteur Qwiic (format proposé par Sparkfun) pour des périphériques I^2C. La connexion au PC de développement se fera via un câble USB type C vers USB type A.

À noter qu’Alchitry a maintenant un partenariat avec Sparkfun pour la fabrication et la revente du matériel de la plateforme, ce qui lui garantit une plus grande diffusion.

Quelque part, le modèle que semble suivre Alchitry est celui de la plateforme Arduino : un environnement matériel pour accéder plus facilement au monde des FPGA, un EDI minimal et un langage de description de matériel, nommé Lucid, surcouche du langage Verilog.

L'EDI Alchitry Labs

III - Langage Lucid

Les programmes de démonstration utilisés par la suite dans ce tutoriel seront donc écrits en langage Lucid. Lucid est un langage de description de matériel (Hardware Description Language ou HDL) prévu spécifiquement pour les FPGA, et conçu d’après leurs auteurs pour éliminer la plupart des chausse-trapes que l’on retrouve dans les autres HDL comme Verilog et VHDL.

D’après Alchitry :

« […] Lucid met l’accent sur la réduction de la quantité de code que vous devez écrire tout en rendant vos projets davantage compréhensibles.
Lucid est très similaire au langage Verilog, tout en partageant une syntaxe commune avec C/C++ (terminé les begin/end).
Nous voulions que votre expérience avec les FPGA se concentre sur l’apprentissage des FPGA plutôt que sur l’apprentissage d’une langue compliquée. Lucid est assez semblable à Verilog, et les concepts sont les mêmes que VHDL (ou n’importe quel autre HDL), de sorte que vous n’ayez pas l’impression de rester enfermé en écrivant du code sous Lucid. Cependant, après avoir codé en Lucid ces derniers mois, je ne veux plus travailler avec quoi que ce soit d’autre. »

Si vous hésitez à franchir le pas avec des langages réputés comme Verilog ou VHDL, Lucid est préconisé dans un premier temps. Le but est de se familiariser avec les fondamentaux de la conception hardware, et revenir plus tard vers des HDL plus pointus quand vous aurez fait vos premières armes.

À ma connaissance, ce langage n’a pas la popularité d’un langage comme le « langage Arduino » pour les microcontrôleurs de la plateforme du même nom, et vous avez peu de chances de le rencontrer en dehors de la plateforme Alchitry. La plateforme est jeune, les choses évolueront peut-être, mais les FPGA peinent encore à séduire le milieu des hobbyistes et des makers. Pour autant, avec cet environnement sous Lucid, vous avez enfin un bon moyen de vous y mettre…

Et si vous souhaitez plus tard évoluer vers un HDL comme Verilog sur la plateforme Alchitry, la transition est toujours possible sans douleur.

Un nouveau projet dans l'EDI Alchitry Labs peut être écrit au choix en Lucid ou en Verilog

IV - Installation des outils

L’installation des outils est décrite à la rubrique Getting Started Tutorials du site, par exemple pour la carte Alchitry Au : Getting Started With the Au

Pour ma part, l’installation sous Windows 10 des outils Xilinx (Vivado) et de l’EDI Alchitry Labs s’est déroulée sans problème. Il faut quand même être patient, il y a 10 Go de données à télécharger sur le site de Xilinx (où il faudra créer un compte). Et une fois la carte connectée au PC, elle fut reconnue tout de suite avec un nouveau port COM visible dans le Gestionnaire de périphériques de Windows.

V - Premiers pas

On commencera par un petit exercice de logique combinatoire. Supposons que votre système embarqué doive générer en sortie un signal x obéissant à une équation logique, soit le produit : x = a . (b + c), où a, b et c sont des entrées du système. Afin de valider le principe et vérifier la table de vérité du système, les trois entrées tout-ou-rien simulées prendront les 2^3=8 états possibles.

Par défaut, quand vous démarrez un nouveau projet dans l’EDI Alchitry Labs, le fichier source au_top.luc du module principal au_top vous est présenté :

Un nouveau projet dans l'EDI Alchitry Labs

On commence par ajouter un composant de bibliothèque (menu Project→ Add Components) en cochant Counter :

La description des lignes de code déjà présentes vous sera détaillée plus tard, mais vous pouvez déjà compléter le code du module principal de la façon suivante :

au_top.luc

1. module au_top (

2. input clk,              // 100MHz clock

3. input rst_n,            // reset button (active low)

4. output led [8],         // 8 user controllable LEDs

5. input usb_rx,           // USB->Serial input

6. output usb_tx           // USB->Serial output

7. ) {

9. sig rst;                  // reset signal

11. .clk(clk) {

12. // The reset conditioner is used to synchronize the reset signal to the FPGA

13. // clock. This ensures the entire FPGA comes out of reset at the same time.

14. reset_conditioner reset_cond;

16. .rst(rst) {

17. counter myCounter(#SIZE(3)); // myCounter : 3 bits counter, freq = 100MHz

18. }

19. }

21. product myProduct;

23. sig x;

25. always {

26. reset_cond.in = ~rst_n; // input raw inverted reset signal

27. rst = reset_cond.out;   // conditioned reset

29. myProduct.a = myCounter.value[0];

30. myProduct.b = myCounter.value[1];

31. myProduct.c = myCounter.value[2];

33. x = myProduct.out;        // x = a AND (b OR C)

35. led = 8h00;             // turn LEDs off

36. usb_tx = usb_rx;        // echo the serial data

37. }

38. }

- Lignes 16 à 18 : instanciation d’un compteur 3 bits nommé myCounter. Ce compteur servira à faire prendre aux trois entrées a, b et c les 2^3=8 combinaisons de 0 et de 1.

- Ligne 21 : déclaration d’une instance myProduct du module product. Ce module effectue le produit logique, voir le code plus loin.

- Ligne 23 : déclaration du signal x.

- Lignes 29 à 31 : connexion des entrées a, b, et c à la sortie de largeur 3 bits du compteur.

- Ligne 33 : connexion du signal x au résultat du produit logique.

Pour le calcul du produit, il faut ajouter un nouveau fichier source au projet, nommé product.luc (menu File→ New File…) :

product.luc

module product (
input a,
input b,
input c,
output out
) {

always {
out = a & (b | c); // a AND (b OR C)
}
}

Quand le code est prêt, il reste à générer le projet et à le transférer grâce aux outils de l’EDI :

Barre d'outils dans l'EDI Alchitry Labs

(Check syntax) : vérification de la syntaxe du code.

(Build project) : génération du projet.

(Debug project) : génération du projet en mode Debug afin de visualiser des signaux.

(program temporary/flash) : transfert en mémoire RAM de façon temporaire ou en mémoire Flash.

Pour visualiser les chronogrammes des signaux, générez le projet en mode Debug puis sélectionnez les signaux myProduct, myProduct.out et myCounter.value :

Attention, la génération du projet peut prendre plusieurs minutes, comme c’est souvent le cas avec les FPGA !

Après avoir transféré le binaire généré dans la carte, vous pouvez lancer une capture des signaux en passant par le menu Tools→ Wave capture :

Les résultats du produit logique de la sortie myProduct.out sont évidemment conformes. À vous de vérifier…

Afin d’avoir un aperçu de ce qui se passe à la génération du projet, ouvrez la suite Vivado de Xilinx. Dans Vivado, ouvrez le fichier du projet avec l’extension .xpr dans le sous-dossier work/vivado. Une fois le projet ouvert, depuis le Flow Navigator à gauche, développez la rubrique RTL Analysis et sélectionnez schematic.

Vous devriez voir le schéma logique qui a servi de base pour la synthèse du projet :

Vous reconnaissez notamment le compteur 3 bits myCounter produisant les entrées du bloc myProduct avec ses portes OR et AND pour faire le produit logique.

En pratique, les portes logiques ne sont pas réellement implantées. En fait, il s’agit de blocs logiques programmables comme les LUT (LookUp Table). L’image ci-dessous montre la LUT synthétisée pour faire le produit logique (depuis la rubrique Synthesis dans Vivado) et sa table de vérité :

Enfin, les images suivantes montrent une partie du schéma d’implémentation physique au sein de la puce Xilinx (rubrique Implementation dans Vivado) et les connexions. La cellule en surbrillance indique la localisation de notre LUT :

À présent, regardons en détail le code du module principal, en particulier ce qui a été passé sous silence…

VI - Constitution du module principal

Typiquement, derrière les langages de description de matériel (HDL), il y a toujours le concept de module. Un module est un bloc de circuit avec un certain nombre d’entrées et de sorties, et contenant la logique comportementale qui lie ces entrées-sorties. Bien qu’il soit possible de mettre la totalité d’un projet dans un seul module, il est préférable de le découper en plusieurs modules afin de réduire sa complexité. Certains de ces modules effectueront des tâches courantes et seront réutilisables dans d’autres projets.

Tout nouveau projet en Lucid comprend un module principal « top ». Et pour tout projet Alchitry, ces modules sont soit dans le fichier cu_top.luc soit au_top.luc en fonction de la carte que vous utilisez (Alchitry Cu ou Alchitry Au).

Voici par exemple le fichier au_top.luc généré automatiquement à la création d’un nouveau projet :

au_top.luc

1. module au_top (

2. input clk,              // 100MHz clock

3. input rst_n,            // reset button (active low)

4. output led [8],         // 8 user controllable LEDs

5. input usb_rx,           // USB->Serial input

6. output usb_tx           // USB->Serial output

7. ) {

9. sig rst;                  // reset signal

11. .clk(clk) {

12. // The reset conditioner is used to synchronize the reset signal to the FPGA

13. // clock. This ensures the entire FPGA comes out of reset at the same time.

14. reset_conditioner reset_cond;

15. }

17. always {

18. reset_cond.in = ~rst_n; // input raw inverted reset signal

19. rst = reset_cond.out;   // conditioned reset

21. led = 8h00;             // turn LEDs off

23. usb_tx = usb_rx;        // echo the serial data

24. }

25. }

Structure d'un module d'après le guide de référence Lucid

VI-A - Déclaration des ports

Le code du module débute avec la déclaration des ports. C’est l’endroit où vous déclarez les entrées-sorties de votre module.

1. module au_top (

2. input clk,              // 100MHz clock

3. input rst_n,            // reset button (active low)

4. output led [8],         // 8 user controllable LEDs

5. input usb_rx,           // USB->Serial input

6. output usb_tx           // USB->Serial output

7. )

Dans ce cas, comme il s’agit du module principal, ces déclarations référencent les signaux disponibles sur les périphériques de la carte elle-même : horloge 100 MHz, bouton reset, jeu de 8 LED programmables, et port USB. Les localisations des broches de la puce vers lesquelles sont dirigées les entrées-sorties du module principal sont définies dans le fichier alchitry.acf (Alchitry Constraint File) à la rubrique Constraints de l’explorateur.

VI-B - Bloc d’instanciation de signaux et modules

Le code à partir de la ligne 11 montre le contenu d’un bloc d’instanciation :

1. .clk(clk) {

2. // The reset conditioner is used to synchronize the reset signal to the FPGA

3. // clock. This ensures the entire FPGA comes out of reset at the same time.

4. reset_conditioner reset_cond;

5. }

Ce genre de blocs s’écrit sous la forme :

Assignment Blocks

.port_name(EXPR), #PARAM_NAME(CONST_EXPR) {
SIGNAL_AND_MODULE_INSTANCES
}

À l’intérieur du bloc, vous trouvez ligne 14 une déclaration d’instance de module.

Pour toute instanciation, vous utilisez tout simplement le nom de la ressource suivi par le nom de cette instance particulière. Ainsi, la ligne reset_conditioner reset_cond; crée une instance du module reset_conditioner nommée reset_cond.

Le bloc débute avec une syntaxe du type .port(signal) qui permet de déclarer des connexions.

Dans notre cas, le module comporte un port clk, et ce port clk de l’instance est donc connecté au signal clk du module principal « top ».

Plus loin dans le code , l’entrée de cette instance est reliée au bouton reset de la carte :

1. reset_cond.in = ~rst_n; // input raw inverted reset signal

VI-C - Bloc always

Le bloc always est l’endroit où vous décrivez toute la logique du comportement de votre module.

À l’intérieur du bloc always, un ensemble de déclarations structurelles ou comportementales : affectations de signaux, structures conditionnelles if ou case, boucles for, etc.

Une structure conditionnelle en Lucid dans le bloc always, et sa traduction au niveau logique par un multiplexeur (ou sélecteur) — Analyse RTL Vivado

Comme son parent Verilog, Lucid est un langage concurrent (parallèle), contrairement aux langages procéduraux (C, C++, Java…) qui sont séquentiels par nature. Les différents processus d’un module s’exécutent donc en parallèle, mais certains blocs d’instructions peuvent aussi s’exécuter de façon séquentielle. C’est notamment le cas à l’intérieur du bloc always (par définition, dont les instructions sont réitérées indéfiniment) qui autorise les constructions procédurales.

Ici, le bloc ne comprend que des affectations de signaux. Il faut imaginer des câbles de connexion tendus entre les signaux dont les expressions figurent de part et d’autre du signe = (avec la condition que l’on puisse « écrire » sur le signal de l’expression à gauche du signe =, et que le signal de l’expression à sa droite puisse être « lu »).

1. always {

2. reset_cond.in = ~rst_n; // input raw inverted reset signal

3. rst = reset_cond.out;   // conditioned reset

5. led = 8h00;             // turn LEDs off

7. usb_tx = usb_rx;        // echo the serial data

8. }

Dans l’ordre :

- l’entrée in de l’instance reset_cond est connectée au signal (inversé avec ~) du bouton reset ;

- le signal rst provient de la sortie out de l’instance reset_cond ;

- les signaux dirigés vers les 8 LED sont à l’état bas (8h00 : signal de largeur 8 bits mis à zéro, h pour indiquer un format hexadécimal) ;

- les signaux reçus sur Rx de la liaison série sont renvoyés vers Tx.

La configuration conçue avec le fichier au_top.luc par défaut peut être résumée par le schéma-blocs suivant :

Réalisé sous Word, ce schéma n’est en rien normalisé, mais il est très utile en phase de réflexion pendant la mise au point du projet. Il est toutefois très proche de celui obtenu après coup avec une analyse sous Vivado :

Finalement, toute cette partie met à disposition un circuit de conditionnement du signal sur appui du bouton reset, synchronisé avec l’horloge. Le signal rst en sortie est indispensable pour réinitialiser proprement (et simultanément) les composants de votre projet (reset local).

VII - Exemples de démonstration

VII-A - Clignotement de LED

Un blinker pour débuter, quelle surprise !

Le schéma-blocs du projet ci-dessous comprend deux instances d’un nouveau module blinker : myBlinker et myOtherBlinker. Chaque blinker a deux entrées (signal d’horloge clk et reset rst) et une sortie out. Sur la sortie out d’un blinker, le module génère un signal carré de période donnée. Il reste à connecter la sortie out d’un blinker à une LED pour la faire clignoter.

On repart du module principal au_top.luc d’un nouveau projet que l’on complète ainsi :

au_top.luc

1. module au_top (

2. input clk,              // 100MHz clock

3. input rst_n,            // reset button (active low)

4. output led [8],         // 8 user controllable LEDs

5. input usb_rx,           // USB->Serial input

6. output usb_tx           // USB->Serial output

7. ) {

9. sig rst;                  // reset signal

11. .clk(clk) {

12. // The reset conditioner is used to synchronize the reset signal to the FPGA

13. // clock. This ensures the entire FPGA comes out of reset at the same time.

14. reset_conditioner reset_cond;

16. .rst(rst) {

17. blinker myBlinker(#DELAY(50000000)); // 50000000 = 0,5s

18. blinker myOtherBlinker(#DELAY(25000000)); // 25000000 = 0,25s

19. }

20. }

22. always {

23. reset_cond.in = ~rst_n; // input raw inverted reset signal

24. rst = reset_cond.out;   // conditioned reset

26. led = c{myOtherBlinker.out, 6x{b0}, myBlinker.out};

28. usb_tx = usb_rx;        // echo the serial data

29. }

30. }

Lignes 17 et 18 : déclaration de deux instances du module blinker nommées myBlinker et myOtherBlinker. Sur la sortie out d’un blinker, le module génère un signal carré de période donnée. La période est définie grâce au paramètre DELAY. DELAY correspond au nombre de ticks de l’horloge avant basculement de l’état du signal. Avec une horloge à 100 MHz, soit 100 millions de ticks par seconde, un DELAY fixé à 50 millions générera un basculement on/off ou off/on toutes les 0,5 s, et donc un signal carré de période 1 s.

Notez comment sont imbriquées les connexions aux ports. L’instance reset_cond est connectée à l’horloge clk seule. Les deux instances de blinker sont reliées à l’horloge clk et au signal reset rst.

Ligne 26 : les sorties des deux instances de blinker sont dirigées vers les LED. La syntaxe c{…} permet la concaténation de tableaux. Ici, on concatène la sortie myOtherBlinker.out (largeur 1 bit) avec 6 bits à 0 puis avec la sortie myBlinker.out (largeur 1 bit). L’ensemble concaténé a une largeur 8 bits et est dirigé vers le tableau led connecté au jeu de 8 LED.

Il reste à coder le comportement du blinker dans un nouveau fichier source blinker.luc rajouté depuis l’EDI :

Fichiers source dans Alchitry Labs

Mais avant cela, il vous faudra étudier le comportement d’un composant essentiel de logique séquentielle : la bascule D.

VII-A-1 - La bascule D

Une bascule D (ou D-type Flip Flop, soit DFF) est un circuit logique qui peut mémoriser un état.

Quand précédemment on avait écrit :

reset_cond.in = ~rst_n;

Vous devez garder à l'esprit que reset_cond.in n’est pas une variable mémorisant une valeur. Vous venez seulement de connecter un point A à un point B pour transporter un signal… c’est tout. Il n’y a pas de valeur stockée dans une variable ici.

Pour autant, un système sans mémoire est plutôt limité, et c’est là que la bascule D intervient.

Une bascule D peut être schématisée comme suit :

Elle a une entrée d’horloge clk de synchronisation (ici, sur front montant de l’horloge).

Le but de ce circuit est de recopier la donnée qui se présente à l’entrée D (Data) sur sa sortie Q à chaque front montant de l’horloge. Entre deux fronts montants de l’horloge, l’état de la sortie Q est maintenu quels que soient les changements sur l’entrée D. Dans cet intervalle de temps, il y a bien un état « mémorisé ».

Ce circuit, dit à logique séquentielle, se distingue des circuits à logique combinatoire où la sortie ne dépend que de l’état courant des entrées.

En Lucid, il n’y a pas explicitement d’interface avec l’entrée en (enable), la bascule est active tant que la carte est alimentée.

Lorsqu’un signal à l’état haut arrive sur l’entrée reset rst (typiquement avec le circuit de reset conditionné et synchronisé vu plus haut pour réinitialiser toutes les bascules du circuit simultanément), la sortie Q est réinitialisée (par défaut à zéro).

Pour déclarer une bascule D en Lucid, il suffit de l’instancier en précisant le type dff :

dff ma_bascule;

Le fonctionnement de la bascule D peut-être généralisé avec des signaux de largeur n bits.

dff octet[8];

Dans ce cas, l’octet qui arrive sur D sera recopié sur la sortie Q de largeur 8 bits à chaque front d’horloge.

Comme toute instance, on accède aux entrées-sorties avec la syntaxe : ma_bascule.d, ma_bascule.q, et ma_bascule.rst.

On peut préciser l’état ou la valeur initiale de la sortie Q à la mise en alimentation de la carte ou après un reset avec un paramètre. Par exemple :

dff compteur[8] (#INIT(100)); // compteur 8 bits initialisé à 100

Tout est prêt pour comprendre le fonctionnement du blinker.

VII-A-2 - Module blinker

On donne le code du blinker (dans un nouveau fichier blinker.luc rajouté au projet) :

blinker.luc

1. module blinker #(DELAY = 50000000 : DELAY > 0) (

2. input clk,  // clock

3. input rst,  // reset

4. output out  // blink signal

5. )

7. {

8. .clk(clk) {

9. .rst(rst) {

10. dff counter[$clog2(DELAY)];

11. dff blink;

12. }

13. }

15. always {

16. counter.d = counter.q + 1;

17. if (counter.q == DELAY - 1) {

18. counter.d = 0;

19. blink.d = ~blink.q;

20. }

21. out = blink.q;

22. }

23. }

Lignes 10 et 11 : instanciation de deux bascules D raccordées aux signaux d’horloge clk et de reset rst : counter et blink.

La première bascule est un simple compteur qui s’incrémentera à chaque front montant de l’horloge. La largeur du compteur en nombre de bits dépend de la valeur de DELAY, et est calculée avec la fonction $clog2 (logarithme en base 2).

La deuxième bascule blink produit le signal carré sur sa sortie.

Dans le bloc always, on met en œuvre l’incrémentation du compteur : counter.d = counter.q + 1;

Compteur qui s'incrémente à chaque front montant de l'horloge.

Juste avant le débordement du compteur, on remet le compteur à zéro et on bascule l’état du signal en sortie : blink.d = ~blink.q;.

Voici le schéma logique du projet obtenu sous Vivado :

Et maintenant, on admire le résultat après transfert du fichier binaire…

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/alchitry_blink.mp4

La LED du bas clignote à la fréquence de 1 Hz. Celle du haut clignote deux fois plus vite.

VII-B - Jouer avec la luminosité des LED avec un signal PWM

Pour ce deuxième exemple, vous allez avoir besoin d’un générateur de signaux PWM. Vous pourriez le développer vous-même, mais autant vous affranchir de cette tâche en allant piocher un composant dédié dans la bibliothèque de composants proposés depuis l’EDI. Pour cela, il faut aller dans le menu Project→ Add components et cocher Pulse Width Modulator (avec sa dépendance Counter) dans le sélecteur de composants :

Les deux nouveaux fichiers pwm.luc et counter.luc correspondants apparaissent dans les sources à la rubrique Components :

L’analyse de ces fichiers, notamment les commentaires au début et la déclaration des entrées-sorties du module nous renseignent sur leur fonctionnement.

Ainsi, avec l’instanciation suivante dans le module au_top.luc, vous déclarez un générateur PWM nommé myPwm de résolution 8 bits raccordé à l’horloge principale et au signal reset :

clk(clk) {
// The reset conditioner is used to synchronize the reset signal to the FPGA
// clock. This ensures the entire FPGA comes out of reset at the same time.
reset_conditioner reset_cond;

.rst(rst) {
pwm myPwm(#WIDTH(8));
// ...
}

Le but est de générer des vagues avec le jeu de LED qui s’allume progressivement, puis s’éteint progressivement et ainsi de suite, comme le montre la vidéo suivante :

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/alchitry_pwm.mp4

Pour générer ces vagues successives, on va aussi avoir besoin d’un compteur nommé ici counterUp.

counter counterUp(#SIZE(9), #DIV(18), #UP(1));

Le compteur instancié est un compteur 9 bits et la vitesse de comptage dépend de l’horloge 100 MHz associée à un diviseur de fréquence par 2^18. Ainsi, ce compteur va mettre (512 x 2^18) / 100 000 000 = 1,34 s pour évoluer de 0 à 511. Le code devient :

clk(clk) {
// The reset conditioner is used to synchronize the reset signal to the FPGA
// clock. This ensures the entire FPGA comes out of reset at the same time.
reset_conditioner reset_cond;

.rst(rst) {
pwm myPwm(#WIDTH(8));
counter counterUp(#SIZE(9), #DIV(18), #UP(1));
}

Tel quel, le compteur évolue entre 0 et 511 en 1,34 s, mais avec un retour à zéro sur débordement, ce qui donne un signal en dents de scie comme sur la figure ci-dessous :

Si vous voulez générer une « vague » avec des transitions « douces », il faut se rapprocher du signal triangulaire suivant :

Il se trouve que le passage du signal en dents de scie au signal triangulaire est assez aisé à obtenir grâce à une astuce décrite dans un tutoriel sur le site de Sparkfun : First FPGA Project - Getting Fancy with PWM.

Comme le compteur a une résolution de 9 bits, son bit de poids fort est à 1 quand le compteur dépasse la valeur 255. Il faudrait qu’à partir de là le compteur se mette à compter à rebours : 255, 254, 253, etc. Ce compte à rebours peut être obtenu par simple inversion de bits. Si on devait par exemple compter de 0 à 7, cela donnerait en binaire : 000, 001, 010, 011, … , 111. Inversez les bits un par un et vous obtiendrez :111, 110, 101, …, 000, soit le compte à rebours voulu : 7, 6, 5, …, 0.

On va donc rajouter cette ligne dans le bloc always :

myPwm.value = counterUp.value[7-:8] ^ 8x{counterUp.value[8]};

- Le signe ^ est celui du OU exclusif (XOR). En effet, a xor 1 inverse le bit a.

- counterUp.value[7-:8] met de côté le bit 8 de poids fort. On récupère les 8 bits restants à partir du bit 7.

- 8x{counterUp.value[8]} forme un nombre 8 bits en concaténant huit fois le bit de poids fort du compteur, soit 1111 1111 pour toute valeur du compteur à partir de 256, et 0000 0000 sinon.

Faites le calcul à la main avec quelques valeurs pour vérifier que vous avez compris le principe, c’est imparable.

Il reste à diriger la sortie du signal PWM vers le jeu de 8 LED et on obtient le code final :

au_top.luc

module au_top (
input clk,              // 100MHz clock
input rst_n,            // reset button (active low)
output led [8],         // 8 user controllable LEDs
input usb_rx,           // USB->Serial input
output usb_tx           // USB->Serial output
) {

sig rst;                  // reset signal

.clk(clk) {
// The reset conditioner is used to synchronize the reset signal to the FPGA
// clock. This ensures the entire FPGA comes out of reset at the same time.
reset_conditioner reset_cond;

.rst(rst) {
pwm myPwm(#WIDTH(8));
counter counterUp(#SIZE(9), #DIV(18), #UP(1));
}
}

always {
reset_cond.in = ~rst_n; // input raw inverted reset signal
rst = reset_cond.out;   // conditioned reset

myPwm.value = counterUp.value[7-:8] ^ 8x{counterUp.value[8]};
myPwm.update = 1;  // always update

led = c{8x{myPwm.pulse}};  // pulse leds

usb_tx = usb_rx;        // echo the serial data
}
}

Schéma logique Vivado

Et si vous voulez une validation plus rigoureuse du fonctionnement, vous pouvez vous tourner à nouveau vers la suite Vivado de Xilinx et faire une simulation comportementale. La copie d’écran ci-dessous du résultat de cette simulation montre les signaux du compteur 9 bits (tout en bas, en orange) et de la valeur 8 bits du PWM (juste au-dessus, en rouge) aux alentours de t = 0,67 s. Avant de lancer la simulation, il faut « forcer » le signal d’horloge à 100 MHz et le signal reset rst à 0 :

Simulation comportementale aux alentours de t = 0,67 s

On voit qu’au-delà de 255 pour le compteur, la valeur du PWM commence à décroître. À t = 0,67 s, les LED sont à la luminosité maximale.

Aux alentours de t = 1,34 s :

Simulation comportementale aux alentours de t = 1,34 s

Lorsque le compteur 9 bits déborde, la valeur du PWM revient à 0. À ce moment-là, les LED sont éteintes, la première « vague » est passée comme prévu à t = 1,34 s. La simulation montre bien une évolution conforme au signal triangulaire souhaité.

VII-C - Piloter une LED avec un bouton-poussoir

Dans cet exemple, l’unique bouton en surface de la carte est détourné de sa fonction d’origine (reset) pour piloter une LED. Ici, chaque appui sur le bouton doit basculer l’état de la LED. Un premier appui pour allumer la LED, un deuxième appui pour l’éteindre, etc.

Pour cela, il faut détecter les fronts montants du signal. Après quelques tests, on se rend compte aussi qu’un système anti-rebonds sur le bouton (debouncer) est nécessaire pour un bon fonctionnement.

Il y a plusieurs moyens d’atteindre l’objectif, mais ici on fera appel aux composants de la bibliothèque, soit : Button Conditionner, et Edge Detector. Là encore, voir les fichiers source de ces modules pour découvrir les entrées-sorties et les paramètres optionnels.

Le premier composant élimine les rebonds du bouton :

{
.clk(clk) {
// ...

.in(btn) {
button_conditioner myButtonCond;
}

// ...
}

Et la sortie du conditionneur est raccordée à l’entrée du détecteur de front montant :

{
.clk(clk) {
// ...

.in(btn) {
button_conditioner myButtonCond;
}

.in(myButtonCond.out) {
edge_detector myEdgeDetector(#RISE(1), #FALL(0));
}
}

Voici le code du module toggle_button.luc.

toggle_button.luc

module toggle_button (
input clk,  // clock
input btn,  // button
output out
) {
.clk(clk) {
dff toggle;

.in(btn) {
button_conditioner myButtonCond;
}

.in(myButtonCond.out) {
edge_detector myEdgeDetector(#RISE(1), #FALL(0));
}
}

always {
out = toggle.q;
if (myEdgeDetector.out) {
toggle.d = ~toggle.q;
}
}
}

Dans le bloc always, on organise la sortie out de façon à basculer uniquement sur front montant du signal d’entrée (le bouton).

Et voici le code du module principal au_top.luc, qui instancie un toggle_button. La sortie est dirigée vers les LED :

au_top.luc

module au_top (
input clk,              // 100MHz clock
input rst_n,            // reset button (active low)
output led [8],         // 8 user controllable LEDs
input usb_rx,           // USB->Serial input
output usb_tx           // USB->Serial output
) {
.clk(clk) {
.btn(~rst_n) {
toggle_button myToggleButton;
}
}

always {
led = c{8x{myToggleButton.out}};

usb_tx = usb_rx;        // echo the serial data
}
}

Schéma logique Vivado

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/alchitry_toggle_button.mp4

VII-D - Envoyer un message sur la liaison série

Dans ce dernier exemple, on propose de configurer la carte de sorte qu’elle envoie en continu un simple Hello World! toutes les secondes sur la liaison série. L’animation qui suit est le résultat obtenu dans le terminal série intégré à l’EDI Alchitry Labs (menu Tools→ Serial Port Monitor) :

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/serial_port_monitor.mp4

Pour la communication série, l’EDI intègre déjà dans ses composants la plupart des protocoles courants : UART, SPI, I^2C et SCCB :

Pour transmettre un message sur la liaison série, on cochera donc le composant UART TX.

Dès lors, la configuration s’avère plus complexe que les exemples précédents, et une description par schéma-blocs devient indispensable :

Schéma-blocs UART Tx

Module uart_tx : ce module représenté par le bloc en haut à droite est le composant rajouté au projet depuis le sélecteur de l’EDI. Les signaux du message série de la sortie tx sont dirigés vers le port USB selon le protocole série UART. Quand une transmission est en cours, le signal busy est à 1 pour indiquer que la liaison est occupée. Comme leur nom l’indique, l’entrée new_data sera mise à 1 pour signaler l’arrivée d’une nouvelle donnée, l’octet de donnée à transmettre arrivant sur l’entrée data de largeur 8 bits.

Module timer : ce module du bloc en bas à gauche envoie une impulsion toutes les secondes sur son unique sortie, afin de cadencer l’envoi du message Hello World!.

Module controller : ce module est chargé de préparer le message textuel et d’envoyer les codes ASCII des caractères un par un au module suivant uart_tx. Une description plus précise des blocs contenus dans ce module est proposée ci-dessous.

Schéma-blocs UART Tx Controller

On donne le code du sous-module message.luc :

message.luc

module message (
input index[4],
output letter[8]
) {

const TEXT = $reverse("Hello World!\n\r");

always {
letter = TEXT[index];
}
}

Le texte du message sous la forme d’une chaîne de caractères "Hello World!\n\r" est stocké en ROM. En sortie, vous aurez le code ASCII letter à l’indice de position index de la chaîne.

Par exemple si index=0, la sortie sera le code ASCII du premier caractère « H », etc. À noter que la chaîne doit être retournée avant d’être stockée (fonction $reverse) pour sortir le message dans le bon ordre.

En amont, on a donc besoin d’un compteur counter dont la fonction est de faire varier l’indice de position dans la chaîne de caractères entre 0 et 13, 13 étant la position du dernier caractère \r de la chaîne.

L’envoi des codes ASCII par ce controller est géré par une machine à états finis grâce à l’instanciation d’un nouveau type fsm. Le type fsm est semblable au type dff pour les bascules D avec son entrée d et sa sortie q, à l’exception que ces dernières sont des états.

On donne l’extrait du code correspondant au fonctionnement de cette machine à états :

.clk(clk) {
.rst(rst) {
fsm state = {IDLE, TX};
// ...
}
}
// …

always {
myMessage.index = counter.q;
ctl_tx_data = myMessage.letter;
ctl_tx_new = 0; // default to 0

case (state.q) {
state.IDLE:
counter.d = 0;
if (ctl_start) {
state.d = state.TX;
}

state.TX:
if (!ctl_tx_busy) {
counter.d = counter.q + 1;
ctl_tx_new = 1;
if (counter.q == NUM_LETTERS - 1)
state.d = state.IDLE;
}
}
}
}

L’instance state peut prendre deux états : un état repos IDLE, et un état TX pour indiquer une transmission d’un code ASCII en cours. Les actions et les transitions sont gérées dans le bloc case comme en C/C++. À l’état repos, l’indice de position est remis à 0 et on attend le signal start pour démarrer la transmission. À l’état actif TX, on envoie les codes ASCII un par un en incrémentant l’indice de position jusqu’au dernier caractère de la chaîne, et on retourne à l’état repos IDLE.

Ci-après le code complet des différents modules, n’oubliez pas de rajouter le composant de bibliothèque UART TX avec le sélecteur :

au_top.luc

module au_top (
input clk,              // 100MHz clock
input rst_n,            // reset button (active low)
output led [8],         // 8 user controllable LEDs
input usb_rx,           // USB->Serial input
output usb_tx           // USB->Serial output
) {

sig rst;                  // reset signal
sig rx;

.clk(clk) {
// The reset conditioner is used to synchronize the reset signal to the FPGA
// clock. This ensures the entire FPGA comes out of reset at the same time.
reset_conditioner reset_cond;

.rst(rst) {
uart_tx tx (#BAUD(1000000), #CLK_FREQ(100000000));
controller myController;
timer myTimer(#PERIOD(100000000));
}
}

always {
reset_cond.in = ~rst_n; // input raw inverted reset signal
rst = reset_cond.out;   // conditioned reset

usb_tx = tx.tx;         // connect tx output
rx = usb_rx;            // rx not used

tx.new_data = myController.ctl_tx_new;
tx.data = myController.ctl_tx_data;
myController.ctl_tx_busy = tx.busy;

myController.ctl_start = myTimer.out;

tx.block = 0;           // no flow control, do not block

led = 8h00;             // turn LEDs off

}
}

controller.luc

module controller (
input clk,  // clock
input rst,  // reset
input ctl_tx_busy,  // busy tx
input ctl_start,
output ctl_tx_new,
output ctl_tx_data[8]

) {
const NUM_LETTERS = 14;

.clk(clk) {
.rst(rst) {
fsm state = {IDLE, TX};
dff counter[$clog2(NUM_LETTERS)];
}
}

message myMessage;

always {
myMessage.index = counter.q;
ctl_tx_data = myMessage.letter;
ctl_tx_new = 0; // default to 0

case (state.q) {
state.IDLE:
counter.d = 0;
if (ctl_start)
state.d = state.TX;

state.TX:
if (!ctl_tx_busy) {
counter.d = counter.q + 1;
ctl_tx_new = 1;
if (counter.q == NUM_LETTERS - 1)
state.d = state.IDLE;
}
}
}
}

message.luc

module message (
input index[4],
output letter[8]
) {

const TEXT = $reverse("Hello World!\n\r");

always {
letter = TEXT[index];
}
}

timer.luc

module timer #(PERIOD = 100000000 : PERIOD > 0) (
input clk,  // clock
input rst,  // reset
output out
) {

dff count[$clog2(PERIOD)] (.clk(clk), .rst(rst) );

always {
count.d = count.q + 1;
out = 0;
if (count.q == PERIOD - 1) {
out = 1;
count.d = 0;
}
}
}

Schéma logique Vivado

VIII - Conclusion

Il y a un monde entre les microcontrôleurs et les FPGA. Les premiers ont une structure comparable à celle d’un ordinateur intégré dans une puce unique avec des périphériques et de la mémoire. On les programme en C ou C++ (en général) pour effectuer des tâches simples en suivant le jeu d’instructions fourni pour accéder aux ressources intégrées dans la puce. En face, les FPGA sont par nature très basiques, des cellules logiques par milliers, voire par millions, ce qui les rend très flexibles pour répondre à n’importe quelle fonctionnalité logique, du moment que le nombre de cellules est suffisant. Rien ne vous empêche de configurer votre FPGA avec 20 générateurs de signaux PWM ou 5 ports série UART si vous en avez besoin, alors que le nombre de ces périphériques est figé et limité dans un microcontrôleur classique. Cette flexibilité peut vous permettre en phase de conception de valider le fonctionnement de vos circuits numériques.

Une conséquence de la nature des FPGA est que les tâches configurées dans les circuits peuvent être hautement parallélisées. Les deux blinkers du premier exemple vu plus haut agissent « en même temps », et on aurait pu implémenter le PWM du deuxième exemple et la réaction sur appui du bouton-poussoir du troisième exemple, le tout fonctionnant en parallèle. Sur microcontrôleur, vous auriez dû jouer avec les interruptions, programmer des machines à états finis ou passer par un OS temps réel, car les traitements du processeur sont séquentiels par nature. Beaucoup d’algorithmes de traitement d’images par exemple sont naturellement parallèles et méritent une mise en œuvre sur FPGA.

Pour autant, s’il faut peser le pour et le contre au moment du choix entre un microcontrôleur et un FPGA, il faudra aussi tenir compte du coût et de la consommation d’énergie tous deux plus élevés sur FPGA. Et en phase de développement, le temps nécessaire pour la synthèse du projet sur FPGA peut en rebuter plus d’un (mais on peut faire de la simulation comportementale avant de commencer la synthèse).

Du point de vue du programmeur, des efforts ont été faits pour que les programmeurs C/C++ retrouvent une syntaxe familière en Lucid. Il faut toutefois prendre conscience qu’une ligne du type :

c = a ^ b;      // c = a XOR b

qui s’écrit de la même façon en C ou en Lucid, produira des effets différents :

- en C, vous programmez une affectation de la variable c résultant d’un XOR entre deux autres variables a et b en mémoire (par exemple pour un microcontrôleur 8 bits AVR, avec l’instruction EOR, Exclusive OR) ;

- en Lucid, vous allez synthétiser une porte logique XOR dont les entrées sont des signaux a et b, et dont la sortie sera dirigée vers un port c.

De même, si vous imaginez facilement le fonctionnement d’une boucle for en langage C/C++ pour effectuer un traitement répétitif, en langage Lucid vous pouvez très bien écrire :

for (i = 1; i < DEPTH; i++)
pipe.d[i] = pipe.q[i-1];

Dans ce cas, la boucle permet de synthétiser des connexions en les dupliquant.

Personnellement, j’ai trouvé ce langage qu’est Lucid très efficace pour réaliser les prototypes de mes démonstrations. Il n’est pas évident pour un développeur logiciel de raisonner « hardware » sans un minimum de culture en électronique numérique. J’espère malgré tout que ce tutoriel vous aidera à mettre un pied à l’étrier, c’est le but de la plateforme Alchitry.

Objectif suivant… s’initier au langage Verilog, toujours sur la plateforme Alchitry !

Je remercie Vincent PETIT pour sa relecture technique et Claude Leloup pour ses corrections orthotypographiques.

IX - Bibliographie et sitographie

- Le site officiel Alchitry.

- Alchitry chez Sparkfun.

- Learning FPGAs: Digital Design for Beginners with Mojo and Lucid HDL, par Justin Rajewski.