Responding Immediately with Interrupts
| Difficulty | Advanced |
|---|---|
| Category | Foundations |
| Supported boards | Arduino Uno R3 and compatible ATmega328P boards |
| Time needed | 55 minutes |
| Prerequisites | Timing Without delay() |
| Tested board | Not yet verified |
| Tested on | Not yet verified |
| Arduino IDE version | Not yet verified |
| Library | Not yet verified |
What this covers
Everything so far has worked by checking: loop() runs round, reads a pin, and decides. That is fine while the loop is quick, and it starts to fail when the loop has real work in it.
An interrupt is the other way round. You tell the chip “when this pin changes, stop whatever you are doing and run this function”. The hardware watches the pin, not your code, so the response happens in microseconds regardless of what the sketch was in the middle of.
It is the right tool for a narrow set of jobs and the wrong one for most. Knowing which is which is most of this page.
Before you begin
Nothing new to wire. A push button on pin 2 is enough, and Read a Push Button already built it.
When you need one
- A pulse too short to catch by polling. A rotary encoder turned quickly, or a flow meter’s pulses. Miss one and the count is permanently wrong.
- Something that must respond immediately, such as an emergency stop.
- Waking a sleeping board, which is how a battery project lasts months instead of days.
When you do not
- A button a person presses. Human fingers are slow and
millis()polling catches every press. Using an interrupt here adds bounce problems and buys nothing. - Reading a sensor regularly. That is what the
millis()pattern is for. - Anything that takes time to handle. The handler must be short, and most work is not.
The honest summary: if Timing Without delay() solves it, use that. Interrupts are for things polling genuinely cannot catch.
The two interrupt pins
On an Uno only pin 2 and pin 3 support attachInterrupt(). That is the whole list.
attachInterrupt(digitalPinToInterrupt(2), myHandler, FALLING);
digitalPinToInterrupt() converts the pin number into the interrupt number the hardware uses. Writing attachInterrupt(0, ...) happens to mean pin 2 on an Uno and something else on a Mega, so use the macro and the sketch stays portable.
The modes are RISING, FALLING, CHANGE and LOW. FALLING is right for a button to ground with INPUT_PULLUP.
A Mega has six interrupt pins and an ESP32 allows any pin, which is one more reason to check the board before designing around two.
The rules for a handler
An interrupt handler, or ISR, runs with the rest of the sketch frozen. That imposes real constraints, and breaking them produces faults that are very hard to find because they are intermittent.
Keep it short. Set a flag, increment a counter, record micros(). Nothing else. While it runs, nothing else on the board does.
Do not use delay(). It needs interrupts to work and they are off. It will hang.
Do not use Serial.print(). It needs interrupts too. It sometimes appears to work, which is worse than failing.
millis() does not advance. You can read it, and it will not move while the handler runs. micros() is usable for short measurements.
Variables shared with loop() must be volatile. This is the rule most often broken, so it gets its own section.
Why volatile, and what goes wrong without it
The compiler optimises. If it sees a loop reading a variable that nothing in that loop changes, it is entitled to read it once and reuse the value.
That is correct reasoning and wrong here, because the ISR does change it, invisibly.
volatile bool pressed = false; // volatile: the ISR changes this
void handler() {
pressed = true;
}
void loop() {
if (pressed) { // without volatile, this may be read once, forever
pressed = false;
Serial.println("pressed");
}
}
The symptom is a sketch that works at one optimisation level and not another, or works until you add an unrelated line. Mark every variable shared with an ISR volatile and the problem does not arise.
Reading a multi-byte variable safely
volatile is not enough on its own for anything larger than a byte.
An ATmega328P is 8-bit, so reading a long takes four separate operations. If an interrupt fires between the second and the third, you get half the old value and half the new one, which can be a number that never existed.
volatile unsigned long pulseCount = 0;
unsigned long readCount() {
noInterrupts(); // briefly block, so the read is not split
unsigned long value = pulseCount;
interrupts();
return value;
}
Keep the blocked section to the copy itself. Anything longer starts costing you the interrupts you went to the trouble of setting up.
Bounce, which interrupts make worse
A mechanical button bounces: one press produces several electrical transitions. Polling with a debounce delay hides this; an interrupt catches every one of them.
So an interrupt-driven button counts one press as three or four. If you need a button on an interrupt, debounce in the handler by time:
volatile unsigned long lastInterrupt = 0;
volatile int count = 0;
void handler() {
unsigned long now = micros();
if (now - lastInterrupt > 200000) { // ignore anything within 200 ms
count++;
lastInterrupt = now;
}
}
micros() works in a handler, and comparing unsigned values this way survives the rollover for the same reason the millis() pattern does.
A worked example: counting fast pulses
This is a job polling cannot do reliably.
// Siratek: counting pulses that are too short and too frequent to poll for.
const int pulsePin = 2;
volatile unsigned long pulseCount = 0;
void onPulse() {
pulseCount++; // short, and nothing else
}
unsigned long readCount() {
noInterrupts();
unsigned long value = pulseCount;
interrupts();
return value;
}
void setup() {
pinMode(pulsePin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(pulsePin), onPulse, FALLING);
Serial.begin(9600);
Serial.println("Siratek: counting pulses on pin 2.");
}
void loop() {
// The loop can be as slow as it likes. No pulse is missed.
delay(1000);
Serial.print("pulses so far: ");
Serial.println(readCount());
}
The delay(1000) is deliberate. It would make a polling version useless, and here it costs nothing: the hardware is counting whether the sketch is paying attention or not. That is the whole argument for interrupts in one sketch.
Timer interrupts, and a warning
The other kind fires on a timer rather than a pin, which is how you run something at an exact interval regardless of the loop.
It is genuinely useful, for refreshing a multiplexed display or sampling at a fixed rate. It is also where beginners break things, because the Uno’s three timers are already in use:
- Timer0 runs
millis(),micros()anddelay(). Touch it and all three break. - Timer1 drives PWM on pins 9 and 10, and the
Servolibrary takes it. - Timer2 drives PWM on pins 3 and 11, and
tone()and IRremote take it.
So there is no free timer on an Uno, only a choice about what to give up. Before reconfiguring one, know which pins and libraries stop working.
The MsTimer2 and TimerOne libraries wrap this up safely and are the sensible starting point. Writing timer registers directly is worth learning and is not the place to begin.
If you want a fixed interval and do not need microsecond accuracy, the millis() pattern is simpler, portable, and breaks nothing.
Check yourself
- A button on an interrupt counts three presses for one. Why?
- A sketch works, you add an unrelated line, and a flag set by an ISR stops being noticed. What is missing?
- Why can
Serial.print()not go in a handler? - You reconfigure Timer1 and the servo stops working. Why?
Answers in order: contact bounce, which an interrupt catches every transition of; debounce by time in the handler. volatile on the shared flag. Serial transmission depends on interrupts, which are disabled inside a handler. The Servo library uses Timer1, and reconfiguring it takes that away.
Troubleshooting
| Symptom | Likely cause | Check or fix |
|---|---|---|
| Handler never runs | Not on pin 2 or 3 | Only those two on an Uno |
| Flag set but never noticed | Missing volatile |
Mark every shared variable |
| Board hangs when it triggers | delay() in the handler |
Remove it |
| Serial output garbled or missing | Serial.print() in the handler |
Set a flag; print in loop() |
| Counter occasionally absurd | Multi-byte read torn | Copy it inside noInterrupts() |
| One press counted several times | Bounce | Debounce by time in the handler |
millis() frozen |
Expected inside a handler | Use micros() for short spans |
delay() and millis() both broken |
Timer0 reconfigured | Leave Timer0 alone |
| Servo stopped after adding a timer | Timer1 taken | Use a different timer, or the millis() pattern |
| Works on an Uno, not a Mega | Interrupt pin numbers differ | Use digitalPinToInterrupt() |
Next steps
- Easier first: Timing Without delay(), which solves most of what people reach for interrupts to solve.
- Related: Making a Button Behave, for why bounce matters here.
- Next: a rotary encoder or a flow meter, which are the classic jobs for this.
Sources and attribution
All text and code in this tutorial are original Siratek work.
- ATmega328P datasheet for the external interrupt pins, the timer assignments and the 8-bit register access that makes torn multi-byte reads possible.
- Arduino core documentation for
attachInterrupt(),digitalPinToInterrupt()and the timers used bymillis(),Servoandtone().
No third-party tutorial text, diagrams, photographs or code has been reproduced here.

