Principes des Langages de Programmation
La chaine de compilation

John Samuel
CPE Lyon

Année: 2024-2025
Courriel: john.samuel@cpe.fr
Creative Commons License

3. Principes des Langages de Programmation : Objectifs

Objectifs

3.1. La chaine de compilation

6 étapes de la chaine de compilation

3.1. La chaine de compilation

6 étapes de la chaine de compilation

1. Prétraitement (Étape 1)

La sortie du prétraitement est une version modifiée du code source original, qui est ensuite transmise à l’étape suivante.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

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.1. La chaine de compilation

6 étapes de la chaine de compilation

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.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

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.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

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.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

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.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

Supposons que nous ayons un programme “Hello, World!” simple écrit en C :

#include <stdio.h>

int main() {
    printf("Bonjour le monde !!!\n");
    return 0;
}

3.1. La chaine de compilation

6 étapes de la chaine de compilation

Le processus de compilation se déroulerait comme suit :

  1. Prétraitement : Le préprocesseur développe la directive #include pour inclure le fichier d’en-tête de la bibliothèque standard.
  2. Analyse lexicale : L’analyseur lexical divise le code source en jetons individuels, tels que des mots-clés, des identificateurs, des littéraux et des symboles.
  3. Analyse syntaxique : L’analyseur syntaxique construit un arbre syntaxique abstrait (AST) à partir du flux de jetons, qui représente la structure syntaxique du programme.

3.1. La chaine de compilation

6 étapes de la chaine de compilation

  1. Analyse sémantique : L’analyseur sémantique vérifie que les types des variables et des expressions correspondent aux types attendus et résout les problèmes de portée.
  2. Génération de code intermédiaire : Le générateur de code intermédiaire traduit l’AST validé en bytecode ou en arbres syntaxiques abstraits.
  3. Génération de code natif : Le générateur de code natif applique des optimisations et génère des instructions spécifiques à la machine à partir de la représentation intermédiaire.

La sortie finale est le code exécutable qui peut être exécuté directement sur la plate-forme cible.

3.1. La chaine de compilation

1. Préprocesseur

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.

cercle.c

#ifndef PI
#define PI 3.14159
#endif

float aire( float rayon) {
 return(PI * rayon * rayon);
}

3.1. La chaine de compilation

1. Préprocesseur

$ 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.

3.1. La chaine de compilation

2. Langage intermédiaire

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

3.1. La chaine de compilation

2. Langage intermédiaire

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;
}

3.1. La chaine de compilation

3. Optimisation de code

$ gcc -O2 bonjour.c

3.1. La chaine de compilation

4. Génération de code natif

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

3.1. La chaine de compilation

4. Génération de code natif et l'optimisation de code

int main() {
  int num = 2 + 3;
  return(0);
}

Compilation (Pas d'optimisations)

$ 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.

3.1. La chaine de compilation

4. Génération de code natif et l'optimisation de code

somme.s

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

3.1. La chaine de compilation

4. Génération de code natif et l'optimisation de code

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.

3.1. La chaine de compilation

4. Génération de code

sur une machine d'architecture 64 bits

$ 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.

3.1. La chaine de compilation

4. Génération de code

sur une machine d'architecture 64 bits

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

3.1. La chaine de compilation

5. Code objet

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.

3.1. La chaine de compilation

5. Code objet (Modifications et recompilation)

$ 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é.

3.2. L’interprétation de Python

Le processus d’interprétation :

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 :

  1. Lexing : Le code source est converti en une séquence de tokens.
  2. Parsing : Les tokens sont organisés en un arbre syntaxique abstrait (AST).
  3. Compilation : L’AST est compilé en bytecode (code intermédiaire).
  4. Exécution : Le bytecode est interprété et exécuté par la machine virtuelle Python (PVM).

3.2. L’interprétation de Python

1. Analyse lexicale (Lexing)

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 :

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.

3.2. L’interprétation de Python

1. Analyse lexicale (Lexing)

Exemple :

Pour le code x = 5 + 10, les tokens générés seront

3.2. L’interprétation de Python

1. Analyse lexicale (Lexing)

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)

3.2. L’interprétation de Python

1. Analyse lexicale (Lexing)

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')

3.2. L’interprétation de Python

1. Analyse lexicale (Lexing)

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)

3.2. L’interprétation de Python

2. Analyse syntaxique (Parsing)

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.).

3.2. L’interprétation de Python

2. Analyse syntaxique (Parsing)

Exemple :

Pour x = 5 + 10, l’AST pourrait ressembler à ceci :

Assign (Affectation)

Si une erreur syntaxique est détectée (comme des parenthèses mal fermées), une erreur de syntaxe est levée ici.

3.2. L’interprétation de Python

2. Analyse syntaxique (Parsing)

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)

3.2. L’interprétation de Python

2. Analyse syntaxique (Parsing)

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),
                ],

3.2. L’interprétation de Python

3. Compilation en bytecode

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.

3.2. L’interprétation de Python

3. Compilation en bytecode

Exemple de bytecode (simplifié) :

Pour x = 5 + 10, le bytecode pourrait ressembler à ceci :

LOAD_CONST 5
LOAD_CONST 10
BINARY_ADD
STORE_NAME x

3.2. L’interprétation de Python

3. Compilation en bytecode

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.2. L’interprétation de Python

3. Compilation en bytecode

  3           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

3.2. L’interprétation de Python

4. Exécution par la machine virtuelle Python (PVM)

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.

3.2. L’interprétation de Python

5. Gestion des exceptions

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.

3.2. L’interprétation de Python

6. Collecte des objets non utilisés (Garbage Collection)

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.

3.2. L’interprétation de Python

Exécution avec des débogueurs

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)

3.2. L’interprétation de Python

Bytecode Python précompilé

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

3. Principes des Langages de Programmation : Références

Références

Crédits d'images