Passer d'un testbench en couches aux notions UVM | FPGA Pour Tous
Résumé
Passer d'un testbench en couches aux notions UVM
Construire un environnement complet et relier ses phases, sa configuration, ses callbacks et sa création de composants aux notions qu'UVM industrialise.
Ce qu'UVM apporte au patron déjà construit
UVM, pour Universal Verification Methodology, industrialise avec une bibliothèque de classes les responsabilités vues dans l'environnement en couches : objets de données, génération de scénarios, driver, monitor, modèle, scoreboard, couverture, environnement et test.
Il ne remplace ni le plan de vérification, ni le protocole, ni le modèle de référence. Il standardise surtout la construction, la configuration, les points d'extension, la création des composants et leurs communications.
Ce chapitre est une passerelle conceptuelle. Il montre comment préparer un environnement compatible avec ces idées sans présenter un pseudo-testbench UVM incomplet. Les séquences, le séquenceur, les objections et le graphe complet des phases nécessitent l'API UVM exacte du projet et de sa version.
Correspondance avec l'environnement en couches
Environnement SystemVerilog du cours
Notion industrialisée dans UVM
transaction et méthode de copie
objet de données avec opérations communes de copie et de représentation
driver, monitor, scoreboard, environnement, test
composants dans une hiérarchie nommée
build(), run(), wrap_up()
sous-ensemble pédagogique d'un mécanisme de phases plus riche
interfaces virtuelles passées aux classes
ressource de configuration transmise au bon composant
appels de callbacks
points d'extension enregistrés sans dériver tout le driver
publication du monitor vers plusieurs consommateurs
communication transactionnelle vers scoreboard et couverture
choix d'une classe dérivée à la construction
factory et override de type
Les responsabilités ne changent pas. Le driver applique, le monitor observe, la prédiction reste indépendante et le test choisit le scénario sans reconstruire l'infrastructure.
Phases : raisonner avant de mémoriser des noms
Le découpage minimal reste utile :
Build : créer la configuration, construire et connecter les composants, réinitialiser puis configurer le DUT ;
Run : lancer les activités concurrentes, exécuter le scénario, attendre la propagation des transactions et protéger l'exécution par un timeout ;
Wrap-up : vider les dernières réponses, vérifier les attendus restants, publier le verdict et conserver la couverture seulement si le run est valide.
Une bibliothèque UVM réelle découpe davantage ce cycle. La règle de conception reste stable : un constructeur initialise un objet sans consommer de temps ; le comportement temporel appartient à une tâche de phase ; le bilan ne commence qu'après la vidange.
Exemple complet : un environnement inspiré d'UVM
Le mot complet signifie ici que l'exemple instancie le DUT, génère les transactions, les applique, observe les deux côtés du DUT, calcule les résultats attendus, compare, mesure la couverture, protège la simulation par un timeout et produit seul PASS ou FAIL.
Ce code est un environnement SystemVerilog en couches prêt à être transposé vers UVM, pas une imitation partielle de la bibliothèque. Les noms exacts des classes, séquences, ports et phases UVM sont volontairement laissés à l'API UVM choisie. En revanche, toute la chaîne fonctionnelle à conserver pendant cette migration est présente et exécutable.
Le DUT de l'exemple accepte une paire d'octets lorsque in_valid && in_ready vaut 1. Il publie leur somme sur 9 bits, avec out_valid, au cycle suivant. Les réponses restent ordonnées et il existe exactement une réponse par commande.
1. Frontière signal et DUT
L'interface donne une vue de pilotage au driver et une vue passive aux monitors. Le clocking block impose le même contrat temporel à tous les composants.
// sum_dut.svmodule sum_dut(sum_if.dut bus); timeunit 1ns; timeprecision 1ps; assign bus.in_ready = 1'b1; always_ff @(posedge bus.clk or negedge bus.rst_n) begin if (!bus.rst_n) begin bus.out_valid <= 1'b0; bus.sum <= '0; end else begin bus.out_valid <= bus.in_valid && bus.in_ready; if (bus.in_valid && bus.in_ready) bus.sum <= {1'b0, bus.a} + {1'b0, bus.b}; end endendmodule
2. Objets, transactors, modèle, scoreboard et couverture
Le package suivant contient tout l'environnement. Chaque mailbox n'a qu'une responsabilité. Le monitor d'entrée publie des copies séparées vers le modèle et la couverture afin que deux consommateurs ne se volent jamais une transaction.
// sum_tb_pkg.sv - à compiler après sum_if.svpackage sum_tb_pkg; timeunit 1ns; timeprecision 1ps; typedef virtual sum_if.drv sum_drv_vif_t; typedef virtual sum_if.mon sum_mon_vif_t; class sum_item; randc logic [7:0] a; rand logic [7:0] b; rand int unsigned idle_cycles; logic [8:0] sum; constraint legal_idle { idle_cycles inside {[0:3]}; } function sum_item copy(); sum_item result; result = new(); result.a = a; result.b = b; result.idle_cycles = idle_cycles; result.sum = sum; return result; endfunction endclass class sum_config; int unsigned transaction_count = 80; time timeout = 20us; endclass class sum_generator; sum_item blueprint; mailbox #(sum_item) outbox; int unsigned generated_count; bit done; function new(mailbox #(sum_item) outbox); this.outbox = outbox; blueprint = new(); endfunction task run(int unsigned count); sum_item item; generated_count = 0; done = 0; repeat (count) begin if (!blueprint.randomize()) $fatal(1, "[GENERATOR] randomization failed"); item = blueprint.copy(); outbox.put(item); generated_count++; end done = 1; endtask endclass class sum_driver_callback; virtual task pre_drive(sum_item item); endtask endclass class sum_driver; sum_drv_vif_t vif; mailbox #(sum_item) inbox; sum_driver_callback callbacks[$]; int unsigned driven_count; function new(sum_drv_vif_t vif, mailbox #(sum_item) inbox); this.vif = vif; this.inbox = inbox; endfunction function void add_callback(sum_driver_callback callback); callbacks.push_back(callback); endfunction task run(); sum_item item; vif.drv_cb.in_valid <= 1'b0; forever begin inbox.get(item); foreach (callbacks[i]) callbacks[i].pre_drive(item); repeat (item.idle_cycles) @vif.drv_cb; vif.drv_cb.a <= item.a; vif.drv_cb.b <= item.b; vif.drv_cb.in_valid <= 1'b1; do @vif.drv_cb; while (vif.drv_cb.in_ready !== 1'b1); vif.drv_cb.in_valid <= 1'b0; driven_count++; end endtask endclass class sum_input_monitor; sum_mon_vif_t vif; mailbox #(sum_item) to_reference; mailbox #(sum_item) to_coverage; int unsigned accepted_count; function new(sum_mon_vif_t vif, mailbox #(sum_item) to_reference, mailbox #(sum_item) to_coverage); this.vif = vif; this.to_reference = to_reference; this.to_coverage = to_coverage; endfunction task run(); sum_item observed; forever begin @vif.mon_cb; if (vif.mon_cb.rst_n === 1'b1 && vif.mon_cb.in_valid === 1'b1 && vif.mon_cb.in_ready === 1'b1) begin observed = new(); observed.a = vif.mon_cb.a; observed.b = vif.mon_cb.b; to_reference.put(observed.copy()); to_coverage.put(observed.copy()); accepted_count++; end end endtask endclass class sum_output_monitor; sum_mon_vif_t vif; mailbox #(sum_item) to_scoreboard; int unsigned observed_count; function new(sum_mon_vif_t vif, mailbox #(sum_item) to_scoreboard); this.vif = vif; this.to_scoreboard = to_scoreboard; endfunction task run(); sum_item observed; forever begin @vif.mon_cb; if (vif.mon_cb.rst_n === 1'b1 && vif.mon_cb.out_valid === 1'b1) begin observed = new(); observed.sum = vif.mon_cb.sum; to_scoreboard.put(observed); observed_count++; end end endtask endclass class sum_reference_model; mailbox #(sum_item) inbox; mailbox #(sum_item) expected_out; int unsigned predicted_count; function new(mailbox #(sum_item) inbox, mailbox #(sum_item) expected_out); this.inbox = inbox; this.expected_out = expected_out; endfunction task run(); sum_item input_item; sum_item expected; forever begin inbox.get(input_item); expected = input_item.copy(); expected.sum = {1'b0, input_item.a} + {1'b0, input_item.b}; expected_out.put(expected); predicted_count++; end endtask endclass class sum_scoreboard; mailbox #(sum_item) expected_in; mailbox #(sum_item) actual_in; int unsigned expected_count; int unsigned observed_count; int unsigned compared_count; int unsigned errors; function new(mailbox #(sum_item) expected_in, mailbox #(sum_item) actual_in); this.expected_in = expected_in; this.actual_in = actual_in; endfunction task run(); sum_item expected; sum_item actual; forever begin expected_in.get(expected); expected_count++; actual_in.get(actual); observed_count++; compared_count++; if (actual.sum !== expected.sum) begin errors++; $error("[SCOREBOARD] a=%0h b=%0h expected=%0h actual=%0h time=%0t", expected.a, expected.b, expected.sum, actual.sum, $time); end end endtask task wrap_up(int unsigned requested, int unsigned generated, int unsigned driven, int unsigned accepted, int unsigned predicted, int unsigned output_observed, int unsigned covered, real coverage_percent); if (generated != requested || driven != requested || accepted != requested || predicted != requested || output_observed != requested || expected_count != requested || observed_count != requested || compared_count != requested || covered != requested) begin errors++; $error("[COUNTS] req=%0d gen=%0d drv=%0d in=%0d pred=%0d out=%0d exp=%0d obs=%0d cmp=%0d cov=%0d", requested, generated, driven, accepted, predicted, output_observed, expected_count, observed_count, compared_count, covered); end if (expected_in.num() != 0 || actual_in.num() != 0) begin errors++; $error("[LEFTOVERS] expected=%0d actual=%0d", expected_in.num(), actual_in.num()); end if (compared_count == 0) begin errors++; $error("[SCOREBOARD] no comparison was performed"); end if (errors == 0) $display("PASS: %0d comparisons, coverage=%0.2f%%", compared_count, coverage_percent); else $fatal(1, "FAIL: %0d error(s)", errors); endtask endclass class sum_coverage; mailbox #(sum_item) inbox; sum_item current; int unsigned sampled_count; covergroup operands_cg; option.per_instance = 1; a_cp: coverpoint current.a { bins zero = {0}; bins low = {[1:127]}; bins high = {[128:254]}; bins max = {255}; } b_cp: coverpoint current.b { bins zero = {0}; bins low = {[1:127]}; bins high = {[128:254]}; bins max = {255}; } operands_cross: cross a_cp, b_cp; endgroup function new(mailbox #(sum_item) inbox); this.inbox = inbox; operands_cg = new(); endfunction task run(); forever begin inbox.get(current); operands_cg.sample(); sampled_count++; end endtask function real percentage(); return operands_cg.get_inst_coverage(); endfunction endclass
3. Environnement, test et top
build() construit et connecte sans avancer le temps. run() lance les composants en parallèle, attend des comparaisons même lorsqu'elles échouent, puis laisse deux cycles de vidange. Le watchdog distingue ainsi une réponse absente d'une réponse fausse.
Ordre de compilation : sum_if.sv, sum_tb_pkg.sv, sum_dut.sv, puis tb_top.sv. Il faut activer SystemVerilog et utiliser un simulateur qui prend en charge classes, mailboxes, interfaces virtuelles et covergroups.
Le verdict PASS signifie que toutes les commandes de ce run ont été observées et correctement comparées. Il ne signifie pas que le plan de vérification a atteint 100 % : le pourcentage de couverture sert précisément à choisir les scénarios suivants.
4. Lire cet exemple avec le vocabulaire UVM
Élément exécutable
Responsabilité conservée lors de la migration
sum_item
objet transactionnel copiable et randomisable
sum_generator
production du scénario ; UVM sépare ensuite séquence et arbitrage
sum_driver
conversion transaction vers signaux à travers une interface virtuelle
monitors
observation passive et publication des transactions acceptées
sum_reference_model
prédiction indépendante du RTL
sum_scoreboard
appariement, comparaison, restes et verdict
sum_coverage
mesure séparée, déclenchée après observation réelle
sum_environment
construction, connexion, exécution, vidange et bilan
sum_test
choix du scénario, de la configuration et des extensions
Dans une bibliothèque UVM, la factory remplace les new aux points devant être substituables, la configuration distribue cfg et les interfaces virtuelles, et la publication du monitor alimente plusieurs abonnés. La migration ne doit modifier ni le contrat du DUT, ni l'oracle, ni les règles de terminaison.
5. Prouver que l'environnement détecte de vraies fautes
Faire trois mutations contrôlées du DUT :
remplacer temporairement + par - : les comparaisons se terminent et le scoreboard publie FAIL ;
forcer out_valid à 0 : le watchdog doit publier TIMEOUT ;
produire une réponse supplémentaire : le contrôle des compteurs ou des mailboxes restantes doit publier FAIL.
Ces tests négatifs vérifient le testbench lui-même. Une exécution nominale ne suffit pas à démontrer que l'oracle, la vidange et le verdict sont discriminants.
Configuration : distribuer sans variables globales
Un environnement réutilisable doit fournir à chaque composant les ressources dont il dépend : interface virtuelle, nombre de transactions, timeout, mode actif ou passif, configuration du DUT.
Une base de configuration associe conceptuellement :
un type de valeur ;
une portée dans la hiérarchie ;
un nom ;
la valeur elle-même.
Le composant récupère ensuite la ressource dans sa portée. Chaque récupération obligatoire doit être contrôlée immédiatement et produire un message explicite. Une clé mal orthographiée, un type différent ou une portée trop large ne doit pas créer un échec tardif et mystérieux.
Pour une interface virtuelle, conserver le même modport dans le type publié et le type demandé. Cela évite qu'un driver obtienne par erreur une vue de monitor ou contourne son clocking block.
Factory : remplacer un type sans modifier ses créateurs
Un constructeur SystemVerilog n'est pas virtuel. Si l'environnement écrit new partout, remplacer un driver de base par une version qui injecte des erreurs oblige à modifier chaque site de création.
La factory utilise un registre et un objet proxy pour choisir le type concret au moment de créer l'instance. Une création de composant UVM prend alors cette forme générale :
Après l'enregistrement prévu par la bibliothèque, un test peut demander qu'un type dérivé soit produit à la place du type de base. Le reste de l'environnement continue à manipuler le handle de base et les méthodes virtual sélectionnent le comportement réel.
La factory n'est utile que si la création passe effectivement par elle. Les objets purement locaux sans besoin de remplacement peuvent rester construits directement.
Callbacks et publication
Un callback est un point d'extension prévu : avant l'envoi, il peut retarder, supprimer ou corrompre une transaction ; après l'envoi ou l'observation, il peut alimenter modèle, scoreboard ou couverture.
L'ordre protège la justesse :
appliquer les callbacks qui modifient la transaction ;
réaliser le transfert ;
confirmer ce qui a réellement été accepté ;
seulement alors produire l'attendu et échantillonner la couverture.
Lorsqu'un monitor doit alimenter plusieurs consommateurs indépendants, une publication transactionnelle évite qu'il connaisse directement chaque scoreboard ou collecteur. Ajouter un abonné ne change alors pas le monitor. Il faut transmettre une transaction qui ne sera plus modifiée, ou une copie par consommateur si celui-ci peut la transformer.
Garder le test au-dessus de l'environnement
Un test spécialisé doit régler la configuration, remplacer éventuellement un type, modifier les contraintes du blueprint et installer des callbacks. Il ne recopie pas les classes communes. Cette séparation permet de lancer des tests nominaux, limites, erreurs et concurrence sur le même environnement.
UVM devient pertinent lorsque plusieurs interfaces, configurations, équipes ou niveaux d'intégration doivent réutiliser ces composants. Pour un petit bloc et quelques scénarios dirigés, le testbench auto-vérifiant du chapitre 12 reste souvent plus direct.
Chemin de migration concret
Faire passer un testbench simple avec un verdict autonome.
Introduire interface et clocking blocks pour stabiliser le temps.
Séparer transaction, driver, monitors, référence et scoreboard.
Ajouter Build, Run, vidange, Wrap-up et timeout.
Centraliser configuration et interfaces virtuelles.
Prévoir callbacks et création remplaçable seulement aux points utiles.
Migrer vers les classes et phases exactes d'UVM lorsque la réutilisation le justifie.
À chaque étape, injecter une erreur connue et vérifier que l'environnement échoue. Une architecture sophistiquée qui laisse passer un DUT fautif reste un mauvais testbench.
À retenir
UVM industrialise un environnement SystemVerilog en couches ; il n'en change pas les responsabilités.
Un exemple complet relie génération, pilotage, observation, prédiction, comparaison, couverture, timeout et verdict autonome.
Build, Run et Wrap-up forment un modèle mental utile, mais pas la liste complète des phases UVM.
La vidange attend les comparaisons terminées, pas seulement les comparaisons réussies ; une réponse fausse produit FAIL, une réponse absente produit TIMEOUT.
La configuration fournit localement ressources et interfaces virtuelles, avec contrôle immédiat des récupérations.
La factory rend un type remplaçable lorsque les objets sont créés par type_id::create.
Callbacks et publication étendent l'environnement sans coupler tous les composants.
UVM se justifie par la réutilisation et l'échelle, pas par le nom de la méthode.