Du clavier à la LED
Tu sais ce qu’est un microcontrôleur et à quoi il sert. Mais entre le code que tu écris et la LED qui se met à clignoter, il se passe beaucoup de choses : quand tu cliques sur « Téléverser », que se passe-t-il réellement ?
Ce concept suit ce voyage de bout en bout, du texte que tu tapes jusqu’aux électrons qui parcourent la puce. Les concepts suivants ouvriront ensuite le capot pour voir comment elle est faite à l’intérieur.
À la fin de ce concept, tu sauras :
- expliquer la différence entre un langage compilé (C, C++) et un langage interprété (Python) ;
- suivre le trajet code → compilation → flash → exécution ;
- comprendre
setup()/loop() et ce qui tourne (ou non) autour de ton programme ;
- éviter un
delay() bloquant grâce à millis().
En quel langage ?
Sur un microcontrôleur, on programme presque toujours en C ou en C++. Le framework Arduino, utilisé dans l’atelier, est du C++ (le C, avec quelques outils en plus, comme les objets : Serial.println()). Tu as peut-être appris à programmer en Python : la logique reste la même, mais le code n’est pas traduit pour le processeur au même moment.
Arduino : la carte, le logiciel et le framework
Le mot « Arduino » désigne trois choses différentes :
- des cartes de développement (Uno, Nano, Mega), souvent construites autour d’un microcontrôleur ATmega ;
- un logiciel, l’Arduino IDE, pour écrire, compiler et téléverser un programme ;
- un framework : les fonctions
setup(), loop(), pinMode(), digitalWrite()… qui simplifient l’accès au matériel. Il a été porté sur des centaines de puces, dont l’ESP32-S3 (sous le nom Arduino-ESP32).
Dans l’atelier, on utilise le framework, avec PlatformIO à la place de l’Arduino IDE, sur une carte XIAO ESP32S3 plutôt que sur une carte Arduino. Une Arduino Uno, avec son ATmega328P (8 bits, 16 MHz, 2 Ko de RAM, pas de Wi-Fi), serait trop limitée pour un écran couleur et un joueur connecté en Wi-Fi : on garde la simplicité du framework, et on gagne la puissance et le Wi-Fi de l’ESP32-S3.
Compilé ou interprété
flowchart LR
subgraph C["C / C++ : compilé"]
C1["Code source"] --> C2["Compilateur\n(sur ton PC)"] --> C3["Code machine\nexécuté par la puce"]
end
subgraph P["Python : interprété"]
P1["Code source"] --> P2["Interpréteur\n(pendant l'exécution)"] --> P3["Traduit et exécuté\nau fur et à mesure"]
end
- Compilé (C, C++) : avant l’exécution, un programme appelé compilateur traduit tout ton code, une fois pour toutes, en code machine : les instructions que le processeur sait exécuter directement. La puce ne reçoit que ce code machine.
- Interprété (Python) : un programme appelé interpréteur lit ton code pendant l’exécution et le traduit au fur et à mesure. Il doit donc être présent et tourner sur la machine qui exécute le programme.
| |
Compilé (C, C++) |
Interprété (Python) |
| Quand le code est traduit |
Une fois, avant, sur ton PC |
À chaque exécution, au fur et à mesure |
| Ce qui tourne sur la machine |
Le programme seul |
Le programme et l’interpréteur |
| Vitesse |
Très rapide |
Plus lent |
| Erreurs de frappe ou de type |
Détectées à la compilation, avant de flasher |
Découvertes quand la ligne est exécutée |
Pourquoi le C/C++ sur un microcontrôleur ?
Un microcontrôleur a peu de mémoire et doit souvent réagir vite. Un langage compilé lui convient parfaitement : pas d’interpréteur à loger en mémoire, un programme rapide, et beaucoup d’erreurs repérées par le compilateur avant même d’envoyer le programme sur la carte. C’est pour ça que chaque fabricant fournit un compilateur C/C++ pour ses puces.
Du texte au courant électrique
Un fichier source (main.cpp sous PlatformIO, .ino sous l’Arduino IDE) n’est que du texte. Pour qu’il fasse clignoter une LED, il doit traverser plusieurs transformations avant d’atteindre la puce :
flowchart LR
A["Code source\n(.cpp)"] --> B["Compilateur\n(gcc-xtensa)"]
B --> C["Binaire\n(.bin)"]
C --> D["Flashage\n(USB/UART)"]
D --> E["Exécution\nsur l'ESP32-S3"]
Chaque étape est prise en charge par la toolchain : l’ensemble des outils qui transforme le code en programme exécutable.
Le framework Arduino-ESP32 fournit trois éléments :
| Élément |
Rôle |
| Compilateur (gcc pour Xtensa) |
Traduit le C++ en instructions machine pour le CPU de l’ESP32-S3 |
| Core Arduino |
Bibliothèque qui définit pinMode(), digitalWrite(), analogRead()… |
| Uploader (esptool) |
Transfère le binaire compilé vers la Flash via USB |
L’IDE (Arduino IDE ou PlatformIO) orchestre ces outils en arrière-plan quand tu cliques sur Téléverser, mais la chaîne reste la même : compiler → flasher → exécuter.
Ce que fait vraiment le compilateur
« Compiler » recouvre en réalité plusieurs étapes enchaînées :
- Préprocesseur : il traite les
#include et #define : il colle le contenu des bibliothèques et remplace les macros. C’est lui qui lit le #include <Arduino.h> en tête de main.cpp : cette ligne déclare pinMode(), digitalWrite(), Serial… Sous l’Arduino IDE, elle est ajoutée en cachette aux fichiers .ino ; sous PlatformIO, un .cpp doit l’écrire lui-même, sinon le compilateur ne connaît aucune fonction Arduino.
- Compilation : chaque fichier C++ est traduit en code objet (instructions machine), séparément.
- Édition de liens (linker) : tous les fichiers objets et les bibliothèques sont assemblés en un seul binaire, en résolvant les références entre eux.
Distinguer ces étapes aide à lire les erreurs : une erreur de compilation (étape 2) est une faute de syntaxe ou de type dans ton code ; une erreur de link (« undefined reference ») signale une fonction ou une bibliothèque manquante à l’étape 3.
Flasher : écrire dans la mémoire persistante
Flasher, c’est écrire le binaire compilé (le programme traduit en instructions machine : des 0 et des 1 exécutables par la puce) dans la Flash du microcontrôleur (voir Architecture d’un microcontrôleur). Contrairement à la RAM, la Flash retient son contenu hors tension : le programme redémarre automatiquement à chaque mise sous tension, sans intervention.
Sur la XIAO ESP32S3, le flashage passe par l’USB natif de la puce. Si le programme en cours plante ou monopolise l’USB, l’upload échoue : on force alors le mode téléversement en maintenant BOOT pendant qu’on branche le câble USB, puis on appuie sur RESET une fois l’upload terminé. Si le flashage échoue systématiquement, c’est la première chose à essayer.
Au reset : la séquence de démarrage
Entre la mise sous tension et l’exécution de ton setup(), l’ESP32-S3 traverse plusieurs étapes automatiques :
- ROM bootloader : un petit programme gravé en usine dans la puce démarre. Il lit les broches de strapping (dont GPIO0) pour décider : démarrage normal, ou mode téléversement (attente d’un flash par USB/UART).
- Bootloader de second niveau : chargé depuis la Flash, il initialise l’horloge et la mémoire, puis choisit la partition applicative à lancer.
- Ton application : enfin, le code compilé s’exécute :
setup() une fois, puis loop() en boucle.
flowchart LR
P["Mise sous<br/>tension"] --> R{"ROM bootloader<br/>BOOT enfoncé ?"}
R -->|"oui"| U["Mode<br/>téléversement"]
R -->|"non"| B["Bootloader<br/>de second niveau"]
B --> S["setup()<br/>une fois"]
S --> L["loop()"]
L --> L
C’est ce mécanisme qui explique le bouton BOOT : le maintenir enfoncé au reset force l’étape 1 en mode téléversement.
setup() et loop() : toute la structure
Un programme Arduino tient dans deux fonctions :
#include <Arduino.h> // indispensable dans un .cpp : déclare pinMode, Serial...
constexpr int LED_PIN = D3; // une LED externe sur la broche D3
void setup() {
// Exécuté une seule fois, au démarrage
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
}
void loop() {
// Exécuté en boucle infinie, indéfiniment
digitalWrite(LED_PIN, HIGH);
delay(500);
digitalWrite(LED_PIN, LOW);
delay(500);
}
setup() initialise le matériel (broches, ports série, périphériques), une seule fois.
loop() s’exécute en boucle infinie, du démarrage jusqu’à la coupure d’alimentation.
Pas de système d’exploitation… au sens d’un PC
Sur un ordinateur, un programme est un processus parmi des centaines d’autres, géré par un OS complet (Windows, Linux : fichiers, fenêtres, mémoire virtuelle, arrêt propre). Sur un microcontrôleur, rien de tout ça : ton programme est le seul programme de la puce.
Sur l’ESP32-S3, Arduino-ESP32 s’appuie tout de même sur un tout petit noyau temps réel, FreeRTOS, qui reste invisible : il fait tourner loop() comme une tâche, et à côté les tâches du Wi-Fi, du Bluetooth et de l’USB. Ce qui compte pour ton code :
loop() est une seule tâche : ce que tu y écris s’exécute ligne après ligne, une chose à la fois.
delay() met cette tâche en pause : le Wi-Fi continue de tourner en coulisse, mais rien de ton programme n’avance, y compris la lecture des boutons.
- Pas d’arrêt propre : couper l’alimentation coupe le programme instantanément, sans sauvegarde automatique.
Sur un ATmega328 (Arduino Uno), il n’y a vraiment aucun noyau : loop() est appelée en boucle par un simple main() écrit par le core Arduino, et delay() fait tourner le CPU à vide. Pour ton code, le résultat est le même : pendant un delay(), ton programme ne fait rien d’autre.
C’est pour cette raison que la structuration du code (game loop non bloquante, machine à états finis) devient essentielle dès que le programme gère plusieurs tâches à la fois.
Faire plusieurs choses à la fois avec millis()
Le défaut de delay() : pendant qu’il attend, rien d’autre de ton programme ne s’exécute. Impossible de lire un bouton, rafraîchir un écran et faire clignoter une LED « en même temps » avec des delay().
La solution est de ne jamais bloquer : au lieu d’attendre, on regarde l’heure. millis() renvoie le nombre de millisecondes écoulées depuis le démarrage. En mémorisant la date de la dernière action, on décide s’il est temps d’agir, sans jamais figer le programme.
unsigned long dernierClignotement = 0;
const unsigned long INTERVALLE = 500; // ms
int etatLed = LOW;
void loop() {
// ... ici, le reste du programme tourne librement (boutons, écran…)
if (millis() - dernierClignotement >= INTERVALLE) {
dernierClignotement = millis();
etatLed = !etatLed;
digitalWrite(LED_PIN, etatLed);
}
}
Ce motif (comparer millis() à une date mémorisée) est le fondement de la game loop non bloquante et se combine naturellement avec la machine à états finis.

Dans un jeu, ce même motif cadence une game loop : une image toutes les 16 ms, et le temps restant rendu à loop().

La taille des entiers
La taille d’un int dépend de la puce : le même code peut déborder sur l’une et pas sur l’autre.
| Type |
Arduino Uno (8 bits) |
ESP32-S3 (32 bits) |
int |
16 bits, jusqu’à 32 767 |
32 bits, jusqu’à 2,1 milliards |
long, unsigned long |
32 bits |
32 bits |
uint8_t |
8 bits, de 0 à 255 |
identique |
uint32_t |
32 bits |
identique |
Les types de <stdint.h> (uint8_t, int16_t, uint32_t) ont la même taille partout : utilise-les dès que la taille compte.
Le débordement de millis()
millis() renvoie un entier non signé de 32 bits : il repasse à 0 au bout de 2³² ms, soit 49,7 jours. Selon la façon d’écrire la comparaison, le programme survit ou non à ce passage à 0 :
if (millis() - dernierClignotement >= INTERVALLE) // juste, toujours
if (millis() >= dernierClignotement + INTERVALLE) // faux au passage à 0

La soustraction de deux non signés donne l’écart réel même si le compteur a fait le tour, comme sur une horloge : de 23 h à 1 h, il s’est écoulé 2 h. La seconde forme, elle, compare deux dates : juste avant le passage à 0, dernierClignotement + INTERVALLE déborde et devient tout petit, et la condition se déclenche à tort.
Écris toujours maintenant - date >= durée. 49 jours, c’est long pour un jeu, mais pas pour un capteur ou une borne qui ne s’éteint jamais : ce bug n’apparaît jamais en test, seulement en production.
Pour une pause courte et unique (initialiser un composant, un petit délai au démarrage), delay() est parfaitement acceptable. C’est dans la loop(), quand plusieurs choses doivent avancer en parallèle, qu’il faut l’éviter.
Le programme le plus simple relie tous les concepts vus jusqu’ici :
#include <Arduino.h>
void setup() {
pinMode(LED_BUILTIN, OUTPUT); // configure la broche en sortie (GPIO, registre)
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // écrit 3,3 V sur la broche (registre)
delay(500); // met loop() en pause 500 ms : rien d'autre n'avance
digitalWrite(LED_BUILTIN, LOW); // écrit 0 V sur la broche
delay(500);
}
pinMode et digitalWrite écrivent dans des registres (Architecture d’un microcontrôleur).
- La broche bascule entre deux niveaux logiques (GPIO & monde numérique).
delay() suspend loop() : ton programme ne fait rien d’autre pendant ce temps.
- Sur la XIAO, la LED orange est en logique inversée :
HIGH l’éteint et LOW l’allume, mais elle clignote de la même façon.
- Ce même code, une fois compilé et flashé, tourne en boucle infinie tant que la carte est alimentée.
Ajouter une bibliothèque
Rares sont les projets qui n’utilisent que le core Arduino : un écran, un capteur ou un servo s’appuient sur une bibliothèque tierce. On ne copie pas son code : on la déclare, et l’outil la télécharge :
- Arduino IDE : menu Croquis → Inclure une bibliothèque → Gérer les bibliothèques, puis rechercher par nom.
- PlatformIO : ajouter la dépendance dans
platformio.ini :
lib_deps =
adafruit/Adafruit ST7735 and ST7789 Library
madhephaestus/ESP32Servo
Ensuite, un #include <...> dans le code suffit à utiliser la bibliothèque.
Déboguer avec le moniteur série
Le moniteur série est l’outil de débogage n°1 : Serial.println() affiche l’état du programme en direct. Encore faut-il que les deux extrémités parlent à la même vitesse.
Si le moniteur affiche des caractères illisibles, c’est presque toujours un baud rate qui ne correspond pas. Vérifie que la vitesse du moniteur série est identique à celle passée à Serial.begin() (souvent 115200).
Quiz express
1. Quelles étapes transforment un fichier source en programme qui s’exécute sur la puce ?
Voir la réponse
Compilation (toolchain) → binaire → flashage dans la Flash → exécution.
2. Pourquoi éviter delay() dans une loop() qui gère plusieurs tâches ?
Voir la réponse
`delay()` bloque tout le programme pendant l'attente ; on utilise `millis()` pour agir sans bloquer.
3. Pourquoi l’ESP32-S3 n’a-t-il pas besoin d’un interpréteur pour exécuter ton programme ?
Voir la réponse
Le C++ est un langage compilé : le compilateur a traduit le programme en code machine sur ton ordinateur, avant le flashage. La puce exécute directement ce code machine.
4. Que fait le ROM bootloader au reset ?
Voir la réponse
Il lit les broches de strapping (dont GPIO0) pour choisir entre démarrage normal et mode téléversement.
Pour aller plus loin
Résumé
- On programme un microcontrôleur en C ou C++ (Arduino est du C++) : des langages compilés, traduits en code machine une fois pour toutes, avant l’exécution.
- Python est interprété : le code est traduit pendant l’exécution, par un interpréteur qui doit tourner sur la machine.
- Chaîne complète : code source → compilation (toolchain) → flashage (Flash) → exécution.
- Arduino-ESP32 fournit le compilateur, le core (
pinMode, digitalWrite…, déclarés par #include <Arduino.h>) et l’uploader.
- Compiler = préprocesseur → compilation (code objet) → édition de liens (binaire) ; une erreur de link = fonction ou bibliothèque manquante.
setup() s’exécute une fois ; loop() tourne en boucle infinie jusqu’à la coupure d’alimentation.
- Au reset : ROM bootloader (lit le strapping) → bootloader de second niveau → application (
setup/loop).
- Pas d’OS au sens d’un PC :
loop() est une seule tâche (sous FreeRTOS sur l’ESP32-S3), et delay() la met en pause.
- Pour faire avancer plusieurs tâches en parallèle : comparer
millis() à une date mémorisée plutôt qu’utiliser delay().
- Le Blink relie architecture, GPIO et fonctionnement de
loop() en quatre lignes de code.
- On ajoute une bibliothèque via le gestionnaire (Arduino IDE) ou
lib_deps (PlatformIO), puis un #include.
- Débogage :
Serial.println() + un moniteur série au même baud que Serial.begin().