To achieve precision timing with Arduino, you must abandon the blocking delay() function and instead use millis() for relative elapsed time, hardware timers (like Timer1) for microsecond-critical interrupts, and a DS3231 Real-Time Clock (RTC) for absolute wall-clock time. Relying on delay() halts the microcontroller, causing missed sensor readings and unresponsive inputs. By combining these three timing domains, you can build robust, multi-tasking embedded systems that maintain accurate schedules without freezing the main loop.
This guide assumes you are using an Arduino Nano V3.0 (ATmega328P, 16MHz) operating at 5V logic. We will build a non-blocking environmental logger that demonstrates all three timing methods simultaneously.
The Hierarchy of Arduino Timing Methods
Before wiring the project, you need to understand the trade-offs between the four primary timing mechanisms available on the ATmega328P. Each serves a distinct purpose, and choosing the wrong one is the root cause of 90% of embedded timing bugs.
| Method | Resolution | Drift / Accuracy | Blocking? | Power Dependency | Best Use Case |
|---|---|---|---|---|---|
delay() |
~1ms | N/A (Halts CPU) | Yes (Strict) | Loses state on reset | Simple hardware debounce, startup settling |
millis() |
1ms | ~10-50ppm (Resonator dependent) | No | Resets to 0 on power loss | Non-blocking intervals, UI timeouts, blink-without-delay |
| Hardware Timer (Timer1) | 0.0625µs (at /1 prescaler) | Matches system clock (16MHz crystal) | No (Interrupt driven) | Resets to 0 on power loss | PWM generation, precise pulse counting, high-speed sampling |
| DS3231 RTC (I2C) | 1 second (or 1Hz square wave) | ±2ppm (TCXO compensated) | No | Retains time via CR1220 coin cell | Data logging timestamps, scheduled wakeups, wall-clock time |
Project Build: Non-Blocking Environmental Logger
This build reads a DHT22 temperature/humidity sensor every 5 seconds using millis(), updates an internal 1-second heartbeat using a Timer1 hardware interrupt, and timestamps the data using the DS3231 RTC. The output is displayed on a 16x2 I2C LCD.
Parts List
- Microcontroller: Arduino Nano V3.0 (ATmega328P, 16MHz, 5V)
- RTC Module: DS3231 Chronodot or Adafruit Precision RTC (I2C)
- Sensor: DHT22 (AM2302) with 10kΩ pull-up resistor on data line
- Display: 16x2 LCD with PCF8574 I2C backpack (Address 0x27)
- Power: CR1220 3V Lithium Coin Cell (for RTC backup)
Pin Mapping Table
| Arduino Nano Pin | Module | Module Pin | Notes |
|---|---|---|---|
| 5V | All Modules | VCC | Ensure Nano is powered via USB or regulated 5V |
| GND | All Modules | GND | Common ground required for I2C |
| A4 (SDA) | DS3231 & LCD | SDA | I2C Data bus (shared) |
| A5 (SCL) | DS3231 & LCD | SCL | I2C Clock bus (shared) |
| D2 | DHT22 | DATA | Requires 10kΩ pull-up to 5V |
| D9 | None | None | Warning: Timer1 disables PWM on D9 and D10 |
Wiring Steps
- Insert the Arduino Nano into the breadboard. Connect the 5V and GND rails across the board.
- Wire the I2C bus: Connect Nano A4 to the SDA pins of both the DS3231 and the LCD backpack. Connect A5 to the SCL pins.
- Wire the DHT22: Connect VCC to 5V, GND to GND, and the DATA pin to Nano D2. Solder or breadboard a 10kΩ resistor between VCC and DATA.
- Insert the CR1220 battery into the DS3231 holder. Safety Note: Ensure the battery polarity is correct (+ facing up) to prevent damaging the RTC IC.
- Connect the Nano to your PC via USB and verify the I2C addresses using an I2C scanner sketch before proceeding.
Complete Compilable Code
This code requires the RTClib, TimerOne, DHT sensor library, and LiquidCrystal_I2C libraries, all available via the Arduino Library Manager. The target board is the Arduino Nano (ATmega328P).
#include <Wire.h>
#include <RTClib.h>
#include <TimerOne.h>
#include <DHT.h>
#include <LiquidCrystal_I2C.h>
// --- PIN DEFINITIONS ---
#define DHTPIN 2
#define DHTTYPE DHT22
// --- OBJECT INSTANTIATION ---
RTC_DS3231 rtc;
DHT dht(DHTPIN, DHTTYPE);
LiquidCrystal_I2C lcd(0x27, 16, 2); // 0x27 is standard for PCF8574
// --- TIMING VARIABLES ---
unsigned long previousSensorMillis = 0;
const long sensorInterval = 5000; // Read sensor every 5 seconds
volatile bool heartbeatFlag = false; // Updated by hardware interrupt
// --- INTERRUPT SERVICE ROUTINE (Timer1) ---
void timerIsr() {
heartbeatFlag = true; // Set flag every 1 second
}
void setup() {
Serial.begin(115200);
// Initialize LCD
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("System Booting");
// Initialize DHT
dht.begin();
// Initialize RTC with Error Handling
if (!rtc.begin()) {
Serial.println("ERROR: Couldn't find RTC. Check I2C wiring.");
lcd.clear();
lcd.print("RTC I2C FAIL");
while (1); // Halt execution
}
if (rtc.lostPower()) {
Serial.println("RTC lost power, setting compile time.");
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// Initialize Hardware Timer (Timer1) for 1-second interrupt
// 1,000,000 microseconds = 1 second
Timer1.initialize(1000000);
Timer1.attachInterrupt(timerIsr);
lcd.clear();
Serial.println("System Ready.");
}
void loop() {
unsigned long currentMillis = millis();
// 1. Handle Hardware Timer Heartbeat (Non-blocking)
if (heartbeatFlag) {
heartbeatFlag = false; // Reset flag
DateTime now = rtc.now();
char timeStr[9];
sprintf(timeStr, "%02d:%02d:%02d", now.hour(), now.minute(), now.second());
lcd.setCursor(0, 0);
lcd.print("Time: ");
lcd.print(timeStr);
Serial.print("Heartbeat: ");
Serial.println(timeStr);
}
// 2. Handle Sensor Reading via millis() (Non-blocking)
// CRITICAL: Use subtraction to handle the 49.7-day millis() rollover safely
if (currentMillis - previousSensorMillis >= sensorInterval) {
previousSensorMillis = currentMillis;
float h = dht.readHumidity();
float t = dht.readTemperature();
// Check if any reads failed and exit early (to try again)
if (isnan(h) || isnan(t)) {
Serial.println("ERROR: Failed to read from DHT sensor!");
lcd.setCursor(0, 1);
lcd.print("Sensor Err ");
return;
}
lcd.setCursor(0, 1);
lcd.print(t, 1);
lcd.print("C ");
lcd.print(h, 1);
lcd.print("% ");
Serial.print("Env: ");
Serial.print(t);
Serial.print("C, ");
Serial.print(h);
Serial.println("%");
}
}
Debugging Timing Failures: Top 3 Checks
When timing code fails on the ATmega328P, it rarely fails silently. Here are the first three things to check when your logger freezes, drifts, or throws compilation errors.
1. Exact Error: fatal error: TimerOne.h: No such file or directory
- Cause: The TimerOne library is not installed, or you are compiling for an unsupported architecture (like an ESP32 or ARM-based Arduino Zero).
- Fix: Open the Arduino IDE Library Manager, search for "TimerOne" by Jesse Tane, and install it. Ensure your board selector is set to "Arduino Nano" and not a 32-bit board. TimerOne relies on AVR-specific 8-bit/16-bit timer registers.
2. Symptom: Serial Monitor prints ERROR: Couldn't find RTC and halts
- Cause: I2C bus failure. The ATmega328P cannot see the DS3231 at address 0x68.
- Fix:
- Verify SDA is on A4 and SCL is on A5 (not reversed).
- Check that the I2C pull-up resistors on the DS3231 breakout are enabled (most cheap clones have 4.7kΩ SMD resistors pre-soldered, but some require jumper pads to be closed).
- Run a basic I2C Scanner sketch to confirm the device responds at 0x68.
3. Symptom: System runs perfectly for 49.7 days, then freezes or behaves erratically
- Cause: The
millis()rollover bug. Themillis()counter is a 32-bit unsigned integer. At 16MHz, it overflows and resets to 0 every 49.71 days. - Fix: Look at your interval logic. If you wrote
if (currentMillis >= previousMillis + interval), it will fail on rollover becausepreviousMillis + intervaloverflows. You must always use subtraction:if (currentMillis - previousMillis >= interval). The code block above implements this correctly.
TimerOne library takes exclusive control of Hardware Timer1. On the ATmega328P, Timer1 drives the hardware PWM for Pins 9 and 10. If you attempt to use analogWrite(9, 128) while TimerOne is active, the PWM will fail or behave unpredictably. Use Pins 3, 5, or 6 (controlled by Timer2 and Timer0) for PWM in this build.
Extending and Simplifying the Build
Depending on your project requirements, you may need to scale this architecture up or strip it down.
How to Simplify (Low-Power / Battery Operation)
If you are running this logger on a 18650 Li-ion cell via a buck converter, power consumption matters.
- Drop the LCD: The I2C LCD backlight draws ~20mA continuously. Remove it and log data to a MicroSD card or transmit via an ESP8266 UART bridge.
- Use RTC Alarms for Sleep: Instead of using Timer1 to keep the chip awake, configure the DS3231's SQW pin to output a 1Hz interrupt. Connect the SQW pin to Nano D2 (INT0). Use the
avr/sleep.hlibrary to put the ATmega328P intoPOWER_DOWNmode, waking only when the RTC pulls D2 low. This drops idle current from ~15mA to under 0.1mA.
How to Extend (Higher Precision / More Sensors)
- Microsecond Timestamping: If you are logging high-speed events (like anemometer wind gusts),
millis()is too coarse. Usemicros()for relative timing, but be aware it rolls over every 70 minutes. Combine it with the RTC's 1Hz square wave output to create a custom 64-bit microsecond timestamp that survives reboots. - Upgrade the Microcontroller: If you need more hardware timers or native WiFi without an external module, migrate this exact logic to an ESP32-WROOM-32. The ESP32 uses the
esp_timerAPI instead of AVR TimerOne, and its FreeRTOS operating system handles non-blocking tasks via hardware task scheduling rather than manualmillis()polling.
Mastering timing with Arduino requires matching the tool to the task. Use millis() for human-scale intervals, hardware timers for machine-scale precision, and an RTC to anchor your data to the real world.
References:
1. Arduino Official Reference: millis() and Rollover Handling
2. Adafruit Learning System: DS3231 Precision RTC Breakout






