Mini-LessonIntermediateFirmware

Mini Lesson: Interrupt-Safe Data Sharing

Why volatile isn't enough, how to use critical sections correctly, and when to reach for a lock-free ring buffer instead.

Mini Lesson: Interrupt-Safe Data Sharing

Why volatile isn't enough, how to use critical sections correctly, and when to use a lock-free ring buffer instead.

The problem

ISRs and task-level code share state. Without the right synchronization, you get:

  • Torn reads/writes — 32-bit value updated in two instructions, ISR fires between them
  • Stale reads — compiler caches value in a register, never re-reads from memory
  • Missed updates — compiler reorders reads/writes across a "safe" boundary

Why volatile is not enough

volatile tells the compiler to not optimize away reads/writes to a variable. It does not:

  • Prevent the CPU from reordering memory accesses (on weakly-ordered architectures)
  • Make multi-word updates atomic
  • Prevent preemption between two volatile reads

volatile is correct for memory-mapped I/O registers. It is not a synchronization primitive.

Tool 1 — Critical section (disable/enable interrupts)

Use when: sharing a small variable or simple flag between an ISR and one task.

/* Enter critical section */
taskENTER_CRITICAL();  /* FreeRTOS — masks interrupts up to configMAX_SYSCALL_INTERRUPT_PRIORITY */
/* or: __disable_irq() for bare-metal Cortex-M */

shared_counter++;      /* Safe to read-modify-write */

taskEXIT_CRITICAL();

Rules:

  • Keep the critical section as short as possible — you're blocking all preemption
  • Never call blocking APIs inside a critical section
  • On Cortex-M, only masks interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY in FreeRTOS — higher-priority ISRs still fire

Tool 2 — Atomic operations (Cortex-M)

For single-word values on Cortex-M3+ use LDREX/STREX (or C11 _Atomic):

#include <stdatomic.h>

atomic_uint event_flags;

/* In ISR */
atomic_fetch_or(&event_flags, EVENT_READY);

/* In task */
unsigned flags = atomic_exchange(&event_flags, 0);

No interrupt masking, no blocking — correct on single-core Cortex-M.

Tool 3 — Lock-free ring buffer (ISR → task)

Use when: streaming data from an ISR to a task (UART RX, ADC samples, sensor bursts).

#define BUF_SIZE 64  /* Must be power of 2 */
typedef struct {
    uint8_t  data[BUF_SIZE];
    uint32_t head;  /* Written by ISR only */
    uint32_t tail;  /* Read by task only */
} RingBuf;

/* ISR */
void uart_rx_isr(void) {
    uint32_t next = (rb.head + 1) & (BUF_SIZE - 1);
    if (next != rb.tail) {         /* Not full */
        rb.data[rb.head] = UART->DR;
        rb.head = next;            /* Single-word write — atomic on Cortex-M */
    }
}

/* Task */
bool ring_read(uint8_t *out) {
    if (rb.tail == rb.head) return false;  /* Empty */
    *out = rb.data[rb.tail];
    rb.tail = (rb.tail + 1) & (BUF_SIZE - 1);
    return true;
}

Safe because head is only written by the ISR and tail only by the task — no shared mutable state.

Decision guide

| Situation | Tool | |---|---| | Single flag or counter, brief update | Critical section | | Single-word value, single writer | atomic_ / LDREX-STREX | | Stream of data, ISR → task | Lock-free ring buffer | | Multiple tasks sharing state | Mutex (task context only) | | Task waiting for ISR event | Semaphore (xSemaphoreGiveFromISR) |

Next Step

Ready for a full course path?

Use this resource as a starting point and continue with structured modules in Learn courses.