Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87
Après avoir vu la théorie, nous allons étudier un cas particulier de jeu d'instruction. Précisément, nous allons étudier une extension de l'architecture x86. Pour rappel, les processeurs x86 sont ceux qui sont présents à l'intérieur des PC, à savoir tous les processeurs Intel et AMD actuels. Il s'agit d'une architecture qui a recu de nombreux ajouts au cours du temps, et qui a été fortement modifiée, tout en gardant une certaine compatibilité avec les versions plus anciennes. Ce qui a donné un jeu d’instruction particulièrement compliqué. Dans ce chapitre, nous allons étudier un de ces ajout, une extension de ce jeu d'instruction, qui a ajouté la gestion des flottants au x86. La gestion des flottants est une option qui a été rajoutée au cours de l'existence de l'architecture. Si les processeurs x86 des années 80 ne pouvaient pas faire des calculs flottants, ou alors seulement avec l'aide d'un coprocesseur, tous les processeurs x86 actuels le peuvent. L'ensemble des instructions machine x86 liées aux flottants s'appelle l'extension x87. L'extension x87 est encore utilisée par défaut sur les PC 32 bits. Par contre, avec le jeu d'instructions x86-64 bits, c'est une autre extension qui est utilisée pour les calculs flottants, l'extension SSE.
Les registres x87
modifierL'extension x87 fournit, en plus des instructions, plusieurs registres :
- 8 registres pour les opérandes flottantes ;
- 3 registres d'état pour configurer les exceptions, les arrondis, etc ;
- 1 registre utilisé pour gérer les exceptions flottantes, auquel seul le processeur a accès.
Une organisation en pseudo-pile
modifierL'extension x87 est un cas d'architecture à pile, assez spécialisée, totalement différente de la gestion des registres x86 normaux. Les 8 registres x87 sont ordonnés et numérotés de 0 à 7 : le premier registre est le registre 7, tandis que le dernier registre est le registre 0. Lorsque la FPU x87 est initialisée, ces 8 registres sont complètement vides : ils ne contiennent aucun flottant. Les registres x87 sont organisés sous la forme d'une pile de registres, similaire à une pile d'assiette, que l'on remplit dans un ordre bien précis. Lors du chargement d'une opérande de la RAM vers les registres, il n'est pas possible de décider du registre de destination. Si on veut ajouter des flottants dans nos registres, on doit les « remplir » dans un ordre de remplissage imposé : on remplit d'abord le registre 7, puis le 6, puis le 5, et ainsi de suite jusqu'au registre 0. Si on veut ajouter un flottant dans cette pile de registres, celui-ci sera stocké dans le premier registre vide dans l'ordre de remplissage indiqué au-dessus. Prenons un exemple, les 3 premiers registres sont occupés par un flottant et on veut charger un flottant supplémentaire : le 4e registre sera utilisé pour stocker ce flottant. La même chose existe pour le « déremplissage » des registres. Imaginez que vous souhaitez déplacer le contenu d'un registre dans la mémoire RAM et effacer complètement son contenu. On ne peut pas choisir n'importe quel registre pour faire cela : on est obligé de prendre le registre non vide ayant le numéro le plus grand.
Les instructions à une opérande (les instructions de calcul d'une tangente, d'une racine carrée et d'autres) vont dépiler le flottant au sommet de la pile. Les instructions à deux opérandes (multiplication, addition, soustraction et autres) peuvent se comporter de plusieurs manières différentes. Le cas le plus simple est celui attendu de la part d'une architecture à pile : l'instruction dépile les deux opérandes au sommet de la pile. La seconde possibilité est celle attendue de la part d'une architecture à pile à une adresse : elles dépilent le sommet de la pile et chargent l'autre opérande depuis la mémoire RAM. Le troisième cas est plus intéressant : l'instruction dépile le sommet de la pile, et charge l'autre opérande depuis n'importe quel autre registre de la pile. En somme, les registres de la pile sont adressables, du moins pour ce qui est de gérer la seconde opérande. C'est cette particularité qui vaut le nom de pseudo-pile à cette organisation à mi-chemin entre une pile et une architecture à registres.
Le registre d'état
modifierSi vous avez bonne mémoire, vous vous souvenez sûrement de ce que j'ai dit que la FPU contient 3 registres spéciaux qui ne stockent pas de flottants, mais sont malgré tout utiles. Ces 3 registres portent les noms de Control Word, Status Word et Tag Word. Le registre Tag Word indique, pour chaque registre flottant, s'il est vide ou non. Avouez que c'est pratique pour gérer la pile de registres vue au-dessus ! Ce registre contient 16 bits et pour chacun des 8 registres de données de la FPU, 2 bits sont réservés dans le registre Tag Word. Ces deux bits contiennent des informations sur le contenu du registre de données réservé.
- Si ces deux bits valent 00, le registre contient un flottant « normal » différent de zéro ;
- Si ces deux bits valent 01, le registre contient une valeur nulle : 0 ;
- Si ces deux bits valent 10, le registre contient un NAN, un infini, ou un dénormal ;
- Si ces deux bits valent 11, le registre est vide et ne contient pas de nombre flottant.
Passons maintenant au Status Word. Celui-ci fait lui aussi 16 bits et contient tout ce qu'il faut pour qu'un programme puisse comprendre la cause d'une exception.
Bit | Utilité |
---|---|
TOP | Ce registre contient trois bits regroupés en un seul ensemble nommé TOP, qui stocke le numéro du premier registre vide dans l'ordre de remplissage. Idéal pour gérer notre pile de registres |
U | Sert à détecter les underflow. Il est mis à 1 lorsqu'un underflow a lieu. |
O | Pareil que U, mais pour les overflow : ce registre est mis à 1 lors d'un overflow |
Z | C'est un bit qui est mis à 1 lorsque notre FPU exécute une division par zéro |
D | Ce bit est mis à 1 lorsqu'un résultat de calcul est un dénormal ou lorsqu'une instruction doit être exécutée sur un dénormal |
I | Bit mis à 1 lors de certaines erreurs telles que l'exécution d'une instruction de racine carrée sur un négatif ou une division du type 0/0 |
Enfin, voyons le Control Word, le petit dernier. Il fait 16 bits et contient lui aussi des bits ayant chacun une utilité précise. Beaucoup de bits de ce registre sont inutilisés et on ne va citer que les plus utiles.
Bit | Utilité |
---|---|
Infinity Control | S'il vaut zéro, les infinis sont tous traités comme s'ils valaient . S'il vaut un, les infinis sont traités normalement |
Rouding Control | C'est un ensemble de deux bits qui détermine le mode d'arrondi utilisé
|
Precision Control | Ensemble de deux bits qui détermine la taille de la mantisse de l'arrondi du résultat d'un calcul. En effet, on peut demander à notre FPU d'arrondir le résultat de chaque calcul qu'elle effectue. Cette instruction ne touche pas à l'exposant, mais seulement à la mantisse. La valeur par défaut de ces deux bits est 11 : notre FPU utilise donc des flottants double précision étendue. Les valeurs 00 et 10 demandent au processeur d'utiliser des flottants non pris en compte par la norme IEEE 754.
|
Les instructions flottantes x87
modifierL’extension x87 comprend les instructions de base supportées par la norme IEEE 754, ainsi que quelques autres. On y retrouve les quatre instructions arithmétiques de base de la norme IEEE754 (+, -, *, /), avec quelques autres calculs supplémentaires.
Les comparaisons
modifierVoici une liste de quelques instructions de comparaisons supportées par l'extension x87 :
- FTST : compare le sommet de la pseudo-pile avec la valeur 0 ;
- FICOM : compare le contenu du sommet de la pseudo-pile avec une constante entière ;
- FCOM : compare le contenu du sommet de la pseudo-pile avec une constante flottante ;
- FCOMI : compare le contenu des deux flottants au sommet de la pseudo-pile.
Les instructions arithmétiques
modifierOn trouve aussi des instructions de calculs, qui comprennent les cinq opérations définies par la norme IEE754, mais aussi quelques instructions supplémentaires :
- l'addition : FADD ;
- la soustraction FSUB ;
- la multiplication FMUL ;
- la division FDIV ;
- la racine carrée FSQRT ;
- des instructions de calcul de la valeur absolue (FABS) ou encore de changement de signe (FCHS).
L'extension x87 implémente aussi des instructions trigonométriques et analytiques telles que :
- le cosinus : instruction FCOS ;
- le sinus : instruction FSIN ;
- la tangente : instruction FPTAN ;
- l'arc tangente : instruction FPATAN ;
- ou encore des instructions de calcul de logarithmes ou d'exponentielles.
Il va de soi que ces dernières ne sont pas supportées par la norme IEEE 754 et que tout compilateur qui souhaite être compatible avec la norme IEEE 754 ne doit pas les utiliser.
Les instructions d'accès mémoire
modifierEn plus de ces instructions de calcul, l'extension x87 fournit des instructions pour transférer des flottants entre la mémoire et les registres. Celles-ci sont des équivalents des instructions PUSH et POP qu'on trouve sur les machines à pile, à l'exception d'une instruction équivalente à l'instruction SWAP. On peut citer par exemple les instructions dans le tableau suivant. D'autres instructions chargent certaines constantes (PI, 1, 0, certains logarithmes en base 2) dans le registre au sommet de la pile de registres.
Instruction | Ce qu'elle fait |
---|---|
FLD | Elle est capable de charger un nombre flottant depuis la mémoire vers notre pile de registres vue au-dessus. Cette instruction peut charger un flottant codé sur 32 bits, 64 bits ou 80 bits |
FSTP | Déplace le contenu d'un registre vers la mémoire. Une autre instruction existe qui est capable de copier le contenu d'un registre vers la mémoire sans effacer le contenu du registre : c'est l'instruction FST |
FXCH | Échange le contenu du dernier registre non vide dans l'ordre de remplissage (celui situé au sommet de la pile) avec un autre registre |
Le phénomène de double arrondi
modifierChacun des registres de données vus plus haut stocke un nombre flottant codé sur 80 bits. Oui, vous avez bien lu, 80 bits et non 32 ou 64 : cette FPU calcule sur des nombres flottants double précision étendue et non sur des flottants simple ou double précision, qui ne sont pas gérés par la FPU x87. On peut alors se demander comment le processeur fait pour calculer avec des flottants simple et double précision. Tout se joue lors de l'accès à la mémoire avec l'instruction FLD : celle-ci se comporte différemment suivant le flottant qu'on lui demande de charger. En effet, cette instruction peut charger depuis la mémoire un flottant simple précision, double précision ou double précision étendue. Le format du flottant qui doit être chargé est stocké directement dans l'instruction. Je m'explique : une instruction machine est stockée en mémoire sous la forme d'une suite de bits, et pour certaines instructions, des bits supplémentaires sont ajoutés. Dans notre cas, ces bits optionnels servent à indiquer à notre instruction le format du flottant qu'elle doit charger.
La FPU x87 peut charger depuis la mémoire un nombre flottant 80 bits directement dans un registre. Pour les flottants 32 et 64 bits, la FPU va devoir effectuer une conversion de notre flottant simple ou double précision en un flottant 80 bits. Tous les calculs faits par notre FPU vont donner des résultats codés sur 80 bits, et ceux-ci restent codés sur 80 bits tant que ceux-ci sont stockés dans les registres de la FPU. Par contre, dès qu'il faut enregistrer un nombre flottant en mémoire RAM, les problèmes commencent. Si le flottant en question est stocké dans la mémoire sur 32 ou 64 bits, notre processeur doit convertir le contenu du registre dans le format du flottant en mémoire, histoire de conserver le bon format de base. Cette conversion est faite automatiquement par l'instruction d'écriture en mémoire utilisée. Par contre, si notre flottant est représenté en mémoire sur 80 bits, l'écriture en mémoire est directe : pas de conversion. Et ces conversions posent problème : elles ne respectent pas la norme IEEE 754 !
Comparons un calcul effectué sur un processeur gérant nativement les formats 64 et 32 bits et ce même calcul exécuté par la x87. Dans tous les cas, les flottants seront chargés dans les registres, le calcul s'effectuera et le résultat sera enregistré en mémoire RAM. Sur un processeur qui gére nativement les formats simple et double précision, ni le chargement, ni les calculs, ni l'enregistrement ne demanderont de faire des conversions vers des flottants 80 bits. Avec la x87, les flottants 32/64 bits sont convertis en un flottant x87 80 bits lors des échanges entre la pseudo-pile et la RAM. Les calculs sont effectués sur des flottants 80 bits uniquement, sans conversions. Lors de l'enregistrement d'un flottant x87 80 bits en mémoire, celui-ci est converti dans son format de base, au flottant 32 ou 64 bits le plus proche. On se retrouve donc avec un arrondi supplémentaire, en plus des arrondis liés aux calculs : c'est le phénomène du double rounding (qui signifie double-arrondi en français). Et rien n'implique que le résultat de ces deux conversions aurait donné le même résultat que le calcul effectué sur des flottants 64 bits !
Pour citer un exemple, sachez que des failles de sécurité de PHP et de Java aujourd'hui corrigées et qui avaient fait la une de la presse informatique étaient causées par ces arrondis supplémentaires. Bien sûr, sachez que ce bogue a pu être reproduit sur de nombreux autres langages et n'était certainement pas limité au PHP ou au Java : c'est le non-respect de la norme IEE754 par notre unité de calcul x87 qui était clairement en cause.
De plus, si une série de calculs est faite sur des flottants stockés dans les registres, les résultats intermédiaires auront une précision supérieure à ce qui se serait passé avec des flottants simple ou double précision. Dans ces conditions, le résultat peut être différent de celui qu'on aurait obtenu en utilisant seulement des flottants 64 bits lors des calculs. Le pire, c'est qu'on n'a aucune solution à ce problème, pour les calculs faits avec l'extension x87.
Autre problème, lié au précédent : rares sont les calculs effectués intégralement dans les registres, et on est parfois obligé de temporairement sauvegarder en mémoire le contenu d'un registre pour laisser le registre libre pour un autre nombre flottant. C'est le programmeur ou le compilateur qui gère quand effectuer ce genre de sauvegarde et sur quels registres. Chacune de ces sauvegardes va arrondir le flottant que l'on souhaite sauvegarder. Conséquence : suivant l'ordre de ces sauvegardes, le moment auquel elles ont lieu et les flottants qui sont choisis pour être sauvegardés, le résultat ne sera pas le même ! Avec le même programme, si vous décidez de sauvegarder un flottant et votre voisin un autre, ce ne sera pas le même flottant qui sera arrondi lors de son transfert en mémoire, et le résultat des calculs sur votre ordinateur sera différent des résultats obtenus sur l'ordinateur de votre voisin. Pour limiter la casse, il existe une solution : sauvegarder tout résultat d'un calcul sur un flottant directement dans la mémoire RAM. Comme cela, on se retrouve avec des calculs effectués uniquement sur des flottants 32/64 bits ce qui supprime pas mal d'erreurs de calcul.