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
volatilereads
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_PRIORITYin 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) |