Why Polling Fails: The Case for Hardware Interrupts

Use an Arduino interrupt when your main loop() execution time is too slow to reliably catch fast state changes. If you are reading a rotary encoder spinning at 3,000 RPM, decoding a 38kHz IR remote, or counting pulses from a high-flow water sensor, polling a digital pin with digitalRead() will miss events. Polling is like checking your mailbox every five minutes; a hardware interrupt is the mail carrier ringing your doorbell the second a package arrives.

When an interrupt fires, the ATmega328P microcontroller immediately pauses the main program, saves its state, and jumps to an Interrupt Service Routine (ISR). Once the ISR finishes, it resumes the main loop exactly where it left off. This guarantees zero missed pulses, provided your ISR is lean and your hardware is properly conditioned.

Difficulty Rating: Intermediate
Time to Build: 20 minutes
Target Board Variant: Arduino Nano V3 (ATmega328P, 5V/16MHz). Note: The pin mappings and ISR vectors in this guide are specific to the ATmega328P architecture. ESP32 and RP2040 boards handle interrupts differently.

ATmega328P Interrupt Pin Mapping and Trigger Modes

Unlike modern 32-bit microcontrollers where almost any GPIO can trigger an interrupt, the ATmega328P has strict hardware limitations. It features two dedicated external interrupt pins (INT0 and INT1) and three banks of Pin Change Interrupts (PCINT). You must wire your high-speed sensor to one of the dedicated pins for the cleanest, lowest-latency response.

Arduino Nano Pin ATmega328P Port Interrupt Vector Supported Trigger Modes Typical Use Case
D2 PD2 INT0 LOW, CHANGE, RISING, FALLING High-speed encoders, flow sensors
D3 PD3 INT1 LOW, CHANGE, RISING, FALLING Secondary encoder channel, IR receivers
D8 - D13 PB0 - PB5 PCINT0 CHANGE only Keypads, slow mechanical switches
A0 - A5 PC0 - PC5 PCINT1 CHANGE only Analog fallbacks, button arrays
D0 - D7 PD0 - PD7 PCINT2 CHANGE only Legacy shield compatibility

For this project, we will use D2 (INT0) configured to trigger on the FALLING edge, which is the standard output behavior for Hall-effect flow sensors pulling a line low.

Project Build: YF-S201 Flow Sensor High-Speed Pulse Counter

The YF-S201 Hall Effect Water Flow Sensor outputs a square wave proportional to water flow. At maximum flow (20L/min), it pulses at roughly 400Hz. While 400Hz sounds slow, if your main loop includes Serial.print() delays, I2C display updates, or WiFi routines, you will drop pulses. Hardware interrupts solve this.

Parts List

  • Microcontroller: Arduino Nano V3 (ATmega328P, 16MHz crystal)
  • Sensor: YF-S201 Hall Effect Water Flow Sensor (1/2' NPT threads)
  • Resistor: 10kΩ through-hole (for external pull-up)
  • Capacitor: 0.1µF ceramic (for hardware debouncing / noise filtering)
  • Wiring: 22 AWG solid core jumper wires, half-size breadboard

Pin Mapping & Wiring Steps

Arduino Nano V3 YF-S201 Sensor Wire Notes & Conditioning
5V Red (VCC) Sensor requires 4.5V - 18V DC
GND Black (GND) Common ground required
D2 Yellow (Signal) Must use INT0 pin
5V to D2 N/A 10kΩ pull-up resistor across these nodes
D2 to GND N/A 0.1µF capacitor across these nodes
  1. Wire Power: Connect the sensor Red to Nano 5V and Black to Nano GND.
  2. Wire Signal: Connect the sensor Yellow to Nano D2.
  3. Add the Pull-Up: The YF-S201 has an internal pull-up, but it is notoriously weak (often >50kΩ). Long cable runs will act as antennas, inducing ghost pulses. Bridge a 10kΩ resistor between 5V and D2 to create a stiff, reliable logic HIGH.
  4. Add the Decoupling Cap: Bridge the 0.1µF ceramic capacitor between D2 and GND. This forms a low-pass RC filter with the pull-up resistor, physically crushing high-frequency EMI and contact bounce before it ever reaches the ATmega328P silicon. This is vastly superior to software debouncing inside an ISR.

Complete Firmware: ISR-Safe Pulse Counting

The golden rule of Arduino interrupts is that variables shared between the ISR and the main loop must be declared as volatile. Furthermore, because the ATmega328P is an 8-bit microcontroller, reading a 16-bit or 32-bit integer takes multiple clock cycles. If an interrupt fires while the main loop is reading the multi-byte variable, you will get corrupted data. We use noInterrupts() and interrupts() to create a critical section for safe reading.

/*
 * High-Speed Pulse Counter using Hardware Interrupts
 * Target: Arduino Nano V3 (ATmega328P)
 * Sensor: YF-S201 Flow Sensor on D2 (INT0)
 */

// Pin Definitions
const uint8_t FLOW_SENSOR_PIN = 2; // Hardware INT0 on ATmega328P

// Volatile variables shared with ISR
volatile unsigned long pulseCount = 0;
volatile unsigned long lastPulseTime = 0;

// Main loop variables
unsigned long previousMillis = 0;
const long interval = 1000; // 1 second calculation window

void setup() {
  Serial.begin(115200);
  
  // Configure pin as input. The external 10k resistor handles the pull-up,
  // but we enable INPUT_PULLUP as a secondary safety net.
  pinMode(FLOW_SENSOR_PIN, INPUT_PULLUP);
  
  // Attach the hardware interrupt
  // digitalPinToInterrupt() translates D2 to INT0 vector automatically
  attachInterrupt(digitalPinToInterrupt(FLOW_SENSOR_PIN), pulseISR, FALLING);
  
  Serial.println(F("System Initialized. Waiting for pulses..."));
}

void loop() {
  unsigned long currentMillis = millis();
  
  if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;
    
    // CRITICAL SECTION: Disable interrupts to safely read multi-byte volatile
    noInterrupts();
    unsigned long safePulseCount = pulseCount;
    unsigned long safeLastPulse = lastPulseTime;
    pulseCount = 0; // Reset for next window
    interrupts();
    
    // Error Handling: Check for sensor disconnect or massive EMI spike
    if (safePulseCount > 50000) {
      Serial.println(F("ERROR: Pulse count exceeds physical limits. Check wiring for EMI."));
    } else if (safePulseCount == 0 && (currentMillis - safeLastPulse > 5000)) {
      Serial.println(F("WARNING: No pulses detected for 5 seconds. Sensor may be disconnected."));
    } else {
      // YF-S201 outputs ~4.5 pulses per second per Liter/min
      float flowRateLPM = safePulseCount / 4.5;
      Serial.print(F("Flow Rate: "));
      Serial.print(flowRateLPM, 2);
      Serial.println(F(" L/min"));
    }
  }
}

// Interrupt Service Routine (ISR)
// Must be as fast as possible. No Serial.print, no delay, no floating point math.
void pulseISR() {
  pulseCount++;
  lastPulseTime = millis();
}

Debugging Interrupt Failures: The First Three Checks

When your interrupt-driven project fails, the symptoms are often silent or erratic. Before rewriting your code, check these three ranked failure modes.

1. Symptom: Count stays at zero or Compiler Warning

Exact Error String: warning: variable 'pulseCount' set but not used [-Wunused-but-set-variable]

Cause: You declared the variable and incremented it in the ISR, but the compiler's optimizer removed it because it didn't see it being read in the main loop, or you forgot to declare it as volatile. Without volatile, the compiler caches the variable in a CPU register and never checks RAM for ISR updates.

Fix: Ensure the variable is declared as volatile at the global scope. Ensure you are actually reading it inside a noInterrupts() / interrupts() block in the main loop.

2. Symptom: System hard-locks or reboots when interrupt fires

Exact Error String: N/A (Hardware watchdog reset or silent freeze)

Cause: You used a blocking function inside the ISR. Functions like delay(), Serial.print(), or Wire.requestFrom() (I2C) rely on interrupts to function. If you call them from inside an ISR, interrupts are globally disabled, and the microcontroller deadlocks waiting for an interrupt that can never fire.

Fix: The ISR must only update variables and exit. Move all I2C, Serial, and timing logic to the main loop(). As Nick Gammon's definitive guide on interrupts notes, an ISR should ideally execute in under 5 microseconds.

3. Symptom: Erratic 'Ghost' Counts (Numbers jump wildly)

Cause: Switch bounce or electromagnetic interference (EMI) on the signal line. Mechanical relays and Hall-effect sensors with weak internal pull-ups are highly susceptible to noise from nearby motors or long unshielded cables.

Fix: Do not attempt to fix this with software debounce (like checking millis() inside the ISR) unless absolutely necessary, as it burns CPU cycles. Instead, fix it in hardware. Add the 10kΩ external pull-up and the 0.1µF ceramic capacitor to ground as detailed in the wiring steps. This RC filter physically prevents the voltage from crossing the ATmega328P's logic threshold on micro-spikes.

Extending and Simplifying the Build

Depending on your final application, you may need to scale this architecture up or strip it down.

How to Simplify

If you are only counting slow events (like a rain gauge tipping bucket that pulses once every few seconds), you can drop the critical section (noInterrupts()) and the 32-bit unsigned long. Change pulseCount to an 8-bit volatile uint8_t. The ATmega328P can read a single 8-bit byte in one atomic clock cycle, meaning an interrupt cannot corrupt the read. This saves memory and removes the overhead of toggling the global interrupt flag.

How to Extend

  • More Sensors: If you need to monitor three flow sensors but only have D2 and D3 available, you must migrate to Pin Change Interrupts (PCINT). Libraries like EnableInterrupt allow you to attach ISRs to any digital pin, though you will have to manually check which pin triggered the interrupt inside the ISR.
  • Migrate to 32-Bit: If you are building a multi-sensor IoT node in 2026, consider migrating to an ESP32-S3. The ESP32 architecture allows any GPIO pin to act as a hardware interrupt, entirely eliminating the D2/D3 bottleneck. The Arduino attachInterrupt() API remains identical, making code porting trivial.
  • Add Sleep Modes: Interrupts are the only way to wake an ATmega328P from deep sleep. By configuring the ISR to trigger on LOW instead of FALLING, you can put the Nano into SLEEP_MODE_PWR_DOWN and wake it instantly when water starts flowing, saving massive amounts of battery in remote telemetry applications.