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.
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 |
- Wire Power: Connect the sensor Red to Nano 5V and Black to Nano GND.
- Wire Signal: Connect the sensor Yellow to Nano D2.
- 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.
- 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
EnableInterruptallow 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
LOWinstead ofFALLING, you can put the Nano intoSLEEP_MODE_PWR_DOWNand wake it instantly when water starts flowing, saving massive amounts of battery in remote telemetry applications.






