TD 8 INF422 — Langage d'assemblage, compilation

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.

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).

1. Mise en place du TD

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.

Voir la correction

2. Un langage assembleur simplifié

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:

Nous allons aussi avoir besoin de branchements, conditionnels ou inconditionnels :

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 :

3. Exemple

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

Voir la correction

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 Debugger

gdb accepte un certain nombre de commandes (à tapper après l'obtention du prompt) qui vont nous être utiles pendant ce TD.

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 ?

4. Premiers pas

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.

Voir la correction

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; }

Voir la correction

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; }

Voir la correction

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; }

Voir la correction

5. Boucles et fonctions

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.

Voir la correction

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); }

Voir la correction

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 !

Voir la correction

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 ?

Voir la correction

6. Appels système

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.

Voir la correction

7. Optimisations

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é ?

Modifiez le code source de somme.c de façon à empêcher le compilateur de supprimer la boucle. Recompilez (toujours avec -O2) et analysez le nouveau résultat obtenu.

8. Pour s'amuser encore un peu

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.