Cette page est la première d’une série consacrée aux resets dans les FPGA. Comme beaucoup utilisent des resets asynchrones (asynchronous resets) sans savoir qu’ils ne fonctionnent pas vraiment comme prévu, cette première page explique pourquoi le sujet n’est pas aussi simple qu’il en a l’air.
Votre reset fonctionne‑t‑il vraiment ?
Prenons cet exemple pour réinitialiser une machine à états (state machine), et qui est faux :
always @(posedge clk or negedge resetn)
if (!resetn)
state <= ST_START;
else
case (state)
ST_START:
begin
state <= ST_NEXT;
[ ... do some stuff maybe? ... ]
end
ST_NEXT:
begin
[ ... do something ... ]
end
endcase
Vous vous demandez peut‑être ce qui ne va pas avec ce reset ? C’est exactement ce que montrent les manuels ! Un reset asynchrone actif à l’état bas, qui met la bascule qui implémente @state dans son état initial. Qu’est‑ce qui pourrait mal se passer ?
Pour les besoins de la discussion, supposons que le signal @resetn soit correct en lui‑même. Autrement dit, il n’est pas relié directement à un bouton poussoir ou à quelque chose du genre. Peut‑être a‑t‑on utilisé une puce conçue pour générer un signal de reset, ou le reset est‑il généré en interne par le FPGA. D’une manière ou d’une autre, nous supposons que le reset devient actif de façon stable, qu’il le reste assez longtemps, puis qu’il devient inactif. C’est quand même faux.
Et quand je dis faux, je veux dire le genre de faux qui fait que le FPGA se comporte bizarrement de temps en temps, sans raison apparente.
Alors, quel est le problème ? Eh bien, comme le reset reste actif assez longtemps, il va certainement mettre @state dans son état initial. Mais que se passe‑t‑il quand il devient inactif (c’est‑à‑dire quand il repasse à « 1 » dans l’exemple ci‑dessus) ? C’est à ce moment que les bascules (flip‑flops) concernées devraient commencer à répondre aux fronts montants de @clk.
Il faut cependant un petit peu de temps à la bascule pour se remettre du signal de reset et pour commencer à échantillonner l’entrée de données sur les fronts montants de l’horloge. Et comme le reset est, par définition, asynchrone, il peut être désactivé à n’importe quel moment par rapport à @clk.
Si le premier front montant de @clk arrive trop tôt après la désactivation du reset, la bascule ignore ce front montant. C’est parfaitement acceptable si toutes les bascules connectées à @clk font la même chose. Mais toutes les bascules ne sont pas exactement identiques : certaines reçoivent le front d’horloge un peu avant les autres, et certaines reçoivent la désactivation du reset plus tard que les autres.
Pour être honnête, cette explication est un peu simpliste. Pour une vision plus précise du problème, reportez‑vous à la partie consacrée au temps de recouvrement (recovery) et au temps de retrait (removal) sur la page qui explique les bases des contraintes temporelles.
Tout revient donc à ceci : avec un peu de malchance, le reset peut devenir inactif suffisamment près du front montant de l’horloge pour que certaines bascules répondent au premier front montant, et que d’autres l’ignorent. En fait, certaines bascules peuvent même mettre un peu plus de temps à décider quoi faire. On peut accuser les différences entre bascules d’une même puce, ou le décalage d’horloge (clock skew) et le décalage du reset : le résultat est que certaines bascules se retrouvent un cycle d’horloge en avance sur les autres.
Pour comprendre à quel point c’est grave, reprenons l’exemple ci‑dessus. Si le synthétiseur (synthesizer) reconnaît une machine à états, il y a de bonnes chances qu’il implémente la variable d’état avec un encodage one-hot. Autrement dit, il attribue une bascule d’un bit pour chaque état. Chacune de ces bascules est active quand la machine à états se trouve dans l’état correspondant. Disons que le synthétiseur a attribué une bascule appelée hot_state_0 à l’état nommé ST_START, et hot_state_1 à ST_NEXT. Bien entendu, le reset rend hot_state_0 active et désactive hot_state_1.
Remarquez maintenant que la machine à états passe sans condition de ST_START à ST_NEXT. Par conséquent, hot_state_0 devient inactif au premier coup d’horloge après la désactivation du reset, et hot_state_1 devient actif.
Mais que se passe‑t‑il si le reset devient inactif à un moment malencontreux, si bien que certaines bascules ratent le premier front d’horloge et que d’autres non ? Une possibilité est que hot_state_0 rate le premier front, mais que hot_state_1 y réponde. Résultat : les deux deviennent actives, ce qui est une condition illégale avec un encodage one-hot. Si c’est l’inverse, les deux registres deviennent inactifs et, en fait, tous les registres one-hot de la machine à états sont inactifs. Dans les deux cas, la machine à états risque de ne jamais pouvoir revenir à un état valide.
À quoi cela ressemble‑t‑il en pratique ? Cela dépend de l’application, bien sûr, mais il y a de fortes chances que quelque chose ne fonctionne pas correctement jusqu’à ce qu’un reset soit à nouveau appliqué sur le FPGA. Trouver la raison de ce comportement peut être extrêmement difficile, car ce problème apparaît de façon aléatoire, et pas forcément souvent. Il est également probable que le comportement diffère d’une compilation à l’autre d’un design FPGA, et peut‑être d’une carte à l’autre. En bref, c’est le genre de bug qui peut vous rendre fou. Cela ne semble peut‑être pas si grave dans cette discussion, puisque la source du problème est précisément le sujet de cette discussion. Mais quand une telle instabilité se produit dans la vraie vie, elle peut se manifester de n’importe quelle façon, et on a souvent l’impression que le FPGA est hanté.
Mais je fais ça tout le temps, et ça marche !
Effectivement. Dans la grande majorité des cas, ce n’est pas très grave si certaines bascules ratent le premier front d’horloge après le reset.
La principale raison pour laquelle l’exemple de machine à états ci‑dessus peut échouer, c’est qu’il quitte l’état initial dès le premier cycle d’horloge. La plupart des machines à états des circuits réels ont une règle pour quitter l’état initial, et elles y restent donc pendant les premiers cycles d’horloge. On peut donc s’en sortir avec cette erreur.
Mais voici un autre exemple. Un simple compteur :
reg [15:0] counter;
always @(posedge clk or negedge resetn)
if (!resetn)
counter <= 0;
else
counter <= counter + 1;
Dans ce cas, @counter est constitué de 16 bascules. Chacune de ces bascules reçoit sur son entrée de données la valeur que le compteur devra prendre au prochain cycle d’horloge, ainsi que @resetn sur son entrée de reset asynchrone.
Quand @resetn est actif, @counter passe à 0, et la valeur du compteur au prochain cycle est 1. Par conséquent, toutes les bascules sauf counter[0] restent à zéro, qu’elles ratent ou non le premier front d’horloge. Le compteur se met donc à compter correctement dans les deux cas. Dans la grande majorité des cas où ce genre de code est écrit, peu importe que le compteur ait raté ou non le premier cycle d’horloge.
En revanche, c’est une autre histoire avec ceci :
reg [15:0] counter;
always @(posedge clk or negedge resetn)
if (!resetn)
counter <= 0;
else
counter <= counter - 1;
Une petite différence, mais significative : si le compteur démarre à zéro et compte à rebours, la valeur du compteur au prochain cycle est 0xffff. Autrement dit, toutes les bascules doivent changer de valeur au premier front d’horloge après le reset. Donc si certaines répondent au premier front d’horloge après le reset et que d’autres ne le font pas, le compteur peut démarrer avec pratiquement n’importe quelle valeur aléatoire.
Mais qui remet un compteur à zéro pour ensuite compter à rebours ?
Voici donc un exemple plus réaliste : un signal d’activation d’horloge (clock enable) qui fait que la logique se comporte comme si la fréquence de l’horloge avait été réduite de moitié (et qui permet donc un chemin multicyle (multi-cycle path) si nécessaire) :
reg en;
always @(posedge clk or negedge resetn)
if (!resetn)
en <= 0;
else
en <= !en;
always @(posedge clk)
if (en)
[ ... do something ... ]
On pourrait penser que rien ne peut mal tourner ici : l’activation d’horloge (clock enable), @en, est une simple bascule, donc peu importe quand elle commence à basculer, n’est‑ce pas ? Le problème, c’est qu’un signal d’activation d’horloge a tendance à avoir une forte sortance (fan-out), et le synthétiseur pourrait le dupliquer pour ne pas dépasser la limite de fan-out.
Mon expérience anecdotique avec un synthétiseur Vivado a montré que chacune des bascules dupliquées qui implémentaient @en dépendait de sa propre sortie. Autrement dit, il n’y avait pas un signal unique que toutes les bascules utilisaient pour décider de leur prochaine sortie. Il y avait plutôt de nombreuses bascules indépendantes qui changeaient toutes leur valeur sur un front montant de l’horloge. Par conséquent, si ces bascules ne commencent pas à basculer au même cycle d’horloge, leurs sorties restent différentes indéfiniment.
Si un tel accident se produit, il est très probable que la logique ne fonctionne plus du tout. Donc si vous tenez à utiliser un reset asynchrone, assurez‑vous au moins que toutes les activations d’horloge reposent sur une source unique, par exemple comme ceci :
reg pre_en; // Apply some don't-touch synthesis directive on this
reg en;
always @(posedge clk or negedge resetn)
if (!resetn)
pre_en <= 0;
else
pre_en <= !pre_en;
always @(posedge clk or negedge resetn)
if (!resetn)
en <= 0;
else
en <= pre_en;
always @(posedge clk)
if (en)
[ ... do something ... ]
L’astuce est que @pre_en est la bascule qui décide de la valeur suivante. Cette bascule a une faible sortance et éventuellement un attribut qui dit au synthétiseur de ne pas y toucher. Ainsi, il s’agit à coup sûr d’une bascule unique. Toutes les bascules qui implémentent @en dépendent de @pre_en, donc elles sont toutes d’accord sur la valeur de @en au prochain coup d’horloge. Quant au premier front après le reset, peu importe que certaines bascules le ratent, car la valeur de @en au premier cycle est de toute façon zéro.
En résumé, utiliser un reset asynchrone de manière incorrecte passera en général sans problème, surtout parce que la logique tolère en général l’incertitude sur le moment où le reset devient inactif par rapport à l’horloge. Néanmoins, appliquer des resets asynchrones sans précaution peut conduire à des dysfonctionnements occasionnels extrêmement difficiles à résoudre.
Utiliser une contrainte temporelle entre le reset et l’horloge
Le moyen apparemment évident d’éviter la relation temporelle incertaine entre la désactivation du reset et le front montant de l’horloge est d’appliquer une contrainte temporelle (timing constraint) au signal de reset. Mais en faisant cela, le signal de reset devient synchrone.
Mais avec quelle horloge le signal de reset est‑il synchrone ? Il est souvent pratique d’avoir un reset asynchrone global pour tout le design. Ce reset est créé par une logique synchrone avec une horloge spécifique. Si ce reset est utilisé avec une logique synchrone avec une horloge différente, on a une traversée de domaine d’horloge (clock domain crossing). C’est un sujet en soi, mais le point le plus important est la possibilité que les outils ignorent le timing sur les chemins (paths) concernés.
Le point de départ pour utiliser des contraintes temporelles sur les resets asynchrones est donc d’avoir un reset asynchrone séparé pour chaque horloge. Ou, si vous y tenez, un reset asynchrone séparé pour chaque groupe d’horloges liées (related clocks). Sinon, une contrainte temporelle n’a aucun sens. Si cela paraît curieux, c’est parce que le reset asynchrone n’est plus asynchrone.
Le fait que le code Verilog utilise un motif de reset asynchrone n’y change rien. Peu importe aussi que l’entrée de reset asynchrone de la bascule soit effectivement utilisée, ou que la bascule soit configurée pour considérer son entrée de reset comme asynchrone : si le reset est synchrone avec une horloge et qu’une contrainte temporelle est appliquée, alors le reset est en pratique synchrone. Dans ce cas, autant utiliser directement le motif Verilog correspondant :
always @(posedge clk)
if (!resetn)
state <= ST_START;
[ ... ]
Cela dit, la vidéo YouTube d’Intel sur la fermeture temporelle (timing closure) recommande d’utiliser des resets asynchrones synchronisés avec une horloge, et d’y appliquer aussi des contraintes temporelles. Le choix d’un reset asynchrone vise à utiliser des ressources dédiées pour le routage global dans le FPGA. Je trouve cela assez curieux, car même le routage global peut avoir un délai important, en particulier sur les gros FPGA. Mais il existe sûrement des scénarios où cela a du sens.
En fait, il y a une autre raison de rester avec le reset asynchrone même quand il est effectivement synchrone, ce qui est abordé sur la page suivante : cela permet de propager l’activation du reset de manière asynchrone, ce qui est utile aussi bien dans les simulations que dans les tests pour ASIC.
Si vous utilisez cette méthode avec un reset asynchrone synchronisé, il est important de vous assurer que la contrainte temporelle est bien appliquée à tous les chemins qui mènent aux entrées de reset asynchrone des bascules. Le fait que le reset soit généré par une bascule synchrone avec exactement la même horloge que la bascule destinataire ne garantit pas en soi que le chemin entre elles soit pris en compte dans les contraintes temporelles. Par défaut, certains outils de timing ignorent tout chemin qui se termine sur une entrée asynchrone ; cette prise en compte doit donc peut‑être être activée explicitement. Veillez à vérifier dans les rapports de timing que ces chemins sont bien couverts par les contraintes temporelles. Il est facile de se faire piéger sur ce point.
Ceci conclut la première page de cette série consacrée aux resets. La page suivante aborde les différentes options pour les resets et l’initialisation du FPGA.