Embedded HandbookApps
All sections › XXV. Mini-projects
STM32F103 · level intermediate · 7 min read
Open in the interactive handbook

198. Mini-project: an RTC clock with an alarm and rate correction

The third device is a clock: time keeps running from a battery without main power, there is an alarm, and the rate is trimmed to the particular crystal. The RTC of the F103 is simpler than in later series: it is just a 32-bit seconds counter. The calendar, an alarm for "every day at 7:30" and rate correction are the job of the program.

As in sections 196 and 197, the logic (clock.c) is separated from the platform (plat_f103.c): the calendar was run on 20000 random moments and compared with the Python calendar, the correction on crystal errors from −60 to +150 ppm. The files are in _verify/proj198/, checked by _verify/proj198_check.py.

Terms used in the documentation

Term Meaning
backup domain, VBAT the battery-backed power domain, the battery pin
LSE (32.768 kHz) an external watch crystal
RTC prescaler (PRL), counter (CNT), alarm (ALR) the RTC prescaler, counter and alarm
RTC calibration (CAL) rate correction by skipping pulses
backup data register (BKP_DRx) a register that survives a reset
ppm (parts per million) one millionth: 1 ppm ≈ 0.0864 s per day

198.1 What the device does

Pin Purpose
PC14, PC15 32768 Hz crystal
VBAT a CR2032 battery — keeps the RTC and the BKP registers alive without main power
PA9, PA10 USART1 console, 115200 baud
PC13 the board LED — the alarm has gone off

Commands: get — the time; set 2026-09-21 12:34:56 — set it; alarm 07:30 — an alarm every day; trim +37.0 — enter the measured crystal error in ppm.

The F103 RTC chain: a 32768 Hz crystal, the PRL prescaler, CAL pulse skipping, the 32-bit seconds counter and the alarm


198.2 Time is a single number

The RTC counter is 32 bits of seconds. Counting from 2000-01-01 00:00:00 (a Saturday) gives headroom until 2136-02-07 06:28:15; counting from 1970, it would run out in 2106. Converting the number to a date and back is done by the functions cal_from_secs and cal_to_secs: division by 86400, stepping through the years with leap years taken into account, stepping through the months.

The check: 20000 random moments from 2000 to 2135, plus the second after "February 28 23:59:59" for every year — not a single mismatch with the Python calendar, neither in the date nor in the day of the week; the reverse conversion returns the same number.

Why not store the date in registers field by field? Then you would have to handle the carries yourself: 59 seconds → a minute, December 31 → a year. With a single number the counter does the carries, and arithmetic like "8 hours from now" or "how much time has passed" is just a subtraction.


198.3 RTC registers: write through configuration mode

The RTC lives in its own domain and is clocked from the crystal, not from the bus. That is why writes to its registers go through a "gateway":

  1. wait for RTOFF = 1 — the previous write has finished;
  2. CNF = 1 — configuration mode;
  3. write PRL, CNT or ALR (each is two 16-bit halves);
  4. CNF = 0 — the write goes into the RTC domain;
  5. wait for RTOFF = 1 again.

After a processor reset, until the bus clock has synchronised with the RTC, you must not read: clear RSF and wait until the block sets it by itself. Before all of this, enable writes to the backup domain: PWR_CR.DBP = 1.

First power-on, or a dead battery? The firmware puts the marker 0xA5A5 into BKP_DR1. The BKP registers are powered from VBAT and survive a reset. If there is no marker, the domain is reset, the crystal is started, the prescaler is set to 32767 (32768 / (32767 + 1) = 1 Hz) and the counter to 0. If the marker is there, the time is running, and nothing may be touched.

Reading 32 bits from two halves. Between reading CNTH and CNTL the counter may pass from 0xFFFF to 0x10000. So the upper half is read twice: if they do not match, read again.


198.4 Rate correction: slowing down only

A watch crystal is not accurate: ±20 ppm is common, and that is 1.7 seconds a day, or almost a minute a month. The F103 has a correction: CAL = how many crystal pulses out of every 2²⁰ to throw away. The step is 10⁶ / 2²⁰ = 0.954 ppm, the limit is 127 steps, 121 ppm. But only in one direction: you can throw a pulse away, but you cannot add one.

The trick: change the prescaler. A prescaler of 32766 instead of 32767 speeds the clock up by 30.5 ppm; then CAL slows it down to the exact rate. For a crystal running fast by more than 121 ppm, the other way round — 32768. The program takes the prescaler closest to the standard one for which CAL gets within half a step:

Crystal Uncorrected PRL CAL Residual Per month
+150 ppm +13.0 s/day 32768 125 +0.26 ppm +0.66 s
+130 ppm +11.2 s/day 32768 104 +0.29 ppm +0.74 s
+90 ppm +7.8 s/day 32767 94 +0.35 ppm +0.90 s
+37 ppm +3.2 s/day 32767 39 −0.19 ppm −0.50 s
+5 ppm +0.4 s/day 32767 5 +0.23 ppm +0.60 s
−10 ppm −0.9 s/day 32766 22 −0.46 ppm −1.20 s
−25 ppm −2.2 s/day 32766 6 −0.20 ppm −0.53 s
−40 ppm −3.5 s/day 32765 22 +0.06 ppm +0.14 s
−60 ppm −5.2 s/day 32765 1 +0.08 ppm +0.21 s

Clock drift over 30 days for +37 and −25 ppm crystals: uncorrected and with the chosen PRL and CAL

How to measure the crystal error. The CCO bit in BKP_RTCCR outputs the crystal frequency divided by 64 (512 Hz) on the TAMPER pin (PC13; the LED is not needed there while you measure), and you measure it with a frequency counter. The frequency counter's reference must be an order of magnitude more accurate than what is measured: for ±20 ppm, no worse than ±2 ppm. The frequency counter from section 197 does not give that: its 8 MHz crystal is itself off by ±10…30 ppm (section 174), so the instrument will show the difference between two crystals, and a "correction" based on it can make the rate worse. What works is an instrument with a temperature-compensated oscillator, or the pulse-per-second output of a satellite navigation receiver. The second method needs no instruments: set the time, and a week later compare it with an accurate clock; 1 second in 7 days is 1.65 ppm.

The correction stays in the backup domain: PRL in the RTC, CAL in BKP_RTCCR.


198.5 The alarm: a single comparison

The ALR register is compared with the counter; when they match, the ALRF flag is set. An alarm "every day at 7:30" is the nearest 7:30 moment strictly after the current one; after it goes off, the firmware sets the next one — a day ahead. The run:

Now Alarm Goes off
2026-09-21 07:29:59 07:30 in 1 s
2026-09-21 07:29:59 07:29 2026-09-22 07:29:00 — tomorrow
2026-09-21 07:29:59 00:00 2026-09-22 00:00:00
2028-02-28 23:00:00 06:00 2028-02-29 06:00:00 — the leap day

The alarm is also a number in the RTC domain, so it survives a processor reset. You can check the flag in an interrupt (EXTI line 17) — then the alarm wakes the processor even from Stop mode (section 10).


198.6 Console and firmware

Command Reply
get 2026-09-21 07:29:59 пн
set 2026-09-21 12:34:56 ok 2026-09-21 12:34:56 пн
set 2027-02-29 00:00:00 ? дата: … — 2027 is not a leap year
alarm 07:30 будильник 2026-09-22 07:30:00 вт
trim +37.0 PRL=32767 CAL=039
trim +250.0 ? вне диапазона коррекции

Built with arm-none-eabi-gcc -Os for Cortex-M3: 5356 bytes of Flash, no variables in RAM (everything is on the stack). Of that, 2220 bytes (41 %) are software double arithmetic from the compiler library used to choose the correction: addition and subtraction, multiplication and division, comparison.


198.7 What to improve


198.8 Pitfalls

The time is reset at every reflash. The marker in BKP_DR1 is not checked: the firmware configures the RTC from scratch every time.

A write to the RTC "does not go through". You did not wait for RTOFF, did not set CNF, or did not enable DBP — the domain registers are write-protected.

Once every 18 hours the time jumps by 65536 seconds. The upper and lower halves of the counter were read on different sides of a carry.

The clock runs slow, and CAL does not help. CAL only slows it down. Decrease the prescaler.

The alarm went off only once. After it went off, nobody set it again.

The LED on PC13 is dim or disturbs the crystal. The PC13–PC15 pins are powered from the RTC domain and are weak: no faster than 2 MHz at 30 pF and not a current source. Connect the LED only from the supply to the pin and light it with a low level (as on the BluePill and in the firmware).

The clock runs from the battery, but after a battery change it starts from zero. Change the battery while the main power is on — then the domain does not lose power.


198.9 Summary

Next: section 198а "Mini-project: a battery-powered sensor — a year on one CR2032" — the clock runs from the mains; now a device that lives on a battery.

Open in the interactive handbook