Built-in project · Arduino Nano

Stopwatch with lap times on an Arduino Nano

Stopwatch with lap times on an Arduino Nano and a 16x2 LCD: one button starts, stops and carries on, the other takes laps or resets, the fastest lap starred. The whole build, an Arduino Nano and 4 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Beginner Runs in your browser. Free, and no account needed.

The parts retail for about $32. On Mokxi they're free.

Stopwatch with lap times · Arduino Nanolive0.000 s 0.00x
Press Run, then the green button to start and the blue one for a lap
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Nano
// Stopwatch with lap times on a 16x2 I2C LCD, on an Arduino Nano.
//
//   start/stop button   D2 to GND
//   lap/reset button    D3 to GND
//   LCD                 SDA -> A4, SCL -> A5, VCC -> 5 V, GND -> GND (0x27)
//   piezo               D12 and GND: a click on every press
//
// The top row shows the time in minutes, seconds and hundredths, 00:00.00.
// Press start and it runs; press again and it stops, and again to carry on
// from where it stopped. While it runs, the lap button takes a lap: the
// bottom row shows that lap's own time and the Serial Monitor prints it with
// the total so far. When it is stopped, the lap button resets it to zero.
// The fastest lap is marked with a star.
//
// # Keeping time with millis()
//
// millis() counts the milliseconds since the board started, on its own, in
// the background. The stopwatch never counts anything itself. It remembers
// the millis() value when it was started, and the time on the screen is just
// "now minus then", plus whatever had built up before the last stop. That is
// why the time stays right even though the screen is only redrawn every
// 30 ms: drawing the screen takes time, but the clock it reads keeps going.
//
// # Catching the press at the right moment
//
// Writing to the LCD takes a few milliseconds, and a press that came while
// loop() was busy writing would be noticed late, which a stopwatch would
// count as part of the lap. So the buttons are on D2 and D3, the Nano's two
// interrupt pins. attachInterrupt() makes the chip stop whatever it is doing
// the instant a button goes down and run a tiny function that writes down
// millis() there and then. loop() acts on the press when it gets to it, but
// uses the time that was written down.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

const uint8_t START_BUTTON = 2;
const uint8_t LAP_BUTTON = 3;
const uint8_t PIEZO = 12;

LiquidCrystal_I2C lcd(0x27, 16, 2);

bool running = false;
unsigned long startedAt = 0;   // millis() when it was last started
unsigned long banked = 0;      // the time built up before the last stop
unsigned long lapStart = 0;    // the total time when this lap began
unsigned long bestLap = 0;     // 0 is no lap yet
uint8_t laps = 0;
unsigned long drawnAt = 0;

// Written by the interrupts, read by loop(): `volatile` tells the compiler
// they can change at any moment, so it reads them fresh every time.
volatile unsigned long startPressAt = 0;
volatile unsigned long lapPressAt = 0;
volatile bool startPressed = false;
volatile bool lapPressed = false;

// The time on the stopwatch at the moment `at` (a millis() value).
unsigned long elapsedAt(unsigned long at) {
  return running ? banked + (at - startedAt) : banked;
}

unsigned long elapsed() {
  return elapsedAt(millis());
}

// Writes mm:ss.hh into `out`, which needs 9 bytes.
void format(unsigned long ms, char *out) {
  unsigned long hundredths = ms / 10;
  uint8_t h = hundredths % 100;
  uint8_t s = (hundredths / 100) % 60;
  uint8_t m = (hundredths / 6000) % 100;
  out[0] = '0' + m / 10;
  out[1] = '0' + m % 10;
  out[2] = ':';
  out[3] = '0' + s / 10;
  out[4] = '0' + s % 10;
  out[5] = '.';
  out[6] = '0' + h / 10;
  out[7] = '0' + h % 10;
  out[8] = '\0';
}

// What the top row shows now, so a redraw can send only what changed.
char shown[15] = "";

void drawTime() {
  char text[15];
  format(elapsed(), text);
  strcpy(text + 8, running ? "  RUN" : " STOP");
  // Every character is a few messages on the I2C bus, so only the ones
  // that changed are sent: most of the time that is the last digit or two.
  for (uint8_t i = 0; i < 13; i++) {
    if (text[i] != shown[i]) {
      lcd.setCursor(i, 0);
      lcd.write(text[i]);
      shown[i] = text[i];
    }
  }
}

void drawLap(unsigned long lap) {
  char text[9];
  format(lap, text);
  lcd.setCursor(0, 1);
  lcd.print("Lap ");
  lcd.print(laps);
  lcd.print(laps < 10 ? "  " : " ");
  lcd.print(text);
  lcd.print(lap == bestLap ? "*" : " ");
}

void click() {
  tone(PIEZO, 2000, 15);
}

void startStop(unsigned long at) {
  click();
  char text[9];
  if (running) {
    banked = elapsedAt(at);
    running = false;
    format(banked, text);
    Serial.print("stop at ");
    Serial.println(text);
  } else {
    startedAt = at;
    running = true;
    Serial.println(banked == 0 ? "start" : "carry on");
  }
  drawTime();
}

void lapOrReset(unsigned long at) {
  click();
  if (running) {
    unsigned long total = elapsedAt(at);
    unsigned long lap = total - lapStart;
    lapStart = total;
    laps++;
    bool best = bestLap == 0 || lap < bestLap;
    if (best) bestLap = lap;
    drawLap(lap);
    char text[9];
    Serial.print("lap ");
    Serial.print(laps);
    Serial.print(": ");
    format(lap, text);
    Serial.print(text);
    Serial.print(" (total ");
    format(total, text);
    Serial.print(text);
    Serial.println(best ? ") best so far" : ")");
  } else {
    banked = 0;
    lapStart = 0;
    bestLap = 0;
    laps = 0;
    lcd.clear();
    memset(shown, ' ', sizeof(shown) - 1);  // the screen is all spaces now
    drawTime();
    Serial.println("reset");
  }
}

// The interrupts. A button's contacts bounce for a few milliseconds, which
// looks like several presses, so anything within 50 ms of the last press is
// the same press.
void onStart() {
  unsigned long now = millis();
  if (now - startPressAt < 50 && startPressAt != 0) return;
  startPressAt = now;
  startPressed = true;
}

void onLap() {
  unsigned long now = millis();
  if (now - lapPressAt < 50 && lapPressAt != 0) return;
  lapPressAt = now;
  lapPressed = true;
}

void setup() {
  Serial.begin(115200);
  pinMode(START_BUTTON, INPUT_PULLUP);
  pinMode(LAP_BUTTON, INPUT_PULLUP);
  pinMode(PIEZO, OUTPUT);
  attachInterrupt(digitalPinToInterrupt(START_BUTTON), onStart, FALLING);
  attachInterrupt(digitalPinToInterrupt(LAP_BUTTON), onLap, FALLING);
  lcd.init();
  lcd.backlight();
  drawTime();
  Serial.println("Stopwatch: press start");
}

void loop() {
  // Take each press and its time with interrupts off for a moment, so an
  // interrupt cannot change one half-way through being read.
  noInterrupts();
  bool start = startPressed;
  bool lap = lapPressed;
  unsigned long startAt = startPressAt;
  unsigned long lapAt = lapPressAt;
  startPressed = false;
  lapPressed = false;
  interrupts();
  if (start) startStop(startAt);
  if (lap) lapOrReset(lapAt);

  unsigned long now = millis();
  if (running && now - drawnAt >= 30) {
    drawnAt = now;
    drawTime();
  }
}

Parts list

6 parts, plus the jumper wires. They retail for about $32; on Mokxi they're free.

How it is wired

10 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Arduino Nano pin 2; Arcade button (2) pin 2
  • Arduino Nano pin 3; Arcade button (1) pin 2
  • Arduino Nano pin 12; Piezo speaker pin 1
  • Arduino Nano pin A5; Character LCD with I2C backpack pin SCL
  • Arduino Nano pin A4; Character LCD with I2C backpack pin SDA
  • Ground: Arcade button (1) pin 1
  • Ground: Arcade button (2) pin 1
  • Ground: Piezo speaker pin 2
  • Ground: Character LCD with I2C backpack pin GND
  • 5 V: Character LCD with I2C backpack pin VCC

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.