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

  1. A peripheral sets a pending flag, for example the UART receive-not-empty flag.
  2. 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.
  3. 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.
  4. The CPU reads the address of the handler from the vector table and jumps to it.
  5. The ISR runs, clears the cause of the interrupt and returns.
  6. 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.

Sharing Data Safely Between ISR and Main

volatile makes sure each access happens, but it does not make an access atomic. On an 8-bit AVR, reading a 32-bit millisecond counter takes four separate byte loads. If the timer ISR updates the counter between the second and third load, main gets half the old value and half the new one. On a 32-bit core a single aligned 32-bit read is atomic, but a read-modify-write such as counter++ or reading two related variables is still not.

The standard fix is a critical section: disable interrupts, copy or update the shared data, re-enable interrupts. Keep it to a few instructions. The example below compiles and runs on a PC: the critical section macros are empty, and the ISR is called directly to simulate events. The comments show the real versions for Cortex-M (CMSIS) and AVR (avr-libc).

ISR-to-main hand-off with a volatile flag, a counter and a critical section (PC simulation)
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

/* Cortex-M (CMSIS):  save PRIMASK, then __disable_irq();  restore with __set_PRIMASK()
 * AVR (avr-libc):    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { ... } from <util/atomic.h>
 * PC simulation:     nothing to do                                              */
#define ENTER_CRITICAL()  do { } while (0)
#define EXIT_CRITICAL()   do { } while (0)

static volatile uint32_t pulse_count = 0;    /* written by ISR, read by main */
static volatile bool     sample_ready = false;
static volatile uint16_t last_sample  = 0;

/* Would be, for example, void EXTI0_IRQHandler(void) on an STM32 */
void pulse_isr(uint16_t adc_value)
{
    pulse_count++;            /* only the ISR writes this */
    last_sample  = adc_value; /* store raw data, no processing here */
    sample_ready = true;      /* signal main */
    /* real ISR: clear the peripheral's pending flag here */
}

static uint32_t get_pulse_count(void)
{
    uint32_t copy;
    ENTER_CRITICAL();
    copy = pulse_count;       /* atomic snapshot even on 8-bit cores */
    EXIT_CRITICAL();
    return copy;
}

int main(void)
{
    const uint16_t fake_adc[] = {512, 530, 498, 600};

    for (int i = 0; i < 4; i++) {
        pulse_isr(fake_adc[i]);           /* simulate the hardware event */

        if (sample_ready) {
            uint16_t s;
            ENTER_CRITICAL();
            s = last_sample;              /* take data and flag together */
            sample_ready = false;
            EXIT_CRITICAL();

            /* slow work happens here, outside the ISR */
            printf("sample %u, total pulses %lu\n",
                   (unsigned)s, (unsigned long)get_pulse_count());
        }
    }
    return 0;
}

Two details matter. The main loop copies last_sample and clears sample_ready inside the same critical section, so a new sample cannot arrive between the two steps and be lost silently. And the Cortex-M recipe saves and restores PRIMASK instead of blindly re-enabling interrupts, so the function is safe to call from code that already had interrupts disabled.

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.

MethodHow it worksGood forWeakness
PollingMain code checks a status flag in a loopSimple programs, very slow or very rare events, start-up codeWastes CPU time and can miss short events if the loop is slow
InterruptHardware calls an ISR per eventUnpredictable events: button presses, received bytes, timer ticksOverhead per event; at very high rates the CPU spends all its time entering and leaving ISRs
DMAA DMA controller copies data between peripheral and memory without the CPUContinuous streams: ADC sampling, audio, SPI displays, long UART transfersMore 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.