ESP32 Hardware-Timer statt millis(): Präzise Intervalle, saubere ISR & jitterfreie Abtastung
Für eine blinkende LED reicht millis() vollkommen. Sobald du aber exakt 1000 Messwerte pro Sekunde brauchst, einen Frequenzzähler baust oder ein Signal abtastest, das nicht warten will, fällt die Software-Zeitmessung auseinander: Jeder Serial.print(), jedes WLAN-Paket und jede Display-Übertragung verschiebt deinen Takt um Millisekunden. Der ESP32 hat dafür vier echte Hardware-Timer, die völlig unabhängig von deinem Sketch laufen und auf die Mikrosekunde genau feuern. In diesem Artikel richtest du sie ein, schreibst eine ISR die nicht abstürzt – und lernst den API-Bruch zwischen Arduino-Core 2.x und 3.x kennen, an dem die meisten Tutorials im Netz inzwischen scheitern.
Warum millis() irgendwann nicht mehr reicht
Der millis()-Ansatz ist der richtige Einstieg ins nicht-blockierende Programmieren und deckt 90 % aller Projekte ab. Sein Prinzip: Du fragst in jedem Schleifendurchlauf, ob genug Zeit vergangen ist. Das heißt aber auch – dein Timing ist nie besser als die Dauer eines Schleifendurchlaufs.
Solange loop() in 200 Mikrosekunden durch ist, merkst du davon nichts. Sobald aber eines dieser Dinge dazukommt, wird es unangenehm:
- Ein OLED-Update überträgt 1024 Byte über I2C – bei 400 kHz sind das rund 2,5 ms, in denen nichts anderes passiert.
- Ein WLAN-Request blockiert je nach Router zwischen 20 und 500 ms.
- Ein DHT22 braucht für eine Messung rund 25 ms, in denen der Bus mit Timing-kritischen Pulsen belegt ist.
- Der WiFi-Stack im Hintergrund unterbricht deinen Code auch dann, wenn du gar nichts anforderst.
| Verfahren | Typischer Jitter | Kleinstes Intervall | Läuft während Blockaden weiter? |
|---|---|---|---|
delay() |
— | 1 ms | nein |
millis()-Vergleich |
Länge eines loop()-Durchlaufs (oft 1–50 ms) | 1 ms | nein |
micros()-Vergleich |
wie oben, nur feiner ablesbar | ca. 10 µs | nein |
| Hardware-Timer + ISR | ca. 1–3 µs | < 1 µs | ja |
💡 Die Faustregel: Alles, was langsamer als etwa 50 Hz ist und wo ein paar Millisekunden Abweichung egal sind, gehört in eine millis()-Struktur. Alles, was schneller ist oder wo ein gleichmäßiger Abstand zwischen den Ereignissen zählt – Abtastung, Frequenzmessung, Tonerzeugung, Schrittmotor-Rampen – gehört an einen Hardware-Timer.
Was im ESP32 tatsächlich passiert
Der klassische ESP32 (WROOM32) enthält vier 64-Bit-Timer, verteilt auf zwei Timer-Gruppen. Jeder besteht aus drei Bausteinen:
- Ein Basistakt, der bei den meisten Modulen vom APB-Bus mit 80 MHz kommt.
- Ein Prescaler (Vorteiler), der diesen Takt herunterteilt. Bei einem Prescaler von 80 tickt der Zähler mit 1 MHz – also exakt einmal pro Mikrosekunde. Das ist der Grund, warum 80 in fast jedem Beispiel steht.
- Ein Alarmwert: Erreicht der Zähler diese Zahl, löst er einen Interrupt aus und startet (bei aktiviertem Autoreload) wieder bei null.
Ein Timer mit Prescaler 80 und Alarmwert 1000 feuert also alle 1000 Mikrosekunden – exakt 1000-mal pro Sekunde, egal was dein Sketch gerade tut. Der Zähler läuft in Hardware, nicht in Software.
1Der API-Bruch: Core 2.x gegen Core 3.x
Hier scheitern die meisten Tutorials, die älter als ein Jahr sind. Mit dem Arduino-ESP32-Core 3.0 wurde die Timer-API grundlegend vereinfacht. Wenn du einen Beispielcode kopierst und es heißt „too few arguments to function timerBegin", dann hast du genau dieses Problem.
| Aufgabe | Core 2.x (alt) | Core 3.x (aktuell) |
|---|---|---|
| Timer anlegen | timerBegin(0, 80, true) |
timerBegin(1000000) |
| Bedeutung des Arguments | Nummer, Prescaler, Zählrichtung | Frequenz in Hz |
| ISR anhängen | timerAttachInterrupt(t, &isr, true) |
timerAttachInterrupt(t, &isr) |
| Alarm setzen | timerAlarmWrite(t, 1000, true) |
timerAlarm(t, 1000, true, 0) |
| Alarm aktivieren | timerAlarmEnable(t) |
entfällt – timerAlarm() startet direkt |
| Zählerstand lesen | timerRead(t) |
timerRead(t) |
Die gute Nachricht: Du kannst beides in einem Sketch abfangen. Der Core definiert das Makro ESP_ARDUINO_VERSION_MAJOR, und damit lässt sich der richtige Zweig automatisch wählen:
#include <Arduino.h>
hw_timer_t *timer = nullptr;
volatile uint32_t ticks = 0;
void IRAM_ATTR onTimer() {
ticks++;
}
// Legt einen Timer an, der mit der gewuenschten Frequenz feuert.
// Funktioniert mit Core 2.x und 3.x.
void timerSetupHz(uint32_t hz) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
// Core 3.x: Basistakt 1 MHz, Alarm bei 1000000/hz Mikrosekunden
timer = timerBegin(1000000);
timerAttachInterrupt(timer, &onTimer);
timerAlarm(timer, 1000000UL / hz, true, 0);
#else
// Core 2.x: Timer 0, Prescaler 80 -> 1 Tick = 1 us
timer = timerBegin(0, 80, true);
timerAttachInterrupt(timer, &onTimer, true);
timerAlarmWrite(timer, 1000000UL / hz, true);
timerAlarmEnable(timer);
#endif
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.printf("Arduino-ESP32 Core %d.%d.%d\n",
ESP_ARDUINO_VERSION_MAJOR,
ESP_ARDUINO_VERSION_MINOR,
ESP_ARDUINO_VERSION_PATCH);
timerSetupHz(1000); // 1000 Interrupts pro Sekunde
}
void loop() {
static uint32_t letzte = 0;
if (millis() - letzte >= 1000) {
letzte = millis();
noInterrupts();
uint32_t t = ticks;
ticks = 0;
interrupts();
Serial.printf("Ticks in der letzten Sekunde: %lu\n", t);
}
}
💡 Der Selbsttest: Bei 1000 Hz muss die Ausgabe jede Sekunde exakt 1000 lauten – nicht 998, nicht 1003. Weicht der Wert ab, stimmt entweder deine Rechnung nicht oder du hast noch einen zweiten Timer laufen, der denselben Kanal belegt. Zum Vergleich: Zähle einmal in loop() statt in der ISR hoch und lass nebenbei Serial.print() laufen – dort wirst du die 1000 nie sauber treffen.
Das brauchst du
| Bauteil | ESP32-Pin | Hinweis |
|---|---|---|
| LED (mit 220 Ω) | GPIO2 | auf vielen Boards schon onboard |
| OLED SDA | GPIO21 | I2C-Standardbelegung |
| OLED SCL | GPIO22 | — |
| Analogsignal | GPIO34 | nur ADC1 ist mit WLAN nutzbar |
| FC-51 OUT | GPIO27 | Impulsquelle für den Zähler |
| VCC / GND | 3V3 / GND | FC-51 verträgt auch 5 V |
2Die goldenen Regeln für die ISR
Eine Interrupt-Service-Routine ist kein normaler Funktionsaufruf. Sie unterbricht mitten im laufenden Programm – auch mitten in einer I2C-Übertragung, mitten in einem Serial.print() und mitten im WLAN-Stack. Was in einer ISR erlaubt ist, ist entsprechend eng begrenzt.
⚠️ IRAM_ATTR ist Pflicht. Ohne dieses Attribut liegt der ISR-Code im Flash. Wird der Interrupt ausgelöst, während gerade auf den Flash zugegriffen wird, stürzt der Chip mit einem Cache disabled but cached memory region accessed-Panic ab. Das Attribut legt die Funktion ins interne RAM.
⚠️ Kein Serial.print(), kein delay(), kein Wire, kein malloc, kein String. Alles, was selbst auf Interrupts wartet oder Speicher anfordert, kann in einer ISR blockieren oder den Heap zerstören. Setz stattdessen ein Flag und erledige die Arbeit in loop().
⚠️ Jede Variable, die ISR und Hauptprogramm teilen, muss volatile sein. Sonst optimiert der Compiler den Zugriff weg und dein while (!flag) {} läuft ewig – der Compiler hat schlicht angenommen, dass sich flag nicht ändern kann.
⚠️ Mehr als 32 Bit brauchen einen Schutz. Ein uint32_t wird auf dem ESP32 atomar geschrieben. Ein uint64_t, ein float-Array oder eine Struktur nicht – hier musst du beim Lesen im Hauptprogramm die Interrupts kurz sperren oder ein portMUX_TYPE-Spinlock nutzen.
So sieht die saubere Variante mit Spinlock aus, wie sie in der ESP-IDF üblich ist:
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
volatile uint32_t impulse = 0;
volatile uint64_t summeRoh = 0;
volatile bool neuesFenster = false;
void IRAM_ATTR onTimer() {
portENTER_CRITICAL_ISR(&timerMux);
impulse++;
summeRoh += impulse;
neuesFenster = true;
portEXIT_CRITICAL_ISR(&timerMux);
}
void loop() {
if (neuesFenster) {
portENTER_CRITICAL(&timerMux);
uint32_t i = impulse;
uint64_t s = summeRoh;
neuesFenster = false;
portEXIT_CRITICAL(&timerMux);
// Ab hier gefahrlos: lokale Kopien, kein Zugriff mehr auf volatile
Serial.printf("Impulse %lu, Summe %llu\n", i, s);
}
}
ℹ️ portENTER_CRITICAL_ISR versus portENTER_CRITICAL: Die _ISR-Variante gehört in die Interrupt-Routine, die andere ins normale Programm. Beide sperren denselben Bereich, aber sie sichern unterschiedliche Zustände. Sie zu vertauschen führt zu sporadischen Abstürzen, die man tagelang sucht.
3Praxisbeispiel: 1000 Messwerte pro Sekunde, jitterfrei
Das ist die klassische Anwendung: Du willst ein analoges Signal in gleichmäßigen Abständen abtasten, um daraus einen Effektivwert, eine Frequenz oder eine FFT zu berechnen. Ungleichmäßige Abstände machen jede dieser Rechnungen kaputt.
Der Trick besteht aus zwei Teilen: Die ISR macht nur die Messung und legt sie in einen Ringpuffer. Die Auswertung passiert im Hauptprogramm.
#include <Arduino.h>
#define ADC_PIN 34
#define ABTASTRATE 1000 // Hz
#define PUFFER_N 256 // Zweierpotenz -> schneller Modulo
hw_timer_t *timer = nullptr;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile uint16_t puffer[PUFFER_N];
volatile uint16_t schreibIdx = 0;
volatile uint32_t verloren = 0;
uint16_t leseIdx = 0;
void IRAM_ATTR abtastIsr() {
// analogRead ist auf dem ESP32 IRAM-sicher und braucht ca. 10 us
uint16_t wert = analogRead(ADC_PIN);
portENTER_CRITICAL_ISR(&mux);
uint16_t naechste = (schreibIdx + 1) & (PUFFER_N - 1);
if (naechste == leseIdx) {
verloren++; // Puffer voll - Hauptprogramm zu langsam
} else {
puffer[schreibIdx] = wert;
schreibIdx = naechste;
}
portEXIT_CRITICAL_ISR(&mux);
}
bool naechsterWert(uint16_t &out) {
bool da = false;
portENTER_CRITICAL(&mux);
if (leseIdx != schreibIdx) {
out = puffer[leseIdx];
leseIdx = (leseIdx + 1) & (PUFFER_N - 1);
da = true;
}
portEXIT_CRITICAL(&mux);
return da;
}
void setup() {
Serial.begin(115200);
analogReadResolution(12);
analogSetPinAttenuation(ADC_PIN, ADC_11db);
#if ESP_ARDUINO_VERSION_MAJOR >= 3
timer = timerBegin(1000000);
timerAttachInterrupt(timer, &abtastIsr);
timerAlarm(timer, 1000000UL / ABTASTRATE, true, 0);
#else
timer = timerBegin(0, 80, true);
timerAttachInterrupt(timer, &abtastIsr, true);
timerAlarmWrite(timer, 1000000UL / ABTASTRATE, true);
timerAlarmEnable(timer);
#endif
}
void loop() {
static uint32_t summe = 0, n = 0;
static uint32_t min = 4095, max = 0;
static uint32_t letzteAusgabe = 0;
uint16_t w;
while (naechsterWert(w)) {
summe += w;
n++;
if (w < min) min = w;
if (w > max) max = w;
}
if (millis() - letzteAusgabe >= 1000) {
letzteAusgabe = millis();
if (n > 0) {
float mittel = (float)summe / n;
Serial.printf("n=%lu Mittel=%.1f Min=%lu Max=%lu SpitzeSpitze=%lu verloren=%lu\n",
n, mittel, min, max, max - min, verloren);
}
summe = 0; n = 0; min = 4095; max = 0;
}
}
💡 Der Zähler verloren ist dein wichtigstes Diagnosewerkzeug. Steht er nach einer Minute noch auf 0, hält dein Hauptprogramm mit der Abtastrate mit. Steigt er, ist entweder die Rate zu hoch oder loop() hängt zu lange – zum Beispiel an einem Display-Update oder einem WLAN-Request. Erhöhe dann PUFFER_N oder senke die Rate.
⚠️ Über etwa 20 kHz Abtastrate stößt dieser Ansatz an seine Grenze. Ein analogRead() braucht rund 10 µs, dazu kommt der Interrupt-Overhead. Bei 20 kHz ist der Prozessor damit schon zu einem Viertel ausgelastet. Für höhere Raten nimmt man den ADC im DMA-Modus (adc_continuous aus der ESP-IDF) – der schreibt direkt in den Speicher, ganz ohne CPU. Wie du den ADC vorher kalibrierst, steht im Artikel ESP32 ADC richtig auslesen.
4Praxisbeispiel: Frequenzzähler mit exaktem Zeitfenster
Beim Drehzahl- oder Frequenzmessen gibt es zwei Wege: Man misst die Zeit zwischen zwei Impulsen, oder man zählt die Impulse in einem exakt definierten Zeitfenster. Der zweite Weg ist bei hohen Frequenzen deutlich genauer – und er steht und fällt mit der Genauigkeit des Fensters. Genau dafür ist ein Hardware-Timer gemacht.
#include <Arduino.h>
#define IMPULS_PIN 27
#define FENSTER_MS 500 // Messfenster
#define IMPULSE_PRO_U 1 // Marken pro Umdrehung
hw_timer_t *fensterTimer = nullptr;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile uint32_t zaehler = 0;
volatile uint32_t letzterStand = 0;
volatile bool fensterFertig = false;
volatile uint32_t letzteFlankeUs = 0;
// Impuls-ISR mit Entprellung ueber Mindestabstand
void IRAM_ATTR impulsIsr() {
uint32_t jetzt = micros();
if (jetzt - letzteFlankeUs < 200) return; // max. 5 kHz
letzteFlankeUs = jetzt;
zaehler++;
}
// Timer-ISR: schnappt den Zaehlerstand und meldet das Fenster
void IRAM_ATTR fensterIsr() {
portENTER_CRITICAL_ISR(&mux);
letzterStand = zaehler;
zaehler = 0;
fensterFertig = true;
portEXIT_CRITICAL_ISR(&mux);
}
void setup() {
Serial.begin(115200);
pinMode(IMPULS_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(IMPULS_PIN), impulsIsr, FALLING);
#if ESP_ARDUINO_VERSION_MAJOR >= 3
fensterTimer = timerBegin(1000000);
timerAttachInterrupt(fensterTimer, &fensterIsr);
timerAlarm(fensterTimer, (uint64_t)FENSTER_MS * 1000ULL, true, 0);
#else
fensterTimer = timerBegin(1, 80, true);
timerAttachInterrupt(fensterTimer, &fensterIsr, true);
timerAlarmWrite(fensterTimer, (uint64_t)FENSTER_MS * 1000ULL, true);
timerAlarmEnable(fensterTimer);
#endif
}
void loop() {
if (!fensterFertig) return;
portENTER_CRITICAL(&mux);
uint32_t n = letzterStand;
fensterFertig = false;
portEXIT_CRITICAL(&mux);
float hz = n * (1000.0f / FENSTER_MS);
float rpm = hz * 60.0f / IMPULSE_PRO_U;
Serial.printf("%lu Impulse -> %.2f Hz = %.0f U/min\n", n, hz, rpm);
}
ℹ️ Warum das Fenster in einer eigenen ISR endet: Würdest du das Fenster mit millis() im Hauptprogramm abschließen, wäre seine Länge um die Dauer eines Schleifendurchlaufs unsicher – bei 500 ms Fenster und 5 ms Jitter wären das 1 % Messfehler. Mit dem Hardware-Timer liegt der Fehler bei unter 0,001 %. Eine ausführliche Variante mit Display und IR-Sensor findest du im Artikel Drehzahlmesser mit Arduino bauen.
5Timer plus FreeRTOS-Task: die saubere Trennung
Wenn in der ISR etwas passieren soll, das eigentlich verboten ist – Display schreiben, Daten senden, Datei speichern – dann weckst du aus der ISR einen Task auf. Der Timer sorgt für das exakte Timing, der Task erledigt die Arbeit mit allen Freiheiten eines normalen Programms.
#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
hw_timer_t *timer = nullptr;
TaskHandle_t anzeigeTask = nullptr;
volatile uint32_t sekunden = 0;
void IRAM_ATTR sekundenIsr() {
sekunden++;
// Task aus der ISR aufwecken - das ist erlaubt und sehr schnell
BaseType_t hoeherPrioritaetGeweckt = pdFALSE;
vTaskNotifyGiveFromISR(anzeigeTask, &hoeherPrioritaetGeweckt);
if (hoeherPrioritaetGeweckt) portYIELD_FROM_ISR();
}
void anzeigeLoop(void *param) {
char zeile[24];
for (;;) {
// Blockiert bis die ISR uns weckt - kostet null CPU-Zeit
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
uint32_t s = sekunden;
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x12_te);
u8g2.drawUTF8(0, 12, "Hardware-Timer");
u8g2.drawHLine(0, 16, 128);
u8g2.setFont(u8g2_font_logisoso24_tn);
snprintf(zeile, sizeof(zeile), "%02lu:%02lu:%02lu",
s / 3600UL, (s / 60UL) % 60UL, s % 60UL);
u8g2.drawStr(0, 48, zeile);
u8g2.setFont(u8g2_font_5x8_tf);
snprintf(zeile, sizeof(zeile), "Heap %u kB", (unsigned)(ESP.getFreeHeap() / 1024));
u8g2.drawStr(0, 62, zeile);
u8g2.sendBuffer();
}
}
void setup() {
Wire.begin();
Wire.setClock(400000);
u8g2.begin();
u8g2.enableUTF8Print();
xTaskCreatePinnedToCore(anzeigeLoop, "anzeige", 4096, nullptr, 1, &anzeigeTask, 1);
#if ESP_ARDUINO_VERSION_MAJOR >= 3
timer = timerBegin(1000000);
timerAttachInterrupt(timer, &sekundenIsr);
timerAlarm(timer, 1000000, true, 0);
#else
timer = timerBegin(2, 80, true);
timerAttachInterrupt(timer, &sekundenIsr, true);
timerAlarmWrite(timer, 1000000, true);
timerAlarmEnable(timer);
#endif
}
void loop() {
// Bleibt komplett frei - hier koennte WLAN, MQTT oder was auch immer laufen
delay(1000);
}
💡 Diese Uhr geht wirklich richtig. Eine Uhr, die im loop() mit millis() hochzählt, verliert bei jedem längeren WLAN-Zugriff Sekundenbruchteile – über einen Tag summiert sich das. Der Hardware-Timer feuert 86400-mal am Tag, exakt einmal pro Sekunde. Für langfristig genaue Zeit brauchst du trotzdem eine DS3231 oder NTP – der Quarz des ESP32 driftet mit der Temperatur. Mehr zu Tasks im Artikel ESP32 FreeRTOS Tutorial.
Troubleshooting
⚠️ too few arguments to function 'hw_timer_t* timerBegin(uint32_t)': Du hast Core 3.x installiert, aber Code für Core 2.x kopiert. Nimm die #if ESP_ARDUINO_VERSION_MAJOR-Weiche von oben, dann läuft der Sketch auf beiden.
⚠️ Guru Meditation Error: Core 1 panic'ed (Cache disabled but cached memory region accessed): In der ISR fehlt IRAM_ATTR, oder du rufst darin eine Funktion auf, die selbst nicht im IRAM liegt. Bibliotheksfunktionen sind fast nie IRAM-sicher – hol dir in der ISR nur Rohwerte.
⚠️ Der Sketch startet neu, sobald der Timer läuft: Meist ein Stack-Overflow, weil in der ISR zu viel passiert, oder ein Serial.print() darin. Reduziere die ISR auf Zuweisungen und ein Flag.
⚠️ Die Interruptrate stimmt nicht: Bei Core 2.x ist der Prescaler 16 Bit – Werte über 65535 sind unzulässig. Bei Core 3.x gibst du die Basisfrequenz an, nicht den Prescaler; timerBegin(80) bedeutet dort 80 Hz Zählertakt, nicht Prescaler 80. Das ist die häufigste Verwechslung beim Umstieg.
⚠️ Werte im Hauptprogramm springen oder sind halb aktualisiert: Du liest eine mehrteilige Variable ohne Schutz. Kopiere sie zwischen portENTER_CRITICAL und portEXIT_CRITICAL in lokale Variablen und rechne erst danach.
⚠️ Nach einem Deep-Sleep ist der Timer weg: Hardware-Timer überleben den Deep-Sleep nicht – die Peripherie wird abgeschaltet. Richte sie nach jedem Aufwachen in setup() neu ein. Für Aufwachzeiten nimmt man stattdessen den RTC-Timer, siehe ESP32 Deep Sleep Tutorial.
⚠️ Der Timer läuft, aber der Zähler bleibt bei 0: Bei Core 2.x fehlt timerAlarmEnable(). Ohne diesen Aufruf zählt der Timer zwar, löst aber nie einen Alarm aus.
Praxistipps
- Timer 0 lieber freilassen. Einige Bibliotheken und der Task-Watchdog greifen auf Timer 0 zu. Wenn du nur einen brauchst, nimm bei Core 2.x lieber Timer 1 oder 2 – das erspart schwer auffindbare Konflikte.
- Miss die Dauer deiner ISR. Setz am Anfang einen GPIO auf HIGH, am Ende auf LOW, und häng ein Oszilloskop oder einen Logic Analyzer dran. Die ISR sollte deutlich unter 10 % des Intervalls liegen – bei 1 kHz also unter 100 µs.
-
Zweierpotenzen für Ringpuffer.
idx = (idx + 1) & (N - 1)ist eine einzelne Bit-Operation,idx = (idx + 1) % Neine Division. In einer ISR bei 10 kHz macht das einen messbaren Unterschied. - Ein Timer kann mehrere Aufgaben bedienen. Lass ihn mit dem kleinsten gemeinsamen Intervall laufen und zähle in der ISR mit: Bei 1 kHz ist jeder 10. Tick 100 Hz, jeder 1000. eine Sekunde. Das spart Timer und hält die Aufgaben phasenstarr.
- Zum Debuggen die LED nehmen. Eine LED an GPIO2, die in der ISR getoggelt wird, zeigt dir die halbe Timerfrequenz. Bei 2 Hz Toggle siehst du 1 Hz Blinken – und ob es gleichmäßig ist, erkennt das Auge erstaunlich gut.
- Für reine Signalerzeugung ist LEDC oft besser. Wenn du nur ein Rechtecksignal oder ein PWM-Signal brauchst, mach das nicht mit einem Timer-Interrupt, sondern mit der LEDC-Einheit – die erzeugt das Signal komplett in Hardware, ganz ohne CPU-Last.
-
Auf dem Arduino Nano heißt das anders. Der Nano hat drei Timer, die man über Register wie
TCCR1BundOCR1Akonfiguriert. Das Prinzip ist identisch, die Schreibweise völlig anders – dafür gibt es Bibliotheken wie TimerOne.
🚀 Bauteile für präzises Timing
- ESP32 DEV KIT WROOM32 – vier Hardware-Timer und zwei Kerne
- ESP32-S3 N16R8 – wenn die Abtastdaten in den PSRAM sollen
- ESP32-C3 Mini – klein, RISC-V, zwei Timer
- 0,96 Zoll OLED SSD1306 – Messwerte im Blick behalten
- FC-51 IR-Sensor – Impulsgeber für den Frequenzzähler
- N20 Getriebemotor – etwas Drehendes zum Messen
- 3 mm LED Sortiment – der einfachste Timing-Test überhaupt
- 600x Widerstände Sortiment – Vorwiderstände und Teiler
- Breadboard + Dupont-Kabel Set – Aufbau in fünf Minuten
Fazit
Hardware-Timer sind keine Raketenwissenschaft, sondern drei Zeilen Einrichtung plus eine strenge Regel: In der ISR passiert so wenig wie irgend möglich. Wert holen, Flag setzen, fertig – die Auswertung gehört ins Hauptprogramm oder in einen FreeRTOS-Task.
Wann lohnt sich der Aufwand? Immer dann, wenn der Abstand zwischen zwei Ereignissen zählt und nicht nur, dass sie ungefähr rechtzeitig passieren: bei Abtastung, Frequenzmessung, Schrittmotor-Rampen und überall dort, wo aus Zeitdifferenzen gerechnet wird. Für alles andere bleibt millis() die einfachere und völlig ausreichende Lösung. Und wenn du beim Umstieg auf Core 3.x über timerBegin stolperst – die Weiche mit ESP_ARDUINO_VERSION_MAJOR aus diesem Artikel macht deinen Code zukunftssicher.