07 · Timers & Interrupts¶
Every sketch so far had one job, so delay() was fine. Real devices do many
things at once — blink a status LED, poll a sensor every 2 s, answer serial
commands instantly — and delay() makes that impossible, because it freezes
everything. This module teaches the two standard escapes: cooperative
scheduling with millis() (the workhorse pattern of Arduino programming)
and hardware interrupts (for events too fast or too important to poll).
Why delay() blocks¶
delay(2000) is a busy-wait: the CPU sits in a loop checking the clock for
two seconds. Nothing else in loop() runs. Combine "read sensor every 2 s"
and "respond to serial commands" with delays, and commands take up to 2 s to
answer. Add "blink LED at 5 Hz" and it's simply impossible — the timings
fight.
millis()-based scheduling¶
millis() returns the number of milliseconds since boot as a uint32_t,
ticking in the background regardless of what your code does. Instead of
waiting for time to pass, you check whether enough time has passed:
uint32_t lastBlink = 0;
void loop() {
if (millis() - lastBlink >= 500) { // 500 ms elapsed since last toggle?
lastBlink = millis();
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
// falls straight through — loop() keeps spinning
}
Because nothing blocks, you can stack as many of these as you like. Three
independent "tasks", one loop():
const uint8_t LED_PIN = 2;
uint32_t lastBlink = 0;
uint32_t lastReport = 0;
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
uint32_t now = millis(); // sample the clock ONCE per pass
// Task 1: heartbeat LED at 2 Hz
if (now - lastBlink >= 250) {
lastBlink = now;
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
// Task 2: status report every 2 seconds
if (now - lastReport >= 2000) {
lastReport = now;
Serial.print(F("uptime_s="));
Serial.println(now / 1000);
}
// Task 3: serial commands — answered within microseconds, always
if (Serial.available() > 0) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "ping") Serial.println(F("pong"));
}
}
This is cooperative multitasking: each task runs briefly and returns.
The one rule: no task may block — no delay(), no while loops waiting
on hardware. One slow task stalls all the others.
Why now - lastBlink and not now >= lastBlink + 500
millis() wraps to 0 after ~49.7 days. Written as a difference of
unsigned numbers, now - lastBlink stays correct straight through the
wraparound (unsigned arithmetic wraps too, cancelling out). Written as a
comparison of absolute times, it breaks on day 49. Devices are expected
to run for months — always use the subtraction form.
Hardware interrupts¶
Polling has a blind spot: an event shorter than one trip around loop() can
be missed entirely. An interrupt flips the model — the hardware pauses
your code the instant a pin changes, runs a special function (an ISR,
interrupt service routine), then resumes your code exactly where it was.
const uint8_t BUTTON_PIN = 4;
const uint8_t LED_PIN = 2;
volatile uint32_t pressCount = 0; // shared with the ISR → volatile!
void IRAM_ATTR onButtonPress() { // the ISR: runs on the falling edge
pressCount++; // short and simple — nothing else
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButtonPress, FALLING);
}
void loop() {
static uint32_t lastReported = 0;
if (pressCount != lastReported) { // notice changes from the ISR
lastReported = pressCount;
Serial.print(F("presses: "));
Serial.println(lastReported);
}
}
digitalPinToInterrupt(pin)maps the pin to its interrupt number (on the Uno only pins 2 and 3 can interrupt; on the ESP32, any GPIO).- The trigger mode is
FALLINGhere (HIGH→LOW, i.e. the press with a pull-up); other options:RISING,CHANGE,LOW. IRAM_ATTRis ESP32-specific: it places the ISR in RAM, required so it's runnable even during flash operations. On AVR it's unnecessary — define it as a no-op or omit for Uno-only code.
The ISR rules¶
An ISR runs in a delicate context — normal code is frozen, and on most cores other interrupts are masked. The rules are strict:
- Keep it short. Set a flag, bump a counter, capture
millis()— get out. Microseconds, not milliseconds. - No
Serial.print, nodelay(), no I2C/SPI calls inside an ISR — they rely on interrupts themselves and can deadlock or crash. - Every variable shared with an ISR must be
volatile— it tells the compiler the value can change at any moment, so it must actually read memory each time instead of caching it in a register. Without it,loop()may never see the ISR's updates (the compiler "optimized" the read away). - Multi-byte shared values need atomic access on 8-bit chips: an Uno
reads a
uint32_tin four steps, and an interrupt can strike mid-read. Guard withnoInterrupts(); copy = pressCount; interrupts();— a critical section.
The standard division of labor, exactly as in the example: ISR records the
event, loop() does the work.
How It Actually Works¶
Where millis() gets its number: it isn't derived from any wall clock —
a hardware timer/counter peripheral (Timer0 on AVR, one of the ESP32's
64-bit hardware timers) is configured at startup to generate an interrupt at
a fixed rate (roughly every 1.024 ms on AVR, tuned so software correction
lands it on exact milliseconds), and each time that timer overflow
interrupt fires, its ISR increments a global counter variable. millis()
just reads that counter. This means millis()'s accuracy is only as good as
the crystal/resonator clocking the timer — cheap ceramic resonators drift a
bit with temperature, which is why long-uptime timing-critical systems
(Level 3) eventually move to a proper RTC crystal.
What actually happens at an interrupt: when the GPIO peripheral's edge
detector (configured by attachInterrupt's trigger mode) senses the
requested transition, dedicated hardware signals the CPU core between
instruction boundaries. The core then, entirely in hardware: pushes the
current program counter (and on some architectures, status flags) onto the
stack, looks up your ISR's address in the interrupt vector table — a
fixed array of function-pointer-like entries near the start of flash, one
slot per interrupt source — and jumps there. Your ISR runs to completion,
then its generated epilogue (reti on AVR) pops the saved state back off the
stack and execution resumes in loop() at the exact instruction it was
about to execute, as if nothing happened — except any global state the ISR
touched.
Why volatile is not optional: the compiler's optimizer is allowed to
assume that if your code never explicitly writes a variable between two
reads, its value hasn't changed, and it will cache that value in a CPU
register rather than re-reading memory. But an ISR modifies pressCount
through a completely separate code path the optimizer doesn't model as
occurring "between" statements in loop() — so without volatile, the
compiler can (and does, at -O2) hoist pressCount into a register once and
never look at RAM again, silently missing every ISR update. volatile forces
an actual load/store instruction to the variable's memory address on every
access, which is also why multi-byte volatile reads on an 8-bit AVR aren't
atomic: reading a volatile uint32_t is four separate byte-wide LD
instructions, and if the ISR fires and updates the value between the 2nd and
3rd byte, loop() reads a torn, half-old-half-new value — exactly the
scenario noInterrupts()/interrupts() (which set/clear the CPU's global
interrupt-enable flag, blocking all interrupts for that critical section)
exists to prevent.
Cheat sheet¶
| Concept | Detail |
|---|---|
millis() |
uint32_t ms since boot; wraps after ~49.7 days |
| Schedule pattern | if (now - last >= interval) { last = now; ...task... } |
| Wraparound safety | Always compare with subtraction, never absolute times |
| Cooperative rule | No task in loop() may block |
attachInterrupt(digitalPinToInterrupt(pin), isr, mode) |
Run isr on pin event |
| Modes | RISING, FALLING, CHANGE, LOW |
| Interrupt pins | Uno: only 2, 3 — ESP32: any GPIO |
| ISR rules | Short; no Serial/delay/I2C; shared vars volatile |
IRAM_ATTR |
ESP32: keeps the ISR in RAM — required |
| Critical section | noInterrupts() / interrupts() around multi-byte shared reads |
Exercise¶
Rebuild module 3's two-button counter (Wokwi, ESP32) the professional way:
button presses captured by a FALLING interrupt that only records
millis() into a volatile variable; loop() notices the new timestamp,
applies a 200 ms software lockout for debouncing (ignore a press within
200 ms of the previous accepted one), and updates the count. Meanwhile a
heartbeat LED must blink at a steady 2 Hz via millis() scheduling — verify
it never stutters no matter how fast you hammer the button, then explain (in
a comment) why the delay()-based debounce from module 3 could not make that
guarantee.