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 |
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:
- Library Vector Collision:
__vector_1corresponds 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 useattachInterrupt()on a pin that a library is already using for timing or serial emulation. - Manual ISR Redefinition: You wrote
ISR(INT0_vect) { ... }manually in your code while also callingattachInterrupt(). Fix: Choose one method. UseattachInterrupt()for standard Arduino sketches, or manualISR()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:
volatile KeywordIf 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.






