INITIATION A l'ASSEMBLEUR N° 3

 

Attention, baissez l'intensitée de votre moniteur jusqu'à obtention d'un beau texte blanc sur fond marron (parceque tel quel, blanc sur fond rouge vif, vous ne tiendrez pas jusqu'à la fin). (Sur écran VGA appuyez sur les touches F5 à F8).

Maintenant reprenons cette initiation...

 

CODAGE D'UNE INSTRUCTION

L'avantage de l'asm est d'offrir un contrôle total du processeur. Il est donc nécessaire que l'assembleur traduise sans altérer votre source de manière à ce que vous tapiez en mnémoniques anglaises devienne directement ce qui sera exécuté par le processeur. Mieux encore : il est très intéressant (i.e. indispensable) de savoir plus ou moins assembler soit même des instructions, c'est à dire prévoir à l'avance ce que devra générer l'assembleur et aussi pouvoir modifier ou créer des instructions dans votre programme.

Ce chapitre va donc tenter de vous décrire sommairement comment le processeur décode la mémoire pour déterminer les instructions.

Les préfixes

Toute instruction se compose de préfixes (facultatifs), d'un OpCode, et d'opérandes (facultatives). Chacun des 11 préfixes possibles est constitué d'un octet. Dans l'ordre d'apparition, ces préfixes peuvent être :

       hF2/hF3  (REPNZ/REPZ)
       hF0      (LOCK)
       h67      (Taille d'adresse non-defaut)
       h66      (Taille d'opérande non-defaut)
       h2E/h36/h3E/h26/h64/h65  (OverRide de segment)

Une instruction peut posséder plusieurs préfixes, et peut même, selon le processeur, posséder des préfixes sans rapport avec l'instruction (par exemple un OverRide de segment et/ou un REPZ devant un 'MOV AX,12').

LOCK est le moins utile de tous ces préfixes puisqu'il ne fait que bloquer le bus pendant l'instruction qu'il précède. Ceci n'est utile que pour les multiprocesseurs.

Pour faire assembler ce préfixe, il suffit d'écrire 'LOCK' devant l'instruction concernée.

REPZ et REPNZ n'ont de conséquences qu'avec certaines instructions, les instructions de chaînes (Cf. Chapitre suivant. Et oui, faut tout lire dans tous les sens, mais que voulez vous...) Devant ces instructions, chacun de ces deux préfixes occasionnera un test sur la valeur de eCX (eCX = CX ou ECX). Selon le résultat de ce test (eCX nul ou pas), l'instruction qui suit le préfixe sera exécutée ou pas. Après l'exécution, eCX sera automatiquement décrémenté et le processeur ré-exécute la même instruction, ce qui fait que eCX est à nouveau testé, puis décrémenté, etc...

Ce préfixe est donc très interressant pour exécuter plusieurs fois la même instruction (n'oubliez pas quand même que ça ne marche qu'avec les instructions de chaîne !).

Pour assembler ces préfixes, il suffit d'écrire soit 'REPZ' (ou 'REPE' ou 'REP', c'est identique), soit 'REPNZ' (ou 'REPNE') devant l'instruction.

Au fait : dans ce cas précis, 'REP' ne signifie par REPorter mais REPeat (REPéter) ;)

Les préfixes d'override de segment servent à spécifier au processeur que nous voulons utiliser un registre de segment particulier plutôt que le registre de segment par défaut pour l'instruction (qui dépend du mode d'adressage, cf. articles précédents). h2E correspond à CS, h36 à SS, h3E à DS, h26 à ES, h64 à FS et h65 à GS.

Selon les assembleurs, vous devez spécifier l'override devant l'instruction ("CS: MOV AX,[BX]"), ou devant l'opérande mémoire ("MOV AX,CS:[BX]").

 

L'OpCode

L'OpCode quant à lui spécifie l'action que devra exécuter le processeur, et aussi les registres impliqués dans l'opération. L'OpCode fait le plus souvent un seul octet, sauf si cet octet est 0F (séquence escape), auquel cas un second octet vient s'ajouter au premier.

Notez que suivant le syntaxe d'Intel, il correspond souvent plus d'un OpCode à une instruction, ce qui prête parfois à confusion.

L'OpCode peut être suivi, dans le cas d'instructions 386, d'un octet appelé ModR/M et servant de complément à l'Opcode. Dans le cas d'un mode d'adressage 32 bits, un autre octet, le SIB, spécifie l'index, la base et le facteur.

Les Opérandes

Puis suivent un éventuel déplacement (sur 1, 2, 3 ou 4 octets), puis une éventuelle valeur immédiate (sur 1, 2, 3 ou 4 octets aussi). Ces valeurs sont toujours étendues avec propagation du signe à la taille des opérandes.

Spécifier que les opérandes ou que les adresses n'auront pas la taille par défaut laisse entrevoir que le processeur peut travailler dans deux modes : le mode 16 bits et le mode 32 bits. Dans le mode 16 bits, tous les registres utilisés implicitement seront les registres 16 bits, et en mode 32 bits les registres utilisés implicitement le seront sous leur forme 32 bits. Ceci vaut pour eCX dans les LOOPs, eIP pour pointer sur les instructions, et en fait tout registre 16/32 bit utilisé dans toute instruction. Le choix entre mode 16 ou 32 bit se fait dans la partie cachée de CS. Pour des raisons évidentes, le DOS et le BIOS sont écrits pour le mode 16 bits, et donc ne nous programmerons pas en mode 32 bits. Quoiqu'il en soit les préfixes h66 et h67 devant une instruction permettent de faire exécuter celle-ci en tant qu'instruction 32 bits (et inversement si nous étions en mode 32 bits). H66 permet de changer la taille des opérandes (registres) et h67 permet d'utiliser l'un des modes d'adressage 32 bits (cf. articles précédents).

Vous n'aurez pas à préciser manuellement ces préfixes si vous utilisez un assembleur 386. Sachez tout de même que lorsque vous tapez 'MOV EAX,15' l'assembleur assemble un simple 'MOV AX,15' en rajoutant le préfixe h66, et que lorsque vous utilisez un mode d'adressage 32 bits, l'octet h67 se rajoute devant l'instruction. Sur les assembleurs MASM/TASM, la directive USE16 ou USE32 permet à l'assembleur de déterminer si le mode par défaut est 16 ou 32 bits.

 

INSTRUCTIONS DE CHAINE

Les instructions de chaîne sont les instructions sur lesquelles agissent les préfixes REPZ/REPNZ. Elles tiennent ce nom de ce qu'elles ont étée concues pour simplifier le traitement de listes d'octets, mots ou doubles-mots.

 

MOVS

Il s'agit de la plus intéressante de ces instructions, car elle copie la valeur DS:[eSI] vers ES:[eDI], puis incrémente ou décrémente les deux pointeurs eSI et eDI de façon à ce qu'ils pointent sur la valeur suivante/précédente.

Le choix entre incrémentation ou décrémentation se fait grâce au flag D, dont les instructions CLD/STD permettent de fixer la valeur. Si D=1, les pointeurs seront décrémentés, et incrémentés dans le cas contraire.

Notez aussi qu'un override de segment sur cette instruction permet de changer le choix de DS pour référencer eSI.

Cette instruction est intéressante car c'est le moyen le plus rapide pour déplacer des octets (sur 386 et 486 en tout cas).

 

LODS / STOS

Les instructions LODS et STOS font chacune la moitié de MOVS : LODS charge dans AL, AX ou EAX la valeur pointée par DS:eSI, tandis que STOS recopie la valeur de AL, AX, ou EAX dans ES:[eDI]. Notez qu'à partir du 486 ces instructions sont plus lentes qu'un simple MOV + ADD/SUB.

 

CMPS / SCAS

L'instruction CMPS compare les valeurs pointées par DS:eSI et ES:eDI et ajuste le flag Z en conséquence, si bien qu'il est possible par exemple, avec les préfixes REPZ/REPNZ, de trouver les différences entre deux listes ou bien au contraire de trouver les similitudes. Comme la comparaison s'arrête toujours quand eCX=0, il est alors intéressant d'utiliser l'instruction JCXZ pour séparer le cas ou l'instruction à été stoppée car DS:[eSI]=ES:[EDI] ou bien car eCX=0.

SCAS compare quant à elle de la même manière AL ou eAX à la valeur de même taille pointée par ES:eDI.

 

INS / OUTS

Les deux instructions INS et OUTS permettent d'écrire ou de lire des valeurs en direction/provenance des ports. Nous reverrons cela lors d'un chapitre traitant des I/O.

Retenez toutefois que ces instructions ne peuvent être utilisées que lorsque le hard les supporte, ce qui est rare.

 

SECOND PROGRAMME : NOMBRES PREMIERS

Voici un petit exemple qui va nous permettre d'utiliser une autre catégorie d'instructions : les instructions de manipulation de bit.

Le but du jeu est d'afficher la liste des premiers nombres premiers (hum, pas terrible la phrase).

Pour cela il y a deux solutions simples (je ne doute pas une seule seconde que Pierre connaisse trente autres solutions meilleures que les miennes) : Verifier pour chaque nombre s'il est premier ou pas, en essayant de le diviser par tous les entiers qui lui sont inférieurs (en fait, pas nécessairement tous, mais bon), ou bien considérer une table de nombres et éliminer tous les multiples de 2, puis de 3, de 4, etc... Remarquez d'ores et déjà une optimisation : une fois que l'on a éliminé les multiples d'un entier, il n'est plus nécessaire d'éliminer les multiples des multiples de cet entier. Concrètement, une fois qu'on à retiré 4 - multiple de 2 - il devient inutile d'éliminer les multiples de quatres - multiples de 2 aussi par la force des choses.

C'est cette solution que nous allons mettre en application.

Nous allons utiliser une table de 64Ko pour représenter les 64*1024*8=524288 premiers nombres entiers (un bit par nombre). Si le bit n est à 0, sela signifiera que le n-ième entier est premier, et s'il est à 1 qu'il est multiple d'un autre entier différent de 1 (et sinon, qu'il y a un sérieux bug quelque part au niveau du hard).

La première partie du programme consiste donc à éliminer les multiples. Après cela, on affichera tous les nombres dont le bit est à 0.

 

Initialisation

Première chose à faire : afficher un petit message histoire de faire patienter. Pour cela, pas de problèmes : interruption DOS (21h), fonction 9 (dans AH), avec le message pointé par DS:DX et terminé par un '$' :

	mov dx,o message
        mov ah,9
        int 21h

        ...

        message  db 'Calcul des nombres premiers en cours...',10,13,36

10 et 13 sont les caractères de fin de ligne (pour que le curseur aille au début de la ligne suivante), et 36 est le code ASCII de '$' (Valeurs décimales).

<message> étant défini dans l'unique segment d'un programme .COM, et DS étant égal à CS au début du programme, il n'y a aucune raison de changer le contenu de DS.

Le "o" dans "mov dx,o message" est une abrévation pour "offset" (zut, je vais vous refiler mes sales manies de flemmard).

Puis il faut réserver 64Ko de mémoire. Pour cela on utilise la fonction 48h de l'interruption DOS, avec dans BX la taille en paragraphes (bloc de 16 octets) de la zone demandée. Mais avant cela, il faut préciser au DOS que le programme n'utilise pas toute la mémoire. En effet le DOS réserve toute la mémoire lorsqu'il charge un programme COM (alors que 64Ko suffiraient forcément, il est bête ce DOS), ce qui pose problème car si on sait que l'on dispose de mémoire supplémentaire, on ne sait pas où elle est. On ramène donc la taille de notre programme à 64Ko (ce qui est très large vu que le programme fait en fait 221 octets) grace à la fonction 4Ah :

	mov bx,1000h    ; 1000h pour 65536 octets,
        mov ah,4ah      ; c'est la taille réservée pour le
        int 21h         ; programme.
        mov ah,48h      ; Et on alloue ensuite 65536 octets.
        int 21h
        mov es,ax       ; ES:0 = DS:0 = début de la zone
        mov ds,ax       ; allouée.

Il faut ensuite initialiser la table de 524288 bits à zéro. Pour cela on utilise l'instruction STOSd (équivalent à MOV ES:[DI],EAX ; ADD DI,4", mais plus court) :

	xor di,di       ; idem "MOV DI,0"
        mov cx,4000h    ; 4000h doubles-mots = 65536 octets
        xor eax,eax     ; idem "MOV EAX,0"
        cld             ; Pour que DI soit incrémenté
        rep stosd       ; Pour repeter 4000h fois STOSd

Remarquez que pour mettre un registre 16 ou 32 bits à zéro on fait généralement XOR Reg,Reg (vérifiez que cela efface bien tous les bits du registre) ou plus simplement SUB Reg,Reg. Ce n'est pas plus rapide qu'un MOV, mais c'est plus court. Pour un registre 8 bits par contre, on préferera MOV Reg,0 qui est quand même plus explicite.

 

Elimination des multiples

Nous allons donc boucler sur tous les entiers qui ne sont pas eux même des multiples d'autres entiers, et éliminer tous leurs multiples. Voici l'algo :

      1/ A = 2                                      (Pas 1 surtout !)
      2/ Si le Aième bit est 1, GOTO 7       (A est-il un multiple ?)
      3/ B = A*2    (le premier multiple est A+A. Attention de ne pas
                                            mettre le bit de A à 1 !)
      4/ Mettre le bit B à 1                    (B est multiple de A)
      5/ B = B+A                          (Passe au multiple suivant)
      6/ Si B<524288 GOTO 4                  (Tant qu'on n'arrive pas
                                                à la fin de la table)
      7/ A = A+1         (Sinon, on recommence avec l'entier suivant)
      8/ Si A<262144 GOTO 2       (Tant que l'on arrive pas à la fin)

Plusieurs commentaires :

Traduire cela en assembleur est très facile à condition de connaître les instructions de bit. Nous utiliserons uniquement BTS pour mettre un bit à 1, et BT pour tester si A ou B ne sont pas supérieurs à leur plus grande valeur permise.

BT (Bit Test) recopie un bit dans le carry (CF). Le bit en question est repéré grace à l'adresse de la table et le rang du bit. Dans notre cas, l'adresse de la table est DS:0 et le rang est A ou B (le nombre entier considéré).

BTS fait exactement la même chose sauf qu'en plus de recopier le bit, BTS le met à 1 ensuite dans la table (Bit Test and Set).

Ceci nous donne, en prenant EAX pour A et EBX pour B :

              mov eax,2            ; A = 2
	boucle_A:
              bt ds:[0],eax        ; Teste le A-ième bit
              jc nxt_A             ; Si CF=1, GOTO nxt_A
      
              mov ebx,eax          ; B = A
              add ebx,ebx          ; B = B+B  (B = 2*A)
       	boucle_B:
              bts ds:[0],ebx       ; Met le B-ième bit à 1
              add ebx,eax          ; B = B+A
              bt ebx,19            ; B >= 524288 ?
              jnc boucle_B         ; Si non, boucle
      
       	nxt_A:
              inc eax              ; Si oui, passe au A suivant
              bt eax,18            ; A >= 262144 ?
              jnc boucle_A         ; Si non, boucle
                                   ; Si oui, c'est fini

Quelques explications au sujet des BT : 524288=2^19, donc en binaire cela s'écrit 10000000000000000000b (19 zéros). Donc, quand ebx sera supérieur ou égal à 524288, le 19ème bit sera égal à 1, et 0 sinon. De même avec le 18ème bit pour eax (262144=524288/2 donc =2^18).

 

Affichage du résultat

Le temps mis à afficher le résultat va être bien supérieur à celui nécessaire au calcul de la table. Nous allons encore utiliser la fonction DOS N°9.

Voici comment nous allons procéder : Nous allons boucler sur tous les entiers en utilisant ecx comme compteur. Si le ECXième bit de la table est 0, on affichera ECX (ECX est premier dans ce cas là).

Le problème à résoudre est celui d'afficher un nombre entier (ECX). En assembleur, il faut tout décomposer : afficher un nombre entier, cela signifie envoyer à l'écran les caractères ASCII qui composent une représentation (décimale, hexadécimale, binaire, ou autre) du nombre. Le but n'étant pas de faire des choses compliquées nous allons afficher les entiers en hexadécimal.

Rien de plus simple. Les nombres étant tous inférieurs à 2^19, il suffit de 5 chiffres pour représenter les nombres (19/4 = 4.75, que l'on arrondi à 5 car envoyer trois quarts de caractère ASCII me pose un gros problème métaphysique). Si vous avez compris ce que j'ai raconté dans le premier article, vous devez savoir que le nombre à afficher en hexa peut se décomposer sous la forme :

       Digit1 + Digit2*16 + Digit3*256 + Digit4*4096 + Digit5*65536

... avec chacun des digits pouvant aller de 0 à 15 (0 à Fh).

Plus fort encore : l'avantage d'afficher le nombre en hexadécimal c'est que les Digits sont directement déductibles de la valeur par manipulation binaire. Prenons un exemple, le nombre 132569 :

         18 17 16 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00
        +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
        | 0| 1| 0| 0| 0| 0| 0| 0| 1| 0| 1| 1| 1| 0| 1| 1| 0| 0| 1|
        +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

Vous devez vous souvenir que pour convertir du binaire en hexa il suffit de remplacer chaque groupe de 4 bits par le digit hexadécimal correspondant (cf table dans le premier article). Ceci nous donne : 205D9

Nous allons donc utiliser une table contenant les 16 digits hexa ASCII dans l'ordre, et isoler les 4 bits qui nous intéressent (masque + décalage), puis afficher le caractère se trouvant à l'adresse de la table plus les 4 bits en question.

En fait, nous n'allons pas afficher les caractères les uns après les autres mais plutôt constituer une chaîne de caractères que nous enverrons en une seule fois pour tout le nombre (plus simple et plus rapide).

Voici le programme :

           ; affichage de la liste des nombres premiers jusqu'a 52428

           push cs       ; DS=CS à partir de maintenant, pour utiliser
           pop ds        ; la fonction 9 de l'int 21h (DS:DX -> texte).
           mov ecx,1     ; Premier entier à tester (0 est toujours exclu).

    boucle_m:           ; Boucle sur tous les entiers.
           bt es:[0],ecx ; Ce nombre (ECX) est-il premier ?
           jc nxt_m      ; Si non, on ne l'affiche pas.

           ; afficher ecx en hexadecimal sur 5 digits

           mov edx,ecx   ; On va travailler sur EDX et laisser ECX.
           mov eax,4     ; EAX boucle sur les 5 digits (4 à 0).
    boucle_cur:
           mov bx,dx     ; On isole les 4 bits faibles de EDX
           and bx,1111b  ; dans BX.
           mov bl,[tbl_convert+bx]     ; BL = l'ASCII du digit.
           mov [texte+eax],bl   ; On écrit cet ASCII dans la chaine.
           shr edx,4     ; On décale EDX pour traiter les 4 bits du
                         ; second digit (en partant de la droite).
           dec ax        ; AX pointe sur la prochaine position dans
                         ; texte.
           jns boucle_cur ; tant qu'il n'est pas négatif, on remet ca.

           mov dx,o texte       ; Ici, AX=-1 donc on a constitué toute
                                ; la chaîne.
           mov ah,9             ; Il reste à l'afficher.
           int 21h

    nxt_m:
           inc ecx              ; Quand c'est fait, on passe à l'entier
           bt ecx,19            ; suivant.
           jnc boucle_m         ; Tant que l'on n'arrive pas à la fin.

           ...

    tbl_convert db '0','1','2','3','4','5','6','7'   ; Table de conversion
                db '8','9','A','B','C','D','E','F'
    texte       db '00000 ; ',36  ; La chaîne de caractère à ecrire
                                  ; (après écriture des digits corects).

 

Quitter le programme

Avant de rendre la main au DOS, il est indispensable de désallouer la mémoire réservée pour la table de bits (fonction 49h, adresse = ES:0). Puis c'est le traditionnel appel à la fonction 4Ch qui met fin au programme.

	; exit

        mov ah,49h
        int 21h
        mov ah,4ch
        int 21h

 

PREMIERES TECHNIQUES DE CODE

Voici terminé ce second programme d'exemple. Je pense que désormais vous avez une petite idée de la façon dont on programme en assembleur (maintenant, vous n'êtes plus un programmeur, vous êtes un codeur, woaouh !). Toutefois, j'aimerais vous glisser quelques conseils qui n'engagent que moi mais qui je l'espère pourront vous être utiles. Faites en ce que vous voulez, mais c'est toujours bon à prendre.

Côté organisation, il me semble que certaines règles peuvent être salutaires aux débutants.

La plus importante de toutes vous la connaissez déjà, Warrant à déjà insisté sur ce point mais je le refait : ne commencez jamais à coder directement sur votre micro ! C'est le meilleur moyen pour passer sur un programme plus de temps que nécessaire ! En effet, avec les éditeurs avec fiches techniques intégrées, DOCs en lignes, environnements de programmation complets et gestionnaires de projets, on a tendance à ne plus éteindre sa machine pour programmer.

Grave erreur car réflechir à un programme avec un stylo et un bloc de papier est plus profitable que d'affronter d'entrée de jeu la machine : sur papier, on peut écrire en abrégé, ne pas se soucier des fautes, écrire du code non assemblable, etc, alors que, tout convivial qu'il soit, votre éditeur nécessitera de votre part une plus grande part d'attention que votre stylo. Du reste, on écrit souvent plus rapidement qu'on ne tappe au clavier, ce qui permet de ne pas être ralentit dans son raisonnement par le temps de frappe (n'oubliez pas qu'en asm, il faut généralement beaucoup de lignes pour faire bien peu de choses).

En fait, idéalement, vous devriez ne vous servir de votre éditeur que pour recopier votre programme, et non pour le créer. Bien sûr, comme tout le monde, il m'arrive souvent moi même de ne pas respecter ce principe, mais je vous assure que quand je m'y tiens, je gagne du temps (souvent du temps de débuggage, comme le précisait Warrant).

Tant qu'on y est, voici le second principe : ne pas forcément chercher à commencer par le début. En effet, le début d'un programme asm est souvent long et peu intéressant (initialisations en tout genre). Ce n'est pas la peine de se noyer dans cette phase pour ne pas arriver au point essentiel, comme cela m'est souvent arrivé. Contentez-vous donc du strict minimum pour faire tourner une version de test sous un debugger.

Troisième principe concernant l'assemblage : ne laissez pas l'assembleur faire des choses dans votre dos ! Je suis peut-être le seul à penser ça, mais je vois mal ce qu'apportent les directives de MASM/TASM en lisibilité, clarté, ou simplicité. Tenez vous en donc toujours aux instructions, c'est un conseil. Oubliez donc les 'RET', 'PROC', 'JUMPS', 'ARG', 'MODEL', 'STARTUPCODE' et compagnie, dont le but semble être d'embrouiller le codeur en lui cachant le résultat final.

Pour le debuggage, juste un dernier principe très important : Evitez à tout prix le "trash-code" ! Il n'y a pas grand chose de pire qu'un bug qui disparait sans que l'on comprenne pourquoi. Il vous arrivera souvent ceci : "j'ai changé ça et pis ça, et maintenant ça marche alors j'y touche plus". Il n'y a rien de pire ! Si vous ne comprennez pas d'où vient un bug, attendez-vous à le voir ressurgir sous une autre forme, et là se sera vraiment sans espoir. Si vous avez déjà taté d'un peu de code, vous comprenez sûrement pourquoi j'insiste là dessus...

Mais comme on ne code pas en assembleur uniquement pour le plaisir, et qu'il arrive que l'on recherche les performances, voici aussi quelques points à garder dans un coin de sa tête pour avoir un code rapide :

Comptabilisez les cycles, les pénalités, ainsi que la taille du code.

Quelques règles simples :

 

A partir de la prochaine fois, nous ne travaillerons plus qu'à base d'exemples. Relisez l'init vidéo de Mogar car on va sûrement faire une petite excursion au pays des graphismes, histoire de matérialiser un peu nos programmes (juste en mode 13h, mais je ne rappellerai pas comment ça marche ni où ça se trouve).

 

Codez bien d'ici là !

===========================[JMP $]=============================