Responding Immediately with Interrupts

Draft. This tutorial has not been checked against the physical product yet. Confirm wiring, pin assignments and voltage limits against the product documentation before building it.
DifficultyAdvanced
CategoryFoundations
Supported boardsArduino Uno R3 and compatible ATmega328P boards
Time needed55 minutes
PrerequisitesTiming Without delay()
Tested boardNot yet verified
Tested onNot yet verified
Arduino IDE versionNot yet verified
LibraryNot 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.

Two timelines showing a short pulse missed between two polling checks, and the same pulse caught every time by an interrupt handler
A pulse that starts and ends between two checks is lost for ever. The hardware does not have to be looking.

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() and delay(). Touch it and all three break.
  • Timer1 drives PWM on pins 9 and 10, and the Servo library 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

  1. A button on an interrupt counts three presses for one. Why?
  2. A sketch works, you add an unrelated line, and a flag set by an ISR stops being noticed. What is missing?
  3. Why can Serial.print() not go in a handler?
  4. 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 by millis(), Servo and tone().

No third-party tutorial text, diagrams, photographs or code has been reproduced here.

Shop
Sidebar
0 Wishlist
0 Cart