La compilation et les modules en C et en C++

mamiemando Messages postés 33372 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 22 novembre 2024 - Modifié le 30 mai 2022 à 05:15
Cet article introduit les notions de bases liées à la compilation en C et en C++, notamment en cas de la programmation modulaire.

Il permet de mieux comprendre les messages d'erreur du compilateur. Les notions abordées ici sont indépendantes du système d'exploitation (Windows, Linux, ...), même si les exemples considèrent que l'on est sous Linux avec le compilateur
gcc
ou
g++
.




Introduction

De manière générale, un compilateur convertit un fichier texte (contenant un code source) en binaire (par exemple un exécutable ou une librairie) directement compréhensibles par le micro-processeur.

Une fois le binaire généré, celui-ci est directement utilisable sans qu'on ait besoin du code source. Un langage compilé (comme le C ou le C++) se distingue donc d'un langage interprété (shell, PHP, ...) ou pseudo-interprété (python, ...).

Installation d'un compilateur C et C++

Linux

En général, on utilise
gcc
(pour le C) et
g++
(pour le C++).

Exemple : Sous Debian ou les distributions qui en dérivent
sudo update
sudo apt install gcc g++


On peut éventuellement installer un environnement de développement comme par exemple
kdevelop
(KDE) ou
anjuta
(gnome).

Windows

On peut utiliser
dev-cpp
ou www.codeblocks.org
code::blocks
, reposant tous les deux sur
gcc
et
g++
:

Article connexe : ou trouver un compilateur c c

Les phases de compilation

On commence par écrire ou récupérer le code source (écrit en langage C ou C++) à compiler.

Par convention :
  • en C : les fichiers ont pour extension
    .c
    (source) et
    .h
    (header) ;
  • en C++ : les fichiers ont pour extension
    .cpp
    (source) et
    .hpp
    (header) ;


Ensuite, on compile le programme :
  • en C : avec
    gcc
    ;
  • en C++ : avec
    g++
    .


La compilation (au sens vague du terme) se déroule en trois grandes phases détaillées ci-dessous.

1) La précompilation (pré-processeur)

Le compilateur commence par appliquer chaque instruction passée au préprocesseur (toutes les lignes qui commencent par un
#
, dont les
#define
). Ces instructions sont en fait très simples car elles ne consistent en gros qu'à recopier ou supprimer des sections de codes sans chercher à les compiler. C'est de la substitution de texte, ni plus ni moins.

C'est notamment à ce moment là que les
#define
présents dans un fichier source (
.c
ou
.cpp
) ou dans un header (
.h
ou
.hpp
) sont remplacés par du code C/C++. À l'issue de cette étape, il n'y a donc plus, pour le compilateur, d'instructions commençant par un
#
.

2) La compilation

Le compilateur compile chaque fichier source (
.c
et
.cpp
) individuellement. Chacun engendre un fichier binaire (
.o
).

Cette étape est assurée par
cc
lorsqu'on utilise
gcc
/
g++
.

3) Le linkage

Enfin, le compilateur agrège et lie les fichiers
.o
produits, les éventuelles librairies tierces (fichiers
.a
et
.so
sous linux, fichiers
.dll
et
.lib
sous windows).
  • Une librairie dynamique (.dll et .so) n'est pas recopiée dans l'exécutable final (ce qui signifie que le programme est plus petit et bénéficiera des mises à jour de ladite librairie). En contrepartie, la librairie doit être présente sur le système sur lequel tourne le programme.
  • Une librairie statique (.a) est recopiée dans l'exécutable final ce qui fait que celui-ci est complètement indépendant des librairies installées du le système sur lequel il sera recopié. En contrepartie, l'exécutable est plus gros, il ne bénéficie pas des mises à jour de cette librairie etc...


Au cours de cette étape, le linker vérifie en particulier que chaque fonction appelée dans le programme n'est pas seulement déclarée (ceci est fait lors de la compilation), mais aussi implémentée (chose qu'il n'avait pas vérifié à ce stade) une et une seule fois.

Cette phase, appelée aussi édition de lien, constitue la phase finale pour obtenir un exécutable (pas d'extension sous linux, extension
.exe
sous windows).

Cette étape est assurée par
ld
lorsqu'on utilise
gcc
/
g++
.

Warnings et erreurs

Dans un environnement de développement, il suffit de cliquer sur "build" et ces trois phases s'enchaînent de manière transparente. Toutefois, il est important de les avoir en tête pour interpréter correctement les messages d'erreur ou de warning d'un compilateur lorsqu'il y en a.

Définitions :
  • Un warning signifie que le code est ambigu et que le code peut être interprété différemment d'un compilateur à l'autre, mais l'exécutable peut être créé.
  • Une erreur signifie que le code n'a pas pu être compilé complètement et que le binaire n'a pas pu être (re)créé. Si un code peut compiler avec des warnings et doit compiler sans erreurs, il vaut mieux essayer de coder de sorte à n'avoir ni erreur, ni warning.

Les grandes étapes pour écrire un programme en C ou en C++

Écrire le code source

Un simple bloc note peut suffire, par exemple on peut écrire dans le fichier plop.c :
#include <stdio.h>

int main(){
    printf("plop !\n");
    return 0;
}

Compiler

Sous linux on peut appeler directement
gcc
(les options
-W
et
-Wall
permettent d'afficher plus de messages pour vérifier si le code est propre, le
-o plop
permet de dire que l'exécutable à créer doit s'appeler
plop
) :
gcc -W -Wall -o plop plop.c

Cette commence enchaîne les trois étapes décrites précédemment.

1) Précompilation

/* Tout ce qui est défini par <stdio.h>, y compris printf() */
int main(){
    printf("plop !\n");
    return 0;
}


2) Compilation (il trouve bien le
printf
car celui-ci est déclaré dans <stdio.h>)

3) Édition de lien (il trouve bien le printf dans le binaire de la lib c). On peut d'ailleurs le vérifier sous linux avec
ldd plop
:
        linux-gate.so.1 =>  (0xb7f2b000)
libc.so.6 => /lib/i686/cmov/libc.so.6 (0xb7dbb000)
/lib/ld-linux.so.2 (0xb7f2c000)

Sur la deuxième ligne on voit bien qu'il utilise la librairie c. À la fin du linkage, l'exécutable
plop
est créé.

Exécution

Sous Linux

Il ne reste plus qu'à le lancer le programme :
./plop

Résultat :
plop !


Si une erreur se produit à ce moment là (langage erreur de segmentation, pas assez de mémoire etc...) il faut souvent recourir à un débogeur (par exemple
gdb
ou
ddd
), revoir le code source, etc... Dans tous les cas, ce n'est plus un problème de compilation mais une erreur de programmation.

Sous Windows

Pour exécuter un programme sous windows, deux méthodes sont possibles.

Méthode 1 : via les commandes ms-dos (en cliquant sur démarrer exécuter (Win+4) et en tapant
cmd
). On se place ensuite dans le bon répertoire avec la commande
cd
et on lance le programme. Dans ce cas tout se passera bien.

Méthode 2 : Si on lance le programme depuis l'explorateur, windows lance les commandes ms-dos, qui lance le programme, celui-ci se termine, rend la main aux commandes ms-dos, et windows ferme les commandes ms-dos. Concrètement on n'a alors rien le temps de voir. Il faut donc mettre une "pause" juste avant la fin du programme si on veut lancer ton exécutable de cette manière :

#include <stdio.h>

int main(){
    printf("plop !\n");

    getchar(); /* le programme n'avance plus tant qu'on appuie pas sur une touche */
    return 0;
}

Les erreurs classiques

Erreur de compilation

Supposons que le code source oublie d'inclure le fichier
<stdio.h>
(dans lequel est déclarée la fonction
printf
), ou qu'un
;
soit manquant. Dans ce cas, le compilateur renverra un message d'erreur (syntax error, parse error...).

Erreur de linkage

Ces erreurs sont plus subtiles, car elles ne concernent pas la syntaxe, mais la manière dont est structuré et compilé le projet. Elles sont faciles à reconnaître quand on utilise
gcc
ou
g++
puisque les messages d'erreurs correspondant parlent explicitement de
ld
(le linker).

Mutlidéfinition

Une erreur de linkage peut survenir dès que l'on écrit le code d'un programme à l'aide de plusieurs fichiers. Nous allons à présent illustrer ce genre d'erreur.

Supposons que :
  • le projet soit implémenté au travers de 3 fichiers :
    a.h
    ,
    a.c
    , et
    main.c
    ;
  • le header
    a.h
    soit inclus par les deux fichiers source
    main.c
    et
    a.c
    ;
  • le fichier
    main.c
    contienne la fonction
    main()
    .


1) Si l'on compile juste
a.c
, le fichier ne contenant pas de fonction
main
, il faut le préciser au compilateur (option
-c
dans
gcc
), sans quoi celui-ci tentera d'en faire un exécutable et plantera faute de trouver la fonction
main
.

C'est pourquoi le fichier contenant le main (pas d'option -c) et les autres fichiers sources se compilent différemment. En l'occurrence :
gcc -W -Wall -c a.c
gcc -W -Wall -o plop main.c a.o
  • La première commande construit
    a.o
    à partir de
    a.c
    .
  • La seconde génère l'exécutable
    plop
    à partir
    main.c
    et
    a.o
    .


Si le programme comporte une erreur dans
a.c
, le compilateur déclenchera une erreur au moment de compiler
a.c
. Ceci provoquera des erreurs en cascade dans les autres fichiers. C'est pourquoi lorsqu'un programme ne compile pas, on commence par les premiers messages d'erreurs, on les résout, on recompile etc... jusqu'à ce que toutes les erreurs soient résolues.

2) Rappelons qu'en temps normal, on déclare la fonction dans le header (par exemple
a.h
) :
void plop();


... et qu'on l'implémente dans le fichier source (par exemple
a.c
) :
#include "a.h"
#include <stdio.h>

void plop(){
  printf("plop !\n");
}


Supposons à présent que j'implémente la fonction
plop()
dans
a.h
, et que ce dernier contienne :
#include <stdio.h>

void plop(){
  printf("plop !\n");
}


... alors que
a.c
contient :
#include "a.h"

void f(){
  plop();
}


Le fichier
a.h
est inclus par
main.c
et
a.c
. Ainsi, le contenu de
a.h
est recopié dans
a.c
et dans
main.c
(précompilation). Ainsi, ces deux fichiers sources disposeront chacun d'une implémentation de la fonction
plop()
(la même certes !), mais le compilateur ne saura pas laquelle prendre et déclenchera une erreur de multi définition au moment du linkage:
(mando@aldur) (~) $ gcc -W -Wall main.c a.o
a.o: In function `plop':
a.c:(.text+0x0): multiple definition of `plop'
/tmp/ccmRKAvQ.o:main.c:(.text+0x0): first defined here
collect2: ld returned 1 exit status


Cela explique pourquoi l'implémentation d'une fonction doit se faire en général dans un fichier source (
.c
ou
.cpp
) et non dans un header (
.h
et
.hpp
). On retiendra juste deux exceptions à cette règle en C++ : les fonctions/méthodes
inline
et les fonctions/méthodes
templates
.

Boucles d'inclusion et inclusion multiples.

Supposons que :
  • le projet comporte les fichiers suivant :
    main.c
    ,
    a.h
    ,
    b.h
    et
    c.h
    (et leurs fichiers source correspondants
    a.c
    ,
    b.c
    ,
    c.c
    ;
  • a.h
    et
    b.h
    incluent tout deux
    c.h
    .


Si on ne prend pas de précautions, le fichier
c.h
étant inclus à différents endroit, ses déclarations vont apparaître dans
a.o
,
b.o
et
c.o
et engendrer une erreur au moment du linkage.

C'est pourquoi pour s'en prémunir, on protège les headers ainsi.

Exemple pour a.h :

#ifndef A_H
#define A_H

// Contenu de a.h

#endif


(même principe pour
b.h
et
c.h
)

Ainsi, quelque soit l'ordre dans lequel le compilateur traite les fichiers, chaque définition ne sera considérée qu'une et une seule fois.

Fonction déclarée ... mais pas trouvée

Si une fonction est déclarée, utilisée, mais pas implémentée, une erreur de linkage se produit également. Ceci survient typiquement dans deux cas :
  • on a déclaré une fonction mais on ne l'a pas encore implémentée
  • on veut utiliser une fonction d'une librairie, on a correctement inclu les headers correspondant, mais on a oublié de passer en paramètre du compilateur lesdites librairies.

Plus loin avec la compilation : makefile

Afin d'éviter de taper à chaque fois les commandes
gcc
pour chaque fichier source (ce qui est très fastidieux, surtout quand le projet devient gros), on scripte souvent la compilation à l'aide d'un fichier
Makefile
.

Ce fichier peut être écrit manuellement ou au travers de générateurs de makefile comme
cmake
.

Une fois le fichier
Makefile
prêt et correct, et sous réserve que la commande
make
soit installée, il suffit alors de taper la commande suivante pour compiler le projet :
make