Conslabs
Real-Time Systems & RTOS
intermediateMarch 18, 2025 · 3 min read

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.