Interrupt-Safe Design
Critical sections, atomic access, and the lock-free patterns that keep shared data consistent between interrupt handlers and task code.
Task scheduling covers how tasks share the CPU with each other. Interrupt handlers add a second, sharper problem: they can preempt any task at any point, including in the middle of an operation that data shared with that task assumed would be atomic.
Critical sections
The bluntest tool is disabling interrupts entirely around a critical section:
uint32_t critical_disable_interrupts(void) {
uint32_t primask = __get_PRIMASK();
__disable_irq();
return primask;
}
void critical_restore(uint32_t primask) {
__set_PRIMASK(primask);
}
// usage
uint32_t state = critical_disable_interrupts();
shared_counter++;
critical_restore(state);This works, but every microsecond interrupts are disabled is a microsecond every other interrupt — including ones with hard real-time deadlines — is delayed. Critical sections should be as short as physically possible.
Atomic access
On most microcontroller architectures, a properly aligned single-word read or write is naturally
atomic — it can't be interrupted partway through. A multi-word structure, or an operation like
counter++ that compiles to a read-modify-write sequence, is not atomic unless you explicitly
make it so. This is exactly why a variable shared between an ISR and main-line code needs both
volatile (covered in Timers & Interrupts) and,
for anything wider than a single word, explicit protection.
Lock-free patterns
Disabling interrupts works but doesn't scale well to high-throughput producer/consumer data, like a stream of ADC samples. A common alternative is a ring buffer: the ISR writes to a head index, the task reads from a tail index, and as long as each index is only ever written by one side, no locking is needed at all — just careful ordering of the index updates relative to the data writes.
volatile uint16_t head = 0, tail = 0;
uint8_t buffer[256];
// ISR: producer
void uart_rx_isr(void) {
buffer[head] = UART_DR;
head = (head + 1) % 256;
}
// Task: consumer
bool buffer_pop(uint8_t *out) {
if (head == tail) return false; // empty
*out = buffer[tail];
tail = (tail + 1) % 256;
return true;
}One hard rule: never block in an ISR
A blocking mutex or semaphore wait is designed for tasks, which the scheduler can suspend and resume.
An interrupt handler isn't a task — it has no context to suspend into. Calling a blocking RTOS
primitive from an ISR is undefined behavior on most RTOSes (FreeRTOS, for one, provides separate
...FromISR() variants of its APIs specifically to make this safe).
Why this matters in practice
Bugs from unsafe sharing between interrupts and tasks are some of the hardest to reproduce in embedded systems — they depend on exact timing, show up rarely, and vanish under a debugger that slows things down. Designing the sharing strategy deliberately, rather than discovering it's wrong in the field, is what separates firmware that's merely working from firmware that's correct.
This closes out the real-time systems lesson. The last piece of building a shippable embedded product is the hardware itself: Hardware Design.