Constructions de base en assembleur x86

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 

Introduction

Le but de cette séance est de découvrir le processeur Pentium (ou plus précisément l'architecture Intel ia32) que l'on va utiliser ce semestre pour écrire du code assembleur.

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 du cours de Logiciel de Base, 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 n'est pas basé sur des critères pédagogique (il existe des architectures beaucoup plus simples à apprendre, comme le MIPS par exemple), mais sur la constatation que ce processeur est de très loin le plus répandu dans les machines grand-public. Cela permet de faire les TP directement sur les machines disponibles à l'école ou sur les portables personnels des élèves, sans avoir à utiliser d'émulateurs ou de cross-compilateurs.

Langage et architecture matérielle

Le langage assembleur

L'assembleur (on dit plus rarement « langage d'assemblage » ou parfois de façon imprécise « langage machine ») 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.

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.

On utilisera la ligne de commande suivante pour traduire un programme assembleur en binaire :

gcc -o BINAIRE -m32 -g -gstabs SOURCE

L'option -gstabs est nécessaire sous MacOS pour pouvoir utiliser le débogueur gdb.

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 Logiciel de Base :

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 du cours de Logiciel de Base, on n'utilisera qu'un sous-ensemble très restreint des instructions fournies.

L'instruction de copie

movsuffixe 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 :

  • addw %ax, %bx : calcule %bx := %bx + %ax ;
  • subb $20, %al : calcule %al := %al - 20 ;
  • negl %eax : calcule %eax := -%eax ;
  • 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 ;
  • 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 ;
  • 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 ;
  • andw $0xFF00, %cx : calcule un « et bit-à-bit » %cx := %cx et 0xFF00 ;
  • orb $0x0F, %al : %al := %al ou 0x0F ;
  • xorl %eax, %eax : %eax := %eax ou-exclusif %eax ;
  • 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 :

  • cmpl $5, %eax : compare %eax avec 5, en effectuant la soustraction %eax - 5 sans stocker le résultat ;
  • 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, %ebx
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

On suppose dans le code C suivant que la variable x est stockée dans le registre %eax :

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

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

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

Structure d'une boucle while

On rappelle qu'en C, int représente le type des entiers signés :

int x = ...;

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

Cela impose d'utiliser le bon branchement conditionnel dans le code assembleur :

while:
    cmpl $5, %eax
    jle fin_while
    subl $1, %eax
    jmp while
fin_while:

Structure d'une boucle for

En C, un for n'est qu'une écriture condensée d'un while :

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

Le code aura donc une forme similaire à celle d'un while (on suppose que i est dans %ecx) :

    movl $0, %ecx
for:
    cmpl $5, %ecx
    jae fin_for
    addl $4, %eax
    addl $1, %ecx
    jmp for
fin_for:

Exemples

Premier programme en assembleur : calcul du PGCD

Code en C

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

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

Traduction en assembleur Pentium

En assembleur Pentium, on traduit ce code comme suit :

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

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.

Attention, sous MacOS la fonction main du C doit s'appeller _main en assembleur : il faut donc rajouter un blanc-souligné devant l'étiquette et dans la directive.

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

Exercice : 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 :

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

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 :

    // r = 0;
    movl $0, %edx