image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

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 :

  1. L'analyse lexicale : le compilateur découpe le code source en lexèmes élémentaires (mots-clés du langages, types, identificateurs, littéraux...)
  2. 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
  3. 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
  4. 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)
  5. 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

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 :

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) :

  1. On compile d'abord les modules classiquement en fichiers objets .o
  2. On ajoute ces modules objets avec la commande ar dans une archive : ar rvs mylib.a mymodule1.o mymodule2.o
  3. 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 :

Graphe

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 :

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 :

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.

  1. 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.