Embedded firmware · Cortex-M · compiled code samples

Interrupt on the Edge,
Poll on the Level

Interrupts and polling are not competitors. An interrupt tells you an edge happened; polling tells you the current level (the state). Robust firmware uses both, and the best answer is often a third option that needs neither. Seven code samples, each compiled against real vendor headers.

A reply to "Why Are Interrupts Better Than Polling?" by Embedded_Systems_World · Daniel Liezrowice, ESL

The axis is wrong: who pays for each event, and when?

The usual framing is a two-way choice: the CPU keeps asking, or the peripheral notifies. In practice there are at least five mechanisms, and their costs scale differently with event rate.

MechanismCPU cost per eventLatencyIdle powerBehaviour under loadWorst case analyzable?
PollingPaid every loop, even with no eventsUp to one loop periodHigh (CPU awake)Graceful, fixed costYes, by design
InterruptEntry + exit per event12 cycles + masking timeLow (WFI)Can livelockOnly with bounded arrival rate
Hybrid (NAPI)One IRQ per burstInterrupt when idleLowBecomes polling under loadYes (bounded budget)
DMA + idle lineOne IRQ per messageEnd of frameLowHardware moves the bytesYes
Event routingZeroFixed, no jitterLowest (core asleep)Hardware onlyYes

1Hybrid: interrupt to wake, poll to drain

Linux handles network cards this way (NAPI). The first packet raises an IRQ. The driver masks that IRQ, polls the receive ring until it is empty (within a work budget), and then re-arms it. When the link is idle you get interrupt latency and low power. Under load you get polling efficiency, with no per-packet interrupt overhead.

Pure interrupt-driven receive at high rates leads to receive livelock: the CPU spends all its time entering and leaving the ISR, and the code that consumes the data never runs (Mogul & Ramakrishnan, Eliminating Receive Livelock in an Interrupt-Driven Kernel, 1997). The same pattern works on a microcontroller UART:

static volatile bool rx_work;

void USART2_IRQHandler(void) {
    USART2->CR1 &= ~USART_CR1_RXNEIE;   // mute: switch to polling mode
    rx_work = true;
}

void uart_service(void) {             // main loop or RTOS task
    if (!rx_work) return;
    int budget = 64;                    // bounded work keeps other tasks schedulable
    while ((USART2->SR & USART_SR_RXNE) && budget-- > 0)
        ring_put(&rx, (uint8_t)USART2->DR);
    if (USART2->SR & USART_SR_RXNE) return;   // still busy: keep polling
    rx_work = false;
    USART2->CR1 |= USART_CR1_RXNEIE;          // re-arm
}

Why there is no race on re-arm: RXNE is a level flag. If a byte arrived between the last check and the re-arm, the IRQ becomes pending as soon as RXNEIE is set. Here the hardware level protects you from a lost edge. That is the "edge plus level" idea in practice.

Caveat: the STM32F4 USART has a 1-byte data register. At 115200 baud a character arrives about every 87 µs, so in polling mode the service must run faster than that, or you get an overrun (ORE). The pattern fits parts with an RX FIFO (STM32G4/H7/L4+, most network MACs) or is combined with DMA (section 2).

2DMA + idle line: one interrupt per message, not per byte

Most serial protocols are framed: a burst of bytes followed by silence. The UART can detect the silence (IDLE line), and DMA moves the bytes. The CPU never touches individual bytes; it receives one callback for each complete frame.

HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, sizeof rx_buf);

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *h, uint16_t len) {
    if (HAL_UARTEx_GetRxEventType(h) == HAL_UART_RXEVENT_HT) return;
    frame_ready(rx_buf, len);            // whole frame; the CPU never copied a byte
    HAL_UARTEx_ReceiveToIdle_DMA(h, rx_buf, sizeof rx_buf);
}

Gotcha that many examples miss: the same callback also fires on the DMA half-transfer event, with len equal to half the buffer. Without the HAL_UART_RXEVENT_HT check you would process half a frame as if it were complete.

3Event routing: zero CPU, zero jitter

Many modern MCUs let one peripheral's event trigger another peripheral's task directly in hardware: nRF52 PPI / nRF53 DPPI, Microchip SAM and AVR Dx EVSYS, ESP32-C6/H2 ETM, and STM32 timer trigger chains. A GPIO edge can capture a timer value while the core stays asleep. No polling, no ISR, and no software jitter in the timestamp.

// nRF52: falling edge on P0.13 captures TIMER1 into CC[0], no CPU involved
NRF_GPIOTE->CONFIG[0] = (GPIOTE_CONFIG_MODE_Event << GPIOTE_CONFIG_MODE_Pos)
                      | (13UL << GPIOTE_CONFIG_PSEL_Pos)
                      | (GPIOTE_CONFIG_POLARITY_HiToLo << GPIOTE_CONFIG_POLARITY_Pos);
NRF_TIMER1->TASKS_START = 1;
NRF_PPI->CH[0].EEP = (uint32_t)&NRF_GPIOTE->EVENTS_IN[0];
NRF_PPI->CH[0].TEP = (uint32_t)&NRF_TIMER1->TASKS_CAPTURE[0];
NRF_PPI->CHENSET   = PPI_CHENSET_CH0_Msk;

uint32_t last_edge_ticks(void) { return NRF_TIMER1->CC[0]; }

Insight: an ISR timestamp includes interrupt entry, tail-chaining, any masked sections and higher-priority ISRs, so it jitters. A hardware capture does not. For input capture, PWM sync, ADC triggering or quadrature decoding, any CPU path (polling or interrupt) is the slower and noisier option.

4The lost-wakeup race in "check the flag, then sleep"

This is how many people write a low-power loop that waits for an interrupt flag. It has a race condition:

// BROKEN: if the IRQ fires between the check and WFI, the flag is already set
// but the core goes to sleep anyway, until some *other* interrupt wakes it.
while (!rx_ready) __WFI();

With a 1 ms SysTick this shows up as occasional 1 ms latency spikes. In a tickless low-power design it can mean sleeping until the next unrelated event, which may be seconds away. The fix relies on an architectural guarantee: WFI wakes the core when an interrupt becomes pending, even while PRIMASK masks it.

// CORRECT: check and sleep atomically
__disable_irq();
while (!rx_ready) {
    __WFI();           // wakes on a pending IRQ even with PRIMASK = 1
    __enable_irq();    // let the ISR run now
    __disable_irq();
}
rx_ready = false;
__enable_irq();

Actual arm-none-eabi-gcc 14.2 -O2 output: the flag is checked with interrupts masked, so no window exists between the check and the wfi.

wait_rx:
    cpsid   i                 ; mask
    ldr     r2, [pc, #32]
    ldrb.w  r3, [r2, #464]    ; check rx_ready (masked)
    cbnz    r3, done
loop:
    wfi                       ; sleep; a pending IRQ wakes us
    cpsie   i                 ; ISR runs here
    cpsid   i
    ldrb.w  r3, [r2, #464]
    cmp     r3, #0
    beq.n   loop
done:
    movs    r3, #0
    strb.w  r3, [r2, #464]    ; rx_ready = false
    cpsie   i
    bx      lr

Use PRIMASK here, not BASEPRI. An interrupt masked by BASEPRI does not wake WFI, so a BASEPRI version of this loop would sleep through the very event it is waiting for.

5Your interrupt latency is your longest critical section

Datasheets quote 12 cycles of interrupt entry latency for Cortex-M3/M4 (with zero-wait-state memory) and 6 cycles for tail-chaining. That figure is the best case. In a real system the worst-case response time is:

Rworst = entry (12 cycles, more with FPU stacking and flash wait states)
        + longest interrupts-disabled section anywhere in the code
        + execution time of all higher-priority ISRs that can arrive meanwhile

One __disable_irq() around a 40 µs logging call in a library you didn't write is now your motor-control latency. Instead of masking everything, mask only the priorities that actually share the data:

uint32_t prev = __get_BASEPRI();
__set_BASEPRI_MAX(5U << (8U - __NVIC_PRIO_BITS));  // block priority 5 and lower
shared_update();                                    // IRQs at priority 0..4 still run
__set_BASEPRI(prev);

Why _MAX: __set_BASEPRI_MAX only ever raises the masking level. If the code is already running under a stricter mask, it is not accidentally loosened, so nested critical sections stay correct.

6Interrupt storm guard: enforce physics in the ISR

An interrupt is only analyzable if its arrival rate is bounded. A broken sensor wire, EMI, or a floating input can produce edges at MHz rates and starve every lower-priority task. Polling has a fixed cost no matter what the input does; interrupts don't. The fix is to make the ISR enforce the minimum inter-arrival time that the physical signal can actually produce:

#define MIN_GAP_CYCLES (168000000U / 10000U)   // sensor cannot exceed 10 kHz

void EXTI0_IRQHandler(void) {
    EXTI->PR = EXTI_PR_PR0;
    uint32_t now = DWT->CYCCNT;
    if ((now - last_edge) < MIN_GAP_CYCLES) {   // faster than physics allows
        EXTI->IMR &= ~EXTI_IMR_MR0;              // mute; a timer re-enables later
        storm_count++;                           // diagnostic for the fault log
        return;
    }
    last_edge = now;
    handle_edge();
}

Safety angle: this turns an unbounded interrupt into a sporadic task with a known minimum period, so response-time analysis (WCET) holds again. It is also why many ISO 26262 and DO-178C designs deliberately choose a time-triggered, polled architecture (Kopetz-style TTA): the worst case is fixed by design rather than by the environment. Unsigned subtraction makes now - last_edge correct across the 32-bit CYCCNT wrap. Remember to enable the DWT cycle counter at startup.

7Polling as a filter: debouncing for free

An edge interrupt on a mechanical button can fire many times per press while the contact bounces. Polling at a fixed rate is a sampled low-pass filter, so you get debouncing without extra hardware. An integrator debouncer with hysteresis:

#define DEBOUNCE_MAX 4U                  // 4 x 5 ms = 20 ms
static uint8_t integ;
static bool pressed;

void button_poll_5ms(void) {               // from a 5 ms timer tick
    bool raw = (GPIOA->IDR & GPIO_IDR_ID0) == 0U;   // active low
    if (raw && integ < DEBOUNCE_MAX) integ++;
    else if (!raw && integ > 0U) integ--;
    if (integ == DEBOUNCE_MAX) pressed = true;
    else if (integ == 0U)      pressed = false;
}

Best of both: for battery devices, use the edge interrupt only to wake from deep sleep. Disable it in the ISR, then debounce by polling and re-arm once the button is released. That is the same pattern as section 1: interrupt on the edge, poll on the level.

Decision guide

Is the event rate bounded and low? ├─ yes ── Does the core need to sleep between events? │ ├─ yes ── INTERRUPT (wake) ── check + WFI atomically [4] │ └─ no ─── POLL in the scheduler tick (deterministic) [6, 7] └─ no / bursty ├─ framed data (UART, SPI, I2S)? ── DMA + IDLE / half-full [2] ├─ peripheral → peripheral reaction? ── EVENT ROUTING [3] └─ otherwise ── HYBRID: IRQ once, poll with a budget, re-arm [1] Always: bound arrival rate [6] · mask by priority, not globally [5]

How these samples were checked

Polling asks. Interrupts notify. DMA delivers.
Event routing doesn't need the CPU at all.