The 30-Second Verdict: When to Actually Use attachInterrupt()

The attachInterrupt() function maps a hardware pin state change (RISING, FALLING, or CHANGE) directly to an Interrupt Service Routine (ISR), bypassing the main loop(). It is strictly for events that happen faster than your main loop can reliably poll—typically sub-millisecond pulses from optical encoders, flow meters, or zero-cross detection circuits.

Do not use hardware interrupts for mechanical pushbuttons. Switch bounce will trigger the ISR dozens of times per press, and software debouncing inside an ISR is an anti-pattern that blocks the processor. Use the decision matrix below to pick the right approach for your specific sensor.

Decision Tree: Polling vs. Hardware Interrupts
Signal Source Event Duration / Frequency Recommended Approach Concrete Implementation Pick
Mechanical Pushbutton > 50ms, high bounce Timer-based Polling 1ms Timer Interrupt polling (e.g., TimerOne library)
Optical Rotary Encoder 1ms - 50ms, clean edges Hardware Interrupt attachInterrupt() on RISING edge
High-Speed Flow Meter < 100µs, > 10kHz Hardware Counter Microcontroller native Timer/Counter pin (e.g., T1 on ATmega328P)
AC Zero-Cross Detector Exact 8.3ms / 10ms intervals Hardware Interrupt attachInterrupt() on FALLING edge

Hardware & Pin Mapping: Uno R4 vs ESP32

Not all pins support hardware interrupts, and the mapping changes depending on the silicon. The code in this guide targets the Arduino Uno R4 Minima (Renesas RA4M1 Cortex-M4, $27.50), which supports interrupts on all digital pins, but we will map it to the traditional Uno R3 interrupt pins (2 and 3) for backward compatibility. If you are using an ESP32-WROOM-32 DevKit V1 ($6.00), the ESP-IDF GPIO matrix allows attachInterrupt() on almost any GPIO, but you must avoid strapping pins (GPIO 0, 2, 12, 15) to prevent boot failures.

Project Parts List

  • MCU: Arduino Uno R4 Minima (ABX00080)
  • Sensor: KY-040 Rotary Encoder Module ($3.50)
  • Pull-ups: 2x 10kΩ carbon film resistors (if module lacks them)
  • Debounce: 2x 0.1µF (104) ceramic capacitors
  • Wiring: 22 AWG solid core jumper wires

Pin Mapping Table

Component Pin Uno R4 Minima Pin ESP32 DevKit V1 Pin Notes
Encoder CLK (A) D2 (INT0) GPIO 4 Primary interrupt trigger
Encoder DT (B) D3 GPIO 5 Read inside ISR for direction
Encoder VCC 5V 3.3V ESP32 is strictly 3.3V logic
Encoder GND GND GND Common ground required

The Bulletproof ISR Code Block

This sketch targets the Arduino Uno R4 Minima. It reads a rotary encoder, determines direction, and handles multi-byte variable reads safely. Notice the use of volatile, the digitalPinToInterrupt() macro, and the critical section guards (noInterrupts() / interrupts()) to prevent data tearing when the main loop reads the counter.

Pro-Tip: Never use Serial.print() or delay() inside an ISR. They rely on interrupts themselves and will cause a deadlock, freezing your microcontroller permanently until a hard reset.
// Target: Arduino Uno R4 Minima (Renesas RA4M1)
// Sensor: KY-040 Rotary Encoder

#define ENCODER_PIN_A 2
#define ENCODER_PIN_B 3
#define MIN_PULSE_US 500 // Error handling: ignore bounces faster than 500us

volatile long encoderCount = 0;
volatile unsigned long lastInterruptTime = 0;

void setup() {
  Serial.begin(115200);
  
  // Configure pins with internal pull-ups as a fallback
  pinMode(ENCODER_PIN_A, INPUT_PULLUP);
  pinMode(ENCODER_PIN_B, INPUT_PULLUP);
  
  // Attach interrupt using the mandatory macro
  attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), handleEncoder, FALLING);
  
  Serial.println("Encoder initialized. Rotate to test.");
}

void loop() {
  // Safely read the multi-byte volatile variable
  long currentCount;
  noInterrupts();       // Disable interrupts briefly (Critical Section)
  currentCount = encoderCount;
  interrupts();         // Re-enable interrupts
  
  static long lastPrintedCount = 0;
  if (currentCount != lastPrintedCount) {
    Serial.print("Position: ");
    Serial.println(currentCount);
    lastPrintedCount = currentCount;
  }
}

// The Interrupt Service Routine (ISR)
void handleEncoder() {
  unsigned long currentTime = micros();
  
  // Error Handling: Software debounce filter
  if (currentTime - lastInterruptTime < MIN_PULSE_US) {
    return; // Ignore phantom bounce triggers
  }
  lastInterruptTime = currentTime;
  
  // Determine direction by reading the B pin state
  if (digitalRead(ENCODER_PIN_B) == HIGH) {
    encoderCount++;
  } else {
    encoderCount--;
  }
}

Debugging: Compile Errors and Phantom Triggers

When attachInterrupt() fails, it usually manifests as either a hard compiler error or erratic runtime behavior. Here is the exact diagnostic path for the two most common failure modes.

Compiler Error: Invalid Conversion

Exact Error String: error: invalid conversion from 'int' to 'void (*)()' [-fpermissive]

Ranked Causes & Fixes:

  1. Missing Macro (90% of cases): You passed the raw pin number instead of the interrupt number.
    Fix: Change attachInterrupt(2, myISR, FALLING) to attachInterrupt(digitalPinToInterrupt(2), myISR, FALLING).
  2. Wrong ISR Signature (10% of cases): Your ISR function takes arguments or returns a value.
    Fix: Ensure the ISR is strictly void myISR() with no parameters.

Runtime Failure: Phantom Triggers & Erratic Counting

If your serial monitor shows the counter jumping by +5 or -5 with a single physical click, your ISR is suffering from switch bounce or electromagnetic interference (EMI).

The First 3 Things to Check When It Fails:
  1. Floating Pins: Did you enable INPUT_PULLUP or add external 10kΩ resistors? A floating pin will act as an antenna, triggering the ISR from ambient 50/60Hz mains noise.
  2. Missing 'volatile': Is your counter variable declared as volatile? Without it, the GCC compiler will cache the variable in a CPU register and the main loop will never see the updates made by the ISR.
  3. Wire Length: Are your jumper wires longer than 6 inches? Long unshielded wires capacitively couple noise. Keep encoder wires short and twist the signal and ground pairs together.

For a deep dive into the physics of contact bounce, the RC time constant formula ($\tau = R \times C$) is your best friend. Adding a 0.1µF capacitor from the signal pin to ground, combined with a 10kΩ pull-up, creates a 1ms low-pass filter that physically prevents the voltage from crossing the logic threshold during the microsecond-scale bounces of a mechanical contact. See All About Circuits' guide on switch bounce for the oscilloscope traces proving this hardware fix.

Extending the Build: Flow Meters and Simplification

Once you have the encoder working, the same ISR architecture applies to other pulse-output sensors. If you connect a YF-S201 hall-effect water flow meter (outputs ~4.5 pulses per second per liter/minute), you can reuse the exact code block above. Simply change the MIN_PULSE_US constant to match the maximum expected flow rate, and multiply the final count by the sensor's calibration factor in the main loop.

How to Simplify: Pin Change Interrupts (PCINT)

If you run out of dedicated hardware interrupt pins (a common issue on the older ATmega328P-based Uno R3, which only has pins 2 and 3), you must use Pin Change Interrupts. The native attachInterrupt() does not support PCINT.

The Fix: Install the EnableInterrupt library via the Arduino Library Manager. It abstracts the complex PCINT registers and allows you to attach interrupts to any digital pin using a nearly identical syntax. Note that the Uno R4 Minima and ESP32 do not suffer from this limitation; their modern architectures support native interrupts on almost all GPIOs, as detailed in the official Arduino attachInterrupt documentation and the Espressif ESP32 GPIO API reference.

Final Recommendation: Hardware Interrupts vs Polling

Do not default to attachInterrupt() just because it sounds faster. Hardware interrupts carry overhead: pushing registers to the stack, jumping to the ISR, and popping registers takes roughly 5µs to 15µs depending on the architecture. If you are reading a slow I2C sensor or a mechanical limit switch, that overhead is wasted, and bounce will ruin your data.

The Concrete Pick: If your signal source is a clean, optical, or hall-effect digital pulse occurring faster than 1ms, use attachInterrupt() with the digitalPinToInterrupt() macro and volatile variables. If your signal source is a mechanical switch, relay, or slow sensor, abandon attachInterrupt() entirely and implement a 1ms Timer Interrupt polling loop using the TimerOne library to handle software debouncing cleanly without blocking your main code.