Constructions de base en assembleur x86

Sommaire
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.
- IA-32 Intel® Architecture Software Developer’s Manual Volume 1: Basic Architecture
- IA-32 Intel® Architecture Software Developer’s Manual Volume 2A: Instruction Set Reference, A-M
- IA-32 Intel® Architecture Software Developer’s Manual Volume 2B: Instruction Set Reference, N-Z
- IA-32 Intel® Architecture Software Developer’s Manual Volume 3: System Programming Guide
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 :
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 .
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 :
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