Loading header...

Interrupts, Shared Data, and Atomic Operations

Interrupts let firmware react to hardware events without constantly polling every peripheral. A timer tick, UART byte, ADC conversion, button edge, DMA completion, or fault input can preempt the main loop, run a short interrupt service routine, and then return to the interrupted code. That speed is useful, but it creates concurrency: main code and an ISR may touch the same data at unpredictable instruction boundaries.

Good embedded C treats ISR-shared data as a design interface. The code must define who writes each variable, who reads it, when updates are atomic, and how long interrupts may be disabled.

Learning Objectives

By the end of this lesson, you should be able to:

  • explain why interrupt preemption creates shared-data hazards;
  • use volatile for visibility without mistaking it for atomicity;
  • protect multi-byte or multi-step shared access with short critical sections;
  • design simple ISR-to-main communication using flags, counters, and ring buffers;
  • debug missed interrupts, corrupted values, and race conditions with evidence.

The Interrupt Model

sequenceDiagram participant Main as Main loop participant IRQ as Interrupt source participant ISR as ISR Main->>Main: execute foreground code IRQ-->>Main: event becomes pending Main->>ISR: CPU saves context and vectors ISR->>ISR: acknowledge event and capture data ISR-->>Main: return from interrupt Main->>Main: resume at interrupted instruction

The main loop does not choose the interruption point. An ISR can occur between loading a variable and storing it back, between two byte reads of a wider variable, or while a data structure is temporarily inconsistent.

Visibility: What Volatile Does

Use volatile for objects that can change outside the compiler's normal view: ISR flags, memory-mapped registers, DMA status fields, or data updated by another execution context.

#include <stdbool.h>
#include <stdint.h>

static volatile bool uart_rx_ready;
static volatile uint8_t uart_rx_byte;

void UART_IRQHandler(void)
{
    uart_rx_byte = UART_RX_REG;
    uart_rx_ready = true;
}

int main(void)
{
    for (;;) {
        if (uart_rx_ready) {
            uint8_t b = uart_rx_byte;
            uart_rx_ready = false;
            process_byte(b);
        }
    }
}

volatile forces actual loads and stores; it does not make a read-modify-write operation indivisible. volatile count++ still means load, add, and store. An interrupt between those steps can lose an update.

Atomicity and Critical Sections

An operation is atomic when no observer can see it half-complete. On a 32-bit Cortex-M, an aligned 32-bit load is usually atomic, but a 64-bit value is not. On an 8-bit MCU, a 16-bit timer count may require two byte operations.

static volatile uint32_t system_ms;

uint32_t millis(void)
{
    uint32_t snapshot;

    disable_interrupts();
    snapshot = system_ms;
    enable_interrupts();

    return snapshot;
}

Keep critical sections short: copy shared state, update one index, or protect one small invariant. Do not print, wait for a peripheral, allocate memory, parse commands, or call slow drivers while interrupts are disabled.

Read-Modify-Write Hazard

static volatile uint16_t events_seen;

void main_loop_service(void)
{
    events_seen++;        /* not atomic on many targets */
}

void TIMER_IRQHandler(void)
{
    events_seen++;        /* update can be lost */
}

Safer options include:

  • let only one context write the variable;
  • use a critical section around the update;
  • use a hardware-supported atomic primitive if the target provides one;
  • replace shared increments with an ISR-owned counter and main-loop snapshots.

Flag, Counter, and Queue Patterns

Pattern Good for Rule
volatile flag event happened at least once ISR sets, main clears after handling
volatile counter repeated events ISR increments, main snapshots and subtracts
ring buffer byte streams and event records one context owns head, the other owns tail
message queue multi-field events publish complete records only

Flags can lose event counts if the event happens twice before main clears the flag. Counters preserve counts but can overflow. Buffers preserve order, but they need overflow handling.

Single-Producer Single-Consumer Ring Buffer

For UART RX, the ISR is the producer and the main loop is the consumer. A simple ring buffer is safe when each index has one writer.

#define RB_SIZE 64u

typedef struct {
    uint8_t data[RB_SIZE];
    volatile uint8_t head;       /* written by ISR */
    volatile uint8_t tail;       /* written by main */
    volatile bool overflow;
} rb_t;

static rb_t uart_rx;

static bool rb_push_isr(rb_t *rb, uint8_t byte)
{
    uint8_t next = (uint8_t)((rb->head + 1u) % RB_SIZE);

    if (next == rb->tail) {
        rb->overflow = true;
        return false;
    }

    rb->data[rb->head] = byte;
    rb->head = next;
    return true;
}

bool rb_pop(rb_t *rb, uint8_t *out)
{
    if (rb->tail == rb->head) {
        return false;
    }

    *out = rb->data[rb->tail];
    rb->tail = (uint8_t)((rb->tail + 1u) % RB_SIZE);
    return true;
}

This example avoids a shared count field because both contexts would need to update it. The buffer is full when advancing head would equal tail, so one slot is intentionally left unused.

flowchart LR UART["UART RX interrupt"] --> PUSH["write byte and head"] PUSH --> BUF["ring buffer"] BUF --> POP["read byte and tail"] POP --> MAIN["parser in main loop"] PUSH --> OF["overflow flag"]

Interrupt Priority and Nesting

Some MCUs allow one interrupt to preempt another. That makes shared data more complicated because there may be more than two execution contexts. Document priority assumptions:

  • Which ISRs can preempt this ISR?
  • Which shared objects are touched by multiple priorities?
  • Which critical sections mask all interrupts and which mask only selected priorities?
  • What is the maximum acceptable interrupt latency?

On ARM Cortex-M systems, prefer the vendor CMSIS primitives and understand the difference between globally disabling interrupts and using priority masking. On small MCUs, the equivalent may be a single global interrupt enable bit.

Worked Example: Safe Tick Snapshot

Assume a 1 ms interrupt increments system_ms. A 32-bit tick wraps after:

$$
T_\text{wrap}=\frac{2^{32}\ \text{ms}}{1000\ \text{ms/s}\times 3600\ \text{s/h}\times 24\ \text{h/day}}
$$

$$
T_\text{wrap}=49.7\ \text{days}
$$

Use unsigned subtraction for elapsed-time checks:

bool elapsed_ms(uint32_t now, uint32_t start, uint32_t interval)
{
    return (uint32_t)(now - start) >= interval;
}

This works across wraparound when interval is less than 2^31 ticks. For a 1 ms tick, that is about 24.8 days, much longer than normal firmware delays.

ISR Rules

  • Capture the event, acknowledge the interrupt source, and exit quickly.
  • Do not block on locks, delays, UART printing, I2C transactions, or heap allocation.
  • Prefer fixed-size buffers and deterministic execution time.
  • Make every shared variable's writer and reader obvious.
  • Clear interrupt flags in the order required by the peripheral manual.
  • Measure ISR duration if latency matters.

Common Mistakes

  • Assuming volatile makes compound operations atomic.
  • Clearing a flag in main while the ISR can set it at the same time.
  • Sharing a multi-byte variable on an 8-bit target without a snapshot.
  • Updating both ring-buffer indexes from both contexts.
  • Doing command parsing or formatted printing inside an ISR.
  • Disabling interrupts across long calculations or peripheral waits.

Practical Checks

  • Compile with warnings enabled and inspect every volatile access intentionally.
  • Stress-test event rates above the expected maximum.
  • Toggle a spare GPIO at ISR entry and exit to measure service time.
  • Force buffer overflow and confirm memory is not corrupted.
  • Review the disassembly for critical shared reads on small MCUs when atomicity is uncertain.

Summary

Interrupts make embedded firmware responsive, but they also create preemptive shared-data problems. Use volatile for visibility, short critical sections for atomic snapshots, and simple ownership rules for buffers and counters. The safest ISR captures facts quickly and leaves interpretation to the main loop.

Further Reading

Mind Map

mindmap root((ISR Shared Data)) Core idea ISR can preempt main Shared state needs ownership Visibility differs from atomicity Volatile Reloads memory Good for flags Good for registers Not count plus plus Atomicity One indivisible access Multi byte needs snapshot Critical section short Latency budget checked Patterns Flag for one event Counter for repeats Ring buffer for bytes Queue for records Calculations Tick wrap equals two power N ticks 32 bit 1 ms wrap 49.7 days Elapsed now minus start Interval below half range Practical checks Stress event rate Measure ISR GPIO pulse Force overflow Inspect writers Common mistakes Volatile as lock Long disabled interrupts Slow code in ISR Shared head and tail