Skip to content
C++
Domain Deep-Dive
Advanced

Interrupt Handling Patterns in C++

"C++ ISR patterns for embedded: volatile, std::atomic, memory barriers, ISR-safe data structures, deferred processing, and debugging interrupt-related bugs."

TL;DR

Interrupt Service Routines (ISRs) run asynchronously, potentially interrupting any instruction. Variables shared between ISRs and normal code need either volatile (prevents compiler optimization) or std::atomic (prevents both compiler and hardware reordering). Keep ISRs short: just set a flag or write to a ring buffer, then process in the main loop.

volatile vs std::atomic

These are different tools for different problems:

cpp
// volatile — tells the compiler to always read/write memory
// Does NOT prevent CPU instruction reordering
// Does NOT guarantee atomicity for multi-byte types
volatile bool g_data_ready = false;

// std::atomic — prevents both compiler AND hardware reordering
// Guarantees atomic read-modify-write for supported types
std::atomic<bool> g_data_ready_atomic{false};

When to use which

ScenarioUse
Single-byte flag set by ISR, read by mainvolatile bool or atomic<bool>
32-bit counter updated by ISRatomic<uint32_t> (not volatile alone)
ISR updates multiple fields atomicallyDisable interrupts + normal access
MMIO register accessvolatile (hardware-driven, not CPU-reordered)
Shared between ISR and RTOS taskatomic or mutex (if ISR uses FromISR variant)

Simple flag pattern

The most common ISR pattern: set a flag, poll in main loop.

cpp
#include <atomic>

std::atomic<bool> g_uart_rx_ready{false};
uint8_t           g_uart_byte;  // written before flag set — happens-before via atomic

extern "C" void UART1_IRQHandler() {
    g_uart_byte = UART1->DR;
    g_uart_rx_ready.store(true, std::memory_order_release);
    // release: ensures g_uart_byte write is visible before flag
}

void mainLoop() {
    while (true) {
        if (g_uart_rx_ready.load(std::memory_order_acquire)) {
            // acquire: ensures g_uart_byte is visible after flag load
            uint8_t b = g_uart_byte;
            g_uart_rx_ready.store(false, std::memory_order_relaxed);
            processUartByte(b);
        }
    }
}

Ring buffer for ISR → task communication

A lock-free single-producer, single-consumer ring buffer (SPSC) is ideal for ISR→task:

cpp
template<typename T, size_t N>
class ISRRingBuffer {
    static_assert((N & (N - 1)) == 0, "N must be power of 2");
public:
    // Called from ISR — producer side
    bool push(const T& item) {
        size_t head = head_.load(std::memory_order_relaxed);
        size_t next = (head + 1) & (N - 1);
        if (next == tail_.load(std::memory_order_acquire))
            return false;  // full
        buf_[head] = item;
        head_.store(next, std::memory_order_release);
        return true;
    }

    // Called from main/task — consumer side
    bool pop(T& item) {
        size_t tail = tail_.load(std::memory_order_relaxed);
        if (tail == head_.load(std::memory_order_acquire))
            return false;  // empty
        item = buf_[tail];
        tail_.store((tail + 1) & (N - 1), std::memory_order_release);
        return true;
    }

    bool empty() const {
        return head_.load(std::memory_order_acquire) ==
               tail_.load(std::memory_order_acquire);
    }

private:
    alignas(32) T buf_[N];
    std::atomic<size_t> head_{0};
    std::atomic<size_t> tail_{0};
};

// Usage
ISRRingBuffer<uint8_t, 256> uart_rx_buf;

extern "C" void UART1_IRQHandler() {
    uart_rx_buf.push(UART1->DR & 0xFF);
    // If you need to wake a task: set a flag or signal a semaphore
}

void processUart() {
    uint8_t b;
    while (uart_rx_buf.pop(b))
        protocol.consume(b);
}

Disabling interrupts for critical sections

When atomics aren't enough (e.g., updating two related variables atomically):

cpp
// ARM Cortex-M — disable/enable IRQs
class CriticalSection {
public:
    CriticalSection() {
        primask_ = __get_PRIMASK();
        __disable_irq();
        __DSB();  // data sync barrier
        __ISB();  // instruction sync barrier
    }
    ~CriticalSection() {
        __set_PRIMASK(primask_);
    }
private:
    uint32_t primask_;
};

// Usage
struct SensorData { int32_t temp; int32_t pressure; uint32_t timestamp; };
SensorData g_latest;

extern "C" void SENSOR_IRQHandler() {
    CriticalSection cs;  // all three fields updated atomically
    g_latest.temp      = readTemp();
    g_latest.pressure  = readPressure();
    g_latest.timestamp = getSysTick();
}

SensorData getSensorSnapshot() {
    CriticalSection cs;
    return g_latest;  // consistent snapshot
}

MMIO register access

Hardware registers must be volatile — the hardware can change them without the CPU doing anything:

cpp
// Direct MMIO access — always volatile
#define UART_DR   (*reinterpret_cast<volatile uint32_t*>(0x40011004))
#define UART_SR   (*reinterpret_cast<volatile uint32_t*>(0x40011000))

// Better: typed register structs
struct UARTRegisters {
    volatile uint32_t SR;   // status
    volatile uint32_t DR;   // data
    volatile uint32_t BRR;  // baud rate
    volatile uint32_t CR1;  // control 1
};

static auto* const UART1 = reinterpret_cast<UARTRegisters*>(0x40011000);

// Read-modify-write is NOT atomic on multi-byte MMIO — use with care
void enableUART() {
    UART1->CR1 |= (1u << 13);  // UE bit
}

Nested interrupt handling

ARM Cortex-M supports nested (preemptible) interrupts via NVIC priority levels:

cpp
// Set interrupt priorities — lower number = higher priority
// (ARM uses inverted priority: 0 = highest)
void configureNVIC() {
    // UART ISR — medium priority
    NVIC_SetPriority(USART1_IRQn, 5);
    NVIC_EnableIRQ(USART1_IRQn);

    // DMA done — high priority (can preempt UART)
    NVIC_SetPriority(DMA1_Stream0_IRQn, 2);
    NVIC_EnableIRQ(DMA1_Stream0_IRQn);

    // SysTick — lowest priority (never preempts other ISRs)
    NVIC_SetPriority(SysTick_IRQn, 15);
}

Debugging interrupt issues

Spurious interrupts

cpp
extern "C" void UART1_IRQHandler() {
    uint32_t sr = UART1->SR;

    // Always check AND clear the flag — leaving it set causes infinite re-entry
    if (sr & USART_SR_RXNE) {
        uint8_t b = static_cast<uint8_t>(UART1->DR);  // reading DR clears RXNE
        uart_rx_buf.push(b);
    }
    if (sr & USART_SR_ORE) {
        // Overrun — read DR to clear (even if we discard data)
        (void)UART1->DR;
        ++g_uart_overruns;
    }
}

Stack overflow in ISR

Each interrupt uses stack space. Nested interrupts stack on top of each other:

cpp
// At startup: fill stack with sentinel pattern
extern uint32_t _sstack, _estack;
void fillStackSentinel() {
    for (uint32_t* p = &_sstack; p < &_estack; ++p)
        *p = 0xDEADBEEF;
}

// Check watermark
uint32_t stackHighWatermark() {
    uint32_t* p = &_sstack;
    while (*p == 0xDEADBEEF && p < &_estack) ++p;
    return (uint32_t)(&_estack - p) * sizeof(uint32_t);
}

Timing ISR execution

cpp
// Measure ISR duration with a GPIO toggle (visible on oscilloscope)
extern "C" void UART1_IRQHandler() {
    GPIOA->BSRR = GPIO_PIN_5;  // set PA5 high

    uint8_t b = UART1->DR;
    uart_rx_buf.push(b);

    GPIOA->BSRR = GPIO_PIN_5 << 16;  // set PA5 low
}

ISR entry/exit overhead (ARM Cortex-M)

cpp
Standard Cortex-M interrupt entry (no FPU context):
  - Push {R0-R3, R12, LR, PC, xPSR}  → 8 words pushed to stack
  - Branch to ISR vector
  - Total: ~12 cycles @ 168MHz = ~71ns

With FPU (lazy stacking):
  - First FPU instruction triggers additional ~16-cycle push
  - Use __attribute__((naked)) for absolutely minimal ISR entry
cpp
// Naked ISR — no compiler prologue/epilogue
// You manage the stack yourself — only for extreme latency cases
__attribute__((naked)) extern "C" void EXTI0_IRQHandler() {
    __asm volatile(
        "push {r0-r3, r12, lr}\n"
        "bl  handleEXTI0\n"       // call your C++ handler
        "pop {r0-r3, r12, pc}\n"
    );
}

extern "C" void handleEXTI0() {
    EXTI->PR |= EXTI_PR_PR0;  // clear pending bit
    gpio_toggle(LED_PIN);
}
Edit on GitHubUpdated 2026-05-01T00:00:00.000Z