Principes des Langages de Programmation La chaine de compilation
John Samuel
CPE Lyon
Année: 2024-2025
Courriel: john.samuel@cpe.fr
1. Prétraitement (Étape 1)
#include
, qui peuvent être des fichiers d’en-tête ou des fragments de code
source.#ifdef
, #ifndef
, #else
et
#endif
.
La sortie du prétraitement est une version modifiée du code source original, qui est ensuite transmise à l’étape suivante.
2. Analyse lexicale (Étape 2)
La sortie de l’analyse lexicale est une liste de jetons, qui est ensuite transmise à l’analyseur syntaxique pour un traitement ultérieur.
3. Analyse syntaxique (Étape 3)
La sortie de l’analyse syntaxique est un AST validé, qui représente la structure syntaxique du programme. Cette étape garantit que le code source respecte les règles de syntaxe du langage.
4. Analyse sémantique (Étape 4)
La sortie de l’analyse sémantique est un AST validé avec des informations de type et de portée. Cette étape garantit que le code source respecte les règles sémantiques du langage.
5. Génération de code intermédiaire (Étape 5)
La sortie de la génération de code intermédiaire est une représentation intermédiaire indépendante de la plate-forme, qui peut être exécutée par un environnement d’exécution. Cette étape garantit que le code source est converti en une forme qui peut être exécutée de manière efficace.
6. Génération de code natif (Étape 6)
La sortie de la génération de code natif est le code exécutable final qui peut être exécuté directement sur la plate-forme cible. Cette étape garantit que le code source est traduit en code machine optimisé pour l’exécution.
Supposons que nous ayons un programme “Hello, World!” simple écrit en C :
#include <stdio.h>
int main() {
printf("Bonjour le monde !!!\n");
return 0;
}
Le processus de compilation se déroulerait comme suit :
#include
pour
inclure le fichier d’en-tête de la bibliothèque standard.La sortie finale est le code exécutable qui peut être exécuté directement sur la plate-forme cible.
Cette étape consiste à traiter les directives de préprocesseur contenues dans le code source, telles que les directives d'inclusion (#include) et de remplacement de macros (#define). Le résultat est généralement un fichier source modifié avec ces directives résolues.
#ifndef PI
#define PI 3.14159
#endif
float aire(
float rayon) {
return(PI * rayon
* rayon);
}
$ gcc -E cercle.c
# 1 "cercle.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2
# 1 "cercle.c"
float aire(float rayon) {
return(3.14 * rayon * rayon);
}
Le résultat affiche le code source après le traitement des directives de préprocesseur. Cela peut inclure des inclusions de fichiers, la résolution de macros et d'autres transformations avant la compilation proprement dite.
Ces étapes montrent comment le code source est transformé en diverses représentations intermédiaires avant d'atteindre le code machine final. Chacune de ces étapes permet une analyse et une optimisation du code à différents niveaux.
$ gcc -v bonjour.c # les étapes importantes
$ gcc -save-temps -v bonjour.c # les fichiers *.i, *.s
$ gcc -fdump-tree-all bonjour.c
$ gcc -fdump-rtl-all bonjour.c
Ce code généré montre comment les opérations mathématiques sont exprimées en AST (Abstract Syntax Tree - arbre de syntaxe abstraite).
$ gcc -fdump-tree-all -c cercle.c
$ cat cercle.c.005t.gimple
aire (float rayon)
{
float D.1914;
_1 = (double) rayon;
_2 = _1 * 3.140000000000000124344978758017532527446746826171875e+0;
_3 = (double) rayon;
_4 = _2 * _3;
D.1914 = (float) _4;
return D.1914;
}
$ gcc -O2 bonjour.c
L'option -S indique au compilateur de produire le code assembleur au lieu de générer un exécutable. Le résultat de cette étape est un fichier avec l'extension .s, qui contient le code assembleur généré.
$ gcc -S bonjour.c
$ cat bonjour.s
int main() {
int num = 2 + 3;
return(0);
}
$ gcc -O0 -S somme.c
L'absence d'optimisation (-O0) signifie que le compilateur n'a pas appliqué d'optimisations particulières, ce qui permet d'obtenir un code assembleur directement équivalent au code C source.
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $5, -4(%rbp)
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
int main() {
int num = 2 + 3;
return(0);
}
$ gcc -O2 -S somme.c
somme.s
main:
.LFB0:
.cfi_startproc
xorl %eax, %eax
ret
.cfi_endproc
Compilation (optimisation: 2) : L'optimisation de niveau 2 a permis au compilateur de reconnaître que l'addition 2 + 3 est constante et que la valeur de retour de la fonction est toujours 0. Par conséquent, le calcul inutile est éliminé, ce qui donne un code assembleur très efficace et court. Cette optimisation est typique des optimisations de constantes que le compilateur peut effectuer pour améliorer les performances du code.
$ gcc bonjour.c
$ file a.out
a.out: ELF 64-bit LSB shared object, x86-64
Cela signifie que le programme compilé est conçu pour être exécuté sur une machine d'architecture 64 bits, spécifiquement une architecture x86-64.
Cette commande indique au compilateur GCC de générer un exécutable 32 bits en utilisant l'architecture i686 (Intel 80386). L'option -m32 spécifie que l'exécutable doit être compilé en tant qu'application 32 bits.
$ gcc -march=i686 -m32 bonjour.c
Cette commande est similaire à la précédente, mais elle génère également le code assembleur (-S) pour le programme.
$ gcc -S -march=i686 -m32 bonjour.c
$ file ./a.out
./a.out: ELF 32-bit LSB shared object, Intel 80386
Cette commande compile le fichier source "client.c" en un fichier objet "client.o". L'option -c indique au compilateur de générer uniquement le fichier objet sans produire d'exécutable. Cela permet de diviser le processus de compilation en deux étapes.
$ gcc -c client.c
$ gcc -c color.c
Cette commande prend les fichiers objets "color.o" et "client.o" et les compile en un exécutable nommé "color". Le compilateur lie les fichiers objets pour créer l'exécutable final.
$ gcc -o color color.o client.o
Cette approche est couramment utilisée pour séparer la compilation en plusieurs étapes, ce qui peut être utile dans des projets plus importants où différentes parties du programme sont gérées séparément avant d'être liées ensemble pour former l'exécutable final.
$ gcc -c client.c color.c
$ gcc -o client client.o color.o
$ vim client.c # Modification du fichier
$ gcc -c client.c
$ vim client.c # Modification du fichier
$ gcc -c client.c
$ gcc -o client client.o color.o
L'objectif de cette séquence de commandes est de compiler des fichiers source en fichiers objet, puis de recompiler le programme avec des modifications apportées au fichier source "client.c". Cela permet de mettre à jour le programme en reflétant les changements apportés au code source sans avoir à recompiler entièrement toutes les sources. Cela peut accélérer le processus de développement en évitant de recompiler inutilement les parties du programme qui n'ont pas changé.
L’interprétation de Python suit un processus multi-étapes qui permet de transformer le code source en exécution sur la machine. Voici les étapes importantes :
Lors de l’analyse lexicale, le code source (fichier .py
) est lu et divisé en
tokens, les plus petites unités significatives du code. Un token représente
généralement des éléments comme :
def
, if
, else
)
+
, -
, =
, etc.)()
, crochets []
,
etc.)L’analyse lexicale est gérée par un lexeur qui scanne le texte ligne par ligne pour générer des tokens. Si une erreur de syntaxe est trouvée, une exception est levée à ce stade.
Pour le code x = 5 + 10
, les tokens générés seront
x
=
5
+
10
import tokenize
from io import BytesIO
code = """
def somme(a, b):
return a + b
"""
# Convertir le code en flux de bytes pour le tokenizer
code_bytes = BytesIO(code.encode('utf-8')).readline
# Tokenizer le code
tokens = tokenize.tokenize(code_bytes)
# Afficher les tokens
for token in tokens:
print(token)
TokenInfo(type=63 (ENCODING), string='utf-8', start=(0, 0), end=(0, 0), line='')
TokenInfo(type=62 (NL), string='\n', start=(1, 0), end=(1, 1), line='\n')
TokenInfo(type=1 (NAME), string='def', start=(2, 0), end=(2, 3), line='def somme(a, b):\n')
TokenInfo(type=1 (NAME), string='somme', start=(2, 4), end=(2, 9), line='def somme(a, b):\n')
TokenInfo(type=54 (OP), string='(', start=(2, 9), end=(2, 10), line='def somme(a, b):\n')
TokenInfo(type=1 (NAME), string='a', start=(2, 10), end=(2, 11), line='def somme(a, b):\n')
TokenInfo(type=54 (OP), string=',', start=(2, 11), end=(2, 12), line='def somme(a, b):\n')
TokenInfo(type=1 (NAME), string='b', start=(2, 13), end=(2, 14), line='def somme(a, b):\n')
TokenInfo(type=54 (OP), string=')', start=(2, 14), end=(2, 15), line='def somme(a, b):\n')
TokenInfo(type=54 (OP), string=':', start=(2, 15), end=(2, 16), line='def somme(a, b):\n')
TokenInfo(type=4 (NEWLINE), string='\n', start=(2, 16), end=(2, 17), line='def somme(a, b):\n')
TokenInfo(type=5 (INDENT), string=' ', start=(3, 0), end=(3, 4), line=' return a + b\n')
TokenInfo(type=1 (NAME), string='return', start=(3, 4), end=(3, 10), line=' return a + b\n')
Inspecter les tokens à partir d'un fichier
import tokenize
with open('bonjour.py', 'rb') as fichier:
tokens = tokenize.tokenize(fichier.readline)
for token in tokens:
print(token)
Une fois les tokens générés, ils sont passés à l’analyse syntaxique qui construit un arbre syntaxique abstrait (AST). L’AST représente la structure hiérarchique du programme en termes de nœuds.
Chaque nœud de l’AST correspond à une construction syntaxique dans le code source, comme une affectation, une fonction, ou une boucle. Le rôle de l’analyse syntaxique est de vérifier que les tokens respectent les règles syntaxiques du langage Python (par exemple, l’ordre des déclarations, l’utilisation correcte des parenthèses, etc.).
Pour x = 5 + 10
, l’AST pourrait ressembler à ceci :
Assign
(Affectation)
Target
: x
(variable)Value
: Add
(addition)
5
(nombre)10
(nombre)Si une erreur syntaxique est détectée (comme des parenthèses mal fermées), une erreur de syntaxe est levée ici.
import ast
import astpretty
code = """
def somme(a, b):
return a + b
"""
# Parser le code en AST
arbre = ast.parse(code)
# Afficher l'AST avec indentation
astpretty.pprint(arbre, indent=4)
Module(
body=[
FunctionDef(
lineno=2,
col_offset=0,
end_lineno=3,
end_col_offset=16,
name='somme',
args=arguments(
posonlyargs=[],
args=[
arg(lineno=2, col_offset=10, end_lineno=2, end_col_offset=11, arg='a', annotation=None, type_comment=None),
arg(lineno=2, col_offset=13, end_lineno=2, end_col_offset=14, arg='b', annotation=None, type_comment=None),
],
Une fois l’AST validé, Python compile cet arbre en bytecode, un ensemble d’instructions de bas niveau que la machine virtuelle Python peut exécuter. Le bytecode est indépendant de la machine, ce qui permet à Python d’être portable sur différentes plateformes (Windows, Linux, macOS, etc.).
Le bytecode est stocké dans des fichiers .pyc
(ou .pyo
pour
les fichiers optimisés) dans un répertoire __pycache__
. Ces fichiers permettent d’éviter
une recompilation lors des exécutions ultérieures, accélérant ainsi le démarrage des programmes.
Pour x = 5 + 10
, le bytecode pourrait ressembler à ceci :
LOAD_CONST 5
LOAD_CONST 10
BINARY_ADD
STORE_NAME x
Vous pouvez voir le bytecode généré pour un script en utilisant le module
dis
:
import dis
def somme(a, b):
return a + b
dis.dis(somme)
3 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE
Le bytecode généré est ensuite exécuté par la machine virtuelle Python (PVM), qui est une boucle d’interprétation lisant les instructions du bytecode et les exécutant une par une.
La PVM gère des tâches comme : - L’allocation de la mémoire pour les variables et objets - L’exécution des opérations (comme les calculs mathématiques ou les appels de fonctions) - La gestion du contrôle de flux (boucles, conditions, etc.)
La machine virtuelle Python fait abstraction du matériel sous-jacent (processeur, architecture, etc.), ce qui garantit la portabilité de Python sur diverses plateformes.
Pendant l’exécution, Python surveille constamment les erreurs à l’aide de son gestionnaire
d’exceptions. Si une erreur se produit (comme une division par zéro ou une tentative
d’accès à une variable non définie), une exception est levée. Si elle n’est pas capturée par un bloc
try-except
, le programme s’arrête et affiche une trace d’erreur.
Python utilise un mécanisme de ramasse-miettes (garbage collector) pour gérer la mémoire. Lorsque des objets ne sont plus utilisés (aucune référence active à eux), Python libère automatiquement la mémoire qu’ils occupent. Cela se fait en arrière-plan pendant l’exécution du programme et garantit que la mémoire est utilisée efficacement.
import pdb
def somme(a, b):
pdb.set_trace() # Déclencher le débogueur ici
return a + b
# Appel de la fonction
somme(3, 5)
Les fichiers .pyc
sont des fichiers contenant le bytecode Python précompilé, et ils sont
générés automatiquement par Python lors de l'exécution d'un script pour accélérer les futures
exécutions.
python -m py_compile mon_script.py
Les fichiers .pyc
sont généralement stockés dans un dossier nommé __pycache__
dans le même répertoire que le fichier source. Leur nom inclut une partie indiquant la version de Python
utilisée, par exemple mon_script.cpython-310.pyc
pour Python 3.10.
Pour exécuter un fichier .pyc
, vous pouvez utiliser directement la commande python en
spécifiant le fichier .pyc.
python __pycache__/mon_script.cpython-310.pyc