An interrupt on an Arduino is a hardware signal that forces the microcontroller to immediately pause its main loop, execute a specific Interrupt Service Routine (ISR), and then resume exactly where it left off. While polling a pin in the loop() works for slow inputs like push buttons, it fails catastrophically for high-speed signals like rotary encoders, optical tachometers, or AC zero-cross detection. If your main loop takes 5 milliseconds to execute, you will miss any pulse shorter than 5ms. Hardware interrupts solve this by operating at the silicon level, independent of your code's execution speed.
This guide covers exact pin mappings, a complete high-speed pulse counter build, and the specific debugging steps required when your ISR refuses to fire. We are targeting the Arduino Nano V3 (ATmega328P, 16MHz) for the primary build, with comparative data for the Mega 2560 and ESP32 DevKit V1.
Arduino Interrupt Pin Mapping & Hardware Limits
Not all digital pins support hardware interrupts. On 8-bit AVR boards, only specific pins are wired to the external interrupt vectors (INT0, INT1). Attempting to use attachInterrupt() on a non-supported pin will silently fail or throw a compiler error depending on your core version. Furthermore, the maximum frequency your ISR can handle is limited by the execution time of the ISR itself and the microcontroller's clock speed.
| Board Variant | Microcontroller | External INT Pins | Pin Change (PCINT) Support | Max Practical ISR Frequency |
|---|---|---|---|---|
| Arduino Uno R3 | ATmega328P | 2 (INT0), 3 (INT1) | All digital & analog pins | ~150 kHz (minimal ISR) |
| Arduino Nano V3 | ATmega328P | 2 (INT0), 3 (INT1) | All digital & analog pins | ~150 kHz (minimal ISR) |
| Arduino Mega 2560 | ATmega2560 | 2, 3, 18, 19, 20, 21 | Most digital pins | ~120 kHz (minimal ISR) |
| ESP32 DevKit V1 | Xtensa LX6 (Dual-core) | Any GPIO (except 6-11, 24-28, 34-39 input only) | N/A (All are external) | ~1.2 MHz (minimal ISR) |
On the ESP32, pins 34, 35, 36 (VP), and 39 (VN) are input-only. They can be used for interrupts, but they lack internal pull-up/pull-down resistors. You must provide external 10kΩ resistors if your sensor has an open-collector output.
Project Build: High-Speed Optical Pulse Counter
To demonstrate proper interrupt handling, we will build a high-speed tachometer using an LM393 slotted optocoupler speed sensor module. This module outputs a clean 5V square wave when the optical beam is broken, making it ideal for testing ISR limits without dealing with mechanical switch bounce.
Parts List & Pin Mapping
- Microcontroller: Arduino Nano V3 (ATmega328P, 16MHz, 5V logic)
- Sensor: LM393 Slotted Optocoupler Speed Sensor Module (Open-collector output)
- Resistor: 10kΩ pull-up resistor (often built into the module, but verify with a multimeter)
- Wiring: 22 AWG solid core hookup wire or Dupont cables
| LM393 Module Pin | Arduino Nano V3 Pin | Notes |
|---|---|---|
| VCC | 5V | Do not use 3.3V; the LM393 comparator needs headroom. |
| GND | GND | Ensure a common ground reference. |
| DO (Digital Out) | D2 | Hardware INT0. Requires 10kΩ pull-up to 5V if not on module. |
| AO (Analog Out) | Not Connected | Unused for digital pulse counting. |
Complete Compilable Code
This code targets the Arduino Nano V3. It uses volatile variables, atomic reads via noInterrupts(), and calculates RPM based on a 1-second sampling window. It includes basic error handling for sensor disconnects.
#include <Arduino.h>
// --- Pin Definitions ---
const uint8_t SENSOR_PIN = 2; // Hardware INT0 on Nano V3
// --- Volatile Variables for ISR ---
// MUST be volatile since they are modified inside the ISR and read in loop()
volatile unsigned long pulseCount = 0;
volatile unsigned long lastPulseMicros = 0;
// --- Main Loop Variables ---
unsigned long previousMillis = 0;
const unsigned long sampleInterval = 1000; // 1 second sample window
void IRAM_ATTR countPulse() {
pulseCount++;
lastPulseMicros = micros();
}
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 2000) {
// Wait for serial port to connect (max 2 seconds)
}
// Configure pin with internal pull-up as a safety net
pinMode(SENSOR_PIN, INPUT_PULLUP);
// Attach the hardware interrupt
// digitalPinToInterrupt() translates pin 2 to INT0 vector
attachInterrupt(digitalPinToInterrupt(SENSOR_PIN), countPulse, FALLING);
Serial.println("Optical Pulse Counter Initialized.");
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= sampleInterval) {
previousMillis = currentMillis;
// --- ATOMIC READ BLOCK ---
// On 8-bit AVR, reading a 32-bit variable takes 4 clock cycles.
// If an interrupt fires mid-read, the value corrupts.
noInterrupts();
unsigned long safePulseCount = pulseCount;
unsigned long safeLastPulse = lastPulseMicros;
pulseCount = 0; // Reset for next window
interrupts();
// Calculate RPM (assuming 1 pulse per revolution for this example)
// RPM = (pulses per second) * 60
unsigned long rpm = safePulseCount * 60;
// Stale data detection: If no pulse in 2 seconds, flag as stopped
unsigned long timeSinceLastPulse = (micros() - safeLastPulse) / 1000;
bool isStopped = (safePulseCount == 0 && timeSinceLastPulse > 2000);
if (isStopped) {
Serial.println("Status: STOPPED (Sensor disconnected or motor off)");
} else {
Serial.print("Pulses/sec: ");
Serial.print(safePulseCount);
Serial.print(" | RPM: ");
Serial.println(rpm);
}
}
}
Debugging: First Three Things to Check When ISRs Fail
Interrupts are unforgiving. A single misplaced function call or missing keyword will either halt compilation or cause silent, erratic runtime failures. If your pulse count stays at zero or your board freezes, follow this ranked troubleshooting path.
1. Compiler Error: Global Scope Violation
Exact Error String: error: 'attachInterrupt' does not name a type
The Cause: You placed the attachInterrupt() function call in the global scope (outside of setup() or loop()). The Arduino compiler processes global variable declarations and function prototypes first, but executable statements must reside inside a function block.
The Fix: Move attachInterrupt(digitalPinToInterrupt(pin), ISR_name, mode); strictly inside void setup().
2. Runtime Failure: The 'Missing Volatile' Bug
Symptom: The code compiles, but the variable updated inside the ISR never changes when printed in the loop().
The Cause: The GCC compiler optimizes code by caching variables in CPU registers. If a variable modified by an ISR isn't declared as volatile, the compiler assumes the main loop is the only thing changing it, caches the initial value (zero), and never reads the updated RAM value.
The Fix: Prepend volatile to every variable shared between the ISR and the main loop (e.g., volatile unsigned long pulseCount = 0;).
3. System Freeze or Missed Pulses: ISR Bloat
Symptom: The board reboots randomly, the main loop stutters, or high-speed pulses are dropped.
The Cause: You put blocking code inside the ISR. Functions like delay(), Serial.print(), or millis() rely on timer interrupts. Since hardware interrupts disable further interrupts by default (on AVR), calling delay() inside an ISR creates a deadlock, freezing the microcontroller permanently.
The Fix: Keep ISRs under 5 microseconds. Only increment counters, set flags, or capture micros(). Do all math, serial printing, and display updates in the main loop().
In the code above, notice the
noInterrupts() and interrupts() block. The ATmega328P is an 8-bit chip. Reading a 32-bit unsigned long requires four separate 8-bit memory fetches. If a pulse arrives between the second and third fetch, your RPM calculation will return garbage data. Always disable interrupts momentarily when copying multi-byte volatile variables in the main loop.
Extending and Simplifying the Build
Once you have the basic hardware interrupt working, you will inevitably hit physical limits. Here is how to scale the architecture up or down based on your project requirements.
Extending: Pin Change Interrupts (PCINT)
If you are building a multi-axis CNC router or a complex robotics platform, the Nano's two hardware INT pins (2 and 3) will run out quickly. The ATmega328P supports Pin Change Interrupts on almost all other pins, but configuring the raw PCICR and PCMSK registers is tedious.
The Solution: Use the EnableInterrupt library by GreyGnome. It abstracts the register math and allows you to attach ISRs to any digital or analog pin. Note that PCINTs do not natively support RISING/FALLING edge detection; the ISR fires on ANY state change, so you must read the pin state inside the ISR to determine direction.
Simplifying: Hardware Timer Counters
If you are measuring a 50 kHz PWM signal or a high-frequency flow meter, even an optimized software ISR will consume too much CPU overhead, starving your main loop. At these frequencies, you should stop using attachInterrupt() entirely.
The Solution: Route the signal into the microcontroller's hardware Timer/Counter pins (e.g., T1 on Pin 5 for the ATmega328P). By configuring the TCCR1A and TCCR1B registers, the silicon counts the pulses completely autonomously. Your main loop simply reads the TCNT1 register once per second. This shifts the burden from software to hardware, allowing you to count signals well into the megahertz range with zero CPU overhead.
For deeper architectural details on ESP32 interrupt allocation and memory mapping, refer to the official Espressif Interrupt Allocation API documentation. Understanding the boundary between software polling, hardware ISRs, and peripheral counters is what separates a hobbyist blinking an LED from an engineer designing a robust motor controller.






