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:
// 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
| Scenario | Use |
|---|---|
| Single-byte flag set by ISR, read by main | volatile bool or atomic<bool> |
| 32-bit counter updated by ISR | atomic<uint32_t> (not volatile alone) |
| ISR updates multiple fields atomically | Disable interrupts + normal access |
| MMIO register access | volatile (hardware-driven, not CPU-reordered) |
| Shared between ISR and RTOS task | atomic or mutex (if ISR uses FromISR variant) |
Simple flag pattern
The most common ISR pattern: set a flag, poll in main loop.
#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:
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):
// 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:
// 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:
// 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
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:
// 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
// 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)
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// 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);
}