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 :0si tout s'est bien passé, autre chose sinon. Le shell le garde dans$?. voidentre les parenthèses dit quemainne prend pas d'argument. On verra au chapitre 6 la version qui reçoit la ligne de commande.printfn'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!
| Option | Effet |
|---|---|
-o hello | nomme l'exécutable hello plutôt que a.out |
-std=c11 | suit la norme C de 2011 (sans elle, gcc accepte ses propres extensions) |
-Wall | active les avertissements courants (le nom est trompeur : pas tous) |
-Wextra | en ajoute d'autres |
-Werror | transforme chaque avertissement en erreur : le programme ne compile pas tant qu'il en reste |
-O2 | optimise (-O0 : aucune optimisation, -O3 : plus agressif) |
-g | garde 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 fichierstdio.h;#define TAILLE 100fait remplacer chaqueTAILLEqui suit par100;#if,#ifdef,#ifndef…#endifgardent 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 code | recopié dans l'exécutable | reste dans la bibliothèque, chargé au lancement |
|---|---|---|
| Taille de l'exécutable | plus grande | plus petite |
| Sur la machine qui l'exécute | rien à installer | la bibliothèque doit être présente |
| Si dix programmes l'utilisent | dix copies en mémoire | une seule, partagée |
| Correction d'un bogue de la bibliothèque | il faut relier chaque programme | il 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 :
| Section | Ce qu'on y trouve | Lire | Écrire | Exécuter | Dans le fichier |
|---|---|---|---|---|---|
.text | le code machine des fonctions | oui | non | oui | oui |
.rodata | les constantes, les chaînes littérales | oui | non | non | oui |
.data | les variables globales et static initialisées | oui | oui | non | oui, avec leur valeur |
.bss | les variables globales et static sans initialisation, ou à zéro | oui | oui | non | non, 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.