Where the Hard Bugs Live

Ask an embedded engineer about the worst bug they ever chased and the answer is almost always an interrupt bug. A device that locks up after nine days. A sensor reading wrong once in ten thousand samples. These do not reproduce on the bench, do not show up in unit tests, and surface after the enclosure is tooled and units are in customers' hands.

The reason is structural. Interrupts introduce true concurrency into code that looks sequential. Your main loop can be suspended between any two machine instructions, including in the middle of writing a 32-bit value on an 8-bit or 16-bit core. Most interrupt bugs are not logic errors; they are assumptions about atomicity that hold 99.99 percent of the time.

Principle One: The ISR Does Almost Nothing

An interrupt service routine should acknowledge the hardware, capture the minimum state, and exit. Target under 10 microseconds, and treat anything over 50 microseconds as a design smell needing justification.

What belongs inside: clearing the interrupt flag, reading the data register before it is overwritten, pushing a byte into a ring buffer, and setting a flag or posting to a queue. What does not: floating-point math, printf or any logging, dynamic memory allocation, blocking waits on another peripheral, and long loops.

The pattern is capture in the ISR, process in the main context. A UART receive ISR reads one byte into a circular buffer and returns; the main loop parses the protocol. An ADC completion ISR stores the raw count; filtering happens elsewhere. This discipline eliminates a large fraction of timing bugs, because it keeps every other interrupt's latency bounded.

The consequence of ignoring it is data loss you never see. A UART at 115200 baud delivers a byte every 87 microseconds. An ISR that takes 200 microseconds to format a debug string guarantees overruns, and overruns present as corrupted protocol frames, which get blamed on the cable.

Principle Two: Assign Priorities Deliberately

On a Cortex-M part you get a configurable priority per interrupt, and leaving them all at the reset default is a decision, just a bad one. Rank by cost of being late, not by importance of the feature.

  • Highest: safety and hard-deadline events. Motor overcurrent, emergency stop, commutation timing. Missing these damages hardware or people.
  • High: data that is lost if not serviced immediately. High-rate ADC or DMA half-transfer, high-baud UART receive.
  • Medium: periodic control loops and the RTOS tick.
  • Low: user input, indicator updates, and anything a human perceives, where 50 milliseconds is invisible.

Two rules go with this. Keep the number of distinct priority levels small, three or four, because a deeply nested scheme is unanalyzable. And never call RTOS API functions from an ISR above the kernel's maximum syscall priority; on FreeRTOS that means respecting configMAX_SYSCALL_INTERRUPT_PRIORITY, and violating it produces corruption that appears days later. The relevant configuration differences between kernels are covered in FreeRTOS vs Zephyr.

Principle Three: Shared Data Is the Real Minefield

Any variable written by an ISR and read by main code, or vice versa, is a race waiting to happen. Three defenses, in order of preference.

Declare it volatile. Without it the compiler is entitled to cache the value in a register and your main loop spins forever on a flag the ISR already set. Volatile is necessary and it is not sufficient; it prevents caching, not tearing.

Keep shared objects atomic in width. A single byte or a native-word variable is read or written in one instruction. A 32-bit counter on an 8-bit MCU, a struct, or a 64-bit timestamp is not, and an interrupt landing mid-write yields a value that never existed. A millisecond counter read as half-old and half-new produces a delay that appears to jump backwards by 65 seconds.

Use a critical section for anything wider. Disable interrupts, copy, re-enable, and keep the window under a few microseconds. Save and restore the previous interrupt state rather than blindly re-enabling, or a nested critical section will silently open the door.

Better still is avoiding shared mutable state. A single-producer single-consumer ring buffer needs no locking at all when only the ISR moves the head and only main code moves the tail. Under an RTOS, a queue or stream buffer does the same job with clearer intent, following from the architecture decision in RTOS vs bare metal.

Principles That Save Debugging Nights

  • Clear the flag correctly and early. Some peripherals require a specific read-then-write sequence, and a write buffer can delay the clear so the ISR re-enters immediately on exit. Clear at the top, then read a register back to force the write to land.
  • Implement every vector, including unused ones. An unhandled interrupt on many parts jumps to a default handler that resets or spins. A default handler that logs the active vector number turns a mystery reboot into a one-line diagnosis.
  • Debounce in hardware or in a timer, never by delaying in an ISR. A mechanical switch generates dozens of edges over 5 to 20 milliseconds. Sample the pin from a periodic timer instead of taking an interrupt per bounce.
  • Never busy-wait inside an ISR. Waiting for an I2C transaction inside an interrupt at high priority can stall the entire system; the bus-level timing that makes this dangerous is described in I2C vs SPI vs UART.
  • Watch stack depth. Nested interrupts each push a frame. Fill the stack with a known pattern at boot and check the high-water mark before release.
  • Prefer DMA for bulk transfers. One interrupt per buffer instead of one per byte removes whole categories of overrun problems.

Designing It Rather Than Discovering It

Before writing handlers, build a one-page table listing every interrupt source, its worst-case rate, its deadline, its assigned priority, its measured execution time, and the data it shares. Sum the worst-case ISR time against the worst-case rate to get CPU load from interrupts; above 30 percent, the design needs rethinking.

Measure rather than estimate. Toggle a GPIO at ISR entry and exit and put a scope or logic analyzer on it, which gives execution time and, over a long capture, worst-case latency and jitter directly. That workflow is standard practice with the equipment described in embedded debugging tools.

Two structural aids matter as products grow. Feeding interrupt events into an explicit state machine, as in state machines in firmware, keeps asynchronous events from turning into tangled flag logic. And a properly serviced watchdog, per watchdog timers and firmware recovery, converts the interrupt deadlock you did not prevent into a two-second recovery instead of a dead product.

Have Your Interrupt Architecture Reviewed

Projects House reviews firmware interrupt design against measured timing, shared-state analysis, and priority assignment, usually before a product hits the field rather than after. Send your MCU, peripheral list, and timing requirements through our contact form.