LYCÉE → PRÉPA · L18

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

Définition (système embarqué). Calculateur intégré à un dispositif physique, avec des ressources contraintes (mémoire en Ko, énergie, pas d’OS ou un OS minimal) et des entrées/sorties matérielles : GPIO, ADC (analogique → numérique), PWM (modulation de largeur d’impulsion), bus série (UART, I²C, SPI, CAN), timers, interruptions.
Définition (temps réel). Un système est temps réel si la validité d’un résultat dépend aussi de son instant de production. Temps réel dur : rater une échéance est une faute (airbag, contrôle de vol) ; mou : dégradation (vidéo). Ce n’est pas « rapide », c’est « prévisible ».
Définition (tâche périodique, WCET, échéance). Tâche τi de période Ti, temps d’exécution pire cas Ci (WCET), échéance Di (souvent = Ti). Utilisation U = Σ Ci/Ti. Un ordonnancement est faisable si toutes les échéances sont respectées.
Définition (ordonnanceurs). Rate Monotonic (RM) : priorité fixe, plus la période est courte plus la priorité est haute. Earliest Deadline First (EDF) : priorité dynamique à l’échéance la plus proche. Préemptif : une tâche plus prioritaire interrompt la courante.
Définition (interruption, section critique, inversion de priorité). Une interruption suspend le programme pour exécuter un gestionnaire (ISR) court. Une section critique accède à une ressource partagée et doit être atomique (masquer les interruptions, mutex). Inversion de priorité : une tâche haute attend un mutex tenu par une tâche basse, elle-même préemptée par une tâche moyenne — remède : héritage de priorité.
Définition (virgule fixe). Représenter un réel x par l’entier round(x·2f) (format Qm.f) : addition exacte, multiplication suivie d’un décalage de f bits, pas de FPU nécessaire. Résolution 2−f, plage ±2m.

Fiche de cours · Formules

Formules à connaître

Utilisation : U = Σi Ci/Ti ; condition nécessaire de faisabilité : U ≤ 1
Borne de Liu-Layland (RM, échéances = périodes) : U ≤ n(21/n − 1) suffit — 0,828 (n = 2), 0,780 (n = 3), → ln 2 ≈ 0,693
EDF : faisable ⇔ U ≤ 1 (échéances = périodes)
Temps de réponse (RM, analyse exacte) : Ri = Ci + Σj plus prioritaires ⌈Ri/Tj⌉·Cj, itéré jusqu’au point fixe ; faisable si Ri ≤ Di
PWM : tension moyenne = V·(rapport cyclique) ; fréquence > bande passante du moteur (≈ 20 kHz pour ne pas siffler)
ADC n bits sur [0, Vref] : quantum Vref/2ⁿ (10 bits, 3,3 V → 3,2 mV) ; bruit de quantification σ = quantum/√12
Shannon-Nyquist : féchantillonnage > 2·fmax du signal, sinon repliement (aliasing)
PID discret (période Ts) : uk = Kpek + KiTsΣe + Kd(ek − ek−1)/Ts ; anti-windup : borner l’intégrale
Q15 : x ∈ [−1, 1[ codé sur 16 bits ; produit de deux Q15 = Q30, décaler de 15 ; latence UART à 115 200 bauds : 10 bits/octet → 87 µs par octet

Fiche de cours · Théorèmes et démonstrations

Démonstrations à savoir refaire (1/2)

Théorème 1 (instant critique). Pour des tâches périodiques à priorités fixes, le pire temps de réponse d’une tâche survient quand elle est activée en même temps que toutes les tâches plus prioritaires.
Le temps de réponse de τi est Ci plus l’interférence des tâches plus prioritaires pendant l’intervalle de réponse. Décaler l’activation d’une tâche plus prioritaire τj pour qu’elle coïncide avec celle de τi ne peut qu’augmenter le nombre d’activations de τj dans l’intervalle (on maximise les activations en commençant au début). Le maximum d’interférence est donc atteint quand toutes démarrent ensemble. Conséquence : il suffit de simuler ce scénario (ou l’équation de temps de réponse) pour vérifier la faisabilité — pas besoin d’explorer toutes les phases.
Théorème 2 (optimalité de Rate Monotonic parmi les priorités fixes, cas de deux tâches). Si un jeu de deux tâches (échéances = périodes) est ordonnançable avec une affectation de priorités fixes, il l’est avec RM.
Soit T₁ < T₂. Si τ₂ a la priorité haute (non-RM) et que c’est faisable, alors à l’instant critique τ₁ attend C₂ puis s’exécute : C₂ + C₁ ≤ T₁. Passons à RM (τ₁ prioritaire) : τ₁ est trivialement respectée (C₁ ≤ T₁). Pour τ₂ : dans [0, T₂], τ₁ s’exécute ⌈T₂/T₁⌉ fois au plus, et comme C₁ + C₂ ≤ T₁, on montre que le temps restant suffit : ⌈T₂/T₁⌉C₁ + C₂ ≤ T₂ découle de C₁ + C₂ ≤ T₁ (en écrivant T₂ = kT₁ + r et en distinguant selon que C₁ ≤ r). Donc RM est faisable. L’argument s’étend à n tâches par échanges successifs de priorités adjacentes (Liu & Layland 1973).
Théorème 3 (borne de Liu-Layland pour n = 2). Deux tâches avec U ≤ 2(√2 − 1) ≈ 0,828 sont toujours ordonnançables par RM.
On cherche le pire cas : T₁ < T₂, τ₁ prioritaire. Le cas le plus défavorable a T₂ = kT₁ + r avec la dernière activation de τ₁ « débordant » sur la fin de la période de τ₂. En fixant C₁ et en prenant C₂ maximal faisable, l’utilisation U(C₁) est minimale quand C₁ = T₂ − kT₁ (τ₁ occupe juste la fin) ; le calcul donne U = k + (r/T₁)² − … dont le minimum sur r/T₁ ∈ [0, 1[ vaut, pour k = 1, 2(√2 − 1) atteint en T₂/T₁ = √2. Toute paire d’utilisation inférieure est faisable ; la borne est atteinte (cas limite), donc optimale. En pratique : U ≤ 69 % garantit RM pour tout n ; au-delà, faire l’analyse de temps de réponse exacte.

Fiche de cours · Théorèmes et démonstrations

Démonstrations à savoir refaire (2/2)

Théorème 4 (EDF est optimal : faisable ⇔ U ≤ 1). Pour des tâches périodiques indépendantes préemptibles avec Di = Ti.
(⇒) U > 1 : sur l’hyperpériode H (ppcm des périodes), la demande totale est Σ (H/Ti)Ci = U·H > H : impossible quel que soit l’ordonnanceur. (⇐, idée) Par l’absurde, soit t la première échéance ratée par EDF ; soit t₀ le dernier instant avant t où le processeur était oisif ou exécutait une tâche d’échéance > t. Dans [t₀, t], EDF n’a exécuté que des tâches d’échéance ≤ t, activées après t₀, dont la demande est ≤ Σi ⌊(t − t₀)/Ti⌋Ci ≤ (t − t₀)·U ≤ t − t₀ ; le processeur a été occupé en continu par ces tâches et pourtant l’une rate son échéance : la demande dépasse t − t₀ — contradiction. EDF exploite 100 % du processeur, contre 69 % garantis pour RM, mais ses priorités dynamiques sont plus coûteuses et son comportement en surcharge est pire (effet domino).
Théorème 5 (une section critique non protégée corrompt une variable partagée). Si un compteur 32 bits est incrémenté par le programme principal et lu par une ISR sur un microcontrôleur 8 bits, l’ISR peut lire une valeur qui n’a jamais existé.
L’incrément est compilé en plusieurs instructions (charger 4 octets, ajouter, stocker 4 octets). Une interruption peut survenir entre deux stockages : l’ISR lit deux octets nouveaux et deux anciens. Exemple : compteur 0x0000FFFF + 1 : après stockage de l’octet bas (0x00) et avant celui du suivant, la valeur en mémoire est 0x0000FF00 — ni l’ancienne ni la nouvelle. Remède : désactiver les interruptions autour de l’accès (cli/sei), ou utiliser un type atomique de la taille du mot machine, ou volatile + double lecture jusqu’à cohérence. volatile seul empêche le compilateur de mettre la variable en registre mais ne rend rien atomique.
Théorème 6 (bruit de quantification). L’erreur de quantification d’un ADC de quantum q, supposée uniforme sur [−q/2, q/2], a une variance q²/12.
Variance de la loi uniforme sur un intervalle de largeur q : q²/12 (L11). Rapport signal/bruit d’un ADC n bits sur un signal pleine échelle sinusoïdal : SNR = 6,02n + 1,76 dB — 10 bits ≈ 62 dB. Moyenner k mesures divise la variance par k (si le bruit est indépendant) : suréchantillonner de 4× gagne 1 bit effectif.

Fiche de cours · Méthodes

Méthodes et pièges

Méthode — structurer un firmware. Boucle principale à période fixe (timer) : lire capteurs → estimer → décider → commander → journaliser, chaque étape bornée en temps. ISR minimales (poser un drapeau, copier un octet) ; tout le travail dans la boucle. Machine à états explicite pour les modes (init, marche, erreur, arrêt d’urgence). Chien de garde (watchdog) réarmé seulement si toutes les tâches ont tourné.
Méthode — vérifier la faisabilité. Mesurer les WCET (toggle d’une broche + oscilloscope, ou compteur de cycles), avec marge 20–50 %. Calculer U ; si ≤ borne RM, fini ; sinon, temps de réponse exact. Prévoir la dégradation : que se passe-t-il à 110 % de charge ?
Méthode — passer un algorithme en virgule fixe. Déterminer la plage de chaque variable (simulation en flottant, max/min) → choisir m ; la précision requise → f ; vérifier les débordements des produits (Qm.f × Qm.f = Q2m.2f, sur 2× plus de bits) ; comparer flottant/fixe sur des entrées de test ; borner les accumulateurs (anti-windup).

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

Exercice 1. Trois tâches : contrôle moteur (T = 2 ms, C = 0,5 ms), fusion capteurs (T = 10 ms, C = 3 ms), télémétrie (T = 50 ms, C = 12 ms). Ordonnançable par RM ? Par EDF ? Calculer le temps de réponse de la télémétrie sous RM.
Correction. U = 0,25 + 0,3 + 0,24 = 0,79. Borne RM pour n = 3 : 0,780 : U dépasse de peu, la borne suffisante ne conclut pas. EDF : U ≤ 1, faisable. Temps de réponse RM de la télémétrie (priorité la plus basse) : R⁰ = 12 ; R¹ = 12 + ⌈12/2⌉·0,5 + ⌈12/10⌉·3 = 12 + 3 + 6 = 21 ; R² = 12 + ⌈21/2⌉·0,5 + ⌈21/10⌉·3 = 12 + 5,5 + 9 = 26,5 ; R³ = 12 + 14·0,5 + 3·3 = 28 ; R⁴ = 12 + 14·0,5 + 3·3 = 28 : point fixe 28 ms ≤ 50 ms. Fusion : R = 3 + ⌈R/2⌉·0,5 → 3 + 1 = 4 → 3 + 2·0,5 = 4 : 4 ms ≤ 10. Moteur : 0,5 ms. Tout est faisable sous RM malgré U > 0,78 : la borne est suffisante, non nécessaire.
Exercice 2. Un capteur de distance renvoie 0–3,3 V pour 0–2 m, lu par un ADC 10 bits. Résolution en mm ? Bruit de quantification ? Le signal est échantillonné à 50 Hz mais le capteur a une bande passante de 100 Hz : problème ? Si l’on moyenne 16 échantillons, quel gain ?
Correction. Quantum : 3,3/1024 = 3,22 mV ↔ 2 000 mm/1024 = 1,95 mm. Bruit : σ = 1,95/√12 = 0,56 mm. Nyquist : il faut fe > 200 Hz ; à 50 Hz, les composantes entre 25 et 100 Hz se replient dans la bande utile (vibrations à 60 Hz apparaissent à 10 Hz) — mettre un filtre anti-repliement analogique avant l’ADC (RC de coupure ≈ 20 Hz) ou échantillonner à 250 Hz et filtrer numériquement. Moyenne de 16 : σ/4 = 0,14 mm (si bruit indépendant) et, avec un peu de bruit analogique (dither), 2 bits effectifs de plus ; en contrepartie, latence de 16 échantillons.
Exercice 3. Implémenter un filtre passe-bas yk = 0,9yk−1 + 0,1xk en virgule fixe Q15 sur un microcontrôleur 16 bits sans FPU, et analyser l’erreur.
Correction. Coefficients en Q15 : 0,9 → 29 491 ; 0,1 → 3 277 (arrondis : 0,9 = 29 491,2 ; erreur 6·10⁻⁶). Calcul : 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

BlocRôleOrdres de grandeur (STM32F4 / ESP32 / ATmega328)
Cœur (CPU)Exécute les instructions ; Cortex-M4 / Xtensa LX6 / AVR 8 bits168 MHz / 240 MHz ×2 / 16 MHz
FlashLe programme (non volatile)512 Ko / 4 Mo / 32 Ko
SRAMLes variables, la pile, le tas (volatile)128 Ko / 520 Ko / 2 Ko
GPIOBroches numériques entrée/sortie80 / 34 / 23
TimersCompteurs matériels : PWM, mesure de durées, cadencement14 / 4 / 3
ADC / DACAnalogique ↔ numérique12 bits 2,4 MHz / 12 bits / 10 bits
UART, SPI, I2C, CAN, USBCommunication sérieplusieurs de chaque
DMACopie mémoire ↔ périphérique sans le CPU16 canaux / oui / non
NVICContrôleur d’interruptions : priorités, imbrication16 niveaux / oui / 2 niveaux
WatchdogRedémarre si le programme ne donne plus signe de vieoui

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
  1. Courte : quelques microsecondes. Pas de printf, pas d’attente, pas d’allocation. Elle bloque tout ce qui est moins prioritaire.
  2. Acquitter le drapeau matériel, sinon elle boucle.
  3. Communiquer avec le reste par des variables volatile et des drapeaux ; le travail long se fait dans la boucle principale.
  4. 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.
  5. 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)
  1. Pas de récursion, pas de goto ; boucles à borne fixe prouvable.
  2. Pas d’allocation dynamique après l’initialisation.
  3. Fonctions courtes (une page).
  4. Au moins deux assertions par fonction ; une assertion qui échoue déclenche une action de sûreté, pas un simple message.
  5. Portée minimale des données ; pas de globales non nécessaires.
  6. Vérifier toutes les valeurs de retour, tous les paramètres.
  7. Préprocesseur limité aux includes et constantes simples.
  8. Un seul niveau de déréférencement de pointeur ; pas de pointeurs de fonction.
  9. 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

SectionCe qu’on y chercheExemple (STM32F401, ESP32, ATmega328P)
Caractéristiques électriques absoluesCe qui détruit la puceVDD max 3,6 V ; courant par broche 25 mA ; 5 V sur une entrée 3,3 V = mort
Brochage (pinout) et fonctions alternéesQuelle 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émoireAdresses de la Flash, SRAM, périphériquesSRAM à 0x2000 0000 ; GPIOA à 0x4002 0000
Arbre d’horlogesQuelle fréquence arrive à chaque périphériqueUn timer à 84 MHz sur APB1×2, l’UART à 42 MHz : le calcul du baud rate en dépend
Description des registresBit par bit : nom, valeur au reset, lecture/écritureGPIOx_MODER, bits 2y+1:2y : 00 entrée, 01 sortie, 10 alternée, 11 analogique
Caractéristiques temporellesLatences, temps d’établissement, fréquences maxADC : 12 bits à 2,4 Méch/s ; temps de réveil du mode stop : ~13 µs
ErrataLes 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) ?

  1. 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.
  2. 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.
  3. Gestionnaire EXTI0_IRQHandler : acquitter (écrire 1 dans EXTI_PR), incrémenter le compteur : ~10 instructions, ~120 ns.
  4. Retour (bx lr avec une valeur magique EXC_RETURN) : dépilage automatique, 12 cycles. Total ≈ 0,4 µs par impulsion.
  5. À 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

BugMécanismeExempleParade
Condition de concurrence (race)Deux contextes lisent-modifient-écrivent la même donnéecompteur++ dans main et dans une ISR : une incrémentation perdueDé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 entreUn uint32_t sur AVR 8 bits, un double sur Cortex-M0Copier sous section critique ; ou double tampon avec index atomique
Réordonnancement / cacheLe compilateur ou le cœur réordonne des accès mémoirePublier « donnée prête » avant que la donnée soit écritevolatile n’est PAS une barrière ; utiliser __DMB(), ou les atomiques C11 (<stdatomic.h>)
RéentranceUne fonction interrompue puis rappelée depuis l’ISRprintf, malloc, une machine à états avec variables staticNe jamais appeler ces fonctions depuis une ISR
FamineUne ISR trop fréquente ou trop longue empêche le resteDébordement du tampon UART parce que l’ISR encodeur monopoliseISR 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)

TP guidé

TP 2 — FreeRTOS sur ESP32 : tâches, files, priorités, et une inversion de priorité (2 h 30)

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 None

Exercices

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 manquees

Exercice 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 None

06 / 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

MotDéfinition
RegistreCase mémoire à adresse fixe qui commande un périphérique.
ISRRoutine d’interruption : courte, acquitte, communique par drapeaux.
volatileVariable modifiable hors du flot du programme.
AtomiqueOpération indivisible ; BSRR, sections critiques.
Tampon circulaireFile producteur/consommateur sans verrou.
Super-boucleOrdonnancement non préemptif par horodatages.
RMS / EDFPriorités par période / par échéance.
WCET / giguePire temps d’exécution / variation de la période.
RTOSTâches, files, sémaphores, mutex ; FreeRTOS, Zephyr.
Inversion de prioritéHaute bloquée par basse via un verrou ; héritage de priorité.
DMATransferts sans CPU.
Virgule fixeRéels par entiers avec facteur d’échelle (Q15).
WatchdogRedé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

← L17SommaireL19 : Perception, filtrage et vision →