Maîtriser la vérification aléatoire contrainte avec OSVVM
La vérification aléatoire contrainte est une stratégie de banc de test qui repose sur la génération de transactions pseudo-aléatoires pour le device under test (DUT). Le but est d'atteindre une couverture fonctionnelle d'un certain nombre d'événements prédéfinis par une interaction aléatoire avec le DUT.
Open Source VHDL Verification Methodology (OSVVM) est une bibliothèque VHDL gratuite qui inclut un certain nombre de paquets pratiques pour créer des bancs de test à vérification aléatoire contrainte. Nous sommes particulièrement intéressés par RandomPkg et CoveragePck, que nous utiliserons dans cet article. Je recommande de visiter la page GitHub d'OSVVM pour en savoir plus sur les fonctionnalités de cette bibliothèque.
Le device under test
Je vais plonger directement dans un exemple pour mieux expliquer comment un banc de test à vérification aléatoire contrainte diffère du banc de test classique, qui utilise des tests dirigés. Nous avons créé une FIFO tampon circulaire dans l'article précédent de ce blog, mais nous n'avons pas créé de banc de test auto-vérifiant pour vérifier la correction du module.
Nous allons créer un banc de test approprié pour la FIFO tampon circulaire qui utilise la vérification aléatoire contrainte.
entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Port d'écriture wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Port de lecture rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Drapeaux empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- Le nombre d'éléments dans la FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer;
L'entité du module tampon circulaire est montrée dans le code ci-dessus. Nous allons traiter le DUT comme une boîte noire, ce qui signifie que nous n'assumerons aucune connaissance de la façon dont le DUT est implémenté. Après tout, cet article parle du banc de test, pas de la FIFO tampon circulaire.
Nous instancierons le DUT dans le banc de test en utilisant la méthode d'instanciation d'entité. L'instanciation est triviale, donc j'omettrai le code pour l'instant, mais il peut être téléchargé plus loin dans cet article.
Les génériques du DUT seront mappés aux valeurs suivantes :
- RAM_WIDTH: 16
- RAM_DEPTH: 256
Stratégie de banc de test
Passons en revue la stratégie de test avant de commencer à implémenter quoi que ce soit. L'image ci-dessous montre le concept principal du banc de test que nous allons créer.
Nous effectuerons des transactions d'écriture aléatoires du côté entrée du DUT. Les données d'entrée seront définies à une valeur aléatoire à chaque cycle d'horloge, et les strobes sur l'entrée write enable seront de durée aléatoire.
De même, nous effectuerons des lectures au hasard. Nous affirmerons le signal read enable en rafales qui durent un nombre aléatoire de cycles d'horloge.
Il y aura un modèle comportemental en parallèle avec le DUT. C'est une FIFO qui est implémentée différemment du tampon circulaire utilisé dans le DUT, mais qui a toujours la même interface. Contrairement au DUT, le modèle comportemental n'a pas à être synthétisable. Cela nous donne la liberté d'utiliser des fonctionnalités de programmation VHDL avancées pour le créer.
Nous comparerons la sortie du DUT avec la sortie du modèle comportemental dans un processus séparé. Ce processus sera uniquement responsable de faire cette comparaison à chaque cycle d'horloge en utilisant des instructions assert. Si les deux implémentations FIFO se comportent différemment à un moment donné, un échec d'assertion causera l'arrêt de la simulation avec une erreur.
Finalement, nous collecterons des données de couverture fonctionnelle en observant les transactions qui vont vers et viennent du DUT. Un point de couverture fonctionnelle pourrait signifier par exemple une lecture et écriture simultanées, ou que la FIFO est remplie au moins une fois. Nous surveillerons ces événements dans notre processus séquenceur principal du banc de test. La simulation sera arrêtée quand tous les événements de couverture fonctionnelle que nous surveillons auront eu lieu.
Importation de la bibliothèque OSVVM
La bibliothèque OSVVM peut être utilisée avec tout simulateur supportant VHDL-2008. Elle peut déjà être incluse avec les bibliothèques par défaut qui viennent avec votre simulateur. Elle est incluse dans l'édition ModelSim PE Student, qui peut être téléchargée gratuitement depuis Mentor Graphics.
ModelSim est livré avec une version plus ancienne d'OSVVM, mais c'est OK, elle a tout ce dont nous avons besoin. Nous pouvons juste aller de l'avant et importer les paquets random et coverage comme ceci :
library osvvm; use osvvm.RandomPkg.all; use osvvm.CoveragePkg.all;
La dernière version de la bibliothèque OSVVM peut toujours être téléchargée depuis la page GitHub. Faites-le si votre simulateur ne l'a pas incluse, ou si vous voulez utiliser les dernières fonctionnalités de la bibliothèque.
Déclaration des variables OSVVM
La bibliothèque OSVVM contient des paquets avec des types protégés. Les variables créées de ces types seraient limitées en portée au processus où elles ont été définies. Par conséquent, nous les déclarerons plutôt comme variables partagées dans la région déclarative de l'architecture du banc de test, comme montré dans le code ci-dessous.
-- Variables OSVVM shared variable rv : RandomPType; shared variable bin1, bin2, bin3, bin4, bin5, bin6 : CovPType;
La variable rv de type RandomPType est pour générer des valeurs aléatoires. Nous n'en avons besoin que d'une, car nous pouvons utiliser le même objet dans chaque processus où nous avons besoin de générer des valeurs aléatoires. La dernière ligne de code déclare six variables de type CovPType.
Nous avons déclaré six variables de couverture car nous allons avoir six objectifs de couverture, nous nous référons à ces objets comme « bacs » (bins). Les variables partagées doivent être initialisées avant de pouvoir être utilisées pour collecter des données de couverture. Nous le faisons en appelant la procédure AddBins sur chacun des bacs CovPType.
-- Configurer les bacs de couverture bin1.AddBins("Write while empty", ONE_BIN); bin2.AddBins("Read while full", ONE_BIN); bin3.AddBins("Read and write while almost empty", ONE_BIN); bin4.AddBins("Read and write while almost full", ONE_BIN); bin5.AddBins("Read without write when almost empty", ONE_BIN); bin6.AddBins("Write without read when almost full", ONE_BIN);
Nous fournissons une description en chaîne du bac de couverture comme premier paramètre à la procédure AddBins. Cette chaîne réapparaîtra à la fin de la simulation quand nous imprimerons les statistiques pour chaque bac de couverture. Comme vous pouvez le voir dans les descriptions textuelles, nous allons utiliser les bacs pour vérifier si certains cas de coin très spécifiques se sont produits.
AddBins est une procédure surchargée qui peut être utilisée pour créer plusieurs tableaux de score dans les variables de bac. Cependant, nous n'aurons qu'un tableau de score associé à chaque bac. Par conséquent, nous fournirons la constante de commodité ONE_BIN comme paramètre à la procédure AddBins. Cela initialisera les variables CovPType avec un bac chacune. Les tableaux de score représentés par les bacs sont considérés comme couverts quand les événements qu'ils surveillent se sont produits au moins une fois.
Génération d'entrée aléatoire
Commençons par créer le processus qui génère les données d'entrée pour le DUT. La FIFO tampon circulaire est conçue pour ignorer les tentatives d'écrasement et de sur-lecture. Par conséquent, nous pouvons simplement écrire des données aléatoires en rafales de durée aléatoire. Nous n'avons pas à penser si le DUT est réellement prêt à absorber les données ou non.
PROC_WRITE : process begin wr_en <= rv.RandSlv(1)(1) and not rst; for i in 0 to rv.RandInt(0, 2 * RAM_DEPTH) loop wr_data <= rv.RandSlv(RAM_WIDTH); wait until rising_edge(clk); end loop; end process;
La seule considération que ce processus prend est que le DUT n'est pas en reset. Nous activons ou désactivons aléatoirement le signal write enable dans la première ligne de ce processus, mais il ne sera activé que si rst est '0'.
La boucle for suivante écrira des données aléatoires au DUT pour un nombre aléatoire de cycles d'horloge, même si le signal enable n'est pas actif. Nous pouvons faire cela car le DUT est censé ignorer le port wr_data à moins que le signal wr_en ne soit '1'. Après la boucle for, le programme rebouclera au début du processus, déclenchant une autre transaction d'écriture aléatoire.
Effectuer des lectures aléatoires
Le processus qui lit les données du DUT est similaire au processus d'écriture. Nous pouvons activer aléatoirement le signal rd_en à tout moment car le DUT est conçu pour ignorer les tentatives de lecture quand il est vide. Les données qui apparaissent sur le port rd_data ne sont pas réellement vérifiées. Ce processus ne contrôle que le signal read enable.
PROC_READ : process begin rd_en <= rv.RandSlv(1)(1) and not rst; for i in 0 to rv.RandInt(0, 2 * RAM_DEPTH) loop wait until rising_edge(clk); end loop; end process;
Vérification comportementale
Nous allons construire un modèle comportemental du DUT dans notre banc de test pour vérifier son comportement. C'est une stratégie de banc de test bien connue. Premièrement, nous alimentons le modèle comportemental simultanément avec la même entrée que le DUT. Ensuite, nous pouvons comparer la sortie des deux pour vérifier si le DUT a le comportement correct.
L'image ci-dessus montre la disposition de base d'un tel banc de test. Le modèle comportemental fonctionne en parallèle avec le DUT. Nous l'utilisons comme plan pour vérifier les sorties du DUT.
La FIFO du banc de test
Nous utiliserons une liste chaînée pour implémenter le modèle comportemental. Les listes chaînées ne peuvent pas être synthétisées, mais elles sont parfaites pour les bancs de test. Vous vous souvenez peut-être de l'article Comment créer une liste chaînée en VHDL si vous êtes un lecteur régulier de ce blog. Nous utiliserons le code de cet article pour implémenter le modèle comportemental pour la FIFO tampon circulaire.
package DataStructures is type LinkedList is protected procedure Push(constant Data : in integer); impure function Pop return integer; impure function IsEmpty return boolean; end protected; end package DataStructures;
La déclaration de paquet pour la liste chaînée FIFO est montrée dans le code ci-dessus. C'est un type protégé qui a les trois fonctions ; Push, Pop, et IsEmpty. Elles sont utilisées pour ajouter et supprimer des éléments de la FIFO, ainsi que pour vérifier s'il reste zéro élément dedans.
-- Testbench FIFO qui émule le DUT shared variable fifo : LinkedList;
Les types protégés sont des constructions de type classe en VHDL. Nous créerons un objet de la liste chaînée en déclarant une variable partagée dans la région déclarative du banc de test, comme montré dans le code ci-dessus.
Le modèle comportemental
Pour émuler pleinement le comportement de la FIFO tampon circulaire, nous déclarons deux nouveaux signaux qui miroirent les signaux de sortie du DUT. Le premier signal contient les données de sortie du modèle comportemental, et le second est le signal valid associé.
-- Signaux de la FIFO du banc de test signal fifo_out : integer; signal fifo_out_valid : std_logic := '0';
Le code ci-dessus montre la déclaration des deux signaux de sortie du modèle comportemental. Nous n'avons pas besoin de signaux d'entrée dédiés pour le modèle comportemental, car ce sont les mêmes que ceux connectés au DUT. Nous utilisons des signaux pour émuler la sortie du DUT car cela nous permet de collecter facilement des données de couverture, comme nous le verrons plus tard dans cet article.
PROC_BEHAVIORAL_MODEL : process begin wait until rising_edge(clk) and rst = '0'; -- Émuler une écriture if wr_en = '1' and full = '0' then fifo.Push(to_integer(unsigned(wr_data))); report "Push " & integer'image(to_integer(unsigned(wr_data))); end if; -- Émuler une lecture if rd_en = '1' and empty = '0' then fifo_out <= fifo.Pop; fifo_out_valid <= '1'; else fifo_out_valid <= '0'; end if; end process;
Le processus qui implémente le modèle comportemental de la FIFO tampon circulaire est montré dans le code ci-dessus. Ce processus sera déclenché à chaque front montant de l'horloge, si le signal de reset n'est pas actif.
Le modèle comportemental pousse une nouvelle valeur dans la FIFO du banc de test chaque fois que le signal wr_en est affirmé tandis que le signal full est '0'. De même, la logique de lecture dans le processus du modèle comportemental fonctionne en écoutant les signaux rd_en et empty. Ce dernier est contrôlé par le DUT, mais nous vérifierons qu'il fonctionne dans le prochain processus que nous créerons.
Vérification des sorties
Toute la logique responsable de vérifier les sorties du DUT est rassemblée dans un processus nommé « PROC_VERIFY ». Nous utilisons des instructions assert pour vérifier que les sorties du DUT correspondent à celles du modèle comportemental. Nous vérifions aussi que le DUT et le modèle comportemental s'accordent sur le moment où la FIFO est vide.
Le code pour le processus de vérification est montré ci-dessous.
PROC_VERIFY : process begin wait until rising_edge(clk) and rst = '0'; -- Vérifier que DUT et TB FIFO signalent empty simultanément assert (empty = '1' and fifo.IsEmpty) or (empty = '0' and not fifo.IsEmpty) report "empty=" & std_logic'image(empty) & " while fifo.IsEmpty=" & boolean'image(fifo.IsEmpty) severity failure; -- Vérifier que les signaux valid correspondent assert rd_valid = fifo_out_valid report "rd_valid=" & std_logic'image(rd_valid) & " while fifo_out_valid=" & std_logic'image(fifo_out_valid) severity failure; -- Vérifier que la sortie du DUT correspond à la TB FIFO if rd_valid then assert fifo_out = to_integer(unsigned(rd_data)) report "rd_data=" & integer'image(to_integer(unsigned(rd_data))) & " while fifo_out=" & integer'image(fifo_out) severity failure; report "Pop " & integer'image(fifo_out); end if; end process;
Le processus est déclenché par le front montant de l'horloge, comme on peut le voir sur la première ligne de code. Le DUT est un processus horlogé, et la logique en aval connectée au DUT est aussi attendue comme synchrone au signal d'horloge. Par conséquent, il est logique de vérifier les sorties sur le front montant d'horloge.
Le second bloc de code vérifie que le signal empty venant du DUT n'est affirmé que quand la FIFO du banc de test est vide. Le DUT et le modèle comportemental doivent tous deux être d'accord que la FIFO est vide ou non pour que ce test passe.
Suit une comparaison des signaux valid de données lues. Le DUT et le modèle comportemental devraient sortir des données simultanément, sinon il y a quelque chose qui ne va pas.
Finalement, nous vérifions que les données de sortie du DUT correspondent au prochain élément que nous dépilons de la FIFO du banc de test. Cela, bien sûr, ne se produit que si le signal rd_valid est affirmé, signifiant que le signal rd_data peut être échantillonné.
Collecte des données de couverture
Pour contrôler le flux principal du banc de test, nous allons créer un processus séquenceur. Ce processus initialisera les bacs de couverture, exécutera les tests, et arrêtera le banc de test quand tous les objectifs de couverture auront été atteints. Le code ci-dessous montre le processus complet « PROC_SEQUENCER ».
PROC_SEQUENCER : process begin -- Configurer les bacs de couverture bin1.AddBins("Write while empty", ONE_BIN); bin2.AddBins("Read while full", ONE_BIN); bin3.AddBins("Read and write while almost empty", ONE_BIN); bin4.AddBins("Read and write while almost full", ONE_BIN); bin5.AddBins("Read without write when almost empty", ONE_BIN); bin6.AddBins("Write without read when almost full", ONE_BIN); wait until rising_edge(clk); wait until rising_edge(clk); rst <= '0'; wait until rising_edge(clk); loop wait until rising_edge(clk); -- Collecter les données de couverture bin1.ICover(to_integer(wr_en = '1' and empty = '1')); bin2.ICover(to_integer(rd_en = '1' and full = '1')); bin3.ICover(to_integer(rd_en = '1' and wr_en = '1' and empty = '0' and empty_next = '1')); bin4.ICover(to_integer(rd_en = '1' and wr_en = '1' and full = '0' and full_next = '1')); bin5.ICover(to_integer(rd_en = '1' and wr_en = '0' and empty = '0' and empty_next = '1')); bin6.ICover(to_integer(rd_en = '0' and wr_en = '1' and full = '0' and full_next = '1')); -- Arrêter le test quand tous les objectifs de couverture ont été atteints exit when bin1.IsCovered and bin2.IsCovered and bin3.IsCovered and bin4.IsCovered and bin5.IsCovered and bin6.IsCovered; end loop; report("Coverage goals met"); -- S'assurer que le DUT est vide avant de terminer le test wr_en <= force '0'; rd_en <= force '1'; loop wait until rising_edge(clk); exit when empty = '1'; end loop; -- Afficher les données de couverture bin1.WriteBin; bin2.WriteBin; bin3.WriteBin; bin4.WriteBin; bin5.WriteBin; bin6.WriteBin; finish; end process;
D'abord, nous initialisons les objets bac de couverture en appelant la procédure AddBins sur eux, comme nous l'avons déjà discuté plus haut dans cet article. Ensuite, après la libération du reset, nous passons à la collecte de données de couverture. À chaque front montant de l'horloge, le code à l'intérieur de la boucle sera exécuté.
Le premier bloc de code à l'intérieur de la boucle est pour la collecte de données de couverture. Nous pouvons appeler la procédure ICover sur le bac pour enregistrer un coup sur le point de couverture qu'il représente. Si nous fournissons le paramètre entier 0, l'appel n'aura aucun effet. Si nous utilisons le paramètre entier 1, cela comptera comme un coup.
Il n'y a qu'un seul « bac » à l'intérieur de chaque objet bac de couverture, car nous les avons initialisés en utilisant la constante ONE_BIN. Ce bac unique peut être atteint en appelant ICover(1). Nous pouvons enregistrer un coup ou un manquement sur le point de couverture en convertissant nos expressions booléennes en entiers 1 ou 0 en utilisant la fonction to_integer.
Après que les données de couverture ont été enregistrées, nous vérifions si tous les objectifs de couverture ont été atteints en appelant la fonction IsCovered sur tous les bacs. Ensuite, nous sortons de la boucle si tous les objectifs de couverture ont été atteints.
Nous nous assurerons que le DUT est vide avant de terminer le test. Pour y parvenir, nous reprenons le contrôle des processus d'écriture et de lecture en forçant le signal wr_en à '0' et le signal rd_en à '1'.
Finalement, nous imprimons les statistiques pour combien de fois chaque objectif de couverture a été atteint en appelant la fonction WriteBin sur chacun des bacs de couverture. Le mot-clé finish à la fin du processus fera arrêter la simulation par le simulateur.
Exécution du banc de test
Vous pouvez télécharger le projet ModelSim complet avec tous les fichiers VHDL en utilisant le formulaire ci-dessous.
Après avoir chargé le projet en exécutant le do-file inclus dans le Zip, nous pouvons lancer le banc de test simplement en tapant « runtb » dans la console ModelSim. Le temps d'exécution du banc de test sera aléatoire car les objectifs de couverture sont atteints au hasard. Cependant, les résultats de test sont reproductibles car c'est en fait une séquence pseudo-aléatoire qui est utilisée.
Nous n'avons pas initialisé de graine dans notre code, ce qui signifie que la valeur de graine par défaut sera utilisée pour le générateur pseudo-aléatoire. Il est possible de définir une graine différente en appelant la procédure InitSeed sur l'objet RandomPType, ce qui produira une séquence aléatoire différente.
La sortie console
La sortie affichée sur la console ModelSim après avoir donné la commande « runtb » est montrée ci-dessous. Il y aura beaucoup de poussées et retraits aléatoires de et vers la FIFO du banc de test pendant que la simulation s'exécute.
VSIM 2> runtb # ** Warning: Design size of 15929 statements or 2 leaf instances exceeds # ModelSim PE Student Edition recommended capacity. # Expect performance to be quite adversely affected. # ** Note: Push 34910 # Time: 790 ns Iteration: 0 Instance: /ring_buffer_tb ... # ** Note: Pop 37937 # Time: 83100 ns Iteration: 0 Instance: /ring_buffer_tb # ** Note: Pop 13898 # Time: 83110 ns Iteration: 0 Instance: /ring_buffer_tb # %% WriteBin: # %% Write while empty Bin:(1) Count = 2 AtLeast = 1 # # %% WriteBin: # %% Read while full Bin:(1) Count = 3 AtLeast = 1 # # %% WriteBin: # %% Read and write while almost empty Bin:(1) Count = 106 AtLeast = 1 # # %% WriteBin: # %% Read and write while almost full Bin:(1) Count = 1 AtLeast = 1 # # %% WriteBin: # %% Read without write when almost empty Bin:(1) Count = 1 AtLeast = 1 # # %% WriteBin: # %% Write without read when almost full Bin:(1) Count = 3 AtLeast = 1 # # Break in Process PROC_SEQUENCER at C:/crv/ring_buffer_tb.vhd line 127
Les statistiques pour tous les bacs de couverture sont imprimées quand tous les objectifs de couverture ont été atteints. Certains bacs n'ont été atteints qu'une fois, tandis qu'un a été atteint 106 fois. Mais à la fin, chaque bac a été atteint au moins une fois. Ainsi, nous pouvons savoir que tous les événements pour lesquels nous avons défini des bacs de couverture ont été testés et vérifiés.
La forme d'onde
Examinons la forme d'onde pour avoir une idée de ce que le banc de test a fait. L'image ci-dessous montre la forme d'onde avec le signal fill_count représenté comme une valeur analogique. La FIFO est pleine quand la trace pour ce signal est en haut, et vide quand elle est en bas.

Comme nous pouvons le voir sur la forme d'onde, le tampon circulaire est rempli et vidé au hasard. Pourtant, nous n'avons pas explicitement programmé ces pentes et déclins de niveau de remplissage. Néanmoins, nous voyons un motif d'interaction organique avec le DUT.
Plus sur la vérification aléatoire contrainte
La vérification aléatoire contrainte est une bonne stratégie de banc de test quand le vecteur de test a trop de permutations pour qu'un test exhaustif soit pratique. Les interactions aléatoires exhibent un comportement plus naturel qu'un test de cas de coin dirigé n'aurait fait, sans sacrifier la précision.
Nous pouvons être sûrs que tous les cas de coin ont été atteints, tant que nous avons correctement configuré la collecte de données de couverture. L'avantage supplémentaire est que les tests randomisés sont plus susceptibles d'exposer des faiblesses dans le DUT que vous ne testez pas spécifiquement. Tant que vous connaissez tous les cas de coin, vous pouvez créer des tests dirigés pour eux. Mais les cas de coin sont facilement oubliés, et c'est là que vous pourriez bénéficier de la méthodologie de vérification aléatoire contrainte.
Cet article n'a fait qu'effleurer la surface de ce que vous pouvez faire avec la vérification aléatoire contrainte. Je recommande de lire la documentation sur la page GitHub d'OSVVM pour creuser le sujet.
Je recommande aussi le cours Advanced VHDL Testbenches and Verification de SynthWorks, dont je ne suis pas affilié. Cependant, j'ai assisté à la version 5 jours de ce cours physique. Le cours est enseigné par Jim Lewis, le président du VHDL Analysis and Standardization Group (VASG). Dans l'ensemble, un excellent investissement pour toute entreprise qui veut amener ses bancs de test VHDL au niveau supérieur.
VHDL
- Cadence dévoile des solutions d'émulation et prototypage haute performance pour vérification SoC milliard de portes
- Synopsys dévoile une suite IP HBM3 complète pour systèmes multi-dies, portant la bande passante à 921 Go/s
- Système de contrôle d'accès intégré QR, RFID et vérification de température
- Les défis imprévisibles de la maintenance : Regards de l'expert Jason Afara
- Générer des Nombres Aléatoires en Java : Guide Pratique avec Random et Math.random
- Java 8 Streams : Approche déclarative du traitement de données
- Tour à lit incliné Hurco TM8 : design compact, commande précise, outillage polyvalent
- Conception PCB haute vitesse : traitement des signaux différentiels et vérification par simulation
- Programme de vérification des performances des compresseurs rotatifs CAGI : garantir un fonctionnement fiable et efficace