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.
| Mechanism | CPU cost per event | Latency | Idle power | Behaviour under load | Worst case analyzable? |
|---|---|---|---|---|---|
| Polling | Paid every loop, even with no events | Up to one loop period | High (CPU awake) | Graceful, fixed cost | Yes, by design |
| Interrupt | Entry + exit per event | 12 cycles + masking time | Low (WFI) | Can livelock | Only with bounded arrival rate |
| Hybrid (NAPI) | One IRQ per burst | Interrupt when idle | Low | Becomes polling under load | Yes (bounded budget) |
| DMA + idle line | One IRQ per message | End of frame | Low | Hardware moves the bytes | Yes |
| Event routing | Zero | Fixed, no jitter | Lowest (core asleep) | Hardware only | Yes |
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 lrUse 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:
+ 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
How these samples were checked
- Sections 1, 2, 4, 5, 6 and 7 compiled together with Arm GNU Toolchain 14.2.Rel1 (
-mcpu=cortex-m4 -mthumb -O2 -Wall -Wextra -std=c11) against the real STM32F407 CMSIS device header, CMSIS-Core and STM32F4 HAL (STMicroelectronics/cmsis_device_f4,stm32f4xx_hal_driver,ARM-software/CMSIS_5). Result: zero errors, zero warnings. - Section 3 compiled the same way against the Nordic nRF52832 MDK (
nrfx v2.11.0). Result: zero errors, zero warnings. - The section 4 disassembly is real
arm-none-eabi-objdumpoutput from that build (labels renamed for readability). - Not run on hardware. These are teaching snippets. Ring buffers, DMA/clock initialization and fault handling are omitted.
Polling asks. Interrupts notify. DMA delivers.
Event routing doesn't need the CPU at all.