INITIATION A L'ASSEMBLEUR - 2ème Partie

 

Sont abordés dans cet article :

 

  • NOTIONS FONDAMENTALES

    • Exemples d'Instructions
    • La Pile
    • Les Interruptions
    • L'Indicateur FLAG
    • Instructions Conditionnelles
    • L'Architecture d'un Programme

       

  • UN PROGRAMME EN ASSEMBLEUR

    • L'Environnement du PC
    • Assemblage d'un Programme
    • Premier Programme
    • ** Source : Welcome.ASC **

 

NOTIONS FONDAMENTALES

Exemples d'Instructions

Nous allons maintenant voir nos premières instructions, en commencant par l'instruction fondamentale, celle qui permet de copier une valeur :

 

        +----------------------------------------------------------+
        | OV  MOV  MOV  MOV  MOV  M  de l'anglais "move", mais at- |
        | V  MOV  MOV  MOV  MOV  MO  -tention,  il s'agit bien  de |
        |   MOV  MOV  MOV  MOV  MOV  copier et  non de  déplacer ! |
        +----------------------------------------------------------+

Cette instruction nécessite deux opérandes, une destination et une source, spécifiées dans cet ordre. Chacun des trois types d'opérande est utilisable (registre, contenu mémoire ou valeur immédiate), avec les restrictions suivantes :

Exemples :

            MOV AL,BL                     copie BL dans AL (AL <- BL)

            MOV CX,Word [ES:BX]           CL <- Byte [ES:BX]
                                          CH <- Byte [ES:BX+1]

            MOV DWord [h1038],h03BC2771   [DS:h1038] <- h71
                                          [DS:h1039] <- h27
                                          [DS:h103A] <- hBC
                                          [DS:h103B] <- h03

Exemples incorrects :

            MOV 5,1                       Incodable et idiot :)

            MOV Byte [FS:EDX*2],Byte [0]  Incodable, dommage :(

            MOV AL,DWord [GS:BX+DI]       Les deux opérandes n'ont
                                          pas la même taille

            MOV Word [SS:10+EAX+EAX],ECX  Idem

Maintenant, que se passerait-il pour quelque chose comme ceci :

            MOV BX,Word [BX]              ???

Et bien, il se passe en fait la seule chose possible : BX prend la valeur du mot à l'adresse que contenait BX au moment du décodage de l'instruction. Aucun problème, donc.

 

        +----------------------------------------------------------+
        | MP  JMP  JMP  JMP  JMP  JMP  JMP  JM  de "jump", sauter. |
        +----------------------------------------------------------+
Jmp appartient à une autre catégorie d'instructions. Son rôle est de modifier le pointeur d'instruction.

Pour ceci, un jmp ne requiert qu'une opérande. Cette opérande peut être une valeur relative signée à ajouter à (E)IP (on parle alors de jmp short ou jmp relatif), ou bien un pointeur à charger dans CS et (E)IP (jmp far), ce pointeur pouvant être spécifié par une valeur immédiate, par un registre ou par le contenu d'une adresse mémoire.

Exemples :

            JMP h5D80:h134C            CS <- h5D80
                                       (E)IP <- h134C

            JMP 0                      JMP relatif de déplacement
                                       nul : ne sert à rien

            JMP -50                    JMP relatif (E)IP <- (E)IP-50

            JMP h34BB                  JMP relatif (E)IP <- (E)IP+h34BB

            JMP hA5BC0012              JMP relatif (E)IP <- (E)IP+hA5BC0012

 

        +----------------------------------------------------------+
        | DD  ADD  ADD  ADD  ADD  AD  de "add", ajouter.           |
        | B  SUB  SUB  SUB  SUB  SUB  de "substract",  soustraire. |
        +----------------------------------------------------------+

ADD sert à additionner, et nécessite deux opérandes : une source et une destination. La destination prendra la valeur de source + destination. Les contraintes de validité sont les mêmes que pour MOV, mais cette fois-ci on ne peut pas du tout utiliser de registre de segment.

Exemples :

            ADD EDX,EAX                   EDX <- EDX+EAX
                                          EAX reste inchangé

            ADD Byte [SI+2],-123          [SS:SI+2] <- [SS:SI+2] - 123

            ADD Byte [SI+2],133           Idem (-123=133)

Exemples incorrects :

            ADD 5,Word [GS:1+EBP+EDX*2]
            ADD BX,AL
            ADD DWord [0], DWord [1]

SUB soustrait deux valeurs. Le fonctionnement est le même que ADD, je n'y reviens pas.

 

        +----------------------------------------------------------+
        | OT  NOT  NOT  NOT  NOT  NOT  NOT  NOT  N  de "not", non  |
        | D  AND  AND  AND  AND  AND  AND  AND  AN  de "and",  et  |
        |   OR   OR   OR   OR   OR   OR   OR   OR   de "or",   ou  |
        |  XOR  XOR  XOR  XOR  XOR  XOR  XOR  XOR   "eXclusive OR" |
        +----------------------------------------------------------+

NOT inverse tous les bits de l'opérande spécifiée (Cf. table logique à la fin de ce chapitre).

AND nécessite une destination et une source tout comme ADD ou SUB. La destination prendra la valeur de destination ET source, la relation ET s'appliquant bien évidement à chacun des bits (Cf. table logique à la fin de ce chapitre).

OR nécessite une destination et une source tout comme ADD ou SUB. La destination prendra la valeur de destination OU source, la relation OU s'appliquant bien évidement à chacun des bits (Cf. table logique à la fin de ce chapitre).

XOR nécessite une destination et une source tout comme ADD ou SUB. La destination prendra la valeur de destination OU-Exclusif source, la relation OU-Exclusif s'appliquant bien évidement à chacun des bits (Cf. table logique à la fin de ce chapitre).

Et vive le copier-coller !

            Tables de vérité :
            ------------------


            +-----+-----+        +-------+-------+-----+----+-----+
            | Bit | NOT |        | Bit 1 | Bit 2 | AND | OR | XOR |
            +-----+-----+        +-------+-------+-----+----+-----+
            |  0  |  1  |        |   0   |   0   |  0  |  0 |  0  |
            |  1  |  0  |        |   0   |   1   |  0  |  1 |  1  |
            +-----+-----+        |   1   |   0   |  0  |  1 |  1  |
                                 |   1   |   1   |  1  |  1 |  0  |
                                 +--------------------------------+

Exemples :

	NOT b10011010 = b01100101
        b10011010 AND b11110100 = b10010000
        b10011010 OR  b11110100 = b11111110
        b10011010 XOR b11110100 = b01101110

J'arrête là les présentations, vous avez maintenant une idée de ce qu'est une instruction. Le plus dur maintenant cela va être d'apprendre à programmer avec elles.

Mais avant de commencer à organiser ces instructions pour former un programme, il nous manque encore quelques notions telles que la pile, les interruptions, les indicateurs, etc... Ces notions vous sont expliquées dans les paragraphes suivants.

 

La Pile

La pile est un type de structure de données, c'est à dire en fait un concept tout ce qu'il y a de plus abstrait. Cette structure de données particulière facilite le stockage de valeurs temporaires dans un système "LIFO", ce qui est très utile au programmeur mais aussi au processeur, en conséquence de quoi presque tous les ordinateurs (et calculatrices) utilisent une pile.

"LIFO" signifie "Last In First Out", c'est à dire "dernier entré, premier sorti". Plutôt que d'entrer dans les détails maintenant, je vais d'abord vous donner les deux principales instructions de pile, tout le fonctionnement apparaîtra ensuite clairement.

        +----------------------------------------------------------+
        | SH  PUSH  PUSH  PUSH  PUSH  PUSH  PUSH  PU  = "pousser"  |
        | P  POP   POP   POP   POP   POP   POP   POP  = "extraire" |
        +----------------------------------------------------------+

Push nécessite une opérande (source) sur 16 ou 32 bits. Si l'opérande est sur 16 bits (respectivement 32 bits), le processeur commence par soustraire 2 à (E)SP (respectivement 4), puis il met l'opérande dans [SS:(E)SP]. Tout se déroule comme si l'on faisait:

        PUSH s16  =  SUB (E)SP,2          PUSH s32  =  SUB (E)SP,4
                     MOV [SS:(E)SP],s16                MOV [SS:(E)SP],s32

... Sauf qu'ici s32 peut représenter une adresse mémoire.

POP est l'opération inverse et nécessite une opérande destination sur 16 ou 32 bits pour y recopier la valeur contenue dans [SS:(E)SP] avant d'augmenter (E)SP de 2 ou 4 (selon la taille de l'opérande, comme pour PUSH).

         POP d16/32  =  MOV d16/32,(D)Word [SS:(E)SP]
                        ADD (E)SP,2/4

Vous commencez donc probablement à voir pourquoi on appelle un tel système "LIFO" : l'ordre dans lequel on récupère les valeurs de la pile est l'inverse de celui dans lequel on à envoyé ces valeurs dans la pile. Petit exemple :

            PUSH Word 15                  'Word' car cela pourrait
            PUSH Word -8                  aussi être à prendre comme
            PUSH Word 51000               des DWords
            POP AX                        AX <- 51000
            POP BX                        BX <- -8
            POP CX                        CX <- 15
            PUSH 1234ABCDh                Ici c'est forcément DWord
            POP DX                        DX <- ABCDh
            POP SI                        DI <- 1234h

Nous pouvons maintenant revenir à la notion de pile...

Prenons la séquence de code suivante :

            PUSH Dword v1
            PUSH Word v2

On représente la pile grace à ce schéma :

                      .           .                           |
                      :           :                           | Sens des
                      |           |                           | adresses
                      +-----------+                           | croissantes
     SS:(E)SP après ->|    v2     | <----- Sommet de la pile  |
                      +-----------+                           |
                      |  0..15 v1 |                           |
                      +-----------+                           |
                      | 16..31 v1 |                           |
                      +-----------+                           |
     SS:(E)SP avant ->|   ? ? ?   | <----- Base de la pile    |
                      +-----------+                           |
                      |           |                           |
                      :           :                           |
                      .           .                           V

Vous remarquerez qu'aucune valeur n'est jamais écrite dans la base de la pile, et que (E)SP pointe toujours sur la dernière valeur envoyée.

Vous remarquerez aussi que l'aspect général ressemble bien à une pile : on rajoute des éléments au sommet, ou on retire des éléments du sommet. Avant de retirer un élément, il faut déjà retirer tous ceux qui sont au-dessus de lui (PUSHés après). Bref, cette fois plus de doute : c'est bien du LIFO...

Il faut en général veiller à l'équilibre de la pile, c'est à dire à POPer tout ce qu'on y a PUSHé.

 

Les Interruptions

Le principe d'interruption permet aux divers composants du PC d'influencer le fonctionnement de la machine en faisant exécuter au processeur du code particulier. Pour cela, le processeur dispose de deux broches d'interruption, la broche INTr et la broche NMI.

Entre l'exécution de deux instructions, le processeur vérifie l'état de ces deux broches (NMI d'abord, puis INTr). Si l'une d'entre elles est active, cela signifie qu'il doit interrompre son travail pour exécuter du code plus important. La différence entre les deux broches est que la broche INTr est masquable (NMI signifie d'ailleurs Non-Masquable-Interrupt). En effet l'indicateur IF permet, s'il est à zéro, d'inhiber la broche INTr (Cf. Indicateur FLAG).

Chaque interruption est identifiée par son numéro, de 0 à 255. Lorsque le processeur doit servir une interruption, il faut tout d'abord qu'il en détermine le numéro.

Le numéro d'interruption associé à la broche NMI est automatiquement le numéro 2.

Pour la broche INTr, il doit communiquer avec la puce qui a enclenchée cette broche : le contrôleur d'interruption 8259 (ou encore PIC pour Programmable Interrupt Controler). C'est ce contrôleur qui est relié aux différentes extensions et qui leur permet de déclencher ces interruptions en associant à chacune d'elles une IRQ (Interrupt ReQuest), c'est à dire un numéro dont découlera le numéro d'interruption (par l'addition d'une valeur de base, 8 ou h70 en général). Le fonctionnement du 8259 dépassant largement le cadre de cet article, je ne détaillerai pas plus son fonctionnement.

Une fois le numéro de l'interruption connu, il reste à trouver l'adresse du code à exécuter. Pour cela, le processeur utilise une table, nommée table d'interruption, dont l'adresse est spécifiée par un registre spécial (IDTR). Dans plus de 99% des cas, cette adresse sera 00000. Cette table contient - en mode réel - une série d'adresses segment:offset dont la première est celle du gestionnaire de l'interruption 0, puis celle du gestionnaire de l'interrutpion 1, etc... L'adresse du gestionnaire de l'interruption N se trouve donc à l'offset N*4 (4 octets par adresse segment:offset).

Pour transférer le contrôle à cette adresse, le processeur fait plus qu'un simple JMP Far, car il doit préserver l'adresse actuelle du code interrompu pour pouvoir reprendre son exécution sitôt que le gestionnaire de l'interruption aura fini son travail. Référez-vous au chapitre sur les appels de routines pour voir les détails de ce transfert de contrôle (Cf. Architecture d'un programme / INT).

 

L'Indicateur FLAG

Les indicateurs sont les bits d'un registre un peu spécial (registre de flag ou d'indicateurs). Ces bits sont indépendants les uns des autres, et peuvent être modifiés selon le résultat des instructions exécutées par le processeur. En effet, la plupart des instructions ont la propriétés de modifier la valeur de certains de ces indicateurs en fonction de leur résultat.

Voici le contenu du registre de flag, les bits les plus interressants seront détaillés ensuite :

     32     ...     17 16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
      +------------+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |           ???????            |OF|DF|IF|??|SF|ZF| 0|AF| 0|PF| 1|CF|
      +------------+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      \___________________________  EFLAGS  _____________________________/
                         \__________________  FLAGS  ____________________/

Les bits marqués 0 ou 1 ne doivent pas être modifiés. Les bits marqués '??' ne sont pas étudiés.

CF (Carry Flag) :

C'est le flag de retenue : dans les opérations arithmétiques, il arrive parfois que le résultat ne soit pas codable sur le même nombre de bits que les opérandes ; il sort donc un bit, dit "bit de retenue". Ce bit est recopié dans CF. Par ailleurs, de nombreuses instructions peuvent modifier la valeur du carry.

Instructions associées : CMC (complement carry), qui inverse la valeur de CF, STC (set carry), qui le met à un, et CLC (clear) qui le met à zéro.

PF (Parity Flag) :

Globalement, cet indicateur est mis à 1 si le nombre de bits d'une opérande est pair. Cela n'est toutefois pas d'une grande importance.

AF (Auxiliary carry) :

Ce bit fonctionne comme CF mais considère la retenue sur les 4 bits de poids faibles. Sans grand interêt...

ZF (Zero Flag) :

Mis à un par un résultat nul. Ce flag est très utilisé.

SF (Signe Flag) :

Mis à un par un résultat signé (bit le plus fort à 1).

IF (Interrupt Flag) :

Inhibe (0) ou autorise (1) la prise en charge par le processeur de l'état de sa broche INTr.

Instructions associées : CLI, qui efface ce bit, et STI, qui le remet à un.

DF (Direction Flag) :

Détermine le sens de déplacement des pointeurs dans les instructions de chaînes (Cf. Instructions de chaînes).

Instructions associées : CLD et STD.

OF (Overflow Flag) :

Mis à un par un résultat qui éxcède la taille de stockage maximal. Permet de repérer (et donc corriger) certaines erreurs arithmétiques.

Instruction associée : INTO, qui déclenche l'interruption 4 si ce flag est armé (i.e. à 1).

 

Instructions Conditionnelles

Le but premier de ces indicateurs est d'influencer le déroulement du programme en fonction du résultat des opérations effectuées. Pour cela, il est nécéssaire de disposer d'instructions dont le résultat dépend de ces indicateurs.

Parmis cette classe d'instructions se trouvent les sauts conditionnels, autours desquels s'articulent les programmes. Ces sauts sont des JMP near ou relatifs qui ne s'effectuent que si certains indicateurs sont correctement positionnés.

Voici à titre d'exemple la liste de tous ces sauts conditionnels (que cela ne vous dispense pas de trouver une liste d'instructions !) :

      Instructions    Signification                      Indicateurs
                      (Jump if...)                       testés

      JB/JNAE/JC     Below/Not Above or Equal/Carry      CF=1
      JAE/JNB/JNC    Above or Equal/Not Below/Not Carry  CF=0
      JE/JZ          Equal/Zero                          ZF=1
      JNE/JNZ        Not Equal/Not Zero                  ZF=0
      JO             Overflow                            OF=1
      JNO            Not Overflow                        OF=0
      JP/JPE         Parity/Parity Even                  PF=1
      JNP/JPO        No Parity/Parity Odd                PF=0
      JS             Signed                              SF=1
      JNS            Not Signed                          SF=0
      JA/JNBE        Above/Not Below or Equal            CF=0 et ZF=0
      JBE/JNA        Below or Equal/Not Above            CF=1 ou ZF=1
      JG/JNLE        Greater/Not Less or Equal           ZF=0 et SF=OF
      JGE/JNL        Greater or Equal/Not Less           SF=OF
      JL/JNGE        Less/Not Greater or Equal           SF!=OF
      JLE/JNG        Less or Equal/Not Greater           ZF=1 ou SF!=OF

Dans la liste ci-dessus, vous remarquerez que JA/JB/etc s'appliquent aux valeurs non-signées et JG/JL/etc aux valeurs signées (Cf. SF et OF).

La plupart des appelations des ces sauts conditionnels est due à l'instruction suivante : CMP.

CMP est un SUB qui ne modifie ni la source ni la destination, mais qui se contente de modifier les indicateurs. Par exemple, "CMP AX,BX" équivaudra à "SUB AX,BX" sauf que AX ne sera pas modifié. Par contre, le flag ZF sera mis si BX=AX (AX-BX=0).

Il est donc plus naturel de faire suivre une instruction de comparaison par un saut tel que "JE", "JB", "JAE", etc, plutôt que par leurs équivalents "JZ", "JC", "JNC", etc, dont la signification dans le contexte est moins nette.

 

Architecture d'un Programme

Pour réaliser un programme, il ne suffit pas de connaître les instructions, mais il faut aussi organiser ces instructions de telle sorte que l'on atteigne l'objectif que l'on s'était fixé. C'est ceci qui est réellement appelé "programmation", et c'est ceci que nous allons aborder succinctement dans ce chapitre.

Pour réaliser un problème donné, la règle générale est de décomposer ce problème en problèmes plus petits, et de recommencer encore et encore, jusqu'à arriver au niveau des instructions (un compilateur accélère la manoeuvre car il n'est pas nécéssaire de décortiquer autant - mais vous savez déjà une petite partie de tout le mal que je pense des compilateurs, n'y revenons pas).

Pour la décomposition, on s'efforcera d'isoler les concepts qui s'appliquent plusieurs fois à des objets identiques ou similaires pour limiter la quantité de code à produire (les membres de la secte de la POO - dont le crie de raliement est "Virtualisons, virtualisons, mes frères !" - doivent me traiter de païen pour l'utilisation que je viens de faire du terme divin "objet").

Nous appellerons d'ailleurs "routine" toute partie de code paramétrée dont le but est d'être exécutée plusieurs fois dans le déroulement du programme pour traiter des objets similaires. Prenons un exemple trivial : un morceau de code qui met dans EAX le résultat de l'opération : EAX+EBX+ECX+EDX

                       ...
                       ADD EAX,EBX
                       ADD EAX,ECX
                       ADD EAX,EDX
                       ...

Pour pouvoir exécuter une même routine à partir de plusieurs endroits différents dans le programme, on utilise des instructions qui permettent de sauter à l'adresse de la routine (comme pour un JMP), puis de revenir où l'on s'était arrêté lorsque la routine est terminée. Pour réaliser un tel retour, il faut, pendant l'exécution de la routine, garder en mémoire quelque-part l'adresse du retour. C'est évidemment la pile qui va être utilisée pour stocker cette valeur.

            CALL Far :

CALL Far fonctionne comme un JMP Far, sauf qu'avant de réaliser le saut, le processeur PUSHe CS puis (E)IP sur la pile. CALL Far est donc équivalent à :

                       PUSH CS
                       PUSH (E)IP+      (IP+ = IP + taille du CALLF)
                       JMP Far v16/32

Notez cependant qu'il est impossible normalement de PUSHer IP avec un 'PUSH IP'.

            RETF (RETurn Far) :

RETF est utilisé conjointement avec CALL Far pour effectuer un retour de procédure. Ainsi, l'action du processeur pour le RETF est-elle le contraire de cette pour le CALLF : POPer (E)IP puis CS. L'exécution reprend donc ensuite là où elle avait été laissée après le CALL Far.

RETF admet optionnellement une opérande Im16/32 qui spécifie une valeur à ajouter à (E)SP après le retour, dans un but que nous étudierons plus tard.

            CALL Near :

Comme le CALL Far, CALL Near transmet l'exécution à une routine. La différence entre les deux vient du fait que CALL Near n'entraine pas le PUSH de CS, ce qui signifie que l'appel est limité au même segment de code.

            RETN (RETurn Near) :

RETN est le retour de routine associé à CALL Near : Il s'agit donc uniquement du POPage de (E)IP, et éventuelement de l'ajout de l'opérande Im16/32 à (E)SP.

            INT (INTerrupt) :

INT sert à appeler le code d'une interruption dont le numéro est fournit en opérande (octet).

Le processeur commence par sauvegarder les flags sur la pile (PUSHF - pour PUSH Flags), puis l'adresse complète segment:offset comme dans le cas d'une CALL Far.

C'est la même procédure qui est suivie lors du déclenchement d'une véritable interruption, à ceci près que le bit IF est mis à 0 avant que le processeur ne démare l'exécution d'une interruption hard.

            IRET (Interrupt RETurn) :

IRET est le retour associé à INT : Il consiste en un déPUSHage de (E)IP, CS, puis (E)Flags.

En reprenant tout ceci, completons la routine :

                       ADD EAX,EBX
                       ADD EAX,ECX
                       ADD EAX,EDX
                       RETN

Pourquoi RETN et pas RETF ? Car en général on n'utilise qu'un seul segment pour le code et donc CS ne change jamais de valeur - sauf en cas d'interruption, bien sur.

Pour receuillir dans EAX la somme EAX+EBX+ECX+EDX, il suffira donc de faire un CALL Near à l'adresse de la routine.

Le concept de la routine, s'il n'est jamais indispensable à un programme, permet toutefois d'économiser la mémoire et d'alleger le code.

La mode actuelle lorsqu'on veut écrire un programme est de s'assoir en face de sa machine, de lancer son éditeur, de se gratter la tête, puis de commencer à tracer les plans du programme.

Je vous arrête tout de suite : développer un programme sur ordinateur est une absurditée absolue ! Il vaut mieux un bon bloc de papier que le dernier éditeur high-tech avec fiches techniques incorporées.

En effet, on écrit généralement plus rapidement qu'on ne tape au clavier, et il est plus prudent de commencer par prendre des notes abrégées de ce que l'on veut faire plutôt que d'affronter d'emblée la rigueur d'un éditeur, surtout en asm où un concept même simple demande souvent plus d'une instruction. Sur papier, lors de l'ébauche, on ne se soucie pas d'être compris par la machine, et on peut économiser sa concentration.

De plus, cette approche satisfera bobonne qui sera heureuse de constater que, par moment, il vous arrive d'éteindre votre PC.

La première chose à faire est de déterminer précisément la conduite du programme, puis d'en tirer la structure générale, pour finalement approfondir et construire les algorithmes. Tout ceci donnera l'organigramme du programme.

Ensuite commence le codage du programme. Le but est alors de réaliser le code le plus optimal possible pour traduire les concepts de l'organigramme. C'est dans cet aspect de la programmation que le codeur s'exprime : il faut optimiser en taille, en vitesse, partout, chaque instruction, utiliser au maximum la machine et ses composants. Le code optimal existe toujours, mais on ne le trouve jamais, comme de juste.

Nous aurons tout le loisir de découvrir chacun de ces aspects le long de nos pérégrinations au royaume de l'assembleur.

 

UN PROGRAMME EN ASSEMBLEUR

L'environnement du PC

Le BIOS

Le BIOS (inBoard Input/Output System) désigne le code inclu dans la ROM (Read Only Memory, i.e. mémoire morte dans laquelle on ne peut pas écrire et qui se conserve si le PC n'est plus alimenté) de chaque PC. Somme toute, le BIOS n'est rien d'autre qu'une grosse puce. La zone d'adressage de la ROM-BIOS s'étend de l'adresse physique hF0000 à hFFFFF (Cf. "Ports I/O" pour une explication sur ces zones d'adressage détournées).

Le code du BIOS dépend du constructeur mais les fonctions qu'il doit remplir sont standardisées. C'est tout d'abord le premier code à être exécuté au reset du processeur. Sa première tâche est donc de charger le système d'exploitation (Cf. Le DOS). Ses autres fonctions assurent la gestion des extensions, ce qui, considérant le nombre et la variété des extensions possibles, facilite grandement la tâche du système d'exploitation.

Malheureusement, le code des BIOS ne progresse pas aussi rapidement que les processeurs, et le BIOS est actuellement totalement obsolète (en cela qu'il ne fonctionne correctement qu'en mode réel).

Les fonctions du BIOS sont accessibles par des interruptions (ce qui laisse le choix des adresses des différentes routines au constructeur).

Ces fonctions sont regroupées en 7 catégories :

Notez que les numéros de ces interruptions sont très mals choisis car ces numéros sont réservés par Intel - Intel à réservé les interruptions 0 à h20 -

Je ne donnerai aucune description de ces fonctions ; si vous ne possedez pas encore votre liste d'interruption, dépéchez-vous d'en trouver une !

 

Le DOS

Je n'apprendrai rien à personne en écrivant que le DOS (Disk Operating System) est un système d'exploitation. Comme il est toujours malheuresement le plus répendu, nous ne travaillerons que sous son environnement.

Le DOS assure deux fonctionnalités : la gestion du système de fichier, et la gestion de l'exécution des programmes (chargement, gestion mémoire, etc...). Si son système de fichier est suffisent (il s'agit d'une copie améliorée du vieux CP/M), sa gestion des programmes se limite au chargement de fichiers exécutables et à la gestion mémoire (i.e. allocation d'un bloc de mémoire à un programme, ou libération d'un bloc).

Le DOS dispose aussi de quelques petites fonctions assez inutiles concernant la video, le clavier, etc...

Pour le système de fichier, reférez-vous à votre documentation du DOS.

Mes derniers mots sur le DOS iront à la gestion mémoire : Le DOS ne gère que les 640 premiers Ko de la mémoire du PC. Ainsi, le reste de la mémoire est-il livré aux lois barbares des gestionnaires type XMS ou EMS.

Le DOS associe à chaque programme une structure de données appelée PSP (Program Segment Prefix). Ce bloc de 256 octets contient divers paramètres donc la ligne de commande (généralement la chaîne de caractères tapée après le nom du programme dans votre interpréteur de commande). Au démarrage du programme, ES et DS pointent sur le début du PSP (i.e. ES:0 = DS:0 = 1er octet du PSP).

Nous reverrons le PSP lors d'un chapitre consacré au DOS.

 

Assemblage d'un Programme

Nous allons ici, brievement, aborder un point indispensable: la syntaxe de l'asm. En effet, cette syntaxe n'a pas étée fixée par Intel (qui n'à fixé que le nom des instructions, comme il se doit) mais varie d'un assembleur à un autre. Nous allons donc étudier les principes essentiels de cette syntaxe, avant de fixer nos propres conventions syntaxiques.

Un programme est toujours divisé en segments, et tout ce qui est assemblé doit être inclu dans un segment. On spécifie donc toujours à l'assembleur le début, le nom, le type (taille du code), et l'alignement de chaque segment (la fin est optionnelle : un segment s'arrête là où le suivant commence). Certains assembleurs offrent d'autres alternatives mais nous ne retiendrons que celle-ci. Pour déclarer un nouveau segment, on utilise généralement la directive SEGMENT (j'appelerai directive toute commande écrite dans le source du programme et qui n'est pas destinée à être assemblée mais qui fournit des informations à l'assembleur) :

     {nom}    SEGMENT {type : USE16/USE32} {alignement : 16,32, etc...}

USE16 ou USE32 déterminent la manière d'encoder les instructions qui figurent dans ce segment. En général, nous choisirons USE16 car sous DOS le processeur se trouve par défaut en mode 16 bits.

L'alignement permet d'aligner le premier octet du segment sur une adresse divisible par 16, 32, etc. Cet alignement ne sert strictement à rien au programme lui même mais permet à l'assembleur de connaître l'alignement de chaque octet du segment, ce qui est très utile pour aligner ensuite certains de ces octets sur des adresses que l'on sait divisibles par 2, 4, 16 ... (cela accélère leur traitement).

Notez à ce sujet qu'il vaut mieux éviter d'aligner un segment à moins de 16, car CS contiendra l'adresse physique divisée par 16. Si L'adresse physique n'était pas multiple de 16, IP devrait être initialisé à autre chose que 0 pour pointer le début du segment, ce qui poserait de sérieux problèmes d'organisation en plus de problèmes évidents d'"éthique"...

Il existe aussi un type de segment particulier, le segment de pile. Ce segment possède ceci de particulier qu'au chargement du programme, le DOS initialisera SS sur ce segment et enverra SP à la fin du segment (base de la pile). Nous noterons ce segment "STACKSEG" au lieu de "SEGMENT".

Autre point capital : les adresses.

Lorsque nous écrirons des programmes, nous n'allons pas calculer à chaque fois l'adresse d'une position de notre code. D'autant que dans le cas d'un JMP on pourait être ammené à calculer l'adresse relative par rapport à la position du JMP, ce qui ne serait franchement pas commode (immaginez un peu !).

C'est pourquoi tous les assembleurs actuels sont 'symboliques', c'est à dire qu'ils accèptent de travailler avec des références, des symboles, plutôt qu'avec des adresses véritables. Par exemple, une adresse pourra être appelée 'TOTO' et utilisée dans un MOV ou JMP grace à cette désignation. C'est l'assembleur qui déterminera la valeur de ce symbole au moment de l'assemblage.

Ce concept s'étend d'ailleurs à tout symbole, qu'il représente une adresse ou une constante ou même un texte.

Lorsque nous définirons une constante, nous procèderons de la sorte :

         Nom_du_symbole = Valeur

Par exemple :

         Nombre_de_dents = 32
         ...
         MOV EAX,Nombre_de_dents

Notez que l'appelation "constante" ne signifie pas forcément que le symbole aura une valeur constante tout le long du source (il est permis de définir les constantes plusieurs fois).

Pour finir, notez qu'il existe des constantes définies automatiquement par l'assembleur. Nous n'en utiliserons qu'une, celle notée '$' et appelée "compteur de position".

'$' représente le compteur d'adresse utilisé par l'assembleur. Il est donc toujours égual à l'adresse à laquelle on le place. Par exemple :

              ici = $
              ...
              là = $

'ici' et 'là' seront égaux aux adresses auquelles on a positionné le symbole. S'il n'y à rien entre les deux, on aura bien naturellement ici=là ('ici' et 'là' sont des déclarations de symbole et ne sont donc pas assemblés ; ces deux constantes ne modifient donc pas la valeur du compteur de position '$').

Vous voyez que '$' est un bel exemple de constante qui ne garde pas la même valeur tout le long d'un source...

Pour un symbole de substitution de texte :

              Nom_du_symbole = <texte>

Par exemple :

              Mettre_dans_AX = <MOV AX>
              ...
              Mettre_dans_AX ,5

La seule utilité à la substitution de texte est à mon avis la définition d'instructions que ne connait pas l'assembleur (instructions "non-documentées").

Pour un symbole d'adresse (= un "label"), nous marquerons à la position représentée le nom du symbole suivit de ':' .

               Nom_du_symbole:

Par exemple :

               Routine1:
               ...
               CALL Routine1

Notez que l'on ne met pas les deux points ':' lorsque l'on utilise le symbole.

Cette virtualisation de l'adressage est pratique car c'est l'assembleur qui déterminera, en fonction de la position du symbole, le type de saut (relatif, near ou far). De plus, si l'on déplace le symbole (en rajoutant quelquechose devant par exemple), il ne sera pas nécessaire de modifier toutes les instructions qui font référence à l'adresse représentée.

Directives très importantes : Celles qui permettent d'éditer des zones mémoires au lieu d'assembler des instructions. Ces directives sont nombreuses, et nous ne verrons dans ce chapitre que le strict minimum : DB, DW, DD et DUP().

DB (Define Byte) permet de réserver des octets qui contiendront en général des données initialisées. Par exemple, quand l'assembleur rencontrera 'DB 5,3,1' il sortira dans l'exécutable la suite d'octet 5, 3 et 1.

Comprenez bien que l'interêt d'un DB est de générer des octets qui ne seront pas exécutés.

DW et DD (Define Word et Define Double-word) procurent les mêmes effets mais définissent des mots ou doubles_mots au lieu des octets.

DUP ne définit rien à lui tout seul mais permet d'initialiser de larges tables de données en répétant un nombre de fois spécifié une suite d'octets (DUP pour DUPlicate). Le format est :

         Db/w/d  Nombre_de_répétitions  DUP(suite de b/w/d)

Par exemple :

         DB 8 DUP(0)
           <=>  DB 0,0,0,0,0,0,0,0
           <=>  DW 0,0,0,0
           <=>  DD 0,0

         DW 4 DUP(24,h65C)
           <=>  DW 24,h65C,24,h65C,24,h65C,24,h65C
           <=>  DB 24,00,h5C,h06,24,00,h5C,h06,24,00,h5C,h06,24,00,h5C,h06

Ceci nous amène à un nouveau type de symbole : la variable.

Une variable est le nom donné à un champ d'octets. Notez la différence avec le label : le label marque une position et la variable désigne autant la position que la taille de ce qui est référé.

On définit une variable comme un label sauf qu'on ne rajoute pas ':' après le nom de symbole. La taille de la variable est la taille du 'Define' qui la suit.

Les variables ne sont pas indispensables mais permettent d'accélérer la frappe des programmes. Par exemple, imaginons que vous vouliez recopier AX à la position ES:h1245. Vous écririez :

             MOV ES:[h1245],AX

Maintenant, comme vous ne souhaiteriez pas devoir modifer cette instruction si l'endroit auquel recopier AX est modifié, vous placez un label 'toto' à cette position dans le source. Pour faire la même chose, vous devriez écrire :

             MOV ES:[Offset toto],AX

Le mot 'Offset' rajouté devant TOTO permet à l'assembleur de savoir que nous attendons de lui qu'il recopie la valeur 'offset' de l'adresse représentée par le label 'toto' (on aurait tapé 'SEG' pour avoir la valeur 'segment' de cette adresse).

Si nous déclarions plutôt une variable 'var1' de type mot à cette position (var1 DW 0), nous n'aurions plus qu'a écrire :

             MOV ES:var1,AX

... ce qui est déjà plus court.

Pour éviter d'avoir à taper 'ES:', on utilise une autre directive de l'assembleur : 'ASSUME'

'ASSUME' permet à l'assembleur de connaître les registres de segment à utiliser devant les références aux variables. Pour cela, on indique :

         ASSUME RSeg1:Nom_du_seg1, RSeg2:Nom_du_seg2, etc...

Après ceci, si vous ne spécifiez pas de registre de segment, l'assembleur utilisera celui qui est assumé pour le segment qui contient la variable (MOV var1,AX).

Nous allons maintenant voir un exemple concret de programme qui va nous éclairer sur la mise en pratique de tout ceci.

 

Premier Programme

Notre premier programme n'aura qu'un interêt syntaxique : il s'agit d'afficher un petit message à l'écran. Cet exemple miniature nous permettra de nous pencher sur la manière d'assembler un programme.

     [ Source : Welcome.ASC ]------------------------------------------
                                                                       
     000    ;       Welcome.ASC  -  Programme 1                        
     001    ;       ===========                                        
     002    ;       Affichage d'un message                             
     003                                                               
     004    CODE    SEGMENT USE16 ALIGN=16                             
     005    $=h100                                                     
     006                                                               
     007            MOV DX,Offset Message        ; Afficher le message.
     008            MOV Ah,9                                           
     009            INT h21                                            
     010                                                               
     011            MOV AX,h4C00             ; Mettre fin au programme.
     012            INT h21                                            
     013                                                               
     014    Message DB "Hello World !",36                              
                                                                       
     ------------------------------------------------------------------

0..2 : Tout ce qui suit un ";" dans une ligne est considéré par l'assembleur comme étant un commentaire. Nous réservons donc ces trois lignes dans le source pour le titre.

4 : Ouverture d'un segment nomé 'CODE', aligné sur 16 octets et déclaré en mode 16 bits.

5 : On donne la valeur h100 (256) au pointeur d'instruction. Ceci est indispensable car nous allons réaliser un programme 'COM'. L'extention ASC signifie d'ailleurs ASm-Com. On utilisera ASE (ASm-Exe) pour le format de fichier EXE.

Nous reviendrons la fois prochaine sur ces deux formes de fichiers exécutables.

7..9 : On utilise la fonction 9 de l'interruption DOS h21 qui affiche le texte situé en DS:DX. Notez qu'on ne modifie pas DS car il est égal à CS au démarrage d'un programme au format COM.

11 et 12 : On utilise la fonction h4C de l'interruption DOS h21 qui met fin à un programme. On renvoit le code de sortie Al=0.

14 : On associe le symbole 'Message' à la chaîne d'octets représentée par notre texte.

Il faut savoir que l'assembleur considère un caractère entre guillemets ou apostrophes comme la valeur ASCII de ce caractère en octet (Cf. une table de référence ASCII), et un texte entre guillemets ou apostrophes comme la suite d'octets formée par les codes ASCII de la suite de lettre, ce qui est bien pratique.

Le ',36' rajouté à la fin sert à délimiter la fin de la chaîne pour l'afficher avec la fonction 9 de l'interruption h21.

Notez que 36 étant le code ASCII de '$', on aurait pu écrire :

     014    Message DB "Hello World !$"

Pour ceux qui possèdent TASM ou MASM, voici la version de ce programme traduit dans la syntaxe MASM :

     [ Version MASM ]--------------------------------------------------
                                                                       
     ;       Welcome.ASC  -  Programme 1                               
     ;       ===========                                               
     ;       Affichage d'un message                                    
                                                                       
     CODE    SEGMENT USE16                                             
             ORG 100h                                                  
     start:                                                            
             MOV DX,Offset Message        ; Afficher le message.       
             MOV Ah,9                                                  
             INT 21h                                                   
                                                                       
             MOV AX,4C00h             ; Mettre fin au programme.       
             INT 21h                                                   
                                                                       
     Message DB "Hello World !",36                                     
                                                                       
     CODE    ENDS                                                      
                                                                       
     END     start                                                     
                                                                       
     ------------------------------------------------------------------

Vous pouvez constater que la syntaxe instaurée par MASM (TASM est compatible) est plus complexe et moins logique que la nôtre, mais qu'il est tout de même simple de passer de l'une à l'autre.

C'est parceque cette syntaxe est illogique et pénible que j'utiliserai toujours la petite syntaxe que j'ai décrit en début de ce chapitre.

 

Rixed

     ==================================================================

            Pfiouuu, c'était bien l'article le plus long que j'ai
     jamais écrit pour ce mag.

            Au prochain numéro, nous travaillerons sur un programme qui
     affiche des nombres premiers (un nombre premier est un nombre
     uniquement divisible par lui même et par 1).  Vous pouvez donc
     chercher dès à présent comment vous procèderiez.


            Pour ce qui est de ces articles : Certains d'entre vous ont
     imaginé que cette initiation était faite "pour apprendre le 386".
     Mon but, c'est plutôt "apprendre l'assembleur tout court".
     J'espère qu'à la lecture de ce qui précède les lecteurs dans
     l'erreur auront rectifié d'eux mêmes.  J'essaye de trouver un
     compromis entre DOS et 386, mais ce n'est pas simple.

     ============================[enFIN]===============================