Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter

Support de cours · chapitre 1

Compiler et exécuter

Du fichier source au programme qui tourne - préprocesseur, compilateur, assembleur, éditeur de liens et chargeur. Les sections de l'exécutable (text, rodata, data, bss) et leurs droits. Les options de gcc, les programmes en plusieurs fichiers, les fichiers d'en-tête et make.

Un premier programme

Hello World

#include <stdio.h>

int main(void)
{
    printf("Hello World!\n");
    return 0;
}

On l'enregistre dans hello_world.c, et on le compile avec gcc :

$ ls
hello_world.c
$ gcc hello_world.c
$ ls
a.out  hello_world.c
$ ./a.out
Hello World!

gcc a produit un exécutable, un fichier de code machine que le système sait charger et lancer. Faute d'autre indication, il s'appelle a.out. Il faut le lancer avec ./a.out : le shell ne cherche les commandes que dans les répertoires de la variable PATH, dont le répertoire courant ne fait pas partie.

Quelques remarques sur ce programme :

  • Tout programme C commence par la fonction main. Elle rend un entier, le code de retour du programme : 0 si tout s'est bien passé, autre chose sinon. Le shell le garde dans $?.
  • void entre les parenthèses dit que main ne prend pas d'argument. On verra au chapitre 6 la version qui reçoit la ligne de commande.
  • printf n'est pas une instruction du langage, c'est une fonction de la bibliothèque standard. La ligne #include <stdio.h> rend sa déclaration visible.

Les options utiles

$ gcc -std=c11 -Wall -Wextra -Werror -O2 -o hello hello_world.c
$ ./hello
Hello World!
OptionEffet
-o hellonomme l'exécutable hello plutôt que a.out
-std=c11suit la norme C de 2011 (sans elle, gcc accepte ses propres extensions)
-Wallactive les avertissements courants (le nom est trompeur : pas tous)
-Wextraen ajoute d'autres
-Werrortransforme chaque avertissement en erreur : le programme ne compile pas tant qu'il en reste
-O2optimise (-O0 : aucune optimisation, -O3 : plus agressif)
-ggarde les informations de débogage, pour gdb et valgrind

Par exemple, sans #include <stdio.h> :

int main(void)
{
    printf("oups\n");
    return 0;
}
$ gcc -std=c11 -Wall -Wextra oubli.c
oubli.c: In function 'main':
oubli.c:3:5: warning: implicit declaration of function 'printf'
oubli.c:1:1: note: include '<stdio.h>' or provide a declaration of 'printf'

Le compilateur a vu passer un appel à une fonction qu'il ne connaît pas. Il ne sait donc pas quels arguments elle attend ni ce qu'elle rend. Sans -Werror, il produit quand même un exécutable, qui marche ici par chance.

De la source à l'exécution

Le mot « compiler » recouvre en fait plusieurs étapes, faites chacune par un outil différent. gcc les enchaîne toutes pour nous, mais on peut les faire une à une.

flowchart LR
    A[hello.c] -->|préprocesseur| B[source pur]
    B -->|compilateur| C[hello.s]
    C -->|assembleur| D[hello.o]
    D -->|éditeur de liens| E[a.out]
    L[bibliothèques] --> E
    E -->|chargeur| P[processus]

Le préprocesseur

Il traite les lignes qui commencent par #, et ne fait que du texte : il ne connaît rien au C.

  • #include <stdio.h> est remplacé par le contenu du fichier stdio.h ;
  • #define TAILLE 100 fait remplacer chaque TAILLE qui suit par 100 ;
  • #if, #ifdef, #ifndef … #endif gardent ou retirent des morceaux du texte : c'est la compilation conditionnelle.

gcc -E s'arrête après cette étape et affiche le résultat. Nos sept lignes en deviennent plus de huit cents : tout stdio.h, et ce qu'il inclut lui-même.

$ gcc -E hello_world.c | wc -l
820

Le compilateur

C'est le cœur : il traduit le C en assembleur, le langage textuel du processeur. gcc -S s'arrête là et produit hello_world.s :

main:
        pushq   %rbp
        movq    %rsp, %rbp
        leaq    .LC0(%rip), %rax
        movq    %rax, %rdi
        call    puts@PLT
        movl    $0, %eax
        popq    %rbp
        ret

On y reconnaît la chaîne, rangée à part sous l'étiquette .LC0, et l'appel de fonction. Le compilateur a même remplacé printf par puts, plus simple, puisque la chaîne ne contient aucun %.

L'assembleur

Il traduit l'assembleur en code machine, dans un fichier objet .o. gcc -c s'arrête là. Un fichier objet n'est pas encore exécutable : il contient le code de nos fonctions, et la liste de celles qu'il utilise sans les avoir. nm affiche cette liste :

$ gcc -c hello_world.c
$ nm hello_world.o
0000000000000000 T main
                 U puts

T : main est définie ici, dans le code (text). U : puts est utilisée, mais undefined, définie ailleurs.

L'éditeur de liens

Il rassemble les fichiers objets et les bibliothèques, et relie chaque utilisation d'une fonction à sa définition. Il produit l'exécutable. S'il manque une définition, c'est lui qui se plaint, pas le compilateur :

void dire_bonjour(void);

int main(void)
{
    dire_bonjour();
    return 0;
}
$ gcc lien.c
/usr/bin/ld: /tmp/ccuTbmNs.o: in function `main':
lien.c:(.text+0x9): undefined reference to `dire_bonjour'
collect2: error: ld returned 1 exit status

Le compilateur était content : on lui a déclaré dire_bonjour, il savait comment l'appeler. Mais personne ne l'a définie, et l'éditeur de liens (ld) ne trouve son code nulle part.

Le chargeur

Au lancement, le système charge l'exécutable en mémoire, le relie aux bibliothèques dynamiques dont il a besoin, puis l'exécute. Le programme en cours d'exécution s'appelle un processus (chapitre 6).

Bibliothèques statiques et dynamiques

Une bibliothèque est un ensemble de fonctions déjà compilées : la bibliothèque standard du C (la libc) contient printf, malloc, strlen… Elle peut être liée de deux façons.

Où est le coderecopié dans l'exécutablereste dans la bibliothèque, chargé au lancement
Taille de l'exécutableplus grandeplus petite
Sur la machine qui l'exécuterien à installerla bibliothèque doit être présente
Si dix programmes l'utilisentdix copies en mémoireune seule, partagée
Correction d'un bogue de la bibliothèqueil faut relier chaque programmeil suffit de remplacer la bibliothèque

Par défaut, gcc lie la libc dynamiquement. ldd affiche les bibliothèques dont un exécutable aura besoin :

$ ldd a.out
        linux-vdso.so.1
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
        /lib64/ld-linux-x86-64.so.2
$ gcc -static hello_world.c -o hello_static
$ ls -l a.out hello_static
-rwxrwxr-x 1 philr philr  15960 a.out
-rwxrwxr-x 1 philr philr 785360 hello_static

ld-linux-x86-64.so.2 est le chargeur lui-même. La version statique pèse cinquante fois plus : elle embarque tout ce dont printf a besoin.

Dans l'exécutable : les sections

Un fichier objet, et après lui l'exécutable, n'est pas un bloc uniforme : il est rangé en sections, une par sorte de contenu. Au lancement, le chargeur place chacune à son endroit de la mémoire, et lui donne des droits : lecture (r), écriture (w), exécution (x). Le processeur les fait respecter à chaque accès. Quatre sections comptent pour le programmeur :

SectionCe qu'on y trouveLireÉcrireExécuterDans le fichier
.textle code machine des fonctionsouinonouioui
.rodatales constantes, les chaînes littéralesouinonnonoui
.datales variables globales et static initialiséesouiouinonoui, avec leur valeur
.bssles variables globales et static sans initialisation, ou à zéroouiouinonnon, seulement leur taille

Aucune n'est à la fois modifiable et exécutable. Les variables locales ordinaires ne sont dans aucune : elles n'existent que sur la pile, pendant l'appel, et les zones de malloc sont dans le tas (chapitre 3).

Qui va où

#include <stdio.h>

const char salut[] = "bonjour";  /* constante      : .rodata */
int compteur = 42;               /* initialisée    : .data   */
int tableau[1000];               /* sans initialisation, donc à zéro : .bss */

int main(void)
{
    static int appels;           /* locale statique, à zéro : .bss */
    appels++;
    compteur++;
    tableau[0] = compteur;
    printf("%s %d %d %d\n", salut, compteur, tableau[0], appels);
    return 0;
}

nm indique la section de chaque symbole par une lettre, en majuscule pour un symbole visible des autres fichiers, en minuscule pour un symbole local :

$ gcc -c sections.c
$ nm sections.o
0000000000000fa0 b appels.0
0000000000000000 D compteur
0000000000000000 T main
                 U printf
0000000000000000 R salut
0000000000000000 B tableau

T pour text, R pour read-only data, D pour data, B pour bss. La variable locale appels est rangée comme une globale, car static lui donne la durée de vie du programme, mais sous un nom privé (appels.0) que les autres fichiers ne voient pas.

size -A donne la taille de chaque section :

$ size -A sections.o
sections.o  :
section              size   addr
.text                 112      0
.data                   4      0
.bss                 4004      0
.rodata                21      0
.comment               46      0
.note.GNU-stack         0      0
.note.gnu.property     32      0
.eh_frame              56      0
Total                4275

Les quatre dernières lignes sont des informations pour les outils (version du compilateur, déroulement de la pile pour le débogueur), qui ne concernent pas le programme.

.text : le code

.text contient les instructions de toutes les fonctions : ici les 112 octets de main. Le processeur ne peut exécuter que ce qui est dans une page marquée x, et c'est la seule section qui l'est.

Elle est en lecture seule : un programme ne peut pas modifier son propre code. C'est une protection contre les erreurs, un pointeur fou ne peut pas écraser une fonction, et contre les attaques, qui cherchent justement à faire exécuter des octets qu'elles ont écrits. Comme elle ne change jamais, le système peut aussi la partager : dix processus qui exécutent le même programme n'en ont qu'une copie en mémoire.

.rodata : les constantes

.rodata (read-only data) contient ce que le programme lit sans jamais le modifier : les variables globales déclarées const, et les chaînes littérales. Ici 21 octets : "bonjour" (8, avec son '\0') et le format de printf, "%s %d %d %d\n" (13).

Elle est en lecture seule, et pas exécutable. C'est le système, et non le compilateur, qui garantit qu'une constante ne change pas : si l'on retire le const de force, le programme compile, mais meurt à la première écriture. C'est pour cela que char *s = "bonjour"; s[0] = 'B'; plante (chapitre 3).

.data : les variables initialisées

.data contient les variables globales et static qui ont une valeur initiale non nulle : ici les 4 octets de compteur, qui valent 42. Cette valeur est écrite dans le fichier, et le chargeur la recopie en mémoire au lancement.

Elle est en lecture et écriture : le programme modifie ces variables librement. Chaque processus en a donc sa propre copie, qu'il ne partage avec personne.

.bss : les variables à zéro

.bss contient les variables globales et static sans initialisation, ou initialisées à zéro : ici tableau et appels, 4 004 octets. La norme impose qu'elles valent zéro au démarrage.

Comme tout y vaut zéro, le fichier n'a pas besoin de contenir ces octets : il note seulement la taille de la section, et au lancement le chargeur réserve la mémoire, remplie de zéros. Le fichier objet ci-dessus pèse 2 000 octets en tout, moins que le seul tableau. On le vérifie en donnant une valeur à une seule case, int tableau[1000] = {1}; : le tableau passe dans .data, et ses mille entiers entrent dans le fichier.

$ size sections gros
   text    data     bss     dec     hex filename
   1471     604    4040    6115    17e3 sections
   1471    4648       8    6127    17ef gros
$ ls -l sections gros
-rwxrwxr-x 1 philr philr 20136 gros
-rwxrwxr-x 1 philr philr 16104 sections

Sans l'option -A, size regroupe les sections en trois colonnes, et compte .rodata avec le code dans text, puisque les deux sont en lecture seule. Les chiffres de l'exécutable dépassent ceux du fichier objet : l'éditeur de liens a ajouté, autour de main, le code de démarrage du programme et ses données.

Une fois chargée, .bss se comporte exactement comme .data : lecture et écriture. C'est ce qui fait qu'en C une variable globale ou static non initialisée vaut zéro sans que cela coûte rien, alors qu'une variable locale non initialisée contient n'importe quoi. Le nom vient d'un assembleur des années 1950 : Block Started by Symbol.

Les droits, vérifiés

Sous Linux, /proc/self/maps décrit la mémoire du processus qui le lit : une ligne par zone, avec ses adresses et ses droits. Ce programme affiche une adresse de chaque section, puis les premières lignes, celles de l'exécutable :

#include <stdio.h>

const char salut[] = "bonjour";
int compteur = 42;
int tableau[1000];

int main(void)
{
    printf(".text   main     %p\n", (void *)main);
    printf(".rodata salut    %p\n", (void *)salut);
    printf(".data   compteur %p\n", (void *)&compteur);
    printf(".bss    tableau  %p\n", (void *)tableau);
    fflush(stdout);

    FILE *f = fopen("/proc/self/maps", "r");
    char ligne[256];
    for (int i = 0; i < 5 && fgets(ligne, sizeof ligne, f) != NULL; i++)
        fputs(ligne, stdout);
    fclose(f);
    return 0;
}
.text   main     0x5b03e4c61209
.rodata salut    0x5b03e4c62008
.data   compteur 0x5b03e4c64010
.bss    tableau  0x5b03e4c64040
5b03e4c60000-5b03e4c61000 r--p  ...  droits
5b03e4c61000-5b03e4c62000 r-xp  ...  droits
5b03e4c62000-5b03e4c63000 r--p  ...  droits
5b03e4c63000-5b03e4c64000 r--p  ...  droits
5b03e4c64000-5b03e4c65000 rw-p  ...  droits

(Les chemins sont raccourcis, et les adresses changent à chaque exécution.) main tombe dans la page r-x : lecture et exécution. salut dans une page r-- : lecture seule. compteur et tableau dans la page rw- : lecture et écriture, et aucune page n'est à la fois w et x. La première page r-- contient l'en-tête du fichier ; la quatrième, des données que l'éditeur de liens dynamiques remplit au lancement puis protège en écriture.

Écrire dans chaque section confirme ces droits :

#include <stdio.h>
#include <string.h>

const char salut[] = "bonjour";  /* .rodata */
int compteur = 42;               /* .data   */
int tableau[1000];               /* .bss    */

int main(int argc, char *argv[])
{
    compteur = 7;                /* .data : permis */
    tableau[999] = 7;            /* .bss  : permis */
    printf("data %d, bss %d\n", compteur, tableau[999]);

    if (argc > 1 && strcmp(argv[1], "rodata") == 0) {
        char *p = (char *)salut; /* on retire le const de force */
        p[0] = 'B';
        printf("rodata modifiée : %s\n", salut);
    }
    if (argc > 1 && strcmp(argv[1], "text") == 0) {
        unsigned char *code = (unsigned char *)main;
        code[0] = 0xc3;
        printf("code modifié\n");
    }
    return 0;
}
$ ./ecrire
data 7, bss 7
$ ./ecrire rodata
data 7, bss 7
Segmentation fault (core dumped)
$ ./ecrire text
data 7, bss 7
Segmentation fault (core dumped)

Le compilateur a tout accepté : les conversions forcées lui disent de se taire. C'est le système qui arrête le programme, à la première écriture dans une page qui ne l'autorise pas. Aucun des deux printf qui suivent ne s'exécute. (Convertir l'adresse d'une fonction en pointeur de données n'est pas permis par la norme, -pedantic le signale ; c'est accepté sous Linux, et commode pour cette démonstration.)

Plusieurs fichiers

Découper un programme

Un vrai programme ne tient pas dans un fichier. On le découpe en modules, chacun avec deux fichiers :

  • un fichier source .c, qui définit les fonctions (leur code) ;
  • un fichier d'en-tête .h (header), qui les déclare (leur prototype), pour que les autres fichiers puissent les utiliser.

bonjour.h :

#ifndef BONJOUR_H
#define BONJOUR_H

void dire_bonjour(void);

#endif

bonjour.c :

#include <stdio.h>
#include "bonjour.h"

void dire_bonjour(void)
{
    printf("Bonjour !\n");
}

aurevoir.h et aurevoir.c sont bâtis de la même façon, avec une fonction dire_au_revoir. Enfin salut.c :

#include "bonjour.h"
#include "aurevoir.h"

int main(void)
{
    dire_bonjour();
    dire_au_revoir();
    return 0;
}

#include <…> cherche le fichier dans les répertoires du système ; #include "…" le cherche d'abord à côté du fichier source. On utilise les guillemets pour ses propres en-têtes.

Les gardes d'inclusion

Les trois lignes #ifndef BONJOUR_H, #define BONJOUR_H et #endif sont une garde d'inclusion. La première fois que le préprocesseur lit le fichier, BONJOUR_H n'est pas défini : il garde le contenu, et définit BONJOUR_H. Les fois suivantes, BONJOUR_H est défini : tout le contenu est sauté.

Sans elle, un en-tête inclus deux fois (directement, ou parce que deux autres en-têtes l'incluent) ferait apparaître deux fois ses définitions de types, ce que le compilateur refuse. Tout fichier d'en-tête a sa garde, avec un nom qui lui est propre.

Compiler séparément

On produit un fichier objet par fichier source, puis on les relie :

$ gcc -std=c11 -Wall -Wextra -Werror -c bonjour.c     # produit bonjour.o
$ gcc -std=c11 -Wall -Wextra -Werror -c aurevoir.c    # produit aurevoir.o
$ gcc -std=c11 -Wall -Wextra -Werror -c salut.c       # produit salut.o
$ gcc -o salut salut.o bonjour.o aurevoir.o
$ ./salut
Bonjour !
Au revoir !

L'intérêt apparaît quand on modifie un fichier. Si l'on change le message de aurevoir.c, il suffit de refaire aurevoir.o et l'édition de liens : salut.c et bonjour.c n'ont pas changé, leurs fichiers objets restent bons. Sur un programme de quelques milliers de fichiers, c'est la différence entre une seconde et une demi-heure.

Encore faut-il savoir quoi recompiler. Si l'on modifie bonjour.h, il faut recompiler bonjour.c, mais aussi salut.c, qui l'inclut. C'est le travail de make.

make

Un premier Makefile

make lit un fichier Makefile, qui décrit des règles :

cible: dépendances
<TAB>commande

Pour produire la cible, make exécute la commande, mais seulement si la cible n'existe pas ou si l'une de ses dépendances est plus récente qu'elle. Et il applique la même logique, d'abord, à chaque dépendance.

CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -Werror -O2

salut: salut.o bonjour.o aurevoir.o
	$(CC) -o salut salut.o bonjour.o aurevoir.o

salut.o: salut.c bonjour.h aurevoir.h
	$(CC) $(CFLAGS) -c salut.c

bonjour.o: bonjour.c bonjour.h
	$(CC) $(CFLAGS) -c bonjour.c

aurevoir.o: aurevoir.c aurevoir.h
	$(CC) $(CFLAGS) -c aurevoir.c

clean:
	rm -f salut *.o

CC et CFLAGS sont des variables : on les définit une fois, on les utilise avec $(…). La première règle du fichier est celle que make exécute par défaut ; make clean exécute la règle clean, qui n'a pas de dépendance et ne produit aucun fichier.

$ make
gcc -std=c11 -Wall -Wextra -Werror -O2 -c salut.c
gcc -std=c11 -Wall -Wextra -Werror -O2 -c bonjour.c
gcc -std=c11 -Wall -Wextra -Werror -O2 -c aurevoir.c
gcc -o salut salut.o bonjour.o aurevoir.o
$ ./salut
Bonjour !
Au revoir !

On modifie aurevoir.c, et on relance :

$ make
gcc -std=c11 -Wall -Wextra -Werror -O2 -c aurevoir.c
gcc -o salut salut.o bonjour.o aurevoir.o
$ make
make: 'salut' is up to date.

Seul aurevoir.o a été refait, puis l'exécutable. Au second appel, tout est à jour : il n'y a rien à faire.

Aller plus loin

make connaît déjà des règles implicites : il sait fabriquer un .o à partir du .c du même nom, avec $(CC) $(CFLAGS) -c. On pourrait donc se contenter d'écrire les dépendances. Il a aussi des variables automatiques ($@ pour la cible, $^ pour toutes les dépendances), des règles génériques, et peut calculer lui-même quels en-têtes chaque fichier inclut. Pour en savoir plus : Using GNU Make, de Marwan Burelle, et le manuel de GNU Make.