The Core Difference: Polling vs. Hardware Interrupts

When building reactive embedded systems, relying on digitalRead() inside the main loop() is a bottleneck. Polling forces the microcontroller to sequentially check pin states, meaning a rapid 50-microsecond pulse from a rotary encoder or a flow sensor might occur while the CPU is busy executing a delay() or updating a display. Hardware interrupts on Arduino solve this by allowing the silicon to immediately pause the main program, execute an Interrupt Service Routine (ISR), and resume exactly where it left off.

On the classic Arduino Uno R3 (ATmega328P), interrupts are handled at the silicon level via the External Interrupt Control Register (EICRA) and the External Interrupt Mask Register (EIMSK). When a configured edge (rising, falling, or change) is detected on a mapped pin, the AVR hardware sets an interrupt flag, finishes the current machine instruction, pushes the program counter to the stack, and jumps to the specific interrupt vector address. This entire context-switch takes roughly 5 to 7 microseconds, making it vastly superior to software polling for high-speed signal capture.

ATmega328P Interrupt Pin Mapping & Capabilities

Not all pins on the Uno R3 support dedicated external interrupts. The ATmega328P features two dedicated external interrupt pins (INT0 and INT1) and 23 Pin Change Interrupt (PCINT) pins. Below is the exact hardware mapping you need before wiring your breadboard.

Arduino Pin ATmega328P Port INT# (External) PCINT# (Pin Change) Supported Trigger Modes ISR Vector Name
D2 PD2 INT0 PCINT18 LOW, CHANGE, RISING, FALLING INT0_vect
D3 PD3 INT1 PCINT19 LOW, CHANGE, RISING, FALLING INT1_vect
D8 - D13 PB0 - PB5 None PCINT0 - PCINT5 CHANGE only PCINT0_vect
A0 - A5 PC0 - PC5 None PCINT8 - PCINT13 CHANGE only PCINT1_vect
D0, D1, D4-D7 PD0, PD1, PD4-PD7 None PCINT16-17, 20-23 CHANGE only PCINT2_vect
Bench Tip: Always use the digitalPinToInterrupt(pin) macro in your code rather than hardcoding the INT number. Passing 2 directly into attachInterrupt() works on the Uno, but will silently fail or map to the wrong pin on an Arduino Mega 2560 or Leonardo.

Wiring and Parts List for a Rotary Encoder Test

To demonstrate reliable interrupt handling, we will wire a KY-040 rotary encoder. Mechanical switches suffer from contact bounce, which can generate dozens of phantom interrupts in a single millisecond. While software debouncing (checking millis() inside the ISR) works, it consumes CPU cycles. For this build, we use hardware RC debouncing.

Parts List

  • Microcontroller: Arduino Uno R3 (ATmega328P)
  • Sensor: KY-040 Rotary Encoder Module (or bare EC11 encoder)
  • Resistors: 2x 10kΩ (for I2C-style pull-ups if module lacks them)
  • Capacitors: 2x 0.1µF (104) ceramic capacitors (for hardware debounce)
  • Wiring: 22 AWG solid core jumper wires

Pin Mapping Table

Component Pin Arduino Uno R3 Pin Notes
Encoder CLK D2 (INT0) Connect 0.1µF cap between CLK and GND
Encoder DT D3 (INT1) Connect 0.1µF cap between DT and GND
Encoder SW (Button) D4 Polled via digitalRead, internal pull-up enabled
Encoder VCC (+) 5V Do not use 3.3V on a 5V Uno
Encoder GND GND Common ground required

Complete Compilable Code: Debounced Interrupt Handling

This code targets the Arduino Uno R3 (ATmega328P). It uses hardware debounce capacitors, allowing the ISR to remain ultra-lean. Notice the strict use of the volatile keyword and the critical section in the loop() using noInterrupts() to safely copy multi-byte variables.

/*
 * Hardware Interrupt Rotary Encoder Reader
 * Target Board: Arduino Uno R3 (ATmega328P)
 * Requires: 0.1uF capacitors on CLK and DT pins for hardware debounce
 */

// --- Pin Definitions ---
#define ENCODER_CLK_PIN 2  // Maps to INT0 on Uno R3
#define ENCODER_DT_PIN  3  // Maps to INT1 on Uno R3
#define ENCODER_SW_PIN  4  // Pushbutton, polled

// --- Volatile Variables (Modified in ISR) ---
volatile int encoderPosition = 0;
volatile bool encoderUpdated = false;

void setup() {
  Serial.begin(115200);
  
  // Configure encoder pins with internal pull-ups
  pinMode(ENCODER_CLK_PIN, INPUT_PULLUP);
  pinMode(ENCODER_DT_PIN, INPUT_PULLUP);
  pinMode(ENCODER_SW_PIN, INPUT_PULLUP);
  
  // Attach interrupts to the correct hardware vectors
  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK_PIN), handleClockChange, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENCODER_DT_PIN), handleDataChange, CHANGE);
  
  Serial.println("Encoder initialized. Turn the knob.");
}

void loop() {
  // Create a local copy of the volatile variable safely
  int localPosition;
  bool localUpdated;
  
  noInterrupts(); // Disable interrupts for atomic read
  localPosition = encoderPosition;
  localUpdated = encoderUpdated;
  encoderUpdated = false; // Reset flag
  interrupts();   // Re-enable interrupts
  
  if (localUpdated) {
    Serial.print("Position: ");
    Serial.println(localPosition);
  }
  
  // Poll the button (no interrupt needed for slow human presses)
  if (digitalRead(ENCODER_SW_PIN) == LOW) {
    delay(50); // Simple software debounce for the button
    if (digitalRead(ENCODER_SW_PIN) == LOW) {
      Serial.println("Button Pressed! Resetting.");
      noInterrupts();
      encoderPosition = 0;
      interrupts();
      while(digitalRead(ENCODER_SW_PIN) == LOW); // Wait for release
    }
  }
}

// --- Interrupt Service Routines (ISRs) ---
// Keep these as short as mathematically possible. No Serial.print(), no delay().

void handleClockChange() {
  uint8_t clkState = digitalRead(ENCODER_CLK_PIN);
  uint8_t dtState = digitalRead(ENCODER_DT_PIN);
  
  if (clkState != dtState) {
    encoderPosition++;
  } else {
    encoderPosition--;
  }
  encoderUpdated = true;
}

void handleDataChange() {
  // In a full quadrature decoder, you read both pins here too.
  // For simplicity and to avoid double-counting with hardware caps,
  // we rely primarily on the CLK edge, but DT changes can refine resolution.
  encoderUpdated = true;
}

Debugging: Linker Errors and Silent Failures

Working with interrupts on Arduino introduces specific compilation and runtime hazards. If your code fails to compile or your microcontroller seemingly freezes, check these exact failure modes.

The "Multiple Definition" Linker Error

If you are mixing interrupts with libraries like SoftwareSerial, Servo, or IRremote, you may encounter this exact compile error:

ld.exe: multiple definition of '__vector_1'
collect2.exe: error: ld returned 1 exit status

Ranked Causes & Fixes:

  1. Library Vector Collision: __vector_1 corresponds to INT0 on the ATmega328P. If a library you included already claims this vector (or the PCINT vectors), the linker will throw this error. Fix: Check the library documentation. You cannot use attachInterrupt() on a pin that a library is already using for timing or serial emulation.
  2. Manual ISR Redefinition: You wrote ISR(INT0_vect) { ... } manually in your code while also calling attachInterrupt(). Fix: Choose one method. Use attachInterrupt() for standard Arduino sketches, or manual ISR() macros for bare-metal AVR optimization, but never both for the same pin.

First Three Things to Check When Interrupts Fail Silently

If the code compiles but the ISR never triggers, or the Uno freezes, run through this checklist:

1. Missing the volatile Keyword
If you declare int count = 0; instead of volatile int count = 0;, the GCC compiler will optimize the variable into a CPU register. The ISR will update the memory address, but the main loop() will only ever read the stale register value. Always use volatile for ISR-shared variables.

2. Floating Input Pins (Phantom Triggers)
If you forgot the 10kΩ pull-up resistors (or failed to enable INPUT_PULLUP), the pin is floating. Electromagnetic interference from nearby wires or even your hand approaching the breadboard will induce voltage spikes, triggering the interrupt thousands of times a second and starving the main loop. Always verify pin states with a multimeter (should read ~5V when open, ~0V when grounded).

3. Blocking Code Inside the ISR
If you put delay(), Serial.print(), or Wire.requestFrom() inside your ISR, the microcontroller will lock up. Why? Because delay() relies on Timer0 interrupts, and Serial relies on UART interrupts. When you enter an ISR, global interrupts are automatically disabled (cli()). Calling a function that waits for an interrupt creates a permanent deadlock. Keep ISRs under 5 microseconds.

Extending the Build: Pin Change Interrupts and ESP32 Migration

The Uno R3 is limited to two dedicated external interrupts. If your project requires reading three flow sensors or a keypad, you must extend the architecture.

Option A: Pin Change Interrupts (PCINT) on AVR

As shown in the mapping table, almost every pin on the ATmega328P supports Pin Change Interrupts. However, they only support the CHANGE mode (not RISING or FALLING), and they share a single vector per port (e.g., all analog pins share PCINT1_vect). To use them without writing raw register code, install the PinChangeInterrupt library by NicoHood. It abstracts the port-masking math and allows you to attach callbacks to any digital or analog pin.

Option B: Migrating to the ESP32

If you are upgrading to an ESP32 (like the ESP32-WROOM-32 DevKit V1), the interrupt architecture changes entirely. The ESP32 uses a GPIO matrix, meaning any GPIO pin can be routed to an external interrupt. However, the ESP32 runs FreeRTOS, which introduces a strict memory requirement for ISRs.

When writing interrupt code for the ESP32, you must prepend the IRAM_ATTR attribute to your ISR function. This forces the compiler to place the ISR code into the ESP32's fast Instruction RAM (IRAM) rather than the slower external SPI Flash. Failing to do this will result in a Guru Meditation Error: Core 1 panic'ed (Cache disabled but cached memory region accessed) crash the moment the interrupt fires.

// ESP32 Specific ISR Declaration
void IRAM_ATTR handleEncoder() {
  // ESP32 ISR code here
}

For authoritative details on AVR interrupt vectors and compiler attributes, refer to the official avr-libc Interrupt Documentation and the Arduino attachInterrupt() Reference.