An Accurate Arduino Clock (Without an External RTC)
Every now and then somebody will ask can you do clock with arduino? Naturally, the forum know-it-alls will instantly parrot use RTC, RTC, RTC... without explaining much. But do you really need to? (Spoiler: you don't) That's what I setup to find out.
32khz quartz for time keeping is used because you need low power consumption on hand watch for good battery life. Higher frequency crystals should be more accurate, and looking at the data we find sources that confirm that they have better temperature coefficient than standard 32khz quartz crystals: https://www.ieee802.org/1/files/public/docs2021/60802-McCormick-Osc-Stability-0221-v01.pdf
So we just need to calibrate out initial tolerance and test. Which is what I did.
I wrote the following app to count seconds by adjusting every second interval for initial tolerance, run that for a week and arrived at initial 18ppm tolerance.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// --- Settings ---
const int16_t driftPPM = 18
; // Calibrated PPM (positive if clock is fast)
const int pinMenu = 4;
const int pinPlus = 5;
enum ClockMode { NORMAL, SET_HOUR, SET_MIN };
ClockMode currentMode = NORMAL;
bool minutesChanged = false;
uint32_t lastDisplayUpdate = 0;
// --- Timekeeping ---
volatile int8_t hours = 12, minutes = 0, seconds = 0;
volatile int16_t errorAccumulator = 0;
// Standard match value for 1 second at 16MHz/256 prescaler
// (16,000,000 / 256) - 1 = 62499
const uint16_t BASE_COMPARE = 62499;
// This fires exactly once per second
ISR(TIMER1_COMPA_vect) {
seconds++;
if (seconds >= 60) { seconds = 0; minutes++; }
if (minutes >= 60) { minutes = 0; hours++; }
if (hours >= 24) { hours = 0; }
// --- Continuous Drift Adjustment ---
// 1 tick = 16 microseconds. We accumulate PPM (microseconds)
errorAccumulator += driftPPM;
int16_t adjustment = 0;
while (errorAccumulator >= 16) {
adjustment++; // Add one tick to the delay
errorAccumulator -= 16;
}
while (errorAccumulator <= -16) {
adjustment--; // Subtract one tick from the delay
errorAccumulator += 16;
}
/*
//fast hack for big numbers
int16_t acc = errorAccumulator; // Use a local variable
adjustment = acc / 16;
acc &= 0x0F;
if (adjustment < 0) acc -= 16;
errorAccumulator = acc; // Sync back to RAM once
*/
// Apply adjustment to the target register for the NEXT second
OCR1A = BASE_COMPARE + adjustment;
}
bool wasClicked(int pin) {
if (digitalRead(pin) == LOW) {
delay(50);
if (digitalRead(pin) == LOW) {
while(digitalRead(pin) == LOW);
return true;
}
}
return false;
}
bool isPressed(int pin) {
static uint32_t lastRepeat = 0;
if (digitalRead(pin) == LOW) {
if (millis() - lastRepeat > 150) { // Repeat speed (150ms)
lastRepeat = millis();
return true;
}
}
return false;
}
void setup() {
// Define the pins for TX and RX LEDs
// TX LED is on Digital Pin 17 (PD5)
// RX LED is on a special internal mapping (PB0)
//pinMode(LED_BUILTIN_TX, OUTPUT);
//pinMode(LED_BUILTIN_RX, OUTPUT);
// Set them HIGH to turn them OFF (active-low logic)
//digitalWrite(LED_BUILTIN_TX, HIGH);
//digitalWrite(LED_BUILTIN_RX, HIGH);
pinMode(pinMenu, INPUT_PULLUP);
pinMode(pinPlus, INPUT_PULLUP);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
// --- Timer 1 Setup (CTC Mode) ---
cli();
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
OCR1A = BASE_COMPARE; // Set initial 1s target
TCCR1B |= (1 << WGM12); // Turn on CTC mode
TCCR1B |= (1 << CS12); // Set 256 prescaler
TIMSK1 |= (1 << OCIE1A); // Enable timer compare interrupt
sei();
}
void loop() {
// 1. Menu and Input
if (wasClicked(pinMenu)) {
if (currentMode == NORMAL) {
currentMode = SET_HOUR;
}
else if (currentMode == SET_HOUR) {
currentMode = SET_MIN;
// NOW we clear it. Any minute changes from this point forward
// will trigger the hardware sync upon exit.
minutesChanged = false;
}
else {
// Exiting SET_MIN mode
if (minutesChanged) {
cli();
seconds = 0;
errorAccumulator = 0;
TCNT1 = 0;
OCR1A = BASE_COMPARE;
TIFR1 |= (1 << OCF1A);
sei();
}
currentMode = NORMAL;
}
}
// 2. Adjustments with Repeat Logic
if (currentMode != NORMAL) {
if (isPressed(pinPlus)) {
if (currentMode == SET_HOUR) {
hours = (hours + 1) % 24;
}
else if (currentMode == SET_MIN) {
minutes = (minutes + 1) % 60;
minutesChanged = true; // Any adjustment here marks the clock for sync
}
}
}
if (millis() - lastDisplayUpdate >= 100) {
lastDisplayUpdate = millis();
updateDisplay();
}
}
void updateDisplay() {
// 3. Render
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// Menu Header
display.setTextSize(1);
display.setCursor(0,0);
if (currentMode == SET_HOUR) display.print(F("> SETTING HOUR"));
else if (currentMode == SET_MIN) display.print(F("> SETTING MINUTE"));
else {
display.print(F("STABLE CLOCK "));
display.print(driftPPM);
display.print(F(" PPM"));
}
// Main Clock
display.setTextSize(2);
display.setCursor(15, 25);
bool blink = (millis() % 600 < 300);
// Hour
if (currentMode == SET_HOUR && blink) display.print(F(" "));
else { if(hours < 10) display.print('0'); display.print(hours); }
display.print(':');
// Minute
if (currentMode == SET_MIN && blink) display.print(F(" "));
else { if(minutes < 10) display.print('0'); display.print(minutes); }
display.print(':');
// Seconds
if(seconds < 10) display.print('0'); display.print(seconds);
display.display();
}
Then I set that tolerance and began experiment.
Experiment Duration: The experiment began on March 29th (during the DST transition) and ran for 105 days.
The results are as follows:
Adjusted Arduino quartz crystal: Achieved a 6-second drift (0.66 ppm).
SKMEI (Casio Clone): Achieved a 21-second drift (2.32 ppm).
Original Casio F-105: Achieved a 25-second drift (2.75 ppm).
Conclusion: An Arduino quartz crystal works perfectly fine for timekeeping, provided you calibrate and adjust for the initial drift. For acceptable everyday accuracy, dedicated RTC modules are not strictly necessary.
