attachInterrupt() is a microcontroller function that pauses the main program loop to immediately execute a specific block of code (an Interrupt Service Routine) the moment a designated pin changes voltage state.

Instead of constantly asking a sensor 'are you triggered yet?' (polling), the hardware handles the event the microsecond it happens. This shifts your architecture from a sequential polling model to an event-driven model, ensuring you never miss a fast pulse even if your main loop is busy driving a display or sending MQTT payloads.

Think of polling like staring at your front door to see if someone knocks, while an interrupt is like installing a doorbell that rings in your ear no matter what room you are in. What people commonly confuse attachInterrupt() with is either standard digitalRead() polling or timer-based interrupts; external pin interrupts are strictly triggered by physical voltage changes on a specific GPIO pin, not by an internal clock tick.

The Numeric Reality: Polling vs. attachInterrupt() at 10kHz

To understand what this function changes in a real circuit, we have to look at the math of execution time. Let us look at a high-speed digital flow meter outputting a 10 kHz square wave pulse train.

  • Signal Frequency: 10,000 pulses per second (10 kHz)
  • Signal Period: 100 µs total (50 µs HIGH, 50 µs LOW)
  • Main Loop Execution Time: ~5,200 µs (5.2 ms) due to a 5ms delay() and a Serial.print() statement.

If you use digitalRead() polling inside that 5.2 ms loop, your microcontroller checks the pin exactly once per loop iteration. In the 5.2 ms it takes your loop to run, 52 complete pulses have already occurred. Your polling method will catch perhaps one HIGH state, completely missing the other 51 pulses. Your flow rate calculation will be off by 98%.

When you use attachInterrupt() configured to trigger on the RISING edge, the hardware peripheral detects the voltage transition in nanoseconds. It pauses the 5.2 ms loop, increments a counter variable in roughly 3 µs, and resumes the loop. You catch all 52 pulses with zero data loss, while your main loop continues to handle your serial output uninterrupted.

Where You Meet External Interrupts in Practice

You will reach for external pin interrupts whenever a physical event is too fast, too critical, or too asynchronous to wait for your main loop to cycle back around. Common bench and jobsite applications include:

  • Rotary Encoders (e.g., EC11 or KY-040): Capturing the rapid quadrature pulses of a user turning a dial or a motor shaft spinning. Missing a pulse results in backwards or jittery position tracking.
  • AC Zero-Crossing Detectors: Using an H11AA1 optocoupler to detect the exact microsecond the AC sine wave crosses 0V. This is mandatory for phase-angle dimming or synchronizing TRIAC firing in motor controls.
  • Emergency Stop (E-Stop) Circuits: When a safety button is slammed, the system must halt machinery immediately, regardless of whether the main loop is currently stuck writing to an SD card.
  • Anemometers and Rain Gauges: Tipping buckets and reed-switch wind sensors that generate slow but highly asynchronous pulses that must be logged accurately over long sleep cycles.

Decision Tree: Polling, attachInterrupt, or Hardware Counters?

Choosing the right method depends entirely on your signal frequency and your microcontroller architecture. Use this decision path to select your approach.

Signal FrequencyBest MethodWhy?
< 50 HzPolling (digitalRead)Main loop runs thousands of times per second; polling is simpler and avoids ISR overhead.
50 Hz to 5 kHzattachInterrupt()Fast enough to be missed by a busy loop, but slow enough that CPU overhead per interrupt is negligible.
> 5 kHz (AVR/Uno)attachInterrupt() (with caution)AVRs can handle ~20kHz interrupts, but your main loop will starve. Keep ISR to 1-2 instructions.
> 1 kHz (ESP32)Hardware PCNT PeripheralESP32 has a dedicated Pulse Counter (PCNT) that counts in hardware without waking the CPU at all.

Default Recommendation: If your signal is under 50 Hz, use standard polling. If it is between 50 Hz and 5 kHz on an Arduino Uno, use attachInterrupt(). However, if you are using an ESP32 and measuring anything above 1 kHz, skip attachInterrupt() entirely and configure the hardware PCNT (Pulse Counter) peripheral via the ESP-IDF or ESP32 Arduino core. It offloads the counting to dedicated silicon, freeing your dual cores for WiFi and logic.

Critical Failure Modes: Bounce, Blocking ISRs, and Volatile

When an attachInterrupt() implementation fails on the bench, it is almost always due to one of these three hardware or software mismatches.

1. Mechanical Switch Bounce

People commonly confuse the electrical interrupt trigger with the physical behavior of a mechanical switch. When you press a tactile button, the metal contacts physically bounce for 2 to 5 milliseconds before settling. If your interrupt is set to CHANGE or FALLING, a single button press will fire the ISR 20 to 50 times. The Fix: Add a hardware RC debounce filter (a 10kΩ series resistor and a 100nF capacitor to ground) or implement a software lockout that ignores subsequent triggers if millis() - last_trigger_time < 50.

2. Blocking the ISR

An Interrupt Service Routine must be ruthlessly fast. If you put delay(), Serial.print(), or complex floating-point math inside the ISR, you will crash the microcontroller or cause severe timing jitter. On AVR boards, the timer interrupt that increments millis() is masked (paused) while your external ISR runs. If your ISR takes 10ms, your millis() clock literally stops ticking for 10ms. The Fix: The ISR should only set a flag or increment a variable, then return immediately.

3. Forgetting the 'volatile' Keyword

If you share a variable between the ISR and the main loop, the C++ compiler will optimize the main loop by caching the variable in a CPU register, completely ignoring the updates made by the ISR. The Fix: Always declare shared variables as volatile (e.g., volatile unsigned long pulseCount = 0;). This forces the compiler to read the value from RAM every single time.

ESP32 GPIO Warning: On the original ESP32, GPIO pins 34, 35, 36, and 39 are input-only. They can be used with attachInterrupt(), but they lack internal pull-up/pull-down resistors. If you wire a reed switch to GPIO 34 without an external 10kΩ pull-up resistor, the pin will float, and electromagnetic noise will trigger thousands of phantom interrupts per second.

FAQ: Common attachInterrupt() Confusions

What is the difference between RISING, FALLING, and CHANGE?

RISING triggers when the pin goes from LOW to HIGH (0V to 3.3V/5V). FALLING triggers from HIGH to LOW. CHANGE triggers on both edges. For a standard flow meter or anemometer, use RISING to count exactly one pulse per cycle. Using CHANGE will double your count and double your CPU load.

Can I use attachInterrupt() for a timer instead of a pin?

No. attachInterrupt() is strictly for external hardware pins. If you need code to execute on a strict time interval (e.g., every 10ms) regardless of pin states, you need a Timer Interrupt. On AVR, this requires manipulating hardware registers (like OCR1A), while on ESP32, you use the hw_timer_t API or FreeRTOS software timers.

Why does my Arduino Uno only allow interrupts on Pin 2 and Pin 3?

The ATmega328P chip on the Arduino Uno only has two dedicated external interrupt vectors (INT0 and INT1), which are hardwired to physical pins 2 and 3. While the chip supports 'Pin Change Interrupts' (PCINT) on almost all other pins, the standard Arduino attachInterrupt() function abstracts this away. If you need interrupts on Pin 10 on an Uno, you must use a third-party library like EnableInterrupt to access the PCINT registers directly.

For deeper architectural details, consult the official Arduino attachInterrupt() reference or the Espressif PCNT peripheral documentation when scaling up to high-frequency ESP32 pulse counting.