Interrupts and ISRs Done Right: Rules, Latency and Shared Data
An interrupt lets hardware stop the CPU, run a short function called an interrupt service routine (ISR), and then return to exactly where the program was. Interrupts are how a microcontroller reacts within microseconds to a byte arriving on a UART, a timer expiring or a pin changing. They are also the most common source of bugs that appear once a day and vanish under the debugger.
This article explains what happens during an interrupt, the rules for writing ISRs, how to share data between an ISR and the main loop safely, and how interrupts compare with polling and DMA.
What Happens When an Interrupt Fires
- A peripheral sets a pending flag, for example the UART receive-not-empty flag.
- If that interrupt is enabled in the peripheral and in the interrupt controller, and its priority is high enough, the CPU finishes (or on some cores abandons and later restarts) the current instruction.
- The CPU saves context. On ARM Cortex-M the hardware pushes eight registers (R0-R3, R12, LR, PC and xPSR) onto the stack automatically; on AVR the hardware pushes only the program counter and the compiler-generated ISR prologue saves the rest.
- The CPU reads the address of the handler from the vector table and jumps to it.
- The ISR runs, clears the cause of the interrupt and returns.
- Context is restored and the interrupted code continues without knowing anything happened.
The time from the hardware event to the first ISR instruction is the interrupt latency. It depends on the core, on flash wait states, on whether a higher-priority ISR is already running, and on how long the main code keeps interrupts disabled. That last part is under your control, which is why critical sections must be short.
On cores with a nested vectored interrupt controller, such as the NVIC in ARM Cortex-M, a higher-priority interrupt can pre-empt a lower-priority ISR. On classic AVR, interrupts are disabled on entry to an ISR by default, so ISRs do not nest unless you re-enable them.
Rules for Writing ISRs
- Keep it short. Read the data, store it, set a flag, clear the interrupt source, return. Do processing in the main loop or a task.
- Never block. No delay loops, no waiting for another flag, no waiting for a UART to finish sending.
- Avoid printf and malloc. They are slow, usually not reentrant, and may use locks or large stack frames.
- Clear the interrupt flag. If the source is not cleared, the ISR runs again immediately after it returns and the main code starves. Some flags clear on reading a data register, others need an explicit write; the reference manual says which.
- Mark shared variables volatile so the compiler re-reads them in the main loop.
- Avoid floating point on cores where the ISR must then save FPU registers, unless you have measured the cost.
- Know your stack. ISRs run on the current stack on many cores, or on a dedicated main stack on Cortex-M; either way, deep calls inside an ISR need stack budget.
Polling vs Interrupts vs DMA
Interrupts are not always the best choice. Three strategies exist for moving data between a peripheral and memory, and good designs mix them.
| Method | How it works | Good for | Weakness |
|---|---|---|---|
| Polling | Main code checks a status flag in a loop | Simple programs, very slow or very rare events, start-up code | Wastes CPU time and can miss short events if the loop is slow |
| Interrupt | Hardware calls an ISR per event | Unpredictable events: button presses, received bytes, timer ticks | Overhead per event; at very high rates the CPU spends all its time entering and leaving ISRs |
| DMA | A DMA controller copies data between peripheral and memory without the CPU | Continuous streams: ADC sampling, audio, SPI displays, long UART transfers | More setup, fewer channels, and cache or buffer ownership rules to respect |
A common combination is DMA for the bulk transfer plus one interrupt when the transfer is half or fully complete. The CPU then processes one half of a buffer while DMA fills the other half, which is called double buffering or ping-pong buffering.
Common Mistakes
- Misspelling the handler name, for example
USART1_IRQHandle. The vector table then points to the default handler, which is usually an infinite loop. - Enabling the interrupt in the peripheral but not in the NVIC, or the other way round.
- Clearing the flag as the last ISR instruction on a core with a write buffer, so the flag is still set when the ISR exits and it fires once more. Reading the register back after clearing it is a common remedy on Cortex-M.
- Leaving interrupts disabled across a long operation such as writing flash or printing, which inflates latency for every other interrupt.
Summary
An interrupt saves context, jumps through the vector table to an ISR and returns. Good ISRs are short, never block, clear their source and hand data to the main code through volatile variables. Protect multi-step accesses with short critical sections that save and restore the previous interrupt state. Choose polling for rare or start-up work, interrupts for unpredictable events and DMA for continuous streams.