Fixing the Stopwatch Overflow and Drift Bug
When implementing a stopwatch using elapsedMillis or raw millis() counters, two separate timing issues can appear:
- Long‑term overflow (~49 days) due to a 32‑bit counter wraparound.
- Short‑term drift (~35 seconds) caused by resolution loss and resynchronization conflicts between multiple
elapsedMillisinstances sharing the same timer backend.
This guide explains both problems, their causes, and how to fix them using hierarchical accumulators and isolated timing logic.
Update timeLib Library
Section titled “Update timeLib Library”First ensure you are using the latest version of the timeLib library, which includes fixes for shared timer backends. Download the updated library. This can solve some timing issues automatically. Update and compile your stopwatch project with the new library before proceeding.
49‑Day Overflow
Section titled “49‑Day Overflow”The classic bug occurs when accumulating time directly into a 32‑bit variable:
if (gRunning) { uint32_t delta = stopwatchTick; if (delta > 0U) { gStopwatchMs += delta; // overflow after ~49 days stopwatchTick = 0; }}Since gStopwatchMs is 32‑bit, it will wrap around every 4,294,967,295 milliseconds (≈49.7 days). Once that happens, your stopwatch suddenly resets.
Fix for Overflow: Hierarchical Accumulators
Section titled “Fix for Overflow: Hierarchical Accumulators”Instead of a single large counter, use four smaller ones:
uint16_t ms = 0; // 0–999uint8_t sec = 0; // 0–59uint8_t min = 0; // 0–59uint8_t hr = 0; // 0–99Then propagate overflow naturally:
if (gRunning) { uint32_t delta = stopwatchTick; // ms since last tick if (delta > 0U) { stopwatchTick = 0; // reset elapsedMillis ms += delta; while (ms >= 1000) { ms -= 1000; sec++; if (sec >= 60) { sec = 0; min++; if (min >= 60) { min = 0; hr++; if (hr > 99) hr = 99; } } } }} else stopwatchTick = 0;Now each unit rolls over smoothly—no overflow, no wraparound.
35‑Second Drift
Section titled “35‑Second Drift”A more subtle bug occurs much earlier (~30–35 seconds). It’s not an overflow, but a drift or sudden jump in the displayed time.
Root Cause
Section titled “Root Cause”- Multiple
elapsedMillisobjects (e.g.,displayTick,buttonTick,stopwatchTick) share the same hardwareTimerbackend. - Each object tracks time since its last reset. When one is set to zero, it reads from the same timer reference as the others.
- Inside the main loop, you had code that repeatedly did
stopwatchTick = 0;after every delta calculation. - Because the LCD drawing and button polling also took variable time, the resets occasionally aligned in such a way that stopwatchTick and displayTick drifted apart, causing missing or repeated milliseconds.
This drift accumulates tiny errors that become visible as a timing glitch around 30–40 seconds.
Simplified Diagram
Section titled “Simplified Diagram”Hardware Timer (shared)│──────────────────────────────────────────────▶ time│ ↑ reset A ↑ reset B ↑ reset A│ │ │ ││ stopwatchTick resets too often → misalignment with displayTickOver time, rounding differences cause the stopwatch to run slightly slower or faster—or jump.
Fix for 35‑Second Drift
Section titled “Fix for 35‑Second Drift”- Use one dedicated
elapsedMillisfor delta measurement only (no shared resets). - Reset it immediately after reading, without touching any other timers.
- Base display refresh purely on a separate
displayTickinterval. - Use hierarchical accumulators to store stable, human‑readable time.
This isolates timing functions so each one has a distinct purpose and avoids interference.
Display Logic Update
Section titled “Display Logic Update”The display no longer depends on gStopwatchMs. It simply refreshes at a fixed interval or when the stopwatch’s running state changes:
if ((displayTick >= DISPLAY_REFRESH_MS) || (lastRunning != gRunning)) { drawStopwatchScreen(sContext, gRunning); drawButton(sContext, btnStart); drawButton(sContext, btnReset);
#ifdef GrFlush GrFlush(&sContext); #endif
lastRunning = gRunning; displayTick = 0;}Drawing Function
Section titled “Drawing Function”static void drawStopwatchScreen(tContext &context, bool running){ char str[20]; snprintf(str, sizeof(str), "%02u:%02u:%02u:%03u", hr, min, sec, ms);
GrContextForegroundSet(&context, running ? ClrYellow : ClrOlive); GrStringDrawCentered(&context, str, -1, 64, 54, false);
GrContextForegroundSet(&context, running ? ClrGreen : ClrRed); GrStringDrawCentered(&context, running ? "RUNNING" : "STOPPED", -1, 64, 74, false);}Advantages
Section titled “Advantages”| Issue | Fix | Result |
|---|---|---|
| 49‑Day Overflow | Hierarchical accumulators | Infinite runtime without wraparound |
| 35‑Second Drift | Dedicated elapsedMillis + decoupled display timer | Stable timekeeping, no visible drift |