Rappels sur la compilation avec gcc#
Les différentes étapes de la compilation#
La compilation d’un programme C est un processus en plusieurs étapes, chacune transformant le code d’un format à un autre.
[.c] ---> [.i] ---> [.s] ---> [.o] ---+---> [ELF]
|
[.so] --+
Étape 1 : Preprocessing (Prétraitement)#
Commande : gcc -E fichier.c -o fichier.i
Le préprocesseur effectue les opérations suivantes :
Il supprime les commentaires du code source
Il développe les macros (#define)
Il inclut récursivement les fichiers d’en-tête (#include .h)
Il traite les directives conditionnelles (#ifdef, #ifndef, etc.)
À la fin de cette étape, nous obtenons du code C pur et compilable. Le fichier contient également des « line markers » (méta-données) commençant par #
, qui permettent de tracer l’origine du code (de quel include une ligne vient).
Exemple :
// Avant preprocessing
#include <stdio.h>
#define MAX 100
int main() {
printf("Max: %d\n", MAX);
}
// Après preprocessing (fichier .i)
// [Contenu de stdio.h et des includes imbriqués]
int main() {
printf("Max: %d\n", 100);
}
Étape 2 : Compilation#
Commande : gcc -S fichier.i -o fichier.s
Maintenant que nous avons tout le code C nécessaire au bon fonctionnement de notre programme, il faut le traduire en un langage plus compréhensible pour la machine. Cette étape se charge de :
L’analyse syntaxique du code
La vérification des types
L’optimisation du code (-O1, -O2, -O3)
La génération du code assembleur pour l’architecture ciblée
La majorité des optimisations s’effectue à cette étape. L’objectif est de bien traduire le langage de haut niveau (le C dans notre exemple) en code assembleur tout en tirant parti des optimisations offertes par l’architecture ciblée.
Le code assembleur se compose d’un mnémonique d’opération et d’opérandes. Il reste compréhensible par l’humain, mais moins que les langages de haut niveau comme le C ou Java.
; 'movl' est le mnémonique, '$100' et '%esi' sont les opérandes
movl $100, %esi
Options importantes :
-march=architecture
: Spécifie l’architecture cible-O[0-3]
: Niveau d’optimisation-fverbose-asm
: Ajoute des commentaires dans l’assembleur
Exemple :
.file "exemple.c"
.text
.section .rodata
.LC0:
.string "Max: %d\n"
.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
movl $100, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
Étape 3 : Assembly (Assemblage)#
Commande :
Via GCC :
gcc -c fichier.s -o fichier.o
Direct :
as fichier.s -o fichier.o
Malheureusement, même le code assembleur n’est pas compréhensible par nos CPU qui ne fonctionnent qu’avec des 0 et des 1. L’assembleur as
se charge de faire la dernière traduction en transformant les instructions en valeurs binaires. Cette représentation binaire est directement exécutable par le processeur, elle est compréhensibles par le décodeur du CPU, la partie qui dit aux autres composants quoi faire.
L’assembleur :
Convertit le code assembleur en code machine
Crée une table des symboles
Génère des relocations si nécessaire
Produit un fichier objet au format ELF
Étape 4 : Linkage (Édition des liens)#
Commande :
Via GCC :
gcc fichier.o -o executable
Direct :
ld fichier.o -o executable
On a tendance à bien structurer notre code et à le séparer en plusieurs fichiers pour mieux s’y retrouver lors de la relecture, que ce soit nous ou une autre personne. Il se peut même qu’on fasse appel à du code que nous n’avons pas écrit nous-mêmes, la libc étant le meilleur exemple (longue vie à printf !).
Notre exécutable doit inclure tous nos différents fichiers et aussi faire référence aux bibliothèques auxquelles on fait appel. Tout cela est géré par l’éditeur de liens ld
qui :
Résout les symboles externes
Combine les fichiers objets
Lie les bibliothèques dynamiques (libc, …) au fichier
Génère l’exécutable final
Je vais réécrire la section sur le Linkage pour inclure ces informations importantes :
Étape 4 : Linkage (Édition des liens)#
Commande :
Via GCC :
gcc fichier.o -o executable
Direct :
ld fichier.o -o executable
On a tendance à bien structurer notre code et à le séparer en plusieurs fichiers pour mieux s’y retrouver lors de la relecture, que ce soit nous ou une autre personne. Il se peut même qu’on fasse appel à du code que nous n’avons pas écrit nous-mêmes, la libc étant le meilleur exemple (longue vie à printf !).
L’éditeur de liens ld
(invoqué par gcc
) a pour mission de rassembler tous ces morceaux de code. Il existe deux façons principales de lier ces différentes parties :
La liaison statique :
Toutes les bibliothèques sont directement copiées dans l’exécutable final
Avantages : l’exécutable est autonome et portable
Inconvénients : taille plus importante de l’exécutable, pas de mise à jour automatique des bibliothèques
La liaison dynamique (avec les .so) :
Seules les références aux bibliothèques sont incluses dans l’exécutable
Les bibliothèques partagées (.so - Shared Objects sous Linux) sont chargées au moment de l’exécution (les pages mémoire sont partagées par tous les processus l’utilisant)
Avantages :
Économie d’espace disque (les .so sont partagées entre les programmes)
Mise à jour des bibliothèques sans recompiler les programmes
Chargement à la demande des bibliothèques
Inconvénients :
Dépendance aux bibliothèques installées sur le système
Potentielles incompatibilités lors des mises à jour
L’éditeur de liens effectue donc plusieurs tâches cruciales :
Résout les symboles externes (trouve où se trouvent les fonctions et variables utilisées)
Combine les fichiers objets (.o)
Gère les liaisons avec les bibliothèques (statiques .a ou dynamiques .so)
Vérifie que toutes les dépendances sont satisfaites
Génère l’exécutable final au format ELF
Exemple pratique :
# Création d'une bibliothèque partagée
gcc -shared -fPIC ma_lib.c -o libma_lib.so
# Compilation avec liaison dynamique (par défaut)
gcc mon_prog.c -L. -lma_lib -o prog
# Compilation avec liaison statique
gcc mon_prog.c -static -L. -lma_lib -o prog_static
Cette étape finale du processus de compilation est cruciale car elle détermine non seulement comment notre programme va s’exécuter, mais aussi comment il va interagir avec le reste du système.
Références :
Récupérer l’assembleur d’un ELF#
objdump#
objdump
est un outil puissant de la suite binutils qui permet d’analyser et de désassembler des fichiers objets et des exécutables.
Option |
Description |
Exemple d’utilisation |
---|---|---|
|
Désassemble les sections contenant des instructions |
|
|
Désassemble toutes les sections (y compris .data) sauf le .bss |
|
|
Mélange code source si disponible et assembleur |
|
|
Affiche uniquement la section spécifiée |
|
|
Identique à -j |
|
|
Utilise la syntaxe Intel |
|
|
Utilise la syntaxe AT&T (par défaut) |
|
nm#
Affiche les symboles d’un fichier objet :
nm fichier.o
readelf#
Analyse détaillée des fichiers ELF :
# En-tête ELF
readelf -h executable
# Table des sections
readelf -S executable
# Segments (Program Headers)
readelf -l executable