l'assembleur  [livre #4061]

par  pierre maurette



Jeu
d'
instructions

Nous allons présenter, dans ce chapitre, le jeu d'instructions x86 standard, de façon relativement détaillée et pratiquement exhaustive. Nous avons utilisé une organisation thématique, l'index permettant de retrouver une instruction particulière si nécessaire. Cette partie sera précédée de généralités concernant les instructions.

7.1 Présentation générale des instructions

Vous n'y trouverez pas tout à fait une référence complète, comprenant les flags affectés, les exceptions générées dans chaque mode d'utilisation, ces renseignements étant souvent répétitifs et intuitivement évidents. La référence absolue est la documentation des fabricants Intel et AMD, disponible en ligne gratuitement et actualisée en permanence. Dans des cas bien particuliers, il faudra aller jusqu'à vérifier l'adéquation exacte de la version de la documentation et du ou des processeurs ciblés.

Cependant, cette présentation sera commentée et accompagnée d'exemples. Les instructions pourront ainsi être testées. Nous avons cherché avant tout à donner du sens aux exemples. Quand une instruction semble inutile, nous pouvons toujours la tester brutalement, mais il est fréquent dans ce cas que nous ne l’ayons pas entièrement saisie (reportez-vous par exemple aux instructions  XADD et suivantes).

Le jeu d'instructions x86 est tout sauf Reduced (de RISC). Cet inventaire nous a amenés à des instructions qui nous étaient totalement inconnues, parfois fort pratiques mais jamais indispensables.

La répartition des exemples n'a rien d'homogène ; nous n'avons pas hésité à en proposer certains qui nous ont paru amusants pour des instructions marginales et à négliger des instructions incontournables, justement parce qu'elles sont abondamment présentes tout au long de l'ouvrage. Sont exclues du champ des exemples les instructions qui ne peuvent pas être testées dans le cadre d’une programmation client sous Windows, typiquement les instructions privilégiées.

Vous pouvez, bien entendu, vous reporter à ce chapitre dans le cadre de la recherche d'une instruction pour une tâche particulière. Il en va de même pour une recherche sur ce qu’effectue une instruction donnée, en sachant toutefois que si le problème est très pointu, autant consulter directement la documentation officielle pour peu qu'elle soit installée sur votre machine.

Mais vous pouvez l'adopter comme un support linéaire d'initiation. Dans ce cas, il est conseillé d'installer Delphi 6 et de le prendre en main auparavant, en vous inspirant du chapitre qui lui est consacré. Un autre environnement de développement en C/C++, voire une version plus ancienne de Delphi, suffira pour peu qu'il intègre l'assembleur en ligne, qu’il dispose d'un débogueur niveau CPU et que vous le maîtrisiez correctement.

Pourquoi se restreindre au jeu d'instructions standard ? Pour une question de place, certes, mais surtout pour des raisons d'efficacité de l'apprentissage. Une fois cette partie introductive de l'assembleur correctement maîtrisée, vous serez en mesure d'aborder le reste non pas instruction par instruction, mais par l'appropriation de technologies  : FPU, MMX, 3DNow!, SSE/SSE2 ; cette appropriation s'accompagne alors d'une présentation synthétique des nouvelles instructions spécifiques à cette technologie.

7.1.1 Structure d'une instruction

En assembleur , MASM par exemple, il est habituel de distinguer quatre champs dans une instruction :

  Une étiquette, ou label, facultative.

  Le mnémonique de l'instruction, le seul champ obligatoire.

  De 0 à plusieurs opérandes, ou arguments. Ce nombre dépend de l'instruction.

  Un champ commentaire, facultatif.

Un exemple en MASM :

debut: mov eax, [eax] ;transfère dans EAX la donnée qu'il pointe

Sous Delphi ou C++Builder, il se présenterait ainsi :

@debut:   mov eax, [eax] //transfère dans EAX la donnée qu'il pointe

Les éléments 1 et 4 sont propres à l'assembleur. Le langage machine ne connaît qu'un code de 1 à plusieurs octets, que le processeur va analyser pour identifier une instruction, ses arguments éventuels et parfois un préfixe.

Attention, une instruction sur 1 octet peut représenter un mnémonique avec opérande ( PUSH CX ), alors que 2 octets peuvent être nécessaires pour une instruction dénuée d'argument ( CPUID , RDTSC par exemple). Les raisons, d'efficacité et historiques, sont faciles à imaginer :

Au départ, un jeu d'instructions 16 bits, sur lequel il a été facile de coder sur un seul octet les instructions fréquemment utilisées. Par exemple, la série des PUSH registre se code par un seul octet, obtenu en ajoutant à une valeur donnée, 50h ici, une valeur entre 0 et 7 qui indique le registre. L'autre solution aurait été de coder systématiquement par 50h et d’employer un opérande pour coder le registre, ce qui aurait fait utiliser un octet de plus mais rendu libre pour une autre utilisation les valeurs de 51h à 57h .

Puis peu à peu, le jeu d'instructions et le choix des opérandes se sont considérablement étoffés, d'où un grand besoin de codes. La compatibilité a empêché de revenir en arrière : un 51h en tant que premier octet suivant une instruction est définitivement un PUSH ECX . En d'autres termes, la compatibilité ascendante a rendu impossible le codage des instructions sur 16 bits. Les techniques de modes de fonctionnement et d'attributs divers ont facilité les choses, mais le manque de codes était réel. Les codes restants, les sauvés du gaspi, se sont retrouvés en vedette, et il a fallu en faire le meilleur usage. En particulier en les utilisant comme préfixe à un autre octet, ce qui peut être vu comme une façon de coder sur 16 bits ce qui peut encore l'être. Il est possible d'y voir également l'idée de préfixe définissant une famille d'instructions, ce qui existait déjà pour les instructions FPU du 8087. Ainsi, CPUID et RDTSC débutent par 0Fh , et nous verrons qu'elles sont fonctionnellement liées et que 0Fh introduit des instructions dites instructions système ou instructions privilégiées.

Cette idée d'augmenter la taille de codage des instructions se justifie par deux points au moins : la taille du code est devenue une caractéristique secondaire dans les programmes d'aujourd'hui (un processus de lecture vidéo de quelques dizaines de kilo-octets va couramment traiter des fichiers de plusieurs giga-octets, exemple réellement disponible au supermarché). De plus, ces instructions de plus grande taille ne sont pas nécessairement plus longues à traiter, l'interprétation du préfixe pouvant être faite de façon asynchrone, dans la structure de caches et pipelines divers.

Donc, PUSH CX sur un octet et CPUID sur deux n'est plus un mystère.

Quelques instructions et leur code objet
figure 7.01 Quelques instructions et leur code objet

 

Tiens, PUSH ECX a le même code que PUSH CX . Eh bien oui, nous sommes dans un mode de fonctionnement qui associe par défaut aux instructions des opérandes 8 ou 32 bits. Nous avons évoqué ce point dans les chapitres sur l'architecture, et nous y reviendrons.

Nous avons donc abordé, mais à peine, la forme prise en mémoire par l'instruction, dans le code objet. Ce sont des données peu importantes en général pour le programmeur, qui sont prises en charge par l'assembleur.

Il n'en demeure pas moins intéressant d'avoir une idée sur la façon de coder un jeu d'instructions de plus en plus complexe, tout en maintenant la compatibilité ascendante et en continuant à favoriser les instructions les plus primitives par un codage sur peu d'octets.

La documentation Intel nous montre ce à quoi ressemble le schéma général d'une instruction en machine.

Format général d'une instruction
figure 7.02 Format général d'une instruction [the .swf]

Nous pouvons distinguer plusieurs champs dans ce schéma, dont un seul est obligatoire : l'opcode.

Préfixe  : c'est le modificateur de l'instruction. Il occupe de 0 à 4 octets. Ces préfixes sont répartis en 4 groupes, et il ne sera possible d'appliquer qu'un seul préfixe par groupe. Ils concernent LOCK (voir ce mot), la répétition par REPEAT , les surcharges de segment, de taille d'opérandes et d'adresses, etc. Les mots LOCK et REPEAT n'ont de sens que dans du source assembleur (ou de la documentation). La capture d'écran suivante est intéressante car elle montre que le préfixe 66h modifie la taille de l'opérande de 32 en 16 bits, pour le PUSH et le dernier  ADD . Nous sommes en effet en mode 32 bits par défaut (cet exemple, comme beaucoup d’autres dans ce chapitre, est pris sur du code en ligne BASM de DELPHI). 26h est le préfixe de surcharge de segment (voir à ce sujet la section suivante sur les modes d'adressage), il permet d'imposer ES comme registre de segment à la place du DS implicite. Enfin, dans ADD AX, ES:[EBX] , les deux préfixes sont appliqués simultanément.

Effets de deux préfixes
figure 7.03 Effets de deux préfixes

Il y a un rapport entre le fait que l'accès aux registres 16 bits s’effectue en préfixant l'instruction 32 bits correspondante et celui de dire que, dans cette génération de processeurs, la donnée naturelle est de 32 bits. C'est également la notion d'entier générique en Pascal. Le mode 32 bits est souvent qualifié de mode natif de l'architecture IA32.

  Opcode  : est le code de l'instruction elle-même. Notez que la frontière avec le champ suivant  ModR/M est floue. Dans une instruction comme POP EAX , codée sur 1 octet, quelques bits de l'opcode déterminent le registre opérande, comme nous l'avons abondamment commenté.

  ModR/M  : détermine de façon générale le mode d'adressage et le nom du registre faisant partie du mode d'adressage s'il y a lieu. Sa valeur détermine la présence ou non des champs suivants. Il peut également préciser le sens du champ opcode .

  SIB  : ces trois lettres correspondent aux mots scale, index et base. Reportez-vous à la section suivante sur les modes d'adressage.

  Déplacement  et Immédiat  : ces deux champs codent un déplacement et une valeur immédiate, selon les nécessités déterminées par les champs ModR/M et SIB précédents.

Vous pouvez vous amuser à interpréter le désassemblage suivant, à l'aide des tableaux fournis dans la documentation Intel (Vol. II, § 2.6) :

662603845378563412 add ax, es:[ebx + edx * 2 + $12345678]

Pensez à lire la partie consacrée à l'emplacement des octets en mémoire (little endian) dans ce livre, si ce n'est déjà fait.

Bon courage.

7.1.2 Les modes d'adressage

Une fois l'instruction identifiée, avec éventuellement des préfixes, ce qui reste décrit les données sur lesquelles va s'appliquer l'instruction. Trouver ces données, c'est cela, au sens large, que nous appellerons les modes d'adressage . Une donnée peut se situer :

  Codée directement avec l'instruction : c'est l'adressage immédiat .

  Présente dans un registre spécifié par l'instruction : c'est l'adressage registre .

  Quelque part en mémoire, à un emplacement indiqué dans l'instruction : c'est l'adressage mémoire .

  Sur un port d' entrée/sortie . Nous verrons cela rapidement, avec les instructions IN et OUT .

En règle générale, une instruction INST manipule deux données : la source   SRC et la destination  DEST. Dans le cas général, la notation est : INST DEST , SRC , ou INST DEST si l'instruction ne prend qu'un seul opérande.

SRC est modifiée par l'opération  INST , éventuellement en faisant intervenir DEST , et le résultat déposé dans  DEST . Par exemple :

  ADD Var32, ECX additionne ECX à la valeur actuelle de Var32 et place le résultat dans Var32 . Var32 désigne une variable, donc une adresse en mémoire.

  MOV EAX, $1234 place la valeur source immédiate $1234 dans EAX.

  XOR EDX, EDX exécute le OU exclusif de EDX avec lui-même, et dépose le résultat, soit 0, dans EDX.

La logique indique que l'adressage immédiat ne concerne que la source. Les adressages registre et mémoire peuvent définir la source et/ou la destination. Une contrainte : il n'est pas possible d'utiliser l'adressage mémoire sur les deux opérandes d'une même instruction. Cette limitation doit être connue, puisque conceptuellement il n'y a aucune raison que ce soit impossible : transférer une valeur d'un endroit en mémoire vers un autre est fréquent, et la limitation que nous évoquons nous obligera à le faire en deux temps. Pendant longtemps, il a été possible d'affirmer qu'en termes de séquencement électrique, le résultat serait identique ou presque : même avec un bon niveau de parallélisme au cœur du processeur, l'accès mémoire est séquentiel. Il faudrait donc aller chercher la première donnée, la mettre au chaud dans un registre, puis l'écrire. Les techniques d'accès mémoire récentes, basées sur les notions de requêtes voire de parallélisme (ou pseudo-parallélisme ?) pourraient changer cet état de fait.

Ce schéma reste valable si une seule donnée est fournie :

NEG VarSigned32 inverse le signe de la variable VarSigned32 et place le résultat dans VarSigned32 .

Certaines données sont implicites : dans POP EAX , la destination est EAX, et la source le sommet de la pile, soit [ESP] .

Le fait que l'affectation s’effectue de la droite vers la gauche doit devenir naturel. Cette habitude se prend généralement rapidement, même si certains trouvent au début cet ordre troublant.

Adressage immédiat

L'adressage immédiat consiste à coder directement une valeur dans l'opérande. Remarquons finement qu'une donnée immédiate ne peut être qu'un opérande source. Elle ne doit pas être confondue avec la valeur immédiate d'un déplacement. Son codage en machine est donc fait à la suite de l'opcode, en little endian. Les assembleurs laissent généralement une grande latitude dans la saisie des valeurs immédiates. Profitez-en impérativement pour que la valeur saisie soit parlante ; cela remplace avantageusement les commentaires :

mov al, 103   ; code du caractère g dans al
mov al, 'g'
........
and al, 7     ; masque pour extraire les 3 premiers bits
and al, 00000111b

Quelles sont les lignes les plus efficaces, ou les plus jolies ?

Les instructions de division entière  DIV et  IDIV sont les seules instructions arithmétiques à ne pas accepter d'opérande source immédiat. Aucune instruction n'admet en IA32 d'argument immédiat de largeur supérieure à 32 bits.

Adressage registre

Dans l'adressage de type registre, c'est le contenu d'un registre qui est utilisé ou c'est vers le registre lui-même que se dirige le résultat. Si le contenu du registre est considéré comme un offset, le cas relève de l'adressage mémoire.

Les registres se classent, quant à leur comportement en tant que donnée, en plusieurs catégories :

  Les registres généraux : EAX, EBX, ECX, EDX, ESI, EDI, EBP en 32 bits, AX, BX, CX, DX, SI, DI, BP en 16 bits et AH, BH, CH, DH, AL, BL, CL, DL en 8 bits. Nous pouvons ajouter le couple EDX:EAX , considéré comme un registre 64 bits par certaines instructions. Ces registres sont tous utilisables pour accueillir de données, même si certains sont spécialisés dans un usage particulier. Du plus général au plus spécialisé, nous pouvons proposer l'ordre suivant : EAX, EDX, EBX, ECX, ESI, EDI, EBP.

  Les registres très spécialisés : EFLAGS, EIP et ESP en 32 bits, FLAGS, IP et SP en 16 bits. Leur manipulation correspond à des opérations très particulières, éventuellement à une mauvaise idée.

  Les registres de segment : CS, DS, SS, ES, FS, GS.

  Les registres système : CR0 à CR4, TR, GDTR, LDTR, IDTR, les registres de débogage dont nous n'avons pas parlé, les registres MSR (spécifiques au modèle) et d'autres.

Les registres généraux seront source ou destination pour les instructions "normales".

L'instruction MOV par exemple pourra être utilisée pour manipuler les registres de segment, à l'exception de l'utilisation de CS (segment de code) comme destination : MOV CS, AX se compile, mais génère une exception. Mais l'instruction MOV appliquée à des registres système (MOV to/from debug Registers, MOV to/from Control Registers) devient une instruction privilégiée.

Le registre ESP peut être modifié, mais dans le cadre strict de son utilisation, de la maintenance de la pile.

Le registre EFLAGS, en dehors de son utilisation classique dans les sauts conditionnels Jcc par exemple, pourra être manipulé directement, mais de façon restrictive et contrôlée par les instructions PUSHFD et POPFD ( PUSHF et POPF pour FLAGS).

Le registre EIP, qui indique un déplacement dans le segment de code, pointé par CS, vers la prochaine instruction à exécuter, s'autogére le plus souvent. Il est cible dans toutes les instructions provoquant un saut de programme.

Enfin, les registres système seront travaillés au sein de procédures particulières par des instructions système, dont le registre en question est parfois un paramètre implicite.

Nous donnerons si nécessaire des renseignements supplémentaires lors de la présentation du jeu d'instructions.

Adressage mémoire

L'adressage mémoire concerne les données qui sont situées quelque part dans la mémoire. Les méthodes de détermination de ce quelque part sont très puissantes. Ce sont elles qui justifient le titre "modes d'adressage" de ce chapitre.

Nous avons signalé que, quand une instruction admet deux opérandes, source et destination, un seul peut être dans la mémoire, l'autre étant une donnée immédiate ou un registre. Les opérations ou les transferts mémoire-mémoire devront se traiter en deux temps, si nous faisons exception du cas particulier de la pile : push dword ptr [var32] réalise effectivement un transfert intersegment mémoire vers mémoire.

Une donnée, tout comme une adresse de code, est pointée en mémoire de la façon la plus générale par la donnée d'un segment , sur 16 bits, et d'un déplacement depuis le début de ce segment ou offset sur 16 ou 32 bits. Le fait que ce segment soit un sélecteur ou non importe peu au niveau de l'instruction.

Structure d'une adresse
figure 7.04 Structure d'une adresse [the .swf]
Reportez-vous au chapitre intitulé Architecture système pour plus de détails.

Nous travaillerons à priori dans deux cas de figure : le mode 8086, qu'il soit le mode Réel ou V86, ce dernier est en réalité un mode Protégé, mais vu comme un mode 8086, soit le mode Protégé (flat) 32 bits. Ce mode est celui des applications sous Windows, c'est celui dans lequel nous met d'autorité Delphi par exemple. Le mode Protégé 16 bits est plus marginal.

Nous travaillerons, dans un premier temps au moins, et plus sans doute, dans l'environnement mémoire mis à notre disposition par le système d'exploitation, lui laissant le soin de la gestion de la mémoire. Vous devez retenir essentiellement les registres de segments sur 16 bits et l'adresse effective, sur 16 ou 32 bits, ainsi que l'existence d'un mode 16 bits et d'un mode 32 bits, ces chiffres correspondant à la taille de l'adresse effective.

détermination du segment

Un segment sera affecté par défaut pour compléter l'adresse de chaque référence à la mémoire, comme opérande d'une instruction ou tout autre accès mémoire. Ce mécanisme prend en compte le contexte, et ce d'une façon fort logique. Le voici, résumé dans un tableau :

Affectation d'un segment par défaut

Type de référence mémoire

Type du segment

Reg.

Règle de sélection

Instructions

Code (code)

CS

Chargement d'instruction.

Pile

Pile (stack)

SS

Instructions POPxx et PUSHxx .

Données

Données (Data)

DS

Toute référence à des données, sauf relatives à la pile et chaînes décrites ci-dessous.

Chaînes

Données (Data)

ES

Destination des instructions chaînes ( string ).

Dans un certain nombre de cas, ces segments par défaut pourront être surchargés, mot qui traduit le moins mal l'anglais "overriden". En effet, c'est bien surchargé, et non modifié, qui convient, puisque le registre de segment par défaut reste le même par la suite, son contenu est inchangé, mais il est supplanté le temps de l'instruction par un autre registre.

Un cas de surcharge fréquent est la surcharge de DS par ES. En assembleur, elle s'écrit MOV EAX, ES:offset . Nous avons vu qu'au niveau machine, l'instruction normale sera préfixée par 26h .

L'instruction très courante MOV EAX, DWORD PTR [EBP+04h] qui va chercher une variable locale vers le sommet de la pile ne provoque pas de surcharge. Comme indiqué sur le tableau, la référence à EBP suffit à indiquer SS comme registre de segment. Donc, même si les cadres de pile sont du ressort des concepteurs de systèmes d'exploitation et de logiciels de programmation, et non une réalité du processeur, messieurs Intel, Microsoft, Borland et consorts doivent de temps en temps partager une pizza ou une assiette de sushis.

La surcharge n'est pas possible dans les cas suivants :

  Le processeur ira toujours chercher les instructions dans le segment de code.

  Dans les instructions sur les chaînes, la chaîne de destination sera toujours sauvée dans le segment de données pointé par ES.

  Les instructions PUSH et POP utiliseront toujours le segment de pile pointé par SS.

Détermination de l'adresse effective

 ou offsetCette adresse est déterminée, dans le cas le plus général, par le polynôme suivant.

Calcul de l'adresse effective
figure 7.05 Calcul de l'adresse effective [the .swf]

Nous vérifions que Base est un choix d'un registre parmi huit et peut donc se coder sur 3 bits, de même que Index (un registre parmi sept, 3 bits également). Scale est un choix d'une valeur parmi 4 et 2 bits suffisent. Cela est conforme à la structure machine de l'instruction, vue précédemment.

Nous voyons également comment coder une version complète de l'instruction pour un assembleur. Version complète, car aucun élément parmi Base, Index, Scale et Déplacement n'est obligatoire. Bien entendu, pour utiliser Scale, il faut définir Index.

Cette formule très complète et puissante cache des possibilités d'adressage très variées. Elle impose également quelques contraintes.Voyons quelques cas de figures :

Déplacement seul

mov ax, [$00410000]

Charge AX par le contenu de la mémoire 16 bits situé en DS:00410000 . Adressage direct , ou absolu, ou statique. L'adresse est donnée de façon immédiate, codée dans l'instruction. Ne confondez pas toutefois avec l'adressage immédiat :

mov eax, $00410000

où c'est la valeur de la donnée, et non son adresse , qui est codée de façon immédiate.

Base seule

mov edx, $00410000  // préparation
mov ax, [edx]

Il s'agit de l'adressage indirect , la destination AX est chargée par la valeur trouvée à l'adresse pointée par le registre EDX.

Base + Déplacement ou Déplacement + Base

mov ax, [edx + $120]
mov ax, [$120 + edx]

Ces deux lignes représentent deux fois la même instruction. La première est parlante si EDX contient l'adresse d'une structure complexe, élément d'un tableau, et si $120  est l'offset d'un membre 16 bits de cette structure. Dans la seconde, $120  est l'adresse d'un tableau d'entiers 16 bits, et EDX permet de se positionner de façon dynamique dans ce tableau.

Déplacement + (Index x Scale)

mov eax, [$00410000 + edx * 4]

$00410000  peut représenter l'adresse du début d'un tableau d'entiers de 32 bits. EDX contient alors le rang de l'élément à charger dans EAX.

Déplacement + Base + Index

mov eax, [$00410000 + ebx + edx]

Si nous travaillons sur un tableau d'entiers 32 bits à deux dimensions, $00410000 représente l'adresse du début du tableau, EBX l'index ligne i multiplié par le nombre de colonnes et EDX l'index colonne j. Alors, la donnée atteinte est D[i, j] .

Nous pourrions multiplier les exemples. Notons que les noms Base, Index, Scale et Déplacement ne sont pas toujours heureux, selon le rôle que nous leur affectons, ainsi que l'ordre dans lequel ils sont cités dans les documentations. Par exemple, le Déplacement devrait souvent être cité en tête.

Dans le cas le plus général, nous pourrons pointer dans un tableau de structures ou d'instances de classes. Le déplacement, codé en dur, sera tout à fait adapté pour exprimer l'offset d'une propriété au sein de la structure. Nous serons souvent restreints par l'étendue de valeur de scale ; celui-ci est trop limité pour se déplacer de structure en structure dans le tableau ; il est donc réservé à des données simples.

La difficulté pour un débutant, et même un peu plus, est de saisir correctement l'expression, en accord avec l'assembleur utilisé. Une vérification au moyen d'un fichier listing, d'un désassemblage ou d'un débogueur sera au début très utile.

7.1.3 Les fiches d'instruction

Nous espérons, et nous pensons, que ce chapitre couvrira la quasi-totalité des besoins documentaires concernant le jeu d'instructions. Mais ce jeu va évoluer.

De plus, en cas de problème pour faire fonctionner un programme, il est humain de mettre en cause la planète entière avant de se pencher sérieusement sur son propre code. Paroles d'un spécialiste de la chose qui espère être guéri. Il nous faut donc une référence indiscutable sur les instructions.

Cette référence existe ; elle est fournie par Intel, au format  .pdf , en libre téléchargement : IA32-Intel Architecture Software Developer’s Manual, Volume 2: Instruction Set Reference . C’est la référence officielle. Il existe également nombre de sites consacrés aux secrets du microprocesseur et autres instructions et registres cachés. Reportez-vous aux liens sur le CD-Rom. Il serait vraiment dommage de vous en passer.

À côté de ses immenses qualités, cette documentation présente deux défauts :

  Elle est en anglais. C’est gênant, car en plus de fiches, elle contient d’excellents chapitres explicatifs.

  La liste alphabétique des instructions, de plus de 400 pages, n’est soutenue par aucune mention dans l’onglet Signets , sous Acrobat Reader. L’index n’offre pas non plus de liens directs. Pour des résultats corrects, il faut se positionner vers le début de la liste et, dans la boîte de recherche, saisir le nom de l’instruction en capitales et cocher Respect de la casse et Mot entier .

Sur ce dernier point, les documents d’origine AMD ( .pdf également, téléchargeables) sont plus légers et plus pratiques. Les fiches d’instructions sont pratiquement une copie carbone de celles d’Intel. La dernière référence complète que nous ayons trouvée sur le site est celle de l’AM486, datant de 1994. Ensuite, nous ne trouvons plus que des documents thématiques ou consacrés aux évolutions d’un modèle. La documentation complète AMD est vendue sur CD-Rom.

Remarque

Dernière minute !

C'est ce que nous écrivions dans la première édition. Et au moment de terminer cette seconde mouture, divine surprise au téléchargement de la dernière version : un magnifique index accompagne la liste absolument complète du jeu d'instructions. À télécharger impérativement, les trois volumes de références Basic Architecture : 245470-011 , Instruction Set Reference : 245471-011 et System Programming Guide : 245472-011 . Les derniers liens valides sont sur le CD-Rom, et pour une fois, en voici un : http://developer.intel.com/design/pentium4/manuals/ .

Joie !
figure 7.06 Joie !

 

Parcourons une fiche issue de la documentation Intel. Les images sont des extraits de copies d’écran sous Acrobat Reader.

 

En-tête d’une fiche instruction
figure 7.07 En-tête d’une fiche instruction

En titre, se trouvent le mnémonique et sa signification en anglais.

Dans le tableau qui suit, l’ opcode est donné, dans une syntaxe développée dans le même manuel, en début de la liste des instructions. Nous y retrouvons la structure des instructions déjà évoquée.

La colonne Instructions est utile : elle fournit en condensé l’ensemble des modes d’adressage possibles. La colonne Description apporte peu de renseignements supplémentaires, nous savons lire la précédente.

Remarque

Deux codes pour la même instruction ?

Vous trouverez souvent dans une fiche, pour la même instruction, deux modes qui semblent faire double emploi : ADC r/m32, imm32 et ADC EAX, imm32 par exemple. La seconde expression semble être contenue dans la première. La réponse est au niveau du codage machine de l'instruction. Les deux existent.

*           La première est l'instruction normale, issue de la structure générale d'une instruction quant à son mode d'adressage.

*           La seconde est une forme plus compacte et certainement plus performante, réservée aux cas d'adressages les plus fréquents. Tous les assembleurs gèrent ce type d'optimisation en choisissant la forme la plus adaptée.

Description textuelle
figure 7.08 Description textuelle

La description est un véritable petit chapitre pour certaines instructions (voir CALL ). Elle dépasse la simple explication fonctionnelle. Il est par exemple rappelé ici que la caractéristique signée ou non d’un nombre dépend de l’idée que s’en fait le programmeur et n’a pas d’existence réelle dans le microprocesseur. L’utilisation habituelle est évoquée. Remarquez que cette description semble affirmer que l’état de CF représente toujours la retenue d’une addition précédente. Voyez l’exemple IntToBin au chapitre sur l'assembleur intégré ; cela n’est pas une obligation.

Description algorithmique
figure 7.09 Description algorithmique

Sous le titre Opération , nous trouvons un pseudo-code, qui est la description sèche mais exacte du comportement d’une instruction. C’est l’algorithme du microcode. Celle de ADC n’est pas impressionnante. Celle de CALL occupe 6 pages. Voici celle de BSF (Bit Scan Forward).

Instruction BSF
figure 7.10 Instruction BSF

Avec cet algorithme et le nom de l’instruction, pourrez-vous déterminer ce qu’elle effectue ? Quand ce point sera réglé, demandez-vous à quoi elle sert. La fiche est muette à ce sujet.

Description textuelle
figure 7.11 Description textuelle

Le rôle de la rubrique est clair. C’est un point à vérifier quand un code ne fait pas ce que vous en attendez. Il peut par exemple être utile de savoir que NOT , l’inversion bit à bit, ne positionne aucun flag, de même que MOV .

Description textuelle
figure 7.12 Description textuelle

Les rubriques sur les exceptions possibles selon le mode de fonctionnement seront utiles pour déboguer un programme, à partir des informations d’un débogueur ou du système d’exploitation. Le programme pourrait également traiter certaines exceptions, division par 0 par exemple.

Si vous comparez cette fiche et une autre, un peu plus ancienne (document AMD sur le 486 par exemple, mais il en est de même pour les fiches Intel de la même époque), vous constaterez de très grandes similitudes et une petite différence, mais significative.

Une fiche plus ancienne
figure 7.13 Une fiche plus ancienne

Cette ancienne documentation mentionnait un nombre de cycles nécessaires pour effectuer l'instruction. Parfois, cette valeur variait selon le résultat, dans les instructions conditionnelles. Ces données ne sont même plus aujourd'hui une indication exploitable. Il faut se tourner vers les guides d'optimisation des fabricants, dans lesquels nous trouvons un autre type de tableaux de performances.

7.1.4 Les indicateurs d'état

Tous les microprocesseurs disposent d'au moins un registre, interprétable non pas globalement mais bit par bit, centralisant les indicateurs d'état et de contrôle. Dans l'architecture IA, il s'agit du registre FLAGS (16 bits), étendu en EFLAGS depuis le 80386.

Registres de FLAGS et EFLAGS
figure 7.14 Registres de FLAGS et EFLAGS [the .swf]

Chacun de ces bits est un flag . Vous pouvez employer indifféremment les mots fanion , indicateur , voire drapeau . Mais, le plus souvent, un flag particulier sera désigné par son abréviation. Par exemple ZF pour "zéro flag" ou PF pour "parity flag".

Voici un tableau résumant les caractéristiques des flags du registre EFLAGS (et donc FLAGS) :

 

Les bits de EFLAGS

Symbole

Bit

Type

Nom

Traduction

ID

21

SYSTÈME

ID Flag

Identification. La possibilité de modifier ce bit indique que l’instruction CPUID est implémentée.

VIP

20

SYSTÈME

Virtual Interrupt Pending Flag

Interruption(s) en attente. En conjonction avec VIF, indique qu’au moins une interruption est en attente. Écrit par logiciel, lu par le microprocesseur.

VIF

19

SYSTÈME

Virtual Interrupt Flag

Image virtuelle de IF. Quand VME est activé, image en mode V86 de IF.

AC

18

SYSTÈME

Alignment Check Flag

Vérification de l’alignement mémoire. Si positionné en même temps que AM de CR0, autorise la vérification de l’alignement mémoire. Dans ce cas, une exception est déclenchée lors de l'accès à des données non alignées sur leur propre taille.

VM

17

SYSTÈME

Virtual-8086 Mode Flag

Mode Virtuel 8086. Indique ou positionne le mode V86.

RF

16

SYSTÈME

Resume Flag

Reprise. Contrôle la réponse aux exceptions de débogage.

NT

14

SYSTÈME

Nested Task Flag

Tâche chaînée. Indique si la tâche courante est liée à une tâche parent par un CALL ou une interruption.

IOPL

12, 13

SYSTÈME

I/O Privilege Level

Niveau de privilège E/S. Indique le niveau de privilège E/S de la tâche courante. Codé sur 2 bits (4 valeurs). Doit être inférieur ou égal à IOPL pour que la tâche puisse accéder aux E/S.

OF

11

ÉTAT

Overflow Flag

Dépassement en arithmétique signée. Traduit un dépassement affectant le bit de signe (MSB-1 vers MSB). Voir Annexe A.

DF

10

CONTRÔLE

Direction Flag

Direction. Le positionner pour décrémenter dans les instructions chaînes ( MOVS , CMPS , SCAS , LODS , STOS ).

IF

9

SYSTÈME

Interrupt Enable Flag

Autorisation d'interruptions matérielles masquables. Le positionner pour autoriser le traitement de ces interruptions.

TF

8

SYSTÈME

Trap Flag

Mode Pas à pas (trace). Le positionner pour valider ce mode. Dans ce mode, une exception est levée après chaque instruction.

SF

7

ÉTAT

Sign Flag

Signe. Recopiage du MSB du résultat.

ZF

6

ÉTAT

Zero Flag

Zéro. Si le résultat est nul (traduit également l’égalité).

AF

4

ÉTAT

Adjust Flag

Retenue intermédiaire. Si un dépassement est généré au-delà du bit 3 (utilisé en BCD).

PF

2

ÉTAT

Parity Flag

Parité. Si le nombre de bits à 1 de l’octet bas est pair.

CF

0

ÉTAT

Carry Flag

Retenue. Si un dépassement est généré au-delà du MSB

D'une façon générale, en fonctionnement normal :

  Les flags d'état (status flags) sont positionnés par le déroulement du programme. Ils seront essentiellement utilisés dans les instructions conditionnelles et les codes de conditions, vus à la section suivante. Ce sont des indicateurs.

  Les flags système (system flags) concernent... la programmation système.

  Les flags de contrôle (control flags) sont positionnés par le programmeur pour modifier le comportement du système. Ce sont en quelque sorte des commandes. Ainsi, une fois DF (Direction Flag) mis à 1, toutes les instructions chaînes fonctionneront par décrémentation du compteur, jusqu'à modification de DF.

7.1.5 Les codes de conditions

Il existe des instructions dites conditionnelles. À l’origine, seules les instructions de saut pouvaient être conditionnées ; aujourd'hui encore, elles sont de très loin les plus courantes.

Nous trouvons, au côté des Jcc , les familles LOOPcc , CMOVcc , FCMOVcc , SETcc . Ce sont respectivement des sauts, saut avec gestion d'un compteur, déplacement de donnée entière, déplacement de donnée flottante, mise à 1 ou à 0 d'une donnée, dont l'action dépend de la valeur d'un flag ou d'une combinaison de flags.

Il est clair que les instructions de saut Jcc suffisent à résoudre tous les problèmes d'algorithmique. Les autres ne sont là que pour plus de confort.

Ces conditions sur les flags, génériquement le cc de Jcc , sont "normalisées" sous le nom de "Condition Codes" en anglais.

Ils sont résumés dans le tableau suivant.

Les codes de conditions et leur sens
figure 7.15 Les codes de conditions et leur sens [the .swf]

Pour obtenir le mnémonique final, il faut remplacer cc par son expression.

Pour que le programme effectue un saut si le résultat d'une opération est nul, il faudra faire suivre l'instruction effectuant cette opération de l'instruction JZ (pour Jump if Zero).

Les conditions sur les flags correspondant à chaque cc sont regroupées dans le tableau :

Conditions sur les flags des cc

cc

Arithmétique

Condition

O

Les deux

OF = 1

NO

Les deux

OF = 0

E, Z

Les deux

ZF = 1

NE, NZ

Les deux

ZF = 0

B, C, NAE

Non Signée

CF = 1

NB, NC, AE

Non Signée

CF = 0

BE, NA

Non Signée

(CF or ZF) = 1

NBE, A

Non Signée

(CF or ZF) = 0

S

Les deux

SF = 1

NS

Les deux

SF = 0

P, PE

Les deux

PF = 1

NP, PO

Les deux

PF = 0

L, NGE

Signée

(SF xor OF) = 1

NL, GE

Signée

(SF xor OF) = 0

LE, NG

Signée

((SF XOR OF) OR ZF) = 1

NLE, G

Signée

((SF XOR OF) OR ZF) = 0

Plusieurs cc différents se traduisent souvent par la même condition sur les flags. La raison est l'effet mnémonique recherché, qui permettra d'en choisir un selon le contexte. Le cas le plus évident est JZ / JE . C'est strictement la même instruction, un saut si ZF est à 1. Mais cet événement peut, soit traduire qu'une valeur est nulle, soit que deux valeurs sont égales, à l'issue d'un SUB ou d'un CMP par exemple.

Les anglophones sont, encore une fois, avantagés. Il est fortement conseillé, si possible, de penser ces codes en anglais. Pour autant, il faut être prudent. CMOVNBE se dira : Move if Neither Below nor Equal , soit Déplace si ni au-dessous, ni égal . Donc, Déplace si au-dessus . La condition est (CR OR ZF) = 0 . À ne pas confondre avec Déplace si pas au-dessus ou égal , dont la signification demanderait des parenthèses.

Cette particularité de mnémoniques multiples n'existe que dans la documentation et dans les outils comme les assembleurs. Le microprocesseur ne connaît que l'opcode.

Pour preuve, sous DEBUG, saisissez :

-a 100
1D2B:0100 cmp ax, cx
1D2B:0102 je 120
1D2B:0104 dec ax
1D2B:0105 jz 130
1D2B:0107 int 20
1D2B:0109

Reformulons ce code en langage courant :

Compare AX et CX (positionne ZF)
Saute en 120 si Egaux (si ZF = 1)
Décrémente AX (positionne ZF)
Saute en 130 si Nul (si ZF = 1)

Puis désassemblez :

-u 100 108
1D2B:0100 39C8          CMP     AX,CX
1D2B:0102 741C          JZ      0120
1D2B:0104 48            DEC     AX
1D2B:0105 7429          JZ      0130
1D2B:0107 CD20          INT     20

Il n'aurait pas été bien difficile au désassembleur de DEBUG de traduire par JZ en général et par JE si l'instruction précédente est un CMP. Sauf s'il s'agit de CMP xx, 0 .

Justement, d'autres précisions sur les comparaisons seront données plus loin dans ce chapitre, avec les instructions arithmétiques, et en particulier CMP.

7.2 Le jeu d'instructions

Nous allons parcourir le jeu d'instructions x86 "standard" par familles, en donnant souvent un exemple. L'ensemble des exemples est consigné dans un programme Delphi. Vous trouverez sur le CD-Rom une version complète de ce programme, ainsi qu'une version vierge du code exemple. Seules les variables et le squelette sont conservés.

// Variables à la disposition des tests
// Globales au niveau Unité
u08_0:Byte;     // 8 bits non signé
resut_u16,
u16_0:Word;     //16 bits non signé
u32_0:Longword; //32 bits non signé
 
s08_0:Shortint; // 8 bits signé
result_s16,
s16_0:Smallint; //16 bits signé
s32_0:Longint;  //32 bits signé
s64_0:Int64;    //64 bits signé

Les variables sont déclarées au niveau de l'unité (de la fiche). Ce sont des variables globales, si vues depuis les procédures où le code est testé. Ce détail permet de ne les saisir qu'une seule fois : elles sont placées dans le tas ; mais, surtout, ce fait change le comportement du compilateur, comme expliqué par ailleurs.

Dans la plupart des cas, la sortie est moins intéressante que le code lui-même. En mode Édition de programme, il suffit de double-cliquer sur le bouton de la fiche correspondant aux instructions pour accéder directement au code concerné.

Nous tentons de rassembler les groupes d'instructions en chapitres thématiques. Par exemple, les instructions BT , BTS , BTR et BTC forment indubitablement un groupe. Celui-ci est étudié dans la section sur les instructions de manipulation de bits. Il ne faut pas accorder trop d'importance à cette classification. Par exemple, l'instruction XADD n'est qu'une séquence XCHG puis ADD. Où la classer ? Avec XCHG (mouvements de données) ou avec ADD (calcul arithmétique) ? Dans ce cas, nous nous sommes inspirés de la documentation Intel, qui est notre principale source d'informations.

7.2.1 Mouvements des données

Le mouvement des données concerne leur déplacement dans les registres et la mémoire, ainsi que l'affectation d'une valeur immédiate à une mémoire ou à un registre. Il peut être une simple copie d'une source vers une destination, un échange entre source et destination sans mémoire intermédiaire explicite. Des instructions spécifiques existent quand la mémoire est la pile. Certains mouvements traités dans ce chapitre s'accompagnent d'une modification des opérandes.

Transfert de données : MOV

L'instruction MOV  copie la valeur de l'opérande source dans l'opérande destination. C'est donc plus un "copy" au sens de Windows qu'un "move". Cette instruction est fondamentale, et sa richesse tient à la définition de la source et de la destination, ainsi que des modes d'adressage de la mémoire. Passons en revue le matériel transférable.

 

Les transferts classiques

Source

Destination

r8

r/m8

r16

r/m16

r32

r/m32

r/m8

r8

r/m16

r16

r/m32

r32

imm8

r8

imm6

r16

imm32

r32

imm8

r/m8

imm6

r/m16

imm32

r/m32

Reportez-vous à la présentation des modes d'adressage pour des exemples. Remarquez simplement à nouveau l'impossibilité de coder un transfert direct de mémoire à mémoire.

 

Les transferts depuis et vers AL, AX et EAX

Source

Destination

Byte situé à [segment:offset]

AL

Word situé à [segment:offset]

AX

Doubleword situé à [segment:offset]

EAX

AL

Byte situé à [segment:offset]

AX

Word situé à [segment:offset]

EAX

Doubleword situé à [segment:offset]

Voilà quelques instructions qui ont l'air bien complexes, surtout après avoir lu la documentation. Alors qu'il s'agit tout simplement d'une version des précédentes, réduite à AL, AX et EAX, et plus compacte. Voyons le code d'essai suivant :

mov  al, u08_0 // placer un point d'arrêt
mov  ax, u16_0
mov eax, u32_0
 
mov u08_0,  al
mov u16_0,  ax
mov u32_0, eax
 
mov u08_0,  bl
mov u08_0,  cl
mov u08_0,  dl
 
db $88
db $05
dd $0045BC88 // attention, à modifier

Observons ce programme à l'aide de la fenêtre CPU.

Fenêtre CPU
figure 7.16 Fenêtre CPU

Les six premières lignes (au sens du listing source) montrent que Delphi utilise bien la forme compacte de l'instruction : témoins les opcodes A0, A1, A2 et A3, préfixés 66 pour AX. Nous en profitons pour noter l'adresse effective de u08_0 , en espérant qu’elle restera stable le temps des essais, ce qui est plus que probable. Les trois lignes suivantes nous permettent d'étudier la structure de l'instruction dans sa forme normale. Intéressons-nous au second octet, le seul qui change d'une instruction à l'autre, et plus particulièrement à sa forme binaire :

bl   1D   00 011 101
cl   0D   00 001 101
dl   15   00 010 101

Si nous nous reportons à la structure machine d'une instruction, cela ressemble furieusement à une escadrille de ModR/Ms . Et nous sommes très tentés par un :

al   05   00 000 101

C'est ce que nous testons à la dernière ligne. Bingo ! De plus, Delphi constate bien au désassemblage que 0045BC88 est l'offset de u08_0 .

Ces petites expérimentations, qui certes prennent du temps, ne sont pas inutiles : c'est à travers elles que nous entrons dans l'intimité de notre compilateur et de l'assembleur.

 

Les transferts de registres de segment

Source

Destination

Registre de segment

r/m16

r/m16

Registre de segment

Cette déclinaison particulière de l'instruction MOV transfère les divers registres (16 bits) de segment.

Attention

Manipulation des registres de segment et autres ressources système

La manipulation des registres de segment, de contrôle, des registres système relève du domaine de la programmation système ou de l'exécutable pointu. Nous voyons simplement dans ce chapitre comment coder les instructions , et non réellement comment les utiliser . Il est courant d'écrire que les manipulations se font aux risques et périls des expérimentateurs, ce qui est la moindre des choses.

Néanmoins, des essais sous Delphi, s'ils restreignent votre liberté, limitent bien les risques sérieux de dysfonctionnement. Et, quoi qu'on en dise, Windows participe efficacement à cette protection, plus ou moins selon les versions. Il n'est même pas utile à ce niveau de l'apprentissage de traiter les exceptions. Si vous faites vraiment les fous, rebooter de temps en temps ne peut pas nuire.

Essayons :

mov u16_0, cs
//mov cs, u16_0
mov u16_0, ds
mov ds, u16_0
mov u16_0, ss
mov ss, u16_0
mov u16_0, fs
mov fs, u16_0
mov u16_0, gs
mov gs, u16_0
mov u16_0, es
mov es, u16_0
 
mov u16_0, cs
//mov ds, u16_0
mov ax, u16_0 //accès DS en lecture
 
mov u16_0, fs
//mov ds, u16_0
mov ax, u16_0 //accès DS en lecture
 
mov u16_0, es
mov ds, u16_0

Toutes les lignes s'assemblent, y compris celles mises en commentaires. Mais ces dernières provoquent une exception à l'exécution.

mov cs, u16_0 provoque directement l'exception. C'est normal et évoqué dans la présentation des modes d'adressage.

Les deux autres provoquent une exception pour violation d'accès. Pour l'un (CS dans DS), l'accès en lecture fonctionne, mais l'accès en écriture est refusé. Normal. Pour l'autre (FS dans DS), la violation se produit dès le premier accès en lecture.

Enfin, les deux dernières lignes fonctionnent ; mais il se trouve que ES, SS et DS ont la même valeur. Ces résultats ne sont valables que dans l'environnement et le mode de test choisi, sous Delphi.

MOV des registres de segment sous Delphi
figure 7.17 MOV des registres de segment sous Delphi

 

Les transferts de registres de contrôle et débogage

Source

Destination

Registre de contrôle cr0 cr2 cr3 cr4

r32

r32

Registre de contrôle cr0 cr2 cr3 cr4

Registre de débogage dr0 à dr7

r32

r32

Registre de debug dr0 à dr7

Ces instructions sortent du cadre de ce chapitre. Les registres de contrôle gèrent le mode du processeur et les caractéristiques de la tâche en cours. Nous pouvons imaginer que c'est le domaine du task manager de Windows. Nous sommes dans le domaine de la programmation système.

Les registres de débogage contrôlent le mode Debug (évidemment), quatre d'entre eux conservant les adresses de quatre points d'arrêt possibles. Remarquons qu'il existe deux types de points d'arrêt : d'abord, ceux qui exploitent les possibilités de ces registres de  débogage, mais ils sont en nombre limité. Ensuite, le débogueur va modifier le code objet en plaçant une interruption spécifique, int 3 , à la place du premier octet de chaque instruction à tracer.

Quoi qu'il en soit, nous comprenons que nous ne sommes pas en situation de tester réellement ces instructions, et ce même en sortant du mode Debug de Delphi.

La syntaxe ne pose pas de problème. Remarquons que les registres dr4 et dr5 ne sont pas acceptés par le compilateur ; leur usage est effectivement "reserved" dans la documentation. Les tests présents sur le programme d'essai permettent de constater que même l'écriture dans ces registres ne génère pas d'exception visible. Testez ce programme avec Delphi, sous débogueur, puis générez un . exe et lancez-le directement. Posez également des points d'arrêt dans le code et faites du pas à pas (  F7  et  F8  ). Vous constaterez un comportement étrange. Le code d'essai est constitué de séquences comme :

.......
mov dr6, eax
mov dr7, eax
end; //asm
 
asm
mov eax, cr0
mov u32_0, eax
end; //asm
Add('cr0: ' + IntToHex(u32_0, 8));
.......

Les résultats ne semblent pas significatifs.

Transfert de données conditionnel : CMOVcc

Il s'agit d'une instruction MOV , liée à un code de condition. Les flags CF, OF, PF, SF et ZF peuvent ainsi être testés. Si la condition n'est pas satisfaite, l'instruction ne fait rien. Comme toujours avec les codes de conditions, il existe plus de mnémoniques que d'opcodes : CMOVNE (not equal, non égal) et CMOVNZ (not zero, non nul) représentent la même instruction, qui teste le flag ZF. Ce qui donne la liste complète des instructions de la famille :

 

La famille CMOVcc

Mnémoniques

 

Condition (sur le résultat précédent généralement)

CMOVE  ou CMOVZ

copie si

Égal, Nul, Zéro

CMOVNE  ou CMOVNZ

copie si

Non Égal, Différent

CMOVA  ou CMOVNBE

copie si

Ni au-dessous ni Égal

CMOVAE  ou CMOVNB  ou CMOVNC

copie si

Pas au-dessous, Pas de Retenue

CMOVB  ou CMOVNAE  ou CMOVC

copie si

Au-dessous, Retenue

CMOVBE  ou CMOVNA

copie si

Au-dessous ou Égal

CMOVG  ou CMOVNLE

copie si

Pas inférieur ni Égal à

CMOVGE  ou CMOVNL

copie si

Pas inférieur à

CMOVL  ou CMOVNGE

copie si

Inférieur à

CMOVLE  ou CMOVNG

copie si

Inférieur ou Égal à

CMOVS

copie si

Négatif

CMOVNS

copie si

Positif

CMOVO

copie si

Dépassement

CMOVNO

copie si

Pas Dépassement

CMOVPE  ou CMOVP

copie si

Pair

CMOVPO  ou CMOVNP

copie si

Impair

Les conditions exactes sur les flags concernés sont listées en début de chapitre, à l’occasion de la présentation des codes de conditions.

Le MOV sous-jacent est limité en terme d'opérandes. En fait, pour chaque code de condition, il existe seulement deux formes de l'instruction : CMOVcc r16, r/m16 et CMOVcc r32, r/m32 . La destination est donc toujours un registre, et l'instruction ne travaille pas sur 8 bits.

Aucun flag n'étant modifié, il est possible de tester plusieurs conditions à la suite.

u32_0 := 12348;
u32_1 := 12349;
asm
  mov    eax, u32_0
  cmp    eax, u32_1
  cmovb  eax, u32_1
  mov    u32_0, eax
end; //asm
Add('le plus grand: ' + IntToStr(u32_0));

Le but est d'affecter à u32_0 la plus grande des valeurs de u32_0 et u32_1 .

L'instruction effectuée à la suite d'un CMOVcc est connue, quel que soit le résultat, ce qui n'est pas le cas pour un Jcc . Nous évitons ainsi un risque d'erreur de prédiction du microprocesseur. Si le test est aléatoire et effectué de nombreuses fois en boucle, ce détail peut influer grandement sur les performances.

Échanges de données

Tout d'abord, l'instruction XCHG , qui permute la source et la destination, qui sont classiquement un registre et un registre ou une mémoire, en 8, 16 et 32 bits, plus une version compacte pour AX et EAX.

Cette instruction possède une caractéristique intéressante : même si ce n'est pas précisé explicitement par le préfixe LOCK , le bus est verrouillé pendant la durée de l'instruction, ce qui est utile dans une configuration multiprocesseur.

Imaginons plusieurs processus partageant des ressources. L'utilisation d'une ressource est liée à la possession d'un jeton. Chaque processus posséderait donc une adresse mémoire ou un registre destiné à recevoir ce jeton, par exemple 0 sans jeton et 1 avec le jeton. Imaginons de plus une mémoire équivalente dans une zone mémoire partagée par tous les processus, le reposoir à jeton. Un processus intéressé par la ressource va tenter de s'approprier le jeton, par un  XCHG . En cas de succès, il va utiliser la ressource, puis remettre le jeton dans le reposoir. Nous voyons l'avantage de l'instruction XCHG  : une interruption ne peut en aucun cas être traitée pendant une instruction, mais attend la fin de celle-ci. En cas d'utilisation de MOV sans précautions, il suffirait qu'un processus soit interrompu par le task manager, alors que le jeton est déjà préchargé mais pas encore effacé, pour qu'il fasse des petits (des faux jetons ?).

Cette instruction, ainsi que celles qui suivent, ont donc deux caractéristiques liées : elles condensent en une seule instruction plusieurs actions élémentaires, assurant ainsi leur exécution complète avant qu'une éventuelle interruption ne soit traitée. De plus, elles sont LOCKables, c'est-à-dire qu'elles peuvent être protégées d'un vol de bus de la part d'un autre processeur. Elles sont intimement liées à la programmation système.

Nous avons testé plusieurs façons d'échanger deux variables a et b, avec un chronométrage rudimentaire mais suffisant. Consultez le code complet sur le programme d'essais. Voyons les résultats. Pour info, tests sur un Athlon 1700 XP +.

Le chronométrage à vide :

asm
  rdtsc
  rdtsc
  mov chrono, eax
      // Rien !
  rdtsc
  sub eax, chrono
  mov chrono, eax
end; //asm
Add(IntToStr(chrono));

nécessite 11 cycles. Essayez avec 25 (!) NOP . Toujours 11 cycles. Donc, ne comptez pas sur un ou deux NOP pour calmer un code un peu rapide.

Comment échanger deux variables a et b, sans variable intermédiaire, dans n'importe quel langage ?

a := a + b;
b := a - b;
a := a - b;

17 cycles.

Ce qui donne, codé en assembleur :

mov eax, b
add a, eax
mov eax, a
sub eax, b
mov b, eax
mov eax, b
sub a, eax

17 cycles également. Donc, l'assembleur n'apporte d'amélioration qu'en soignant le code. Essayons :

push a
push b
pop  a
pop  b

18 cycles. Les apparences sont trompeuses. Essayons XCHG  :

mov  eax, a
xchg eax, b
mov  a, eax

15 cycles. C'est-à-dire qu'en retirant les 11 cycles, nous sommes passés de 7 à 4 cycles.

mov eax, a
mov ecx, b
mov a, ecx
mov b, eax

15 cycles également. Nous pouvons dire que, en gros, dans ces conditions, un XCHG prend 2 cycles et un MOV en prend 1. Le XCHG perd de son intérêt dans le cas d'un échange mémoire-mémoire.

L'instruction  XADD  (échange et additionne) permute les valeurs de la destination (un registre ou une mémoire sur 8, 16 ou 32 bits) et de la source (un registre 8, 16 ou 32 bits), puis remplace la destination par le résultat de l'addition des deux valeurs. Le préfixe LOCK est supporté, mais il n'est pas implicite.

La documentation Intel ne donne pas d'application typique de cette instruction. Nous avons donc cherché, d'abord en tentant de coder quelque chose : l'idée fut de transformer un tableau de valeurs en tableau de valeurs cumulées ; mais ce n'était pas une bonne idée. Il n’y a rien dans notre documentation, et pas grand-chose sur internet, à part le fait que l'instruction est apparue avec le 486, et des allusions à la gestion des processus. Avec la possibilité de LOCKer l'instruction signalée dans la documentation, le rapport avec XCHG et ses jetons devient clair.

Rappelons qu'à une ressource utilisable par un seul processus simultanément était associé un jeton, et que le processus désirant mobiliser la ressource s'appropriait le jeton s'il était disponible, et le reposait en fin d'utilisation.

Considérons maintenant une ressource qui, une fois créée, devient disponible pour plusieurs processus. Ce peut être une DLL, mais également certaines fonctions et propriétés dites "de classe" en programmation objet, et dont la mémoire est rendue au système uniquement avec la destruction de la dernière instance de cette classe.

Mais, prenons plutôt l'exemple de la connexion internet, qui sera explicite pour tout le monde. Nous avons, dans un espace mémoire accessible à tout processus, une case mémoire appelée InetCnx . Nous voulons nous connecter pour lire notre courrier. Le gestionnaire de courrier ajoute un jeton dans InetCnx . Si c'est le premier, l'objet connexion est créé. Pendant l'activité du gestionnaire de courrier, nous lançons le navigateur pour surfer, processus pendant lequel un téléchargement est lancé. Navigateur et gestionnaire de téléchargement déposent chacun leur jeton dans InetCnx . Entre-temps, le gestionnaire de courrier aura terminé son travail ; et, avant de se fermer, il aura repris son jeton. L'avantage de cette méthode est maintenant clair : quand un processus retirera le dernier jeton, la ressource connexion internet pourra être libérée, la ligne coupée. Il est possible, et même probable, que les connexions internet ne soient pas gérées de cette façon, mais elles pourraient l'être. Nous sommes plus proches de la réalité dans le cas d'une DLL. Au retrait du dernier jeton, elle sera détruite ou deviendra destructible, selon la demande en mémoire. Voyons comment programmer cette gestion des jetons :

Pour utiliser la ressource :

mov eax, 1
lock xadd SacDeJetons, eax

Ainsi, SacDeJetons se trouve incrémenté d'une unité, et EAX contient sa valeur d'avant l'incrémentation. Si cette valeur est nulle, le processus pourra, si c'est son rôle, initialiser la ressource.

En fin d'utilisation :

mov eax, -1
lock xadd SacDeJetons, eax

SacDeJetons est ici décrémenté d'une unité (un jeton est retiré). Si EAX contient 1, c'est que nous venons de retirer le dernier jeton. Nous pourrons éventuellement agir en conséquence.

Dans la même catégorie, nous trouvons CMPXCHG  (compare et échange). Les opérandes source et destination sont les mêmes que XADD. Bien entendu LOCKable, elle remplace une séquence complexe. Expliquée de façon brutale, elle effraie un peu :

  Compare AL, AX ou EAX (selon la taille des opérandes) avec la destination.

  Si égalité, la source est copiée dans la destination.

  Sinon, c'est la destination qui est copiée dans AL, AX ou EAX.

Voyons cela par l'exemple. Encore une fois, le salut se trouve dans le jeton, que nous appellerons cette fois-ci sémaphore. Nous avons donc une ressource partageable, mais à tour de rôle, c'est-à-dire qui ne peut avoir à un moment donné qu'un seul propriétaire, ou aucun. Nous savons traiter ce problème avec XCHG et un sémaphore binaire (libre/occupé). Mais, ici, nous désirons identifier le propriétaire actuel de la ressource, à destination du système d'exploitation par exemple. Chaque processus possède une identification unique, ProcessID . Nous souhaitons donc que la variable sémaphore de la ressource contienne le ProcessID du propriétaire ou la valeur RessourceDispo si la ressource est... disponible. Allons-y :

mov eax, RessourceDispo
mov edx, ProcessID
lock cmpxchg semafor, edx

Si la EAX = semafor , c'est-à-dire si la ressource est disponible, EAX conserve sa valeur et semafor prend la valeur ProcessID .

Sinon, EAX prend la valeur de semafor ; le processus est donc informé de l'indisponibilité de la ressource et connaît, par EAX, le nom du coupable. Ce dernier peut être le processus lui-même, tout fonctionne encore.

Il nous reste CMPXCHG8B , un CMPXCHG sur 8 bytes, c'est-à-dire 64 bits. Les registres sur cette largeur n'existant pas en tant que tel, l'instruction va travailler sur les pseudo-registres EDX:EAX et ECX:EBX , plus l'adresse d'une mémoire 64 bits, la destination. Le fonctionnement est strictement le même que CMPXCHG , en remplaçant EAX par EDX:EAX et la source par ECX:EDX .

var
RessourceDispo, PID, semafor: Int64;
......
RessourceDispo := 0;
PID := Int64(GetCurrentProcessId);
semafor := 0; // à modifier pour tests
 
Add(IntToStr(PID));
Add(IntToStr(semafor));
 
asm
pushad
  mov eax, dword ptr[RessourceDispo]
  mov edx, dword ptr[RessourceDispo + 4]
  mov ebx, dword ptr[PID]
  mov ecx, dword ptr [PID + 4]
  lock cmpxchg8b semafor
  mov dword ptr[PID], eax
  mov dword ptr [PID + 4], edx
  popad
end; //asm
 
Add(IntToStr(PID));
Add(IntToStr(semafor));

Il est intéressant de tester au moins cet exemple, la manipulation de données 64 bits n'étant pas encore courante. L'utilisation de GetCurrentProcessId n'a pas de signification particulière. Tout autre nombre aurait convenu pour le test. PID a été choisi à la place de ProcessID , parce que c'est un nom qui pourrait très bien avoir déjà été défini dans l'environnement. Normalement, la déclaration locale est prioritaire, mais c'est une précaution générale.

BSWAP  inverse l'ordre des octets de son opérande, un registre sur 32 bits.

L'instruction BSWAP
figure 7.18 L'instruction BSWAP [the .swf]

L'utilité première de cette instruction est de résoudre les problèmes de compatibilité big endian vers little endian, et inversement. Pour effectuer la même opération sur un mot de 16 bits, utilisez par exemple xchg al, ah . L'exemple suivant montre la conversion de données pour une transmission par mots de 16 bits puis par mots de 32 bits :

for i := 0 to 100 do t08[i] := i;
 
asm
  mov ax, word ptr[t08 + 0]
  xchg al, ah
  mov u16_0, ax
  mov ax, word ptr[t08 + 2]
  xchg al, ah
  mov u16_1, ax
end; //asm
Add('Transfert par  words: ' +
     IntToHex(u16_0, 4) +
     IntToHex(u16_1, 4));
 
asm
  mov eax, dword ptr[t08 + 0]
  bswap eax
  mov u32_0, eax
  mov eax, dword ptr[t08 + 4]
  bswap eax
  mov u32_1, eax
end; //asm
Add('Transfert par dwords: ' + 
    IntToHex(u32_0, 8) + 
    IntToHex(u32_1, 8));

Peu importe de savoir si l'exemple est réaliste ; ce qui est ici important, c'est de bien voir que la notion de little endian n'a de sens qu'en considérant une brique de base, l'octet, et un mot conteneur plus grand, ici de 16 puis 32 bits. Pour un mot de 64 bits dans EDX:EAX , la conversion ressemblerait à :

xchg edx, eax
bswap edx
bswap eax

Rappelons que ces conversions ne sont pas communes : elles sont du domaine des communications entre machines de familles différentes, de lecture de fichiers, etc.

7.2.2 Transtypages

Ou instructions de conversion (de type). Ils permettent de convertir des bytes en words, des words en double-words, et des double-words en quad-words. Elles n'ont de sens qu'en arithmétique signée, où leur rôle est de propager le bit de signe. En arithmétique non signée, il suffit de forcer à 0 la partie haute de la donnée cible.

Conversion simple

Ce sont quatre instructions. Elles ne prennent pas d'argument et ne travaillent que sur EAX ou ses sous-registres, et éventuellement EDX et ses sous-registres.

  CBW  copie le bit 7 de AL (bit de signe) partout dans AH. AX prend donc la valeur, en signé, de AL.

  CWDE  copie le bit 15 de AX partout dans le mot haut de EAX. EAX prend donc la valeur, en signé, de AX.

  CWD  copie le bit 15 de AX partout dans DX. C'est donc DX:AX qui prend la valeur, en signé, de AX.

  CDQ  copie le bit 31 de EAX partout dans EDX. EDX:EAX prend la valeur, en signé, de EAX.

Exemple de code :

xor ax, ax     // 0 -> ax
mov al, -120
cbw            // propage le bit de signe
mov cx, 25
add cx, ax
mov result_s16, cx
mov result_u16, cx

Le test est on ne peut plus simple : AX est initialisé à 0 pour que AH ne soit pas indéterminé, puis une valeur 8 bits est chargée et additionnée à une valeur sur 16 bits dans CX. À tester avec et sans CBW.

Pour tester CDQ dans son utilisation normale, nous utilisons uniquement du Pascal :

// test de CDQ
s32_0 := -1515870811; // placer ici un point d'arrêt
s64_0 := s32_0;

Puis deux séquences, méthodes compactes pour initialiser EDX et EAX :

// mise à 00000000h compacte de EDX et EAX
asm
  xor eax, eax
  cdq
end; //asm
 
// mise à FFFFFFFFh compacte de EDX et EAX
asm
  xor eax, eax
  not eax
  cdq
end; //asm

Et nous plaçons un point d'arrêt. Nous observons.

Code assembleur généré par Delphi
figure 7.19 Code assembleur généré par Delphi

En progressant de quelques pas dans le code (  F8  ), nous voyons la valeur immédiate -1515870811 (qui vaut effectivement A5A5A5A5h ) transférée dans s32_0 , puis s32_0 dans EAX. Puis, quand le CDQ est exécuté, nous voyons EDX passer à FFFFFFFFh . Enfin, EAX est transféré à l'adresse s64_0 et EDX 4 octets plus loin (conforme à little endian). Vérifions le résultat : dans la fenêtre d'affichage de la mémoire, par deux clics droits, tapons Aller à l'adresse s64_0 , et Afficher en QWords . Nous retrouvons bien notre Int64 .

Exercice : une carte de contrôle industriel, munie d'un convertisseur A/D sur 12 bits, peut fonctionner en positif/négatif. Soit, pour 2 12  = 4 096 points, une plage de mesure : -2048 à +2047.

Malheureusement, le résultat est fourni au calculateur dans les 12 premiers bits d'un mot de 16, les 4 restants étant forcés et garantis à 0. Il aurait été facile, à l'aide de quelques portes logiques, de nourrir ces bits soit par des 0 (mode Positif) soit par une copie du bit 11 (mode Positif/négatif). Mais cela n'a pas été fait par le concepteur, puisque le cas est réel. Peut-être y a-t-il une raison à cela ! Il faut donc propager le signe.

Pour les 3 codes proposés, s16_0 est initialisé à 0000111110001000b , soit -120 sur 12 bits, 3976 sur 16 bits. Le code assembleur testé est encadré par :

mov ax, s16_0
// code à tester
mov s16_0, ax .

Solution 1 : tous les nombres dont le bit 11 est à 1 sont supérieurs ou égaux à 000010000000b . D'où le code. Cela revient à tester le bit 11.

cmp ax, 0000100000000000b
jl @suite
or ax, 1111000000000000b  // forçage des 4 bits de poids fort
@suite:

Solution 2 : multiplication (par un décalage vers la gauche), puis division signée.

sal ax, 4         // décalage, correspond à une multiplication par 16
cwd               // tous les bits de dx à la valeur du bir 15 de ax
mov cx, 16    
idiv cx           // voir IDIV

Les solutions 1 et 2 sont là pour montrer ce qu'il convient d'éviter...

Solution 3 : il suffit, après décalage à gauche, de décaler arithmétiquement à droite (le bit de signe est alors propagé).

sal ax, 4
sar ax, 4

À conserver dans ses papiers : peu de risques d'erreur, contrairement à la solution 1, grande étendue d'utilisation, fonctionne sur tous les registres et directement en mémoire, pour des mots de départ de toutes tailles. Et cela sans mobiliser de mémoire ou de registre supplémentaire. La taille pourrait même être déterminée à l'exécution par la valeur de CL, pour peu que nous y trouvions une utilité. Pour transtyper le byte dans CL en double-word dans ECX, il suffirait de :

sal ebx, 24
sar ebx, 24

 

Déplacement avec conversion

Ces deux instructions déplacent une mémoire ou registre de 8 bits vers un registre de 16 bits, ou une mémoire ou registre de 8 ou 16 bits vers un registre de 32 bits. Ce sont :

  MOVSX  : les bits supplémentaires sont remplis de la valeur du bit de poids fort de la donnée source, le bit de signe est donc propagé, c'est une opération signée.

  MOVZX  : les bits supplémentaires sont remplis par des 0, c'est une opération non signée.

Testons encore avec du code Delphi :

u08_0 := 12;
u16_0 := u08_0;
u32_0 := u16_0;
u32_0 := u08_0;
 
// signés
s08_0 := -120;
s16_0 := s08_0;
s32_0 := s16_0;
s32_0 := s08_0;

Plaçons un point d'arrêt sur la première ligne et observons le code généré.

Code assembleur généré par Delphi
figure 7.20 Code assembleur généré par Delphi

Nous constatons, outre certaines redondances apparentes dues à la volatilité supposée des variables globales (le XOR EAX, EAX par exemple), que le MOVZX n'est employé qu'une seule fois au lieu de trois attendues. Pour être certain qu'il s'agit d'une optimisation, il faudrait faire des tests réalistes, c'est-à-dire sur de grands tableaux, et non pas une fois ou un grand nombre de fois en boucle sur la même variable.

7.2.3 Flux du programme

Le carton plié perforé d'un automate, d'un limonaire ou d'un orgue de Barbarie est un programme qui va se dérouler de façon linéaire, du début à la fin ou parfois en boucle. Il en est de même du programme blanc 90° avec prélavage sur notre machine à laver : fonctionnement sans grand suspense, jusqu'à ce que le linge soit lavé, rincé et enfin essoré. C'est en tout cas une première approche, puisque le choix du programme de lavage ou la fin du remplissage lorsque la cuve est pleine sont des formes de déroutements de programme. La notion de programme utile n'est donc pas nécessairement liée à celle de déroutement.

L'ordinateur, lui, va résoudre des problèmes différents à chaque utilisation et certaines actions ne devront être effectuées que sous certaines situations. C'est ici qu'apparaît la nécessité d'actions conditionnelles et de déroutement du flux de programme. Ces deux notions sont différentes, mais souvent liées dans l'esprit du programmeur.

Concernant la gestion du déroulement du programme, les processeurs qui nous intéressent, comme beaucoup d'autres, permettent de distinguer plusieurs catégories de sauts :

  Le saut inconditionnel, ou jump, qui transfère le programme à une adresse différente de celle de l'instruction suivante.

  Les sauts inconditionnels de type sous-programme, qui associent le saut à la sauvegarde d'une adresse de retour, celle de l'instruction suivant celle de saut.

  Les sauts conditionnels, qui effectuent ce transfert sous certaines conditions, selon certaines combinaisons de valeurs de flags.

En première approche, les sauts inconditionnels simples semblent inutiles. En effet, un déroutement de ce type ne modifie pas réellement la structure d'un programme.

Les sauts pourraient être les seules instructions conditionnelles du jeu d'instructions. Les autres, qui existent en x86, sont proposées en plus, dans le cadre d'un jeu d'instructions  CISC .

Nous allons passer en revue les instructions concernant la gestion du flux de programme, mais en nous situant comme d’habitude en tant qu’application client : nous ne considérerons que l’aspect de l’environnement mis à notre disposition par le système d’exploitation, comme si notre programme était unique. C’est bien entendu faux ; ce sont même les instructions de saut que nous allons voir qui permettent de basculer entre les tâches. Ce point n’est pas non plus d’une compréhension si difficile ; ce sont simplement les expérimentations qui sont ardues à mettre en œuvre.

Après cela, nous reviendrons en quelques lignes sur la forme particulière d’algorithmique appliquée à l’assembleur.

JMP  (JuMP), est l’instruction de saut inconditionnel. Elle modifie le registre (E)IP, éventuellement le registre CS, et c’est tout. Le programme va ainsi continuer à la nouvelle adresse. Contrairement à un CALL , le flag NT (Nested Task, tâche imbriquée) n’est pas positionné ; le programme reste au même niveau dans la hiérarchie d’imbrication des tâches.

Classés en fonction de la longueur du branchement, il existe trois (plus une) catégories de JMP  :

  Le saut court ( SHORT JMP ) : la destination est entre -128 et +127 octets, par rapport à l’(E)IP courant, qui est l’adresse de l’instruction suivant le JMP . L’opérande est une valeur immédiate sur 8 bits signée, indiquant justement ce déplacement : adressage relatif. Ainsi, JMP +$00 ne fait rien. JMP -$02 bloque le programme (dans une boucle infinie), puisque cette instruction fait justement 2 octets de long. C'est un saut relatif.

  Le saut proche ( NEAR JMP ) : le programme reste dans le même segment CS. Il existe 4 variantes de ce type : les versions 16 et 32 bits du précédent, en tous points identiques (saut relatif), et les versions 16 et 32 bits du saut absolu indirect : le déplacement (offset) depuis le début du segment de code est donné par le contenu d'un registre ou d'une mémoire, avec utilisation des différents modes d’adressage pour accéder à cette mémoire.

  Le saut lointain ( FAR JMP ) : le programme saute vers une instruction située dans un segment différent du CS actuel. Au cours du traitement de l’instruction, les registres CS et (E)IP seront simultanément modifiés. Cette idée de simultanéité est importante, puisque si par exemple CS (ou (E)IP) était d’abord modifié, l’adresse alors pointée serait n’importe quoi. Ce couple ne peut naturellement pas être donné de façon relative. Rappelons que le segment occupant toujours 16 bits, la taille de l'adresse effective sera, selon l'attribut de taille d'opérande, de 32 bits ( 16:16 ) ou 48 bits ( 16:32 ). Cette adresse effective est donnée soit de façon absolue immédiate, l'adresse sur 32 ou 48 bits étant directement donnée dans l'opérande, soit de façon absolue indirecte, l'adresse figurant elle-même à une adresse mémoire.

  Le saut de changement de tâche : le saut s‘effectue via une porte de tâche (Task Gate), vers une configuration de tâche sauvée dans le TSS (Task State Segment), qui comprend l’EIP à exécuter, ainsi que la cartographie segments de la tâche. Ce saut est une forme particulière de FAR JMP .

Tout cela peut sembler compliqué ; mais, la plupart du temps, vous n’aurez pas trop à vous en préoccuper : vous placez des étiquettes, et l’assembleur effectue les calculs pour générer un code optimal. Cette remarque s’applique également à toutes les instructions de sauts ou de sous-programmes. La difficulté peut être alors de forcer un mode non optimal. Il faut dans ce cas se pencher sur la syntaxe de l’assembleur.

Procédons à quelques tests sur cette instruction importante :

jmp @suite
nop
nop
nop
@suite:
nop

À l'aide du débogueur, nous constatons que le JMP se désassemble en :

EB 03  jmp +$03

Si nous tentons (Delphi) :

nop
jmp @suite
@suite:
nop

Le jmp @suite n'est simplement pas assemblé, que Optimisation soit coché ou pas dans les options du projet. Nous pouvons alors saisir, si nous tenons absolument à ce code inutile :

nop
db $EB, $00
nop

qui se désassemble bien en :

EB 03  jmp +$00

Nous avons souhaité tester la possibilité de fabriquer de faux appels de sous-programmes à l'aide d'instruction JMP . Nous avons obtenu un morceau de BASM, qui aurait certainement ses chances aux Debil's Awards. La variable u32_1 accepte les 3 valeurs 0, 1 et 2, qui produisent en sortie respectivement 11, 12 et 13. Bien entendu, toute autre valeur en entrée devrait fournir en sortie un message d'exception. D'où les deux lignes de test en début de bloc asm .

u32_1 := 2;
asm
  cmp u32_1, 2
  ja @fin
 
  call @bidon
  mov ecx, u32_0
  add ecx, 4
  shl u32_1, 4
  add ecx, u32_1
  jmp ecx
  nop // suite du code utile (sic)
  nop // suite du code utile (sic)
  jmp @fin
 
 
  @sp1:
  mov u32_1, 11
  jmp u32_0
 
  @sp2:
  mov u32_1, 12
  jmp u32_0
 
  @sp3:
  mov u32_1, 13
  jmp u32_0
 
 
  @bidon:
  pop eax
  push eax
  add eax, 24
  mov u32_0, eax
  ret
 
  @fin:
end; //asm
MemoSortie.Lines.Add(IntToStr(u32_1));

Le premier CALL permet de récupérer dans u32_0 le déplacement du premier NOP du code utile. Il y a plus simple, mais nous avons tenu à équilibrer les CALL et les RET . Delphi n'accepte malheureusement pas la forme add eax,@label2 - @label1 , qui aurait permis d'alléger le code, et est calculable en tant que constante à la compilation.

Ensuite, l'adresse de la routine est calculée en fonction du paramètre dans u32_1 . Nous avons la chance que chaque sous-programme soit distant du précédent de 16 octets, d'où le shl u32_1, 4 (multiplication par 16). L'Award se rapproche.

Les fonctions CALL  et RET , respectivement appel vers et retour depuis un sous-programme, ainsi que les instructions de gestion de procédures de haut niveau et de cadre de pile ENTER  et LEAVE , sont traitées en détail au chapitre intitulé Pile, cadres de pile et sous-programmes .

Les principes logiciels des interruptions se rapprochent de la technique des sous-programmes et de CALL et RET .

Dans le cas des interruptions matérielles, il n'y a pas d'instruction équivalente au CALL , puisque c'est un événement électrique sur la patte INTR (ou NMI) qui déclenche la procédure : en particulier, acquisition d’un numéro d’interruption, donc d’un vecteur, donc d’une adresse de traitement de l’interruption.

Les interruptions logicielles sont INTn avec n valeurs immédiates sur 8 bits correspondant au vecteur d’interruption ; INT3 est traitée par des mnémoniques à part, opcode CC, utilisé par les débogueurs pour placer des points d’arrêt, de même que INTO (INT4), qui teste le flag de dépassement OF et traite ce dépassement s’il y a lieu. Une partie de ces vecteurs, les 32 premiers, sont réservés ainsi à des exceptions, en règle générale des interruptions levées par des conditions de fonctionnement anormales, comme des divisions par 0, mais également INT3, INT0, la NMI, interruption externe non masquable, des instructions non définies, voire une instruction indéfinie volontaire UD2.

En première intention, toutes ces causes d’interruptions sont traitées par le système d’exploitation, ou par des routines élaborées par votre environnement de développement ; Delphi dans le cadre de nos essais actuels. Quoi qu’il en soit, il existe un fond de traitement commun de ces conditions ou instructions de départ en routine de traitement d’interruption : le traitement se faisant au niveau système d ‘exploitation, le saut généré est nécessairement intersegment (et de plus intertâche et avec changement de niveau de privilège). Donc (E)IP et CS seront préservés, ainsi que (E)FLAGS. En dernier sera empilé un éventuel code d’erreur.

Toutes les routines de traitement des exceptions et interruptions, matérielles ou logicielles, ont en commun également de se terminer par l’instruction IRET  (ou IRETD , version 32 bits, autre mnémonique pour un même opcode).

L’instruction BOUND  (limites, frontières) génère l’interruption 5 si une des limites, d’un tableau par exemple, est dépassée. Dans sa version 32 bits, l’instruction prend en premier opérande un registre sur 32 bits, et en second pointe vers une mémoire où s’enchaînent deux mots de 32 bits, la limite inférieure et la limite supérieure. Le registre est comparé avec ses deux limites ; en cas de dépassement (au sens strict), une exception #BR ( int 5 ) est levée. La version 16 bits se déduit immédiatement de la version 32 bits. Si nous ne testons pas le traitement des interruptions, nous pouvons au moins voir le comportement du programme dans ces conditions et tester BOUND  :

begin
  //asm UD2 end;
  //asm INT 3 end;
  //asm INTO end;
  s64_0 := $0000000500000003;
  u32_0 := 7;
  asm
    mov eax, u32_0
    bound eax, s64_0
  end; //asm
  MemoSortie.Lines.Add('Fin de procédure');
end;

Les lignes mises en commentaires génèrent des exceptions. Un texte est affiché en fin de procédure, ce qui permet de voir que, sous Delphi au moins, la procédure entière est terminée par le traitement de l’exception, sauf dans le cas de int 3 , ce qui est logique. Le programmeur dispose d’instructions comme Try et Except pour gérer les exceptions, et en particulier écrire du code de nettoyage si nécessaire.

Si s64_0 est initialisé à 0000000500000003h , alors les limites inférieure et supérieure traitées par BOUND sont respectivement 3 et 5. Donc, les valeurs valables sont 3, 4 et 5. Testez prudemment, et une par une, les lignes mises en commentaire. Prudemment signifie que vous ne devez pas y souscrire à la volée, avec un programme important ouvert par ailleurs, de gestion commerciale par exemple.

Parmi les instructions de saut conditionnel, et même parmi les instructions tout court, la série des Jcc occupe une place de choix. Elle partage avec MOV le qualificatif d’incontournable. Sa puissance vient des codes de conditions, et même, comme expliqué en tête de ce chapitre, de la redondance astucieuse des mnémoniques des codes, très utile aux anglophones. Voici les mnémoniques engendrés par cette méthode :

La famille Jcc

Mnémoniques

 

Condition (sur le résultat précédent généralement)

JE  ou JZ

saute si

Égal, Nul, Zéro

JNE  ou JNZ

saute si

Non Égal, Différent

JA  ou JNBE

saute si

Ni au-dessous ni Égal

JAE  ou JNB  ou JNC

saute si

Pas au-dessous, Pas de Retenue

JB  ou JNAE  ou JC

saute si

Au-dessous, Retenue

JBE  ou JNA

saute si

Au-dessous ou Égal

JG  ou JNLE

saute si

Pas inférieur ni Égal à

JGE  ou JNL

saute si

Pas inférieur à

JL  ou JNGE

saute si

Inférieur à

JLE  ou JNG

saute si

Inférieur ou Égal à

JS

saute si

Négatif

JNS

saute si

Positif

JO

saute si

Dépassement

JNO

saute si

Pas Dépassement

JPE  ou JP

saute si

Pair

JPO  ou JNP

saute si

Impair

JCXZ

saute si

CX = 0

JECXZ

saute si

ECX = 0

Les conditions exactes sur les flags concernés sont listées en début de chapitre, à l’occasion de la présentation des codes de conditions.

Deux instructions supplémentaires par rapport aux  cc habituels sont apparues : JCXZ et JECXZ . Ce n'est pas dans les flags que se trouve la raison du saut, mais dans la nullité du registre (E)CX. Cette version est utile en liaison avec les instructions de la famille  LOOP .

Cette instruction, très puissante comme déjà signalé, trouve une limitation dans sa portée : un simple saut relatif, 8 bits ou 32 bits selon le mode de fonctionnement. La version la plus efficace est la courte, permettant de sauter à une distance de -128 à +127 par rapport à (E)IP. Pour satisfaire des besoins de sauts plus éloignés, intersegments en particulier, il faudra utiliser des JMP conjointement au Jcc . C’est parfois l’assembleur qui se charge de générer cette séquence de remplacement. Le sujet est abordé au chapitre nommé DOS et DEBUG, premiers programmes . Un saut éloigné conditionnel correspondant à JE ressemblera donc à :

  cmp reg, mem
  jne suite
  jmp adresse_lointaine
suite :
  ..

Pour passer de la version courte à la version longue selon cette méthode, il faut donc inverser le code de condition. C’est là que le tableau trouvera son utilité.

Nous pouvons affirmer que le saut conditionnel est une instruction en kit, tirant sa finesse de test des Jcc et l'étendue de son adressage de JMP .

L'instruction LOOP , ainsi que LOOPE , LOOPZ , LOOPNE et LOOPNZ sont présentées avec les instructions chaînes/tableaux. Signalons l'utilisation conjointe de JCXZ/JECXZ et d'une instruction de la famille LOOP .

Remarques sur l'algorithmique et l'assembleur

L'assembleur est tout le contraire d'un langage structuré. C'est vous qui devrez gérer vos propres contraintes, vous les imposer. Quand vous utilisez un assembleur intégré à un langage de haut niveau, vous bénéficiez de l'apport de cet environnement.

Il sera facile de déterminer des contraintes, propres ou mieux normalisées, pour la définition des sous-programmes : voir à ce sujet les cadres de pile, qui apportent énormément à la structuration, mais ne sont pas implantés quand une optimisation est demandée.

C'est en réalité au niveau le plus bas que vont se poser les difficultés. En dehors des boucles stratégiques pour l'optimisation du code, il peut être bon de prendre un certain nombre de précautions, d'habitudes.

Une d'entre elles est de toujours utiliser les mêmes structures de choix, par exemple en faisant précéder systématiquement les instructions conditionnelles d'une instruction spécifique de comparaison. Le code sera très souvent trop gras de quelques octets, mais la lecture et la maintenance beaucoup plus sûres. À vous donc de vous fabriquer des modèles de codes pour les quelques cas classiques de l'algorithmique ( if..then..else , while , until , case, etc.). Ces structures vous sont proposées prêtes à l'emploi par les macro-assembleurs comme MASM. L'idéal est, même pour des structures incomplètes pouvant se coder de façon plus compacte, d'en conserver dans un premier temps, durant le débogage, tous les éléments : comparaisons, tests, branchements.

Il faut bien voir que les structures de choix dans du code assembleur sont simplissimes : lors d'un choix, le code ne peut que progresser tout droit ou diverger dans une direction. Pas de choix multiple au niveau de l'instruction. La seule façon de terminer un cheminement est un JMP , pouvant survenir à la suite d'un Jcc , pour un choix de fin binaire.

Cette façon d'aborder le codage assembleur au niveau de l'instruction est réellement une affaire personnelle.

Une dernière remarque : certaines personnes déclarent encore que tout travail de programmation doit débuter par une longue phase préparatoire, durant laquelle l'ordinateur doit rester éteint. Balivernes. C'est ignorer le confort offert par les environnements de développement modernes. En revanche, il semble indispensable de programmer en assembleur, certes directement sur son éditeur, mais en gardant à portée de quoi crayonner des bouts d'organigrammes, des tables de variables, etc.

7.2.4 Manipulations de la pile

Puisque le chapitre  Pile, cadres de pile et sous-programmes traite essentiellement des sous-programmes, et donc de la pile, nous y avons regroupé une étude détaillée du fonctionnement de celle-ci, ainsi que la présentation des instructions suivantes :

  PUSH  et POP  : empilent et dépilent une donnée.

  PUSHA , POPA  : empilent et dépilent tous les registres 16 bits.

  PUSHAD , POPAD  : empilent et dépilent tous les registres 32 bits.

  PUSHF , POPF  : empilent et dépilent le registre FLAGS (16 bits).

  PUSHFD , POPFD  : empilent et dépilent le registre EFLAGS (32 bits).

Y sont également traitées les instructions de gestion des sous-programmes CALL et RET ( NEAR et FAR ), ainsi que ENTER et LEAVE .

7.2.5 Manipulations des flags

Les flags, à l'exception des flags de contrôle, ont peu de raisons d'être manipulés directement par le programme : c'est la plupart du temps le processeur qui s'en charge, en allant les modifier ou les consulter au cours du traitement des instructions. Néanmoins, il existe quelques instructions permettant ces manipulations. Passons en revue celles intéressant tel ou tel flag individuellement. Nous verrons ensuite celles qui voient FLAGS et EFLAGS comme des registres.

Dans la liste donnée plus haut dans ce chapitre, nous ne trouvons qu’un seul flag de contrôle : DF (Direction Flag). Son rôle est d’indiquer le sens dans lequel les instructions spécifiques aux chaînes (et en fait aux tableaux) vont parcourir celles-ci, en d’autres mots si les registres (E)SI et (E)DI seront décrémentés ou incrémentés à chaque boucle. Positionner directement ces bits, préalablement à une séquence de code, est donc la façon normale de travailler. Nous disposons pour cela des instructions STD  (SET Direction flag) et CLD  (CLear Direction flag). La seule précaution est de ne pas se méprendre quant à leurs effets respectifs :

Effet de STD et CLD

Instruction

État de DF

(E)DI et (E)SI

Sens de parcours

STD

1

Décrémenté

Fin vers Début

CLD

0

Incrémenté

Début vers Fin

Pour mémoriser ce comportement, nous pouvons tenter de nous souvenir de DF comme le flag de parcours à l’envers. Pour voir ces instructions à l’œuvre, reportez-vous à la rubrique des instructions chaînes.

Le flag IF (Interrupt enable Flag) est un flag système, qui gère l’acceptation ou masquage des interruptions matérielles sur la patte INTR (INTerrupt Request) mais c’est également un flag de contrôle. Nous disposerons donc des instructions STI  (SeT Interrupt flag) et CLI  (CLear Interrupt flag). Pour ne pas se tromper, il faut se rappeler que IF est le flag d’autorisation (enable) des interruptions.

Effets de STI et CLI

Instruction

État de IF

Interruptions

STI

1

Traitées

CLI

0

Masquées

Nous ne proposons pas dans cet ouvrage de manipulation sur les interruptions matérielles.

Le fonctionnement normal de CF, le flag de retenue Carry Flag, est, comme tous les flags d’état, d’être positionné par une instruction en fonction de son résultat et d’influencer ensuite des instructions conditionnelles. CF est utilisé également dans ADC, ADd with Carry. Dans ces conditions, pas besoin d’accès direct au flag. Mais les usages de CF sont en réalité nombreux. Dans les instructions de décalages et de rotations, c’est lui qui reçoit le bit perdu qui tombe de la donnée ou qui, parfois, fournit celui qui y pénètre. C’est en quelque sorte le couteau suisse du flag. Il pourra être utilisé par le programmeur pour renvoyer un résultat, comme dans certaines interruptions du DOS. Voyez ce bout de code, extrait du chapitre DOS et DEBUG, premiers programmes:

         ; fermeture fichier d'essai   
         mov ah, INT21_CloseFile
         mov bx, handle
         int 21h
         jc  FinPrematuree         

L’interruption 21h du DOS, fonction CloseFile , renvoie 1 dans la carry (le CF est tellement populaire qu’il bénéficie d’un sobriquet) en cas d’erreur. Il faut donc pouvoir positionner directement ce flag. C’est ce que font STC  (SeT Carry flag) et CLC  (CLear Carry flag), dont le fonctionnement est maintenant évident. Ajoutons CMC  (CoMplement Carry flag), qui inverse le CF.

STC et CLC seront surtout utilisées pour renvoyer un résultat. Quant à CMC , son utilité n’est pas flagrante. Nous n’osons penser à la correction sauvage, au petit bonheur, d’erreurs de programmation. Ou alors juste pour un essai, en prenant toutes les précautions qui s’imposent. Nous proposons donc en exemple quelques lignes de simple illustration, un modèle d’anti-optimisation. En vous reportant aux schémas illustrant les instructions RCR et SHR , le code et son résultat se suffisent à eux-mêmes :

begin
  u16_0 := 145;
  u16_1 := 145;
  u16_2 := 176;
  asm
    push ecx
    mov cl, 16
    @boucle1:
    stc
    rcr u16_0, 1
    clc
    rcr u16_1, 1
    dec cl
    jnz @boucle1
    pop ecx
  end; //asm
  MemoSortie.Lines.Add('u16_0 = ' + IntToStr(u16_0));
  MemoSortie.Lines.Add('u16_1 = ' + IntToStr(u16_1));
  MemoSortie.Lines.Add('u16_2 = ' + IntToStr(u16_2));
  asm
    push ecx
    mov cl, 16
    @boucle1:
    shr u16_2, 1
    cmc
    rcr u16_1, 1
    dec cl
    jnz @boucle1
    pop ecx
  end; //asm
  MemoSortie.Lines.Add('    u16_1 = ' + IntToStr(u16_1));
  MemoSortie.Lines.Add('NOT u16_1 = ' + IntToStr(NOT u16_1));
  MemoSortie.Lines.Add('    u16_2 = ' + IntToStr(u16_2));
end;

Les résultats sont :

u16_0 = 65535
u16_1 = 0
u16_2 = 176
    u16_1 = 65359
NOT u16_1 = 176
    u16_2 = 0

Le premier bloc asm décale 16 fois un bit à 1 ( STC ) dans u16_0 , un bit à 0 ( CLC ) dans u16_1 . Le second bloc vide les bits de u16_2 (en les remplaçant par des 0), les complémente ( CMC ) et les introduit dans u16_1 . Si nous complémentons à nouveau u16_1 , nous retrouvons bien 176, la valeur initiale de u16_2 . Dans quel but ?

Voyons maintenant quelques instructions jouant plus globalement sur les registres FLAGS et EFLAGS.

LAHF  (Load AH with Flags), charge dans AH la partie basse du registre FLAGS, qui contient les flags CF, PF, AF, ZF et SF aux positions 0, 2, 4, 6 et 7. Les valeurs chargées aux autres positions 1, 3 et 5 sont 1, 0 et 0. SAHF  (Store AH into Flags) est l'opération exactement inverse. Quelle que soit leur valeur dans AH, les bits 1, 3 et 5 restent à 1, 0 et 0. Les usages de ces deux instructions sont variés, mais peu fréquents. Il est possible, à l'aide des deux instructions et d'un travail à base de masques, de lire ou de modifier n'importe lequel de ces 5 flags. LAHF peut être pratique pour renvoyer l'état des flags à une procédure appelante à la suite d'une erreur d'exécution. Les instructions PUSHF , PUSHFD , POPF et POPFD , qui empilent et dépilent FLAGS et EFLAGS, produisent le même effet et, éventuellement, participent à la sauvegarde/restauration complète du contexte de fonctionnement. Mais ces instructions présentent des limitations, en fonction du niveau de privilège en particulier. Certains flags système ne sont pas affectés.

Voici un petit test de LAHF et SAHF  :

  asm
    mov u08_0, 0
    mov ax, 12
    sub ax, 12
    lahf
    and ah, 10111111b
    sahf
    jz @fin
    mov u08_0, 1
    @fin:
  end; //asm
  MemoSortie.Lines.Add(IntToStr(u08_0));

Le résultat est 1, puisque le flag ZF est forcé à 0 après la soustraction. Si nous retirons les trois instructions de forçage, le résultat devient 0.

7.2.6 Calcul binaire

L'arithmétique binaire concerne les quatre opérations primitives appliquées aux types fondamentaux du microprocesseur, les entiers de largeur 8, 16 et 32 bits. Nous écrivons "les entiers", nous reviendrons sur la notion de signé/non signé ; c'est même un point qui nous occupera abondamment.

Souvenirs et vocabulaire

Nous avons tout intérêt à nous souvenir de la façon dont, brillants élèves à l'école primaire, nous menions scrupuleusement nos calculs (mâchouiller la gomme au bout du crayon peut favoriser la résurgence des souvenirs).

figure 7.21 Les quatre opérations, à l'ancienne [the .swf]

Les quatre opérations, à l'ancienne
Remarque

Pour l'addition, nous additionnons les deux opérandes pour obtenir la somme . Nous disons 8 plus 6 font 14, je pose 4 et je retiens 1. Ce 1 est la retenue  ou report  carry  en anglais. Cette retenue est ensuite ajoutée : 1 plus 5 plus 0 font 6. Et ainsi de suite.

Dans la soustraction, nous soustrayons le second opérande B du premier A, pour obtenir la différence . Nous disons parfois "de B à A il y a". Nous voulons retirer 7 de 6 ; ce n'est pas possible. Nous empruntons donc une dizaine au chiffre suivant. C'est encore une retenue ou un report, mais négatif, donc nous ajoutons au terme inférieur. Nous disons dont 16 moins 7 font 9 et j'emprunte 1. Ensuite, ce sera 5 moins (1 plus 0) font 4. En anglais, emprunt se dit borrow .

La multiplication multiplie le multiplicande  12 456 par le multiplicateur   22 pour obtenir le produit . Nous commençons par : 2 fois six 12, je pose 2 et je retiens 1. Mais ce processus est ici, comme pour la division, moins lié à celui du microprocesseur. Remarquons que si nous multiplions deux nombres de deux chiffres, le produit est souvent sur quatre chiffres.

Avec la division entière, nous divisons le dividende  2 436 par le diviseur  22, pour obtenir le quotient  110 et le reste  16. 22 va 110 fois dans 2 436, et il reste 16. Les mathématiciens diront que 2 436 est congru à 16 ( modulo  22), et Delphi que 16 = 2436 mod  22 .

Il peut être utile de (re)lire ici l’Annexe A, consacrée à la numération. Rappel important : la convention de codage des nombres négatifs, en complément à deux, a été choisie aussi parce que l'opération d'addition fonctionnait, que le nombre soit considéré comme signé ou non. Il n'y a donc pas, en addition comme en soustraction, de version signée et non signée d’instruction. Ce point, souvent évoqué, sera l'objet du premier listing de test. Les bits SF, CF, OF et AF sont toujours positionnés ; au programmeur de les utiliser à bon escient. Le SF (Sign Flag) est la simple copie du bit de poids fort. Le CF (Carry Flag) indique un dépassement de la capacité, retenue ou emprunt, sur la taille maximale, celle à priori d'un non signé. Le OF (Overflow Flag) indique une condition de dépassement sur un entier signé, en fait un changement inopportun du bit de signe, donc un report du bit 6 sur le bit 7 sous 8 bits. Le AF (Adjust Flag) qui signale un report du bit 3 vers le 4, sera utile en arithmétique décimale BCD.

La philosophie du jeu d’instructions est donc bien de tenter au maximum de proposer une panoplie arithmétique standard, qui fonctionne directement sur du binaire naturel (non signé), et à côté de flags surnuméraires et d’un kit d’instructions modifiées ou d’ajustement, pour s’adapter aux autres systèmes, comme le binaire signé ou le BCD.

Sauf indication contraire, les instructions comportant une source et une destination fonctionnent sur des registres, mémoires et valeurs immédiates (source) de 8, 16 ou 32 bits, sans possibilité de mémoire/mémoire. Il existe en général une forme compacte avec une valeur immédiate en source et AL, AX ou EAX en destination. Les instructions ne prenant qu'un opérande travaillent sur mémoire ou registre de 8, 16 ou 32 bits. Il existe une forme compacte registre 16 ou 32 bits. Les flags sont toujours positionnés according to the result (of course).

ADD  et SUB  sont les instructions d'addition et de soustraction primitives. Précisons que SUB  ôte la source de la destination et dépose le résultat dans la destination. Le programmeur et le compilateur savent si une variable est signée ou non. Le microprocesseur ne voit, quant à lui, que des bits. Vérifions :

asm
  mov u08_0, $8F  // = 143 en unsigned
  mov s08_0, $8F  // mais -113 en unsigned
end; //asm
Add('u08_0: ' + IntToStr(u08_0));
Add('s08_0: ' + IntToStr(s08_0));
asm
  mov al, u08_0
  add al, 11
  mov u08_0, al
  mov al, s08_0
  add al, 11
  mov s08_0, al
end; //asm
Add('u08_0: ' + IntToStr(u08_0));
Add('s08_0: ' + IntToStr(s08_0));
 
asm
  mov u08_0, $8F
  mov s08_0, $8F
end; //asm
Add('u08_0: ' + IntToStr(u08_0));
Add('s08_0: ' + IntToStr(s08_0));
asm
  mov al, u08_0
  sub al, 11
  mov u08_0, al
  mov al, s08_0
  sub al, 11
  mov s08_0, al
end; //asm
Add('u08_0: ' + IntToStr(u08_0));
Add('s08_0: ' + IntToStr(s08_0));

Ce listing n'appelle pas de commentaire particulier. Il peut être utile de placer un point d'arrêt dans les blocs asm si tout n'est pas limpide.

ADC  (Add with Carry), un ADD avec ajout de CF, suit en général un ADD ou un ADC, dans le cadre d'une addition de variables multi-mots et peut être utilisé après toute instruction positionnant le CF. Rappelons que, quel que soit le système de numération, il n’est jamais nécessaire de retenir 2. Le CF suffit toujours.

Pour SBB  (Substraction with Borrow), la documentation précise que le CF est ajouté à la source, avant de se poursuivre comme un  SUB . Nous pourrions écrire que SBB A, B est équivalent à SUB A, (B+CF) . Il est ajouté à la source avant soustraction et non ôté immédiatement de la destination. Quoi qu'il en soit, les flags sont ensuite positionnés par le résultat global de l'opération. Rappelons que le CF, dans le cas d'une soustraction SUB ou SBB , indique une retenue (borrow) sur des nombres considérés comme non signés.

Quand nous traitons des variables multi-mots, comme V3:V2:V1:V0 pour une variable 128 bits si V3 à V0 sont des variables 32 bits, ou comme EDX:EAX pour un int64 , la question du signe ne se pose que pour le mot le plus significatif, V3 ou EDX. Tous les autres, ici V2, V1, V0 et EAX seront traités pour l'arithmétique comme des non signés. Dans l'exemple qui suit, nous prenons des int64 de Delphi, puisqu'ils sont faciles à afficher. Notons qu'ils sont signés, ce qui valide la démonstration :

var
  A, B, Somm, Diff: int64;
begin
  A := 12;
  B := $00000000FFFFFFFF;
  MemoSortie.Lines.Add('A     = ' + IntToStr(A));
  MemoSortie.Lines.Add('B     = ' + IntToStr(B));
  asm
    mov eax, dword ptr[A+0]
    mov edx, dword ptr[A+4]
    sub eax  , dword ptr[B+0]
    sbb edx  , dword ptr[B+4]
    mov dword ptr[Diff+0], eax
    mov dword ptr[Diff+4], edx
 
    mov eax, dword ptr[A+0]
    mov edx, dword ptr[A+4]
    add eax  , dword ptr[B+0]
    adc edx  , dword ptr[B+4]
    mov dword ptr[Somm+0], eax
    mov dword ptr[Somm+4], edx
 
  end; //asm
  MemoSortie.Lines.Add('A - b = ' + IntToStr(Diff));
  MemoSortie.Lines.Add('A + b = ' + IntToStr(Somm));
  MemoSortie.Lines.Add('A - b = ' + IntToHex(Diff, 16));
  MemoSortie.Lines.Add('A + b = ' + IntToHex(Somm, 16));
end;

Nous avons tout pour faire varier A et B, saisis en décimal ou en hexadécimal, et étudier les résultats. Nous utilisons EDX et EAX comme registres intermédiaires, c'est une (bonne) habitude et non une nécessité. Utiliser EDX:EAX comme pseudo-registre 64 bits est devenu une norme, comme nous le vérifierons un peu plus loin à propos des instructions MUL et DIV .

INC  (Increment) et DEC  (Decrement) incrémentent et décrémentent respectivement l'opérande, considéré encore une fois non signé, d'une unité. Leur usage doit être recherché, en face de celui d'un ADD ou SUB . CF n'est pas affecté, OF, SF, ZF, AF et PF le sont.

NEG  inverse le signe de l'opérande, c'est-à-dire le remplace par son complément à 2. À ne pas confondre avec NOT , qui complémente chaque bit de l'opérande.

neg var  // Complément à 2

effectue la même opération que :

not var  // Complément à 1
add var, 1

CMP  est exactement, au niveau de la syntaxe et du comportement, une instruction SUB . Simplement, aucun opérande n'est modifié. Son rôle est de modifier les flags CF (carry), OF (overflow), SF (sign), ZF (zero), AF (adjust) et PF (parity). C'est l'instruction conditionnelle à suivre, ou plus rarement une autre exploitation de ces flags, qui lui donnera tout son intérêt. Sa puissance est en grande partie dans la syntaxe redondante des codes de conditions, avec un avantage encore une fois aux anglophones. À la suite d'une instruction CMP , nous placerons un JE (Jump if Equal) de préférence à un JZ (Jump if Zero), bien qu'il s'agisse de la même instruction.

var
  car: Char;
asm
  mov u08_0, 51 // à modifier
  cmp u08_0, 50
  mov cx, 'E'
  cmove ax, cx
  mov cx, 'G'
  cmova ax, cx
  mov cx, 'P'
  cmovl ax, cx
  mov car, al
end; //asm
Add('u08_0 (' + IntToStr(u08_0) + ') est ' + car + ' que 50');

Remarquez au passage que l'instruction CMOVcc ne travaille pas sur 8 bits, ne prend qu'un registre en destination et n'accepte pas de valeur immédiate en source, ce qui fait beaucoup de contraintes et ici en limite sérieusement l'intérêt.

Noter qu'entre le  CMP et le  CMOVL , aucune instruction n'est susceptible de modifier les flags.

Une hésitation est possible quant au sens des comparaisons. Le premier opérande, la destination, est le maître du jeu, c'est lui qui nous intéresse ; c'est donc lui qui est pris en compte dans l'expression de langage courant sous-jacente dans le code de condition. Ainsi :

cmp ax, bx
jge @adresse

se prononcera : "compare AX à BX et saute à @adresse s'il (AX) est plus grand ou égal (que BX)". De la même façon que SUB AX, BX ôte la valeur de BX de AX et dépose le résultat dans AX.

Le code de test pour cette première partie dans son intégralité est relativement long, et ne nécessite pas d’explications supplémentaires. Consultez-le et testez-le sur le CD-Rom.

Les instructions de multiplication entière et de division entière comportent chacune une version non signée : MUL  et DIV , et une version signée : IMUL  et IDIV . Avant de découvrir ces instructions, signalons que, très souvent, quand la source est une valeur immédiate connue à la compilation, leur utilisation est coûteuse et inutile. Il existe au moins un site internet consacré exclusivement au recensement des ruses d’optimisation pour remplacer ces instructions. Voir également dans ce chapitre l'instruction LEA .

Quand nous additionnons deux nombres de 8 bits (par exemple), le résultat tient sur 8 bits, avec 1 bit de retenue éventuellement. En revanche, si nous multiplions entre eux deux nombres de 8 bits, le résultat pourra nécessiter jusqu’à 16 bits.

La notation DX:AX ou EDX:EAX correspond à un pseudo-registre 32 ou 64 bits, dont la partie haute est DX ou EDX et la partie basse AX ou EAX.

MUL se présente sous trois formes :

MUL (AL,) SRC8 , SRC8 étant un registre ou une mémoire de taille octet, 8 bits. La saisie de la destination est facultative, mais doit impérativement être AL. AL est multiplié par SRC8, et le résultat placé dans AX.

  asm
    mov ax, 12FFh
    mov cl, 10h
    mul cl
    mov u16_0, ax
    mov al, 128
    mov cl, 10
    mul al, cl
    mov u16_1, ax
  end; //asm
  Add(IntToHex(u16_0, 4));
  Add(IntToStr(u16_1)); 

Les résultats sont : 0FF0h et 1280. Notez que la partie haute de AX ( 12h ) est oubliée et nettoyée, éventuellement pour y placer des 0. C’est bien la multiplication de AL par CL qui est effectuée.

MUL (AX,) SRC16 , SRC16 étant un registre ou une mémoire de 16 bits. La saisie de la destination est facultative, mais doit impérativement être AX. AX est multiplié par SRC16, et le résultat placé dans DX:AX . En effet, cette instruction fonctionne sur, par exemple, un 286, dans lequel EAX n’existait pas.

  asm
    mov eax, 1234FFFFh
    mov cx, 10h
    mul cx
    mov u32_0, eax
    mov word ptr u32_1, ax
    mov word ptr u32_1 + 2, dx
    mov ax, 12812
    mov cx, 10
    mul ax, cx
    mov word ptr u32_2, ax
    mov word ptr u32_2 + 2, dx
  end; //asm
  Add(IntToHex(u32_0, 8));
  Add(IntToHex(u32_1, 8));
  Add(IntToStr(u32_2)); 

Les résultats sont : 1234FFF0 , 000FFFF0 et 128120. Bien entendu, la partie haute de EAX n’est pas nettoyée.

MUL (EAX,) SRC32 , SRC32 étant un registre ou une mémoire de 32 bits. La saisie de la destination est facultative, mais doit impérativement être EAX. EAX est multiplié par SRC32, et le résultat placé dans EDX:EAX .

    asm
    mov eax, 1234FFFFh
    mov ecx, 10h
    mul ecx
    mov dword ptr s64_0, eax
    mov dword ptr s64_0 + 4, edx
    mov eax, 123455678
    mov ecx, 10
    mul eax, ecx
    mov dword ptr s64_1, eax
    mov dword ptr s64_1 + 4, edx
  end; //asm
  Add(IntToHex(s64_0, 16));
  Add(IntToStr(s64_1));

Les résultats sont : 00000001234FFFF0 et 1234556780.

Dans les trois formes, les registres OF et CF sont mis à 0 si la partie haute du résultat (AH, DX ou EDX) est nulle ; sinon ils sont mis à 1.

Pour multiplier deux valeurs sur 16 bits, résultats sur 32 bits, il est possible d’utiliser une des deux dernières formes. Dans la forme 16 bits, le résultat dans DX:AX doit être remis en forme dans un registre ou une mémoire 32 bits. Dans la forme 32 bits, le résultat se trouve directement dans EAX. Mais EDX sera mis à 0, et il faudra veiller à nettoyer les parties hautes de EAX et de la source.

DIV se présente sous les mêmes trois formes que MUL  :

DIV SRC8 , SRC8 étant un registre ou une mémoire de taille octet. AX est divisé (division entière) par SRC8, le quotient est placé dans AL, le reste dans AH. Si le quotient doit être supérieur à 255, rien n’est modifié, et une exception est générée :

  asm
  mov ax, 1234
  mov cl, 5
  div cl
  mov u08_0, al
  mov u08_1, ah
  end; //asm
  Add('Quotient: ' + IntToStr(u08_0));
  Add('   Reste: ' + IntToStr(u08_1));

Delphi accepte div al, cl  ; il vaut mieux s’en abstenir puisque cela ne veut rien dire. En remplaçant par exemple le diviseur par 4 ou une valeur inférieure, ou le dividende dans AX par une valeur supérieure ou égale à 256 * 5 , une exception sera levée. Testez, c’est sans danger. Vous pouvez également remplacer le diviseur par 0. D’après la documentation, la même exception DivideError #DE est générée. Néanmoins, le système d’exploitation, qui gère l’exception, identifie et différencie par le message s’il s’agit d’un débordement ou d’une division par 0. Cela s’applique aux trois formes de l’instruction  DIV .

DIV SRC16 , SRC16 étant un registre ou une mémoire de 16 bits. La valeur contenue dans DX:AX est divisée (division entière) par SRC16, le quotient est placé dans AX, le reste dans DX. Si le quotient doit être supérieur à 65535, rien n’est modifié et une exception est générée.

  asm
    mov eax, 1234567
    shld edx, eax, 16
    //mov edx, eax
    //shr edx, 16
    mov cx, 50
    div cx
    mov u16_0, ax
    mov u16_1, dx
  end; //asm
  Add('Quotient: ' + IntToStr(u16_0));
  Add('   Reste: ' + IntToStr(u16_1));

Notez la façon de transférer le mot fort de EAX dans DX avec l’instruction SHLD . Mise en commentaire, il y a une version plus explicite.

DIV SRC32 , SRC32 étant un registre ou une mémoire de 32 bits. La valeur (64 bits) contenue dans EDX:EAX est divisée (division entière) par SRC32, le quotient est placé dans EAX, le reste dans EDX. Si le quotient doit être supérieur à 4294967295 (valeur maximale des 32 bits non signés -1), rien n’est modifié et une exception est générée.

  s64_0 := $1234ABCDEF12;
  asm
    mov eax, dword ptr s64_0
    mov edx, dword ptr s64_0 + 4
    mov ecx, 10000000h
    div ecx
    mov u32_0, eax
    mov u32_1, edx
  end; //asm
  Add('Quotient: ' + IntToHex(u32_0, 8));
  Add('   Reste: ' + IntToHex(u32_1, 8));

Nous avons utilisé des valeurs exprimées en hexadécimal. Les résultats pour les trois derniers essais sont :

Quotient: 246
   Reste: 4
Quotient: 24691
   Reste: 17
Quotient: 0001234A
   Reste: 0BCDEF12

Les trois formes de la version signée IDIV ne posent pas de problème particulier, si ce n’est que la division d’une valeur entière positive par une valeur entière négative est un peu étrange. Les frontières de débordement sont modifiées logiquement à cause du signe. Le code modifié donne :

  asm
    mov ax, -234
    mov cl, -5
    idiv cl
    mov s08_0, al
    mov s08_1, ah
  end; //asm
  Add('Quotient: ' + IntToStr(s08_0));
  Add('   Reste: ' + IntToStr(s08_1));
 
  asm
    mov eax, -1234567
    shld edx, eax, 16
    //mov edx, eax
    //shr edx, 16
    mov cx, -50
    idiv cx
    mov s16_0, ax
    mov s16_1, dx
  end; //asm
  Add('Quotient: ' + IntToStr(s16_0));
  Add('   Reste: ' + IntToStr(s16_1));
 
  s64_0 := $123456789123321;
  asm
    mov eax, dword ptr s64_0
    mov edx, dword ptr s64_0 + 4
    mov ecx, -2000000000
    idiv ecx
    mov s32_0, eax
    mov s32_1, edx
  end; //asm
  Add('Quotient: ' + IntToStr(s32_0));
  Add('   Reste: ' + IntToStr(s32_1));

À essayer en modifiant les signes. Voici les résultats de la version 32 bits/16 bits. Seuls les signes nous intéressent.

IDIV et les signes

Dividende

Diviseur

Quotient

Reste

1234567

50

24691

17

-1234567

50

-24691

-17

1234567

-50

-24691

17

-1234567

-50

24691

-17

Le signe du reste est celui du dividende, comme précisé dans la documentation. Celui du quotient suit normalement les règles des signes de l’arithmétique, - par - donne +.

Reste IMUL . Nous aimerions écrire que IMUL est à MUL ce que IDIV est à DIV . En fait, IMUL est beaucoup plus compliquée ; elle se présente sous trois formes bien différentes, selon le nombre d’opérandes attendus : un, deux ou trois.

La forme de IMUL à un opérande est effectivement à MUL ce que IDIV est à DIV . Modifions simplement le code de test de MUL (tests sur une seule version, 16 x 16 -> 32) :

  asm
    mov eax, 25678
    mov cx, -10
    imul cx
    mov word ptr s32_0, ax
    mov word ptr s32_0 + 2, dx
  end; //asm
  Add(IntToStr(s32_0));

Peu de commentaires : modifier les valeurs pour vérifier que la règle des signes est bien appliquée. L’étendue des opérandes est -32768 à +32767. Enfin, IMUL AX, CX est compilé, mais c’est une autre instruction, que nous allons voir maintenant.

La deuxième forme de IMUL prend deux opérandes, DEST et SRC. DEST est multiplié par SRC, et le résultat déposé dans DEST. La destination est un registre général 16 ou 32 bits, la source soit un registre 16 ou 32 bits, soit une mémoire 16 ou 32 bits, soit une valeur immédiate. Il y a donc de sérieuses chances de dépassement, qu’il faudra tester avec OF ou CF.

var
  OverFlag: Bytebool;
..
  asm
    mov eax, 678123
    mov ecx, -10
    imul eax, ecx
    mov dword ptr s32_0, eax
    mov s16_1, 10
    mov cx, 457
    imul cx, s16_1
    mov word ptr s16_1, cx
    mov  dx, -3276
    imul dx, -10
    mov OverFlag, 0
    jno @suite
    inc OverFlag
    @suite:
    mov word ptr s16_2, dx
   end; //asm
  Add(IntToStr(s32_0));
  Add(IntToStr(s16_1));
  Add(IntToStr(s16_2));
  if OverFlag then Add('Dépassement') else Add('Pas dépassement');

Pour voir fonctionner le test de dépassement sur la troisième opération, basculer la valeur de dx entre, par exemple, -3286 et -3276.

La troisième forme de IMUL prend trois opérandes, DEST, SRC1 et SRC2. SRC1 est un registre ou une mémoire, SRC2 une valeur immédiate. C’est le produit de SRC1 et SRC2 qui est déposé dans DEST. Les autres considérations sont les mêmes que pour la forme à deux opérandes.

  asm
    mov eax, 678123
    imul eax, eax, -10
    mov dword ptr s32_0, eax
    mov s16_1, 10
    imul ax, s16_1, 457
    mov word ptr s16_1, ax
    mov  dx, -3276
    imul dx, dx, -10
    mov OverFlag, 0
    jno @suite
    inc OverFlag
    @suite:
    mov word ptr s16_2, dx
   end; //asm
  Add(IntToStr(s32_0));
  Add(IntToStr(s16_1));
  Add(IntToStr(s16_2));
  if OverFlag then Add('Dépassement') else Add('Pas dépassement');

Nous en terminons ici avec l’arithmétique binaire. Il sera difficile de travailler si les instructions d’addition et de soustraction, et la notion de signe, ne sont pas parfaitement maîtrisées. Les instructions de multiplications et divisions entières, malgré le volume d’explications qu’elles ont nécessité, sont certainement moins fondamentales.

7.2.7 Calcul décimal

L'arithmétique décimale traite des entiers codés en BCD (pour Binary Coded Decimal) soit en français des décimaux codés en binaire. L'idée fondamentale est de ne plus manipuler la valeur des entiers, mais leur représentation décimale, c'est-à-dire les chiffres du nombre.

Les chiffres de 0 à 9 se codent par leur propre valeur, sur 4 bits, donc se représentent par un chiffre hexadécimal, qui est le même que le chiffre décimal.

Dans un premier temps, la plus petite quantité accessible étant l'octet, codons un chiffre par octet. Bien entendu, les quatre bits de poids fort sont inutiles, donc toujours à 0. La valeur de l'octet est donc la valeur du chiffre représenté : le 7, par exemple, se code 0000 0111 ou 07h en BCD sur un octet. Cette représentation extrêmement dispendieuse en mémoire est nommée unpacked BCD, ou BCD non compacté.

Nous pouvons coder deux chiffres dans un octet : un sur chaque quartet. Donc, le nombre décimal 13, ou une partie de 1395, se codera 0001 0011, soit 13h . Les représentations écrites décimales et hexadécimales sont les mêmes. Nous codons donc 100 valeurs, de 00 à 99, dans un objet pouvant prendre 256 états différents.

Ce codage particulier ne prend pas en charge le signe, mais la soustraction sera possible.

Cette notion packed/unpacked se retrouve souvent, par exemple quand des octets sont stockés dans des mots de 16 bits. En général, non compacté favorise la vitesse et compacté la... compacité.

Les processeurs x86 ne savent pas calculer directement en BCD. Ils proposent toutefois une série d'instructions d'ajustement, dont le fonctionnement est le suivant :

Nous faisons notre opération sur nos deux octets BCD, 09h et 07h en unpacked par exemple, en prenant soin de choisir AL comme destination. Pour le processeur, ce sont deux entiers. Le résultat d'une addition sera 10h . Ensuite, une instruction d'ajustement est lancée, qui convertit ce 10h en 16h (qui vaut 22, mais représente 16 en BCD).

Nous allons passer en revue ces instructions, qui ne sont pas nombreuses, avec un bout d'essai pour chacune et un bref commentaire. Ce sera peut-être la dernière fois que nous les manipulerons.

Toutes ont en commun de travailler sur l'octet, et exclusivement sur AL et parfois AH. Aucune ne prend donc d'argument.

Les variables utilisées seront :

var
  s: Bytebool;  // 'Byte' de signe
  a, b, c: Byte;
  chaineTempo : String;

Les deux premières agissent sur des packed BCD :

DAA  (Decimal Adjust After Addition) :

a := $39;
b := $43;
c := $00;
asm
  mov al, a
  add al, b
  daa
  jnc @suite1
  inc c
  @suite1:
  mov a, al
end; //asm
if c = 0 then chaineTempo:= '' else chaineTempo := IntToStr(c);
Add('DAA: ' + chaineTempo + IntToStr((a div 16)*10 + (a mod 16)));

Ce programme fonctionne avec 39h et 43h , qui représentent 39 et 43 en BCD. Il produit évidemment le même résultat avec 3Ah et 42h , puisque le résultat binaire de l'addition est le même. Avec 45 et 98, le résultat est 43, mais DAA positionnera CF, ce qui permet d'incrémenter le digit immédiatement supérieur, ici c . Le résultat est donc 143.

DAS  (Decimal Adjust After Substraction) :

a := $12;
b := $37;
s := false;
asm
  mov al, a
  mov cl, b 
  cmp al, cl
  jge @suite2
  xchg al, cl
  inc s
  @suite2:
  sub al, cl    // AL-CL -> AL
  das
  mov a, al
end; //asm
if s then chaineTempo := '- ' else chaineTempo := '+ ';
Add('DAS: ' + chaineTempo + IntToStr((a div 16)*10 + (a mod 16)));

Le but est d’évaluer a - b . Si a est strictement plus petit que b, alors nous effectuons b - a , par un XCHG de registres et nous positionnons le bit de signe. Mais si cette opération intervient comme élément d'un calcul, il suffit de faire "comme à la main": 12 - 18 donne bien "94 et je reporte 1" en utilisant les flags.

Les quatre dernières instructions agissent sur des unpacked BCD :

AAA  (ASCII Adjust after Addition) :

a := $09;
b := $05;
c := $00;
asm
  mov ah, c
  mov al, a
  add al, b
  aaa
  mov a, al
  mov c, ah
end; //asm
Add('AAA: ' + IntToStr(c) + IntToStr(a));

AAA traite la retenue décimale en incrémentant dans ce cas AH. Cela doit permettre d'écrire un code assez compact pour additionner deux nombres de plusieurs digits. Nous pouvons faire ici une petite remarque : si nous additionnons trois chiffres x, y et une retenue r (0 ou 1), le résultat maximal sera 9 + 9 + 1 = 19 (retenue de 1). Nous pourrions écrire :

mov al, x
add al, r
add al, y
aaa

C'est-à-dire que nous ne sommes pas tenus d'ajuster après la première addition. Écrivons donc le début d'un code permettant d'additionner deux nombres exprimés en BCD, représentés : N1 = ...,c1,d1,u1 et N2 = ...,c2,d2,u2 . c, d et u valent pour centaines, dizaines et unités. Le résultat sera dans N3 = ...,c3,d3,u3 . Nous n'avons représenté que la fin des nombres, leurs tailles important peu pour l'exemple. Rappelons que nous sommes en unpacked BCD.

mov al, u1
mov ah, d1
add al, u2
aaa
mov u3, al
 
mov al, ah
mov ah, c1
add al, d2
aaa
mov d3, al
 
mov c3, ah

La dernière ligne n'est là que pour "stopper" le processus dans l'exemple, qui fonctionnera si c2 = 0 . Le code complet, sur le CD-Rom, additionne 56 à 187. Les nombres décimaux sont des tableaux d'octets ; si ces tableaux sont dynamiques, la taille des nombres n'a pas à être définie à la conception. Programmer une addition sur ces nombres sera simple.

AAS  (ASCII Adjust AL after Substraction) :

a := $05;
b := $09;
c := $02;
asm
  mov ah, c
  mov al, a
  sub al, b
  aas
  mov a, al
  mov c, ah
end; //asm
Add('AAS: ' + IntToStr(c) + IntToStr(a));

Ce qui a été affirmé sur AAA s'applique ici. AH étant décrémenté en cas de report, c'est le premier terme de la soustraction qui doit y être chargé, d1 pour reprendre l'exemple précédent. Il nous suffit de nous remémorer la façon dont nous faisions nos premières soustractions. Vous pouvez à titre d'entraînement écrire, comme pour AAA , la cellule de base d'un code pour un nombre indéterminé de digits. Considérez N1 - N2 avec N1 >= N2 . Comme vu dans DAS, en traitant le signe séparément, toute opération d'addition et de soustraction peut se ramener à ce cas, ou à N1 + N2 .

AAM  (ASCII Adjust AX After Multiply) :

Cette instruction ne joue que sur AX. Si nous multiplions 5 par 9, nous trouvons 45, soit 002Dh dans AX. AAM transforme cette valeur en 0405h , soit 1029. Ce que confirme le code suivant :

//AAM
  a := $05;
  b := $09;
  asm
    xor ax, ax
    mov al, a
    mov cl, b
    mul cl
    mov u16_0, ax
    mov u08_0, al
    mov u08_1, ah
    aam
    mov u16_1, ax
    mov u08_2, al
    mov u08_3, ah
  end; //asm
  Add('AAM-1_1: ' + IntToStr(u16_0));
  Add('AAM-1_2: ' + IntToStr(u08_1) + IntToStr(u08_0));
  Add('AAM-2_1: ' + IntToStr(u16_1));
  Add('AAM-2_2: ' + IntToStr(u08_3) + IntToStr(u08_2));

Ce code affiche :

AAM-1_1: 45
AAM-1_2: 045
AAM-2_1: 1029
AAM-2_2: 45

La réalité est plus subtile. Intel explique que AAM prend en argument une valeur immédiate sur 8 bits, qui représente la base de numération utilisée. Par défaut, les assembleurs utilisent la base 10 ( 0Ah ), en générant le code machine : D4 0A . La documentation Intel précise que le seul mnémonique accepté est AAM sans argument, les autres bases devant être codées à la main. C'est effectivement le cas de MASM 6.14, qui refuse AAM 10 ou AAM 16 . Il faut alors saisir : db 0D4h, 16 . En revanche, BASM accepte très bien l'opérande :

  asm
    mov ax, 64
    aam 16
    mov u08_0, al
    mov u08_1, ah
  end; //asm
  Add('AAM-base: ' + IntToHex(u08_1, 1) + IntToHex(u08_0, 1));

Tel quel, le résultat est 40. C'est correct. En remplaçant par AAM 10 , l'affichage est 64. Normal. Avec AAM 8 (octal), le programme affiche 80. 8 n'est pas un chiffre octal valide. Le résultat est en réalité 100. La valeur maximale dans AX pour un résultat cohérent en octal est 63, qui affiche bien 77.

Nous pouvons dire que AAM effectue une conversion base 10 vers base X.

AAD  (ASCII Adjust AX Before Division) :

Si AL et AH contiennent chacun un digit BCD, AAD est censé préparer AX à la division. Tout simplement en remplaçant AX par (AL + (10 x AH)) . À la lumière de ce qui vient d'être vu sur AAM et qui s'applique à AAD , nous pouvons affirmer que AAD  est une instruction de conversion base X vers base 10. Utilisez le code suivant en testant diverses valeurs d'opérandes de AAD et de AX :

    asm
    mov al, 0
    mov ah, 4
    aad 16
    mov u16_0, ax
  end; //asm
  Add('AAD-1: ' + IntToStr(u16_0));

Pour mener à bien une division BCD, résultat en BCD, il faut bien entendu ajuster le quotient à l'aide d' AAM , après la division :

  asm
    mov al, 3
    mov ah, 4
    aad
    mov cl, 3
    div cl
    mov u08_0, al
    mov u08_1, ah
    aam
    mov u08_2, al
    mov u08_3, ah
  end; //asm
  Add('Quotient avant AAM: ' + IntToStr(u08_0));
  Add('   Reste avant AAM: ' + IntToStr(u08_1));
  Add('Quotient BCD: ' + IntToHex(u08_3, 1) + IntToHex(u08_2, 1));

Naturellement, si le diviseur est en BCD sur 1 octet, alors le reste n'a pas besoin d'être ajusté.

Nous pouvons nous interroger sur les raisons de présenter AAD et AAM comme des instructions d'ajustement BCD, alors que ce sont de puissantes instructions de conversion de bases.

Le contenu de cette rubrique consacrée au calcul BCD ne sera peut-être pas souvent utilisé dans vos applications. En revanche, elle offre de la méthode pour élaborer de petits exercices pédagogiques amusants. Par exemple traiter les quatre opérations arithmétiques selon les algorithmes de l'école primaire, donc en BCD.

7.2.8 Opérations sur les bits

Le terme anglais est "bitwise instructions". Nous allons passer en revue les instructions qui agissent sur les registres ou la mémoire au niveau du bit et non pas du mot de 8, 16 ou 32 bits.

Chacune de ces opérations peut avoir une signification au niveau du mot, comme un décalage à droite en remplacement d'une division entière. Elle peut ne pas en avoir, comme le test du bit 19 du registre EFLAGS qui nous renseigne sur l'existence d'une interruption en souffrance.

Parmi les registres de la CPU, seul EFLAGS est uniquement interprétable bit à bit. Une opération arithmétique sur ce registre n'a aucun sens. Les autres registres sont à priori des mots, en particulier tous ceux prévus pour contenir des adresses ou des parties d'adresses comme les registres de segment. Les registres généraux et la mémoire s'interprètent selon leur contenu du moment.

Attention, en C/C++ et en Pascal, une variable de type Boolean, c'est-à-dire True/False , occupe un mot complet. Les quatre types booléens sous Delphi sont : "Boolean, ByteBool, WordBool et LongBool. Boolean est le type de prédilection. Les autres existent uniquement pour proposer une compatibilité avec d'autres langages et systèmes d'exploitation." (Extrait de l'aide Delphi 6.) Il existe en C/C++ la notion de champ de bit.

La plupart des instructions bitwise existent en langage de haut niveau ; mais il n'est souvent pas possible de saisir directement des constantes binaires. Nous pourrons donc préférer utiliser quelques lignes d'assembleur.

Voyons un cas réel qui pourra nous servir d'exemple pour écrire du code d'essai. Il s'agit de la façon dont sont codés le secteur et le cylindre de début (et de fin) de partition dans la table des partitions d'un disque (peu importe la génération).

Codage des secteurs et cylindre de début de partition
figure 7.22 Codage des secteurs et cylindre de début de partition [the .swf]

Le fait de lire les octets n et n+1 dans l'ordre inverse, si la lecture se fait en 16 bits little endian, ne change rien. Les bits du numéro de cylindre ne sont alors pas dans le bon ordre.

Les opérations logiques

Quatre instructions primitives de la logique combinatoire : AND , OR , XOR  et NOT , soit respectivement les fonctions ET, OU INCLUSIF, OU EXCLUSIF et INVERSION. Voici pour rappel la table de vérité de ces instructions.

Les quatre opérations logiques primitives
figure 7.23 Les quatre opérations logiques primitives [the .swf]

NOT , inversion de chaque bit de l'opérande, est appelée également complément à 1. Il ne faut pas confondre cette instruction avec NEG , qui est une véritable instruction arithmétique qui inverse le signe de l'opérande. Souvenez-vous qu'en arithmétique signée, l'inverse d'un nombre s'obtient en inversant chacun de ses bits et en ajoutant 1 au résultat. D'où le code d'essai :

not s32_0
add s32_0, 1

qui effectue la même opération que :

neg s32_0

Attention : NOT ne positionne aucun flag.

Voyons quelques utilisations des instructions logiques qu'il faut connaître :

Le OU EXCLUSIF est rempli de caractéristiques intéressantes. La mise à 0 d'un registre se fait par un XOR REG, REG plutôt que par un MOV REG, 0 comme dans :

xor eax, eax ; mise à 0 de EAX

La réciproque est vraie. XOR A, B = si et seulement si A = B :

var
  test: Boolean;
..........
s16_0 := -15248;
u16_0 := 50288;
asm
  mov test, 0
  mov ax, s16_0
  xor ax, u16_0
  jnz @suite
  mov test, 1
  @suite:
end;
if test then Add('valeurs égales') else Add('Valeurs différentes');

À tester avec différentes valeurs. Tel quel, le programme conclut à l'égalité. En effet, -15248 et 50288 ont la même représentation binaire. L'utilité ? Cette méthode permet d'embrouiller un peu le code et éventuellement de gagner quelques lignes :

var
  test: WordBool;
..........
s16_0 := -15248;
u16_0 := 50288;
asm
  mov ax, s16_0
  xor ax, u16_0
  mov test, ax
end;
if not test then Add('valeurs égales') else Add('Valeurs différentes');

Rappelons que, quand une valeur booléenne n'est pas codée sur un seul bit, elle vaut Faux pour 0 et Vrai pour toute autre valeur.

Si vous réalisez deux fois un XOR d'une variable avec la même valeur, vous retrouvez votre variable :

mov eax, u32_0 
xor eax, VarCle32
mov u32_0, eax    ; u32_0 est cryptée
mov eax, u32_0 
xor eax, VarCle32
mov u32_0, eax    ; u32_0 est décryptée

Nous ne pouvons pas réellement parler de cryptage, mais la méthode peut rendre des services pour rendre un texte illisible, dans un cadre familial ou dans un bureau. Il est possible d'améliorer un peu l'efficacité du système en utilisant plus d'un mot comme clé, éventuellement une chaîne de caractères. Attention, ce procédé, même en choisissant une clé sur 4 mots de 32 bits, donc 128 bits, n'a que peu à voir avec les procédés de cryptage clé privée/clé publique de 128 bits, qui eux sont efficaces.

Terminons avec l'utilisation la plus importante de cette famille d'instructions : les masquages et les forçages. Au niveau du bit, quand nous effectuons un AND entre une valeur inconnue et un 0, le résultat est toujours 0. De même un OR et un 1 donnent toujours un 1. Donc, les couples AND/0 et OR/1 débouchent sur un masquage, la variable disparaît. À l'inverse, les couples AND/1 et OR/0 préservent la valeur du bit. Le (faux) listing ci-dessous rassemble quelques masquages et forçages classiques, en faisant abstraction d'autres instructions éventuellement mieux adaptées :

and ax, 0                  ;mise à 0. peu utile
or  ax, -1                 ;mise à $FFFF = -1. peu utile 
 
and ax, $00FF              ;force AH à 00h
and ax, 0111111111111111   ;force le bit de signe à 0
and ax, $7FFF              ;autre écriture du précédent  
or  ax, $FF00              ;force AH à FFh
or  ax, 1000000000000000   ;force le bit de signe à 1
or  ax, $8000              ;autre écriture du précédent 
 
and ax, 0010000000000000   ;isole le bit 13, mais détruit ax
jnz Bit13Vaut1             ;teste le résultat

 

and ax, 0010010000000000   ;
jnz UneAdresse             ;saute quand (bit 13 OR bit 10)

Etc. Nous voyons que l'intérêt du OR est de forcer à 1. Sinon, le AND est plus pratique pour les masquages, puisque, mettant à 0 les autres bits, le test du résultat est facilité.

Terminons par un exercice à la Pif-Gadget, c'est-à-dire inutile et peut-être amusant : résoudre le petit problème de descripteur de partitions en utilisant exclusivement des MOV , Jcc et les quatre instructions logiques de ce paragraphe.

Les données du problème :

Deux variables (8 bits) : E1 : c9 c8 s5 s4 s3 s2 s1 s0 et E2 : c7 c6 c5 c4 c3 c2 c1 c0.

Nous voulons obtenir deux variables (16 bits) :

S : 0 0 0 0 0 0 0 0 0 0 s5 s4 s3 s2 s1 s0 et

C : 0 0 0 0 0 0 c9 c8 c7 c6 c5 c4 c3 c2 c1 c0

Voici le listing Delphi d'une proposition de solution :

procedure TMainForm.Button5Click(Sender: TObject);
var
E1, E2: Byte;
S, C: Word;
begin
  with MemoSortie.Lines do begin
    Clear;
    asm mov E1,10010101b end;
    E2 := 120;
    asm
      mov al, E1
      and ax, 0000000000111111b //nettoyage
      mov S, ax                 //terminé pour S
      xor ecx, ecx
      mov cl, E2
      xor al, E1       //voir texte
      jz @fin
      and al,10000000b //masquage bit 7
      jz @suite
      or ch, 00000010b //forçage  bit 1
    @suite:
      mov al, E1
      and al,01000000b //masquage bit 6
      jz @fin
      or ch, 00000001b //forçage  bit 0
    @fin:
      mov C, cx
    end; //asm
    Add(' Secteurs: ' + IntToStr(S));
    Add('Cylindres: ' + IntToStr(C));
  end; //with .. do begin
end; //procedure

En faisant varier de 00 à 11, les deux bits de poids forts de E1, vous devez obtenir pour "Cylindres" les valeurs 120, 376, 632, 888.

Les lignes :

xor al, E1       //voir texte
jz @fin

ne sont pas indispensables, un MOV AL, E1 aurait suffit. Nous avons simplement constaté qu'à cet instant du programme, AX contient E1 avec ses deux bits de poids fort à 0. Donc, par le XOR , les 6 premiers bits seront mis à 0 (XOR A, A = 0), ne resteront que les deux derniers (XOR 0, X = X). En cas de nullité du résultat, nous pouvons donc sauter directement à la fin.

Décalages et rotations

Toutes les instructions de cette partie décalent d'un certain nombre de bits un registre ou une mémoire.

Pour les instructions de simple décalage SHR , SAR , SAL et SHL , ainsi que celles de rotation RCL , RCR , ROL et ROR , le décalage peut être de 1 bit (codage plus court) ; sinon, le nombre de bits sera codé en immédiat de 0 à 255 ou contenu dans le registre CL :

shl s16_0, 1
shl s32_0, 6
mov cl, 14
sar s32_0, cl
rol cl, 24

La dernière ligne s'assemble ou se compile puis s'exécute sans problème : elle ne fait rien, puisque le registre CL de 8 bits va tourner 3 fois sur lui-même.

À partir du 286 (compris) la valeur du décalage ou de la rotation, dans CL ou en valeur immédiate, est masquée : seuls les 5 bits de poids faible sont pris en compte, ce qui correspond à des valeurs entre 0 et 31. En d'autres termes, cette valeur est lue modulo 32. Ceci bien entendu afin d'accélérer le traitement interne de l'instruction, sans modifier le résultat. À lire la documentation officielle Intel, nous pourrions penser que ce masque est toujours sur 5 bits, donc shl al, 3 serait plus rapide que shl al, 27 , pour le même résultat. Il faut se garder de prendre cette affirmation pour argent comptant, quels que soient le type et la marque du processeur.

Le mnémonique permet de déterminer le comportement de chaque instruction. En première position, S pour shift (décalage) R pour rotate. En seconde, le A signifie arithmétique et concerne la préservation du bit de signe, et le C est pour CF, le flag de retenue. Ce qui donne :

Instruction SHL/SAL
figure 7.24 Instruction SHL/SAL [the .swf]

  SHL  : décalage vers la gauche. Le bit le plus à gauche tombe dans CF, et la position libérée à droite est alimentée par un 0.

  SAL  : autre mnémonique pour SHL , puisque la conservation du bit de signe n'a pas d'objet dans ce sens.

Instruction SHR
figure 7.25 Instruction SHR [the .swf]

  SHR  : décalage vers la droite. Le bit le plus à droite tombe dans CF, et la position libérée à gauche est alimentée par un 0.

Instruction SAR
figure 7.26 Instruction SAR [the .swf]

  SAR  : décalage vers la droite. Le bit le plus à droite tombe dans CF, et la position libérée à gauche est alimentée par la valeur initiale du bit de poids fort. Le bit de signe est donc préservé.

Instructions de rotation
figure 7.27 Instructions de rotation [the .swf]

  ROL  : rotation vers la gauche. Le bit libéré à droite est alimenté par celui éjecté à gauche, dont une copie va également dans CF.

  ROR  : rotation vers la droite. Le bit libéré à gauche est alimenté par celui éjecté à droite, dont une copie va également dans CF.

  RCL  : rotation vers la gauche. Le bit libéré à droite est alimenté par la valeur de CF, puis le bit éjecté à gauche tombe dans CF. La rotation se fait donc sur 9, 17 ou 33 bits.

  RCR  : rotation vers la droite. Le bit libéré à gauche est alimenté par la valeur de CF, puis le bit éjecté à droite tombe dans CF. La rotation se fait donc sur 9, 17 ou 33 bits.

Un exemple où figurent les instructions RCR et SHR est proposé avec les instructions STC , CLC , CMC .

Il existe enfin deux instructions qui ressemblent à du 64 bits, SHLD et SHRD . Elles se codent :

shld u16_0, ax, 15
shrd edx, eax, cl

La destination est soit une mémoire, soit un registre. La source est toujours un registre.

Instructions de décalage double
figure 7.28 Instructions de décalage double [the .swf]

  SHLD  : décalage vers la gauche de la destination. Les bits libérés à droite sont alimentés par les bits qui tomberaient lors du même décalage sur la source. La source n'est pas modifiée.

  asm
    mov eax, 1234567
    shld edx, eax, 16
    mov cx, 50
    div cx
    mov u16_0, ax
    mov u16_1, dx
  end; //asm

Ce morceau de code, illustrant l’instruction DIV , montre une utilisation de SHLD pour transférer le mot de poids fort de EAX dans DX, en vue de préparer une division 32 bits par 16 bits.

  SHRD  : décalage vers la droite de la destination. Les bits libérés à gauche sont alimentés par les bits qui tomberaient lors du même décalage sur la source. La source n'est pas modifiée.

Les utilisations des instructions de décalage et de rotation sont nombreuses : nous avons vu, avec les instructions de transtypage, la séquence :

sal regx, n
sar regx, n

qui permet d'étendre le signe de n bits à x, avec n plus petit que x, mais pouvant prendre des valeurs différentes de 8 ou 16. Cela permet donc de normaliser des entiers signés sur par exemple 10 ou 12 bits, éventuellement obtenus à la sortie de convertisseurs analogiques vers numériques.

Un décalage d'un bit vers la droite, avec remplissage à gauche par un 0, correspond à une division entière par 2. Le même sur deux bits, une division par 4, etc. Bien entendu, le reste est perdu. Dans le sens inverse, nous obtenons des multiplications. Il faut naturellement penser au débordement possible. En creusant un peu cette piste, nous constatons que toute multiplication est une addition du même nombre, à chaque fois décalé d'un certain nombre de bits. Regardons par exemple la multiplication de 118 par 9.

Décomposition d'une multiplication
figure 7.29 Décomposition d'une multiplication [the .swf]

Il n'y a aucune différence entre le binaire et le décimal, si ce n'est que les multiplications unitaires ne sont que par 0 ou 1, donc triviales. Codons une multiplication par 9, d'un opérande dans EAX :

mov eax, u32_0
mov edx, eax
shl edx, 3
add eax, edx
mov u32_0, eax

Rappelons que cette ruse va fonctionner, en signé et en non signé, tant que le résultat reste dans le cadre de l'entier utilisé ; par ailleurs, la valeur du multiplicateur (9) étant codée en dur, elle doit être impérativement connue à la conception.

Mais signalons surtout qu'il existe des aficionados de la multiplication des entiers par des constantes, qui publient des tableaux sur internet, et qui, voyant ce code poussif, considéreraient qu’"un LEA EAX, [EAX + EAX*8] est bien suffisant". Nous y reviendrons avec l'instruction  LEA .

Test et modification d'un bit

Quatre instructions pour lire un bit spécifique dans l'opérande. L'opérande est un registre ou une mémoire de 16 ou 32 bits. Le rang du bit est spécifié, de 0 à 15 ou de 0 à 31, par une valeur immédiate 8 bits ou contenue dans un registre 16 ou 32 bits.

Lire un bit signifie le copier dans le flag CF. Nous disposons ensuite de l'arsenal des instructions conditionnelles pour réagir.

De plus, le bit lu est en même temps modifié, de la façon suivante :

  BT  : le bit n'est pas modifié.

  BTS  (SET) : le bit est mis à 1.

  BTR  (RESET) : le bit est mis à 0.

  BTC  (COMPLEMENT) : le bit est inversé.

L'instruction BT , conjointement aux instructions conditionnelles, est très pratique pour travailler, par exemple, sur des circuits d'interface de périphériques. Les trois autres apportent un plus.

Mettons ces instructions en œuvre :

s32_0 := -118;  // changer le signe
Add(IntToStr(s32_0));
asm
  mov edx, 999
  mov eax, s32_0
  bt s32_0, 31
  cmovc eax, edx
  mov s32_0, eax
end; //asm
Add(IntToStr(s32_0));
asm bts s32_0, 31 end;
Add(IntToStr(s32_0));
asm btr s32_0, 31 end;
Add(IntToStr(s32_0));
asm btc s32_0, 31 end;
Add(IntToStr(s32_0));

La première partie du programme teste le bit de signe de s32_0 et remplace sa valeur par 999 (pourquoi pas) si ce bit est à 1.

Scrutation (Bit Scan)

Ces deux instructions parcourent la source (registre ou mémoire sur 16 ou 32 bits) à la recherche du premier bit à 1. Le rang (0 à 15 ou 0 à 31) du premier bit trouvé est sauvé dans la destination, un registre 16 ou 32 bits. Ces deux instructions sont :

  BSF (FORWARD) : parcourt les bits en montant, de 0 à max.

  BSR (REVERSE) : parcourt les bits en descendant, de max à 0.

Attention, le résultat est bien, pour les deux instructions, le rang à partir du bit 0 du bit trouvé.

asm
  mov u16_0, 0000100000100110b
  bsf eax, u16_0
  mov u32_0, eax
  bsr edx, u16_0
  mov u32_1, edx
end; //asm
Add('bsf: ' + IntToStr(u32_0));
Add('bsr: ' + IntToStr(u32_1));
 
asm
  mov eax, 6666
  mov edx, eax
  mov u16_0, 0
  bsf eax, u16_0
  mov u32_0, eax
  bsr edx, u16_0
  mov u32_1, edx
end; //asm
Add('bsf: ' + IntToStr(u32_0));
Add('bsr: ' + IntToStr(u32_1));

Le résultat du programme est :

bsf: 1
bsr: 11
bsf: 6666
bsr: 6666

Ce qui est attendu pour les deux premiers. Les deux derniers résultats montrent que si aucun bit à 1 n'est trouvé, la destination n'est pas modifiée. Il sera donc bon de lui affecter une valeur non ambiguë avant l'appel de BSF ou BSR , -1 ou MaxInt par exemple.

SETcc

L'instruction conditionnelle SETcc  positionne la destination (registre ou mémoire sur 8 bits) à 0 ou à 1, selon la valeur du flag correspondant au code de condition. Si ZF est éteint, donc à 0, ce qui signifie, selon le contexte, non nul ou différent, alors la destination sera mise à 0 ( 00000000b ). Si ZF est allumé, donc à 1, ce qui signifie égalité ou résultat nul, alors la destination sera mise à 1 ( 00000001b ).

Les flags concernés sont CF (carry), OF (overflow), SF (sign), ZF (zero) et PF (parity). Cela donne la liste d'instructions, certaines acceptant plusieurs mnémoniques :

La famille SETcc

Mnémoniques

 

Condition (sur le résultat précédent généralement)

SETE  ou SETZ

mis à 1 si

Égal, Nul, Zéro

SETNE  ou SETNZ

mis à 1 si

Non Egal, Différent

SETA  ou SETNBE

mis à 1 si

Ni au-dessous ni Égal

SETAE  ou SETNB  ou SETNC

mis à 1 si

Pas au-dessous, Pas de Retenue

SETB  ou SETNAE  ou SETC

mis à 1 si

Au-dessous, Retenue

SETBE  ou SETNA

mis à 1 si

Au-dessous ou Égal

SETG  ou SETNLE

mis à 1 si

Pas inférieur ni Égal à

SETGE  ou SETNL

mis à 1 si

Pas inférieur à

SETL  ou SETNGE

mis à 1 si

Inférieur à

SETLE  ou SETNG

mis à 1 si

Inférieur ou Égal à

SETS

mis à 1 si

Négatif

SETNS

mis à 1 si

Positif

SETO

mis à 1 si

Dépassement

SETNO

mis à 1 si

Pas Dépassement

SETPE  ou SETP

mis à 1 si

Pair

SETPO  ou SETNP

mis à 1 si

Impair

Les conditions exactes sur les flags concernés sont listées en début de chapitre, à l’occasion de la présentation des codes de conditions.

Cette instruction est en fait une façon de transtyper bit vers byte, qui a certainement été implémentée pour faciliter le traitement des booléens au sens des langages de haut niveau. Rappelons qu'un booléen est un type représenté par un entier, que la valeur 0 correspond à Faux, et toute autre valeur à Vrai.

var
  ZeroFlag1, ZeroFlag2: Boolean;
.........
asm
  mov ax, 1025
  cmp ax, 1024
  sete u08_0
  sete ZeroFlag1
  cmp ax, 1025
  sete u08_1
  sete ZeroFlag2
end; //asm
Add('zero flag: ' + IntToStr(u08_0));
if ZeroFlag1 then Add('Egalité') else Add('Inégalité');
Add('zero flag: ' + IntToStr(u08_1));
if ZeroFlag2 then Add('Egalité') else Add('Inégalité');

Résultat :

zero flag: 0
Inégalité
zero flag: 1
Egalité

La difficulté est de ne pas inverser la signification de la condition. Il suffira d'y réfléchir au départ et de donner à sa variable un nom évocateur : ici, les booléens auraient avantageusement pu se nommer Egalite1 et Egalite2. La lecture du listing s'en trouve facilitée.

TEST

TEST  est strictement une instruction AND , à ceci près que la destination n'est pas modifiée. TEST est à AND ce que CMP est à SUB . L'intérêt de cette instruction est de pouvoir tester différents bits dans la même destination, sans avoir à la régénérer entre chaque test. Appliquons cela à l'exercice sur le descripteur de partition, vu avec les instructions logiques primitives :

asm
  mov al, E1 
  and ax, 0000000000111111b //nettoyage 
  mov S, ax                 //terminé pour S
  xor ecx, ecx
  mov cl, E2
  mov al, E1
  test al,10000000b //test bit 7
  jz @suite
  or ch, 00000010b //forçage  bit 1
  @suite:
  test al,01000000b //test bit 6
  jz @fin
  or ch, 00000001b //forçage  bit 0
  @fin:
  mov C, cx
end; //asm

Le premier AND sert au nettoyage, c'est-à-dire ici à forcer à 0 les 10 bits de poids fort. C'est donc son effet sur la destination qui nous intéresse. Le AND est justifié. Notons que si les 6 bits conservés avaient été une valeur signée, il aurait été judicieux d'utiliser la séquence SAL / SAR vue par ailleurs.

Dans le listing original, le second AND ne préservait que le bit 7 et servait simplement à positionner ZF. Il fallait ensuite recharger AL à partir de la donnée E1. Le remplacement par un TEST est donc la bonne solution.

7.2.9 Entrées/sorties

Nous avons abordé au chapitre intitulé L'architecture IA   la façon dont les processeurs de la famille x86 accédaient aux entrées/sorties, dont l’espace est constitué par un maximum de 65 536 octets. Sous forme de mots de 8, 16 ou 32 bits, ces adresses constituent les ports. Pour lire une donnée sur un port, nous disposons de l’instruction IN . La syntaxe générale est IN DEST, PORT. DEST peut être AL, AX ou EAX. PORT est donné soit en valeur immédiate 8 bits, auquel cas il est limité à l’étendue 0 à 255, soit dans DX, et tout l’espace d’E/S est alors accessible. Par exemple (code bidon non testé) :

in al, 2F8h
mov DX, Port_Buffer_Entree
in eax, dx
mov eax, valeur_a_sortir
out dx, eax
out 2F8h, al

L’instruction OUT , syntaxiquement OUT PORT, DEST est totalement symétrique de l’instruction IN . Notons que la forme INST DEST, SOURCE est respectée dans les deux cas.

Attention, aucun flag n’est modifié par ces instructions.

Il existe une forme chaîne de ces deux instructions, qui est présentée à la rubrique consacrée aux chaînes et tableaux.

7.2.10 Chaînes et tableaux

Les instructions de type chaîne sont MOVS (MOVe String), CMPS (CoMPare String), SCAS (SCAn String), LODS (LOaD String) et STOS (STOre String), auxquelles nous ajoutons les instructions d’entrée/sortie INS et OUTS . Cette famille travaille sur des éléments individuels de chaînes ou de tableaux. Selon la taille 8, 16 ou 32 bits de l'élément traité, trois versions de chacune de ces instructions existent, par exemple MOVSB , MOVSW et MOVSD . Une taille de donnée de 32 bits montre bien que l’utilité de ces instructions dépasse le simple cadre des chaînes de caractères.

Pour travailler sur la structure entière, nous leur associerons un préfixe de la famille REP (REPeat) pour effectuer plusieurs fois la même instruction ou les placerons au sein d'une boucle, gérée éventuellement par l'instruction LOOP .

Comme les instructions ordinaires, elles manipulent une donnée source et une donnée destination. La source est pointée par (E)SI et la destination par (E)DI.

(E)SI est par défaut associé au registre DS. La surcharge est possible pour utiliser CS, SS, ES, FS et GS. (E)DI est associé nécessairement à ES. Donc, pour travailler sur le même segment, il faudra soit surcharger pour associer (E)SI à ES, soit initialiser ES avec la même valeur que DS et utiliser les registres par défaut.

(E)SI et (E)DI sont automatiquement incrémentés ou décrémentés, de la valeur correspondant à la taille de la donnée, 1, 2 ou 4 octets. Le sens (incrémenté ou décrémenté) dépend du flag de direction DF, positionné par les instructions STD et CLD (voir ces instructions à la rubrique sur les manipulations de flags).

Au niveau assembleur, les opérandes ne sont nécessaires que pour surcharger le segment associé à (E)SI ou pour préciser la taille de la donnée. Tapons sous Delphi les lignes suivantes :

  movs byte ptr[edi], byte ptr[fs:esi]
  movs byte ptr[edi+ecx], byte ptr[fs:esi]
  movs byte ptr[edi], byte ptr[esi]
  movsb
  movs word ptr[edi], word ptr[fs:esi]
  movs word ptr[edi+ecx], word ptr[fs:esi]
  movs word ptr[edi], word ptr[esi]
  movsw
  movs dword ptr[edi], dword ptr[fs:esi]
  movs dword ptr[edi+ecx], dword ptr[fs:esi]
  movs dword ptr[edi], dword ptr[esi]
  movsd

Mettons un point d'arrêt sur la première ligne et observons le code généré dans la fenêtre CPU :

  64A4    movsb
  64A4    movsb
    A4    movsb
    A4    movsb
6664A5    movsw
6664A5    movsw
  66A5    movsw
  66A5    movsw
  64A5    movsd
  64A5    movsd
    A5    movsd
    A5    movsd

Remarquons qu'à la ligne 2 par exemple, [ edi + ecx] est accepté par le compilateur, mais n'est pas pris en compte, ce qui peut être trompeur. Pour une taille d'opérandes donnée, il n'existe qu'une seule instruction, associée éventuellement à un préfixe de surcharge de segment ou de taille de données. Comme pour beaucoup d'instructions, il existe une version 8 bits et une version 32 bits. La version 16 bits est obtenue par surcharge.

Passons maintenant aux instructions :

  MOVS , MOVSB , MOVSW  et MOVSD  : la valeur de la donnée source est copiée dans la donnée destination.

  INS , INSB , INSW  et INSD  : le port d’E/S est toujours contenu dans DX et remplace la donnée source. Pour le reste, cette instruction se comporte comme un MOVS .

  OUTS , OUTSB , OUTSW  et OUTSD  : le port d’E/S est toujours contenu dans DX et remplace la donnée destination. Pour le reste, cette instruction se comporte comme un MOVS .

  LODS , LODSB , LODSW  et LODSD  : l'élément source est transféré dans le registre EAX, AX ou AL, selon la taille d'opérande.

  STOS , STOSB , STOSW  et STOSD  : c'est l'opération inverse de la série LODS, chargement de l'élément destination par la valeur du registre EAX, AX ou AL.

  CMPS , CMPSB , CMPSW  et CMPSD  : effectuent un CMP entre l'élément source et l'élément destination, c'est-à-dire un SUB à blanc, pour positionner les flags CF, ZF, OF, SF, PF et AF. Comme dans le CMP , ni la source ni la destination ne sont modifiées.

  SCAS , SCASB , SCASW  et SCASD  : comme la famille des CMPS , mais la destination est remplacée par EAX, AX ou AL, selon la taille d'opérande. Pas de modification là non plus de la source ni du registre.

Voyons également deux séries d’instructions de répétition :

REP , répéter, REPE  ou REPZ , répéter tant que égalité ou tant que nul, REPNE  ou REPNZ , répéter jusqu’à égalité ou nullité, préfixent une des instructions chaîne précédentes. La répétition ne s’applique donc qu’à une instruction et non à un bloc. La syntaxe est REP Instruction . C’est avant tout la valeur de (E)CX qui détermine le nombre d’itérations. L’instruction est d’abord exécutée, puis (E)CX décrémenté et comparé à 0 pour une sortie de boucle. Donc, la valeur de (E)CX est exactement le nombre d’exécutions de l’instruction.

L’utilisation de REP est à priori inutile pour des instructions comme CMPS  : seule la dernière comparaison laisse des traces dans les registres. Mais une condition de fin de boucle est ajoutée dans certains cas :

  REPZ / REPE  : la boucle s’arrête dès que ZF = 0.

  REPNZ / REPNE  : la boucle s’arrête dès que ZF = 1.

Cette condition supplémentaire est à mettre en OU avec la condition (E)CX = 0 . Ces versions vont alors très bien fonctionner avec CMPS . Dans beaucoup de langages, la valeur nulle est le marqueur de fin des chaînes de caractères.

LOOP , boucler, LOOPE  ou LOOPZ , boucler tant que égalité ou tant que nul, LOOPNE  ou LOOPNZ , boucler jusqu’à égalité ou nullité, sont souvent présentées avec les instructions de gestion de flux. En fait, par rapport à (E)CX et à ZF, le comportement est strictement celui de la famille REP . Nous n’y revenons d’ailleurs pas. Ces instructions prennent en argument un déplacement relatif au compteur programme (E)IP, dont généralement le calcul est laissé à la charge de l’assembleur. Ce point est effectivement commun à d’autres instructions de gestion du flux de programme, les Jcc par exemple. Le comportement de cette famille d’instructions est le suivant : tant qu’aucune condition de fin de boucle n’est vérifiée, le saut relatif est effectué. Quand une de ces conditions se produit, le programme continue à l’instruction suivante. Cette instruction est généralement, sur les processeurs récents, moins performante que son équivalent à base de DEC et Jcc .

Si (E)CX vaut 0 à l'entrée de la boucle, celle-ci sera parcourue pendant un tour complet de CX (65 536 itérations) ou ECX (65 536 x 65 536 itérations). Il faudra donc faire précéder la boucle d'un test, grandement facilité par les instructions JCXZ et JECXZ . La structure habituelle sera donc :

   mov ecx, valeur
   jecxz @fin
@boucle:
   ..
   traitement
   ..
   loop @boucle
@fin:
   ..
   suite du programme
   ..

Nous avons testé un échantillon de ces instructions. Le code complet est sur le CD-Rom, une partie de ce code étant mise en commentaire, puisque tous les essais ne s’exécutent pas simultanément.

Pour mener à bien les tests, nous utilisons exclusivement des chaînes à 0 terminal. Ainsi, le code n’aura aucune spécificité Delphi. À ce sujet, sous Delphi 6, le comportement est différent sous Windows 98 et sous Windows XP.

Le code suivant fonctionne sous 98 :

var
  str1: PChar;
  str2: PChar;
..
  str1 := '0123456789';
  str2 := 'valeur_ini';
..
  mov esi, str1
  mov edi, str2

Mais, sous XP, impossible d’écrire la mémoire, même hors assembleur, par str2[2] := 'R' par exemple. Pour un fonctionnement sous les deux systèmes, utilisez la syntaxe de déclaration suivante :

var
  str1: array[0..63] of Char;
  str2: array[0..63] of Char;
..
  str1 := '0123456789';
  str2 := 'valeur_ini';
..
  lea esi, str1
  lea edi, str2

Cette syntaxe est satisfaisante puisque, comme en assembleur, nous réservons de façon explicite des zones mémoire. Autre comportement inattendu, l'instruction :

asm
  ..
  cld
  ..
end;  //asm

perturbe les routines d'affichage. Nous avons donc inséré nos blocs asm entre un pushfd et un popfd (empiler et dépiler EFLAGS). Nos bouts d’essai seront donc insérés comme suit :

asm
  pushfd
  pushad
  lea esi, str1
  lea edi, str2  
 
  //test a
  CODE A TESTER
 
  popad
  popfd
end; //asm

 

Testons :

  //test 1
  cld
  mov ecx, 11
  rep movsb
  mov test, ecx

Cette séquence transfère le contenu de la chaîne str1 vers str2 . Le 0, ou NULL, final de la chaîne str1 fait partie du voyage. Se souvenir que (E)CX est toujours initialisé par le nombre de caractères à convoyer. Les registres ESI et EDI pointent vers les débuts des chaînes. La valeur finale de ECX est de 0. Voici le même code pour un parcours des chaînes dans l’autre sens :

  //test 2
  std
  add esi, 10
  add edi, 10
  mov ecx, 11
  rep movsb
  mov test, ecx

Notez les valeurs de ESI et EDI, initialisées à l’offset de début des chaînes + longueur -1.

  lea edi, str1  
  cld
  mov ecx, 11
  @boucle1:
  lodsb
  cmp al, 'a'
  jb @suite1
  cmp al, 'x'
  ja @suite1
  add al, 'A' - 'a'
  @suite1:
  stosb
  loop @boucle1
  mov test, ecx

Pour cet exemple, nous avons initialisé EDI à la même valeur que ESI, pour bien vérifier que source et destination peuvent être la même chaîne.

Les instructions LODS et STOS nous permettent de placer du code de conversion. Ici, les lettres minuscules sont converties en majuscules. Pour que cet exemple montre un résultat visible, il faut initialiser str1 à une valeur comportant des minuscules. Attention, dans d’autres algorithmes, ne convertissez pas le 0 final s’il est traité.

Remarquez la façon d’écrire le code sur les caractères : la seule chose que nous ayons à savoir est que les lettres sont dans l’ordre par bloc dans le code ASCII. La position relative des majuscules et des minuscules n’a aucune importance. Bien voir que le calcul de ‘A’ - ‘a’ (‘M’ – ‘m’ donnerait le même résultat) est fait par le compilateur ou l’assembleur ; donc, les performances sont les mêmes que si nous avions effectué le calcul nous-mêmes. Cette façon d’écrire le code s’applique également aux langages de haut niveau.

  cld
  mov ecx, 64
  xor al, al
  repnz scasb
  not ecx
  add ecx, 64
  mov test, ecx

Ici, nous mettons AL à 0 avant de lancer l’instruction SCASB pour chercher le 0 final. ECX est positionné à la taille maximale de la chaîne, puisqu’il faut bien s’arrêter à un moment. La variable test reçoit la longueur apparente de la chaîne, sans le 0 final. Le code :

  not ecx
  add ecx, 64

remplace, pour une improbable optimisation :

  neg ecx
  add ecx, 63

En effet, NEG est le complément à 2, NOT le complément à 1. Petite ruse à éviter, qui rend le code un peu plus difficile à lire.

  cld
  mov ecx, 64
  repe cmpsb
  not ecx
  add ecx, 64
  mov test, ecx

Ce code renvoie dans test le nombre de caractères identiques en début des chaînes str1 et str2 . Si les chaînes sont identiques, le résultat est supérieur à la longueur de la chaîne et inférieur à 64, puisque la zone mémoire après le 0 final de la chaîne a un contenu indéterminé. Il est aisé d’en déduire une fonction de comparaison de deux chaînes.

7.2.11 Instructions diverses

LEA Load Effective Address

LEA  (Load Effective Address) calcule l'offset dans le segment ou adresse effective, du second opérande, l'opérande source, puis copie le résultat dans le registre 16 ou 32 bits de destination.

Cette instruction, dont l'usage justifié est relativement rare, est parfois mal comprise. Elle n'a d'utilité que parce que l'opérande source, celui dont l'adresse est calculée, est spécifié avec toutes les possibilités des modes d'adressage.

Imaginez l'instruction : mov ax, word ptr [bx + 2*di + 4] . Que se passe-t-il à l'exécution ? D'abord, l'offset est calculé, égal à BX + (2 * DI) + 4 . Puis cet offset est utilisé pour accéder à la mémoire et effectuer le transfert. LEA ne fait que le calcul de l'offset, n'accède pas à la mémoire mais sauve l'offset calculé dans le registre de destination. C'est en quelque sorte une calculatrice d'adresse effective.

Il n'est d'ailleurs pas assuré que l'assembleur assemble toujours un LEA quand nous saisissons un LEA  : lea ax, var1 , si var1 est une variable statique dans le segment de code, se réduit à mov ax, OFFSET var1 , OFFSET var1 étant une valeur immédiate, ce genre de valeurs connues à l'assemblage sans l'être dont nous parlons par ailleurs. Il n'est pas impossible que tel ou tel assembleur décide d'optimiser et de générer un MOV . Ce n'est pas le cas de MASM, et ce n'est normalement pas le rôle d'un assembleur.

Quand le processeur calcule BX + (2 * DI) + 4 , il ne fait ni plus ni moins que calculer un polynôme. Cette instruction est effectivement souvent utilisée pour effectuer des multiplications par des constantes, en évitant les instructions de multiplication arithmétiques lentes. Voici quelques exemples simples extraits de la documentation Microsoft :

lea ebx, [eax*2]     ; EBX = 2 * EAX
lea ebx, [eax*2+eax] ; EBX = 3 * EAX
lea ebx, [eax*4]     ; EBX = 4 * EAX
lea ebx, [eax*4+eax] ; EBX = 5 * EAX
lea ebx, [eax*8]     ; EBX = 8 * EAX
lea ebx, [eax*8+eax] ; EBX = 9 * EAX

Il en existe de plus farfelus, mais très efficaces.

La taille d’opérande est donnée par la taille du registre destination, la taille d’adresse par l’attribut du segment de code. Selon ces deux valeurs, quatre cas peuvent se présenter :

Les 4 modes de l’instruction LEA

Opérande

Adresse

Exemple

Action (exemple)

16 bits

16 bits

LEA AX, mem

Adresse effective sur 16 bits déposée dans AX.

16 bits

32 bits

LEA DI, mem

Adresse effective calculée sur 32 bits, puis tronquée à 16 bits, et enfin déposée dans DI.

32 bits

16 bits

LEA ESI, mem

Adresse effective calculée sur 16 bits, puis étendue à 32 bits avec ajout de 0 à gauche, et enfin déposée dans ESI.

32 bits

32 bits

LEA EAX, mem

Adresse effective sur 32 bits déposée dans EAX.

En programmation quotidienne, la taille d’adresse est imposée par le modèle (32 bits sous Delphi). Vous choisissez la taille d’adresse par le choix de la taille du registre destination. Encore que ce choix, s’il est possible, n’est pas vraiment libre quant à son utilité.

Une des difficultés, comme pour toute instruction utilisant des modes d’adressage un peu pointus, est de bien voir ce qui est fait par l’assembleur à la compilation et ce qui est calculé à l’exécution. Nous avons testé quelques lignes de code d’essai :

begin
  with MemoSortie.Lines do begin
    asm
      mov u32_0, $11223344
      lea ecx, byte ptr[128]
      lea ecx, dword ptr[128]

Dans ces deux cas, ecx reçoit la valeur... 128.

 

      //mov cx, offset u32_0
      mov ecx, offset u32_0
      mov dl, byte ptr[ecx]
      mov u08_0, dl

La ligne en commentaire est naturellement refusée. La suivante est traitée par l’assembleur en un MOV ECX, valeur_immédiate .

 

      xor ecx, ecx
      lea cx, u32_0
      lea ecx, u32_0
      mov dl, byte ptr[ecx]
      mov u08_1, dl

Le LEA CX, u32_0 est compilé, mais sans grand intérêt.

 

      mov eax, 1
      lea ecx, u32_0 + eax * 2 + $1
      mov dl, byte ptr[ecx]
      mov u08_2, dl

Une utilisation efficace de  LEA .

 

      mov ecx, offset u32_0
      mov eax, 1
      shl eax, 1 // X2
      add ecx, eax
      add ecx, 1
      mov dl, byte ptr[ecx]
      mov u08_3, dl

L’équivalent du précédent sans LEA est possible mais laborieux. Et si, dans l’adresse, EAX avait été multiplié par 3 et non par 2, le code se serait un peu plus compliqué.

 

    end; //asm
    Add(IntToHex(u08_0, 2));
    Add(IntToHex(u08_1, 2));
    Add(IntToHex(u08_2, 2));
    Add(IntToHex(u08_3, 2));
  end; //with .. do begin
end; //procedure

En cas de difficultés, testez ce code, tel quel ou modifié, en le suivant dans le débogueur.

Chargements de pointeurs LxS

Les instructions LDS , LES , LFS , LGS  et LSS  sont des super- LEA . Au lieu de charger simplement l'offset de la source dans la destination, elles chargent l'adresse complète, segment compris. Cette adresse, sur 4 ou 6 octets (2 octets pour le segment, et 16 ou 32 bits pour l'offset), est appelée pointeur lointain (far pointer).

Comme pour LEA , la destination, pour la partie offset, est impérativement un registre 16 ou 32 bits. La partie segment de la destination est dans l'opcode, c'est-à-dire pour nous dans le nom de l'instruction : DS, ES, FS, GS ou SS.

var
  str1: array[0..63] of Char;
  str2: array[0..63] of Char;
  test: Longword;
begin
 
  with MemoSortie.Lines do begin
  str1 := '0123456789';
  str2 := '          ';
  Add(str1);
  Add(str2);
 
  asm
    pushfd
    pushad
 
    lea esi, str1
 
    lea eax, str2
    mov dword ptr s64_0, eax
    mov word ptr s64_0 + 4, ds
    les edi, fword ptr [s64_0]
 
    cld
    mov ecx, 11
    rep movsb
    mov test, ecx
 
    popad
    popfd
  end; //asm
 
  Add(str1);
  Add(str2);
 
  Add(IntToStr(test));
 
  end; // with MemoSortie.Lines do begin
end;

Ce programme n'est certainement pas exemplaire, il s'agit en effet d'une simple modification d'un exemple existant (concernant les instructions chaîne). Nous voyons néanmoins la façon de fabriquer une adresse effective valide (ici, dans un mot de 64 bits, mais nous aurions dû, ou pu, déclarer un pointeur). Ensuite, et c'est là tout l'intérêt, ce pointeur alimente à la fois ES et EDI.

XLAT/XLATB conversion par table

XLAT  prend un opérande. XLATB  ne prend pas d'opérande.

Cette instruction va chercher un octet à DS:(E)BX + AL et le place dans AL. Donc, si (E)BX est le début d'une table de conversion de 256 octets, XLAT réalisera cette conversion.

Les deux mnémoniques et les explications floues de la documentation sont trompeurs. Nous l'avons donc testée et suivie au débogueur. Les conclusions sont valables pour BASM et vérifiées sous MASM 6.

Il n'y a qu'une seule instruction XLAT au niveau machine, d'opcode D7. Simplement, elle est préfixable, dans le but de surcharger le registre de segment. C'est donc au niveau de l'assembleur que se fait la différence. Il accepte d'ailleurs XLAT sans argument. XLAT et XLATB génèrent un code court, D7, pour effectuer XLAT DS:[EBX] . Sinon, il faut saisir XLAT SEG:[REG]. SEG (ES par exemple) sert à préfixer l'instruction, et ce qui suit les deux points peut pratiquement être n'importe quoi, il n'en est pas tenu compte. Nous avons même testé avec succès XLAT DS:DS .

Attention, sous BASM de Delphi 6 en tout cas, si vous saisissez XLAT DS:EBX , à des fins de documentation (dixit Intel), le code fera inutilement un octet supplémentaire.

Voici le code qui nous a permis de mener ces tests :

var
  transtabl: array[0..255] of Shortint;
  tempo: Integer;
  test: Shortint;
begin
with MemoSortie.Lines do begin
  // Création de la table
  asm
    xor ecx, ecx
    mov cl, 00h
    @boucle1:
    mov al, cl
    neg al
    mov byte ptr[transtabl[ecx]], al
    inc cl
    jnz @boucle1
  end; //asm
 
  // Utilisation de la table
  asm
    push ebx
    lea ebx, transtabl
    mov al, -115
    //xlat ds:[ebx]
    xlat
    mov test, al
    pop ebx
  end; //asm
  //for tempo:= 0 to 255 do Add(IntToStr(transtabl[tempo]));
  Add(IntToStr(test))
end;

Nous allons maintenant présenter une petite application fondée sur XLAT et le code binaire réfléchi, dit code Gray.

Imaginons un codeur optique absolu. Optique parce que la lecture de l'information s‘effectue par ce moyen, et absolu parce que la position est lue directement. Le pendant est le codeur incrémental, qui compte les changements de position à partir d'une position connue. Voyons à quoi ressemble une version 4 bits (16 positions) d'un tel dispositif.

Un codeur absolu rudimentaire
figure 7.30 Un codeur absolu rudimentaire [the .swf]

La vue de droite est une vue latérale du disque de codage, les éléments de lecture étant symbolisés. Ils tentent de représenter des couples ampoules (ou LED)/phototransistor. La lecture se fait sans frottement, le disque pourrait ainsi être (solidaire de) la rose d'un compas, ou boussole, magnétique de navire, et les informations exploitées par un système de pilotage automatique. Hissez haut !

Nous voyons que la piste la plus centrale représente le bit de poids faible de l'information. Au sortir du dispositif, la position est codée directement, de 0 à 15. En signé, de -8 à +7, ce serait encore excellent. Puisque nous sommes dans les boussoles, un autre codage pertinent serait N, NNE, NE, ENE, NE, etc.

Mais le dispositif de lecture est nécessairement imparfait. Imaginons notre disque plus ou moins stable entre les positions 7 et 8. Entre ces deux valeurs, tous les bits changent. Ils ne changeront jamais absolument de façon simultanée. Si nous passons de 7 à 8, et si le bit fort change en premier, nous avons temporairement la valeur 0 en sortie. Sur ce passage 7 à 8, toutes les valeurs du disque peuvent se présenter fugitivement. C'est précisément gênant dans le cas de notre bateau : entre 7 et 8 peut être précisément le cap suivi, et la rose toujours hésiter autour de cette position, donnant plus de résultats aberrants que de bons. Il existe bien entendu des solutions technologiques, mais il en existe une mathématique, et qui nous intéresse.

Précisons que, dans la réalité, les rayons n'existent pas, le passage du blanc au blanc se fait sans noir. Le passage du 0 au 1 se fait sans état parasite intermédiaire, puisqu'un seul bit change. Pour éviter les états transitoires, il suffirait donc d'enchaîner les bits du codeur de telle façon qu'un seul bit change à chaque transition. En partant de 00, nous pouvons trouver 4 valeurs qui respectent cette condition. Le passage du dernier 10 au premier 00 est également correct. Pour passer à 3 bits, nous constatons qu'il suffit d’ajouter un 0 devant les 4 valeurs, de les reproduire dans l'ordre inverse, en miroir (d'où le terme de code réfléchi) et d’ajouter un 1 devant cette nouvelle série de 4.

Le code binaire réfléchi
figure 7.31 Le code binaire réfléchi [the .swf]

Il est évident que ce procédé pourra se reproduire à l'infini, pour obtenir par exemple un code sur 8 bits. Nous constatons, ou prouvons, que toutes les valeurs du binaire existent, donc une seule fois. Les mathématiciens parlent de bijection, condition nécessaire ici pour pouvoir coder. Ils diraient également que ce code est non pondéré, c'est-à-dire inutilisable pour des calculs. C'est ce code qui s'appelle code Gray ou code réfléchi. Son seul intérêt est de ne modifier qu'un seul bit à la fois.

Le disque du codeur codé en Gray.

Le codeur Gray
figure 7.32 Le codeur Gray [the .swf]

Sur les capteurs du commerce, à partir du moment où ils sont actifs (dotés d'électronique), il est facile d’ajouter un étage de conversion. Le codage Gray est alors transparent pour l'utilisateur. Il dispose d'un codeur binaire avec moins de défauts, voilà tout.

À titre d’exercice, plus que pour faire fonctionner l’instruction XLAT , nous allons programmer cette conversion. En réalité, la conversion elle-même nécessite 2 ou 3 lignes d’assembleur. Nous allons donc nous intéresser essentiellement à la fabrication de la table ; à titre d’exercice, puisque cette table existe déjà dans la littérature. Nous avons simplement profité de l’occasion pour faire un peu de code. Même si notre démarche était justifiée, la construction de la table n’intervenant qu’une fois n’aurait aucune raison de se faire en assembleur. Au niveau de l’analyse, nous partons simplement de ce qui vient d’être expliqué à propos du code Gray.

Nous avons un codeur Gray, donc en entrée un code Gray 8 bits. Nous voulons la position de la roue en binaire naturel 8 bits, donc réaliser une conversion Gray‑>Binaire.

Nous disposons d’une méthode qui nous permet de construire la roue codeuse, soit une table des codes Gray dans l’ordre des codes binaires. Cette table ne permet donc que la traduction Binaire‑>Gray.

Nous allons néanmoins la construire, sous le nom de Tempo . Ensuite, si nous lisons cette table Tempo ligne par ligne, il suffit, pour fabriquer notre table TransGray , de considérer l'adresse de la ligne dans Tempo comme la donnée dans TransGray , et la donnée dans Tempo comme l'adresse de cette donnée dans TransGray (quand nous parlons d'adresse, il s'agit bien entendu du déplacement par rapport au début de la table). Les mathématiciens, encore eux, diraient que nous inversons une bijection.

Voilà, muni de ce début d'analyse, vous pouvez coder cet exercice, ce sera un bon récapitulatif sur les modes d'adressage, et accessoirement (quand même) sur l'instruction XLAT . Une solution est sur le CD-Rom.

CPUID identification du processeur

CPUID  a été introduite avec le Pentium et ensuite sur les dernières moutures du 486, postérieures à la sortie du Pentium. Cette précision historique a ici son importance, puisque le rôle de cette instruction est justement d'identifier le processeur et ses possibilités. Finis donc les petits bouts de codes bizarres ou amusants à coups de modifications de flags ou d'instructions illégales. Un exemple, qui détecte le 8088 (en particulier, qui le différencie du 8086) :

push sp     // empile le pointeur de pile
pop  ax     // puis le dépile 
cmp  ax, sp // si non égal, c'est un 8088

puisque le 8088 est le seul qui modifie SP avant l'empilage. Code non testé sur un 8088.

CPUID est apparue au moment où, avec l'arrivée de MMX par exemple, il devenait crucial de tester la disponibilité de telle ou telle possibilité. Jusque-là, seule la présence ou non d'un coprocesseur arithmétique était importante à déceler.

La documentation Intel est un exercice de style, en ce sens qu'à sa lecture personne ne peut supposer qu'un autre fabricant produit des microprocesseurs fondés sur l'architecture IA. Toujours est-il que CPUID identifiera parfaitement le matériel d'origine AMD. Ces derniers proposent en ligne la note d'application "AMD processor recognizion".

La fonction CPUID ne prend pas d'opérande. Mais son comportement dépend du contenu de EAX au moment de l'appel. Les résultats sont à récupérer dans EAX, EBX, ECX et EDX. Ces particularités évoquent plus un sous-programme avec passage d'arguments par registres qu'une instruction banale.

L'instruction est une usine à gaz, essentiellement évolutive. Son utilisation ne s'envisage pas sans avoir sa fiche descriptive sous les yeux. Vous trouverez dans cet ouvrage plusieurs exemples illustratifs.

La valeur passée dans EAX peut être, actuellement, 0, 1 ou 2. Pour obtenir ce renseignement (la valeur maximale dans EAX égale à 2), il faut invoquer CPUID avec EAX à 0, valeur toujours possible bien entendu. Ce nombre maximal est retourné dans EAX, tandis que EBX, EDX et ECX, dans cet ordre, contiennent les 12 caractères de "GenuineIntel" (et rien d'autre ?). Ce nombre de 2 est certainement appelé à évoluer.

En plaçant 1 dans EAX, nous récupérons des informations sur le type et la version du processeur, ses possibilités et beaucoup de place reste libre.

Enfin, 2 dans EAX nous renvoie des informations sur les caches et TLB (Translation-Lookaside Buffer).

Voici un exemple totalement primitif :

var
  chaineTempo : String;
begin
  chaineTempo := '123456789012';
  with MemoSortie.Lines do begin
  asm
    pushad
    xor eax, eax
    cpuid
    mov u32_0, eax
    mov eax, [chaineTempo]
    mov dword ptr[eax + 0], EBX
    mov dword ptr[eax + 4], EDX
    mov dword ptr[eax + 8], ECX
    popad
  end; //asm
  Add('Paramètre maxi: ' + IntToStr(u32_0));
  Add(chaineTempo);

Voici la sortie qui, bien entendu, est dépendante de la machine de test (dans ce cas, ce n'est pas la machine qui sert à tester le programme, mais plutôt le contraire).

Sortie écran du programme
figure 7.33 Sortie écran du programme

N'y a-t-il pas une nuance ironique dans ce "AuthenticAMD" à la place du "GenuineIntel" ?

UD2 instruction non définie

UD2  (UnDefined) : est un opcode inattendu pour le microprocesseur. Son intérêt est de générer une exception #UD , à des fins de test par exemple. Nous nous en servirons par exemple pour tester le comportement d'un programme face à une exception, traitée ou non, sous débogueur et en  .exe autonome.

Il existe d'autres opcodes équivalents. L'intérêt de celui-ci est double : le source est plus lisible, UD2 est mieux que db $0F db $0B . De plus, cette valeur conservera son statut UD dans les versions futures du processeur.

NOP no operation

NOP , opcode 90, ne fait rien. Ses usages sont aujourd'hui rares. Elle a pour effet de décaler le registre EIP, d'une unité. Elle peut permettre de réserver de la place pour une modification ultérieure, utile avec debug par exemple. Elle peut accueillir un point d'arrêt, mais à quelles fins ? Un usage historique était de consommer du cycle, de perdre du temps d'autres termes. Il fut un temps où il était possible de générer des temporisations précises par des boucles, le NOP étant la brique de base, pour numéroter sur une ligne téléphonique ou bricoler une liaison série simplifiée ; sur un Apple 2 sans carte supplémentaire par exemple. Mais, sur nos processeurs actuels, ne comptez pas sur cette possibilité : NOP ne consomme pas de cycle. Le processeur anticipe. Voyez le test mené lors de l'étude de l'instruction XCHG , avec 25  NOP . Nous avons mené les tests un peu plus loin, et sur ce programme, avec un Athlon 1700 XP+, jusqu' à 40  NOP peuvent être enfilés sans consommer un seul cycle. Intel explique que l'instruction NOP est un mnémonique alternatif pour XCHG (E)AX, (E)AX .

LOCK protection du bus

LOCK  n'est pas exactement une instruction, mais un préfixe, $F0 , applicable à certaines instructions. Pour des instructions comme XCHG , par exemple, ce préfixe est implicite, c'est-à-dire que l’ajouter alourdira le code objet sans rien apporter de plus.

Une des pattes du microprocesseur s'appelle LOCK# , elle verrouille le bus. Utile dans un environnement multiprocesseur, elle interdit à un autre matériel l'accès à la mémoire durant son état actif.

Quand LOCK préfixe une instruction, de façon explicite ou implicite, la patte LOCK# est activée pendant la durée cette instruction et désactivée ensuite.

Ainsi, la ligne :

lock xadd SacDeJetons, eax

qui permute mémoire et registre en plusieurs opérations élémentaires, puis additionne les deux valeurs pour enfin déposer le résultat dans SacDeJetons , ne pourra pas voir la valeur de SacDeJetons modifiée pendant que se déroule l'instruction.

Cette ligne pourrait s'écrire :

call JeMasqueLesInterruptions
lock mov edx, SacDeJetons
lock add eax, edx
lock mov SacDeJetons, eax
lock mov eax, edx
call JeNeMasquePlusLesInterruptions

La première et la dernière lignes sont des appels système, dont l'effet doit être d'empêcher la séquence d'être interrompue, par le système d'exploitation en partage des tâches par exemple.

Si vous écrivez un bout de code destiné à vivre plus tard que la fin de la journée, ne considérez pas à priori que l'environnement sera monoprocesseur.

Instructions système

Nous allons recenser ici une liste d'instructions que nous ne tenterons pas de tester ici, au motif qu'elles sont réservées au système d'exploitation parce que privilégiées, à l'exception notable de RDTSC .

La liste des instructions système varie avec les sources documentaires. Notre liste n'est pas complète, ce n'est pas une possibilité, c'est une certitude. Nous avons eu l'occasion de signaler tout l'avantage qu'il y avait à travailler avec accès à la documentation officielle. Au niveau système, c'est une nécessité.

RDTSC  (ReaD Time Stamp Counter), est une instruction système normalement non privilégiée , puisque le bit TSD (Time Stamp Disable) du registre CR4, qui en restreint l'usage au ring 0, est normalement à 0. Cette instruction lit la valeur du compteur 64 bits TSC (Time Stamp Counter) et fournit le résultat dans EDX:EAX . La lecture de ce compteur se fait à la volée, plusieurs applications peuvent l'utiliser simultanément : aucune ne peut modifier la valeur du compteur, le système ne le fait d'ailleurs pas non plus, ce compteur démarre à 0 et s'incrémente ensuite au rythme de l'horloge, rebouclant au bout de 195 ans avec une horloge à 3 GHz.

RDTSC est à la base du chronométrage simple de code, en assembleur mais également en langage évolué :

var
  Chrono: int64;
begin
  asm
    rdtsc
    mov dword ptr [Chrono]    , eax
    mov dword ptr [Chrono + 4], edx
  end; //asm
  // Partie de code à chronométrer
  asm
    rdtsc
    sub eax, dword ptr [Chrono]
    sbb edx, dword ptr [Chrono + 4]
    mov dword ptr [Chrono]    , eax
    mov dword ptr [Chrono + 4], edx
  end; //asm
  MemoSortie.Lines.Add('Nombre de cycles :' + IntToStr(Chrono));

Nous traitons plus longuement de cette instruction, ainsi que de la suivante, au chapitre intitulé Optimisation et chronométrage .

RDPMC  (ReaD Performance Monitoring Counters) permet d'accéder à une batterie de registres de contrôle de performances, les PMC, contrôle bien plus fin que celui offert par RDTSC . Le registre visé est spécifié à l'appel par le contenu d'ECX. De façon parallèle mais exactement inverse à RDTSC , RDPMC est normalement privilégiée et réservée au ring 0. Le système peut débloquer cette protection, mais certainement temporairement et à son usage exclusif.

Les registres (TSC, la famille des PMC) font partie des MSR (ou Model Specific Register). Ces registres gèrent des caractéristiques propres à tel ou tel modèle de processeur, bien que certains comme par exemple TSC deviennent la norme. Citons des possibilités étendues de débogage, ou le contrôle du matériel (Machine Check). En plus d'instructions également spécifiques pour y accéder, RDMSR  (ReaD Model-Specific Register) et WRMSR  (WRite Model-Specific Register) permettent, en ring 0, de les lire et de les écrire.

SYSENTER et SYSEXIT sont des instructions d'accès rapide à des procédures en ring 0. Elles sont présentées rapidement au chapitre Architecture système .

Le rôle des instructions suivantes est évident, à partir du moment où nous connaissons l'objet, registre ou flag, auquel elles s'appliquent. Le chapitre déjà cité Architecture système en présente une grande partie :

  LGDT  (Load Global Descriptor Table register) ;

  LLDT  (Load Local Descriptor Table register) ;

  LTR  (Load Task Register) ;

  LIDT  (Load Interrupt Descriptor Table register) ;

  LMSW  (Load Machine Status Word).

Toutes ces instructions transfèrent le contenu d'un registre général ou d'une mémoire vers le registre système spécifié. À l’inverse, les instructions suivantes transfèrent le contenu du registre dans un registre ou une mémoire :

  SGDT  (Store Global Descriptor Table register) ;

  SLDT  (Store Local Descriptor Table register) ;

  STR   (Store Task Register) ;

  SIDT  (Store Interrupt Descriptor Table register) ;

  SMSW  (Store Machine Status Word).

L'instruction CLTS  (CLear Task-Switched flag) éteint le flag TS (Task Switched) de CR0. Ce flag est allumé automatiquement par le système à chaque commutation de tâche.

L'instruction ARPL  (Adjust Requested Privilege Level) compare le niveau de privilège d'un sélecteur de segment source (un registre) et d'un sélecteur de segment destination (un registre, ou en mémoire). Si le RPL de la destination est inférieur à celui de la source, il est monté à ce niveau (donc, le sélecteur destination devient moins privilégié) et ZF est allumé, pour test à suivre. Dans le cas contraire, ZF est éteint et rien d'autre ne se passe.

L'instruction LAR  (Load Access Rights) prend un sélecteur de segment (registre ou mémoire) en source. Elle accède au descripteur de segment pointé par le sélecteur, en prend le second DWORD (l'autre ne contient qu'une partie de l'adresse de base et de la limite), le masque pour en extraire ce qui l'intéresse et transfère le résultat vers la destination, qui est un registre général. Si ce registre fait 16 bits de large, sont ainsi transférées les indications DPL et Type, au travers du masque FF00h . Si la destination fait 32 bits, il faut ajouter S, P, AVL, D/B et G, ceci au travers du masque 00FXFF00h .

L'instruction LSL  (Load Segment Limit), la source est toujours un sélecteur. La limite décodée (elle figure en plusieurs morceaux dans le descripteur) est transférée dans la destination, un registre. Cette manœuvre est un peu plus compliquée, voir la documentation.

Les instructions VERR  (VErify segment foR Reading) et VERW  (VErify segment foR Writing) vérifient si le segment désigné par le sélecteur opérande (registre ou mémoire, en 16 bits bien entendu) est autorisé en lecture ( VERR ) et en écriture ( VERW ) pour le niveau de privilège courant CPL. Réponse dans ZF.

L'instruction INVD  (INValiDate cache, no writeback) dévalide les caches internes du processeur en oubliant les contenus, donc en perdant les valeurs qui auraient été modifiées dans le cache et non sauvées en mémoire. Usage rare, généralement quand la situation est déjà dégradée en terme de cohérence, peut-être dans les cas où le contenu du cache est plus douteux que celui de la mémoire associée. WBINVD  (INValiDate cache, With writeBack) est généralement préférée : elle fait la même chose en synchronisant au préalable caches et mémoire.

L'instruction INVLPG  (INVaLidate TLB Entry) prend en opérande une adresse en mémoire. Elle détermine dans quelle page (voir pagination) elle se trouve, et vide l'entrée correspondante dans le TLB (Translation Lookaside Buffer). À quoi ça sert ? Sais pas !

L'instruction HLT   (HaLT processor) est une des rares au sein de cette liste à exister depuis les premiers modèles de CPU. Elle place le processeur dans l'état HALT, un état de léthargie totale dont il ne peut sortir que par une interruption NMI ou SMI, un INT non masquée, les signaux #RESET , #INIT et #BINIT .

Rappelons que le mode SMM (System Management Mode) n'est accessible qu'à travers une interruption. L'instruction RSM   (Return from System Management mode) est mise à la disposition des programmes système (et même très système) tournant dans ce mode, pour renvoyer l'exécution à l'endroit et dans le contexte du moment de l'interruption.

Voilà pour les instructions système, il faudrait y ajouter des avatars particuliers de certaines instructions classiques : MOV , quand un opérande est un registre de contrôle CRn. En fait, toutes les instructions qui accèdent ou pire modifient des registres chauds ou des registres contenant des flags non modifiables.

Envoyer un message à l'auteur

se connecter pour pouvoir écrire

Ce site est créé et maintenu (laborieusement) par Pierre Maurette
hébergé par 1 & 1