An Interrupt Service Routine (ISR) pauses your microcontroller's main loop to handle time-critical hardware events immediately. If you are polling a rotary encoder, capturing microsecond sensor pulses, or reacting to a limit switch, standard loop() polling will inevitably miss events. You need a properly configured Arduino ISR. This guide targets the Arduino Nano V3 (ATmega328P, 16MHz) and uses a KY-040 rotary encoder to demonstrate bulletproof interrupt wiring, atomic variable reading, and debugging for the most common compiler and logical failures.

Project Spec Sheet & Parts List

Difficulty Rating: Intermediate (Requires understanding of memory volatility and atomic operations)

Estimated Build Time: 30 minutes

ComponentExact Variant / SpecificationNotes
MicrocontrollerArduino Nano V3 (ATmega328P, 16MHz)Ensure you select the "Old Bootloader" option in the IDE if using a clone board.
SensorKY-040 Rotary Encoder ModuleIncludes built-in 10kΩ pull-up resistors on the breakout board.
Wiring22 AWG Solid Core Jumper WiresPre-cut for breadboard use.
Prototyping400-Tie Point Solderless BreadboardStandard half-size.

Pin Mapping & Hardware Wiring

The ATmega328P supports two dedicated hardware external interrupts: INT0 (Digital Pin 2) and INT1 (Digital Pin 3). While Pin Change Interrupts (PCINT) exist on all other pins, they require more complex register manipulation. For high-reliability sensor decoding, always prefer dedicated hardware interrupt pins when available.

Arduino Nano V3 PinKY-040 Module PinWiring Notes
D2 (INT0)CLKThis is the interrupt trigger pin. Must be D2 or D3 on the Nano.
D3DTRead inside the ISR to determine rotation direction.
D4SWPushbutton switch (active LOW). Polled or secondary interrupt.
5V+Powers the module and internal pull-ups.
GNDGNDCommon ground reference.

Bench Tip: Mechanical encoders like the KY-040 suffer from contact bounce. While software state-machines handle most of this, adding a 0.1µF ceramic capacitor between the CLK pin and GND physically filters high-frequency bounce spikes before they reach the microcontroller, drastically reducing spurious ISR triggers.

The Bulletproof Arduino ISR Code

The code below is fully compilable for the Arduino Nano V3. It implements three critical E-E-A-T best practices often missing from basic tutorials: it uses the volatile keyword, performs atomic reads using noInterrupts(), and avoids blocking functions inside the ISR.

// Target Board: Arduino Nano V3 (ATmega328P)
// Sensor: KY-040 Rotary Encoder

#define ENCODER_CLK 2  // Hardware INT0
#define ENCODER_DT  3  // Direction data pin
#define ENCODER_SW  4  // Pushbutton pin

// CRITICAL: Variables modified in an ISR MUST be declared volatile.
// This prevents the compiler from caching the value in a CPU register.
volatile long encoderPos = 0;
volatile bool encoderChanged = false;

// Keep ISR-local state in standard memory
uint8_t lastClkState;

void setup() {
  // Use internal pull-ups as a fallback, though KY-040 has them onboard
  pinMode(ENCODER_CLK, INPUT_PULLUP);
  pinMode(ENCODER_DT, INPUT_PULLUP);
  pinMode(ENCODER_SW, INPUT_PULLUP);
  
  lastClkState = digitalRead(ENCODER_CLK);
  
  // Always use digitalPinToInterrupt() macro for portability across AVR boards
  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), readEncoder, CHANGE);
  
  Serial.begin(115200);
  Serial.println("Arduino ISR Encoder Initialized.");
}

void loop() {
  long currentPos;
  bool changed;
  
  // ATOMIC READ: The ATmega328P is an 8-bit MCU. Reading a 32-bit 'long'
  // takes multiple clock cycles. If an ISR fires mid-read, you get corrupted data.
  // We briefly disable interrupts to safely copy the memory.
  noInterrupts();
  currentPos = encoderPos;
  changed = encoderChanged;
  encoderChanged = false; // Reset flag
  interrupts();
  
  if (changed) {
    Serial.print("Encoder Position: ");
    Serial.println(currentPos);
  }
  
  // Handle pushbutton polling (debounced via simple delay for this example)
  if (digitalRead(ENCODER_SW) == LOW) {
    delay(50); // Basic debounce
    if (digitalRead(ENCODER_SW) == LOW) {
      noInterrupts();
      encoderPos = 0;
      interrupts();
      Serial.println("Button Pressed: Position Reset to 0");
      while(digitalRead(ENCODER_SW) == LOW); // Wait for release
    }
  }
}

// THE ISR: Must be as fast as possible. No delays, no Serial prints.
void readEncoder() {
  uint8_t clkState = digitalRead(ENCODER_CLK);
  
  // State machine logic to determine direction
  if (clkState != lastClkState) {
    uint8_t dtState = digitalRead(ENCODER_DT);
    if (dtState != clkState) {
      encoderPos++;
    } else {
      encoderPos--;
    }
    encoderChanged = true;
  }
  lastClkState = clkState;
}

Debugging: 3 Things to Check When Your ISR Fails

When an Arduino ISR misbehaves, it rarely throws a standard syntax error. Instead, you get silent logical failures or cryptic linker errors. Here are the first three things to check, ranked by frequency.

1. The Silent Killer: Missing the volatile Keyword

Symptom: The encoder physically turns, but the Serial Monitor never updates, or only updates randomly when other serial traffic occurs.

Cause: The GCC compiler optimizes code by caching variables in CPU registers. If encoderPos is not marked volatile, the main loop reads the cached register value and never checks RAM, completely ignoring the updates made by the ISR.

Fix: Add volatile before the data type of any variable shared between the ISR and the main loop.

2. Corrupted Data: Non-Atomic Memory Reads

Symptom: The position counter occasionally jumps to massive negative or positive numbers (e.g., from 50 to -2147483648) for a single reading.

Cause: The ATmega328P is an 8-bit processor. A long (32-bit) variable requires four separate 8-bit memory fetches. If the ISR fires between the second and third fetch, the main loop reads half of the old value and half of the new value.

Fix: Wrap the variable copy operation in the main loop with noInterrupts() and interrupts(), as demonstrated in the code above.

3. The Linker Error: Vector Collisions

Exact Error String: multiple definition of `__vector_1' (or __vector_2, __vector_4, etc.)

Cause: This is a compile-time linker error. It happens when two separate libraries (or your manual code and a library) attempt to claim the exact same hardware interrupt vector. For example, using the Tone library (which hijacks Timer2 interrupts) alongside a library that relies on Pin Change Interrupts, or manually defining ISR(INT0_vect) while simultaneously calling attachInterrupt() on Pin 2.

Fix:

  1. Remove manual ISR() macros if you are using attachInterrupt().
  2. Check library conflicts. If using SoftwareSerial, it disables interrupts during byte transmission. Swap to AltSoftSerial or use hardware serial pins.

Extending and Simplifying the Build

Writing raw ISRs is an excellent way to understand microcontroller architecture, but in production environments, you often want to simplify or extend the system.

To Simplify: If you do not want to manage atomic reads and state machines manually, use Paul Stoffregen’s Encoder Library. It handles Pin Change Interrupts, hardware timers, and atomic reads under the hood. You simply instantiate the object and call myEnc.read().

To Extend:

  • High-Speed Encoders: Optical encoders can generate thousands of pulses per second. The digitalRead() function inside the ISR takes roughly 3-4 microseconds on an AVR. For optical encoders, replace digitalRead() with Direct Port Manipulation (e.g., PIND & (1<<PD2)) to drop ISR execution time to under 1 microsecond.
  • Sleep Modes: Use the ISR to wake the ATmega328P from power_down sleep mode. Configure the interrupt to trigger on LOW rather than CHANGE, as only level interrupts can wake the CPU from deep sleep.

Arduino ISR FAQ

Can I use any digital pin for an Arduino ISR?

On the Arduino Nano V3 (ATmega328P), only Digital Pins 2 and 3 support dedicated hardware external interrupts (INT0 and INT1). However, all other digital pins support Pin Change Interrupts (PCINT). PCINTs trigger on any state change but do not tell you which pin changed; your ISR must manually poll the port registers to find the culprit. For dedicated, low-latency hardware interrupts, you are restricted to Pins 2 and 3.

Why is my Arduino ISR missing fast encoder clicks?

If you spin the encoder rapidly and the count falls behind, your ISR execution time is too long, or your main loop is blocking. First, ensure you have absolutely no delay(), Serial.print(), or LCD.update() functions inside the ISR. Second, check your main loop: if you have a delay(100) in the loop, the serial buffer might overflow, causing the microcontroller to stall while waiting for UART registers to clear, which indirectly starves the CPU of cycles needed to process the interrupt queue.

What is the maximum execution time for an Arduino ISR?

There is no hard hardware limit, but the practical limit is dictated by your system's real-time requirements. An ISR should ideally execute in under 5 microseconds on a 16MHz AVR. If an ISR takes too long, it blocks the main loop, prevents other lower-priority interrupts from firing (unless you explicitly re-enable interrupts inside the ISR, which is highly discouraged due to stack overflow risks), and causes missed sensor events. If your ISR requires complex math, set a volatile bool flag inside the ISR and perform the math in the main loop.

How do I clear or detach an Arduino ISR?

To completely disable an interrupt without rewriting your code, use the detachInterrupt(digitalPinToInterrupt(pin)) function. This is useful when you want to lock out a sensor during a critical mechanical movement. To re-enable it later, simply call attachInterrupt() again. Note that detaching an interrupt does not clear pending hardware flags; if the pin changes state while detached, the event is simply ignored.

For deeper technical analysis on AVR interrupt vectors and execution cycles, refer to Nick Gammon's comprehensive guide on microcontroller interrupts and the official Arduino attachInterrupt() documentation.