Learning Goals 5 min
- Explain
millis(): the built-in "milliseconds since boot" counter. It returns anunsigned longthat grows from 0 to about 49 days' worth, then wraps back to 0. - Use
millis()to measure elapsed time between two events — the foundation of every non-blocking sketch. - Understand the safe-subtraction trick:
millis() - lastTime >= INTERVALis always correct even across the 49-day wrap, whilemillis() >= lastTime + INTERVALcan fail. Why? You'll find out the hard way.
Warm-Up 10 min
Yesterday we proved delay() is the villain. Today we meet the hero. millis() is a function — every time you call it, it returns the number of milliseconds since the Arduino booted. Like reading the time on a wall clock, except the clock counts from zero whenever you reset.
Quick sanity check
Try this in your head: power on the Arduino, wait 5 seconds, call millis(). What does it return?
Reveal
Approximately 5000. Not exactly — there's a few hundred milliseconds of boot time, plus your "5 seconds" was probably more like 5.2. But the order of magnitude is right.
New Concept · The elapsed-time pattern 20 min
millis() in one paragraph
Returns the number of milliseconds since the Arduino last booted. Return type is unsigned long — a 32-bit number that goes up to 4 294 967 295 (~49 days). After that it wraps back to 0 and starts counting again. The hardware timer that drives it runs even during delay(), even while you're reading a sensor, even while the LCD is updating — it's a free background service.
The simplest pattern: measure how long something takes
unsigned long start = millis();
delay(1234);
unsigned long elapsed = millis() - start;
Serial.print("Took "); // ~1234
Serial.println(elapsed);Use this any time you want to know how slow your sensor / library / LCD is. Wrap any call in "start, do thing, elapsed = millis() − start".
The big pattern: scheduling without delay
Here's the equivalent of "do something every 2 seconds" — but without delay():
unsigned long lastTime = 0;
const unsigned long INTERVAL = 2000;
void loop() {
if (millis() - lastTime >= INTERVAL) {
lastTime = millis();
// do the thing
}
// ... the rest of the loop runs at full speed every iteration ...
}Read it line-by-line:
- Every loop iteration (every fraction of a millisecond), check
millis() - lastTime. - If that's ≥ INTERVAL, the gap has reached the threshold — fire the action.
- Update
lastTimeto now so the next fire happens 2 seconds from now. - Otherwise (which is > 99% of the time), skip past the if-block and continue with the rest of the loop.
The loop spins at full speed. Other things in the loop (button polling, LCD refresh, other timers) run on every iteration. The millis() check is the only thing gating the "every 2 seconds" action.
Why millis() - lastTime instead of millis() >= lastTime + INTERVAL?
Both look equivalent. Try it normally and they are. But near the 49-day wrap point, only the first form works. Worked example:
// Imagine millis() is about to wrap.
// lastTime = 4 294 966 000 (1 second before the wrap)
// millis() = 200 (just wrapped, now 200 ms past wrap)
// INTERVAL = 2000
// Form A: millis() - lastTime
// 200 - 4 294 966 000 (unsigned subtract)
// = 200 + 0 - 4 294 966 000 mod 2^32
// = 1 496 ms (CORRECT — that's the real gap)
// 1496 >= 2000? false. We wait some more. Good.
// Form B: millis() >= lastTime + INTERVAL
// lastTime + INTERVAL = 4 294 968 000
// ... but unsigned long max is 4 294 967 295
// So lastTime + INTERVAL wraps to 705
// 200 >= 705? false. Same answer this time, but...
// Form B's failure mode comes when the wrap happens DURING the interval:
// lastTime = 4 294 967 000, INTERVAL = 2000
// lastTime + INTERVAL = 1704 (after wrap)
// At millis() = 5000 (well past the wrap), Form B says
// 5000 >= 1704? YES (true) — we fire too early or even repeatedly
// Form A says
// 5000 - 4 294 967 000 = 3296 (correct elapsed time)
// 3296 >= 2000? YES — fire once, correctly.Don't worry if you don't fully follow the maths. The rule is: always use millis() - lastTime >= INTERVAL. It's the safe form. Unsigned-long subtraction is "wrap-aware" in C++ in a way that addition isn't.
Worked Example · Two timers in one loop 25 min
The whole point of millis() is that you can have MULTIPLE schedules running at once without conflict. Today's example does exactly two things at different rates, in one loop, with no delay:
- Print "A" to Serial every 500 ms.
- Print "B" to Serial every 1700 ms.
Why 1700? Because it's a weird non-multiple of 500 — proves the two timers really are independent.
Step 1 — no wiring needed
This is a Serial-only lesson. Just a USB cable.
Step 2 — the sketch
Save as two-timers.ino:
// L02-34: Two independent timers, no delay
unsigned long lastA = 0;
unsigned long lastB = 0;
const unsigned long INTERVAL_A = 500;
const unsigned long INTERVAL_B = 1700;
void setup() {
Serial.begin(9600);
Serial.println("Two timers go!");
}
void loop() {
unsigned long now = millis();
if (now - lastA >= INTERVAL_A) {
lastA = now;
Serial.print("A @ ");
Serial.println(now);
}
if (now - lastB >= INTERVAL_B) {
lastB = now;
Serial.print("B @ ");
Serial.println(now);
}
}Step 3 — upload and watch
Expected output:
Two timers go! A @ 500 A @ 1000 A @ 1500 B @ 1700 A @ 2000 A @ 2500 A @ 3000 B @ 3400 A @ 3500 A @ 4000 A @ 4500 B @ 5100 A @ 5000 ← oops! out of order?
Wait, why "A @ 5000" after "B @ 5100"? Look more carefully — both timers fired at very close moments, and the print order in the loop puts A's if-check before B's, but they got captured at slightly different millis() values. Use now (captured once at the top of loop) and the prints come out in order. Always capture now once per loop.
Step 4 — verify the "at full speed" promise
Replace the loop with this:
void loop() {
unsigned long now = millis();
if (now - lastA >= INTERVAL_A) { lastA = now;
Serial.print("tick @ ");
Serial.println(now);
}
// Print every 100th iteration to see how fast the loop spins
static unsigned long iterations = 0;
iterations++;
if (now - lastB >= INTERVAL_B) { lastB = now;
Serial.print("iters in last 1700ms: ");
Serial.println(iterations);
iterations = 0;
}
}On a UNO with just this sketch and Serial.print, you'll see the iteration count is in the tens of thousands per second. With delay(2000), it would be one. That's the responsiveness difference.
Step 5 — add a third timer
Add a third lastC with INTERVAL_C = 4444 (another odd number). Run. You'll have three timers running independently. They never interfere. Now you understand why every "sensor every 2 seconds, LCD every 500 ms, button every 50 ms" project is straightforward with millis() and a nightmare with delay().
Basic 7 min
Goal: Print the current millis() value to Serial every second. Use the millis()-based pattern, not delay(1000). Watch the values increment by ~1000 each line.
Challenge 1 7 min
Goal: Measure how long it takes analogRead() to complete. Run it 10 000 times in a row, time the loop, divide. (Spoiler: should be ~100 µs per call.)
Challenge 2 6 min
Goal: Build a reaction timer. Print "READY?", wait a random 2–5 seconds (using millis(), of course), print "GO!" and start a clock. Whoever presses the button on D2 stops the clock. Print the reaction time in milliseconds.
Challenge 3 · Refactor one of your sketches 15 min
Take the alarm from yesterday's Challenge 3 (L02-33), or any earlier sketch with a long delay(). Refactor it to use millis() scheduling instead of delay(). Document the change.
- Identify the "every N seconds" pattern in the original sketch.
- Replace the
delay(N)with the safe-subtract idiom. - Add a button or sensor that should respond quickly, to prove the loop is now responsive.
- Tap the button 10 times before and after the refactor. Count how many taps get a response each time.
The point is to convert intellectual understanding into muscle memory. After refactoring one sketch you'll start writing new sketches in the millis() style automatically.
Tomorrow we'll work through the canonical "Blink Without Delay" example and dissect it line-by-line.
Recap 5 min
millis() returns the count of milliseconds since boot. Capture it into an unsigned long variable, do a subtraction against a previous timestamp, compare to an interval. That's the entire pattern. With it you can run any number of independent timers in a single loop, keep your button polling instantaneous, and never miss a sensor event. The one quirk — millis() wraps around at ~49 days — is harmlessly handled by always using millis() - lastTime >= INTERVAL rather than the addition form. Tomorrow we apply this to the canonical "blink without delay" example and bring the whole thing together.
millis()- Built-in Arduino function. Returns an
unsigned longequal to the number of milliseconds since the Arduino last booted. Backed by a hardware timer; runs in the background regardless of what your code is doing. micros()- Same idea at microsecond resolution. Wraps every 70 minutes. Used when you need sub-millisecond timing — pulse measurements, fast schedulers.
unsigned long- The 32-bit unsigned integer type. Range 0 to 4 294 967 295. The right type for storing millis() values and intervals.
- Elapsed-time pattern
- Storing a "last did it" timestamp, computing
millis() - lastTimeon each loop iteration, firing when the difference exceeds the interval. - Safe subtraction
- Using
millis() - lastTime >= INTERVAL(subtract form) rather thanmillis() >= lastTime + INTERVAL(add form). The subtract form is correct even across the 49-day wrap. - 49-day wrap
- When
millis()reaches 4 294 967 295 (~49.7 days) it overflows and starts again at 0. With safe-subtract, your timers continue working unaffected; with add form they break. - Capture
nowonce - At the top of
loop(), storeunsigned long now = millis();and usenowthroughout the iteration. Avoids subtle bugs where two calls tomillis()return slightly different values. - Multiple timers in one loop
- The killer feature of
millis()scheduling. Each timer has its ownlastXandINTERVAL_X; the loop checks them all every iteration. No nesting, no conflict.
Extra Mission 5 min
Part 1 — Design a two-timer gadget
Pick an everyday gadget that keeps time for two things at once. Ideas: a plant reminder that blinks and beeps, a bike light with a heartbeat, a study timer with a break alarm. On paper, design it.
Your design must include:
- A name and a one-sentence job.
- At least two timed jobs, each with its interval in ms.
- One input that must respond within 50 ms.
- A table with three columns: job, interval, and the name of its timestamp variable.
Part 2 — Make it
Build it with millis() only — zero delay() calls. Give each timed job its own unsigned long timestamp and the safe-subtract test millis() - last >= INTERVAL. Print a line each time a job fires, so you can check the timings.
Bring back next class: your design table, your hw-l02-34.ino sketch, and a Serial Monitor screenshot showing both jobs firing on time.