arduino intermediate 30 min

Arduino: hardware interrupts, the right way to react to events

Wire a button to an interrupt pin and react to it instantly. The pattern that makes pin-change detection feel like magic, and the rules you cannot break.

Code available for: Arduino CESP32 Arduino
Published Aug 26, 2026

I wired a button to pin 7 on an Arduino once and wrote a sketch that checked it in loop(). The button worked, mostly. Then I added a few delay(1000) calls for a status blink and the button started dropping presses. By the time I was blinking an LED, reading a sensor, and talking over serial, the button was missing roughly half the presses.

The fix is interrupts. The Arduino has dedicated hardware that watches a pin for you while your code does other things. When the pin changes, your code gets interrupted (which is the literal meaning), runs a small function you wrote, and picks up where it left off. No polling delay, no missed button press.

This is the tutorial I wish I had read before trying to debounce my way out of the problem.

What you need

  • Any Arduino board (Uno, Nano, Mega). The MKR and ESP32 also work, but with a slightly different API.
  • A momentary pushbutton
  • A 10k ohm resistor (pull-down)
  • Jumper wires
  • USB cable

The Uno has interrupt-capable pins on digital 2 and 3 only. If you want more, use the Mega (six interrupt pins) or the ESP32 (every pin is interrupt-capable).

Wiring

Arduino 5V --[ button ]-- Arduino D2
                          |
                       (button also has a leg on D2)

Arduino D2 --[ 10k ]-- GND

The resistor pulls D2 to ground when the button is open, so the pin reads LOW. When you press the button, D2 connects to 5V and reads HIGH. That is a pull-down resistor. The alternative is to wire the button to GND and use the Arduino’s internal pull-up (more on that in a minute).

   5V o----[ button ]----o----o D2
                          |
                       [ 10k ]
                          |
                         GND

The code

const byte LED_PIN = 13;
const byte BUTTON_PIN = 2;
volatile bool buttonPressed = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onPress, RISING);
}

void loop() {
  if (buttonPressed) {
    buttonPressed = false;
    digitalWrite(LED_PIN, HIGH);
    delay(50);
    digitalWrite(LED_PIN, LOW);
  }
  // do other stuff here; the button still gets attention
}

void onPress() {
  buttonPressed = true;
}

The whole thing comes down to one line: attachInterrupt(...). The first argument is the interrupt number, but you almost never want the bare number. digitalPinToInterrupt(pin) converts a pin number to the right interrupt number for whichever board you are on. The second argument is the function to call when the event happens. The third is the trigger: RISING (LOW to HIGH), FALLING (HIGH to LOW), or CHANGE (either direction).

What you learned

The volatile keyword is the most important line in this sketch and it is the one that trips up beginners. volatile tells the compiler “this variable can change outside the normal flow of your code, so do not optimize reads of it into a register.” Without volatile, the loop might cache the value and never see the change. With it, every read goes to RAM.

The second thing: the function you pass to attachInterrupt is called an ISR (Interrupt Service Routine). ISRs have rules.

  • No Serial.print. The serial hardware shares an interrupt with some ISRs, and you can deadlock. The fix is to set a flag in the ISR and print in loop().
  • No delay(). delay() itself uses interrupts to count milliseconds. Calling it from an ISR that preempts the timer interrupt will hang the chip.
  • No malloc. The heap is not safe inside an ISR. Same reason.
  • Keep it short. The shorter the ISR, the less time your main code spends paused. Milliseconds are too long. Microseconds are the budget.

A common pattern: the ISR sets a flag, the main loop checks the flag and does the work. That is what this sketch does.

When something breaks

  • The button triggers twice on one press. This is bounce. Real buttons have metal contacts that bounce for a few milliseconds before settling. Fix it with a 50-millisecond ignore window in your loop, or by tracking the last time you saw a press.
  • Nothing happens at all. You probably have the wrong pin. The Uno can only interrupt on digital 2 and 3. If you wired to pin 7 by accident, the attachInterrupt call still compiles but does nothing.
  • The LED stays on after the press. You forgot to clear the flag inside the if. The ISR set it, the loop saw it, the loop turned the LED on, but the flag is still set so next time through the loop the LED turns on again. Clear it.
  • The compiler complains about digitalPinToInterrupt. Some older cores do not have that macro. Use the raw interrupt number (0 for pin 2, 1 for pin 3 on the Uno) instead.

The “interrupt every microsecond” gotcha

If you wire a button directly without debouncing, the ISR can fire multiple times for one press. Now imagine the button is a 1 kHz square wave from a sensor. The ISR fires 1000 times per second, every second, forever. Your main loop never gets a chance to run.

Two fixes. First, debounce in software (a 50 ms ignore window in the loop, or a 50 ms ignore window in the ISR with millis()). Second, if you genuinely have a high-frequency signal, use a hardware timer interrupt to count pulses, not a pin-change interrupt.

When NOT to use interrupts

For a slow sensor you read once a second, polling in loop() is simpler and easier to debug. Interrupts add three things you have to get right (volatile, atomicity, debouncing) for one thing you get back (no missed events between polls). If you do not need instant response, do not pay the cost.

The threshold I use: if the event happens more often than my loop() runs, and the event matters, I use an interrupt. Otherwise I poll.

The 2-pin vs any-pin trade-off

The ATmega328P (Uno, Nano) has exactly two external interrupt pins (2 and 3) and 20 GPIO pins total. The ATmega2560 (Mega) has six. The ESP32 has an interrupt on every pin. The SAMD21 (MKR, Nano Every) also has interrupts on every pin.

If you are on an Uno and need more than two interrupt sources, you have three options: use Pin Change Interrupts (PCINT, more code), upgrade to a Mega, or multiplex the inputs and read them with a shift register.

ISR priority on different boards

The Uno has a fixed interrupt priority. Some interrupts can preempt others (e.g. the timer interrupt can preempt a pin change). On the ESP32 you can set priorities with attachInterrupt with a priority argument. On the Uno you cannot. If two interrupts fire at the same time on a Uno, the one with the lower vector number wins. There is no “interrupt priority” knob for you to turn.

What to build next

  • A button that toggles the LED on and off (track the state in a variable, flip it in the loop).
  • A button that controls motor speed through an H-bridge.
  • A rotary encoder, which is two interrupts plus a state machine (covered in the encoder tutorial).
  • A wake-up from sleep: attachInterrupt works while the chip is asleep, and a button press can wake it.

The encoder tutorial is the natural next project. The H-bridge plus encoder is a closed-loop motor, which is the start of a real robot drive train.