Module L18 · Partie I · Robotique et systèmes embarqués
Dans le microcontrôleur : interruptions, temps réel, registres.
Arduino cachait tout derrière digitalWrite et delay. Un vrai système embarqué — le contrôleur d’un moteur, d’un drone, d’un pacemaker — ne peut pas attendre : il doit réagir en microsecondes, garantir ses délais, ne jamais planter, et tenir des années sur une pile. Ce module ouvre le capot : architecture d’un microcontrôleur, registres, interruptions, timers, DMA, ordonnancement temps réel, RTOS, bus, sûreté de fonctionnement.
Durée : 4 séances · Prérequis : séances 21-26, L07 (C). Objectifs : lire une fiche technique, programmer par registres, écrire une routine d’interruption correcte, comprendre l’ordonnancement (super-boucle, coopératif, préemptif, RMS), utiliser un RTOS (FreeRTOS), concevoir un tampon circulaire sûr, gérer le temps sans délais bloquants, penser énergie et sûreté. Le C se compile sur votre carte ; Python sert à simuler l’ordonnancement.
Ce que vous saurez faire à la fin
- Configurer un timer et une interruption en lisant la documentation d’un STM32 ou d’un ESP32.
- Écrire un firmware structuré en tâches à cadences garanties, sans
delay. - Analyser si un ensemble de tâches périodiques est ordonnançable.
- Diagnostiquer les bugs typiques : condition de concurrence, débordement de pile, watchdog, ISR trop longue.
Références : Making Embedded Systems (Elecia White), Embedded Systems: Real-Time Operating Systems for Arm Cortex-M (Valvano), documentation FreeRTOS, manuels de référence STM32 (RM0368 pour la F4), cours MIT 6.115.
Fiche de cours · Définitions
Systèmes embarqués et temps réel : définitions
Fiche de cours · Formules
Formules à connaître
Fiche de cours · Théorèmes et démonstrations
Démonstrations à savoir refaire (1/2)
Fiche de cours · Théorèmes et démonstrations
Démonstrations à savoir refaire (2/2)
volatile + double lecture jusqu’à cohérence. volatile seul empêche le compilateur de mettre la variable en registre mais ne rend rien atomique.Fiche de cours · Méthodes
Méthodes et pièges
Pièges : delay() dans une boucle temps réel ; printf flottant dans une ISR ; variable partagée non volatile (le compilateur l’optimise en registre : boucle d’attente infinie) ; débordement de pile (récursion, gros tableaux locaux) sans protection mémoire ; oublier le pull-up sur I²C ; fréquence d’échantillonnage sous Nyquist ; watchdog désactivé « pour déboguer » et jamais réactivé.
Fiche de cours · Exercices corrigés
Exercices corrigés
int32_t acc = (int32_t)29491 * y + (int32_t)3277 * x; y = (int16_t)(acc >> 15); — le produit Q15×Q15 tient sur 32 bits (Q30), le décalage de 15 revient en Q15 ; ajouter 1 << 14 avant le décalage pour arrondir plutôt que tronquer. Erreur par pas : ≤ ½ LSB = 1,5·10⁻⁵ ; comme le filtre est stable (pôle 0,9), l’erreur accumulée est bornée par 0,5 LSB/(1 − 0,9) = 5 LSB ≈ 1,5·10⁻⁴ : acceptable. Piège : troncature systématique (au lieu d’arrondi) crée un biais qui, amplifié par 1/(1 − 0,9) = 10, produit une zone morte près de 0 (le filtre ne converge pas vers x quand x est petit).01 / Architecture
Ce qu’il y a dans un microcontrôleur
| Bloc | Rôle | Ordres de grandeur (STM32F4 / ESP32 / ATmega328) |
|---|---|---|
| Cœur (CPU) | Exécute les instructions ; Cortex-M4 / Xtensa LX6 / AVR 8 bits | 168 MHz / 240 MHz ×2 / 16 MHz |
| Flash | Le programme (non volatile) | 512 Ko / 4 Mo / 32 Ko |
| SRAM | Les variables, la pile, le tas (volatile) | 128 Ko / 520 Ko / 2 Ko |
| GPIO | Broches numériques entrée/sortie | 80 / 34 / 23 |
| Timers | Compteurs matériels : PWM, mesure de durées, cadencement | 14 / 4 / 3 |
| ADC / DAC | Analogique ↔ numérique | 12 bits 2,4 MHz / 12 bits / 10 bits |
| UART, SPI, I2C, CAN, USB | Communication série | plusieurs de chaque |
| DMA | Copie mémoire ↔ périphérique sans le CPU | 16 canaux / oui / non |
| NVIC | Contrôleur d’interruptions : priorités, imbrication | 16 niveaux / oui / 2 niveaux |
| Watchdog | Redémarre si le programme ne donne plus signe de vie | oui |
Tout périphérique est piloté par des registres : des cases mémoire à adresse fixe dont chaque bit commande quelque chose. La fiche technique (« reference manual », 1000+ pages) est la seule vérité ; les bibliothèques (HAL, Arduino) ne font que l’envelopper.
01 / Architecture
Programmer par registres : allumer une LED sans bibliothèque
/* STM32F4 : LED sur PA5. Étapes : 1) activer l'horloge du port A ; 2) configurer PA5 en sortie ; 3) écrire le bit. */
#include <stdint.h>
#define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830) /* horloges des périphériques (RM0368 §6.3.9) */
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000) /* mode de chaque broche : 2 bits par broche */
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014) /* état des sorties */
#define GPIOA_BSRR (*(volatile uint32_t *)0x40020018) /* set/reset atomique : bits 0-15 set, 16-31 reset */
int main(void) {
RCC_AHB1ENR |= (1u << 0); /* GPIOAEN : sans horloge, le port est mort */
GPIOA_MODER &= ~(3u << (5 * 2)); /* effacer les 2 bits de PA5 */
GPIOA_MODER |= (1u << (5 * 2)); /* 01 = sortie */
while (1) {
GPIOA_BSRR = (1u << 5); /* allumer : écriture atomique, pas de lecture-modification-écriture */
for (volatile int i = 0; i < 500000; i++); /* attente active (mauvaise pratique, on verra mieux) */
GPIOA_BSRR = (1u << (5 + 16)); /* éteindre */
for (volatile int i = 0; i < 500000; i++);
}
}Chaque |= sur un registre partagé est une séquence lecture-modification-écriture non atomique : une interruption peut s’intercaler. Les concepteurs de puces fournissent des registres « set » et « reset » séparés (BSRR) précisément pour cela. Sur AVR (Arduino Uno) : PORTB |= (1 << 5) compile en une seule instruction sbi, atomique — le compilateur connaît le matériel.
02 / Interruptions
L’interruption : le matériel appelle votre fonction
/* Compter les impulsions d'un encodeur de roue (module 27) SANS rater d'impulsion, même à 20 kHz */
#include <stdint.h>
volatile uint32_t impulsions = 0; /* volatile : modifié par l'ISR, lu par main */
volatile uint8_t nouvelle_mesure = 0;
void EXTI0_IRQHandler(void) { /* ISR : appelée par le NVIC sur front montant de PA0 */
EXTI_PR = (1u << 0); /* acquitter le drapeau, sinon l'ISR est rappelée sans fin */
impulsions++; /* court : quelques instructions */
}
void TIM2_IRQHandler(void) { /* toutes les 10 ms : signaler au programme principal */
TIM2_SR &= ~1u;
nouvelle_mesure = 1;
}
int main(void) {
configurer_exti0_front_montant(); configurer_tim2_10ms();
uint32_t precedent = 0;
while (1) {
if (nouvelle_mesure) {
nouvelle_mesure = 0;
__disable_irq(); /* section critique : lecture atomique d'un uint32 sur AVR 8 bits (sur Cortex-M, 32 bits est atomique) */
uint32_t n = impulsions;
__enable_irq();
uint32_t vitesse = (n - precedent) * 100; /* impulsions par seconde */
precedent = n;
/* … régulation PID (séance 29) … */
}
__WFI(); /* dormir jusqu'à la prochaine interruption : économie d'énergie */
}
}Les règles d’une ISR
- Courte : quelques microsecondes. Pas de
printf, pas d’attente, pas d’allocation. Elle bloque tout ce qui est moins prioritaire. - Acquitter le drapeau matériel, sinon elle boucle.
- Communiquer avec le reste par des variables
volatileet des drapeaux ; le travail long se fait dans la boucle principale. - Toute donnée partagée de plus d’un mot machine (ou toute séquence lecture-modification-écriture) doit être protégée : désactiver les interruptions le temps de la copie, ou utiliser des opérations atomiques.
- Ne jamais appeler une fonction non réentrante (malloc, la plupart des bibliothèques).
Le rebond d’un bouton mécanique génère des dizaines de fronts : on l’ignore pendant 20 ms après le premier (anti-rebond logiciel) ou on filtre matériellement (RC).
02 / Interruptions
Le tampon circulaire : passer des octets de l’ISR au programme sans verrou
/* Réception UART : l'ISR écrit, main lit. Un producteur, un consommateur, indices séparés : pas de verrou nécessaire. */
#define TAILLE 64 /* puissance de 2 : le modulo devient un ET */
static volatile uint8_t tampon[TAILLE];
static volatile uint8_t tete = 0; /* écrit par l'ISR seulement */
static volatile uint8_t queue = 0; /* écrit par main seulement */
void USART2_IRQHandler(void) {
uint8_t octet = USART2_DR; /* lire acquitte le drapeau RXNE */
uint8_t suivante = (tete + 1) & (TAILLE - 1);
if (suivante != queue) { /* sinon plein : on perd l'octet (et on compte l'erreur) */
tampon[tete] = octet;
tete = suivante; /* publier APRÈS avoir écrit la donnée */
} else erreurs_debordement++;
}
int lire_octet(uint8_t *out) {
if (queue == tete) return 0; /* vide */
*out = tampon[queue];
queue = (queue + 1) & (TAILLE - 1);
return 1;
}
/* Découper les lignes « V=0.4;W=-0.1\n » (séance 26) sans jamais bloquer */
void traiter_serie(void) {
static char ligne[32]; static uint8_t n = 0; uint8_t c;
while (lire_octet(&c)) {
if (c == '\n') { ligne[n] = '\0'; interpreter(ligne); n = 0; }
else if (n < sizeof ligne - 1) ligne[n++] = c;
else n = 0; /* ligne trop longue : jeter — jamais déborder le tableau */
}
}C’est la file circulaire du module L03, avec une subtilité : l’ordre des écritures (donnée puis index) garantit que le lecteur ne voit jamais une case non encore remplie. Sur des cœurs à exécution dans le désordre, il faudrait des barrières mémoire ; sur Cortex-M, un accès 8 bits est atomique et l’ordre du programme est respecté. La ligne else n = 0 est la parade au débordement de tampon, faille n°1 de l’embarqué.
03 / Temps réel
Gérer le temps sans delay : la super-boucle cadencée
/* Trois tâches à cadences différentes, aucune n'attend. millis() vient d'une ISR timer (L07). */
uint32_t t_pid = 0, t_tele = 0, t_led = 0;
while (1) {
uint32_t maintenant = millis();
if (maintenant - t_pid >= 10) { t_pid += 10; reguler_moteurs(); } /* 100 Hz, prioritaire */
if (maintenant - t_tele >= 100){ t_tele += 100; envoyer_telemetrie(); } /* 10 Hz */
if (maintenant - t_led >= 500) { t_led += 500; basculer_led(); } /* 2 Hz */
traiter_serie(); /* dès que possible */
}
/* t += période (et non t = maintenant) : pas de dérive cumulée. La soustraction non signée survit au débordement de millis(). */Dès qu’une tâche dure plus que la période de la plus rapide, la super-boucle ne tient plus ses délais : la télémétrie de 40 ms retarde le PID de 40 ms, et le robot oscille. Solutions : découper les tâches longues en étapes (machine à états, séance 28), les passer en DMA, ou passer à un ordonnanceur préemptif.
03 / Temps réel
Ordonnancement préemptif : priorités et test de Liu & Layland
Préemptif, coopératif, et le vocabulaire
Préemptif : une tâche plus prioritaire interrompt la tâche courante (comme une ISR, mais avec des tâches complètes ayant chacune leur pile). Coopératif : une tâche rend la main explicitement (comme async/await en Python). Temps réel dur : rater une échéance est une faute (airbag, contrôle de vol) ; mou : c’est une dégradation (vidéo). RMS (priorité = période) et EDF (priorité = échéance la plus proche, optimal mais moins prévisible en surcharge). Le pire temps d’exécution (WCET) d’une tâche est difficile à borner sur un processeur avec cache : les systèmes critiques utilisent des cœurs simples et des outils d’analyse statique.
03 / Temps réel
FreeRTOS : des tâches, des files, des sémaphores
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
QueueHandle_t file_mesures; /* communication sûre entre tâches (et depuis une ISR) */
void tache_capteurs(void *arg) {
TickType_t reveil = xTaskGetTickCount();
for (;;) {
Mesure m = lire_imu(); /* I2C, ~1 ms */
xQueueSend(file_mesures, &m, 0); /* ne bloque pas si la file est pleine */
vTaskDelayUntil(&reveil, pdMS_TO_TICKS(20)); /* période exacte de 20 ms, sans dérive */
}
}
void tache_controle(void *arg) {
Mesure m;
for (;;) {
if (xQueueReceive(file_mesures, &m, pdMS_TO_TICKS(50)) == pdTRUE) /* bloque (sans consommer de CPU) jusqu'à une mesure */
reguler(m);
else
arret_urgence(); /* 50 ms sans mesure : quelque chose ne va pas */
}
}
void ISR_encodeur(void) {
BaseType_t reveiller = pdFALSE;
xSemaphoreGiveFromISR(sem_encodeur, &reveiller); /* versions *FromISR obligatoires dans une ISR */
portYIELD_FROM_ISR(reveiller);
}
int main(void) {
file_mesures = xQueueCreate(8, sizeof(Mesure));
xTaskCreate(tache_controle, "ctrl", 256, NULL, 3, NULL); /* pile en mots, priorité haute */
xTaskCreate(tache_capteurs, "imu", 256, NULL, 2, NULL);
xTaskCreate(tache_telemetrie, "tele", 512, NULL, 1, NULL);
vTaskStartScheduler(); /* ne revient jamais */
}Les pièges du multitâche
- Condition de concurrence : deux tâches modifient la même variable. Parade : file, mutex, ou une seule tâche propriétaire de la donnée.
- Inversion de priorité : une tâche haute attend un mutex tenu par une tâche basse, elle-même préemptée par une moyenne. Le rover Mars Pathfinder (1997) redémarrait pour cela. Parade : héritage de priorité (FreeRTOS le fait pour les mutex).
- Interblocage : A attend B qui attend A. Parade : toujours prendre les verrous dans le même ordre, ou éviter les verrous (files).
- Débordement de pile d’une tâche : symptômes aléatoires. Parade :
uxTaskGetStackHighWaterMark, canaris.
ESP32 tourne sur FreeRTOS nativement (même sous Arduino : xTaskCreate est disponible). Zephyr est l’alternative moderne, avec un vrai modèle de pilotes.
04 / Périphériques
Timers, PWM, ADC, DMA : décharger le processeur
/* PWM matériel pour un moteur : le timer génère le signal, le CPU ne fait rien. TIM3 canal 1 sur PA6, 20 kHz. */
TIM3_PSC = 84 - 1; /* 84 MHz / 84 = 1 MHz de comptage */
TIM3_ARR = 50 - 1; /* période 50 ticks = 20 kHz (au-delà de l'audible) */
TIM3_CCR1 = 30; /* rapport cyclique 30/50 = 60 % */
TIM3_CCMR1 |= (6u << 4); /* mode PWM 1 */
TIM3_CCER |= 1u; TIM3_CR1 |= 1u; /* activer sortie et compteur */
/* ADC en continu par DMA : 8 capteurs échantillonnés à 10 kHz, rangés dans un tableau, ZÉRO instruction CPU par échantillon */
static uint16_t echantillons[8];
DMA2_S0PAR = (uint32_t)&ADC1_DR; /* source : registre de donnée de l'ADC */
DMA2_S0M0AR = (uint32_t)echantillons; /* destination : notre tableau */
DMA2_S0NDTR = 8; /* 8 transferts, puis recommencer (mode circulaire) */
DMA2_S0CR |= DMA_CIRC | DMA_MINC | DMA_EN; /* circulaire, incrémenter l'adresse mémoire, activer */
ADC1_CR2 |= ADC_DMA | ADC_CONT | ADC_ADON; /* l'ADC enchaîne les conversions et déclenche le DMA */
/* Ensuite, echantillons[] est mis à jour en permanence « par magie » ; l'ISR DMA (demi-plein / plein) signale les lots. */Un Cortex-M0 sans FPU fait une multiplication flottante en ~100 cycles, une multiplication entière en 1. Les filtres, les PID et même les réseaux de neurones embarqués (TinyML, CMSIS-NN) tournent en virgule fixe Q15/Q31 ou en entiers 8 bits quantifiés.
05 / Sûreté
Ne jamais planter : watchdog, défensive, mise à jour
/* Watchdog indépendant : si on ne le « caresse » pas toutes les 100 ms, la puce redémarre */
IWDG_KR = 0x5555; IWDG_PR = 4; IWDG_RLR = 0xFA0; IWDG_KR = 0xCCCC; /* ~100 ms */
while (1) {
if (toutes_les_taches_vivantes()) /* chaque tâche pose un drapeau ; on ne caresse que si TOUTES ont tourné */
IWDG_KR = 0xAAAA;
/* … */
}
/* Programmation défensive : vérifier tout ce qui vient de l'extérieur */
int regler_vitesse(int32_t consigne) {
if (consigne < -1000 || consigne > 1000) return -1; /* plage */
if (!moteur_pret) return -2; /* état */
if (temperature_moteur > 80) consigne /= 2; /* dégradation gracieuse plutôt qu'arrêt */
return appliquer(consigne);
}
/* Au démarrage : d'où vient-on ? */
if (RCC_CSR & RCC_CSR_IWDGRSTF) { compteur_redemarrages_watchdog++; sauvegarder_journal(); }
RCC_CSR |= RCC_CSR_RMVF;Les règles des systèmes critiques (NASA « Power of Ten », MISRA C)
- Pas de récursion, pas de
goto; boucles à borne fixe prouvable. - Pas d’allocation dynamique après l’initialisation.
- Fonctions courtes (une page).
- Au moins deux assertions par fonction ; une assertion qui échoue déclenche une action de sûreté, pas un simple message.
- Portée minimale des données ; pas de globales non nécessaires.
- Vérifier toutes les valeurs de retour, tous les paramètres.
- Préprocesseur limité aux includes et constantes simples.
- Un seul niveau de déréférencement de pointeur ; pas de pointeurs de fonction.
- Compiler avec tous les avertissements, zéro avertissement ; analyse statique quotidienne.
Ces règles ne sont pas de la paranoïa : Therac-25 (1985-87, radiothérapie, 6 morts, condition de concurrence), Ariane 5 (1996, débordement d’une conversion 64→16 bits), Toyota (2013, pile et globales), Boeing 737 MAX (2019, un seul capteur sans redondance). Un ingénieur embarqué connaît ces histoires.
05 / Sûreté
Énergie : durer des années sur une pile
Trois leviers : dormir (modes sleep/stop/standby, réveil par interruption ou RTC), réduire la fréquence (la consommation dynamique est ∝ f·V²), couper les périphériques (horloges désactivées, capteurs alimentés par une broche GPIO). Mesurer avec un ampèremètre haute résolution (Nordic PPK2) : les fuites d’énergie sont aussi invisibles que les fuites de mémoire.
Cours
Cours 1 — Lire une fiche technique : les sections qui comptent et comment les lire
| Section | Ce qu’on y cherche | Exemple (STM32F401, ESP32, ATmega328P) |
|---|---|---|
| Caractéristiques électriques absolues | Ce qui détruit la puce | VDD max 3,6 V ; courant par broche 25 mA ; 5 V sur une entrée 3,3 V = mort |
| Brochage (pinout) et fonctions alternées | Quelle broche peut faire quoi (UART, PWM, ADC…) | PA5 = SPI1_SCK ou TIM2_CH1 ou GPIO ; certaines broches sont réservées au démarrage (ESP32 : GPIO0, 2, 12, 15) |
| Carte mémoire | Adresses de la Flash, SRAM, périphériques | SRAM à 0x2000 0000 ; GPIOA à 0x4002 0000 |
| Arbre d’horloges | Quelle fréquence arrive à chaque périphérique | Un timer à 84 MHz sur APB1×2, l’UART à 42 MHz : le calcul du baud rate en dépend |
| Description des registres | Bit par bit : nom, valeur au reset, lecture/écriture | GPIOx_MODER, bits 2y+1:2y : 00 entrée, 01 sortie, 10 alternée, 11 analogique |
| Caractéristiques temporelles | Latences, temps d’établissement, fréquences max | ADC : 12 bits à 2,4 Méch/s ; temps de réveil du mode stop : ~13 µs |
| Errata | Les bugs matériels connus | « I2C : faux front sur SCL en mode… » — lisez-les avant de passer une nuit à déboguer |
Méthode pour configurer un périphérique : 1) activer son horloge (RCC) — l’oubli n°1 ; 2) configurer les broches en fonction alternée ; 3) écrire les registres de configuration dans l’ordre indiqué par la fiche (souvent : désactiver, configurer, activer) ; 4) activer les interruptions côté périphérique et côté NVIC ; 5) vérifier avec un oscilloscope ou un analyseur logique (10 €) — pas avec printf.
Cours
Cours 2 — Exemple travaillé : de l’événement matériel au code, avec les temps
Une impulsion d’encodeur arrive sur PA0. Que se passe-t-il, et combien de temps ça prend (Cortex-M4 à 84 MHz) ?
- Front détecté par le périphérique EXTI (quelques ns) ; le drapeau EXTI_PR bit 0 passe à 1 ; une requête d’interruption part vers le NVIC.
- NVIC : si la priorité est supérieure à l’exécution courante et que les interruptions sont autorisées, le cœur termine l’instruction en cours (max ~ quelques cycles), empile automatiquement r0-r3, r12, lr, pc, xPSR sur la pile courante (12 cycles), charge l’adresse du gestionnaire dans la table des vecteurs, et saute : latence ≈ 12 cycles = 143 ns.
- Gestionnaire
EXTI0_IRQHandler: acquitter (écrire 1 dans EXTI_PR), incrémenter le compteur : ~10 instructions, ~120 ns. - Retour (
bx lravec une valeur magique EXC_RETURN) : dépilage automatique, 12 cycles. Total ≈ 0,4 µs par impulsion. - À 20 kHz d’impulsions : 20 000 × 0,4 µs = 8 ms par seconde, soit 0,8 % du CPU. À 2 MHz (encodeur haute résolution sur moteur rapide) : 80 % — il faut le mode encodeur matériel du timer, qui compte sans le CPU.
Cours
Cours 3 — Concurrence sans OS : sections critiques, atomicité, et le vocabulaire des bugs
| Bug | Mécanisme | Exemple | Parade |
|---|---|---|---|
| Condition de concurrence (race) | Deux contextes lisent-modifient-écrivent la même donnée | compteur++ dans main et dans une ISR : une incrémentation perdue | Désactiver les interruptions autour (section critique courte), ou opérations atomiques (LDREX/STREX sur Cortex-M), ou une seule écriture par contexte |
| Lecture déchirée (torn read) | Une donnée multi-mots lue en plusieurs accès, modifiée entre | Un uint32_t sur AVR 8 bits, un double sur Cortex-M0 | Copier sous section critique ; ou double tampon avec index atomique |
| Réordonnancement / cache | Le compilateur ou le cœur réordonne des accès mémoire | Publier « donnée prête » avant que la donnée soit écrite | volatile n’est PAS une barrière ; utiliser __DMB(), ou les atomiques C11 (<stdatomic.h>) |
| Réentrance | Une fonction interrompue puis rappelée depuis l’ISR | printf, malloc, une machine à états avec variables static | Ne jamais appeler ces fonctions depuis une ISR |
| Famine | Une ISR trop fréquente ou trop longue empêche le reste | Débordement du tampon UART parce que l’ISR encodeur monopolise | ISR courtes, priorités (NVIC), DMA, mode encodeur matériel |
/* Section critique correcte sur Cortex-M : sauvegarder/restaurer l'état des interruptions (pas juste enable, sinon on réactive
des interruptions qui étaient volontairement coupées par l'appelant) */
uint32_t etat = __get_PRIMASK(); __disable_irq();
copie = compteur_partage; /* quelques cycles seulement */
__set_PRIMASK(etat);TP guidé
TP 1 — Registres, interruptions, timers sur carte réelle (Pico ou STM32, 3 h)
- Chaîne d’outils. Raspberry Pi Pico :
pico-sdk+ CMake +arm-none-eabi-gcc(ou l’extension VS Code « Raspberry Pi Pico ») ; STM32 : STM32CubeIDE ou PlatformIO. Compilez et flashez l’exemple « blink ». Ouvrez le manuel de référence (RP2040 datasheet §2.19 GPIO / RM0368) à côté. - LED par registres. Sans bibliothèque (ni
gpio_putni HAL) : définissez les adresses (SIO_BASE + 0x014 GPIO_OUT_SETsur RP2040 ;GPIOA_BSRRsur STM32), configurez la broche, faites clignoter. Vérifiez à l’analyseur logique que la période est celle calculée. - Timer + interruption. Configurez un timer matériel pour une interruption exacte à 1 kHz ; dans l’ISR, incrémentez
ticks; dans la boucle, basculez une broche toutes les 500 ticks. À l’analyseur : mesurez la gigue de la période (doit être < 1 µs). Ajoutez undelaylogiciel de 3 ms dans la boucle : la LED reste exacte (c’est le timer) — comparez avec un clignotement pardelayseul. - Encodeur. Un encodeur en quadrature (ou deux boutons) sur deux broches ; ISR sur front des deux voies avec la table de décodage (états 00,01,11,10) ; comptez ; comparez au mode encodeur matériel du timer (STM32 TIM2 mode 3 ; RP2040 : PIO). Faites tourner vite : à quelle fréquence l’ISR logicielle rate-t-elle des pas ?
- UART par tampon circulaire + DMA. Réception par ISR dans le tampon du cours ; découpage des lignes ; commande « f=<Hz> » qui reprogramme le timer à chaud. Puis remplacez l’ISR de réception par un DMA circulaire et mesurez la charge CPU (broche « occupé » haute pendant le traitement, lue à l’oscilloscope).
- Watchdog et faute. Activez le watchdog (100 ms) ; caressez-le dans la boucle ; provoquez un blocage (
while(1);sur commande « hang ») : la carte redémarre ; au démarrage, détectez la cause (registre de reset) et affichez-la. Provoquez un HardFault (écriture à une adresse invalide) et lisez les registres de faute (CFSR, BFAR) dans le gestionnaire pour localiser l’instruction. - Livrable. Dépôt avec le firmware par registres (commenté avec les références de pages de la fiche technique), captures de l’analyseur logique (période, gigue), mesures de charge CPU, et
RESULTATS.md.
TP guidé
TP 2 — FreeRTOS sur ESP32 : tâches, files, priorités, et une inversion de priorité (2 h 30)
- Trois tâches. Sous Arduino-ESP32 (FreeRTOS intégré) :
tache_imu(200 Hz, lit un MPU-6050 en I2C, envoie dans une file),tache_controle(attend la file, calcule, écrit la PWM),tache_tele(10 Hz, sérialise en JSON sur l’UART). Priorités 3, 2, 1.vTaskDelayUntilpour la périodicité. - Mesurer.
uxTaskGetStackHighWaterMarkpour dimensionner les piles ;vTaskGetRunTimeStats(activer le compteur de temps d’exécution) pour la charge par tâche ; horodatageesp_timer_get_time()pour la latence file → contrôle (viser < 200 µs). - Inversion de priorité. Une ressource partagée (le bus I2C) protégée par un sémaphore binaire ; une tâche basse la prend, une tâche moyenne calcule longtemps, la haute attend : mesurez le retard de la haute. Remplacez par un mutex (héritage de priorité) : le retard disparaît. Documentez avec les horodatages.
- Robustesse. Faites échouer la lecture I2C (débranchez) : la tâche contrôle doit détecter l’absence de mesure (timeout de
xQueueReceive) et couper la PWM ; à la reconnexion, tout repart sans redémarrage. - Livrable. Code, tableau de charge et de piles, graphe de latence (avant/après mutex), et une page « ce que le RTOS a résolu / compliqué par rapport à la super-boucle du TP 1 ».
Exercices
Exercices auto-corrigés — bits, registres, tampons
Exercice 1 — Manipuler des champs de bits comme un registre
Un registre 32 bits contient : bits 0-1 MODE, 2-4 VITESSE, 5 ENABLE, 8-15 SEUIL. Écrivez lire_champ(reg, pos, largeur), ecrire_champ(reg, pos, largeur, val) (ne modifie que ce champ), et decoder(reg) → dict.
Correction
def lire_champ(reg, pos, largeur): return (reg >> pos) & ((1 << largeur) - 1)
def ecrire_champ(reg, pos, largeur, val):
masque = ((1 << largeur) - 1) << pos
return (reg & ~masque & 0xFFFFFFFF) | ((val << pos) & masque)
def decoder(reg): return {"MODE": lire_champ(reg, 0, 2), "VITESSE": lire_champ(reg, 2, 3), "ENABLE": lire_champ(reg, 5, 1), "SEUIL": lire_champ(reg, 8, 8)}Exercice 2 — Tampon circulaire de taille fixe, sans allocation
Classe Tampon(taille) (taille puissance de 2) avec ecrire(octet) → bool (False si plein, compte les débordements), lire() → octet | None, disponible(), et lire_ligne() qui renvoie une ligne complète (sans le \n) seulement si elle est entièrement présente, sinon None sans consommer.
Correction
class Tampon:
def __init__(self, taille):
self.t = bytearray(taille); self.m = taille - 1; self.tete = self.queue = 0; self.debordements = 0
def disponible(self): return (self.tete - self.queue) & self.m
def ecrire(self, b):
if ((self.tete + 1) & self.m) == self.queue: self.debordements += 1; return False
self.t[self.tete] = b; self.tete = (self.tete + 1) & self.m; return True
def lire(self):
if self.tete == self.queue: return None
b = self.t[self.queue]; self.queue = (self.queue + 1) & self.m; return b
def lire_ligne(self):
i, n = self.queue, self.disponible(); out = bytearray()
for _ in range(n):
b = self.t[i]; i = (i + 1) & self.m
if b == 10: self.queue = i; return bytes(out)
out.append(b)
return NoneExercices
Exercices auto-corrigés — temps réel
Exercice 3 — Ordonnançabilité : temps de réponse et comparaison de politiques
Écrivez temps_reponse(taches) (analyse exacte RMS du cours, renvoie {nom: R}) et ordonnancable(taches). Puis simuler_edf(taches, horizon) : simulation pas à pas (1 ms) d’un ordonnanceur EDF préemptif ; renvoie le nombre d’échéances manquées. Trouvez un jeu de tâches ordonnançable par EDF mais pas par RMS (U ≤ 1 mais analyse RMS négative).
Correction
def temps_reponse(taches):
ts = sorted(taches, key=lambda t: t[2]); R = {}
for i, (nom, C, T) in enumerate(ts):
r = C
while True:
r2 = C + sum(-(-r // Tj) * Cj for _, Cj, Tj in ts[:i])
if r2 == r or r2 > T: break
r = r2
R[nom] = r2
return R
def ordonnancable(taches): return all(R <= T for (nom, C, T), R in zip(sorted(taches, key=lambda t: t[2]), temps_reponse(taches).values()))
def simuler_edf(taches, horizon=2000):
reste = {n: 0 for n, _, _ in taches}; echeance = {n: 0 for n, _, _ in taches}; manquees = 0
for t in range(horizon):
for n, C, T in taches:
if t % T == 0:
if reste[n] > 0: manquees += 1
reste[n] = C; echeance[n] = t + T
prets = [n for n in reste if reste[n] > 0]
if prets: reste[min(prets, key=lambda n: echeance[n])] -= 1
return manqueesExercice 4 — Anti-rebond et détection d’appui long, en machine à états
Écrivez Bouton avec maj(niveau, t_ms) appelée à chaque cycle (niveau brut 0/1, temps courant), qui renvoie None, "court" (relâché avant 800 ms) ou "long" (maintenu 800 ms, émis une seule fois), avec un anti-rebond de 20 ms (un niveau doit être stable 20 ms pour être pris en compte).
Correction
class Bouton:
def __init__(self, rebond_ms=20, long_ms=800):
self.rebond, self.long = rebond_ms, long_ms; self.stable = 0; self.brut = 0; self.t_brut = 0; self.t_appui = None; self.long_emis = False
def maj(self, niveau, t):
if niveau != self.brut: self.brut, self.t_brut = niveau, t
if self.brut != self.stable and t - self.t_brut >= self.rebond:
self.stable = self.brut
if self.stable: self.t_appui, self.long_emis = t, False
elif not self.long_emis: return "court"
if self.stable and not self.long_emis and t - self.t_appui >= self.long:
self.long_emis = True; return "long"
return None06 / Défis
Défi ★ — Firmware sans delay (sur carte)
Consigne
Sur votre Arduino / Pico / ESP32 (en C/C++) : 1) une LED clignote à 2 Hz ; 2) un bouton (avec anti-rebond par interruption + horodatage) change la cadence ; 3) une commande série « f=5 » règle la fréquence, lue par un tampon circulaire non bloquant ; 4) un compteur de cycles de la boucle principale envoyé chaque seconde. Aucun delay(). Puis mesurez : combien de cycles/s la boucle fait-elle ? Que devient ce nombre si vous ajoutez un delay(1) ?
Piste
Structure : ISR bouton → horodatage millis() + drapeau ; boucle principale : if (drapeau && millis() - dernier > 20) { … }. La cadence de la boucle (des centaines de milliers de cycles/s sur ESP32) est votre marge de réactivité ; delay(1) la ramène à 1000.
06 / Défis
Défi ★★ — Ordonnanceur coopératif maison, puis FreeRTOS
Consigne
1) Écrivez en C un mini-ordonnanceur : un tableau de tâches {fonction, période, prochaine échéance, durée max mesurée} ; la boucle exécute les tâches échues, la plus urgente d’abord, et mesure le pire temps d’exécution de chacune avec un timer à la microseconde. Affichez périodiquement le tableau (une console de diagnostic). 2) Ajoutez une tâche « bruyante » de 30 ms et observez la gigue des autres. 3) Portez les mêmes tâches sur FreeRTOS (ESP32 ou simulateur POSIX de FreeRTOS sur PC) avec des priorités RMS et comparez la gigue. 4) Provoquez volontairement une inversion de priorité avec un mutex sans héritage, puis avec.
Piste (structure C)
typedef struct { void (*f)(void); uint32_t periode, prochaine, wcet_us; const char *nom; } Tache;
Tache taches[] = { {pid, 10, 0, 0, "pid"}, {imu, 20, 0, 0, "imu"}, {tele, 100, 0, 0, "tele"} };
for (;;) {
uint32_t t = millis(); int choix = -1;
for (int i = 0; i < N; i++)
if (t >= taches[i].prochaine && (choix < 0 || taches[i].periode < taches[choix].periode)) choix = i;
if (choix >= 0) {
uint32_t t0 = micros(); taches[choix].f(); uint32_t d = micros() - t0;
if (d > taches[choix].wcet_us) taches[choix].wcet_us = d;
taches[choix].prochaine += taches[choix].periode;
}
}06 / Défis
Défi ★★★ — Esprit prépa : un noyau préemptif minimal sur Cortex-M
Consigne
Écrivez un ordonnanceur préemptif à priorités fixes pour Cortex-M (STM32 ou Pico) en C + quelques lignes d’assembleur : 1) chaque tâche a sa pile ; créer une tâche = préparer une pile qui « ressemble » à une tâche interrompue ; 2) l’interruption SysTick (1 ms) choisit la tâche prête la plus prioritaire et déclenche PendSV ; 3) le gestionnaire PendSV sauvegarde les registres r4-r11 de la tâche courante sur sa pile, charge ceux de la suivante (le matériel a déjà empilé r0-r3, r12, lr, pc, xPSR) ; 4) une primitive attendre(ms) et un sémaphore binaire. Testez avec trois tâches LED. Puis : quelle est la latence de changement de contexte en cycles ? Que faut-il ajouter pour l’héritage de priorité ? Pour la protection mémoire (MPU) ?
Piste (PendSV)
__attribute__((naked)) void PendSV_Handler(void) {
__asm volatile(
"mrs r0, psp \n" /* pile de la tâche courante */
"stmdb r0!, {r4-r11} \n" /* sauvegarder les registres non empilés par le matériel */
"ldr r1, =courante \n"
"ldr r2, [r1] \n"
"str r0, [r2] \n" /* courante->sp = r0 */
"ldr r2, =suivante \n"
"ldr r2, [r2] \n"
"str r2, [r1] \n" /* courante = suivante */
"ldr r0, [r2] \n" /* r0 = suivante->sp */
"ldmia r0!, {r4-r11} \n"
"msr psp, r0 \n"
"bx lr \n" /* retour d'exception : le matériel restaure r0-r3, pc… */
);
}C’est le cœur de FreeRTOS (port ARM_CM4F, quelques centaines de lignes). Le comprendre, c’est comprendre ce qu’est un processus, un fil d’exécution, un changement de contexte — les notions centrales d’un système d’exploitation. Latence typique : ~50-100 cycles. Un excellent sujet de TIPE.
07 / Vérification
Dans une routine d’interruption UART, vous recevez un octet. Que faites-vous ?
Deux questions supplémentaires
1. Pourquoi t += periode plutôt que t = millis() ? Pour ne pas accumuler le retard d’exécution à chaque tour : la cadence moyenne reste exacte.
2. Qu’est-ce qu’une inversion de priorité ? Une tâche haute bloquée par une basse (qui tient un verrou), elle-même préemptée par une moyenne : la haute attend la moyenne. Parade : héritage de priorité.
Référence
Les mots à retenir
| Mot | Définition |
|---|---|
| Registre | Case mémoire à adresse fixe qui commande un périphérique. |
| ISR | Routine d’interruption : courte, acquitte, communique par drapeaux. |
| volatile | Variable modifiable hors du flot du programme. |
| Atomique | Opération indivisible ; BSRR, sections critiques. |
| Tampon circulaire | File producteur/consommateur sans verrou. |
| Super-boucle | Ordonnancement non préemptif par horodatages. |
| RMS / EDF | Priorités par période / par échéance. |
| WCET / gigue | Pire temps d’exécution / variation de la période. |
| RTOS | Tâches, files, sémaphores, mutex ; FreeRTOS, Zephyr. |
| Inversion de priorité | Haute bloquée par basse via un verrou ; héritage de priorité. |
| DMA | Transferts sans CPU. |
| Virgule fixe | Réels par entiers avec facteur d’échelle (Q15). |
| Watchdog | Redémarrage automatique en cas de blocage. |
Pour continuer
Vous savez ce qui se passe dans la puce
Module suivant : la perception — filtrer les capteurs (Kalman), fusionner une IMU, traiter des images : donner au robot une estimation fiable de son état et de son environnement.
À faire chez soi
- Lire les chapitres 1-4 de Making Embedded Systems ; parcourir le manuel de référence de votre carte (au moins les chapitres GPIO, timers, interruptions).
- Refaire le robot de la séance 27-29 sans un seul
delay, avec ISR encodeurs, PWM matériel et super-boucle cadencée. - Installer FreeRTOS sur ESP32 (natif) et découper le firmware en trois tâches.