Programmer en C

Retour sur la compilation

Je suis tombé amoureux du C il y a bien longtemps. C'est le langage de programmation qui, selon moi, ne cache pas (trop) ce qui se passe en dessous, et ne réduit pas la machine à une simple abstraction. Je ne vais pas vous faire ici un cours de C, mais simplement partager avec vous une synthèse sur la compilation : un petit rappel sur ce que fait un compilo, dans quel ordre et comment. Le document original n'est pas de moi: je l'ai simplement traduit et complété pour les nouveaux entrants dans mon équipe de recherche. Qui sait ? Cela peut peut-être vous être utile ?

Les étapes de la compilation

Nous allons prendre pour exemple un programme très simple, rédigé en C, et suivre ses transformations au fur et à mesure de la compilation. Notre example sera le fichier suivant :

#include <stdio.h>

int main ( int argc, char** argv, char** envv )
{
        /* afficher le mot bonjour et sauter une ligne */
        printf("bonjour !!!\n");

        return 0 ;
}

Dire que nous allons effectuer une “compilation” est en fait un abus de langage. En effet, la génération d’un éxécutable se décompose en quatre étapes élémentaires, dont une porte le nom de “compilation” … Ces quatre étapes sont les suivantes :

  1. Passage au pré-processeur (preprocessing)
  2. Compilation en langage assembleur (compiling)
  3. Conversion du langage assembleur en code machine (assembling)
  4. Edition des liens (linking)

My helpful screenshot

Sur ce blog, il pourra donc m’arriver d’utiliser le mot compilation pour l’étape numéro 2, ou pour le processus dans son intégralité. C’est un abus de langage courant en informatique, et après tout, c’est mon blog et je fais ce que je veux !

Plus sérieusement, chacune de ces quatre étapes joue un rôle bien précis, que tout programmeur en C doit connaître. Malheureusement, les différents compilateurs que j’ai pu rencontrer n’ont pas forcément tous la même interprétation de ce qui doit être fait à chaque étape et de subtiles variations peuvent exister. Nous allons donc voir ensemble comment gcc effectue ces quatre opérations et ce qu’il fait en pratique.

Etape 1/4 : Le Preprocessing

Le pré-processeur réalise plusieurs opérations de substitution sur le code C, notamment :

  • La suppression des commentaires

  • L’inclusion des fichiers .h dans le fichier source à la place des #include

  • Le traitement des directives de compilation qui commencent par un caractère #

Les deux première opérations relèvent du traitement de texte. Le pré-processeur fait ce que vous pourriez faire sur OpenOffice ou Word à coup de “Rechercher/Remplacer”.

La troisième étape, cependant, est moins triviale. Derrière le doux nom de “directives de compilation”, ou “directives préprocesseur”, se cache un véritable langage de programmation qui va permettre des modifications textuelles avant le traitement du C proprement dit. Ces directives sont les suivantes :

    #define
    #elif
    #else
    #endif
    #error
    #if
    #ifdef
    #ifndef
    #include
    #line
    #pragma
    #undef

Le fichier qui est produit est un fichier texte en langage C qui est lisible. Voici le fichier obtenu pour notre example “bonjour.c” :

blue@asimov ~ $ gcc -E bonjour.c > bonjour.i 
blue@asimov ~ $ cat bonjour.i 
[...] 
extern int printf (const char *__restrict __format, ...);
[...]

# 1 "bonjour.c"

int main (int argc, char** argv, char** envv)
{
  printf("bonjour !!!\n");

  return 0;
}                                                                                   

Comme on peut le voir ci-dessus, la directive “#include " a été remplacée par le contenu du fichier stdio.h, qui contient, entre autres choses, la définition de la fonction printf que nous utilisons dans notre code.

Particularité de gcc, Le pré-processeur contrôle également la syntaxe du programme. Lorsque le programme contient des erreurs de syntaxe, gcc affiche la liste des erreurs à la console et il interrompt la suite des opérations (il ne passe pas à l’étape suivante). Par exemple, si j’oublie de fermer le guillemet du printf :

blue@asimov ~ $ gcc -E bonjour.c > bonjour.i 
bonjour.c: In function 'main':
bonjour.c:6:10: warning: missing terminating " character [enabled by default]
bonjour.c:6:3: error: missing terminating " character
bonjour.c:8:3: error: expected expression before 'return'
bonjour.c:9:1: error: expected ';' before '}' token
blue@asimov ~ $  

Détail utile, dans les messages d’erreur de syntaxe, le premier chiffre après le nom de fichier indique la ligne de l’erreur, le second le caractère sur la ligne. De même, les erreurs de compilation sont bloquantes, mais pas les avertissements (warning).

Lorsqu’on compile avec gcc, les avertissements ne sont pas automatiquement affichés sur la console, ils sont cependant très utiles car ils permettent de résoudre des bugs. Pour les afficher, il faut utiliser l’option -Wall (Le W signifiant “Warning”).

Etape 2/4 : Compilation proper

Le code est ensuite transformé en code assembleur. Il suffit de donner à gcc l’option -S suivie du nom de fichier qui est issu du preprocessing pour obtenir la transformation en langage assembleur. Un fichier bonjour.s est automatiquement produit, c’est un fichier texte qui est lisible, mais difficilement compréhensible si on ne connaît pas l’assembleur …

blue@asimov ~ $ gcc -S bonjour.i 
blue@asimov ~ $ cat bonjour.s 
	.file	"bonjour.c"
	.section	.rodata
.LC0:
	.string	"bonjour !!!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5
	andl	$-16, %esp
	subl	$16, %esp
	movl	$.LC0, (%esp)
	call	puts
	movl	$0, %eax
	leave
	.cfi_restore 5
	.cfi_def_cfa 4, 4
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Gentoo 4.6.3 p1.6, pie-0.5.2) 4.6.3"
	.section	.note.GNU-stack,"",@progbits

Il n’y a en général pas d’erreur pendant cette phase de la compilation. Elles sont soulevées pendant la phase de préprocessing.

Etape 3/4 : Assembling

Le code assembleur, qui est lisible, est transformé en code machine binaire. Il suffit de donner à gcc l’option -c suivie du nom du fichier qui contient le code assembleur pour obtenir un fichier objet bonjour.o. Ce fichier binaire ne peut pas être directement édité et lu par un humain (en tout cas, de façon confortable !)

blue@asimov ~ $ gcc -c bonjour.s 
blue@asimov ~ $ od -x bonjour.o 
0000000 457f 464c 0101 0001 0000 0000 0000 0000
0000020 0001 0003 0001 0000 0000 0000 0000 0000
0000040 0120 0000 0000 0000 0034 0000 0000 0028
0000060 000d 000a 8955 83e5 f0e4 ec83 c710 2404
0000100 0000 0000 fce8 ffff b8ff 0000 0000 c3c9
0000120 6f62 6a6e 756f 2072 2121 0021 4700 4343
0000140 203a 4728 6e65 6f74 206f 2e34 2e36 2033
0000160 3170 362e 202c 6970 2d65 2e30 2e35 2932
0000200 3420 362e 332e 0000 0014 0000 0000 0000
0000220 7a01 0052 7c01 0108 0c1b 0404 0188 0000
0000240 001c 0000 001c 0000 0000 0000 001c 0000
0000260 4100 080e 0285 0d42 5805 0cc5 0404 0000
0000300 2e00 7973 746d 6261 2e00 7473 7472 6261
0000320 2e00 6873 7473 7472 6261 2e00 6572 2e6c
0000340 6574 7478 2e00 6164 6174 2e00 7362 0073
0000360 722e 646f 7461 0061 632e 6d6f 656d 746e
0000400 2e00 6f6e 6574 472e 554e 732d 6174 6b63
0000420 2e00 6572 2e6c 6865 665f 6172 656d 0000
0000440 0000 0000 0000 0000 0000 0000 0000 0000

( ... )

Ce code machine est la traduction exacte du code assembleur présenté dans la partie précédente. Il comporte donc le code de la fonction main que nous avons écrite, mais pas le code de la fonction printf que nous utilisons.

Etape 4/4 : Linking

Le fichier bonjour.o produit par l’étape 3 est donc incomplet: il ne contient pas le code de la fonction printf. En effet, ce code est dans une bibliothèque. Afin que le programme puisse être compilé, nous avons inclus son prototype (nom, arguments, type de retour) mais le code de la fonction n’est pas présent dans bonjour.o.

L’édition des liens va réunir le fichier objet et les fonctions contenues dans les bibliothèques, pour produire un éxécutable complet.

blue@asimov ~ $ gcc -o monprog bonjour.o
blue@asimov ~ $ ./monprog
bonjour !!!
blue@asimov ~ $ 

( Eureka ! Ca marche ! )

Les erreurs provenant de l’éditeur de lien sont très caractéristiques. En voici un exemple :

undefined reference to `foo'
collect2: ld returned 1 exit status 

Elles sont en général assez simples à résoudre, nous verrons cela dans les prochaines parties.

Bien utiliser gcc

gcc supporte de très nombreuses options, et un débutant a vite fait de s’y perdre. Je vous invite à lire la page de man de gcc pour vous en convaincre ( man gcc sous linux ). Pour commencer, je veux vous rappeler le processus “normal” de compilation, que beaucoup de monde a oublié.

gcc -c mathutils.c
gcc -c robot.c
gcc -c main.c
gcc -o MyFantasticProject mathutils.o robot.o main.o 

Dans le cas présenté ci-dessus, mon projet se compose de trois fichiers source :

mathutils.c, robot.c et main.c

Probablement accompagnés de deux headers, les fichiers robot.h et mathutils.h

Première remarque, les fichiers .h ne sont jamais compilés : le linker s’occupera tout seul d’aller les chercher.

Ensuite, on peut remarquer que la compilation et le linking on été gérés séparément. En effet, les trois premières lignes ( celles qui utilisent l’option -c de gcc ) correspondent aux trois premières étapes de la compilation (preprocessing, assembling et compilation). Le linking est effectué séparément à la quatrième ligne.

Je sais ce que vous allez me dire : mon prof de C, il m’a jamais dit ça et moi, je compile tout ca en une seule commande et ca marche très bien !!!

gcc -o MyFantasticProject robot.c main.c mathutils.c

Et bien oui, ca marche aussi, mais vous n’êtes plus à l’école maintenant ! Lachez ce doudou, et mettez vous dans le contexte d’un vrai programme, le genre de truc qui met une bonne dizaine de minutes à compiler (et quand je dis 10 minutes, je suis gentil). L’avantage de la compilation et du linking séparé est que vous n’êtes pas obligé de tout recompiler à chaque fois que vous changez un point virgule dans un code !

Un exemple : si mon projet a déjà été compilé une fois, et que je modifie seulement une fonction du fichier robot.c, je peux me contenter de taper :

gcc -c robot.c
gcc -o MyFantasticProject mathutils.o robot.o main.o 

Ce qui prendra beaucoup moins de temps ! Ceci étant dit, on compile rarement un gros projet à la main de cette façon, on utilise plutôt Cmake, un Makefile ou les autotools (bon courage !!).

Les options que l’on passe à gcc ne sont pas tout à fait les mêmes suivant que vous êtes dans la phase de compilation ou dans la phase de linking. Nous allons donc étudier les deux séparément.

Options gcc pour la compilation

La ligne de base pour effectuer la phase de compilation est la suivante :

gcc -c fichier_source.c

qui entrainera la génération du fichier objet fichier_source.o

Comme expliqué dans la première partie, ceci effectue en fait trois étapes :

  • Preprocessing
  • Compilation
  • Assembling

que l’on regroupe sous le terme abusif de “compilation”.

Il existe tout d’abord les options liées au langage et à sa syntaxe. Pour les plus courantes :

Option Description
-Wall Activer tous les warnings
-Werror Traiter les warnings comme des erreurs
-pedantic Afficher les warnings requis par la norme ANSI du langage
-ansi Switch vers le C norme C90 (pour les puristes)
-D Définir une macro depuis la ligne de compilation
-g Générer les informations de debugging

Les options -D vous permettent de définir des macros directement par la ligne de compilation. Cela peut être utile, mais aussi particulièrement vicieux.

Un petit exemple :

#include<stdio.h>

int main(void)
{
        #ifdef MY_MACRO
	        printf("\n Macro defined \n");
        #endif
        
        printf("\n Yellow world !!!\n");
        
        return 0;
}
  $ gcc -Wall -DMY_MACRO main.c -o main
  $ ./main

   Macro defined 

   Yellow world !!!
  $ gcc -Wall main.c -o main
  $ ./main

   Yellow world !!!

Il existe également des options permettant d’optimiser plus ou moins le code assembleur produit. Ceci impacte directement la quantité de mémoire utilisée et la vitesse d’exécution de votre programme en fonction de l’option choisie :

Option Description
-O0 Aucune optimisation n’est activée. C’est le mode par défaut de gcc.
-O1 active le premier niveau d’optimisation. Le compilateur tente de réduire à la fois la taille du code générée et le temps d’exécution
-O2 active le seconde niveau d’optimisation. Gcc effectue pratiquement toutes les optimisations supportées qui ne provoquent pas un compromis temps/mémoire.
-O3 Encore un cran plus loin.
-Os Optimise la taille de l’exécutable. Active toutes les options de O2 qui ne provoquent pas d’augmentation de taille de l’exécutable.

En pratique, O3 n’est pas toujours la plus rapide, et bcp de monde se contente de O2. Cependant, il n’existe pas de règle générale et il faut tester les trois pour vraiment savoir laquelle conviendra.

La plus importante des options de gcc, à mon avis, concerne les chemins d’inclusion. Par défaut, gcc recherche les fichiers headers dans certains répertoires du système :

     /usr/local/include
     /usr/lib/gcc/target/version/include
     /usr/target/include
     /usr/include

vous pouvez cependant préciser à gcc d’autres chemins dans lesquels aller chercher grâce à l’option -I. Par exemple :

    gcc -c test.c -I. -I/usr/local/include/seb_lib -I/home/seb/include

Vous pouvez également utiliser les variables d’environnement C_INCLUDE_PATH et CPLUS_INCLUDE_PATH ( je vous laisse deviner laquelle est pour gcc, laquelle est pour g++ ). Par exemple :

   export C_INCLUDE_PATH = "/home/seb/local/include"
   export CPLUS_INCLUDE_PATH = "/home/seb/local/include/cpp"

Ceci est utile quand, par exemple, vous voulez inclure de façon définitive certains chemins.

Options pour le linking

Voici un exemple de ligne de commande utilisée lors d’une étape de linking:

gcc -o test bonjour.o main.o test.o -lm -laruco -L/usr/local/lib -L.

Le résultat de cette commande est la génération de l’executable test ( signalé par l’option -o ) à partir des fichiers bonjour.o, main.o et test.o.

Pour effectuer cette compilation, les librairies m et aruco doivent être liées. l’option -l permet de signaler au compilateur quelles librairies doivent être utilisées durant la phase de linking. On peut préciser où chercher ces librairies en ajoutant des chemins de recherche par l’option -L.

note: Si vous utilisez le drapeau -ltoto, le fichier correspondant sur votre système devrait s’appeler libtoto.so (dans le cas d’une librairie dynamique) ou libtoto.a (pour une librairie statique). Nous reviendrons une autre fois sur les notions de librairies dynamiques et statiques, promis !

Références et conclusion

Nous voici donc maintenant à la fin de cet article. Bravo à ceux qui auront réussi à me lire jusque là ! N’hésitez pas à poser des questions, suggérer des modifications, signaler les typos, etc dans les commentaires. Vos contributions seront les bienvenues !

Ce document est basé sur cette page Web, dont je vous conseille la lecture. Il ne me reste plus qu’à vous laisser approfondir tout ça et vous souhaiter une bonne soirée !

A bientôt !

Seb

Dialogues & Discussions