Programmation système 01 – Mécanique de compilation et d'édition des liens

Ce document a été écrit au début des années 2000. Son contenu est valide, mais ne reflète pas nécessairement toutes les subtilités de la programmation contemporaine. Conséquemment, lisez-le d'un oeil éveillé.

Ce document vise à décrire ce que signifie programmer au sens de la mécanique de la programmation en tant que tel. Nous y examinerons :

Mécanique de la compilation C++

Ce qu'on nomme compilation, par souci de simplicité, est souvent un abus de langage, du fait que nous tendons à y regrouper la combinaison de plusieurs actions connexes incluant la compilation à proprement dit.

Idée de projet

Résumé : un(e) informaticien(ne) développe un ou plusieurs algorithmes qu'il/ elle traduit à l'aide d'un langage de programmation donné. L'objectif du code ainsi produit est d'en faire un exécutable, soit un automatisme permettant de résoudre un problème ou une classe de problèmes, ou encore d'en faire une bibliothèque susceptible d'être utilisée par des programmes.

Dans ce document ,j'utiliserai exécutable par abus de langage pour éviter d'alourdir le propos, mais l'essentiel de ce qui sera discuté ici s'appliquera aussi aux bibliothèques.

Dans nos exemples plus bas, nous présumerons un projet nommé UnProjet qui inclura les fichiers sources octets.cpp, bits.cpp et UnProjet.cpp, et qui aura pour but de générer l'exécutable UnProjet.exe.

On pourrait donc dire, à la limite, que le projet du projet UnProjet est la génération de l'exécutable UnProjet.exe.

Commençons par quelques définitions :

Puisque la programmation C++ a entre autres pour but de générer des exécutables, il est de mise d'y construire des projets. Au minimum, tous les fichiers sources utilisés (donc contenant du code dont le programme a besoin – il y a une nuance à apporter au sujet des bibliothèques à liens dynamiques, mais nous ne nous en occuperons pas pour le moment – devront y figurer.

Les fichiers essentiels à la génération d'un exécutable sont les suivants :

Question piège

Combien d'exécutables la compilation d'un projet générera-t-elle?

La compilation C++

Il y a au moins deux manières, grossièrement, de prendre du code source et d'en faire un exécutable : le compiler et l'interpréter. Dans ce document, nous allons nous intéresser à la mécanique de la compilation, spécialement pour le langage C++.

Le code machine produit par la compilation n'est pas un exécutable, mais y ressemble beaucoup. On dit de ce code que c'est du code objet (portant souvent l'extension .obj).

On compile donc du code source pour générer du code objet, mais la compilation en soi n'est pas suffisante pour générer un exécutable. Dans le schéma ci-dessus, on remarque qu'il y a une phase précédant la compilation en langage C++ (tout comme en langage C). C'est la phase de précompilation, qui repose en bonne partie sur l'action du préprocesseur.

Question piège

Peut-on compiler un fichier d'en-tête (.h)?

Que compile-t-on?

On ne compile, en C++, que des fichiers sources. Les fichiers d'en-tête servent de support à la compilation, mais ne sont pas compilés.

On inclut un fichier pour éviter la redondance, et ainsi réduire les risques d'erreur.

On inclut un fichier parce qu'on veut réutiliser le code, et ainsi économiser temps et effort en évitant de réinventer la roue.

Ainsi, pour le projet UnProjet en exemple ici, les fichiers bits.cpp, octets.cpp et UnProjet.cpp seront compilés, mais pas les fichiers bits.h et octets.h. Toutefois, ces derniers seront inclus dans des fichiers sourced pour que ceux-ci aient tout le nécessaire pour être correctement compilés.

Dans notre exemple, on présumera avoir écrit des fonctions capables de manipuler des bits et des octets (respectivement dans bits.cpp et dans octets.cpp). Le module UnProjet.cpp comptera sur leur existence pour sa propre exécution.

Par exemple, imaginons un fichier bits.h contenant les déclarations visibles à droite. Portez surtout attention au prototype de fonction; si les alias de types vous préoccupent, je vous invite à lire cet article en notant que j'utilise ici using, qui remplace avantageusement typedef en C++ depuis C++ 11.

Dans ce cas, tout fichier ayant la directive suivante...

#include "bits.h"

...inclura automatiquement le prototype en question.

using mot_t = unsigned short;
using posbit_t = unsigned char;
bool lire_bit(mot_t mot, posbit_t position);

Ceci signifie que tous les fichiers source connaissant ce prototype savent comment appeler une fonction appelée lire_bit(), retournant un bool et prenant deux paramètres (un mot_t, ce qui équivaut à un unsigned short , et un posbit_t, ce qui équivaut à un unsigned char).

Inclure le prototype d'un sous-programme n'est pas la même chose qu'inclure sa définition (son corps).

Le lien entre la fonction appelée et le code associé, lors de l'appel de code se trouvant dans un autre fichier objet, se fera lors de l'édition des liens.

Prototypes de sous-programmes

Le prototype d'un sous-programme indique au compilateur quelle est la signature de ce sous-programme. Muni de cette information, le compilateur est en mesure de valider toute utilisation faite du sous-programme en question et de distinguer les sous-programmes les uns des autres.

En programmation orientée objet, lorsque le sous-programme est une méthode d'instance, la signature peut aussi inclure des qualifications const et volatile, nommées en jargon technique les qualifications cv.

De manière non portable, certains compilateurs incluent aussi des conventions d'appel dans la signature des sous-programmes.

Un prototype de sous-programme déclare son nom, la description (type et – optionnellement – nom) de chacun de ses paramètres, et son type, aussi appelé type de sa valeur de retour. De manière optionnelle, le prototype peut aussi lister les exceptions que le sous-programme est susceptible de lever.

On dit de deux sous-programmes qu'ils sont différents l'un de l'autre s'ils diffèrent en signature.

En langage C, qui est le lingua franca des programmeuses et des programmeurs, deux sous-programmes ne sont considérés différents que s'ils diffèrent en nom. Cette caractéristique a un grand mérite : la simplicité.

En général, si vous désirez exposer un sous-programme à des programmes écrits dans d'autres langages, les différencier par leur nom est une sage pratique. À peu près tous les langages de programmation sur la planète savent interagir avec du code C, ce qui ouvre énormément de portes à qui en respecte les règles.

Évidemment, je ne vous recommande pas de programmer en C seulement et de vous limiter en totalité à ce que C est en mesure de digérer, mais bien de retenir que, pour les morceaux de vos programmes qui ont à interagir avec le monde extérieur, respecter les règles de C est, en général, une bonne idée.

À la base, cela implique que les deux sous-programmes diffèrent en nom, en nombre de paramètres ou en type de paramètres. Dans le cas des méthodes d'instance, il faut ajouter les qualifications const et volatile à la liste des facteurs permettant de distinguer un sous-programme d'un autre.

Question piège

Quelles sont les fonctions considérées différentes de toutes les autres par un compilateur C++ dans ce qui suit?

int f(int);
int g(int);
int g(int, int);
void g(int, int);

Une règle à retenir

En fait, ce qui importe le plus est l'introduction du nom dans le programme avant son utilisation. C'est d'ailleurs l'une des choses qui explique que la notation de C++ pour les struct et les enum soit nettement supérieure à celle du langage C : elle introduit les noms plus tôt dans un programme.

Le langage C++ nécessite qu'on déclare chaque objet avant de s'en servir, peu importe que cet objet soit un type, une constante, une variable, une fonction, etc..

Parfois, un sous-programme ne peut être défini avant son appel. Parfois aussi on aimerait utiliser un sous-programme qui sera défini dans un autre module que celui où il est utilisé (ce qui est extrêmement commun dans les bibliothèques). Dans ces cas, le compilateur C++ acceptera que la définition du sous-programme en question vienne après son utilisation, dans la mesure où il en connaît déjà le prototype.

Le compilateur, en fait, valide la légalité des appels de sous-programmes. Il vérifie les noms et les types mais ne valide pas l'existence effective des sous-programmes invoqués. Il est possible qu'un appel de sous-programme compile (parce que le compilateur en connaît le prototype) mais que l'exécutable ne puisse être généré du fait que le code objet du sous-programme invoqué n'est pas accessible lors de l'édition des liens.

Déclarer un prototype permet au compilateur de reconnaître l'existence de la signature d'un sous-programme avant que celui-ci ne soit défini. Ainsi, sans savoir exactement ce que le sous-programme en question exécutera comme séquence d'instructions, le compilateur peut en générer le schéma d'appel.

Le code à droite est une définition de sous-programme, du fait qu'on en voit à la fois la signature et le corps, soit le code qui sera exécuté lors d'un appel de ce sous-programme

// additionner(a,b) : retourne la somme de a et b
double additionner(double a, double b) {
   return a + b;
}

Pris isolément, le prototype lui-même de cette fonction serait l'une ou l'autre des deux formulations proposées à droite, au choix (les noms des paramètres dans le prototype jouent un rôle strictement documentaire, et peuvent être omis au sens du compilateur (si vous estimez que documenter le rôle des paramètres est une bonne chose, alors faites-le!

Le prototype d'un sous-programme offre donc le minimum nécessaire au compilateur pour que celui-ci puisse générer des appels valides à ce sous-programme sans avoir à connaître son contenu précis.

double additionner(double a, double b); // ou
double additionner(double, double);

En fait, la situation est plus subtile. Il est légal pour une définition de sous-programme de ne pas nommer certains de ses paramètres dans la mesure où ceux-ci ne sont pas utilisés. Il arrive, pour diverses raisons, que l'on souhaite réserver des paramètres pour usage futur; ne pas les nommer pour la période où ils ne sont pas encore utilisés évite des avertissements de la part du compilateur.

Le rôle du prototype d'un sous-programme est de suppléer à l'absence de sa définition par l'essentiel de sa signature.

Le prototype devra posséder (cela va de soi) la même structure que l'en-tête de la définition du sous-programme, à la différence que la déclaration ne possède pas de corps et puisqu'il s'agit d'une instruction elle doit se terminer par un point virgule (;). Évidemment, là où les noms des paramètres sont optionnels dans le prototype, ils sont obligatoires dans la définition.

La syntaxe de la déclaration d'un prototype de sous-programme va comme suit.

type nom ([type0 [nom0], type1 [nom1], ... typeN [nomN]]);
Question piège

Quels sont les prototypes de sous-programmes valides en C++ dans ce qui suit?

int f(int a);
int g(int);
int g(int, int)
void g(int &a, int b) {
   a = b;
};

En C++, il est obligatoire de déclarer un sous-programme (donc d'indiquer sa définition ou au moins son prototype) avant de tenter de l'invoquer.

Certains compilateurs permettent parfois, par souci de compatibilité avec le code existant, de contourner cette obligation à l'aide d'options de compilation. Toutefois, les prototypes sont d'une utilité indéniable. En plus de clarifier le code en tant que tel, ils permettent d'éviter des erreurs fortuites dans l'exécution du code généré.

Il faut comprendre que C permet de négliger les prototypes, et même de négliger d'indiquer certains types (dont le type des valeurs retournées par les sous-programmes). Cela dit, en C « traditionnel », lorsqu'un prototype est manquant, le compilateur présume que tous les appels sont valides, que les paramètres sont des int et que le sous-programme est une fonction de type int (on parle de la règle du implicit int).

Cette simplification apparente est très souvent source de bogues plutôt que source de simplification du travail de programmation. En C++, aucune présomption du genre n'est permise.

Le compilateur

Une fois que le préprocesseur aura terminé son travail, le compilateur cherchera à traduire le code source en code objet.

Pour ce faire, il accomplira trois types de validation :

Dans les trois cas, il produira des erreurs s'il n'est pas en mesure de générer du code objet étant donné le code source qu'on lui demande de traduire, et produira des avertissements s'il note des opéraitons à première vue douteuses mais ayant un certain risque de fonctionner.

Une fois rendu à la compilation à proprement dit, il n'y a plus qu'un fichier source à traduire en code objet. Les fichiers d'en-tête inclus en chemin se sont fondus dans le code source en traitement.

Le fichier source en cours de traitement est ce qu'on nomme une unité de traduction (Translation Unit).

Ce qui manque au code objet pour en faire un exécutable

Le code objet contient le fruit de la compilation, mais n'est pas tout à fait complet. Il lui manque plusieurs petites choses pour pouvoir être transformé en exécutable. La première chose qui lui manque, d'ailleurs, est le code requis pour exécuter correctement les fonctions qui y sont invoquées mais qui sont définies dans d'autres modules.

Par exemple, si UnProjet.cpp contient un appel à la fonction lire_bit() définie dans bits.cpp, alors la compilation de UnProjet.cpp en UnProjet.obj aura été rendue possible du fait que le prototype de la fonction se trouve dans bits.h, que UnProjet.cpp incluait (sagement).

Par contre, du fait que le prototype d'une fonction se résume à sa signature, pas à son corps, il faut (éventuellement) que le code (compilé dans bits.obj, puisque la définition se trouvait dans bits.cpp) de cette fonction soit liée au code objet se trouvant dans UnProjet.obj.

L'éditeur de liens (Linker)

Un programme spécial se charge généralement des résolutions externes requises pour passer du code objet à un module qui sera exécutable.

L'éditeur de liens cherchera à résoudre les liens externes à chaque module objet (chaque fichier .obj), dans le but d'en arriver à un exécutable cohérent.

Ainsi, il pourra rencontrer certaines situations l'empêchant de réaliser sa tâche, la plupart du temps parce qu'il sera incapable de faire un choix entre plusieurs entités équivalentes ou parce qu'un sous-programme invoqué dans l'un des modules à lier dans l'exécutable en cours de génération ne semble être présent dans aucun autre.

Quelques exemples classiques de problèmes rencontrés lors de l'étape d'édition des liens :

Si l'éditeur de liens parvient à ses fins, alors un exécutable pourra être généré. Il ne reste plus qu'à regarder tout ça en détail.

Voici donc un schéma global du processus de compilation, tel que nous l'avons vu jusqu'ici.

Lectures complémentaires

Quelques liens pour enrichir votre compréhension.


Valid XHTML 1.0 Transitional

CSS Valide !