The Core Architecture of Arduino ISR Compatibility

When migrating a sketch from an Arduino Uno to an ESP32 or a SAMD-based board, the most common point of catastrophic failure is the Interrupt Service Routine (ISR). An Arduino ISR is not a universal construct; it is deeply tethered to the underlying microcontroller's interrupt controller, timer architecture, and memory mapping. Understanding Arduino ISR compatibility requires looking beyond the attachInterrupt() wrapper and examining how hardware peripherals, third-party libraries, and memory caches interact with your interrupt vectors.

This compatibility guide dissects the hardware limitations, timer collisions, and architectural quirks you must navigate when deploying ISRs across the modern maker ecosystem in 2026.

Microcontroller Family Compatibility Matrix

Not all boards handle external and pin-change interrupts equally. The table below maps the fundamental ISR capabilities across the three most dominant microcontroller families used in the Arduino environment.

MCU Family Common Boards Dedicated External Pins Pin-Change Support Memory / Cache Constraints
AVR (8-bit) Uno, Nano, Mega2560 Limited (e.g., D2, D3 on ATmega328P) Yes, grouped by PORT (B, C, D) None (Direct SRAM execution)
SAMD (32-bit ARM) Zero, MKR WiFi 1010, Nano 33 IoT Up to 16 EIC lines mapped to specific GPIOs Limited via EIC multiplexing None (Flash execution is zero-wait-state)
ESP32 (Xtensa/RISC-V) ESP32 DevKit, ESP32-S3, ESP32-C3 Any GPIO (except strapping pins) N/A (All GPIOs support edge/level triggers) Strict: Requires IRAM_ATTR to prevent cache-miss panics

Hardware-Level Pin Compatibility and Quirks

The AVR Pin-Change Illusion

On the ATmega328P, hardware external interrupts are strictly limited to pins 2 (INT0) and 3 (INT1). To use other pins, developers rely on Pin Change Interrupts (PCINT). However, PCINTs are not individually vectored. They are grouped by hardware ports. For example, pins 8 through 13 all share the PCINT0_vect. If pin 8 triggers, the ISR fires, but the hardware does not tell you which pin caused it. Your ISR must manually read the PINB register and compare it against a cached state to identify the culprit. This adds execution overhead and makes high-speed rotary encoder decoding highly unreliable on PCINT pins compared to dedicated INT pins.

The ESP32 IRAM_ATTR Mandate

According to the official Espressif interrupt allocation documentation, ESP32 microcontrollers execute code directly from an external SPI flash chip via a cache. When the system performs a flash operation (like writing to SPIFFS or performing an OTA update), the cache is disabled. If an interrupt fires while the cache is disabled, and the ISR resides in flash memory, the CPU will attempt to fetch an instruction from an inaccessible bus, resulting in a fatal LoadStoreError or InstrFetchError panic.

Compatibility Fix: Always prefix ESP32 ISR functions with the IRAM_ATTR macro. This forces the compiler to place the function in the limited internal SRAM, ensuring it remains accessible during flash operations.

Timer and Peripheral Conflicts: The Hidden ISR Killers

The most insidious compatibility issues arise not from the pins, but from the timers. The Arduino core and popular libraries silently hijack hardware timers to function. If your custom Arduino ISR relies on a specific timer, or if you attempt to use timer-dependent libraries inside an ISR, your sketch will deadlock or behave erratically.

Library Collision Matrix

  • Servo.h (AVR): Consumes Timer1. If your custom ISR uses Timer1 for PWM frequency generation or pulse counting, the Servo library will overwrite your TCCR1A and TIMSK1 registers, destroying your interrupt setup.
  • SoftwareSerial.h: This library is fundamentally incompatible with time-critical ISRs. It achieves serial communication by bit-banging, which requires disabling global interrupts (noInterrupts()) for the duration of every byte transmitted or received. At 9600 baud, this blocks all ISRs for over 1 millisecond per byte, causing missed encoder ticks or dropped RF packets.
  • Tone(): Consumes Timer2 on the ATmega328P. Using tone() will break any custom ISR relying on the Timer2 overflow vector (TIMER2_OVF_vect).
  • Wire.h (I2C): The I2C bus relies on its own hardware ISR (TWI_vect). Calling Wire.requestFrom() or Wire.endTransmission() inside a custom external ISR will cause a deadlock, as the I2C interrupt cannot preempt your currently executing ISR (AVR nested interrupts are disabled by default).

Memory Constraints and Variable Volatility

A frequent compatibility error when porting code from 32-bit ARM boards back to 8-bit AVR boards involves the volatile keyword and memory tearing. Declaring a variable as volatile tells the compiler not to optimize it out of the read loop, but it does not guarantee atomicity.

On a 32-bit SAMD or ESP32 board, reading a 32-bit integer is a single-clock-cycle atomic operation. On an 8-bit AVR, reading a 16-bit or 32-bit variable requires multiple clock cycles. If your Arduino ISR fires exactly between the clock cycles while the main loop is reading the variable, you will experience a "torn read"—resulting in corrupted data.

The Atomic Block Solution

To ensure cross-platform compatibility and data integrity, you must protect multi-byte volatile reads in the main loop. As detailed in Nick Gammon's comprehensive guide to Arduino interrupts, you should use atomic blocks or manual interrupt toggling:

volatile uint32_t pulseCount = 0;
uint32_t localCopy;

void loop() {
  noInterrupts();
  localCopy = pulseCount;
  interrupts();
  
  // Use localCopy for calculations safely
}

Best Practices for Cross-Platform ISR Portability

To ensure your Arduino ISR code compiles and functions reliably across Uno, Nano 33 IoT, and ESP32 architectures, adhere to this compatibility checklist:

  1. Keep it Micro: An ISR should execute in under 5 microseconds. Set a flag or increment a counter, then handle the heavy logic (like Serial printing or I2C writes) in the main loop().
  2. Avoid millis() inside ISRs: The millis() function relies on the Timer0 overflow ISR. While it can technically be read inside another ISR on AVR, it will never increment while inside the ISR context. Use micros() for high-resolution timestamping instead.
  3. Clear Spurious Flags: On AVR boards, if you dynamically enable/disable interrupts, the hardware flag might already be set. Write a 1 to the External Interrupt Flag Register (EIFR) before calling attachInterrupt() to clear stale triggers.
  4. Use Digital Pin Abstractions: Instead of hardcoding attachInterrupt(0, ...), which maps to Pin 2 on Uno but Pin 3 on Mega, always use digitalPinToInterrupt(PIN_NUMBER). This macro, documented in the official Arduino language reference, ensures the correct hardware vector is mapped regardless of the target board.

Frequently Asked Questions

Can I use I2C or SPI inside an Arduino ISR?

No. Both I2C (Wire) and SPI rely on their own hardware interrupts to complete byte transfers. Because standard Arduino ISRs do not allow nested interrupts, calling I2C or SPI functions inside an ISR will cause the microcontroller to deadlock indefinitely.

Why does my ESP32 crash only when I write to the SD card?

SD card writes require SPI communication and often trigger flash cache disables on the ESP32. If your ISR lacks the IRAM_ATTR attribute, the CPU cannot fetch the ISR instructions from flash during the SD write operation, resulting in a Guru Meditation Error / Cache Panic.

How many ISRs can I attach on an Arduino Mega2560?

The ATmega2560 supports 8 dedicated external interrupts (INT0 through INT7), mapped to pins 2, 3, 18, 19, 20, 21, and a few others. While you can use Pin Change Interrupts to cover almost all 86 GPIOs, the shared port-vector architecture makes managing more than a handful of high-speed PCINT sources practically unfeasible without severe timing jitter.