Assemblage d'instructions

Plongée au cœur du processeur et de son langage

Maintenant que vous en savez un peu plus sur les deux modes d’exécution d’un programme informatique grâce au billet précédent, il est temps de mettre fin a une imposture que les plus au courant d’entre vous auront déjà relevé. J’ai en effet mentionné à plusieurs reprises l’assembleur comme seul langage de programmation compris par l’ordinateur. Mais la vérité est plus complexe; il existe plusieurs assembleurs différents qui partagent des concepts communs. Nous verrons que cela ne facilite pas nos problèmes de compilation…

Désassembler un programme

Dans le premier article, nous avons pudiquement illustré l’assembleur par une recette de cuisine « bas niveau ». Malheureusement, il est ici nécessaire de montrer à quoi ressemble vraiment l’assembleur, ce langage si particulier compris par la machine. Ce qui suit, extrait du résultat de la commande objdump -d sur l’exécutable UNIX ls, s’appelle le désassemblage d’un programme assembleur (x86 en l’occurence).

40cc2c:	48 89 44 24 08       	mov    %rax,0x8(%rsp)
40cc31:	31 c0                	xor    %eax,%eax
40cc33:	48 85 ff             	test   %rdi,%rdi
40cc36:	0f 84 04 01 00 00    	je     40cd40 <sprintf_chk@plt+0xa360>
40cc3c:	45 31 ed             	xor    %r13d,%r13d
40cc3f:	80 3b 27             	cmpb   $0x27,(%rbx)
40cc42:	0f 84 88 00 00 00    	je     40ccd0 <sprintf_chk@plt+0xa2f0>

Passé le choc initial, tentons de reprendre nos esprits et remarquons quatre colonnes dans ce fragment de code. De gauche à droite, on a : la référence de l’instruction dans l’exécutable, le fragment de code binaire assembleur, le type d’instruction, ses arguments. L’ordinateur lit le programme instruction par instruction dans l’ordre où elles sont déclarées et les exécute. Observons de plus près la première instruction.

40cc2c:	48 89 44 24 08       	mov    %rax,0x8(%rsp)
  • 40cc2 est la référence de l’instruction dans le désassemblage.
  • 48 89 44 24 08 est littéralement l’instruction, encodée en format binaire (et ici affichée en format hexadécimal). Les détails de cet encodage n’ont que peu d’intérêt; par contre c’est bien sous cette forme que le programme est lu par l’ordinateur. L’affichage du programme assembleur sous format textuel est une commodité faite pour les humains qui doivent le lire, et est exactement équivalent à la forme binaire.
  • mov est le type d’instruction, ici il s’agit de déplacer des données d’une case mémoire à une autre.
  • %rax,0x8(%rsp) sont les arguments de l’instruction. Puisqu’il s’agit d’un déplacement, %rax est la destination du déplacement.%rax est en réalité un registre mémoire, qui se situe physiquement dans le processeur et qui constitue la mémoire immédiate de l’ordinateur. Dans 0x8(%rsp), qui est la source des données, %rsp est un autre registre mémoire et l’expression signifie « données qui se situent dans la RAM à l’adresse contenue dans %rsp à laquelle il faut ajouter 8 ».

Cette instruction va donc chercher des données dans la RAM pour les mettre dans un registre. On comprend rapidement que la lecture ou l’écriture manuelle d’assembleur provoque un sentiment de dégout mêlé d’ennui très puissant. Mais comment l’ordinateur comprend et exécute quelque chose comme 48 89 44 24 08? La description de l’architecture d’un processeur serait trop compliquée pour ce modeste blog; disons simplement que celui-ci considère le code instruction par instruction et que chacun des bits de celle-ci passe à travers un réseau de portes logiques (les fameux transistors) magiquement conçu de telle sorte qu’à la sortie, on ait bien le résultat de ce que fait l’instruction.

À chacun son assembleur

Dès lors, on comprend que l’assembleur est intimement lié aux caractéristiques du processeur. Or le type d’instructions et l’encodage binaire de celles-ci varie de processeur en processeur! Il existe même des philosophie d’assembleur différentes, le RISC et le CISC, selon que le processeur n’offre que le minimum syndical d’instructions (mais garantit qu’elles soient exécutées rapidement), ou bien propose d’innombrables instructions aux effets baroques (mais qui peuvent être plus lentes). Les deux langages assembleurs les plus répandus sont le x86 des processeurs Intel et utilisé majoritairement par les ordinateurs classiques, et l’ARM produit par l’entreprise du même nom utilisé majoritairement par les téléphones portables. En passant, la différence 32 bits/64 bits dont vous avez peut-être entendu parler fait référence à la taille de base des registres mémoire du processeur, unité fondamentale de manipulation de la mémoire qui a changé progressivement au cours de la dernière décennie.

Les différents assembleurs ne sont pas compatibles : exécuter un programme assembleur ARM sur un processeur x86 ne marchera pas. Mais il y a pire encore. En effet, un fichier exécutable ne contient pas que le code assembleur du programme, il contient aussi des informations sur les données initiales et la manière dont sera structurée la mémoire durant l’exécution. Et là aussi, il existe différent formats de fichiers exécutables, qui dépendent du système d’exploitation utilisé. Ainsi un même programme devra être traduit en quatre fichiers exécutables différents pour être exécuté sur un Windows, un Mac, un iPhone ou un Android… Doublez le nombre si il faut tenir compte de la différence 32 bits/64 bits! Chacun de ces environnements d’exécution différents est appelé une architecture.

Un compilateur pour les gouverner tous

Ainsi à partir d’un seul programme source, il faut effectuer autant de traductions différentes que de plateformes sur lesquelles on veut exécuter le programme. Mais à y regarder de plus près, tous ces assembleurs ne sont pas conceptuellement différents les uns des autres : ils n’ont pas tous les mêmes opérateurs ni les même formats mais ils consistent tous en une suite d’instructions exécutées séquentiellement par le processeur, manipulant des registres et adresses mémoire.

Pour éviter d’écrire autant de compilateurs que d’assembleurs cible, on a recours à une idée fondamentale et très puissante en compilation : la création d’un langage intermédiaire. Dans ce cas, ce langage intermédiaire ressemblera à un assembleur : ce sera une suite d’instructions manipulant des registres et la mémoire. Néanmoins, les instructions présentes dans ce langage intermédiaire seront choisies de telle manière que chacune d’entre elle peut se traduire aisément dans n’importe lequel des assembleurs « réels ». À partir de là, la compilation devient une traduction en deux temps : d’abord du langage source vers le langage intermédiaire, puis du langage intermédiaire vers un assembleur particulier. Dans notre métaphore linguistique, nous voudrions interpréter de l’anglais oral vers du français métropolitain, québecois, belge ou suisse. Plutôt que d’engager 4 interprètes de l’anglais vers chacun des dialectes du français, on peut choisir le français métropolitain comme langage intermédiaire, engager un seul traducteur de l’anglais vers le français et recruter 3 locaux québequois, belge et suisse qui auront une tâche beaucoup plus facile consistant à interpréter le français métropolitain pour y ajouter leur accent local.

Grâce au langage intermédiaire, on divise le problème en tâches moins complexes. Il devient plus facile d’ajouter une mini-traduction si un nouveau processeur arrive sur le marché avec son propre format d’assembleur. Ce processus est assez standard dans les gros compilateurs modernes comme LLVM, comme en témoigne ce tutoriel à l’intention de celui qui voudrait rajouter une de ces mini-traductions au compilateur.

Au delà de ces mini-traductions, le plus intéressant est de décider de ce que l’on met ou pas dans ce langage intermédiaire. Cet arbitrage est crucial car c’est ce qui détermine la difficulté relative des traductions de et vers le langage intermédiaire. Ainsi en informatique, le traducteur doit s’improviser linguiste et créer son propre langage!

Pour aller plus loin