< Volver 

Owake

2026-03-06

Reloj despertador retro, cronómetro y reloj digital para AVR. Construido sobre una máquina de estados finitos jerárquica y un driver propio para LCD HD44780.

C++AVREmbedded
Ir a vista rápida

Owake: notas de diseño de un firmware de reloj despertador para AVR de 8 bits

Mastedore, septiembre de 2026. Corresponde a la versión 1.2.0 del firmware.

Resumen

Owake es un reloj despertador, reloj digital y cronómetro para un ATmega328P o un ATmega32U4 que maneja una pantalla LCD de caracteres de 16x2, tres botones, un buzzer y, de forma opcional, un reloj de tiempo real DS3231. Este documento explica las decisiones técnicas detrás del firmware. El programa está organizado por TitaniumFSM, una máquina de estados de tres niveles formada por subsistemas, estados y programas. La hora se guarda como segundos desde la medianoche en 32 bits, y la fecha, empaquetada en 16. Cada pantalla se dibuja con los ocho caracteres personalizados que ofrece el controlador del display, y un driver pequeño se encarga de hablar con el DS3231. Casi todas estas decisiones salen de una sola restricción: el microcontrolador tiene 2 KB de SRAM (2.5 KB en el 32U4). Por eso el firmware reserva toda su memoria antes de que corra setup(), guarda sus datos constantes en la flash, empaqueta banderas en campos de bits y no usa punto flotante en ningún lado. Algunas partes del código son más repetitivas de lo que podrían ser, y en la mayoría de esos casos la versión genérica habría costado memoria o una indirección extra que un núcleo de 8 bits paga en cada llamada.

1. Introducción

El proyecto empezó con una queja sencilla. Usar el teléfono como alarma obliga a tenerlo en la mesa de noche, y para eso existen los despertadores. Owake es la respuesta del autor: un reloj pequeño armado con unos 23 dólares en componentes (la lista está en el repositorio), con un firmware escrito desde cero y pensado para gente a la que le gusta abrir las cosas y modificarlas.

El dispositivo tiene cuatro modos, a los que se entra desde un menú principal:

  • Reloj, con una vista de dígitos grandes y otra compacta que agrega la fecha y la temperatura.
  • Alarma, una cuenta regresiva que suena al llegar a cero.
  • Cronómetro, con resolución de milisegundos y dos fuentes de dígitos grandes.
  • Acerca de, una pantalla de créditos animada.

Las secciones 2 y 3 describen el hardware y el presupuesto de recursos que condiciona todo lo demás. La sección 4 da la estructura general y la 5 explica TitaniumFSM, la máquina de estados sobre la que se monta el resto. De la 6 a la 9 se revisan el manejo del tiempo, cada modo, la entrada y la pantalla. La 10 documenta el driver del DS3231. De la 11 a la 13 se tratan la confiabilidad, la compilación y un desglose de la memoria, y la 14 enumera lo que falta.

2. Hardware

La versión de referencia usa un Arduino Nano o Uno (ATmega328P a 16 MHz). El mismo firmware compila para el ATmega32U4 del Leonardo, el Micro y el Pro Micro. Los periféricos son:

Señal Pin Notas
Botón OK D4 activo en alto, pull-down externo
Botón arriba D5 activo en alto, pull-down externo
Botón abajo D6 activo en alto, pull-down externo
Salida de alarma D7 en alto mientras suena la alarma
Buzzer D11 buzzer activo por defecto
I2C SDA / SCL A4 / A5 en el 328P, D2 / D3 en el 32U4 módulo del LCD en 0x27, DS3231 en 0x68

Cada botón conecta su entrada a +5 V y tiene una resistencia de 10 kOhm a tierra, así que al presionarlo se lee un nivel alto. Por eso el firmware configura los pines como INPUT simple y no como INPUT_PULLUP.

La pantalla es un módulo HD44780 de 16x2 detrás de un expansor I2C PCF8574. Las ocho salidas del expansor van a RS, RW, E, el transistor de la retroiluminación y las cuatro líneas altas de datos (D4 a D7), lo que obliga a usar el LCD en modo de 4 bits. El LCD y el DS3231 comparten un solo bus I2C, que corre a los 100 kHz que la biblioteca Wire de Arduino usa por defecto.

Funcionan los dos tipos de buzzer. Los pitidos cortos de la interfaz (al abrir el editor de la alarma, al confirmar, al cancelar) llaman directamente a tone(), y la onda cuadrada que genera hace sonar un buzzer activo igual que uno pasivo. Lo único que depende de la pieza es el timbre de la alarma: beep() mantiene el pin en alto con un buzzer activo y llama a tone() con uno pasivo, y eso se elige al compilar con BUZZER_TYPE en main.hpp. La salida de alarma en D7 solo refleja si la alarma está sonando, así que se le puede conectar una lámpara o un timbre más fuerte (con un transistor o un relé de por medio) sin tocar el firmware.

Los temporizadores del hardware están repartidos para que nada choque:

Temporizador Lo usa
Timer0 el core de Arduino: millis(), micros(), delay()
Timer1 Owake: la base de 1 kHz del cronómetro
Timer2 (328P) / Timer3 (32U4) tone(): los pitidos de la interfaz y, con buzzer pasivo, el timbre de la alarma
Watchdog recuperación ante bloqueos, sección 11

3. Las restricciones y las reglas que salen de ellas

El ATmega328P tiene 32 KB de flash, 2 KB de SRAM y 1 KB de EEPROM. El 32U4 tiene la misma flash y EEPROM y 2.5 KB de SRAM, pero su bootloader ocupa 4 KB de flash y su pila USB se lleva una parte de la RAM. El recurso escaso es la SRAM. Ahí viven todas las variables globales, el heap y la pila, y quedarse sin ella no produce ningún mensaje de error. Lo que pasa es que la pila crece en silencio encima de las variables globales y el programa empieza a hacer cosas raras.

El firmware sigue unas cuantas reglas para mantenerse lejos de ese límite.

  1. Reservar memoria una sola vez, al arrancar. Owake y sus bibliotecas hacen exactamente tres reservas en el heap, todas en el constructor del LCD, que corre durante la inicialización estática, antes de setup(). Nunca se libera nada, así que el heap no se puede fragmentar.
  2. Guardar las constantes en la flash. El AVR tiene arquitectura Harvard y la flash es un espacio de direcciones aparte, así que un arreglo const común se copia de todos modos a la SRAM al arrancar. Los mapas de bits de los glifos, la tabla que arma los dígitos, los días de cada mes, los desplazamientos para el día de la semana, la tabla de fracciones de temperatura y el guion de los créditos se declaran PROGMEM y se leen con pgm_read_byte o pgm_read_word.
  3. Solo enteros. No hay un solo float ni double en el firmware ni en sus bibliotecas, y la función de temperatura en punto flotante del driver del RTC está comentada. Las fracciones que sí hay que mostrar, como los cuartos de grado del sensor de temperatura, salen de tablas.
  4. Empaquetar lo que se pueda. La configuración de la pantalla, el estado de los botones, las banderas del RTC, las fechas y las marcas de tiempo del RTC son campos de bits, cada uno del tamaño justo para su rango.
  5. Elegir el ancho de cada entero según lo que guarda. La marca de tiempo de un botón es de 16 bits porque nadie mantiene un botón presionado más de un minuto. La hora del día es de 32 bits porque su valor más grande, 86399, pasa de 216−1=65535.
  6. No usar String ni Print. El String de Arduino reserva memoria en el heap mientras el programa corre, y heredar de Print traería una tabla virtual y código de formato para funciones que la pantalla nunca usa.
  7. Dejar que el compilador quite el resto. La optimización en tiempo de enlace, junto con una sección por función y por objeto, hace que el código que nadie llama, como la mayor parte del core de Arduino, no llegue al binario (sección 12).

Estas reglas explican las partes del código que a primera vista parecen repetitivas. El editor de hora y el de fecha en tedd.cpp son dos funciones casi idénticas, por ejemplo. Unirlas en una plantilla acortaría el código fuente, pero el compilador instanciaría dos copias igual, y el binario quedaría prácticamente del mismo tamaño. Lo mismo pasa con los namespaces por estado de la sección 5: cada uno son unas cuantas funciones libres sin datos propios, y eso evita los punteros ocultos y los constructores que agregaría una jerarquía de clases (sección 5.2).

4. Estructura del programa

El core de Arduino trae su propio main(), que llama a setup() una vez y a loop() para siempre. En Owake, loop() tiene una sola línea, fsm.go(), y todo lo demás vive dentro de la máquina de estados.

setup()   temporizadores, LCD, RTC, botones, watchdog
loop()    fsm.go()
            |
            OwakeFSM         qué subsistema está activo
              |
              Subsystem      qué estado está activo, sección, palabra de datos
                |
                State        -> Program { setup, loop, exit }

El código fuente está dividido de la misma forma:

Archivo Contenido
src/main.cpp configuración del hardware, la interrupción de Timer1, el grafo estático de objetos
src/fsm.cpp, src/subsystem.cpp TitaniumFSM
src/state/*.cpp un archivo por subsistema: menú, reloj, alarma, cronómetro, créditos
src/time.cpp aritmética de hora y fecha
src/tedd.cpp el editor de hora y fecha compartido
src/thislcd.cpp conjuntos de glifos y el dibujo de dígitos de dos renglones
lib/rtc3231 driver del DS3231, sección 10
lib/SolidCrystalI2C, lib/Beverly, lib/fault, lib/Facebits pantalla, botones, pantalla de fallas y utilidades de máscaras de bits, cada una un proyecto aparte

5. TitaniumFSM

TitaniumFSM es un framework de máquinas de estados escrito por el autor, y Owake usa una copia modificada de su edición Community. Su trabajo es decidir qué código corre en cada vuelta de loop() y llamar al código de preparación y de salida en el momento correcto cuando cambia la pantalla activa.

5.1 Tres niveles

La máquina tiene tres niveles:

  • La FSM (OwakeFSM) es dueña de los subsistemas y sabe cuál está activo.
  • Un subsistema (Subsystem) es un modo del dispositivo, como Reloj o Alarma. Tiene un arreglo pequeño de estados, sabe cuál de ellos está activo y lleva un contador section y un puntero a una palabra de datos de 32 bits.
  • Un estado (State) es una pantalla o comportamiento dentro de un modo, como “configurando la alarma” o “alarma sonando”. Su comportamiento es un Program, una estructura con tres punteros a función: setup, loop y exit.

Owake tiene cinco subsistemas y once estados:

Subsistema Estados
Menú Main
Reloj Set, BigView, View
Alarma Set, Running, Ringing
Cronómetro Stop, Running, List
Acerca de Main

Los subsistemas también pueden tener una SubsystemRoutine, un par de funciones opcionales que se llaman al entrar al subsistema (begin) y al salir (cleanup). El menú las usa para dibujar y borrar su marco, y el cronómetro usa begin para poner su contador en cero y cargar su fuente.

5.2 Un estado son tres funciones

Los estados son funciones libres agrupadas por namespace, y una macro declara las tres:

#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; }
}

La alternativa obvia sería una clase abstracta State con tres métodos virtuales y una subclase por estado. En AVR, avr-gcc guarda las tablas virtuales en la SRAM igual que cualquier otro dato inicializado, y cada objeto carga un puntero oculto a su tabla. La versión con punteros a función paga más o menos lo mismo por la tabla en sí, porque el arreglo programs[] ocupa 6 bytes por estado, 66 en total. Lo que se ahorra es todo lo que rodea a la tabla: un estado no tiene objeto, ni constructor, ni puntero oculto, y la tabla de despacho es un arreglo que cualquiera puede leer, no algo que el compilador genera por cada clase. Todo el contexto de un estado le llega por StateCtx, que apunta de vuelta al State y, desde ahí, al subsistema dueño y a la FSM.

5.3 Un grafo de objetos estático

Todo lo que la máquina necesita es una variable global en main.cpp: la tabla programs[], un arreglo de State por subsistema, el arreglo systems[] y la propia fsm. Los constructores enlazan los punteros de regreso: un Subsystem le asigna sys a cada uno de sus estados, y OwakeFSM le asigna fsm a cada subsistema. C++ inicializa las globales de una misma unidad de traducción en el orden en que están declaradas, y las declaraciones están ordenadas para que cada objeto exista antes de que algo apunte a él. Cuando corre setup(), el grafo ya está completo y no vuelve a cambiar.

Esto tiene un costo de mantenimiento. La posición de un estado dentro del arreglo de su subsistema tiene que coincidir con su identificador numérico, definido en types.hpp. El comentario encima de programs[] en main.cpp lo advierte, porque nada lo verifica al compilar.

5.4 Identificadores

OwakeStateID es un enum class de 8 bits cuyos valores son índices de arreglo locales a cada subsistema. Por eso CLOCK_SET, ALARM_SET y CHRONOMETER_STOP valen 0. INIT también vale 0, así que al entrar a cualquier subsistema se empieza por su primer estado. La parte alta del rango de 8 bits está reservada para valores centinela: EXIT (252), CLEANUP (253), NONE (254) e INVALID (255), que ningún índice real va a alcanzar. OwakeSubsystemID funciona igual y sirve de índice para systems[].

El menú se apoya directamente en esa numeración. La opción seleccionada es el identificador del subsistema menos uno, así que elegir “Reloj” (opción 0) pide el subsistema 1.

5.5 Las transiciones son solicitudes

Los estados nunca cambian la máquina directamente. Subsystem::shiftMode() y Subsystem::requestShiftSubsystem() solo anotan el destino en un campo next, y el cambio se aplica al inicio del siguiente dispatch() (para estados) o go() (para subsistemas). El código que pide una transición siempre termina de correr antes, de modo que un estado nunca se desarma mientras su propia función sigue en la pila de llamadas. La preparación y la salida pasan además en un solo punto conocido, y no en donde sea que se haya originado la solicitud.

Al cambiar de subsistema, OwakeFSM::go() hace esto, en este orden:

  1. Llama a exit() del estado activo del subsistema anterior.
  2. Llama a la rutina cleanup del subsistema anterior, si tiene, y marca su estado como NONE.
  3. Vuelve actual al nuevo subsistema y llama a su rutina begin, si tiene.
  4. Pide el estado INIT (0) en el nuevo subsistema.
  5. Llama a dispatch(), que corre setup() y luego loop() de ese estado.

Dentro de un subsistema, dispatch() corre exit() del estado anterior, setup() del nuevo y después su loop(). Hay una excepción: si el propio setup() pide otro estado, loop() no se ejecuta y el estado solicitado toma el control en la siguiente vuelta. El reloj usa esto. Su primer estado, Set, revisa el RTC y si la hora ya se había capturado. Si no hay nada que preguntar, su setup() salta directo a la vista guardada y el editor de hora de su loop() nunca corre.

5.6 Secciones

Cada subsistema tiene un byte section y un límite total_sections. Es un pedazo de estado que pertenece al modo y no a una pantalla en particular, y se conserva mientras el usuario anda en otros modos. sectionMod() lo mueve dando la vuelta en los extremos, con un ajuste porque en C++ el operador % conserva el signo del operando izquierdo (-1 % 4 da -1, no 3).

Cada subsistema lo usa a su manera. El menú guarda ahí la opción resaltada, el reloj empaqueta dos banderas (sección 6.5) y la alarma lo reinicia al salir de su pantalla de configuración.

5.7 Una palabra de datos por subsistema

Cada subsistema recibe un puntero a un solo uint32_t. Un comentario en subsystem.hpp muestra que el plan original era un contexto void*. Ganó la palabra de 32 bits porque los modos que la usan necesitan exactamente un número de ese tamaño (la hora del día en el reloj, los segundos restantes en la alarma), y un puntero con tipo se ahorra las conversiones. Todo lo demás que necesite un modo vive en variables static dentro de su propio .cpp, donde ningún otro código puede tocarlas.

5.8 Planificación cooperativa

No hay RTOS ni desalojo de tareas, así que la máquina responde tan rápido como su loop() más lento. La mayoría de los loops hacen una pasada de trabajo y regresan, pero hay tres lugares que se quedan con el control a propósito hasta que el usuario hace algo: el menú, el editor de hora y fecha, y la animación de los créditos. Cada uno alimenta el watchdog por su cuenta (sección 11).

Bloquear sería un problema si el reloj dependiera del loop principal para avanzar, pero no depende. El reloj calcula la hora actual a partir de millis() o del RTC cada vez que se vuelve a mostrar, en lugar de sumar un segundo en cada vuelta, así que el tiempo que se pasa en el menú no se pierde. Lo único que tiene que seguir corriendo pase lo que pase, la cuenta del cronómetro, vive en una interrupción.

5.9 Estados inválidos

Si next o current llegan a valer INVALID, la máquina llama a FAULT(INVALID_STATE). La biblioteca de fallas muestra el motivo en el LCD y deja que el watchdog reinicie la placa dos segundos después. La misma función cubre las ramas imposibles de los switch, como una sección del menú fuera del rango 0 a 3.

6. El tiempo

6.1 La hora del día

La hora del día es un uint32_t t que cuenta segundos desde la medianoche, 0≤t<86400. El editor la mueve en pasos de una unidad u∈{1,60,3600} con una suma y un módulo:

tsig=(t+u)mod86400tant=(t+86400−u)mod86400

Sumar un día completo antes de restar evita que el valor sin signo se desborde por debajo de cero. PackedTime, un campo de bits con hora, minuto y segundo, existe solo para separar ese número en dígitos a la hora de mostrarlo.

6.2 Fechas

Una fecha es un PackedDate de 16 bits: un año de 7 bits contado desde la época del proyecto, 2026 (o sea 2026+[0,27−1], de 2026 a 2153), un mes de 4 bits y un día de 5. operateDate() implementa las reglas que necesita el editor:

  • Avanzar el día más allá del fin de mes pasa al mes siguiente, y después de diciembre, al año siguiente.
  • Retroceder el día da la vuelta dentro del mismo mes, que es lo que uno espera de un editor por campos.
  • Después de cambiar el mes o el año, el día se recorta, d←min⁡(d,D(m,a)), donde D(m,a) es la duración del mes m en el año a. Así, el 31/01 pasa a 28/02, y el 29/02 de un año bisiesto pasa a 28/02 al año siguiente.
  • La comparación se hace antes del incremento. Como el día es un campo de 5 bits, 31 + 1 daría 0 en silencio antes de que cualquier prueba de “mayor que” lo alcanzara a ver.

Los años bisiestos siguen la regla gregoriana completa:

bisiesto(a)⟺(4|a∧100∤a)∨400|a

Como el rango termina en 2153, el año 2100 queda adentro, y no es bisiesto.

6.3 El día de la semana

El DS3231 tiene un registro para el día de la semana que debe valer entre 1 y 7. dayOfWeek() lo calcula con el método de Sakamoto, que necesita una tabla T de 12 desplazamientos (guardada en la flash) y unas cuantas divisiones enteras:

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

Aquí [m<3] vale 1 en enero y febrero y 0 en el resto, y w=0 corresponde al domingo. La función devuelve ((w+6)mod7)+1, que va de 1 para el lunes a 7 para el domingo.

6.4 Llevar la hora sin RTC

Sin un DS3231, el reloj se apoya en millis(). Cuando el usuario captura la hora, el firmware la guarda como baseTime (t0) y guarda el valor actual de millis() como baseMillis (n0). A partir de ahí, con n como el valor actual de millis():

t=t0+⌊n−n01000⌋

En cada actualización t0←t, y n0 avanza solo en segundos completos:

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

De esa forma los milisegundos sobrantes se arrastran a la siguiente actualización y la división entera nunca los tira. millis() se desborda después de 232 ms, unos 49.7 días, y la resta sin signo sigue funcionando a través del desborde siempre que el reloj se actualice al menos una vez en ese lapso. Si el reloj estuvo un rato fuera de pantalla y la nueva hora pasa de 86 399, un ciclo while resta días completos y avanza la fecha una vez por cada medianoche que pasó.

Este modo es tan preciso como la fuente de reloj de 16 MHz de la placa. En las placas Arduino típicas es un resonador cerámico o un cristal barato, que se desvía mucho más que el RTC y empeora con la temperatura. Esa diferencia es la razón de ser del camino con DS3231.

6.5 Llevar la hora con el DS3231

Con RTC, cada actualización lee la hora y la fecha del chip. La section del subsistema de reloj guarda dos banderas: el bit 0 elige la vista (0 para los dígitos grandes, 1 para la compacta) y el bit 1 indica que la hora viene del DS3231. Cambiar de vista solo toca el bit 0, así que el modo se vuelve a abrir en la última vista que se usó, con la fuente de hora que esté disponible.

Al entrar al reloj se pasa por Clock::Set::setup(), que decide si hay que preguntar la hora:

  • Si el RTC responde y su bandera de oscilador detenido (OSF) está apagada, la hora guardada es confiable y la vista guardada se abre de inmediato.
  • Si el RTC responde pero OSF está encendida, el chip se quedó sin energía en algún momento y su hora no sirve, así que el editor pide la hora y la fecha y las escribe en el chip.
  • Si no hay RTC pero antes sí había, la cuenta de millis() nunca se sincronizó con él, así que se vuelve a pedir la hora.
  • Si no hay RTC y la hora nunca se capturó, se pide. Si ya se había capturado, se abre la vista basada en millis().

Si escribir en el chip falla, el reloj sigue corriendo con millis(), porque baseTime ya quedó guardado cuando el usuario terminó con el editor.

7. Los modos

7.1 Menú

El menú corre en su propio ciclo hasta que se presiona OK. Arriba y abajo cambian la opción resaltada, que se redibuja en el segundo renglón. Las flechas en los dos extremos de ese renglón viven en la capa overlay de la pantalla, así que reescribir el texto de la opción debajo nunca las borra.

7.2 Reloj

BigView dibuja horas, minutos y segundos con dígitos de dos renglones (sección 9.3), más la temperatura en grados enteros si hay RTC. View usa caracteres normales y agrega la fecha y la temperatura con su parte fraccionaria. OK alterna entre las dos vistas y abajo regresa al menú.

7.3 Alarma

La alarma es una cuenta regresiva: el valor que se captura es cuánto falta para que suene, no una hora del día. Eso la hace independiente de si el reloj ya se configuró o no. El estado Running resta los segundos completos transcurridos y mueve su marca de tiempo exactamente esa cantidad, lo que mantiene la cuenta alineada con millis() durante periodos largos, con el mismo truco que usa el reloj.

Running tiene dos detalles pensados para usarse de noche:

  • La retroiluminación se apaga cinco segundos después de la última vez que se presionó un botón, para que la pantalla no ilumine el cuarto toda la noche. Cualquier botón la vuelve a encender.
  • Cancelar toma dos pasos. Mantener OK muestra “Cancel?” en la capa overlay, encima de la cuenta regresiva, y presionar OK otra vez lo confirma. Arriba o abajo esconden el aviso. Así, un solo toque accidental a las 3 de la mañana no puede desactivar la alarma.

Cuando la cuenta llega a cero, Ringing enciende la retroiluminación, pone en alto la salida de alarma en D7 y pita en ráfagas de 500 ms. Con un buzzer activo cada pitido bloquea, por lo que el botón OK solo se revisa unas dos veces por segundo, y para apagar la alarma hay que mantener OK entre uno y dos segundos, más o menos lo que se le debe pedir a alguien medio dormido. Con un buzzer pasivo tone() regresa de inmediato, el botón se revisa en cada vuelta y basta con mantenerlo los 600 ms normales.

7.4 Cronómetro

El cronómetro cuenta en una interrupción de Timer1 configurada en modo CTC, con un preescalador de 64 y un valor de comparación de 249:

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

es decir, un tick por milisegundo. La interrupción está habilitada todo el tiempo, y mientras el cronómetro está detenido cuesta una comprobación de bandera por milisegundo. Habilitarla solo mientras cuenta ahorraría esos pocos ciclos, a cambio de tocar los registros del temporizador desde más lugares del código.

Mientras corre, la interrupción también revisa el botón OK con el mismo código antirrebote que usa el loop principal, y detiene la cuenta con una nueva pulsación. Así el instante de paro no depende del loop principal, que se tarda milisegundos en cada redibujado mandando caracteres por I2C. El arranque lo detecta el loop principal y el paro la interrupción, pero los dos pasan por el mismo antirrebote. Si el usuario presiona en ta y otra vez en tb, la cuenta empieza en ta+δ+j y se detiene en tb+δ, donde δ son los 20 ms de antirrebote y j≥0 es lo que tardó el loop principal en notar la primera pulsación. El intervalo medido es (tb−ta)−j, más o menos un tick, así que el antirrebote se cancela y solo queda la variación al arrancar.

El contador de ticks es de 32 bits y el AVR lo lee byte por byte, por lo que la interrupción podría cambiarlo a mitad de una lectura. Por eso el loop principal lo copia dentro de un ATOMIC_BLOCK. La pantalla muestra mm:ss.mmm con alguna de las dos fuentes grandes o con caracteres normales, y el borde derecho muestra la configuración actual. Hay un estado List para tiempos por vuelta reservado en la tabla de estados (con espacio para diez registros), pero todavía no está implementado.

7.5 Acerca de

Los créditos son un pequeño guion de bytes que interpreta typeEffect(). Los bytes imprimibles se escriben uno por uno, los bytes del 0 al 7 muestran los glifos personalizados que forman el logo de Owake, y tres bytes de control significan “siguiente renglón” (0x0B), “pausa y borra la pantalla hacia atrás” (0x0A) y “fin” (0x0C). El guion mide 175 bytes y vive en la flash. Antes era un arreglo local, y eso ponía el guion completo en la pila cada vez que corrían los créditos.

El guion está guardado como bytes en hexadecimal en lugar de una cadena literal. No hay ninguna razón técnica detrás, simplemente al autor le gusta así. Como se recorre con un índice uint8_t, no puede pasar de 255 bytes. Dos scripts en tools/ convierten texto legible (con secuencias de escape como \0 para un glifo, \n para un renglón nuevo y \p para una página nueva) en el arreglo de C++ y viceversa. El codificador además revisa el límite de tamaño y avisa cuando un renglón es más ancho que la pantalla.

8. Entrada y edición

8.1 Eventos de los botones

El manejo de los botones viene de Beverly, una biblioteca aparte del mismo autor. Aplica un antirrebote de 20 ms a cada entrada y reporta Pressed, Held (a los 600 ms, y en cada llamada mientras el botón siga abajo), Released y, después de soltar, si la pulsación fue larga. Owake los usa siempre con el mismo criterio:

  • Pressed para navegar y para todo lo que es fácil de deshacer.
  • Held para acciones que no deberían pasar por accidente, como cancelar la alarma o apagarla cuando suena.
  • Released junto con wasHeld() cuando un toque y una pulsación larga del mismo botón hacen cosas distintas, como en el cronómetro, donde un toque en arriba cambia el modo de arranque y mantenerlo cambia la fuente.

8.2 El editor de hora y fecha

Configurar la hora, la fecha y la alarma se hace con un solo editor (tedd.cpp). La pantalla se divide en cinco secciones: un botón de cancelar, tres campos de dos dígitos y un botón de listo. Una tabla de cinco entradas asigna a cada sección la unidad de tiempo que edita, y el editor de fecha reutiliza esa misma tabla sumándole un desplazamiento fijo, de modo que el campo 1 edita las horas en el editor de hora y los días en el de fecha. Sobre un campo, arriba y abajo cambian el valor y OK pasa al siguiente campo. Sobre los botones, arriba y abajo se mueven entre secciones.

El editor trabaja sobre una copia del valor y solo la escribe de vuelta al presionar “listo”, así que cancelar deja intacto el valor original. Las posiciones de los campos se calculan en lugar de guardarse, porque cada campo son dos dígitos más un separador y empieza tres celdas después del anterior, tanto en hh:mm:ss como en dd/mm/aaaa. Mover el cursor del hardware cuesta una transacción I2C, por lo que el editor recuerda en qué sección dejó el cursor y solo lo mueve cuando eso cambia.

9. La pantalla

9.1 Salida con buffer

Todo el dibujo pasa por SolidCrystalI2C, la biblioteca del autor para el HD44780, que tiene su propio documento. Para Owake importa que las escrituras caen en una copia de la pantalla en memoria y que flush() solo manda las celdas que cambiaron. Una segunda capa, el overlay, se puede dibujar encima del texto normal sin destruirlo. Owake la usa para las flechas del menú y para el aviso “Cancel?”.

9.2 Ocho caracteres personalizados

El HD44780 puede guardar ocho caracteres definidos por el usuario de 5x8, y Owake necesita más de ocho: flechas, íconos de cada modo, las partes del logo y las piezas de dos fuentes de dígitos grandes. El firmware los reparte en conjuntos que nunca están en pantalla al mismo tiempo:

Conjunto Lo usan Contenido
Símbolos del menú menú, alarma, editores flecha izquierda, barra, flecha derecha, reloj de arena, reloj, casa, cronómetro, persona
Fuente 0 créditos cinco glifos del logo, símbolo de copyright, una cara guiñando el ojo, una figura de sobra
Fuente 1, Fuente 2 reloj grande, cronómetro ocho mitades de dígito cada una

Subir un glifo cuesta nueve bytes al LCD por I2C. La biblioteca recuerda qué mapa de bits hay en cada ranura, y defineHomeSymbols() solo revisa el primero: si la flecha izquierda sigue en la ranura 0, el conjunto completo sigue cargado y las otras siete subidas se omiten.

9.3 Diez dígitos con ocho figuras

Los dígitos grandes miden dos celdas de alto. Diez dígitos con dos mitades cada uno necesitarían 10×2=20 figuras, y solo hay ocho ranuras. El truco es que muchas mitades se comparten, y varias figuras pensadas como “mitad de abajo” funcionan igual de bien arriba. Cada dígito se guarda en una tabla en la flash como un par de 16 bits de números de ranura (el byte bajo arriba, el alto abajo):

Dígito Arriba Abajo
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

Las dos fuentes grandes cargan sus figuras en las mismas ranuras y en el mismo orden, así que una sola tabla sirve para ambas. Los separadores como : y . pasan por la misma rutina que los dígitos, codificados con su código ASCII, y en las fuentes grandes salen como celdas en blanco.

10. El driver rtc3231

lib/rtc3231 es un driver mínimo para el DS3231 de Maxim, escrito para este proyecto.

10.1 El chip

El DS3231 es un reloj de tiempo real con un oscilador de cristal compensado en temperatura. Su especificación es de más o menos 2 ppm entre 0 y 40 grados, lo que en un año da como mucho 2×10−6×31536000 s≈63 s. Guarda la hora en registros BCD, tiene entrada para una pila de botón con la que sigue contando cuando el reloj se desconecta, y mide su propia temperatura para la compensación. Sus registros son:

Dirección Contenido
0x00 - 0x06 segundos, minutos, horas, día de la semana, día, mes y siglo, año
0x07 - 0x0D dos alarmas
0x0E control
0x0F estado, incluida la bandera de oscilador detenido (bit 7)
0x11 - 0x12 temperatura, parte entera y fracción

10.2 Lectura y escritura

Todas las lecturas siguen el mismo patrón. El driver escribe la dirección del registro, termina esa escritura con un inicio repetido en lugar de una condición de paro (endTransmission(false)) y pide los bytes, de modo que la dirección y la lectura forman una sola operación en el bus sin interrupciones. Para leer la hora se piden los siete registros en una sola solicitud. Eso importa porque el chip copia sus contadores a un buffer al inicio de una lectura en ráfaga, y así los siete valores corresponden al mismo instante. Leerlos uno por uno podría atrapar un cambio entre minutos y horas.

updateTime() revisa que hayan llegado los siete bytes antes de decodificar nada. Una lectura incompleta devuelve el código de error 4, el mismo que Wire usa para “otro error”, y deja en su lugar la marca de tiempo anterior. El reloj entonces sigue mostrando la última lectura buena.

10.3 El tipo de la marca de tiempo

TimestampDS3231 guarda fecha y hora completas en cinco bytes: 32 bits de campos de bits para segundo, minuto, hora, día de la semana, día, mes, bandera AM/PM, bandera 12/24 h y bandera de paro, más un byte para el año y el bit de siglo. El constructor le da a cada campo un valor válido (01/01/00, 00:00:00, formato de 24 h, día de la semana 1), así que una marca de tiempo a la que solo se le asignan algunos campos nunca le manda al chip un valor fuera de rango.

Los setters recortan lo que reciben, y algunos dependen de otros. setHour() limita a 0-23 o a 1-12 según el formato, así que el formato va primero. setDay() recorta según el mes y el año bisiesto, así que el año y el mes van antes que el día. Clock::Set respeta ese orden al escribir la hora.

10.4 Horas y siglos

El registro de horas significa cosas distintas según su bit 6. En formato de 12 horas, el bit 5 es la bandera AM/PM, y en formato de 24 horas ese mismo bit es la decena de las horas 20 a 23. El decodificador lee primero el bit de formato y aplica la máscara que corresponde, así que las 21:00 se leen como 21 y no como la 1 PM ni como las 01:00. Owake escribe en formato de 24 horas, pero puede leer un chip que otro dispositivo haya dejado en 12 horas, y convierte el resultado.

El chip guarda años de dos dígitos, con un bit de siglo en el registro del mes. Owake convierte entre esos años y los suyos, contados desde 2026, y enciende el bit de siglo a partir de 2100. La prueba de año bisiesto del driver solo mira los dos dígitos del año, y como el propio DS3231 considera bisiesto el 2100, los dos coinciden.

10.5 La bandera de oscilador detenido

El bit 7 del registro de estado (OSF) se enciende cada vez que el oscilador se detuvo, por ejemplo la primera vez que se energiza el chip o cuando se agotó la pila de respaldo. El chip nunca lo apaga por su cuenta. El driver lo lee en start(), y setTime() lo borra después de una escritura exitosa, porque en ese momento la hora vuelve a ser confiable. Sin ese último paso, el reloj pediría la hora cada vez que se abriera, para siempre.

Varios campos de un bit, OSF entre ellos, se asignan con un != 0 explícito. Un campo de bits en C++ se queda solo con los bits bajos del valor que recibe, así que asignar status & 0x80 directamente a un campo de un bit siempre guarda 0. Una versión anterior del driver cometía justo ese error y nunca veía la bandera.

10.6 Temperatura sin punto flotante

La temperatura es un valor en complemento a dos de 10 bits, en cuartos de grado, repartido en dos registros. El driver la entrega como dos enteros: itemperature() devuelve los grados enteros del registro 0x11 como un byte con signo, y temperatureMantis() pasa los dos bits altos del registro 0x12 por una tabla de cuatro entradas en la flash y devuelve 0, 25, 50 o 75. En términos de los dos registros:

T=R11h+14⌊R12h64⌋ ∘C

con R11h leído como byte con signo. La pantalla imprime las dos partes separadas por un punto. El chip actualiza la lectura una vez por ciclo de conversión de 64 segundos, así que leerla más seguido no da nada nuevo.

10.7 Errores y presencia

Todas las funciones que hablan con el chip devuelven el código de estado de Wire (0 si todo salió bien, 2 si nadie respondió a la dirección, etcétera) o uno de dos códigos propios del driver: 4 para una lectura incompleta y 7 para “no iniciado”. Antes de leer o escribir la hora o la temperatura, isAvailable() manda una transmisión vacía y revisa que el chip reconozca su dirección. Eso cuesta una transacción extra en el bus por llamada. A cambio, un módulo ausente se reporta como código de error en lugar de decodificar lo que sea que devuelva el bus, y la siguiente vez que se abre el reloj, este nota que el RTC ya no está y pide la hora.

11. Confiabilidad

Un reloj que se congela es peor que uno que se reinicia, así que Owake se apoya en el watchdog. Se habilita con un límite de 4 segundos al final de setup() y se alimenta desde OwakeFSM::go() y desde cada uno de los tres ciclos que bloquean. Si una transacción I2C se cuelga, cosa que puede pasar en un bus con un cable flojo, la placa se reinicia en menos de cuatro segundos y vuelve al menú.

Dos detalles hacen que esto funcione. Al arrancar, la bandera de reinicio por watchdog se borra antes de deshabilitar el watchdog, porque en el AVR esa bandera encendida obliga al watchdog a seguir activo, y sin ese paso un reinicio por watchdog provocaría otro durante setup(). Además, las fallas usan el mismo mecanismo: FAULT() muestra el motivo en el LCD, arma el watchdog a 2 segundos y deja la CPU detenida, así que cualquier error fatal termina en un reinicio limpio, con un mensaje que el usuario alcanza a leer antes.

El README advierte de un caso de hardware. Algunos clones viejos del Arduino Nano traen un bootloader que no maneja los reinicios por watchdog y se queda reiniciando para siempre. Grabarle Optiboot lo arregla.

12. Compilación

Owake se compila con un Makefile (el Universal Tiny Project Makefile del autor, en su edición para AVR) en lugar del IDE de Arduino. Está probado con avr-gcc 14.3 y usa estas opciones:

  • -Os para optimizar por tamaño.
  • -flto para la optimización en tiempo de enlace. El optimizador ve el programa completo al enlazar, así que las funciones pequeñas se integran entre archivos y las que no se usan desaparecen.
  • -ffunction-sections -fdata-sections junto con --gc-sections, para que el enlazador tire cada función y variable que nadie referencia, incluida la mayor parte del core de Arduino.
  • -fno-rtti -fno-exceptions, porque ninguna de las dos funciones se usa y ambas tienen un costo en tiempo de ejecución.
  • -fno-threadsafe-statics, que quita las variables de guarda alrededor de las variables static locales. No hay hilos de los que protegerse.
  • -std=c++23 y una lista larga de advertencias (-Wall -Wextra -Wpedantic -Wshadow -Wcast-qual -Wlogical-op -Wmissing-declarations, entre otras).
  • -MMD -MP para seguir las dependencias de los headers.

Las bibliotecas se compilan en archivos estáticos con avr-gcc-ar. Con LTO, los archivos objeto contienen la representación intermedia de GCC en lugar de código máquina, y un ar común solo puede indexar sus símbolos si el plugin de LTO de GCC está instalado justo donde binutils lo busca. Algunas distribuciones de Linux no lo ponen ahí, y el resultado es un enlace con todos los símbolos de las bibliotecas sin definir. avr-gcc-ar carga el plugin de forma explícita.

El core AVR de Arduino viene dentro del repositorio, sin lo que Owake no usa (HID, SoftwareSerial, SPI, bootloaders, variantes de placas que no aplican). Los cambios están listados en core/MODIFICATIONS.md, como lo pide la LGPL.

13. Cuentas de memoria

La tabla lista la SRAM que ocupan los objetos propios de Owake, calculada a partir de la distribución de los tipos en AVR, donde los punteros miden dos bytes y las estructuras no llevan relleno. No incluye el core de Arduino ni la biblioteca Wire, que por sí sola reserva cinco buffers de 32 bytes (160 bytes). El total real sale de correr avr-size sobre el firmware compilado.

Objeto Bytes
programs[] (11 x 3 punteros a función) 66
systems[] (5 subsistemas x 12) 60
Estados (11 x 4) 44
Objeto LCD (configuración, punteros, caché de glifos) 30
Buffers del LCD en el heap (32 de texto + 32 de overlay + 4 de celdas modificadas), más 2 bytes de encabezado del asignador por bloque 74
Botones (3 x 6) 18
Estado del reloj (fecha, banderas, hora base y millis base) 12
Palabras de datos de los subsistemas (3 x 4) 12
OwakeFSM 8
Rutinas de subsistema (2 x 4) 8
DS3231 (marca de tiempo de 5 bytes + 2 bytes de banderas) 7
Estado del editor y tabla de secciones 7
Contadores y banderas de la interrupción 6
Configuración del cronómetro 4
Todo lo demás (banderas de la alarma, bandera de LCD listo, cursor de dígitos) 4
Total 360

La máquina de estados completa, con sus tablas, objetos y punteros de regreso, suma unos 190 bytes. Pasar programs[] a la flash ahorraría 66 de ellos (sección 14).

14. Limitaciones y trabajo futuro

  • La alarma es una cuenta regresiva. El siguiente paso lógico es una alarma a una hora del día usando los registros de alarma del propio DS3231, que el driver ya define, y además sobreviviría a un reinicio.
  • programs[] y los arreglos de estados viven en la SRAM. La tabla de programas nunca cambia después de compilar y podría pasar a la flash, con el costo de leer cada puntero a función con pgm_read_word antes de llamarlo.
  • Un redibujado completo de la pantalla tarda del orden de 60 ms, porque cada celda se manda como un byte de dirección y uno de dato, cada uno partido en dos nibbles, y cada nibble cuesta dos escrituras I2C. El documento de la biblioteca de pantalla describe dos formas de reducir el número de transacciones.
  • La lista de vueltas del cronómetro y su modo de arranque “hold” están reservados pero no implementados.
  • Mantener arriba o abajo en el editor repite tan rápido como corre el loop, que depende sobre todo del tráfico I2C. Una frecuencia de repetición propia sería más cómoda.
  • Mantener OK en el reloj alterna entre las dos vistas en cada vuelta, porque Held se repite mientras el botón sigue abajo.
  • El reloj siempre muestra formato de 24 horas. El formato de 12 horas está marcado como pendiente en el código.
  • Las temperaturas negativas se mostrarían mal, porque la fracción se suma como número positivo a una parte entera negativa. Es poco probable que un reloj de interior llegue a verlas.

Referencias

  1. Hitachi, HD44780U (LCD-II) Dot Matrix Liquid Crystal Display Controller/Driver, hoja de datos, 1998.
  2. Maxim Integrated, DS3231 Extremely Accurate I2C-Integrated RTC/TCXO/Crystal, hoja de datos.
  3. NXP Semiconductors, PCF8574 Remote 8-bit I/O expander for I2C-bus, hoja de datos.
  4. Microchip Technology (Atmel), hojas de datos del ATmega328P y el ATmega32U4.
  5. Huang, H. W. (2009). The HCS12/9S12: An Introduction to Software and Hardware Interfacing (2.a ed.), pp. 323-338. Delmar Learning.
  6. T. Sakamoto, algoritmo del día de la semana, publicado en el grupo de noticias comp.lang.c.

Este texto de Mastedore. Todos los derechos reservados.