Understanding Arduino interrupts is the key to writing truly responsive embedded code. Without interrupts, your Arduino must constantly poll for events in the main loop — meaning it can miss brief signals, introduce latency, or waste CPU cycles. This arduino interrupts tutorial covers everything: which pins support external interrupts, how to use pin-change interrupts, how timer interrupts work, and best practices for writing safe interrupt service routines that keep your projects reliable.
Table of Contents
- What Are Interrupts and Why Do They Matter?
- External Interrupts: INT0 and INT1
- Pin-Change Interrupts (PCINT)
- Timer Interrupts
- ISR Best Practices
- Common Pitfalls and Debugging
- Frequently Asked Questions
What Are Interrupts and Why Do They Matter?
An interrupt is a hardware signal that tells the CPU to stop whatever it is doing, save its current state, and execute a special function called an Interrupt Service Routine (ISR). When the ISR finishes, the CPU resumes normal execution exactly where it left off.
Consider a button that needs to be detected instantly. If you poll the button in loop(), you might miss a press that lasts only 50 ms while the loop is busy with other tasks. An interrupt fires within microseconds of the button press, regardless of what the main loop is doing.
Interrupts are essential for:
- Detecting fast digital pulses (rotary encoders, IR receivers)
- Implementing precise timing without polling
- Receiving UART data without a buffer overflow
- Waking the MCU from sleep on an external event
- Measuring frequency or pulse width with high accuracy
External Interrupts: INT0 and INT1
The ATmega328P (used in the Arduino Uno and Nano) has two dedicated hardware external interrupt pins:
- INT0 → Digital Pin 2 (D2)
- INT1 → Digital Pin 3 (D3)
The Arduino Mega has more: INT0–INT5 on pins 2, 3, 21, 20, 19, 18 respectively.
Trigger Modes
External interrupts can be triggered on four conditions:
- LOW — fires continuously while pin is LOW (use carefully — can cause ISR to fire in a loop)
- CHANGE — fires on any transition (LOW→HIGH or HIGH→LOW)
- FALLING — fires when pin goes HIGH→LOW
- RISING — fires when pin goes LOW→HIGH
attachInterrupt() Syntax
void setup() {
pinMode(2, INPUT_PULLUP); // Enable internal pull-up for button
attachInterrupt(
digitalPinToInterrupt(2), // Convert pin number to interrupt number
buttonISR, // ISR function name
FALLING // Trigger on falling edge (button press)
);
}
void buttonISR() {
// Keep this short!
buttonPressed = true;
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
// Handle button press here
}
}
Always use digitalPinToInterrupt(pin) rather than hardcoding the interrupt number. On the Uno, INT0 = interrupt 0 and INT1 = interrupt 1, but on the Mega the mapping is different. Using digitalPinToInterrupt() makes your code portable.
Volatile Variables
Variables shared between an ISR and the main code must be declared volatile. This tells the compiler not to cache the variable in a register — the main loop must always read it from RAM where the ISR may have modified it:
volatile bool buttonPressed = false;
volatile unsigned long pulseCount = 0;
Debouncing in Interrupt Context
Mechanical buttons bounce — a single press generates multiple transitions within 5–20 ms. A simple software debounce inside the ISR:
volatile unsigned long lastInterruptTime = 0;
void buttonISR() {
unsigned long now = millis();
if (now - lastInterruptTime > 50) { // 50ms debounce
buttonPressed = true;
lastInterruptTime = now;
}
}
Note: millis() works inside ISRs on AVR Arduino boards since Timer 0 ISR updates the millis counter independently.
Pin-Change Interrupts (PCINT)
External interrupts on D2 and D3 are hardware-dedicated and highly capable, but you are limited to just two pins. When you need interrupt capability on more pins, use Pin-Change Interrupts (PCINT).
On the ATmega328P, ALL digital pins support pin-change interrupts. They are grouped into three banks:
- PCINT0 (PCICR bit 0) — Pins D8–D13
- PCINT1 (PCICR bit 1) — Pins A0–A5
- PCINT2 (PCICR bit 2) — Pins D0–D7
The limitation: each bank shares a single ISR vector. If you have three buttons on D8, D9, and D10, they all call the same ISR — you must manually determine which pin changed by comparing to a saved state.
Direct Register Approach
volatile uint8_t lastPIND;
void setup() {
PCICR |= (1 << PCIE2); // Enable PCINT2 (D0–D7)
PCMSK2 |= (1 << PCINT20); // Enable interrupt on D4
PCMSK2 |= (1 << PCINT21); // Enable interrupt on D5
lastPIND = PIND;
}
ISR(PCINT2_vect) {
uint8_t changed = PIND ^ lastPIND;
if (changed & (1 << 4)) {
// D4 changed
}
if (changed & (1 << 5)) {
// D5 changed
}
lastPIND = PIND;
}
EnableInterrupt Library
For a friendlier interface, the EnableInterrupt library extends Arduino’s attachInterrupt() syntax to work on all pins:
#include <EnableInterrupt.h>
enableInterrupt(4, pin4ISR, CHANGE);
enableInterrupt(5, pin5ISR, FALLING);
The library handles the PCINT bank management automatically.
Timer Interrupts
Timer interrupts fire based on the internal timer counters, not external pin events. They are perfect for generating precise periodic callbacks without blocking the main loop.
The ATmega328P has three timers:
- Timer 0 — 8-bit, used by Arduino’s
millis(),micros(), and PWM on D5/D6. Avoid modifying. - Timer 1 — 16-bit, used by PWM on D9/D10. Most flexible for custom timer interrupts.
- Timer 2 — 8-bit, used by PWM on D11/D3. Can be used for precise intervals.
Timer1 Compare Match Interrupt
The following example fires an ISR every 1 ms using Timer 1 in CTC (Clear Timer on Compare) mode:
#include <avr/interrupt.h>
void setup() {
cli(); // Disable interrupts during setup
// Timer 1 CTC mode (WGM12 = 1)
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
// Compare match at 1 ms: 16000000 / (prescaler * freq) - 1
// Prescaler 64, target 1000 Hz: 16000000 / (64 * 1000) - 1 = 249
OCR1A = 249;
TCCR1B |= (1 << WGM12); // CTC mode
TCCR1B |= (1 << CS11) | (1 << CS10); // Prescaler 64
TIMSK1 |= (1 << OCIE1A); // Enable compare match A interrupt
sei(); // Re-enable interrupts
}
ISR(TIMER1_COMPA_vect) {
// This fires every 1 ms — ideal for scheduler ticks, PID loops, etc.
millisCounter++;
}
MsTimer2 and TimerOne Libraries
For easier timer interrupt setup, use:
- TimerOne library — Controls Timer 1 with simple
Timer1.initialize()andTimer1.attachInterrupt()API - MsTimer2 library — Controls Timer 2 for millisecond-precision callbacks
#include <TimerOne.h>
void setup() {
Timer1.initialize(1000); // 1000 microseconds = 1 ms
Timer1.attachInterrupt(timerISR);
}
void timerISR() {
// Called every 1 ms
}
ISR Best Practices
ISRs run in a privileged context with all other interrupts disabled (by default on AVR). Poor ISR design causes missed interrupts, timing glitches, and hard-to-debug crashes.
Keep ISRs Short
The ISR should do the absolute minimum: set a flag, store a value, or increment a counter. Complex operations, Serial.print(), String allocation, and function calls that themselves use interrupts must never appear in an ISR.
No Blocking Calls
Never call delay(), Serial.print(), or any blocking function inside an ISR. These functions either use interrupts internally (and will hang since global interrupts are disabled in the ISR) or waste time that delays normal interrupt processing.
Atomic Access for Multi-Byte Variables
Reading a 16-bit or 32-bit variable that an ISR modifies can cause a race condition. On an 8-bit processor, reading a 32-bit integer takes multiple cycles — the ISR could fire halfway through and corrupt the read. Use atomic read:
#include <util/atomic.h>
unsigned long safeCount;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
safeCount = isrCounter; // Read 32-bit variable atomically
}
Re-entrant Safety
AVR Arduino ISRs are non-re-entrant by default (global interrupt flag cleared on entry). Do not enable global interrupts inside an ISR (sei()) unless you have a specific reason and understand the consequences fully.
Common Pitfalls and Debugging
Forgetting volatile
The most common bug. If a variable shared with an ISR is not volatile, the compiler may optimise it into a register and the main loop will never see the ISR’s updates. Always declare shared variables volatile.
Interrupt Flooding
If your interrupt fires too frequently (e.g., using LOW trigger mode or reading a bouncing button), the ISR can consume all CPU time and starve the main loop. The program appears frozen. Add debounce logic or use FALLING/RISING instead of CHANGE/LOW.
Conflicts with Libraries
Many Arduino libraries use interrupts internally: Servo, SoftwareSerial, Wire (I2C), and SD all use timer or pin-change interrupts. Using the same timer for a custom interrupt and a library that also uses it will cause conflicts. TimerOne uses Timer 1 — so will conflict with Servo (which also uses Timer 1 on Uno).
Using Serial Inside ISR
Serial communication relies on its own interrupt (UDRE interrupt for TX, RXCI for RX). Calling Serial.print() inside an ISR will hang because the Serial TX interrupt cannot fire while the global interrupt flag is cleared in your ISR.
Frequently Asked Questions
How many interrupt pins does the Arduino Uno have?
The Arduino Uno has 2 dedicated external interrupt pins: D2 (INT0) and D3 (INT1). However, using pin-change interrupts, ALL digital and analog pins can trigger an interrupt — but they share ISR vectors per bank and require more manual coding to identify which pin triggered.
Can I use interrupts and delay() together?
Yes, but carefully. delay() in the main loop() will not block interrupts — external interrupts and timer interrupts will still fire during a delay(). The problem is the reverse: calling delay() inside an ISR will hang the program because the Timer 0 interrupt (which increments millis) cannot fire while global interrupts are disabled inside your ISR.
What is the difference between attachInterrupt() and direct register manipulation?
attachInterrupt() is the high-level Arduino API — easy to use, portable across boards. Direct register manipulation (writing to EIMSK, EICRA, etc.) gives you more control, slightly less overhead, and access to features not exposed through the API. For most projects, attachInterrupt() is the right choice.
My interrupt fires randomly even without any input. Why?
A floating input pin can pick up electromagnetic noise and trigger spurious interrupts. Always connect an appropriate pull-up or pull-down resistor (or enable the internal pull-up with pinMode(pin, INPUT_PULLUP)) on interrupt pins. Also add a 100 nF capacitor from the pin to GND for additional noise filtering.
Can I have an interrupt inside an interrupt?
By default, no — the AVR automatically clears the global interrupt enable flag (I-bit in SREG) when entering an ISR. You can re-enable interrupts inside an ISR with sei() to allow nested interrupts, but this requires careful design to avoid stack overflow and re-entrant bugs. It is almost never necessary in typical Arduino projects.
Mastering interrupts transforms your Arduino projects from simple polling loops into truly responsive, professional-grade embedded systems. External interrupts give you instant reaction to hardware events; timer interrupts enable precise scheduling without blocking delays. With the best practices outlined here — short ISRs, volatile variables, atomic access, and careful library conflict avoidance — you will build reliable interrupt-driven applications that perform exactly as designed.
Add comment