One of the most fundamental decisions in embedded programming is how your microcontroller responds to events: does it continuously check — polling — or does it wait and react instantly — interrupts? For Arduino developers moving beyond simple LED blink sketches, mastering this distinction separates robust real-time firmware from fragile code that misses events and wastes CPU cycles. This guide breaks down both techniques, when to use each, and the pitfalls that catch even experienced developers.
What Is Polling?
Polling is the technique of repeatedly checking a condition in a loop. Your code asks: “Has this event happened yet?” — hundreds or thousands of times per second — until the answer is yes.
void loop() {
if (digitalRead(buttonPin) == LOW) {
handleButtonPress();
}
// rest of loop code
}
Polling is simple, predictable, and easy to debug. You can trace exactly when and why your code sees an event. However, it has two significant weaknesses:
- CPU waste: The processor burns cycles checking conditions that are mostly false. On battery-powered devices, this kills battery life.
- Missed events: If an event like a short pulse from a sensor occurs while your code is doing something else inside loop(), you will never see it. The event window might be microseconds wide; your loop might take milliseconds to complete one cycle.
Despite these limitations, polling is the right choice for many situations — particularly when events are slow compared to loop speed, or when you need deterministic timing.
What Is an Interrupt?
An interrupt is a hardware signal that immediately pauses the main program, saves its state, and jumps to a special function called an Interrupt Service Routine (ISR). After the ISR finishes, execution returns to exactly where it left off.
AVR microcontrollers support several interrupt sources:
- External interrupts (INT0, INT1): Triggered by signal changes on specific pins — digital pins 2 and 3 on Uno.
- Pin change interrupts (PCINT): Any digital pin can trigger these, but all pins on a port share one ISR.
- Timer interrupts: Fire at precise intervals set by hardware timers — Timer0, Timer1, Timer2.
- UART/SPI/I2C interrupts: Triggered when communication peripherals receive or finish sending data.
The key advantage is immediacy. A hardware interrupt latency on AVR is 4 to 5 clock cycles — about 250 to 312 nanoseconds at 16 MHz. No amount of polling can match that responsiveness.
Polling vs Interrupts: Head-to-Head Comparison
| Aspect | Polling | Interrupts |
|---|---|---|
| Response time | Depends on loop duration | ~250 ns hardware latency |
| Short pulse detection | Can miss fast pulses | Reliable for any pulse width |
| CPU usage | 100% if in tight loop | Near 0% when idle |
| Power consumption | High (CPU always active) | Low (can use sleep modes) |
| Code complexity | Simple, linear | Requires volatile, atomic access |
| Debugging | Straightforward | Harder — race conditions possible |
Using External Interrupts on Arduino
Arduino Uno and Nano expose two external interrupt pins: Pin 2 (INT0) and Pin 3 (INT1). Mega adds pins 18 to 21. The Arduino library makes attaching an ISR a one-liner:
volatile bool buttonPressed = false;
void handleButton() {
buttonPressed = true;
}
void setup() {
pinMode(2, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), handleButton, FALLING);
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
Serial.println("Button pressed!");
}
}
The four interrupt trigger modes available:
- LOW — Triggers continuously while pin is LOW. Use carefully; can cause interrupt storm.
- CHANGE — Triggers on any transition (LOW to HIGH or HIGH to LOW).
- RISING — Triggers on LOW to HIGH transition.
- FALLING — Triggers on HIGH to LOW transition.
Always use digitalPinToInterrupt(pin) instead of hardcoding interrupt numbers. This keeps code portable across different Arduino boards where the mapping may differ.
ISR Best Practices: Keeping It Short and Safe
ISRs run with all other interrupts disabled by default on AVR. Every microsecond your ISR runs is a microsecond during which serial data can be lost, timers can be missed, and other hardware peripherals can overflow. The golden rules:
Rule 1: Keep ISRs as short as possible. Set a flag, increment a counter, or save a value — that is it. Move all processing back to loop().
Rule 2: Declare shared variables as volatile. Any variable read by both an ISR and loop() must be declared volatile. Without this keyword, the compiler may optimise the variable into a CPU register that the ISR cannot see.
volatile unsigned long pulseCount = 0; // Shared between ISR and loop()
Rule 3: Use atomic access for multi-byte variables. Reading a 32-bit unsigned long takes multiple assembly instructions on AVR. Use noInterrupts() and interrupts() guards:
unsigned long getCount() {
noInterrupts();
unsigned long c = pulseCount;
interrupts();
return c;
}
Rule 4: Never call delay() or Serial.print() inside an ISR. Both rely on interrupts internally. Calling them inside an ISR that has disabled interrupts causes a deadlock or corrupts internal state.
Debouncing Buttons with Interrupts
Mechanical buttons produce multiple transitions during a single press due to contact bounce. This can trigger your ISR 5 to 50 times in a few milliseconds. Software debouncing inside an interrupt context requires care — you cannot use delay():
volatile unsigned long lastPressTime = 0;
volatile bool newPress = false;
const unsigned long DEBOUNCE_MS = 50;
void onButton() {
unsigned long now = millis();
if (now - lastPressTime > DEBOUNCE_MS) {
lastPressTime = now;
newPress = true;
}
}
Note: millis() is safe to call inside an ISR on AVR Arduino because it reads Timer0’s counter registers. However, millis() will not increment while your ISR is running. For sub-millisecond debouncing, use micros() instead.
When to Use Polling vs Interrupts
Choose polling when:
- Events are slow (button presses, temperature readings every second) and your loop completes in microseconds.
- You need simple, linear code flow that is easy to debug.
- You are reading a sensor at a fixed interval and do not need to react to unexpected changes.
- Multiple sensors need round-robin attention with roughly equal priority.
Choose interrupts when:
- You need to detect short pulses — encoder signals, IR remote signals, ultrasonic echo.
- The event timing is unpredictable and cannot risk being missed during a slow loop().
- You want to implement sleep modes to save power — interrupts are the only way to wake from deep sleep.
- You need precise timing for PWM generation, frequency counting, or motor control.
The pragmatic rule: Start with polling. If you find you are missing events, seeing timing jitter, or the loop is getting blocked, switch that specific event to an interrupt. Mix and match — most real projects use both techniques simultaneously.
Frequently Asked Questions
Can I use interrupts on any Arduino pin?
For external interrupts (INT0/INT1), only pins 2 and 3 on the Uno and Nano. However, pin change interrupts are available on all digital pins — they share one ISR per port (PCINT0, PCINT1, PCINT2), so you must check which specific pin triggered the interrupt manually inside the ISR. Libraries like EnableInterrupt or PinChangeInterrupt abstract this for you.
Why does millis() stop incrementing inside my ISR?
millis() itself reads fine from inside an ISR. However, the Timer0 overflow interrupt that increments the internal millisecond counter is blocked while your ISR runs. So millis() will not advance during your ISR runtime. If your ISR takes 1 ms to complete, you lose 1 ms of millis() accuracy. Keep ISRs under 10 microseconds to avoid this.
What happens if two interrupts fire at the same time?
On AVR, each interrupt has a fixed hardware priority. If two interrupts are pending simultaneously, the one with the lower interrupt vector number (higher hardware priority) is served first. On Arduino Uno, INT0 (pin 2) has higher priority than INT1 (pin 3), which has higher priority than Timer0. The second interrupt remains pending and is served immediately after the first ISR returns.
Is polling ever better for real-time than interrupts?
Yes. Tight polling loops can achieve nanosecond-level timing precision for specific operations — far better than interrupt latency of ~250 ns plus ISR overhead. For generating or measuring very precise waveforms such as bit-banged SPI at maximum speed, a tight polling loop is actually more precise. Interrupts add jitter due to the context switch overhead.
What is the ATOMIC_BLOCK macro?
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) is an AVR-libc macro that disables interrupts for a code block and restores the previous interrupt state afterward — cleaner than manual noInterrupts() and interrupts() pairs. Include util/atomic.h to use it.
Browse our complete collection of Arduino boards and modules at Zbotic.in — fast delivery across India.
Add comment