PWM mit Arduino & ESP32 verstehen: LEDs dimmen mit analogWrite und LEDC

Ein digitaler Pin kennt nur zwei Zustände: an oder aus. Trotzdem kannst du damit eine LED stufenlos dimmen, einen Motor langsam anlaufen lassen und einen Servo steuern. Der Trick heißt PWM – Pulsweitenmodulation. In diesem Tutorial verstehst du endlich, was Duty Cycle und Frequenz wirklich bedeuten, warum dein LED-Fade in der Mitte komisch aussieht, wie du das mit einer Gamma-Tabelle behebst und warum der ESP32 mit seiner LEDC-Einheit dem klassischen analogWrite() weit voraus ist.

Was PWM eigentlich macht

PWM schaltet einen Pin sehr schnell zwischen HIGH und LOW hin und her. Entscheidend ist das Verhältnis: Ist der Pin in einer Periode zur Hälfte der Zeit an, kommt im Mittel die halbe Spannung heraus – die LED leuchtet halb so hell. Dieses Verhältnis heißt Duty Cycle und wird in Prozent angegeben.

Zwei Größen beschreiben ein PWM-Signal vollständig:

  • Duty Cycle – wie lange der Pin pro Periode an ist. Beim Arduino stellst du ihn mit analogWrite(pin, 0..255) ein: 0 = dauerhaft aus, 127 = etwa 50 %, 255 = dauerhaft an.
  • Frequenz – wie oft sich diese Periode pro Sekunde wiederholt. Der Arduino UNO und Nano arbeiten voreingestellt mit rund 490 Hz, an den Pins D5 und D6 mit 980 Hz.

ℹ️ Es ist keine echte Analogspannung. Ein Multimeter zeigt bei 50 % Duty Cycle zwar rund 2,5 V an, weil es mittelt – tatsächlich springt die Spannung aber weiterhin zwischen 0 V und 5 V. Für LEDs, Motoren und Heizungen ist das völlig egal, denn Auge und Trägheit mitteln von selbst. Brauchst du eine echte Analogspannung, brauchst du einen Tiefpass aus Widerstand und Kondensator oder einen richtigen DAC, wie ihn der Arduino UNO R4 Minima mitbringt.

Welche Pins können PWM?

Nicht jeder Pin beherrscht PWM. Auf dem Board sind die fähigen Pins mit einer Tilde (~) markiert. Und die verschiedenen Pins hängen an unterschiedlichen Timern, was gleich noch wichtig wird:

Board PWM-Pins Auflösung Standard-Frequenz
Arduino UNO R3 / Nano V3 D3, D5, D6, D9, D10, D11 8 Bit (0–255) 490 Hz (D5/D6: 980 Hz)
ESP32 (WROOM32) fast jeder GPIO als Ausgang 1–16 Bit frei wählbar frei wählbar (1 Hz bis 40 MHz)
ESP8266 (D1 Mini) alle GPIO außer GPIO16 10 Bit (0–1023) 1 kHz, einstellbar
RP2040-Zero alle GPIO 16 Bit einstellbar

⚠️ Timer-Konflikte auf dem AVR: Die Servo-Bibliothek belegt Timer1 und deaktiviert damit PWM auf D9 und D10. Die tone()-Funktion belegt Timer2 und stört D3 und D11. Wenn eine LED plötzlich nur noch an oder aus geht, obwohl der Code stimmt, ist fast immer ein Timer-Konflikt die Ursache. Nutze dann einen Pin an einem freien Timer.

Das brauchst du

Arduino Nano V3 (ATmega328P, USB-C) – für den analogWrite()-Teil
ESP32 Entwicklungsboard (WROOM32, USB-C) – für die LEDC-Beispiele
3 mm LED-Sortiment (100er Box) – zum Dimmen und Experimentieren
Widerstands-Sortiment 600 Stk. (10 Ω – 10 MΩ) – Vorwiderstände, typisch 220 Ω
Rotary Encoder KY-040 / EC11 – als Dimmer-Regler
Mini N20 Getriebemotor – optional, für das Frequenz-Kapitel

1Verkabelung

Ganz simpel: LED mit Vorwiderstand an einen PWM-Pin. Der lange Anschluss der LED ist der Pluspol (Anode), der kurze gehört über den Widerstand an GND.

Bauteil / Pin Arduino Nano ESP32
LED Anode (lang) D9 (~) GPIO18
LED Kathode (kurz) über 220 Ω an GND über 220 Ω an GND
Encoder CLK / DT D2 / D3 GPIO32 / GPIO33
Encoder SW D4 GPIO25
Encoder + / GND 5V / GND 3V3 / GND

⚠️ Vorwiderstand nicht vergessen. Eine LED direkt an den Pin zerstört sie oder den Ausgang – auch bei kleinem Duty Cycle, denn während der Ein-Phase liegt die volle Spannung an. Wie du den passenden Wert berechnest, steht im Artikel LED-Vorwiderstand berechnen. Für eine rote 3-mm-LED an 5 V sind 220 Ω eine gute Wahl.

2Dein erstes Fading mit analogWrite()

Der Einstieg ist eine Zeile Code. analogWrite() braucht kein pinMode(), das erledigt die Funktion selbst:

#define LED_PIN 9

int helligkeit = 0;
int richtung   = 5;

void setup() {
  Serial.begin(115200);
}

void loop() {
  analogWrite(LED_PIN, helligkeit);   // 0..255
  helligkeit += richtung;

  if (helligkeit <= 0 || helligkeit >= 255) {
    richtung = -richtung;             // Richtung umkehren
    helligkeit = constrain(helligkeit, 0, 255);
  }
  delay(20);
}

Die LED atmet auf und ab. Und genau hier fällt dir etwas auf: Der Übergang von 0 auf 30 sieht dramatisch aus, während von 200 auf 255 kaum ein Unterschied zu erkennen ist. Das ist kein Fehler in deinem Code – das ist dein Auge.

3Warum das Fading ungleichmäßig aussieht: Gamma-Korrektur

Menschliche Helligkeitswahrnehmung ist nicht linear, sondern annähernd logarithmisch. Verdoppelst du die Leistung, empfindet dein Auge das keineswegs als doppelt so hell. Die Lösung ist eine Gamma-Korrektur: Du rechnest den gewünschten Wert mit einer Potenzfunktion (typisch γ = 2,8) um, bevor du ihn ausgibst.

Auf dem AVR wäre pow() in jedem Durchlauf viel zu langsam. Deshalb legst du die Werte einmal in eine Tabelle im Flash – hier verkürzt auf 32 Stützstellen, die interpoliert werden:

#include <avr/pgmspace.h>
#define LED_PIN 9

// Gammakurve (gamma = 2.8) in 32 Schritten, im Flash statt im RAM
const uint8_t gammaTab[32] PROGMEM = {
    0,   0,   1,   2,   4,   6,   9,  13,
   17,  22,  28,  35,  43,  51,  61,  71,
   83,  95, 109, 123, 139, 156, 174, 193,
  213, 234, 255, 255, 255, 255, 255, 255
};

uint8_t gamma8(uint8_t wert) {
  uint8_t index = wert >> 3;                    // 0..31
  uint8_t rest  = wert & 0x07;
  uint8_t a = pgm_read_byte(&gammaTab[index]);
  uint8_t b = pgm_read_byte(&gammaTab[min(index + 1, 31)]);
  return a + (((int)(b - a) * rest) >> 3);      // lineare Interpolation
}

int helligkeit = 0, richtung = 2;

void setup() {}

void loop() {
  analogWrite(LED_PIN, gamma8(helligkeit));
  helligkeit += richtung;
  if (helligkeit <= 0 || helligkeit >= 255) {
    richtung = -richtung;
    helligkeit = constrain(helligkeit, 0, 255);
  }
  delay(15);
}

💡 Der Unterschied ist sofort sichtbar. Lade beide Sketche nacheinander hoch und vergleiche. Mit Gamma-Korrektur wirkt der Übergang gleichmäßig und ruhig, ohne wirkt er unten hektisch und oben zerdehnt. Genau diesen Trick nutzt jede kommerzielle Ambientebeleuchtung.

Auf dem ESP32 mit seinen deutlich mehr Rechenreserven kannst du auch direkt rechnen: duty = pow(wert / 255.0, 2.8) * maxDuty;

4ESP32: die LEDC-Einheit statt analogWrite()

Der ESP32 hat eine eigene Hardware-Einheit für PWM: LEDC. Sie bietet bis zu 16 unabhängige Kanäle, eine frei wählbare Frequenz und eine Auflösung von bis zu 16 Bit – also 65.536 Helligkeitsstufen statt 256. Seit ESP32-Core 3.0 ist die API angenehm kurz:

#define LED_PIN 18
#define PWM_FREQ 5000        // 5 kHz - fuer LEDs voellig flimmerfrei
#define PWM_BITS 12          // 12 Bit = 0..4095 Stufen

void setup() {
  Serial.begin(115200);
  ledcAttach(LED_PIN, PWM_FREQ, PWM_BITS);   // Core 3.x
}

void loop() {
  const int maxDuty = (1 << PWM_BITS) - 1;   // 4095
  for (int i = 0; i <= 255; i++) {
    float anteil = i / 255.0;
    int duty = (int)(pow(anteil, 2.8) * maxDuty);   // Gamma direkt gerechnet
    ledcWrite(LED_PIN, duty);
    delay(10);
  }
  for (int i = 255; i >= 0; i--) {
    float anteil = i / 255.0;
    ledcWrite(LED_PIN, (int)(pow(anteil, 2.8) * maxDuty));
    delay(10);
  }
}

ℹ️ Du nutzt noch Core 2.x? Dann lautet die alte, kanalbasierte Schreibweise:

ledcSetup(0, 5000, 12);        // Kanal 0, 5 kHz, 12 Bit
ledcAttachPin(LED_PIN, 0);     // Pin an Kanal 0 haengen
ledcWrite(0, 2048);            // Duty auf Kanal 0 schreiben

Welche Version du hast, siehst du im Boardverwalter unter „esp32 by Espressif Systems“. Bei Core 3.x wird die alte Schreibweise nicht mehr kompilieren – das ist die häufigste Fehlerquelle bei älteren Tutorials aus dem Netz.

Mehrere Kanäle gleichzeitig sind kein Problem – so steuerst du zum Beispiel drei LEDs für Rot, Grün und Blau:

#define PIN_R 18
#define PIN_G 19
#define PIN_B 21

void setup() {
  ledcAttach(PIN_R, 5000, 8);
  ledcAttach(PIN_G, 5000, 8);
  ledcAttach(PIN_B, 5000, 8);
}

void setzeFarbe(uint8_t r, uint8_t g, uint8_t b) {
  ledcWrite(PIN_R, r);
  ledcWrite(PIN_G, g);
  ledcWrite(PIN_B, b);
}

void loop() {
  // sanfter Farbverlauf durch den Farbkreis
  for (int i = 0; i < 256; i++) {
    setzeFarbe(i, 255 - i, (i * 2) % 256);
    delay(20);
  }
}

5Fertiger Dimmer mit Drehgeber und Speicherfunktion

Jetzt kombinierst du alles zu einem brauchbaren Gerät: Der Drehgeber regelt die Helligkeit, die Gamma-Korrektur sorgt für ein sauberes Gefühl beim Drehen, und nach fünf Sekunden ohne Änderung wird der Wert dauerhaft gespeichert:

#include <EEPROM.h>
#include <avr/pgmspace.h>

#define LED_PIN  9
#define PIN_CLK  2
#define PIN_DT   3
#define ADR_WERT 0
#define ADR_MAGIC 1
#define MAGIC 0x5A

extern const uint8_t gammaTab[32] PROGMEM;   // Tabelle aus Schritt 3
uint8_t gamma8(uint8_t wert);

int  helligkeit = 128;
int  gespeichert = 128;
uint8_t letzterClk = HIGH;
unsigned long letzteAenderung = 0;
const unsigned long SPEICHER_VERZOEGERUNG = 5000;

void setup() {
  pinMode(PIN_CLK, INPUT_PULLUP);
  pinMode(PIN_DT,  INPUT_PULLUP);
  Serial.begin(115200);

  if (EEPROM.read(ADR_MAGIC) != MAGIC) {     // Erststart
    EEPROM.update(ADR_WERT, 128);
    EEPROM.update(ADR_MAGIC, MAGIC);
  }
  helligkeit = EEPROM.read(ADR_WERT);
  gespeichert = helligkeit;
  analogWrite(LED_PIN, gamma8(helligkeit));
}

void loop() {
  uint8_t clk = digitalRead(PIN_CLK);
  if (clk != letzterClk && clk == LOW) {
    helligkeit += (digitalRead(PIN_DT) != clk) ? 5 : -5;
    helligkeit = constrain(helligkeit, 0, 255);
    analogWrite(LED_PIN, gamma8(helligkeit));
    letzteAenderung = millis();
    Serial.println(helligkeit);
  }
  letzterClk = clk;

  // erst nach 5 s Ruhe speichern - schont das EEPROM
  if (helligkeit != gespeichert && letzteAenderung != 0 &&
      millis() - letzteAenderung > SPEICHER_VERZOEGERUNG) {
    EEPROM.update(ADR_WERT, (byte)helligkeit);
    gespeichert = helligkeit;
    Serial.println("gespeichert");
  }
}

💡 Warum die Verzögerung? Würdest du bei jeder Rastung speichern, wäre das EEPROM nach ein paar Wochen Dauerbetrieb verschlissen. Wie du dauerhaft speicherst, ohne den Speicher zu ruinieren, erfährst du im Artikel Arduino EEPROM & ESP32 Preferences.

PWM-Frequenz ändern – gegen Flimmern und Motorpfeifen

Die voreingestellten 490 Hz reichen für LEDs meist aus. Zwei Fälle machen aber Ärger: Beim Filmen mit dem Smartphone entstehen störende Streifen, und ein per PWM geregelter Getriebemotor pfeift hörbar, weil 490 Hz mitten im hörbaren Bereich liegen.

Auf dem ESP32 ist die Lösung trivial – du setzt in ledcAttach() einfach 20.000 Hz ein, also oberhalb des Hörbereichs. Auf dem AVR änderst du den Timer-Vorteiler direkt über ein Register:

void setup() {
  pinMode(9, OUTPUT);

  // Timer1 steuert die Pins D9 und D10.
  // Die unteren 3 Bit von TCCR1B sind der Vorteiler:
  //   0x01 =  31372 Hz   (unhoerbar, ideal fuer Motoren)
  //   0x02 =   3921 Hz
  //   0x03 =    490 Hz   (Standard)
  //   0x04 =    122 Hz
  //   0x05 =     30 Hz
  TCCR1B = (TCCR1B & 0b11111000) | 0x01;

  analogWrite(9, 128);   // ab jetzt mit 31 kHz
}

void loop() {}

⚠️ Finger weg von Timer0. Timer0 (Pins D5 und D6) versorgt millis(), micros() und delay(). Änderst du dort den Vorteiler, laufen alle Zeitfunktionen falsch – delay(1000) wartet dann plötzlich 64 Sekunden. Nutze für Frequenzänderungen Timer1 (D9/D10) oder Timer2 (D3/D11).

ℹ️ Motor direkt am Pin? Nein. Ein Arduino-Pin liefert maximal 20 mA – ein Motor zieht ein Vielfaches davon und erzeugt beim Abschalten zusätzlich Spannungsspitzen. Du brauchst einen Treiber oder Transistor mit Freilaufdiode. Den kompletten Aufbau zeigt der Artikel DC-Motor mit Arduino ansteuern.

Troubleshooting

⚠️ LED geht nur an oder aus, nichts dazwischen: Du nutzt einen Pin ohne PWM-Fähigkeit. Auf UNO und Nano funktionieren nur D3, D5, D6, D9, D10 und D11 – erkennbar an der Tilde auf dem Board.

⚠️ Seit dem Einbinden der Servo-Bibliothek dimmt nichts mehr: Klassischer Timer-Konflikt. Servo belegt Timer1 und damit D9 und D10. Weiche auf D3, D5, D6 oder D11 aus.

⚠️ LED flackert sichtbar, besonders am Rand des Blickfelds: Die Frequenz ist zu niedrig. Erhöhe sie über den Timer-Vorteiler oder nutze auf dem ESP32 einfach 5.000 Hz oder mehr.

⚠️ ESP32: ledcSetup was not declared in this scope: Du hast Core 3.x, aber Code für Core 2.x. Ersetze ledcSetup() und ledcAttachPin() durch das einzelne ledcAttach(pin, freq, bits).

⚠️ Bei analogWrite(pin, 255) leuchtet die LED nicht ganz so hell wie bei digitalWrite(pin, HIGH): Das ist normal und liegt an der Umschaltung im letzten Schritt. Willst du garantiert volle Helligkeit, schalte bei 255 auf digitalWrite() um.

⚠️ Der Motor brummt bei niedriger Drehzahl und läuft nicht an: Unterhalb von etwa 25 % Duty Cycle reicht das Drehmoment vieler Motoren nicht. Gib beim Start kurz vollen Duty Cycle und regle dann herunter.

🚀 Bauteile für deine PWM-Experimente

Alles für die Beispiele aus diesem Tutorial:

Fazit

PWM ist eines der wenigen Konzepte, die du einmal verstehst und danach in fast jedem Projekt wiederfindest – vom Dimmer über die Motordrehzahl bis zur Servoansteuerung. Du kennst jetzt den Unterschied zwischen Duty Cycle und Frequenz, weißt, welche Pins mitspielen und welche Timer sich dabei in die Quere kommen können.

Die zwei Details, die den Unterschied zwischen „funktioniert“ und „sieht professionell aus“ ausmachen: die Gamma-Korrektur für gleichmäßige Übergänge und eine ausreichend hohe Frequenz gegen Flimmern und Motorpfeifen. Auf dem ESP32 bekommst du beides quasi geschenkt – 16 Kanäle, 16 Bit Auflösung und eine frei wählbare Frequenz. Viel Spaß beim Dimmen!

Zurück zum Blog