FPGA : tutoriel pour s’initier au langage Verilog

Programmation d’un pilote pour le capteur de température Si7021

f-leb

La carte de développement utilisée dans ce tutoriel est une carte FPGA Alchitry Au que je vous avais déjà présentée dans un tutoriel précédent. Un module avec un capteur de température et d’humidité Si7021 (Silicon Labs) est connecté à la carte.

Après avoir fait vos premiers pas avec Verilog, vous allez apprendre à programmer un pilote qui va gérer le dialogue I^2C avec le module pour acquérir la température ambiante à intervalles réguliers. Dans cette démonstration, la valeur de la température sera retournée via le câble USB pour affichage dans un terminal série.

Commentez

- Titre : FPGA : tutoriel pour s’initier au langage Verilog

- Auteur : f-leb

- Parution : 20 septembre 2021

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2021 f-leb. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Voir également :

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

- Description d'un afficheur 7-segments en langage Verilog

- Débuter avec les FPGA dans l’environnement Intel Quartus Prime

Sommaire

- I. Introduction : microcontrôleur ou FPGA ?

- II. La carte Alchitry Au

- III. L’EDI Alchitry Labs

- IV. Premiers pas en Verilog

- IV-A. Un premier blink

- IV-B. Des processus concurrentiels

- V. Programmation d’un pilote en Verilog pour le capteur Si7021

- V-A. Ajout des ports supplémentaires SCL et SDA

- V-B. Composants supplémentaires

- V-C. Dialogue I2C

- V-D. Description à la façon d’une machine à états finis

- V-E. Programmation de la machine à états finis en Verilog

- V-F. États temporisés

- V-G. Fonctionnement du système

- V-H. Sources du projet complet

- VI. Conclusion

I - Introduction : microcontrôleur ou FPGA ?

Autant le dire tout de suite… Programmer un microcontrôleur pour communiquer avec un capteur de température en liaison I^2C est une activité assez simple avec une bibliothèque adaptée pour ce protocole. Avec une carte Arduino et la bibliothèque du capteur à importer, quelques lignes de programme dans une boucle suffisent. Alors, pourquoi proposer un tutoriel avec un matériel plus coûteux, qui va généralement vider plus rapidement la batterie de votre système embarqué, programmé dans un langage HDL que vous mettrez plus de temps à assimiler qu’un langage procédural comme C ou C++, et qui vous vaudra des heures supplémentaires en débogage et mise au point ?

Bien entendu, les FPGA ont leurs avantages quand la performance et la flexibilité d’une architecture reconfigurable sont requises. Un microcontrôleur ne peut exécuter des instructions que l’une après l’autre, alors que le fonctionnement des FPGA est fondamentalement parallèle. Mais avant d’en profiter pleinement, il faudra commencer par découvrir les bases d’un langage de description de matériel tel que Verilog. Et c’est précisément l’objectif de ce tutoriel qui utilise une plateforme pour débutants…

II - La carte Alchitry Au

On rappelle les caractéristiques principales de la carte Alchitry Au :

FPGA Alchitry Au - Image Sparkfun Licence C.C

Caractéristiques principales

- FPGA

- Xilinx Artix 7 XC7A35T-1C (33 280 cellules logiques)

- Mémoire

- 256 Mo DDR3 via interface 16 bits (~ 10 Gbit/s)

- Entrées-sorties

- 102 broches à 3,3 V, dont 20 peuvent être configurées pour de la transmission différentielle basse-tension 1,8 V

- 9 entrées différentielles analogiques (1 dédiée et 8 partagées avec les entrées-sorties numériques)

- Périphériques en surface de la carte

- 1 jeu de 8 LED vertes

- 1 bouton-poussoir à appui momentané « Reset »

- 1 LED de statut

- 1 horloge 100 MHz

- 1 interface série port USB type C via FT2232H (12 MBauds maxi.)

- 1 connecteur Qwiic (I^2C) SparkFun

- Programmation

- JTAG via port USB, interface FT2232H

- Alimentation

- 5 V par port USB type C, trou métallisé x 2 0,1”

- Dimensions

- 65 mm x 45 mm

Notez les quatre plots de connexion femelle qui permettent d’enficher (et même empiler) des cartes d’extension à la façon des shields Arduino (ou des HAT de la carte Raspberry Pi). Vous avez un aperçu d’une de ces cartes dans ce billet qui met en œuvre un afficheur 7-segments.

III - L’EDI Alchitry Labs

L’EDI Alchitry Labs vient en renfort du débutant en complément de la suite Vivado de Xilinx qui supporte gratuitement la puce FPGA Xilinx Artix-7 de la carte Alchitry.

L’EDI est plutôt rudimentaire, un éditeur basique et un bouton pour lancer la chaîne des outils de synthèse : analyse, synthèse logique, jusqu’à la génération du fichier bitstream. Prenez en compte les deux autres boutons pour téléverser le fichier du projet en RAM ou en ROM qui va configurer la puce FPGA et vous savez l’essentiel.

L’EDI peut toutefois ne servir que d’intermédiaire étant donné qu’il s’appuie en arrière-plan sur les outils de la suite Vivado. Une fois votre projet généré, tous les fichiers nécessaires sont dans un dossier spécial pour poursuivre le travail dans l’environnement professionnel de la suite (pour faire différents types d’analyses, visualiser des schémas, faire des simulations, etc.).

Le même projet sous la suite Vivado

À la création d’un nouveau projet Verilog dans Alchitry Labs, le code du module principal au_top.v se présente comme suit :

au_top.v

1. module au_top(

2. input clk,              // 100MHz clock

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

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

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

6. output usb_tx           // USB->Serial output

7. );

9. wire rst;

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

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

13. reset_conditioner reset_conditioner(.clk(clk), .in(!rst_n), .out(rst));

15. assign led = 8'h00;      // turn LEDs off

17. assign usb_tx = usb_rx;  // echo the serial data

19. endmodule

Les premières lignes décrivent les entrées-sorties des périphériques de la carte : l’horloge principale à 100 MHz, l’entrée du bouton reset et les sorties vers le jeu de 8 LED vertes en surface de la carte, ainsi que les connexions série Rx et Tx du port USB.

En fin de module, des assignations continues assign :

- pour mettre l’anode des LED à l’état bas afin de les éteindre ;

- pour renvoyer en écho les données reçues en raccordant l’entrée Rx et la sortie Tx de la liaison série USB.

Cette description est résumée dans le schéma structurel ci-dessous :

Schéma structurel

Le bloc reset_conditioner au centre du schéma est instancié avec ses connexions à ligne 13 du code précédent.

reset_conditioner reset_conditioner(.clk(clk), .in(!rst_n), .out(rst));

On voit par exemple dans cette description structurelle que l’entrée in de l’instance est raccordée au signal inversé (avec !, comme en langage C) du bouton-poussoir rst_n (suffixe n pour négatif, car le signal est à l’état bas lorsque le bouton est pressé).

reset_conditioner est donc un module complémentaire, ajouté par défaut au projet, et qui permet de conditionner et synchroniser avec l’horloge, le signal issu de l’appui sur le bouton-poussoir reset en surface de la carte. Le nœud rst en sortie de ce bloc est pour l’instant non connecté, mais il pourra servir de signal de réinitialisation synchrone lorsqu’il sera dirigé vers certaines parties du futur système.

IV - Premiers pas en Verilog

IV-A - Un premier blink

Avant de s’attaquer au capteur de température, on propose de découvrir le langage Verilog sur une simple application de clignotement de LED (une fois n’est pas coutume !)

Le code du module principal au_top.v qui suit fait clignoter une des huit LED du bandeau en surface de la carte en la dirigeant vers un signal carré de fréquence 1 Hz :

au_top.v

1. module au_top(

2. input clk,              // 100MHz clock

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

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

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

6. output usb_tx           // USB->Serial output

7. );

9. wire rst;

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

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

13. reset_conditioner reset_conditioner(.clk(clk), .in(!rst_n), .out(rst));

15. assign usb_tx = usb_rx;  // echo the serial data

17. reg [27:0] counter;

18. wire out;

19. localparam PERIOD = 100_000_000; // 1s period

21. assign out = counter > (PERIOD >> 1); // PERIOD >> 1, division by 2

22. assign led = {7'h0, out};

24. always @(posedge clk or posedge rst) begin

25. if (rst) begin

26. counter <= 0;

27. end else begin

28. if (counter < PERIOD) begin

29. counter <= counter + 1;

30. end else begin

31. counter <= 0;

32. end

33. end

34. end

36. endmodule

- Ligne 17 : avec le type reg, on déclare ce qui se rapproche du concept de « variable » dans les langages procéduraux traditionnels. Ici, un vecteur de largeur 28 bits.

- Ligne 18 : le type wire, lui, n’a pas de capacité de mémorisation et désigne des nœuds représentant des connexions entre éléments physiques.

- Ligne 19 : PERIOD est un paramètre local initialisé à 100 000 000, le nombre de cycles par seconde de l’horloge principale.

- Ligne 21 et 22 : des assignations continues pour donner des valeurs aux nœuds. Dans une assignation continue, tout changement dans l’expression à droite du signe = est répercuté sur le terme assigné à gauche du signe =. Ainsi, le signal Out basculera à 1 quand le compteur aura dépassé la moitié de la valeur maximale (au bout de 0,5 s). Avec l’assignation continue qui suit, sept des huit LED du bandeau en surface de la carte seront éteintes, tandis que la huitième clignotera. Les accolades { } réalisent une concaténation de bits.

- Lignes 24 à 34 : un processus always exécuté continuellement. La « liste de sensibilité » est définie sur front montant de l’horloge principale clk et front montant du signal rst (appui sur le bouton Reset). En d’autres termes, ce processus ne s’exécutera que lors d’un événement sur l’un des signaux de la liste. Le processus de clignotement de la LED est ensuite décrit de façon comportementale dans le bloc begin… end exécuté séquentiellement. L’interprétation de ces lignes ne pose pas de difficultés. On incrémente un compteur qui repasse à zéro soit parce qu’il atteint sa valeur maximale, soit parce que le bouton Reset est appuyé.

La génération complète du projet (build) peut prendre plusieurs minutes…

Dans la suite Vivado, on peut visualiser une première analyse du projet générée sous forme schématique :

Vivado RTL Analysis

On voit sur ce schéma que le compteur counter est modélisé avec des bascules D (ou flip-flop). La sortie Q d’une bascule est remise à zéro si le signal CLR est actif. Sinon, elle reproduit l’état de son entrée D en ne répercutant les changements que sur front montant de l’horloge. Cette partie du circuit à logique séquentielle est donc capable de « mémoriser » un état jusqu’à un moment précis qui est le prochain front montant de l’horloge.

La condition if du code s’est traduite par un multiplexeur (ou sélecteur) repéré RTL_MUX et qui dirige l’une de ses deux entrées vers la sortie en fonction de l’état 0 ou 1 du signal de sélection S.

Une fois le projet synthétisé, ces blocs fonctionnels deviennent essentiellement des LUT et des bascules flip-flop. 90 cellules logiques sont nécessaires pour ce projet :

Vivado Synthesis

Le schéma n’est pas très sympathique… Si on zoome sur l’état des LED à droite du schéma, on voit que le système a décidé de diriger la sortie O d’une LUT à 5 entrées vers la LED qui clignote :

Vivado Synthesis - vue détaillée

L’équation logique de cette LUT est même donnée : O = I0 + I1 & I4 + !I1 & I2 & I3 & I4

Évidemment, il faudra creuser le schéma en amont pour comprendre d’où vient cette équation.

Le schéma (partiel) d’implémentation physique ci-dessous montre en surbrillance la LUT précédente et sa localisation dans le réseau des cellules logiques de la puce FPGA :

Vivado implementation

Verilog est donc un langage de description de matériel. Une fois le code passé aux mains des outils de synthèse, la configuration du circuit qui sera implémenté physiquement dans la puce FPGA répondra aux effets décrits par votre code. Si pour un microcontrôleur le code compilé est traduit en suite d’instructions CPU, le synthétiseur FPGA traduit le code en circuit logique matériel.

IV-B - Des processus concurrentiels

Et si on voulait faire clignoter deux LED, « en même temps » (c’est-à-dire en synchronisant les signaux sur la même horloge), mais à des fréquences différentes ? On pourrait dupliquer le bloc always, mettre un autre paramètre pour une fréquence de clignotement différente et diriger la sortie vers une autre LED du bandeau. Les processus always fonctionnent effectivement de façon concurrente (en parallèle).

Mais plutôt que dupliquer du code, on le mettra dans un sous-module paramétré. Dans les sources du projet, on ajoutera le sous-module blinker.v suivant :

blinker.v

module blinker #(parameter PERIOD = 100_000_000) (
input clk,  // clock
input rst,  // reset
output out
);

localparam WIDTH = 28; // 28 bits, compteur jusqu'à 2^28 - 1 maximum
reg [WIDTH-1 : 0] counter;

assign out = counter > (PERIOD >> 1);

always @(posedge clk or posedge rst) begin
if (rst) begin
counter <= 0;
end else begin
if (counter < PERIOD) begin
counter <= counter + 1;
end else begin
counter <= 0;
end
end
end

endmodule

Dans le module principal, on instancie deux blinkers (nommés blinker_slow et blinker_fast) dont on connecte les entrées-sorties (description structurelle) :

au_top.v

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

wire rst;

// 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_conditioner(.clk(clk), .in(!rst_n), .out(rst));

assign usb_tx = usb_rx;  // echo the serial data

wire slow_blink, fast_blink;

blinker #(.PERIOD(100_000_000)) blinker_slow (.clk(clk), .rst(rst), .out(slow_blink));
blinker #(.PERIOD(50_000_000))  blinker_fast (.clk(clk), .rst(rst), .out(fast_blink));

assign led = {slow_blink, 6'h0, fast_blink};

endmodule

Le schéma-blocs sous Vivado donne la structure suivante :

Par contre, même si on évite la duplication de code, il y aura bien deux circuits quasiment identiques qui seront synthétisés.

Avec la même facilité, vous pourriez faire clignoter en parallèle une troisième, une quatrième LED, voire plus, à des fréquences différentes…

Exercice : faire le même exercice de clignotement de LED en parallèle avec un microcontrôleur ?

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

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

Verilog permet donc d’implanter des traitements dans des circuits logiques matériels, sans CPU, mais programmés de façon algorithmique.

V - Programmation d’un pilote en Verilog pour le capteur Si7021

L’étude qui suit concerne maintenant la description d’un pilote gérant la communication I^2C avec le capteur de température (et d’humidité) Si7021.

Le code source complet de l’étude est donné en fin de chapitre.

On commence par une photo du montage :

Le module Si7021 du fabricant Adafruit comporte des connecteurs au format Qwiic de SparkFun. Un connecteur Qwiic est également présent sur la carte Alchitry Au depuis que la production des cartes de la plateforme Alchitry a été confiée au groupe SparkFun.

Avec le cordon Qwiic approprié (4 broches JST), la connexion I^2C devient aisée. Le deuxième connecteur sur le module Si7021 peut servir à relier un autre composant sur le bus I^2C. Dans ce tutoriel, il aura servi à connecter l’un de ces analyseurs logiques à bas coût pour observer les trames échangées sur le bus. Les copies d’écran des trames qui suivent ont été obtenues par ce moyen.

V-A - Ajout des ports supplémentaires SCL et SDA

D’après la documentation, les broches SCL (Serial Clock) et SDA (Serial Data) du connecteur Qwiic sont connectées respectivement aux ports référencés A24 et A23. Comme ce connecteur Qwiic a été rajouté plus tard par SparkFun, cette contrainte n’est pas renseignée par défaut dans l’EDI.

Afin d’y remédier, commencez par rajouter au projet un fichier dit « des contraintes » au format ACF (Alchitry Constraints File), avec ces deux lignes :

pin scl A24;
pin sda A23;

Dans la définition des entrées-sorties du module principal, vous pouvez maintenant rajouter les deux ports scl et sda. Le système sait maintenant que ces deux ports sont bien dirigés vers les connecteurs Qwiic de la carte :

au_top.v

module au_top (
input clk,              // horloge 100 MHz
input rst_n,            // bouton Reset, actif à l'état bas
output [7:0] led,       // jeu de LED x 8, non utilisé
input  usb_rx,          // liaison série USB : Rx, non utilisé
output usb_tx,          // liaison série USB : Tx
output scl,             // Serial Clock I2C, générée par le maître
inout sda               // Serial Data I2C, /!\ SDA, port bidirectionnel
);

// etc.

La broche SDA est bidirectionnelle (inout), les données peuvent circuler du maître vers l’esclave et inversement.

V-B - Composants supplémentaires

Vous avez déjà rencontré précédemment le module reset_conditioner, mais deux autres modules sont nécessaires encore :

- i2c_controller : contrôleur I^2C pour communiquer avec le capteur ;

- uart_tx : contrôleur UART pour transmettre des données série via le câble USB.

Sélection de composants supplémentaires

Le code de ces modules est aussi sur github/alchitry/Alchitry-Labs/library/components/. Ces codes sont écrits en langage Lucid, mais transpilés en Verilog à la génération du projet dans l’EDI Alchitry Labs. La suite Vivado propose également ses bibliothèques certifiées de composants réutilisables (les Intellectual property (IP) cores). Les composants réécrits pour Alchitry Labs en sont souvent des versions simplifiées, plus facilement accessibles aux débutants.

V-C - Dialogue I2C

La séquence pour obtenir la température est décrite dans la datasheet du capteur Si7021 :

Les parties de la séquence avec un fond blanc sont générées par le composant maître (master), c’est-à-dire la carte FPGA toujours à l’initiative de la communication. Les parties avec un fond gris sont générées par le composant esclave (slave), c’est-à-dire le capteur de température Si7021 qui répond aux sollicitations du maître.

- S : Start.

- P : Stop.

- Sr : Start Repeated, un Start qui n’est pas précédé d’un Stop.

- A, NA : acquittement ou non-acquittement.

- Slave Adress : adresse du composant sur 7 bits, ici 0x40.

- Slave Adress + W : demande d’accès au composant en écriture.

- Slave Adress + R : demande d’accès au composant en lecture.

- Measure Cmd : le code de la commande pour obtenir la température, ici 0xF3.

Vous trouverez des explications plus détaillées dans le tutoriel de Michel Semal.

Par exemple, le début de la séquence avec le code 0xF3 pour obtenir la température donnera la trame suivante :

Le point vert marque le bit de Start généré par le maître, c’est-à-dire un front descendant du signal SDA lorsque l’horloge SCL est à l’état haut. Une demande d’accès en écriture est ensuite réclamée au capteur Si7021 à l’adresse 0x40. Le composant esclave doit une première fois acquitter, informant ainsi le composant maître qu’il a bien reçu la demande et qu’il est disposé à recevoir des données. Pour acquitter, une période d’horloge supplémentaire est générée par le composant maître, et le composant esclave doit abaisser l’état de la ligne SDA.

Le code 0xF3 (Measure Temperature) est alors transmis au capteur. Lorsque l’octet est transmis, le module acquitte (ACK) à nouveau pour accuser réception.

Vous trouverez les spécifications du bus I^2C en recherchant le document I^2C-bus specification and user manual (Rev. 6 — 4 April 2014) sur le site du fabricant de semi-conducteurs NXP qui maintient la norme.

Le contrôleur I^2C proposé dans la bibliothèque de composants de l’EDI Alchitry Labs qui va générer les trames se présente comme suit :

Contrôleur I2C : à gauche les entrées, à droite les sorties (SDA est un port bidirectionnel)

Fonctionnement du contrôleur I^2C

Chaque transaction I^2C commence par un Start que l’on déclenche en mettant à l’état haut l’entrée start pendant une période de l’horloge clk. Vous pouvez alors écrire ou lire sur le bus autant de fois que nécessaire. Pour terminer la transaction, vous devez mettre l’entrée stop à l’état haut pendant une période de l’horloge clk.

Chaque fois que vous voulez effectuer une opération sur le bus (start, stop, read, write), vous devez vous assurer au préalable en regardant l’état du signal busy que la ligne n’est pas déjà occupée avec une autre opération. Si vous ne tenez pas compte de cela, l’opération demandée risque d’être ignorée.

Classiquement, une transaction démarre par une opération d’écriture d’un octet comprenant l’adresse du composant esclave sur 7 bits, suivie d’un bit pour indiquer si l’opération est une lecture ou une écriture sur le bus. Voir la documentation du composant esclave pour comprendre la séquence des échanges. Les échanges en écriture ou lecture se poursuivent.

Quand le composant maître opère en lecture sur la ligne, vous devez préciser si celui-ci doit acquitter ou non à chaque octet reçu (ack_read à l’état haut si oui, à l’état bas sinon).

Quand le composant maître opère en écriture sur la ligne, l’état du bit ack_write vous indiquera si le composant esclave a bien acquitté ou non. (Attention : ack_write à l’état bas si acquittement, la documentation annonce l’état inverse ?!).

Code d’instanciation du contrôleur en Verilog :

au_top.v (extrait)

/* ---- Instantiacion & connexion du contrôleur I2C ----------------------------------*/
reg i2c_start;            // signal Start (S) ou Start Repeated (Sr)
reg i2c_stop;             // signal Stop  (P)
wire i2c_busy;            // =1 si le bus I2C est occupé
wire i2c_out_valid;       // =1 quand la donnée renvoyée (1 octet) en sortie est valide et disponible
reg [7:0] i2c_data_in;    // octet à transmettre (maître vers esclave)
wire [7:0] i2c_data_out;  // octet renvoyé (esclave vers maître)
reg i2c_write;            // =1, demande d'écriture par le maître sur le bus
reg i2c_read;             // =1, demande de lecture par le maître sur le bus
reg i2c_ack_read;         // =1 si le maître doit acquitter
wire i2c_ack_write_n, i2c_ack_write;  // sortie ack_write du contrôleur=0 si l'esclave acquitte, /!\ erreur dans la doc.

assign i2c_ack_write = !i2c_ack_write_n;

i2c_controller #(.CLK_DIV(9)) Si7021_controller ( // fréquence clk = 100_000_000 / 2^9 = 195,3 kHz
.clk(clk),
.rst(rst),
.scl(scl),
.sda(sda),
.start(i2c_start),
.stop(i2c_stop),
.busy(i2c_busy),
.data_in(i2c_data_in),
.data_out(i2c_data_out),
.write(i2c_write),
.read(i2c_read),
.ack_write(i2c_ack_write_n),
.ack_read(i2c_ack_read),
.out_valid(i2c_out_valid)
);

localparam [7:0] I2C_ADR_Si7021 = 8'h40, // adresse du composant I2C Si7021 = 0x40
I2C_ADR_Si7021_Read  = (I2C_ADR_Si7021 << 1) + 1,
I2C_ADR_Si7021_Write = (I2C_ADR_Si7021 << 1);
/* ------------------------------------------------------------------------------- */

V-D - Description à la façon d’une machine à états finis

Si on reprend l’extrait de la séquence précédente, on peut en faire une description avec le diagramme d’états-transitions suivant :

Les rectangles définissent les états de la machine. La transition entre deux états s’opère si la condition portant sur les entrées, à gauche du /, est vérifiée. À droite du signe /, on trouve l’état des sorties sur la transition. Par exemple, en supposant que la machine rentre à l’état state_I2C_START : après une temporisation de 1 s, on met la sortie start à l’état haut, et la machine passe à l’état suivant state_I2C_ADRW. Lorsque la condition est à true, la transition s’opère toujours, quel que soit l’état des entrées. Ici, on ne vérifie pas si le module a bien acquitté ou non.

Pour obtenir la valeur de température, il faut ensuite faire une demande en lecture (après un Start Repeated). Mais la conversion analogique-numérique prend un peu de temps (7 ms d’après la documentation), et le module refuse d’acquitter (NA) tant qu’elle n’est pas terminée.

Pour ce tutoriel, on choisit de renouveler la demande de lecture toutes les 2 ms, jusqu’à ce que le module finisse par acquitter (typiquement au bout de quatre tentatives, soit 8 ms). Ce qui donne la description suivante :

On voit dans cette description que les sorties de la machine dépendent de l’état en cours, mais aussi des entrées. Cette description correspond à celle d’une machine de Mealy.

Quand le module acquitte (i2c_ack_write), le composant maître passe en lecture pour récupérer les données (les trois octets MSB, LSB et CHKSUM), puis marque la fin de la transaction :

Le document suivant récapitule le diagramme états-transitions complet de la machine de Mealy utilisé dans ce tutoriel, accompagné des trames I^2C obtenues à l’analyseur logique :

Machine à états finis - dialogue Si7021 : format svg - format png

V-E - Programmation de la machine à états finis en Verilog

Le code Verilog ci-dessous est un gabarit pour gérer une machine de Mealy :

1. module au_top (

2. input clk,              // horloge 100 MHz

3. input rst_n,            // bouton Reset, actif à l'état bas

4. output [7:0] led,       // jeu de LED x 8, non utilisé

5. input  usb_rx,          // liaison série USB : Rx, non utilisé

6. output usb_tx,          // liaison série USB : Tx

7. output scl,             // Serial Clock I2C, générée par le maître

8. inout sda               // Serial Data I2C, /!\ SDA, port bidirectionnel

9. );

11. // etc.

13. /* ------ Gestion de la machine à états finis ------------------------------------ */

14. reg [3:0] I2C_State, I2C_State_Next;

16. localparam state_I2C_RESET_START   = 4'd0,

17. state_I2C_RESET_ADRW    = 4'd1,

18. state_I2C_RESET_CMD     = 4'd2,

19. state_I2C_RESET_STOP    = 4'd3,

20. state_I2C_START         = 4'd4,

21. state_I2C_ADRW          = 4'd5,

22. state_I2C_START_RE      = 4'd6,

23. state_I2C_ADRR          = 4'd7,

24. state_I2C_MEASURE_CMD   = 4'd8,

25. state_I2C_READ_MSB      = 4'd9,

26. state_I2C_READ_LSB      = 4'd10,

27. state_I2C_READ_CHKSUM   = 4'd11,

28. state_I2C_STOP          = 4'd12;

30. always @(posedge clk or posedge rst) begin

31. if (rst) begin

32. I2C_State <= state_I2C_RESET_START;

33. end else begin

34. if (!i2c_busy) begin            // si pas d'action en cours sur le bus I2C,

35. I2C_State <= I2C_State_Next;  // passage à l'état suivant, synchronisé avec l'horloge

36. end

37. end

38. end

40. always @(I2C_state, ...) begin

41. I2C_State_Next = I2C_State; // par défaut, conserver l’état

43. // valeurs par défaut

44. i2c_stop = 0;

45. i2c_write = 0;

46. i2c_read = 0;

47. i2c_start = 0;

48. i2c_data_in = 0;

49. i2c_ack_read = 0;

50. temperature_enable = 0;

52. case (I2C_State)

54. state_I2C_RESET_START : begin

55. if (<condition>) begin

56. // ...

57. I2C_State_Next = state_I2C_RESET_ADRW;

58. // ...

59. end // else begin ...

60. end

62. state_I2C_RESET_ADRW : begin

63. if (<condition>) begin

64. // ...

65. I2C_State_Next = state_I2C_RESET_CMD;

66. // …

67. end // else begin ...

68. end

70. // etc. pour les autres états

72. state_I2C_STOP : begin

73. if (<condition>) begin

74. // ...

75. I2C_State_Next = state_I2C_START;

76. // …

77. end // else begin ...

78. end

80. endcase

82. end

84. endmodule

Deux processus always s’exécutent en parallèle (Verilog est un langage « concurrent » contrairement à la plupart des langages comme C, C++, Java, etc. qui sont des langages séquentiels par nature).

- Lignes 30 à 38 : le premier processus always est défini avec la « liste de sensibilité » posedge clk or posedge rst. Ce processus est donc évalué continuellement à chaque front montant de l’horloge principale ou chaque front montant du signal rst (détection d’appui sur le bouton-poussoir Reset). Ce processus séquentiel met à jour l’état présent (I2C_State) par l’état futur (I2C_State_Next) sur front montant de l’horloge principale 100 MHz, et à condition que le bus I^2C soit libre (i2c_busy).

- Lignes 40 à 82 : ce deuxième processus combinatoire suit la logique du diagramme états-transitions avec une instruction case. Il calcule l’état des sorties ainsi que l’état futur à partir des entrées et de l’état présent. Par exemple, avec l’extrait du diagramme suivant :

Pour l’état State_I2C_READ_MSB, on voit deux situations selon que le module acquitte ou non. Si le module acquitte, on descend dans le diagramme pour récupérer les données disponibles de température en commençant par l’octet MSB de poids fort. Sinon, on doit remonter à un état antérieur pour demander à nouveau si la température est disponible. Les valeurs des sorties dépendent donc de l’état en cours, mais aussi des valeurs des entrées que l’on teste avec un if. Ce qui donne pour cet état :

case (I2C_State)

// …

state_I2C_READ_MSB : begin
if (!i2c_ack_write) begin // Si NAK, la conversion n'est pas terminée
I2C_State_Next = state_I2C_START_RE;
end else begin  // Sinon si ACK, la conversion est terminée
i2c_read = 1; // Lecture Temperature MSB
i2c_ack_read = 1;
I2C_State_Next = state_I2C_READ_LSB;
end
end

// …

endcase

V-F - États temporisés

Dans le diagramme d’états-transitions, vous remarquerez plusieurs états qui sont temporisés.

Par exemple, à l’état state_I2C_START ci-dessous, il y a une temporisation de 1 s avant de passer à l’état suivant. Cette temporisation permet ainsi de cadencer les acquisitions de température à intervalles réguliers :

Pour programmer cette temporisation en Verilog, on passera par un troisième processus concurrent :

always @(posedge clk) begin
if (I2C_State != I2C_State_Next) begin // remise à zéro du chrono à chaque changement d'état
delay <= 0;
end else begin
delay <= delay + 1;
end
end

Un compteur delay est incrémenté à chaque front montant de l’horloge principale 100 MHz, et remis à zéro à chaque changement d’état. Pour temporiser un état, il suffit de tester la valeur du compteur à l’entrée de l’état :

// ...

state_I2C_START : begin
if (delay > 100_000_000) begin // 1 mesure par seconde
i2c_start = 1;
I2C_State_Next = state_I2C_ADRW;
end
end

// ...

V-G - Fonctionnement du système

Pour vérifier le fonctionnement du système, on visualise les températures dans un terminal série comme RealTerm (115 200 bauds, 8 bits de données, 1 bit de stop, sans parité).

Une acquisition de température est lancée chaque seconde avant de s’afficher sur une ligne du terminal série.

L’analyseur logique à bas coût ci-dessous (Saleae ou compatible, 8 canaux, 24 MHz) permet de visualiser les trames échangées :

On voit dans la trame qui suit qu’une acquisition de température est lancée toutes les secondes :

Une acquisition prend 8 ms environ (relance de lecture des données toutes les 2 ms) :

Au début, le module n’acquitte pas (NAK), car la conversion analogique-numérique de la température n’est pas terminée :

Au bout de 8 ms, le module finit par acquitter, les données image de la température sont alors lues :

Les deux premiers octets (CodeMeasure = 0x6610) permettent de calculer la valeur de température en °C avec la formule donnée dans la documentation du capteur Si7021 :

Soit environ 23,2 °C.

Verilog peut synthétiser des formules complexes :

always @(posedge i2c_out_valid) begin // si 1 octet est retourné par le capteur
raw_data[7:0] <= i2c_data_out;      // on le décale dans le registre raw_data
raw_data[23:8] <= raw_data[15:0];
end

always @(posedge clk) begin
if (temperature_enable) begin
temperature_32bits = ((raw_data[23:8] * 1757) >> 16) - 469; // calcul de T(°C)x10, voir datasheet Si7021
// Au besoin, CheckSum est dans raw_data[7:0]
end
end

Quand un octet est retourné par le capteur, il est décalé dans un registre 24 bits raw_data[23:0]. Quand la transaction est terminée, temperature_enable passe à 1, et on calcule une valeur entière 32 bits donnant la température multipliée par 10. Par exemple, pour une température de 23,21 °C, la valeur calculée sera égale à 232. Ce sera au dispositif client recevant et affichant les données de faire la division par 10, les arrondis nécessaires ou de placer la virgule.

Le troisième octet est une somme de contrôle (CheckSum), non exploitée dans ce tutoriel, mais qui sert normalement à vérifier l’intégrité des données transmises.

V-H - Sources du projet complet

On rappelle l’architecture du projet dans Alchitry Labs :

Dans les sources ci-dessous :

- au_top.v : module principal en Verilog.

- serialTemperatureDisplay.v : module prenant en charge la communication série via le port USB pour affichage de la température (115 200 bauds) dans un terminal. La séquence de préparation et d’envoi des données est aussi gérée à la façon d’une machine à états finis.

Dans les composants (Components), les modules sont écrits en Lucid (langage Verilog simplifié), mais transpilés en Verilog à la génération du projet. :

- reset_conditioner.luc : conditionnement et synchronisation du signal du bouton Reset en surface de la carte.

- i2c_controller.luc : contrôleur pour la communication I^2C avec la capteur.

- uart_tx.luc : contrôleur pour l’envoi de données série UART.

Dans les contraintes : en plus des fichiers constraints par défaut, le fichier io.acf donne la localisation des broches SDA et SCL du connecteur Qwiic.

io.acf

// I2C Serial Clock (scl) & Serial Data (sda)
pin scl A24;
pin sda A23;

au_top.v

module au_top (
input clk,              // horloge 100 MHz
input rst_n,            // bouton Reset, actif à l'état bas
output [7:0] led,       // jeu de LED x 8, non utilisé
input  usb_rx,          // liaison série USB : Rx, non utilisé
output usb_tx,          // liaison série USB : Tx
output scl,             // Serial Clock I2C, générée par le maître
inout sda               // Serial Data I2C, /!\ SDA, port bidirectionnel
);

assign led = 8'h00;          // LED x 8 éteintes

wire rst;                          // signal reset synchrone, rst=1 si appui sur bouton Reset
reset_conditioner rst_cond_inst (  // Conditionnement & synchronisation du signal Reset
.clk(clk),
.in(!rst_n),
.out(rst)
);

/* ------- Affichage température en °C via liaison UART Tx 115 200 bauds 8N1-------------- */
integer temperature_32bits;   // Pour calcul de température sur 32 bits
reg temperature_enable;       // =1 quand la transaction I2C est complète

serialTemperatureDisplay std (   // instance affichage via liaison série UART
.clk(clk),
.rst(rst),
.temperature(temperature_32bits),
.usb_tx(usb_tx),
.enable(temperature_enable)
);
/* -------------------------------------------------------------------------------------------- */

/* ---- Instanciation & connexion du contrôleur I2C ----------------------------------*/
reg i2c_start;            // signal Start (S) ou Start Repeated (Sr)
reg i2c_stop;             // signal Stop  (P)
wire i2c_busy;            // =1 si le bus I2C est occupé
wire i2c_out_valid;       // =1 quand la donnée renvoyée (1 octet) en sortie est valide et disponible
reg [7:0] i2c_data_in;    // octet à transmettre (maître vers esclave)
wire [7:0] i2c_data_out;  // octet renvoyé (esclave vers maître)
reg i2c_write;            // =1, demande d'écriture par le maître sur le bus
reg i2c_read;             // =1, demande de lecture par le maître sur le bus
reg i2c_ack_read;         // =1 si le maître doit acquitter
wire i2c_ack_write_n, i2c_ack_write;  // sortie ack_write du contrôleur=0 si l'esclave acquitte, /!\ erreur dans la doc.

assign i2c_ack_write = !i2c_ack_write_n;

i2c_controller #(.CLK_DIV(9)) Si7021_controller ( // fréquence clk = 100_000_000 / 2^9 = 195,3 kHz
.clk(clk),
.rst(rst),
.scl(scl),
.sda(sda),
.start(i2c_start),
.stop(i2c_stop),
.busy(i2c_busy),
.data_in(i2c_data_in),
.data_out(i2c_data_out),
.write(i2c_write),
.read(i2c_read),
.ack_write(i2c_ack_write_n),
.ack_read(i2c_ack_read),
.out_valid(i2c_out_valid)
);

localparam [7:0] I2C_ADR_Si7021 = 8'h40, // adresse du composant I2C Si7021 = 0x40
I2C_ADR_Si7021_Read  = (I2C_ADR_Si7021 << 1) + 1,
I2C_ADR_Si7021_Write = (I2C_ADR_Si7021 << 1);
/* ------------------------------------------------------------------------------- */

/* ------ Gestion de la machine à états finis ------------------------------------ */
reg [3:0] I2C_State, I2C_State_Next;

localparam state_I2C_RESET_START   = 4'd0,
state_I2C_RESET_ADRW    = 4'd1,
state_I2C_RESET_CMD     = 4'd2,
state_I2C_RESET_STOP    = 4'd3,
state_I2C_START         = 4'd4,
state_I2C_ADRW          = 4'd5,
state_I2C_START_RE      = 4'd6,
state_I2C_ADRR          = 4'd7,
state_I2C_MEASURE_CMD   = 4'd8,
state_I2C_READ_MSB      = 4'd9,
state_I2C_READ_LSB      = 4'd10,
state_I2C_READ_CHKSUM   = 4'd11,
state_I2C_STOP          = 4'd12;

always @(posedge clk or posedge rst) begin
if (rst) begin
I2C_State <= state_I2C_RESET_START;
end else begin
if (!i2c_busy) begin            // si pas d'action en cours sur le bus I2C,
I2C_State <= I2C_State_Next;  // passage à l'état suivant, synchronisé avec l'horloge
end
end
end
/* ---------------------------------------------------------------------------------------- */

reg [23:0] raw_data;    // registre avec données brutes retournées par le capteur : | MSB | LSB | CheckSum |
reg [30:0] delay;       // valeur chronomètre pour temporisation

always @(posedge i2c_out_valid) begin // si 1 octet est retourné par le capteur
raw_data[7:0] <= i2c_data_out;      // on le décale dans le registre raw_data
raw_data[23:8] <= raw_data[15:0];
end

always @(posedge clk) begin
if (temperature_enable) begin
temperature_32bits = ((raw_data[23:8] * 1757) >> 16) - 469; // calcul de T(°C)x10, voir datasheet Si7021
// Au besoin, CheckSum est dans raw_data[7:0]
end
end

always @(posedge clk) begin
if (I2C_State != I2C_State_Next) begin // remise à zéro du chrono à chaque changement d'état
delay <= 0;
end else begin
delay <= delay + 1;
end
end

/*----------- Génération de la trame I2C ----------------------------------------------- */
always @(I2C_State, i2c_ack_write, delay) begin
I2C_State_Next = I2C_State;

// valeurs par défaut
i2c_stop = 0;
i2c_write = 0;
i2c_read = 0;
i2c_start = 0;
i2c_data_in = 0;
i2c_ack_read = 0;
temperature_enable = 0;

case (I2C_State)

state_I2C_RESET_START : begin
if (delay > 1_000_000) begin // attendre 10ms avant de démarrer
i2c_start = 1;
I2C_State_Next = state_I2C_RESET_ADRW;
end
end

state_I2C_RESET_ADRW : begin
i2c_write = 1;
i2c_data_in = I2C_ADR_Si7021_Write;
I2C_State_Next = state_I2C_RESET_CMD;
end

state_I2C_RESET_CMD : begin
i2c_write = 1;
i2c_data_in = 8'hFE;  // commande Reset, voir datasheet Si7021
I2C_State_Next = state_I2C_RESET_STOP;
end

state_I2C_RESET_STOP : begin
i2c_stop = 1;
I2C_State_Next = state_I2C_START;
end

state_I2C_START : begin
if (delay > 100_000_000) begin // 1 mesure par seconde
i2c_start = 1;
I2C_State_Next = state_I2C_ADRW;
end
end

state_I2C_ADRW : begin
i2c_write = 1;
i2c_data_in = I2C_ADR_Si7021_Write;  // Slave Address (0x40) + Write
I2C_State_Next = state_I2C_MEASURE_CMD;
end

state_I2C_MEASURE_CMD : begin
/* if (i2c_ack_write) begin... // Si on veut tester que le module acquitte bien... */
i2c_write = 1;
i2c_data_in = 8'hF3; // Command 0xF3, Temperature, No Hold Master Mode. Voir datasheet Si7021
I2C_State_Next = state_I2C_START_RE;
end

state_I2C_START_RE : begin
/* if (i2c_ack_write) begin... // Si on veut tester que le module acquitte bien... */
if (delay > 200_000) begin // relancer après 2 ms
i2c_start = 1; // Start Repeated
I2C_State_Next = state_I2C_ADRR;
end
end

state_I2C_ADRR : begin
i2c_write = 1;
i2c_data_in = I2C_ADR_Si7021_Read; // Slave Address (0x40) + Read
I2C_State_Next = state_I2C_READ_MSB;
end

state_I2C_READ_MSB : begin
if (!i2c_ack_write) begin // Si NAK, la conversion n'est pas terminée
I2C_State_Next = state_I2C_START_RE;
end else begin  // Sinon si ACK, la conversion est terminée
i2c_read = 1; // Lecture Temperature MSB
i2c_ack_read = 1;
I2C_State_Next = state_I2C_READ_LSB;
end
end

state_I2C_READ_LSB : begin
i2c_read = 1; // Lecture Temperature LSB
i2c_ack_read = 1;
I2C_State_Next = state_I2C_READ_CHKSUM;
end

state_I2C_READ_CHKSUM : begin
i2c_read = 1; // Lecture Temperature CheckSum
I2C_State_Next = state_I2C_STOP;
end

state_I2C_STOP : begin
temperature_enable = 1; // transaction complète
i2c_stop = 1;
I2C_State_Next = state_I2C_START;
end

endcase

end

endmodule

SerialTemperatureDisplay.v

module serialTemperatureDisplay(clk, rst, usb_tx, temperature, enable);
input clk;              // 100MHz clock
input rst;              // reset
output usb_tx;          // USB->Serial output
input wire [31:0] temperature;
input enable;

wire busy_tx;
reg [7:0] data_tx;
reg new_data_tx;
reg block_tx;
integer temperature_abs;

localparam LENGTH = 16, STR = "Temp = xxx.x C\r\n"; // gabarit de la chaîne de caractères à envoyer et sa taille
localparam SIGN = 9, HUNDREDS = 8, TENS = 7, UNITS = 5; // position du signe, des chiffres centaines/dizaines/unités

reg [LENGTH*8:1] s;
reg[7:0] t1, t2, t3;

reg [7:0] counter, counter_next;

uart_tx  #(.BAUD(115200), .CLK_FREQ(100000000)) tx (
.clk(clk),
.rst(rst),
.tx(usb_tx),
.busy(busy_tx),
.data(data_tx),
.new_data(new_data_tx),
.block(block_tx)
);

/* ------ Gestion de la machine à états finis ------------------------------------ */
reg state, state_next;

localparam state_1 = 1'd0,
state_2 = 1'd1;

initial begin
counter <= LENGTH;
state <= state_1;
end

always @(posedge clk or posedge rst) begin
if (rst) begin
state <= state_1;
end else begin
state <= state_next;  // passage à l'état suivant, synchronisé avec l'horloge
end
end

always @(posedge clk) begin
counter <= counter_next;
end

always @(posedge enable) begin // préparation de la chaîne de caractères à transmettre
s = STR;

if (temperature[31]) begin // si température négative (bit de poids fort = 1)
s[SIGN*8-:8] =  "-";
end else begin
s[SIGN*8-:8] =  "+";
end

temperature_abs = (temperature[31]) ? ~temperature + 1 : temperature; // valeur absolue si négatif

t1 =  temperature_abs / 100;          // chiffre des centaines
t2 = (temperature_abs % 100) / 10;    // chiffre des dizaines
t3 = ((temperature_abs % 100) % 10);  // chiffre des unités
s[HUNDREDS*8-:8] = "0" + t1;
s[TENS*8-:8]     = "0" + t2;
s[UNITS*8-:8]    = "0" + t3;
end

always @(*) begin // transmission UART Tx

// valeurs par défaut
state_next = state;

new_data_tx = 0;
block_tx = 0;
data_tx = "";

counter_next <= counter;

case (state)

state_1 : begin
counter_next <= LENGTH;
if (enable) begin  // si nouvelle donnée de température disponible
state_next = state_2;
end
end

state_2 : begin
if (!busy_tx) begin // si le port UART Tx est libre
new_data_tx = 1;             // signaler une nouvelle donnée
data_tx = s[(counter*8)-:8]; // caractère à transmettre
counter_next <= counter - 1; // aller au caractère suivant

if (counter  == 1) begin  // si tous les caractères ont été transmis
state_next = state_1;
end
end
end

endcase

end

endmodule

VI - Conclusion

Une conséquence de la nature des FPGA est que les tâches configurées dans les circuits peuvent être hautement parallélisées. 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. Sur microcontrôleur, pour simuler un fonctionnement multitâche, vous devez 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.

La dernière version du langage Verilog est sortie en 2005, mais il y a toutefois des évolutions récentes du langage avec SystemVerilog, dont Verilog est maintenant un sous-ensemble. Même si les européens lui préfèrent le langage VHDL, la syntaxe de certaines expressions Verilog est très appréciée des développeurs familiers avec les langages C ou C++.

Dans ce tutoriel, vous avez pu aussi vous rendre compte du haut niveau d’abstraction permis par Verilog et découvrir différents styles de description dans les projets allant de la description structurelle jusqu’au plus haut niveau d’abstraction avec la description comportementale :

- description structurelle : assemblage de primitives et blocs logiques que l’on interconnecte ;

- description comportementale : le circuit est décrit non plus par sa structure, mais par son comportement qui peut être spécifié de façon procédurale, comme les algorithmes programmés sur les ordinateurs.

Notez que les codes ont été écrits en Verilog 2001 standard, et que les sources du projet peuvent être repris intégralement pour toute autre carte FPGA configurable avec ce langage (à condition de redéfinir la configuration et la localisation des entrées-sorties de la carte et tenir compte des nouvelles caractéristiques d’horloge).

Les FPGA ne conviennent pas forcément à toutes les utilisations, mais ils constituent de puissants outils quand les applications exigent de la vitesse de traitement et de la flexibilité (interface intelligente, intelligence artificielle, apprentissage automatique, traitement d’images et de vidéos, accélération d’algorithme, etc.) Les solutions optimales reposent maintenant sur la combinaison de processeurs et de FPGA, voire des FPGA intégrant des cœurs de processeurs matériels.

Vous êtes maintenant prêts à vous lancer dans de nouveaux projets…

Je remercie escartefigue pour la relecture orthographique.