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 "); Serial.println(elapsed); // ~1234Use 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().
Try It Yourself 20 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.
Hint
unsigned long lastPrint = 0;
void loop() {
if (millis() - lastPrint >= 1000) {
lastPrint = millis();
Serial.println(millis());
}
}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.)
Hint
unsigned long start = millis();
for (int i = 0; i < 10000; i++) {
(void)analogRead(A0);
}
unsigned long elapsed = millis() - start;
Serial.print("Total: "); Serial.print(elapsed); Serial.print(" ms");
Serial.print(" Per call: "); Serial.print(elapsed * 100); Serial.println(" us");(* 100 because we did 10 000 calls and elapsed is in ms, so to get µs/call we multiply by 1000 / 10000 = 0.1, but we want it as a whole number so I scaled. Adapt as needed.)
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.
Hint
This is the L01-22 reaction timer game, but written without any delay. State machine: WAITING → READY → DONE. Use millis() to schedule the "GO!" moment, capture the press timestamp, subtract.
Mini-Challenge · Refactor one of your sketches 15 min
Pick one sketch from yesterday's delay-audit catalogue. 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.
- Side-by-side: write a short note comparing "feel" before and after.
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.
Homework 5 min
Build the heartbeat + reading sketch. Combine two ideas you've seen separately:
- An LED on D9 that blinks once per second (200 ms on, 800 ms off) using only
millis()— nodelay. - A potentiometer reading on A0 printed to Serial every 500 ms — also
millis()-driven.
The two should run together in a single loop without any delay() call at all. The blink should be perfectly steady; the pot reading should be perfectly responsive (turn the pot and within 500 ms you see the new value).
On paper, answer:
- How many separate
lastXtimestamps did your sketch need? - If you wanted to add a third timer (e.g. an LCD refresh every 250 ms), how many lines of code would you need to add?
- If you used
delay()instead, what trade-off would you have to make between blink steadiness and pot responsiveness?
Bring back next class:
- Your
hw-l02-34.inosketch. - Your three written answers.
- Tomorrow we look at the official Blink Without Delay example and the "state-tracking variable" trick.