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.
- 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. - Keep constants in flash. The AVR is a Harvard machine and flash is a separate address space, so a plain
constarray 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 declaredPROGMEMand read withpgm_read_byteorpgm_read_word. - Integers only. There is no
floatordoubleanywhere 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. - Pack what can be packed. Display settings, button state, RTC flags, dates and RTC timestamps are bitfields, sized to the range of each field.
- 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, , is above .
- Stay away from
StringandPrint. Arduino’sStringallocates on the heap at runtime, and inheriting fromPrintwould pull in a virtual table and formatting code for features the display never uses. - 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 asectioncounter 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 aProgram, a struct of three function pointers:setup,loopandexit.
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:
- Call
exit()on the active state of the old subsystem. - Call the old subsystem’s
cleanuproutine, if it has one, and mark its state asNONE. - Make the new subsystem current and call its
beginroutine, if it has one. - Request state
INIT(0) in the new subsystem. - Call
dispatch(), which runssetup()and thenloop()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 counting seconds since midnight, . The editor steps it by a unit with one addition and one modulo:
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 , 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, , where is the length of month in year . 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:
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 (stored in flash) and a few integer divisions:
Here is 1 for January and February and 0 otherwise, and means Sunday. The function returns , 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 () and the current millis() as baseMillis (). From then on, with the current millis():
After each refresh , and moves forward by whole seconds only:
That way the leftover milliseconds are carried over and never dropped by the integer division. millis() overflows after 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:
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 and again at , the count starts at and stops at , where is the 20 ms debounce and is how long the main loop took to notice the first press. The measured interval is , 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:
Pressedfor navigation and anything cheap to undo.Heldfor actions that shouldn’t happen by accident, like cancelling the alarm or stopping it from ringing.Releasedcombined withwasHeld()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 . 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:
with 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:
-Osto optimize for size.-fltofor 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-sectionswith--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++23and a long list of warnings (-Wall -Wextra -Wpedantic -Wshadow -Wcast-qual -Wlogical-op -Wmissing-declarationsamong others).-MMD -MPfor 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 withpgm_read_wordbefore 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
Heldrepeats 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
- Hitachi, HD44780U (LCD-II) Dot Matrix Liquid Crystal Display Controller/Driver, datasheet, 1998.
- Maxim Integrated, DS3231 Extremely Accurate I2C-Integrated RTC/TCXO/Crystal, datasheet.
- NXP Semiconductors, PCF8574 Remote 8-bit I/O expander for I2C-bus, datasheet.
- Microchip Technology (Atmel), ATmega328P and ATmega32U4 datasheets.
- Huang, H. W. (2009). The HCS12/9S12: An Introduction to Software and Hardware Interfacing (2nd ed.), pp. 323-338. Delmar Learning.
- T. Sakamoto, day-of-week algorithm, as posted to the comp.lang.c newsgroup.
This text by Mastedore. All rights reserved.