Ce chapitre a pour objectif de vous présenter les différents éléments structurels de SystemC :
A la fin de ce chapitre,
Un port d'un module est connecté à un canal (sauf dans le cas particulier où il est directement connecté au port d'un module parent). Les processus du module communiquent vers l'extérieur au travers de ces ports, en appelant les méthodes du canal auquel il est relié (ce canal est généralement un signal).
Une interface est un objet C++ ne contenant que les déclarations des méthodes du canal relié à un port, et donc accessibles par l'intérieur du module.
Le port est donc un trou dans un module, et l"interface un moyen de se mettre d'accord avec le canal qui y sera branché sur la manière de discuter.
Dit autrement, quand on accède à un canal depuis l'intérieur d'un module (à travers un port), on ne peut appeler que les méthodes de ce canal déclarées dans l'interface du port.
Quand on accède à un port, on accède en fait de manière cachée
aux méthodes du canal qui lui est relié par l'intermédiaire
de l'interface.
Voici les interfaces standards définies par SystemC, pour les canaux atomiques (signal, fifo, mutex, sémaphore) :
Il existe deux interfaces pour les signaux : sc_signal_in_if pour la lecture seule des signaux (connectés à un port sc_in), et sc_signal_inout_if qui rajoute la notion d'écriture (signaux connectés à un port sc_out ou sc_inout).
sc_signal_in_if<T> (pour les ports en lecture seule)
- bool event() : renvoie true si le signal associé a été modifié dans le delta_cycle précédent
- bool negedge(), bool posedge() : renvoie true si une transition vers l'état false (resp. true) a eu lieu au delta_cycle précédent
- T& read() : renvoie une référence à la valeur courante du signal associé
- print(ostream&) : affiche l'état courant du signal associé sur le flux ostream
- const sc_event& value_changed_event () : renvoie une référence à un événement qui sera notifié lors d'un changement d'état du signal associé. Cet événement sera typiquement utilisé dans les fonction wait().
- const sc_event& posedge_event() : renvoie une référence à un événement qui sera notifié lors d'un changement d'état vers true du signal associé (s'il est de type bool ou sc_logic seulement). Cet événement sera typiquement utilisé dans les fonction wait().
- const sc_event& negedge_event() : renvoie une référence à un événement qui sera notifié lors d'un changement d'état vers false du signal associé (s'il est de type bool ou sc_logic seulement). Cet événement sera typiquement utilisé dans les fonction wait().
sc_signal_inout_if<T> (pour les ports aussi en écriture)
- toutes les méthodes de sc_signal_in_if
- void write (const T&) : permet de changer la valeur du signal associé. Le changement sera effectif au prochain delta_cycle. Un événement ne sera notifié que si la nouvelle valeur diffère de la valeur actuelle.
Un buffer se comporte exactement comme un signal, à la différence près qu'un événement de changement d'état est notifié à chaque affectation, même si on lui affecte la même valeur que celle courante. Pour un sc_signal, le changement d'état n'est notifié que si la valeur change.
Il implémente les même interfaces que sc_signal.
Comme pour les signaux, les fifo disposent de deux interfaces selon si on y accède en lecture ou en écriture.
sc_fifo_in_if<T> :
- T read(), read(T&) : effectue une lecture bloquante de la fifo.
- bool nb_read(T&) : effectue une lecture non bloquante de la fifo. Renvoie true si une lecture a eu lieu, false sinon.
- int num_available() : renvoie le nombre d'élément en attente de lecture dans la fifo.
- sc_event& data_written_event() : renvoie une référence à un événement qui sera notifié lors d'une écriture dans la fifo. Cet événement pourra être utilisé dans une fonction wait().
sc_fifo_out_if<T> :
- write( T&) : effectue une écriture bloquante dans la fifo
- bool nb_write(T&) : effectue une écriture non bloquante dans la fifo. Renvoie true si elle été effectuée, false sinon
- int num_free() : renvoie le nombre d'emplacement libres dans la fifo
- sc_event& data_read_event() : renvoie renvoie référence à un événement qui sera notifié lors d'une lecture de la fifo. Cet événement pourra être utilisé dans une fonction wait().
sc_mutex_if :
- int sc_lock() : si le mutex n'est pas locké, la fonction le locke, sinon elle bloque jusqu'à ce qu'il puisse être locké.
- int try_lock() : si le mutex n'est pas locké, le locke. Renvoie 0 en cas de succès, -1 sinon.
- int unlock() : délocke un mutex. Les processus en attente de lock sur ce mutex seront alors réveillés.
sc_semaphore_if :
- int wait() : acquiert le sémaphore, et décrémente sa valeur de 1. Bloque s'il valait 0.
- int try_wait() : idem, en non-bloquant.
- int post() : relâche le sémaphore, et incrémente sa valeur de 1
- int get_value() : renvoie la valeur actuelle du sémaphore
Une interface est une classe C++ ne comportant que les déclarations des méthodes d'un canal auxquelles un port pourra accéder.
Les ports et les canaux sont des classes C++ qui héritent de leur classe de base et de celle de l'interface.
Le canal doit implémenter les méthodes en question.
Le port redirige les appels aux méthodes de son interface vers l'implémentation du canal.
Passons maintenant à l'étude des canaux.
Vous connaissez les signaux de VHDL ou Verilog. Les canaux sont une généralisation des signaux : un canal est fait pour transmettre de l'information entre un processus et un autre processus ou un port de module. En SystemC, cette information peut être extrêmement abstraite. Ca peut être un bit, un flottant, un packet ethernet, ou une information d'accès exclusif (exemple : "j'ai pris un sémaphore"). Les canaux ne sont pas forcément point à point : ils peuvent modéliser des bus, ...
Les signaux sont donc des types particuliers de canaux, correspondant à des bus ou des fils. SystemC en prédéfinit d'autres dont les principaux sont les buffers, les fifo, les sémaphores, les mutex.
Les canaux sont utilisés :
Les canaux implémentent les interfaces. Quand un processus accède à un port, c'est en fait la méthode du canal correspondant qui est exécutée. Cette méthode s'exécute dans le contexte du processus. Cela une importance particulière pour les fifo : un processus qui fait une lecture bloquante sur un port connecté à une fifo peut se retrouver bloqué (suspendu). Bien que la méthode exécutée soit implémentée par la fifo, c'est le processus dans le contexte duquel elle s'exécute (le processus appelant) qui est suspendu.
Il existe deux types de canaux :
- variables
- Pour transmettre une information qui n'a pas besoin d'affectation différée et pour accélérer à tout prix les simulations.
L'utilisation des variables pour communiquer de l'information entre processus est souvent une mauvaise habitude.
- canaux atomiques
- Pour transmettre de l'information qui a besoin d'affectation différée, et que le moyen de communication ne peut pas être découpé en éléments plus petits (c'est le cas des signaux...).
Les canaux atomiques simulent moins vite que les variables, mais plus vite que les canaux hiérarchiques.
- canaux hiérarchiques
- Dans tous les autres cas : quand le moyen de communication a plusieurs ports, quand il comporte des processus multiples, quand aucun des canaux atomiques ne convient...
Nous ne occuperons ici que des canaux atomiques, les canaux hiérarchiques étant des modules, ils seront étudiés plus tard.
Les signaux sont les principaux canaux atomiques. Ce sont des classes "templatisées" qui doivent donc être personnalisées par le type de la valeur sous-jacente transmise par le signal. On peut les voir comme des tuyaux, le T étant le type de liquide qui s'écoule dedans.
Exemples :
Les affectations des signaux sont toujours différées. Pour cela, ils possèdent trois membres : current_value, new_value et old_value. Une affectation modifie new_value, et à la fin du delta_cycle, si new_value est différente de current_value (au sens de "== renvoie false"), new_value est affectée à current_value et un événement est déclenché (on dit "notifié").
Les signaux ne peuvent être affectés que par un seul processus à la fois. Lors du démarrage de la simulation, une analyse est faite pour vérifier que chaque signal n'est connecté qu'à un seul port en écriture maximum. Le nombre de ports en lecture, par contre, n'est pas limité.
Les signaux implémentent l'interface sc_signal_inout_if. Ils implémentent donc automatiquement sc_signal_in_if (la première dérivant de la deuxième).
Les principales méthodes des signaux sont :
- value_changed_event(), default_event()
- renvoie une référence (pointeur de C++) à un événement qui peut alors être utilisé dans une instruction wait(). Cet événement est notifié (déclenché) au delta cycle suivant la modification de la valeur signal.
- posedge_event(), negedge_event()
- valide seulement pour les signaux de type bool ou sc_logic. Semblable à ci-dessus, mais seulement si le signal passe à true (resp false).
- read() (ou "=" s'il apparait à droite du signe égal)
- renvoie une référence à current_value
- write(const T& val) (ou "=" s'il apparait à gauche du signe égal)
- si val est différent de current_value, alors stocke val dans new_value, et schedule une mise à jour du signal.
- posedge(), negedge()
- renvoie un booléen, true si le signal a subi une transition vers true (resp. false) au delta_cycle précédent, false sinon.
- event()
- renvoie un booléen, true si le signal a été modifié au delta-cycle précédent, false sinon.
Exemples d'utilisation :
sc_signal<bool> enable, control;
sc_signal<sc_lv<32> > data;
long valeur;
enable = 0;
valeur = data; // peut donner un résultat indéfini si data contient un 'X' ou 'Z'...
if(enable==control) {...}
Similaire à sc_signal, à une différence près : sc_signal ne déclenche un événement que si sa nouvelle valeur est différente de la valeur courante, alors que sc_buffer déclenche un événement d'affectation quelque soit la nouvelle valeur.
Ce sont en fait des sc_signal<sc_lv<n> > et sc_signal<sc_logic>, autrement dit des signaux trois-états sur un ou plusieurs bits, avec une différence : ils peuvent être connecté à plusieurs ports en écriture à la fois. Ils sont utilisés pour modéliser des bus 3-états.
Ils possèdent une fonction de résolution, dont le comportement est intuitif :
Leurs méthodes sont les mêmes que pour un sc_signal<sc_lv<n> > et sc_signal<sc_logic>.
Comme son nom l'indique, ce canal implémente une fifo (abstraite), de longueur définie lors de son instanciation (paramètre de construction). C'est un moyen de communication point à point, autrement dit il ne peut avoir qu'un seul processus lecteur, et un seul écrivain.
Comme pour sc_signal, T peut être de n'importe quel type, pourvu qu'il dispose d'un opérateur de copie et de comparaison.
Cette fifo se comporte comme un signal : une écriture dedans n'est prise en compte qu'à la fin du delta-cycle courant. La valeur écrite ne pourra donc être lue au plus tôt qu'au delta-cycle suivant.
Elle implémente les interfaces sc_fifo_in_if et sc_fifo_out_if, déjà vues à la partie sur les interfaces.
Exemples d'utilisation :
// sc_fifo de longueur par défaut égale à 16 sc_fifo<int> fifo; int a,b; a = fifo.read(); b = fifo; fifo.write(a); fifo = b; if(fifo.num_available() > 3) a = fifo;
Ce canal implémente un mutex (un verrou d'accès concurrent). Il permet à plusieurs processus d'accéder à une ressource partagée de façon sûre : avant chaque accès, un processus est sensé essayer d'acquérir le mutex. S'il y arrive, le mutex est verrouillé, et seul ce processus-là peut le déverrouiller. Il peut alors accéder tranquillement à la ressource partagée : SystemC garantit qu'aucun autre processus ne pourra acquérir le verrou alors qu'il est verrouillé.
Si un processus essaye d'acquérir le verrou de façon bloquante (méthode lock() ) alors que le verrou est déjà pris, le processus est suspendu. Il ne sera réveillé qu'à la libération du verrou.
Attention le fait qu'un processus doit acquérir le verrou avant d'accéder à la ressource partagée est une convention. SystemC ne peut pas le vérifier.
Les mutex implémentent l'interface sc_mutex_if, vue à la partie sur les interfaces.
Exemples d'utilisation :
sc_mutex mutex;
...
while( true ) {
wait(clock.posedge_event());
mutex.lock();
... // do something
mutex.unlock();
}
Les sémaphores sont une généralisation des mutex à n accès concurrents. Le sémaphore est initialisé à sa construction avec une valeur n. Lorsqu'un processus veut accéder à une ressource partagée, il doit d'abord accéder au sémaphore. Si le sémaphore a une valeur >0, il est décrémenté et le processus peut accéder à la ressource partagée. Sinon, le processus est mis en veille jusqu'à ce que le sémaphore redevienne > 0. Lorsqu'un processus a fini d'accéder à la ressource partagée, il relâche le sémaphore, ce qui a pour effet de l'incrémenter (et éventuellement de réveiller des processus suspendus en attente du sémaphore).
Si plusieurs processus tentent d'acquérir le sémaphore pendant le même delta_cycle, un seul y arrivera. Mais celui qui y arrivera n'est pas déterminé (car l'ordre d'exécution des processus dans un delta_cycle n'est pas déterminé).
Les sémaphores implémentent l'interface sc_semaphore_if.
Exemples d'utilisation :
// instancie un semaphore à 5 accès concurrents
sc_semaphore sem(5);
// instancie un sémaphore à 1 accès concurrent = mutex
sc_semaphore mut(1);
while( true ) {
wait(clock.posedge_event());
sem.wait();
... // do something
sem.post();
}
int a;
sc_signal<int> b;
sc_signal<int> c;
void f() {
a = 2;
b = a;
c = b;
}
Les canaux sont les moyens de communications de SystemC, une sorte de généralisation des signaux de Verilog et VHDL.
Le type le plus utilisé, pour modéliser des fils et des bus, est le type sc_signal ou sc_rv (3-états)
Les canaux fournissent des méthodes d'accès (read, write, =) et déclenchent éventuellement des événements (accessibles par event). Les méthodes s'éxécutent dans le contexte (au sens UNIX) du processus appelant, autrement dit, si elles bloquent, c'est le thread du processus appelant qui est bloqué, pas le reste du canal.
Les méthodes implémentées par un canal sont déclarées dans une interface.
Passons maintenant à l'étude des ports .
Les ports sont les parties d'un module par lesquelles passent toutes les données qui y entrent ou en sortent. Plus précisément, c'est ce qui permet à un module d'accéder à l'interface d'un canal externe.
Un port hérite d'une interface particulière : il ne peut donc être relié qu'à un canal qui implémente la même interface. En pratique, tout ceci est caché, car on utilise souvent des ports spécialisés pour chaque type de signal.
La plupart des ports sont destinés à être connectés à des signaux. Ils possèdent donc les méthodes des interfaces propres aux signaux (read, write, event, ...). Lors de l'appel du la méthode read d'un port, tout ce que fait ce port c'est de forwarder cet appel à la méthode read du signal auquel il est relié.
Les ports sont soumis à des règles de connexion :
Les deux première règles sont des conventions SystemC. Les classes ont été écrites avec cette convention en tête, et fournissent des mécanismes pour essayer de vérifier (soit à la compilation, soit lors de l'exécution) que le programmeur les a bien respectées. Mais il n'est pas possible d'en faire une vérification totale. Il est donc possible de les transgresser, mais ce n'est vraiment pas une bonne idée !
Quand on déclare un port on a le choix :
C'est la solution la plus compliquée... Tant mieux, la prochaine façon de faire n'en sera que meilleure ! :)
Avant de faire une déclaration manuelle, il faut connaitre le nom de l'interface du canal qui lui sera connecté. Pour les canaux prédéfinis, les interfaces sont disponibles à la section interfaces.
Syntaxe : sc_port<interface_type, N> port_name, port_name,... ;
- interface_type est le nom de l'interface
- N est le nombre maximal de canaux qui pourront être connectés à ce port. 0 signifie "illimité". S'il n'est pas précisé, il vaut 1.
- port_name est le nom du port
Pour accéder au canal qui lui est relié, on utilise l'opérateur -> .
Si un port est relié à plusieurs canaux, on utilise l'opérateur [] pour spécifier auquel on s'adresse.Exemple de déclaration :
Exemple d'utilisation de ces ports :
Quand un port est relié à un signal ou une fifo, il existe des types de ports spécialisés, plus simples à instancier :
Type du canal Entrée Sortie Bidir sc_signal, sc_buffer sc_in sc_out sc_inout sc_signal_rv sc_in_rv sc_out_rv sc_inout_rv sc_signal_resolved sc_in_resolved sc_out_resolved sc_inout_resolved sc_fifo sc_fifo_in sc_fifo_out(pas de sens)
Exemple : le type sc_in<T> est équivalent à sc_port<sc_signal_in_if<T>, 1> .
On accède à ces types de port non pas par l'opérateur -> mais directement par l'opérateur "." .
Exemple :
Remarque : les anciennes versions de SystemC définissaient trois types spéciaux pour des horloges. Ces types sont maintenant obsolètes :
Les ports sont les moyens pour faire communiquer l'intérieur d'un module avec l'extérieur.
A l'extérieur du module, ils sont soit reliés à un canal, soit directement à un port du module parent.
On peut les instancier à la main, avec la classe sc_port<>, mais le moyen le plus simple est d'utiliser des classes dédiées : des ports spécialisés pour un type de canal.
Les principaux ports spécialisés sont :
- pour un sc_signal : sc_in, sc_out et sc_inout
- pour un sc_signal_resolved : sc_in_resolved, sc_out_resolved et sc_inout_resolved
- pour un sc_signal_rv : sc_in_rv, sc_out_rv et sc_inout_rv
On accède aux fonctions d'un port normal par "->" s'il est instancié à la main.
On accède aux fonctions d'un port spécialisé par "."
Maintenant qu'on connait les canaux et les ports, on peut rassembler le tout dans des modules. C'est l'objectif de la prochaine section.
Les modules en SystemC sont similaires aux modules de Verilog et VHDL. Ce sont les blocs structurels de base à l'intérieur desquels sont instanciés d'autres modules, des signaux, des canaux et des processus.
Les modules peuvent contenir les éléments suivants :
La déclaration d'un module se fait, de façon équivalente,
Comme toute classe C++, un module possède un constructeur. Le rôle du constructeur est de faire toutes les initialisations dont l'objet a besoin :
Le passage de paramètres au constructeur d'un module est typiquement la méthode qu'on utilise pour paramétrer un objet lors de son instanciation.
Le constructeur d'une classe C++ est une méthode (fonction) particulière qui porte le même nom que sa classe et n'a aucun type. On peut le déclarer de deux façons :
Comme d'habitude on regarde la solution la plus compliquée en premier...
Le constructeur d'un module se déclare comme un constructeur normal, en gardant à l'esprit qu'un de ses paramètres doit être de type sc_module_name : c'est le nom du module, qui sera utilisé par SystemC pour construire une hiérarchie des modules instanciés et produire éventuellement des messages de débug compréhensibles par les humains. Ce paramètre doit être transmis au constructeur de la classe de base (sc_module).
De plus, si le module contient des processus, il faut ajouter à la déclaration du module la macro SC_HAS_PROCESS, qui définit des symboles spéciaux utilisés par le scheduler SystemC.
Exemples :
On définit un module qu'on appelle mon_module, et on écrit son constructeur à la main. On utilise ici la syntaxe C++ qui appelle le constructeur de la classe de base en lui passant le paramètre name. Remarque : ici on aurait pu utiliser la deuxième méthode (voir plus loin) !
On définit maintenant un autre module, qu'on appelle memoire, en écrivant toujours son constructeur à la main. Mais ici on n'a pas le choix, car on va passer un argument supplémentaire au constructeur : le nom d'un fichier pour initialiser la mémoire.
Si on ne passe pas d'argument supplémentaire au constructeur, on peut utiliser une macro bien pratique : SC_CTOR, qui prend en argument le nom du module.
La macro SC_CTOR se charge d'appeller SC_HAS_PROCESS.Le premier exemple (cf ci-dessus) s'écrit alors :
Ce qui est quand même un peu moins verbeux.
Maintenant que le module a son constructeur, il faut instancier les variables, méthodes, sous-modules, canaux, processus, ... Le cas des processus sera vu au prochain chapitre. Occupons-nous pour l'instant des éléments structurels (canaux, sous-modules).
Comme d'habitude en C/C++, les variables (au sens large) peuvent être instanciées de façon statique ou dynamique. C'est aussi le cas en SystemC.
La déclaration statique des objets se fait comme vu
aux sections précédentes. Les objets instanciés sont alors des membres
(des variables) du module.
Prenons l'exemple du compteur, et rajoutons-lui un registre en sortie
(qu'on a déclaré sous le nom reg8) :
On peut aussi déclarer les objets internes dynamiquement : ce seront en fait des pointeurs vers les objets correspondant. C'est alors le constructeur qui se chargera d'allouer les objets. Bien sûr il ne faudra pas oublier de libérer la mémoire à la fin de vie du module : un bon endroit pour le faire serait dans le destructeur.
Le même exemple, en dynamique (le destructeur n'apparait pas) :
Dernière étape à la création des modules, la connexion des ports des sous-modules.
Cela se fait en appelant la méthode "port_du_sous_module", en lui passant en argument le signal auquel il est relié. Un bon endroit pour le faire est encore le constructeur !
Exemple :
Les modules sont les blocs structurels de base de SystemC. Ils peuvent contenir des canaux, des sous-modules, des variables, des méthodes, des processus...
On les déclare grâce à la macro SC_MODULE, qui prend en argument le nom (la classe) du module à déclarer.
On instancie les éléments internes du module comme des membres (variables) de la classe. A part les ports, ils peuvent être créés soit statiquement, soit dynamiquement (new).
Le constructeur, généralement écrit par la macro SC_CTOR, se charge d'allouer les objets dynamiques, et de relier les signaux internes aux sous-modules.
Ceci clôt le chapitre sur les modules. Passons à un bref résumé du chapitre avant d'attaquer la suite.
Vous savez donc maintenant déclarer un module, déclarer son constructeur, déclarer et instancier les éléments qui le composent (à part les processus), et les relier entre eux.
Vous connaissez aussi ses éléments structurels (canaux, ports, interface), leur rôle et leurs intercations.
Il ne reste plus qu'à voir comment écrire les processus, et vous pourrez écrire vos propres modèles SystemC. C'est l'objectif du prochain chapitre !