Aller au contenu

Cours Assembleur

La mémoire dans le modèle de Von Neumann⚓︎

On l’a vu, dans l’architecture de Von Neumann, la mémoire vive (RAM) contient à la fois les données et les programmes (suite d’instructions).

Dans la mémoire d’un ordinateur on peut donc stocker des données (entiers, flottants, booléens, caractères,...) sous forme binaire. Par exemple 00100110 correspond au nombre entier 38. Mais on peut également y stocker des instructions à destination de l’unité de traitement (UAL).

Ce même code binaire 00100110 pourrait très bien correspondre au code d’une instruction machine, par exemple « stopper le programme ».

C’est le rôle du système d’exploitation (windows,linux,...) et du programmeur de faire en sorte de distinguer en mémoire ce qui correspond à des instructions ou des donnéees.

Les instructions machine⚓︎

Principe⚓︎

Une instruction machine se présente de la façon suivante :

📋 Texte
  Champ de code de l'opération | Champ de l'opérande

Remarque : chaque processeur possède ses propres codes d’opération (opcode). C’est son jeu d’instructions.

Par exemple :

📋 Texte
  Additionner | Valeur contenue dans le registre R1, Nombre 37

Chaque instruction peut occuper un ou plusieurs mots dans la mémoire d’un ordinateur.

Remarque : un mot correspond à l’unité de base pouvant être traitée par le processeur. Avec un proceseur 8 bits la taille du mot correspond à 8 bits soit 1 octet. Avec un processeur 64 bits la taille du mots correspond à 64 bits soit 8 octets.

Au début de l’informatique les programmeurs devaient coder leur programme directement en binaire : le langage machine.

Par exemple, le langage machine suivant est une instruction :

📋 Texte
  01001100 00100101
  • le premier octet 01001100 correspond au code de l’opération à effectuer (opcode) : « ajouter la valeur suivante au registre R1 ».
  • le second octet 00100101 (37 en décimal) est l’opérande : la valeur à ajouter à celle contenue dans le registre R1.

Opérations de base⚓︎

En général on a les opérations assembleur de base suivantes dans la plupart des jeux d’instructions :

  • déplacement :
    • chargement d’une valeur dans un registre ;
    • déplacement d’une valeur entre un emplacement mémoire et un registre et inversement ;
  • calcul :
    • addition (+, -) des valeurs de deux registres et chargement du résultat dans un registre ;
    • opération booléenne entre les valeurs de deux registres (ou opération bit à bit) ;
  • modification du déroulement du programme :
    • saut à un autre emplacement du programme (instructions exécutées séquentiellement) ;
    • saut à un autre emplacement après sauvegarde de l’instruction suivante afin de pouvoir y revenir (point de branchement) ;
    • retour au dernier point de branchement ;
  • comparaison :
    • comparer les valeurs de deux registres.

On trouve également des instructions spécifiques pour des opérations qui nécessitent beaucoup d’instructions élémentaires. Exemples :

  • déplacement de grands blocs de mémoire ;
  • multiplication, division ;
  • arithmétique lourde (sinus, cosinus, racine carrée, opérations sur les vecteurs) ;
  • application d’une opération simple (comme une addition) à un ensemble de données par l’intermédiaire d’extensions spécifiques.

En plus de coder les instructions machine, les assembleurs ont des directives supplémentaires pour assembler des blocs de données et assigner des adresses aux instructions en définissant des étiquettes ou labels. Ils sont également capables de définir des expressions symboliques qui sont évaluées à chaque assemblage, rendant le code encore plus facile à lire ou à comprendre.

Le langage assembleur⚓︎

La programmation en binaire étant loin d’être évidente pour un humain, on a inventé le langage assembleur qui permet d’écrire les instructions de manière plus compréhensible. Dans notre exemple le code 0100110000100101 est remplacé par :

GAS
  ADD R1,37

Ce qui est tout de même déjà beaucoup plus lisible !

Voici un exemple de programme assembleur :

GAS
  INP R0,2
  INP R1,2
  ADD R2,R1,R0
  OUT R2,4
  HALT

Le langage assembleur est donc une simple traduction brute du langage machine. Pour résumer :

  • Le langage machine est une succession de bits qui est directement interprétable par le processeur d’un ordinateur.
  • Un langage assembleur est le langage machine où les combinaisons de bits sont représentées par des « symboles » qu’un être humain peut mémoriser.
  • Un programme assembleur convertit ces « symboles » en la combinaison de bits correspondante pour que le processeur puisse traiter l’information. Le programme assembleur traduit donc le langage assembleur en langage machine.

Remarques :

  • Un langage assembleur est souvent spécifique à un type de processeur.
  • Un langage assembleur est appelé « langage de bas niveau » car il est très proche du langage machine.

Les compilateurs / interpréteurs⚓︎

Le langage assembleur n’est toutefois pas facile à manipuler. C’est pourquoi il a été conçu des langages de programmation plus agréable à utiliser : les langages de haut niveau (Ex : C, Python, Javascript,...).

On parle également de niveau d’abstraction d’un langage. Plus celui-ci est proche de notre langage naturel et plus son niveau d’abstraction est élevé. Plus le langage est proche de la machine (binaire) plus celui-ci est de bas niveau.

{{< figure src="/ox-hugo/abstraction.png" caption="Figure 1 : Abstraction du langage" width="520px" >}}

Mais tout langage de programmation, pour être exécuté par une machine, doit être à un moment où à un autre traduit en langage binaire.

{{< figure src="/ox-hugo/compilation.png" caption="Figure 2 : Compilation interprétation" width="820px" >}}

Il existe plusieurs manières de procéder :

  • La première consiste à traduire le programme dans son ensemble une fois pour toute et générer un fichier avec le code binaire prêt à être exécuté. Il s’agit de la méthode dite de compilation, réalisée par un compilateur. Le langage C est un exemple de langage compilé.
  • La deuxième méthode consiste à traduire les instructions en langage binaire au fur et à mesure de la lecture du programme. Il s’agit de la méthode dîte d’interprétation, réalisée par un interpréteur. Le langage Basic est un exemple de langage interpété.
  • Enfin il existe des méthodes mixtes qui consistent à traduire le programme en pseudo-code (bytecode). Ce pseudo-code est interprété par une machine virtuelle au moment de l’execution. L’intérêt de cette approche est que l’execution et la traduction du pseudo-code en langage binaire est plus rapide. Mais également, le fait que ce pseudo-code permet une certaine indépendance vis à vis du processeur sur lequel il est exécuté. En effet, il suffit juste de disposer d’une machine virtuelle spécifique au processeur en question. Python et Java sont des exemples de langages utilisant cette technique.

L’assembleur, en pratique⚓︎

Présentation du simulateur de processeur AQA⚓︎

Nous allons utiliser un simulateur d’architecture de Von Neumann, réalisé par Peter Higginson pour préparer des étudiants anglais à leur examen de Computer Science. Il se nomme AQUA et on peut l’exécuter en ligne.

Quelques principes de base :

  • On ne peut pas définir de variables. Les données manipulées sont soient stockées à un endroit précis en mémoire soit dans un des registres R0 à R12.
  • Pour calculer avec une donnée en mémoire, il faut d’abord la transférer dans un registre.
  • L’interface se divise verticalement en trois zones :

    • À gauche, l’éditeur de programme en assembleur.

      • On remplit le formulaire et on le soumet avec submit.
      • Puis on assemble le programme en mémoire avec assemble (parfois fait automatiquement).
      • On l’exécute avec run (Plusieurs vitesses d’exécution sont disponibles).
    • Au centre, le processeur, avec :

      • les treize registres de données de R0 à R12.
      • le Compteur de Programme PC.
      • l’ Unité de Contrôle avec son Registre d’Instruction CIR.
      • l’ALU avec ses quatre drapeaux de test (Z pour zéro, N pour négatif, C pour carry, retenue et V pour overflow).
      • les bus reliant les différents composants du processeur et la mémoire (en bleu).
      • Les registres MAR et MBR servent à transférer des données entre la mémoire et les registres :
        • MAR contient l’adresse (en décimal) où l’on veut lire ou écrire.
        • MBR la valeur lue ou à écrire (en hexadécimal).
    • À droite, la mémoire divisée en mots de largeur 32 bits et dont les adresses commencent à 0. Dans « OPTIONS » on peut choisir le format d’affichage (décimal signé ou non, binaire, hexadécimal).

Remarque : il n’existe pas de structure de contrôle conditionnelle comme le « if... then... else » ou les boucles « while », « for ». Pour les implémenter, on utilise des instructions de saut inconditionnel ou conditionnel en fonction du résultat de la comparaison précédente. Les points de chute de saut sont repérés par des étiquettes placées dans le programme.

AQA et le modèle de Von Neumann⚓︎

Ci-dessous, sont encadrés les quatre éléments constitutifs d’une architecture de Von Neumann :

{{< figure src="/ox-hugo/AQA.png" link="http://www.peterhigginson.co.uk/AQA/" >}}

Légende des encadrements :

  • En rouge, le processeur (CPU), comprenant :

    • En rose, l’unité de contrôle (UC)
    • En bleu, l’untité arithmétique et logique (UAL)
  • En vert, la mémoire vive (RAM)

Mon premier programme en assembleur⚓︎

Le jeu d’instructions AQA est précisé dans la documentation.

Remarque préalable : les opérandes ont la syntaxe suivante...

  • Rn correspond au registre numéro n.
  • #n correspond à une valeur entière immédiate n (sans passer par une mémoire).
  • n correspond à une adresse mémoire n (dans la RAM).

Voici quelques exemples d’instructions d’opérations arithmétiques et de transfert de mémoire :

Instruction Traduction
LDR R1, 78 Charge dans le registre R1 la valeur stockée en mémoire à l’adresse 78
STR R1, 123 Stocke le contenu du registre R1 à l’adresse 123 en mémoire
LDR R1, R2 Charge dans le registre R1 la valeur stockée en mémoire à l’adresse contenue dans le registre R2
ADD R1, R0, #128 Additionne le nombre 128 et la valeur stockée dans le registre R0. Place le résultat dans le registre R1
MOV R1, #23 Place le nombre 23 dans le registre R1
MOV R0, R3 Place la valeur stockée dans le registre R3 dans le registre R0
OUT R1, 4 Affiche en sortie 4 la valeur contenue dans le registre R1
HALT Symbole de fin de programme, indispensable pour que le programme se termine sans erreur
  1. Ouvrir le simulateur AQUA.

  2. Saisir le programme ci-dessous dans la fenêtre d’édition puis le soumettre avec submit .

    GAS
       MOV R0, #10
       MOV R1, #250
       ADD R2, R1, R0
       OUT R2, 4
       HALT
    
  3. Choisir l’option hex, pour un affichage hexadécimal des emplacements mémoire.

  4. Déterminer la taille d’un mot de la mémoire des registres.

  5. Constater que...

    • les 5 lignes d’instructions (ligne 0 à 4) sont bien enregistrées dans la RAM aux emplacements mémoire de 0 à 4.
    • les emplacements de mémoire de registre sont vides (valeurs nulles).
  6. Répérer...

    • l’Unité de Contrôle (UC) qui décode l’instruction en cours.
    • l’Unité Arithmétique et Logique (UAL) qui prend deux opérandes en entrée pour sortir le résultat de son calcul.
    • le registre du compteur de programme (PC) qui stocke l’emplacement mémoire de la prochaine instruction à aller chercher.
    • Le champ d’entrée clavier (input).
    • le champ de sortie qui servira d’affichage (output).
  7. Exécuter le programme pas à pas (step) en vitesse lente (« OPTIONS » : « def slow »). A la fin, relancer l’exécution pour essayer de comprendre au mieux les étapes de ce programme.

  8. Décrire à l’écrit l’enchaînement des opérations élémentaires effectuées lors de l’exécution des instructions.

  9. Pour chaque ligne d’instruction, décrire le rôle du PC, de l’UC, de la RAM, du l’UAL. On considérera l’état avant toute exécution de la ligne étudiée (ex: initialement, on étudie l’état des composants avant le début d’exécution de la ligne 0)

  10. Résumer vos notes dans ce tableau à compléter :
| Ligne | Instruction en assembleur | Instruction en hexadécimal | Etat/rôle du PC | Etat/rôle de l’UC | Etat/rôle de l’UAL | Etat/rôle de la RAM | Etat/rôle du registre | |-------|---------------------------|----------------------------|----------------------------------|-----------------------------------------------------------------------------------|--------------------------|-------------------------------------------------|-----------------------------------| | 0 | MOV R0, #10 | e3a0000a | Prochaine adresse à chercher : 0 | Vide | Aucun rôle | Zone 0 en attente de chargement | Vide | | 1 | MOV R0, #250 | e3a010fa | Prochaine adresse à chercher : 1 | A décodé l’instruction e3a0000a. En attente de l’instruction e3a010fa | Aucun rôle | Zone 0 chargée. Zone 1 en attente de chargement | valeur 10 affectée au registre R0 | | ... | ... | ... | ... | ... | ... | ... | ... |

L’addition à partir de données en mémoire vive⚓︎

  1. Saisir le programme ci-dessous dans la fenêtre d’édition puis le soumettre avec submit .

    GAS
       MOV R0, #10
       LDR R1, 10
       ADD R2, R1, R0
       STR R2, 11
       HALT
    
  2. Modifier le mot mémoire d’adresse 10 en lui donnant la valeur 12.

  3. Décrire à l’écrit l’enchaînement des opérations élémentaires effectuées lors de l’exécution des instructions.

  4. Où se trouve le résultat de cette addition ?

Entrées - sorties en assembleur⚓︎

Dans le menu « SELECT », choisir le programme « add ».

Le programme va se charger dans la zone d’édition.

Quelles sont les nouvelles instructions utilisées dans ce programme ?

Comme précédemment, décrire précisément la suite des instructions de ce programme d’addition.

Usage du langage assembleur⚓︎

On peut discuter de l’utilité de l’assembleur dans beaucoup de cas : des compilateurs-optimiseurs peuvent transformer du langage de haut niveau dans un code qui tourne de façon presque aussi efficace qu’un code assembleur écrit à la main, tout en restant beaucoup plus faciles (et surtout moins coûteux) à écrire, à lire et à maintenir.

Cependant, certains calculs complexes écrits directement en assembleur, en particulier sur des machines massivement parallèles, seront plus rapides, les compilateurs n’étant pas encore assez évolués pour tirer parti des spécificités de ces architectures. Certaines routines (les drivers notamment) sont parfois plus simples à écrire en langage de bas niveau ; de même, des tâches très dépendantes du système, exécutées dans l’espace mémoire du système d’exploitation sont parfois difficiles, voire impossibles à écrire dans un langage de haut niveau. Par exemple, les instructions assembleur qui permettent à Windows de gérer le changement de tâche (LCDT et LLDT) sur processeur i386 et suivants ne peuvent pas être émulées ou générées par un langage évolué. Il faut nécessairement les coder dans un court sous-programme assembleur qui sera appelé à partir d’un programme écrit en langage évolué.

Certains compilateurs transforment, lorsque leur option d’optimisation maximale n’est pas activée, des programmes écrits en langage de haut niveau en code assembleur, chaque instruction de haut niveau se traduisant en une série d’instructions assembleur rigoureusement équivalentes et utilisant les mêmes symboles. Cela permet de voir le code dans une optique de débogage et de profilage, ce qui fait gagner parfois beaucoup de temps lors de la réécriture d’un algorithme. En aucun cas, ces techniques ne peuvent être conservées pour la compilation finale.

La programmation des systèmes embarqués, souvent à base de microcontrôleurs, est une niche traditionnelle pour la programmation en assembleur. En effet ces systèmes sont souvent très limités en ressources (par exemple un microcontrôleur PIC 16F84 est limité à 1024 instructions de 14 bits, et sa mémoire vive contient 136 octets) et requièrent donc une programmation de bas niveau très optimisée pour en exploiter les possibilités. Toutefois, l’évolution du matériel fait que les composants de ces systèmes deviennent de plus en plus puissants à un coût et à une consommation électrique constants, l’investissement dans une programmation tout assembleur, beaucoup pius coûteuse en heures de travail, devient alors sans grand intérêt en termes d’efforts. Typiquement, la programmation en assembleur est beaucoup plus longue, plus délicate (car le programmeur doit prendre en compte tous les micro-détails du développement dont il s’abstient en langage évolué) et donc plus coûteuse que ia programmation en langage de haut niveau. Il ne faut donc la réserver qu’aux situations pour lesquelles on ne peut vraiment pas faire autrement.

Résumé en vidéo⚓︎

Que retenir ?⚓︎

A minima...⚓︎

  • Les programmes stockés dans la mémoire centrale de l’ordinateur sont constitués d’instructions de bas niveau, exécutables directement par les circuits logiques du processeur.
  • Le langage assembleur n’est qu’un moyen mnémotechnique de traduire des instructions en langage machine.
  • Le programme assembleur traduit donc facilement un langage assembleur en langage machine.
  • Les opérandes, c’est à dire les données nécessaires à l’exécution de ces instructions sont elles aussi stockées dans la mémoire.
  • Un programme nommé compilateur permet de transformer le texte d’un programme en langage de haut niveau (comme Python ou C) en une série d’instructions en langage machine.
  • Le jeu d’instructions du microprocesseur est restreint.
  • Les jeux d’instructions sont différents d’un processeur à l’autre.

Au mieux...⚓︎

  • L’unité de contrôle décode la série de bits composant chaque instruction :

    • les premiers bits forment un code de l’opération (ex : MOV, STR, ADD,...) qui peut déclencher l’activation des circuits nécessaires dans l’ALU.
    • les bits suivants portent les opérandes :
      • une valeur immédiate si précédée par #.
      • une adresse mémoire de registre si sous la forme Rn.
      • une adresse mémoire vive si sous la forme d’un entier.
  • Savoir coder en langage assembleur des programmes très basiques.

  • Savoir interpréter le résultat d’un simulateur de type AQA pour un programme simple (ex : addition).