FPGA : programmation d'un transmetteur UART en SystemVerilog

Carte FPGA Intel DE0-Nano dans l’environnement Quartus Prime

f-leb

Un UART (Universal Asynchronous Receiver Transmitter) est à la fois un composant matériel et un protocole universel pour transmettre ou recevoir des données à travers une liaison série, qui n’utilise qu’un seul fil pour faire passer tous les bits de données. Aujourd’hui, les UART sont largement intégrés comme périphériques dans les microcontrôleurs.

Ce tutoriel vous permettra de comprendre (ou de réviser) le protocole et de synthétiser un transmetteur UART dans une puce FPGA. SystemVerilog sera utilisé comme langage de description de matériel dans l’environnement Intel Quartus Prime.

Commentez

- Titre : FPGA : programmation d'un transmetteur UART en SystemVerilog

- Auteur : f-leb

- Parution : 9 décembre 2024

- 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 ® 2024 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.

Sommaire

- 1. Rôle d’un récepteur-transmetteur universel UART

- 2. Constitution d’une trame série UART

- 3. Conception d’un transmetteur UART

- 4. La carte et l’environnement de développement FPGA

- 5. Description modulaire du transmetteur UART

- 5-1. Un premier module datapath

- 5-2. Le contrôleur UART – description par une machine à états finis

- 5-3. Le module transmetteur complet

- 5-4. Validation par simulation

- 6. Test avec un transmetteur « Hello World! »

- 6-1. Description du module send_helloworld

- 6-2. Génération du signal d’horloge clk_uart

- 6-3. L’analyseur logique

- 6-4. Signaux du transmetteur UART à l’analyseur logique

- 7. Conclusion

- 8. Annexe : archive Quartus Prime

1 - Rôle d’un récepteur-transmetteur universel UART

Un récepteur-transmetteur asynchrone universel (UART) est un bloc de circuits permettant la communication série. L’UART est comme une passerelle agissant dans les deux sens pour faire passer les données entre les deux interfaces : parallèle et série. À une extrémité de l’UART se trouvent un bus parallèle avec sept ou huit lignes de données, ainsi que des broches de contrôle des entrées-sorties. À l’autre se trouvent les deux fils série : Rx pour la réception et Tx pour la transmission des données.

2 - Constitution d’une trame série UART

Une trame série, selon le protocole UART, ressemble à ceci :

Format d'une trame UART - d'après documentation Arduino, licence C.C (https://docs.arduino.cc/learn/communication/uart/)

L’image montre les 7 bits de données (historiquement, ceux d’un « caractère » ASCII) émis en série en commençant par le bit de poids faible. Le signal est cadencé par une horloge, mais le signal d’horloge n’est pas transmis : UART est un protocole de liaison série asynchrone.

Avant d’établir la communication, les deux parties (transmetteur et récepteur) doivent donc partager la même vitesse de transmission, et l’interface série avec le protocole doit leur permettre, au moins par moment, de se synchroniser. Les marqueurs START et STOP en début et fin de caractères contribuent à cela.

Le bit de parité, facultatif, est un moyen simple de détection d’erreurs dans la transmission.

En résumé :

- la liaison série UART est asynchrone, transmetteur et récepteur doivent partager une même vitesse de transmission exprimée en bauds (ou bits par seconde). Les vitesses de transmission normalisées sont en principe des multiples ou sous-multiples de 9600 :
1200, 2400, 4800, 9600 …, 115 200, 230 400 bauds, etc. On peut aller jusqu’au Mbit/s si le matériel le permet (921 600 bauds exactement) et même plus encore ;

- en 3,3 ou 5 V (technologie TTL ou CMOS), le signal est à l’état haut au repos, et la transmission commence par un bit de START (passage à l’état bas du signal) ;

- s’ensuivent les bits de données, 8 bits de données le plus souvent de nos jours, en commençant par le bit de poids faible ;

- le bit supplémentaire de parité, fixé en fonction du nombre de bits de données à 1, est facultatif. Si la parité est incluse dans la trame, il existe deux types de parités :

- parité paire (Even parity) : le nombre de bits à 1, en prenant en compte les bits de données ainsi que le bit de parité, doit être pair. Si le récepteur compte les bits à 1 et en trouve un nombre impair, une erreur de parité sera détectée,

- parité impaire (Odd parity) : le nombre de bits à 1, en prenant en compte les bits de données ainsi que le bit de parité, doit être impair. Si le récepteur compte les bits à 1 et en trouve un nombre pair, une erreur de parité sera détectée ;

- la transmission se termine généralement par un seul bit de STOP, et le système se retrouve à nouveau à l’état repos, prêt pour la transmission de nouvelles données.

Usuellement, la configuration de la transmission UART est décrite par des expressions du type :

- 9600-8-N-1 : vitesse de transmission à 9 600 bauds, 8 bits de données, pas de parité (None), un seul bit de STOP ;

- 115200-8-E-1 : vitesse de transmission à 115 200 bauds, 8 bits de données, parité paire (Even), un seul bit de STOP ;

- etc.

Parfois, les appareils communicants sont de nature différente, et les risques d’erreurs de transmission sont élevés, par exemple parce que le récepteur est trop lent pour traiter le flot de données. En plus des fils Tx et Rx utilisés pour la Transmission ou la Réception des données série (transmission full-duplex), des fils supplémentaires sont alors utilisés pour faire du contrôle de flux matériel. Les signaux transportés par ces fils de contrôle servent à gérer le transfert de données entre les deux appareils : l’appareil transmetteur prévient d’un envoi de données, le récepteur indique qu’il est prêt à les recevoir, prévient quand il a bien reçu les données, indique qu’il est prêt à en recevoir de nouvelles, etc. Dans ce tutoriel, et comme souvent de nos jours avec les équipements de type microcontrôleur, il n’y aura pas de contrôle de flux matériel (ni logiciel non plus).

3 - Conception d’un transmetteur UART

Le schéma-bloc du transmetteur UART se présente ainsi :

Transmetteur UART

L’entrée data_in[7..0] est le bus parallèle 8 bits où doit être présenté l’octet à transmettre. L’état de cette entrée doit être maintenu du début de la transmission, donné par une impulsion sur l’entrée send, jusqu’à la fin de la transmission signalée par une impulsion sur la sortie sent.

Les données en série selon le protocole UART sont présentées sur la sortie data_out.

L’entrée rst_n est dédiée au signal Reset (n pour negative, car le Reset sera actif sur le niveau bas du signal). Cette entrée sera reliée à un bouton-poussoir de la carte FPGA et forcera l’état repos du transmetteur sur appui du bouton (data_out forcée au niveau haut).

Le bus 4 bits config_uart[3..0] sera relié à quatre interrupteurs de type micro-switches de la carte et permettra de donner des éléments de configuration du transmetteur.
Les deux premiers interrupteurs sur config_uart[3..2] renseignent la vitesse de transmission parmi les valeurs normalisées suivantes :

- 00 : 9600 bauds ;

- 01 : 38 400 bauds ;

- 10 : 115 200 bauds ;

- 11 : 230 400 bauds.

Le bloc transmetteur se charge d’appliquer le diviseur de fréquence approprié au signal d’horloge clk_uart en entrée, fixée à 1 843 200 Hz (multiple de 9600), pour transmettre à la vitesse sélectionnée.

Les deux autres interrupteurs sur config_uart[1..0] renseignent le type de parité :

- 00 : sans parité ;

- 01 ou 11 : parité impaire (valeurs 1 et 3 en décimal, des valeurs impaires) ;

- 10 : parité paire (valeur 2 en décimal, valeur paire).

Par exemple, avec la position des interrupteurs suivante :

micro-switches

Dans ce cas, config_uart[3..0] = 4'b0001, et on aura la configuration 9600-8-O-1 (parité impaire Odd).

4 - La carte et l’environnement de développement FPGA

Je ne vous présente plus la carte DE0-Nano, la cible FPGA déjà utilisée dans de précédents tutoriels :

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

- Programmer un contrôleur pour écran VGA avec une carte de développement FPGA

- Apprendre à développer sur FPGA avec Intel Quartus Prime - Communication SPI avec un convertisseur Analogique-Numérique, simulation fonctionnelle et analyse des signaux

Kit Terasic DE0-Nano (SoC FPGA Intel Cyclone IV)

En surbrillance, les micro-switches SW[3:0] de la carte FPGA DE0-Nano utilisés pour la configuration de la liaison UART.

Je profite également de l’environnement Quartus Prime et la dernière version à ce jour (23.1.1) de l’édition Lite (gratuite) : Quartus® Prime Design Software.

L’environnement Quartus Prime permet, pour l’essentiel dans ce tutoriel, de :

- programmer des modules (des portions de circuits logiques dédiés à des fonctionnalités précises) dans un langage de description matériel comme SystemVerilog ou de générer des modules depuis des bibliothèques grâce à des assistants ;

- instancier les modules et de relier leurs entrées-sorties à la souris dans une interface graphique ;

- réaliser des testbenches simples (bancs de tests, ici à base de simulations fonctionnelles) et visualiser les signaux simulés dans des chronogrammes, là aussi à la souris dans une interface graphique ;

- synthétiser et transférer le design dans la cible DE0-Nano.

Programmation de modules en SystemVerilog

Instanciation de modules et liaison des entrées-sorties à la souris

Simulation fonctionnelle, visualisation des signaux simulés dans des chronogrammes

Transfert du design dans la cible FPGA

5 - Description modulaire du transmetteur UART

La description d’un transmetteur UART n’est pas si compliquée et pourrait très bien être contenue dans un seul fichier Verilog/SystemVerilog sans même passer par un assistant graphique. Mais le choix qui a été fait ici est de décomposer la description en plusieurs modules, chaque module répondant à un sous-problème plus simple à décrire. Les assistants graphiques nous aideront à mieux visualiser l’organisation du circuit qui sera synthétisé.

5-1 - Un premier module datapath

Le datapath sera la portion de circuit qui opérera sur l’octet de données en entrée pour élaborer le signal série en sortie :

Entrées-sorties principales :

- data_in[7..0] : l’octet à transmettre (bus parallèle 8 bits) qui doit être présent sur le bus tout le temps de la transmission ;

- clk_uart : l’horloge principale qui sera fixée à 1 843 200 Hz, un multiple de 9 600, et qui va cadencer les événements ;

- data_out : signal série UART.

On propose le code SystemVerilog suivant pour le module datapath :

datapath.sv

module datapath (
input logic clk_uart,               // signal d'horloge
input logic [7:0] data_in,          // octet à transmettre
input logic start_bit, data_bit, parity_bit, stop_bit,  // nature du bit à activer
input logic [2:0] bit_num,          // numéro du bit en cours
input logic [1:0] config_parity,    // type de parité
output logic data_out               // sortie série
);

logic parity_even, xor_data_in;

always_comb begin
parity_even = config_parity == 2'b10;   // =1'b1 si parité paire, =1'b0 sinon
xor_data_in = ^data_in;                 // xor entre tous les bits de data_in
end

always_ff @(posedge clk_uart) begin
if (start_bit)      // si bit de Start
data_out <= 1'b0;
else if (data_bit)  // si bit de données
data_out <= data_in[bit_num];
else if (parity_bit) // si bit de parité (paire ou impaire)
data_out <= parity_even ? xor_data_in : ~xor_data_in;
else if (stop_bit)  // si bit de Stop
data_out <= 1'b1;
else
data_out <= 1'b1;
end

endmodule

Le bloc always_comb va inférer de la logique combinatoire, alors que le bloc always_ff @(posedge clk_uart) va inférer de la logique séquentielle avec une bascule D sensible au front montant de l'horloge.

Les signaux de contrôle et d’activation :

- start_bit : activation du bit de START. On maintient à 1 cette entrée d’activation durant le nombre de cycles d’horloge nécessaires pour produire un bit de START (data_out à 0) ;

- stop_bit : activation du bit de STOP. On maintient à 1 cette entrée d’activation durant le nombre de cycles d’horloge nécessaires pour produire un bit de STOP (data_out à 1) ;

- parity_bit : activation du bit de parité. Cette entrée ne sera pas forcément activée puisque ce bit est facultatif. Mais si elle est activée durant le nombre de cycles d’horloge nécessaires, le bit de parité généré en sortie est calculé en fonction du nombre de bits à 1 de l’octet de données data_in[7..0] et selon le type de parité (paire ou impaire) à lire sur le bus config_parity[1..0] ;

- config_parity[1..0] : type de parité codé sur deux bits - 00 : pas de parité, 01 ou 11 : parité impaire, et 10 : parité paire.

- bit_num[2..0] : le numéro en cours du bit de l’octet de données data_in[7..0]. 0 pour le bit de poids faible, jusqu’à 7 pour le dernier bit de poids fort.

- data_bit : activation des bits de données. Le numéro du bit en cours, entre 0 et 7, est à lire sur le bus bit_num[2..0]. On maintient à 1 cette entrée d’activation durant le nombre de cycles d’horloge nécessaires pour générer les bits de données en sortie ;

Il est toujours intéressant de jeter un œil sur le bloc logique généré par ce module (Register Transfer-Level View) : des multiplexeurs (ou sélecteurs), un comparateur, une cellule XOR (OU exclusif) à 8 entrées, une bascule D :

Génération du bit de parité

Vous pouvez suivre en bleu le flux du bit généré en sortie lorsque l’entrée parity_bit est activée, et que la parité choisie est paire. Le circuit fait un OU exclusif (XOR) entre tous les bits de l’octet de données data_in[7..0]. Si le nombre de 1 est pair, le OU exclusif produira un bit de parité à 0 qui sera dirigé vers la sortie au prochain front d’horloge. Et si le nombre de bits à 1 est impair, le OU exclusif produira un bit de parité à 1.

Sur la vue ci-dessous, c’est un bit de données qui est dirigé vers la sortie (data_bit activé). Le multiplexeur repéré Mux0 va diriger un des huit bits de données vers la sortie selon la valeur de l’entrée de sélection bit_num[2:0] entre 0 et 7.

Génération du bit de données

Un nouveau bloc logique est nécessaire pour produire la séquence des différents signaux de contrôle et d’activation.

5-2 - Le contrôleur UART – description par une machine à états finis

Une séquence doit être produite : après une impulsion du signal send, il faut activer le bit de START en premier lieu (start_bit=1), puis les bits de données (data_bit=1) en commençant par transmettre le bit de poids faible avec bit_num=3’b000 jusqu’au bit de poids fort où bit_num=3’b111, puis éventuellement activer le bit de parité (parity_bit=1), et enfin pour terminer le bit de STOP (stop_bit=1).

La séquence sera générée par un bloc logique uart_transmitter_controller, à relier au datapath.

Contrôleur UART

Toute la séquence peut-être produite par une machine à états finis (Finite State Machine) et décrite par un diagramme d’états-transitions. La machine est constituée d'états stables, et elle passe d'état en état, suivant les transitions.

Machine à états finis : machine de Moore vs machine de Mealy

Ces « machines » réalisent des actions déterminées en fonction des événements qui se présentent.

À un moment donné, la machine se trouve dans un état (l’état « courant »), le passage d’un état à un autre (la transition) est activé par un événement ou une condition.

Machine de Mealy, d'après https://www.emse.fr/~dutertre/documents/machines_a_etats.pdf

Dans un FPGA :

- les états sont synchrones, mémorisés sur front montant de l’horloge ;

- un processus combinatoire calcule l’état futur à partir de l’état présent et des entrées ;

- dans une machine de Mealy, un processus combinatoire calcule les sorties à partir de l’état présent et des entrées (alors que dans une machine de Moore, les sorties ne dépendent QUE de l’état présent).

Le diagramme d’états-transitions ci-dessous est celui de notre contrôleur UART :

Diagramme états-transitions du contrôleur UART

Vous pouvez constater que les sorties en rouge ne dépendent en général que de l’état en cours comme dans une machine de Moore. Deux signaux en sortie, inc_bit et clr_bit, échappent à la règle puisqu’ils dépendent également de signaux en entrée.

Le signal clr_bit par exemple est activé sur la transition entre les états START et DATA, c’est-à-dire lorsque le signal ticks_done est activé.

Résumons la manière de coder ce graphe…

Six états sont codés, et la mise à jour de l’état présent (state) par un état futur (state_next) est déclenchée sur front montant de l’horloge :

// ...
typedef enum logic[2:0] { IDLE, START, DATA, PAR, STOP, ACK } state_t;
state_t state, state_next;
// ...

always_ff @(posedge clk_uart) begin
if (!rst_n)  // reset synchrone
state <= IDLE;
else
state <= state_next;  // Mise à jour de l’état présent par l’état futur sur front montant de l’horloge
end

Un processus combinatoire calcule l’état futur (state_next) en fonction de l’état présent et des entrées. Par exemple, pour le passage de l’état repos IDLE à l’état START lorsque le signal d’entrée send est activé (démarrage de la transmission) :

// ...
always_comb begin

{clr_bit, start_bit, data_bit, inc_bit, parity_bit, stop_bit, sent} = 7'b0000000; // par défaut
state_next = state; // état conservé par défaut

case (state)
IDLE : begin
if (send)
state_next = START;
end

// ...

endcase
end

Dans ce même processus, on calcule aussi les sorties. Dans l’extrait ci-dessous, la sortie start_bit est activée dans l’état START, mais la sortie clr_bit est activée sur la transition avec l’état DATA lorsque l’entrée ticks_done est activée.

// ...
always_comb begin

{clr_bit, start_bit, data_bit, inc_bit, parity_bit, stop_bit, sent} = 7'b0000000; // par défaut
state_next = state; // état conservé par défaut

case (state)

// ...

START: begin
start_bit = 1;
if (ticks_done) begin
clr_bit = 1;
state_next = DATA;
end
end

// ...

endcase
end

Plusieurs signaux n’ont pas été évoqués jusqu’à présent :

- clr_bit et inc_bit sont deux signaux internes au bloc qui agissent directement sur le numéro du bit de données en cours de transmission, entre 0 (bit de poids faible) et 7 : num_bits[2:0]. clr_bit remet le numéro du bit à zéro, inc_bit l’incrémente. Un signal data_done est activé pour informer que le dernier bit (le numéro 7) de données est en cours de transmission :

// **** Numéro du bit de données **********************************************************
logic inc_bit, clr_bit; // commande incrémentation numéro bit de données et remise à zéro
logic data_done; // =1'b1 si dernier bit de données

always_ff @(posedge clk_uart) begin

if (clr_bit)
bit_num <= 3'd0;
else if (inc_bit) begin
bit_num <= bit_num + 3'd1;
end

end

assign data_done = (bit_num == 3'b111); // dernier bit de données
// ****************************************************************************************

- le signal ticks_done qui figure dans la majorité des transitions du diagramme est aussi un signal interne au bloc du contrôleur. Il fixe la durée du bit qui dépend de la vitesse de transmission choisie. La fréquence de l’horloge clk_uart est fixée à 1 843 200 Hz et doit être divisée pour des nombres de bauds compris entre 9600 et 230 400.

Un compteur ticks_counter s’incrémente à chaque front montant de l’horloge et repart à zéro à chaque bit transmis. Si par exemple la vitesse de transmission choisie est 230 400 bauds, le compteur ticks_counter doit évoluer entre 0 et 7, car 1 843 200 / 8 = 230 400. Quand ticks_done est activé, c’est que la durée du bit a été atteinte et qu’il faut passer au bit suivant :

// **** diviseur de fréquence **********************************
logic [7:0] ticks_counter, ticks_per_bit;
logic ticks_done;

always_comb begin
case (config_uart[3:2])
2'b00 :  ticks_per_bit = (CLK_UART / 9_600 - 1);    // 9600 bauds = 1843200/192
2'b01 :  ticks_per_bit = (CLK_UART / 38_400 - 1);    // 38400 bauds = 1843200/48
2'b10 :  ticks_per_bit = (CLK_UART / 115_200 - 1);    // 115200 bauds = 1843200/16
2'b11 :  ticks_per_bit = (CLK_UART / 230_400 - 1);    // 230400 bauds = 1843200/8
default: ticks_per_bit = (CLK_UART / 9_600 - 1);    // 9600 bauds = 1843200/192
endcase
end

always_ff @(posedge clk_uart) begin
if ((state != state_next) || inc_bit) begin // remise à zéro du compteur à chaque changement d'état et au changement du bit à transmettre
ticks_counter <= 0;
end else begin
ticks_counter <= ticks_counter + 7'd1;
end
end

assign ticks_done = ticks_counter == ticks_per_bit;

// *************************************************************

Ce qui donne le code complet du contrôleur uart_transmitter_controller.sv :

uart_transmitter_controller.sv

module uart_transmitter_controler #(
parameter CLK_UART = 1_843_200
)
(
input logic clk_uart,
input logic config_uart[3:0], // configuration uart
/*
Configuration de l'uart avec 4 micro-switches : [XXYY]
- les deux premiers switches configurent la vitesse en nbr de bauds :
XX = 00 : 9600 bauds
XX = 01 : 38400 bauds
XX = 10 : 115200 bauds
XX = 11 : 230400 bauds

- les deux derniers switches configurent le type de parité:
YY = 00 : pas de bit de parité
YY = 10 : parité paire (even)
YY = 11 ou 01 : parité impaire (odd)

Exemple : [0110] 38400 bauds, parité paire (8 bits de donnée, 1 bit de stop)
*/
input logic rst_n,        // reset
input logic send,         // commande d'envoi

output logic start_bit, data_bit, parity_bit, stop_bit,
output logic [2:0] bit_num,   // numéro du bit en cours (entre 0 et 7)
output logic [1:0] config_parity,
output logic sent             // impulsion si octet transmis
);

// **** diviseur de fréquence **********************************
logic [7:0] ticks_counter, ticks_per_bit;
logic ticks_done;

always_comb begin
case (config_uart[3:2])
2'b00 :  ticks_per_bit = (CLK_UART / 9_600 - 1);  // 9600 bauds = 1843200/192
2'b01 :  ticks_per_bit = (CLK_UART / 38_400 - 1); // 38400 bauds = 1843200/48
2'b10 :  ticks_per_bit = (CLK_UART / 115_200 - 1);  // 115200 bauds = 1843200/16
2'b11 :  ticks_per_bit = (CLK_UART / 230_400 - 1);  // 230400 bauds = 1843200/8
default: ticks_per_bit = (CLK_UART / 9_600 - 1);  // 9600 bauds = 1843200/192
endcase
end

assign ticks_done = ticks_counter == ticks_per_bit;
// *************************************************************

// **** parité ***************************************************
logic parity_none;

always_comb begin
config_parity = {config_uart[1], config_uart[0]};
parity_none = config_parity == 2'b00; // =1'b1 si pas de parité
end
// ***************************************************************

// **** Machine à états *******************************************************************
typedef enum logic[2:0] { IDLE, START, DATA, PAR, STOP, ACK } state_t;
state_t state, state_next;

always_ff @(posedge clk_uart) begin
if ((state != state_next) || inc_bit) begin // remise à zéro du chrono à chaque changement d'état et au changement du bit à transmettre
ticks_counter <= 0;
end else begin
ticks_counter <= ticks_counter + 7'd1;
end
end

always_ff @(posedge clk_uart) begin
if (!rst_n)         // reset synchrone
state <= IDLE;
else
state <= state_next;  // Mise à jour de l’état présent par l’état futur sur front montant de l’horloge
end

/* Combinatoire des états :
Calcul de l’état futur à partir de l'entrée et de l’état présent.

Combinatoire de la sortie :
Calcul des sorties à partir de l'entrée et de l’état présent */

always_comb begin

{clr_bit, start_bit, data_bit, inc_bit, parity_bit, stop_bit, sent} = 7'b0000000; // par défaut
state_next = state; // état conservé par défaut

case (state)
IDLE : begin
if (send)
state_next = START;
end

START: begin
start_bit = 1;

if (ticks_done) begin
clr_bit = 1;
state_next = DATA;
end
end

DATA : begin
data_bit = 1;
if (ticks_done) begin
inc_bit = 1;  // passer au bit de donnée suivant
if (data_done) begin  // si c'est le dernier bit de donnée
if (parity_none)
state_next = STOP;
else
state_next = PAR;
end
end
end

PAR  : begin
parity_bit = 1;
if (ticks_done) begin
state_next = STOP;
end
end

STOP : begin
stop_bit = 1;
if (ticks_done) begin
state_next = ACK;
end
end

ACK  : begin
sent = 1;
if (!send) state_next = IDLE;
end
endcase
end
// **************************************************************************************

// **** Numéro du bit de données **********************************************************
logic inc_bit, clr_bit; // commande incrémentation numéro bit de données et remise à zéro
logic data_done;      // =1'b1 si dernier bit de données transmis

always_ff @(posedge clk_uart) begin

if (clr_bit)
bit_num <= 3'd0;
else if (inc_bit) begin
bit_num <= bit_num + 3'd1;
end

end

assign data_done = (bit_num == 3'b111); // dernier bit de donnée transmis
// ****************************************************************************************

endmodule

5-3 - Le module transmetteur complet

Pour terminer ce bloc transmetteur, il faut créer un module de niveau supérieur où les deux modules datapath et uart_transmetter_controller sont reliés par leurs entrées-sorties, ce qui peut se faire à la souris dans l’environnement Quartus Prime :

On peut même générer un schéma de ce module Block_uart4dvp pour une future exploitation dans un nouveau circuit :

Ce bloc consomme 54 éléments logiques (sur 22 320) et 18 registres de la puce FPGA Intel Cyclone IV E.

Compilation report

5-4 - Validation par simulation

Le module général peut-être simulé pour valider son fonctionnement. Avec l’outil Simulation WaveForm Editor intégré, vous construisez les stimuli à la souris (signaux clk_uart, config_uart[3:0], rst_n, send, et data_in[7:0]) et vous démarrez une simulation fonctionnelle.

Voici un exemple renvoyé à la fin de la simulation pour le caractère « M » transmis avec la configuration 230400-8-O-1 (parité impaire) :

La transmission démarre bien avec un bit de START après l’impulsion du signal send. Le code ASCII du « M » étant 0100 1101 en binaire, les bits de données sont transmis dans le bon ordre (bit de poids faible en premier). Le code binaire du « M » comprend quatre bits à 1, le bit de parité doit être à 1 pour une parité impaire. Une impulsion sur le signal sent est bien générée après le bit de STOP.

Si on fait un zoom sur le bit de START :

Il faut 8 cycles d’horloge pour transmettre un bit, ce qui est bien conforme, car la fréquence du signal clk_uart est de 1 843 200 Hz, et il faut la diviser par 8 pour une vitesse de 230 400 bauds.

Cela s’annonce bien…

6 - Test avec un transmetteur « Hello World! »

Pour ne pas déroger à la règle de la démonstration « Hello World! », un transmetteur de message « Hello World! » est proposé pour conclure cet article !

On donne le schéma complet du test ci-dessous :

Block_uart4dvp : le bloc transmetteur UART étudié précédemment ;

send_helloworld : le bloc qui génère les caractères du message « Hello World! » à envoyer en boucle au transmetteur UART ;

Block_baudrate_generator : le bloc générateur du signal d’horloge clk_uart à 1 843 200 Hz.

6-1 - Description du module send_helloworld

Le module send_helloworld devra envoyer un par un (et en boucle infinie) les caractères de la chaîne « Hello World! » au transmetteur UART. À chaque caractère transmis sur la sortie série, l’impulsion sur la sortie sent du transmetteur est redirigée sur l’entrée char_ack du module send_helloworld pour acquitter de la transmission du caractère et lancer le caractère suivant. La séquence est dirigée, là aussi, par une machine à états finis.

send_helloworld.sv

module send_helloworld #(
parameter SIZE = 14,
parameter logic [7:0] char_array [0:SIZE-1] = '{ "H", "e", "l", "l", "o", " ",
"W", "o", "r", "l", "d", "!",
"\r", "\n" }
)
(
input logic clk_uart, char_ack, rst_n,
output logic [7:0] char, // caractère
output logic send
);

logic clr_idx, inc_idx;
logic [3:0] index;

typedef enum logic[1:0] { IDLE, TRANSMIT, ACK } state_t;
state_t state, state_next;

always_ff @(posedge clk_uart) begin

if (!rst_n) begin
state <= IDLE;
end else begin
state <= state_next;
char <= char_array[index];
if (clr_idx)
index <= 0;
else if (inc_idx)
index <= index + 4'd1;
end
end

always_comb begin

{clr_idx, inc_idx, send} = 3'b000; //par défaut
state_next = state; // état conservé par défaut

case (state)
IDLE:       begin
clr_idx = 1;
state_next = TRANSMIT;
end

TRANSMIT:   begin
send = 1;
state_next = ACK;
end

ACK:        begin
if (char_ack) begin
if (index == SIZE-1)
state_next = IDLE;
else begin
inc_idx = 1;
state_next = TRANSMIT;
end
end
end

endcase
end

endmodule

6-2 - Génération du signal d’horloge clk_uart

Il reste à générer le signal d’horloge avec le module Block_baud_rate_generator, en invoquant un circuit spécialisé disponible dans la bibliothèque de composants de Quartus Prime (IP Catalog → Library → Basic Functions → Clocks, PLLs and Resets → PLL → ALTPLL) : une boucle à verrouillage de phase ou PLL (phase-locked loop) pour asservir la fréquence de sortie sur un multiple de la fréquence d’entrée. L’entrée du bloc est raccordée à l’horloge principale 50 MHz (entrée CLOCK_50) de la carte FPGA. Le ratio de fréquence proposé par l’assistant est 71 / 1926 pour réduire la fréquence de l’horloge principale : (71 / 1926) x 50.10^6 ≈ 1 843 200 Hz (à 2 Hz près).

Configuration du circuit PLL

Voir un exemple de configuration de circuit PLL.

6-3 - L’analyseur logique

Pour analyser les signaux, j’utilise ce petit analyseur logique à l’origine produit par le constructeur Saleae :

Analyseur logique (compatible Saleae)

Ce modèle à bas coût n’est plus produit par Saleae, mais on trouve nombre de constructeurs vendant leurs modèles censés être compatibles (clones) pour 10-15 € (et même moins en cherchant bien sur les sites marchands).

Normalement, le logiciel fourni par Saleae (gratuit) devrait tourner aussi sur ces modèles, mais la documentation vous dirigera sans doute vers une alternative opensource comme PulseView de Sigrok. Bien entendu, le logiciel comprend un décodeur pour les signaux UART.

6-4 - Signaux du transmetteur UART à l’analyseur logique

Les signaux qui doivent être relevés par l’analyseur logique doivent être dirigés vers les ports GPIO de la carte FPGA (voir Affectation des broches et programmation du FPGA).

Pin Planner dans Quartus Prime

L'analyseur logique relève les signaux dirigés vers les ports GPIO de la carte FPGA

Pour ce premier test, les micro-switches de configuration UART sont dans la position [1001], soit 115200-8-O-1 (parité impaire), et on saisit cette même configuration pour le décodeur de l’analyseur logique :

Le design complet du transmetteur « Hello world! » étant transféré dans la carte FPGA, on peut lancer l’acquisition…

… Le message « Hello World! » est bien décodé, les signaux du transmetteur sont bien conformes au protocole UART. On voit aussi les impulsions des signaux send et sent qui marquent le début et la fin de transmission de chaque caractère.

Si l’on zoome sur la lettre « H »:

Après le START, suivent les 8 bits de données (les petits points blancs marquent le moment de l’acquisition), le bit de parité (le petit carré blanc) qui est bien à 1 pour avoir un nombre impair de bits à 1, et l’on termine par un STOP. L’octet envoyé est bien le code ASCII de la lettre « H » (0b0100 1000).

Et si l’on essaye d’estimer la vitesse de transmission avec l’outil :

Le logiciel nous indique fbaud = 115.385 kHz à comparer à la vitesse attendue de 115 200 bauds. Il est difficile d’estimer la précision de l’outil, mais l’erreur relative reste inférieure à 0,2%.

Si l’on règle la parité à « Even parity bit » dans le logiciel de l’analyseur (parité paire), alors que le FPGA continue de transmettre avec une parité impaire, on obtient :

Le message est bien décodé, mais une erreur de parité est indiquée pour chacun des bits, ce qui est là aussi conforme à l’attendu.

7 - Conclusion

Au terme de cet article, nous avons le bilan suivant :

- un circuit logique capable de transmettre des données en série selon le protocole UART a été conçu et synthétisé. Certains paramètres de la liaison série sont même configurables : vitesse de la transmission (parmi 9600, 38 400, 115 200 et 230 400 bauds) et type de parité (sans parité, parité paire ou impaire) ;

- le circuit a été validé par étape grâce à des simulations fonctionnelles d’abord. Puis dans un dernier temps, les signaux dirigés vers des ports GPIO de la carte FPGA sont passés au crible d’un analyseur logique (à bas coûts) capable de décoder les signaux UART.

Le transmetteur est maintenant réutilisable pour de futurs projets où une communication série s’avère nécessaire pour transmettre les données d’un capteur ou envoyer des ordres à un actionneur.

Pour être complet, après le transmetteur UART, un récepteur UART doit encore être développé avec de nouveaux problèmes à résoudre (synchronisme en lecture afin d’acquérir les 0 et les 1 « au bon moment », gestion de la parité, etc.).

Le fait de « programmer » des descriptions de circuits logiques donne une grande flexibilité aux FPGA. Vous pouvez par exemple combiner plusieurs transmetteurs ou récepteurs UART. Si vous avez cinq capteurs ou plus communiquant avec le protocole UART, vous pouvez configurer autant de blocs récepteurs que nécessaire (dans la limite du nombre d’éléments logiques disponibles sur la puce FPGA). Il suffit de dupliquer le bloc et raccorder ses entrées-sorties, les récepteurs UART vont alors tourner en parallèle. Si les capteurs ne sont pas identiques, chaque bloc peut même travailler avec des configurations différentes en termes de nombre de bauds ou de parité. De nouveaux circuits permettront de sélectionner et diriger les sorties des transmetteurs, faire des traitements particuliers pour reconstituer un message avec les données des capteurs qui sera dirigé vers un transmetteur UART. La sortie du transmetteur sera connectée au port série d’une carte Arduino par exemple pour traiter les messages.

Démonstration avec instanciation de deux blocs transmetteurs UART qui fonctionneront en parallèle.

Je remercie Auteur pour ses suggestions. Merci également à escartefigue pour sa relecture orthographique.

8 - Annexe : archive Quartus Prime

Archive du projet de ce tutoriel au format Quartus Archive, à importer directement depuis Quartus Prime :

- uart4dvp.qar

Version de Quartus Prime : 23.1.1 Lite Edition