Skip to content

Embedded Systems

How to make a stopwatch with Arduino and millis()

An Arduino stopwatch is two glances at the clock and a subtraction. When it starts, save millis() in a variable; when it stops, take that away from millis() again. The difference is the time in milliseconds: elapsed = millis() − startedAt. A bool remembers whether it's running, so one button can start and stop it.

Why the start must be saved

millis() counts the milliseconds since the board powered up, and it never pauses. By the time you press stop, it has moved on, so the start has to be written down, like the time stamped on a car park ticket. That's startedAt, declared above setup() so it survives every pass of loop().

Live experiment

Stop it at exactly 2.0 seconds

One press starts the stopwatch and writes millis() on its ticket, startedAt; the next stops it and prints the time in tenths of a second. Try to stop it at exactly 2.0 s. Dividing by 100 throws away what's left over, so anything from 2000 to 2099 ms counts.

Try it yourself: free lesson

“Stopwatch” is lesson 4 of 6 in Clockwork Cove, Embedded Systems World 2. Free with an account. Lessons unlock in order, so you’ll do the 10 before it first.

The sketch

A button from pin 2 to GND, read with INPUT_PULLUP. Each press is caught once, on the change from HIGH to LOW, ignoring the pin for 50 ms after each change while the contacts bounce:

bool running = false;
unsigned long startedAt = 0;
bool last = HIGH;
unsigned long changedAt = 0;

void setup() {
  pinMode(2, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  bool now = digitalRead(2);
  if (now != last && millis() - changedAt > 50) {
    changedAt = millis();
    last = now;
    if (now == LOW) {                     // a new press
      if (!running) {
        startedAt = millis();             // start: note the time
      } else {
        unsigned long elapsed = millis() - startedAt;
        Serial.print("tenths: ");
        Serial.println(elapsed / 100);
      }
      running = !running;                 // flip the state
    }
  }
}

The first press starts it, the second stops it and prints, the third starts again. running = !running flips the state, true to false or false to true, in one line.

Printing it nicely

Milliseconds are hard to read, so the sketch divides by 100 to print tenths of a second. Both numbers are whole, so the division drops what's left over: 1250 ms prints 12, and 1980 ms prints 19, not 20.

Serial.print writes without ending the line and Serial.println ends it, so a label and its number share one line: tenths: 12. For seconds with decimals, divide by 1000.0 and choose the places: Serial.println(elapsed / 1000.0, 2) prints 1.25.

Make it a game

The same two glances time anything: a reaction-time game that lights an LED after a random wait and times how fast you press, a lap timer, or how long a door was left open.

Free lesson

Now build it yourself

Each lesson comes in three short parts: see the idea, try it on a live board, then build it yourself, with quick checks as you go.

Try it yourself: free lesson

“Stopwatch” is lesson 4 of 6 in Clockwork Cove, Embedded Systems World 2. Free with an account. Lessons unlock in order, so you’ll do the 10 before it first.

Or start from the very first lesson, no account needed

Practice questions

Three quick questions from the lessons. Pick an answer to check it.

  1. Question 1 of 3Why save the start time in a variable?
    Show the answer

    Answer: millis() keeps moving, so the start would be lost by the time you stop. The clock never pauses. You have to write down where it was at the start, like a parking ticket.

    • Why not “To make it faster”? Saving it doesn't speed anything up. It keeps the start time, which millis() itself won't.
    • Why not “It doesn't matter”? It matters: by the stop, millis() has moved on, so without a saved start there's nothing to measure from.
    • Why not “Serial needs it”? Serial only prints the result. The start is saved so there's something to subtract at the stop.
  2. Question 2 of 3startedAt is 1500 and you stop at millis() = 3480. The stopwatch prints (millis() - startedAt) / 100 after tenths:. What does it print?
    Show the answer

    Answer: tenths: 19. 3480 − 1500 = 1980 ms. 1980 / 100 is 19.8, and dividing whole numbers drops what's after the point: tenths: 19.

    • Why not “tenths: 1980”? 1980 is the elapsed milliseconds before dividing. The sketch prints it divided by 100.
    • Why not “tenths: 20”? Whole-number division drops the remainder instead of rounding, so 19.8 becomes 19, not 20.
    • Why not “tenths: 19.8”? Both numbers are whole numbers, so the division throws away the .8 instead of printing it.
  3. Question 3 of 3running = !running does…
    Show the answer

    Answer: flips running between true and false. ! means NOT. If running was false it becomes true, and the next time, false again. One line flips the stopwatch's state.

    • Why not “waits until running is false”? Nothing waits here. The line flips the value once and the program carries on.
    • Why not “sets running to true”? It sets true only when running was false. When running is true, the same line makes it false.
    • Why not “nothing”? It changes running every time it runs, from true to false or from false to true.
Keep practising: free lesson

“Stopwatch” is lesson 4 of 6 in Clockwork Cove, Embedded Systems World 2. Free with an account. Lessons unlock in order, so you’ll do the 10 before it first.

Questions people ask

How accurate is a millis() stopwatch?

It counts whole milliseconds, so each reading can be a millisecond or two out, and the board's clock crystal or resonator drifts by a small fraction of a percent. For finer timing, micros() counts in steps of 4 µs on a 16 MHz Uno.

What happens when millis() rolls over?

millis() is an unsigned long, so it wraps to 0 after about 49.7 days. If you store times as unsigned long and always subtract, now minus start, the answer still comes out right across the wrap.

How do I print the time in seconds with decimals?

Divide by 1000.0, with the decimal point, so the sum keeps its fraction, and tell println how many places to show: Serial.println(elapsed / 1000.0, 2) prints 1250 ms as 1.25.

Every topic