Programming Languages Bug Report

Arduino controller stops after 49.7 days: millis() overflow in interval checks

Our dosing controllers ran for weeks, then locked themselves out on day 49. The interval check compared now >= lastRun + interval, which breaks when the sum wraps past 2^32 milliseconds. How we proved it with a simulated clock and fixed it with unsigned subtraction.

Daniel Tran Daniel Tran @fun_dev · May 26, 2026 · 8 min read · 14 Views
Banner: Arduino controller stopping after 49.7 days because of millis overflow in interval checks

We maintain the controllers for a small aquaculture farm. Each tank has an Arduino Nano driving a peristaltic pump that doses a pH buffer every six hours, with a small OLED display and a safety rule: if the pump is triggered more than three times in any 60 minutes, the controller latches into LOCKOUT and stops dosing until someone presses reset. Overdosing kills fish; a stopped pump only lets pH drift slowly, so the rule fails safe.

Seven controllers were installed on the same morning. Forty-nine days later, staff found all seven showing LOCKOUT within a few hours of each other. The event log on the SD card was identical in shape on every unit:

d49 13:02:11 DOSE start (scheduled)
d49 13:02:11 DOSE start (scheduled)
d49 13:02:11 DOSE start (scheduled)
d49 13:02:11 SAFETY: 3 doses within 60 min -> LOCKOUT

Three doses in the same second, on day 49, on every unit. Pressing reset fixed each controller, which then ran normally for another seven weeks before doing it again.

Reproducing it

Waiting 49 days per experiment was not an option. On AVR boards, the Arduino core keeps the millisecond counter in a variable named timer0_millis in wiring.c, so a test build can move the clock forward. We added this to a bench unit, behind a compile-time flag:

#ifdef TEST_FAST_FORWARD
extern volatile unsigned long timer0_millis;

void fastForwardTo(unsigned long ms) {
  noInterrupts();
  timer0_millis = ms;
  interrupts();
}
#endif

void setup() {
  // ...
#ifdef TEST_FAST_FORWARD
  fastForwardTo(0xFFFFFFFFUL - 2UL * 60UL * 60UL * 1000UL);  // two hours before rollover
#endif
}

Within two hours of boot the bench unit locked out with the same log lines. Without the flag it ran normally. That pointed straight at the counter rolling over.

What we ruled out

  • Power or brownouts. The controllers log the reset cause at boot, and no resets had occurred; the uptime counter showed 49 days continuous.
  • Real-time clock drift or a schedule table error. The schedule is relative ("every 6 hours since last dose"), not wall-clock based; the RTC is only used for log timestamps.
  • A stuck pump relay. Each logged dose had its own start and stop, and the pump's flow sensor confirmed three short runs, not one long one.

Root cause

millis() returns an unsigned long, which is 32 bits on AVR. It counts up to 4,294,967,295 ms and then wraps to zero, roughly every 49.7 days. Our scheduler checked whether a dose was due with if (now >= lastDose + interval). When lastDose was within six hours of the maximum, the addition lastDose + interval overflowed and wrapped to a small number. now was still huge, so the condition became true on every pass through loop(), three doses fired back to back, and the safety rule latched. The bug was in the comparison, not in millis().

What is happening under the hood

Unsigned arithmetic in C and C++ is defined modulo 2^N. That makes addition dangerous near the top of the range and subtraction safe, as long as you compare the difference rather than absolute values.

Expression lastDose = 4,294,000,000, interval = 21,600,000, now = 4,294,100,000
lastDose + interval Wraps to 20,632,704
now >= lastDose + interval true: fires immediately (wrong)
now - lastDose 100,000
now - lastDose >= interval false: not due yet (correct)
After rollover, now = 500 now - lastDose wraps to 967,796, still the correct elapsed time

The subtraction form gives the true elapsed time whenever the real elapsed time is less than 49.7 days, even across the rollover, because the wrap in now and the wrap in the subtraction cancel out.

Environment

Component Version / value
Board Arduino Nano (ATmega328P, 16 MHz)
Core Arduino AVR Boards 1.8.6
Toolchain arduino-cli 1.x, avr-gcc from the core
millis() type unsigned long, 32 bits on AVR
Dosing interval 6 hours (21,600,000 ms)
Loop period About 50 ms (display refresh and sensor read)

Minimal reproduction

// broken scheduler
const unsigned long DOSE_INTERVAL_MS = 6UL * 60UL * 60UL * 1000UL;
unsigned long lastDose = 0;

void loop() {
  unsigned long now = millis();
  if (now >= lastDose + DOSE_INTERVAL_MS) {   // overflows when lastDose is near the top
    startDose();
    lastDose = now;
  }
  refreshDisplay();
}

The window is the final interval milliseconds before the counter wraps. With a 6-hour interval, once the last dose lands in that window, the condition is true on every loop until millis() wraps. Our safety rule cut it off after three doses; without it, the pump would have run roughly every 50 ms for up to six hours.

Who is affected

Any code on any platform that adds a duration to a free-running unsigned counter and compares the sum: millis() and micros() on Arduino (micros() wraps after about 71.6 minutes), tick counters in RTOSes, 32-bit hardware timers. Code that stores deadlines (nextRun = millis() + interval) has the same problem. Code that only ever compares now - start >= interval is safe. Using long instead of unsigned long makes it worse: signed overflow is undefined behaviour and in practice breaks after about 24.8 days.

The fix

We moved the scheduling logic into a tiny module with explicit 32-bit types and a single comparison form, so it can be tested on a PC as well as on the board:

// schedule.h
#pragma once
#include <stdint.h>

struct Interval {
  uint32_t last;
  uint32_t period;
};

// True when at least `period` ms have elapsed since `last`. Safe across rollover.
inline bool due(Interval& t, uint32_t now) {
  if ((uint32_t)(now - t.last) >= t.period) {
    t.last = now;
    return true;
  }
  return false;
}
-  if (now >= lastDose + DOSE_INTERVAL_MS) {
-    startDose();
-    lastDose = now;
-  }
+  if (due(doseTimer, millis())) {
+    startDose();
+  }

The explicit uint32_t and cast matter for host testing. On a 64-bit Linux or macOS machine, unsigned long is 64 bits and would never wrap in a test, which would let the bug pass.

We applied the same pattern to the four other timers in the firmware (display refresh, sensor sampling, SD log flush and the safety window itself), and replaced the stored nextFlush deadline with a last + period check.

Alternatives and trade-offs

  • Reboot the controller periodically. A watchdog reset every few weeks avoids the rollover but resets the dosing schedule and hides the bug in every other timer.
  • Use a 64-bit millisecond counter. Extending millis() with a rollover count in software works and never wraps in practice, but costs RAM and interrupt-safe handling on an 8-bit MCU. The subtraction pattern is simpler and sufficient for intervals under 49 days.
  • Schedule by wall clock from the RTC. Useful for "dose at 06:00 every day", and it needs its own handling of RTC failure and daylight saving changes. Our doses are interval based, so we kept millis().

How to test the fix

A host-side test drives due() across the rollover with a simulated clock and compares the fixed logic with the old expression:

// test_schedule.cpp: build with c++ -std=c++17 test_schedule.cpp && ./a.out
#include <cassert>
#include <cstdio>
#include "schedule.h"

static bool dueBuggy(Interval& t, uint32_t now) {
  if (now >= t.last + t.period) { t.last = now; return true; }
  return false;
}

int main() {
  const uint32_t MAX = UINT32_MAX;
  Interval fixed{MAX - 5000u, 60000u}, buggy{MAX - 5000u, 60000u};
  int fixedFires = 0, buggyFires = 0;

  for (uint64_t ms = (uint64_t)MAX - 4000; ms < (uint64_t)MAX + 200000; ms += 10) {
    if (due(fixed, (uint32_t)ms)) fixedFires++;
    if (dueBuggy(buggy, (uint32_t)ms)) buggyFires++;
  }
  std::printf("fixed=%d buggy=%d\n", fixedFires, buggyFires);
  assert(fixedFires == 3);   // at +55 s, +115 s and +175 s past the old maximum
  assert(buggyFires > 3);    // the old expression fires on every step before rollover
  return 0;
}
fixed=3 buggy=404

On hardware, the TEST_FAST_FORWARD build ran through three rollovers in a day on the bench unit (fast-forwarding to two hours before the wrap after each one) with doses logged exactly six hours apart.

Confirming in production

  • The updated firmware was flashed to all seven controllers on day 12 of their new cycle. Each has now passed its rollover point (day 49.7 of uptime) with no lockout and dose spacing in the SD log of 6 hours plus or minus 50 ms.
  • Each controller reports uptime and the time since its last dose over the farm's RS-485 bus; the monitoring dashboard alerts if time since last dose exceeds 6 hours 10 minutes.
  • pH logs for the tanks show the same daily range before and after the rollover window.

Regression guard

  • The host test runs in CI for every firmware change, alongside a compile check for the Nano target.
  • A grep-based CI check rejects millis() + and micros() + in the firmware source, the usual shape of a deadline that will wrap.
  • New timers must use the Interval type, and code review checks for it.

Security implications

None beyond the safety logic itself, which worked as designed: the lockout turned a potentially lethal overdose into a stopped pump. The fast-forward hook writes an internal core variable, so it is compiled only into test builds behind TEST_FAST_FORWARD and never shipped. Rollover bugs are a known class of availability failures in long-running devices, so they deserve the same testing as other fault paths.

Prevention checklist

  • Compare elapsed time with now - start >= interval, never now >= start + interval.
  • Store "last time" values, not computed deadlines.
  • Use fixed-width unsigned types (uint32_t) so host tests behave like the target.
  • Test across the rollover with a simulated or fast-forwarded clock.
  • Keep independent safety limits on actuators so timing bugs fail safe.

References

Add a label

#arduino #millis #avr #cpp #embedded
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.