Designing Arduino Sketches Around External Interrupts: An Architecture Note
An architecture note on Arduino external interrupts: the ISR-as-flag pattern, volatile and atomic-read boundaries, operational checks, and the failure modes that should change your design.
19 Aug 2026, 03:09 UTC

The problem this design solves
Polling a pin in loop() means your sketch either burns CPU cycles re-reading a mostly-static input, or it misses short pulses because the loop was busy doing something else. External interrupts invert that: the hardware watches the pin, and your code runs only when the signal actually changes. The useful takeaway is that interrupts are a boundary mechanism, not a place to do work — the designs that fail are the ones that put application logic inside the interrupt service routine (ISR).
This note covers the smallest sound design for interrupt-driven input on AVR-based boards (Uno, Nano, Mega), where the boundaries sit, and the failure modes that should make you reconsider.
Requirements
A typical requirement set looks like this:
- React to a pin transition (button, pulse from a sensor, encoder edge) within microseconds, not loop iterations.
- Keep
loop()free for slower work: display updates, serial protocol handling, averaging. - Optionally, sleep the MCU between events for battery operation.
- Never lose the count of events, even if the main loop is momentarily busy.
The smallest suitable design
The pattern that satisfies all four requirements is a two-part split: the ISR records the minimum possible fact (usually incrementing a counter or setting a flag), and loop() consumes that fact and does the real work.
// Arduino Uno / Nano (ATmega328P), Arduino IDE 1.8.x or 2.x
const uint8_t kPulsePin = 2; // INT0 on Uno
volatile uint32_t pulseCount = 0; // shared with loop(): must be volatile
void onPulse() {
pulseCount++; // one 32-bit increment; nothing else
}
void setup() {
pinMode(kPulsePin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(kPulsePin), onPulse, FALLING);
}
void loop() {
// Snapshot the counter with interrupts briefly masked.
noInterrupts();
uint32_t snapshot = pulseCount;
interrupts();
static uint32_t lastSeen = 0;
if (snapshot != lastSeen) {
lastSeen = snapshot; // real work happens here, outside the ISR
}
}Three details carry the design:
volatiletells the compiler the variable can change outside the normal flow of the code, so it won't cache the value in a register and miss updates.- The
noInterrupts()/interrupts()pair protects the multi-byte read. On an 8-bit AVR, reading a 32-bit value takes several instructions; an interrupt landing mid-read can hand you a torn value (half old, half new). If a single byte counter suffices, you can skip the masking because single-byte reads are atomic on AVR. digitalPinToInterrupt()maps the pin number to the interrupt index; on the Uno only pins 2 (INT0) and 3 (INT1) have true external interrupts.
Trust and data boundaries
Think of the ISR as untrusted-with-respect-to-time: it can fire between any two instructions of your main code. That produces two hard boundaries.
Data crossing the boundary must be volatile and either single-byte or read under a brief interrupt mask. Never pass pointers to buffers the ISR fills while loop() reads them without a lock or a head/tail index scheme.
Work crossing the boundary must be one-directional: ISR produces, loop consumes. The ISR must not call Serial.print(), delay(), floating-point math, or anything that itself depends on interrupts (millis() does not advance while your ISR runs, because interrupts are globally disabled during ISR execution). Blocking inside an ISR starves the rest of the sketch and can trip the watchdog if you use one.
Operational checks
Before trusting the design, verify it with measurements, not vibes:
- Edge counting: feed a known signal into pin 2 — a second Arduino toggling an output at 1 Hz works. Print
pulseCountfromloop()once per second and confirm it increments by exactly the expected amount. Run this from the Arduino IDE's Serial Monitor; no special permissions needed. - ISR latency sanity check: toggle a spare output pin at the top and bottom of the ISR and watch it with a logic analyzer or oscilloscope. The pulse width is your ISR cost; it should be a few microseconds. If you don't have a logic analyzer, keep the ISR to a single increment and reason from the instruction count instead.
- Torn-read check: temporarily remove the
noInterrupts()guard and feed a fast signal (several kHz). Occasional impossible jumps in the printed count demonstrate why the guard exists; restore it and confirm the jumps disappear.
Failure modes
- Noise-triggered interrupts. Mechanical buttons bounce, and long wires pick up EMI. A FALLING-mode interrupt on a bouncing contact can fire dozens of times per press. Fix in hardware (RC filter, Schmitt trigger) or in software by timestamping with
micros()in the ISR and ignoring edges inside a debounce window. - Missed events from a fat ISR. While your ISR runs, further interrupts on AVR are held pending (one per vector). A second edge arriving during a long ISR is latched, but a third is lost. Keep the ISR short enough that the worst-case event rate can't outrun it.
- Pin-change vs external interrupt confusion. Pins other than 2 and 3 on the Uno only support pin-change interrupts (via a different library mechanism), which fire on any change in a whole port and are more susceptible to noise. If you need clean edge detection, stay on INT0/INT1.
- Sleep/wake bugs. When using interrupts to wake from power-down sleep, the ISR still runs; forgetting that shared state may be mid-update at wake time, or detaching the interrupt and forgetting to re-attach, produces intermittent lockups that are painful to debug.
Conditions that would change the design
Revisit this architecture if any of these become true:
- Event rate exceeds roughly tens of kHz. Per-event ISR overhead becomes significant; move to hardware counters (Timer1 in external clock mode counts edges with zero CPU).
- You need more than two clean interrupt sources on an Uno. Either accept pin-change interrupts with careful filtering, or move to a board with more external interrupt pins (the Mega 2560 has six; many ARM-based boards allow interrupts on nearly every pin).
- The consumer can't keep up. If
loop()work per event is heavy (network I/O, SD writes), a counter isn't enough — you need a queue with explicit overflow policy, or you need to shed load. - Multiple ISRs with ordering requirements. AVR interrupt priority is fixed by vector order; a chatty high-priority source can starve lower-priority ones. If that matters, consolidate event capture into one ISR or reconsider the hardware.
The durable rule: the ISR is a mailbox flag, not a worker. Keep it to a volatile write, guard multi-byte reads, verify with a counted test signal, and the design holds up from a button counter to a battery-powered pulse logger.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.