Assemblage et édition de liens

De l'ASM au programme exécuté

Guy Grave

Quand vous compilez un code source, celui-ci n'est pas transformé en code machine : il est traduit en assembleur. Dans ce cours, nous allons voir tout ce qui se passe entre le moment où l'assembleur est généré, et le moment où le programme exécutable est chargé en mémoire.

Commentez

- Titre : Assemblage et édition de liens

- Auteur : Guy Grave

- Parution : 12 août 2016

- Licence : Le contenu de cet article est rédigé par Guy Grave et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Pas de Modification 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- I. Introduction

- II. La chaîne d'assemblage

- II-A. Résolution des symboles

- II-B. Relocation

- III. Le logiciel d'assemblage

- III-A. Macros

- III-A-1. Deux passes

- III-A-2. Une passe

- III-B. Mnémoniques

- III-C. Symboles locaux

- III-C-1. Compteur de ligne

- III-C-2. Remplissage de la table des symboles

- III-D. Noms de registres et autres constantes

- IV. L'édition des liens

- IV-A. Création d'un exécutable

- IV-B. Relocation

- IV-C. Résolution des symboles globaux

- V. Les chargeurs de programme

- V-A. Loaders absolus

- V-B. Loaders à relocation

- V-B-1. Multiprogrammation et relocation

- V-B-2. Comment se passer de relocation ?

- V-C. Loaders à édition des liens dynamique

- VI. Pour aller plus loin

I - Introduction

Prérequis : dans ce qui va suivre, je suppose que vous avez un minimum de connaissances en assembleur : vous savez ce qu'est une mnémonique, un opérande, un label, une pseudo-instruction, ou une macro assembleur. Les lecteurs qui ne sont pas familiarisés avec l'ASM et le langage machine devront trouver un cours pour combler leurs lacunes. Des connaissances en architecture des ordinateurs seront aussi utiles pour comprendre quelques passages, mais sans que cela soit obligatoire pour poursuivre la lecture jusqu'au bout.

Objectif et approche : ce cours vous apprendra ce qui se passe entre la compilation de votre code et son exécution. Son objectif est d'être un cours relativement théorique, sans mise en pratique, qui sert avant tout à développer votre culture informatique.

II - La chaîne d'assemblage

Tout ce qui se passe entre la compilation et l'exécution du programme est pris en charge par trois programmes qui forment ce qu'on appelle la chaîne d'assemblage :

- le logiciel d'assemblage qui traduit le code assembleur en code machine, mémorisé dans un fichier objet ;

- l'éditeur de liens, ou linker, qui combine plusieurs fichiers objets en exécutable ;

- et le chargeur, aussi appelé loader, qui charge les programmes en mémoire.

II-A - Résolution des symboles

Traduire les couples mnémoniques/opérandes en instructions-machine est la première tâche du logiciel d'assemblage, mais ce n'est pas la seule : la résolution des symboles transforme les labels (et autres symboles) en adresse mémoire (l'adresse de l'instruction cible d'un branchement, ou adresse d'une variable). Pour rappel, les symboles peuvent être classés en deux types :

- les symboles locaux, utilisés uniquement dans le module où ils sont définis : ils servent à fabriquer des fonctions, variables statiques et structures de contrôle, etc. ;

- les symboles globaux, référencés dans d'autres fichiers : ils servent pour nommer les fonctions accessibles à l'extérieur du module, et les variables globales.

II-B - Relocation

Sur les systèmes d'exploitation modernes, le programme ne sait pas à l'avance à quelle adresse il sera chargé : les adresses des données et instructions ne sont pas connues lors de l'assemblage. On est obligé de compiler en supposant que le début du fichier objet/exécutable est à l'adresse 0 : on obtient des adresses dites statiques, qui doivent être corrigées en additionnant l'adresse de début du programme. Cette correction d'adresses, la relocation, est prise en charge par la chaîne d'assemblage.

Pourquoi a-t-on besoin de corriger les adresses ?

Les contraintes d'alignement mémoire, la taille variable des instructions, et les modes d'adressages utilisés vont devoir être pris en compte lors de la relocation.

III - Le logiciel d'assemblage

Dans ce qui va suivre, nous ne parlerons pas des assembleurs de haut-niveau, qui gèrent les structures de contrôle, les fonctions et procédures, les types de données composites (structures et unions), voire la programmation orientée objet par classes, ceux-ci demandant d'ajouter une phase de compilation en assembleur « pur » à un logiciel d'assemblage plus rudimentaire. Nous ne parlerons pas non plus de la passe d'optimisation.

III-A - Macros

La première opération que va faire le logiciel d'assemblage est le remplacement [appel de macro → corps de la macro]. Ces macros sont sauvegardées dans une table de hachage qui associe un nom de macro à son corps : la table des macros. Cependant, les choses deviennent beaucoup plus compliquées quand les macros contiennent des arguments/paramètres, ou quand le logiciel d'assemblage utilise des macros imbriquées ou récursives.

III-A-1 - Deux passes

Pour effectuer ce remplacement, le logiciel d'assemblage peut y aller en deux étapes :

- la première parcourt le code assembleur pour détecter les macros, et exécuter certaines directives ;

- la seconde effectue le remplacement.

L'algorithme de la première passe est le suivant :

1. Lire une ligne de code dans le fichier source ;

2. Si cette ligne est une macro, créer une entrée dans la table des macros et copier les lignes de code suivantes dans celle-ci jusqu'à tomber sur le mot-clé qui indique la fin du corps de la macro, après quoi on retourne à l'étape 1 ;

3. Si cette ligne est une directive qui peut être gérée lors de la passe 0, l'exécuter, puis retourner à l'étape 1 ;

4. Si cette ligne indique la fin du code source, arrêter là.

Par la suite, le logiciel d'assemblage va effectuer le remplacement, qui ne peut pas se faire « en place » : le logiciel d'assemblage doit copier le fichier source dans un nouveau fichier. L'algorithme utilisé ressemble à celui-ci :

1. Lire la ligne de code suivante ;

2. Si cette ligne n'est pas un nom de macro, copier celle-ci dans le nouveau fichier source (sauf s'il s'agit de la ligne qui indique la fin du programme) ;

3. Sinon, accéder à la table des macros et copier le corps de la macro ligne par ligne dans le nouveau fichier.

III-A-2 - Une passe

Certains assembleurs forcent la déclaration des macros en début de fichier, juste avant le code principal. Dans ce cas, les macros sont toutes déclarées avant leur utilisation, ce qui permet de fusionner ces deux passes en une seule : c'est la passe 0.

1. Lire une ligne de code dans le fichier source ;

2. Si cette ligne est un nom de macro, créer une entrée dans la table des macros, et copier les lignes de code suivantes dans celle-ci jusqu'à tomber sur le mot-clé qui indique la fin du corps de la macro : à ce moment-là, on retourne à l'étape 1 ;

3. Si cette ligne est une directive qui peut être gérée lors de la passe 0, l'exécuter, puis retourner à l'étape 1 ;

4. Si cette ligne est une instruction-machine, copier celle-ci dans le nouveau fichier source (sauf s'il s'agit de la ligne qui indique la fin du programme) ;

5. Sinon, accéder à la table des macros et copier le corps de la macro ligne par ligne dans le nouveau fichier.

6. Si cette ligne indique la fin du code source, arrêter là.

III-B - Mnémoniques

Le logiciel d'assemblage traduit chaque ligne de code l'une après l'autre. Cela demande de :

- remplacer la mnémonique par l'opcode correspondant ;

- remplacer les opérandes par la suite de bits correspondante ;

- remplacer les labels par l'adresse qui correspond ;

- ne rien faire sur les commentaires.

Pour traduire les mnémoniques en opcode, le logiciel d'assemblage contient une table de hachage mnémonique → opcode : la table des opcodes. Cette table associe à chaque mnémonique :

- l'opcode qui correspond ;

- éventuellement la taille de l'opcode pour les architectures à instructions de taille variable ;

- et potentiellement d'autres informations.

Il se peut qu'une mnémonique corresponde à plusieurs instructions-machine, suivant le mode d'adressage. Dans ce cas, chaque entrée de la table des mnémoniques contient plusieurs opcodes, le choix de l'opcode étant fait selon le mode d'adressage. Identifier le mode d'adressage est relativement simple : une simple expression régulière sur la ligne de code suffit.

Certaines mnémoniques sont des cas particuliers d'instructions-machine plus générales : on parle de pseudo-instructions. Par exemple, sur les processeurs assez anciens qui utilisent le jeu d'instruction MIPS, l'instruction MOV r1 → r2 est synthétisée à partir de l'instruction suivante :

add r1, r0, r2

Dans ces conditions, la table des mnémoniques ne contient pas l'opcode de l'instruction, mais littéralement une bonne partie de la représentation binaire de l'instruction, avec des opérandes (ici, des noms de registres) déjà spécifiés. Un champ pseudo-instruction est ajouté dans la table des opcodes pour savoir si seul l'opcode doit être remplacé ou non.

III-C - Symboles locaux

Pour traduire les symboles locaux, le logiciel d'assemblage utilise une table des symboles, qui mémorise les correspondances entre un symbole (un label le plus souvent) et sa valeur/adresse. Pour chaque symbole, cette table mémorise :

- son nom ;

- sa valeur ;

- son type ;

- et éventuellement d'autres informations.

Cette table des symboles est sauvegardée dans le fichier objet, vu qu'elle servira par la suite pour la relocation au niveau du linker et du loader.

III-C-1 - Compteur de ligne

Pour remplir la table des symboles, le logiciel d'assemblage doit savoir quelle est l'adresse de l'instruction qu'il est en train de traduire. Pour cela, il suffit de profiter du fait que le logiciel d'assemblage traduit une ligne de code à la fois : le logiciel d'assemblage utilise un simple compteur d'adresse, mis à zéro avant le démarrage de la traduction, qui est incrémenté de la taille de l'instruction après chaque traduction.

III-C-2 - Remplissage de la table des symboles

Il se peut que la valeur d'un symbole soit définie quelques lignes plus bas. Le cas le plus classique est celui des branchements vers une ligne de code située plus loin dans le programme : l'instruction qui utilise ce label ne peut être totalement traduite qu'une fois que la valeur du label est connue. Pour résoudre ce problème, il existe deux méthodes : la première correspond à celle des logiciels d'assemblage à deux passes, et l'autre à celle des logiciels d'assemblage à une passe.

Les assembleurs à deux passes sont les plus simples à comprendre : le logiciel d'assemblage parcourt une première fois le code ASM pour remplir la table des symboles, et repasse une seconde fois pour traduire les instructions. Ces assembleurs font face à un léger problème sur les processeurs dont les instructions sont de taille variable : lors de la première passe, le logiciel d'assemblage doit déterminer la taille des instructions qu'il ne traduit pas, afin de mettre à jour le compteur d'adresse. Et cela demande d'analyser les opérandes et de déterminer le mode d'adressage de l'instruction.

Les assembleurs à une passe fonctionnent autrement : ceux-ci vont passer une seule fois sur le code source. La table des symboles de ces assembleurs contient non seulement :

- le nom du symbole ;

- sa valeur ;

- un bit qui indique si la valeur du symbole est connue ;

- et une liste d'attente, qui contient les adresses des instructions déjà rencontrées qui ont besoin de la valeur en question.

Quand ils rencontrent une référence dont la valeur n'est pas connue, ils ajoutent le symbole à la table des symboles et l'adresse de l'instruction dans la liste, et poursuivent le parcours du code source. S'ils rencontrent une instruction qui utilise ce symbole à valeur inconnue, l'adresse de l'instruction est ajoutée à la liste d'attente. Quand la valeur est enfin connue, le bit de la table de symboles est mis à 0 (pour indiquer que la valeur est connue), et le logiciel d'assemblage met à jour les instructions mémorisées dans la liste d'attente.

Sur certains processeurs à instructions de taille variable, la taille de cette instruction n'est donc pas connue tant que la valeur du symbole est connue : on ne sait pas si l'adresse correspondant au symbole fait 8 ou 16 bits. Et cela pose des problèmes pour les assembleurs à une passe.

III-D - Noms de registres et autres constantes

On pourrait penser qu'il existe une table de correspondance pour les noms de registres, comme la table de mnémoniques pour les mnémoniques, mais on peut ruser en considérant les noms de registres comme des symboles comme les autres : il suffit de remplir la table des symboles avec les correspondances nom de registre → représentation binaire, et le tour est joué. On peut utiliser le même principe pour gérer les préfixes des instructions, comme sur le jeu d'instruction x86.

IV - L'édition des liens

Quand les programmes consistaient en un seul fichier assembleur de plusieurs milliers de lignes de code, il était possible de les traduire en langage machine et de les lancer directement. Le logiciel d'assemblage était un assemble-go loader, un assembleur à une passe qui écrivait le code machine directement en RAM.

De nos jours, les programmes actuels sont composés de plusieurs modules ou classes, qui sont traduits chacun de leur côté en fichier objet. Les symboles globaux, qui référencent une entité déclarée dans un autre fichier objet, ne sont pas traduits en adresses par le logiciel d'assemblage. Pour obtenir un fichier exécutable, il faut combiner plusieurs fichiers objets en un seul fichier exécutable, et traduire les symboles globaux en adresses : c'est le but de la phase d'édition des liens, effectuée par un éditeur de liens, ou linker.

IV-A - Création d'un exécutable

En l'absence de segmentation mémoire (une forme de mémoire virtuelle), le linker concatène les fichiers objets.

Relocation au niveau du linker

Avec la segmentation, chaque fichier objet a ses propres segments de code et de données, qui doivent être fusionnés.

Segmentation et édition des liens

Pour faire son travail, le linker doit savoir où commence chaque segment dans le fichier objet, son type (code, données, etc.), et sa taille. Ces informations sont mémorisées dans le fichier objet, dans une table de segments, créée par le logiciel d'assemblage.

De plus, le linker doit rajouter des vides dans l'exécutable pour des raisons d'alignement au niveau des pages mémoire.

IV-B - Relocation

Combiner plusieurs fichiers objets en exécutable va poser un problème similaire à celui obtenu lors du lancement d'un programme en mémoire : les adresses calculées en supposant que le fichier objet commence à l'adresse 0 sont fausses. Pour corriger celles-ci, le linker doit effectuer une relocation, et considère que le début du fichier exécutable est à l'adresse 0. Pour cela, le linker doit connaître l'emplacement des adresses dans le fichier objet, informations qui sont mémorisées dans la table des symboles sauvegardée dans le fichier objet.

IV-C - Résolution des symboles globaux

De plus, le linker s'occupe de la résolution des symboles globaux. En effet, quand on fait appel à une fonction ou variable localisée dans un autre fichier objet, la position de celle-ci dans le fichier exécutable (et donc son adresse) n'est pas définie avant son placement dans le fichier exécutable : la table des symboles contient des vides. Une fois la fusion des fichiers objets opérée, on connaît les adresses de ces symboles globaux, qui peuvent être traduits : le linker doit donc mettre à jour ces adresses dans le code machine (c'est pour cela qu'on parle d'édition des liens).

V - Les chargeurs de programme

Au démarrage d'un programme, un morceau du système d'exploitation, le chargeur de programme ou loader, rapatrie le fichier exécutable en RAM et l'exécute. Sur les systèmes sans mémoire virtuelle, le loader vérifie s'il y a suffisamment de mémoire pour charger le programme. Pour cela, il a besoin de savoir quelle est la taille du code machine et de la mémoire statique, ces informations étant mémorisées dans l'exécutable par le linker.

V-A - Loaders absolus

Sur les systèmes d'exploitation anciens, il était impossible d'exécuter plusieurs programmes en même temps. Ces systèmes d'exploitation découpaient la mémoire en deux parties :

- une portion de taille fixe, réservée au système d'exploitation ;

- et le reste de la mémoire, réservé au programme à exécuter.

Technique du moniteur résident

Avec cette technique, l'adresse du début du programme est toujours la même : les loaders de ces systèmes d'exploitation n'ont pas à gérer la relocation, d'où leur nom de loaders absolus.

Les fichiers objets/exécutables de tels systèmes d'exploitation ne contenaient que le code machine : on appelle ces fichiers des Flat Binary. On peut citer l'exemple des fichiers .COM, utilisés par le système d'exploitation MS-DOS (le système d'exploitation des PC avant que Windows ne prenne la relève).

V-B - Loaders à relocation

Avec le temps, les systèmes d'exploitation sont devenus capables de faire résider plusieurs programmes en même temps dans la RAM de l'ordinateur. Chaque programme recevait un bloc de RAM pour lui tout seul, auquel on donnait le doux nom de partition mémoire. Suivant l'ordre dans lequel on charge les programmes en RAM, les partitions ne sont pas placées aux mêmes endroits : les programmes ne commencent pas toujours à la même adresse, celle-ci pouvant varier à chaque exécution. En conséquence, le loader devait s'occuper de la relocation.

Allocation de la mémoire d'un ordinateur multiprogramme

Le loader doit donc savoir où sont les adresses dans le fichier exécutable, histoire de les « relocaliser ». Pour cela, il se base sur une table de relocalisation qui indique la localisation des adresses dans le fichier exécutable. Cette table est généralement une copie de la table des symboles, à quelques différences près : pour chaque label, on a ajouté un bit qui indique si l'adresse correspondante doit être relocalisée. Les assembleurs à deux passes peuvent produire une telle table sans aucune modification, ce qui n'est pas le cas des assembleurs habituels à une seule passe.

V-B-1 - Multiprogrammation et relocation

Avec la multiprogrammation, il est devenu possible de démarrer plusieurs copies d'un même programme simultanément. Dans ce cas, il est possible de partager certains segments du code machine entre les différentes copies du programme. Pour cela, le loader doit savoir où se situent le code machine et les données statiques dans le fichier exécutable, ce qui demande de sauvegarder ces informations dans le fichier exécutable. Ces informations sont mémorisées dans un en-tête, qui mémorise la position et la taille des autres segments dans le fichier (avec souvent d'autres informations).

V-B-2 - Comment se passer de relocation ?

Sur certains processeurs, la relocation est totalement gérée en matériel, à l'intérieur du processeur. C'est notamment le cas pour les ordinateurs qui utilisent la mémoire virtuelle. Sur ces ordinateurs, les loaders ne font que modifier la table des pages pour faire croire que le fichier exécutable est « swappé » sur le disque dur : le chargement du programme se fera à la demande, quand un chargement d'instruction déclenchera un défaut de page (page miss).

Certains processeurs ont tenté d'être encore plus novateurs, en se payant le luxe de supprimer aussi l'étape d'édition des liens. Sur ces processeurs, l'attribution d'une adresse à un objet est déterminée au cours de l'exécution du programme, directement au niveau matériel. Mais ces architectures, dites à arithmétique à zéro adresse (Zero address arithmetic), sont cependant très rares.

V-C - Loaders à édition des liens dynamique

Les librairies partagées sont des librairies mises en commun entre plusieurs programmes : il n'existe qu'une seule copie en mémoire, que les programmes peuvent utiliser au besoin. Les librairies statiques ont toujours la même place en mémoire physique : pas besoin de relocation ou de résoudre les labels qui pointent vers cette librairie. Avec les librairies dynamiques, la position de la librairie en mémoire varie à chaque démarrage de la machine : il faut faire la relocation, et résoudre les labels/symboles de la librairie.

Dans certains cas, l'édition des liens s'effectue dès le démarrage du programme, qui doit indiquer les librairies dont il a besoin. Mais dans d'autres cas, le programme charge les librairies à la demande, en appelant le loader via une interruption spéciale : il faut alors passer le nom de la librairie à exécuter en paramètre de l'interruption.

VI - Pour aller plus loin

- Livre Assemblers and loaders, disponible gratuitement via le lien ;

- Livre Linkers and Loaders de John R. Levine, publié par by Morgan-Kauffman, ISBN 1-55860-496-0.