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.

ℹ️ ESP32-C3, S3 und C6 haben weniger Timer. Der klassische WROOM32 bietet vier, der ESP32-C3 und der ESP32-C6 jeweils zwei, der ESP32-S3 vier. In der Praxis kommst du fast nie über zwei – nutze die höheren Nummern nur, wenn du sie wirklich brauchst.

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

ESP32 DEV KIT WROOM32 – vier Hardware-Timer, das Referenzboard für diesen Artikel
ESP32-C3 Mini – funktioniert genauso, hat aber nur zwei Timer
0,96 Zoll OLED SSD1306 – zeigt Messrate und Ergebnis an
3 mm LED Sortiment – für den optischen Timing-Test
600x Widerstände Sortiment – Vorwiderstand und Spannungsteiler
FC-51 IR-Sensor – liefert die Impulse für den Frequenzzähler
N20 Getriebemotor – etwas, dessen Drehzahl du messen kannst
Breadboard + Dupont-Kabel Set – der ganze Aufbau passt auf ein halbes Board
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) % N eine 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 TCCR1B und OCR1A konfiguriert. Das Prinzip ist identisch, die Schreibweise völlig anders – dafür gibt es Bibliotheken wie TimerOne.

🚀 Bauteile für präzises Timing

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.

Zurück zum Blog