Hardware interrupts in Arduino allow the microcontroller to immediately pause its main loop, execute a specific function (the Interrupt Service Routine, or ISR), and then resume exactly where it left off. If you are trying to read a high-speed rotary encoder, a tachometer, or a fast-flowing fluid sensor, polling a pin with digitalRead() in the loop() will result in missed pulses. Hardware interrupts solve this by delegating pulse detection to the MCU's dedicated interrupt controller.

Why Polling Fails and Hardware Interrupts Win

When you poll a pin, your code only checks the voltage state when the execution pointer reaches that specific line. If your loop takes 5 milliseconds to run (perhaps due to I2C display updates or delay() calls), any pulse that rises and falls within that 5ms window is completely invisible to your code.

Hardware interrupts bypass the main loop entirely. The ATmega328P on the Arduino Uno has dedicated external interrupt pins (INT0 and INT1). When a voltage edge (RISING, FALLING, or CHANGE) hits these pins, the MCU finishes its current machine instruction, pushes the program counter to the stack, and jumps to the ISR vector. This takes roughly 5 microseconds, ensuring you never miss a pulse, even if your main loop is busy driving an OLED display.

Project Difficulty Rating: Intermediate
Estimated Build Time: 45 minutes
Target Board Variant: Arduino Uno R3 (ATmega328P) or identical clones (Nano V3).

Parts List

  • MCU: Arduino Uno R3 (ATmega328P)
  • Sensor: A3144 Hall Effect Sensor (Unipolar, open-collector output)
  • Display: 0.96" SSD1306 128x64 I2C OLED (4-pin variant)
  • Passives: 10kΩ pull-up resistor, 0.1µF ceramic bypass capacitor
  • Hardware: Neodymium magnet, solderless breadboard, jumper wires

Interrupt Pin Mapping: Uno, Mega, Nano, and ESP32

Not all pins can trigger hardware interrupts, and the mapping changes drastically depending on the board variant and underlying silicon. The table below maps the dedicated external interrupt pins across the most common maker boards. Always use the digitalPinToInterrupt(pin) macro in your code rather than hardcoding the interrupt number; this ensures your code remains portable across these different architectures.

Board Variant MCU / Silicon INT0 Pin INT1 Pin Other Dedicated Interrupts Total Hardware Interrupts
Uno R3 / Nano V3 ATmega328P D2 D3 None (Pin Change Interrupts available on all) 2
Mega 2560 ATmega2560 D2 D3 D18, D19, D20, D21 6
Nano Every ATmega4809 D2 D3 D6, D7, D10, D11, D12, D13, A0-A5 14+
ESP32 DevKit V1 ESP32-WROOM-32 Any GPIO Any GPIO Configurable (Avoid input-only pins 34-39) 32 (Shared matrix)

Source: Arduino attachInterrupt() Reference and respective MCU datasheets.

Building a High-Speed RPM Tachometer

We will build a non-contact tachometer. As a neodymium magnet passes the A3144 Hall sensor, it pulls the open-collector output to ground. The 10kΩ pull-up resistor brings it back to 5V when the magnet leaves. This creates a clean square wave. The 0.1µF capacitor acts as a low-pass filter to suppress high-frequency EMI from nearby motors.

Pin Mapping Table

ComponentComponent PinArduino Uno R3 PinNotes
A3144 SensorVCC5VA3144 accepts 4.5V - 24V
A3144 SensorGNDGNDCommon ground with Uno
A3144 SensorOUTD2 (INT0)Requires 10kΩ pull-up to 5V
SSD1306 OLEDVCC5V (or 3.3V)Check module silkscreen
SSD1306 OLEDGNDGNDCommon ground
SSD1306 OLEDSCLA5I2C Clock
SSD1306 OLEDSDAA4I2C Data

Assembly Steps

  1. Prep the Sensor: Solder a 10kΩ resistor between the VCC and OUT pins of the A3144. Solder the 0.1µF capacitor between OUT and GND. This keeps your breadboard wiring clean and minimizes parasitic inductance on the signal line.
  2. Wire the I2C Bus: Connect the OLED SDA to A4 and SCL to A5. Bench tip: If your I2C bus acts erratically when the motor spins, add 4.7kΩ pull-up resistors to both SDA and SCL lines.
  3. Connect the Interrupt Line: Route the A3144 OUT pin directly to Digital Pin 2. Do not route this through a breadboard power rail; keep the trace as short as possible to avoid capacitive coupling from the motor's EMF.
  4. Verify Voltages: Before plugging in the USB, use a multimeter in continuity mode to ensure the 5V rail is not shorted to GND through the sensor's open-collector output.

The Code: ISR-Safe RPM Counting

The golden rule of ISRs is speed. Never use delay(), Serial.print(), or I2C transactions (like updating an OLED) inside an ISR. The ISR should only increment a volatile counter. The main loop handles the math and the display updates on a non-blocking timer.

Note: This code targets the Arduino Uno R3 (ATmega328P). If compiling for an ESP32, change the interrupt mode to FALLING and ensure you are not using input-only GPIOs.

// TARGET BOARD: Arduino Uno R3 (ATmega328P)
// REQUIRES LIBRARIES: Adafruit SSD1306, Adafruit GFX, Wire

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// --- PIN DEFINITIONS ---
#define HALL_SENSOR_PIN 2       // Must be an interrupt-capable pin (D2 or D3 on Uno)
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C

// --- GLOBAL VARIABLES ---
// 'volatile' tells the compiler this variable changes outside normal program flow (in the ISR).
// Without it, the compiler may optimize the variable into a register and never read the updated value.
volatile unsigned long pulse_count = 0; 

unsigned long last_millis = 0;
unsigned long last_display_update = 0;
float current_rpm = 0.0;

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- INTERRUPT SERVICE ROUTINE (ISR) ---
// Keep this as short as physically possible. No I2C, no Serial, no delays.
void countPulse() {
  pulse_count++;
}

void setup() {
  Serial.begin(115200);
  
  // Initialize OLED with error handling
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed"));
    for(;;); // Halt execution if display fails to initialize
  }
  
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(2);
  display.setCursor(0, 20);
  display.println("Ready...");
  display.display();

  // Configure pin and attach interrupt
  pinMode(HALL_SENSOR_PIN, INPUT); // External pull-up is used, but INPUT is safe here
  
  // digitalPinToInterrupt() translates the physical pin (2) to the MCU interrupt vector (0)
  attachInterrupt(digitalPinToInterrupt(HALL_SENSOR_PIN), countPulse, FALLING);
  
  last_millis = millis();
}

void loop() {
  unsigned long current_millis = millis();
  
  // Calculate RPM every 500ms to allow for stable readings at low speeds
  if (current_millis - last_millis >= 500) {
    // CRITICAL SECTION: Disable interrupts briefly to read the 32-bit volatile variable.
    // On 8-bit AVR MCUs, reading a 32-bit variable takes multiple clock cycles. 
    // If an ISR fires mid-read, you get a corrupted (torn) value.
    noInterrupts();
    unsigned long safe_count = pulse_count;
    pulse_count = 0; // Reset for next interval
    interrupts();
    
    // Math: (Pulses / 0.5 seconds) * 60 seconds = Pulses per minute
    // Assuming 1 magnet on the shaft. Multiply by 2 if using 2 magnets, etc.
    current_rpm = (safe_count * 2.0) * 60.0; 
    
    last_millis = current_millis;
  }

  // Update display every 250ms (decoupled from the RPM calculation interval)
  if (current_millis - last_display_update >= 250) {
    display.clearDisplay();
    display.setCursor(0, 10);
    display.setTextSize(1);
    display.println("TACHOMETER RPM");
    
    display.setTextSize(3);
    display.setCursor(0, 30);
    display.print(current_rpm, 0); // 0 decimal places
    
    display.display();
    last_display_update = current_millis;
  }
}

Debugging: Bounce, Missed Pulses, and Compiler Errors

When your interrupt-driven circuit fails, the symptoms usually fall into two categories: the counter stays at zero, or the counter registers phantom pulses. Here is how to diagnose the exact failure mode.

The First Three Things to Check When It Fails:
  1. Pull-up Resistor Presence: The A3144 is open-collector. Without a 10kΩ pull-up to 5V, the pin will float, causing thousands of phantom interrupts from ambient EMI.
  2. The volatile Keyword: If your serial monitor prints "0" constantly despite the magnet passing, check your global variable. If it lacks volatile, the AVR compiler caches it in a CPU register and ignores the ISR's updates.
  3. Torn Reads (noInterrupts): If your RPM occasionally spikes to impossible numbers (e.g., 16,777,215), you are reading the 32-bit pulse_count without disabling interrupts first. An 8-bit MCU reads 32-bit variables in four 8-bit chunks; an ISR firing between chunks corrupts the read.

Common Error Strings and Ranked Causes

1. Compiler Error: error: 'digitalPinToInterrupt' was not declared in this scope

  • Cause A: You are using a very outdated Arduino AVR core. Update via the Boards Manager.
  • Cause B: You selected the wrong board in the IDE (e.g., a generic ATtiny core that doesn't support the macro). Ensure "Arduino Uno" is selected.

2. Runtime Panic (ESP32 Only): Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)

  • Cause A: You placed a blocking function like delay(), Wire.requestFrom(), or Serial.print() inside the ISR. The ESP32's watchdog timer assumes the CPU has locked up and reboots the core. Move all I/O to the main loop.
  • Cause B: Switch bounce on a mechanical button. A single button press triggers 50 interrupts in 2 milliseconds, starving the RTOS idle task. Implement a software debounce timer using micros() inside the ISR.

For a deep dive into AVR interrupt vectors and timing, refer to Nick Gammon's authoritative Interrupts Guide.

Extending and Simplifying the Build

How to Simplify:
If you do not have an I2C OLED, strip out the Adafruit libraries and output the RPM purely via Serial.println(). If you are using a mechanical reed switch instead of a Hall sensor, you must add software debouncing. Add a unsigned long last_debounce_time variable and ignore any ISR triggers that occur within 5 milliseconds of the previous trigger.

How to Extend:
To measure the direction of rotation (e.g., for a CNC jog wheel), upgrade to a quadrature rotary encoder. This requires two Hall sensors (or optical slots) offset by 90 degrees. You will attach an interrupt to both D2 and D3. Inside the ISR for Channel A, read the state of Channel B. If Channel B is HIGH, increment the counter; if LOW, decrement it. This turns your simple tachometer into a precise bidirectional position tracker.

Finally, for high-voltage or electrically noisy environments (like measuring an automotive ignition coil), never wire the sensor directly to the Arduino. Use an optocoupler (like the PC817) between the sensor and the interrupt pin to provide galvanic isolation, protecting your MCU's silicon from inductive kickback spikes.