CEP CTD Intro

De Ensiwiki
Aller à : navigation, rechercher
AttentionCette page est maintenue uniquement par les enseignants. Afin de ne pas perturber le déroulement des cours, elle n'a pas vocation à être modifiée par les élèves. Mais si vous avez des modifications à proposer, merci d'en discuter ou d'envoyer un e-mail aux auteurs de la page (cf. historique)

Laptop.png  Première Année  CDROM.png  Informatique 

Bienvenue sur la première page de la partie « Exploitation des Processeurs » du cours de Conception et Exploitation des Processeurs de première année. Le but de cette partie est de fournir à tout futur ingénieur Ensimag une compréhension générale des mécanismes bas-niveau (dans le sens « proches de la machine ») mis en œuvre dans la plupart des systèmes actuels, du point de vue du logiciel (la partie Projet de Conception des Processeurs prend quant à elle le point de vue du matériel).

Plus précisément, on étudiera notamment dans cette partie :

  • comment un compilateur produit du code assembleur à partir d'un programme écrit dans un langage de haut-niveau (dans notre cas le langage C, qui présente l'avantage d'être beaucoup plus proche de l'assembleur qu'un langage comme Ada par exemple) ;
  • la structure d'une unité d'exécution (processus) en termes de gestion de zone mémoires (code, données statiques, tas, pile) ;
  • les interactions entre un programme et son environnement d'exécution (utilisation d'une bibliothèque de fonctions, conventions de gestion des registres et de la mémoire, etc).

Le but de cette partie n'est surtout pas d'apprendre à écrire de gros programmes directement en assembleur, ce qui n'aurait en pratique que peu d'intérêt. Il constitue plutôt une introduction à des notions de base de la programmation système et de l'écriture de compilateurs, qui seront approfondies en 2A.

Si vous souhaitez travailler sur votre machine personnelle, c'est possible (mais attention, les enseignants n'assurent pas de support : pour avoir de l'aide, adressez-vous aux Bug Busters).

  • sous Linux vous ne devriez avoir aucun problème avec les exemples et exercices proposés (sauf si votre version de Linux ne supporte pas la compilation de programmes 32 bits : dans ce cas, consultez cette page) ;
  • sous MacOS, il y a quelques différences génantes pour certains exercices : adressez-vous à Christophe Rippert en précisant bien la version de votre système (ou travaillez directement sur telesun via ssh) ;
  • sous Windows enfin, les différences sont telles qu'il est plus simple de se connecter à distance sur telesun pour faire les exercices.

Introduction

Le but de cette séance est de découvrir le processeur Pentium (ou plus précisément l'architecture ia32) que l'on va utiliser ce semestre pour écrire du code assembleur. On n'écrira pas de code MIPS dans la partie Exploitation des Processeurs, par contre vous devrez en écrire pour tester le processeur développé dans la partie Projet d'Architecture. Si vous souhaitez tester vos programmes MIPS dans un environnement convivial, vous pouvez utiliser le simulateur SPIM gratuit.

Le Pentium est un processeur à jeu d'instructions complexe (CISC en anglais, par opposition aux processeurs RISC plus courants) : cela signifie qu'il supporte beaucoup d'instructions, dont certaines sont capables d'effectuer des opérations très complexes. Dans le cadre de ce cours, on n'utilisera qu'un tout petit sous-ensemble du jeu d'instruction supporté par le processeur.

On fourni à titre informatif les documentations officielles du processeur Pentium : ces documents font plusieurs centaines de pages chacun et contiennent une masse d'information qui dépasse très largement le cadre de ce cours. Vous n'avez absolument pas besoin d'imprimer ces documents : toutes les informations nécessaires vous serons fournies pour les TP et examens.

Le choix du processeur Pentium par rapport au MIPS est une conséquence des différences entre les modèles RISC et CISC : un processeur RISC (comme le MIPS) offre un jeu d'instruction idéal pour l'écriture d'un compilateur qui génère du code de façon systématique, mais écrire du code RISC « à la main » peut devenir rapidement fastidieux. A-contrario, le processeur Pentium offre des instructions puissantes qui simplifient l'écriture de programmes à la main.

Rappels de cours

Le langage assembleur

L'assembleur est le langage compréhensible par un humain qui se situe le plus prêt de la machine.

Le processeur exécute des instructions écrites en binaire comme vous l'avez vu dans le cours d'Architecture des ordinateurs. Il est très difficile pour un humain de se souvenir que la séquence 1011100000000101000000000000000000000000 copie la valeur 5 dans un registre appelé %eax. C'est pour cela qu'on utilise l'assembleur, qui est représentation textuelle des instructions supportées par le processeur : dans le cas de cet exemple, on écrira movl $5, %eax, ce qui est nettement plus clair (si si !).

L'assembleur est un langage sans structure de contrôle (i.e. pas de if, while, etc.) dont chaque instruction peut-être traduite directement dans la séquence binaire équivalente compréhensible par le processeur. Le logiciel qui réalise cette traduction s'appelle aussi un assembleur. Il en existe beaucoup supportant l'architecture Pentium : on utilisera celui fourni avec le compilateur gcc que l'on utilise déjà pour compiler du C.

Le processeur

Le premier processeur de la gamme Pentium est sorti en 1993 et présente la propriété (commune à tous les processeurs conforment à l'architecture ia32) d'être compatible avec le processeur 8086 sorti en 1978. Cette compatibilité ascendante explique en partie la complexité de l'architecture ia32.

Le Pentium est un processeur « 32 bits » : cela signifie que le mot de donnée de base et les adresses en mémoire sont codés sur 4 octets.

Le Pentium contient un nombre limité de registres 32 bits (les processeur RISC en ont généralement beaucoup plus). On détaille ci-dessous ceux qui nous serviront en pratique :

Registres du processeur Pentium
Registre 32 bits bits de 31 à 16 bits de 15 à 8 bits de 7 à 0 Nom courant
%eax %ax accumulateur
%ah %al
%ebx %bx index de base
%bh %bl
%ecx %cx compteur
%ch %cl
%edx %dx registre de données
%dh %dl
%esi %si index source
%edi %di index de destination
%esp %sp pointeur de pile
%ebp %bp pointeur de base
%eip %ip pointeur d'instruction
%eflags %flags registre des indicateurs

Les registres du Pentium sont fractionnables. Par exemple, le nom %ax peut-être utilisé dans un programme assembleur pour accéder aux 16 bits de poids faibles de %eax (les 16 bits de poids forts ne portent pas de nom particulier). De plus, on peut accéder aux 8 bits de poids faibles de %ax (et donc aussi de %eax) en utilisant le nom %al. Le nom %ah permet d'accéder aux 8 bits de poids fort de %ax.

Les 4 registres généraux (%eax, %ebx, %ecx et %edx) peuvent être utilisés pour effectuer des calculs quelconques. Les registres %esi et %edi ont des rôles particuliers lorsqu'on utilise certaines instructions, mais dans notre cas on les utilisera comme des registres de calcul généraux.

Les registres de pile (%esp et %ebp) ont des fonctions particulières que l'on détaillera plus tard. On ne doit jamais les utiliser pour effectuer des calculs.

Le registre %eip pointe en permanence sur la prochaine instruction à exécuter (c'est le compteur programme vu en architecture). On ne peut pas le manipuler directement dans un programme assembleur.

Enfin, le registre des indicateurs %eflags contient notamment les indicateurs Z, C, etc. vus en architecture. On ne s'en servira que via des instructions de branchements conditionnels.

La mémoire

Les adresses sont codées sur 32 bits : la taille maximale de la mémoire adressable est donc 2^{32} = 4 GiO.

Le bus de données est aussi sur 32 bits, mais on peut accéder à des données sur 32, 16 et 8 bits : on devra donc systématiquement préciser la taille des données manipulées.

Le Pentium est un processeur little-endian, ce qui signifie que dans un mot mémoire, les bits de poids faibles sont stockés en premier. Par exemple, si la valeur (sur 32 bits) 0x12345678 est stockée à l'adresse 0x1000, on trouve en mémoire :

Adresses 0x1000 0x1001 0x1002 0x1003
Valeurs 0x78 0x56 0x34 0x12

Programmation en assembleur Pentium

Le Pentium plusieurs centaines d'instructions, et ce nombre augmente à chaque nouvelle version du processeur. Certaines de ces instructions sont capables de réaliser des opérations très puissantes, comme par exemple effectuer des calculs mathématiques complexes en une seule instruction. Dans le cadre de ce cours, on n'utilisera qu'un sous-ensemble très restreint des instructions fournies.

L'instruction de copie

mov src, dst copie une valeur d'un endroit à un autre :

  • d'un registre vers un autre registre : movl %eax, %ebx : copie le contenu du registre 32 bits %eax dans le registre %ebx ;
  • d'un registre vers la mémoire : movb %al, x : copie le contenu du registre 8 bits %al dans la variable x ;
  • de la mémoire vers un registre : movw y, %bx : copie le contenu de la variable sur 16 bits y dans le registre %bx ;
  • une constante vers un registre : movb $45, %dh : copie la valeur sur 8 bits 45 dans le registre %dh ;
  • une constante vers la mémoire : movl $0x1234, z : copie la valeur sur 32 bits 0x00001234 dans la variable z.

Il n'est par contre pas possible de copier une valeur de la mémoire vers la mémoire : movl x, y.

Les suffixes de taille précisent la taille de la valeur manipulée :

  • l (long) : pour les données sur 32 bits ;
  • w (word) : pour les données sur 16 bits ;
  • b (byte) : pour les données sur 8 bits ;

Dans beaucoup de cas l'assembleur est capable de deviner seul la taille de la valeur manipulée (par exemple en se basant sur la taille du registre source ou destination), mais il est recommandé de préciser quand même systématiquement le suffixe de taille. Cela permet à l'assembleur de faire des vérifications de cohérence et de détecter les erreurs dès la phase d'assemblage (et donc d'éviter d'avoir à utiliser gdb pour trouver l'erreur à l'exécution).

Opérations arithmétiques

Il existe de nombreuses opérations arithmétiques, dont on détaille les plus courantes :

  • Addition : add, par exemple addw %ax, %bx calcule %bx := %bx + %ax ;
  • Soustraction : sub, par exemple subb $20, %al calcule %al := %al - 20 ;
  • Négation : neg, par exemple negl %eax calcule %eax := -%eax ;
  • Décalage à gauche : shl, par exemple shll $1, %eax décale la valeur sur 32 bits contenue dans le registre %eax d'un bit vers la gauche, en insérant un 0 à droite ;
  • Décalage arithmétique à droite : sar, par exemple sarl $12, %ebx décale la valeur sur 32 bits contenue dans le registre %ebx de 12 bits vers la droite, en propageant le bit de signe à gauche ;
  • Décalage logique à droite : shr, par exemple shrl $4, %ebx décale la valeur sur 32 bits contenue dans le registre %ebx de 4 bits vers la droite, en insérant des 0 à gauche ;
  • Conjonction logique : and, par exemple andw $0xFF00, %cx calcule un « et bit-à-bit » %cx := %cx et 0xFF00 ;
  • Disjonction logique inclusive : or, par exemple orb $0x0F, %al calcule %al := %al ou 0x0F ;
  • Disjonction logique exclusive : xor, par exemple xorl %eax, %eax calcule %eax := %eax ou-exclusif %eax ;
  • Négation logique : not, par exemple notl %ecx calcule l'opposé bit-à-bit de %ecx.

Comparaisons

On utilise les comparaisons avant un branchement conditionnel, pour implanter un if ou un while :

  • Comparaison arithmétique : cmp, par exemple cmpl $5, %eax compare %eax avec 5, en effectuant la soustraction %eax - 5 sans stocker le résultat ;
  • Comparaison logique : test, par exemple testb $0x01, %bl effectue un et bit-à-bit entre la constante 1 et %bl, sans stocker le résultat.

Les comparaisons ne stockent pas le résultat de l'opération effectuée, mais mettent à jour le registre des indicateurs %eflags, qui est utilisé par les branchements conditionnels.

Branchements

Le branchement le plus simple est le branchement inconditionnel : jmp destination.

Pour préciser la destination d'un branchement, on utilise une étiquette :

    movl $0, %eax
    jmp plus_loin
    movl $5, %edx
plus_loin:
    addl $10, %eax

Pour implanter des if et while, on utilise les branchements conditionnels, qui se basent sur l'état d'un ou plusieurs indicateurs contenus dans le registre %eflags pour déterminer si le saut doit être effectué ou pas :

  • je etiq saute vers l'étiquette etiq ssi la comparaison a donné un résultat « égal » ;
  • jne etiq saute ssi le résultat était différent (not equal) ;

Les indicateurs à tester sont parfois différents selon si on travaille sur des entiers signés ou naturels. Pour les naturels, on utilisera :

  • ja etiq saute ssi le résultat de la comparaison était strictement supérieur (jump if above) ;
  • jb etiq saute ssi le résultat de la comparaison était strictement inférieur (jump if below) ;

Et pour les entiers signés :

  • jg etiq saute ssi le résultat de la comparaison était strictement supérieur (jump if greater) ;
  • jl etiq saute ssi le résultat de la comparaison était strictement inférieur (jump if less) ;

On peut composer les suffixes, et obtenir ainsi plusieurs mnémoniques différents pour la même instruction : jna (jump if not above) est strictement équivalent à jbe (jump if below or equal).

Le tableau ci-dessous résume les différents branchements conditionnels qu'on utilisera :

Branchements conditionnels
Comparaison Entiers naturels Entiers signés
> ja, jnbe jg, jnle
jae, jnb jge, jnl
< jb, jnae jl, jnge
jbe, jna jle, jng
= je, jz
jne, jnz

On trouvera dans cet extrait de la documentation Intel la liste exhaustive de tous les branchements conditionnels existants.

Traduction systématiques des structures de contrôle classiques

On peut traduire les structures de contrôles des langages de haut-niveau de façon très systématique, ce qui réduit le risque d'erreur. Dans le cadre de ce cours, on demandera toujours de traduire systématiquement les algorithmes donnés en C, sans chercher à « optimiser » le code (sauf indication contraire).

Structure d'un if

Soit par exemple le code C suivant, où x est une variable globale initialisée auparavant :

if (x == 5) {
    x = x + 2;
} else {
    x = x – 4;
}

Le code assembleur correspondant prendra toujours la forme ci-dessous :

if:
    cmpl $5, x
    jne else
    addl $2, x
    jmp fin_if
else:
    subl $4, x
fin_if:		

Structure d'une boucle while

On suppose dans le programme ci-dessous que x est une variable de type int déclarée globalement et initialisée auparavant :

while (x > 5) {
    x = x - 1;
}

Le type de x impose d'utiliser le bon branchement conditionnel dans le code assembleur :

while:
    cmpl $5, x
    jle fin_while
    subl $1, x
    jmp while
fin_while:

Structure d'une boucle for

Même supposition pour x que dans le programme précédent, et on suppose aussi que i est une variable globale de type unsigned non-initialisée :

for (i = 0; i < 5; i ++) {
    x = x + 4;
}

Le code aura une forme similaire à celle d'un while :

    movl $0, i
for:
    cmpl $5, i
    jae fin_for
    addl $4, x
    addl $1, i
    jmp for
fin_for:

Représentation d'un programme en mémoire

Principe général

Lorsqu'on compile ou assemble un programme, on obtient un fichier binaire contenant le code de ce programme en langage machine, ainsi que les données dont il aura besoin à l'exécution. Ce fichier binaire respecte un certain format, dont ELF et Mach-O par exemple.

Lorsqu'on lance l'exécution de ce programme (en tapant ./mon_prog dans un terminal par exemple), le fichier binaire est chargé en mémoire et un certain nombre d'opérations dépassant le cadre de ce cours sont effectuées afin de rendre le programme exécutable. Ce qui nous intéresse ici est la représentation finale du programme dans la mémoire, juste avant que le processeur commence à exécuter son code machine.

On parle traditionnellement de processus pour désigner un programme en cours d'exécution dans la mémoire du système. De façon volontairement simplifiée, un processus a en mémoire la structure suivante :

zone text
(code du programme)
zone data
(données statiques)
tas (heap)
(données dynamiques)
pile (stack)
(contexte d'exécution)

Lorsqu'on écrit un programme en C, on ne se préoccupe pas de savoir dans quelle zone du processus va se retrouver telle fonction ou telle donnée : c'est le travail du compilateur. Mais lorsqu'on écrit du code assembleur, on doit définir précisément la structure du programme. On utilise pour ça des directives, qui sont des commandes qui ne font pas partie du langage assembleur mais qui aide le programme d'assemblage à faire son travail.

La zone text

Cette zone contient le code en langage machine de toutes les fonctions du programme. Lorsqu'on écrit de l'assembleur, on est par défaut dans cette zone. Si l'on souhaite le préciser explicitement, on peut utiliser la directive .text avant de commencer à écrire du code assembleur, comme par exemple dans le code ci-dessous :

    .text
    .globl main
main:
    enter $0, $0
    leave
    // return 0
    movl $0, %eax
    ret

La zone data

Structure de la zone

Cette zone contient les données allouées statiquement dans le programme, c'est à dire les variables globales du langage C. En pratique, cette zone est souvent découpées en sous-zones, mais ce découpage est très dépendant de l'architecture de destination. On trouve souvent :

  • la zone data a proprement parler qui contient les données initialisées modifiables ;
  • la zone rodata qui contient des données en lecture seule (read-only) ;
  • la zone bss qui contient des données modifiables mais non-initialisées.

Par exemple, dans le programme C ci-dessous :

int x = 5;

int y;

int main(void)
{
    printf("toto");
    return 0;
}
  • la variable globale x sera placée dans la zone data ;
  • la variable globale y sera placée dans la zone bss ;
  • la chaine de caractères constante "toto" sera (parfois) placée dans la zone rodata.

En pratique, l'utilisation de la zone rodata est facultative : on ne l'utilisera pas dans le cadre de ce cours, et on placera toutes les données initialisées dans la zone data.

La zone bss quant à elle est souvent utilisé, mais la directive permettant de la désigner dépend du système. On s'en servira principalement pour réserver de la place mémoire lors de la définition de tableaux (dans le cadre de l'exemple C ci-dessus, on pourrait aussi bien placer y en zone data et l'initialiser à 0 par exemple).

Note : sur beaucoup de systèmes, la zone bss est initialisée à 0 avant le début de l'exécution du programme, mais en toute rigueur on ne doit pas compter sur ce point car ce n'est pas toujours le cas.

La directive assembleur permettant de signifier le début de la zone data est tout simplement .data. Si l'on veut réserver de l'espace mémoire dans la zone bss, on utilisera la directive .comm dont on définira la syntaxe plus bas.

Directive de « typage »

En assembleur, pas plus que dans la mémoire physique de la machine, il n'existe pas de types de données à proprement parler. On travaille toujours en termes d'espace mémoire réservé pour les variables : on ne sait pas si x est un entier ou un flottant, on sait juste que cette variable occupe 4 octets en mémoire. En pratique, on n'utilisera que des entiers, des tableaux d'entiers et des chaines de caractères dans ce cours.

Dans la zone data du programme, on utilise fréquemment les directives suivantes pour réserver de l'espace pour les variables globales du programme :

  • .long val permet de réserver 4 octets consécutifs de mémoire et de les initialiser avec la valeur val ;
  • .short val permet de réserver 2 octets consécutifs de mémoire et de les initialiser avec la valeur val ;
  • .byte val permet de réserver 1 octet de mémoire et de l'initialiser avec la valeur val ;
  • .ascii "chaine" réserve autant d'octets consécutifs de mémoire qu'il y a de caractères dans la chaine et les initialise avec les caractères de la chaine ;
  • .asciz "chaine" est similaire à .ascii mais ajoute en plus le caractère '\0' à la fin de la chaine : cette directive permet donc de traduire directement les chaines de caractères du C.

Les directives ci-dessus permettent de réserver de l'espace mémoire pour les différentes variables du programme. Mais pour pouvoir les utiliser, on doit pouvoir les nommer : on utilise pour cela des étiquettes, comme pour les fonctions.

Soit par exemple la zone data d'un programme assembleur quelconque :

    .data
x:  .long 12345678
y:  .short 20000
z:  .byte 0xFF
c:  .ascii "a"
ch: .asciz "toto"

.comm tab, 10 * 4

Dans cet exemple, on trouve :

  • une variable globale x qui représente un entier (4 octets) valant initialement 12345678 ;
  • une variable globale y qui représente un entier court (2 octets) valant initialement 20000 ;
  • une variable globale z qui représente un octet valant initialement 0xFF (c'est à dire aussi bien -1 que 255 selon si on considère cet octet comme signé ou non) ;
  • un caractère isolé c qui vaut initialement 'a' ;
  • une chaine de caractère ch qui contient initialement les caractères 't', 'o', 't', 'o', '\0'.

On voit aussi que la directive comm, qui permet de réserver de la place pour des données non-initialisées, a une syntaxe légèrement différente : on indique d'abord la directive, puis ensuite l'étiquette suivie de la place à réserver après une virgule. Ici, on réserve 40 octets à partir d'une étiquette appelée tab : on est donc vraisemblablement en train d'allouer un tableau de 10 entiers (sur 4 octets chacun). L'assembleur est capable d'effectuer des opérations arithmétiques simples, on peut s'en servir pour rendre le code plus clair.

Le tas

Le tas (heap en anglais) est la zone dans laquelle les fonctions malloc et calloc prennent la mémoire à réserver dynamiquement quand on les appelle. Cette zone a en général une taille prédéfinie lors de la création du processus et peut potentiellement grandir grace à un mécanisme appelé « mémoire virtuelle » que vous étudierez dans le cours de système en 2A.

La pile

La pile (stack en anglais) d'exécution est la zone dans laquelle on stocke notamment les variables locales aux différentes fonctions du programme (on rappelle que la zone data ne contient que les variables globales). Cette zone est organisé comme une pile (au sens algorithmique du terme), et part donc de la fin de l'espace mémoire du processus pour remonter vers le début à chaque fois que l'on empile des valeurs.

La pile a une taille fixe qui est généralement une constante dépendant du système utilisé. A noter que dans de nombreux systèmes, on ne vérifie pas que l'on n'empile pas plus de données qu'il n'y a d'espace réservé pour la pile, et on peut parfaitement arriver à écraser d'autres données, ou même du code, si on en empile trop : on parle alors de « débordement de pile » (stack overflow en anglais), une technique couramment utilisée pour exploiter des failles de sécurité dans les systèmes.

On verra plus tard dans ce cours comment on peut utiliser des variables locales et gérer la pile d'exécution.

Etude d'un exemple : le PGCD

Soit le programme en C ci-dessous qui calcule le PGCD de 15 et 10 :

unsigned a;
unsigned b = 10;

int main(void)
{
    a = 15;
    while (a != b) {
        if (a < b) {
            b = b - a;
        } else {
            a = a - b;
        }
    }
    return 0;
}

En assembleur Pentium, on traduit ce code comme suit :

    .text
    .globl main
main:
    enter $0, $0
    // a = 15
    movl $15, a
while:
    // on compare b a a, en calculant b - a
    movl a, %eax
    cmpl %eax, b
    je fin_while
    // si b < a, alors on va au else
    jb else
    // b = b - a
    movl a, %eax
    subl %eax, b
    jmp fin_if
else:
    // a = a - b
    movl b, %eax
    subl %eax, a
fin_if:
    jmp while
fin_while:
    leave
    // return 0
    movl $0, %eax
    ret

    .data
b:    .long 10

// zone bss
.comm a, 4

Point d'entrée et étiquettes publiques

Le programme commence par la directive .globl qui est liée à la déclaration de l'étiquette main :

    .globl main
main:

La fonction C main représente le programme principal. En assembleur, on doit aussi écrire une fonction main, qu'on représente simplement par une étiquette.

Mais par défaut, toutes les étiquettes déclarées dans un programme assembleur sont privées et invisibles à l'extérieur du fichier. Or la fonction main doit être visible pour pouvoir être appelée par le système.

C'est le but de directive .globl qui rend l'étiquette main publique.

En-tête et fin de fonction

enter $0, $0
...
leave
...

Toutes les fonctions que l'on écrira en assembleur commencent par l'instruction enter et se terminent par l'instruction leave : on comprendra plus tard à quoi servent ces instructions.

Valeur renvoyée

movl $0, %eax
ret

Par convention, une fonction en assembleur renvoie sa valeur de retour dans le registre %eax. Si on écrit une fonction qui ne renvoie rien (void), il suffit de ne rien copier dans %eax : son contenu sera de toute façon ignoré par la fonction appelante.

L'instruction ret est l'équivalent du return en C et en Ada, et rend la main à la fonction appelante (ici, le système).

Déclaration des variables

La variable a est déclarée par unsigned a; dans le code C : il s'agit donc d'une variable globale non-initialisée, qu'on doit placer en zone bss, grâce à la directive .comm a, 4 (qui réserve 4 octets, la taille d'un entier, en zone bss).

On déclare b par unsigned b = 10; : il s'agit donc d'une variable globale initialisée, à placer en zone data. On utilise donc la directive .data pour changer de section, puis b: .long 10 pour déclarer b comme un entier et l'initialiser.

Exercices

Utilisation de GDB pour la mise au point de programmes assembleur

Recopier le code assembleur du PGCD ci-dessus dans un fichier pgcd.s et compilez-le en utilisant la ligne de commande suivante (vous pouvez aussi créer votre Makefile si vous voulez) : gcc -o pgcd pgcd.s -m32 -g . Vous pouvez l'exécuter en tapant ./pgcd mais comme il ne fait pas d'affichage, ça n'est pas très parlant !

On va donc utiliser GDB pour tracer l'exécution du programme instruction par instruction :

  • charger le programme dans GDB en tapant gdb ./pgcd ;
  • ajouter un point d'arrêt au début du programme en utilisant la commande « break main » (ou pour moins se fatiguer, simplement b main) ;
  • lancer son exécution en tapant run (ou r): noter que l'exécution s'arrête sur le point d'arrêt positionné sur main : GDB affiche alors la prochaine instruction à exécuter (i.e. elle n'a pas encore été exécutée) ;
  • utiliser la commande display pour afficher le contenu des variables a et b (i.e. display a, display b), et du registre %eax (i.e. display $eax) ;
  • utiliser step (ou s) pour avancer et exécuter l'instruction movl $15, a ;
  • continuer l'exécution du programme pas à pas avec step en vérifiant les valeurs des registres après l'exécution de chaque instruction qui les modifie ;
  • vérifier qu'on fait bien deux itérations de la boucle while avant d'en sortir (i.e. le branchement conditionnel je fin_while n'est pas effectué lors des deux premières itérations) ;
  • vérifier aussi qu'on saute bien vers else la première fois qu'on exécute l'instruction jb else mais pas la deuxième ;
  • vérifier enfin que le PGCD (5) est bien dans a et b lorsqu'on arrive à la fin de la boucle (i.e. sur fin_while) ;
  • ensuite on peut terminer proprement l'exécution du programme en tapant continue (voir c).

On fourni un aide-mémoire pour gdb qui contient beaucoup plus d'information que ce qui vous sera nécessaire ce semestre.

Somme des 10 premiers entiers

Traduire en assembleur de façon systématique le code C suivant qui calcule la somme des 10 premiers entiers naturels, puis tester qu'il fonctionne bien avec GDB :

unsigned i;
unsigned r = 0;

int main(void)
{
    for (i = 1; i <= 10; i++) {
        r = r + i;
    }
    return 0;
}

Note : quand on traduit un programme du C vers l'assembleur, on indiquera toujours la ligne de C en commentaire avant la séquence d'instructions assembleur correspondante, par exemple :

// i = 1;
movl $1, i

Multiplication simple

On va maintenant implanter l'opération res := x * y par additions successives. On donne ci-dessous le code C que l'on va devoir traduire le plus littéralement possible en assembleur, avant de le tester avec GDB :

unsigned x = 5;
unsigned y = 4;
unsigned res;

int main(void)
{
    res = 0;
    while (y != 0) {
        res = res + x;
        y--;
    }
    return 0;
}

Multiplication egyptienne

Refaite le même exercice en implantant cette fois-ci l'algorithme de la multiplication égyptienne :

unsigned x = 5;
unsigned y = 4;
unsigned res;

int main(void)
{
    res = 0;
    while (y != 0) {
        if (y % 2 == 1) { // % est l'operateur modulo
            res = res + x;
        }
        x = x * 2;
        y = y / 2;
    }
    return 0;
}

Pour implanter les opérations modulo, multiplier et diviser par 2, vous aurez sûrement besoin des instructions suivantes :

  • testl A, B compare A et B en effectuant l'opération A and B ;
  • shll $n, A décale A de n bits vers la gauche ;
  • shrl $n, A décale A de n bits vers la droite (c'est un décalage logique, pas arithmétique).

Indication pour le test de la parité de y : l'instruction test effectue une conjonction logique (and) entre la constante 1 (qui s'écrit en binaire sur 32 bits "00000000000000000000000000000001") et y, ce qui peut donner 2 résultats :

  • soit 1 ssi le bit 0 de y vaut 1, c'est à dire ssi y est impair ;
  • soit 0 ssi le bit 0 de y vaut 0, c'est à dire ssi y est pair.

Si le résultat du test est 0, alors le flag Z passe à 1, et on peut utiliser l'instruction jz qui effectue le branchement ssi Z vaut 1 pour sauter le code dans le if ci-dessus.

Multiplication « native »

On va maintenant finir par implanter la multiplication en utilisant l'instruction de multiplication native du processeur Pentium, en traduisant le code ci-dessous :

unsigned x = 5;
unsigned y = 4;
unsigned res;

int main(void)
{
    res = x * y;
    return 0;
}

Le Pentium fourni une instruction de multiplication dont la syntaxe est un peu particulière. Si on suppose qu'on ne travaille ici que sur des valeurs sur 32 bits, lorsqu'on écrit mull x, on effectue le produit de la valeur de la variable x avec le contenu du registre %eax : ce registre est donc un paramètre implicite de l'instruction.

Le résultat (sur 64 bits vu qu'on multiplie deux nombres sur 32 bits), est toujours stocké dans %edx:%eax, c'est à dire que les 32 bits de poids fort du résultat sont dans %edx, et les 32 bits de poids faibles dans %eax (par exemple si le résultat de la multiplication tient sur 32 bits, mul va écrire le résultat dans %eax et 0 dans %edx. Si vous aviez mis quelque chose d'important dans %edx, la donnée est perdue !).

Vous pouvez consulter la documentation de l'instruction si vous souhaitez en savoir plus.

Là encore, vous devez utiliser GDB pour vérifier que votre programme fonctionne. Vous n'avez pas besoin de vérifier que la multiplication ne déborde pas des 32 bits de %eax.

Si vous avez fini en avance

Faites des tests de performances des trois versions de la multiplication, en essayant de trouver des valeurs mettant en évidence le coût de la multiplication simple et de l'égyptienne (pour voir des différences de temps d'exécution avec la commande Unix time, vous devrez sans doute ajouter une boucle effectuant beaucoup de multiplications, vu la puissance des machines actuelles).

Consultez ensuite la documentation du Pentium pour comprendre le fonctionnement de l'instruction div et servez-vous en pour implanter le modulo de la multiplication égyptienne.