4MMPS x86

Cette page contient des rappels rapides concernant l'assembleur des processeurs Intel Pentium 32 bits. Elle n'a pas pour but d'être un cours complet d'assembleur x86, mais juste ce qu'il est nécessaire de savoir pour le mini-projet. Des bases d'architecture des ordinateurs et d'assembleur (quelque-soit le processeur) sont nécessaires pour la programmation système.
Sommaire
Rappels généraux sur le processeur Pentium
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. On détaille ci-dessous ceux qui nous serviront en pratique :
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. communs à la majorité des processeurs.
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 via un suffixe de taille ajouté à la fin des instructions :
- 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.
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 |
Instructions courantes
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, 0x1234 : copie le contenu du registre 8 bits %al dans la case mémoire 8 bits d'adresse 0x1234 ;
- de la mémoire vers un registre : movw 0x1234, %bx : copie le contenu de case mémoire 16 bits d'adresse 0x1234 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, 0x1234 : copie la valeur sur 32 bits 0x00001234 dans la case mémoire 32 bits d'adresse 0x1234.
Il n'est par contre pas possible de copier une valeur de la mémoire vers la mémoire.
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 :
- 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
Les branchements conditionnels 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 |
Adressages mémoire
Les modes d'adressage sont les différents mécanismes fournis par le processeur pour accéder à la mémoire. On ne détaille ici que ceux utiles dans le projet.
L'adressage immédiat
Ce n'est pas un accès mémoire à proprement parler, car il sert à mettre une constante dans un registre (la constante étant codée directement dans l'instruction).
Par exemple : movl $5, %eax charge la constante (sur 32 bits) 5 dans le registre %eax.
L'adressage indirect
Le principe de ce mode d'adressage est simplement de lire dans un registre l'adresse de la variable à accéder. Par exemple, si le registre %eax contient l'adresse d'une case mémoire 32 bits, on peut écrire movl $5, (%eax) pour copier la valeur 5 dans la case mémoire.
L'adressage indirect avec déplacement
Dans ce mode, on ajoute une constante entière quelconque à l'adresse contenue dans le registre.
Par exemple : movw -8(%ebx), %dx va chercher un entier sur 16 bits à l'adresse calculée en enlevant 8 au contenu de %ebx, et stocke cet entier dans %dx.
Le registre contenant l'adresse est souvent appelé « registre de base ».
Une utilisation possible est l'accès à des entiers dans un tableau. Par exemple, si %edx pointe sur l'adresse de base du tableau int t[10];, alors movl 12(%edx),%eax permet de mettre la valeur de t[3] dans le registre %eax. En effet, on rappelle que la taille d'un int est de 4 octets, donc le contenu de t[3] se trouve à l'adresse %edx+3*4.
Appels de fonctions
On doit respecter un certain nombre de conventions de gestion de la pile d'exécution lorsqu'on veut appeler des fonctions C depuis du code assembleur, ou l'inverse. On détaille celles qui nous seront utiles dans le cadre du projet, où on n'aura à écrire que des « fonctions » très particulières.
Les pointeurs de pile
Le Pentium comprend 2 registres dont le rôle est lié à la pile d'exécution : %esp et %ebp.
%esp est le pointeur de pile : il contient en permanence l'adresse de la dernière case occupée dans la pile d'exécution. On ne le manipule pas directement en général pour éviter de risquer de déséquilibrer la pile.
%ebp est le pointeur de base : il contient l'adresse de la base du contexte d'exécution de la fonction en cours d'exécution. Dans notre cas, il pointera en permanence sur la case dans laquelle on a sauvegardé le %ebp précédent. On se sert de %ebp pour accéder aux variables locales et aux paramètres de la fonction en cours d'exécution, via un adressage indirect avec déplacement (e.g. -4(%ebp), 8(%ebp), etc.).
Instructions d'appel et de retour
L'instruction call permet d'appeler une fonction : on l'utilise en précisant l'étiquette correspondant au début de la fonction à appeler, par exemple call pgcd. Cette instruction a le même comportement qu'un branchement inconditionnel (jmp) mais en plus, elle empile automatiquement l'adresse de retour dans la pile d'exécution. L'adresse de retour est simplement l'adresse de l'instruction suivant le call dans la fonction appelante.
L'instruction réciproque s'appelle ret : elle dépile l'adresse de retour et revient donc à l'instruction suivant le call. C'est l'équivalent de l'instruction return qu'on connait déjà en C.
Instructions de sauvegarde et restauration dans la pile
On utilise l'instruction push pour empiler une valeur, c'est à dire :
- déplacer le pointeur de pile %esp qui pointe sur la dernière case occupée ;
- copier la valeur dans la case mémoire pointée maintenant par %esp.
Par exemple, pushl %eax est équivalent à :
subl $4, %esp movl %eax, (%esp)
L'instruction réciproque s'appelle pop et elle permet de dépiler la valeur en sommet de pile. Par exemple, popl %eax est équivalent à :
movl (%esp), %eax addl $4, %esp
Gestion des registres
Le compilateur GCC classe les registres généraux du processeur dans 2 catégories :
- les registres scratch sont des registres « de calcul » qui peuvent être utilisés librement dans les fonctions en assembleur ;
- les registres non-scratch sont des registres « précieux » dans lesquels le compilateur peut stocker des valeurs importantes.
Sur Pentium :
- les registres scratch sont %eax, %edx, %ecx et %eflags;
- les registres non-scratch sont %ebx, %esi, %edi, %ebp et %esp.
Il s'agit d'une convention arbitraire : on doit cependant la prendre en compte lorsqu'on veut interagir avec du code généré par GCC, ce qui est notre cas dans le projet.
Cette distinction est importante lorsqu'on appelle une fonction C depuis du code assembleur :
- la fonction en assembleur ne doit pas laisser de valeurs importantes dans les registres scratch sans les sauvegarder avant d'appeler la fonction C : en effet, l'appel à la fonction C pourrait parfaitement détruire le contenu de ces registres, car GCC considère qu'il peut s'en servir comme bon lui semble ;
- la fonction en assembleur peut par contre laisser des valeurs importantes dans les registres non-scratch car on est sur qu'ils ne seront pas modifiés par l'appel à la fonction C.
Conventions pour le cadre de pile
Lorsqu'on écrit un programme complet en assembleur, en mélangeant des fonctions C et assembleur qui s'appellent les unes les autres, on doit respecter des conventions de gestion de la pile. Dans le cadre de ce projet on n'aura pas besoin d'écrire de fonction entièrement en assembleur, mais on détaille tout de même les conventions classiques sous Unix pour information.
La pile est la zone mémoire contenant le contexte d'exécution des fonctions du programme. A chaque fois que le programme commence l'exécution d'une fonction, on doit mettre en place un cadre de pile (stack frame en anglais) qui contiendra notamment les variables locales de la fonction, les paramètres passés à la fonction, etc. Un cadre de pile a la structure suivante :
Dans ce schéma, les adresses sont croissantes vers le bas mais les valeurs sont empilées en haut car il s'agit d'une pile au sens algorithmique du terme. Les différentes zones sont détaillées ci-dessous :
- les paramètres sont ceux passés à la fonction en cours d'exécution, ils sont mis par l'appelant de la fonction (par des instructions push) ;
- l'adresse de retour est l'adresse de l'instruction suivant l'appel de la fonction en cours d'exécution dans la fonction appelante (i.e. c'est là qu'on revient quand on exécute return à la fin de la fonction), cette adresse est mise dans la pile automatiquement lors de l'appel de la fonction avec l'instruction call ;
- le « %ebp précédent » est la sauvegarde du pointeur de base de la fonction appelante, il est sauvegardé à l'entrée de la fonction par l'instruction pushl %ebp, puis %ebp est mis à jour tout de suite après avec l'instruction movl %esp, %ebp ;
- les variables locales de la fonction en cours d'exécution, si elles existent, sont localisées dans son cadre de pile, à des adresses fixes par rapport à %ebp, on peut réserver l'espace nécessaire en déplaçant le pointeur de pile %esp de la taille à réserver en utilisant l'instruction subl après avoir sauvegardé l'ancien %ebp ;
- on peut éventuellement réserver de la place dans le cadre de pile pour sauvegarder des registres non scratch si besoin.
Exemple de code en assembleur
Le fichier contenant le code assembleur doit avoir l'extension .S. Voilà un exemple de code :
// directive .text indiquant que ce qui est suit //est dans la section réservée pour le code .text // directive .globl indiquant que l'étiquette f est publiée //dans le fichier objet généré pour l'édition de lien . globl f //Etiquette (doit être en début de ligne et suivi de : ) f: //code assembleur //doit être précédé d'espaces ou de tabulations //pour être considéré comme tel movl 4(%esp),%eax addl %eax,%eax ret
Documentations officielles Intel
Vous trouverez ci-dessous les documentations officiels d'Intel : bien entendu, elles dépassent très largement le périmètre de ce cours et vous n'avez pas besoin de les lire en détail pour réussir le projet. Attention : elles font chacune plusieurs centaines de pages, ne les imprimez pas !!.
- 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