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
volatilefor 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
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.
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
volatilemakes 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
- ARM CMSIS Core: Interrupt and exception support
- Memfault Interrupt Blog: Practical firmware debugging articles
- SEI CERT C: Concurrency rules