Ce document est publié sous la Creative Commons Paternité 2.0 France.
http://creativecommons.org/licenses/by/2.0/fr/legalcode
Image de couverture réalisée à l'aide du logiciel povray http://www.povray.org.
Voici les deux buts principaux de ce cours :
Pour ce qui concerne la conception de systèmes matériels, le cours se concentre sur des modèles de haut niveau écrits en SystemC, les notions élémentaires de conception de circuits intégrés sont mentionnées. Un simulateur de microprocesseur, un réseau sur puce sont réalisés. Ces architectures sont utilisées pour aborder la problématique de la répartition des tâches matérielles et logicielles.
Pour ce qui concerne la conception de systèmes logiciels c'est le langage C++ qui a été choisi. La Standard Template Library est présentée permettant de dégager les paradigmes importants utilisés en architecture logicielle.
Aucune hypothèse n'est faite sur les prérequis nécessaires pour suivre le cours. C'est pourquoi l'ensemble des notions C++ nécessaires à un usage raisonnable de SystemC est décrit. Il est cependant clair qu'une pratique préalable d'un langage de programmation permet de tirer un meilleur parti des différents chapitres et que de bonnes compétences en C++, Java ou C# constituent des atouts importants pour suivre le cours avec facilité.
Le chapitre 1 décrit les notions de C++ relatives aux aspects orientés objet. Des exemples simples de SystemC sont exposés.
Le chapitre 2 apporte un complément sur des notions fondamentales en C++ qui n'ont pas été abordées au chapitre précédent. La sémantique des modèles SystemC est décrite avec précision. Le but des exercices SystemC est de simuler la partie mémoire d'un microprocesseur simpliste Jasip.
Le chapitre 3 décrit les principales classes ainsi que l'architecture générale de la STL, la librairie normalisée de C++. Différentes méthodologies de test sont présentées. Les exercices visent à tester les modules créés dans le chapitre précédent.
Le chapitre 4 aborde les constructions internes utilisées dans SystemC.
Le but des exercices SystemC est de simuler un microprocesseur
multic
ur en se basant sur les architectures de réseau sur puce dont la modélisation
en SystemC va nous permettre de trouver le bon dimensionnement.
Le chapitre 5 présente l'usage des principaux motifs de conception (design patterns) souvent utilisés en C++. Les aspects méthodologiques en terme de flexibilité, de réutilisabilité de SystemC sont abordés. Différentes façons de partitionner les tâches entre matériel et logiciel sont présentées. Les exercices visent à évaluer l'opportunité de recourir à des modules spéficiques effectuant le calcul de la propagation d'une onde.
Le cours se base sur de nombreux exemples permettant d'illustrer sur des cas concrêts les concepts introduits. Chaque chapitre est suivi d'exercices, qui forment une partie importante de l'apprentissage. La difficulté de chaque exercice est indiquée avec un nombre d'étoiles :
Les exercices sont la plus part du temps corrigés en ligne à l'url :
Certains problèmes sont difficiles à résoudre entièrement. Il est alors utile de les décomposer en problèmes plus petits, puis d'assembler les solutions obtenues pour résoudre le problème d'origine.
Prenons l'exemple du jeu de solitaire. Le jeu se déroule sur un plateau contenant des positions qui peuvent être occupées ou non. Dans l'état initial toutes les positions sont occupées sauf le centre, comme illustré ci-contre. Le problème est de trouver la suite de déplacements permettant de finir avec un seul pion au milieu du plateau (c'est à dire à l'emplacement initialement vide). Les pions sont enlevés et déplacés en utilisant la règle suivante : un pion peut sauter par dessus l'un de ses quatre voisins immédiats (en haut, en bas, à droite ou à gauche) s'il arrive dans un emplacement vide. Le pion par dessus lequel le saut s'est effectué est alors retiré du plateau. Ci-dessous figure un exemple de déplacement :
La première forme simple à enlever est une rangée de trois pions (en noir sur le schéma) à l'aide d'un pion hachuré en utilisant la place vacante :
![]() |
étapes :
![]() |
Ainsi un nouveau mouvement a été mis en évidence : quand le motif des trois pions avec la case libre et le pion à utiliser est rencontré alors les trois pions peuvent être enlevés directement.
De manière naturelle au regard du découpage du plateau, la forme suivante est un bloc de 6 pions à retirer, en voici deux exemples :
Ce type de démarche est fréquent en informatique, elle s'applique par exemple à du matériel, où l'on cherche à s'abstraire du comportement des transistors, pour dégager des fonctionnalités, puis à nouveau en utilisant un niveau d'abstraction supplémentaire, des fonctionnalités sont regroupées en blocs communiquants entre eux.
De même, de nos jours la programmation en assembleur est peu fréquente, des concepts de plus haut niveau qui tendent à faire abstraction de la machine sont utilisés : ainsi le langage C fait abstraction des instructions du microprocesseur sous jacent, le langage Java tend à faire oublier au programmeur la manipulation de la mémoire.
Même si la programmation peut être un loisir, l'objectif principal est la réalisation de projets. Il est donc fondamental de prendre en compte les besoins nécessaires à la bonne marche des projets.
La gestion de projets vise à la réalisation, dans un temps imparti, d'objectifs fixés, en utilisant des ressources données. Voici quelques rappels sur les besoins nécessaires à une bonne gestion de projets. La suite de ce chapitre présente comment un langage comme C++ peut répondre à ces besoins.
Les personnes participant au projet sont une ressource essentielle. Chacun a ses compétences propres et tout le monde n'est pas interchangeable. Il faut souvent pouvoir au sein du même projet faire cohabiter du code écrit par des personnes ayant des compétences spécifiques, ou pouvoir réutiliser du code existant ayant déjà été écrit avant même le début du projet. Ainsi il est important que tout le monde ne travaille pas sur le même fichier, qu'il existe des espaces de nommage bien déterminés pour que les noms des variables ou des fonctions ne rentrent pas en conflit.
Le matériel, une autre partie importante des ressources est également à prendre en compte. Ce dernier pouvant changer rapidement (taille du disque dur, nouveau périphérique) il faut pouvoir abstraire si possible de tout comportement spécifique à un matériel. Ainsi le matériel est généralement manipulé via des classes d'objets comportant une partie publique : la liste des actions à effectuer sur le matériel, et une partie privée qui est spécifique au matériel. Lors de la prise en charge d'un nouveau matériel, il suffit alors théoriquement de changer la partie privée, mais la manipulation se fera de la même manière.
Le temps est un facteur fréquent d'échec de projets informatiques. Une bonne gestion de projets va définir des tâches et des jalons permettant de valider le bon avancement de ces tâches. Ce découpage, qui est une étape nécessaire, doit être rendu possible par un langage permettant une approche modulaire. La notion de classe d'objets permet d'avoir cette modularité.
L'approche modulaire implique une définition d'interfaces permettant aux différents modules de communiquer. Nous verrons qu'il est possible lors de l'écriture d'un programme de séparer la définition des classes objets de leur fonctionnement. Il suffit alors de fournir le fichier de définition aux personnes souhaitant utiliser la classe d'objets.
Une partie importante du projet est la définition du contenu du projet. Le contenu peut varier facilement, le plus souvent par des ajouts de nouvelles fonctionnalités, qui prises indépendamment les unes des autres sont mineures mais dont la somme est importante.
C++ permet facilement de manipuler des objets ayant des niveaux d'abstraction différents. Cela permet d'avoir dès les premières étapes du développement des prototypes permettant de cerner au plus tôt le périmètre du projet.
Nous avons précédemment évoqué les notions de classe, d'espace de nommage, d'actions à effectuer sur un objet. Nous allons ici développer ces notions. Une série d'exemples propose une approche intuitive au langage de programmation C++. C++ est une norme ISO [iso03] tout comme C [iso99]. Bjarne Stroustrup, à l'initiative de C++, a écrit un ouvrage de référence sur C++ : The C++ Programming Language [Str97].
unsigned char c1 = 255; // définition de c1 comme un unsigned char valant 255 c1=c1+1; // incrémenter c1 de 1, c1 vaut maintenant 0 char c2 = 127; // définition de c2 comme un char valant 127 c2=c2+1; // c2 vaut maintenant -128 short s1 = 32767; s1=s1+1; // s1 vaut maintenant -32768 unsigned short s2 = 65535; s2=s2+1; // s2 vaut maintenant 0 int i=2147483647; i=i+1; // i vaut maintenant -2147483648 unsigned int j=4294967295; j=j+1; // j vaut maintenant 0 unsigned long long int lli = 0; lli=lli-1; // lli vaut maintenant 18446744073709551615=2^64-1Toutes les instructions se terminent par le caractère ;. Les commentaires sont soit compris entre // et la fin de la ligne ou entre /* et */. Voici un exemple :
// ceci est un commentaire sur une ligne /* ceci est un commentaire sur plusieurs lignes */
Outre les opérations arithmétiques il existe des opérations dites bit à bit sur les entiers. Citons en particulier :
En C++ on peut définir des classes d'objets. Par exemple une voiture est une classe d'objet. Les éléments appartenant à la classe sont nommés instances. Par exemple ma voiture est une instance de la classe d'objets voiture. Plus généralement une classe représente un ensemble d'objets ayant un comportement similaire. Voici la syntaxe C++ pour définir ces concepts :
class Voiture { }; // définition de la classe Voiture.
Voiture maVoiture; // création d'une instance particulière nommée maVoiture.
La première ligne déclare une classe nommée Voiture. La syntaxe est la suivante :
Les langages informatiques permettent d'appréhender un monde complexe où des objets de différentes échelles vont pouvoir être exprimés : une voiture, le régulateur de vitesse de la voiture, le microprocesseur dans le régulateur de vitesse, etc... . Seule une approche modulaire permet d'exprimer une telle différence d'échelle. C'est pourquoi une classe peut être considérée comme une unité logique d'expression ou la portée logique de certaines notions.
Ainsi on peut définir d'autres concepts qui peuvent interagir entre eux : par exemple la voiture contient 4 roues et un régulateur de vitesse. Le régulateur de vitesse contient lui même un microprocesseur :
class Microprocesseur { };
class Pneu { };
class Regulateur {
Microprocesseur proc; // déclaration d'une variable au sein de la classe
};
class Voiture {
Pneu pneuAvantDroit;
Pneu pneuAvantGauche;
Pneu pneuArriereDroit;
Pneu pneuArriereGauche;
Regulateur regulateurDeVitesse;
};
Une variable déclarée au sein d'une classe est nommée champ de la classe, ainsi
la classe Regulateur comporte un champ proc qui est une instance
de Microprocesseur. Ainsi toute instance de Regulateur comporte
une instance de Micropro cesseur qui lui est propre.
La classe étant une portée logique il faut définir ce qui est visible de l'extérieur
(c'est à dire ce qui est public) et
ce qui est «caché» à l'intérieur (c'est à dire ce qui est privé). Nous allons définir
une classe d'objets nommée Voiture, où sur les instances de cette classe
il sera possible
d'effectuer les actions suivantes : ouvrir ou fermer une porte.
Nous allons modéliser l'état de la porte par un booléen, c'est le
type C++ bool
(à true pour ouvert et à false pour fermé) :
class Voiture { // définition de la classe Voiture
public:
void ouvrePorte() { // définition de la méthode ouvrePorte.
etatPorte=true; // affecte la valeur true à la variable etatPorte
}
void fermePorte() { // définition de la méthode fermePorte.
etatPorte=false; // affecte la valeur false à la variable etatPorte
}
private:
bool etatPorte; // définition d'une variable booléenne, etatPorte.
};
int main() { // définition d'une fonction nommée main
Voiture maVoiture; // création d'une variable nommée maVoiture
maVoiture.ouvrePorte(); // appel de la méthode ouvrePorte
// sur l'instance maVoiture
return 0; // retourne la valeur zéro, puisque l'exécution
// s'est bien déroulée.
}
Les déclarations situées après l'attribut public: sont publiques,
c'est à dire accessibles depuis l'extérieur de la classe (la fonction main
appelle ainsi la méthode ouvrePorte qui est publique).
Les déclarations situées après private: sont privées,
ainsi la fonction main ne pourrait effectuer l'instruction
maVoiture.etatPorte=true, seules le méthodes de la classe Voiture
peuvent manipuler les champs ou les méthodes privées.
Les actions effectuées au sein d'une classe sont nommées méthodes. Par exemple
ouvrePorte est une méthode de la classe Voiture. Ici void
est le type renvoyé par la méthode ouvrePorte,
en l'occurrence rien du tout, et les parenthèses
() contiennent la liste des arguments de la fonction ou de la méthode,
en l'occurrence aucun argument.
La définition de la méthode suit alors entre les accolades { }.
Hors d'une classe les actions sont regroupées en fonctions. Dans l'exemple précédent, on trouve une fonction nommée main, ne prenant aucun argument et renvoyant un entier int. Par convention la fonction nommée main est la fonction appelée au début du programme. L'instruction return 0 retourne la valeur zéro et signifie par convention que l'exécution du programme s'est bien déroulée.
Voici la syntaxe de la déclaration d'une fonction ou d'une méthode :
Dans l'exemple précédent à la section 1.3.3 un point n'est pas précisé : en effet dans la fonction main après la création de la variable maVoiture à la ligne 13 et avant l'appel à la méthode ouvrePorte à la ligne 14, que vaut la variable etatPorte de l'instance maVoiture ? Sa valeur n'est pas définie, cela ne pose pas de problème dans cette exemple précis car elle n'a pas été lue. Il est cependant souhaitable que tous les champs d'une instance soient initialisés correctement dès sa création. Dans ce but, on rajoute une méthode un peu particulière dénommée constructeur, automatiquement appelée à la création de l'objet.
Nous allons définir une classe comportant deux constructeurs : un constructeur sans argument où l'état de la porte est initialisé à false, et un autre constructeur permettant de choisir la valeur de l'état initial de la porte.
class Voiture {
public:
// constructeur par défaut (sans arguments), la porte est fermée
Voiture () {
etatPorte=false;
}
// constructeur où l'état initial est précisé
Voiture (bool etatInitial) {
etatPorte=etatInitial;
}
private :
bool etatInitial;
}
int main() { // définition d'une méthode nommée main
Voiture maVoiture (true); // création de variable et appel de constructeur
// ou l'état initial est précisé.
Voiture monAutreVoiture; // appel implicite au constructeur par défaut.
return 0;
}
Symétriquement au constructeur un destructeur nommé ~Voiture est appelé quand
la variable est détruite. Dans l'exemple ci-dessous, l'état de la porte
est fixé à true quand l'instance de la classe est détruite :
class Voiture {
public:
// constructeur par défaut, la porte est fermée
Voiture () {
etatPorte=false;
}
// destructeur : ouvre les portes avant de détruire l'objet.
~Voiture () {
etatPorte=true;
}
private :
bool etatInitial;
}
int main() {
Voiture maVoiture; // appel au constructeur par défaut.
return 0;
} // en fermant l'accolade toutes les variables créées dans la fonction sont
// détruites, les destructeurs sont alors appelés.
Ici l'action d'affecter true au champ etatPorte au moment de la destruction
peut sembler dérisoire. En effet à quoi cela sert-il puisque l'objet va être supprimé de la mémoire ? Si le code C++ pilote des équipements externes, affecter certaines valeurs à des variables
peut conditionner effectivement le déplacement d'une porte. Le plus souvent le code
que l'on trouve dans le destructeur consiste à libérer la mémoire qui a été allouée
tout au long de la vie de l'objet. Le chapitre 2 détaille les aspects
liés à l'allocation dynamique de mémoire.
Le fait de disposer de deux fonctions ayant le même nom mais des arguemnts différents (comme pour les constructeurs dans l'exemple précédent) est nommé surcharge.
Lorsque l'on manipule une série de données de même type, il est souvent utile de les indexer par un entier, on peut alors parler du 3eme objet, de l'objet suivant, ou du précédent. Les structures de données les plus simples permettant de faire cela sont les tableaux.
Nous allons définir un tableau comportant quatre instances de la classe Voiture :
// définit la classe Voiture
class Voiture {
public :
Voiture() { valeur = -1;} // constructeur par défaut.
Voiture(int prix) { valeur = prix; }
private :
int valeur;
};
// définit un entier initialisé à la valeur 4
int maxNombreDeVoitures = 4;
// définit un tableau à maxNombreDeVoitures éléments de type Voiture
Voiture monTableau [maxNombreDeVoitures];
Dans cet exemple les indices du tableau vont de 0 à 3 (c'est
à dire maxNombreDeVoitures-1). On remarque
que le constructeur attend un argument i qui est de type int.
Voici par exemple une initialisation du tableau
dans la fonction main :
int main() {
monTableau[0]=Voiture(2700); // initialise le 1er élément du tableau
monTableau[1]=Voiture(2334);
monTableau[2]=Voiture(4750);
monTableau[3]=Voiture(4406); // initialise le 4eme élément du tableau
return 0;
}
Dans l'exemple ci-dessus on aurait également pu initialiser le contenu du
tableau au moment de sa déclaration de la manière suivante :
Voiture monTableau [] = {
Voiture(2700),
Voiture(2334),
Voiture(4750),
Voiture(4406)
};
int main() {
return 0;
}
On remarque alors qu'il n'y pas besoin
de préciser la taille du tableau entre crochets, puisqu'elle est
donnée par le nombre d'éléments.
Il est à noter que si main est la première fonction exécutée,
les constructeurs des variables globales du programme (ici les constructeurs
des éléments du tableau) vont être exécutés avant la fonction main.
Nous avons abordé dans les contraintes de la gestion de projets la possibilité de réutiliser du code. Par exemple supposons l'existence une classe représentant une voiture nommée Voiture1 comporte 4 pneus. Puis un peu plus tard apparaît le besoin de disposer d'une classe Voiture2 comportant 4 pneus d'une marque différente. On peut bien entendu réécrire la classe Voiture2 ex nihilo. Mais plus tard le besoin pourrait se faire sentir pour encore d'autres marques de pneus : des Michelins, des Goodyears, des Bridgestones, des Pirellis, où les mêmes opérations sont effectuées sur la voiture quelque soit la marque de pneus. Pour ne pas réécrire à chaque fois une nouvelle classe, nous allons déclarer un type générique qui pourra être n'importe quel type de pneu :
template <class Pneu> class Voiture {
public:
Voiture () { }
void retirePneu(int i) { mesPneus[i].retire();}
void mettrePneu(int i) { mesPneus[i].mettre();}
private:
Pneu mesPneus[4]; // conserver les 4 pneus dans un tableau
};
On utilise ici une classe Pneu qui n'a pas encore été définie.
On dit que Voiture est une classe template ou patron.
On s'attend juste à pouvoir appeler sur les instances
ayant le type Pneu les méthodes retire et mettre.
Si l'on veut alors créer des instances de Voiture, il faut préciser le type de pneus. Considérons alors un type Michelin et un type Bridgestone :
class Michelin {
public :
Michelin() { etatPneu=false;}
void mettre() { etatPneu=true;}
void retire() { etatPneu=false;}
private :
bool etatPneu;
};
class Bridgestone {
public :
Bridgestone() { etat=0;}
void mettre() { etat=1;}
void retire() { etat=0;}
private :
int etat;
};
int main() {
// créer une voiture avec des pneus Michelin :
Voiture<Michelin> maVoiture();
maVoiture.mettrePneu(0);
maVoiture.mettrePneu(1);
maVoiture.mettrePneu(2);
maVoiture.mettrePneu(3);
// créer une voiture avec des pneus Bridgestone :
Voiture<Brigestone> autreVoiture();
return 0;
}
Tout d'abord deux classes Michelin et Bridgestone sont définies.
Elles ont des mécanismes différents (l'une conserve l'état dans un booléen,
l'autre dans un entier), mais implémentent toutes deux les
méthodes mettre et retire. Ces méthodes étant définies,
on peut dans la suite du programme parler des types
Voiture<Michelin> ou Voiture<Brigestone>.
Ces deux types sont nommés instance de template.
L'exercice 1.5 propose une approche intensive de l'usage des templates permettant de mieux comprendre le mécanisme de typage de C++.
Il existe des types particuliers de voitures : par exemple la voiture de sport, ou la voiture de luxe. A chaque fois les caractéristiques de base de la voiture sont présentes, mais avec des possibilités supplémentaires. Il serait fastidieux de réécrire ce qui a été déjà fait dans la classe modélisant une voiture, par ailleurs en terme de maintenance le travail serait plus important. Nous souhaitons simplement étendre le concept de voiture en ajoutant des données supplémentaires. Ce mécanisme se nomme héritage.
Voici comment à partir d'une classe Voiture définir une classe VoitureDeSport reprenant les méthodes de Voiture et comportant un champ de type SuperMoteur :
// définir une voiture
class Voiture {
public:
Voiture () { etatVoiture = false; }
void demarre() { etatVoiture=true; }
void arrete() { etatVoiture=false; }
private:
bool etatVoiture;
};
class SuperMoteur { };
class VoitureDeSport : public Voiture {
public :
VoitureDeSport () { arrete(); }
private :
SuperMoteur leMoteur;
};
On a tout d'abord définit une classe Voiture et une classe SuperMoteur.
Ensuite la syntaxe class VoitureDeSport : public Voiture
crée une définition de classe se basant sur Voiture. C'est-à-dire
que toute instance de VoitureDeSport pourra également être considéré
comme une instance de Voiture.
Tous les champs
et toutes les méthodes publiques de Voiture, la classe parent,
sont disponibles dans la classe fille VoitureDeSport.
Dans la classe VoitureDeSport on ne peut pas manipuler le champ etatVoiture qui est déclaré avec l'attribut private. Pour rendre le champ etatVoiture disponible dans les classes héritées sans pour autant qu'il soit publique on peut le déclarer protected. Voici un exemple équivalent au précédent :
// définir une voiture
class Voiture {
public:
Voiture () { etatVoiture = false; }
void demarre() { etatVoiture=true; }
void arrete() { etatVoiture=false; }
protected:
bool etatVoiture;
};
class SuperMoteur { };
class VoitureDeSport : public Voiture {
public :
VoitureDeSport () { etatVoiture=false; }
private :
SuperMoteur leMoteur;
};
Reprenons l'exemple précédent, en ajoutant une méthode demarre différente dans la voiture de sport :
class VoitureDeSport : public Voiture {
public :
VoitureDeSport () { etatVoiture = false; }
void demarre() { etatVoiture=true; leMoteur.demarre(); }
private :
SuperMoteur leMoteur;
};
Supposons que l'on dispose d'une fonction, nommée testVoiture, permettant de tester le comportement d'une voiture :
void testVoiture ( Voiture v) {
// effectue les tests:
v.demarre();
v.arrete();
}
Une instance de VoitureDeSport pouvant également être considérée
comme une instance de Voiture on peut appeler la fonction testVoiture sur
une instance de VoitureDeSport.
Lorsque l'on appelle testVoiture avec un objet de type VoitureDeSport
on souhaite que la méthode demarre appelée soit celle définie dans
VoitureDeSport. Ce n'est pas le cas dans avec le code écrit
jusqu'ici. Pour que cela soit le cas, il faut déclarer la méthode demarre
avec l'attribut virtual dans la classe Voiture :
// définir une voiture
class Voiture {
public:
virtual void demarre() { ... }
// ...
};
class VoitureDeSport : public Voiture {
public :
void demarre() { ... }
// ...
};
Si on omet l'attribut virtual dans la classe Voiture
alors dans la fonction testVoiture c'est la méthode
demarre correspondant à la classe Voiture
et non à VoitureDeSport qui est appelée.
Généralement plusieurs personnes travaillent simultanément sur un projet. Ainsi quand une personne p1 écrit une classe voiture, pour qu'une personne p2 s'en serve, il n'y pas nécessairement besoin de connaître exactement le contenu de chaque méthode mais seulement d'avoir une idée du squelette de la classe créée.
Imaginons que p1 souhaite écrire une classe représentant une voiture, et que outre le constructeur la seule méthode sur cette classe renvoie dans un entier le prix de la voiture. Alors p1 peut écrire le code relatif à cette voiture dans deux fichiers : un fichier d'entête (header file) se terminant par .h, et un fichier contenant le corps des méthodes, se terminant généralement par .cc, .cxx ou .cpp. Voici le contenu de voiture.h, on remarque que le corps des méthodes n'est pas présent, en revanche les champs de la classe sont présents :
// fichier voiture.h
class Voiture {
public:
Voiture(int prix);
int donnePrix() const;
private :
int prixDeLaVoiture;
};
On remarque que le fichier voiture.h donne la définition de la structure de la classe :
elle comporte un constructeur attendant un entier, une méthode, et un champ de type
int. On remarque le qualificatif const
après la méthode donnePrix : à cet endroit (après les arguments d'une méthode)
cela signifie que l'appel méthode donnePrix ne modifie pas l'instance sur laquelle
la méthode est appelée. La vérification qu'une méthode puisse bien être qualifiée
par const est effectuée au moment de la compilation, permettant ainsi d'éviter
des erreurs à l'exécution.
Le corps du constructeur et de la méthode sont décrits dans le fichier
voiture.cc :
// fichier voiture.cc
#include "voiture.h" // inclure le fichier voiture.h
/**
* Donne le corps du constructeur de la classe Voiture.
*/
Voiture::Voiture(int prix) {
prixDeLaVoiture=prix;
}
/**
* Donne le corps de la méthode donnePrix.
*/
int Voiture::donnePrix() const {
return prixDeLaVoiture;
}
On remarque l'usage du symbole :: qui signifie que l'on va déclarer
un symbole se situant dans la classe Voiture. De manière générale
cet opérateur permet d'accéder à un espace de nommage, ici celui définit
par la classe Voiture.
Ainsi pour utiliser une librairie il suffit de donner le fichier d'entête, le fichier de définition n'étant pas nécessaire. Voici un usage de la classe voiture dans le fichier main.cc :
// fichier main.cc
#include "voiture.h"
int main() {
Voiture maVoiture(123);
return 0;
}
Le fichier main.cc constitue une unité de compilation, même si les corps
des méthodes de la classe Voiture ne sont pas connues.
On peut donc le compiler séparément du reste du code. Le fichier voiture.cc
constitue également une unité de compilation.
Nous avons vu comment partager des définitions de classes. Il peut également être utile de partager des instanciations de classes ou des variables sur des types entiers. Ainsi si dans une unité de compilation on dispose d'une variable globale i :
// fichier unite1.cc int i=4;pour référencer cette variable depuis une autre unité de compilation il suffit de rajouter une déclaration sans valeur initiale, précédée de l'attribut extern :
// fichier unite2.cc
extern int i;
int multiplicationParI(int j) {
return i*j;
}
Si le mot clé extern ne figure pas dans unite2.cc, la compilation
se fera sans problèmes mais c'est à l'édition de liens que le compilateur va
rencontrer un symbole dupliqué. Lire la fiche pratique 1 sur la compilation pour
plus de détails sur ce qu'est l'édition de liens.
Tout comme il n'est pas pratique de mettre tous ses fichiers dans le même répertoire, il est bon de ne pas mettre toutes ses fonctions et ses classes dans le même espace de nom. Ainsi par exemple pour écrire une librairie nommée communication dont le but est la communication entre un client et un serveur, il est logique de mettre les fichiers C++ dans un répertoire nommé communication. Prenons alors l'exemple déclarant la classe Client :
class Client {
// .. definition
};
Il est possible que le nom de la classe Client définit dans la librairie
puisse entrer en conflit avec une autre classe qui serait déjà nommée Client ailleurs
dans le programme.
Ainsi il est préférable (mais en aucun cas nécessaire) de déclarer
la classe client dans un nouvel espace de nommage (namespace en C++).
namespace Communication {
class Client {
// .. definition
};
}
Dans le programme on peut alors créer une instance de la classe client comme suit :
Communication::Client monClient ();ou si l'on veut se débarrasser du préfixe
Communication:: de manière similaire :
using namespace Communication; // importe tous les noms situés dans Communication Client nomClient();
Un espace de nommage où de nombreuses librairies sont présentes existe déjà : std. Par exemple le programme suivant affiche bonjour à l'écran :
#include <iostream> // demande d'inclure les définitions pour l'affichage
using namespace std; // utilise les noms de l'espace de nommage std
int main() {
cout << "Bonjour!" << endl;
return 0;
}
Plus généralement l'espace de nommage std contient l'ensemble des bibliothèques
du standard C++.
Voici le programme équivalent sans l'usage de la directive using :
#include <iostream> // demande d'inclure les définitions pour l'affichage
int main() {
std::cout << "Bonjour!" << std::endl;
return 0;
}
On peut également obtenir les bornes sur les entiers présentés à la section 1.3.1 qui dépendent de la machine à l'aide du programme suivant :
#include <limits>
#include <iostream>
using namespace std;
int main() {
cout << "plus grand entier " << numeric_limits<int>::max() << endl;
cout << "plus petit entier " << numeric_limits<int>::min() << endl;
return 0;
}
Sur une machine 32 bits ce programme affiche :
plus grand entier 2147483647 plus petit entier -2147483648
Nous venons de voir comment afficher des données à l'écran. Voici plus de détails sur les entrées/sorties : dans des fichiers, dans des chaînes de caractères, ou pour des classes.
Voici comment écrire la chaîne de caractères Bonjour dans un fichier nommé montexte :
#include <fstream>
#include <iostream>
using namespace std;
int main() {
ofstream f ("montexte"); // ofstream = output file stream
if (f.is_open()) {
// le fichier montexte a bien été ouvert en écriture
f << "Bonjour" << endl;
f.close();
return 0;
}
// le fichier montexte n'a pu être ouvert en écriture
cerr << "impossible d'ouvrir le fichier 'montexte'" << endl;
return 1;
}
On créé une instance de ofstream nommée f. Si la création
du fichier montexte est possible alors la méthode is_open
renvoie true, on peut alors écrire dans le fichier
comme s'il s'agissait de la sortie standard. Une fois l'écriture
terminée on peut refermer le fichier à l'aide de la méthode close.
La variable cerr est le flux d'erreur standard
dans lequel un processus peut écrire les messages d'erreur le concernant.
On peut définir l'opérateur << sur des classes pour pouvoir
directement les imprimer dans un fichier ou sur cout. Prenons l'exemple
d'une classe représentant un point par un couple d'entiers :
class Point {
public :
Point(int _x, int _y) {
x=_x; y=_y;
}
int getX() const { return x;}
int getY() const { return y;}
private:
int x,y;
};
Ajoutons alors la définition de l'opérateur << :
ostream & operator<< ( ostream & os, const Point & p ) {
os << "(" << p.getX() << "," << p.getY() << ")" << endl;
return os;
}
Les opérateurs sont définis plus en détails dans le chapitre suivant.
Le symbole & constitue un passage par référence
et est détaillé dans le chapitre suivant.
L'attribut const signifie ici (avant
le type d'un argument) que l'argument p
ne va pas être modifié par l'appel à l'opérateur operator<<.
En particulier, cela impose que seules des méthodes de Point qualifiées par const
peuvent être appelées dans l'opérateur operator<<.
Alors pour afficher les coordonnées d'un point on peut se
servir du code ci-dessous :
#include <iostream>
using namespace std;
int main() {
Point p (12,13);
cout << p << endl; // appel à l'opérateur << définit précédemment
return 0;
}
On nomme structure de contrôle l'ensemble des opérations qui permettent de choisir si des instructions doivent être exécutées ou non.
#include <iostream> // demande d'inclure la définition
// des primitives d'entrée sortie
using namespace std; // utilise l'espace de nommage std
void afficheSiEgalA4 (int i ) {
if (i==4) {
cout << "i vaut 4" << endl;
} else {
cout << "i ne vaut pas 4" << endl;
}
}
La syntaxe des tests est la suivante :
const int tailleMax = 30000; // nombre d'éléments dans le tableau
int monTableau[tailleMax]; // création d'un tableau à tailleMax éléments
int main() {
// pour i variant de 0 à tailleMax-1 de un en un effectuer monTableau[i]=0
for (int i=0; i<tailleMax; i=i+1) {
monTableau[i]=0;
}
return 0;
}
La structure des boucles for est la suivante :
const int tailleMax = 30000;
int monTableau[tailleMax];
int main() {
int i=0;
while (i<tailleMax) {
monTableau[i]=0;
i=i+1;
}
return 0;
}
La structure des boucles while est la suivante :
const int tailleMax = 30000;
int monTableau[tailleMax];
int main() {
int i=0;
do {
monTableau[i]=0;
i=i+1;
} while (i<=tailleMax);
return 0;
}
La structure des boucles do ... while est la suivante :
Il se peut que l'on ait à écrire des bouts de codes répétitifs. On souhaiterais donner un nom à ces séquences de caractères et invoquer le nom pour ne pas avoir à retaper les mêmes séquences à chaque fois. Le préprocesseur fait ce travail. Voici un exemple indiquant d'une fonction provoquant une erreur quand son argument est négatif :
#include <iostream>
#define ERREUR std::cerr << "Il y a une erreur" << std::endl; exit(1)
int doubleValeurSiPositif(int i) {
if (i<0) {
ERREUR;
}
return 2*i;
}
A chaque fois que l'on écrit ERREUR la séquence std::cerr ... exit(1)
est écrite à la place puis donnée au compilateur. Voici la syntaxe de la directive #define :
#define <nom à définir><valeur>
On souhaite passer un argument donnant plus d'information sur l'erreur. On peut encore le faire à l'aide des macros, en lui donnant un argument :
#include <iostream>
#define ERREUR(x) std::cerr << "Il y a une erreur : " << x << std::endl; exit(1)
int doubleValeurSiPositif(int i) {
if (i<0) {
ERREUR("i est négatif");
}
return 2*i;
}
En déclarant ERREUR(x) partout où l'identifiant x apparaît la valeur
de l'argument sera substituée.
Il existe également des variables particulières qui donnent le nom du fichier en cours
__FILE__ et le numéro de la ligne en cours __LINE__. Ainsi pour facilement retrouver l'endroit dans le code où l'erreur s'est produite on peut rajouter :
#include <iostream>
#define ERREUR(x) std::cerr << __FILE__ << ":" << __LINE__ << ": erreur : " \
<< x << std::endl; exit(1)
int doubleValeurSiPositif(int i) {
if (i<0) {
ERREUR("i est négatif");
}
return 2*i;
}
Noter la présence du caractère \ permettant d'écrire la macro sur plusieurs lignes. A l'exécution en cas d'erreur
on obtiendra un affichage de la forme :
toto.cc:6: erreur : i est négatifNous verrons au paragraphe 4.1.2 le mécanisme d'exception permettant de gérer les cas d'erreur de manière plus sémantique.
SystemC est un ensemble de classes C++ permettant de décrire du matériel ou du logiciel : des microprocesseurs avec un programme fonctionnant dessus, un bus avec des périphériques communiquant.
Le site web permettant d'obtenir la documentation et les logiciels est http://www.systemc.org. On y trouve en particulier le manuel de référence du langage [osc05], rédigé par l'Open SystemC Initiative (OSCI). Le noyau dur des membres de l'Open SystemC Initiative (OSCI) sont Arm, Cadence, CoWare, Forte, Mentor Graphics, STMicroelectronics, Synopsys, Philips.
Au début de l'électronique il y avait de nombreux composants différents
sur une carte : des transistors, des condensateurs, des résistances.
Les composants n'ont cessés d'être plus compacts et plus intégrés.
Actuellement, la tendance est même d'avoir plusieurs c
urs de
microprocesseurs connectés entre eux dans un seul composant (c'est le type
d'architecture proposé en exercice au chapitre 4).
On trouve de plus des circuits permettant d'offrir de nouvelles
fonctionnalités pour une consommation réduite : encodeur/décodeur MPEG4
pour la vidéo par exemple.
Ainsi pour faire un nouveau téléphone portable supportant
la vidéo il faut prendre un microprocesseur qui possède déjà cette fonctionnalité.
La conception de matériel est de plus en plus dans une logique d'assemblage, où un composant est constitué d'un microprocesseur et de modules (comme l'encodeur/décodeur MPEG4) relié par un bus ou un réseau sur puce. Cette conception spécifique est amortie par les volumes importants qui vont être produits.
Cette phase d'assemblage prend du temps. Il peut y avoir des difficultés à assembler les différents composants. Ces difficultés doivent être repérées le plus tôt possible. C'est là où les modèles écrits en SystemC interviennent. On réalise tout d'abord un modèle SystemC relativement haut niveau des composants à intégrer. Ce modèle ne comportant pas tous les détails au niveau des transistors est relativement rapide à simuler. On peut alors dès ce stade avancé de la conception étudier les performances du système, ou observer si le comportement est correct.
Le microprocesseur est uniquement constitué de signaux électriques. On considère au dessus d'un certain voltage que la valeur du signal représente le nombre 1, et en dessous elle représente le nombre 0.
La brique de base est le transistor, que l'on peut voir ici comme un petit interrupteur. Voici les deux types de transistors - et + :
On abstrait rapidement les transistors en utilisant des portes logiques ET (c vaut 1 si et seulement si a et b valent 1) et OU (c vaut 1 si et seulement si a ou b vaut 1) :
L'addition de trois chiffres binaires a, b et c est dès lors possible. Le résultat varie de 0 à 3, soit comme valeur possible en écriture binaire : 0,1, 10 (pour le nombre décimal 2) et 11 (pour le nombre décimal 3). Le chiffre des unités est noté s comme somme et l'autre chiffre est noté r comme retenue. On peut alors calculer s et r à l'aide du circuit suivant qui est un assemblage des portes précédentes :
En se servant de l'additionneur complet on peut alors écrire un circuit additionnant deux nombres a et b respectivement d'écriture binaire sur 8 bits a7, a6, a5, a4, a3, a2, a1, a0 et b7, b6, b5, b4, b3, b2, b1, b0 :
Comme précédemment mentionné les transistors ont un petit temps de basculement, que l'on souhaite minimiser pour pouvoir augmenter la fréquence de l'horloge. Ici on a une chaîne de huit additionneurs complets mis en série. Il faut donc attendre que les huit additionneurs se stabilisent, chacun attendant le précédent. Ainsi plus les nombres seront grand plus l'addition prendra du temps. On peut se débrouiller pour avoir un nombre d'additionneurs complets en série logarithmique en la taille des nombres additionnés. La réalisation d'un tel additionneur est proposée dans l'exercice 1.4.
En SystemC la notion fondamentale est celle de module. Elle est relativement similaire aux boites représentées dans les exemples précédentes. En pratique un module va être une classe C++ héritant d'une classe nommée sc_module et pouvant faire pratiquement n'importe quoi.
On distingue principalement trois types de ports d'entrée :
#include "systemc.h" // inclut les définitions SystemC
typedef unsigned char u8; // définit 'u8' comme un type char non signé
SC_MODULE(EightBitAdder) { // définit un module nommé EightBitAdder
// liste des ports
sc_in<u8> a,b; // spécifie que le module possède deux ports d'entrée
sc_out<u8> sum; // le module possède un port de sortie sur 8 bits
sc_out<bool> overFlow; // le module possède un port de sortie sur 1 bit
// définition des actions à effectuer
void prcEightBitAdder() {
int intSum = a; // calcul de la somme sur un entier
intSum=intSum+b;
sum = intSum & 255; // calcul de la somme sur 8 bits
overFlow = ((intSum & 256) != 0); // calcul de l'overflow
}
// constructeur du module
SC_CTOR (EightBitAdder) {
// spécifie que la méthode prcEightBitAdder
// est sensible aux signaux a et b
SC_METHOD (prcEightBitAdder);
sensitive << a << b;
}
};
Le programme commence par importer les définitions nécessaires
à l'usage des classes SystemC à l'aide de la directive
#include "systemc.h".
Le type unsigned char est ensuite nommé u8
à l'aide de la commande typedef.
Nous pouvons donner de nouveaux noms à des types. Par exemple
ci dessous au lieu de réécrire à chaque fois Toto::MaClasse nous l'abrégeons
en Tata :
namespace Toto {
class MaClasse {} ;
}
typedef Toto::MaClasse Tata;
Tata maVariable;
Voici la syntaxe de typedef :
class EightBitAdder : sc_module {
public:
// définition du module
...
};
Ceci est rendu possible grâce aux macros C++. Ainsi en définissant la macro SC_MODULE avec l'argument x par :
#define SC_MODULE(x) class x : sc_moduleLa valeur exacte de la macro SC_MODULE est donnée à la section 4.2.1. Dans le corps du module on remarque 4 champs : des champs de la forme sc_in définissant les ports d'entrée dans le module, des champs de la forme sc_out définissant les ports de sortie du module. Il s'agit de classes template qui sont, dans l'exemple, instanciées avec le type de données en entrée ou en sortie.
Les modules SystemC étant des classes, on trouve bien entendu le constructeur. C'est ce qui est fait à la ligne 19 par la déclaration SC_CTOR (EightBitAdder). SC_CTOR est en fait une macro (tout comme SC_MODULE) permettant de déclarer un constructeur prenant en argument un chaîne de caractères.
Dans le corps du constructeur est ensuite spécifié qu'une méthode SystemC est présente, par la macro SC_METHOD (prcEightBitAdder). La déclaration sensitive « a « b indique au moteur de simulation SystemC qu'il faudra exécuter la dernière méthode enregistrée (en l'occurrence prcEightBitAdder) si une nouvelle donnée arrive sur les ports a ou b. Sur un tel évènement la méthode prcEightBitAdder est exécutée jusqu'au bout avant que l'ordonnanceur SystemC ne choisisse la prochaine méthode à appeler.
Maintenant que nous avons un module EightBitAdder nous souhaiterions tester son bon fonctionnement. Ci-dessous figure le dispositif de test utilisé :
#include "eightbitadder.h"
SC_MODULE(Tester) { // définition du module Tester
// ports
sc_out<u8> a,b;
sc_in<u8> sum;
sc_in<bool> overFlow;
sc_in_clk clock;
// conserve les valeurs fournies à l'additionneur
int aValue, bValue;
bool firstTime;
void sendInput() {
if (firstTime) { // envoyer zéro la première fois
firstTime=false;
aValue=0;bValue=0;
a=0;b=0;
return;
}
aValue = rand() & 255; // génère une valeur aléatoire pour a
bValue = rand() & 255; // génère une valeur aléatoire pour b
a.write(aValue); // écrit la valeur aléatoire sur le port a
b.write(bValue); // écrit la valeur aléatoire sur le port b
cout << "Sending " << aValue << " and " << bValue << endl;
}
void checkOutput() {
int sumValueRead = sum.read();
int overFlowValueRead = overFlow.read();
if (aValue+bValue!= (sumValueRead + (overFlowValueRead?256:0))) {
cout << "Error : sent " << aValue << " and " << bValue
<< " but received " << (sumValueRead + (overFlowValueRead?256:0)) << endl;
}
}
SC_CTOR(Tester) {
srand(0); // initialisation du générateur aléatoire
firstTime=true;
SC_METHOD(sendInput);
sensitive << clock;
SC_METHOD(checkOutput);
sensitive << sum << overFlow;
}
};
On retrouve sur le modèle de Tester le pendant les ports
sur EightBitAdder et le port pour l'horloge clock.
L'horloge déclenche la méthode sendInput. On remarque l'usage
de la fonction rand pour la génération de nombres
aléatoires. C'est ici raisonnable car on ne cherche pas à disposer
de nombre aléatoires d'une bonne qualité, se reporter à [PFTV92]
ou [Knu81] pour disposer de nombre aléatoire d'une bonne qualité.
Nous pouvons alors lancer la simulation en rajoutant le code suivant :
int sc_main(int argc, char** argv) {
// les 4 signaux permettant de relier les modules tester et eightBitAdder
sc_signal<u8> testerAOut, testerBOut;
sc_signal<u8> testerSumIn;
sc_signal<bool> testerOverIn;
// créer une horloge avec une période de 1ns donnant au tester les impulsions
// pour envoyer de nouveaux échantillons
sc_clock clock("clockForTester",sc_time(1,SC_NS));
// instancier les modules tester et eightBitAdder
Tester tester ("tester");
EightBitAdder eightBitAdder ("eightBitAdder");
// lier les ports aux signaux.
tester.a(testerAOut);
eightBitAdder.a(testerAOut);
tester.b(testerBOut);
eightBitAdder.b(testerBOut);
tester.sum(testerSumIn);
eightBitAdder.sum(testerSumIn);
tester.overFlow(testerOverIn);
eightBitAdder.overFlow(testerOverIn);
tester.clock(clock);
// lancer la simulation pour 100 nano secondes
sc_start(sc_time(100,SC_NS));
// tout s'est bien passé renvoyer zéro.
return 0;
}
Dans la fonction sc_main, qui est l'équivalent de la fonction main
dans un programme C++, figure les quatre signaux permettant de relier
les modules, ainsi que la définition de l'horloge. Les arguments de la fonction
sc_main sont détaillés ultérieurement à la section 2.2.5.
L'instruction sc_start lance la simulation. Nous n'avons pas jusqu'à présent parlé de la sémantique d'exécution de SystemC, mais c'est ce qui en fait sont principal intérêt : les modèles sont exécutables et on peut voir leur comportements. Nous ne détaillons que sommairement la sémantique d'exécution de SystemC dans ce chapitre.
Voici comment compiler le modèle SystemC :
g++ -o eightbitadder.exe -I$SC_HOME/include eightbitadderwrapper.cc \
-L$SC_HOME/lib-linux -lsystemc
On suppose que $SC_HOME est une variable d'environnement de l'interpréteur donnant le répertoire dans lequel
SystemC est installé.
L'instruction de compilation signifie que le fichier généré doit être eightbitadder.exe,
que le répertoire $SC_HOME/include va être utilisé pour lire des fichiers d'entête,
que le répertoire $SC_HOME/lib-linux va être utilisé pour lire les librairies,
et que la librairie SystemC va être liée à l'exécutable. Voici le résultat
de l'exécution :
SystemC 2.1_oct_12_04.beta --- Jun 21 2005 16:28:31
Copyright (c) 1996-2004 by all Contributors
ALL RIGHTS RESERVED
Sending 103 and 198
Sending 105 and 115
Sending 81 and 255
...
Aucune erreur n'est affichée, les tests se sont bien déroulés.
Enfin nous avons décrit le positionnement du langage SystemC et décrit comment le langage C++ permet facilement de faire un nouveau dialecte pour décrire, entre autre, du matériel.
Ecrire un programme affichant «Hello World!», et l'exécuter.
Se reporter à la fiche pratique Compilation C++ à la page
pour les détails permettant
d'exécuter le programme.
On inclut les fichiers à l'aide de la directive
#include " nom de fichier ".
On considère les deux fichiers suivants d'une ligne seulement :
**** f1.h : #include "f2.h" **** f2.h : #include "f1.h"
Q1 Essayer de compiler le fichier main.cc suivant :
#include "f1.h"
Q2 Pour remédier à l'erreur précédente mettre en place un mécanisme permettant de n'inclure qu'une fois le fichier f1.h à l'aide des directives suivantes :
#define TOTO définit le symbole TOTO
#ifndef TOTO
#endif où le code C++ n'est exécuté que si le symbole TOTO n'est pas définit.
Ex 1.3 ** Simulation d'écosystème
On s'intéresse à modéliser un écosystème contenant des lapins et des renards. Ci-dessous figure un exemple de courbe obtenue donnant les variations de populations. Après une phase initiale transitoire, on observe un rythme régulier : la population de lapins croit, cela permet alors aux renards de se développer, mais ils consomment alors trop de lapins, la population de lapins décroît alors, il n'y a donc plus assez de lapins et la population de renards décroît alors à son tour.
On se donne une classe générique nommée Animal définie ci-dessous :
#include <string>
using namespace std;
class Animal {
public :
Animal() { x=0; y=0; energie=0; energieRepro=100;}
Animal(int x, int y) { fixePosition(x,y); }
void fixePosition(int xx,int yy) { x=xx; y=yy; }
void energieAjoute(int e) { energie=energie+e; }
int donneEnergie() { return energie; }
int lireX() { return x;}
int lireY() { return y;}
void initEnergieRepro(int i) { energieRepro=i; energie=0;}
int donneEnergieRepro() { return energieRepro; }
void tue() { energie=-100;}
virtual int valeurEnergetique() { return 0;}
virtual string donneLettre() { return "A";}
private :
int x;
int y;
int energie;
int energieRepro;
};
Q1 Dans un fichier nommé lapin.h écrire une classe nommée Lapin héritant de Animal pour laquelle la méthode valeurEnergetique renvoie 5 et donneLettre renvoie "L". Faire de même dans un fichier renard.h avec une classe Renard pour laquelle les valeurs renvoyées sont 10 et "R". Le code source de la class Animal est disponible à l'url suivant :
Q2 Dans l'archive précédente se trouve définie la classe Monde dans les fichiers monde.h et monde.cc. Ecrire un fichier nommé simule.cc contenant une procédure nommée main et effectuant les actions suivantes :
La solution de l'exercice est disponible à l'url suivant :
Ex 1.4 *** Addition en temps logarithmique
Le but de cet exercice est de prouver la correction de l'additionneur en temps logarithmique évoqué à la section 1.4.4.
Q1 Considérons deux nombres A et B ayant une écriture binaire de taille 2n. On note S=A+B et S'=A+B+1. On découpe A et B en A=A0+2nA1 et B=B0+2nB1, avec A0, A1, B0, B1 < 2n. On note S0=A0+B0, S'0=A0+B0+1, S1=A1+B1 et S'1=A1+B1+1. Calculer S et S' à l'aide de l'écriture de S0, S'0, S1 et S'1.
Q2 A l'aide de transistors plus et moins, créer un bloc à trois entrées sur un bit : a, b, c et une sortie s, qui vaut b si a vaut zéro et c si a vaut 1.
Q3 Utiliser le bloc de la question 2 pour implémenter sur silicium l'algorithme 1 réalisant un additionneur sur 8 bits en temps logarithmique.
Q4 Implémeter l'additionneur en temps logarithmique en SystemC.
Le but de l'exercice est de prouver que 13 est premier en utilisant le mécanisme de typage des templates de C++. L'exercice se base sur un programme écrit par Dan Piponi (http://www.sigfpe.com).
Précisons tout d'abord une construction qui n'a pas été présentée dans le cours : pour définir une classe dont tous les champs sont publics, on peut utiliser le mot clé struct en lieu et place de class.
Pour définir les entiers nous allons utiliser
les axiomes de Péano : nous disposons d'un ensemble de valeurs nommé Naturel.
Une de ces valeurs est nommée O (la lettre o en majuscule),
elle représente le nombre zéro. Nous disposons d'une opération nommée successeur
notée
. Ainsi le nombre 1 sera représenté par S(O),
2 par S(S(O)) et ainsi de suite.
On se fixe les définitions suivantes :
template<class T> struct Naturel { typedef T valeur; };
// définition de zéro
struct zero : public Naturel<zero> { };
// définition d'un successeur et d'un prédécesseur d'un entier
template<class C> struct S
: public Naturel<S<C> > { typedef C predecesseur; };
Q1 En utilisant un typedef définir les types un, deux, trois, quatre, cinq, six, sept huit, neuf et dix représentants les entiers de valeur correspondante.
Nous pouvons définir l'addition par les axiomes suivants :
template<class C,class D> struct plus
: public S<plus<C,typename D::predecesseur> > { };
template<class C> struct plus<C,zero>
: public C { };
On remarque l'usage du qualificatif typename pour préciser
que D::predecesseur définit bien un type.
Q2 Voici la définition de la soustraction :
Q3 Voici la définition de la multiplication :
Q4 Voici les deux axiomes définissant l'opération «plus grand ou égal à» :
Q5 Dans cette question nous allons mettre en place un
test logique pour savoir si un élément est divisible par un autre.
L'expression booléenne équivalente à D divise C sera
représentée par le type Divise<C,D>. Nous introduisons un
troisième argument par défaut égal à deux, que l'on va remplacer
par la valeur booléenne de
:
template<class C,class D,class E = S<S<zero> > > struct Divisible { };
template<class C,class D> struct Divisible<C,D,S<S<zero> > >
: public Divisible<C,D,typename ge<C,D>::valeur> { };
Ainsi le type Divise<C,D>, est équivalent au type
Divise<C,D,deux> qui hérite de Divisible<C, D, typename ge<C,D>::valeur> .
En se servant du fait que si C<D alors D divise C si et seulement si C vaut zéro, écrire les valeurs de Divisible<C, D, zero>.
En se servant du fait que si
alors D divise C si et seulement si
D divises C-D, écrire les valeurs de Divisible<C, D, un>.
Q6 Ecrire un type nommé Premier vérifiant si un entier naturel est premier en utilisant un algorithme simple : il est premier si et seulement si il ne peut être divisé par nombre supérieur ou égal à 2 plus petit que lui. Tout comme nous avons réussi à faire un test à la question précédente, il faut ici faire une boucle.
Pour vérifier que 13 est premier il suffit alors de définir la valeur à partir de l'écriture décimale :
template<class C,class D> struct Decimal
: public plus<typename fois<dix,C>::valeur,D> { };
Et d'exécuter les instructions suivantes :
#include <string>
#include <iostream>
using namespace std;
template<class C> string output(C);
template<> string output(zero) { return "Non"; }
template<> string output(un) { return "Oui"; }
int main() {
// Est-ce que 13 est premier ?
cout << output(Premier<Decimal<un,trois>::valeur>::valeur()) << endl;
return 0;
}
Ce chapitre donne un complément sur le langage C++ et SystemC. Nous nous servons alors de SystemC pour simuler un microprocesseur simpliste exécutant du byte code java : Jasip.
Cette section ajoute quelques compléments sur le langage C++ qui n'étaient pas présents dans le premier cours.
Voici le code suivant où une fonction nommée test attend en paramètre une instance de classe :
class Voiture { };
void test(Voiture v) {
// faire des tests
}
int main() {
Voiture voiture();
test(voiture);
return 0;
}
Voici les actions effectuées lors de l'exécution de la fonction main du programme :
C'est un peu long, de plus il y a une recopie du contenu de l'instance voiture ce qui peut prendre du temps. Deux solutions alternatives existent :
Ces approches sont détaillées dans les deux sections suivantes.
Pour chaque variable dans le programme on peut obtenir l'adresse (i.e., sa position dans la mémoire) de celle-ci en ajoutant le symbole & devant. Ainsi le programme suivant affiche l'adresse de la variable entière i :
#include <iostream>
int main() {
int i=0;
std::cout << &i << std::endl;
return 0;
}
Si ce programme est dans le fichier t.cc on obtient l'exécution suivante :
>g++ -o t.exe t.cc >./t.exe 0xbffffa54On sait alors que la valeur de i est stockée à partir de la case mémoire numéro bffffa54 (en hexadécimal). Si une variable t à un type Toto alors le type de &t l'adresse de t est noté Toto *. Ainsi un programme équivalent au précédent est :
#include <iostream>
int main() {
int i=0;
int * j = &i;
std::cout << j << std::endl;
return 0;
}
Ainsi pour éviter la recopie dans l'appel de fonction présenté dans la
section précédente, il suffit d'écrire :
class Voiture { };
void test(Voiture * v) {
// faire des tests
}
int main() {
Voiture voiture();
test(&voiture);
return 0;
}
Une autre façon existe pour éviter la recopie : le passage par référence.
La syntaxe est simple il suffit dans le prototype de la fonction
ou de la méthode de rajouter le symbole & après le nom de type.
Voici le résultat sur l'exemple :
class Voiture { };
void test(Voiture & v) {
// faire des tests
}
int main() {
Voiture voiture();
test(voiture);
return 0;
}
Les références et les pointeurs sont en fait la même chose : une adresse mémoire associée à un type décrivant l'objet pointé. La différence est qu'une référence doit obligatoirement référencer un objet, ce qui n'est pas le cas d'un pointeur qui peut être n'importe quelle adresse mémoire, y compris une adresse ne correspondant en fait à rien. Une telle adresse généralement utilisée est un pointeur vers l'adresse mémoire 0 de type void *, et noté NULL, définit dans le fichier stdlib.h :
#include <stdlib.h>
int main() {
int * intPointer = NULL; // on peut initialiser le pointeur sans avoir
// la variable
int i=0;
int & intReference = i; // on doit avoir la variable pour initialiser
// la référence.
intPointer=&i;
return 0;
}
La question de l'initialisation est encore plus sensible dans le cas de champs d'une classe. Voici par exemple une classe possédant un champ qui est un pointeur :
class MaClasse {
public :
MaClasse(int * i) { pointeur=i; }
int * pointeur;
};
En revanche le code suivant aurait été impossible à écrire avec une référence,
en effet la référence doit être initialisée avant même l'exécution du corps
du constructeur. Ainsi pour les références on utilise la syntaxe suivante :
class MaClasse {
public :
MaClasse(int & i) : maReference(i) {
// corps du constructeur
}
int & maReference;
};
Ce type d'initialisation (hors du constructeur) est également utile pour les classes ne disposant pas
d'un constructeur par défaut (c'est à dire d'un constructeur
ne prenant aucun argument). Ainsi le code suivant ne compile pas :
// définition d'une classe A ne possédant pas de constructeur par défaut
class A {
public :
A(int i) {}
};
// définition d'une classe B possédant un champ de type A
class B {
public:
B(int i) { a=A(i);} // ERREUR : pas de constructeur par défaut pour A !
A a;
};
Le programme ne peux compiler car a doit être initialisé avant l'entrée
dans le constructeur de B. Voici comment écrire le constructeur de B :
class A {
public :
A(int i) {}
};
class B {
public:
B(int i) : a(i) { }
A a;
};
Ainsi nous avons des pointeurs qui sont des adresses mémoire vers des objets d'un certain type. Pour récupérer l'objet pointé il existe un opérateur *, c'est en quelque sorte l'inverse de l'opérateur & :
#include <iostream>
#include <stdlib.h>
class Voiture {
public :
int valeur;
};
void afficheValeur(const Voiture * v) {
if (v==NULL) return;
Voiture voiture= *v;
std::cout << voiture.valeur << std::endl;
}
La fonction précédente fonctionne correctement mais créé à la ligne 9 une nouvelle instance
de voiture dans laquelle est recopiée le contenu de la valeur pointée par v.
On perd le bénéfice du passage par pointeur. Il faut soit utiliser des
références, soit utiliser l'opérateur -> permettant d'accéder à
une méthode ou un champ d'une classe pointée :
#include <iostream>
#include <stdlib.h>
class Voiture {
public :
int valeur;
};
void afficheValeur(const Voiture * v) {
if (v==NULL) return; // si v ne pointe vers rien, quitter.
std::cout << v->valeur << std::endl;
}
Avec des références, il n'y a en revanche pas besoin de tester la validité :
#include <iostream>
class Voiture {
public :
int valeur;
};
void afficheValeur(const Voiture & v) {
std::cout << v.valeur << std::endl;
}
Comme on définit des pointeurs vers des variables, on peut également définir des pointeurs vers des fonctions.
Par exemple considérons le code suivant :
#include <string>
// la fonction f renvoie la taille de la chaîne de caractères
int f(std::string s) {
return s.size();
}
Cela définit le symbole f comme un pointeur de fonction.
Nous allons définir le type PointeurIntString qui
pointe vers une fonction renvoyant un int en prenant
en argument un objet de type std::string :
typedef int (*PointeurIntString)(std::string);Voici la syntaxe pour déclarer un type pointeur de fonction :
#include <iostream>
int main() {
PointeurIntString i = f;
std::cout << i("mon texte") << std::endl;
return 0;
}
Les pointeurs de fonctions sont utiles quand le choix de la fonction à appeler varie. Par exemple pour écrire le code d'une calculatrice l'opération va dépendre du bouton sur lequel l'utilisateur appuie. Si les pointeurs de fonctions sont dans un tableau il suffit alors d'appeler la fonction dont l'indice correspond au bouton.
Voici un exemple de tableau de pointeurs de fonctions :
int plus (int x, int y) { return x+y;}
int minus (int x, int y) { return x-y;}
int times (int x, int y) { return x*y;}
int div (int x, int y) { return x/y;}
int (*fun_array[]) (int,int) = { plus,minus,times,div};
int donneResultat(int operation, int operande1, int operande2) {
return *(fun_array[operation])(operande1,operande2);
}
Voici la syntaxe pour déclarer directement un tableau de pointeurs de fonction :
La section précédente abordait le principe des pointeurs de fonctions. On trouve l'équivalent pour les méthodes des classes.
class A {
public :
int methode1() { return 14;}
int methode2() { return 300;}
};
typedef int (A::*MethodeDansA)();
MethodeDansA methodTab [] = { &A::methode1, & A::methode2 };
Dans cet exemple on définit une classe A comportant deux méthodes
ayant la même signature : elles ne prennent pas d'argument
et renvoient un entier.
On définit alors à l'aide de la commande typedef
le type MethodeDansA comme étant un pointeur vers une
méthode de A ne prenant pas d'argument
et renvoyant un entier.
Voici la syntaxe pour déclarer à l'aide de typedef un type pointeur de
méthode :
::*<nom du nouveau type>
)(<liste des types d'arguments>);
On peut alors définir un tableau methodTab conservant des pointeurs vers methode1 et methode2.
Nous avons présenté différentes structures de contrôle : if, for, while ou do while. Il existe en outre le switch qui permet de manière efficace d'effectuer l'équivalent d'une suite de commandes if. Ainsi le code suivant permet d'effectuer différentes actions en fonction de la valeur du caractère c :
void lireTouche(char c) {
switch (c) {
case 'q' :
// l'utilisateur a tapé 'q' nous allons quitter l'application
exit(0);
case 'l' :
// effectuer la lecture d'un fichier
lireFichier();
break; // quitter le switch
case 'a':
case 'b':
cerr << "Action impossible" << endl;
break;
default:
// la touche n'est pas reconnue
cerr << "La touche " << c << " n'est pas reconnue." << endl;
}
}
Quelque soit le nombre de choix de la commande switch,
la bonne option est
déterminée en temps quasi constant. Avec des commandes if à la suite
on aurait en moyenne un temps linéaire si les choix sont équiprobables.
L'exercice 2.4 montre l'efficacité de la commande switch.
Un type C++ que nous n'avons pas abordé jusqu'à présent sont les énumérés. Ainsi pour une voiture si on dispose de quatre couleurs possibles blanc, rouge, jaune et noir il est possible de coder la couleur dans un champ en utilisant une convention : par exemple 0 signifie blanc, 1 rouge, 2 jaune et 3 noir. On obtient alors la classe suivante :
class Voiture {
public:
int couleur;
};
int main() {
Voiture v;
v.couleur=2;
return 0;
}
Une telle convention peut ne pas être extrêmement pratique, il faut se souvenir du code
ou bien faire des macros en utilisant #define. Il existe un type énuméré permettant
de manipuler la liste de couleurs souhaitées :
enum CouleurVoiture {
CV_BLANC,
CV_ROUGE,
CV_JAUNE,
CV_NOIR
};
class Voiture {
public:
CouleurVoiture couleur;
};
int main() {
Voiture v;
v.couleur=CV_JAUNE;
return 0;
}
La syntaxe pour déclarer un type énuméré est :
class Voiture {
public:
enum CouleurVoiture {
BLANC,
ROUGE,
JAUNE,
NOIR
};
CouleurVoiture couleur;
};
int main() {
Voiture v;
v.couleur=Voiture::JAUNE;
return 0;
}
En pratique les énumérés sont implémentés par des type entiers.
Les valeurs commencent à zéros puis vont de un en un dans l'ordre de déclaration.
Dans l'exemple précédent en pratique BLANC vaut zéro, ROUGE vaut 1, JAUNE vaut 2
et NOIR 3. On peut si on le souhaite forcer les valeurs :
enum CouleurVoiture {
BLANC=4, // BLANC vaut 4
ROUGE, // ROUGE vaut 5
JAUNE, // JAUNE vaut 6
NOIR=50 // NOIR vaut 50
};
A noter que si nous avions imposé NOIR=5 alors ROUGE et NOIR auraient eu
la même valeur, ce qui peut poser de graves problèmes.
Les opérateurs sont les opérations classiques que l'on effectue en général sur des entiers, mais déclinées sur des classes. Pour cette raison les opérateurs peuvent être définis pour une maîtrise totale de leur action.
Ainsi par exemple pour deux entiers i et j on peut écrire l'expression i+j. Qu'en est-il pour deux classes ? Il faut définir l'opérateur + sur cette classe. Ainsi on peut définir l'addition sur une classe comportant des vecteurs de taille 4 :
class Vector {
public :
// construction du vecteur à l'aide de 4 entiers
Vector (int i,int j,int k,int l) {
valeur[0]=i;
valeur[1]=j;
valeur[2]=k;
valeur[3]=l;
}
// définition de l'opérateur + entre
// l'instance de la classe et l'argument v
Vector operator+(const Vector & v) const {
return Vector(valeur[0]+v.valeur[0],
valeur[1]+v.valeur[1],
valeur[2]+v.valeur[2],
valeur[3]+v.valeur[3]);
}
private:
int valeur[4];
};
int main() {
Vector v1 (1,2,3,4);
Vector v2 (5,6,7,8);
Vector v3 = v1+v2; // appel à l'opérateur +
// v3 est égal à [ 6 8 10 12 ]
return 0;
}
Pour définir un opérateur la syntaxe est semblable à celle d'une methode:
Le nombre d'arguments est fixé par l'opérateur. Ici c'est 1 argument pour l'opérateur + définit dans une classe ou deux arguments si l'opérateur est définit hors de la classe.
Voici comment définir l'opérateur + hors de la classe Vector. Comme nous souhaitons accéder aux champs privés de la classe Vector depuis l'opérateur + qui va être déclaré de manière externe à la classe il nous faut tout d'abord déclarer le prototype de l'opérateur + avec l'attribut friend dans la classe Vector. On peut alors librement définir l'opérateur + hors de la classe, avec cette fois deux arguments, qui sont les deux vecteurs à ajouter.
class Vector {
// déclarer l'opérateur + comme \og ami\fg~pour qu'il puisse
// accéder aux champs privés des instances de Vector.
friend Vector operator+(const Vector &, const Vector &);
public :
// construction du vecteur à l'aide de 4 entiers
Vector (int i,int j,int k,int l) {
valeur[0]=i;
valeur[1]=j;
valeur[2]=k;
valeur[3]=l;
}
private:
int valeur[4];
};
// définition de l'opérateur + entre deux vecteurs
Vector operator+(const Vector & v1, const Vector & v2) {
return Vector(v1.valeur[0]+v2.valeur[0],
v1.valeur[1]+v2.valeur[1],
v1.valeur[2]+v2.valeur[2],
v1.valeur[3]+v2.valeur[3]);
}
int main() {
Vector v1 (1,2,3,4);
Vector v2 (5,6,7,8);
Vector v3 = v1+v2; // appel à l'opérateur +
// v3 est égal à [ 6 8 10 12 ]
return 0;
}
Beaucoup d'autres opérations sont des opérateurs. Voici la liste complète des opérateurs en C++ :
+ - * / % ^ & | ~ ! = < >
+= -= *= /= %= ^= &= |= << >> >>= <<= ==
!= <= >= && || ++ -- , ->* -> () []
new delete new[] delete[]
Ci-dessous figure une liste d'exemples d'opérateurs qu'il est souvent utile
de redéfinir.
Les opérateurs * unaire ainsi que de pré et de post
incrémentation et décrémentation ++ et -
sont détaillés au chapitre suivant.
Nous venons de détailler l'usage de operator+ l'opérateur d'addition.
Il existe de même les équivalents pour les autres opérations usuelles possédant
deux arguments :
operator- binaire pour la soustraction,
operator* pour la multiplication,
operator/ pour la division,
operator&& pour le et logique,
operator|| pour le ou logique,
operator| pour le ou bit à bit,
operator& pour le et bit à bit,
operator^ pour le ou exclusif.
Pour ces opérateurs nous trouvons également la variante d'assignation.
Ainsi pour une variable i de type int l'expression
i+=4; signifie i=i+4;, mais += est un opérateur
spécifique. On trouve ainsi les opérateurs suivants :
operator+= pour l'addition,
operator-= pour la soustraction,
operator*= pour la multiplication,
operator/= pour la division,
operator|= pour le ou bit à bit,
operator&= pour le et bit à bit,
operator^= pour le ou exclusif.
Il est à noter que operator* et operator& donnés ici en exemple sont des opérateurs binaires (c'est à dire supportant deux arguments). Il existe les mêmes opérateurs unaires, détaillés un peu plus loin et ayant un sens différent : le operator* unaire est l'opérateur de déréférençage, et operator& unaire est l'opérateur permettant d'accéder à l'adresse d'un élément.
L'opérateur « de décalage binaire est souvent utilisé pour l'affichage.
Initialement ce symbole est le décalage binaire sur la gauche (c'est à dire de
rajouter un zéro à droite dans l'écriture binaire). Ainsi pour une variable
x de type int, x « 1
est équivalent à x*2 et x « n revient à ajouter
n zéros à droite en écriture binaire , c'est à dire
à multiplier par
2</SUP>n.
Pour l'affichage le flux est redirigé sur un objet de type ostream.
Voici un exemple sur un objet de type Vector. L'exécution de l'exemple
affiche [1 2 3 4 ] :
On trouve deux types de variables : des variables globales accessibles
depuis n'importe où dans le programme ainsi que des variables locales à chaque fonction.
Parfois on peut avoir besoin de créer de nouvelles variables globales, ou au moins partagées
entre plusieurs fonctions.
Pour cela on peut réserver de la mémoire à l'aide de l'opérateur new.
Ainsi, étant donné une classe A l'instruction ci-dessous :
L'instruction new est en fait l'appel à un opérateur et peut donc être
surchargé.
Cela peut être utile pour forcer les endroits dans la mémoire où sont alloués
les objets, ou pour simplement tracer les allocations réalisées.
Voici l'exemple d'une classe V où les instances allouées via
new le seront dans un tableau nommé tableauDeV.
L'opérateur d'assignation est utilisé quand une variable est affectée à une autre.
Considérons une classe Vector représentant un vecteur de taille variable.
La taille du vecteur est donnée en argument au constructeur.
Notons que l'exemple précédent n'est pas pratique. En effet
pour une expression Vector v2 = v1
ce n'est pas le constructeur par défaut qui est appelé pour v2 ( il n'y en
a d'ailleurs pas), suivit d'une affectation par l'opérateur = mais le constructeur par recopie. Le constructeur par recopie est un constructeur créé par défaut permettant
de recopier une instance de d'une classe dans une autre.
La signature de ce constructeur est pour la classe Vector :
Tout comme l'opérateur operator== on trouve son inverse operator!=.
Il est a priori saint de définir operator!= comme la négation
de operator== même si rien ne le contraint. Voici l'exemple sur
la classe Vector :
Pour accéder aux valeurs données d'une instance v de
la classe Vector, il est pratique d'utiliser la syntaxe v[i].
Il faut pour cela redéfinir l'opérateur [] :
L'opérateur () permet d'utiliser les instances de classes
avec une syntaxe d'appel de fonction. Cela peut être une alternative
à l'usage des pointeurs de fonction.
Imaginons que nous disposons d'une fonction nommée trieList
qui trie une liste
à laquelle on souhaite passer en argument la manière de comparer
deux éléments. On pourrait utiliser un pointeur vers la fonction
effectuant la comparaison.
Nous allons créer une classe nommée Compare dans laquelle nous
allons définir l'opérateur (). Le type de comparaison
sera donné au constructeur de l'instance par un type énuméré
CmpType.
On peut redéfinir l'opérateur -> ce qui peut permettre
par exemple d'appeler des méthodes d'un autre objet.
Ainsi par exemple ici nous définissons une classe A
et nous appelons sur une instance de t de T
une méthode de A via l'opérateur -> :
Outre la description de modèles abordée dans le cours précédent, SystemC
propose une sémantique pour l'exécution de ses modèles.
L'exécution est basée sur un modèle événementiel qui se déroule en plusieurs phases.
La simulation commence avec l'invocation de la fonction sc_start.
Si aucun paramètre n'est fourni la simulation finit avec l'invocation à sc_stop.
Si un temps est donné en argument à sc_start la simulation s'arrête quand ce temps
est écoulé ou sur un appel à sc_stop.
Voici les étapes répétées de la simulation :
Les phases de mise à jour (update) sont utiles par exemple pour les canaux fifos
où les valeurs ne sont écrites qu'au moment des phases de mise à jour.
Voici un module GiveTime affichant la valeur de l'horloge :
0 s
500 ps
1 ns
1500 ps
2 ns
2500 ps
3 ns
3500 ps
4 ns
4500 ps
0 s
1 ns
2 ns
3 ns
4 ns
500 ps
1500 ps
2500 ps
3500 ps
4500 ps
Les SC_METHOD rencontrées dans le chapitre précédent doivent être entièrement exécutées
et forment donc des delta cycles. Pour écrire des procédures sur plusieurs delta cycles (ce qui permet d'écrire un code plus lisible), il existe une autre déclaration : SC_THREAD.
Les SC_THREAD sont semblables aux SC_METHOD par les caractéristiques suivantes :
Le fait que les SC_THREAD puissent être interrompus
permet de faciliter l'écriture dans les fifos où les lectures
ou écritures peuvent être bloquantes :
SystemC propose d'autres classes que les signaux pour communiquer entre les modules.
Une structure fréquente est nommée fifo et représente une file de messages
de type fifo (First In First Out). Voici la définition d'une file
de message de type fifo :
Prenons un exemple avec une classe Producer qui écrit dans
une fifo et une classe Consumer qui lit cette même fifo.
Dans cette section, nous décrivons schématiquement les fonctionnalités
requises par un microprocesseur, et nous proposons une architecture
extrêmement simplifiée permettant de les implémenter.
Un émulateur Jasip, écrit en C++, est téléchargeable à l'url :
On va s'intéresser à la partie mémoire autour du processeur Jasip.
Ci dessous figure l'architecture mémoire du processeur :
Les adresses mémoires au dessus de 0x1000 passent par le cache comme illustré ci-dessous :
En revanche les adresses plus petites que 0x1000 sont écrites directement en mémeoire :
Cet accès direct à la mémoire permet d'accéder rapidement à des périphériques.
Les périphériques Jasip sont en effet accédés par des adresses mémoires.
Ainsi le fait de lire à l'adresse 0x290 permet de récupérer la dernière touche tapée. L'écran de 20 lignes sur 40 colonnes est accessible à partir de l'adresse 0x300 jusqu'à l'adresse 0x620.
Le cache est une structure conservant des valeurs mémoire. L'acception du mot cache est la même que dans «la cache des pirates» : c'est là que l'on met les données qui sont précieuses.
L'image classique pour comprendre le rôle du cache est celle de la table à la bibliothèque : pour faire un exposé sur un thème précis on ramène plusieurs livres sur sa table et on travaille avec. Parfois on se relève pour aller en chercher de nouveaux et reposer les livres qui ne servent pas. Si il n'y avait pas de mémoire cache, c'est un peu comme si à chaque fois que l'on souhaite lire quelques lignes dans un ouvrage, il fallait perdre du temps à aller prendre un livre dans les rayons de la bibliothèque.
Un des rôles du simulateur est de pouvoir aider à dimensioner ces caches pour des applications particulières.
La RAM permet de conserver des informations mémoire ou bien de communiquer avec le monde extérieur.
Nous avons ici décrit sommairement une architecture.
De nombreux points doivent être précisés :
Pour toutes ces raisons il y a un besoin important de simuler le microprocesseur.
Nous allons voir comment SystemC va nous aider dans cette tâche.
Q1 On considère le code suivant :
Q2 On considère le code suivant :
Q3 On considère le code suivant :
Ex 2.2 * Opérateur et constructeur
Qu'affiche le programme ci-dessous ? Commentez le résultat.
Ex 2.3 ** Validité de pointeur
On se donne la classe Voiture définie ci-dessous :
Q1 Que retourne la fonction main ci-dessous :
Q2 Si vous êtes sur un PC sous linux utiliser le logiciel
valgrind pour diagnostiquer
l'erreur dans le code précédent.
Ex 2.4 ** Efficacité du switch
On se donne le programme suivant p.cc :
Q1 Compiler le programme à l'aide de l'instruction g++ -c -S p.cc.
Expliquer pourquoi dans le fichier assembleur p.s le temps d'accès aux instructions
du swicth se fait en temps constant. Pour information, dans le fichier
assembleur les valeurs
explicites de nombres commencent par
La documentation complète sur l'assembleur GNU se trouve
à l'url :
Q2
Observez les différences et commentez l'assembleur du programme suivant où
les cas d'entrée sont sur une plage d'entiers beaucoup plus grande :
Les exercices suivants servent à se familiariser avec le système Jasip.
Ils n'ont pas de rapport avec SystemC.
Ils nécessitent l'installation de l'émulateur Jasip, disponible à l'url :
tar xvfj jasip_0.1.tar.bz2
cd jasip_0.1
./configure
make
make install
Dans le répertoire
Ecrire une classe java dont l'exécution affiche l'alphabet. On se servira de la méthode java org.jasip.Jasip.term.setChar
pour l'affichage. Remarque : pour que le programme ne se termine pas tout de suite il faut faire une boucle infinie.
Une aide est fournie dans les commenataires de la classe org.jasip.Jasip située dans le fichier src/org/jasip/Jasip.java.
On pourra par exemple créer la solution dans la classe org.jasip.app.alphabet.Alphabet et l'écrire dans le fichier
Le but de l'exercice est d'écrire un programme java dans le système Jasip affichant
sur l'écran les caractères frappés au clavier.
Q1 Nous allons tout d'abord afficher les caractères au même endroit.
Ecrire un programme qui charge le registre r1 avec le contenu de l'adresse
0x300. Si r1 est différent de zéro alors, une touche a été enfoncée
on peut écrire le contenu de r1 à l'adresse 0x310 pour que l'affichage
s'effectue.
Q2 Incrémenter l'adresse où r1 est écrite, pour pouvoir
écrire sur toute la ligne.
Q1 Ecrire une classe
package org.jasip.app.si;
public class Item {
}
Q2 Ecrire une classe AlienSet
qui stocke l'ensemble des aliens à afficher à l'écran dans un
tableau d'éléments de type
org.jasip.app.si. Cette classe sera également en charge de déplacer
les aliens de droite à gauche.
Q3 Ecrire une classe MissileSet affichant les
missiles enovoyés par le vaisseau. Cette classe sera également en charge de déplacer
les missiles vers le haut.
Q4 Ecrire une classe BombSet affichant les
bombes enovoyées par les aliens. Cette classe sera également en charge de déplacer
les bombes vers le bas.
Q5 Assembler les classes précédentes pour réaliser le space invader.
Le but de cet exercice est de mettre en oeuvre un simulateur pour la mémoire
du système Jasip.
Un squelette contenant le code du processeur et des périphériques
est disponible à l'url :
tar xvfj jasip_sim_0.1.tar.bz2
cd jasip_sim_0.1
./configure -with-systemc=/répertoire/vers/systemc
make
Pour le répertoire vers l'installation SystemC
on pourra utiliser /users/profs/info/derepas/systemc
Le but est d'écrire les classes SystemC RAM et Cache décrites à la section 2.3.1. Il faut pour cela mettre à jour les fichers
ram.h et cache.h dans le répertoire jasip_sim_0.1.
Une fois ces classes écrites on peut les tester à l'aide de la commande :
Ce chapitre présente la librairie standard C++. Les problématiques
de test et d'intégrations sont présentés.
La STL (Standard Template Library) est une librairie importante qui
fait partie de la norme C++ [iso03]. Elle définit une
interface standard pour effectuer des manipulations communes :
listes d'objets, tables de hachage. Une bonne documentation
en ligne sur la STL se trouve à l'url :
Les chaînes de caractères sont conservées dans les type
Quand on souhaite mettre plusieurs données dans une chaîne de
caractères et non les afficher à l'écran on peut utiliser un objet de
type
Une structure souvent utilisée est la liste.
Les listes sont définies par le type template
Un itérateur sur la liste est à la fin de la liste quand il vaut le résultat
renvoyé par
Les associations sont effectuées à l'aide de la classe template
Les containers sont des types pouvant conserver d'autres objets (ses éléments).
La «vie» d'un élément contenu ne peut dépasser celle du contenant, pour cette raison
on utilise souvent les containers avec des pointeurs.
Pour tout objet X on va trouver entre autre les types suivants :
Voici les méthodes que l'on trouve souvent utilisées sur une instance a du container :
Les types suivant sont des containers : vector, deque, list, set, map, multiset multimap.
L'idée derrière cette architecture est de pouvoir facilement passer d'un vecteur à une liste
à un ensemble car les interfaces de ces containers sont relativement similaires.
Le type
La fonction que nous venons de créer : for_each_element, existe
en fait déjà dans les algorithmes de la STL et est nommée for_each.
Nous venons de constater comme les interfaces extrêmement structurées
de la STL nous permettent d'écrire des algorithmes génériques. Les algorithmes
fournis par la STL forment une partie importante et complémentaire
des structures de données exposées jusqu'ici.
Nous avons déjà vu l'algorithme for_each.
Un autre algorithme est le tri. Il faut que les opérations d'addition et de
soustraction entre itérateurs
soient implémentées. Voici un exemple utilisant la classe vector et affichant
100 nombres tirés au hasard triés aléatoirement.
On trouve également un algorithme nommé binary_search permettant de savoir en
temps logarithmique si
un élément est dans une séquence déjà triée.
Voici la hiérarchie complète des classes standard utilisées pour les entrée sorties :
Nous avons vu à travers différents exemples dans la STL les
bénéfices à utiliser une architecture modulaire. Nous allons décrire
comment de manière générale essayer de respecter ce style en C++.
Ceci sera ensuite appliqué à SystemC dans le chapitre suivant
avec la mise en place des canaux hiérarchiques.
Une notion fréquemment utilisée présente dans Java et C# est la notion d'interface.
Cette notion est souvent utilisée en particulier avec des interfaces graphiques :
un composant représentant un bouton va par exemple posséder une interface
lui permettant de réagir à un évènement du clavier mais également une autre
interface lui permettant de réagir à un évènement de la souris.
Cette notion existe également en C++. Il suffit de créer des classes
ne possédant que des méthodes virtuelles «pures», c'est à dire sans implémentation.
L'attribut «pure» est dénoté par =0 à la fin du prototype de la fonction (cela
suggère que la valeur du pointeur associé à cette méthode n'est pas valide).
Voici un exemple d'interface :
Maintenant nous souhaiterions disposer d'une classe Bouton
implémentant les deux interfaces précédentes. C++ possède alors une
caractéristique que ne possèdent ni Java ni C# c'est l'héritage multiple.
Ainsi pour déclarer une classe Bouton mettant en
Pour certaines classes il peut être utile même si
elles apparaissent plusieurs fois dans le diagramme d'héritage
d'une classe, qu'il n'y ait qu'une seule instance, comme illustré à la figure ci-dessous :
Voici une façon permettant de mettre en oeuvre ce mécanisme d'interface
en utilisant l'héritage multiple :
Il est important de vérifier que le modèle SystemC est conforme à l'idée
que l'on s'en fait. De plus les modèles SystemC ne sont qu'une partie
avant la création de modèles synthétisables VHDL ou Verilog.
C'est donc dans un cycle de développement plus large qu'il faut considérer
le modèle SystemC.
Considérons un cycle de développement de logiciel en «V» standard :
Suite aux spécifications fonctionnelles on trouve des spécifications techniques
qui décrivent les solutions techniques les mieux adaptées pour résoudre
le problème exposé dans les spécifications fonctionnelles.
Ce découpage peut être répété sur plusieurs niveaux d'abstraction.
Typiquement sur un projet d'une centaine d'hommes ans on trouvera
tout d'abord des spécifications fonctionnelles puis techniques
de haut niveau, puis des spécifications fonctionnelles
et techniques de plus bas niveau pour chaque module identifié.
Une fois le développement effectué on va trouver
des tests unitaires permettant de vérifier
chaque module.
Puis surviennent des tests pour l'assemblage des différents modules.
Le but de SystemC étant de faire communiquer facilement à haut niveau
un nombre de composants pouvant être important, cet aspect d'intégration
à haut niveau est important.
On va donc uniquement se focaliser sur les échanges avec l'extérieur
pour savoir si l'implémentation à tester est conforme avec les spécifications
fonctionnelles.
Au contraire du test boite noire, le test boite blanche va chercher à valider
le fonctionnement interne d'un module SystemC. Ce type de démarche est soit
utile pour valider que la simulation correspond bien à nos attentes
(typiquement le module est bien passé par tel et tel état) ou pour
valider le fonctionnement interne d'un module relativement critique.
Ex 3.1 * Mauvaise utilisation des itérateurs
Considérons le programme suivant :
Q1 Ce programme provoque une erreur d'exécution.
Expliquer pourquoi. On pourra se servir du logiciel
valgrind pour valider la réponse.
Q2 Rédiger une version correcte de la fonction
moveElementsInOtherList.
Ex 3.2 ** Performance des containers associatifs
On se donne la fonction suivante renvoyant une chaine de caractères aléatoire :
Q1 Ecrire un programme prenant en argument un entier n,
qui crée un container de type
Q2 Effectuer 10 millions de fois des
recherches dans le container définit à la question précédente.
Q3 Mesurer les résultats pour différentes valeurs
de n.
Pour mesurer la durée d'un bloc d'instruction on pourra utiliser
la fonction
Ex 3.3 ** Test des modules ram et cache
Réaliser un module testant les modules
créés à l'exercice 2.8.
Après un bref complément sur C++, ce chapitre aborde
les structures internes utilisées dans SystemC.
Les réseaux sur puce sont présentés. Enfin
les exercices visent à simuler un réseau sur puce pouvant
servir à faire un processeur multic
Collections ordonnées
Un des traits du langage C++ qui n'a pas été abordé jusqu'ici sont les exceptions.
Les exceptions sont, comme le nom l'indique des structures permettant de gérer
les cas exceptionnels.
La sémantique est d'essayer un bloc d'instructions C++ et de «rattraper» des erreurs
qui pourraient survenir pendant l'exécution de ces instructions. Les informations sur
les erreurs survenues sont des classes arbitraires.
Le bloc d'instruction à exécuter commence par le mot clé try.
La récupération des erreurs s'effectue par le mot clé catch suivit
du type d'exception levée. Voici un exemple : définissons tout d'abord un
type ErreurInterne portant les informations que nous souhaitons remonter :
la raison de l'erreur, la position dans le fichier où l'erreur s'est produite.
Considérons le programme suivant qui effectue 4.109 appels à une méthode virtuelle :
Si une class B hérite de A ,
elle va disposer de sa propre table des méthodes virtuelles.
Elle comporte également une entrée qui est un pointeur de méthode
vers la méthode getValue surchargée dans B.
Lors de l'appel à la méthode getValue dans la fonction f
il faut tout d'abord rechercher dans la table le pointeur de méthode
puis l'appeler. Il y a donc un coût plus important avec une méthode virtuelle
que sans.
Les fonctions ou les méthodes peuvent être déclarées inline.
Cela signifie que quand elle sont appelées dans le code C++,
au lieu d'être appelées dans le code assembleur, le code assembleur sera inséré au
lieu de faire l'appel, ceci permet de gagner en performance pour des fonctions
qui sont appelées souvent à partir du même endroit dans le programme.
D'un point de vue syntaxique il suffit de rajouter le mot clé inline
devant la déclaration de la fonction. En voici un exemple :
Pour mieux comprendre la description d'un modèle SystemC il est
important de comprendre ce que font les macros utilisées. Ceci
est par exemple nécessaire si l'on souhaite disposer d'un module
dont le constructeur a des arguments spécifiques : on ne peut utiliser
la macro SC_CTOR mais uniquement une version expansée de celle-ci.
La définition d'un module se fait par SC_MODULE(MonModule). Cette définition
est équivalent à struct MonModule : sc_module. Ainsi les modules SystemC
sont définis comme des classes SystemC héritant de sc_module.
On peut ne pas souhaiter utiliser la macro SC_MODULE si l'on souhaite
que certains champs de la classe soient privés.
Le constructeur d'un module est déclaré par l'entête SC_CTOR(MonModule).
Cette déclaration est équivalente à :
Pour qu'une méthode d'un module SystemC soit invocable par l'ordonnanceur SystemC,
on utilise l'instruction SC_METHOD (maMethode). Ceci est équivalent à :
Il existe plusieurs types de canaux en SystemC. Nous avons présenté sc_signal
que l'on peut voir comme un signal électrique sur un cable mais également
sc_fifo est plus élaboré.
On peut définir ses propres types comme étant des canaux.
Il faut pour cela respecter les
règles suivantes :
Nous allons dans
cette section définir un canal de communication à l'aide d'un sc_module.
Ce canal va implémenter une pile, c'est à dire une structure lifo (par opposition à fifo)
pour «last in first out».
Nous allons définir deux interfaces sur la pile : une pour l'écriture et une pour la lecture.
Les interfaces doivent hériter de manière virtuelle (cf. section 3.3.3)
de la classe sc_interface.
Le code complet pour faire fonctionner l'exemple est disponible
à l'url :
Deux périphériques sont présents dans le système Jasip : le clavier et
le terminal. Nous décrivons ici comment rajouter un périphérique pour
accéder au système de fichiers.
Déterminons d'abord les opéations à effectuer :
Il nous faut pour cela :
Nous allons pour manipuler un fichier utiliser une zone mémoire de 128 bits comme suit :
Ceci est résumé sur l'image suivante :
Pour ouvrir un fichier il faut :
Pour écrire dans un fichier :
Les autres opérations sont implémentées dans la classe
org.jasip.DiskDriver. Le source de cette classe
se trouve dans le répertoire src
de la distribution jasip ou jasip_sim.
Le système jasip propose d'ouvrir 4 fichiers par microprocesseur :
Ces plages mémoires viennent s'ajouter aux adresses
La classe org.jasip.DiskDriver mentionnée précédemment
permet également d'avoir un accès de plus haut niveau sans manipuler
les adresses mémoires.
Voici comment écrire un fichier nommé
Nous avons déjà mentionné le fait que les composants électroniques devenaient
de plus en plus intégrés. On trouve actuellement des systèmes sur puce ou SoC (System on
Chip) dans une variété d'équipements (assistant numérique, voiture, ...). Utiliser
un bus pour faire communiquer tous ces composants peut être mal adapté, en effet
la capacité du bus est souvent limitée et le bus est mal adapté à une évolution
rapide du nombre de composant connectés. Utiliser un système où chaque composant est relié à tous les autres est très coûteux et pas nécessairement adapté.
Ainsi le réseau sur puce ou NoC (Network on Chip) tendent à être un compromis
en coût et volutivit acceptable [GG00]. C'est en particulier important
pour la réalisation d'un microprocesseur multic
Il existe des environnements spécialisés basés sur SystemC pour modéliser les NoC
comme par exemple OCCN http://occn.sourceforge.net [CCG+04].
Nous utiliserons dans les exercices suivant simplement les classes
SystemC.
Le but des exercices est de simuler un réseau de communication.
Voici un rappel des différentes couches du modèle OSI :
Ex 4.1 ** Couche réseau du modèle OSI
Nous nous plaçons dans cet exercice au niveau de la couche réseau.
Q1 Ecrire la classe NetworkLayer possédant 4 entrées i1, i2, i3 et i4 et 4 sorties o1, o2, o3 et o4, qui va être l'un des noeud
du réseau présenté ci-dessous. On ajoutera de plus une entrée sur l'horloge du système
permettant d'envoyer spontanément de manière aléatoire des paquets de données.
Q2 Ecrire une fonction sc_main permettant d'instancier le réseau de 16 n
Ci-dessous figure les résultats de perte de paquets obtenus pour différentes valeurs de fréquence d'émission de paquets, et pour différentes tailles de fifo. On remarque qu'une taille
de fifo de 8 permet d'avoir de très bon résultats, et que pour notre topologie
il s'agit sans doute d'un bon compromis prix/performance.
Ex 4.2 ** Utilisation du réseau
Dans l'exercice précédent nous avons développé un réseau pouvant comporter des pertes
de paquets en cas d'engorgement. Ce type de réseau peut s'appliquer à des applications
ayant un débit de communication constant ne générant pas d'engorgement, c'est ce que
nous allons développer dans cet exercice en utilisant un code C++
effectuant du calcul de propagation d'onde, ce calcul étant distribué sur les différents
n
Q1 Implémenter l'interface NetworkIf sur le module
NetworkLayer de l'exercice 4.1.
Q2 L'interface NetworkPlugableIf
est implémentée par la classe NodeCompute. Utiliser une instance
de NodeCompute par noeud de réseau pour obtenir une simulation
de propagation d'onde.
On veillera à utiliser dans la compilation les fichiers
paquet.cc, displaynodes.cc, nodecompute.cc.
Le lien avec les librairies graphiques se fera (comme indiqué
dans le makefile fourni dans l'archive) par la commande -L/usr/X11R6/lib -lX11.
Ex 4.3 *** Couche transport du modèle OSI
Nous nous plaçons dans cet exercice au niveau de la couche transport. Nous réutilisons
la couche réseau faite dans l'exercice précédent. Ainsi à chaque noeud de la couche
réseau nous rajoutons un port nt (network vers transport) et un port tn (transport vers network)
permettant de communiquer avec la couche de transport :
Q1 Modifier la structure des paquets de l'exercice précédent pour rajouter :
Q2 Modifier le module de la couche réseau de l'exercice précédent et lui adjoindre
le module de la couche transport.
Q3 Produire les graphes affichant le taux de rupture de communication au niveau
de la couche transport en fonction de la taille des fifos et de la fréquence d'émission
des messages.
Tout comme à la section 4.4 on a plaqué sur l'espace mémoire
un périphérique de disque, définir et implémenter un
périphérique réseau conforme au modèle présenté dans l'exercice 4.3.
Ex 4.5 *** Microprocesseur Multic
Utiliser le réseau des exercices 4.3 et 4.4 pour réaliser un
microprocesseur multic
Il faut pour cela ajouter un nouveau périphérique mémoire au module ram.h de l'exercice 2.8. On déterminera donc des adresses mémoire spécifiques permettant à un microprocesseur de communiquer avec le réseau. Pour créer un programme Java testant le réseau il suffit d'utiliser les méthodes :
Le complément C++ de ce chapitre vise à poser les bases
d'une pratique fréquente : la relecture de code.
La relecture de code est une réunion de travail où l'on relit du code.
Elle comprend en général :
On veillera lors d'une revue de code à la présence de développeurs
«sénior ».
L'idée de la revue de code n'est pas de placer le concepteur
devant un tribunal jugeant son travail mais de répandre les bonnes
pratiques de conception et d'homogénéiser les méthodologies
entre développeur.
Aussi l'aspect relationnel est important aussi bien pendant la préparation
de la réunion que lors de son déroulement.
Voici trois occasions de faire des relectures de code :
Tout d'abord la relecture de code vise à corriger le code
dans sa forme. La forme est généralement donnée par
un document nommé «règles de codages »,
et contenant généralement des informations de la forme :
L'une des principales source de perte de temps dans un développement C++
mal maîtrisé est les fuites mémoires.
Il faut vérifier qu'à chaque allocation (new ou new[])
correspond bien une desallocation (delete ou delete[]).
L'idée n'est bien sur pas de couvrir tous les cas comme le ferait un programme
de type Valgrind mais d'avoir pour chaque membre d'une classe
une vision synthétique de la politique d'allocation/desallocation,
c'est à dire qu'il soit clair à tout moment quel objet à la charge de
desallouer la mémoire allouée.
Un dépassement de capacité dans l'allocation mémoire peut se produire par mégarde.
Considérons ainsi le code suivant sur une machine 32 bits :
Il est important de parenthéser les macros. Considérons ainsi le code suivant:
Ainsi la «bonne »façon de définir ces macros est la suivante :
De manière générale il est facile de se tromper dans l'usage
des macros en utilisant par exemple un objet du mauvais type
(il n'y a pas de vérification de type dans les macros).
Aussi il est important dès qu'une macro est de taille conséquente
de se demander si on ne pourrait pas utiliser une fonction «inline ».
Dès que l'on utilise les opérateurs logiques
Il faut se méfier de l'évaluation sur les opérateurs logiques :
en effet dans l'expression
La sémantique précédente change complètement si l'opérateur
La précédence des opérateurs C ou C++ est souvent mal connue. Aussi vaut-il toujours mieux
rajouter des parenthèses pour supprimer toute ambiguité.
Ainsi les deux lignes suivantes sont équivalentes :
En C et en C++ plusieurs séquences d'appel ne sont pas spécifiées et varient d'un compilateur
à l'autre (ou même pourraient varier pour un même compilateur).
Ainsi dans l'expression ci-dessous :
En revanche certains opérateurs nous garantissent que certains appels vont avoir lieu avant
d'autres :
Il est à noter que touche surcharge de ces opérateurs fait basculer la sémantique sous
celle de l'appel fonctionnel.
Voici un exemple type de code à banir :
De même si on dispose d'un fonction lisant un flux
Dans un switch l'absence de break (ou de return) entre deux case entraine une exécution du
cas suivant :
La relecture de code, même si elle est complémentaire
à l'usage d'outils, peut tout de même s'appuyer sur les résultats
donnés par :
Dans ce chapitre nous allons nous intéresser aux problématiques de
conception simultanée de matériel et de logiciel à travers un exemple
sur la propagation des ondes sur une surface plane.
Cet exemple a été utilisée dans l'exercice 4.2 pour
valider la bonne marche de la simulation de réseau.
Voici l'équation de propagation des ondes en milieu homogène utilisée :
Le but est de calculer une solution approchée en discrétisant le problème sur une grille carrée.
On se donne les tableaux ci-dessous contenant respectivement la valeur du niveau
en un point de la grille, la vitesse de changement
de ces valeurs et l'accélération de changement de ces valeurs :
On a imposé la contrainte que les bords restaient à une hauteur constance. On
remarque la réflexion de l'onde sur les bords au temps t=120.
Nous allons détailler comment un système matériel et logiciel
en charger de calculer la propagation d'onde peut être modélisé
à l'aide de SystemC.
La première étape consiste tout d'abord à valider au sein d'une architecture
plus large les actions faites par le module.
Cette étape permet de configurer des paramètres importants de l'environnement
(taille des fifos, validation du mécanisme de transport)
ou de l'algorithme (précision suffisante). A ce niveau de détail on ne sait
encore si le module SystemC sera implémenté par du matériel ou du logiciel.
Si l'on dipose d'un modèle SystemC d'un microprocesseur comme Jasip produit
par l'exercice 2.8
on peut
écrire une version des équations de la section 5.2.2,
puis lancer ce code sur le modèle du microprocesseur. On obtient alors l'architecture suivante :
Cette architecture nous permet de connaitre si un réseau de microprocesseurs MUP
peut calculer pour le débit nécessaire, pour le coût nécessaire
l'équation de propagation des ondes.
On remarque dans les formules de la section 5.2.2 trois fois
on fait un ajout d'une d'valeur v multipliée par un certain facteur f.
Il pourrait dès lors être intéressant de disposer d'une instruction
macc multiply accumulate de la forme :
macc r1 v1 f qui ajoute au registre r1 la valeur v multipliée
par le facteur f.
Disposer d'une telle instruction fait gagner du temps et permet une exécution
plus rapide
Nous remarquons que l'on effectue des instructions répétitives sur des données différentes.
Ainsi l'instruction I :
Dès lors si nous disposons d'une unité vectorielle qui effectue une même opération
sur plusieurs instructions nous pourrions gagner plusieurs cycles d'horloge.
Un vecteur est une suite continue de cases mémoire.
La taille en octets de chaque case doit être spécificiée par vsets.
Le nombre d'éléments du vecteur doit être spécifié par vsetn.
L'instruction I donnée en exemple peut alors s'écrire dans l'assembleur
utilisant le co-processeur :
Si l'unité vectorielle marche en parallèle de l'unité normale on peut même écrire
les instructions du co-processeur en parallèle des instructions du microprocesseur :
Avec l'unité vectorielle on obtient un gain au moins égal au nombre d'éléments dans un vecteur.
Si le matériel est prévu pour être produit en un nombre très important d'exemplaires ou bien
qu'un FPGA (Field Programmable Gate Array) est présent alors un circuit matériel dédié
peut être construit.
Dans le cas présent les opérations les plus fréquentes sont les additions.
Il faut donc pourvoir avoir un maximum d'additions. Il faut en outre des circuits
matériels fixes pour multiplier par 4 et diviser par 200.
La méthode sera la même que dans le cas de l'unité vectorielle, mais sera moins généraliste
et pourra donc comporter plus d'additionneurs pour une surface de silicium égale.
Ex 5.1 *** Co-Processeur vectoriel
Implémenter le co-processeur définit à la section 5.3.3.
Nous décrivons comment un cadre conceptuel comme Jasip peut être décliné
en pratique. Nous présentons les initiatives basées sur Java et proches
du matériel. Puis nous présentons Unisim un cadre d'utilisation
de SystemC permettant de simuler des sytèmes complets.
Jasip propose une API Java à un système. Cette approche n'est pas spécialement originale
et on la retrouve dans plusieurs systèmes embarqués, en particulier les téléphones.
L'avantage d'une telle approche est de pouvoir utiliser des développeurs standards
pour la partie applicative.
Très rapidement Sun a proposé une version embarquée de java nommée J2ME
pour Java 2 Mobile/Micro Edition, par rapport à la version standard nommée J2SE
Java 2 Standard Edition.
Tout comme dans Jasip on retrouve des classes de base réduites
par rapport à J2SE, c'est la même chose dans J2ME.
Il y a quelques bibliothèques en plus dans J2ME, par exemple pour gérer la mémoire si
l'on ne dispose pas de la possibilité d'avoir un ramasse miette (garbage collector).
Début novembre 2007, la société Google vient de sortir une plate-forme
pour téléphones portables nommée Android (http://code.google.com/android).
Tout comme Jasip elle se programme en Java.
L'architecture d'Android est bien sûr plus complexe que celle de Jasip.
La partie applicative ainsi que le cadre de base sont dans l'approche similaires à ceux
de Jasip.
On trouve simplement des libraires en plus, ce qui augmente considérablement la puissance
du cadre.
Il y a également plusieurs couches avec le matériel : linux joue le rôle de couche
d'abstraction par rapport au matériel et une machine virtuelle (basée sur des registres,
contrairement à la machine virtuelle de Sun) nommée Dalvik est utilisée.
J2ME dispose d'une interface unique (à travers la machine virtuelle) pour accéder au matériel.
Cela assure une plus grande portabilité.
Android en revanche utilise plus de couches d'abstraction avec le matériel. Cela peut conduire à une certaine pénalité à l'exécution mais permet de mieux réutiliser le code existant.
Android est une plate-forme en devenir dont la sortie opérationnelle est programmée
mi 2008. Outre l'appreciation de l'architecture d'autre facteur sont à prendre en compte pour
savoir laquelle des deux sortira gagnante dans quelques années. Ainsi Andoid est
une plate-forme open-source alors que J2ME est une solution propriétaire actuellement
largement dominante sur laquelle Sun a basé une partie important de ses recettes.
Dans cette section nous détaillons unisim unisim.orgun environnement de simulation basé sur SystemC et
permettant de faire du prototypage virutel,
c'est à dire permettant de facilement simuler des plate-formes matérielles
à l'aide de logiciel.
Unisim est distribué avec une licence open-source.
Il existe deux versions : une version précise au cycle d'horloge près
et un version dite «TLM »(Transaction Level Modeling)
plus rapide mais moins précise sur les aspects temporels.
Les plate-formes virtuelles recouvrent les usages suivants :
Ci-dessous figure un exemple de système réalisé à l'aide de la plate-forme unisim.
L'interface n'est implémentée qu'à travers un seule classe : TlmSendIf.
Cette interface ne dispose que d'une méthode notifiant l'envoi d'un message.
La classe TlmSendIf est une classe générique parmétrée par le type de la requête
et le type de la réponse.
C'est un initiateur qui va appeler la méthode Send sur la cible.
Le message contient trois données : la requête, la réponse et un évènement.
Si une réponse est attendue, l'initiateur doit mettre un évènement.
Il doit alors attendre jusqu'à ce que la réponse soit écrite.
Pour être plus performant et donc éviter les recopies le message est partagé entre
l'initiateur et la cible. Pour éviter de savoir à qui est la responsabilité de
desallouer la mémoire du message, cette tâche est confiée à une classe nommée
Pointer en charge de compter le nombre de référence à l'objet.
Ainsi l'interface devient :
Considérons le code suivant :
Ce code est correct mais si une exception est lancée, ou si une autre personne
rajoute un return au milieu de la fonction alors la libération mémoire n'est pas
effectuée.
On peut déléguer la responsabilité de la libération mémoire à
une structure nommée auto-pointeur (
Ainsi le code résultant est
Prenons un exemple plus long :
On peut mettre les auto pointeurs dans les champs de classes,
cela évite d'avoir à les détruire dans le destructeur
Un pointeur n'est géré que dans un seul auto pointeur. Ainsi l'exemple ci-dessous
conduit à une erreur :
Comme le fait de recopier un auto pointeur transfert sa valeur on peut
utiliser des auto pointeurs en retour de fonction :
Il ne faut pas utiliser les auto pointeurs dans les containers, typiquement
dans :
Nous venons de voir les auto pointeurs. Leur limitation réside dans le fait
qu'un pointeur ne peut être dans un seul auto pointeur à la fois.
Ainsi l'usage des auto pointeurs n'est pas compatibles avec
la STL et la sémantique de l'affectation n'est pas nécessairement
intuitive.
L'utilisation des pointeurs partagés (shared pointers) est semblables
à celle des auto pointeurs. Simplement le nombre de références
d'un objet est compté.
Voici une utilisation simple des
Le même pointeur pouvant être utilisé dans plusieurs shared pointers,
cela permet une utilisation facile dans un contexte multi threadé :
Ex 6.1 *** Gestion des pointeurs
Q1 Proposer une implémentation pour les auto pointeurs.
Q2 Proposer une implémentation pour les pointeur partagés.
Cette fiche pratique s'attache détailler les étapes
de la compilation de fichiers C++ en ligne de commande
sous linux.
Nous supposons que l'utilisateur dispose d'un environnement linux
comprenant les logiciels :
Nous supposons l'usage d'un shell bash. Le shell en cours s'obtient
à l'aide de la commande echo $SHELL.
La compilation c'est le fait de transformer
un langage dit de haut niveau comme C ou C++ en
une série d'instructions assembleur directement compréhensible
par le microprocesseur de la machine.
Du point de vue de l'utilisateur la compilation C++ a lieu en deux
passes :
Dans le même répertoire qu'un fichier toto.cc taper :
Pour voir tous les mises en gardes (warnings) possibles rajouter
l'option de compilation -Wall, et pour garder dans le fichier
objet des information donnant les numéros de lignes et de fichier
(utiles au débuggage) utiliser
l'option -g :
Pour afficher l'ensemble du code ramené par les directives
Etant donné les fichiers objets toto.o, tata.o et tutu.o,
si l'un de ces fichiers définit une fonction main on peut alors créer
un exécutable nommé monexecutable grâce à l'instruction suivante :
Considérons le programme suivant dans le fichier main.cc :
Tous ces symboles prennent de la place et peuvent être supprimés à l'aide
de la commande strip. Il n'est alors plus possible d'utiliser la commande
nm, mais on a gagné en taille :
Taper ces lignes de commande à chaque fois que l'on veut tester à nouveau
une modification est relativement fastidueux. Pour automatiser
le processus de compilation existent les makefiles.
Un makefile est un fichier généralement nommé Makefile ou makefile.
Il est lancé par la commande make exécutée dans le répertoire où il
se trouve.
Pour compiler le fichier C++ toto.cc en le fichier objet toto.o
il faut écrire le makefile suivant :
De même pour créer un exécutable il suffit de rajouter au début du makefile les
deux lignes suivantes :
Le compilateur peut parfois être g++, ou un autre. Si l'on veut tout changer d'un coup,
il faut mieux utiliser une variable. De même pour les options :
Si l'on souhaite compiler tous les fichiers *.cc d'un même répertoire
on peut définir la liste de fichiers sources dans SOURCES et la
liste de fichiers objets dans OBJ à l'aide de la commande
suivante :
Il existe de plus deux variables définies à chaque règle :
On peut également écrire des règles génériques
s'appliquant à un ensemble de fichiers définis par une extension. Par exemple
au lieu d'écrire les deux règles suivantes :
Un fichier *.cc inclus en général des fichiers d'entête *.h.
Si un de ces fichiers *.h est modifié, il est en général sage de
recompiler les fichiers *.cc qui l'incluent. Voici la règle
à rajouer pour un fichier toto.cc incluant toto.h :
En résumé voici un makefile par défaut qui compile tous les fichiers
*.cc d'un répertoire en un exécutable monexe, en tenant
compte des dépendances sur les fichiers d'entête :
Cette fiche pratique s'attache à détailler la compilation de programmes
Java en bytecode.
Un ensemble de fichiers Java source est compilé vers un ensemble de fichiers
byte code qui peut être exécuté sur une machine virtuelle. À l'inverse du C
ou C++ il n'y a pas d'étape d'édition de lien puisque celle-ci est réalisée
de manière dynamique au moment de l'exécution.
Pour compiler tous les fichiers dans le répertoire src vers le répertoire build :
Pour que les symboles de debug soient présents il faut rajouter
l'option -g :
Pour visualiser le byte code avec l'exemple précédent il suffit d'invoquer
la commande :
Chaque cible est décrite dans une balise
L'ensemble des syntaxes possibles sont décrites sur le site http://ant.apache.org.
1.3 Simulation d'écosystème **
1.4 Addition en temps logarithmique ***
2.2 Opérateur et constructeur *
3.1 Mauvaise utilisation des itérateurs *
3.2 Performance des containers associatifs **
3.3 Test des modules ram et cache **
4.1 Couche réseau du modèle OSI **
4.3 Couche transport du modèle OSI ***
4.5 Microprocesseur Multic
5.1 Co-Processeur vectoriel ***
#include <iostream>
using namespace std;
class Vector {
friend ostream & operator<< (ostream &, const Vector &);
public :
Vector (int i,int j,int k,int l) {
valeur[0]=i;
valeur[1]=j;
valeur[2]=k;
valeur[3]=l;
}
private:
int valeur[4];
};
ostream & operator<< (ostream & os, const Vector & v) {
os << "[";
for (int i=0;i<4;++i) {
os << v.valeur[i] << " ";
}
os << "]";
return os;
}
int main() {
Vector v1 (1,2,3,4);
cout << v1 << endl;
return 0;
}
operator new
A * a = new A();
va réserver une zone mémoire contenant une instance de A initialisée
avec le constructeur par défaut. Cette adresse peut alors être manipulée par n'importe
quelle fonction du programme.
La réservation continue jusqu'à ce que l'opérateur delete
soit appelé quelque part dans le programme :
delete a;
On appelle généralement fuite mémoire un oubli répété de libération
de mémoire à l'aide de la commande delete. Un programme qui fonctionne pendant
10 minutes et a une fuite mémoire ne pose pas de problèmes. Cependant, si ce programme
tourne plusieurs heures il peut finir par consommer l'ensemble de la mémoire de la machine.
class V {
public:
void * operator new (unsigned int);
int a;
};
#define NULL (void*)0
V tableauDeV [10];
int indexInArray=0;
void * V::operator new (unsigned int size) {
if (indexInArray>=10) return NULL;
return &(tableauDeV[indexInArray++]);
}
Au bout de 10 allocations new renverra NULL.
operator=
class Vector {
public :
Vector (int i) {
// allocation d'un tableau de taille i
valeur = new int[taille=i]; // appel à l'opérateur new[] sur les entiers
}
~Vector() {
// effacer l'allocation faite dans le constructeur
delete [] valeur; // appel à l'opérateur delete[]
}
void setValue(int n, int i) {
if (i<0 || i>=taille) return;
valeur[i]=n;
}
Vector & operator=(const Vector & v) {
if (taille!=v.taille) {
delete [] valeur;
valeur = new int[taille=v.taille];
}
for (int i=0;i<taille;++i) {
valeur[i]=v.valeur[i];
}
}
private:
int * valeur;
int taille;
};
int main() {
Vector v1 (10);
for (int i=0;i<10;++i) v1.setValue(i,i);
Vector v2 (10);
v2=v1;
return 0;
}
Il est à noter que si nous avions écrit directement
Vector v2 = v1 c'est le constructeur par
recopie (détaillé ci-dessous) qui est appelé et non l'opérateur d'affectation.
Aparté sur le constructeur par recopie
Vector (const Vector & v) {
if (taille!=v.taille) {
delete [] valeur;
valeur = new int[taille=v.taille];
}
for (int i=0;i<taille;++i) {
valeur[i]=v.valeur[i];
}
}
Voici la différence entre le constructeur par recopie par défaut et le nouveau
constructeur par recopie où le tableau est réalloué :
operator== et operator!=
Voici la définition de l'opérateur == sur la classe vecteur
précédemment décrite :
bool operator==(const Vector & v) {
if (taille!=v.taille) {
return false;
}
for (int i=0;i<taille;++i) {
if (valeur[i]!=v.valeur[i])
return false;
}
}
bool operator!=(const Vector & v) {
return !(*this==v); // appel à l'opérateur == précédemment définit.
}
La variable this est un pointeur vers l'instance
sur laquelle la méthode a été appelée. Elle peut être utilisée dans toute
méthode non statique d'une classe.
operator[]
int operator[](int i) {
if (i<0 || i>=taille) return 0;
return valeur[i];
}
Le cas où i n'est pas dans le bon intervalle pourrait également être traité par
le mécanisme des exceptions (cf. section 4.1.2) plutôt que
de simplement retourner zéro.
operator()
enum CmpType {
CMP_PLUS_GRAND,
CMP_PLUS_GRAND_OU_EGAL,
CMP_PLUS_PETIT,
CMP_PLUS_PETIT_QUE
};
class Compare {
public:
Compare(CmpType t) {type=t;}
bool operator()(int i1,int i2) {
switch (type) {
case CMP_PLUS_GRAND:
return i1>i2;
case CMP_PLUS_GRAND_OU_EGAL:
return i1>=i2;
case CMP_PLUS_PETIT:
return i1<i2;
case CMP_PLUS_PETIT_QUE:
return i1<=i2;
}
return true;
}
private:
CmpType type;
};
void trieList(Compare &);
int main() {
Compare cmp (CMP_PLUS_PETIT_QUE);
bool b1 = cmp(1,3); // b1 vaut true
bool b2 = cmp(4,2); // b2 vaut false
trieList(cmp); // trier au regard de la fonction définie par cmp
return 0;
}
operator->
#include <iostream>
class A { // définition d'une classe A
public:
A() { }
int getValue() { return 314; }
};
class T { // définition de T
public:
T(A *_a) { a=_a;}
A * operator -> () { // redéfinition de ->
return a;
}
A * a;
};
int main() {
A * a = new A();
T t (a);
std::cout << t->getValue() << std::endl;
return 0;
}
L'exécution du programme ci-dessus affiche 314.
2 Sémantique d'exécution SystemC
1 Phases de simulation d'un modèle SystemC
1 Elaboration
Il y a tout d'abord la phase d'élaboration. Elle commence
avec l'invocation de la fonction sc_main, et elle finit
avec l'invocation de la fonction sc_start.
C'est la phase dans laquelle le modèle est construit :
les modules sont instanciés, les ports sont attachés aux signaux.
2 Simulation
L'étape 2 est nommée delta-cycle, et les étapes 2 à 8 forment un cycle, où l'horloge est
effectivement incrémentée. Par rapport au temps dans la simulation, la durée d'un delta
cycle est nulle. Il peut y avoir un nombre arbitraire de delta-cycles dans un cycle d'horloge.
2 Temps et horloge
#include "systemc.h" // inclut les définitions SystemC
SC_MODULE(GiveTime) { // définit un module nommé GiveTime
// un seul port d'entrée : l'horloge.
sc_in_clk clock;
void showClock() {
cout << sc_time_stamp() << endl;
}
SC_CTOR(GiveTime) {
SC_METHOD(showClock);
sensitive << clock;
}
};
int sc_main(int argc, char** argv) {
sc_clock clock("clock",sc_time(1,SC_NS)); // créé un objet représentant l'horloge
// avec une période de 1ns.
GiveTime giveTime("giveTime"); // créé l'instance du module
giveTime.clock(clock); // lie l'horloge clock au port giveTime.clock
sc_start(sc_time(5,SC_NS)); // lance la simulation jusqu'à 5 ns
return 0;
}
L'horloge du système est déclarée à la ligne 15. Les horloges sont des
instances de sc_clock. Elles sont
construites avec un nom et une période. Le port associé dans le module
GiveTime est de type sc_in_clk.
Voici le résultat de l'exécution du module :
0 s

0 s

0 s

3 Instances d'exécutions
Les SC_THREAD diffèrent des SC_METHOD par les caractéristiques suivantes :
#include "systemc.h"
SC_MODULE(MonModule) {
// ports en sortie
sc_fifo_out<int> outFifo;
// ports en entrée
sc_fifo_in<int> inFifo;
void prcMonModule() {
while (true) {
outFifo.write(inFifo.read());
}
}
SC_CTOR(MonModule) {
SC_THREAD(prcMonModule);
sensitive << inFifo.data_written();
}
};
Voici le même code en utilisant une SC_METHOD :
#include "systemc.h"
SC_MODULE(MonModule) {
// ports en sortie
sc_fifo_out<int> outFifo;
// ports en entrée
sc_fifo_in<int> inFifo;
void prcMonModule() {
if (inFifo.num_available()<1) { return;}
if (outFifo.num_free()<1) { return;}
outFifo.write(inFifo.read());
}
SC_CTOR(MonModule) {
SC_METHOD(prcMonModule);
sensitive << inFifo.data_written();
}
};
4 Communication par files de messages
Une file de messages de type fifo de capacité n peut contenir n messages.
Deux types d'opérations sont possibles sur les fifos : l'écriture et la lecture.
L'écriture ajoute un message dans la file, la lecture donne et supprime le message
le plus ancien.
Pour chaque opération, on distingue des variantes bloquantes et non bloquantes :
écriture bloquante ou non bloquante si la file est pleine,
lecture bloquante ou non bloquante si la file est vide.
Le type représentant la fifo est sc_fifo.
Les ports associés sont sc_fifo_in et sc_fifo_out.
#include "systemc.h"
#include <iostream>
using namespace std;
SC_MODULE(Producer) {
sc_fifo_out<int> fifoOut; // un seul port en entrée : la fifo
int counter; // compteur à incrémenter à chaque envoi
void prcProducer() {
while(true) {
fifoOut.write(counter);
cout << "Sending " << counter++ << endl;
if (counter>=100) {
sc_stop();
}
}
}
SC_CTOR(Producer) {
counter=0;
SC_THREAD(prcProducer);
}
};
Le producteur envoie les nombres de 1 à 100 puis arrête la simulation.
Le consommateur lui affiche les nombres lus :
SC_MODULE(Consumer) {
sc_fifo_in<int> fifoIn;
void prcConsumer() {
while (true) {
int counter = fifoIn.read();
cout << "Receiving " << counter << endl;
}
}
SC_CTOR(Consumer) {
SC_THREAD(prcConsumer);
sensitive << fifoIn.data_written();
}
};
Il reste maintenant à relier les deux modules à l'aide d'une fifo, que nous choisissons
de taille 5 :
int sc_main(int argc, char ** argv) {
sc_fifo<int> myFifo (5); // création d'une fifo de taille 5
Producer p ("producer"); // instanciation du producteur
p.fifoOut(myFifo); // lier la fifo au port du producteur
Consumer c ("consumer"); // instanciation du consommateur
c.fifoIn(myFifo); // lier la fifo au port du consommateur
sc_start(); // lancer la simulation
return 0;
}
Voici le résultat tronqué de l'exécution :
Sending 0
Sending 1
Sending 2
Sending 3
Sending 4
Receiving 0
Receiving 1
Receiving 2
Receiving 3
Receiving 4
Sending 5
Sending 6
...
Sending 98
Sending 99
SystemC: simulation stopped by user.
On remarque ici la politique d'ordonnancement du moteur de simulation SystemC :
si un SC_THREAD est lancé, son exécution continue jusqu'à ce qu'un blocage intervienne.
En effet le producteur rempli complètement la fifo, le consommateur pourrait commencer de lire,
mais l'ordonnanceur ne lui donne pas la main.
5 Passage d'arguments à la simulation
int sc_main(int argc, char * argv[]);
Les variables argc (argument count) et argv (argument value)
représentent respectivement le nombre d'arguments en ligne de commande et leur valeurs.
En effet char * représente un pointer vers des caractères. Ce type est souvent utilisé
dans le langage C pour désigner une chaîne de caractères se terminant par zéro.
Ainsi en C ou en C++ l'instruction char * p = "toto"; fait pointer
p vers une zone mémoire de 5 caractères comme représentée
à la figure ci-contre.
Si le modèle SystemC compilé est nommé toto.exe, l'appel
sur la ligne de commande
toto.exe -monparametre 314
va appeler appeler main avec argc valant 3 et argv valant :
{ "toto.exe", "-monparametre", "314"}
Voici comment récupérer la valeur après l'option -monparametre :
#include <string>
#include <iostream>
#include <stdlib.h> // prototype de la fonction atoi
using namespace std;
int monParametre; // variable à affecter
int main(int argc, char * argv[] ) {
int i=1;
while (i<argc) { // faire parcourir à i tous les indexes possibles
if (string(argv[i])=="-monparametre") {
// l'option -monparametre a été passée par l'utilisateur
++i;
if (i>=argc) {
// la ligne de commande s'arrete après -monparametre
cerr << "erreur une valeur est attendue après -monparametre" << endl;
return 1;
} else {
// nous utilisons la fonction atoi (Ascii To Integer) pour obtenir la valeur.
monParametre=atoi(argv[i]);
}
}
++i;
}
cout << "monParametre vaut " << monParametre << endl;
return 0;
}
3 Le microprocesseur Jasip
1 Les éléments en jeux
2 Description du cache
3 Description de la RAM
4 Le besoin de modélisation
4 Exercices sur C++
void f(int i) {
i=3;
}
int main() {
int i=0;
f(i);
return i;
}
Quelle est la valeur retournée par la fonction main ?
void f(int & i) {
i=3;
}
int main() {
int i=0;
f(i);
return i;
}
Quelle est la valeur retournée par la fonction main ?
void f(int * i) {
*i=3;
}
int main() {
int i=0;
f(&i);
return i;
}
Quelle est la valeur retournée par la fonction main ?
#include <iostream>
using namespace std;
class C {
public:
C() { i=1;}
C(const C & c) { i=2;}
C & operator=(const C & c) { i=3; return *this;}
friend ostream & operator<<(ostream & os,const C & c) {
return os << c.i;
}
private:
int i;
};
int main() {
C c1;
C c2 = c1;
C c3;
c3=c1;
cout << c1 << " " << c2 << " " << c3 << endl;
return 0;
}
class Voiture {
public:
Voiture (int v) {
valeur=v;
}
~Voiture () {
valeur=-1;
}
int valeur;
};
Voiture * donneAdresse() {
Voiture v(32);
return &v;
}
int main () {
Voiture * ptr = donneAdresse();
return ptr->valeur;
}
int f(int i) {
switch (i) {
case 1: return 2;
case 2: return 3;
case 3: return 4;
case 4: return 5;
case 5: return 6;
case 6: return 7;
case 7: return 8;
case 8: return 9;
case 9: return 10;
default : break;
}
return 11;
}
$, les registres commencent par le caractère %.
int f(int i) {
switch (i) {
case 132: return 2;
case 232: return 3;
case 312: return 4;
case 4432: return 5;
case 542: return 6;
case 6234: return 7;
case 7234: return 8;
case 8543: return 9;
case 9364: return 10;
default : break;
}
return 11;
}
5 Exercices sur le système Jasip
~/jasip, effectuer les commandes suivantes :
~/jasip on a alors trois répertoires :
~/jasip/src/org/jasip/app/alphabet/Alphabet.java
La compilation de la classe s'effectue alors à l'aide de la commande :
cd ~/jasip
javac -cp lib -g -sourcepath src/org/jasip/app/alphabet \
-d lib src/org/jasip/app/alphabet/*.java
Les options de compilation utilisées sont décrite dans la fiche pratique 2 : compilation java.
La classe peut alors s'exécuter avec la machine virtuelle par la commande :
~/jasip/bin/jasip org/jasip/app/alphabet/Alphabet.class
A). Les aliens bougent
alternativement en groupe de gauche à droite de l'écran
et lancent des bombes (caractères *).
org.jasip.app.si.Item
qui va représenter un objet par un caractère à l'écran. On pourra
utiliser une classe avec la struture suivante :
public Item(char c,int x, int y);
public void display(org.jasip.TerminalInterface t);
public void moveLeft(org.jasip.TerminalInterface t);
public void moveRight(org.jasip.TerminalInterface t);
public void moveUp(org.jasip.TerminalInterface t);
public void moveDown(org.jasip.TerminalInterface t);
6 Exercices SystemC
jasip_sim -l org/jasip/app/alphabet/Alphabet.class
jasip_sim -l org/jasip/app/si/SpaceInvaders.class
3 Test et intégration
1 Exemple d'utilisation de la STL
1 Chaînes de caractères
std::string.
Voici un usage de la classe string :
#include <string>
#include <iostream>
using namespace std;
int main() {
string s1 = "conception de systèmes";
cout << s1 << endl;
cout << "taille de s1=" << s1.size() << endl;
s1 += " matériels et logiciels";
cout << s1 << endl;
cout << "taille de s1=" << s1.size() << endl;
cout << "5eme catactère de s1 =" << s1[4] << endl;
cout << "la séquence 'sys' commence au caractère "
<< s1.find("sys") << endl;
if (s1.find("blabla")==string::npos) {
cout << "séquence 'blabla' non trouvée" << endl;
}
return 0;
}
Voici le résultat produit par l'exécution du programme ci-dessous
conception de systèmes
taille de s1=22
conception de systèmes matériels et logiciels
taille de s1=45
5eme catactère de s1 =e
la séquence 'sys' commence au caractère 14
séquence 'blabla' non trouvée
Nous avons vu à la section 2.2.5 la structure des chaînes de caractères en
langage C.
Pour obtenir ce type de chaîne, terminée par le caractère zéro, il faut invoquer la
méthode c_str :
string s = "foo";
char * cStyleString = s.c_str();
Ainsi cStyleString [0] vaut 'f' et cStyleString [3] vaut zéro.
2 Ecrire dans une chaîne de caractères
std::ostringstream. Voici un exemple écrivant dans
une chaîne de caractères la valeur décimale et hexadécimale d'un entier i :
#include <string>
#include <iostream>
#include <sstream> // contient le type ostringstream
#include <iomanip> // contient setbase
using namespace std;
int main() {
int i=65;
ostringstream oss;
oss << "i vaut " << i << " en décimal, soit Ox"
<< setbase(16) << i << " en hexa." ;
string s = oss.str();
cout << s << endl;
return 0;
}
On remarque que l'on peut écrire dans un objet de type ostringstream
comme on écrit sur la sortie standard ou comme dans un fichier via un objet
de type ofstream.
Voici le résultat produit par l'exécution du programme :
i vaut 65 en décimal, soit Ox41 en hexa.
3 Listes
list<class T>.
Voici un exemple où nous créons une liste contenant des éléments
de type std::string :
#include <list>
#include <string>
#include <iostream>
int main() {
std::list<std::string> maListe;
maListe.push_back("Pomme");
maListe.push_back("Poire"); // la liste contient [Pomme, Poire]
maListe.push_front("Orange"); // la liste contient [Orange, Pomme, Poire]
// boucle pour afficher tous les éléments
for (std::list<std::string>::iterator i = maListe.begin();
i!=maListe.end();++i) {
std::cout << *i << std::endl; // affiche l'élément pointé par i
}
return 0;
}
Des nouveaux éléments sont ajoutés en tête de la liste avec
la méthode push_front et en fin de liste à l'aide de la méthode
push_back. Pour parcourir les éléments de la liste
on a besoin d'un itérateur de type list<T>::iterator,
obtenu à l'aide de la méthode begin.
L'itérateur passe à l'élément suivant (respectivement précédent)
à l'aide de l'opérateur ++ (respectivement à l'aide de l'opérateur --).
Pour obtenir la valeur pointée par un itérateur il faut utiliser
l'opérateur unaire *.
list<T>::end(). L'élément pointé est alors en dehors
de la liste, et déréférencer l'itérateur n'a alors pas de sens. Voici un
schéma résumant ce fonctionnement :
maListe.remove("Poire"); // supprime TOUTES les occurrences de "Poire"
4 Associations
std::map<class Cle,class Valeur>.
#include <map>
#include <string>
#include <iostream>
using namespace std;
int main() {
// donne le bureau dans lequel se trouve une personne
map<string,int> bureau;
bureau["anne"]=314;
bureau["chris"]=132;
bureau["laure"]=32;
bureau["léon"]=423;
bureau["didier"]=342;
cout << "taille de la table " << bureau.size() << endl;
map<string,int>::iterator i = bureau.find("laure");
if (i!=bureau.end()) { // un bureau a bien été attribué à laure.
cout << i->first << " est dans le bureau " << i->second << endl;
}
return 0;
}
Voici le résultat de l'exécution du programme ci-dessous :
taille de la table 5
laure est dans le bureau 32
Pour parcourir tous les éléments de l'association on utilise
une syntaxe d'itérateur très proche de la liste :
for (map<string,int>::iterator i = bureau.begin();
i!=bureau.end();++i) {
cout << i->first << " est dans le bureau " << i->second << endl;
}
Pour supprimer le bureau de Didier il suffit d'effectuer les instructions :
map<string,int>::iterator i = bureau.find("didier");
if (i!=bureau.end()) { // didier a bien un bureau
bureau.erase(i);
}
2 Architecture de la STL
1 Containers
X::iterator un type itérateur vers les éléments contenus.
X::const_iterator un type permettant seulement d'observer les
éléments du container.
X::value_type le type des données conservées.
X::const_iterator,
sinon X::iterator.
X::const_iterator,
sinon X::iterator.
2 Presque des containers
std::string peut être vu presque comme un container. En effet un type de base
basic_string constitue en fait l'implémentation des chaînes de caractères
pour différents types de caractères possibles. Le type std::string est
une instanciation de template particulier pour le type char :
template <class Ch, class Tr = char_traits<Ch>, class A = allocator<Ch> >
class basic_string {
// ....
};
typedef basic_string<char> string;
On peut ainsi définir d'autres types de chaînes de caractères :
struct CChar {
// définition des caractères chinois.
};
typedef basic_string<CChar> Cstring;
Cependant les basic_string ne sont pas des containers car
ils imposent des contraintes sur les éléments qu'ils contiennent.
Une classe dont nous ne développerons pas l'usage ici permettant d'implémenter
des caractères est char_traits.
3 Itérateurs
On peut voir les itérateurs comme une généralisation des pointeurs. Ils sont souvent utilisés
pour parcourir des groupes d'éléments.
L'uniformité d'interface entre les itérateurs permet facilement
de mettre en
uvre des algorithmes indépendamment des structures sur lesquelles
ils s'appliquent. Ainsi pour écrire une fonction for_each_element qui applique
un objet fonctionnel à une série d'éléments compris entre deux itérateurs
il suffit d'écrire :
template<typename InputIter, typename Function>
void for_each_element(InputIter first, InputIter last, Function f) {
for ( ; first != last; ++first) f(*first);
}
Cette fonction template peut alors très facilement être appliquée à une liste ou tout autre objet
disposant d'un itérateur :
#include <list>
class Times2 { // objet fonctionnel doublant la valeur pointée
public:
void operator() (int *i) { *i= 2*(*i); }
};
class DeleteInt { // objet fonctionnel libérant la mémoire
public:
void operator() (int *i) { delete i; }
};
int main() {
// création d'une liste de 100 éléments aléatoires
list<int*> l;
srand(0);
for (int i=0;i<100;++i) l.push_front(new int(rand()));
// doubler tous les éléments de la liste
Times2 times2;
for_each_element(l.begin(),l.end(),times2);
// libérer la mémoire allouée
DeleteInt di;
for_each_element(l.begin(),l.end(),di);
return 0;
}
4 Algorithmes
#include <vector>
#include <iostream>
using namespace std;
class Print {
public:
void operator() (int i) { cout << i << endl; }
};
int main() {
vector<int> l (100);
srand(0);
for (int i=0;i<100;++i) l[i]=rand();
sort(l.begin(),l.end());
Print p;
for_each(l.begin(),l.end(),p);
return 0;
}
La classe list possède une méthode sort qui lui est propre.
On trouve d'autre types de méthode de tri comme stable_sort
qui garantit que l'ordre des éléments égaux est préservé,
ou partial_sort ne triant que les n-plus petits éléments.
5 Flux d'entrée/sortie
#include <iostream>
class ios;
class istream : virtual public ios;
class ostream : virtual public ios;
class iostream : public istream, public ostream;
#include <sstream>
class stringstream : public iostream;
class istringstream : public istream;
class ostringstream : public ostream;
#include <fstream>
class fstream : public iostream;
class ifstream : public istream;
class ofstream : public ostream;
Ces définitions ne sont pas exactes. En effet par exemple
un flux ostream est un mécanisme qui permet de convertir des
objets en une séquence de caractères. Il y a une façon normalisée
de représenter les caractères en C++ : les char_traits déjà utilisée
par le type basic_string.
Ainsi les flux ostream sont, en fait, le résultat
de la séquence de déclarations suivante :
template < class Ch, class Tr = char_traits<Ch> >
class std::basic_ostream : virtual public basic_ios<Ch,Tr> {
// ....
};
typedef basic_ostream<char> ostream;
3 Style de codage modulaire
1 Interfaces
class TraiteurEvenementClavier {
public:
virtual void traiteClavier(char touche) = 0;
};
class TraiteurEvenementSouris {
public:
virtual void traiteClic(int x, int y) = 0;
};
2 Héritage multiple
uvre ces deux interfaces
on peut écrire :
#include <iostream>
class Bouton :
public TraiteurEvenementClavier,
public TraiteurEvenementSouris
{
void traiteClavier(char touche) {
std::cout << "Traitement touche " << touche << std::endl;
}
void traiteClic(int x,int y) {
std::cout << "Traitement clic x=" << x << " y=" << y << std::endl;
}
};
On peut alors utiliser cette classe partout où un TraiteurEvenementClavier
ou un TraiteurEvenementSouris est attendu :
void appliqueEvenement(TraiteurEvenementClavier & tec, char touche) {
tec.traiteClavier(touche);
}
int main() {
Bouton b;
appliqueEvenement(b,'a');
return 0;
}
Le programme ci-dessus provoque l'affichage :
Traitement touche a
L'héritage multiple peut également avoir
lieu pour des classes qui ne sont pas des interfaces mais possèdent bien des champs.
Voici l'exemple :
#include <iostream>
using namespace std;
class A {
public :
A() { i=0;}
A(int _i) { i=_i;}
int i;
};
class B : public A {
public :
B(int _i) {i=_i;}
};
class C : public A {
public :
C(int _i) {i=_i;}
};
class D : public B, public C {
public:
D(int i, int j) : B(i), C(j) {}
};
ostream & operator<<(ostream & os, const D & d) {
return os << d.B::i << " " << d.C::i;
}
int main() {
D d(1,2);
cout << d << endl;
return 0;
}
Le programme ci-dessus affiche 1 2. Voici le diagramme de classes associé :
d.B::i et d.C::i.
3 Classes de base virtuelles
#include <iostream>
using namespace std;
class A {
public :
A() { i=0;}
A(int _i) { i=_i;}
int i;
};
class B : virtual public A {
public :
B(int _i) {i=_i;}
};
class C : virtual public A {
public :
C(int _i) {i=_i;}
};
class D : public B, public C {
public:
D(int i, int j) : B(i), C(j) {}
};
ostream & operator<<(ostream & os, const D & d) {
return os << d.B::i << " " << d.C::i;
}
int main() {
D d(1,2);
cout << d << endl;
return 0;
}
Le programme ci-dessus affiche 2 2, contrairement à la section précédente
où l'on obtenais 1 2 sans le mot clé virtual.
4 Application au codage modulaire
class MaClasse :
public monInterface,
protected monImplementation
{
// implémentation des fonctions requises par monInterface
// à l'aide des méthodes de monImplementation.
};
4 Test des modèles SystemC
1 Test boite noire
Une boite noire est une boite dont on ne connaît pas le mécanisme interne.
Typiquement si un automate peut modéliser la boite on ne s'attache
pas à connaître les états de cet automate.
2 Test boite blanche
5 Exercices C++
#include <list>
using namespace std;
template<class T>
void moveElementsInOtherList(list<T> & l,list<T> & otherList) {
for (typename list<T>::iterator i = l.begin(); i!=l.end() ;++i) {
otherList.push_back(*i);
l.erase(i);
}
}
int main() {
list<int> l1,l2;
l1.push_back(1);
l1.push_back(2);
l1.push_back(3);
moveElementsInOtherList(l1,l2);
return 0;
}
Il dispose d'une fonction template moveElementsInOtherList
supprimant les éléments de l1 et les ajoutant
à la fin de l2.
#include <stdlib.h>
#include <string>
using namespace std;
string getRandomString(int size) {
char buf [size+1];
for (int i=0;i<size;++i)
buf[i]=65+(int)(26.0*(rand()/(RAND_MAX+1.0)));
buf[size]=0;
return string(buf);
}
map<string,string>
ayant n entrées.
gettimeofday :
#include <sys/time.h>
...
struct timeval start;
struct timeval end;
gettimeofday(&start,NULL);
{
// bloc d'instruction à mesurer
}
gettimeofday(&end,NULL);
// durée du bloc en micro secondes :
int duree = (end.tv_sec-start.tv_sec)*1000000+(end.tv_usec-start.tv_usec);
6 Exercices SystemC
4 Réseau sur puce
ur.
1 Complément C++
1 Retour sur la STL : compléxité des opérations
2 Exceptions
#include <iostream>
using namespace std;
class ErreurInterne {
public:
ErreurInterne(string _raison,string _nomFichier, int _ligne)
: raison(_raison), nomFichier(_nomFichier), ligne(_ligne) { }
friend ostream & operator<<(ostream & os, const ErreurInterne & e) {
os << e.nomFichier << ":" << e.ligne << ": " << e.raison << endl;
}
protected:
string raison;
string nomFichier;
int ligne;
};
Pour déclencher une exception il suffit alors d'utiliser l'instruction throw
suivie de l'instance du type désiré. Voici un exemple avec une fonction nommée
doubleIfPositive qui déclenche une exception quand son argument est négatif.
int doubleIfPositive(int i) {
if (i<0) throw ErreurInterne("i est négatif",__FILE__,__LINE__);
return 2*i;
}
int main() {
try {
int i=doubleIfPositive(-314);
} catch (ErreurInterne e) {
cerr << "Erreur " << e;
}
return 0;
}
L'exécution du programme ci-dessous déclenche une exception qui produit l'affichage suivant :
Erreur exception.cc:19: i est négatif
Dans l'exemple les instructions aux lignes 20 et 26 ne sont jamais exécutées car l'exception est
déclenchée avant leur exécution.
3 Coût des méthodes virtuelles
#define VIRTUAL virtual
class A {
public:
A(int _i) : i(_i) {}
VIRTUAL int getValue() { return i; }
protected:
int i;
};
int f(A & a) {
return a.getValue()+ a.getValue()+ a.getValue()+ a.getValue();
}
int main() {
A a (314);
for (int i=0;i<1000000000;++i) {
int j=f(a);
}
return 0;
}
Si la macro VIRTUAL est définie à une valeur vide on compile
le programme sans méthode virtuelle. Voici le résultat d'exécution :
Cette différence s'explique par l'implémentation utilisée pour mettre
en oeuvre les méthodes virtuelles. En effet
le fait de rajouter le mot clé virtual à une méthode de
A fait que le compilateur va créer une table des méthodes virtuelles
associée à A. Elle va contenir ici qu'une entrée, puisque A
ne dispose que d'une méthode virtuelle, cette entrée étant un pointeur
vers la méthode à exécuter.
4 Copie en ligne
inline int f(int i,int j, int k, int l, int m) {
return i+j+k+l+m;
}
int main() {
int j=0;
for (int i=0;i<1000000000;++i) {
j+=f(i,i+1,i+2,i+3,i+4);
}
return j;
}
Le programme a été compilé avec un niveau d'optimisation de 2.
Le temps d'exécution du programme pour la version inline : 1.9 s.
Le temps d'exécution du programme pour la version non inline : 7.9 s.
Le gain important s'explique ici par le nombre d'arguments de la fonction
(autant de recopies en moins) et les simplifications sur les opérations
arithmétiques.
La contrepartie de l'usage de méthodes inline est l'augmentation de la taille
du code.
2 Structures internes de SystemC
1 SC_MODULE
2 SC_CTOR
typedef MonModule SC_CURRENT_USER_MODULE;
MonModule( sc_module_name)
Ainsi dans chaque module le nom du module est SC_CURRENT_USER_MODULE.
Si on souhaite disposer d'un constructeur ayant différents paramètres, au lieu
d'utiliser SC_CTOR il suffit de déclarer :
typedef MonModule SC_CURRENT_USER_MODULE;
MonModule(sc_module_name * param1, int param2) {
// corps du constructeur du module
}
Omettre le typedef sur SC_CURRENT_USER_MODULE provoquerait une
erreur, car cette définition de type est utilisée dans SC_METHOD,
SC_THREAD et SC_CTHREAD.
3 SC_METHOD
declare_method_process(maMethode_handle, "maMethode",
SC_CURRENT_USER_MODULE, maMethode)
Voici le code de la macro SC_METHOD :
#define SC_METHOD(func) \
declare_method_process( func ## _handle, \
#func, \
SC_CURRENT_USER_MODULE, \
func )
On remarque l'usage de l'opérateur de concaténation du préprocesseur ##,
ainsi que l'opérateur de mise sous chaîne de caractère #.
Cette macro appelle declare_method_process qui est elle même une macro :
#define declare_method_process(handle, name, host_tag, func) \
{ \
sc_method_handle handle = simcontext()->register_method_process( name,\
SC_MAKE_FUNC_PTR( host_tag, func ), this ); \
sc_module::sensitive << handle; \
sc_module::sensitive_pos << handle; \
sc_module::sensitive_neg << handle; \
}
Les opérations effectuées par la fonction simcontext()->register_method_process
consistent à mettre dans la table de toutes les SC_METHOD un
pointeur vers la méthode passée en argument.
3 Canaux hiérarchiques
1 Définition de l'interface
#ifndef STACK_IF_H
#define STACK_IF_H
#include "systemc.h"
class StackWrite_if : virtual public sc_interface {
public:
virtual bool nb_write(char) = 0;
};
class StackRead_if : virtual public sc_interface {
public:
virtual bool nb_read(char&) = 0;
};
#endif
2 Implémentation du canal
Le canal est un module implémentant les interfaces de lecture et d'écriture.
#ifndef STACK_H
#define STACK_H
#include "systemc.h"
#include "stack_if.h"
#define STACK_SIZE 20
class Stack :
public sc_module,
public StackWrite_if,
public StackRead_if
{
public:
Stack(sc_module_name nm) : sc_module (nm), top(0) {}
bool nb_write(char c) {
if (top<STACK_SIZE) {
data[top++]=c;
return true;
}
return false;
}
bool nb_read(char& c) {
if (top>0) {
c=data[--top];
return true;
}
return false;
}
void register_port(sc_port_base & _port,
const char * _if_typename)
{
cout << "binding "<< _port.name() << " to "
<< "interface: " << _if_typename << endl;
}
private:
int top;
char data[STACK_SIZE];
};
#endif
3 Utilisation des ports
#ifndef CONSUMER_H
#define CONSUMER_H
#include "systemc.h"
#include "stack_if.h"
SC_MODULE(Consumer) {
sc_port<StackRead_if> in;
sc_in_clk clock;
void prcRead() {
while (true) {
char c;
wait();
if (in->nb_read(c)) {
cout << "Lecture de " << c << endl;
}
}
}
SC_CTOR(Consumer) {
SC_THREAD(prcRead);
sensitive_pos << clock;
}
};
#endif
4 Ajout de périphériques
1 Opérations à effectuer
2 Mise en mémoire
3 Exemples
4 Zones mémoires
0x80 à 0xff.
0x100 à 0x17f.
0x180 à 0x1ff.
0x200 à 0x27f.
0x290 pour le clavier et la plage 0x300
à 0x61f pour l'écran.
5 Une interface de plus haut niveau
tmpFileName.txt
contenant la chaîne de caractères "hello world!".
Puis le relire pour vérifier son contenu:
import org.jasip.DiskDriver;
...
// contenu du fichier
String s = new String("hello world!");
// écriture du fichier
String fileName = new String("tmpFileName.txt");
int fd = DiskDriver.open(fileName,DiskDriver.OPEN_MODE_WRITE);
if (fd==-1) {
System.out.println("Could not open file for writing.");
return;
}
if (DiskDriver.write(fd,s)!=0) {
System.out.println("Could not write to file.");
return;
}
if (DiskDriver.close(fd)!=0) {
System.out.println("Error closing file.");
return;
}
// vérification du contenu du fichier
fd=DiskDriver.open(fileName,DiskDriver.OPEN_MODE_READ);
if (fd==-1) {
System.out.println("Could not open file.");
return;
}
String ss = DiskDriver.readWholeFile(fd);
if (ss==null) {
System.out.println("Could not read file.");
return;
}
if (!ss.equals(s)) {
System.out.println("Error in the content of the file.");
return;
}
if (DiskDriver.close(fd)!=0) {
System.out.println("Error closing file.");
return;
}
5 Réseaux sur puce
ur où le type de communication
entre les microprocesseurs varie fortement en fonction de l'application considérée.
6 Exercices SystemC
Le modèle OSI est un standard développé par l'ISO [DZ83], formalisant la
notion de couche, aujourd'hui tombé quelque peu dans l'oubli par rapport
à d'autres protocole comme Internet [Cla88] ou ATM [Kes97] mais
dont la structure en couche se retrouve dans ces protocoles.
Nous utilisons pour le réseau une topologie de tore comme représenté ci-dessous :
les 16 noeuds de communication sont sur une grille de 4 par 4 et chaque noeud communique
avec ses 4 voisins directs modulo 4.
uds avec la topologie
présentée dans l'énoncé. Afficher le nombre de paquets émis et le nombre de paquets reçus,
en générant aléatoirement des paquets d'un n
ud du réseau vers n'importe quel autre n
ud.
uds du réseau. Ceci est illustré à la figure ci-dessous où les cases du
damier vert et rouge représente les différents n
uds du réseau qui calculent
la propagation de l'onde :
#ifndef NETWORKIF_H
#define NETWORKIF_H
#include "paquet.h"
class NetworkIf;
/**
* Définition de l'interface d'une classe
* pouvant se connecter sur le réseau.
*/
class NetworkPlugableIf {
public:
virtual void setNetwork(NetworkIf*)=0;
virtual void readPaquet(Paquet)=0;
virtual void start()=0;
};
/**
* Interface de communication avec le réseau
*/
class NetworkIf {
public:
virtual int getXPosition() =0;
virtual int getYPosition() =0;
virtual void sendPaquet(Paquet &) =0;
};
#endif
ur
ur basé sur le simulateur Jasip réalisé dans le chapitre précédent.
org.jasip.Jasip.writeByteAtAddress
org.jasip.Jasip.readByteAtAddress
5 Exemple de co-design
1 Complément C++ : relecture de code
1 Qu'est-ce que la relecture de code ?
2 Quand faire des relectures de code ?
3 Règles de codage
niveau_huile
ou niveauHuile par exemple.
/**
* Given a name of the class file returned its associated ClassFile.
* If the class has not been loaded yet in the virtual machine it is then loaded.
* If the class file can not be loaded NULL is returned.
* @param className name of the class
* @param noLoad tells if the class should be loaded each time
* @return return the ClassFile instance representing the
* class which name is in className.
*/
ClassFile * VM::getClassFile(string className,bool noLoad) {
// ...
}
Donne le bout de page html ci-dessous après avoir été traité par
doxygen :
4 Mémoire
int size = 1073741824;
int *buffer = new int[size];
L'opération consiste en faite à allouer un nombre d'octets égal à sizeof(int)*size.
Or ce nombre vaut 232, soit -1. Le tableau n'est donc finalement pas de la bonne taille.
5 Macros
#define MUL(a, b) a*b
#define ADD(a, b) a+b
Est mauvais pour deux raisons
MUL(ADD(3,4),2) est étendu en
3+4*2 soit une valeur de 11 et non 14.
MUL(3+4,2) soit correctement évalué.
#define MUL(a, b) ((a)*(b))
#define ADD(a, b) ((a)+(b))
6 Logique
&& et ||
ou les opérateurs bit à bit & et | il est important de vérifier
qu'il n'y a pas eu de confusion entre les symboles.
f() && g() si f renvoie
une valeur fausse alors aucun appel à la fonction g n'est effectué.
De même dans l'expression f() || g() si f renvoie
une valeur vraie alors aucun appel à la fonction g n'est effectué.
&& ou ||
est redéfinit. En effet l'instruction a && f() est vue par le compilateur
comme a.operator&&(f()). Dans ce cas la fonction f est toujours évaluée
contrairement à ce qui a été dit dans le paragraphe précédent.
(x * 2) + 1;
(x << 1) + 1;
Alors que les deux suivantes ne le sont pas :
x * 2 + 1;
x << 1 + 1; // équivalent à (x*4)
7 Séquence d'appel
f()+g()
nous ne disposons d'aucune garantie pour savoir quelle fonction va être appelée en premier.
&& ou || le membre de gauche est évalué avant le membre de droite
, utilisé dans une expression (comme int i=f(2), j=g(3);) assure que le membre de gauche est évalué avant le membre de droite. En revanche pour les arguments d'appel à une fonction aucun ordre n'est spécifié, ainsi dans l'expression f(g(),h()) on ne sait qui de g ou h est appelé en premier.
; tout ce qui est avant le point virgule est évalué avant ce qui suit.
if, while, switch, for, do while : la partie conditionnel est toujours évaluée avant le corps de boucle.
int x = 0;
f(x++, x++, x++);
On ne sait quel vont être les trois arguments de f.
lireFlux l'expression :
x = lireFlux() * 256 + lireFlux();
Est à banir, on ne sait pas dans quel ordre les arguments du flux sont lus.
Il faut écrire :
int tmp = lireFlux();
x = tmp * 256 + lireFlux();
switch (n) {
case 1:
f();
break; // quitter le switch
case 2:
g();
case 3:
h(); // exécuté pour n==2 et n==3
break; // quitter le switch
}
Pour être certain que l'absence de break à la fin du cas 2 n'est pas une erreur
c'est une bonne pratique d'imposer un commentaire :
switch (n) {
case 1:
f();
break;
case 2:
g();
// Attention : pas de break ici
// continuer maintenant sur le cas 3 en appelant h.
case 3:
h();
break;
}
8 Outils complémentaires
Le résultat de tels outils peut en effet aider à cibler la relecture sur certains
points.
2 Cas d'étude pour le co-design
1 Equation des ondes
La constante c représente la vitesse de propagation de l'onde,
et
représente un facteur d'atténuation de l'onde que l'on pourra assimiler
à un frottement visqueux.
2 Calcul approché
int values[VAL_MAX][VAL_MAX];
int speed [VAL_MAX][VAL_MAX];
int accel [VAL_MAX][VAL_MAX];
La discrétisation de l'équation précédente pour c2=1/10 et
plus
un facteur proportionnel de 10 sur les valeurs de values,
donnent les formules suivantes pour tout i et j :
accel[i][j]=values[i+1][j]+values[i-1][j]+values[i][j-1]+values[i][j+1]
-4*values[i][j]-speed[i][j]/200;
speed[i][j]+=accel[i][j];
values[i][j]+=speed[i][j]/10;
Les itérations de cette série d'équations donnent un temps discret.
Voici des exemples de résultats obtenus à l'aide de ces équations :
t=20
t=120
3 Exemples de co-design
1 Validation du principe
2 Implémentation Logicielle
3 Ajout de nouvelles commandes
4 Adjonction d'un co-processeur
accel[i][j] = values[i+1][j] + values[i-1][j] + values[i][j-1] + values[i][j+1]
-4*values[i][j] - speed[i][j]/200;
est effectuée pour de nombreux couples i et j : 65 536 si on a un carré de 256
par 256.
1 Interface de l'unité vectorielle
2 Usage de l'unité vectorielle
# l'adresse de values[i][j] est dans r1
# l'adresse de accel[i][j] est dans r2
# l'adresse de speed[i][j] est dans r3
# r4 contient l'adresse d'une zone temporaire
add r5 r1 4
vset r2 r5 # accel[i][j] = values[i+1][j]
sub r5 4 r1
vadd r2 r5 # accel[i][j] += values[i-1][j]
add r5 1024 r1
vadd r2 r5 # accel[i][j] += values[i][j+1]
sub r5 1024 r1
vadd r2 r5 # accel[i][j] += values[i][j-1]
vmul r4 r1 4
vsub r2 r4 r2 # accel[i][j] -= 4*values[i][j]
vdiv r4 r3 200
vsub r2 r4 r2 # accel[i][j] -= speed[i][j]/200
add r5 r1 4
sub r5 4 r1 vset r2 r5
add r5 1024 r1 vadd r2 r5
sub r5 1024 r1 vadd r2 r5
nop vadd r2 r5
nop vmul r4 r1 4
nop vsub r2 r4 r2
nop vdiv r4 r3 200
nop vsub r2 r4 r2
Ce type de processeur est nommé VLIW (Very Long Instruction Word).
5 Matériel dédié
4 Exercice
6 Dans le monde réel
1 Approches à la Jasip
1 J2ME
2 Android
3 Comparaisons
2 Unisim
1 Rôle des plate-formes virtuelles
2 Un exemple de réalisation
\scalebox{.5}{\includegraphics{cours6/ptf.eps}
3 L'interface TLM
template <typename REQ, typename RSP>
class TlmSendIf : public virtual sc_interface {
public:
virtual bool Send(TlmMessage<REQ, RSP> &message) = 0;
};
template <typename REQ, typename RSP>
class TlmMessage{
public:
REQ req;
RSP rsp;
sc_event event;
};
#include "pointer.h"
template <typename REQ, typename RSP>
class TlmSendIf : public virtual sc_interface {
public:
virtual bool Send(Pointer<TlmMessage<REQ, RSP> > &message) = 0;
};
template <typename REQ, typename RSP>
class TlmMessage{
public:
REQ req;
RSP rsp;
sc_event event;
};
4 La gestion mémoire
1 Les auto pointeurs
class A {} ;
void maFonction() {
A * a = new A();
// ...
delete a;
}
auto_ptr) dans la stl.
#include <memory>
using namespace std;
class A {} ;
void maFonction() {
auto_ptr<A> a (new A());
// ...
} // la libération est faite automatiquement
// au moment de la destruction de a
#include <memory>
#include <assert.h>
using namespace std;
void g() {
int* pt1 = new int; // g gère le pointeur pt1
auto_ptr<int> pt2( pt1 ); // g cède la gestion de pt1 à pt2
*pt2 = 12; // équivalent à "*pt1 = 12;"
assert( pt1 == pt2.get() ); // les deux pointeurs sont équivalents
int* pt3 = pt2.release(); // g récupère la gestion de pt1
delete pt3; // il faut maintenant détruire pt3
} // pt2 ne possède plus de pointeur
// il n'y a pas de double desallocation.
On remarque que les auto pointeurs se manipulent exactement comme les pointeurs.
On peut reprendre la main sur la gestion du pointeur à l'aide de la
méthode release.
#include <memory>
using namespace std;
class B {};
class A {
public:
A();
/*...*/
private:
auto_ptr<B> b;
};
void f() {
auto_ptr<T> pt1( new T );
auto_ptr<T> pt2;
pt2 = pt1; // pt2 gère maintenant le pointeur
// et pt1 ne gère rien
pt1->DoSomething(); // ERREUR ! PAS DE POINTEUR
}
auto_ptr<A> monAllocateur() {
// ...
return auto_ptr<A> (new A());
}
Libre ensuite à la fonction qui récupère la valeur de prendre ou non la
gestion du pointeur :
vector< auto_ptr<T> > v; // A NE PAS FAIRE !!!
En effet il peut y avoir des recopie dans l'usage d'algorithmes (comme le tri par exemple),
ce qui a pour effet de faire perdre la gestion du pointeur aux éléments du vecteur.
2 Les pointeurs partagés
shared_ptr dans la librairie boost :
#include <boost/shared_ptr.hpp>
using namespace boost;
void f () {
shared_ptr<int> p (new int);
*p = 4;
}
Les librairies boost sont disponibles à
l'url http://www.boost.org/.
#include <boost/shared_ptr.hpp>
using namespace boost;
class A {
public :
int methode1() { return 1;}
int methode2() { return 2;}
};
A * a = new A();
void thread1() {
shared_ptr<A> p1(a);
p1->methode1();
}
void thread2() {
shared_ptr<A> p2(a);
p2->methode2();
}
3 Exercice
7 Fiche Pratique 1 : Compilation C++
1 Compilation
1 Qu'est-ce que la compilation ?
2 Compilation d'un fichier *.cc
g++ -c toto.cc
Cela génère le fichier toto.o qui n'est pas un fichier lisible
pour un humain. Si l'on souhaite voir le code assembleur
(comme dans l'exercice 1.4) il faut taper
g++ -S toto.cc, le fichier assembleur toto.s lisible
par un humain est alors généré.
g++ -Wall -g -c toto.cc
#include
et remplacer les définitions #define par leur valeur,
effectuer la commande suivante :
g++ -E toto.cc
3 Génération de l'exécutable ou édition de liens
g++ -o monexecutable toto.o tata.o tutu.o
Il suffit alors de taper ./monexecutable pour lancer l'exécution.
4 Symboles dans l'exécutable
int i=0;
int main() {
return 0;
}
En le compilant par g++ -c main.cc on obtient le fichier main.o.
Pour ensuite pouvoir effectuer l'édition de lien de main.o avec un programme toto.o
utilisant la variable i faut que dans main.o on puisse retrouver
une variable par son nom. La commande permettant d'inspecter les symboles (ou noms)
est nm.
Ainsi sur le programme main.o on obtient :
> nm main.o
00000000 B i
00000000 T main
Le premier nombre de la ligne représente l'adresse où se situe le symbole,
ici la variable i ou la fonction main. Ici zéro est indiqué car
le placement en mémoire n'est fait qu'au moment de l'édition de liens.
La lettre détermine ensuite le type de symbole : T signifie
un symbole définit dans le code et B un symbole dans la section
des données non initialisées. Faire man nm pour obtenir
la signification des autres lettres utilisées.
Si on compile main.o en un programme exécutable on obtient alors
une liste de symboles beaucoup plus longue (abrégée ici) :
> g++ -o monprog main.cc
> nm monprog
080494cc D _DYNAMIC
080495b0 D _GLOBAL_OFFSET_TABLE_
080484b0 R _IO_stdin_used
w _Jv_RegisterClasses
080494bc d __CTOR_END__
080494b8 d __CTOR_LIST__
...
080495d0 B i
08048384 T main
080495c8 d p.0
On remarque que les variables ont été placées en mémoire à des adresses bien
définies.
> ls -lh monprog
-rwxr-xr-x 1 fabrice fabrice 12K May 18 10:10 monprog
> strip monprog
> ls -lh monprog
-rwxr-xr-x 1 fabrice fabrice 3.0K May 18 10:10 monprog
> nm monprog
nm: monprog: no symbols
2 Automatisation de la compilation
1 Exemple simple
toto.o : toto.cc
g++ -c toto.cc
La première ligne signifie que l'on souhaite générer le fichier toto.o
à partir du fichier toto.cc.
La deuxième ligne commence par un caractère de tabulation suivit de la commande
effective pour générer le fichier toto.o.
En tapant make sur la ligne de commande on génère alors le fichier toto.o,
la commande exécutée s'affiche alors.
En tapant à nouveau make rien ne se passe, c'est normal, à cause de la dépendance
de la première ligne, puisqu'aucune modification n'a été apportée à toto.cc,
et que la date de création de toto.o est plus récente, il n'y a rien à faire.
>make
g++ -c toto.cc
>make
make: `toto.o' is up to date.
>
Si la règle pour créer toto.o n'est pas la première règle
du makefile, il faut alors taper make toto.o pour exécuter la règle correspondante.
Si le fichier toto.cc inclus un fichier tata.h qui peut être
modifié par l'utilisateur il faut alors
le rajouter dans la liste de dépendances :
toto.o : toto.cc tata.h
g++ -c toto.cc
monexecutable : toto.o tata.o tutu.o
g++ -o monexecutable toto.o tata.o tutu.o
2 Utilisation de variables
CXX:=g++
CXXFLAGS:=-Wall -g
monexecutable : toto.o
$(CXX) -o monexecutable toto.o
toto.o : toto.cc tata.h
$(CXX) $(CXXFLAGS) -c toto.cc
SOURCES := $(wildcard *.cc) # prend tous les fichiers *.cc du répertoire
OBJ := $(SOURCES:.cc=.o) # remplace tous les .cc par .o
Cette syntaxe est spécifique à Gnu Make.
Notons que les commentaires dans les makefile sont compris
entre le symbole # et la fin de la ligne.
$@ qui représent la cible et $< qui représente la première dépendance.
Ainsi la règle :
monexecutable : toto.o
$(CXX) -o monexecutable toto.o
est-elle équivalente à :
monexecutable : toto.o
$(CXX) -o $@ $<
3 Règles génériques
toto.o : toto.cc
$(CXX) $(CXXFLAGS) -c toto.cc
tata.o : tata.cc
$(CXX) $(CXXFLAGS) -c tata.cc
Il suffit d'écrire la règle suivante :
%.o : %.cc
$(CXX) $(CXXFLAGS) -c $<
Qui signifie : si à un moment donné un fichier ayant le suffixe .o doit
être généré alors on appliquera la règle $(CXX) $(CXXFLAGS) %.cc
si %.cc est plus récent que %.o.
4 Dépendance avec les fichiers d'entête
%.o : %.cc
$(CXX) $(CXXFLAGS) -c $<
toto.o : toto.cc toto.h
La liste des dependances peut être générée automatiquement à l'aide de l'option -MM
de g++. Ainsi la ligne 3 dans le makefile précent peut être écrite
dans le fichier .deps à l'aide de la commande en ligne :
g++ -MM toto.cc > .deps
Voici comment utiliser ces dépendances dans un makefile :
SOURCES := $(wildcard *.cc) # prend tous les fichiers *.cc du répertoire
.deps : $(SOURCES)
$(CXX) $(CXXFLAGS) -MM $(SOURCES) > .deps
-include .deps
5 Résumé
#
# définitions
#
CXX:=g++
CXXFLAGS:=-Wall -g
SOURCES := $(wildcard *.cc) # prend tous les fichiers *.cc du répertoire
OBJ := $(SOURCES:.cc=.o) # remplace tous les .cc par .o
EXE_NAME := monexe
#
# cibles
#
$(EXE_NAME) : $(OBJ)
$(CXX) -o $@ $(OBJ)
%.o : %.cc .deps
$(CXX) $(CXXFLAGS) -c $<
.deps : $(SOURCES)
$(CXX) $(CXXFLAGS) -MM $(SOURCES) > $@
-include .deps
#
# supprimer les fichiers générés
#
clean :
rm -f *.o $(EXE_NAME) *~ .deps
8 Fiche Pratique 2 : compilation Java
1 Compilation
1 Qu'est-ce que la compilation Java ?
2 Compilation d'un ensemble de fichiers *.java
javac -d build `find src -name \*.java`
Ainsi si on dispose du fichier src/org/jasip/GraphTerminal.java contenant
les lignes suivantes
package org.jasip;
public class GraphTerminal {
// ....
}
Alors la commande de compilation précédente créé le fichier
build/org/jasip/GraphTerminal.class.
javac -g -d build `find src -name \*.java`
3 Visualisation du bytecode
javap -classpath build -c org.jasip.GraphTerminal
Pour une description détaillée de la structure du bytecode
se reporter à la version en ligne de [LY99].
2 Automatisation de la compilation
1 Makefiles
Comme pour les fichiers C ou C++ on peut faire des makefiles.
Il suffit de faire une cible lançant la bonne commande :
compil_java :
javac -g -d build `find src -name \*.java`
Cependant en développement Java on préfère utiliser le logiciel ant décrit à la section ci-dessous qui comprend plusieurs raccourcis permettant de facilier la mise en place de gros projets.
2 ant
La manière standard d des compilations en java est d'utiliser
un script ant (http://ant.apache.org).
Pour cela il suffit de créer le fichier build.xml suivant :
<?xml version="1.0"?>
<project name="myproject" basedir="." default="compile">
<!-- initialise les constantes compile.dir et compile.src -->
<target name="init">
<property name="compile.dir" value="build"/>
<property name="compile.src" value="src"/>
</target>
<!-- compile le contenu de ${compile.src} dans ${compile.dir} -->
<target name="compile" depends="init">
<mkdir dir="${compile.dir}"/>
<javac srcdir="${compile.src}"
destdir="${compile.dir}"
debug="on"/>
</target>
<!-- cette cible efface les fichiers générés -->
<target name="clean">
<!-- supprime le répetoire build -->
<delete dir="${compile.dir}"/>
<!-- supprime dans tous les sous répetoires les fichiers *~ -->
<delete>
<fileset dir="." includes="**/*~" defaultexcludes="no"/>
</delete>
</target>
</project>
Puis d'invoquer la commande ant. Pour effacer les fichiers générés
appeler ant clean.
<target name="..."> ... </target>.
Son nom est précisé par l'attribut name. Elle est invoquée par la commande ant
<nom de la cible>. Si une cible dépend d'une autre cible il faut utiliser l'attribut
depends pour expliciter la liste des dépendances. Ici la cible compile dépend
de la cible init.
Bibliographie
OCCN: A Network-On-Chip Modeling and Simulation Framework.
In Proceedings of the conference on Design, automation and test
in Europe - Volume 3, 2004.
http://occn.sourceforge.net/occn_date04.pdf.
The Design Philosophy of the DARPA Internet Protocols.
Proceedings of ACM SIGCOMM'88, pages 106-114, August 1988.
The OSI reference model.
Proceedings of the IEEE, 71(12), December 1983.
A Generic Architecture for On-chip Packet-switched
Interconnections.
In Proceedings of the DATE'2000 Conference, pages 250-256,
2000.
ftp://asim.lip6.fr/pub/reports/2000/ar.gue.date00.pdf.
ISO/IEC, 1999.
ISO/IEC, 2003.
http://www.open-std.org/jtc1/sc22/wg21. Le standard est payant,
mais un bouillon est disponible : http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2315.pdf.
An Engineering Approach to Computer Networking, chapter 4.
Addison-Wesley, 1997.
The Art of Computer Programming, chapter 3.
Addison-Wesley, 1981.
The JavaTM Virtual Machine Specification.
Sun Microsystems, 1999.
http://java.sun.com/docs/books/vmspec/index.html.
www.systemc.org, 2005.
http://www.systemc.org/web/sitedocs/lrm_2_1.htm.
Basic VLSI Design, 3rd edition.
Prentice Hall, 1994.
Numerical Recipes in C: The Art of Scientific Computing,
chapter 7.
Cambridge University Press, 1992.
The C++ Programming Language.
Addison-Wesley, 1997.
ISBN 0-201-88954-4.
Liste des exercices
ur ***
Index
2007-11-18