Code UART · Interruptions Session 7 · 10 min de lecture

Ring Buffer UART
en C Bare-Metal

Implémentation d'un anneau circulaire pour la réception UART — avec gestion par interruption, protection contre le débordement, et sans allocation dynamique.

Pourquoi un ring buffer ?

En UART bare-metal, les octets arrivent caractère par caractère à des moments imprévisibles. Sans buffer, si l'application est occupée au moment où un octet arrive, il est perdu.

Le ring buffer (ou circular buffer) est la solution classique : il découple la réception des données (dans l'interruption) de leur traitement (dans la boucle principale).

// principe

Producer (interruption UART) → écrit dans le buffer. Consumer (application) → lit depuis le buffer. Les deux opèrent indépendamment, sans se bloquer mutuellement.

Visualisation du buffer circulaire

Le buffer est un tableau de taille fixe avec deux indices : head (où le prochain octet sera écrit) et tail (où le prochain octet sera lu). Quand un index atteint la fin du tableau, il repart à zéro — d'où le "circulaire".

// État du buffer après réception de 4 octets — head=4, tail=1
[0] empty
[1] 'H' TAIL ↑
lecture
[2] 'i'
[3] '!'
[4] --- HEAD ↑
écriture
[5] empty
[6] empty
[7] empty
Données valides
HEAD (écriture)
TAIL (lecture)
Buffer vide  →  head == tail
Buffer plein  →  (head + 1) % SIZE == tail
Données disponibles  →  head != tail

La struct du buffer

// ring_buffer_t — structure en mémoire
typedef struct RingBuffer_t
uint8_t
buffer[UART_BUF_SIZE]
tableau circulaire, taille = 2^n
volatile uint16_t
head
index d'écriture (ISR)
volatile uint16_t
tail
index de lecture (app)

Le mot-clé volatile est indispensable — il indique au compilateur que ces variables peuvent changer hors du flux normal (dans une ISR).

Implémentation complète

C · uart_driver.h — types publics
#define UART_BUF_SIZE  64U   // doit être une puissance de 2
#define UART_BUF_MASK  (UART_BUF_SIZE - 1U)

typedef struct {
    uint8_t          buffer[UART_BUF_SIZE];
    volatile uint16_t head;  // écrit par l'ISR
    volatile uint16_t tail;  // lu par l'application
} RingBuffer_t;
C · uart_driver.c — opérations sur le buffer
// Écriture (appelée depuis l'ISR UART)
static bool ring_push(RingBuffer_t *rb, uint8_t data) {
    uint16_t next_head = (rb->head + 1U) & UART_BUF_MASK;

    if (next_head == rb->tail) {
        return false;  // buffer plein — octet perdu
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;          // mise à jour atomique en dernier
    return true;
}

// Lecture (appelée depuis la boucle principale)
static bool ring_pop(RingBuffer_t *rb, uint8_t *out) {
    if (rb->tail == rb->head) {
        return false;  // buffer vide
    }
    *out = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1U) & UART_BUF_MASK;
    return true;
}

// ISR UART — appelée automatiquement à chaque octet reçu
void USART2_IRQHandler(void) {
    if (USART2->SR & USART_SR_RXNE) {
        uint8_t byte = (uint8_t)(USART2->DR & 0xFF);
        ring_push(&uart_rx_buf, byte);  // stocker l'octet
    }
}

// Dans la boucle principale
void UART_ProcessRx(void) {
    uint8_t c;
    while (ring_pop(&uart_rx_buf, &c)) {
        App_HandleChar(c);  // traiter l'octet
    }
}
⚠ subtilité critique

La mise à jour de head doit se faire après l'écriture des données. Si le compilateur réordonne les instructions, utiliser une barrière mémoire (__DMB()) ou déclarer le champ volatile.

L'astuce du masque binaire

Plutôt qu'un if (index >= SIZE) index = 0;, on utilise un ET binaire : index = (index + 1) & MASK;. C'est plus rapide (pas de branchement) et c'est exact si SIZE est une puissance de 2.

// Pourquoi (x + 1) & 63 == (x + 1) % 64 pour SIZE = 64
SIZE = 64 = 0b01000000
MASK = 63 = 0b00111111   (= SIZE - 1)
index = 63 :  (63 + 1) & 63 = 64 & 63 = 0  ✓ retour à zéro
index = 10 :  (10 + 1) & 63 = 11 & 63 = 11 ✓ inchangé

Points clés à retenir

volatile obligatoire
Tout index modifié dans une ISR doit être volatile — sinon le compilateur peut cacher sa valeur dans un registre et ne pas voir les mises à jour.
Taille = puissance de 2
Dimensionner le buffer à 32, 64, 128... pour pouvoir utiliser le masque binaire à la place du modulo — bien plus rapide sur Cortex-M.
Plein vs vide
On sacrifie une case : un buffer de 64 octets ne stocke que 63 données. C'est le prix de la détection fiable plein/vide avec head == tail.
Jamais de malloc
En embarqué industriel, le buffer est toujours de taille statique (static) — pas d'allocation dynamique, pas de heap fragmentation.
// extension possible

Ce même pattern ring buffer s'applique pour la transmission UART, les événements entre tâches FreeRTOS, et les messages de logging — c'est une structure fondamentale en firmware.