Skip to content
Home » Embedded Systems » Polling vs Interrupts in Embedded Systems: When to Use Which

Polling vs Interrupts in Embedded Systems: When to Use Which

Stressed figure weighing polling versus interrupts decision in embedded systems design
Embedded Systems Learning Path
Part 61 of 129View Full Path →

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 interrupt

void 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.

Leave a Reply

Your email address will not be published. Required fields are marked *