Introduction FAQ Glossary TD 1 TD 2 TD 3 TD 4 TD 5 TD 6 TD 7 TD 8
Retour au cours.
Objectifs :
Le but de ce TD est de comprendre le travail effectué par un compilateur. Pour cela, nous allons essayer d'effectuer ce travail manuellement, en traduisant de petits programmes vers un sous-ensemble de l'assembleur x86, utilisé sur les machines des salles de TD.
Attention. La plupart des machines modernes
utilisent le mode 64 bits des processeurs Intel. Ce TD a été conçu
pour fonctionner sur les machines des salles informatiques, qui,
elles, utilisent toujours le mode 32 bits. Si vous avez une machine
Linux, il est probable que le TD ne fonctionne pas dessus, sauf si
vous installez le mode de compatibilité 32 bits
(gcc-multilib et ia32-libs) et que vous
demandiez à gcc de générer du code 32 bits (en utilisant
gcc -m32).
Téléchargez les fichiers td8.s
et main.o dont vous allez avoir besoin pour ce
TD (vous pouvez également télécharger le source C
td8_main.c pour information).
Voici le contenu du fichier td8.s, que vous allez devoir modifier
pendant ce TD:
.file "td8.s"
.text
.globl ma_fonction
.type ma_fonction, @function
ma_fonction:
movl $4, %eax
subl %eax, %esp
movl %ebp, (%esp)
movl %esp, %ebp
# debut de la partie à modifier
movl $0, %eax
# fin de la partie à modifier
movl %ebp, %esp
popl %ebp
ret
.size ma_fonction, .-ma_fonction
Commençons par tester ces fichiers, en vérifiant que l'exécution des commandes suivantes donne bien le résultat attendu :
$ gcc td8.s main.o $ ./a.out Vous devez fournir 3 entiers $ ./a.out 1 2 3 ma_fonction(1,2,3) = 0 $
Comme vous pouvez le constater, td8.s contient le code
de la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return 0; }
Fournir le code Java correspondant à cette application.
Les machines des salles de TD ont des processeurs 64 bits Intel 4 coeurs, configurés pour exécuter uniquement du code 32 bits. Nous allons donc devoir programmer dans l'assembleur que ces processeurs comprennent, i.e. l'assembleur x86.
Cet assembleur est extrêmement complexe : l'une des spécificité d'Intel est que chaque processeur comprend l'assembleur des processeurs précédents, plus un grand nombre de nouvelles instructions à chaque génération. Aussi, nous allons simplifier considérablement ce langage, pour se concentrer sur une toute petite partie qui nous suffira pour ce TD.
Tout d'abord, nous aurons besoin de registres, i.e. de variables
dans la mémoire du processeur, pouvant stocker des entiers 32
bits. Nous utiliserons quatre registres génériques uniquement
: %eax, %ebx, %ecx
et %edx. Nous avons aussi besoin du pointeur de
pile %esp, et nous suivons la convention que le registre
de base %ebp contient la valeur du pointeur de pile à
l'entrée dans la fonction, après sa propre sauvegarde (i.e. -4, voir
plus loin).
Ensuite, nous avons besoin de quelques instructions de base de l'assembleur x86:
movl num(%src), %dst : déplace un entier
de l'espace mémoire pointé par l'adresse contenue dans le
registre src, additionnée de la valeur de num, vers le registre dst.movl %src, num(%dst) : déplace un un
entier du registre src vers l'espace mémoire pointé par
l'adresse contenue dans le registre dst, additionnée de la valeur de num.movl %src, %dst : déplace un entier
du registre src vers le registre dst.movl $num, %dst :
déplace l'entier num vers le registre dst.addl %src, %dst :
ajoute l'entier dans le registre src à l'entier dans le
registre dst, modifiant ce dernier pour contenir le
résultat.subl %src, %dst :
retranche l'entier dans le registre src de l'entier dans le
registre dst, modifiant ce dernier pour contenir le
résultat.Nous allons aussi avoir besoin de branchements, conditionnels ou inconditionnels :
.Lxxx:dans le code en début de ligne
indique la cible d'un branchement (où xxx peut être
tout mot ou nombre).jmp .Lxxx indique que le processeur devra exécuter
les instructions après le label .Lxxx au lieu des
instructions suivant cette instruction (branchement inconditionnel).cmpl %reg1, %reg2 jne .Lxxxindique au processeur qu'il doit brancher sur le label
.Lxxx si les deux registres %reg1
et %reg2 contiennent des valeurs distinctes.cmpl %reg1, %reg2 jge .Lxxxindique au processeur qu'il doit brancher sur le label
.Lxxx si le registre %reg2 contient
un entier plus grand ou égal que l'entier dans le
registre %reg1.Finalement, nous aurons besoin de respecter la convention d'appel de C, ici simplifiée, pour appeler des fonctions et récupérer les arguments de notre fonction :
call fonction est
appelée. Celle-ci diminue la valeur du pointeur de pile de 4, puis
stocke à cet emplacement l'adresse de retour.%eax.%ebp, sont susceptibles d'être modifiés par la
fonction. Les registres dont la valeur peut être utile par la suite
doivent donc être sauvegardés avant l'appel de la fonction.Pour s'échauffer, nous allons commencer par lire un premier programme écrit dans ce mini-assembleur :
Fournir le code Java correspondant à la fonction dont la partie interne est :
movl 8(%ebp), %eax
movl $0, %ebx
.Lbranch:
movl 12(%ebp), %edx
addl %edx, %ebx
movl $1, %edx
subl %edx, %eax
cmpl %edx, %eax
jge .Lbranch
movl %ebx, %eax
Ce petit exemple nous donne aussi l'occasion de tester les commandes de la première partie :
Exécuter le code Java sur plusieurs exemples de valeurs pour les 3 arguments. Vérifier que vous obtenez les mêmes résultats en exécutant le code assembleur avec les mêmes arguments (cela ne donne pas une garantie que les deux codes sont équivalents, mais une bonne probabilité).
Pour mieux comprendre ce qui se passe, nous allons utiliser un
outil de déverminage (debugger). L'outil standard sous Linux
est gdb.
gdb, the GNU Debuggergdb accepte un certain nombre de
commandes (à tapper après l'obtention du prompt) qui vont nous être
utiles pendant ce TD.
run args : (re-)commencer l'exécution du
programme avec les arguments args;b fonction : bloquer (par
un point d'arrêt ou breakpoint) le programme à l'entrée de la
fonction fonction; c : reprendre l'exécution; disass : afficher le code assembleur de la fonction
courante; si : exécuter uniquement la prochaine instruction
puis bloquer; info registers : afficher le contenu des registres; Lancer gdb sur le programme principal :
gdb a.out
Mettre un point d'arrêt au début de la fonction
de td8.s, lancer l'éxécution, puis évaluer pas à pas ce
qui se passe.
L'utilisation de gdb n'étant pas très conviviale, nous
allons utiliser une de ces vieilles interfaces graphiques
appelée ddd.
ddd, une interface graphique pour gdbUne fois ddd lancé et sa fenetre affichée, il suffit
de tapper les mêmes commandes que dans gdb dans la
fenêtre de ce dernier.
Néanmoins, pour profiter au maximum de ddd, on peut
sélectionner dans le menu View la fenêtre Machine
Code Window et désélectionner la fenêtre Source
Windows. Dans le menu Status, on peut demander la
fenêtre Registers. Enfin, dans le menu View,
on peut demander la fenêtre Command Tool, dont le
bouton Stepi permet d'exécuter le programme instruction
par instruction.
Lancer ddd sur le programme principal :
ddd a.out
Mettre un point d'arrêt au début de la fonction
de td8.s, lancer l'éxécution, puis évaluer pas à pas ce
qui se passe.
Il peut être intéressant d'évaluer les performances de ce programme :
Modifier les arguments du programme pour qu'il prenne exactement
une seconde à s'exécuter (on pourra utiliser time ./a.out 1 2
3 pour mesurer le temps d'exécution).
Calculer le nombre d'instructions assembleur exécutées pendant cette seconde, avec ces arguments, à partir de leur valeurs et du source du programme.
En déduire la cadence du processeur (en GHz).
Comparer avec la valeur indiquée
dans /proc/cpuinfo. Est-ce satisfaisant ?
Nous pouvons maintenant commencer à écrire nos premiers programmes en asembleur.
Modifier le code de td8.s pour implanter la fonction
suivante :
int ma_fonction(int a, int b, int c)
{ return 10; }
A chaque fois, on testera en utilisant les commandes fournies au début du TD, si nécessaire avec des arguments différents.
Pour bien comprendre ce qui se passe dans un ordinateur, rien ne vaut un bon dessin !
Faîtes un dessin montrant l'état de la pile (valeurs placées au
sommet de la pile avec leurs positions relatives, valeurs
de %esp et %ebp, etc.) au début de la partie
à modifier de la fonction ma_fonction.
Maintenant que nous comprenons mieux où sont les arguments, nous pouvons y accéder :
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return a; }
Il vaut mieux vérifier que l'on sait accéder à tous les arguments:
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return c; }
Nous pouvons maintenant commencer à travailler un peu :
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return a+b+c; }
Nous allons maintenant essayer de programmer des boucles, puis utiliser des appels de fonctions :
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{
int x = c;
for(int i = a; i<b; i++) { x = x + i; }
return x;
}
N'oubliez pas de vérifier que vous obtenez bien les mêmes résultats avec les deux fonctions.
Nous avons défini dans main.o une
fonction divide(x,y) qui retourne le résultat de la
division entière de x par y.
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return a/(b+c); }
L'utilisation de la fonction divide est un peu trop facile !
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{ return a/(b+c); }
pour ne pas utiliser du tout la fonction divide !
Finalement, nous allons tester un peu la récursivité...
Modifier le code pour implanter la fonction suivante :
int ma_fonction(int a, int b, int c)
{
if(a == 0) return 0;
if(a == 1) return 1;
return ma_fonction(a-1) + ma_fonction(a-2);
}
Quelle est cette fonction ?
Depuis un programme, il faut parfois exécuter un appel système au
lieu d'un simple appel de fonction. Pour cela, nous allons rajouter
une instruction à notre assembleur: int $0x80 déclenche
un appel système, dont le numéro se trouve dans le registre
%eax. Les arguments de l'appel sont placés dans les
registres au lieu de la pile, dans l'ordre %ebx,
%ecx et %edx.
Faîtes un programme qui affiche la chaîne "bonjour"
dans le terminal. Pour cela, on utilisera l'appel
sys_write de numéro 4, qui prend en argument le
descripteur de fichier où écrire (stdout correspond à la
valeur 1), un pointeur vers la chaîne de caractères (que nous
stockerons dans la pile), et un nombre correspondant au nombre de
caractères à afficher.
Le fichier somme.c définit une fonction C qui calcule la somme des entiers de 0 à 99.
void somme()
{
int i, s;
s = 0;
for(i=0; i < 100; i++) {
s += i;
}
}
int main()
{
somme();
return 0;
}
Compilez ce programme avec gcc :
$ gcc -O0 -o somme somme.c $ ./somme $
gcc a produit un fichier somme.o qui
contient le code binaire généré. Il est possible de visualiser son
contenu au moyen de objdump :
$ objdump -d somme.o
Il est possible de demander directement à gcc de
sauvegarder le code assembleur avec
l'option -save-temps. Cela demande en fait
à gcc de ne pas effacer les fichiers
intermédiaires. Regardez le fichier somme.s.
Compilez maintenant ce programme avec différents niveaux
d'optimisation, en remplaçant l'option -O0
par -O1 puis par -O2.
Regardez l'assembleur à chaque fois. Que s'est-il passé ?
somme.c de façon à empêcher le compilateur de supprimer la boucle. Recompilez (toujours avec -O2) et analysez le nouveau résultat obtenu.
Modifier le programme pour afficher le premier argument en base
10, sans utiliser de fonction externe (i.e. seulement l'appel système
sys_write).
Afficher le nombre d'entiers premiers inférieurs au premier argument en utilisant le Crible d'Etathosthème.