Table of Contents
KEY TAKEAWAYS
- Polling continuously checks a condition in a loop; interrupts respond to events asynchronously
- Interrupts are more CPU-efficient since the processor can do other work while waiting for an event
- Polling is simpler to implement and debug but wastes CPU cycles checking status repeatedly
- Most embedded systems use a combination of both: interrupts for critical events and polling for non-urgent checks
Interrupt-Driven Design Patterns
In practice, most embedded systems use a combination of polling and interrupts. Here are the most common interrupt-driven patterns:
ISR + Flag Pattern — the ISR sets a flag, the main loop processes it:
volatile uint8_t uart_rx_flag = 0;
volatile uint8_t uart_rx_data;void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uart_rx_data = USART1->DR; uart_rx_flag = 1; } }
int main(void) { while (1) { if (uart_rx_flag) { uart_rx_flag = 0; process_byte(uart_rx_data); } // Other main loop tasks… } } “`
ISR + Ring Buffer Pattern — for high-throughput data where flags are insufficient:
#define RX_BUF_SIZE 256
volatile uint8_t rx_buf[RX_BUF_SIZE];
volatile uint16_t rx_head = 0;
volatile uint16_t rx_tail = 0;void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { // Buffer not full rx_buf[rx_head] = USART1->DR; rx_head = next; } } }
int rx_available(void) { return rx_head != rx_tail; }
uint8_t rx_read(void) { uint8_t data = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return data; } “`
DMA + Interrupt Pattern — for bulk transfers where per-byte interrupts are too expensive:
// Configure DMA to transfer 100 ADC samples automatically
DMA1_Channel1->CNDTR = 100;
DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR;
DMA1_Channel1->CMAR = (uint32_t)adc_buffer;
DMA1_Channel1->CCR |= DMA_CCR_TCIE; // Transfer complete interruptvoid DMA1_Channel1_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_TCIF1) { DMA1->IFCR = DMA_IFCR_CTCIF1; // Clear flag process_adc_buffer(adc_buffer, 100); // Process all 100 samples } } “`
When Polling Is Actually Better Than Interrupts
Despite interrupts being the “default” choice for most embedded systems, polling wins in several specific scenarios:
1. Very high-frequency events — if an event occurs faster than the interrupt overhead (entry + exit + context save/restore takes 12-30 cycles on ARM Cortex-M), polling in a tight loop is more efficient. Example: bit-banging a protocol at MHz speeds.
2. Predictable timing requirements — polling gives you complete control over when you check each input. Interrupts can arrive at any time, potentially disrupting time-critical code. Some safety-critical systems use polling exclusively for determinism.
3. Multi-channel polling with round-robin — when monitoring 10+ slow inputs (buttons, limit switches), one polling loop is simpler than configuring 10+ interrupt handlers and dealing with bounce filtering in each.
// Polling 8 buttons with software debounce — simpler than 8 interrupt handlers
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
uint8_t stable_state;
uint8_t raw_state;
uint8_t count;
} Button_t;Button_t buttons[8] = { /* … */ };
void poll_buttons(void) { // Called every 5 ms from timer ISR or main loop for (int i = 0; i IDR >> buttons[i].pin) & 1; if (current != buttons[i].raw_state) { buttons[i].raw_state = current; buttons[i].count = 0; } else if (++buttons[i].count >= 4) { // 20 ms debounce if (current != buttons[i].stable_state) { buttons[i].stable_state = current; on_button_change(i, current); } } } } “`
4. Busy-wait for short durations — when waiting for a hardware operation that completes in a few microseconds (SPI byte transfer, ADC conversion), polling the status flag is faster and simpler than setting up an interrupt.
Interrupt Priority and Nesting on ARM Cortex-M
ARM Cortex-M processors support nested, priority-based interrupt handling through the NVIC (Nested Vectored Interrupt Controller). Understanding priority configuration is essential.
Priority levels: Cortex-M supports 3 to 8 bits of priority (8 to 256 levels). However, most MCUs implement only the upper 3-4 bits, giving 8-16 actual priority levels. Lower numerical values = higher priority.
// STM32 NVIC configuration (4 bits of priority = 16 levels)
// Priority 0 is highest, Priority 15 is lowest// Critical: UART for real-time protocol NVIC_SetPriority(USART1_IRQn, 2); NVIC_EnableIRQ(USART1_IRQn);
// Medium: sensor data acquisition NVIC_SetPriority(ADC1_IRQn, 5); NVIC_EnableIRQ(ADC1_IRQn);
// Low: periodic housekeeping NVIC_SetPriority(TIM3_IRQn, 10); NVIC_EnableIRQ(TIM3_IRQn); “`
Priority grouping splits the priority field into pre-emption priority and sub-priority. Pre-emption priority determines nesting (higher priority interrupts can preempt lower ones). Sub-priority determines order when two interrupts of the same pre-emption priority are pending simultaneously.
// Group 4: All 4 bits for pre-emption (16 levels, no sub-priority)
NVIC_SetPriorityGrouping(0);// Group 3: 3 bits pre-emption (8 levels) + 1 bit sub-priority (2 levels) NVIC_SetPriorityGrouping(4); “`
Design rule: assign the highest priority to safety-critical ISRs (fault handlers, emergency stop), medium priority to real-time communication (UART, CAN), and lowest priority to slow periodic tasks (LED blinking, watchdog feeding).
Side-by-Side: Reading a Button with Polling vs Interrupts
The best way to understand the difference is to see the exact same task — detecting a button press and toggling an LED — implemented both ways. Compare the structure, CPU usage, and responsiveness.
Version 1: Polling
#include <avr/io.h>
#include <util/delay.h>
/* Button on PD2 (active low, external pull-up)
LED on PB5 */
int main(void)
{
DDRB |= (1 << PB5); /* LED output */
DDRD &= ~(1 << PD2); /* Button input */
PORTD |= (1 << PD2); /* Enable pull-up */
uint8_t last_state = 1; /* Button released */
while (1) {
uint8_t current = (PIND >> PD2) & 1;
/* Detect falling edge (press) */
if (last_state == 1 && current == 0) {
PORTB ^= (1 << PB5); /* Toggle LED */
_delay_ms(50); /* Debounce */
}
last_state = current;
/* >>> Problem: this loop runs thousands of times per second,
burning CPU cycles just to check one pin. If you add a
long task here (e.g., sensor read), button response suffers. */
}
}What happens: The CPU sits in a tight loop, reading PD2 on every iteration. If you add any blocking work inside the loop — a sensor read, an LCD update, a delay — the button becomes sluggish or misses presses entirely. The CPU is 100% occupied even when nothing is happening.
Version 2: Interrupt
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
/* Same hardware: button on PD2 (INT0), LED on PB5 */
volatile uint8_t button_pressed = 0;
ISR(INT0_vect)
{
_delay_ms(50); /* Simple debounce in ISR */
if (!(PIND & (1 << PD2))) { /* Confirm still pressed */
button_pressed = 1;
}
}
int main(void)
{
DDRB |= (1 << PB5); /* LED output */
DDRD &= ~(1 << PD2); /* Button input */
PORTD |= (1 << PD2); /* Enable pull-up */
/* Configure INT0: trigger on falling edge */
EICRA |= (1 << ISC01); /* ISC01=1, ISC00=0 → falling edge */
EIMSK |= (1 << INT0); /* Enable INT0 */
sei(); /* Global interrupt enable */
while (1) {
if (button_pressed) {
PORTB ^= (1 << PB5); /* Toggle LED */
button_pressed = 0;
}
/* >>> The CPU can do other work here — read sensors,
update display, or even enter sleep mode.
The button press is caught instantly by hardware. */
}
}What happens: The CPU is free to do other work (or sleep). When the button is pressed, the hardware triggers INT0 automatically, sets a flag, and the main loop handles it on the next iteration. No CPU cycles are wasted checking a pin that has not changed.
When to Choose Which
- Use polling when you have a simple single-purpose loop, timing is not critical, and you want the simplest possible code (e.g., a quick prototype or a bare-bones bootloader).
- Use interrupts when your main loop has other work to do, when you cannot afford to miss events, or when you want to use sleep modes for low power consumption.
- Hybrid approach: Use an interrupt to set a flag and polling in the main loop to process it. This gives you the responsiveness of interrupts without putting complex logic inside the ISR.
Related on this site
- The most common place the polling-vs-interrupt choice matters is on GPIO pins (buttons, sensors, signals) — see introduction to GPIO for the underlying register-level setup.
- ADC reading offers the same polling / interrupt / DMA choice — see ADC in microcontrollers for what each pattern looks like in code.
- Interrupt service routines must be fast — inline functions and macros in embedded C covers the techniques for keeping ISR code minimal.

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.






