Créer un testbench SystemVerilog de A à Z | FPGA Pour Tous
Résumé
Créer un testbench SystemVerilog de A à Z
Construire un testbench exécutable : instancier le DUT, produire horloge et reset, appliquer les scénarios, contrôler les sorties et terminer avec un verdict fiable.
Le résultat attendu
Un testbench est du code de simulation qui place le design à vérifier, le DUT (Design Under Test), dans des situations choisies. Il doit produire un verdict sans obliger à inspecter chaque forme d'onde.
À la fin de ce chapitre, le testbench saura :
instancier et connecter le DUT ;
générer l'horloge et le reset ;
appliquer des stimuli au bon moment ;
calculer ou mémoriser les résultats attendus ;
comparer automatiquement attendu et observé ;
détecter un blocage et terminer avec PASS ou FAIL.
Le fichier du testbench porte généralement un nom comme pulse_counter_tb.sv. Il n'est pas synthétisable et ne devient pas du matériel dans le FPGA.
Le chemin cyan applique les stimuli au DUT. Le chemin violet reconstruit ce qui s'est réellement produit. Le chemin vert calcule le résultat attendu indépendamment, puis le scoreboard confronte les deux chemins.
Partir du contrat, pas du code interne
L'exemple vérifie un compteur 4 bits nommé pulse_counter. Son contrat est volontairement court :
Port
Rôle
i_clk
le compteur agit au front montant
i_rst_n
reset asynchrone actif à 0
i_enable
à 1, le compteur avance ; à 0, il conserve sa valeur
o_count
résultat 4 bits, avec retour de 4'hF vers 4'h0
Avant d'écrire du SystemVerilog, cette table donne déjà les scénarios obligatoires : reset, maintien, comptage, opérations consécutives et débordement. Le testbench ne doit pas lire les signaux internes du compteur pour prédire le résultat.
La structure minimale
Le module de test n'a aucun port. Il déclare les signaux de connexion, instancie le DUT et contient les processus de simulation.
Les déclarations timeunit et timeprecision rendent les délais indépendants d'une directive située dans un autre fichier. Les connexions nommées .i_clk(clk) résistent mieux à une modification de l'ordre des ports qu'une instanciation positionnelle.
Le reset démarre actif. Le DUT voit ainsi une valeur déterminée dès ses premiers événements de simulation. Sa désactivation sera pilotée explicitement par le scénario.
Un testbench complet et auto-vérifiant
Voici une version complète. Elle pilote les entrées sur le front descendant, donc à distance du front montant utilisé par le DUT. Elle contrôle aussi la sortie sur un front descendant, après la mise à jour des registres du front montant précédent.
module pulse_counter_tb; timeunit 1ns; timeprecision 1ps; localparam time CLK_PERIOD = 10ns; logic clk = 1'b0; logic rst_n = 1'b0; logic enable = 1'b0; logic [3:0] count; int unsigned checks = 0; int unsigned errors = 0; pulse_counter dut ( .i_clk (clk), .i_rst_n (rst_n), .i_enable (enable), .o_count (count) ); always #(CLK_PERIOD / 2) clk = ~clk; task automatic check_count( input logic [3:0] expected, input string case_name ); checks++; count_matches_a: assert (count === expected) begin // Réussite : le compteur de contrôles suffit. end else begin errors++; $error("%s: count=%h, attendu=%h, temps=%0t", case_name, count, expected, $time); end endtask // Le premier appel a lieu sur un front descendant. Chaque appel revient // sur le front descendant suivant, prêt pour le scénario suivant. task automatic step_and_check( input logic next_enable, input logic [3:0] expected, input string case_name ); enable <= next_enable; @(negedge clk); check_count(expected, case_name); endtask initial begin : test_sequence logic [3:0] expected; expected = '0; // Le premier front montant se produit pendant le reset. @(negedge clk); check_count('0, "reset initial"); rst_n <= 1'b1; // Trois incréments consécutifs. repeat (3) begin expected++; step_and_check(1'b1, expected, "comptage actif"); end // enable=0 doit conserver la valeur. step_and_check(1'b0, expected, "maintien"); // Treize incréments depuis 3 doivent traverser 15 et revenir à 0. repeat (13) begin expected++; step_and_check( 1'b1, expected, $sformatf("test du modulo, attendu=%0d", expected) ); end // Vérifier aussi le reset après une période d'activité. repeat (3) begin expected++; step_and_check(1'b1, expected, "préparation du reset en activité"); end rst_n <= 1'b0; enable <= 1'b1; @(negedge clk); expected = '0; check_count(expected, "reset en activité"); rst_n <= 1'b1; enable <= 1'b0; if (checks == 0) $fatal(1, "FAIL: aucun contrôle exécuté"); if (errors == 0) begin $display("PASS: %0d contrôles", checks); $finish; end $fatal(1, "FAIL: %0d erreur(s) sur %0d contrôles", errors, checks); end initial begin : watchdog #(30 * CLK_PERIOD); $fatal(1, "FAIL: timeout à %0t", $time); endendmodule
Le modèle attendu est ici la variable expected. Il suit la règle fonctionnelle du compteur, sans reproduire son architecture RTL. Son type 4 bits reproduit naturellement le retour de 15 à 0.
Pourquoi séparer les fronts
Si le testbench pilote enable exactement au même front montant que le DUT le lit, l'ordre d'exécution peut créer une course : le DUT peut voir l'ancienne ou la nouvelle valeur selon l'ordonnancement des processus.
Pour ce petit DUT, la convention est simple :
piloter après un front descendant ;
laisser le DUT agir au front montant suivant ;
contrôler au front descendant suivant, quand les affectations non bloquantes du DUT sont terminées.
Cette convention n'ajoute aucun #1ns arbitraire au contrôle synchrone. Si le DUT travaille sur le front descendant, possède plusieurs horloges ou impose un protocole plus précis, il faut choisir une autre stratégie et la documenter.
Comparer les quatre états
Le checker affirme count === expected. L'égalité de cas compare aussi les valeurs X et Z et renvoie toujours un résultat vrai ou faux. Comme la valeur attendue ne contient ici que 0 ou 1, une sortie inconnue devient un échec clair. La forme équivalente pour la branche d'erreur serait if (count !== expected).
Pour un contrôle qui doit arrêter immédiatement la simulation, une assertion convient aussi :
$error permet ici de continuer et de regrouper plusieurs écarts. Le $fatal final donne malgré tout un statut d'échec à la campagne.
Factoriser le protocole avec des tâches
La tâche step_and_check cache les détails temporels. Le scénario exprime quoi tester tandis que la tâche décide quand piloter et observer. Pour un bus, on peut appliquer la même idée avec des tâches write, read ou send_packet.
Une petite tâche peut réunir pilotage et contrôle. Dès que le protocole grandit, il vaut mieux séparer driver, monitor, modèle de référence et scoreboard afin qu'une même erreur de code ne produise pas à la fois le stimulus et son verdict.
Passer à un clocking block
Sur une interface synchrone plus riche, un clocking block centralise les directions et les instants de pilotage ou d'échantillonnage.
Le test se synchronise alors avec @(bus.tb_cb), pilote bus.tb_cb.enable <= ... et lit bus.tb_cb.count. Il ne doit pas contourner le clocking block en accédant directement aux mêmes signaux. Par défaut, une entrée #1step est échantillonnée juste avant le front courant et une sortie #0 est pilotée au front courant. Les sorties du clocking block se pilotent avec des affectations non bloquantes.
La prise en charge des clocking blocks doit être vérifiée dans le simulateur utilisé. Le testbench complet précédent reste utilisable sans cette construction grâce à sa convention de fronts.
Séparer le banc physique du scénario temporel
Dans un petit test, un module unique et une convention de fronts opposés restent très lisibles. Pour un environnement en classes, la séparation suivante tient mieux à l'échelle :
un module instancie le DUT, les interfaces et le générateur d'horloge ;
une interface rassemble signaux, modports et clocking blocks ;
un program automatic, lorsque le simulateur le prend correctement en charge, contient le scénario et évite que ses variables soient statiques par défaut ;
les classes accèdent à l'interface physique par une interface virtuelle.
Un program ne contient pas de processus always : l'horloge reste donc dans le module. La fin de tous ses blocs initial peut terminer la simulation ; driver, monitor, watchdog et vidange doivent être coordonnés avant cette fin.
Ajouter #0 ou #1 à quelques lignes n'est pas une correction générale des courses. #0 ne garantit aucun ordre stable entre threads et #1 dépend des unités temporelles. Une convention d'échantillonnage explicite ou un clocking block exprime réellement le protocole temporel.
Adapter le patron à un DUT combinatoire
Un DUT purement combinatoire n'a ni horloge ni reset. La tâche applique les entrées, laisse les événements combinatoires se stabiliser, puis contrôle la sortie :
Le délai appartient au testbench, jamais au RTL combinatoire. Il doit être supérieur aux délais simulés que l'on souhaite laisser se propager, sans servir à masquer une mauvaise synchronisation.
Timeout, bilan et formes d'onde
Le watchdog empêche une attente infinie de faire tourner la simulation sans verdict. Sa limite doit être assez large pour le test normal, mais assez courte pour signaler rapidement un protocole bloqué.
Dans un environnement en couches, le bilan vient après une vidange : le générateur s'arrête, puis les transactions déjà acceptées traversent driver, DUT, monitors et scoreboard. Le wrap-up vérifie enfin qu'aucun attendu ne reste sans réponse et publie les nombres générés, observés, comparés et erronés.
Les formes d'onde restent un outil de diagnostic. Avec un simulateur compatible VCD, on peut ajouter :
initial begin $dumpfile("pulse_counter.vcd"); $dumpvars(0, pulse_counter_tb);end
On ouvre la trace après un échec pour comprendre le contexte. Elle ne remplace ni les comparaisons ni le message final.
Compiler et lancer
Avec Icarus Verilog, le mode SystemVerilog doit être activé et le module de tête précisé :
Si le projet contient des packages ou des interfaces, ils doivent apparaître avant les fichiers qui les utilisent. Un script ou une liste de fichiers évite que l'ordre change d'une exécution à l'autre.
Checklist avant de faire confiance au test
Le module de test n'a aucun port et le bon top est lancé.
Les largeurs, signes, noms et polarités de reset correspondent au DUT.
Les entrées sont initialisées avant leur première utilisation.
Le moment de pilotage est distinct du moment où le DUT échantillonne.
Chaque exigence importante possède au moins un scénario et un contrôle.
Les bornes, opérations consécutives, reset en activité et valeurs interdites sont prévus.
Un résultat X ou Z inattendu fait échouer le test.
Le watchdog, le compteur de contrôles et le bilan final sont actifs.
Le modèle attendu vient du cahier des charges, pas d'une copie du RTL.
À retenir
Un testbench fiable relie stimulus, valeur attendue, contrôle et verdict.
L'horloge et le reset font partie du scénario de vérification.
Une convention de fronts ou un clocking block évite les courses avec le DUT.
#0 et #1 ne remplacent pas une synchronisation définie.
Les tâches rendent le scénario lisible en cachant le protocole bas niveau.
!==, $error, $fatal, un watchdog et un compteur de contrôles rendent l'échec visible.
Les formes d'onde servent à expliquer une erreur déjà détectée automatiquement.