C et C++ sont des langages permettant l'obtention de programmes exécutables en code natif pour pratiquement toutes les architectures matérielles existantes. Un programme peut être compilé pour la même architecture cible que celle de la machine de compilation ou alors pour une architecture différente (compilation croisée ou crosscompilation).
Compilation des sources
Principales étapes de compilation
Le compilateur C++ fait appel préalablement au préprocesseur pour transformer les sources dans un format brut compilable comprenant la définition de tous les types et fonctions utilisés. Après la phase de preprocessing, voici les principales étapes de la compilation :
- L'analyse lexicale : le compilateur découpe le code source en lexèmes élémentaires (mots-clés du langages, types, identificateurs, littéraux...)
- L'analyse syntaxique : en utilisant une grammaire du langage, le compilateur analyse la syntaxe et génère un arbre syntaxique représentant le code-source
- L'analyse sémantique : le compilateur vérifie si les types et identificateurs sont bien déclarés et si l'usage des types est bien cohérent ; il peut également trouver des erreurs triviales de programmation qui peuvent générer erreurs fatales de compilation ou avertissements
- La génération de code assembleur : à partir de l'arbre de syntaxe, le compilateur génère un flot d'instructions exécutable par le microprocesseur cible ; diverses optimisations peuvent être mises en œuvres afin d'accélérer l'exécution du code (comme l'utilisation efficiente des registres par coloration de graphe)
- La traduction en code binaire : il s'agit en dernière étape de traduire le code assembleur en code binaire directement interprétable par le microprocesseur
La conception d'un compilateur C++ est un travail assez ardu, C++ étant probablement l'un des langages les plus complexes car intégrant de nombreuses constructions syntaxiques avec quelquefois des cas d'ambiguïté.
Les compilateurs
Il existe de nombreux compilateurs C++. Nous en citons ici quelques uns :
- GCC (GNU Compiler Collection) : il s'agit d'un ensemble d'outils Open Source analysant plusieurs langages (C, C++, Objective C, Ada, Go, Fortran...) et supportant une multitude d'architectures cibles. GCC autorise notamment la compilation croisée. GCC est disponible sous la plupart des systèmes de type Unix (comme Linux) mais également sous Windows en utilisant l'environnement Cygwin.
- Clang : c'est la principale alternative au compilateur C++ de GCC ; il s'intègre au sein du projet plus global LLVM (framework de développement de compilateur) pour générer une représentation compilée intermédiaire traduisible ensuite sur différentes architectures. Clang, de conception plus récente, possède une architecture plus modulaire que GCC et permet d'obtenir des programmes offrant des performances plus ou moins similaires à GCC. Clang est plus adapté à la compilation incrémentale que GCC.
- Intel C++ Compiler : ce compilateur propriétaire (et gratuit) est développé par Intel pour la compilation à destination de ses propres processeurs (de type x86) en utilisant certaines optimisations maison.
- Visual C++ Compiler : ce compilateur propriétaire pour Windows accompagne l'environnement de développement Visual Studio de Microsoft
GCC
Nous présentons ici une introduction à l'utilisation de GCC en ligne de commande.
La première étape consiste à générer des fichiers dits modules objets (contenant le code compilé binaire) à partir des sources en C++. Le compilateur se charge de réaliser toutes les étapes de compilation précédemment décrites.
Ainsi, pour transformer un fichier main.cpp en main.o, nous utilisons la commande :
g++ -Wall -c main.cpp
Si tout se passe bien, un fichier main.o est généré. Mais il est possible qu'il y ait des soucis lors de la compilation. Dans ce cas le compilateur affiche deux types de messages :
- Des avertissements (warnings) signalant un problème plus ou moins important qui n'empêche pas la compilation mais qui est susceptible de poser des soucis (perte de précision lors d'une conversion, code non accessible, code pouvant présenter une ambiguïté, code absurde...)
- Des erreurs qui elles sont bloquantes et empêchent la production du fichier .o. Il peut s'agit d'erreurs de syntaxe (lexique ou grammaire du langage non respectés) ou des erreurs à un niveau sémantique telle que l'utilisation d'une variable non-déclarée, une incohérence concernant le typage, la modification d'un objet déclaré constant...)
Quelquefois les erreurs et avertissements peuvent être un peu cryptiques : une petite erreur à un certain endroit (particulièrement de syntaxe) peut entraîner une avalanche d'erreurs dans le reste du fichier.
Il est possible d'indiquer des options de compilation. Ici -Wall permet d'activer le report de tous les avertissements ce qui est une bonne pratique ; il est possible aussi d'activer des catégories d'avertissement individuellement (-Wreorder pour vérifier l'ordre des affectations dans les constructeurs, -Wunused-variable pour signaler les variables non-utilisées, -Wextra permet des vérifications supplémentaires non gérées par -Wall). Si on est faché avec les warnings, on utilisera l'option -w pour les désactiver[1]
Il existe plus d'un milliers d'options de compilation utilisables pour gcc : personne ne peut prétendre toutes les connaître ; la consultation de la page de manuel avec man g++ s'impose pour en savoir plus lorsque des besoins spécifiques sont requis. L'option -c est indispensable pour indiquer que l'on souhaite compiler le module.
Parmi ces options -std=X est très utile car elle permet d'indiquer quelle version du langage C ou C++ nous utilisons. Par exemple, si on emploie du C++ version 2017, on utilisera -std=c++17. Si on utilise le bon vieux standard ANSI C, on utilisera -std=c89.
L'option -g s'avère indispensable lors de l'étape de débuggage du programme : elle intègre des données pour faciliter ce travail en étiquetant le code objet avec des numéros de ligne renvoyant vers le source. On pourra ensuite plus facilement utiliser un debugger tel que gdb.
Après voir compilé le module main, nous pouvons éventuellement compiler d'autre modules en fichiers .o si notre programme le requiert. C'est souvent le cas car à part pour des petits programmes, il est conseillé de séparer les différents aspects de notre programme en différents modules de taille raisonnable pour faciliter sa maintenance.
Assemblage des modules objets en exécutable
Une fois les fichiers .o de tous les modules obtenus par compilation, nous les assemblons pour réaliser un exécutable sous la forme d'un fichier unique : il s'agit de l'édition de liens. g++ intègre un éditeur de liens qui fusionne les fichiers .o et lie les déclarations de variables à la définition du type qu'elles utilisent ou les appels de méthodes aux méthodes qu'elle exécutent. L'édition de liens peut échouer si on oublie de fournir un module nécessaire avec l'implantation d'une méthode que l'on utilise. L'éditeur réalise aussi des optimisations en ne conservant que les méthodes et définitions de types réellement utilisés.
Normalement un seul de ces modules doit contenir une unique fonction main qui est la porte d'entrée du programme ; si ce n'est pas le cas (aucun main ou au moins deux main), l'éditeur de liens protestera.
Réalisation de bibliothèques
Une bibliothèque est un fichier binaire utilisant un format spécifique qui lui permet d'être utilisé par d'autres programmes au moment de leur compilation (liaison statique) ou alors lors de l'exécution (chargement dynamique). On peut ainsi compiler une fois une bibliothèque composée d'un ou plusieurs modules ; elle pourra être ensuite utilisée par divers projets.
Pour la création d'une bibliothèque avec liaison statique (lors de la compilation) :
- On compile d'abord les modules classiquement en fichiers objets .o
- On ajoute ces modules objets avec la commande ar dans une archive : ar rvs mylib.a mymodule1.o mymodule2.o
- Il est possible d'utiliser la bibliothèque .a comme si c'était un module individuel lors de l'édition de liens : g++ mylib.a othermodule.o qui équivaut à g++ mymodule1.o mymodule2.o othermodule.o.
Pour la création d'une bibliothèque dynamique (sans liaison statique mais avec chargement à l'exécution), on utilisera :
g++ -shared -o generatedLib.so mymodule1.o mymodule2.o
Les bibliothèques dynamiques permettent de découper les programmes en différents morceaux, certains pouvant être réutilisables par des programmes différents et chargés uniquement lorsque la fonctionnalité apportée par la bibliothèque est utilisée. On peut concevoir des programmes modulaires avec des plugins les étendant. Toutefois cette flexibilité requiert une gestion attentive des bibliothèques dynamique dont dépend un programme afin de garder une cohérence des versions installées.
Automatisation de la chaîne de compilation avec make
Compiler un programme nécessitant un ou deux modules peut facilement être réalisé en entrant manuellement des lignes de commande. Mais si le projet s'étoffe, compiler devient vite fastidieux. On cherche donc à réaliser un script permettant d'automatiser la création de l'exécutable de notre programme.
Lors du développement, on sera souvent amené à recompiler de nombreuses fois après avoir corrigé des petits détails sur un seul module (alors que notre projet peut en comporter de nombreux). Créér la nouvelle version de l'exécutable ne devrait alors nécessiter que la recompilation du module modifié, les autres modules restant inchangé. Ainsi la mission d'un outil d'automatisation de la compilation consiste à détecter les modules modifiés et à ne réaliser que les tâches de compilation strictement nécessaires.
L'outil d'automatisation de compilation le plus utilisé est make : il permet à l'aide d'un Makefile de construire (et reconstruire) un programme compilé. Un Makefile est constitué de règles qui indiquent tous les fichiers intermédiaires nécessaires pour parvenir à l'exécutable final en spécifiant comment ces fichiers peuvent être construits. On définit ainsi un graphe de dépendance entre les fichiers.
Prenons l'exemple du projet HelloWorld qui nécessite le module main et helloTeller:
// Fichier main.cpp #include "helloTeller.hpp" int main(int argc, char * argv[]) { HelloTeller ht(argv[0]); ht.sayHello(); }
// Fichier helloTeller.hpp #ifndef _HELLOTELLER_HPP class HelloTeller { private: const char * name; public: HelloTeller(char * name): name(name) {} void sayHello() const; }; #endif
// Fichier helloTeller.cpp #include "helloTeller.hpp" #include <iostream> using namespace std; void HelloTeller::sayHello() const { cout << "Hello " << name << "!" << endl; }
Voici le graphe de dépendance des fichiers en indiquant sur chaque arête la ligne de commande nécessaire pour la construction du fichier :
Pour chaque commande, il est possible de spécifier un fichier de sortie souhaité avec l'option -o outputFile ; par exemple g++ -o hello main.o helloTeller.o créé un exécutable nommé hello au lieu de la valeur par défaut a.out.
Nous pouvons écrire le fichier Makefile suivant pour automatiser la construction :
CXX = g++ CXXFLAGS = -Wall $(DEBUG) -std=c++17 -pthread INCLUDE = headers OBJS = main.o helloTeller.o LIBS = -lstdc++ HEADERS = headers/helloTeller.hpp TARGET = hello all: $(TARGET) $(CXX) $(CXXFLAGS) -I$(INCLUDE) -o $@ -c $< $(TARGET): $(OBJS) $(CXX) -o $(TARGET) $(OBJS) $(LIBS) clean: rm -f $(OBJS) $(TARGET)
Ce Makefile indique comment construire de façon générique un fichier .o à partir d'un fichier .cpp : la commande utilise les variables $< pour indiquer le chemin du fichier source et $@ pour désigner le fichier cible.
On définit en tête du fichier des variables que l'on pourra faire évoluer par la suite en fonction de nos besoins pour indiquer le compilateur, les options utilisées {flags), quels répertoires d'en-tête on souhaite inclure (de cette façon, lorsque l'on utilisera #include <foobar> dans le code-source, le compilateur recherchera le fichier foobar dans tous ces répertoires), les chemins des modules à compiler, les bibliothèques à utiliser et enfin le nom du fichier exécutable à générer.
On constate que les règles sont spécifiées sous la forme :
cible: dépendance1 dépendance2 ... commande1 à exécuter commande2 à executer ...
Dans l'exemple présenté, chaque règle n'utilise qu'une seule commande. La cible peut être un fichier à construire ou alors désigner une action tel que all. all est généralement la première action du Makefile : il s'agit de l'action par défaut exécutée lorsque l'on utilise la commande make. On peut également expliciter l'action : ainsi make clean effacera tous les fichiers compilés (bien sûr il ne faut pas effacer les fichiers source). La règle clean est dite phony (règle bidon) car elle n'a pas de dépendance : c'est l'utilisateur qui doit l'appeler quand il en a besoin.
Afin de connaître quelles actions réaliser, make génère le graphe de dépendance et réalise un tri topologique de ces nœuds afin de déterminer l'ordre d'enchaînement des actions. Certaines actions ne sont pas nécessaires lorsque des sources sont inchangées et que l'on a déjà une compilation antérieure. make se base sur la date de dernière modification des fichiers : par exemple s'il constate que le fichier helloteller.o a une date de modification plus tardive que helloteller.cpp et helloteller.hpp, il considérera inutile de le regénérer.
Génération automatique de Makefile
Générateurs automatiques
Un Makefile fourni à make représente une recette de compilation pour un environnement de développement spécifique (par exemple une machine sous Linux utilisant le compilateur GCC et disposant de certaones bibliothèques installées). Adapter ses Makefile pour différentes configurations peut rapidement devenir fastidieux.
Des outils existent pour faciliter la construction de projets C/C++ portables sous plusieurs environnements :
- autotools : il s'agit d'un jeu d'outils (constitués de programmes C, scripts shell et Modulo4) permettant de générer des fichiers Makefile. La structure du programme à compiler (emplacement des sources, bibliothèques...) est décrite dans un fichier Makefile.am (ainsi que Configure.in). On exécute ensuite autoconf qui génère un script shell configure à la racine du projet. Ce script est auoto-suffisant pour générer le Makefile final à partir de Makefile.am. Ainsi un utilisateur du programme souhaitant le compiler n'a pas besoin de tous les autotools mais uniquement du fichier configure généré ainsi que de GNU Make pour construire le projet à partir du Makefile généré par le script configure. L'inconvénient des autotools est qu'ils sont très liés à l'écosystème de compilation GNU avec l'utilisation de GCC et l'emploi d'un système d'exploitation de type Unix. L'usage sur un système Windows est possible mais requiert l'installation d'un environnement de type Unix comme Cygwin.
- CMake : il s'agit d'un générateur de fichiers de construction de projets conçu avec un souci de portabilité. Il s'accomode de la plupart des environnements de développement comme Eclipse CDT ou MS Visual Studio. Il peut aussi générer des Makefile utilisables avec GNU Make. La description du projet à construire se fait grâce à des fichiers CMakeLists.txt disposés dans chaque répertoire du projet. Nous allons privilégier l'utilisation de CMake par rapport aux autotools pour la suite de ce cours.
Utilisation de CMake
La description du projet à construire est fournie à CMake à l'aide d'un fichier nommé CMakeLists.txt placé à la racine du projet. Voici un exemple de fichier CMakeLists.txt minimal pour construire un exécutable à partir de deux fichiers cpp et un fichier hpp :
cmake_minimum_required (VERSION 2.8.11) project (HelloWorld) include_directories(headers) add_executable (hello src/main.cpp src/helloTeller.cpp headers/helloTeller.hpp)
On notera que l'on utilise la directive include_directories afin de spécifier le répertoire où sont stockés les fichiers d'en-tête.
La génération des fichiers de construction par CMake se réalise en se plaçant à la racine du projet et en exécutant la commande suivante :
cmake .
On remarquera que les fichiers et répertoires suivants sont générés automatiquement par CMake :
- CMakeCache.txt : ce fichier contient différentes variables de configuration indiquant notamment les chemins vers les différents outils utilisés pour la compilation ainsi que les options de compilation employées
- CMakeFiles/ : ce répertoire contient des fichiers temporaires utilisés pour réaliser la compilation
- cmake_install.cmake : il s'agit du script d'installation du programme ; il permet de réaliser une installation du programme sur le système (typiquement en copiant l'exécutable dans le répertoire /usr/local/bin pour un système Unix)
- Makefile : il s'agit du Makefile généré par CMake contenant le processus de compilation avec différentes règles
Normalement, nous ne devons pas être amenés à modifier ces fichiers automatiquement générés. En cas d'évolution de la structure du projet (rajout de nouveaux fichiers sources), on relancera cmake . qui mettra à jour les fichiers de construction.
Afin de construire le projet, on utilise le Makefile généré en lançant la commande make à la racine du projet.
CMake permet aussi de modulariser le projet en plusieurs sous-projets avec génération de bibliothèques. On pourra consulter ce tutoriel sur le site de CMake pour plus d'informations.
- Mais ce n'est pas une bonne idée car comme le dit un célèbre dicton, ce n'est pas en cassant le thermomètre que l'on arrêtera la fièvre.