🔝 Retour au Sommaire
Niveau : Débutant
Prérequis : Section 1.3 (Cycle de compilation), Section 2.5 (Premier programme)
Objectifs : Exécuter et comprendre chaque phase de la compilation de manière isolée, interpréter les fichiers intermédiaires produits, et savoir exploiter ces étapes pour diagnostiquer des problèmes.
Dans la section 2.5, nous avons présenté les quatre étapes de la compilation et exécuté chacune d'elles sur notre programme main.cpp. Ici, nous allons approfondir chaque étape, examiner en détail les fichiers intermédiaires produits, et montrer comment cette compréhension se traduit en compétences concrètes de diagnostic.
Nous travaillons avec le fichier main.cpp introduit en section 2.5 :
// main.cpp
#include <iostream>
#include <string>
#include <cmath>
#define APP_VERSION "1.0.0"
constexpr double PI = 3.14159265358979323846;
double aire_cercle(double rayon) {
return PI * rayon * rayon;
}
int main() {
std::string nom = "Ubuntu";
double rayon = 5.0;
double aire = aire_cercle(rayon);
std::cout << "Bienvenue sur " << nom << " !" << std::endl;
std::cout << "Version : " << APP_VERSION << std::endl;
std::cout << "Aire d'un cercle de rayon " << rayon
<< " = " << aire << std::endl;
std::cout << "Vérification avec cmath : sqrt(144) = "
<< std::sqrt(144.0) << std::endl;
return 0;
}Nous allons également utiliser un second fichier pour illustrer le cas multi-fichiers, qui est la réalité de tout projet au-delà du simple exercice. Créez mathutils.hpp et mathutils.cpp :
// mathutils.hpp
#ifndef MATHUTILS_HPP
#define MATHUTILS_HPP
constexpr double PI = 3.14159265358979323846;
double aire_cercle(double rayon);
double perimetre_cercle(double rayon);
#endif // MATHUTILS_HPP// mathutils.cpp
#include "mathutils.hpp"
double aire_cercle(double rayon) {
return PI * rayon * rayon;
}
double perimetre_cercle(double rayon) {
return 2.0 * PI * rayon;
}Et modifions main.cpp en conséquence :
// main.cpp (version multi-fichiers)
#include <iostream>
#include <string>
#include <cmath>
#include "mathutils.hpp"
#define APP_VERSION "1.0.0"
int main() {
std::string nom = "Ubuntu";
double rayon = 5.0;
std::cout << "Bienvenue sur " << nom << " !" << std::endl;
std::cout << "Version : " << APP_VERSION << std::endl;
std::cout << "Aire d'un cercle de rayon " << rayon
<< " = " << aire_cercle(rayon) << std::endl;
std::cout << "Périmètre : " << perimetre_cercle(rayon) << std::endl;
std::cout << "sqrt(144) = " << std::sqrt(144.0) << std::endl;
return 0;
}Le préprocesseur est le premier maillon de la chaîne. Ce n'est pas un compilateur à proprement parler : c'est un outil de transformation textuelle qui opère avant toute analyse syntaxique du C++. Il ne comprend pas le langage C++ — il manipule du texte brut selon ses propres directives.
Ses responsabilités sont les suivantes :
- Inclusion de fichiers : chaque
#includeest remplacé par le contenu intégral du fichier référencé, récursivement. - Expansion de macros : chaque occurrence d'un
#defineest substituée par sa valeur. - Compilation conditionnelle : les blocs
#ifdef/#ifndef/#endifsont évalués, et les branches non retenues sont éliminées. - Directives spéciales :
#pragma,#line,#error, etc.
Le résultat est une unité de traduction (translation unit) : un fichier source unique et autonome, prêt à être analysé par le compilateur.
g++ -E main.cpp -o main.ii
g++ -E mathutils.cpp -o mathutils.ii L'extension .ii est la convention pour le C++ préprocessé (.i pour le C pur). Ces fichiers sont du texte brut, parfaitement lisibles.
Commençons par mesurer l'ampleur de l'expansion :
wc -l main.cpp mathutils.cpp main.ii mathutils.iiUn résultat typique sur Ubuntu avec GCC 15 :
22 main.cpp
10 mathutils.cpp
48372 main.ii
187 mathutils.ii
48591 total
Notre main.cpp de 22 lignes est devenu un fichier de près de 50 000 lignes. C'est l'intégralité du contenu de <iostream>, <string>, <cmath>, et de toutes les en-têtes qu'ils incluent transitivement (<type_traits>, <memory>, <algorithm>, etc.). En revanche, mathutils.cpp reste compact car il n'inclut que notre propre header minimaliste.
Ce constat explique pourquoi la compilation C++ est souvent jugée lente : le compilateur doit analyser des dizaines de milliers de lignes pour chaque fichier source, même si votre code propre ne représente qu'une infime fraction de l'unité de traduction.
💡 C'est l'une des motivations principales des modules C++20, qui visent à éliminer cette redondance. Voir section 12.13.
En examinant le fichier .ii, vous remarquerez des lignes qui commencent par # suivies d'un numéro :
head -20 main.ii# 0 "main.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.cpp"
# 1 "/usr/include/c++/15/iostream" 1 3
# 36 "/usr/include/c++/15/iostream" 3
# 1 "/usr/include/x86_64-linux-gnu/c++/15/bits/c++config.h" 1 3
...
Ces linemarkers (marqueurs de ligne) permettent au compilateur de rapporter les erreurs avec le bon nom de fichier et le bon numéro de ligne, même après expansion. Le format est # <numéro_ligne> "<fichier>" [flags]. Les flags possibles sont 1 (début d'un nouveau fichier inclus), 2 (retour au fichier incluant), 3 (code système), et 4 (contenu à traiter comme du extern "C").
C'est grâce à ces marqueurs que, lorsqu'une erreur survient dans <vector> à cause d'un mauvais usage dans votre code, le message du compilateur peut remonter jusqu'à votre ligne fautive.
Cherchons notre macro APP_VERSION dans le fichier préprocessé :
grep 'APP_VERSION' main.iiAucun résultat. La chaîne APP_VERSION a été entièrement remplacée par "1.0.0". Cherchons la preuve :
# Afficher les dernières lignes — notre code transformé
tail -25 main.iiVous verrez quelque chose comme :
int main() {
std::string nom = "Ubuntu";
double rayon = 5.0;
std::cout << "Bienvenue sur " << nom << " !" << std::endl;
std::cout << "Version : " << "1.0.0" << std::endl;
...La macro a disparu, remplacée par sa valeur littérale. C'est un comportement fondamental à comprendre : une macro n'a aucune existence après le préprocesseur. Elle n'apparaîtra pas dans le débogueur, ne pourra pas être inspectée à l'exécution, et ne respecte pas les règles de portée du C++. C'est une raison parmi d'autres de préférer constexpr aux macros dans le C++ moderne (voir section 3.5).
grep -n 'aire_cercle\|perimetre_cercle' main.iiVous retrouverez les déclarations de aire_cercle et perimetre_cercle provenant de mathutils.hpp, insérées textuellement dans l'unité de traduction de main.cpp.
Notre header mathutils.hpp utilise un include guard (#ifndef MATHUTILS_HPP). Pour voir son effet, créons un fichier qui inclut deux fois le même header :
// test_double_include.cpp
#include "mathutils.hpp"
#include "mathutils.hpp"
int main() { return 0; }g++ -E test_double_include.cpp -o test_double.ii
grep 'aire_cercle' test_double.ii La déclaration aire_cercle n'apparaît qu'une seule fois, car la seconde inclusion est éliminée par le #ifndef. Sans include guard, les déclarations seraient dupliquées, ce qui provoque des erreurs de redéfinition lors de la compilation.
Pour savoir quels fichiers sont effectivement inclus et dans quel ordre, GCC propose des options dédiées :
# Lister tous les fichiers inclus, avec indentation selon la profondeur
g++ -H main.cpp -o /dev/null 2>&1 | head -30La sortie utilise des points pour indiquer la profondeur d'imbrication :
. /usr/include/c++/15/iostream
.. /usr/include/x86_64-linux-gnu/c++/15/bits/c++config.h
... /usr/include/x86_64-linux-gnu/c++/15/bits/os_defines.h
.... /usr/include/features.h
...
. /usr/include/c++/15/string
.. /usr/include/c++/15/bits/allocator.h
...
. /usr/include/c++/15/cmath
. mathutils.hpp
C'est extrêmement utile pour diagnostiquer des conflits d'inclusion, ou pour comprendre pourquoi un #include provoque l'inclusion en cascade de centaines de fichiers.
Une autre option pratique génère un fichier de dépendances au format Makefile :
g++ -M main.cppCette sortie liste main.cpp et tous les headers dont il dépend, directement ou transitivement. C'est le mécanisme que CMake et Make utilisent en interne pour savoir quand recompiler un fichier source suite à la modification d'un header.
C'est l'étape la plus complexe et la plus coûteuse en temps de calcul. Le compilateur prend l'unité de traduction (le .ii) et réalise successivement une analyse lexicale (découpage en tokens), une analyse syntaxique (construction de l'arbre syntaxique abstrait, ou AST), une analyse sémantique (vérification des types, résolution des surcharges), des passes d'optimisation, et finalement la génération du code assembleur pour l'architecture cible.
g++ -S main.cpp -o main.s
g++ -S mathutils.cpp -o mathutils.s On peut aussi partir du fichier préprocessé :
g++ -S main.ii -o main.sExaminons le fichier produit :
wc -l main.s mathutils.sLe fichier mathutils.s est nettement plus court que main.s, car ses fonctions sont simples et il n'utilise pas la machinerie lourde de <iostream>.
cat mathutils.sLa structure générale d'un fichier assembleur GCC contient des directives (lignes commençant par ., comme .file, .text, .globl, .type) et des instructions (le code machine mnémonique). Les directives guident l'assembleur sur l'organisation des sections et des symboles ; les instructions sont le code à proprement parler.
Détaillons les directives les plus courantes :
| Directive | Rôle |
|---|---|
.file |
Nom du fichier source d'origine |
.text |
Début de la section code (instructions exécutables) |
.data |
Début de la section données initialisées |
.rodata |
Début de la section données en lecture seule |
.bss |
Début de la section données non initialisées |
.globl |
Déclare un symbole comme global (visible par le linker) |
.type |
Déclare le type d'un symbole (fonction, objet) |
.size |
Déclare la taille d'un symbole |
.align |
Aligne la prochaine donnée/instruction sur une frontière mémoire |
.string |
Insère une chaîne littérale dans la section courante |
Cherchons notre fonction dans mathutils.s :
grep -A 20 '_Z11aire_cercled:' mathutils.sLe nom _Z11aire_cercled est la version manglée de aire_cercle(double). La convention de mangling de GCC encode le namespace, le nom de la fonction, et les types de ses paramètres. Ici, _Z est le préfixe standard, 11 est la longueur du nom aire_cercle, et d représente le type double.
En syntaxe AT&T par défaut, le code ressemble à :
_Z11aire_cercled:
endbr64
pushq %rbp
movq %rsp, %rbp
movsd %xmm0, -8(%rbp)
movsd -8(%rbp), %xmm1
movsd .LC0(%rip), %xmm0
mulsd %xmm1, %xmm0
mulsd -8(%rbp), %xmm0
popq %rbp
retDécodons cette séquence. L'instruction endbr64 est une protection contre les attaques par détournement de flux (Control-Flow Enforcement Technology). Les deux lignes suivantes (pushq %rbp / movq %rsp, %rbp) établissent le stack frame de la fonction. Le paramètre rayon arrive dans le registre xmm0 (convention d'appel x86-64 pour les flottants). La constante PI est chargée depuis .LC0 (une étiquette dans .rodata). Deux multiplications scalaires double (mulsd) calculent PI * rayon * rayon. Le résultat reste dans xmm0, qui est aussi le registre de retour pour les double.
Par défaut, GCC produit de l'assembleur en syntaxe AT&T, où la source précède la destination (movq %rsp, %rbp signifie "copier rsp dans rbp"). La syntaxe Intel, plus répandue dans la documentation et les manuels de processeur, inverse cet ordre et omet les préfixes % et $ :
g++ -S -masm=intel mathutils.cpp -o mathutils_intel.s
grep -A 20 '_Z11aire_cercled:' mathutils_intel.s _Z11aire_cercled:
endbr64
push rbp
mov rbp, rsp
movsd QWORD PTR [rbp-8], xmm0
movsd xmm1, QWORD PTR [rbp-8]
movsd xmm0, QWORD PTR .LC0[rip]
mulsd xmm0, xmm1
mulsd xmm0, QWORD PTR [rbp-8]
pop rbp
retLe choix entre les deux syntaxes est une question de préférence. La syntaxe Intel est généralement jugée plus lisible. L'une comme l'autre produisent le même code machine à l'étape suivante.
Comparons le code produit avec et sans optimisation :
g++ -S -O0 -masm=intel mathutils.cpp -o mathutils_O0.s
g++ -S -O2 -masm=intel mathutils.cpp -o mathutils_O2.s Examinons la version optimisée :
grep -A 10 '_Z11aire_cercled:' mathutils_O2.s_Z11aire_cercled:
endbr64
mulsd xmm0, xmm0
mulsd xmm0, QWORD PTR .LC0[rip]
retLe code optimisé est radical : plus de stack frame, plus de sauvegarde en mémoire. Le compilateur a compris que rayon * rayon peut se faire directement dans xmm0, suivi d'une multiplication par PI. Trois instructions au lieu d'une dizaine — c'est la puissance des passes d'optimisation du compilateur.
📎 La section 2.6.2 détaille les niveaux d'optimisation (
-O0,-O2,-O3,-Os) et leur impact.
Avec l'option -g, le compilateur ajoute des directives de débogage dans le fichier assembleur :
g++ -S -g mathutils.cpp -o mathutils_debug.s
wc -l mathutils.s mathutils_debug.s Le fichier avec débogage est sensiblement plus gros. Il contient des directives .loc (correspondance entre instructions assembleur et lignes du source), .cfi_* (informations de déroulement de pile pour le débogueur et les exceptions), et des sections .debug_* (format DWARF). Ces informations n'affectent pas le code machine produit, mais permettent à GDB de faire la correspondance entre l'exécution et votre code source.
Si vous avez installé Clang (section 2.1.2), vous pouvez comparer :
clang++ -S -masm=intel mathutils.cpp -o mathutils_clang.s
grep -A 15 '_Z11aire_cercled:' mathutils_clang.s Le code assembleur produit par Clang diffère dans les détails (choix des registres, ordre des instructions, directives), mais le résultat fonctionnel est identique. Les deux compilateurs appliquent des stratégies d'optimisation différentes, ce qui peut mener à des performances légèrement différentes selon le code. Le nom manglé (_Z11aire_cercled) est en revanche identique, car GCC et Clang suivent la même ABI (Itanium C++ ABI) sur Linux.
L'assembleur (as, le GNU Assembler, invoqué automatiquement par g++ -c) convertit le code assembleur en code objet : un fichier binaire au format ELF de type relocatable. Chaque instruction mnémonique est traduite en son encodage binaire, et les références aux symboles externes sont laissées sous forme de relocations — des marqueurs que l'éditeur de liens résoudra à l'étape suivante.
g++ -c main.cpp -o main.o
g++ -c mathutils.cpp -o mathutils.o On peut aussi partir des fichiers assembleur :
g++ -c main.s -o main.o
g++ -c mathutils.s -o mathutils.o file main.o mathutils.omain.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
mathutils.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
Le terme relocatable est le point clé : ce fichier contient du code machine valide, mais les adresses des fonctions et variables externes ne sont pas encore fixées. Ce sont des "trous" que le linker comblera.
Les relocations sont les instructions laissées par l'assembleur à l'attention du linker. Elles indiquent précisément quels emplacements dans le code doivent être corrigés et quel symbole doit y être inséré :
readelf -r main.o | head -20Vous verrez des entrées du type :
Relocation section '.rela.text' at offset ...:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000c00000002 R_X86_64_PC32 0000000000 _ZSt4cout - 4
...
Cela signifie : "à l'offset 0x25 dans la section .text, insérer l'adresse de _ZSt4cout (c'est-à-dire std::cout), en utilisant un adressage relatif au PC (Program Counter)." Sans la résolution de cette relocation, le code à cet emplacement contient un zéro ou un placeholder, et l'exécution planterait.
ls -l main.o mathutils.o
size main.o mathutils.o La commande size montre la répartition entre les sections text (code), data (données initialisées) et bss (données non initialisées). Le fichier mathutils.o a une section text beaucoup plus petite que main.o, ce qui est logique puisqu'il ne contient que deux fonctions arithmétiques simples.
Maintenant que nous avons deux fichiers objets, l'éditeur de liens les combine avec les librairies standard pour produire un exécutable :
g++ main.o mathutils.o -o mainL'ordre des fichiers objets sur la ligne de commande n'a généralement pas d'importance pour les fichiers .o. En revanche, l'ordre des librairies (options -l) peut compter : le linker GNU traditionnel (ld) traite les librairies de gauche à droite et ne résout que les symboles indéfinis au moment où il rencontre une librairie. En pratique, avec g++ et les librairies standard, ce n'est pas un problème — mais c'est une subtilité importante dans les projets plus complexes.
L'option -v (verbose) de g++ affiche la commande exacte transmise au linker :
g++ -v main.o mathutils.o -o main 2>&1 | grep collect2La sortie révèle l'appel à collect2 (le wrapper de GCC autour de ld) avec une longue liste d'arguments. Parmi eux, on trouve les fichiers de démarrage du runtime C (crt1.o, crti.o, crtbeginS.o, crtendS.o, crtn.o). Ces fichiers fournissent le code qui s'exécute avant et après votre main() : initialisation des variables globales, appel des constructeurs d'objets statiques, enregistrement des fonctions atexit, etc.
Pour un niveau de détail encore supérieur, on peut demander au linker lui-même d'afficher sa trace :
g++ main.o mathutils.o -o main -Wl,--verbose 2>&1 | head -50L'option -Wl,--verbose passe --verbose directement à ld via le flag -Wl (qui signifie "passer au linker"). La sortie montre le script de linkage utilisé, les chemins de recherche des librairies, et la résolution de chaque symbole.
Avant le linkage, main.o contenait des symboles indéfinis pour aire_cercle et perimetre_cercle. Après le linkage, ils sont résolus :
# Avant : symboles indéfinis dans main.o
nm -Cu main.o | grep -E 'aire|perimetre'
# Après : symboles résolus dans l'exécutable
nm -C main | grep -E 'aire|perimetre'Dans l'exécutable final, chaque symbole a une adresse virtuelle concrète.
La compréhension de cette étape permet de diagnostiquer les erreurs de linkage, parmi les plus déroutantes pour les débutants. Testons en oubliant volontairement mathutils.o :
g++ main.o -o main/usr/bin/ld: main.o: in function `main':
main.cpp:(.text+0x5e): undefined reference to `aire_cercle(double)'
main.cpp:(.text+0x8a): undefined reference to `perimetre_cercle(double)'
collect2: error: ld returned 1 exit status
Le message undefined reference vient de l'éditeur de liens — pas du compilateur. Le fichier main.o a été compilé avec succès (le compilateur a trouvé la déclaration dans mathutils.hpp), mais le linker ne trouve pas la définition (le code compilé de ces fonctions). La solution est de fournir mathutils.o au linkage.
Un autre cas classique est l'oubli de extern "C" quand on mélange du C et du C++, ce qui cause un désaccord de mangling entre le symbole recherché et le symbole défini. Le compilateur C produit aire_cercle, tandis que le compilateur C++ cherche _Z11aire_cercled — les deux ne correspondent pas.
📎 La section 43.1 traite en détail de l'interopérabilité C/C++ et du rôle de
extern "C".
En temps normal, on ne décompose pas ces étapes manuellement. La commande suivante réalise la chaîne complète en une invocation :
g++ main.cpp mathutils.cpp -o mainCela équivaut exactement à :
g++ -E main.cpp -o main.ii
g++ -E mathutils.cpp -o mathutils.ii
g++ -S main.ii -o main.s
g++ -S mathutils.ii -o mathutils.s
g++ -c main.s -o main.o
g++ -c mathutils.s -o mathutils.o
g++ main.o mathutils.o -o main Les fichiers intermédiaires sont normalement créés dans un répertoire temporaire et supprimés automatiquement. L'option -save-temps permet de les conserver :
g++ -save-temps main.cpp mathutils.cpp -o main
ls -la *.ii *.s *.o main C'est un raccourci très pratique pour l'analyse : une seule commande qui produit tous les fichiers intermédiaires tout en générant l'exécutable final.
| Extension | Contenu | Produit par | Lisible |
|---|---|---|---|
.cpp, .cc, .cxx |
Code source C++ | Développeur | Oui |
.hpp, .h |
Headers C/C++ | Développeur | Oui |
.ii |
Source C++ préprocessé | Préprocesseur (-E) |
Oui |
.s, .S |
Code assembleur | Compilateur (-S) |
Oui (avec effort) |
.o |
Code objet (ELF relocatable) | Assembleur (-c) |
Non (binaire) |
.a |
Archive statique (collection de .o) |
ar |
Non (binaire) |
.so |
Librairie partagée (ELF shared) | Linker (-shared) |
Non (binaire) |
| (sans ext.) | Exécutable (ELF executable) | Linker | Non (binaire) |
En pratique quotidienne, vous utiliserez CMake ou un autre build system qui orchestre ces étapes automatiquement. Mais la décomposition manuelle reste un outil de diagnostic précieux dans plusieurs situations.
Lorsque vous rencontrez une erreur de compilation cryptique, examiner le fichier préprocessé (-E) permet de voir exactement ce que le compilateur reçoit — y compris les macros inattendues, les conflits d'inclusion, ou les headers système mal configurés.
Face à un problème de performance, examiner l'assembleur (-S) permet de vérifier que le compilateur génère bien le code attendu : vectorisation effective, inlining réalisé, branches éliminées.
Pour une erreur de linkage, comprendre la distinction entre déclaration (visible à la compilation) et définition (nécessaire au linkage) vous oriente immédiatement vers la cause : fichier objet manquant, librairie non liée, ou problème de mangling.
Prochaine section : 2.5.2 — Inspection des binaires avec
nm,objdumpetldd, où nous apprendrons à lire le contenu des fichiers.oet des exécutables produits.