ESP32-Hardware-, Software- und LVGL-Timer

Was sind Timer eigentlich und wozu braucht man sie?

Die Idee der Timer sind uralt. Es gab sie schon in der MOS 6502 CPU aus den 1970ern. Im Grunde sind Timer Zähler, die mit jedem Taktzyklus hochzählen. Außerdem kann man einen Alarm-Zählerstand (und eine Speicheradresse eines Unterprogrammes) definieren, der einen Interrupt (IRQ) auslöst, wenn dieser Zählerstand erreicht wird. Dann wird die Ausführung des normalen Programmes, dass die CPU gerade ausführt, kurz unterbrochen, die Interrupt-Routine kurz ausgeführt, und danach wird wieder in das normale Programm zurückgekehrt und es wird ganz normal weitergemacht. Siehe auch Artikel 8-Bit-Breadboard-Computer auf Basis einer 6502-CPU - selektives Interrupt Handling über den VIA 6522.

Mit Timern kann man zum Beispiel auch wiederholt etwas aufrufen, etwa, wenn man bei einem Computer ständig abrufen will, ob eine Taste gedrückt wurde. Dann springt der Timer z. B. alle Millisekunde in die Interrupt-Routine checkKey() und schaut dann dort nach, ob eine Taste niedergedrückt wurde. Die Interrupt-Routine darf nicht lange dauern, sonst würden zum Beispiel Animationen sichtlich ruckeln, weil der Computer gerade mit etwas anderem (eben der Interrupt-Routine) beschäftigt ist. Für checkKey() wäre das also nur: Leitungen überprüfen, gedrückten Key oder NULL in eine Speicherstelle schreiben. Und evtl. noch einen "Unterbrecher-Key" - beim Commodore 64 war das RUN/STOP-RESTORE - mit dem man die Ausführung des übergeordneten Programmes (beim C64 den BASIC-Interpreter) abbrechen kann.

Eine Timer-Wiederholung realisiert man, in dem man nach einem abgelaufenen Timer automatisch einen neuen startet. Mit ein bisschen Software drumherum kann man natürlich auch "rufe alle x Sekunden die Routine y auf" schreiben. Aber Grundlage sind immer Hardware-Timer: Zähler, die rauf- oder runterzählen. Meist werden sie auf einen bestimmten Wert (etwa der Zeit in Mikrosekunden bis zur Auslösung) gesetzt und dann wird heruntergezählt, weil es in Maschinensprache schneller geht, gegen Null als gegen einen Wert zu prüfen.

Hardware-Timer auf dem ESP32

Genau diesen rudimentären Zählern entsprechen die Hardware-Timer des ESP32. Davon hat der ESP32 ingesamt 4: zwei Timer-Gruppen (TIMERG0, TIMERG1), je Gruppe 2 Timer. Jeder Timer ist ein 64-Bit Zähler (mit 16-Bit Prescaler) und kann hoch- oder runter zählen, Alarme auslösen und optional Auto-Reload verwenden. Der Standard-Taktgeber ist dabei APB_CLK (typischerweise 80 MHz).

Der Prescaler, auch Divider genannt, definiert, wie oft gezählt wird. Ist der Prescaler auf 1, dann jeden einzelnen Taktzyklus. Das wäre 80 mal pro Mikrosekunden. Das macht natürlich überhaupt keinen Sinn, weil die MCU (Micro Controller Unit, die CPU des Mikrocontrollers) dann nur noch mit dem Timer beschäftigt wäre. Aber ein Divider von 80 wäre möglich und würde so alle Mikrosekunde (alle 80 Taktzyklen) den Zähler eins herunterzählen. Bei einem Alarm würde man den Zähler z. B. bei 1000 loslaufen lassen und hätte dann alle Millisekunde einen Alarm, bei dem ein Interrupt ausgelöst und eine Routine ausgeführt werden kann.

Der Prescaler hat übrigens eine Breite von 16 Bit (ulong), was einen Maximalwert von 65'536 (im Bereicht von 2...65536) ergibt. Bei einem Divider von 65'536 würde nur jeder 65'536 Taktzyklus gezählt, dementsprechend immerhin noch 1220.7 mal pro Sekunde.

Die Timer-Zähler sind 64-Bit breit. Bei einem Divider von 80 wäre also erst ein Überlauf in 264 (ca. 18.4 Trillionen) Mikrosekunden zu erwarten, das sind 584'554 Jahre. Darüber müssen wir uns also keine Sorgen machen ;)

Bei Auto-Reload wird automatisch ein konfigurierter Start-Wert wieder geladen. Nach einer Auslösung ist der Alarm allerdings standardmäßig deaktiviert und muss wieder eingeschaltet werden. Aber im Prinzip entspricht das einer Wiederholung.

Hardware-Timer bieten gegenüber Software-Timern (die komfortabler sind und gleich noch besprochen werden) eine höhere Präzision, sind also ideal, um zum Beispiel bestimmte Frequenzen zu erzeugen.

Befehle für Hardware-Timer

Wie benutzt man nun Hardware-Timer? Nun, als erstes müssen wir erst einmal einen samt der Randbedingungen in einem timer_config_t-Struct definieren und dann mit timer_init() initialisieren:

esp_err_t timer_init(timer_group_t group_num, timer_idx_t timer_num, const timer_config_t *config) group_num ist die Timergruppennummer: 0 für TIMERG0 oder 1 für TIMERG1 timer_num ist der Timer-Index: 0 für hw_timer[0] und 1 für hw_timer[1] timer_config_t ist ein Zeiger auf die Initialisierungsparameter des Timers mit timer_alarm_t alarm_en: Timer-Alarm de-/aktivieren, Enum mit TIMER_ALARM_DIS = 0: disabled oder TIMER_ALARM_EN = 1: enabled oder TIMER_ALARM_MAX timer_start_t counter_en: Zähler de-/aktivieren, Enum mit TIMER_PAUSE = 0: Timer pausieren oder TIMER_START = 1: Timer aktiv timer_intr_mode_t intr_type: Interrupt Modus, Enum mit TIMER_INTR_LEVEL = 0: Interrupt mode ist level mode TIMER_INTR_MAX timer_count_dir_t counter_dir: Zähl-Richtung (hoch / Runter), Enum mit TIMER_COUNT_DOWN = 0 TIMER_COUNT_UP = 1 TIMER_COUNT_MAX timer_autoreload_t auto_reload: Automatisches Neuladen des Timers, Enum mit TIMER_AUTORELOAD_DIS = 0 TIMER_AUTORELOAD_EN = 1 TIMER_AUTORELOAD_MAX uint32_tdivider: Teiler des Zählertakts. Der Prescaler reicht von 2 bis 65536.
Sobald der Timer enabled ist, rennt der Zähler los. Haben wir das nicht schon beim Init mit alarm_en, müssen wir das spätestens jetzt mit timer_start() machen. Mit timer_get_counter_value() bzw. timer_get_counter_time_sec() kann man übrigens nachschauen, wie weit der Zähler schon ist. Wir können den Timer auch mit timer_pause() anhalten - und dann mit timer_start() dann weiterlaufen lassen.

Was wäre ein Timer ohne einen Alarm? Eine verdammt genaue Uhr, die wir mit timer_get_counter_value() abfragen können. Aber eigentlich wollen wir ja eine Interrupt-Routine nach einer bestimmten Zeit ausführen, nicht? Dazu müssen wir einen Alarm definieren.
esp_err_t timer_set_alarm_value(timer_group_t group_num, timer_idx_t timer_num, uint64_t alarm_value) group_num: wie bei timer_init() timer_num: wie bei timer_init() alarm_value: Der Zähler, bei dem etwas passieren soll (max. 264)
Den Alarm können wir dann mit timer_set_alarm() aktivieren. Zuvor sollten wir uns aber auch um das was kümmern, denn timer_set_alarm_value() kümmert sich nur um das wann. Das was ist immer eine Funktion, eine sogenannte CallBack-Function, die wir vorher geschrieben haben und die aufgerufen werden soll. Das geschieht mit:
esp_err_t timer_isr_callback_add(timer_group_t group_num, timer_idx_t timer_num, timer_isr_t isr_handler, void *arg, int intr_alloc_flags) group_num: wie bei timer_init() timer_num: wie bei timer_init() isr_handler: Adresse der CallBack Function arg: Parameter für die CallBack Function intr_alloc_flags: Flags, die angeben, in welcher Speicherart sich die CallBack Function befindet. Genaueres in esp_intr_alloc.h
Damit hätte wir alles zusammen: Wann was aufgerufen werden soll. Ganz schön umständlich und viele Aufrufe. Aber das kann man natürlich in Software kapseln und für den Programmierer einfacher gestalten. Die höchsten Präzision und den vollen Zugriff auf die Hardware-Timer bekommt man aber mit diesen Befehlen. Aber es gibt auch ...

ESP32 High-resolution Software Timer

Es gibt ein Software-Timer-System, dass uns viel Arbeit bei der Definition abnimmt, eine Auflösung von 1 Mikrosekunden bei einem Zähle bis 264 bietet. Intern verwendet das Software-Timer-System natürlich auch Hardware-Timer. Aber es schafft es, gleich mehrere Software-Timer auf einen Hardware-Timer zu legen und ist damit ressourcenschonender. Es nutzt Callback-Funktionen von einem speziellen esp_timer-Task auf und liegt damit im Task-Kontext statt im ISR-Kontext, es sei denn wählt explizit ISR-Dispatch.

Ein Software-Timer legen wir an mit:
esp_timer_create(&esp_timer_create_args_t, &handle) esp_timer_create_args_t ist ein Struct mit esp_timer_cb_t callback: Die CallBack-Function, die bei Ablauf des Timer aufgerufen werden soll. void *arg: Die Parameter für die CallBack-Function esp_timer_dispatch_t: Dispatch-Methode: Dispatch callback vom Task or ISR; wenn nicht spezifiziert: esp_timer task const char *name?: Name des Timer, der in esp_timer_dump() ausgegeben wird bool skip_unhandled_events: Unvorgesehene Ereignisse werden ignoriert handle: hier wird das Timer-Handle zurückgegeben, auf das wir bei weiteren Calls Bezug nehmen.
Dann können wir den Timer einmal starten mit esp_timer_start_once(handle, timeout_us) oder wiederholend mit esp_timer_start_periodic(handle, period_us). Mit esp_timer_stop(handle) wird der Timer gestoppt und mit esp_timer_delete(handle) gelöscht. esp_timer_get_time() gibt uns die Mikrosekunden seit Start zurück.

Schon viel einfacher, nicht? Und für die allermeisten Anwendungen vollkommen ausreichend.

LVGL-Timer

Ich will noch auf eine weitere Timer-Variante zu sprechen kommen, die LVGL-Timer. LVGL ist ein Dialog-Framework für den ESP32 und andere Mikrocontroller, die einem viel Arbeit beim Design von Dialogen abnehmen kann.

Man definiert Buttons und andere Steuerelemente und muss dann nur noch regelmäßig eine LVGL-Routine aufrufen. Diese sorgt dann dafür, dass eine kleine Animation beim Niederdrücken eines Buttons ausgeführt wird und das Tap-Ereignis an eine eigene Routine weitergeleitet wird. Dort wird dann ein eigener Programm-Code ausgeführt und wenn der beendet ist, übernimmt das LVGL-System wieder und lässt den Button mit einer kleinen Animation wieder los.

Wenn der eigene Programm-Code jetzt länger dauert, dann bleibt auch der Button länger niedergedrückt. Eigentlich kein Problem. Was aber, wenn das Dialogsystem auf weitere Buttons reagieren soll? Das geht dann nicht.

Und diesen Fall hatte ich bei meinem On/Off-Counter, den ich mittels LVGL auf einem Cheap Yellow Display mit ESP32 programmiert habe: Da gibt es einen Button "Go", bei dem dann der Countdown startet. Bei Beginn und Ende des Countdown soll etwas passieren. Aber es soll auch möglich sein, den Dialog weiter zu benutzen, um den Countdown abzubrechen.

Dazu benötigt es dann etwas, das parallel läuft, und dafür ist ein Timer wie geschaffen. Der soll alle Sekunde eine Callback-Funktion aufrufen, die den Countdown-Zähler runterzählt und die Anzeige aktualisiert. Ansonsten funktioniert der Hauptdialog ganz normal weiter und ich kann Abbruch drücken, das in einer globalen Variablen speichern und dann den Timer stoppen.

Wir besprechen das gleich anhand eines praktischen Beispiels, und auch, wie man mit einem zweiten Timer ein PowerSave oder Sleep-Modus nach einer bestimmtes Zeit Nichtstun realisieren kann. Doch vorher noch kurz der Grund, warum ich hier die LVGL-Timer benutze und einen Code-Überblick:

Die LVGL-Timer und nicht die normalen ESP32-Timer benutze ich, damit das LVGL-System Kenntnis davon hat und weiß, dass diese Timer existieren. LVGL kann dann die Timer viel besser in sein Öko-System integrieren und zum Beispiel für ruckelfreie Animationen sorgen. LVGL-Timer laufen in einem eigenen Timer-Subsystem und werden nicht preemptiv ausgeführt (ein Timer kann z. B. keinen anderen unterbrechen) und werden in lv_timer_handler() ausgeführt. Damit sind LVGL-Timer dafür gedacht, LVGL-spezifische Arbeiten sicher auszuführen (UI-Erzeugung, Redraw, Input-Poll, Animationen etc.).
Die LVGL-Timer-Callbacks laufenwie gesagt im Kontext von lv_timer_handler() (also in dem Thread/Task, der lv_timer_handler() aufruft). Weil LVGL-Timer non-preemptiv sind, dürfen sie LVGL-APIs direkt aufrufen (anders als externe Timer, die nicht LVGL-safe sein müssen). Was bedeutet, dass man in LVGL-Timer-Callbacks den LVGL-Dialog verändern kann.

Die LVGL-Timer funktionieren aber ähnlich der ESP32-Timer und ich glaube auch, dass die LVGL-Timer auf den ESP32-Timern basieren, halt mit ein bisschen "Drumherum", um das LVGL "geschmeidig" zu halten. Allerdings ist die Zeitbasis eine Millisekunde (nicht eine Mikrosekunde wie bei ESP32-Timern) und es muss regelmäßg lv_tick_inc(ms) ausgerufen werden. Aber solange das LVGL-Dialogsystem am Laufen gehalten wird, läuft das Alles mit.

Eine Timer legen wir wir folgt an:
lv_timer_t *TimerName = lv_timer_create(lv_timer_cb_t cb, uint32_t period_ms, void *user_data) TimerName: Der Handle für den Timer cb: Die Callback-Funktion period_ms: Die Zeitspanne in Millisekunden für den Timer, die er wiederholend aufgerufen wird user_data: zusätzliche Informationen, die der Programmierer hier speicher kann
Es empfiehlt sich lv_timer_t *TimerName als globale Variable zu definieren, um überall darauf zugreifen zu können, z. B. um den Timer ...

zu pausieren: void lv_timer_pause(lv_timer_t *timer)
ein- und auszuschalten: void lv_timer_enable(bool en)
weiterlaufen zu lassen: void lv_timer_resume(lv_timer_t *timer)
wieder von Null losrennen zu lassen: void lv_timer_reset(lv_timer_t *timer)
zu löschen: void lv_timer_delete(lv_timer_t *timer)

Das ist auch schon alles, was man an LVGL-Timer-Befehler benötigt. Will man den Timer nur einmal laufen lassen, kann man ans Ende der Callback-Funktion void lv_timer_delete(lv_timer_t *timer) schreiben. Damit ist der Timer dann gelöscht und wird nicht wieder ausgeführt.

Kommen wir nun zu einer praktischen Anwendung von LVGL-Timern, eben dem schon erwähnten On/Off-Timer-Projekt, um meine Mikrowelle für eine bestimme Zeit einzuschalten und den Bildschirm nach einer bestimmten Zeit auszuschalten, damit sich der Dialog nicht einbrennt und um Strom zu sparen.

Praktisches Beispiel für mein On/Off-Timer Projekt


Ich benutze Timer in meinem Projekt ESP32-CYD (Cheap Yellow Display) Projekt mit LVGL: 433 MHz On/Off-Timer Funk-Steckdosen-Steuerung, das uns hier als praktisches Beispiel dienen soll.

Rechts eine Abbildung des Dialoges, damit klarer wird, was durch die Timer gewollt ist, nämlich zwei Dinge:

1. Wenn Go gedrückt wird, soll switchOn() aufgerufen werden, um die Mikrowelle einzuschalten. Gleichzeitig wird auf dem grünen Go ein rotes Stop und der Go-Button wird zu einem Abbruch-Button.

Timer 1 soll jetzt sekündlich den Countdown-Zähler herunterzählen... von 1m26s auf 1m25s auf 1m24s usw. usf. und den Zähler natürlich auch anzeigen.

Erreicht der Zähler Null, dann wird switchOff() aufgerufen und die Mikrowelle geht aus. Gleichzeitig wird Stop wieder zu Go und der Dialog ist klar für eine neue Runde.

Wird allerdings während des Countdown der Abbruch-Button betätigt, dann wird sofort switchOff() aufgerufen und die Mikrowelle ausgeschaltet. Der Countdown-Timer und die Countdown-Anzeige werden gelöscht.
2. Es wird ein 30-Sekunden-Timer gestartet. Jedesmal, wenn man auf Display tappt, wird Timer2 auf Null gesetzt und muss wieder 30 Sekunden erreichen. Nach 30 Sekunden Nichtstun wird dann der Timer2 gelöscht und der PowerSave-Modus eingeleitet. Ist der PowerSave zuende, weil jemand wieder auf den Touchscreen tappt, startet ein neuer 30 Sekunden-Timer2.

Countdown-Timer definieren

Damit wir auf die Timer von überall Zugriff haben, müssen diese als globale Variablen definiert sein:
lv_timer_t * SleepTimer = NULL; const uint32_t SleepTimeout = 30000; // 30 Sekunden lv_timer_t * CountdownTimer = NULL;

Countdown-Timer für die Zeit, nach der Bildschirm ausgeht und das CYD in PowerSave geht

Für den Auto-Sleep nach 30 Sekunden Nicht-Eingabe sind folgende Funktionen zuständig:

Einmal die Ausführung selbst, die nach 30 Sekunden stattfindet:
void sleep_timer_cb(lv_timer_t * timer) { lv_timer_del(SleepTimer); SleepTimer=NULL; enter_powersave(); }
Den rufen wir einmalig zum Start auf. Die Funktion gibt es eigentlich nur wegen der Lesbarkeit:
void setup_auto_sleep() { // beim ersten mal wird der Timer neu gestartet reset_sleep_timer(); }
Dann rennt der Timer los. Und wird nach 30 Sekunden sleep_timer_cb() ausführen. Wenn da nicht zwischendrin der Timer wieder gelöscht und neu angelegt wird, weil eine Interaktion stattgefunden hat:
void reset_sleep_timer() { // Alten Timer löschen, falls vorhanden if(SleepTimer != NULL) { lv_timer_del(SleepTimer); SleepTimer=NULL; delay (10); } // Neuen Timer starten SleepTimer = lv_timer_create(sleep_timer_cb, SleepTimeout, NULL); if(SleepTimer != NULL) lv_timer_set_repeat_count(SleepTimer, 1); // Nur einmal ausführen }
Der Timer wird also einmal am Anfang gestartet und rennt dann los. Erreicht er das Ziel, die 30 Sekunden-Marke, dann schaltet er den Bildschirm ab. Wie beim "Mensch-ärgere-dich-nicht"-Spiel wird bei jeden Touchscreen-Tap der Timer rausgeschmissen und wieder auf das Start-Feld gesetzt und muss wieder von vorne loslaufen. Darum muss jede Aktion, die den SleepCounter wieder auf Null setzen soll, reset_sleep_timer() aufgerufen werden.

Countdown-Timer für die Zeit, die die Mikrowelle läuft

void countdown_timer_cb(lv_timer_t * timer) { int m=0; int s=0; SeksToGo--; // Display aktualisieren refreshCounterFromSeksToGo(); // Countdown beendet? if(SeksToGo <= 0) { lv_timer_del(CountdownTimer); CountdownTimer = NULL; CountdownActive = false; switchOff(); for (int i = 0; i <5; i++) { // Essen Fertig! delay(1000); beep(1000); } // Button wieder grün und Go lv_obj_set_style_bg_color(btnGo, lv_color_hex(0x00c000), 0); lv_label_set_text(lab_btnGo, "Go"); Counter=""; lv_label_set_text(lab_counter, Counter.c_str()); refreshLVGL(); } reset_sleep_timer(); // Bei jeder Aktivität und wenn aktiv bleiben soll: Sleep-Timer zurücksetzen } void evt_btn(lv_event_t *e) { ... reset_sleep_timer(); // Bei jeder Aktivität und wenn aktiv bleiben soll: Sleep-Timer zurücksetzen ... switch(*btn_id) { ... case 'G': // GO! if(!CountdownActive) { ... CountdownSeconds=SeksToGo; CountdownActive = true; // Timer wird alle 1000 ms aufgerufen CountdownTimer = lv_timer_create(countdown_timer_cb, 1000, NULL); ... }
Jede Sekunde wird countdown_timer_cb() aufgerufen und der Countdown verringert und angezeigt. In evt_btn() werden die Tastendrücke ausgewertet und der Countdown gestartet und ggf. auch abgebrochen.

Sleep-Timer (light_sleep und deep_sleep)

Sleep-Timer benutzt man dafür, um das Gerät schlafen zu legen. im Schlafmodus verbraucht es dann nur noch sehr wenig Strom. Aber irgendwie muss man dem ESP32 auch sagen, wann der wieder aufwachen soll. Das geschieht mit einem Hardware-Interrupt, der z. B. durch einen Tastendruck ausgelöst werden kann. Sleep-Timer sind damit irgendwie das Gegenteil der normalen Timer: Statt eine Interrupt-Routine nach einer bestimmten Zeit aufzurufen wartet es bei einem Sleep-Timer quasi unendlich lange, bis von außen ein Interrupt ausgelöst wird.

Dieses Thema habe ich in einem separaten Artikel behandelt: Den ESP32 schlafen legen, um Strom zu sparen.

Demo und Video

Hier ein kleines Demonstrations-Video für die On/Off-Timer-App für das CYD gemacht, dass die Dialoge und Timer in Aktion zeigt:




Quellen, Literaturverweise und weiterführende Links