Unit E - College Practice

29. Interrupts on the Uno

Use Uno interrupts and sleep: external, pin-change, timer, USART, ADC, comparator, watchdog, and power-down wake.

Estimated time 3-4 hours

Learning outcomes

  • Explain why an ISR must stay short and use volatile shared data
  • Use attachInterrupt on D2/D3 with the correct trigger mode
  • Enable a pin-change interrupt on another digital pin
  • Set up a Timer1 compare-match interrupt for a steady tick
  • Recognise USART, ADC, comparator and watchdog interrupt templates
  • Put the Uno into power-down sleep and wake it with an interrupt without restarting setup()

Parts and preparation

Uno, push-button, LED with 330 ohm resistor and a passive buzzer. Serial Monitor for the USART example.

Before power: inspect wiring, confirm supply voltage and ensure all connected circuits share GND.

Interrupts on the Uno instructional connection diagram
Open the diagram for a larger view. Trace every connection before building.

What an interrupt does

An interrupt pauses the main program when a hardware event happens, runs an ISR (interrupt service routine), then returns. Use interrupts for rare or time-critical edges: buttons, encoders, UART bytes, timer ticks.

On the ATmega328P Uno you will meet several interrupt families. The worked sketch at the bottom uses an external interrupt; the sections below give a short template for each family.

ISR rules (all interrupt types)

Keep every ISR extremely short: set a flag or bump a count, then return. Do not call delay, Serial, floating-point maths or long library routines inside an ISR.

Any variable written in an ISR and read in loop must be volatile. For multi-byte values that must stay consistent, briefly disable interrupts while you copy them in loop:

volatile unsigned int eventCount = 0;

void loop() {
  noInterrupts();
  unsigned int countCopy = eventCount;
  interrupts();
}

External interrupt (INT0 / INT1)

The classic Uno has two dedicated external interrupt pins: INT0 on D2 and INT1 on D3. Use attachInterrupt for these. Always map the pin with digitalPinToInterrupt so the same sketch stays clearer on other boards.

Template: INPUT_PULLUP button to GND on D2, FALLING edge sets a flag. Finish the work in loop.

const byte buttonPin = 2;

volatile bool eventPending = false;

void onButtonEdge() {
  eventPending = true;
}

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  Serial.begin(9600);
  attachInterrupt(digitalPinToInterrupt(buttonPin), onButtonEdge, FALLING);
}

void loop() {
  if (eventPending) {
    eventPending = false;
    Serial.println("Edge");
  }
}

External trigger modes

The last argument to attachInterrupt selects when the ISR runs. With INPUT_PULLUP to GND, FALLING is the usual button press. Swap only the mode constant in the template above.

ModeFires whenTypical use
LOWPin stays LOWLevel detect (can fire repeatedly while held)
CHANGEAny edge HIGH/LOWToggle / encoder style edges
RISINGLOW to HIGHActive-high pulse start
FALLINGHIGH to LOWINPUT_PULLUP button to GND
attachInterrupt(digitalPinToInterrupt(buttonPin), onButtonEdge, CHANGE);

Pin-change interrupt

Pin-change interrupts watch groups of pins, not only D2/D3. On the Uno: PCINT0 group covers D8-D13, PCINT1 covers A0-A5, PCINT2 covers D0-D7.

You enable a group in PCICR and the pin mask in PCMSKx. The ISR does not tell you which pin changed - read the pin in loop if you need that.

Template: watch D8 (PCINT0).

volatile bool edgePending = false;

ISR(PCINT0_vect) {
  edgePending = true;
}

void setup() {
  pinMode(8, INPUT_PULLUP);
  PCICR |= (1 << PCIE0);
  PCMSK0 |= (1 << PCINT0);
  Serial.begin(9600);
}

void loop() {
  if (edgePending) {
    edgePending = false;
    Serial.println("Pin change on Port B");
  }
}

Timer interrupt

Timer interrupts fire from Timer0, Timer1 or Timer2 on overflow or compare-match. Arduino already uses Timer0 for millis() and delay(), so prefer Timer1 for your own ticks.

Template: Timer1 CTC at about 1 Hz (16 MHz clock, /1024 prescale, OCR1A = 15624). Toggle the built-in LED from loop.

volatile bool tickPending = false;

ISR(TIMER1_COMPA_vect) {
  tickPending = true;
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);

  noInterrupts();
  TCCR1A = 0;
  TCCR1B = 0;
  TCNT1 = 0;
  OCR1A = 15624;
  TCCR1B |= (1 << WGM12);
  TCCR1B |= (1 << CS12) | (1 << CS10);
  TIMSK1 |= (1 << OCIE1A);
  interrupts();
}

void loop() {
  if (tickPending) {
    tickPending = false;
    digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
  }
}

USART receive interrupt

The UART can interrupt when a byte arrives. Do not mix this template with Serial.begin / Serial.read on the same USART - they share the hardware.

Template: 9600 baud on the Uno USB-serial port. Send '1' or '0' from a terminal to drive LED_BUILTIN.

volatile char lastChar = 0;
volatile bool charPending = false;

ISR(USART_RX_vect) {
  lastChar = UDR0;
  charPending = true;
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  UBRR0H = 0;
  UBRR0L = 103;
  UCSR0B = (1 << RXEN0) | (1 << RXCIE0);
  UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}

void loop() {
  if (charPending) {
    noInterrupts();
    char c = lastChar;
    charPending = false;
    interrupts();
    digitalWrite(LED_BUILTIN, c == '1');
  }
}

ADC conversion interrupt

The ADC can interrupt when a conversion finishes instead of waiting in analogRead(). Start a conversion, handle the result in the ISR or in loop via a flag.

Template: free-running style start on A0; print the result from loop.

volatile int adcValue = 0;
volatile bool adcReady = false;

ISR(ADC_vect) {
  adcValue = ADC;
  adcReady = true;
}

void setup() {
  Serial.begin(9600);
  ADMUX = (1 << REFS0);
  ADCSRA = (1 << ADEN) | (1 << ADIE) | (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0);
  ADCSRA |= (1 << ADSC);
}

void loop() {
  if (adcReady) {
    noInterrupts();
    int value = adcValue;
    adcReady = false;
    interrupts();
    Serial.println(value);
    ADCSRA |= (1 << ADSC);
  }
}

Analog comparator interrupt

The analog comparator watches AIN0 (D6) against AIN1 (D7) by default, or against the bandgap / ADC mux depending on registers. It can interrupt on a compare-match change.

Template: enable the comparator interrupt and print when the output toggles. Keep wiring at safe 0-5 V levels only.

volatile bool comparePending = false;

ISR(ANALOG_COMP_vect) {
  comparePending = true;
}

void setup() {
  Serial.begin(9600);
  ADCSRB &= ~(1 << ACME);
  ACSR = (1 << ACIE);
}

void loop() {
  if (comparePending) {
    comparePending = false;
    Serial.println("Comparator change");
  }
}

Watchdog interrupt

The watchdog timer can reset the MCU or raise an interrupt. Interrupt mode is useful for a timed wake or a fail-safe tick when loop has stalled.

Template: about 1 s watchdog interrupt (WDP bits). Clear the flag in loop. Reset WDT carefully in real projects so a hung loop still recovers.

#include <avr/wdt.h>

volatile bool wdtPending = false;

ISR(WDT_vect) {
  wdtPending = true;
}

void setup() {
  Serial.begin(9600);
  noInterrupts();
  MCUSR &= ~(1 << WDRF);
  WDTCSR |= (1 << WDCE) | (1 << WDE);
  WDTCSR = (1 << WDIE) | (1 << WDP2) | (1 << WDP1);
  interrupts();
}

void loop() {
  if (wdtPending) {
    wdtPending = false;
    Serial.println("Watchdog tick");
  }
}

Sleep and wake on the Uno

The Uno (ATmega328P) does not have an ESP32-style deepSleep() API. You choose a sleep mode with set_sleep_mode() from avr/sleep.h, then call sleep_cpu(). The two modes students use most:

IDLE - lightest sleep. The CPU stops but timers and many peripherals keep running. millis() still advances. Almost any interrupt can wake the chip.

POWER-DOWN - deepest sleep on the Uno (the closest thing to deep sleep). Most clocks stop to save power. Timer0 stops, so millis() freezes until you wake. Only selected wake sources work (see the table).

What happens on wake: the sketch does not restart. setup() does not run again. Execution continues at the next statement after sleep_cpu(). RAM and most register state stay intact. Only a real reset (power cycle, reset button, brown-out, or watchdog reset mode) starts the program from the beginning again.

Important for POWER-DOWN: dedicated external INT0/INT1 wake best as LOW level (not FALLING/RISING). Pin-change and watchdog interrupt mode can also wake from POWER-DOWN. Template: button on D2 to GND with INPUT_PULLUP; sleep until the pin is held LOW.

Sleep modeDepthTypical wake sources
SLEEP_MODE_IDLELightAlmost any interrupt (timer, USART, external, pin-change, ...)
SLEEP_MODE_ADCLight-mediumADC complete; some other interrupts
SLEEP_MODE_PWR_SAVEDeeperTimer2 async, external/pin-change, watchdog, TWI address match
SLEEP_MODE_STANDBYDeep (osc. on)Similar to power-down but faster wake
SLEEP_MODE_PWR_DOWNDeepestExternal INT as LOW, pin-change, watchdog interrupt, TWI address match
#include <avr/sleep.h>

const byte wakePin = 2;

void wakeISR() {
}

void enterPowerDown() {
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);
  sleep_enable();
  attachInterrupt(digitalPinToInterrupt(wakePin), wakeISR, LOW);
  sleep_cpu();
  sleep_disable();
  detachInterrupt(digitalPinToInterrupt(wakePin));
}

void setup() {
  pinMode(wakePin, INPUT_PULLUP);
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.begin(9600);
  delay(100);
  Serial.println("Running");
}

void loop() {
  Serial.println("Sleeping");
  Serial.flush();
  digitalWrite(LED_BUILTIN, LOW);

  enterPowerDown();

  digitalWrite(LED_BUILTIN, HIGH);
  Serial.println("Woke - continued after sleep_cpu()");
  delay(500);
}

Typical current draw (board vs chip)

Sleep only cuts MCU core current. On a USB Uno board the USB-serial chip, regulators and LEDs still draw tens of milliamps, so POWER-DOWN barely changes what you measure at the USB cable. On a bare ATmega328P (minimal 3.3 V/5 V circuit, BOD off where allowed) the same modes drop into the microamp range.

Figures below are typical order-of-magnitude values for teaching, not a guarantee for every board revision.

ModeUNO development board (typical)ATmega328P chip alone (typical)
Normal active runabout 45 mA to 50 mAabout 12 mA to 15 mA
Idle sleepabout 30 mA to 35 mAabout 1.0 mA to 1.5 mA
Power-down (deepest sleep)about 30 mA to 35 mAabout 0.1 uA to 0.5 uA (BOD off)

SPI and TWI (I2C) interrupts

The Uno also has SPI and TWI (I2C) interrupt vectors. In Arduino sketches you almost always use the SPI and Wire libraries instead of writing these ISRs yourself.

Know they exist for datasheet reading; prefer the libraries for coursework unless a brief specifically asks for register-level SPI/TWI interrupts.

SourceVector (typical)Course approach
SPISPI_STC_vectUse SPI.h unless told otherwise
TWI / I2CTWI_vectUse Wire.h unless told otherwise

Interrupt map at a glance

Quick map of the families covered on this page for Uno / ATmega328P coursework.

FamilyHow you enable itBest for
External INT0/INT1attachInterrupt on D2/D3Clean button / limit edges
Pin-changePCICR + PCMSKx + ISR(PCINTx_vect)Other GPIO pins
TimerTCCRx / TIMSKx + ISR(TIMERn_...)Steady ticks without delay
USART RXUCSR0B RXCIE0 + ISR(USART_RX_vect)Byte-by-byte serial input
ADCADCSRA ADIE + ISR(ADC_vect)Non-blocking analogue reads
Analog comparatorACSR ACIE + ISR(ANALOG_COMP_vect)Fast threshold detect
WatchdogWDTCSR WDIE + ISR(WDT_vect)Timeout / wake helper
Sleep / wakeset_sleep_mode + sleep_cpu()Save power; continue after wake
SPI / TWILibrary or STC/TWI vectorsUsually SPI.h / Wire.h

Wiring and safe build sequence

  1. For the worked sketch: button from D2 to GND with INPUT_PULLUP
  2. D8 -> 330 ohm -> LED -> GND
  3. Passive buzzer signal -> D7; buzzer GND -> GND
  4. Other templates: follow the pin named in that section (D8 for pin-change, LED_BUILTIN for timer/USART)
  5. Sleep template: same D2 button; hold LOW to wake from power-down
Power rule: switch off before moving wires. Arduino I/O pins are control signals; high-current loads require a driver and suitable external supply.
const byte buttonPin = 2;
const byte ledPin = 8;
const byte buzzerPin = 7;

volatile bool eventPending = false;
volatile unsigned int eventCount = 0;

unsigned long beepUntil = 0;
bool ledOn = false;

void onButtonEdge() {
  eventPending = true;
  eventCount++;
}

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(ledPin, OUTPUT);
  pinMode(buzzerPin, OUTPUT);
  Serial.begin(9600);
  attachInterrupt(digitalPinToInterrupt(buttonPin), onButtonEdge, FALLING);
}

void loop() {
  if (eventPending) {
    noInterrupts();
    eventPending = false;
    unsigned int countCopy = eventCount;
    interrupts();

    ledOn = !ledOn;
    digitalWrite(ledPin, ledOn);
    tone(buzzerPin, 1200);
    beepUntil = millis() + 80;
    Serial.print("Events: ");
    Serial.println(countCopy);
  }

  if (beepUntil != 0 && millis() >= beepUntil) {
    noTone(buzzerPin);
    beepUntil = 0;
  }
}

How the code works

  1. FALLING matches an INPUT_PULLUP button that goes LOW when pressed.
  2. digitalPinToInterrupt(2) maps D2 to interrupt 0 on Uno.
  3. The beep duration is finished in loop with millis, not delay inside the ISR.

Test and record evidence

Expected result: Each clean button edge toggles the LED, chirps briefly and prints an increasing count.

Practical evidence checklist

Common faults and checks
  • Multiple counts per press: add software debounce in loop or a short ignore window after an event.
  • No response: confirm the button is on D2 and uses INPUT_PULLUP to GND.
  • USART template silent: do not also call Serial.begin on the same port.
  • Pin-change fires often: the group watches several pins; mask only the pin you need.
Extension challenge: Ignore further external interrupts for 200 ms after each accepted event to debounce in software.

Check your understanding

Q1. Which Uno pins have dedicated external interrupts?

Show answer

D2 (INT0) and D3 (INT1).

Q2. What should an ISR usually do?

Show answer

Only set a flag or update a simple count, then return quickly.

Q3. Why mark shared ISR data volatile?

Show answer

So the compiler always reads the latest value written by the ISR.

Q4. Name one interrupt family besides external INT0/INT1.

Show answer

Examples: pin-change, timer, USART RX, ADC, analog comparator, watchdog.

Q5. Why avoid Serial inside an ISR?

Show answer

Serial is slow and can block; finish printing from loop after a flag is set.

Q6. After sleep_cpu() wakes the Uno, what runs next?

Show answer

The next statement after sleep_cpu(). setup() does not run again unless the board resets.

Q7. Which sleep mode is the Uno deepest power-down style sleep?

Show answer

SLEEP_MODE_PWR_DOWN.

Q8. Why does a USB Uno still draw tens of mA in power-down?

Show answer

Board parts such as the USB-serial chip, regulator and LEDs keep drawing current; the bare ATmega328P is what drops to microamps.