Why Polling Fails: The Case for Arduino Hardware Interrupts
An Arduino hardware interrupt immediately pauses the main loop to execute an Interrupt Service Routine (ISR) when a specific pin changes state, guaranteeing zero missed pulses even at high frequencies. If you rely on digitalRead() inside a standard loop() to catch fast sensor pulses—like those from a YF-S201 water flow meter or an optical encoder—you will inevitably miss data. The main loop is bogged down by serial printing, display updates, and delay() calls, creating blind spots where pin transitions go unnoticed.
By offloading edge-detection to the microcontroller's dedicated interrupt hardware, the pin state is latched at the silicon level the microsecond it changes. This guide walks through wiring a high-speed pulse counter, writing a bulletproof ISR, and debugging the most common hardware and software traps that cause missed or erratic counts.
Hardware Interrupt Pin Mapping & Limits Across AVR Boards
Not all digital pins can trigger an external hardware interrupt. The ATmega architecture routes specific pins to dedicated interrupt vectors (INT0, INT1, etc.). Using attachInterrupt() on a non-interrupt pin will silently fail. Always use the digitalPinToInterrupt(pin) macro rather than hardcoding vector numbers, as the mapping changes between board variants.
| Board Variant | MCU | Total External INTs | INT0 Pin | INT1 Pin | INT2+ Pins |
|---|---|---|---|---|---|
| Uno / Nano v3 | ATmega328P | 2 | D2 | D3 | None |
| Mega 2560 | ATmega2560 | 6 | D2 | D3 | D18, D19, D20, D21 |
| Leonardo / Micro | ATmega32U4 | 5 | D3 | D2 | D0, D1, D7 |
| Pro Mini (3.3V/5V) | ATmega328P | 2 | D2 | D3 | None |
Source: Arduino Language Reference and Microchip ATmega Datasheets.
Project Build: High-Speed Pulse Counter for Flow Meters
We will wire a YF-S201 Hall effect water flow sensor. This sensor outputs a 5V square wave (up to ~100Hz at max flow, but can spike higher during turbulence) with an open-collector NPN output. Because it is open-collector, it requires a pull-up resistor to generate a clean HIGH signal.
Parts List
- Microcontroller: Arduino Nano v3 (ATmega328P, 16MHz)
- Sensor: YF-S201 Hall Effect Flow Sensor (3/4" BSP threads)
- Resistor: 10kΩ (External pull-up for noise immunity)
- Capacitor: 0.1µF ceramic (Decoupling across sensor power)
- Consumables: Breadboard, 22 AWG solid jumper wires
Pin Mapping
| Component | Wire Color | Arduino Nano Pin | Notes |
|---|---|---|---|
| Sensor VCC | Red | 5V | Add 0.1µF cap to GND |
| Sensor GND | Black | GND | Common ground |
| Sensor OUT | Yellow | D2 (INT0) | 10kΩ pull-up to 5V |
Wiring Steps
- De-energize: Ensure the Nano is unplugged from USB before wiring.
- Power the Sensor: Connect the sensor's red wire to the Nano's 5V pin and the black wire to GND.
- Decouple: Solder or place the 0.1µF capacitor directly across the sensor's VCC and GND pins to suppress motor-induced EMI.
- Pull-Up: Connect the 10kΩ resistor between the Nano's 5V pin and D2. (While the ATmega328P has internal pull-ups, they are ~30kΩ-50kΩ and too weak to pull the line up fast enough for high-frequency edges in noisy environments).
- Signal: Connect the sensor's yellow output wire to D2.
The Code: Volatile Variables and ISR Best Practices
The code below targets the Arduino Nano v3 (ATmega328P). It uses the volatile keyword for the pulse counter, which tells the compiler not to cache the variable in a register, as it can change asynchronously. Crucially, it uses a critical section (noInterrupts() / interrupts()) to copy the 4-byte unsigned long to a local variable. On an 8-bit AVR, reading a 32-bit integer takes four clock cycles; if the ISR fires between byte reads, you get a "torn read" resulting in wildly inaccurate numbers.
// Target Board: Arduino Nano v3 (ATmega328P)
// Application: High-speed pulse counting via hardware interrupt
const int FLOW_SENSOR_PIN = 2; // Must be INT0 (D2) on Nano/Uno
const int STATUS_LED_PIN = 13; // Built-in LED for heartbeat
// Volatile variable modified inside the ISR
volatile unsigned long pulseCount = 0;
// Variables for main loop calculations
unsigned long previousCount = 0;
unsigned long lastPrintTime = 0;
const unsigned long PRINT_INTERVAL = 1000; // 1 second
void setup() {
Serial.begin(115200);
pinMode(STATUS_LED_PIN, OUTPUT);
// Configure pin as input. External 10k pull-up is used, so INPUT is correct.
pinMode(FLOW_SENSOR_PIN, INPUT);
// Attach interrupt on FALLING edge (open-collector pulls to GND)
attachInterrupt(digitalPinToInterrupt(FLOW_SENSOR_PIN), countPulseISR, FALLING);
Serial.println(F("[SYS] Hardware interrupt attached to D2."));
}
// ISR: Keep it as short as mathematically possible. No delays, no serial prints.
void countPulseISR() {
pulseCount++;
}
void loop() {
unsigned long currentCount;
// CRITICAL SECTION: Disable interrupts to safely read the 32-bit volatile variable
noInterrupts();
currentCount = pulseCount;
interrupts();
// Check for torn reads or logic errors (Debugging aid)
if (currentCount < previousCount) {
Serial.print(F("[ERR] Torn read or overflow detected! Prev: "));
Serial.print(previousCount);
Serial.print(F(" Curr: "));
Serial.println(currentCount);
}
unsigned long currentMillis = millis();
if (currentMillis - lastPrintTime >= PRINT_INTERVAL) {
lastPrintTime = currentMillis;
unsigned long delta = currentCount - previousCount;
previousCount = currentCount;
// YF-S201 outputs ~4.5 pulses per second per liter/min
float flowRateLPM = (delta / 4.5);
Serial.print(F("[DATA] Pulses/sec: "));
Serial.print(delta);
Serial.print(F(" | Flow Rate: "));
Serial.print(flowRateLPM, 2);
Serial.println(F(" L/min"));
// Heartbeat blink
digitalWrite(STATUS_LED_PIN, !digitalRead(STATUS_LED_PIN));
}
}
Debugging: "ISR Not Triggering" and Missed Pulse Errors
When working with hardware interrupts, the compiler won't warn you if you attach an ISR to a non-interrupt pin. You will simply see zero counts. If your serial monitor outputs [ERR] Torn read or overflow detected! or your pulse count remains stubbornly at 0, follow this diagnostic path.
- Pin Mapping: Did you use
digitalPinToInterrupt(2)or just2? On the Nano, D2 is INT0 (vector 0). If you hardcodedattachInterrupt(2, ...), you actually attached it to INT2, which doesn't exist on the 328P, or mapped to the wrong pin on a Mega. - Floating Pin: Disconnect the sensor and measure D2 with a multimeter. If it reads ~2.5V or fluctuates, your pull-up resistor is missing or broken. The interrupt is triggering off EMI noise.
- ISR Bloat: Did you put
delay(),Serial.print(), ormillis()inside the ISR? These rely on timer interrupts, which are blocked while your ISR runs, causing a hard lockup.
Ranked Causes for Erratic Counts
| Symptom / Error String | Most Likely Cause | Fix / Measurement |
|---|---|---|
| Count jumps backward randomly | Torn Read (Missing critical section) | Wrap 32-bit variable read in noInterrupts() |
| Count is exactly 2x expected | Triggering on CHANGE instead of FALLING | Change mode to FALLING or RISING |
| Massive spikes when motor starts | EMI noise on long unshielded wires | Add 0.1µF cap at sensor, use shielded twisted pair |
| Board hard-lockups after 10 mins | I2C/SPI comms inside ISR | Move all comms to main loop, set flag in ISR |
For a deep dive into AVR interrupt vectors and timing penalties, Nick Gammon's interrupt guide remains the definitive reference for ATmega architectures.
Extending and Simplifying the Build
Not every application requires a dedicated hardware interrupt. If you are measuring a slow signal—like a tipping-bucket rain gauge that pulses once every few seconds—using an interrupt is overkill and introduces unnecessary complexity.
How to Simplify (Low Frequency Signals)
For signals under 1kHz with generous pulse widths, replace the ISR entirely with the pulseIn() function or a simple state-change polling loop. pulseIn(pin, HIGH) will block execution but guarantees a clean read without managing volatile variables or critical sections.
How to Extend (More Pins & Higher Speeds)
- Pin Change Interrupts (PCINT): If you run out of INT0/INT1 pins on a Nano, you can use the PinChangeInterrupt library. PCINTs group pins by port (e.g., D8-D13 share PCINT0). The ISR must read the port register to determine which specific pin triggered the interrupt.
- Upgrade to ESP32 PCNT: If you are measuring encoder speeds exceeding 10kHz, the ATmega328P will spend 100% of its CPU time inside the ISR. Migrate to an ESP32 and use the Pulse Counter (PCNT) hardware peripheral. The PCNT counts pulses in silicon with zero CPU intervention, freeing the ESP32's dual cores to handle WiFi and MQTT tasks simultaneously.






