§ 01The polite fiction of sequential code

On a microcontroller, your code appears to run one line at a time, top to bottom, forever. That picture is mostly true — and the exception is what catches everyone out. An interrupt is the processor's way of saying stop what you're doing right now: some event — a pin going high, a timer overflowing, a byte arriving on a UART — triggers a signal that makes the CPU abandon its current instruction stream, save just enough state to resume later, and jump to a small handler function called an ISR (interrupt service routine). When the ISR returns, execution resumes exactly where it left off. The main loop never knew anything happened.

an oscilloscope showing a waveform in a dark lab Enlarge ⤢
Fig. 2 — Interrupt latency is visible the moment you put a marker pin on it.

That invisibility is the trap. The main loop genuinely cannot tell when an interrupt fired, how long the ISR ran, or how many times it happened while the main code was staring at something else. All of that is happening behind the scenes, in slices of time the main loop cannot observe — and the data they share is where things go wrong.

§ 02Shared variables and the race you didn't write

Imagine an ISR that increments a counter every time a pulse arrives on a pin. The main loop reads that counter to compute a frequency. Seems harmless. But on an 8-bit microcontroller, a 16-bit variable takes two read instructions. If an interrupt fires between those two reads — after the low byte is fetched but before the high byte — the main loop sees a corrupt value: half old, half new. The code has a race condition. Nobody wrote a race; the hardware created one by arriving at the wrong moment.

The fix is to make the read atomic — indivisible. The common approach is to disable interrupts briefly while copying the variable, then re-enable them. Most toolchains provide intrinsics or macros for this; some architectures offer native atomic load instructions. Either way, the shared variable that an ISR can modify should be declared volatile in C, which tells the compiler not to cache it in a register between reads. Without volatile, the optimiser may legally read the variable once, assume it cannot change, and use the stale copy forever. The bug appears only with optimisation enabled — one of embedded programming's most reliable gotchas.

§ 03Latency, jitter and what "on time" actually means

Every interrupt has latency: the gap between the event and the first instruction of the ISR. On a Cortex-M processor, the hardware saves registers automatically (a process called exception entry), adding a handful of cycles before your ISR code even starts. If another interrupt is already being serviced, yours must wait — that waiting time is jitter, and it means your ISR does not run at a perfectly regular interval even if the triggering event is rock-steady.

Nested interrupt controllers — the NVIC in Arm Cortex-M devices is a well-known example — assign each interrupt a priority level, so a critical handler can preempt a less important one. This reduces worst-case latency for high-priority events, but it means an ISR can itself be interrupted, and any shared state between two ISRs needs the same atomic protection as shared state between an ISR and the main loop.

Keep ISRs short. Set a flag, copy a byte, increment a counter — then get out. Heavy work belongs in the main loop, where it runs without holding off other interrupts. The ISR's only job is to capture the moment; the main loop's job is to act on it. Hold that division clearly in your head, and the strange timing behaviour of embedded code becomes predictable rather than mysterious.

Notes

  1. If a variable is touched by an interrupt and by the main loop, it needs protecting. That is the whole rule. ↩