Interrupt-Driven Design: Writing Non-Blocking Firmware for Microcontrollers
Why polling loops kill embedded systems and how to replace them with interrupt service routines. Covers ISR setup, volatile variables, debounce, critical sections and the rules that separate good embedded firmware from bad.
Most beginner embedded tutorials use a polling loop. Check the button, check the sensor, check the flag, repeat. It works when you have one thing to check and nothing else to do. The moment you add a second responsibility, the loop breaks down. While you are waiting for one thing, you miss another.
Interrupt-driven design solves this. Instead of asking the hardware if something happened, you tell the hardware to notify you when it does. Your main loop stays free. The interrupt fires only when it needs to. This is not just an optimisation. For real-time systems it is often a requirement.
What an Interrupt Actually Is
An interrupt is a hardware signal that pauses the current execution context and jumps to a predefined function called an Interrupt Service Routine, or ISR. When the ISR returns, execution resumes exactly where it left off. The CPU saves and restores the relevant registers automatically. From the perspective of the main loop, the interrupt happened between two instructions.
Every microcontroller has an interrupt vector table: a fixed block of memory at the start of flash that maps each interrupt source to an address. When an interrupt fires, the CPU looks up that table and jumps to the correct handler. On AVR devices, you define an ISR with the ISR() macro from avr-libc. On STM32 you use HAL_NVIC_EnableIRQ() and write a handler with the exact name the linker expects.
The volatile Keyword
Any variable shared between an ISR and the main loop must be declared volatile. Without it, the compiler assumes the variable cannot change between two reads in the main loop and caches it in a register. When the ISR updates the variable in memory, the main loop never sees the new value. volatile forces the compiler to re-read the variable from memory every time it is accessed.
volatile uint8_t mode = 0; // shared between ISR and main loop
ISR(INT0_vect) {
mode = (mode + 1) % 9;
}
int main(void) {
// ...
while (1) {
switch (mode) { // always reads from memory, not a register
case 0: run_chase(); break;
case 1: run_blink_all(); break;
// ...
}
}
}Button Debounce in an ISR
Mechanical buttons bounce: the contacts make and break several times in the first millisecond after a press. Without debounce, a single button press registers as multiple interrupts. The simplest software fix is a short delay inside the ISR, which works for low-frequency button presses but is not appropriate for latency-sensitive interrupt handlers.
A better approach is a state-machine debounce in the main loop with a timer interrupt setting a flag. The ISR stays short and fast; the debounce logic runs in the main thread with full access to blocking operations. The choice depends on your system: if you care about response time more than absolute precision, the delay in the ISR is acceptable. If you are building a real-time controller, keep ISRs short.
Critical Sections
A critical section is a block of code that must not be interrupted mid-way. If the main loop reads a 16-bit variable in two byte-wide operations and the ISR updates that variable between those two reads, the main loop sees a corrupted value. This is called a data race.
On AVR, you protect a critical section by disabling interrupts with cli() before the read and re-enabling with sei() after. On ARM Cortex-M, you use __disable_irq() and __enable_irq() or the LDREX/STREX exclusive access instructions for lock-free patterns. The guiding rule: keep critical sections as short as possible and never block inside one.
What ISRs Should Not Do
- Call malloc or any function that uses dynamic memory - it is not re-entrant
- Block or delay - the ISR must return quickly so other interrupts can fire
- Print over UART directly - use a ring buffer instead and drain it from the main loop
- Perform floating-point arithmetic on cores without hardware FPU - it is slow and may corrupt FPU state
- Access hardware peripherals that require multi-step initialisation
A Practical Example: UART Receive Buffer
A common pattern is a ring buffer populated by a UART receive interrupt. Each byte that arrives fires the ISR, which writes the byte into the buffer and advances the write pointer. The main loop reads from the buffer and advances the read pointer independently. This decouples the hardware event rate from the processing rate: the ISR is fast, the main loop can be slow, and no bytes are lost as long as the buffer does not fill.
#define BUF_SIZE 64
volatile uint8_t rx_buf[BUF_SIZE];
volatile uint8_t rx_head = 0, rx_tail = 0;
ISR(USART0_RX_vect) {
uint8_t next = (rx_head + 1) % BUF_SIZE;
if (next != rx_tail) { // only write if buffer is not full
rx_buf[rx_head] = UDR0;
rx_head = next;
}
}
uint8_t uart_read(void) {
while (rx_head == rx_tail); // block until a byte arrives
uint8_t b = rx_buf[rx_tail];
rx_tail = (rx_tail + 1) % BUF_SIZE;
return b;
}Interrupt Priority and Nesting on ARM Cortex-M
On AVR, interrupts are non-nested by default - a lower-priority interrupt cannot interrupt a higher-priority one without explicitly re-enabling interrupts inside the ISR. ARM Cortex-M is different. The NVIC (Nested Vectored Interrupt Controller) supports true hardware preemption: a higher-priority interrupt can interrupt a lower-priority ISR mid-execution. Priority is configurable per interrupt, with lower numerical values meaning higher priority.
Priority grouping matters. On STM32 devices with 4 priority bits, NVIC_SetPriorityGrouping(3) gives you 4 preemption priority levels and 4 subpriority levels. Subpriority only matters when two interrupts of the same preemption priority fire simultaneously - the one with lower subpriority number runs first. If you have a time-critical ISR (say, a UART byte receive) and a less critical one (say, a timer overcount), set the UART ISR to preemption priority 0 and the timer ISR to priority 3. The UART ISR will always interrupt the timer ISR, but not the reverse.
// NVIC priority setup on STM32 - I set this before enabling any interrupts
NVIC_SetPriorityGrouping(3); // 4 preemption levels, 4 sub-levels
// High priority: UART receive - must not be delayed by anything
NVIC_SetPriority(USART1_IRQn, NVIC_EncodePriority(3, 0, 0));
NVIC_EnableIRQ(USART1_IRQn);
// Low priority: SysTick-based housekeeping
NVIC_SetPriority(TIM2_IRQn, NVIC_EncodePriority(3, 3, 0));
NVIC_EnableIRQ(TIM2_IRQn);Applying This in Practice
In the avr-zac project I used INT0 for the mode button with a 50ms debounce delay in the ISR. For a production system I would move the debounce to a timer-based state machine and keep the ISR to a single volatile increment. The principle translates directly to STM32 via HAL GPIO interrupt callbacks and to ESP32 via gpio_isr_handler_add(). The hardware details change but the design pattern does not.
When something seems broken in an interrupt-driven system, the diagnostic checklist is: check that the interrupt is enabled in both the peripheral and NVIC, check that the vector name in the handler exactly matches what the linker expects (a typo results in the default_handler running instead, often resetting the device), and check that volatile is on every shared variable. Most interrupt bugs are one of these three.
References
- 01.ARM Cortex-M3 Technical Reference Manual - section 8 covers the NVIC
- 02.Wikipedia: Interrupt - hardware and software interrupt overview
- 03.Microchip AVR Instruction Set Manual - ISRs, RETI and sei/cli behaviour
- 04.Making Embedded Systems - Elecia White - chapter 4: interrupts and their interaction with the main loop
- 05.Phillip Johnston: Better Embedded System Software - interrupt design patterns
- 06.avr-zac project - the ATmega644P project referenced in this post
- 07.Microchip AVR Interrupt Handling application note (AVR004)
You might also like
Bare Metal AVR: Building a Nine-Mode State Machine Without Any Framework
How I built a nine-mode state machine on an ATmega644P from scratch using bare metal C, writing directly to hardware registers with no framework, no HAL and no shortcuts. Still ongoing.
UART From Scratch: Serial Communication Without a Library
How to set up UART on an AVR microcontroller using bare metal C, configure baud rate registers, transmit and receive bytes and debug embedded systems over a serial monitor.
Getting Started with FPGAs: What They Are and How to Think About Them
A beginner-friendly introduction to FPGAs and VHDL: what an FPGA actually is, how it differs from a microcontroller, why hardware description languages feel so different from programming and the mental model shift you need to make sense of it all.
React to this post