Skip to content

Latest commit

 

History

History
606 lines (420 loc) · 24.2 KB

File metadata and controls

606 lines (420 loc) · 24.2 KB

🔝 Retour au Sommaire

2.5.1 Compilation étape par étape (g++ -E, -S, -c)

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.

Rappel du programme source

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;
}

Étape 1 : Le préprocesseur (g++ -E)

Ce que fait le préprocesseur

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 #include est remplacé par le contenu intégral du fichier référencé, récursivement.
  • Expansion de macros : chaque occurrence d'un #define est substituée par sa valeur.
  • Compilation conditionnelle : les blocs #ifdef / #ifndef / #endif sont é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.

Exécution

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.

Analyse du fichier préprocessé

Commençons par mesurer l'ampleur de l'expansion :

wc -l main.cpp mathutils.cpp main.ii mathutils.ii

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

Les marqueurs de ligne (linemarkers)

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.

Vérifier la substitution des macros

Cherchons notre macro APP_VERSION dans le fichier préprocessé :

grep 'APP_VERSION' main.ii

Aucun 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.ii

Vous 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).

Vérifier l'inclusion de notre header

grep -n 'aire_cercle\|perimetre_cercle' main.ii

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

Vérifier les include guards

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.

Outil de diagnostic : voir les chemins d'inclusion

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 -30

La 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.cpp

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


Étape 2 : La compilation (g++ -S)

Ce que fait le compilateur

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

Structure du fichier assembleur

Examinons le fichier produit :

wc -l main.s mathutils.s

Le 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.s

La 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

Analyser notre fonction aire_cercle

Cherchons notre fonction dans mathutils.s :

grep -A 20 '_Z11aire_cercled:' mathutils.s

Le 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
    ret

Dé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.

Syntaxe Intel vs AT&T

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
    ret

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

Impact des optimisations

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]
    ret

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

Informations de débogage dans l'assembleur

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.

Clang : même principe, sortie différente

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.


Étape 3 : L'assemblage (g++ -c)

Ce que fait l'assembleur

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  

Vérification du type

file main.o mathutils.o
main.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.

Examiner les relocations

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 -20

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

Comparaison des tailles

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.


Étape 4 : L'édition de liens

Liaison des fichiers objets

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 main

L'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.

Observer le linker en action

L'option -v (verbose) de g++ affiche la commande exacte transmise au linker :

g++ -v main.o mathutils.o -o main 2>&1 | grep collect2

La 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 -50

L'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.

Vérifier la résolution des symboles

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.

Erreurs de linkage courantes

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


La commande tout-en-un et son équivalent décomposé

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 main

Cela é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.


Vue d'ensemble des extensions de fichiers

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)

Quand décomposer la compilation est utile

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, objdump et ldd, où nous apprendrons à lire le contenu des fichiers .o et des exécutables produits.

⏭️ Inspection des binaires (nm, objdump, ldd)