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.
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() +andmicros() +in the firmware source, the usual shape of a deadline that will wrap. - New timers must use the
Intervaltype, 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, nevernow >= 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
- Arduino documentation:
millis()— https://docs.arduino.cc/language-reference/en/functions/time/millis/ - Arduino documentation:
unsigned long— https://docs.arduino.cc/language-reference/en/variables/data-types/unsignedLong/ - Arduino documentation: Blink Without Delay — https://docs.arduino.cc/built-in-examples/digital/BlinkWithoutDelay/
Add a label
Embed this post
<iframe src="https://hibug.me/arduino-millis-overflow-49-days" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Related Stories
Customers in Japan and Korea charged 100x: zero-decimal currencies multiplied by 100
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.