Skip Navigation Links
Accueil
Java Standard EditionExpand Java Standard Edition
Java EE 5Expand Java EE 5
Visual Basic .Net 2005Expand Visual Basic .Net 2005
Visual C++ .Net 2005Expand Visual C++ .Net 2005
Visual C# .Net 2005Expand Visual C# .Net 2005
Cours ASP .Net 2.0Expand Cours ASP .Net 2.0
PostgresqlExpand Postgresql
LinuxExpand Linux
Visual Studio 2008Expand Visual Studio 2008
ASP 3.0 ClassiqueExpand ASP 3.0 Classique
Cours Javascript - DOM - DHTMLExpand Cours Javascript - DOM - DHTML
Cours AjaxExpand Cours Ajax
VBAExpand VBA
AssembleurExpand Assembleur
PerlExpand Perl
MembresExpand Membres
L'auteur du site
Nouveautés sur le site
Contacts
Plan du site
Accueil > Assembleur > Assembleur
____________________________________________________________________________________________________
Connexion

ASSEMBLEUR

14/02/2008

Sommaire :

I) Les registres 16 bits du 8086
II) Les registres 32 bits du 80386
III) Le mode protégé 16 bits
IV) Le mode protégé 32 bits
V) Les interruptions
VI) Le langage machine
VII) Le langage assembleur( ou assembleur, par abus de langage)
VIII) L’assembleur nasm
IX) Les directives de nasm
X) Usage du langage C combiné à l’assembleur, avec DJGPP
XI)Interfaçage avec le C : La convention d’appel C
XII) Les structures de contrôles
XIII)Les variables locales, suivant la convention d’appel C
XIV)Les nombres à virgule flottante
XV) Le coprocesseur arithmétique

Merci à tous les tutos du net sur lesquels je me suis appuyé, notamment le tuto du Dr. Paul A. Carter( qui est une référence pour la programmation assembleur en mode protégé 32 bits, car peu de documents existent sur l’assembleur en mode protégé).
Et aussi merci au cours d’assembleur du site de l’IUT de Caen. Même si je suis parti de ces sources, je crois apporter parfois des points de vues ou explications que je n'ai pas trouvé dans ceux-ci, donc mon tuto a une raison d'être.

I) Les registres 16 bits du 8086

a) Les registres généraux

Le microprocesseur 8086( la première génération, avec le 8088, des microprocesseurs de PC) possède 4 registres généraux de 16 bits. Ces registres sont AX, BX, CX et DX. On remarque que le début commence par les lettres de A à D, et que la fin et "X".
Chacun de ces registres peut être décomposé en deux registres de 8 bits. Par exemple, le registre AX peut être découpé en AH et AL. H comme High et L comme LOW. AH, c'est donc les 8 bits de poids forts du registre AX, et AL les 8 bits de poids faibles de AX. En changeant la valeur de AH, on change par conséquent la valeur de AX.
De même, en changeant la valeur de AX, on change la valeur de AH et de AL! On utilise généralement ces regitres 8 bits comme des regitres indépendants.
Pourquoi des registres? Les registres servent à placer une valeur, ce sont donc l'équivalent des variables en programmmation de plus haut niveau. Le microprocesseur met moins de temps à accéder au contenu des registres, qu'à accéder au contenu de la mémoire, car les registres se situent dans le microprocesseur,et non à l'extérieur.
Pour les besoins courants, c'est donc une bonne idée de laisser à disposition quelques variables, dont l'accès est très rapide.

Pourquoi ces registres 16 bits se découpent-ils en 2 registres 8 bits? Avec le même registre 16 bits du microprocesseur, on peut utiliser au choix un registre 16 bits ou deux registres 8 bits. Donc on peut avoir au choix une "grande" variable ou deux petites variables, selon le besoin. On dispose donc de plusieurs outils, avec la même ressource du processeur( cette ressource est le registre), donc on profite au maximum de cette ressource!
Cette idée de profiter au maximum des ressources du microprocesseur se rencontre continuellement.
De plus, il peut parfois arriver qu'on veuille obtenir les 8 bits de poids forts(ou de poids faibles) d'une valeur 16 bits: encore un intérêt. Les nombres étant stockés en base 2 dans les ordinateurs, il est très facile pour un ordinateur de donner les 8 bits de poids forts ou de poids faibles d'une valeur 16 bits: il lui suffit de lire 8 des bits au lieu de 16 bits.

b) Les registres de pointeurs

b.1) Les registres d'index

Il existe deux registres d'index 16 bits: SI et DI. Ces registres sont utilisés comme des pointeurs, mais peuvent aussi servir de registres généraux: ici aussi on profite au maximum des ressources du microprocesseur. SI = Source index( index source), et DI = Destination Index( index de destination). On remarque qu'une sémantique( destination ou source) est rattachée au registre d'index. Comme on ne peut pas choisir le nom des registres, c'est une manière de pallier à cet handicap. C'est comme si on ne vous donnait pas la possibilité de choisir le nom de vos variables, mais qu'en contrepartie, on vous donnait des variables avec un nom prédéfini. Mais cette sémantique n'est pas obligatoire: la preuve est qu'on peut utiliser ces deux registres d'index comme des registres généraux.

Les registres SI et DI ne se décomposent pas en deux registres de 8 bits( et c'est logique, car leur rôle prévu est quand même d'être un pointeur; d'ailleurs index est un synonyme de pointeur).

b.2) Les registres de pointeur

Les registres SP et BP sont deux registres 16 bits qui contiennent des pointeurs sur un emplacement mémoire de la pile.
La pile( stack) est une zone de mémoire.

b.2.a) Le registre SP( stack pointer)

SP est appelé pointeur de pile( stack pointer; stack = pile). SP est un registre 16 bits qui pointe sur le haut de la pile. Pour une pile non vide, SP pointe sur le dernier élément empilé. Si la pile est vide, SP pointe sur l'emplacement où sera contenu le prochain élément empilé.

b.2.b) Le registre BP( base pointer)

BP est appelé pointeur de base(base pointer). BP est un registre 16 bits qui sert comme registre pour pointer sur une donnée de la pile, quand on a besoin d'un registre temporaire pour pointer sur cette donnée.

b.2.c) Le registre IP( Instruction Pointer = pointeur d’instruction)

Ce registre pointe sur l’adresse de la prochaine instruction à exécuter. Avec le registre CS, on a l’adresse complète. Ce registre est utilisé par le processeur pour connaître l’adresse de l’instruction qu’il va exécuter. IP est incrémenté à chaque exécution d’instruction, ou alors en cas de saut ou de call, c’est l’adresse de saut qui est mise dans IP. C’est logique que c’est au moment de l’exécution de l’instruction qu’on est le mieux placé pour calculer la prochaine adresse à exécuter. C’est pour cette raison que IP est mis à jour juste après chaque exécution d’instruction, car c’est à ce moment là qu’on connaîtra la prochaine adresse( incrémentation ou adresse du saut si saut il y a). Le registre IP n’est pas directement utilisable.

b.3) Les registres de segment

Il y a 4 registres de segments: CS, DS, SS, ES. Ce sont des registres 16 bits. CS = Code Segment; DS = Data Segment; SS = Stack Segment; ES = Extra Segment. Vous avez certainement remarqué que les registres de pointeur que nous avons vus sont des registres de 16 bits seulement. Avec 16 bits, on peut adresser seulement 64 Ko. Il faut donc un système pour étendre notre capacité d'adressage. C'est là qu'interviennent les registres de segment, qui vont dire sur quelle zone de mémoire on travaille. Puis le registre de pointeur affinera l'adresse: il donnera le déplacement(offset). Comment obtenir l'adresse réelle à partir du segment + du déplacement? ce n'est pas direct, nous l'expliquerons dans le paragraphe suivant. Mais toujours est il qu'à partir de ce couple segment/offset, nous pouvons obtenir l'adresse réelle.
Les registres de segment indiquent quel segment est utilisé pour les différentes parties d'un programme. Le registre CS contient le numéro de segment de mémoire qu'on utilise pour le code. DS le segment utilisé pour les données.
SS le segment de la pile. ES est un registre de segment temporaire( d'où son nom).

b.3.a) La mémoire en mode réel

Le microprocesseur 8086 ne connait que le mode réel, contrairement à des processeurs plus récents, qui connaissent le mode protégé.
Le mode réel est une façon de définir l'adressage mémoire. Ce mode est tombé en désuétude. Il permet d'adresser au maximum 1Mo de mémoire(seulement!). La mémoire est adressé par un couple segment:offset. Le segment et l'offset sont chacun sur 16 bits. Le numéro de segment est aussi appelé selecteur.
On remarque que pour adresser 1Mo, il faut 5 chiffres hexa: de 00000 à FFFFF( car avec 20 bits, on peut représenter 1048576 valeurs(65536*16)). C'est donc suffisant car 1Mo=1024*1Ko, = 1024*1024= 1048576 octets, donc c'est parfait!.

b.3.a.2) Le couple segment:offset

En mode réel, la mémoire est découpée en segments de 64Ko. Seulement, il y a 65536 segments: donc les segments se chevauchent. La logique aurait voulu qu'il y ait 1048576 / 64Ko = 16 segments de 64Ko. Dans ce cas, les segments ne se seraient pas chevauchés. Or ce n'est pas ce qui est utilisé. Les segments se chevauchent.
Comme il y a 65536 segments; le numéro de segment a besoin de 16bits. Et comme chaque segment a une longueur de 64Ko, le déplacement(offset) a besoin de 16bits.
Comme il y a 65536 segments( donc le nombre maximal de segments, pour qu'on puisse avoir un numéro sur 16 bits, a été choisi) pour 1Mo, tous les 1Mo/64Ko=16 octets débute un nouveau segment. Pour retrouver l'adresse physique de début de segment, on fait numéro_segment*16. Et pour retrouver l'adresse physique à partir d'un couple segment:offset, on rajoute le déplacement à l'intérieur de ce segment, déplacement qui est de 64Ko, car chaque segment est d'une taille de 64Ko.
On a en résumé: adresse physique = 16*segment + offset.

Le couple segment:offset permet donc de retrouver une adresse physique de 20 bits.
Par exemple, l'adresse physique qui est référencée par 053E:A15B est 073E * 10h = 73E0 + A15B = 1153B ou 70971 en décimal.

073E = 1854 en décimal. A15B = 41307 en décimal. 073E * 10h = 73E0( facile, il suffit de décaler d'un chiffre vers la gauche pour multiplier par 16). 73E0 = 29664 en décimal. 73E0+A15B = 29664+41307=70971 en décimal.

On remarque qu'une même adresse physique peut être écrite de plusieurs manières différentes. A partir d'un couple segment:offset, une seule adresse physique correspond( c'est le principal, sinon ce système de codage ne nous intéresserait pas! Car l'objectif est bien d'écrire les adresses physiques avec 2*16 bits). Mais à partir d'une adresse physique, il y a plusieurs couples segment:offset possibles. Démonstration: tant qu'on a un offset avec une marge restante >=16 octets, on peut diminuer le segment de 1, et augmenter l'offset de 16! Par exemple, le couple 5000:0001, peut s'écrire 4000:0010, ou encore 3000:0100, ou encore 2000:1000, soit 4 façons différentes d'écrire l'adresse physique 50001h(h comme hexa)(50001h=327681 en décimal).

b.3.a.2) Pourquoi avoir choisi ce système de codage?

Un avantage est que pour retrouver l'adresse physique, on a juste à multiplier par 16 le numéro de segment( et rajouter l'offset).
Par ailleurs, si on avait pris des segments de 65536 octets, on aurait eu 16 segments, et le couple segment:offset aurait été unique. Avec des segments de 16 octets, on a 65536 segments, donc le nombre maximal de segments possibles. Intel a donc cherché à maximiser le nombre de segments. Avec 65536 segments, on exploite au maximum les 16bits des registres de segments, tout en utilisant également les 16 bits de l'offset( car les segments font tout de même 64Ko). On a donc un système de codage de l'adresse physique qui exploite au maximum le couple 16 bits/16bits. Un avantage de maximiser le nombre de segment est que, rien qu'avec le numéro de segment, on a l'adresse physique, à 16 octets près. Ce qui est la précision maximale qu'on aurait pu espérer pour un numéro de segment en ayant 16bits pour pouvoir coder les numéros de segment. Si on avait que 16 segments, avec le numéro de segment, on aurait eu l'adresse physique à 64Ko près. Alors qu'avec 65536 segments, on a l'adresse physique à 16 octets près, ce qui est 4096 fois plus précis.
Quant à l'offset, quelque soit le nombre de segments qu'on aurait choisi, il aurait toujours représenté le déplacement en octets dans ce segment, car les segments auraient toujours fait 64Ko, dans tous les cas!
Il est logique d'avoir cherché à maximiser le nombre de segments, car chaque programme a une valeur CS, DS, et SS.
Et chaque programme est susceptible d'utiliser les 64Ko de ses segments. Or, plus on a de segments, plus on peut mettre de programmes différents dans la mémoire. Avec 16 segments, ca aurait été très peu! Et n'oublions pas, comme nous le verrons bientôt, qu'un programme peut dépasser ses 64Ko, et utiliser d'autres segments. Tout ceci nous démontre que la maximisation du nombre de segment est souhaitable.

b.3.a.3) Inconvénients de la segmentation en mode réel

- Comme vu précédemment, une même adresse physique correspond à plusieurs adresses segmentées.
- La taille de 64Ko par segment à des conséquences:
* Si les données sont contenues dans plus de 64Ko, une unique valeur de DS n'est pas suffisante. On doit changer la valeur de DS si on veut des données d'un autre segment.
* De même, si le programme occupe plus de 64Ko, une unique valeur de CS ne suffit pas. On doit changer la valeur de CS quand le programme exécute du code se trouvant dans un autre segment.

b.4) Le registre FLAGS

C’est un registre dont les bits ont une signification propre. Ce registre contient des indications sur le déroulement de l’instruction précédente. Le registre FLAGS n’est pas directement utilisable. Le microprocesseur veut mettre à notre disposition des indicateurs. Ces indicateurs sont des bits. Par exemple, le bit Z, qui est mis à 1 lorsque le résultat de l’instruction précédente était zéro. Ces bits indicateurs, il faut bien qu’ils soient quelque part. On a donc choisi de les placer dans un registre du microprocesseur, un registre spécial. Ce registre est le registre FLAGS. Ces indicateurs sont l’équivalent de tests faits automatiquement, par exemple le test « dernier résultat = 0 ? », pour le le bit Z.
Mais toutes les instructions ne modifient pas forcément le registre FLAGS.

II)Les registres 32 bits du 80386

A partir du processeur 80386, les registres du microprocesseur se sont étendus, et sont passés à 32 bits au lieu de 16 bits. Les registres AX, BX, CX et DX deviennent respectivement EAX, EBX, ECX et EDX. Les registres BP et SP deviennent EBP et ESP. Le registre IP devient EIP. SI et DI deviennent ESI et EDI. FLAGS devient EFLAGS.
Les anciens registres AX, BX, CX, DX, SI et DI sont gardés, pour rester compatibles. Contrairement à EBP, ESP, EIP, EFLAGS, dont les versions 16 bits n’existent plus.
Les registres de segments( CS, DS, ES, SS) sont restés sur 16 bits, mais on a rajouté deux registres de segments généraux( comme ES) : FS et GS, qui n’ont pas de signification précise d’utilisation(ce sont des registres de segments temporaires).
AX forme les 16 bits de poids faibles de EAX, et on ne peut pas récupérer les 16 bits de poids forts de EAX de manière directe.

III)Le mode protégé 16 bits

Le processeur 80286 a introduit le mode protégé 16 bits.
Ecrivons ici la structure d’un descripteur de segment. Nous expliquerons ce qu’est un descripteur de segment dans les explications du mode protégé 32 bits. Donc revenez aux explications ci-dessous après avoir vu le chapitre sur le mode protégé 32 bits.



Image venant du site de l’IUT de Caen

La taille du descripteur de segment est de 8 octets.
On remarque que la longueur du segment est sur 16 bits. La taille d’un segment peut aller par conséquent jusqu’à 64 Ko. Et nouveauté : la taille peut aller de 1 octet à 64Ko, elle n’est pas figée à 64Ko comme en mode réel. D’ailleurs, si elle était figée, il n’y aurait pas de champ longueur dans le descripteur de segment ! De toutes façons, la taille d’un segment ne peut être que de 64Ko maximum, car le déplacement(offset) est sur 16 bits pour le microprocesseur 80286.

Les deux premiers octets sont réservés aux 80386, nous les verrons dans le prochain chapitre. Ils sont inutilisés pour le microprocesseur 80286.
L’adresse de segment est sur 24 bits( 16 + 8). La mémoire adressable est donc de 2 puissance 24 = 16Mo(théoriquement, selon moi, 16Mo + 64Ko taille offset), ce qui est d’ailleurs la capacité mémoire maximale que peut adresser un 80286 !

La taille limitée à 64 Ko des segments est un gros problème du 286.

Le système de mémoire virtuelle existe(nous verrons la mémoire virtuelle dans le chapitre sur le mode protégé 32 bits). Lorsqu’un segment est remis depuis le disque, vers la mémoire, il sera en général placé à un autre endroit de la mémoire que sa place précédente. Mais il concernera toujours le même descripteur de segment. Tout ce travail est fait de manière transparente.

IV)Le mode protégé 32 bits

Le processeur 80386 a introduit le mode protégé 32 bits.

La segmentation est toujours présente comme dans le mode réel, mais elle est programmable. En mode réel, le sélecteur était un numéro de segment. Ici, le sélecteur devient un indice dans une table de descripteurs de segments( appelée « tableau des descripteurs »). Le sélecteur est toujours sur 16 bits, comme en mode réel. D’ailleurs, on a remarqué que les registres de segments( CS, DS, SS, ES, et les nouveaux FS et GS ) étaient toujours sur 16 bits.
L’adresse mémoire que le programmeur indiquera( appelée adresse virtuelle) sera donc formée d’un couple sélecteur sur 16 bits et offset sur 32 bits. L’offset est contenue par exemple dans les registres 32 bits comme EBP, ESI etc. Et le sélecteur sera, bien sûr, dans un registre de segment.

Les segments ne sont plus à des positions fixes en mémoire, comme en mode réel. On désigne ici le segment par son indice de sélecteur, ce qui permet d’introduire une souplesse. On peut dès lors changer le segment réel en mémoire utilisé, quand on veut, puisque l’indice de descripteur, lui, ne changera pas. On peut aussi décider que ce segment utilisé ne se trouvera plus en mémoire, mais sur un disque dur par exemple( il faut alors, bien entendu, dire où il se trouve sur le disque). C’est donc une autre forme de souplesse introduite, qui permet d’avoir le système de mémoire virtuelle.

Initialement( en mode réel), la segmentation avait pour raison d’être initiale de pallier au fait qu’on ne disposait que de registres 16 bits pour adresser. L’adresse était alors découpée, en quelques sortes, en deux registres 16 bits. En mode protégé 32 bits, cette raison d’être de la segmentation reste vraie. La segmentation permet encore de pallier à la petitesse des registres(32 bits ici), et permet de découper l’adresse en un registre 16 bits couplé à un registre 32 bits.

IV.a) Remarque sur la mémoire virtuelle

Le principe premier de la mémoire virtuelle est de ne plus laisser en mémoire les programmes et les données qui ne sont pas utilisés pour le moment, et de les sauvegarder sur le disque provisoirement jusqu’à ce qu’ils soient demandés à nouveau. Comment savoir qu’un programme n’est pas utilisé, c’est une autre question. Mais le principe est bon : on retire de la mémoire ce qui n’est pas utilisé pour le moment. Car ces choses inutilisées occupent de la mémoire inutilement. Et la mémoire étant toujours limitée sur les ordinateurs, on peut les retirer, puisque ces choses ne servent pas. C’est évident. On gagne alors de la mémoire !
On appelle fichier de swap ou fichier d’échange, le fichier sur le disque dur qui contient les informations qui étaient en mémoire. Pourquoi échange ? Car ce fichier sert à faire des échanges RAM -> disque dur et aussi disque dur->RAM.

IV.b) Retour sur le mode protégé

Revenons sur le schéma du descripteur de segment ci-dessus, dans le chapitre sur le mode protégé 16 bits. Nous pouvons maintenant expliquer les deux octets réservés pour le 386( les deux premiers).
Le premier octet à gauche contient les bits 24 à 31 de l’adresse de segment(appelée aussi adresse de base). La mémoire adressable est de 2 puissance 32 = 4Go, puisque l’adresse de segment forme l’adresse de base(théoriquement, selon moi, 4Go + 1 Mo taille offset).
Un segment peut faire jusqu’à 1 Mo. D’ailleurs la longueur du segment, dans le descripteur de segment, est comprise sur 16+4 = 20 bits.
Le déplacement(offset) devient sur 32 bits(n’oubliez pas les registres étendus), donc théoriquement la taille d’un segment peut aller jusqu’à 4 Go. Ceci peut paraître paradoxal car la longueur du segment, dans le descripteur de fichier, est sur 20 bits, donc est de 1 Mo maximum. Il y a une explication à cela ! Il y a un bit, dans le descripteur de segment, qui est le bit 7 de l’octet de droite des 2 octets réservés pour le 386, qui est appelé bit de Granularite. Il sert à indiquer comment interpréter la taille du segment indiquée. Si le bit est 0, la taille est en octet, et peut donc aller de 1 octet à 1Mo. Si le bit est à 1, la taille peut aller de 4 Ko à 4Go, par incréments de 4Ko.
Le bit 2 est le bit TI( Table Indicator), il sert à indiquer de quel table de descripteur de segment on parle( eh oui, il peut y en avoir plus d’une table de descripteurs de segments). Si le bit est à 0, il s’agit de la GDT( Global Descriptor Table). Si le bit est à 1, il s’agit d’une des optionnelles LDT(Local Descriptor Table), et plus précisément de la LDT courante.
Les bits 1 et 0 sont le niveau de privilège du sélecteur( varie de 0 à 3). La plupart des systèmes d’exploitation modernes ne se servent que de deux de ces niveaux : le plus libre, le niveau 0, appelé mode noyau. Et le niveau 3, le plus restrictif, appelé aussi mode utilisateur. Il peut paraître étonnant que ce soit le programmeur qui donne le niveau de privilège, mais je n’ai pas pour l’instant de réponse à cette question, à moins que ces bits soit positionnés automatiquement en mode utilisateur, par le système d’exploitation par exemple, lors des demandes d’accès à la mémoire.

IV.c) Mécanisme de pagination

Depuis le mode protégé 32 bits( c'est-à-dire depuis le 386), un mécanisme de pagination a été introduit. Il s’agit d’un mécanisme de plus bas niveau que le mécanisme de segmentation, et qui est transparent. La mémoire est alors découpée en pages de 4Ko chacune. 4Go de mémoire représentent alors 1024*1024 pages. On peut décrire des informations sur chacune de ces pages, en créant un système de tables, sur le même principe que les tables de descripteur de segment. On utilise une table appelée répertoire de pages( page directory), contenant 1024 entrées. Chacune de ces entrées est un pointeur vers une table de pages( page table), qui elle contient les informations sur les pages( une table de pages a 1024 entrées, et concerne donc 1024 pages). On retrouve donc bien 1024 tables de pages * 1024 pages = 1024*1024*4Ko = 4Go. Pour retrouver les informations sur une page, il suffit donc de trouver, dans le répertoire de pages, le pointeur sur la bonne table de pages. Puis, dans la table de pages, il suffit de lire l’entrée correcte (correspondant à la page cherchée).

Le mécanisme de pagination n’a pas les mêmes objectifs que le mécanisme de segmentation. Il sert à la gestion de la mémoire virtuelle, et aux allers-retours vers le fichier de swap. Alors que le mécanisme de segmentation, comme indiquée ci-dessus, a pour rôle notamment de diviser une adresse pour s’adapter à la petite taille des registres. Mais aussi il permet d’avoir le mécanisme des descripteurs de segments, qui permet d’avoir des adresses de segments qui peuvent changer, tout en gardant le même sélecteur de segment. La pagination est donc de plus bas niveau, et est presque du même niveau que la mémoire elle-même.

Les segments sont donc divisés en pages de 4Ko. Et la mémoire virtuelle se sert des pages au lieu des segments, car cela permet de ne pas être obligé d’avoir l’intégralité du segment en mémoire, comme en mode protégé 16 bits. Le système des pages permet de ne charger en mémoire que certains morceaux du segment, et étant donné que les segments peuvent être très grands en mode protégé 32 bits, cela est très important. De plus, seules certaines parties du segment sont sauvegardées sur disque dans le cadre de la mémoire virtuelle, et pas obligatoirement la totalité( donc le fichier de swap sera moins grand, et les durées de sauvegarde et de chargement également).

La pagination est donc un mécanisme transparent destiné à assouplir le système des segments, et à faciliter l’implémentation du système de mémoire virtuelle. Une page, c’est comme une page d’un livre. Un livre est divisé lui aussi en pages ! Ainsi, on n’est pas obligé d’avoir tout le livre en mémoire(tout le segment), si on est intéressé uniquement par deux phrases d’une page, on n’aura alors que la page en question du livre. C’est sur ce principe qu’est basée la pagination.
On n’expliquera pas la pagination plus en détail pour le moment, mais ce que nous en savons est suffisant, il n’est pas indispensable de comprendre la totalité du mécanisme de pagination, connaître le principe général est suffisant.

V) Les interruptions

Quand une interruption survient, le microprocesseur arrête l’exécution du programme courant. Puis le microprocesseur sauvegarde le contexte dans lequel il était( c’est-à-dire des données indispensables pour rétablir le microprocesseur ensuite dans les mêmes conditions qu’on était au moment de l’interruption). Et enfin, le microprocesseur exécute une routine de traitement de l’interruption. Cette routine est appelée routine d’interruption( ou encore handler d’interruption). Quand la routine d’interruption a terminé de s’exécuter, le microprocesseur reprend l’exécution du programme, à l’endroit où il s’était arrêté, et en restaurant le contexte précédemment sauvegardé.

V.a) Plusieurs types d’interruptions

Il y a plusieurs types d’interruptions : les interruptions matérielles, les interruptions logicielles, et les exceptions.
On peut ajouter qu’on appelle interruption externe, les interruptions qui proviennent de l’extérieur du processeur. Les périphériques génèrent ces interruptions( par exemple le clavier). Alors que les interruptions internes sont déclenchées par le processeur, par exemple après une instruction INT.

V.a.1) Les interruptions matérielles

Les interruptions matérielles sont déclenchées par un composant électronique de l’unité centrale. Un certain évènement a lieu, et cela provoque le déclenchement d’une interruption matérielle par un composant de l’unité centrale. Cela peut être, par exemple, un appui sur une touche du clavier.
Plusieurs interruptions matérielles peuvent avoir lieu en même temps, c’est pour cela qu’un circuit électronique appelé contrôleur d’interruption ou PIC(Programmable Interrupt Controler) est présent. PIC = contrôleur d’interruption programmable, en français. Le contrôleur d’interruption reçoit donc tous les signaux d’interruption des composants. Puis, suivant l’origine du signal d’interruption, il attribut une priorité, et envoie au microprocesseur( par l’intermédiaire du bus de données) le numéro de la routine d’interruption( c'est-à-dire le numéro de l’interruption) dont la priorité est la plus forte(c’est logique).

Une demande d’interruption est appelée aussi IRQ(Interrupt Request).
Le 8088 utilisait comme PIC un composant électronique numérique qui était le I8259.
Le Intel 8259 du 8088 pouvait traiter jusqu’à 8 demandes d’interruptions au même moment, et disposait d’ailleurs de 8 entrées, appelées IRQ(0) à IRQ(7).

Les 286, 386 et 486 utilisaient deux 8259 chaînés, et disposaient donc de 16 interruptions possibles. Etendre le nombre d’interruptions simultanées possibles est important car cela étend et ouvre les capacités de l’ordinateur.Ici, chaque contrôleur peut gérer 8 périphériques différents. En effet, une entrée est suffisante pour gérer un périphérique entier, car tout ce dont le périphérique a besoin, c’est uniquement de signaler, de déclencher une interruption. Les données à propos de cette interruption ne sont pas envoyées de cette façon, le problème est juste de signaler une interruption.

A partir du pentium 1, le contrôleur d’interruption est intégré dans le chipset( attention, chipset ne signifie pas microprocesseur), ce n’est plus un circuit spécialisé.

V.a.1.a) Définition du chipset :

Le chipset est apparu avec le pentium 1, et gère l’ensemble des communications entre le processeur et les composants. C’est un des composants électroniques essentiel de la carte mère. Le chipset évite d’avoir des composants spécialisés pour chaque fonction, et réduit donc le nombre de circuits implantés sur la carte mère.

V.a.2) Les interruptions logicielles

Une interruption logicielle est déclenchée par un programme en cours d’exécution dans le processeur. Cette interruption provoque alors l’interruption( n’oublions pas que « interruption » veut dire « interrompre ») du programme en cours d’exécution, et l’exécution de la routine d’interruption dont le numéro a été demandé. La routine d’interruption est une routine du DOS ou du BIOS.
Pour déclencher une telle interruption logicielle, il existe l’instruction INT suivie du numéro de l’interruption souhaitée. INT vient de « INTerrupt » bien sûr.
Exemple : INT 21H : appel du DOS
INT 14H : appel des fonctions de communication du BIOS

V.a.3) Les exceptions

Une exception est une interruption déclenchée par une erreur lors de l’exécution du programme ( et qui n’est d’ailleurs pas forcément imputable au programme lui-même). Le terme exception a ici le même sens que les exceptions en Java par exemple, à ceci près que ce n’est pas toujours le programme lui-même qui est cause de cette exception. Mais peu importe, le problème a eu lieu alors qu’on essayait d’exécuter le programme, même s’il n’est pas directement responsable. Pourquoi « exception », car ce qui est caractéristique ici, est le caractère exceptionnel de l’évènement( eh oui, on nomme un phénomène d’après ce qui peut le mieux le caractériser, mais on aurait été libre de l’appeler « carotte » au lieu de « exception »). Il y a trois types d’exceptions : les fautes, les trappes, et les arrêts.

Les exceptions sont bien les seules interruptions qui sont imprévues( les interruptions matérielles tel un appui sur une touche clavier, sont tout de même prévues, même si elles n’ont pas lieu systématiquement). Les exceptions portent donc bien leur nom d’exception.

V.a.3.a) Les arrêts

Un arrêt est une des classes d’exception. Le microprocesseur déclenche une exception de type arrêt lorsqu’une erreur grave se produit. Cela peut être une panne matérielle, ou alors une valeur incohérente dans les tables systèmes. Le programme qui s’exécutait ne pourra plus être continué, dans ce type d’exception.

V.a.3.b) Les trappes et les fautes

Nous ne définirons pas pour l’instant les trappes et les fautes.

V.a.4) Remarque sur les interruptions :

Question : pourquoi avoir choisi la notion d’interruption pour définir ce phénomène dont on parle ?
Cette notion d’interruption du programme en cours d’exécution est bien caractéristique de ce qu’on veut, car notre cas ( quel que soit le type d’interruption, matérielle, logicielle, etc) est bien un souhait d’arrêter le programme en cours, et de se voir exécuter une routine de traitement( appropriée bien sûr).
Par ailleurs, ayez conscience(même si cela peut paraître évident) que « Interruption » signifie « Interrompre ». Le nom « interruption » ne paraît pas le mieux approprié pour les interruptions logicielles, étant donné que c’est juste un appel de routine, mais il convient tout de même, car il y a quand même interruption, et il y a aussi sauvegarde du contexte d’origine après.

Les interruptions matérielles sont asynchrones, c'est-à-dire qu’elles interrompent de façon imprévisible le déroulement du programme courant.
Les interruptions logicielles sont synchrones : c’est le programme lui-même qui les déclenche, elles sont donc prévues par le programme lui-même. D’ailleurs ce n’est pas vraiment, par conséquent, une interruption( car une interruption qui est prévue n’est pas vraiment une interruption !), c’est plus un appel de routine( du DOS ou du BIOS).

V.b) La table des vecteurs d’interruption

Un vecteur d’interruption est un emplacement mémoire contenant l’adresse de début d’une routine d’interruption.
La table des vecteurs d’interruption est, comme son nom l’indique, une table contenant l’ensemble des vecteurs d’interruption, l’un à la suite de l’autre. Cette table commence en mémoire basse, et plus précisément au tout début de la mémoire RAM, à l’adresse 0h. On peut noter qu’un intérêt de faire commencer cette table au début de la mémoire est qu’il n’est pas nécessaire de connaître son emplacement pour y accéder. Donc il est très facile d’accéder à n’importe quelle routine d’interruption, et donc les interruptions sont très facilement traitables. Et ceci est bien pratique, bien entendu, pour quelque chose d’aussi primordial et vital que les interruptions.

Le 8086 possède une table ayant 256 entrées, il a donc 256 interruptions possibles. Attention, ne confondez pas avec le nombre d’interruptions matérielles: ici il y a toutes les interruptions, y compris les interruptions logicielles. Chaque interruption est identifiée par un numéro entre 00h et FFh( entre 0 et 255). Les adresses de début des routines d’interruption sont des adresses réelles, c’est un couple segment:offset, donc deux fois 16 bits, soit 4 octets. La table fait donc 4*256=1024 octets= 1 Ko.
Remarque : les adresses sont notées à « l’envers », c’est d’abord l’offset, puis le segment( c’est un choix de codage). Et à l’intérieur même des 16 bits de l’offset et du segment, c’est aussi codé à « l’envers » : d’abord l’octet de poids faible, puis l’octet de poids fort.
Les adresses des routines des interruptions matérielles sont stockées en début de table, les interruptions logicielles sont après.

Le code des routines d’interruptions est stocké dans la ROM du BIOS, ou bien dans la ROM du périphérique, ou alors dans le driver donné sur CD-ROM avec le périphérique. C’est du code classique, de l’assembleur, exécutable par le microprocesseur, comme n’importe quel code, bien entendu.

La table des vecteurs d’interruption est initialisée au démarrage de l’ordinateur( de toutes façons, comme elle se trouve en RAM, on se doute qu’elle est volatile !).

N° Adresse Fonction

00 000-003 CPU : Division par zéro

01 004-007 CPU : Pas à pas

02 008-00B CPU:NMI(défaut circuit RAM)

03 00C-00F CPU : Break point atteint

04 010-013 CPU : Débordement numérique

05 014-017 Copie d'écran

06 018-01B Instruction inconnue (que 80286)

07 01C-01F Réservé

08 020-023 IRQ0: Timer

09 024-027 IRQ1: Clavier

0A 028-02B IRQ2: 2nd 8259 (AT uniquement)

0B 02C-02F IRQ3: Interface sérielle 2

0C 030-033 IRQ4: Interface sérielle 1

0D 034-037 IRQ5: Disque dur

0E 038-03B IRQ6: Disquette

0F 03C-03F IRQ7: Imprimante

10 040-043 BIOS: Fonction vidéo

11 044-047 BIOS: Déterminer configuration

12 048-04B BIOS: Déterminer la taille mémoire de la RAM

13 04C-04F BIOS: Fonctions disquettes/disque dur

14 050-053 BIOS: Accès à l'interface sérielle

15 054-057 BIOS: Fonctions cassettes ou étendues

16 058-05B BIOS: Test du clavier

17 05C-05F BIOS: Accès à l'imprimante parallèle

18 060-063 Appel du BASIC en ROM

19 064-067 BIOS: Lancer système (ALT CTRL DEL)

1A 068-06B BIOS: Lire date et heure

1B 06C-06F Touche Break actionnée

1C 070-073 Appelé après tout INT 08

1D 074-077 Adresse de la table des paramètres vidéo

1E 078-07B Adresse de la table des paramètres disquette

1F 07C-07F Adresse des modèles bits de caract.

20 080-083 DOS: Fin du programme.

21 084-087 DOS: Appeler fonctions DOS

22 088-08B Adresse routine DOS fin prg

23 08C-08F Adresse routine CTRL BREAK

24 090-093 Adresse routine erreur DOS

25 094-097 DOS :Lire disquette/disque dur

26 098-09B DOS :Ecrire disquette/disque dur

27 09C-09F DOS: Fin prg. Laisser résident

28- 0A0- Réservé différentes fonctions

3F -0FF

40 100-103 BIOS: Fonctions disquettes

41 104-107 Adresse table disque dur 1

42- 108- Réservés

45 -117

46 118-11B Adresse table disque dur 2

47- 11C- Libres

-49 -127 Libres

4A 128-12B Heure alarme atteinte (AT)

4B- 12C- Libres

-67 -19F

68- 1A0- Inutilisés

-6F -1BF Inutilisés

70 1C0-1C3 IRQ8: Horloge temps réel (AT)

71 1C4-1C7 IRQ9: (AT)

72 1C8-1CB IRQ10: (AT)

73 1CC-1CF IRQ12: (AT)

74 1D0-1D3 IRQ13: 80287 NMI (AT)

75 1D4-1D7 IRQ14: Disque dur (AT)

76 1D8-1DB IRQ15: (AT)

77 1DC-1DF Inutilisés

78- 1E0- Utilisés par l'interpréteur BASIC

-7F -1FF Utilisés par l'interpréteur BASIC

80- 200- Inutilisés

-F0 -3C3 Inutilisés

F1- 3C4- Inutilisés

-FF -3CF Inutilisés

Table des vecteurs d’interruption, venant du site de l’IUT de Caen.

Le DOS fournit une API(Application Programming Interface) qui est disponible par les interruptions logicielles( l’instruction INT). Unix, et les systèmes d’exploitations plus récents, ont une API basée sur le langage C. Par exemple, le système unix nous permet d’effectuer des appels systèmes au travers de fonctions C. L’interface pour programmer est donc différente du type d’interface de DOS.

VI) Le langage machine

Le réel langage compris par le processeur s’appelle le langage machine. Il porte bien son nom.
C’est donc un langage à part entière, constitué uniquement d’une suite de nombre, et plus concrètement d’une suite d’octets.
Chaque instruction correspond à un nombre particulier, qui est appelé opcode( operation code, ou code d’opération en français). Certaines instructions sont constituées aussi de données comme des adresses par exemple, dont l’instruction a besoin dans sa syntaxe. L’opcode est placée toujours en premier.
Le langage machine est quasiment impossible à programmer directement pour un être humain, car trop peu lisible.

VII) Le langage assembleur( ou assembleur, par abus de langage)

Le langage assembleur, c’est tout simplement du langage machine écrit sous une forme lisible pour un humain. Les nombres représentant les instructions du langage machine sont écrits sous forme de mnémoniques( appelés ainsi car faciles à retenir). « add » est un exemple de mnémonique.
Le programme assembleur ou assembleur permet de traduire les mnémoniques du langage assembleur en langage machine. Ce n’est pas une compilation, c’est une traduction directe.
Aux débuts de l’informatique, ce travail de traduction en langage machine était fait par le programmeur, à la main.

La syntaxe de l’assembleur est la suivante :

Mnémonique operande(s)

Remarque : les adresses sont toujours un offset relatif au début d’un segment.

VIII) L’assembleur NASM

NASM est un assembleur gratuit et open-source. Vous pouvez le télécharger facilement à http://nasm.sourceforge.net/ , puis « Download ».

Si vous téléchargez la version binaire( « Win32 binaries »), elle se présente sous la forme d’un zip( par exemple nasm-2.02-win32.zip ). En décompressant ce zip, un répertoire nasm-2.02 est créé. Dedans se trouvent notamment les deux exécutables nasm.exe et ndisasm.exe( le programme assembleur et le programme désassembleur), un fichier texte COPYING sur la license GNU, et un répertoire rdoff contenant uniquement des exécutables.

Il est possible aussi, pour les personnes intéressées, de télécharger le code source( qui est en langage C) de nasm,et de le compiler nous-même, avec un makefile pour chaque plateforme.

IX) Les directives de NASM

NASM, comme le langage C avec le #, dispose de directives, qui commencent par %.
NASM fait passer d’abord un préprocesseur, comme en C. Les directives sont analysées à ce moment-là, et ne sont, bien entendu, pas traduites en langage machine. Ce sont des directives de « compilation », qui servent à donner des indications à NASM sur la façon d’assembler( bref, ce sont des métadonnées).

IX.a) La directive EQU

Equ vient de « equal » bien sûr.
Syntaxe : label equ valeur
Exemple : affecter_bl equ mov bl,
Et, dans le code du programme, on pourra écrire :
Affecter_bl 242
Ceci équivaut à mov bl,242

On remarque que le label est remplacé par la valeur telle qu’elle.

Autre exemple :
MaChaine equ « Bonjour »

IX.b) La directive define

%define vingt 20

Cette directive équivaut au #define du langage C. Elle est plus puissante que le equ.
Nous n’en verrons pas plus pour le moment à son sujet.

IX.c) Les directives de données

IX.c.1) Directive res + lettre b, w, d, q, t suivant l’unité de taille

resb : octet( b comme byte)
resw : mot(w comme word) = 2 octets
resd : double mot( d comme double)= 4 octets
resq : quadruple mot = 8 octets
rest : dix octets = 10 octets

res comme « reserve ».

Cette directive permet de laisser de la place pour un certain nombre d’octets, sans initialisation de ces octets

Exemple :
MonLabel resb 15

Ici, on réserve de la place pour 15 octets, sans initialiser ces octets.

MonLabel est un label, qui permet après de faire référence à l’adresse de début de ces octets.

IX.c.2) Directive equ + lettre b, w, d, q, t suivant l’unité de taille( même lettre que pour la directive res)

Cette directive laisse de la place pour un certain nombre d’octets, mais en plus initialise ces octets.
Exemple :
MonLabel db 10
On réserve un octet, et on l’initialise avec la valeur 10
Autre exemple :
MonLabel db 1, 2, 3, 4, 5
On réserve 5 octets, on les initialise respectivement avec les valeurs 1, 2, 3, 4 et 5.

IX.c.3) Les « » et ‘’

Les guillemets simples et doubles ont la même signification.
MonMessage db « Bonjour », 0
MonMessagePareil db « B », « o », « n », « j », « o », « u », « r », 0
MonMessagePareil2 db ‘ Bonjour ‘, 0
MonMessagePareil3 db ‘B’, ‘o’, ‘n’, ‘j’, ‘o’, ‘u’, ‘r’, 0

Voici quatre façons de définir la même chaîne de caractères.

IX.c.4) La directive times

Exemple : MonLabel times 20 db ‘a’
Permet de répéter n fois ce qu’on a écrit juste après, ici le db ‘a’.
Ici, c’est comme si on avait écrit 20 lignes de db ‘a’.

IX.c.5) Les labels

Les labels sont donc l’adresse( sur 32 bits en mode protégé 32 bits), plus précisément l’offset de la donnée.
Pour obtenir la valeur à cette adresse, on utilise [monLabel], qui est l’équivalent à l’étoile en c.

IX.c.6) Inclure un fichier

%include « monFichier.asm »
Permet d’inclure un fichier, équivalent au #include du langage C. C’est comme si le programme source avait été rallongé avec le contenu du fichier inclus : le préprocesseur remplace le fichier par son contenu.

X) Usage du langage C combiné à l’assembleur, avec DJGPP

DJGPP est un compilateur C/C++ gratuit, c’est le gcc de Linux pour Windows. Nous l’utiliserons comme compilateur C.

X.A) Installation de DJGPP

Page de téléchargement : http://www.delorie.com/djgpp/zip-picker.html

Un certain nombre de zip sont à télécharger(selon ce que vous voulez), par exemple ceux-ci, qui nous suffisent( 8 zips ici, et un exécutable) :

unzip32.exe to unzip the zip files 95 kb

v2/copying.dj DJGPP Copyright info 3 kb
v2/djdev203.zip DJGPP Basic Development Kit 1.5 mb
v2/faq230b.zip Frequently Asked Questions 664 kb
v2/readme.1st Installation instructions 22 kb

v2apps/rhid15ab.zip RHIDE 6.0 mb

v2gnu/bnu217b.zip Basic assembler, linker 3.9 mb
v2gnu/gcc423b.zip Basic GCC compiler 4.3 mb
v2gnu/gdb611b.zip GNU debugger 1.5 mb
v2gnu/mak3791b.zip Make (processes makefiles) 267 kb
v2gnu/txi411b.zip Info file viewer 888 kb

Puis vous devez dézipper( ici les 8 zip) en utilisant le programme unzip32.exe, dans un répertoire djgpp.

C:\> mkdir djgpp
C:\> cd djgpp
C:\DJGPP> unzip32 d:\tmp\djdev203.zip
C:\DJGPP> unzip32 d:\tmp\faq230b.zip
C:\DJGPP> unzip32 d:\tmp\rhid15ab.zip
C:\DJGPP> unzip32 d:\tmp\bnu217b.zip
C:\DJGPP> unzip32 d:\tmp\gcc423b.zip
C:\DJGPP> unzip32 d:\tmp\gdb611b.zip
C:\DJGPP> unzip32 d:\tmp\mak3791b.zip
C:\DJGPP> unzip32 d:\tmp\txi411b.zip

Enfin, vous devez rajouter dans le path de Windows( clic droit sur poste de travail -> Propriétés -> Onglet « Avancé » -> Variables d’environnement) « ;c:\DJGPP\bin » à la variable PATH. Ajouter aussi une variable système DJGPP qui vaut « C:\DJGPP\DJGPP.env », c'est-à-dire qui contient le chemin du fichier .env de DJGPP.

X.B) Compilation avec NASM

NASM –h pour l’aide de NASM.
DJGPP se sert du format COFF pour les fichiers objet( Common Object File Format).
Avec NASM, si vous souhaitez que le résultat soit un fichier à ce format COFF, il suffit de rajouter l’option –f COFF ( f comme format). L’extension du fichier sera « .o ».

X.B.1) Les fichiers objets

Un fichier objet est un fichier contenant du langage machine, et aussi des informations nécessaires à l’édition des liens, comme le nom des fonctions( les étiquettes), avec leur point d’entrée. Si le fichier objet est un fichier qui a été produit pendant la compilation d’un programme C, il contient aussi des informations utiles pour le déboguage, c’est-à-dire des informations permettant de faire le lien entre le source C et le programme assembleur contenu dans le fichier objet ( programme qui est la traduction de ce source C).
Un fichier objet n’est pas exécutable directement, il n’a pas de point d’entrée. Il contient uniquement un ensemble de fonctions qu’on pourra utiliser en faisant une édition des liens.
L’édition des liens est la création d’un exécutable à partir des fichiers objets.

Pourquoi ce besoin de fichiers objets ? Ils sont utiles notamment pour se faire des librairies réutilisables par plusieurs programmes. Les adresses de début des fonctions ne seront pas précisées, ni l’adresse de début du segment de données, etc. Ainsi, le contenu des fichiers objets est en « relatif ». Si un programme exécutable est intéressé par une routine d’un fichier objet, il ne restera juste qu’à faire l’édition des liens, c'est-à-dire à insérer le code de la routine dans le bon segment de code, et au bon offset, de manière à s’harmoniser avec l’exécutable.
Pour le code, on se sert du segment de code. On place juste avant le début du code, la directive « segment .text », sans préciser la valeur du segment. Idem pour les données, on se sert du segment de données, on précise juste « segment .data », juste avant le début de vos déclarations de données initialisées. Les données non initialisées sont à mettre dans le segment BSS( directive « segment .bss »).

X.B.2) Obtenir un fichier listing de la compilation, avec l’option –l de nasm

Avec nasm, vous pouvez obtenir facilement un fichier listing qui vous permet de comprendre comment nasm a compilé votre source assembleur. Il suffit de rajouter l’option « -l nom_fichier_listing » lors de la compilation :
nasm –f coff –l monFichierListing ficSource.asm
Le fichier listing est un fichier texte qui contient :
. première colonne : le numéro de la ligne du code source
. deuxième colonne : l’offset( sur 32 bits) dans le segment en question
. troisième colonne : la traduction en langage machine, en hexadécimal
. quatrième colonne : le code source de la ligne courante du programme source assembleur

Grâce à ce listing, vous pourrez aisément apprendre comment sont codés vos sources assembleur, c’est très instructif et concret.
Les déplacements de chaque segment( segment de données, segment .bss, segment de code …) démarrent à 0 quand nous sommes dans un fichier objet, car n’oublions pas que les déplacements dans ce cas sont en « relatif » pour ainsi dire, c'est-à-dire qu’ils démarrent de 0. Lors de l’édition de liens, nasm rassemblera tous les fichiers objets et le fichier contenant le point d’entrée, pour en faire un seul fichier. Donc tout sera intégré dans le même segment de code, le même segment de donnée, etc. Les déplacements des fichiers objets seront donc recalculés par l’éditeur de liens. C’est cela le travail de base de l’édition de liens.

Exemple de fichier listing, pour la compilation du fichier objet affAinterfaceC.asm, que nous voyons ici: affAinterfC-V1-0-31032008.txt

Télécharger ce fichier listing: affAinterfC-V1-0-31032008.zip

On apprend au passage, pour l’anecdote, que le code machine de « ret » est « C3h »( sur un octet), que celui de « pusha » est 60h, et que celui de « popa » est 61h. C’est très concret, ça fait moins peur !

X.B.3) Les autres formats de fichiers objets

Nous avons appris le format COFF(Common Object File Format), qui est celui utilisé par DJGPP, et dont les fichiers objets sont d’extension « .o ».

Le compilateur C de Linux, qui est gcc, utilise le format ELF. ELF = Executable and Linkable Format. Les noms de vos routines(dans votre .asm) ne doivent pas être précédés de “_”. L’option de nasm à utiliser est « –f elf ». Le fichier objet produit est d’extension « .o » également.

Le compilateur Microsoft C/C++ utilise le format Win32. L’option de nasm à utiliser est « -f win32 ». Le fichier objet produit est d’extension « .obj ».

La première phase, si vous voulez utiliser un programme assembleur avec du C, est donc d’assembler votre programme assembleur pour en faire un fichier objet. Pour cela, utilisez nasm –f formatObjet monProg.asm .

nasm produira alors votre fichier .o ou .obj .

Pourquoi plusieurs formats objet ? On rappelle que le fichier objet contient le langage machine plus différentes informations permettant de faire un lien entre le C et l’assembleur( notamment le nom des routines, qui ne figurerait pas sinon. Mais aussi des informations de déboguage, pour les fichiers objets produits à partir d’un programme C). Ces informations en plus du code machine, il y a plusieurs façons de les inclure : c’est pour cette raison qu’une norme est nécessaire. Et c’est pour cette raison aussi qu’il y a différentes normes, car différentes façons d’organiser les informations sont possibles.

Remarque : Un fichier objet est donc une notion qui est en général rattachée à un langage de plus haut niveau( le C ici). Il permet en général de faire l’interfaçage entre plusieurs programmes C, ou encore de faire l’interface entre de l’assembleur et du C. On pourrait imaginer un interfaçage entre plusieurs programmes assembleur( permettant l’usage de librairies assembleurs), mais je ne sais pas si cela se pratique.

X.B.4) La convention d’appel C

On appelle « convention d’appel C », toutes les règles qu’un compilateur C utilise quand la compilation a lieu. Le « _ », dans le programme assembleur, avant le nom de vos fonctions, est un exemple, par exemple « _maFonction »( ce « _ » n’est pas présent avec gcc). Ce « _ » sert à voir immédiatement qu’il s’agit d’un fonction utilisée par le langage C.

X.C) Un programme C qui contient la fonction main du C

Une façon de procéder intéressante, et d’écrire le point d’entrée en langage C. Et d’appeler nos fonctions assembleurs( ou même notre unique fonction assembleur qui constitue tout le programme) depuis le programme C.

On peut écrire un programme en langage C qui est :

Created with colorer-take5 library. Type 'c'

int main( )
{
int retour ;
retour = maFonctionAss( ) ; /* On appelle la fonction assembleur */
return retour ;
}

On appelle la fonction assembleur maFonctionAss comme on appellerait n’importe quelle fonction C ! maFonctionAss peut contenir la totalité du programme qu’on veut voir exécuter, le C ne servant ici qu’à lancer le programme.

Exemple de programme assembleur correspondant :
Created with colorer-take5 library. Type 'asm'

;Début des données
segment .data
mess1: db "Hello world", 0 ; le 0 de fin de chaîne( convention)

;Début des données non initialisées
segment .bss
resb 5 ;exemple: on réserve 5 octets

;Début du code
segment .text
global _maFonctionAss ; par défaut n'aurait pas été utilisable à l'extérieur!
_maFonctionAss:
;(...)
ret

Dans le programme d’exemple ci-dessous, on remarque que la déclaration d’un label est juste une ligne constituée du nom du label, suivi de « : ». Pas de marqueur de fin de ligne en assembleur( pas comme en C, où il y a le « ; »), on ne peut donc pas utiliser naturellement plusieurs lignes pour écrire notre instruction.

Le « global _maFonctionAss » est une directive précisant la portée de la fonction : on indique que la fonction est accessible de l’extérieur du programme assembleur, c'est-à-dire depuis un programme C, ou depuis un autre programme .asm. Car par défaut, la portée est interne. C’est une directive spécifique aux fichiers objets.

X.D) Intérêt d’appeler depuis le C

Appeler notre programme assembleur depuis la fonction main d’un programme C a plusieurs avantages. Tout d’abord, cela permet de faire effectuer à notre programme assembleur les initialisations nécessaires par rapport au mode protégé 32 bits, c'est-à-dire d’initialiser correctement les valeurs des segments, etc : le C se charge de cela à notre place, on a juste nos fonctions à écrire.
Ensuite, cela permet de pouvoir appeler dans notre programme assembleur, des fonctions du C, comme par exemple les fonctions d’entrées-sorties des librairies du C ( printf, scanf, etc). Ceci est un grand apport en programmation assembleur, car permet de programmer en assembleur, sans être obligé de tout savoir programmer en assembleur( et sans être obligé de tout devoir réécrire).

X.E) Squelette de programme assembleur interfacé avec le langage C

Created with colorer-take5 library. Type 'asm'

;Début des données
segment .data
;données initialisées

;Début des données non initialisées
segment .bss
;données non initialisées

;Début du code
segment .text
global _maFonctionAss ; par défaut n'aurait pas été utilisable à l'extérieur!
_maFonctionAss:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;(...)
popa
mov eax, valeur_de_retour_fonction ; on peut mettre 0 si pas de valeur de retour
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

Explications du enter 0,0 et du leave :

enter 0,0 : effectue PUSH EBP puis MOV EBP, ESP

EBP est d’abord sauvegardé. Puis on utilisera EBP comme registre temporaire de pile. On affecte à EBP la valeur de SP, donc la référence du sommet de la pile. Les paramètres éventuels de la fonction C étant empilés, on disposera facilement de EBP afin de récupérer les paramètres, grâce à [EBP+8] par exemple, qui pointe sur le premier paramètre en environnement 32 bits.

leave : effectue MOV ESP, EBP puis POP EBP

EBP n’a pas été modifié au cours de la fonction. Donc on remet à ESP sa valeur initiale par un MOV ESP, EBP. A mes yeux, un intérêt aussi est que cela a permis aussi de ne pas changer la valeur de ESP : ainsi, si on a trop dépilé(ou pas assez) par erreur durant la fonction, ESP pointera sur la valeur initiale( la pile reprendra son état initial).

pusha : empile la valeur de tous les registres( on sauvegarde tous les registres, ainsi on est certain qu’aucun ne sera modifié par notre fonction). En programmation assembleur « manuelle », on peut sauvegarder uniquement la valeur des registres utilisés dans la fonction, mais on est jamais certain de ne pas avoir oublié d’avoir sauvegardé un registre qu’on a modifié dans la fonction.

popa : dépile les valeurs de tous les registres empilées avec pusha.

mov eax, code_retour_fonction_C : mettre à la fin, dans le registre eax, la valeur de retour de la fonction C. On peut mettre 0 si la fonction n’a pas de valeur de retour.

X.F) Conclusion interfaçage C/assembleur

On n’a pas vu, pour le moment, toutes les conventions d’appel du C, on ne sait pas encore comment toutes les fonctions écrites en C sont traduites en assembleur. Mais on en sait assez sur les conventions d’appel du C, pour pouvoir écrire nos petites fonctions assembleur, et les interfacer avec le C.

X.F.1)L’interfaçage C/assembleur basé sur les fonctions C

L’interfaçage C/Assembleur repose sur un interfaçage sur les fonctions C. En appelant une fonction dans le programme C, on appellera notre fonction assembleur au lieu d’une fonction C. Donc on exécutera l’assembleur qu’on veut, depuis un programme en C. De même, depuis notre fonction assembleur, on pourra aussi appeler des fonctions C, donc on pourra faire exécuter du C depuis de l’assembleur. L’interfaçage est donc complet. On rappelle qu’en C, tout est fonction, y compris la fonction main qui est une fonction particulière. Donc en C, tout code se trouve dans une fonction(d’ailleurs il faut bien qu’il se trouve quelque part !).
Par conséquent, en faisant reposer l’interfaçage C/assembleur sur les fonctions C, l’interfaçage est complet. L’interfaçage est complet, car on peut appeler, à la place d’une fonction C , une fonction C qui exécute notre assembleur. L’assembleur est mis dans l’unité d’exécution minimale du C, qui est la fonction C. L’interfaçage est complet aussi car on peut appeler, depuis l’assembleur, toute fonction C(donc on peut avoir accès à l’intégralité de notre programme C depuis un programme assembleur, car tout est dans des fonctions, en C).

X.G) Un exemple d’interfaçage avec C

On va écrire un programme qui affiche la lettre « A » à l’écran.

Notre programme assembleur, affA.asm :
Created with colorer-take5 library. Type 'asm'

;Début des données
segment .data
caractA: db 'A'

;Début des données non initialisées
segment .bss
resb 5 ;exemple: on réserve 5 octets

;Début du code
segment .text
global _maFonctionAss ; par défaut n'aurait pas été utilisable à l'extérieur!
_maFonctionAss:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction
mov al, [caractA]
mov ah, 0Eh ;permet de préciser quelle fonction de l'interruption 10h on veut: la
fonction d'affichage d'un caractère correspond à 0Eh dans ah
int 10h ;interruption BIOS pour fonctions vidéos
;fin vrai code de notre fonction
popa
mov eax, 0 ; on peut mettre 0 si pas de valeur de retour
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

Notre programme C de lancement, lancement.c :
Created with colorer-take5 library. Type 'c'

int main()
{
int retour ;
retour = maFonctionAss();
return retour;
}


Compilation :
. compilation de affA.asm :
nasm –f coff affA.asm
Ceci a créé affA.o
gcc –o monExe.exe affA.o lancement.c
Ceci a créé l’exécutable monExe.
Remarque : pas de lancement.o généré, ce qui est logique, car aucun fichier objet ne
voulait être généré à partir de lancement.c.

En exécutant monExe, la lettre A s’affiche à l’écran.

Télécharger les sources de cet exemple: affAinterfC-V1-1-17042008.zip

XI)Interfaçage avec le C : La convention d’appel C

XI.A) La convention d’appel C

XI.A.1) La sauvegarde des registres

Une fonction C est censée ne pas modifier les registres suivant : EBX, ESI, EDI, EBP, CD, DS, SS, ES. Vous devez donc effectuer une sauvegarde de tous les registres en les empilant, et les dépiler à la sortie de votre fonction. Ainsi ils auront la même valeur à la sortie qu’à l’entrée de votre routine assembleur représentant la fonction C.

XI.A.2) Le _ devant les noms de fonctions

On en a déjà parlé. Je rappelle que ce n’est pas vrai pour gcc sous linux.

XI.A.3) Le passage des paramètres

XI.A.3.a) Précisions sur ESP

Le registre ESP pointe sur la dernière valeur de la pile. A chaque fois que la pile est modifiée, le registre ESP est, par conséquent, remis à jour. Quand une valeur est empilée, la valeur de ESP diminue( attention, elle n’augmente pas !). Le fait que la pile « recule » permet, pour accéder aux éléments, de faire [ ESP + qqchose ]( et non pas moins quelque chose).

XI.A.3.b) Le passage des paramètres

. Les paramètres sont empilés, et dans l’ordre inverse( c'est-à-dire le dernier paramètre est empilé en premier, puis l’avant-dernier, etc, jusqu’au premier).

Ceci semble logique, car ainsi, le premier paramètre est plus au sommet de la pile que le deuxième, qui lui-même est plus au sommet de la pile que le troisième, etc…
De plus, pour les fonctions à nombre variable de paramètres, c’est pratique que les premiers soient les plus près du sommet. Car on peut trouver le xième paramètre facilement( par exemple le premier), sinon on serait obligé de prendre en compte le nombre de paramètre pour pouvoir trouver le xième( par exemple le premier). Autre raison( même en cas de nombre fixe de paramètres), le premier paramètre est toujours à la même « adresse », dans toutes les fonctions, sinon cela dépendrait du nombre de paramètres de la fonction.

. Ce passage des paramètres par la pile est plus ouvert que si on avait passé les paramètres par les registres, par exemple on n’est pas limité en nombre de paramètres.

. Le registre EBP est utilisé pour accéder aux paramètres.

Le premier paramètre est en EBP + 8, quand on est en environnement 32 bits. Les paramètres ne sont pas dépilés. Sinon on serait obligé de dépiler plusieurs valeurs pour accéder à un paramètre, puis de réempiler le paramètre et ces valeurs, et tout ceci à chaque fois qu’on veut accéder à un paramètre !

Ne pas utiliser ESP permet notamment de pouvoir utiliser la pile( empiler des valeurs, etc). Car si on se référait à ESP pour retrouver les paramètres( par exemple si on faisait [ESP+8] pour accéder au premier paramètre), à chaque fois qu’on empilerait une valeur, ESP pointant sur le sommet de la pile, il faudrait qu’on recalcule la nouvelle position des paramètres par rapport à ESP, lorsqu’on chercherait à accéder à ces paramètres.

. L’instruction enter 0,0 :
elle sauvegarde EBP, puis effectue l’initialisation de EBP( et en même temps cela sauvegarde ESP, car EBP ne sera jamais modifié, donc on pourra s’en servir pour retrouver le ESP du début). En effet, EBP ne sera jamais modifié, car il va nous servir à avoir une référence pour récupérer les paramètres.

Equivalent de enter 0,0 :
Created with colorer-take5 library. Type 'asm'

PUSH EBP ; on sauvegarde EBP
MOV EBP, ESP ; on initialise EBP avec ESP, et en même temps cela sauvegarde ESP

Remarque : Nous ne détaillerons pas ici les 2 opérandes du enter, laissez les à 0,0.

Autre remarque : on peut se demander pourquoi le enter est avant le pusha : c’est parce que le pusha, en empilant les valeurs des registres, va modifier la valeur de ESP. Il faut donc effectuer le enter avant, car il a besoin de la valeur initiale de ESP.

Remarque : On peut encore se demander pourquoi avoir besoin d’un push EBP(du enter), alors qu’on va faire pusha et popa ? La réponse est que le pusha et popa ne sont pas des obligations : on aurait pu sauvegarder uniquement les registres concernés.

. L’instruction leave :
Elle remet EBP et ESP à leurs valeurs initiales. Ce qui est fait est :
Created with colorer-take5 library. Type 'asm'

MOV ESP, EBP ;EBP contient le ESP du début. On ne l’a pas modifié depuis.
POP EBP ;


. Les intructions “enter 0,0” et “leave” sont bien des instructions assembleurs.

Pour preuve, l’instruction leave est codée sur un octet( C9h). L’instruction enter a pour opcode C8h, plus trois octets qui suivent pour les deux opérandes.

. C’est au programme qui appelle à enlever les paramètres de la pile, après appel de la fonction.

. Dans le cas où la taille d’un paramètre est inférieure à 4 octets( 32 bits), ce paramètre doit être converti en 32 bits.

. Les paramètres qui seront modifiés, c'est-à-dire les paramètres en out et in/out, doivent être passés par référence. C'est-à-dire qu’on empile non pas la valeur du paramètre, mais sa référence.

. Le contenu de la pile :
Les paramètres sont empilés en premier. Car c’est nous qui les avons empilés avant l’appel de la fonction.
Puis l’adresse de retour est empilée( comme lors de tout appel à un sous-programme), c’est le call qui provoque cela, lorsqu’on appelle la fonction.
Puis ensuite EBP sera empilé par le « enter ». Ceci fait, ESP pointe sur EBP( car il pointe toujours sur la dernière valeur de la pile).
A ce moment précis, la valeur de EBP est au sommet de la pile, donc [ESP]. La valeur de l’adresse de retour est juste en-dessous dans notre image théorique de la pile, mais comme la pile marche « à l’envers », la valeur est [ESP+4].
La valeur du premier paramètre est donc [ESP+8], c'est-à-dire [ EBP + 8 ]( car EBP = ESP de ce moment-là). C’est bien le premier paramètre, car n’oublions pas que les paramètres sont empilés dans l’ordre inverse, donc le premier paramètre est empilé en dernier.

XI.A.4) Un exemple d’appel d’une fonction des librairies C : appel de printf

On veut écrire une fonction assembleur affiche_string, à laquelle on passe une string , et qui affiche cette string. Notre fonction assembleur va utiliser la fonction C printf. Ceci revient à appeler « printf(« %s », param )».

On veut appeler la fonction printf depuis l’assembleur, cette fonction figure dans les fichiers objets des librairies du C. L’appel aura deux paramètres, une string contenant le format( « %s »), et une string contenant la string. Tout ceci est similaire à un appel depuis le C : printf( « %s », monMessage) : on voit bien ici que le premier paramètre est une chaîne « %s » détaillant le format, et le deuxième paramètre est l’adresse de la string du message à afficher.

PREMIER FICHIER: helloWorld.asm

Created with colorer-take5 library. Type 'asm'

;helloWorld.asm
;07/04/2008
;Programme d'exemple d'affichage de "Hello World", en utilisant un appel à une
;fonction du C(printf), depuis l'assembleur.
;
;Début des données
segment .data
phraseBonjour: db "Hello World", 0
formatString: db "%s", 0;
;Début des données non initialisées
segment .bss

;Début du code
segment .text
extern _printf ;pour le programme assembleur, on déclare explicitement que _printf existe ailleurs

global _monMain ; par défaut n'aurait pas été utilisable à l'extérieur, donc on doit le mettre!
_monMain:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
pushf
;début vrai code de notre fonction
mov eax, phraseBonjour
call affiche_string
;fin vrai code de notre fonction
popf
popa
mov eax, 0 ; on peut mettre 0 si pas de valeur de retour
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

global afficheString ; global, pour être accessible aux autres fichiers assembleurs
affiche_string:
;Fonction affichant une string.
;Un paramètre, mis dans le registre eax:
;IN, dans eax: l'adresse d'une string contenant le message à afficher
;Cette fonction utilise la fonction printf de la librairie C.
pusha
pushf ;sauvegarde du registre flags
push eax ; on empile le deuxième paramètre de _printf
push dword formatString ;om empile le premier paramètre de _prinf
call _printf
pop ecx ;ne pas oublier de dépiler les deux paramètres, sinon, à la fin de la fonction, il va dépiler un double word
pop ecx ;pour connaitre l'adresse de retour, et il va prendre l'adresse de formatString pour l'adresse de retour!
popf
popa
ret

DEUXIEME FICHIER: lanceur.c

Created with colorer-take5 library. Type 'c'

/* Programme lanceur.c */
/* Auteur: Sabri Koffler */
/* Date: 07/04/2008 */
/* Programme générique de lancement d'un programme assembleur */
/* Par exemple, permet de lancer le programme helloWorld.asm */
/* La fonction principale assembleur est appelé monMain, car c'est l'équivalent d'un main */
/* pour le programme assembleur. */
/* Exemple de compilation : */
/* nasm -f coff helloWorld.asm : on crée le fichier objet de notre programme assembleur d'abord
gcc -o helloWorld.exe helloWorld.o lanceur.c : puis compilation de lanceur.c , et édition des liens, pour donner helloWorld */

int main()
{
int retour ;
retour = monMain();
return retour;
}

TROISIEME FICHIER: helloWorld.bat

Created with colorer-take5 library. Type 'Batch'

nasm -f coff helloWorld.asm
gcc -o helloWorld.exe helloWorld.o lanceur.c



Télécharger les sources de cet exemple: helloWorld-V1-1-17042008.zip

XI.A.5) Un exemple de création d’une fonction C qui est écrite en assembleur

Dans notre exemple, nous créerons une fonction C affCar( char ), en assembleur. La fonction sera une fonction C classique, utilisable depuis le C comme toute fonction.
Cela peut être aussi un exemple de réalisation en assembleur d'une fonction qu'on ne pourrait pas écrire en C( bien sûr si on n'avait pas les librairies du C).
Cela prouve qu'un langage de troisième génération tel que le C, qui permet un interfaçage avec l'assembleur, peut s'en sortir pour des fonctions bas niveaux qu'il ne serait pas capable de faire lui-même. L'interfaçage avec l'assembleur est dans ce cas une ouverture indispensable du L3G.

La programme se sert de tout ce qu’on a déjà vu. L’instruction « lea ebx, [ebp+8]; » mérite d’être expliqué. « lea » est une instruction d’affectation qui signifie « load effective address ». C’est comme un « mov », mais ce qu’on met dans le registre est une adresse( on précise le sens, la sémantique de la valeur). Attention, les crochets peuvent prêter à confusion, il ne s’agit pas de la valeur pointée par EBP+8, mais de EBP+8(les crochets ne devraient pas être présent!). Il est autorisé de faire mov ebx, adresse. Mais l’adresse est prise alors comme n’importe quelle valeur, car il n’y a pas une sémantique d’adresse rattachée forcément. Lea est pratique pour travailler sur des tableaux, car la deuxième opérande peut être sous forme de polynôme( addition et multiplication). On fait par exemple ici EBP+8.

L’affichage du caractère se fait par l’interruption BIOS 10h, fonction 0Eh.

AffCar.h a été écrit sur le modèle de stdio.h du C. On y utilise la macro _EXFUN(N,P), juste pour que dans notre code, chaque fonction externe apparaisse de cette façon. Mais on aurait pu écrire le prototype de affCar directement. En début d’include, on fait un #define AffCar_H, car cette définition AffCar_H, sera utilisé en début d’include par un #ifndef AffCar_H, pour ne pas exécuter l’include deux fois, au cas où l’include serait effectué par plusieurs programmes C.

Télécharger les sources de cet exemple: affCar-V1-1-17042008.zip

. Premier fichier : affCar.asm : «

Created with colorer-take5 library. Type 'asm'

;affCar.asm
;14/04/2008

;Programme d'exemple d'interfaçage avec le C, pour un exemple d'une fonction C écrite en assembleur.
;Le programme est une fonction affCar.
;Cette fonction utilise une interruption BIOS pour afficher le caractère
;(interruption 10h(fonctions vidéos), fonction 0Eh dans ah, caractère dans al)
;
; Cela peut être aussi un exemple de réalisation en assembleur d'une fonction qu'on ; ne pourrait pas écrire en C( bien sûr si on n'avait pas les librairies du C).

;Début des données
segment .data

;Début des données non initialisées
segment .bss

;Début du code
segment .text
global _affCar ; par défaut n'aurait pas été utilisable à l'extérieur!
_affCar:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction
lea ebx, [ebp+8];
mov al, [ebx]
mov ah, 0Eh ;permet de préciser quelle fonction de l'interruption 10h on veut: la fonction d'affichage d'un caractère correspond à 0Eh dans ah
int 10h ;interruption BIOS pour fonctions vidéos
;fin vrai code de notre fonction
popa
mov eax, 0 ; on retourne 0 ( ça s'est bien passé)
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

. Deuxième fichier : affCar-mainC.c :

Created with colorer-take5 library. Type 'c'

/* 14/04/2008
Programme d'exemple d'utilisation
de la fonction externe affCar, crée par affCar.asm
*/
#include "affCar.h"

int main()
{
int retour ;
retour = affCar('f');
return retour;
}

. Troisième fichier : affCar.h :

Created with colorer-take5 library. Type 'c'

/* 14/04/2008 Auteur: Sabri Koffler */
/* Contient la déclaration du prototype de la fonction externe affCar */
/* Utilisé par affCar.c notamment */
/* Inspiré de stdio.h */
#ifndef AffCar_H /* Seulement si cet include n'a pas déjà été fait */

#define AffCar_H /* Pour savoir que cet include a déjà été fait */

#ifndef _EXFUN /* si pas défini ailleurs( c'est tout à fait possible) */
#define _EXFUN(N,P) N P
#endif

int _EXFUN( affCar, (char) ); /* Affiche le caractère passé en paramètre. Retourne 0 si pas de pb */

#endif /* AffCar_H */

. Quatrième fichier : affCar.bat

Created with colorer-take5 library. Type 'Batch'

nasm -f coff affCar.asm
gcc -o affCar.exe affCar.o affCar-mainC.c

XI.A.6) Conclusion

L’interfaçage avec le C permet deux choses :
. exécuter facilement un programme assembleur en mode protégé 32 bits, sans devoir effectuer les initialisations relatives au mode protégé.
. pouvoir appeler des fonctions C depuis l’assembleur, et notamment celles de la librairie du C. Cela permet d’apporter toute la puissance des librairies C à nos programmes assembleurs, et de ne pas devoir tout réinventer quand on programme en assembleur.
. pouvoir écrire des fonctions C en assembleur, ce qui est intéressant pour permettre d’écrire des choses inaccessibles depuis le langage C.

. Le langage C est un langage qui s’allie parfaitement et naturellement à l’assembleur, tout en apportant la souplesse des langages de 3ème génération. On comprend alors pourquoi le système linux est écrit en langage C.

XII)Les structures de contrôles

L’assembleur n’a pas d’équivalent des while et autres do…while. Il ne sait faire que des tests et des gotos( il y a une instruction de boucle, mais pas aussi puissante que le for du C, c’est juste un compteur).

XII.A) L’instruction CMP

CMP est l’instruction de comparaison( CoMPare). L’instruction CMP affecte le registre FLAGS, selon le résultat de la comparaison. En réalité, la première opérande est soustraite à la deuxième, mais le résultat de la soustraction n’est pas gardé. Les flags ont été modifiés par cette soustraction, et c’est ce qui nous intéresse.

XII.A.1)Pour les entiers non signés

Voyons le cas des entiers non signés. Il y a alors 2 flags qui nous concernent : ZF et CF.
Si op1-op2=0, le flag ZF( Zero Flag) vaut 1, car le résultat de l’opération est 0. Et op1-op2 ne vaut zéro que si op1 = op2( équation !). Le flag CF( Carry Flag), qui est le flag de retenue, vaut 0 également.

Si op1 > op2, le flag ZF = 0 (résultat non nul), et le flag CF = 0 (pas de retenue !).
Si op1 < op2, le flag ZF = 0 (résultat non nul), et le flag CF = 1.

XII.A.1)Pour les entiers signés

Si op1=op2, le flag ZF = 1.
SF est le flag de signe (Sign Flag), et OF le flag de dépassement (Overflow Flag).

Si op1 > op2 :
.si pas d’overflow, le flag OF vaut 0. Et SF = 0, car cette différence sera positive(op1>op2 !).
.en cas d’overflow( c’est possible), c'est-à-dire si le résultat op1 – op2 dépasse la capacité, le flag OF vaut 1. Et la différence sera négative (résultat pas bon, normalement elle doit être positive car op1>op2! ), donc SF = 1.

On constate que, dans les deux cas, OF = SF quand op1>op2.

Si op1 < op2, même raisonnement :

.si pas d’overflow, le flag OF vaut 0. Et SF = 1, car cette différence sera négative(op1<op2 !).
.en cas d’overflow( c’est possible), c'est-à-dire si le résultat op1 – op2 dépasse la capacité, le flag OF vaut 1. Et la différence sera positive (résultat pas bon, normalement elle doit être négative car op1<op2! ), donc SF = 0.

On constate que, dans les deux cas, OF != SF quand op1<op2.

XII.B)L’instruction JMP

L’instruction JMP est l’instruction de branchement. C’est un saut inconditionnel ( équivalent du « goto »).
JMP etiquette
Une seule opérande, qui est l’adresse du saut.

Il y a trois type de sauts :

JMP SHORT etiquette : on ne peut faire un saut que de 128 octets en avant ou en arrière. Le programme assembleur va convertir l’étiquette en déplacement signé, sur 1 octet. L’intérêt est que le déplacement ne prend qu’un octet. Attention au vocabulaire, ici « déplacement » n’a pas le sens de offset, mais bien de déplacement par rapport à l’IP en cours. Ce déplacement sera rajouté à l’IP( l’IP qui pointe sur l’instruction suivante du JMP). Cependant, en testant sur nasm, j’ai remarqué que nasm peut prendre la décision de l’utiliser si nécessaire, même si on ne le demande pas, mais pas systématiquement. Par exemple, nasm l’utilise, sans qu’on le demande, dans un déplacement de moins quelques octets, mais pas de quelques octets en plus. L’opcode est EBh.

JMP NEAR etiquette : c’est le saut choisi par défaut, quand on ne précise rien après le JMP. On peut faire un saut n’importe où dans le segment.
Il y a deux sortes de saut near :
. Celui où le déplacement est stocké sur 4 octets, le déplacement est signé. L’opcode est E9h suivi des quatre octets. On peut aller n’importe où dans le segment, c’est ce qu’on lit souvent. Cependant, à mes yeux, le déplacement ne peut pas atteindre toutes les adresses du segment, puisque ce déplacement est signé. Mais on peut s’en sortir, dans ce cas( qui n’arrive jamais), avec un JMP FAR, ou même avec deux sauts JMP NEAR consécutifs. De toutes façons, on aurait sûrement utilisé deux segments de code différents, donc le problème ne se pose sûrement jamais.

. Celui où le déplacement est stocké sur 2 octets, le déplacement est signé, et il va de + ou – 32768 octets. Il faut préciser JMP NEAR WORD etiquette pour l’avoir.

JMP FAR : il permet de faire un saut vers un autre segment de code. Ceci est très rarement utilisé en mode protégé 32 bits.

XII.B.1) Exemples de JMP

34 suiteMoins:
35 0000000C CD10           int 10h ;interruption BIOS pour fonctions
vidéos
36 0000000E EBFC           jmp suiteMoins ; nasm a fait un jmp short ! FCh = -4 (00000010h-4=0000000Ch)
37 00000010 EBFA           jmp short suiteMoins. FAh = -6
38 00000012 E901000000 jmp suite ; nasm a fait un jmp near de 4 octets
39 00000017 C3                ret
40 suite:
41
42 00000018 61                 popa

XII.C) Les instructions de branchement conditionnel

- Les instructions simples de branchement conditionnel :

JNZ  Z=0
JZ     Z=1
JNC C=0
JC    C=1
JNO O=0
JO    O=1
JNS  S=0
JS     S=1
JNZ  Z=0
JZ     Z=1
JNP  P=0
JP     P=1

Ce sont des sauts qui dépendent de la valeur d’un seul flag. Le nom de la condition est directement lié au nom du flag. Ceci n’est pas pratique pour les comparaisons courantes, qui dépendent en général de plusieurs flags.

- Les instructions plus complexes de branchement conditionnel :

Ces instructions ont un nom qui est fonction de la condition de saut, ce qui est beaucoup plus lisible. Et surtout, ces sauts se font suivant la valeur de deux flags (parfois un seul), ce qui serait difficile à faire avec les instructions de branchement conditionnel simples.
La raison pour laquelle certains flags sont testés figure dans le paragraphe sur les comparaisons.
On remarque un vocabulaire spécifique suivant qu’on est en signé ou non signé, c’est pour qu’on puisse différencier le cas du signé du cas du non signé. On peut voir aussi que deux dénominations différentes existent pour un même opcode, c’est pour une lisibilité meilleure suivant notre besoin, mais l’opcode est le même.

. Non signé :

JA (above) ou JNBE (not below or equal) 77 C=0 et Z=0
JAE (above or equal) ou JNB (not below) 73 C=0
JB (below) ou JNAE (not above or equal) 72 C=1
JBE (below or equal) ou JNA (not above) 76 C=1 et Z=1
On remarque que le vocabulaire est Above et Below, et Equal

. Signé:

JG (greater) ou JNLE (not less or equal) 7F Z=0 et S=O
JGE (greater or equal) ou JLE (not less) 7D S=O
JL (less) ou JNGE (not greater or equal) 7C S<>O
JLE (less or equal) ou JNG (not greater) 7E C=1 et S<>O
On remarque que le vocabulaire est Greater et Less, et Equal

.Valable pour signé et non signé:

JE (equal) 74 Z=1
JNE (not equal) 75 Z=0

On remarque que le vocabulaire est Equal

XII.D) Partir du C et traduire en assembleur

La méthode à utiliser et de partir d’un pseudo programme C, contenant notre structure de contrôle (par exemple un while), et de le traduire, à la main, en assembleur. C’est une façon d’être certain de bien structurer notre code, et cela nous entraîne aussi à bien comprendre le mécanisme de compilation.

Voici un exemple d’écriture d’une fonction C affString en assembleur, qui affiche la string passée en paramètre:

Télécharger les sources de cet exemple: affString-20080417-V1-2.zip

Premier fichier: aff.asm

Created with colorer-take5 library. Type 'asm'

;aff.asm
;14/04/2008
;
;Donné pour la fonction affString, qui est un exemple d'utilisation
;des comparaisons et sauts conditionnels, et de traduction de structure
;de controle à partir du C.
;Contient 2 fonctions C: int affCar( int );
;et int affString( String );
;
;Fonction affCar
;Cette fonction utilise une interruption BIOS pour afficher le caractère
;(interruption 10h(fonctions vidéos), fonction 0Eh dans ah, caractère dans al)
;
; Fonction affString
;Cette fonction utilise affCar

;Début des données
segment .data

;Début des données non initialisées
segment .bss

;Début du code
segment .text
extern _printf

;FONCTION affCar
;affiche un caractère dans la console
;Un paramètre: le caractère à afficher
global _affCar ; par défaut n'aurait pas été utilisable à l'extérieur!
_affCar:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction
lea ebx, [ebp+8];
mov al, [ebx]
mov ah, 0Eh ;permet de préciser quelle fonction de l'interruption 10h on veut: la fonction d'affichage d'un caractère correspond à 0Eh dans ah
int 10h ;interruption BIOS pour fonctions vidéos
;fin vrai code de notre fonction
popa
mov eax, 0 ; on retourne 0 ( ça s'est bien passé)
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

;FONCTION affString
;affiche une string dans la console
;un paramètre: la string à afficher
;
;TRADUIT à partir du code C suivant:
; for (int ebx=adrPhrase; (al=[ebx])!=0; ebx++)
;{
; affCar([ebx]);
;}
;ce qui revient à faire un while, car le for est un while.
;on aurait pu partir du code C suivant:
;ebx = adresse debut de la chaine
;al = [ebx]
; while (al!=0)
;{
; affCar([ebx]);
; ebx++;
; al = [ebx];
;}
global _affString ; par défaut n'aurait pas été utilisable à l'extérieur!
_affString:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction

;appel de _affCar
mov ebx, [ebp+8] ; ebx vaut l'adresse de la chaine
;
;
mov eax, 0 ;pour pouvoir avoir le caractère dans eax
mov al, [ebx] ; on met dans al le caractère courant
;écriture du for (int ebx=adrPhrase; (al=[ebx])!=0; ebx++)
debFor:
cmp al, 0 ; tant que al !=0 on continue
je finFor
; affichage du caractère dans eax
push eax ; on passe le caractère à afficher en paramètre
call _affCar
pop ecx
;on passe au caractère suivant
inc ebx
mov eax, 0
mov al, [ebx] ; on met dans al le caractère courant
jmp short debFor
;fin vrai code de notre fonction
finFor:
popa
mov eax, 0 ; on retourne 0 ( ça s'est bien passé)
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

Deuxième fichier : affString_mainC.c

Created with colorer-take5 library. Type 'c'

/* 15/04/2008
Programme d'exemple d'utilisation
de la fonction externe affString, crée par aff.asm
*/
#include "aff.h"

int main()
{
int retour ;

retour = affString("Hello\n");
return retour;
}

* Troisième fichier : affString.bat

Created with colorer-take5 library. Type 'Batch'

nasm -f coff aff.asm -l aff.txt
gcc -o affString.exe aff.o aff-mainC.c

* Quatrième fichier : aff.h

Created with colorer-take5 library. Type 'c'

/* 15/04/2008 Auteur: Sabri Koffler */
/* Contient la déclaration du prototype des fonctions externes d'affichage */
/* Inspiré de stdio.h */
#ifndef Aff_H /* Seulement si cet include n'a pas déjà été fait */

#define Aff_H /* Pour savoir que cet include a déjà été fait */

#ifndef _EXFUN /* si pas défini ailleurs( c'est tout à fait possible) */
#define _EXFUN(N,P) N P
#endif

int _EXFUN( affCar, (char) ); /* Affiche le caractère pas
sé en paramètre. Retourne 0 si pas de pb */
int _EXFUN( affString, (char *) ); /* Affiche la string passée en paramètre. Retourne 0 si pas de pb */
#endif /* Aff_H */

XII.E) Les boucles

Les boucles permettent d’avoir un équivalent (moins puissant) du for du langage C.

XII.E.1) L’instruction LOOP

LOOP etiquette
L’instruction LOOP se sert du registre ECX comme compteur. ECX est décrémenté à chaque fois, et on boucle tant que ECX <>0. SI ECX = 0, on effectue un saut vers l’étiquette.

XII.E.2) Les instructions LOOPE, ET LOOPNE

Ces instructions permettent de faire un for particulier, qui est le cas des for où on recherche une valeur, tout en ayant besoin d’un compteur. On effectue une recherche séquentielle.

LOOPNE etiquette (ou LOOPNZ etiquette, exactement équivalent) : on boucle si le compteur ECX <>0, et si le flag Z=0. A vous d’effectuer, juste avant le LOOPNE, un test sur une valeur ou toute autre opération qui modifie le flag Z. Par exemple, « CMP EBX, 15 », puis LOOPNE etiquette. Dans cet exemple, on bouclera tant que la fin de la boucle n’est pas atteinte, et tant que EBX <>15. A noter que la décrémentation de ECX n’affecte pas les flags, sinon ça serait ennuyeux pour le LOOPNE.

LOOPE( = LOOPZ), c’est le contraire du LOOPNE. On boucle tant que le compteur est différent de 0, et tant que le flag Z=1.

XII.E.3) Exemple de boucle : LOOPNE

Soit l’exercice suivant : On veut reprendre notre exemple AffString, mais on veut afficher les 256 premiers caractères par sécurité, et pas plus, pour que si le zéro ne figure pas dans la string, le programme ne tourne pas en boucle infinie.

C’est exactement le rôle du LOOPNE : on a besoin de trouver la valeur 0 de fin de chaîne, en parcourant séquentiellement des adresses. Et on ne veut pas plus de 256 tours de boucle ( le compteur est nécessaire).

Télécharger les sources de cet exemple: affStringLoopNE-V1-0-20080417

- Premier fichier : affString-LoopNE.asm

Created with colorer-take5 library. Type 'asm'

;Début des données
segment .data

;Début des données non initialisées
segment .bss

;Début du code
segment .text
extern _printf

;FONCTION affCar
;affiche un caractère dans la console
;Un paramètre: le caractère à afficher
global _affCar ; par défaut n'aurait pas été utilisable à l'extérieur!
_affCar:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction
lea ebx, [ebp+8];
mov al, [ebx]
mov ah, 0Eh ;permet de préciser quelle fonction de l'interruption 10h on veut: la fonction d'affichage d'un caractère correspond à 0Eh dans ah
int 10h ;interruption BIOS pour fonctions vidéos
;fin vrai code de notre fonction
popa
mov eax, 0 ; on retourne 0 ( ça s'est bien passé)
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

;FONCTION affString
;affiche une string dans la console
;un paramètre: la string à afficher
;affichage d'un nombre maximum de caractère, par sécurité
;
;TRADUIT à partir du code C suivant:
;ebx = adresseDeb;
;ecx = longMax;
; for (int ecx=longMax; ((al=[ebx])!=0) && (ecx!=0); ecx--)
;{
; affCar([al]);
; ebx++;
;}

global _affString ; par défaut n'aurait pas été utilisable à l'extérieur!
_affString:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
;début vrai code de notre fonction

;appel de _affCar
mov ebx, [ebp+8] ;ebx = adresseDeb;
;ebx vaut l'adresse de la chaine
mov ecx, 256 ;ecx = longMax;
;compteur - on affiche 4-1=3 caractères maximum. ecx doit être >=1
;
; al=[ebx]
mov eax, 0 ;pour pouvoir avoir le caractère dans eax
mov al, [ebx] ; on met dans al le caractère courant
;On effectue un premier test, car le loopne force à faire au moins un tour de boucle sinon!Or la chaîne peut être vide
cmp al, 0 ;(al=[ebx])!=0)
; tant que al !=0 on continue
je finFor
; for (int ecx=longMax; ((al=[ebx])!=0) && (ecx!=0); ecx--)
debFor:
; affichage du caractère dans eax
push eax ; on passe le caractère à afficher en paramètre
call _affCar ;affCar([al]);
pop edx ; juste pour dépiler le paramètre
;on passe au caractère suivant
inc ebx ;ebx++;
mov eax, 0
mov al, [ebx] ; on met dans al le caractère courant
cmp al, 0 ;(al=[ebx])!=0)
loopne debFor
;fin vrai code de notre fonction
finFor:
popa
mov eax, 0 ; on retourne 0 ( ça s'est bien passé)
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

- Deuxième fichier : affStringLoopNE-mainC.c

Created with colorer-take5 library. Type 'c'

#include "affString-LoopNE.h"

int main()
{
int retour ;

retour = affString("Hello\n");
return retour;
}

- Troisième fichier : affString-LoopNE.h

Created with colorer-take5 library. Type 'c'

#ifndef AffStringLoopNE_H /* Seulement si cet include n'a pas déjà été fait */

#define AffStringLoopNE_H /* Pour savoir que cet include a déjà été fait */

#ifndef _EXFUN /* si pas défini ailleurs( c'est tout à fait possible) */
#define _EXFUN(N,P) N P
#endif

int _EXFUN( affCar, (char) ); /* Affiche le caractère passé en paramètre. Retourne 0 si pas de pb */
int _EXFUN( affString, (char *) ); /* Affiche la string passée en paramètre. Retourne 0 si pas de pb */
#endif /* Aff_H */

- Quatrième fichier : affString-LoopNE.bat

Created with colorer-take5 library. Type 'Batch'

nasm -f coff affString-LoopNE.asm -l affString-LoopNE.txt
gcc -o affString-LoopNE.exe affString-LoopNE.o affStringLoopNE-mainC.c

XIII)Les variables locales, suivant la convention d’appel C

Les variables locales sont mises dans la pile, suivant la convention d’appel C. Ceci a plusieurs avantages, par rapport à si on les mettait en mémoire dans le segment de données. Grâce à ce système, on peut faire des appels récursifs de notre fonction. Un autre avantage, c’est que les variables n’existent que le temps d’exécution de la fonction, il y a un gain de place en mémoire.

Exemple de pile avec variable locale
La figure ci-dessus montre un exemple d’un appel de la fonction printf, qui, on suppose, utiliserait une première variable locale de type int. On fait un appel « printf(« %s », maString »).
Avant d’appeler printf, on suppose que ESP = 10000. On empile les paramètres dans l’ordre inverse, c'est-à-dire l’adresse de la chaîne à afficher, puis l’adresse de la chaîne de format. N’oubliez pas que la pile marche « à l’envers » : ESP diminue quand on empile, la pile « recule ». L’adresse de retour de la fonction appelante est sauvegardée sur la pile en 9992. Puis la fonction printf empile la valeur de EBP, et on affecte à EBP la valeur de ESP d’à ce moment-là. Cette valeur devient notre repère, et vaut 9988 ici. On retrouve bien, en EBP+8( 9988+8=9996) l’adresse du premier paramètre dans la pile. On a déjà vu tout ceci.
La nouveauté concerne les variables locales : dans notre cas une variable locale de type int est utilisée. Elle prend 4 octets( car c’est un int), et est placée dans la pile juste après l’ancien EBP empilé. La variable locale est donc placée en EBP-4. EBP ne bouge jamais, et sert de repère. Comme les variables locales sont empilées après l’empilement de l’ancien EBP, il est normal qu’il faille faire EBP moins quelque chose pour y accéder( la pile est implémentée « à l’envers »).

Cette partie de la pile contenant les paramètres, l’adresse de retour, et les variables locales se nomme le cadre de pile( stack frame). Lors d’un appel à une fonction, un nouveau cadre de pile est créé.

XIII.A) Le prologue et l’épilogue

L’instruction Enter du début est appelée le prologue. Le premier paramètre est le nombre d’octets total réservé pour les variables locales dans la fonction. Jusqu’à présent nous le mettions à 0. En fait, le microprocesseur va laisser de la place sur la pile pour les futures variables locales, en faisant un SUB ESP, operande1. Nous ne reviendrons pas sur les autres actions que fera le enter, qui sont PUSH EBP puis MOV EBP, ESP. Le deuxième paramètre est toujours à 0, d’après la convention d’appel C.

L’instruction Leave à la fin est appelée l’épilogue, et elle est sans opérande. On a déjà vu qu’elle faisait MOV ESP, EBP puis POP EBP. On remarque qu’en faisant MOV ESP, EBP, esp prend la valeur du repère EBP( qui est l’adresse dans la pile de l’ancien EBP). Ce qui veut dire que cela équivaut à dépiler toutes les variables locales.

Remarque : on n’est pas obligé d’utiliser l’instruction « enter », on peut la remplacer par :
Created with colorer-take5 library. Type 'asm'

push ebp
mov ebp, esp
sub esp, 4 ; 4 dans l’exemple d’une seule variable locale, de type int

Et on peut aussi remplacer le « leave » par :
Created with colorer-take5 library. Type 'asm'

mov esp, ebp
pop ebp

Les instructions enter et leave sont des instructions qui ont pour unique objectif de faire cet épilogue et ce prologue, elles ont été créées pour cela.

XIII.B)Exemple d’utilisation de variables locales

Télécharger les sources de cet exemple: soustr20080418-V1-0.zip

.Premier fichier : soustr.asm

Created with colorer-take5 library. Type 'asm'

%define res ebp-4
;Début des données
segment .data
;Début des données non initialisées segment .bss
;Début du code
segment .text
;FONCTION soustr
;Effectue une soustraction de deux entiers signés
;Deux paramètres: a et b, deux int signés. Effectue a-b
global _soustr
_soustr:
enter 4,0 ; une variable locale: le résultat
pusha
;début vrai code de notre fonction
mov ebx, [ebp+8]
sub ebx, [ebp+12]
mov [res], ebx
;fin vrai code de notre fonction
popa
mov eax, [res] ; on retourne la valeur de la variable locale resultat. Pas oublier ;qu'un popa a eu lieu avant, donc tous les registres ;peuvent avoir été modifiés
leave
ret

.Deuxième fichier : soustr-mainC

Created with colorer-take5 library. Type 'c'

#include <stdio.h>

int soustr( int, int);

int main()
{
int a, b, retour;
a=-100000;
b=50001;
retour = soustr(a,b);
printf( "Le resultat de a-b est %d", retour); /* Affiche -150001 */
return 0;
}

.Troisième fichier :soustr.bat

Created with colorer-take5 library. Type 'Batch'

nasm -f coff soustr.asm -l soustr.txt
gcc -o soustr.exe soustr.o soustr-mainC.c


XIV)Les nombres à virgule flottante

XIV.A) La norme IEEE 754

L’IEEE a défini un standard pour représenter les nombres en virgule flottante et en binaire.
Ce standard est la norme IEEE 754. Aujourd’hui, quasiment tous les processeurs( ou coprocesseurs dédiés) utilisent cette norme pour représenter les nombres à virgule flottante.

L’IEEE 754( ANSI/IEEE Standard 754 - 1985 ) définit quatre formats :

. simple précision 32 bits (Signe : 1 bit, Exposant : 8 bits, Mantisse : 23 bits). Le float du langage C utilise ce format. Limites : -8,43*10 Puissance -37 à 3,37*10 Puissance 38. Nombre de chiffres significatifs : environ 8.

. double précision 64 bits (Signe : 1 bit, Exposant : 11 bits, Mantisse : 52 bits). Le double du langage C utilise ce format. Limites : -4,19*10 Puissance -307 à 1,67*10 Puissance 308. Nombre de chiffres significatifs : environ 16.

. simple précision étendue( pas utilisé, donc nous n’en parlerons pas ) : >= 43 bits. Signe : 1 bit, Exposant : 11 bits ou plus, Mantisse : 32 bits ou plus.

. double précision étendue : >= 79 bits. Le 80x86 utilise ce format en 80 bits, pour ses registres internes, afin de limiter les erreurs d’arrondi dans les calculs.
Signe : 1 bit, Exposant : 15 bits ou plus, Mantisse : 64 bits ou plus.
Limites : -3,4*10 Puissance -4932 à 1,2*10 Puissance 4932.
Nombre de chiffres significatifs : 19 ou 20.

Rappel sur les entiers:
. Entier Word : 16 bits. Limites : -32768 à 32767 (en signé, bien sûr).
. Entier : 32 bits. Limites : -2*10 Puissance 9 à 2*10 Puissance 9 (en signé).
. Entier long : 64 bits. Limites : -9*10 Puissance 18 à 9*10 Puissance 18

Peu nous importe, dans ce cours, de savoir quel est le codage utilisé pour représenter ces nombres, tout ce qu’il nous importe de savoir, c’est qu’on peut représenter un nombre qui n’est pas entier, grâce à ces nombres en virgule flottante, en utilisant 32 bits ou 64 bits( voire 80 bits).

Pour l’instant, nous disons le strict minimum sur les nombres à virgule flottante, le nécessaire pour pouvoir programmer.

XV) Le coprocesseur arithmétique

Depuis le 486 DX, et à fortiori depuis le pentium, tous les processeurs 80x86 ont un coprocesseur arithmétique intégré.
Auparavant, il y avait un coprocesseur arithmétique dédié : le 8086/8088 avaient le coprocesseur 8087. Le 80286 avaient le 80287, et pour le 80386, c’était le 80387.

Aujourd’hui, du point de vue de la façon de programmer, on programme encore comme s’il s’agissait d’un coprocesseur dédié. C’est plus simple de penser ainsi( diviser pour régner).

XV.A) Description du coprocesseur arithmétique

Le terme FPU signifie Floating Point Unit, et sert à désigner la partie du microprocesseur concernée. La FPU remplace l’ancien coprocesseur dédié. Mais on peut utiliser encore le terme de coprocesseur (abusivement).

Toutes les instructions du FPU commencent par F, afin de faciliter la séparation entre les mnémoniques du CPU et ceux du FPU.

XV.A.1) Les registres du FPU

. Le coprocesseur possède 8 registres, chacun en double précision étendue, de 80 bits. Ces registres s’appellent ST0 (appelé aussi ST) à ST7. Il s’agit en fait d’une petite pile LIFO de 8 valeurs, chaque valeur de la pile constituant un registre. ST0 est le sommet de la pile. Cette pile est située dans le FPU, elle n’est pas dans la mémoire, bien évidemment. On peut dire que peu importe l’organisation en pile, une pile de 8 valeurs peut contenir 8 valeurs, donc l’équivalent de 8 registres. Le seul changement de l’implémentation sous forme de pile, est que les valeurs passent d’un registre à l’autre, suivant que vous ajoutez ou retirez une valeur dans cette pile. Quand vous ajoutez une valeur, la valeur de ST7 est perdue.

On peut voir ce système des registres comme une pile interne au FPU, et « ST0 » est la valeur de la pile « d’adresse » 0, c'est-à-dire le dernier élément. « ST1 » la valeur « d’adresse » 1, c'est-à-dire l’avant-dernier élément, etc. Les noms des registres sont vus alors comme des adresses dans la pile, ce qui me semble intéressant, car c’est difficile d’imaginer, en cas d’empilement, que la valeur contenue dans chaque registre est donnée à son registre voisin. On a donc du mal à attacher une notion de registre à ST0..ST7, alors qu’ils ont un fonctionnement lié à une pile( ils ne sont pas indépendants l’un de l’autre).

Il n’est pas possible de charger un registre du FPU avec un registre du CPU. On doit donner une valeur venant de la mémoire, ou d’un autre registre du FPU.

. Il existe un registre 16 bits, appelé registre de contrôle (control word). Il sert à contrôler les opérations de la FPU.

. Il y aussi un registre d’état de 16 bits, qui à un rôle similaire à EFLAGS, et qui permet de donner des informations sur l’état de la FPU. Quatre des flags sont utilisés pour les comparaisons : C0, C1, C2 et C3.

XV.A.2) Le format BCD

Le FPU peut également travailler avec les BCD (Binary Coded Decimal), appelés aussi « Packed Decimal Format ». Le nombre représenté est toujours un entier. Par défaut, le format BCD est de 80 bits. Nombre de bits: 80. Nombre de digits( c'est-à-dire de chiffres) : 18. Chaque chiffre décimal du nombre prend 4 bits, on code le décimal en binaire( par exemple 8 = « 1000 » ).
Limites : -999999999999999999(=-10 puissance 18+1) à +999999999999999999(=+10 puissance 18-1).
Le bit de poids fort( le bit 7) de l’octet de poids le plus fort( l’octet 9) est le bit de signe( 1 si négatif, 0 si positif). Les autres bits de l’octet de poids fort sont inutilisés.

Les BCDs utilisables par le programmeur peuvent être aussi de 16, 32 ou 64 bits. Le TBYTE par défaut (qui est de 10 octets, 80 bits), est la grandeur maximale utilisable d'un BCD.

XV.B) Les instructions du FPU

XV.B.1) Instructions de chargement

FLD source :
Cette instruction charge( « LD » comme « LOAD ») dans ST0 une valeur en virgule flottante contenue en mémoire ou dans un registre du coprocesseur. C'est-à-dire qu’on push dans la pile( du FPU) cette valeur. Cette valeur peut être en simple ou en double précision.
La première opérande( ST0) est implicite. Il ne reste plus qu’une seule opérande.

Exemple : FLD dword[nombre1]
Empile au sommet de la pile du FTU, le nombre à virgule flottante simple précision, se trouvant à l’adresse(l’offset) nombre1.

Rappel :
dword : double mot, c'est-à-dire 32 bits.
qword : quadruple mots, c'est-à-dire 64 bits.
word : mot, c'est-à-dire 16 bits.

FLD1 : range un 1 au sommet de pile.

FLDZ : range un 0 au sommet de la pile.

FLDPI : range PI dans ST.

FILD source :
Comme FLD, mais la valeur source est un entier( « I » comme « Integer »), qui est converti en nombre à virgule flottante, et qui est mis dans ST0.

FBLD source : comme FILD, mais la valeur source est un BCD. Le BCD, de 80 bits par défaut, est converti en nombre à virgule flottante de 80 bits, et est mis dans ST0

XV.B.2) Instructions de stockage

FST destination :

Même principe que FLD, mais dans l’autre sens. On stocke (ST comme Store) en mémoire, ou dans un registre du coprocesseur, la valeur de ST0, c'est-à-dire la valeur au sommet de la pile. Ici aussi une opérande( la deuxième) est implicite( ST0). Il ne reste alors plus qu’une opérande. La destination peut être en mémoire (en simple ou en double précision), ou un registre du FPU.

Exemple :
FST qword resultat
Ici, on range à l’adresse « resultat », la valeur de ST0 convertie en flottant en double précision( qword : 64 bits).

FSTP destination : Identique à FST, sauf que la valeur de ST0 est dépilée à la fin( « P » comme « pop »).

FIST destination :
La destination est uniquement un emplacement mémoire( c’est logique). Et l’entier peut être un mot ou un double mot( 16 ou 32 bits).
Comme FST, mais le nombre flottant dans ST0 est converti en entier, puis est stocké dans l’emplacement mémoire destination. La conversion est par défaut à l’entier le plus proche, mais ce choix peut être changé grâce au registre de contrôle.

FISTP destination :
Comme FIST, mais curieusement l’entier peut être sur 64 bits( quadruple mot). De plus, la valeur est dépilée à la fin, comme d’habitude avec les instructions se terminant par « P ».

XV.B.3) Instructions du registre de contrôle

On rappel que le registre de contrôle est un registre 16 bits.

FLDCW source (Load control word) : permet de charger le registre de contrôle avec la valeur source.

FSTCW destination (Store control word) : stocke la valeur du registre de contrôle vers la destination

XV.B.4) Instructions de manipulation de la pile

FFREE st(n) : libère un registre sans dépiler la pile du FPU. Le registre est alors marqué comme inutilisé, vide. Cela nous permet, à mon avis, d’avoir l’équivalent de la valeur null en base de données( la valeur « pas de valeur »).

FXCH : sans argument, échange st et st(1). On rappelle que st est l’autre nom de st(0). FXCH st(i) : avec argument, échange les deux registres st(0) et st(i). Cela permet d’avoir au sommet de la pile la valeur qu’on veut. Et cela permet également d’affecter au registre qu’on veut, la valeur au sommet de la pile.

XV.B.5) Instructions arithmétiques

FADD source, destination

destination = destination + source ATTENTION, contrairement aux instructions du CPU, c’est la deuxième opérande qui reçoit le résultat de l’addition (et non la première).

FADD source
Avec une seule opérande, fadd ajoute à ST(0) la valeur de source (la destination est implicitement st).
Source peut être, outre un registre, un emplacement mémoire 32/64 bits (réel simple ou double précision).
Exemple : fadd qword [esi]

FADD
Sans opérande, st est la source, et st1 la destination. C'est-à-dire effectue st1 = st1 + st.

FADDP : comme FADD, mais dépile st à la fin. Cette instruction est pratique pour obtenir le résultat dans st, et non dans st1 comme le fait FADD.

FSUB, FMUL, FDIV (et FSUBP, FMULP, FDIVP) ont le même mode de fonctionnement que FADD (et FADDP).

. Avoir un entier comme source

FIADD mem16 ou FIADD mem32 : ST0 = ST0 + l’entier( 16 ou 32 bits) pointé par mem.
Le résultat est réel.

FIDIV mem16 ou mem32 : fait de même que FIADD, ST0 = ST0 / l’entier( 16 ou 32 bits) pointé par mem.
Le résultat est réel.

FIMUL, FISUB : comme FIADD

. Les instructions « reverse » :
FSUBR : comme FSUB, mais effectue la soustraction dans l’ordre inverse, tout en rangeant toujours le résultat dans la destination. Fait destination = source – destination (au lieu de destination = destination – source).

Les instructions reverse n’existent pas pour FADD, FMUL, qui sont des opérateurs commutatifs( à l’inverse de la soustraction et de la division).

XV.B.6) Instructions mathématiques

FSIN : sans opérande. Calcule le sinus, de la valeur en radian dans ST0, et affecte le résultat à ST0.

FCOS : idem que FSIN, mais pour le cosinus.

FSQRT : effectue ST0 = racine carrée de ST0.

FABS : effectue ST0 = valeur absolue de ST0.

FCHS : change le signe de ST0( effectue ST0 = - ST0 ).

XV.C) Exemple : affichage du nombre PI

Télécharger les sources de cet exemple: affPI20080424-V1-0.zip

Fichier affPI.asm :

Created with colorer-take5 library. Type 'asm'

; affPI.asm
; affPI, version 1.0 . 24/04/2008.
;Auteur: sabri koffler, 24/04/2008, pour infkoffler.com.
; Affiche la valeur du nombre PI, en double précision.
;Exemple d'utilisation du coprocesseur arithmétique
;
; à l'exécution, on remarque que les 16 chiffres significatifs sont exacts, et
;correspondent à la valeur exacte de pi qui est 3,141 592 653 589 793 ( 238...)
segment .data
formatResult: db "La valeur de Pi est : %.15f", 0 ;%f pour afficher un flottant en double précision
                                                ;.15: 15 chiffres après la virgules, peut importe devant
                                    
;Début des données non initialisées
segment .bss
piFloat: resq 1

;Début du code
segment .text
extern _printf ;pour le programme assembleur, on déclare explicitement que _printf existe ailleurs

global _affPIMain ; par défaut n'aurait pas été utilisable à l'extérieur, donc on doit le mettre!
_affPIMain:
enter 0,0 ;effectue PUSH EBP puis MOV EBP, ESP
pusha
pushf
;début vrai code de notre fonction
fldpi ;ST0 = PI
fst qword [ piFloat ]
;Affichage de PI
;
;on met d'abord les 32 bits dans ax-bx
mov esi, piFloat
;on empile d'abord le deuxième paramètre de printf, qui est de 64 bits( double précision)
push dword [ esi+4 ] ;on empile d'abord les 4 derniers octets du flottant 64 bits
push dword [ esi ] ;puis les 4 premiers octets ( cet ordre est indispensable à respecter)
                ;ainsi, en les dépilant, la fonction les récupère dans l'ordre où ils étaient écrits en mémoire
;
;puis on empile le premier paramètre de printf
push formatResult
call _printf
pop ecx
pop ecx
pop ecx
;fin vrai code de notre fonction
popf
popa
mov eax, 0 ; on peut mettre 0 si pas de valeur de retour
leave ;fin du enter 0,0 du début : effectue MOV ESP, EBP puis POP EBP
ret

Fichier affPI.c :

Created with colorer-take5 library. Type 'c'

/* affPIMain.c */
/*Auteur Sabri Koffler 24/04/2008 pour infkoffler*/
/*Version 1.0 24/04/2008*/

int main()
{
 affPIMain();
 return 0;
}

Fichier affPI.bat :

Created with colorer-take5 library. Type 'Batch'

nasm -f coff affPI.asm -l affPI.txt
gcc -o affPI.exe affPI.o affPIMain.c

XV.D) Les comparaisons

FCOM
FCOM operande

COM comme COMpare. L’instruction FCOM compare ST0 et l’opérande (si pas d’opérande, ST0 est comparé à ST1). L’opérande peut être un registre ou un flottant( simple ou double précision) en mémoire. Par exemple FCOM qword[ monDouble ] : ici c’est un flottant double précision( en C, l’équivalent serait FCOM( *monDouble ) ).

FCOMP : Comme FCOM, mais ST0 est dépilé à la fin.

FCOMPP : Comme FCOM, mais ST0 et ST1 sont dépilés à la fin.

Ces instructions de comparaison ont un effet sur le registre d’état du FPU (status register). Elles affectent les « condition code bits », que je traduirai par les bits de condition. Ce sont 4 bits, appelés C0, C1, C2, C3, qui sont les bits 8, 9, 10 et 14 du registre d’état (qui est un registre 16 bits). Je ne décrirai pas plus précisément, pour l’instant, le sens de chacun de ces bits. On peut ensuite utiliser les instructions FSTSW et SAHF pour les exploiter. C1 n’est pas modifié par les instructions de comparaison, sauf l’instruction FXAM.

FSTSW AX ou FSTSW m2byte : STore Status Word. Cette instruction copie le status register du FPU, dans le registre AX (qui est lui aussi un registre 16 bits), ou dans un emplacement mémoire de deux octets.

SAHF : Store AH into Flags. Ce n’est pas une instruction du FPU (elle ne commence pas par F). Cette instruction copie le registre AH dans le registre EFLAGS. Elle permet donc de positionner EFLAGS comme on le souhaite.
Plus précisément, elle positionne SF, ZF, AF, PF et CF de EFLAGS selon les bits correspondant de AH (les bits 7,6,4,2 et 0). Les bits 1,3 et 5 de EFLAGS sont respectivement mis à 1,0 et 0. Les bits 0 à 7 de EFLAGS sont concernés, ce qui correspond aux 8 bits de poids faible.

En effectuant un SAHF après un FSTSW (lui-même après une comparaison), on copie notamment le bit 0 de AH (C0), son bit 2 (C2) et son bit 6 (C3), vers, respectivement, le bit 0 de EFLAGS (CF), le bit 1 de EFLAGS (PF), et le bit 6 de EFLAGS (ZF).
Une fois ces deux instructions exécutées, on peut alors utiliser les sauts conditionnels habituels (mais ceux pour les entiers non signés, cela va de soit d’après les bits affectés).

FICOM mem16 ou mem32 : la source (mem16 ou mem32) est un mot ou double mot en mémoire( pas un QWORD), représentant un entier signé. FICOM compare la valeur de l’entier source, à la valeur du réel dans ST0. Comme les instructions ayant une opérande entière et l’autre réelle (FIADD, FISUB, FIMUL, FIDIV, …), la source est d’abord convertie en réel sur 80 bits. L’autre point commun est que l’opérande est un entier sur 16 ou 32 bits, pas sur 64 bits.

FICOMP : idem que FICOM, mais un pop est effectué après.

FCOMI regFPU : Les instructions FCOMI et FCOMIP sont disponibles uniquement pour le pentium pro, et aussi pour les processeurs à partir du pentium II. FCOMI compare ST0 et le registre du coprocesseur donné en opérande. L’opérande de FCOMI est un registre du coprocesseur. C’est identique à FCOM, sauf que les flags du registre EFLAGS sont directement modifiés, on n’a pas à faire une copie des flags du status register. On peut utiliser après, comme pour FCOM, les ja et autres sauts conditionnels pour les entiers non signés.
Attention, le « I » de FCOMI, ne vient pas de « integer », rien à voir, au contraire de FICOM.

FCOMIP regFPU : idem que FCOMI, mais la pile du FPU est dépilée à la fin.

RETOUR HAUT