💻 Le langage C pour microcontrôleurs

Syntaxe, types, registres, opérations bit à bit et différences entre PIC (XC8), Arduino (Wiring/C++) et AVR bare metal (AVR-GCC)

Pourquoi le C est le langage de l'embarqué

Le langage C domine la programmation embarquée depuis plus de 40 ans. Environ 80% du code embarqué mondial est écrit en C. Ce n'est pas un hasard : le C est le seul langage qui combine un accès direct au matériel (registres, adresses mémoire, bits individuels) avec une portabilité entre architectures (PIC, AVR, ARM, RISC-V).

Dans ce tutoriel, nous couvrons les éléments du C spécifiques à l'embarqué — les aspects que vous ne verrez pas dans un cours de C classique orienté PC. Nous comparons systématiquement les trois environnements utilisés sur ce site :

📐 Structure d'un programme embarqué

Un programme embarqué a toujours la même structure fondamentale, quel que soit le microcontrôleur :

  1. Configuration matérielle — Réglage de l'oscillateur, des ports, des périphériques
  2. Boucle infinie — Le programme principal tourne en permanence (pas de système d'exploitation)
  3. Interruptions (optionnel) — Des fonctions spéciales déclenchées par des événements matériels

Voici la même LED clignotante dans les 3 environnements :

PIC (XC8)

#include <xc.h>
#define _XTAL_FREQ 4000000

#pragma config FOSC = INTOSCIO
#pragma config WDTE = OFF

void main(void) {
    OSCCON = 0b01100000;  // 4 MHz interne
    ANSEL  = 0x00;         // Digital
    CMCON0 = 0x07;         // Comparateur OFF
    TRISIO = 0b00111110;  // GP0 sortie
    GPIO   = 0x00;

    while(1) {
        GPIObits.GP0 = 1;
        __delay_ms(500);
        GPIObits.GP0 = 0;
        __delay_ms(500);
    }
}

Arduino (Wiring/C++)

void setup() {
    pinMode(13, OUTPUT);
}

void loop() {
    digitalWrite(13, HIGH);
    delay(500);
    digitalWrite(13, LOW);
    delay(500);
}

AVR bare metal (AVR-GCC)

#include <avr/io.h>
#include <util/delay.h>

int main(void) {
    DDRB |= (1 << PB5);

    while(1) {
        PORTB |= (1 << PB5);
        _delay_ms(500);
        PORTB &= ~(1 << PB5);
        _delay_ms(500);
    }
}

ℹ️ Différence fondamentale : Arduino masque la configuration matérielle dans setup()/loop() (pas de main() visible, pas de while(1) explicite — le framework les gère en coulisses). Sur PIC et AVR, vous écrivez le main() et le while(1) vous-même, avec un contrôle total.

📊 Types de données en embarqué

En embarqué, la taille des types de données est critique. Chaque octet de RAM compte (le PIC12F683 n'a que 128 octets de RAM !). Utilisez toujours le type le plus petit possible.

Type C standard Type stdint.h Taille Plage Usage typique
unsigned charuint8_t1 octet0 – 255Registres, valeurs de port, compteurs courts
signed char / charint8_t1 octet−128 – 127Températures signées, petits offsets
unsigned intuint16_t2 octets0 – 65 535Valeur ADC, compteur timer 16 bits
intint16_t2 octets−32 768 – 32 767Calculs signés, résultats intermédiaires
unsigned longuint32_t4 octets0 – 4 294 967 295millis(), grands compteurs
float4 octets±3.4 × 10³⁸Calculs décimaux (à éviter si possible !)

⚠️ Évitez float en embarqué ! Les microcontrôleurs 8 bits (PIC, AVR) n'ont pas d'unité de calcul flottant (FPU). Chaque opération sur un float est émulée par logiciel et consomme des centaines de cycles CPU et des centaines d'octets de Flash. Préférez l'arithmétique en virgule fixe : par exemple, stockez une température de 23.5°C comme l'entier 235 (en dixièmes de degré).

Recommandation : utilisez stdint.h

Les types uint8_t, uint16_t, int8_t etc. de <stdint.h> ont une taille garantie et explicite, contrairement à int dont la taille varie selon le compilateur (16 bits sur PIC/AVR, 32 bits sur ARM). En embarqué, c'est une bonne pratique de toujours utiliser ces types.

#include <stdint.h>  // ou automatiquement inclus par <xc.h> / <avr/io.h>

uint8_t  compteur = 0;      // Toujours 1 octet, 0-255
uint16_t adc_value = 0;    // Toujours 2 octets, 0-65535
int16_t  temperature = 0;  // Toujours 2 octets, signé

🔧 Opérations bit à bit — Le cœur de l'embarqué

En programmation embarquée, vous manipulez des bits individuels dans les registres matériels. Les opérations bit à bit du C sont votre outil principal. C'est LE concept le plus important à maîtriser.

Les 6 opérateurs bit à bit

Opérateur Nom Exemple Résultat
&ET (AND)0b11001010 & 0b111100000b11000000 — garde les bits communs
|OU (OR)0b11001010 | 0b001100000b11111010 — combine les bits à 1
^OU exclusif (XOR)0b11001010 ^ 0b111100000b00111010 — inverse les bits sélectionnés
~Complément (NOT)~0b110010100b00110101 — inverse tous les bits
<<Décalage gauche1 << 30b00001000 — crée un masque pour le bit 3
>>Décalage droite0b11001000 >> 30b00011001 — décale de 3 positions

Les 4 opérations fondamentales sur les registres

// 1. METTRE UN BIT À 1 (SET)
REGISTRE |= (1 << n);        // Met le bit n à 1, les autres inchangés

// 2. METTRE UN BIT À 0 (CLEAR)
REGISTRE &= ~(1 << n);       // Met le bit n à 0, les autres inchangés

// 3. BASCULER UN BIT (TOGGLE)
REGISTRE ^= (1 << n);        // Inverse le bit n (0→1 ou 1→0)

// 4. TESTER UN BIT (CHECK)
if (REGISTRE & (1 << n)) {  // Vrai si le bit n vaut 1
    // Bit n est à 1
}

Exemples concrets

// PIC : configurer GP0 en sortie
TRISIO &= ~(1 << 0);  // Clear bit 0 → sortie

// AVR : allumer LED sur PB5
PORTB |= (1 << PB5);  // Set bit 5 → HIGH

// PIC : tester si le bouton sur GP3 est pressé
if (!(GPIO & (1 << 3))) {  // GP3 = LOW → bouton pressé (avec pull-up)
}

// Mettre plusieurs bits en une seule opération
PORTD |= (1 << PD2) | (1 << PD5);  // Set bits 2 et 5 en même temps

🔒 Le mot-clé volatile

Le mot-clé volatile est crucial en embarqué. Il indique au compilateur qu'une variable peut changer à tout moment en dehors du flux normal du programme (par une interruption ou par le matériel). Sans volatile, le compilateur peut « optimiser » votre code en cachant la variable dans un registre CPU, ce qui crée des bugs subtils et difficiles à diagnostiquer.

Quand utiliser volatile

// CORRECT — variable partagée avec ISR
volatile uint8_t bouton_presse = 0;

ISR(INT0_vect) {
    bouton_presse = 1;    // Écrit par l'ISR
}

int main(void) {
    // ...
    while(1) {
        if (bouton_presse) {   // Lu par main() — fonctionne grâce à volatile
            bouton_presse = 0;
            // Traiter l'appui
        }
    }
}

// SANS volatile : le compilateur pourrait "croire" que
// bouton_presse ne change jamais dans main() et optimiser
// le if() en le supprimant complètement ! Bug invisible.

📝 #define et macros

Les macros préprocesseur sont omniprésentes en embarqué. Elles remplacent du texte avant la compilation, sans consommer de RAM ni de Flash supplémentaire.

Constantes

#define _XTAL_FREQ  4000000    // PIC : fréquence oscillateur
#define F_CPU       16000000UL // AVR : fréquence CPU
#define LED_PIN     13         // Arduino : numéro de broche
#define BAUD        9600       // Vitesse UART

Macros utilitaires

// Macros pour manipuler les bits (très courantes en AVR)
#define SET_BIT(reg, bit)    ((reg) |= (1 << (bit)))
#define CLEAR_BIT(reg, bit)  ((reg) &= ~(1 << (bit)))
#define TOGGLE_BIT(reg, bit) ((reg) ^= (1 << (bit)))
#define CHECK_BIT(reg, bit)  ((reg) & (1 << (bit)))

// Utilisation :
SET_BIT(PORTB, PB5);     // LED ON
CLEAR_BIT(PORTB, PB5);   // LED OFF

// Macros pour nommer les broches
#define LED       GPIObits.GP0
#define BOUTON    GPIObits.GP3
#define LED_ON    LED = 1
#define LED_OFF   LED = 0

⚙️ #pragma config — Spécifique aux PIC (XC8)

Les bits de configuration sont une particularité des PIC. Ils configurent le comportement fondamental du microcontrôleur (oscillateur, watchdog, protection du code) et sont programmés une seule fois dans une zone mémoire spéciale. Ils ne peuvent pas être modifiés pendant l'exécution.

// Configuration PIC12F683
#pragma config FOSC  = INTOSCIO  // Oscillateur interne, GP4/GP5 en GPIO
#pragma config WDTE  = OFF       // Watchdog OFF (sinon le PIC redémarre seul)
#pragma config PWRTE = ON        // Délai au démarrage (stabilisation alim.)
#pragma config MCLRE = OFF       // Reset interne (GP3 libre en GPIO)
#pragma config CP    = OFF       // Code non protégé (lisible par PICkit)
#pragma config BOREN = ON        // Reset si tension trop basse

Sur Arduino : les fuses (équivalent AVR des pragma config) sont gérés automatiquement par le bootloader. Vous n'avez jamais à les toucher.

Sur AVR bare metal : les fuses se programment avec avrdude, séparément du code. C'est une opération délicate car de mauvais fuses peuvent rendre le chip inaccessible.

🔀 Structures de contrôle en embarqué

La boucle infinie — while(1)

Contrairement à un programme PC qui se termine, un programme embarqué ne s'arrête jamais. La boucle while(1) (ou for(;;)) est le cœur de tout programme embarqué :

while(1) {
    // Ce code tourne en permanence
    // Lire capteurs → traiter → agir → recommencer
}

Machine à états — switch/case

La machine à états (state machine) est le pattern de programmation le plus utilisé en embarqué. Elle permet de gérer des comportements complexes de manière claire et maintenable :

typedef enum { ETEINT, ALLUME, CLIGNOTANT } Etat;
volatile Etat etat_actuel = ETEINT;

while(1) {
    switch(etat_actuel) {
        case ETEINT:
            LED_OFF;
            if (bouton_presse) etat_actuel = ALLUME;
            break;

        case ALLUME:
            LED_ON;
            if (bouton_presse) etat_actuel = CLIGNOTANT;
            break;

        case CLIGNOTANT:
            TOGGLE_LED;
            __delay_ms(200);
            if (bouton_presse) etat_actuel = ETEINT;
            break;
    }
}

⏱️ Temporisations : delay vs millis vs Timer

Trois approches pour gérer le temps en embarqué, du plus simple au plus professionnel :

Méthode PIC (XC8) Arduino AVR bare metal
Delay bloquant
Simple mais bloque le CPU
__delay_ms(500) delay(500) _delay_ms(500)
Compteur non bloquant
CPU libre entre les checks
Manuel (compteur + timer) millis() Manuel (compteur + timer)
Timer + interruption
Professionnel, précis
TMR0/TMR1 + ISR Timer1 + ISR (avancé) TCNTn + ISR(TIMERn_vect)

Pattern non bloquant avec millis() (Arduino)

unsigned long previousMillis = 0;
const long interval = 500;  // 500 ms

void loop() {
    unsigned long currentMillis = millis();

    if (currentMillis - previousMillis >= interval) {
        previousMillis = currentMillis;
        digitalWrite(LED, !digitalRead(LED));  // Toggle
    }

    // Ici le CPU est libre pour faire autre chose !
    // Lire un capteur, communiquer en série, etc.
}

📂 Headers spécifiques à chaque plateforme

Plateforme Header principal Ce qu'il fournit
PIC (XC8)#include <xc.h>Définitions de tous les registres (GPIO, TRISIO, ANSEL, ADCON0…), macros __delay_ms(), types de bits de configuration
AVR (AVR-GCC)#include <avr/io.h>Définitions de tous les registres (DDRB, PORTB, PINB, ADMUX…), constantes de bits (PB5, ADEN, WGM12…)
AVR interruptions#include <avr/interrupt.h>Macro ISR(), sei(), cli(), vecteurs d'interruption
AVR delay#include <util/delay.h>Fonctions _delay_ms(), _delay_us() (nécessite F_CPU défini)
Arduino#include <Arduino.h>Tout le framework : pinMode, digitalWrite, analogRead, Serial, millis… (inclus automatiquement dans l'IDE)

📋 Aide-mémoire : équivalences PIC / Arduino / AVR

Action PIC (XC8) Arduino AVR (AVR-GCC)
Broche en sortieTRISIObits.TRISIO0 = 0pinMode(13, OUTPUT)DDRB |= (1<
Sortie HIGHGPIObits.GP0 = 1digitalWrite(13, HIGH)PORTB |= (1<
Sortie LOWGPIObits.GP0 = 0digitalWrite(13, LOW)PORTB &= ~(1<
Lire entréeif(GPIObits.GP3)digitalRead(2)if(PIND & (1<
Lire ADCADCON0bits.GO = 1; while(GO); val = ADRESH;analogRead(A0)ADCSRA |= (1<
Delay 500 ms__delay_ms(500)delay(500)_delay_ms(500)
Activer pull-upWPU |= (1<<3)pinMode(2, INPUT_PULLUP)PORTD |= (1<
Interruption globaleGIE = 1; PEIE = 1;(automatique)sei()

Ce tableau est votre Rosetta Stone de l'embarqué. Imprimez-le et gardez-le à côté de votre breadboard !

✅ Bonnes pratiques du C embarqué

  1. Utilisez uint8_t / uint16_t au lieu de int — taille explicite et prévisible.
  2. Déclarez volatile toute variable partagée avec une ISR.
  3. Évitez float — préférez l'arithmétique en virgule fixe.
  4. Nommez vos constantes avec #define — pas de « nombres magiques » dans le code.
  5. Commentez les registresSSPCON = 0x28; ne dit rien. SSPCON = 0x28; // SSPEN=1, I2C Master dit tout.
  6. Initialisez toutes les variables — ne supposez jamais une valeur par défaut.
  7. Gardez les ISR courtes — positionnez un flag et traitez dans la boucle principale.
  8. Évitez delay() dans les vrais projets — utilisez des timers ou millis().
  9. Lisez la datasheet — c'est la documentation ultime. Pas un tutoriel, pas un forum : la datasheet.
  10. Compilez souvent — ne codez jamais 100 lignes avant de compiler. Compilez toutes les 5–10 lignes.