< Back 

Owake

2026-03-06

Retro alarm clock, chronometer and digital clock for AVR. Built on a hierarchical finite state machine and a custom HD44780 LCD driver.

C++AVREmbedded
Go to quick view

Owake: design notes on an alarm clock firmware for 8-bit AVR

Mastedore, September 2026. Describes firmware version 1.2.0.

Abstract

Owake is an alarm clock, digital clock and chronometer for an ATmega328P or ATmega32U4 driving a 16x2 character LCD, three push buttons, a buzzer and an optional DS3231 real-time clock. This document explains the technical decisions behind the firmware. The program is organized by TitaniumFSM, a three-level state machine of subsystems, states and programs. Time is kept as 32-bit seconds since midnight plus a 16-bit packed date. Every screen is drawn with the eight custom characters the display controller offers, and a small driver talks to the DS3231. Almost all of these choices come from one constraint: the target has 2 KB of SRAM (2.5 KB on the 32U4). Because of it, the firmware allocates everything before setup() runs, keeps its constant data in flash, packs flags into bitfields and never touches floating point. Some parts of the code are more repetitive than they could be, and in most of those places the generic version would have cost memory or an extra indirection that an 8-bit core pays for on every call.

1. Introduction

The project started from a simple complaint. Using a phone as an alarm means keeping it on the nightstand, and dedicated alarm clocks exist precisely so that doesn’t have to happen. Owake is the author’s answer: a small clock built from about 23 USD of parts (the bill of materials is in the repository), running firmware written from scratch, and meant for people who like to open things up and change them.

The device has four modes, reached from a home menu:

  • Clock, with a large-digit view and a compact view that adds the date and temperature.
  • Alarm, a countdown that rings when it reaches zero.
  • Chronometer, with millisecond resolution and two display fonts.
  • About, an animated credits screen.

Sections 2 and 3 describe the hardware and the resource budget that shapes the rest. Section 4 gives the overall structure, and section 5 covers TitaniumFSM, the state machine everything else plugs into. Sections 6 to 9 go through time handling, the individual modes, input and the display. Section 10 documents the DS3231 driver. Sections 11 to 13 cover reliability, the build and a memory breakdown, and section 14 lists what is still missing.

2. Hardware

The reference build is an Arduino Nano or Uno (ATmega328P at 16 MHz). The same firmware builds for the ATmega32U4 found on the Leonardo, Micro and Pro Micro. The peripherals are:

Signal Pin Notes
OK button D4 active high, external pull-down
Up button D5 active high, external pull-down
Down button D6 active high, external pull-down
Alarm output D7 high while the alarm rings
Buzzer D11 active buzzer by default
I2C SDA / SCL A4 / A5 on the 328P, D2 / D3 on the 32U4 LCD backpack at 0x27, DS3231 at 0x68

Each button connects its input to +5 V and has a 10 kOhm resistor to ground, so a pressed button reads HIGH. That is why the firmware configures the pins as plain INPUT and not INPUT_PULLUP.

The display is a 16x2 HD44780 module behind a PCF8574 I2C expander. The expander’s eight outputs go to RS, RW, E, the backlight transistor and the four upper data lines (D4-D7), which forces the LCD into 4-bit mode. The LCD and the DS3231 share one I2C bus that runs at the 100 kHz default of the Arduino Wire library.

Both kinds of buzzer work. The short interface beeps (opening the alarm editor, confirming, cancelling) call tone() directly, and the square wave it produces makes an active buzzer sound just like a passive one. Only the ringing depends on the part. beep() holds the pin high for an active buzzer and calls tone() for a passive one, chosen at compile time with BUZZER_TYPE in main.hpp. The alarm output on D7 simply mirrors the ringing state, so a lamp or a louder sounder can be attached (through a transistor or a relay) without touching the firmware.

The hardware timers are split so that nothing collides:

Timer Used by
Timer0 Arduino core: millis(), micros(), delay()
Timer1 Owake: 1 kHz tick for the chronometer
Timer2 (328P) / Timer3 (32U4) tone(): interface beeps, and the ringing with a passive buzzer
Watchdog hang recovery, section 11

3. Constraints and the rules that come from them

The ATmega328P has 32 KB of flash, 2 KB of SRAM and 1 KB of EEPROM. The 32U4 has the same flash and EEPROM and 2.5 KB of SRAM, but its bootloader takes 4 KB of flash and its USB stack takes a share of the RAM. SRAM is the scarce resource. It holds every global variable, the heap and the stack, and running out of it doesn’t produce an error message. The stack silently grows into the globals and the program starts doing strange things.

The firmware follows a handful of rules to stay well away from that edge.

  1. Allocate once, at startup. Owake and its libraries perform exactly three heap allocations, all of them in the LCD constructor, which runs during static initialization before setup(). Nothing is ever freed, so the heap cannot fragment.
  2. Keep constants in flash. The AVR is a Harvard machine and flash is a separate address space, so a plain const array is still copied into SRAM at boot. Glyph bitmaps, the digit composition table, days per month, the weekday offsets, the temperature fraction table and the credits script are all declared PROGMEM and read with pgm_read_byte or pgm_read_word.
  3. Integers only. There is no float or double anywhere in the firmware or its libraries, and the RTC driver’s floating point temperature function is commented out. Fractions that have to be shown, like the quarter degrees of the temperature sensor, come from lookup tables.
  4. Pack what can be packed. Display settings, button state, RTC flags, dates and RTC timestamps are bitfields, sized to the range of each field.
  5. Choose integer widths per variable. A button timestamp is 16 bits because a press never lasts longer than a minute. A time of day is 32 bits because its largest value, 86399, is above 216−1=65535.
  6. Stay away from String and Print. Arduino’s String allocates on the heap at runtime, and inheriting from Print would pull in a virtual table and formatting code for features the display never uses.
  7. Let the toolchain remove the rest. Link-time optimization together with per-function and per-object sections means that code nobody calls, such as most of the Arduino core, doesn’t end up in the binary (section 12).

These rules explain the parts of the code that can look repetitive at first. Take the time editor and the date editor in tedd.cpp: they are two nearly identical functions. Merging them into a template would make the source shorter, but the compiler would still instantiate two copies, and the binary would come out about the same. The same goes for the per-state namespaces in section 5. Each one is a few free functions with no object data, which avoids the per-object pointers and constructors a class hierarchy would add (section 5.2).

4. Program structure

The Arduino core provides main(), which calls setup() once and loop() forever. Owake’s loop() is a single line, fsm.go(), and everything else lives inside the state machine.

setup()   timers, LCD, RTC, buttons, watchdog
loop()    fsm.go()
            |
            OwakeFSM         which subsystem is active
              |
              Subsystem      which state is active, section, data word
                |
                State        -> Program { setup, loop, exit }

The source is split along the same lines:

File Contents
src/main.cpp hardware setup, the Timer1 interrupt, the static object graph
src/fsm.cpp, src/subsystem.cpp TitaniumFSM
src/state/*.cpp one file per subsystem: menu, clock, alarm, chrono, info
src/time.cpp time and date arithmetic
src/tedd.cpp the shared time and date editor
src/thislcd.cpp glyph sets and the two-row digit renderer
lib/rtc3231 DS3231 driver, section 10
lib/SolidCrystalI2C, lib/Beverly, lib/fault, lib/Facebits display, buttons, fault screen and bitmask helpers, each a separate project

5. TitaniumFSM

TitaniumFSM is a state machine framework written by the author. Owake uses a modified copy of its Community edition. Its job is to decide which piece of code runs on each pass of loop(), and to call setup and teardown code at the right moment when the active screen changes.

5.1 Three levels

The machine has three levels:

  • The FSM (OwakeFSM) owns the subsystems and knows which one is active.
  • A subsystem (Subsystem) is one mode of the device, such as Clock or Alarm. It owns a small array of states, knows which one is active, and carries a section counter and a pointer to one 32-bit data word.
  • A state (State) is one screen or behavior within a mode, such as “setting the alarm” or “alarm ringing”. Its behavior is a Program, a struct of three function pointers: setup, loop and exit.

Owake has five subsystems and eleven states:

Subsystem States
Menu Main
Clock Set, BigView, View
Alarm Set, Running, Ringing
Chronometer Stop, Running, List
About Main

Subsystems can also have a SubsystemRoutine, a pair of optional functions called when the subsystem is entered (begin) and left (cleanup). The menu uses them to draw and erase its frame, and the chronometer uses begin to reset its counter and load its font.

5.2 A state is three functions

States are free functions grouped by namespace, and a macro declares the three of them:

#define STATE_FUNCTIONS \
    void setup(StateCtx* ctx); \
    void loop(StateCtx* ctx);  \
    void exit(StateCtx* ctx)

namespace Alarm {
    namespace Set     { STATE_FUNCTIONS; }
    namespace Running { STATE_FUNCTIONS; }
    namespace Ringing { STATE_FUNCTIONS; }
}

The obvious alternative would be an abstract State class with three virtual methods and one subclass per state. On AVR, avr-gcc keeps virtual tables in SRAM like any other initialized data, and every object carries a hidden pointer to its table. The function-pointer version pays about the same for the table itself, since the programs[] array holds 6 bytes per state, 66 in total. What it saves is everything around the table: a state has no object, no constructor and no hidden pointer, and the dispatch table is one array anyone can read instead of something the compiler generates per class. A state’s whole context arrives through StateCtx, which points back to the State, and from there to the owning subsystem and the FSM.

5.3 A static object graph

Everything the machine needs is a global in main.cpp: the programs[] table, one State array per subsystem, the systems[] array and the fsm itself. The constructors wire the back pointers. A Subsystem sets sys on each of its states, and OwakeFSM sets fsm on each subsystem. C++ initializes globals within one translation unit in declaration order, and the declarations are ordered so that each object exists before anything points to it. By the time setup() runs, the graph is complete and never changes again.

That has one maintenance cost. A state’s position in its subsystem’s array has to match its numeric ID, and those IDs are defined in types.hpp. The comment above programs[] in main.cpp calls this out, because nothing checks it at compile time.

5.4 Identifiers

OwakeStateID is an 8-bit enum class whose values are array indices local to a subsystem. That is why CLOCK_SET, ALARM_SET and CHRONOMETER_STOP are all 0. INIT is also 0, so entering any subsystem starts at its first state. The top of the 8-bit range is reserved for sentinels: EXIT (252), CLEANUP (253), NONE (254) and INVALID (255). A real state index will never reach them. OwakeSubsystemID works the same way and indexes systems[].

The menu relies on that numbering directly. Its selected item is the subsystem ID minus one, so selecting “Clock” (item 0) requests subsystem 1.

5.5 Transitions are requests

States never switch the machine directly. Subsystem::shiftMode() and Subsystem::requestShiftSubsystem() only record the target in a next field. The change is committed at the start of the next dispatch() (for states) or go() (for subsystems). Code that asks for a transition always runs to its end first, so a state is never torn down while its own function is still on the call stack. Setup and teardown also happen at one well-known point, instead of wherever the request happened to come from.

On a subsystem change, OwakeFSM::go() does the following, in this order:

  1. Call exit() on the active state of the old subsystem.
  2. Call the old subsystem’s cleanup routine, if it has one, and mark its state as NONE.
  3. Make the new subsystem current and call its begin routine, if it has one.
  4. Request state INIT (0) in the new subsystem.
  5. Call dispatch(), which runs setup() and then loop() of that state.

Within a subsystem, dispatch() runs exit() on the old state, setup() on the new one and then its loop(). There is one exception. If setup() itself requests another state, loop() is skipped and the requested state takes over on the next pass. Clock uses this. Its first state, Set, looks at the RTC and at whether the time was already entered. If there is nothing to ask, its setup() goes straight to the saved view, and the time editor in its loop() never runs.

5.6 Sections

Every subsystem has a section byte and a total_sections limit. It is a small piece of state that belongs to the mode rather than to any single screen, and it survives while the user is in other modes. sectionMod() moves it with wrap-around, adjusting for the fact that % in C++ keeps the sign of its left operand (-1 % 4 is -1, not 3).

Each subsystem uses it differently. The menu stores the highlighted item. The clock packs two flags into it (section 6.5). The alarm resets it when leaving its set screen.

5.7 One data word per subsystem

Each subsystem gets a pointer to a single uint32_t. A comment in subsystem.hpp shows that the original plan was a void* context. The 32-bit word won because the modes that use it need exactly one number of that size (the clock’s time of day, the alarm’s remaining seconds), and a typed pointer avoids casts. Anything else a mode needs lives in static variables in its own .cpp file, where no other code can reach it.

5.8 Cooperative scheduling

There is no RTOS and no preemption. The machine is only as responsive as its slowest loop(). Most loops do one pass of work and return, but three places deliberately keep control until the user acts: the menu, the time and date editor, and the credits animation. Each of them feeds the watchdog itself (section 11).

Blocking would be a problem if timekeeping depended on the main loop, and it doesn’t. The clock derives the current time from millis() or from the RTC whenever it is shown again, instead of adding a second on each pass, so time spent in the menu is never lost. The one thing that has to keep running no matter what, the chronometer count, happens in an interrupt.

5.9 Invalid states

If either next or current is ever INVALID, the machine calls FAULT(INVALID_STATE). The fault library prints the reason on the LCD and lets the watchdog reset the board two seconds later. The same function covers impossible switch branches, like a menu section outside 0-3.

6. Time

6.1 Time of day

The time of day is a uint32_t t counting seconds since midnight, 0≤t<86400. The editor steps it by a unit u∈{1,60,3600} with one addition and one modulo:

tnext=(t+u)mod86400tprev=(t+86400−u)mod86400

Adding a full day before subtracting keeps the unsigned value from wrapping below zero. PackedTime, a bitfield of hour, minute and second, exists only to split that number into digits for display.

6.2 Dates

A date is a PackedDate of 16 bits: a 7-bit year counted from the project’s epoch of 2026 (so 2026+[0,27−1], that is 2026 to 2153), a 4-bit month and a 5-bit day. operateDate() implements the rules the editor needs:

  • Moving the day forward past the end of the month rolls into the next month, and past December into the next year.
  • Moving the day backward wraps inside the same month, which is how a field editor is expected to behave.
  • After a month or year change, the day is clamped, d←min⁡(d,D(m,y)), where D(m,y) is the length of month m in year y. So 31/01 becomes 28/02, and 29/02 in a leap year becomes 28/02 the year after.
  • The comparison happens before the increment. Because the day is a 5-bit field, 31 + 1 would silently wrap to 0 before any “greater than” test could see it.

Leap years follow the full Gregorian rule:

leap(y)⟺(4|y∧100∤y)∨400|y

With a range that ends in 2153, the year 2100 is inside it, and it is not a leap year.

6.3 Day of the week

The DS3231 has a day-of-week register that has to hold a value from 1 to 7. dayOfWeek() computes it with Sakamoto’s method, which needs a 12-entry offset table T (stored in flash) and a few integer divisions:

y′=y−[m<3]w=(y′+⌊y′/4⌋−⌊y′/100⌋+⌊y′/400⌋+Tm+d)mod7

Here [m<3] is 1 for January and February and 0 otherwise, and w=0 means Sunday. The function returns ((w+6)mod7)+1, which is 1 for Monday through 7 for Sunday.

6.4 Keeping time without an RTC

Without a DS3231, the clock is built on millis(). When the user enters the time, the firmware stores that value as baseTime (t0) and the current millis() as baseMillis (n0). From then on, with n the current millis():

t=t0+⌊n−n01000⌋

After each refresh t0←t, and n0 moves forward by whole seconds only:

n0←n−((n−n0)mod1000)

That way the leftover milliseconds are carried over and never dropped by the integer division. millis() overflows after 232 ms, about 49.7 days, and unsigned subtraction keeps working across the overflow as long as the clock gets refreshed at least once in that span. If the clock was not on screen for a while and the new time goes past 86,399, a while loop subtracts whole days and advances the date once for each midnight that passed.

This mode is only as accurate as the board’s 16 MHz clock source. On typical Arduino boards that is a ceramic resonator or an inexpensive crystal, which drifts far more than the RTC and gets worse with temperature. That gap is the reason for the DS3231 path.

6.5 Keeping time with the DS3231

With an RTC, every refresh reads the time and date from the chip. The clock subsystem’s section holds two flags. Bit 0 selects the view (0 is the large digits, 1 the compact view), and bit 1 means the time source is the DS3231. Toggling the view only changes bit 0, so the mode reopens in whichever view was last used, with whichever source is available.

Entering the clock goes through Clock::Set::setup(), which decides whether the time has to be asked for:

  • If the RTC answers and its oscillator-stop flag (OSF) is clear, the stored time is trusted and the saved view opens right away.
  • If the RTC answers but OSF is set, the chip lost power at some point and its time is meaningless, so the editor asks for the time and date and writes them to the chip.
  • If there is no RTC but it was present before, the millis() count was never synchronized with it, so the time is asked for again.
  • If there is no RTC and the time was never entered, it is asked for. If it was entered, the millis() view opens.

If writing to the chip fails, the clock keeps running from millis(), since baseTime was already set when the user finished the editor.

7. The modes

7.1 Menu

The menu runs in its own loop until OK is pressed. Up and down change the highlighted item, and the item is redrawn on the second row. The arrows at both ends of that row live on the display’s overlay layer, so rewriting the item text underneath never erases them.

7.2 Clock

BigView draws hours, minutes and seconds with two-row digits (section 9.3), plus the temperature in whole degrees when an RTC is present. View uses normal characters and adds the date and the temperature with its fractional part. OK toggles between the two and Down returns to the menu.

7.3 Alarm

The alarm is a countdown: the value entered is how long until it rings, not a time of day. That keeps it independent of whether the clock has been set at all. The Running state subtracts whole elapsed seconds and moves its timestamp by exactly that amount, which keeps the countdown in step with millis() over long periods, the same trick the clock uses.

Two details in Running exist for nighttime use:

  • The backlight turns off five seconds after the last button press, so the display doesn’t light up a dark room all night. Any button turns it back on.
  • Cancelling takes two steps. Holding OK shows “Cancel?” on the overlay layer, on top of the countdown, and pressing OK again confirms it. Up or Down hides the prompt. A single accidental press at 3 a.m. can’t disable the alarm.

When the count reaches zero, Ringing turns the backlight on, raises the alarm output on D7 and beeps in 500 ms bursts. With an active buzzer each beep blocks, so the OK button is only sampled about twice a second, and stopping the alarm requires holding OK for one to two seconds. That is more or less what an alarm should ask of someone who is half awake. With a passive buzzer tone() returns right away, the button is checked on every pass, and the normal 600 ms hold is enough.

7.4 Chronometer

The chronometer counts in a Timer1 interrupt configured in CTC mode with a prescaler of 64 and a compare value of 249:

f=16MHz64⋅(249+1)=1kHz

which is one tick per millisecond. The interrupt stays enabled all the time, and while the chronometer is stopped it costs one flag test per millisecond. Enabling it only while counting would save those few cycles, at the price of touching timer registers from more places in the code.

While running, the interrupt also polls the OK button through the same debouncing code the main loop uses, and it stops the count on a new press. This makes the stop edge independent of the main loop, which spends milliseconds on every redraw pushing characters over I2C. The start edge is detected by the main loop and the stop edge by the interrupt, but both go through the same debounce. If the user presses at ta and again at tb, the count starts at ta+δ+j and stops at tb+δ, where δ is the 20 ms debounce and j≥0 is how long the main loop took to notice the first press. The measured interval is (tb−ta)−j, give or take one tick, so the debounce cancels out and only the start jitter remains.

The tick counter is 32 bits, and the AVR reads it one byte at a time, so the interrupt could change it in the middle of a read. The main loop therefore copies it inside ATOMIC_BLOCK. The display shows mm:ss.mmm in one of two tall fonts, or in normal characters, and the right edge of the screen shows the current settings. A List state for lap times is reserved in the state table (with room for ten entries) but not implemented yet.

7.5 About

The credits are a small byte script interpreted by typeEffect(). Printable bytes are typed one at a time, bytes 0 to 7 print the custom glyphs that form the Owake logo, and three control bytes mean “next row” (0x0B), “pause and erase the screen backwards” (0x0A) and “end” (0x0C). The script is 175 bytes and lives in flash. It used to be a local array, which put all of it on the stack every time the credits ran.

The script is stored as hex bytes rather than as a string literal. There is no technical reason for it. The author just likes it that way. Since it is walked with a uint8_t index, it can’t grow past 255 bytes. Two scripts in tools/ encode readable text (with escapes such as \0 for a glyph, \n for a new row and \p for a new page) into the C++ array and decode it back. The encoder also checks the length limit and warns about rows wider than the display.

8. Input and editing

8.1 Button events

Button handling comes from Beverly, a separate library by the same author. It debounces each input for 20 ms and reports Pressed, Held (after 600 ms, and on every call while the button stays down), Released and, after a release, whether the press had been a long one. Owake uses them with a consistent pattern:

  • Pressed for navigation and anything cheap to undo.
  • Held for actions that shouldn’t happen by accident, like cancelling the alarm or stopping it from ringing.
  • Released combined with wasHeld() where a tap and a long press on the same button do different things, as in the chronometer, where a tap on Up changes the start mode and holding it changes the font.

8.2 The time and date editor

Setting the clock, setting the date and setting the alarm all use one editor (tedd.cpp). The screen is divided into five sections: a cancel button, three two-digit fields and a done button. A five-entry table maps each section to the time unit it edits, and the date editor reuses the same table by adding a fixed offset, so field 1 edits hours in the time editor and days in the date editor. On a field, Up and Down change the value and OK moves to the next field. On the two buttons, Up and Down move between sections.

The editor works on a copy of the value and only writes it back on “done”, so cancelling leaves the caller untouched. Field positions are computed rather than stored, because every field is two digits plus a separator and starts three cells after the previous one, both in hh:mm:ss and in dd/mm/yyyy. Moving the hardware cursor costs an I2C transaction, so the editor remembers the section the cursor was last placed on and only moves it when that changes.

9. Display

9.1 Buffered output

All drawing goes through SolidCrystalI2C, the author’s HD44780 library, documented separately. For Owake, the relevant property is that writes land in an in-memory copy of the screen and only the cells that changed are sent on flush(). A second layer, the overlay, can be drawn on top of the normal text without destroying it. Owake uses it for the menu arrows and the “Cancel?” prompt.

9.2 Eight custom characters

The HD44780 can hold eight user-defined 5x8 characters, and Owake needs more than eight: arrows, mode icons, the parts of the logo and the pieces of two big-digit fonts. The firmware splits them into sets that are never on screen at the same time:

Set Used by Contents
Home symbols menu, alarm, editors left arrow, bar, right arrow, hourglass, clock, house, chronometer, person
Font 0 credits five logo glyphs, copyright sign, a winking face, one spare shape
Font 1, Font 2 big clock, chronometer eight half-digit shapes each

Uploading a glyph costs nine LCD bytes over I2C. The library remembers which bitmap sits in each slot, and defineHomeSymbols() checks only the first one. If the left arrow is still in slot 0, the whole home set is still loaded, and the other seven uploads are skipped.

9.3 Ten digits from eight shapes

The big digits are two character cells tall. Ten digits times two halves would need twenty shapes, and only eight slots exist. The trick is that many halves are shared, and several shapes drawn as a “bottom” work equally well on top. Each digit is stored as a 16-bit pair of slot numbers (low byte on top, high byte below) in a flash table:

Digit Top Bottom
0 ZERO_0 ZERO_1
1 ONE_1 ONE_1
2 ZERO_0 TWO_1
3 ZERO_0 THREE_1
4 ZERO_1 ONE_1
5 FIVE_0 ZERO_1
6 TWO_1 EIGHT_1
7 SEVEN_0 ONE_1
8 EIGHT_1 EIGHT_1
9 EIGHT_1 ONE_1

Both tall fonts load their shapes into the same slots in the same order, so one table serves both. Separators such as : and . go through the same routine as the digits, encoded as their ASCII codes, and come out as blank cells in the tall fonts.

10. The rtc3231 driver

lib/rtc3231 is a minimal driver for the Maxim DS3231, written for this project.

10.1 The chip

The DS3231 is a real-time clock with a temperature-compensated crystal oscillator. It is specified at plus or minus 2 ppm between 0 and 40 degrees C, which over a year is at most 2×10−6×31536000 s≈63 s. It keeps time in BCD registers, has a coin cell input so it keeps counting when the clock is unplugged, and measures its own temperature for the compensation. Its registers are:

Address Contents
0x00 - 0x06 seconds, minutes, hours, day of week, day, month and century, year
0x07 - 0x0D two alarms
0x0E control
0x0F status, including the oscillator-stop flag (bit 7)
0x11 - 0x12 temperature, integer part and fraction

10.2 Reading and writing

Every read follows the same pattern. The driver writes the register address, ends that write with a repeated start instead of a stop (endTransmission(false)), and requests the bytes, so the address and the read form one uninterrupted bus operation. A time read asks for all seven time registers in one request. That matters because the chip copies its counters into a buffer at the start of a burst read, so all seven values come from the same instant. Reading them one by one could catch a rollover between minutes and hours.

updateTime() checks that all seven bytes arrived before decoding anything. A short read returns error code 4, the same value Wire uses for “other error”, and leaves the previous timestamp in place. The clock then keeps showing the last good reading.

10.3 The timestamp type

TimestampDS3231 stores a full date and time in five bytes: 32 bits of bitfields for second, minute, hour, weekday, day, month, AM/PM flag, 12/24h flag and halt flag, plus one byte for the year and the century bit. The constructor sets every field to a valid value (01/01/00, 00:00:00, 24h, weekday 1), so a timestamp that only gets some of its fields set never sends an out-of-range value to the chip.

The setters clamp their input, and some of them depend on each other. setHour() clamps to 0-23 or 1-12 depending on the hour mode, so the mode goes first. setDay() clamps against the month and the leap year, so the year and the month go before the day. Clock::Set follows that order when it writes the time.

10.4 Hours and centuries

The hours register means different things depending on its bit 6. In 12-hour mode bit 5 is the AM/PM flag. In 24-hour mode that same bit is the tens digit for hours 20 to 23. The decoder reads the mode bit first and masks the register accordingly, so 21:00 is read as 21 and not as 1 PM or 01:00. Owake writes in 24-hour mode but can read a chip set to 12-hour mode by another device, and converts the result.

The chip stores two-digit years, with a century bit in the month register. Owake converts between those and its own 2026-based years. It sets the century bit for years from 2100 on, and the driver’s leap-year test only looks at the two-digit year. The DS3231 itself treats 2100 as a leap year, so the two agree.

10.5 The oscillator-stop flag

Bit 7 of the status register (OSF) is set whenever the oscillator has stopped, for example on first power-up or after the backup battery died. The chip never clears it by itself. The driver reads it in start(), and setTime() clears it after a successful write, since at that point the time is trustworthy again. Without that last step, the clock would ask for the time on every visit, forever.

Several 1-bit fields, OSF among them, are assigned with an explicit != 0. A C++ bitfield keeps only the low bits of the value it is given, so assigning status & 0x80 directly to a one-bit field always stores 0. An early version of the driver made exactly that mistake and never saw the flag.

10.6 Temperature without floating point

The temperature is a 10-bit two’s-complement value in quarter degrees, split across two registers. The driver exposes it as two integers: itemperature() returns the whole degrees from register 0x11 as a signed byte, and temperatureMantis() maps the top two bits of register 0x12 through a four-entry flash table to 0, 25, 50 or 75. In terms of the two registers:

T=R11h+14⌊R12h64⌋ ∘C

with R11h read as a signed byte. The display prints the two parts separated by a dot. The chip updates the reading once per 64-second conversion cycle, so reading it more often gives nothing new.

10.7 Errors and presence

Every function that talks to the chip returns the Wire status code (0 for success, 2 for an address NACK and so on), or one of two driver codes: 4 for a short read and 7 for “not started”. Before reading or writing the time or the temperature, isAvailable() sends an empty transmission and checks that the chip acknowledges its address. That costs one extra bus transaction per call. In exchange, a missing module gets reported as an error code instead of decoding whatever the bus returns, and the next time the clock opens, it notices the RTC is gone and asks for the time.

11. Reliability

A clock that freezes is worse than one that restarts, so Owake leans on the watchdog. The watchdog is enabled with a 4-second timeout at the end of setup() and fed from OwakeFSM::go() and from each of the three blocking loops. If an I2C transaction hangs, which can happen on a bus with a loose wire, the board resets within four seconds and comes back to the menu.

Two details make that work. At boot, the watchdog reset flag is cleared before the watchdog is disabled. On the AVR, a set reset flag forces the watchdog to stay enabled, and without that step a watchdog reset would trigger another reset during setup(). Second, faults are handled by the same mechanism. FAULT() prints the reason on the LCD, arms the watchdog at 2 seconds and parks the CPU, so every fatal error ends in a clean restart with a message the user can read first.

The README warns about one hardware case. Some old Arduino Nano clones ship a bootloader that doesn’t handle watchdog resets and ends up rebooting forever. Flashing Optiboot fixes it.

12. Build

Owake builds with a Makefile (the author’s Universal Tiny Project Makefile, AVR edition) instead of the Arduino IDE. It has been tested with avr-gcc 14.3 and uses these flags:

  • -Os to optimize for size.
  • -flto for link-time optimization. The whole program is visible to the optimizer at link time, so small functions get inlined across files and unused ones disappear.
  • -ffunction-sections -fdata-sections with --gc-sections, so the linker drops every function and variable nobody references, including most of the Arduino core.
  • -fno-rtti -fno-exceptions, since neither feature is used and both have a runtime cost.
  • -fno-threadsafe-statics, which removes the guard variables around function-local statics. There are no threads to protect against.
  • -std=c++23 and a long list of warnings (-Wall -Wextra -Wpedantic -Wshadow -Wcast-qual -Wlogical-op -Wmissing-declarations among others).
  • -MMD -MP for header dependency tracking.

Libraries are compiled into static archives with avr-gcc-ar. With LTO the object files contain GCC’s intermediate representation instead of machine code, and a plain ar can only index their symbols if GCC’s LTO plugin happens to be installed where binutils looks for it. Some Linux distributions don’t put it there, and the result is a link with every library symbol undefined. avr-gcc-ar loads the plugin explicitly.

The Arduino AVR core ships inside the repository, trimmed of what Owake doesn’t use (HID, SoftwareSerial, SPI, bootloaders, unused board variants). The changes are listed in core/MODIFICATIONS.md, as the LGPL requires.

13. Memory accounting

The table lists the SRAM used by Owake’s own objects, computed from the type layouts on AVR, where pointers are two bytes and structures have no padding. It doesn’t include the Arduino core or the Wire library, which on its own reserves five 32-byte buffers (160 bytes). The authoritative total comes from avr-size on the built firmware.

Object Bytes
programs[] (11 x 3 function pointers) 66
systems[] (5 subsystems x 12) 60
States (11 x 4) 44
LCD object (settings, pointers, glyph cache) 30
LCD buffers on the heap (32 text + 32 overlay + 4 dirty bits), plus 2 bytes of allocator header per block 74
Buttons (3 x 6) 18
Clock state (date, flags, base time and millis) 12
Subsystem data words (3 x 4) 12
OwakeFSM 8
Subsystem routines (2 x 4) 8
DS3231 (5-byte timestamp + 2 bytes of flags) 7
Editor state and section table 7
Interrupt counters and flags 6
Chronometer settings 4
Everything else (alarm flags, LCD ready flag, digit cursor) 4
Total 360

The whole state machine, with its tables, objects and back pointers, comes to about 190 bytes. Moving programs[] into flash would save 66 of them (section 14).

14. Limitations and future work

  • The alarm is a countdown. A time-of-day alarm using the DS3231’s own alarm registers, which the driver already defines, is the obvious next step. It would also survive a reset.
  • programs[] and the state arrays live in SRAM. The program table never changes after compilation and could move to flash, at the cost of reading each function pointer with pgm_read_word before calling it.
  • A full screen redraw takes on the order of 60 ms, because each cell is sent as an address byte and a data byte, each split into two nibbles, and each nibble costs two I2C writes. The display library’s own document discusses two ways to cut the number of transactions.
  • The chronometer’s lap list and its “hold” start mode are reserved but not implemented.
  • Holding Up or Down in the editor repeats as fast as the loop runs, which is limited mainly by I2C traffic. A proper auto-repeat rate would be nicer to use.
  • Holding OK in the clock toggles between the two views on every pass, because Held repeats while the button is down.
  • The clock always runs in 24-hour format. A 12-hour display mode is marked as a to-do in the code.
  • Negative temperatures would print wrong, because the fraction is added as a positive number to a negative integer part. An indoor clock is unlikely to see them.

References

  1. Hitachi, HD44780U (LCD-II) Dot Matrix Liquid Crystal Display Controller/Driver, datasheet, 1998.
  2. Maxim Integrated, DS3231 Extremely Accurate I2C-Integrated RTC/TCXO/Crystal, datasheet.
  3. NXP Semiconductors, PCF8574 Remote 8-bit I/O expander for I2C-bus, datasheet.
  4. Microchip Technology (Atmel), ATmega328P and ATmega32U4 datasheets.
  5. Huang, H. W. (2009). The HCS12/9S12: An Introduction to Software and Hardware Interfacing (2nd ed.), pp. 323-338. Delmar Learning.
  6. T. Sakamoto, day-of-week algorithm, as posted to the comp.lang.c newsgroup.

This text by Mastedore. All rights reserved.