Arduino interrupts allow the microcontroller to instantly pause the main loop and execute an Interrupt Service Routine (ISR) when a specific pin changes state. Unlike digitalRead() polling, which wastes CPU cycles checking a pin thousands of times a second, hardware interrupts guarantee zero-latency capture of fast transient signals like rotary encoder pulses or anemometer clicks. If your main loop is busy driving WS2812 LEDs or handling WiFi stacks, polling will miss pulses; interrupts will not.

Why Hardware Interrupts Beat Polling (And When They Don't)

Before wiring up an ISR, you must decide if the overhead is justified. Hardware interrupts introduce complexity: shared variables must be declared volatile, ISRs cannot contain blocking code like delay(), and mechanical switch bounce can trigger multiple phantom interrupts in microseconds.

Criteria Polling (digitalRead) Hardware Interrupts (attachInterrupt)
CPU Overhead High (constant checking) Zero (sleeps until triggered)
Latency Depends on main loop duration Instant (approx. 5µs on 16MHz AVR)
Code Complexity Low (linear execution) High (requires volatile, state machines)
Best Use Case Slow UI buttons, switches Encoders, flow meters, zero-cross detection

ATmega328P Interrupt Pin Mapping & Specs

The most common Arduino boards (Uno, Nano, Pro Mini) use the ATmega328P microcontroller. This chip has exactly two dedicated external hardware interrupt pins: INT0 and INT1. According to the Arduino attachInterrupt() Reference, these map to physical pins D2 and D3.

Arduino Pin AVR Port/Pin Hardware Vector attachInterrupt() Arg Supported Trigger Modes
D2 PD2 INT0 digitalPinToInterrupt(2) LOW, CHANGE, RISING, FALLING
D3 PD3 INT1 digitalPinToInterrupt(3) LOW, CHANGE, RISING, FALLING
D0-D1, D4-D13 Various PCINT0-23 N/A (Requires PCINT library) CHANGE only
A0-A5 PC0-PC5 PCINT8-13 N/A (Requires PCINT library) CHANGE only
Bench Tip: Always use the digitalPinToInterrupt(pin) macro rather than hardcoding the interrupt number (e.g., passing 0 for D2). While 0 works on the Uno/Nano, it maps to a completely different pin on the Arduino Mega or ESP32, making your code non-portable.

Project Build: Debounced Rotary Encoder via ISR

We will build a high-resolution rotary encoder tracker. Mechanical encoders like the KY-040 generate quadrature pulses that can exceed 100Hz during a fast flick of the knob. If your main loop takes 20ms to update an LED strip, you will miss counts. We will use INT0 (D2) to catch the clock pulses.

Parts List

  • Microcontroller: Arduino Nano V3 (ATmega328P, 16MHz, 5V logic)
  • Sensor: KY-040 Rotary Encoder Module (includes breakout board with pull-ups)
  • Hardware Debounce: 2x 0.1µF ceramic capacitors (critical for ISR sanity)
  • Wiring: 22 AWG solid core jumper wires

Pin Mapping Table

KY-040 Pin Arduino Nano Pin Notes
CLK (Clock)D2 (INT0)Solder 0.1µF cap between CLK and GND
DT (Data)D4Read inside ISR to determine direction
SW (Switch)D5Polled in main loop (with internal pull-up)
+ (VCC)5VDo not use 3.3V on a 5V Nano
GNDGNDCommon ground required

Wiring Steps

  1. Connect KY-040 VCC to Nano 5V and GND to Nano GND.
  2. Solder a 0.1µF ceramic capacitor directly across the CLK and GND pins on the encoder module. Do not skip this. Software debouncing inside an ISR is notoriously unreliable and wastes execution time.
  3. Wire CLK to D2, DT to D4, and SW to D5.
  4. Verify continuity with a multimeter before applying power to ensure no 5V-to-GND shorts.

The Code: Non-Blocking ISR Implementation

The following code targets the Arduino Nano V3 (ATmega328P). It uses a hardware-triggered ISR on the FALLING edge of the CLK pin. Notice the strict adherence to ISR rules: no delay(), no Serial.print(), and all shared variables are marked volatile.

#include <Arduino.h>

// --- PIN DEFINITIONS ---
#define ENCODER_CLK 2    // Must be an interrupt pin (D2 or D3 on Nano)
#define ENCODER_DT  4    // Direction pin
#define ENCODER_SW  5    // Push button pin

// --- VOLATILE SHARED VARIABLES ---
volatile long encoderPos = 0;
volatile bool positionChanged = false;

void setup() {
  Serial.begin(115200);
  
  // Configure pins
  pinMode(ENCODER_DT, INPUT);
  pinMode(ENCODER_SW, INPUT_PULLUP);
  
  // Attach hardware interrupt on FALLING edge
  // digitalPinToInterrupt() ensures cross-board compatibility
  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), readEncoder, FALLING);
  
  Serial.println(F(\"System Ready. Flick the knob.\"));
}

void loop() {
  // Handle Serial output OUTSIDE the ISR to prevent UART lockups
  if (positionChanged) {
    // Error handling: Check if serial buffer has space before writing
    if (Serial.availableForWrite() > 20) {
      Serial.print(F(\"Position: \"));
      Serial.println(encoderPos);
    }
    positionChanged = false;
  }
  
  // Handle push button via standard polling (debounced via simple delay)
  if (digitalRead(ENCODER_SW) == LOW) {
    delay(50); // Simple software debounce for slow button
    if (digitalRead(ENCODER_SW) == LOW) {
      encoderPos = 0;
      Serial.println(F(\"Reset to Zero\"));
      while(digitalRead(ENCODER_SW) == LOW); // Wait for release
    }
  }
}

// --- INTERRUPT SERVICE ROUTINE ---
void readEncoder() {
  // Read the DT pin state to determine direction
  // If DT is HIGH, we are turning one way; if LOW, the other.
  uint8_t dtState = digitalRead(ENCODER_DT);
  
  if (dtState == HIGH) {
    encoderPos++;
  } else {
    encoderPos--;
  }
  
  // Set flag for main loop
  positionChanged = true;
}

Debugging: Missed Triggers and Phantom Counts

Interrupts are unforgiving. If you violate AVR architecture rules, the compiler might not stop you, but the runtime behavior will be chaotic. Below are the most common failure modes.

The First Three Things to Check When It Fails

  1. The volatile Keyword: Did you declare encoderPos as volatile? If not, the compiler's optimizer will cache the variable in a CPU register, and the main loop will never see the updates made by the ISR.
  2. Blocking Code in ISR: Is there a Serial.print(), delay(), or Wire.requestFrom() inside readEncoder()? Remove it immediately. ISRs must execute in under 100µs.
  3. Hardware Bounce: Connect an oscilloscope to the CLK pin. If you see 5-10 micro-bounces per click, your capacitor is missing or too small.

Ranked Causes for Specific Errors

Symptom 1: Compiler Warning
warning: variable 'encoderPos' might be clobbered by 'longjmp' or 'vfork' [-Wclobbered]
Cause: You forgot the volatile keyword on a variable shared between the ISR and main loop. The GCC compiler is warning you that its optimization pass assumes the variable doesn't change outside the main execution thread.
Fix: Add volatile to the variable declaration.

Symptom 2: Runtime Serial Lockup
Exact Symptom: Serial output stutters, prints fragmented garbage, or freezes entirely during fast rotation.
Cause: You placed Serial.print() inside the ISR. The UART hardware buffer fills up, and Serial.print() blocks execution waiting for space. Because interrupts are disabled inside an ISR, the UART interrupt cannot fire to clear the buffer, resulting in a deadlock.
Fix: Use a boolean flag (positionChanged) in the ISR and handle all Serial printing in the loop().

Symptom 3: Phantom Counts / Erratic Jumping
Exact Symptom: Encoder position jumps by +3 or -2 on a single physical click.
Cause: Mechanical switch bounce triggering the FALLING edge multiple times within microseconds.
Fix: Add a 0.1µF ceramic capacitor across the CLK pin and GND to create a hardware low-pass filter. Avoid software debouncing (like millis() checks) inside the ISR, as it complicates state tracking.

Extending and Simplifying the Build

How to Extend: Adding More Interrupt Devices

The ATmega328P only has two dedicated external interrupts (D2 and D3). If you need to add a second encoder or a flow meter, you must use Pin Change Interrupts (PCINT). PCINTs trigger on ANY state change (no RISING/FALLING selection) and group pins by port. Use the widely trusted PinChangeInterrupt library by NicoHood to abstract the complex AVR register manipulation. Note that PCINTs have slightly higher latency (approx. 15µs vs 5µs) due to vector routing.

How to Simplify: When to Drop Interrupts Entirely

If your project is a simple menu navigator with no heavy background tasks (no LEDs, no motors, no WiFi), your loop() likely executes in under 50µs. In this scenario, polling the encoder with a robust state-machine library like Nick Gammon's recommended polling techniques is actually simpler and less prone to edge-case bugs than managing ISRs. Reserve hardware interrupts for when the main loop latency exceeds 1ms, or when the signal frequency exceeds 500Hz.