5.4 Représentation de programme au niveau machine
Objectifs d'apprentissage
À la fin de cette section, vous serez capable de :
- Discuter des processeurs Intel x86-64 et de leurs architectures
- Différencier les langages d'assemblage et machine
- Expliquer les concepts de base du langage d'assemblage et les types d'opérations
Lorsque vous écrivez un programme dans un HLL, plusieurs étapes doivent être effectuées avant que le processeur ne puisse commencer à exécuter le code. Reportez-vous à la Figure 4.18 pour une vue d'ensemble du processus.
Supposons que vous écriviez votre programme en C et que votre programme soit réparti sur plusieurs fichiers source pour faciliter la gestion. Comme vous le savez maintenant, la première étape consiste à passer par le compilateur. Le compilateur ignore totalement le fait que les multiples fichiers de code source appartiennent au même programme ; il prend simplement chaque fichier séparément et génère le fichier de langage d'assemblage (assembly language) correspondant pour chacun d'eux. Si l'entrée est constituée de trois fichiers C, la sortie du compilateur sera trois fichiers de langage d'assemblage.
L'étape suivante consiste à prendre ces fichiers de langage et à les traduire en fichiers de code machine, également connus sous le nom de fichiers objet ou fichiers binaires. Ici aussi, l'assembleur ignore le fait que les fichiers d'assemblage d'entrée n'appartiennent pas au même programme, il les traduit donc séparément. Le premier outil de ce flux de travail qui reconnaît que tous les fichiers appartiennent au même programme est l'éditeur de liens. L'éditeur de liens prend tous les fichiers objet générés, recherche les bibliothèques nécessaires et lie le tout en un seul fichier exécutable. Les bibliothèques liées sont nécessaires car il est très peu probable que les programmeurs écrivent du code autonome. Vous utilisez toujours des E/S, par exemple, pour afficher quelque chose à l'écran, mais vous n'avez pas implémenté ces fonctions vous-même — ou vous utilisez des fonctions mathématiques implémentées par quelqu'un d'autre. Une bibliothèque liée à cette étape est appelée une bibliothèque statique.
À ce stade, vous avez un fichier exécutable résidant sur votre disque jusqu'à ce que vous décidiez de l'exécuter en tapant une commande, en cliquant sur une icône, ou même en prononçant une commande. À ce moment-là, une partie du système d'exploitation, appelée le chargeur (loader), charge l'exécutable en mémoire et organise son contenu d'une manière spécifique pour le rendre prêt à l'exécution par le processeur. Une bibliothèque dynamique (dynamic library) est une bibliothèque qui peut être liée pendant l'exécution ou pendant que le programme est en cours d'exécution.
Cette section examine de plus près le langage d'assemblage. À titre d'exemple d'ISA de langage d'assemblage largement utilisé, nous examinerons l'ISA x86 utilisée par les processeurs Intel et AMD. Mais avant d'approfondir ce sujet, nous devons poser une question simple : pourquoi apprendre le langage d'assemblage ? Aurez-vous un jour besoin d'écrire du code en langage d'assemblage ? Très probablement pas, sauf dans de rares cas où vous développez une partie d'un système d'exploitation, un pilote de périphérique ou toute autre application nécessitant une manipulation de très bas niveau. Cependant, en examinant le langage d'assemblage généré par le compilateur pour votre code, vous pouvez trouver des moyens innovants d'optimiser votre code, de détecter des bogues cachés et de raisonner sur les performances de votre code une fois que vous l'exécutez.
Processeurs Intel et architectures associées
Vous vous souvenez peut-être que chaque famille de processeur (processor) comprend un ensemble d'instructions, qui est l'ISA. La famille de processeurs d'Intel et d'AMD partage la même ISA appelée x86 (x86-64 pour la version relativement plus récente des processeurs ultérieurs). Cette ISA a une longue histoire qui remonte aux années 1970. La Figure 5.16 donne un aperçu rapide de l'évolution des choses. La figure ne montre pas tous les processeurs d'Intel, mais se concentre plutôt sur quelques étapes importantes.
Premièrement, à mesure que la technologie évolue et que le besoin de puissance de traitement plus rapide se fait sentir, nous passons des processeurs 16 bits aux 32 bits, puis aux 64 bits. Ce nombre est lié à la taille des registres (entités de stockage rapide à l'intérieur du processeur), à la largeur des bus (fils parallèles connectant le processeur à la mémoire) et à la quantité de mémoire que le processeur peut accéder (pour les machines n-bits, le processeur peut accéder à 2n octets de mémoire). L'ISA évolue également en parallèle pour incorporer les registres plus grands (d'où le passage de l'IA32 au x86-64) et les calculs avec des nombres plus grands. Deuxièmement, nous pouvons constater une augmentation considérable du nombre de transistors à chaque génération. Avoir plus de transistors signifie plus de fonctionnalités implémentées à l'intérieur du processeur et donc des performances plus élevées et une ISA potentiellement plus riche.
La structure architecturale complexe qui aide à l'exécution d'opérations telles que les calculs mathématiques et le stockage en mémoire est appelée ordinateur à jeu d'instructions complexe (CISC). Cela se fait en combinant de nombreuses instructions simples en une seule instruction complexe. Ce concept est né de ce que l'on appelle le fossé sémantique, qui est la différence entre le programme HLL et son équivalent en langage d'assemblage. Il est bon pour les programmeurs de comprendre le langage d'assemblage, car cette compétence les aidera à coder dans n'importe quel langage. Cependant, la programmation en langage d'assemblage est tellement différente de la programmation HLL que la plupart des programmeurs ont des difficultés à la comprendre. Plus la différence est grande, plus le fossé sémantique est large. Pour réduire ce fossé et rendre le langage d'assemblage plus accessible aux programmeurs, le x86 a été conçu pour rendre ses instructions un peu plus compliquées, car les instructions en HLL sont compliquées. Compliqué signifie qu'une seule instruction d'assemblage peut faire plusieurs choses. Par exemple, une instruction comme addw %rax, (%rbx) signifie accéder à la mémoire à une adresse spécifique, obtenir les données qui y sont stockées, ajouter ces données à un nombre et stocker ce nombre à un endroit spécifique. Ainsi, elle accède à la mémoire, effectue une addition et stocke le résultat quelque part. Parce que les instructions sont compliquées, cet ensemble d'instructions est appelé CISC.
Les instructions complexes, telles que celles correspondant à une boucle for en HLL, étaient la norme jusqu'aux années 1980, lorsqu'un autre point de vue est apparu, affirmant que les instructions complexes ralentissaient le processeur. Au cours des années 1990, avec l'apparition des appareils portables et leur sensibilité à la consommation d'énergie et à l'autonomie de la batterie, un autre inconvénient du CISC est apparu : les instructions complexes rendent le processeur non seulement lent, mais aussi gourmand en énergie. Ainsi, l'autre point de vue, celui des instructions plus simples appelées ordinateur à jeu d'instructions réduit (RISC), est devenu la norme. Actuellement, toutes les familles de processeurs dans le monde sont RISC, à l'exception du x86.
Lien interactif
Il y a eu un débat parmi les entreprises qui conçoivent du matériel pour savoir si le CISC ou le RISC est meilleur. Lisez cet article relatant ce débat de MicrocontrollerTips.
Langage d'assemblage et code machine
Dans notre discussion de la Figure 4.18, nous avons vu le langage d'assemblage (sortie du compilateur) et le code objet (object code), le code binaire et le code machine (machine code), qui désignent tous la sortie de l'assembleur. Le code machine est la représentation binaire du code d'assemblage. Dans certains cas, il existe des instructions d'assemblage qui n'ont pas de contrepartie dans le code machine, appelées pseudo-assembleur (pseudo-assembly). Par exemple, il existe des instructions en langage d'assemblage qui exécutent un go to si un nombre est inférieur à un autre nombre. Les seules conditions connues en code machine sont égal et non égal, mais pas inférieur à, inférieur ou égal, et ainsi de suite. Nous pouvons le voir dans un jeu d'instructions comme MIPS.
Le travail de l'assembleur est de s'assurer que le fichier de code machine ne contient que des instructions natives au processeur ; c'est-à-dire faisant partie de l'ISA. Ainsi, nous pouvons considérer le code machine comme un sous-ensemble du code d'assemblage. Vous ne trouverez jamais d'instruction dans le fichier de code machine qui ne fasse pas partie de l'ISA du processeur pour lequel vous voulez générer le binaire. La raison de l'existence des instructions de pseudo-assembleur est de donner au compilateur un peu plus de liberté pour générer du code efficace. Supposons que vous écriviez un programme en C et que vous pensiez aux fonctions qui s'appellent mutuellement. Si vous écrivez un programme en C++ ou en Java, vous pensez en termes d'objets, de méthodes, d'héritage, etc. (reportez-vous au Chapitre 4 Réalisation linguistique des algorithmes : langages de programmation de bas niveau). Nous appelons cela la vue du programmeur sur le langage. Que se passe-t-il si vous écrivez (ou lisez) du code d'assemblage ? Que voyez-vous ? Cette vue est résumée dans la Figure 5.17.
La Figure 5.17 montre ce qui suit :
- Il y a un processeur (CPU) et une mémoire.
- Le CPU et la mémoire sont connectés par un bus (bus), qui est un chemin de données. Lorsque vous accédez à la mémoire, vous pouvez vouloir obtenir des données et devez fournir une adresse. Ou vous pouvez vouloir écrire des données en mémoire, vous devez donc fournir à la fois les données et l'adresse à laquelle ces données seront écrites. Dans les deux cas, le CPU doit fournir une adresse, par conséquent, le bus d'adresses est unidirectionnel. Mais le bus de données est bidirectionnel car vous pouvez envoyer des données à la mémoire ou obtenir des données de la mémoire.
- Les données ne sont pas la seule chose que vous devez apporter de la mémoire au CPU. Le travail principal du CPU est d'exécuter des instructions sur les données. Par exemple, l'addition de deux nombres implique des données (les deux nombres) et la commande d'addition (instruction), c'est pourquoi il existe un bus unidirectionnel de la mémoire vers le CPU pour obtenir les instructions de la mémoire.
- La mémoire contient plusieurs choses : des données, les instructions des programmes (montrées comme « Texte » dans la Figure 5.17), certaines données nécessaires à l'OS pour gérer votre programme, et les ressources dont il a besoin. La pile et le tas sont des emplacements en mémoire pour stocker des données en fonction du programme en cours. La pile (stack) est utilisée pour stocker les variables locales (et d'autres éléments que nous discuterons plus tard), le tas (heap) stocke les données allouées dynamiquement, et les données dans la Figure 5.17 est une autre zone de la mémoire pour stocker les variables globales.
- À l'intérieur du CPU, il y a des registres qui contiennent des éléments matériels qui stockent des données, des instructions, des adresses, etc. Chaque registre stocke un élément. Un programmeur x86 a accès à 16 registres, comme nous le verrons bientôt. Parce que le CPU exécute un programme, qui est une série d'instructions, le CPU doit garder une trace de la prochaine instruction à exécuter.
- Le suivi est le travail d'un registre spécifique, montré séparément des autres registres, appelé le compteur ordinal (PC). Le PC est mis à jour après l'exécution de chaque instruction pour pointer vers la prochaine instruction à exécuter. Il est également utile de conserver certaines informations sur le résultat des instructions exécutées, comme si le résultat généré par l'instruction actuelle est positif, négatif ou nul, ce qui est le travail des indicateurs. Un indicateur (flag) indique à un programme si une condition a été remplie.
Registres
Un registre (register) est une unité de mémoire qui fonctionne à très haute vitesse. La Figure 5.17 montre les registres comme une boîte noire. Si nous ouvrons cette boîte et voyons ce qu'il y a à l'intérieur, nous voyons 16 registres. Toute instruction en langage d'assemblage x86 utilise uniquement ces 16 registres. Le nom de chaque registre est montré dans la Figure 5.18 et commence par un « r ». La convention de nommage des registres est un peu étrange, mais elle est due à un nommage historique (pour les registres de gauche). Pour maintenir la compatibilité ascendante, les anciens noms de registres ne peuvent pas être modifiés.
Chacun de ces registres peut contenir 64 bits. Lorsque nous avions des machines 32 bits, seuls huit registres existaient, comme le montre la gauche de la Figure 5.18. À l'ère 32 bits, chaque registre ne pouvait contenir que 32 bits et son nom commençait par « e » (pour étendu). C'est pourquoi nous voyons que les 32 bits inférieurs des registres actuels conservent les anciens noms pour maintenir la compatibilité ascendante. De plus, à l'ère 16 bits, chaque registre ne contenait que 16 bits. Ceux de gauche avaient des noms : ax, bx, cx, etc., qui sont les noms des 16 bits les plus bas des registres actuels. Les registres du côté droit de la figure n'existaient pas avant l'ère 64 bits. Si nous allons à l'ère 8 bits, nous pouvons même accéder aux 8 bits les plus bas du registre. Le registre rax est montré comme un exemple où des parties du registre peuvent être accédées en utilisant la convention de nommage : rax (16 bits), eax (32 bits), ax (16 bits), et ah et al (8 bits chacun). À l'ère 8 bits, il n'y avait que quatre registres, qui sont les quatre premiers de la colonne de gauche de la figure.
Concepts en pratique
HLL et langage d'assemblage
La plupart des programmeurs utilisent des HLL pour écrire leurs programmes. Pourquoi pensez-vous que les programmeurs de classe mondiale sont très versés dans le langage d'assemblage ? Les programmeurs professionnels écrivent en HLL mais ils aiment souvent aussi regarder la version en langage d'assemblage de leur code. Cela leur permet de découvrir des erreurs dans leur code HLL et aussi de trouver des moyens d'améliorer leur code HLL.
Cependant, il existe certains programmes, ou parties de programmes, qui doivent être écrits en langage d'assemblage et non en HLL pour des raisons de performance (le code d'assemblage écrit par un humain dans ces cas est beaucoup plus rapide que le HLL traduit en langage d'assemblage par un compilateur) et pour un meilleur contrôle du matériel. Les utilisations courantes du langage d'assemblage (Assembly language) aujourd'hui incluent les pilotes de périphériques, les systèmes embarqués de bas niveau et les systèmes temps réel.
Opérandes
Un programme en langage d'assemblage se compose d'un groupe d'instructions. Chaque instruction effectue une opération et pour que cette opération soit exécutée, elle a besoin d'opérandes. Un opérande (operand) est une valeur utilisée comme entrée pour un opérateur. Nous voulons peut-être ajouter deux nombres dans une opération. Pour être exécutée, elle a besoin de deux nombres, qui sont les opérandes. Par exemple : add %RAX, %RBX signifie ajouter le contenu du registre RAX au contenu du registre RBX et placer le résultat dans le registre RAX. Les opérandes en langage d'assemblage peuvent être l'une des trois choses suivantes :
- Un registre
- Un opérande immédiat (par exemple, ajouter 7 à rax ; l'opérande est ici mentionné explicitement dans l'instruction)
- Données de la mémoire
Modes d'adressage mémoire
Pour obtenir des données de la mémoire, nous devons spécifier l'emplacement, appelé le mode d'adressage (addressing mode), dans la mémoire qui contient les données requises. Pourquoi y a-t-il plusieurs « modes » au lieu de simplement spécifier une adresse directement ? Eh bien, la réponse est liée au HLL.
Dans les programmes HLL, nous utilisons des structures de données compliquées, telles que des tableaux à une ou plusieurs dimensions, des structures ou des listes chaînées. Le compilateur doit traduire cette structure de données en un langage d'assemblage beaucoup plus simple. En langage d'assemblage, il n'y a pas de structures de données complexes ; il n'y a que des éléments de données de 1, 2, 4 et 8 octets. Alors, comment pouvons-nous mapper ces structures de données complexes aux simples éléments de données unidimensionnels ? Une façon cruciale est d'avoir des modes d'adressage riches. Dans sa forme la plus générale, une adresse en x86 est spécifiée comme D (Rb, Ri, S) où :
- D est un entier non négatif (mais peut être 0) dont la plage va de 0 à 232 – 1.
- Rb et Ri sont des registres, et ils peuvent être n'importe lequel des 16 registres.
- S est un facteur d'échelle qui prend l'une des valeurs suivantes : 1, 2, 4 ou 8.
L'adresse est calculée comme : D + Rb + (S × Ri). Bien que ce soit une forme générale, elle peut avoir des formes plus réduites telles que (Rb, Ri). Ici, la valeur par défaut de D est 0 et celle de S est 1 ; D (Rb, Ri) ; ou (Rb, Ri, S).
Maintenant que nous connaissons les opérandes, examinons les opérations elles-mêmes qui sont implémentées par les différentes instructions d'assemblage.
Lien interactif
Il est toujours bon de voir les concepts que nous apprenons en action. Visitez ce site pour écrire un programme HLL et voir l'assemblage correspondant en même temps. Toute modification que vous apportez au code HLL aura un effet sur le langage d'assemblage.
Opérations d'assemblage
Dans toute ISA, toutes les instructions d'assemblage se répartissent en trois catégories :
- Mouvement de données : de registre à registre, de mémoire à registre, et de registre à mémoire
- Opérations arithmétiques et logiques incluant l'addition, la soustraction, ET, OU, etc.
- Instructions de contrôle, qui sont les instructions qui implémentent les opérations de « saut », qu'elles soient conditionnelles ou non conditionnelles (cette catégorie inclut également les appels de procédure)
Opérations de mouvement de données
Le mouvement de données (data movement) en langage d'assemblage prend la forme movx source ou destination où :
- « x » spécifie le nombre d'octets à déplacer de la source vers la destination. Il peut prendre l'une des valeurs suivantes : « b » signifie 1 octet, « w » (mot) signifie 2 octets, « l » (long) signifie 4 octets, et « q » (mot quadruple) signifie 8 octets. Si vous y réfléchissez, ce sont les tailles de tous les types de données que nous avons dans n'importe quel HLL.
- la source et la destination peuvent être n'importe lequel des types d'opérande (operand) que nous avons mentionnés précédemment. Il n'y a que trois combinaisons qui ne sont pas autorisées. La première est de déplacer un immédiat vers un immédiat, car cela n'a pas de sens. La seconde est de déplacer de la mémoire vers la mémoire, car le CPU doit être impliqué. La dernière combinaison interdite est lorsque la destination est immédiate, car cela n'a pas non plus de sens. Quelques exemples :
- « movq %rax, %rbx »
Déplace 8 octets du registre rax et les place dans le registre rbx, ce qui n'est pas vraiment un déplacement ; c'est une copie. - « movq (%rax, %rbx, 4), %rcx »
C'est un peu compliqué. Cela implique trois étapes : premièrement, calculer [rax + (4 × rbx)] ; deuxièmement, utiliser cette valeur calculée comme adresse et aller à la mémoire à cette adresse ; et troisièmement, obtenir 8 octets, à partir de l'adresse que vous avez calculée (n'oubliez pas le « q » à la fin de l'instruction mov) et les placer dans le registre rcx. Voyez-vous maintenant pourquoi le x86 est un CISC où C signifie complexe ?
- « movq %rax, %rbx »
Opérations arithmétiques et logiques
Les opérations arithmétiques (arithmetic) et logiques (logic operations) impliquent des opérations très connues telles que l'addition, la soustraction, et, ou, xor, décalage à gauche et décalage à droite. Comme vous l'avez peut-être deviné, ce sont des opérations qui nécessitent deux opérandes. Regardez le type d'opérandes que nous avons étudiés précédemment. Par exemple :
addq %rax, %rbx
means rax = rax + rbx.
De plus, il existe des opérations à un opérande telles que l'incrémentation et la décrémentation. Il y a des complexités impliquées dans la multiplication et la division où il existe différentes instructions pour les entiers signés et non signés. Et, pour la division, une autre complexité est l'endroit où stocker le reste.
Opérations de comparaison et de test
Pour pouvoir implémenter le flux de données complexe dans les HLL (par exemple, switch case ou if-else), le langage d'assemblage doit prendre en charge les sauts conditionnels et inconditionnels. Dans le jargon x86, on parle d'instruction de saut. La forme générique d'un saut est l'instruction de saut suivie d'une étiquette. L'étiquette (label) est un nom de variable que nous donnons à une instruction d'assemblage. C'est nécessaire car si vous voulez dire (aller à cette instruction), comment pouvez-vous définir « cette » instruction ? L'étiquette prend la forme suivante : étiquette: instruction. Par exemple :
part: movq %rax, %rbx
. . . .
jmp part
Dans cet exemple, « part » est l'étiquette de l'instruction de déplacement. Le « jmp » est un saut inconditionnel ; c'est-à-dire qu'il est toujours exécuté. Il en existe aussi pour les branches conditionnelles. Examinons l'une d'entre elles.
Pour le saut si égal, ou je, étiquette, cette instruction signifie sauter à l'étiquette si l'indicateur (flag) zéro est mis à 1. Dans la vue du programmeur du programme d'assemblage, il existe des indicateurs qui donnent des informations sur l'instruction précédente (reportez-vous à la Figure 5.18). L'un des indicateurs est appelé l'indicateur zéro, et il est mis à 1 si l'instruction précédente a généré 0 comme résultat. Par exemple, soustraire deux registres et obtenir le résultat de 0.
Opérations d'appel de procédure
Un dernier élément est lié à l'appel de procédure — le x86 a deux instructions : CALL et RET à implémenter. Cependant, la situation est plus compliquée que cela. Dans les HLL, vous avez le concept de variables locales et de variables globales. Comment cela est-il appliqué en langage d'assemblage ? Rappelez-vous, le programme d'assemblage généré doit se comporter de la même manière que vous l'aviez prévu lorsque vous avez écrit le programme HLL.
Le langage d'assemblage utilise le concept de pile, la structure de données très connue qui fonctionne selon le principe du dernier entré, premier sorti, pour simuler la notion de variables locales, le passage d'arguments à une procédure et la sauvegarde de certains registres en mémoire pendant un appel de procédure. Pourquoi devons-nous sauvegarder certains registres ? Parce que nous n'avons que 16 registres en x86, et les programmeurs utilisent beaucoup plus de variables que cela dans leur HLL.
Instructions vectorielles
Vous avez peut-être réalisé que nous n'avons pas du tout mentionné les virgules flottantes dans les opérations x86. C'est parce qu'il existe un autre ensemble d'instructions et un autre ensemble de registres pour les virgule flottante (floating point) — les instructions vectorielles (vector instructions). Ces instructions n'opèrent pas sur des registres individuels, mais sur un vecteur (c'est-à-dire un groupe de nombres). Ainsi, une opération d'addition peut ajouter 32 nombres à 32 autres nombres en une seule fois. C'est-à-dire que le premier nombre est ajouté au premier nombre correspondant dans le deuxième vecteur, le deuxième nombre au deuxième nombre, et ainsi de suite. Ces opérations sont généralement très efficaces dans de nombreuses applications.