How interrupts work and how to implement them with attachInterrupt()
Why interrupts are essential in embedded projects
In a microcontroller, everything revolves around timing and responsiveness. An interrupt is a signal that breaks out of the normal program flow, allowing you to immediately respond to an external or internal event, such as a button press, an edge on a pin, a timer overflow, or the arrival of a byte on a peripheral. Without interrupts, we would be forced to continuously poll the conditions of interest, risking missing fast events or wasting precious CPU time.
With interrupts you can implement responsive interfaces, count high-frequency pulses, perform precise timing measurements, and synchronize actions with external sources. On Arduino, the simplest entry point into this world is the attachInterrupt() function, which links an event to a service routine (ISR) that will be executed automatically when the condition occurs.
What happens during an interrupt
When an interrupt fires, the microcontroller suspends the code in execution, saves the necessary state, and jumps to the ISR (Interrupt Service Routine), the function you defined to handle that event. The ISR must be extremely short: do only the bare minimum (for example, set a flag or increment a counter) and then return control to the main program. During the ISR, other interrupts may be temporarily disabled depending on the architecture, so every microsecond spent there counts.
attachInterrupt(): signature, parameters, and modes
On Arduino, the modern and recommended form is:
attachInterrupt(digitalPinToInterrupt(pin), isr, mode);
Here “pin” is the number of the digital pin you want to listen to, “isr” is the name of the ISR function (without parentheses), and “mode” defines when the interrupt fires. The helper digitalPinToInterrupt(pin) translates the pin number into the correct interrupt line for the board in use. If it returns -1, that pin does not support external interrupts.
Trigger modes
The most common modes are:
RISING: fires on the rising edge (from LOW to HIGH). Useful for counting pulses.
FALLING: fires on the falling edge (from HIGH to LOW). Often used with buttons wired to GND and internal pull-up enabled.
CHANGE: fires on any state change (both rising and falling). Handy but more sensitive to bounce.
LOW: fires as long as the level remains LOW (level, not edge); it can generate many calls as long as the condition persists.
HIGH: supported only on some architectures; like LOW but for a high level.
Which pins support interrupts
It depends on the board. On Arduino Uno/Nano based on ATmega328P, the typical pins are 2 and 3. On the Mega there are more lines (2, 3, 18, 19, 20, 21). On many SAMD (MKR, Zero) and ESP32 boards, almost all GPIO support interrupts. For portability, always rely on digitalPinToInterrupt() and consult your board’s documentation.
Golden rules for writing safe ISRs
Keep the ISR as short as possible: set a flag or increment a counter and that’s it. Avoid Serial.print(), delay(), String, malloc/new, long computations, or slow accesses.
Share data between ISR and loop() with variables declared volatile, so the compiler won’t optimize away reads/writes that can change asynchronously.
To read/write multi-byte variables atomically (for example, unsigned long on 8-bit MCUs), briefly disable interrupts with noInterrupts()/interrupts() around the access.
Debounce: physical buttons bounce. Don’t try to “clean up” bounce with complicated logic inside the ISR. Prefer simple flags and filtering logic in the loop, or a small hardware filter (RC or a quality switch).
Architecture specifics: on ESP32/ESP8266 the ISR may require the IRAM_ATTR/ICACHE_RAM_ATTR attribute. Check your board core’s guide.
Basic example: button with immediate reaction and debounce in the loop
Scenario: a button between pin 2 and GND, with internal pull-up enabled. We use FALLING to catch the press. The ISR sets a flag; in the loop we print a message only once per press, including simple software debounce.
const byte BUTTON_PIN = 2;
volatile bool buttonEvent = false;
void ISR_button() {
buttonEvent = true;
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), ISR_button, FALLING);
}
void loop() {
if (buttonEvent) {
noInterrupts();
buttonEvent = false; // consume the event
interrupts();
static unsigned long lastHandled = 0;
unsigned long now = millis();
if (now - lastHandled >= 50) { // 50 ms debounce
lastHandled = now;
Serial.println("Button pressed!");
}
}
}
This pattern is robust: the ISR is instantaneous, the debounce lives in the loop, and consuming the event is atomic. If you want to temporarily suspend the interrupt, you can use detachInterrupt(digitalPinToInterrupt(BUTTON_PIN)) and then reattach it when needed.
Advanced example: counting high-frequency pulses
Many sensors (toothed wheels, flow sensors, encoders) generate fast edges. Here the ISR increments a counter and the loop, once per second, copies the counter atomically and clears it to compute the frequency.
const byte SENSOR_PIN = 3;
volatile unsigned long pulseCount = 0;
void ISR_pulse() {
pulseCount++; // O(1) and very fast operation
}
void setup() {
Serial.begin(115200);
pinMode(SENSOR_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SENSOR_PIN), ISR_pulse, RISING);
}
void loop() {
static unsigned long lastPrint = 0;
unsigned long now = millis();
if (now - lastPrint >= 1000) {
lastPrint = now;
noInterrupts();
unsigned long count = pulseCount; // atomic copy
pulseCount = 0; // reset for next window
interrupts();
Serial.print("Pulses in the last 1000 ms: ");
Serial.println(count);
}
}
Since pulseCount is multi-byte on 8-bit MCUs, the access is wrapped between noInterrupts()/interrupts() to avoid partial reads while the ISR modifies it. This pattern is essential when measuring fast events.
When to use CHANGE, RISING, or FALLING
If the goal is to count every transition, CHANGE is tempting but more sensitive to noise and bounce. For buttons and noisy signals, use RISING or FALLING and filter in the time domain. For well-shaped signals (open-collector outputs with appropriate pull-up, clean push-pull), RISING often does the job. Also consider hardware filtering (small RC or Schmitt trigger) when the signal quality is not ideal.
Debug and common issues
Bounces and double triggers: if you see multiple events for a single action, it’s probably bounce. Apply debounce in the loop or insert an RC filter (for example, 10 kΩ + 100 nF) and use RISING/FALLING.
ISR too long: if you put Serial.print() in the ISR, you can block the system or miss events. Move all I/O out of the ISR.
Non-volatile variables: if you share data between ISR and loop without volatile, you’ll notice intermittent behavior (stale values, aggressive optimizations). Add volatile and protect access with atomic sections if necessary.
Incompatible pins: if attachInterrupt has no effect, check with digitalPinToInterrupt() and your board’s map. On Uno, only 2 and 3 are typically valid for external interrupts.
Details for different boards
Arduino Uno/Nano: external interrupts typically on D2 and D3. Always use digitalPinToInterrupt() to avoid mistakes.
Arduino Mega: more interrupt lines (2, 3, 18, 19, 20, 21) allow more sensors in parallel.
SAMD (Zero, MKR): generally support interrupts on many pins; great for projects with many event sources.
ESP32/ESP8266: most GPIO support interrupts. On ESP32, critical ISRs may require IRAM_ATTR and, if you’re working with FreeRTOS, consider mechanisms such as queues or semaphores to pass events from the ISR context to the main task. There is also attachInterruptArg() to pass a context pointer to the ISR.
Pin change interrupt: some MCUs also offer “pin change interrupts” on any pin via dedicated registers or specific libraries; they are more flexible but require low-level code.
Disabling and re-enabling interrupts in a targeted way
For very short critical sections, use noInterrupts() and interrupts(). Keep the section as short as possible. To suspend a single source, prefer detachInterrupt() on the relevant line. It’s a finer practice and limits side effects, especially if the system handles multiple events at the same time.
Best design practices
Design interrupts with a clear pipeline: minimalist ISR that signals, loop or task that processes. Define maximum acceptable times and ensure the ISR does not exceed them. Consider using circular buffers for very frequent events and, when working at high frequencies, measure latency with an oscilloscope or with debug pins that you toggle around the ISR (always with caution, so as not to burden the handler too much).
Conclusion
Interrupts are the key to responsive and reliable applications on microcontrollers. With attachInterrupt() you can implement an efficient mechanism in just a few lines of code, provided you respect the golden rules: ultra-short ISRs, volatile variables, atomic accesses, and noise handling. Whether you’re measuring pulses, reading encoders, or building smooth user interfaces, a well-designed interrupt strategy will make the difference.
Next steps
Put yourself to the test right away: connect a button or a pulse sensor to your board, implement the proposed examples, and customize them. If you like, share the results or the challenges you encounter: I can help you optimize your ISR, choose the best mode, and adapt the code to your specific board.

Comments