An Accurate Arduino Clock (Without an External RTC)

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.

yes, but the RTC modules have a backup battery

As mentioned, RTC modules are far more practical because the Arduino is going to lose the time every time the power is disconnected, the reset button is pressed, new code is uploaded...

An RTC module with a genuine Dallas Semiconductor DS3231 will be very accurate, having temperature and age compensation.

Another option that doesn't require an external RTC is to use NTP, if your Arduino has WiFi.

Whic Arduino are you using? When it is said RTC -- it means Real Time Clock. Does Arduino UNO R3 contain RTC Module? If no, then it is not possible to make RTC Using UNO R3. One has to use external RTC Module.

If you say that you will have battery backup for UNO R3, then I would use Timer1 to generate accurate 1-sec timetick to advance the clock.

The point that @miegapele is making is that it is possible. Compensating for the drift of the Arduino's crystal achieves an accuracy at least as good as digital watches and basic RTC modules based on chips such as DS1307.

But it's not very practical!

MSF60, DCF77, GPS.....

Quick calculation for DS3231 that has been on test for several years, 1 to 2 seconds drift per month compared to MSF60 (National Physical Laboratory) works out at 0.77 ppm if my post match brain is up to it.

Uno does not have quartz at all. It has crystal ceramic oscillator. I tried the same strategy with that, but it's bad. Crystal oscillators have hundreds times worse drift than crystals, so it's useless. You need Arduino micro similar. Or find Chinese uno clone with quartz (these exist).

Any difference between "quartz" and "crystal". I knew that they are synonyms. UNO R3 is driven by an external 16 MHz RC-resonator ceramic oscillator which is inaccurate tough but good enoug for millis() and micros() functions.

Edit: following post #9.

Arduino UNO is using ceramic oscillator, not RC resonator. Sorry, I mixed crystal and ceramic. Crystal oscilator is the same as quartz

Then what is about quartz vs crystal?

full name for quartz is quartz crystal.

You have edited post #7, and as a result, my reply in post #8 has become irrelevant. This goes against the spirit of good forum practice.

It is generally better to leave the original content intact and add any corrections or clarifications in a subsequent edit or follow-up. I would appreciate it if you could restore the original wording in post #7 so that my reply in post #8 remains relevant.

Gemini says that all quartzs are crystals but not all crystals are quartzs.

We need a clock which is both accurtae and precise!

Quartz is a naturally occurring crystal made from silicon dioxide. However, quartz used for timing in electronics are synthetically grown crystals.

@miegapele
Show your HW setup also.

Which Arduino did you use and how many have you tested?

I think it's a good idea and a great learning experience. The important questions are: How accurate does it need to be, and what tolerance is acceptable?

I have an old broken alarm clock that is exactly right every day and I never have to set it. I also have several wall clocks that are within a second over a month, but when the power fails, even with battery backup, they lose a or gain few seconds each time.

If the clock is synchronized periodically using NTP, it can be very accurate. Before electric clocks, people relied on mechanical clocks, and many of those kept remarkably good time with proper adjustment.

Building an RTC in software is an interesting project and a good way to learn about timers, oscillators, calibration, and timekeeping. Even if a dedicated RTC chip is ultimately a better solution for a particular application, there is still value in understanding how to implement one yourself.

So, your clock is not accurate -- am I correct?

When my Computer clock shows 2:10 pm, my wall clock always shows 2:11 pm. I claim that my wall clock is accurate. However, it is not precise like my Computer clock as my wll clock has no second hand.

It depends on how you define "accurate."

I looked up some typical values:

  • A free-running PC clock may drift from about 0.9 to 8.6 seconds per day, depending on the crystal.
  • A PC synchronized with NTP is typically within 10 to 100 ms of the correct time.
  • A GPS-disciplined clock can be accurate to better than 1 ms.
  • A DS3231 RTC is specified at about ±2 ppm (roughly one minute per year).

So, which one is "accurate"?

Even the RTC on a PC motherboard is not particularly accurate by itself. The operating system periodically corrects it using NTP, so the displayed time is usually much more accurate than the hardware clock.

And, of course, there is the old joke that a broken analog clock is exactly right twice a day. The hard part is knowing when that moment arrives.

That also raises another interesting question. Since UTC occasionally inserts leap seconds because the Earth's rotation is not perfectly constant, how do we define "accurate" in the first place? Accurate relative to what: a crystal oscillator, UTC, GPS time, or the Earth's actual rotation?