Code objet, langage machine et langage d'assemblage

Un module objet est composé de code machine auquel il manque un "dernier effort" pour être exécuté (l'édition des liens, qui sera le sujet d'un cours très bientôt).

L'exécutable est composé de code machine, et est prêt à être utilisé.

Information: code machine versus langage d'assemblage

En toute honnêteté, on programme de moins en moins directement en langage d'assemblage, sauf dans quelques cas bien précis où la tâche est relativement simple et où on est sous de fortes contraintes de performance.

Toutefois, il serait bien mal venu pour un(e) informaticien(ne) de ne pas connaître (et comprendre) les concepts derrière ces langages.

Langage machine

Langage d'assemblage

Le langage machine (ou code machine) est le seul langage que le processeur d'une machine donnée comprenne réellement. On parle de code machine lorsqu'on veut discuter des instructions propres au processeur, et représentées de façon numérique.

Chaque instruction que le processeur puisse comprendre se représente par un nombre. Toutes les instructions du langage machine prennent un nombre fixe de paramètres, entre 0 et 2 (ou 3, selon les processeurs) inclusivement.

Le code machine étant une série de nombres (instruction, [[paramètre0], paramètre1], etc.), sa lecture et sa programmation par des humains sont très fastidieuses. C'est pourquoi un utilise un équivalent "lisible" nommé langage d'assemblage.

On transforme un programme écrit en langage d'assemblage en code objet avec un outil nommé assembleur. À chaque mnémonique (instruction au nom simple) du langage d'assemblage pour un processeur correspond une instruction machine; de même, les paramètres des instructions en langage d'assemblage rejoignent ceux de leurs équivalents en langage machine.

Exécuter un programme: ce qui se passe

Lorsqu'on exécute un programme, il faut évidemment que celui-ci ait été traduit en code machine exécutable pour la plate-forme sur laquelle il sera exécuté. Mais une fois cette traduction faite, qu'est-ce qui fait qu'en bout de ligne, un programme s'exécutera?

Note:

certains des termes introduits ici seront explicités plus loin; patience!

Chargement du programme en mémoire

Pour qu'un programme s'exécute, il faut d'abord qu'il soit chargé en mémoire. C'est une maxime fort importante, mais qui devient une règle lorsqu'on regarde de plus près la mécanique de son exécution.

  • On nomme processus un programme s'exécutant en mémoire, muni de ses données[1].
  • Évidemment, charger un programme en mémoire est une chose, mais choisir où il sera placé dans ce large espace qu'on nomme mémoire vive est une tâche en soi. En effet, un programme a une certaine taille, et la mémoire vive peut être vue comme un espace de rangement où une bonne partie de l'espace disponible est déjà occupé.

  • Un programme spécial (parfois un petit ensemble de programmes spéciaux) nommé chargeur ("loader") sert à prendre un programme et ses données et à les installer en mémoire de telle sorte que le programme ait la place requise pour s'exécuter, et soit prêt pour l'exécution.
  • Qu'est-ce qui définit l'espace requis par un programme prêt à s'exécuter? On pourrait offrir une réponse simplifiée mais adéquate en mentionnant les composantes suivantes[2]:

  • le code du programme, à proprement dit, occupe un espace connu à l'avance. On sait, à la compilation (ou, plus précisément, une fois l'édition des liens réalisée), combien d'espace sera requis pour le code d'un programme, puisqu'on le génère explicitement à cette étape;
  • les données du programme, telles que définies par l'ensemble des variables déclarées dans le code et leurs tailles respectives (il est d'ailleurs possible d'analyser cet aspect du code en utilisant judicieusement l'opérateur "sizeof()");
  • la pile d'exécution du programme, outil essentiel à son fonctionnement, particulièrement dans le cas de changements de contexte (comme lors d'appels de fonctions, par exemple). Nous couvrirons cette composante en détail plus loin dans ce chapitre, et le cours 420201 couvrira sous peu--si ce n'est déjà fait--le concept de pile en tant que tel.
  • On peut donc subdiviser l'espace occupé par un programme en mémoire de la façon suivante: un segment de données ("data segment"), un segment de pile ("stack segment") et un segment de code ("code segment"). Et chacun de ces segments est, pour le programme lui-même, à un endroit précis: une adresse qui lui est propre.

    Une fois le programme chargé en mémoire, un registre spécial nommé le pointeur d'instruction (IP, plus bas) reçoit l'adresse de la première instruction à effectuer, puis le processeur entre en scène.

    Le travail accompli par le processeur devient, en gros, ceci:

    
    Effectuer l'instruction indiquée par IP
    IP <-- adresse de la prochaine instruction

    Pour poursuivre notre analyse, il faudra donc s'interroger sur ce qu'est une instruction pour le processeur (donc une instruction en langage machine), ce qu'est en fait IP (un registre, pour être honnête, alors nous allons regarder ce que sont les registres), mais nous allons d'abord poser un regard sur ce qu'est une adresse en mémoire.

    La mémoire et les adresses

    De quoi a l'air la mémoire vive? Matériellement, c'est une petite composante électronique qui ressemble à une barre de chocolat CaramilkMD, mais cela est de bien peu d'utilité lorsqu'on discute de la mécanique d'exécution d'un programme.

  • Pour un programme, la mémoire vive apparaît comme un vaste espace contigu de zones de même taille, qu'on nomme mots mémoire.
  • Le mot mémoire est l'unité de base pour le transfert et la manipulation de données au niveau de la mémoire, du processeur. Sur les ordinateurs mis à votre disposition, ces mots sont d'une taille de 32 bits (donc 4 octets).
  • La mémoire vive en tant que telle est (mis à part quelques petites zones bien précises) un espace "tout usage". C'est le compilateur qui décide, à partir d'un programme donné, de définir des segments dédiés à certaines tâches, et c'est le chargeur qui positionne ces segments à des endroits précis en mémoire.

    Le concept d'adresse

    Chaque objet, chaque variable, chaque instruction en mémoire étant quelque part, on peut donc dire de toute chose en mémoire qu'elle a une adresse.

  • L'adresse d'un objet identifie de façon unique sa localité, l'endroit où il se trouve.
  • Le concept d'adresse est fort important. On trouve des outils pour manipuler des adresses dans la plupart des langages de programmation, y compris C++. Cela fera d'ailleurs l'objet d'une partie du cours 420 231 que vous allez sans doute adorer.

    En pratique, il est utile de noter immédiatement que la taille d'une adresse correspond à celle du mot mémoire[3] (32 bits pour vos stations de travail), et qu'au niveau du code machine, les adresses apparaissent comme de simples entiers non signés sur 32 bits.

    Mots mémoire, octets et autres...

    Dans le schéma plus haut, remarquez que les adresses offertes sont toutes des multiples de 4 (pour 4 octets <==> 32 bits).

    Pourtant, il est parfois pratique de programmer en utilisant des objets de plus petite taille (des entiers sur 16 bits, par exemple, ou même sur huit bits), ou de plus grande taille (des "struct", des classes, des nombres flottants à double précision...). Comment ces deux réalités peuvent-elles être conciliées?

    Voici: le processeur a des registres prêts à opérer sur des mots mémoire. Par contre, le langage machine offrant des instructions bit à bit, il lui est possible de contourner les restrictions propres à la taille du mot mémoire par les manipulations appropriées[4].

    Aussi, certains langages machine (celui des processeurs Intel inclus) offrent des instructions capables de manipuler des objets de taille différente que celle du mot mémoire, de façon à accélérer le traitement de celles-ci sans devoir passer par des manipulations astucieuses.

    En tant que tel, chaque octet en mémoire peut être adressé individuellement; par contre, les opérations les plus rapides en mémoire ont tendance à être celles effectuées sur des objets dont la position en mémoire est un multiple[5] de la taille du mot mémoire.

    Quelques instructions de l'assembleur Intel

    Note:

    les instructions suivantes sont offertes dans le but de vous donner un aperçu du type d'instructions à la disposition du processeur, et donc générées par le compilateur avec votre code source. Remarquez que le ";" joue en assembleur Intel le rôle du "//" en C++.

    C'est avec un langage (vous le verrez) fort restreint que le compilateur passe de vos concepts évolués en C++ à un code objet qui, une fois résolu, deviendra exécutable.

    Ces instructions, offertes en exemple, forment un sous-ensemble de l'ensemble constituant le code machine d'un processeur Intel. Présenter ici l'ensemble des instructions de l'assembleur Intel serait lourd et inutile. Le langage d'assemblage varie selon les processeurs, mais les principes sont généralement les mêmes de l'un à l'autre.

    Instruction

    Opération effectuée

    
    MOV dest,src

    "Move": dépose le contenu de "src" dans "dest". Le résultat se trouve dans "dest".

    
    CMP dest,src

    "Compare" les valeurs de "dest" et de "src". Si le résultat est zéro (0), les deux objets ont la même valeur.

    
    JZ label

    Saute à l'étiquette[6] "label" si le résultat de la dernière comparaison effectuée était zéro (0). Il y a une pléthore de sauts (Jumps) du même acabit, et la mnémonique de chacun commence par un "J".

    L'instruction "JMP label" est un saut inconditionnel vers l'étiquette "label", alors que tous les autres sauts sont en fait des branchements conditionnels en fonction du résultat de l'opération de calcul la plus récente.

    
    XOR dest,src

    Fait un "ou exclusif" entre "dest" et "src". Le résultat se trouve dans "dest". On a aussi "AND", "OR" et "NOT" (ce dernier prenant un seul paramètre).

    
    SHL dest,n

    Glisse vers la gauche ("Shift Left") les bits de "dest" de "n" positions. Le résultat se trouve dans "dest". Il existe aussi "SHR" ("Shift Right") pour un glissement vers la droite.

    
    ADD dest,src

    Additionne "dest" et "src", et dépose le résultat dans "dest". On trouve aussi "SUB" (pour une soustraction), "MUL", "IMUL", "DIV" et "IDIV" (pour les multiplications et les divisions "normales" ou entières), "INC" et "DEC" (incrémenter et décrémenter), etc.

    
    LODS
    STOS

    Ces opérations (sans opérandes) servent à charger un contenu mémoire précis dans un registre[7], et inversement. On verra aussi à l'occasion des instructions "MOV" utilisant des particularités d'adressage de l'assembleur à la place de ces deux instructions.

    Note:

    les exemples de code ci-après sont abusifs du côté des constantes. En effet, en assembleur 32 bits, la valeur "0Ah" (par exemple) signifie "10" sur le mode décimal, mais encodé sur 32 bits. Nous tricherons par souci de simplicité.

    Les registres

    Il existe de petits espaces très importants dans le processeur qui servent à entreposer des valeurs utilisées pour fins de traitement efficace (des espèces de "variables matérielles" si on veut). On nomme ces espaces registres, et c'est sur des registres que travaille le mieux le processeur.

    En pratique, pour généraliser, on peut voir le traitement de code assembleur sous la forme de la séquence suivante:

  • charger des valeurs dans des registres
  • opérer sur ces registres
  • agir sur le résultat de l'opération, si requis
  • déposer le résultat au bon endroit en mémoire, si requis
  • C'est un peu simplet, mais l'idée est là. Les plus "actifs" d'entre eux sont les registres dits "tout usage", mais nous faisons ici un petit écart de conduite pour vous donner au moins un début de description pour ce qui est des autres...

    Les registres "tout usage" de votre processeur

    Il y a un nombre bien précis de registres dans votre processeur. Certains sont disponibles pour vos propres calculs, et d'autres ont un rôle bien précis à jouer.

    Le code objet généré par les compilateurs cherche à tirer profit d'une sage utilisation de ces outils précieux, mais attention: ce n'est pas une mince tâche!

    L'accumulateur

    L'accumulateur se nomme AX (16 bits), EAX (32 bits), AH ou AL (8 bits chacun, correspondant respectivement aux parties haute et basse de AX). Il sert à la plupart des opérations arithmétiques courantes, particulièrement celles générées par un compilateur lorsqu'il traduit votre code.

    
    ; addition de 10 à la variable X, passant par AX
    MOV AX,[X]
    ADD AX,0Ah ; 0Ah en assembleur <==> 0x0a en C++
    MOV [X],AX
    L'adressage indexé[8]

    Le registre surtout utilisé pour fins d'adressage indexé se nomme BX (16 bits), EBX (32 bits), BH ou BL (8 bits chacun, correspondant respectivement aux parties haute et basse de BX).

    Le compteur

    Le registre surtout utilisé pour fins de compteur dans des boucles se nomme CX (16 bits), ECX (32 bits), CH ou CL (8 bits chacun, correspondant respectivement aux parties haute et basse de CX).

    Exemple:

    
    ; initialisation de AX à 0 et de CX à 30...
            MOV AX,0
            MOV CX,30 ; CX servira de compteur de boucle...
    BOUCLE: ADD AX,10 ; BOUCLE est une étiquette
    ; décrémente CX,et revient à BOUCLE si CX diffère de zéro
            LOOP BOUCLE
    ; suite du programme... AX vaut maintenant 300
    L'aide à la multiplication

    Le registre surtout utilisé pour fins de support aux multiplications et aux divisions pour un diviseur ou un multiplicateur de plus de huit bits, si les entiers en jeu sont non signés, se nomme DX (16 bits), EDX (32 bits), DH ou DL (8 bits chacun, correspondant respectivement aux parties haute et basse de DX).

    Hormis cet usage un peu spécifique, DX peut servir à fins générales (et y est fort utile).

    Les segments

    Tel que mentionné plus haut, il existe un certain nombre de segments qui, pris ensembles, constituent le programme lorsque chargé en mémoire. À chacun de ces segments correspond un registre qui, pour le processus en exécution, donne l'adresse à laquelle débute l'espace qui lui a été accordé.

  • Les registres CS et SS indiquent respectivement l'adresse en mémoire du segment de code et du segment de pile.
  • DS indique l'adresse en mémoire du segment de données du programme.
  • ES, FS et GS sont des segments en "extra" qui peut servir aux fins "privées" des programmeurs.
  • Les indicateurs

    Un registre (qui apparaît comme plusieurs registres au niveau du langage d'assemblage, mais est en réalité un espace subdivisé en plusieurs bits) a pour utilité de signaler les événements pertinents propres aux calculs récents. Il est essentiel au bon fonctionnement de l'arithmétique accomplie par le processeur. Parmi les indicateurs disponibles, on note:

  • CF ("carry flag"), qui sert de bit additionnel pour noter la retenue lors d'une addition sur huit ou 16 bits pour laquelle on rencontre un débordement;
  • PF ("parity flag"), qui indique la parité des bits de la dernière opération sur huit ou 16 bits (est mis à 1 si le nombre de bits à 1 dans le résultat est pair, et à 0 sinon);
  • ZF ("zero flag") est mis à 1 lorsque la dernière opération effectuée sur huit ou 16 bits résulte en la valeur zéro, et est mis à 0 sinon. Ceci sert dans la plupart des évaluations de condition en assembleur;
  • SF ("sign flag") est mis à 1 lorsque la dernière opération effectuée sur huit ou 16 bits résulte en une valeur négative, et est mis à 0 sinon;
  • OF ("overflow flag") est mis à 1 lorsque la dernière opération effectuée sur huit ou 16 bits résulte en un débordement, et est mis à 0 sinon.
  • Les registres d'index de votre processeur

    Un certain nombre de registres dits "d'index" sont aussi disponibles (et incontournables). Leur utilité deviendra plus claire bientôt. En attendant, en voici une liste descriptive un peu sommaire (mais c'est de bon coeur).

    Registre(s) propres au segment de pile

    Le registre SP (ou ESP sur 32 bits) sert à noter, avec SS (le segment de pile) la position en mémoire de la pile d'exécution du programme. Cet instrument fera l'objet (bientôt) d'un examen approfondi.

    Registre de base

    Le registre BP (ou EBP sur 32 bits) indique une base pour fins d'adressage indexé. On utilise habituellement ce registre avec un déplacement ("offset") pour exprimer des adresses en mémoire, surtout par rapport au début du segment de données (DS).

    Par exemple, pour adresser les variables locales d'une fonction, on peut placer BP là où commence la première d'entre elles, et calculer le déplacement en fonction de la taille des données entre cette base et l'adresse de la variable à adresser.

    Registres de manipulation de chaînes de caractères

    Les registres DI et SI (EDI et ESI sur 32 bits) servent principalement à manipuler des chaînes de caractères, et aident à faire des opérations sur des suites contiguës en mémoire de données de même nature (des tableaux... que vous reverrez à la fois en 420 201 et en 420 231 au cours des prochaines semaines).

    Le pointeur d'instruction

    Enfin, tel que promis, il y a le pointeur d'instruction (IP), qui indique à tout moment l'adresse en mémoire de la prochaine instruction à effectuer. Sa valeur est mise à jour à chaque fois que le processeur passe à une nouvelle instruction.

    L'adressage en assembleur Intel

    L'adressage au niveau du langage d'assemblage est une considération importante. Nous avons choisi de vous offrir quelques exemples simples vous offrant un aperçu de ce qu'impliquent ces considérations, pour vous aider à saisir leur rôle.

    Soyez prudent(e)s: la présente est incomplète, et elle se veut une introduction au concept, pas un document explicatif détaillé sur l'adressage en langage d'assemblage. Elle a pour but de vous aider à lire du code assembleur, y compris celui généré par VC pour vos programmes.

    Adressage de registres

    On peut déposer le contenu d'un registre dans un autre sans problème, dans la mesure ou les deux registres ont la même taille: on peut par exemple faire

    
    MOV EBX,ECX ; EBX <--ECX, deux registres 32 bits

    mais on ne pourrait pas faire

    
    MOV BX,CL ; CL est un registre 8 bits, et BX un registre 16 bits
    Adressage direct

    Supposons qu'on veuille déposer le contenu de l'octet se trouvant à l'étiquette XYZ dans le registre AL (huit bits).

    Cela s'avère possible par ce qu'on appelle l'adressage direct, et la syntaxe sera:

    
    .DATA ; segment de données
    XYZ  "ALLO TOI!" ; les caractères A L L O ... sont à l'adresse XYZ
    .CODE ; segment de code
    MOV AL,[XYZ] ; le caractère 'A' est déposé dans AL
    ADD AL,3     ; AL contient le code ASCII du caractère 'D'
    MOV [XYZ],AL ; XYZ devient "DLLO TOI"
    Adressage immédiat

    Supposons qu'on veuille déposer une valeur constante dans un registre.

    Cela s'avère possible par ce qu'on appelle l'adressage immédiat, et la syntaxe sera:

    
    MOV BX,12    ; BX <-- 12 (8 bits). Ok: BX est 16 bits
    MOV BX,6000  ; BX <-- 6000 (16 bits). Ok: BX est 16 bits
    MOV BL,6000  ; BL <-- 6000 (16 bits). Incorrect: BL est 8 bits

    [1] Nous y reviendrons sous peu, mais gardons en tête qu'un programme est composé de code--d'instructions--et de données, mais que ce sont là deux choses distinctes.

    [2] Par abus de langage, on pourrait aussi ajouter ce qu'on nomme le tas (en anglais: "heap"). Pour l'instant, nous mentionnerons simplement son existence.

    [3] ...ce qui est presque toujours équivalent à la taille du type "int" en C et en C++. Il est préférable de ne pas compter là-dessus, mais puisqu'on prend parfois cette adéquation pour acquis en entreprise, il est préférable que vous en soyez a priori informés.

    [4] À ce stade-ci, vous devriez être à l'aise avec des considérations comme extraire un octet d'un entier codé sur 32 bits, ou connaître la valeur de la partie haute d'un entier d'une certaine taille.

    [5] on dira alors que l'objet est aligné sur un multiple de la taille du mot mémoire (ex: aligné sur 4 octets).

    [6] Une étiquette (ou label) est une ligne de code assembleur portant un nom. On se sert d'étiquettes pour permettre les sauts et les branchements dans le code, par exemple dans le but de générer des boucles...

    [7] Les registres seront présentés sous peu. Patience!

    [8] ... que nous ne couvrirons pas aujourd'hui, mais n'ayez crainte: ça s'en vient!