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 gdb
Une 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.