Syntaxe, types, registres, opérations bit à bit et différences entre PIC (XC8), Arduino (Wiring/C++) et AVR bare metal (AVR-GCC)
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 :
Un programme embarqué a toujours la même structure fondamentale, quel que soit le microcontrôleur :
Voici la même LED clignotante dans les 3 environnements :
#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);
}
}
void setup() {
pinMode(13, OUTPUT);
}
void loop() {
digitalWrite(13, HIGH);
delay(500);
digitalWrite(13, LOW);
delay(500);
}
#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.
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 char | uint8_t | 1 octet | 0 – 255 | Registres, valeurs de port, compteurs courts |
signed char / char | int8_t | 1 octet | −128 – 127 | Températures signées, petits offsets |
unsigned int | uint16_t | 2 octets | 0 – 65 535 | Valeur ADC, compteur timer 16 bits |
int | int16_t | 2 octets | −32 768 – 32 767 | Calculs signés, résultats intermédiaires |
unsigned long | uint32_t | 4 octets | 0 – 4 294 967 295 | millis(), grands compteurs |
float | — | 4 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é).
stdint.hLes 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é
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.
| Opérateur | Nom | Exemple | Résultat |
|---|---|---|---|
& | ET (AND) | 0b11001010 & 0b11110000 | 0b11000000 — garde les bits communs |
| | OU (OR) | 0b11001010 | 0b00110000 | 0b11111010 — combine les bits à 1 |
^ | OU exclusif (XOR) | 0b11001010 ^ 0b11110000 | 0b00111010 — inverse les bits sélectionnés |
~ | Complément (NOT) | ~0b11001010 | 0b00110101 — inverse tous les bits |
<< | Décalage gauche | 1 << 3 | 0b00001000 — crée un masque pour le bit 3 |
>> | Décalage droite | 0b11001000 >> 3 | 0b00011001 — décale de 3 positions |
// 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
}
// 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
volatileLe 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.
volatilemain() et écrite dans une interruption (ou vice versa) doit être volatile.volatile dans les fichiers headers (<xc.h>, <avr/io.h>).// 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 macrosLes macros préprocesseur sont omniprésentes en embarqué. Elles remplacent du texte avant la compilation, sans consommer de RAM ni de Flash supplémentaire.
#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 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.
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
}
switch/caseLa 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;
}
}
delay vs millis vs TimerTrois 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) |
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.
}
| 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) |
| Action | PIC (XC8) | Arduino | AVR (AVR-GCC) |
|---|---|---|---|
| Broche en sortie | TRISIObits.TRISIO0 = 0 | pinMode(13, OUTPUT) | DDRB |= (1< |
| Sortie HIGH | GPIObits.GP0 = 1 | digitalWrite(13, HIGH) | PORTB |= (1< |
| Sortie LOW | GPIObits.GP0 = 0 | digitalWrite(13, LOW) | PORTB &= ~(1< |
| Lire entrée | if(GPIObits.GP3) | digitalRead(2) | if(PIND & (1< |
| Lire ADC | ADCON0bits.GO = 1; while(GO); val = ADRESH; | analogRead(A0) | ADCSRA |= (1< |
| Delay 500 ms | __delay_ms(500) | delay(500) | _delay_ms(500) |
| Activer pull-up | WPU |= (1<<3) | pinMode(2, INPUT_PULLUP) | PORTD |= (1< |
| Interruption globale | GIE = 1; PEIE = 1; | (automatique) | sei() |
Ce tableau est votre Rosetta Stone de l'embarqué. Imprimez-le et gardez-le à côté de votre breadboard !
uint8_t / uint16_t au lieu de int — taille explicite et prévisible.volatile toute variable partagée avec une ISR.float — préférez l'arithmétique en virgule fixe.#define — pas de « nombres magiques » dans le code.SSPCON = 0x28; ne dit rien. SSPCON = 0x28; // SSPEN=1, I2C Master dit tout.delay() dans les vrais projets — utilisez des timers ou millis().