Processus de compilation

On vient de compiler/exécuter un premier programme minimal en langage C. Dans ce chapitre nous allons aborder plus en détail le processus de compilation (au sens large) qui conduit à la création d’un programme exécutable.

C est un langage compilé, ce qui signifie que le code source doit être transformé avant de pouvoir être exécuté sur la machine. Explorons les différentes étapes de ce processus.

Pour illustrer notre propos, on considère le programme minimal déjà utilisé dans le chapitre Premiers pas :

1#include <stdio.h>
2
3int main(){
4    printf("Hello World !");
5    return 0;
6}

Le processus de compilation (au sens large) se décompose en 4 étapes :

  • le pré processing ;

  • la compilation proprement dite ;

  • l’assemblage ;

  • l’édition de liens.

Abordons chacune de ces étapes.

1. Le pre processing

La première phase s’appelle le pre processing et consiste à simplement faire du remplacement de texte dans le fichier source lorsqu’une directive (débutant par #) est rencontrée. Le texte en question sera remplacé par le contenu d’un autre fichier, d’une expression, etc.

Dans une première approche, on va s’intéresser seulement à deux directives du préprocesseur (il y en a d’autres) :

  • #include

  • #define

La directive #include

Cette étape consiste simplement à inclure les lignes du fichier indiqué après la directive #include dans le code source.

On peut observer le résultat de cette opération avec

$ gcc -E hello.c

Le fichier produit fait plus de 700 lignes ! Il contient la définition des types et les prototypes des fonctions contenus dans <stdio.h> suivi du code de hello.c.

Note

Sous Linux, on peut compter le nombre de lignes produites avec la commande suivante:

$ gcc -E hello.c | wc -l
$ 732

L’opérateur | est un pipe, c’est à dire intercepte les données produites par la commande à sa gauche pour les utiliser comme données d’entrée pour la commande à sa droite.

La visualisation de ce résultat intermédiaire n’est pas strictement nécessaire pour l’exécution du programme, mais facilite la compréhension globale.

La directive #define

Une ligne débutant par #define peut être structurée en 3 parties, chacune séparée de la précédente par un espace :

  • la directive proprement dite suivie d’un espace ;

  • une première chaine de caractère suivie d’un espace ;

  • une seconde chaine de caractère.

Le pré processeur va rechercher la première chaine de caractères dans le code source et la remplacer littéralement par la seconde.

Le code suivant contient trois directives #define et leur utilisation:

 1#include <stdio.h>
 2
 3#define carre1(X) X*X
 4#define carre2(X) X*(X)
 5#define carre3(X) (X)*(X)
 6
 7int main()
 8{
 9    int n = 5;
10    printf("%d\n", carre1(n+3) );
11    printf("%d\n", carre2(n+3) );
12    printf("%d\n", carre3(n+3) );
13}

Effectuer « à la main » l’opération de remplacement pour les trois directives ci dessus, et inférer le résultat obtenu.

  • La ligne 10 affiche la valeur

  • La ligne 11 affiche la valeur

  • La ligne 12 affiche la valeur

Vérifier en exécutant le programme

2. La compilation proprement dite

Par abus de langage, on utilise le terme générique de compilation pour désigner le processus global conduisant à la création d’un fichier exécutable à partir du fichier source.

Cependant la phase de compilation proprement dite n’est qu’une partie de ce processus global en 4 étapes. Elle consiste à produire des instructions compréhensibles par le microprocesseur.

On peut observer ces instructions avec

$ gcc -S hello.c

Le résultat est stocké dans le fichier hello.s

$ more hello.s
        .file   "hello.c"
        .text
        .section        .rodata
.LC0:
        .string "Hello World !"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        leaq    .LC0(%rip), %rdi
        call    puts@PLT
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 9.3.0-10ubuntu2) 9.3.0"
        .section        .note.GNU-stack,"",@progbits
        .section        .note.gnu.property,"a"
        .align 8
        .long    1f - 0f
        .long    4f - 1f
        .long    5
0:
        .string  "GNU"
1:
        .align 8
        .long    0xc0000002
        .long    3f - 2f
2:
        .long    0x3
3:
        .align 8
4:

La visualisation de ce résultat intermédiaire n’est pas strictement nécessaire pour l’exécution du programme, mais facilite la compréhension globale.

3. L’assemblage

L’assemblage permet de produire le langage machine qui sera exécuté sur le microprocesseur.

Cette étape est réalisée par la commande

$ gcc -c hello.c -o hello.o

Le fichier hello.o est appelé un fichier objet.

Le contenu du fichier hello.o est observable avec un éditeur hexadécimal. L’extension Hex editor de VS Code peut être utilisée.

Pour le code source précédent, le code machine est le suivant

7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00
01 00 3E 00 01 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 10 03 00 00 00 00 00 00
00 00 00 00 40 00 00 00 00 00 40 00 0E 00 0D 00
F3 0F 1E FA 55 48 89 E5 48 8D 3D 00 00 00 00 E8
00 00 00 00 B8 00 00 00 00 5D C3 48 65 6C 6C 6F
20 57 6F 72 6C 64 20 21 00 00 47 43 43 3A 20 28
55 62 75 6E 74 75 20 39 2E 33 2E 30 2D 31 30 75
62 75 6E 74 75 32 29 20 39 2E 33 2E 30 00 00 00
04 00 00 00 10 00 00 00 05 00 00 00 47 4E 55 00
02 00 00 C0 04 00 00 00 03 00 00 00 00 00 00 00
14 00 00 00 00 00 00 00 01 7A 52 00 01 78 10 01
1B 0C 07 08 90 01 00 00 1C 00 00 00 1C 00 00 00
00 00 00 00 1B 00 00 00 00 45 0E 10 86 02 43 0D
06 52 0C 07 08 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
01 00 00 00 04 00 F1 FF 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 03 00 01 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 03 00 03 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 03 00 04 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 03 00 05 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 03 00 07 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 03 00 08 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 03 00 09 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

La visualisation de ce résultat intermédiaire n’est pas strictement nécessaire pour l’exécution du programme, mais facilite la compréhension globale.

4. L’édition de liens

Jusqu’à maintenant, nous avons produit le code machine correspondant à notre code source. Mais le code peut faire appel à des fonctions externes ou système qu’il faut « lier » à notre code source pour produire un programme réellement exécutable. Par exemple, notre programme minimal fait appel à la fonction printf(). dont le fichier objet est stocké quelque part dans le système. L’étape qui consiste à lier les différents fichiers objet nécessaires à l’exécution du programme s’appelle l’édition de liens.

La commande

$ gcc hello.c -o hello

réalise l’ensemble des 4 opérations décrites ci dessus:

  • le pré processing

  • la compilation

  • l’assemblage

  • et l’édition de lien

et produit un programme que l’on peut exécuter dans le terminal

$ ./hello
Hello World !

5. Récapitulatif

Le schéma ci dessus récapitule les différentes phases, et les fichiers intermédiaires utilisés par le processus global de compilation.

Le processus de compilation

6. Compilation modulaire

Les programmes C que l’on va écrire seront évidemment plus volumineux que le programme minimal qui nous sert de fil rouge dans ce chapitre.

Pour la lisibilité du code, et une organisation efficace, il sera important de pouvoir structurer un code volumineux en plusieurs fichiers, de façon à pouvoir réutiliser des fonctions écrites par ailleurs sans avoir à les copier/coller dans le code source que l’on écrit.

Pour pouvoir utiliser une fonction externe dans un programme, il y a deux prérequis :

  • son prototype doit être inclus dans le code qui l’utilise ;

  • le compilateur doit savoir où la trouver pour construire le programme exécutable.

Nous avons déjà utilisé cette façon de faire, en faisant appel à la fonction printf(). Pour cette dernière, le prototype <stdio.h> est défini dans la directive #include, et les fonctions système sont automatiquement liées par le compilateur.

Il y aura des situations pour lesquelles il faudra:

  • préciser la localisation des prototypes des fonctions que l’on a écrites ;

  • et indiquer au compilateur la localisation des fichiers objet correspondant.

Le prototype

Le prototype d’une fonction permet de définir toutes les informations nécessaires pour son utilisation : son nom, les paramètres qu’elle nécessite et le type de la valeur qu’elle retourne. Le prototype est constitué de la partie qui précède le corps de la fonction, terminé par un ;.

Ainsi,

int gcd(int a, int b);

est le prototype de

int gcd(int a, int b)
{
    if (b == 0)
    {
        return a;
    }
    else
    {
        return gcd(b, a % b);
    }
}

Les fichiers .h regroupent les prototypes des fonctions par famille. Ainsi :

  • le prototype de printf() est placé dans le fichier système <stdio.h> ;

  • le prototype de sqrt() est placé dans le fichier système <math.h> ;

  • le prototype de rand() est placé dans le fichier système <stdlib.h>.

Le prototype doit être inclus dans le code source avec une directive insérée en tête de fichier :

  • #include <nom_de_fichier.h> pour les fichiers systèmes ;

  • #include "nom_de_fichier.h" pour les autres fichiers ;

Pour illustrer ça, on considère le programme minimal suivant :

1int main(){
2    printf("Hello World !\n");
3    return 0;
4}

Si on essaye de compiler, une erreur se produit ligne 2 lorsqu’on essaye d’utiliser la fonction printf() :

$ gcc -std=c99 -Wall -Wextra hello.c -o hello
hello.c: In function ‘main’:
hello.c:2:2: warning: implicit declaration of function ‘printf’ [-Wimplicit-function-declaration]
    2 |  printf("Hello World !\n");
    |  ^~~~~~
hello.c:2:2: warning: incompatible implicit declaration of built-in function ‘printf’
hello.c:1:1: note: include ‘<stdio.h>’ or provide a declaration of ‘printf’
+++ |+#include <stdio.h>
    1 | int main(){

L’insertion de <stdio.h> règle le problème.

1#include <stdio.h>
2
3int main(){
4    printf("Hello World !\n");
5    return 0;
6}