An Interrupt Service Routine (ISR) in Arduino is a hardware-triggered function that pauses the main loop() to handle time-critical events, executing in microseconds. If you are polling a mechanical rotary encoder or a high-speed flow sensor, your main loop's blocking delays (like delay() or I2C sensor reads) will cause you to miss pulses. The direct answer to when you need an ISR is simple: if the event you are measuring can change state faster than your loop() can reliably check it, you must use a hardware interrupt.
For the standard Arduino Uno R3 (ATmega328P), only digital pins 2 and 3 support dedicated hardware interrupts (INT0 and INT1). However, modern boards like the ESP32 support GPIO interrupts on almost all pins. This guide covers the exact hardware limits, provides a robust, compilable rotary encoder build, and details the specific compiler errors and runtime failures that plague ISR implementations.
Hardware Limits and Microcontroller ISR Constraints
Before wiring your project, you must understand the hardware constraints of your specific microcontroller. An ISR is not magic; it requires the CPU to push registers onto the stack, jump to the ISR memory address, execute your code, and pop registers back. On a 16 MHz ATmega328P, this context-switching overhead takes roughly 11 clock cycles (about 0.68 µs), plus C++ prologue/epilogue overhead, totaling 3 to 5 µs before your first line of code runs.
If your ISR takes 50 µs to execute, and your sensor fires an interrupt every 20 µs, the stack will overflow, and the microcontroller will hard-lock or trigger a watchdog reset. Below is the data-dense reference table for common maker boards.
| Board Variant | MCU Core | Dedicated Hardware ISR Pins | Context Switch Overhead | Supported Trigger Modes |
|---|---|---|---|---|
| Arduino Uno R3 | ATmega328P (AVR) | Pins 2 (INT0), 3 (INT1) | ~3.5 µs | LOW, CHANGE, RISING, FALLING |
| Arduino Mega 2560 | ATmega2560 (AVR) | Pins 2, 3, 18, 19, 20, 21 | ~3.5 µs | LOW, CHANGE, RISING, FALLING |
| ESP32 DevKit V1 | ESP32-WROOM-32 (Xtensa) | All GPIOs except 6-11 & 34-39 (input only) | ~1.5 µs | LOW, CHANGE, RISING, FALLING |
| Raspberry Pi Pico | RP2040 (Cortex-M0+) | All GPIOs (0-29) | ~1.0 µs | LOW, HIGH, RISING, FALLING |
ESP_INTR_FLAG_IRAM flag in the ESP-IDF, or ensure your Arduino core ISR function and any variables it touches are marked with IRAM_ATTR. Otherwise, a cache miss during a flash read will crash the ESP32 with a Guru Meditation Error: Core 1 panic'ed (Cache disabled but cached memory region accessed).
Parts List and Pin Mapping
For this build, we are targeting the Arduino Uno R3 (ATmega328P). We will use a mechanical rotary encoder to demonstrate ISR necessity. Polling a KY-040 encoder at 60 RPM yields pulses roughly every 1.6 ms. If your main loop includes a 5 ms delay() or a blocking Wire.requestFrom() call to an I2C OLED display, you will miss encoder steps, resulting in erratic UI navigation.
Bill of Materials
- MCU: Arduino Uno R3 (or any ATmega328P-based clone)
- Sensor: KY-040 Rotary Encoder Module (includes breakout board with pull-up resistors)
- Wiring: 5x Male-to-Female jumper wires (22 AWG stranded)
- Debounce Hardware (Optional but recommended): Two 0.1 µF ceramic capacitors (placed between CLK/GND and DT/GND)
Pin Mapping Table
| KY-040 Pin | Arduino Uno Pin | Function / Notes |
|---|---|---|
| CLK (Clock) | Digital 2 | Hardware INT0. Triggers the primary ISR. |
| DT (Data) | Digital 3 | Read inside ISR to determine direction. |
| SW (Switch) | Digital 4 | Polled in main loop (button press is slow enough). |
| + (VCC) | 5V | Powers the module's pull-up resistors. |
| GND | GND | Common ground reference. |
Compilable ISR Code with Error Handling
The code below is fully compilable for the Arduino Uno R3. It implements a state-machine approach to read the encoder direction and includes critical error handling: it uses noInterrupts() when reading multi-byte volatile variables in the main loop to prevent data tearing (where the ISR updates the variable exactly while the main loop is reading its bytes).
// Pin Definitions
const int ENCODER_CLK = 2; // Hardware INT0
const int ENCODER_DT = 3; // Hardware INT1 (Read for direction)
const int ENCODER_SW = 4; // Pushbutton switch
// Volatile variables modified by ISR
volatile long encoderCount = 0;
volatile bool switchPressed = false;
// State tracking for debounce/direction
volatile int lastClkState = HIGH;
void setup() {
Serial.begin(115200);
// Configure pins
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
pinMode(ENCODER_SW, INPUT_PULLUP);
// Read initial state
lastClkState = digitalRead(ENCODER_CLK);
// Attach the ISR to the hardware interrupt pin
// digitalPinToInterrupt() is mandatory for cross-board compatibility
attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), readEncoderISR, FALLING);
Serial.println("ISR Encoder Initialized. Rotate the knob.");
}
void loop() {
// 1. Safely read the multi-byte volatile variable
long currentCount;
noInterrupts(); // Pause interrupts to prevent data tearing
currentCount = encoderCount;
interrupts(); // Resume interrupts immediately
// 2. Output state (Rate-limited to avoid flooding Serial buffer)
static long lastPrintedCount = -1;
if (currentCount != lastPrintedCount) {
Serial.print("Position: ");
Serial.println(currentCount);
lastPrintedCount = currentCount;
}
// 3. Handle switch press (Polled, as human presses are slow)
if (digitalRead(ENCODER_SW) == LOW) {
delay(50); // Simple software debounce for the button
if (digitalRead(ENCODER_SW) == LOW) {
noInterrupts();
encoderCount = 0; // Reset counter
interrupts();
Serial.println("Switch pressed. Counter reset to 0.");
while(digitalRead(ENCODER_SW) == LOW); // Wait for release
}
}
}
// The Interrupt Service Routine
// Keep this as short as physically possible. No Serial.print() here!
void readEncoderISR() {
int currentDtState = digitalRead(ENCODER_DT);
// Determine direction based on DT pin state when CLK falls
if (currentDtState == HIGH) {
encoderCount++;
} else {
encoderCount--;
}
// Optional: Hard limits to prevent overflow in downstream logic
if (encoderCount > 10000) encoderCount = 10000;
if (encoderCount < -10000) encoderCount = -10000;
}
Debugging ISR Failures: The First Three Checks
When an ISR implementation fails, it rarely fails silently. It either throws a specific compiler error, or it causes erratic runtime behavior (missed steps, random reboots). If your build is not working, check these three things immediately.
1. The 'Not Declared in Scope' or 'Data Tearing' Bug
Exact Error String: error: 'encoderCount' was not declared in this scope
The Cause: You declared the variable inside setup() or loop() instead of globally. ISRs cannot access local variables. Furthermore, if you declared it globally but forgot the volatile keyword, the compiler's optimizer will cache the variable in a CPU register. The main loop will never see the updates made by the ISR, resulting in a frozen counter.
The Fix: Always declare ISR-shared variables globally with the volatile type qualifier (e.g., volatile long myVar;).
2. The 'Invalid Conversion' AttachInterrupt Error
Exact Error String: error: invalid conversion from 'int' to 'void (*)()' [-fpermissive]
The Cause: You passed the raw pin number to attachInterrupt() instead of the interrupt vector number, or you passed the function name with parentheses. For example, writing attachInterrupt(2, readEncoderISR(), FALLING) executes the function immediately and passes its void return type to the interrupt handler.
The Fix: Use the wrapper macro and pass the function pointer without parentheses: attachInterrupt(digitalPinToInterrupt(2), readEncoderISR, FALLING);. According to the official Arduino attachInterrupt documentation, this wrapper is mandatory for boards like the Mega where Pin 2 is INT4, not INT2.
3. Runtime Erratic Jumping (Switch Bounce)
Symptom: Turning the knob one detent clockwise registers +3, then -1, then +2. The count is entirely unreliable.
The Cause: Mechanical switch bounce. When the metal contacts close, they physically bounce, creating a 50 kHz ring of high/low transitions. The ISR fires 15 times for a single physical click.
The Fix: Do not use delay() inside an ISR (it will freeze the MCU). Instead, use hardware debouncing: solder a 0.1 µF ceramic capacitor between the CLK pin and GND. This creates a low-pass RC filter with the internal pull-up resistor, smoothing the bounce into a single, clean edge. Alternatively, read the encoder state via a hardware timer polling at 1 kHz rather than using pin-change interrupts.
Extending and Simplifying Your ISR Build
Once you have a stable ISR, you will eventually hit the limits of mechanical sensors or CPU overhead. Here is how to scale your project up or down based on your end goal.
How to Simplify (Offload the MCU)
If you only need absolute position tracking and want to eliminate ISRs entirely, replace the mechanical KY-040 with a magnetic absolute encoder like the AS5048A. This chip communicates via SPI or I2C. You simply poll its 14-bit register in the main loop whenever you need the position. Because it has no moving mechanical contacts, there is no bounce, and because it holds its state in a hardware register, you cannot "miss" a step even if your main loop stalls for 100 ms.
How to Extend (Timer Interrupts and Queues)
If you are building a high-speed data acquisition system (like sampling an analog microphone at 20 kHz), pin-change interrupts will fail due to jitter. Instead, extend your build using Hardware Timer Interrupts. Libraries like TimerOne allow you to trigger an ISR at exact microsecond intervals, independent of pin states.
For ESP32 users extending this to multi-core environments, never manipulate shared memory directly in the ISR. Instead, use the FreeRTOS xQueueSendFromISR() function inside your ISR to push the event data into a thread-safe queue, which your main loop tasks can process asynchronously. As detailed in the Espressif Interrupt Allocation API docs, this prevents cache-coherency faults and keeps your Wi-Fi stack from dropping packets during high-frequency sensor events.






