ESP32 PWM Complete Guide: LEDC Channels, Breathing LEDs, Servos and Resolution Trade-offs

Prerequisite: GPIO output is all-or-nothing — full 3.3V or 0V. Want a half-bright LED, a variable-speed motor or a steered servo? You need PWM: switching the pin extremely fast and letting the duty cycle fake the in-between levels.


1. PWM fundamentals: frequency × duty cycle

25% duty:  ████────────────████────────────  → LED at ~25% brightness
75% duty:  ██████████████──██████████████──  → LED at ~75% brightness

Frequency: how many times per second the pattern repeats. Eyes and mechanics
can't follow the switching, so what's perceived is the average = 3.3V × duty.

2. LEDC: the ESP32’s PWM engine

There’s no software bit-banging — the ESP32 has a dedicated LEDC (LED Control) peripheral: 16 independent channels (8 high-speed + 8 low-speed) on the classic chip, each with its own frequency and duty.

1. The hard frequency/resolution constraint

The LEDC timer width is finite — frequency and resolution trade off:

resolution = log2( 80MHz clock / frequency )
Target frequencyMax usable resolutionTypical use
50 Hz20 bitsServos (14 bits is plenty)
1 kHz16 bitsLED breathing
5 kHz14 bitsLEDs
40 kHz11 bitsIR carriers
20 kHz12 bitsMotors (no whine)

Requesting 5kHz + 16-bit silently downgrades resolution — if duty precision looks off, check this table first.

2. Old vs new API (the tutorial dividing line)

arduino-esp32 2.x (most legacy tutorials):

ledcSetup(channel, freq, resolution);   // configure channel
ledcAttachPin(pin, channel);            // bind pin to channel
ledcWrite(channel, duty);

arduino-esp32 3.x (2024 onward, pin-direct):

ledcAttach(pin, freq, resolution);      // configure pin directly
ledcWrite(pin, duty);                   // write to the pin
// Shortcut: analogWrite(pin, 0~255) also works (backed by LEDC)

⚠️ Copied an old tutorial and hit ledcSetup was not declared? That’s the 3.x API removal. This site writes for 3.x only.


3. Project 1: breathing LED (the PWM Hello World)

#define LED_PIN 2

void setup() {
  ledcAttach(LED_PIN, 2000, 12);   // 2kHz, 12-bit (0~4095)
}

void loop() {
  for (int duty = 0; duty <= 4095; duty += 8) {   // fade in
    ledcWrite(LED_PIN, duty);
    delay(2);
  }
  for (int duty = 4095; duty >= 0; duty -= 8) {   // fade out
    ledcWrite(LED_PIN, duty);
    delay(2);
  }
}

Non-blocking version (combining the millis() paradigm from Your First Program so the main loop stays free):

uint32_t t0 = 0;

void loop() {
  if (millis() - t0 > 4) {
    t0 = millis();
    float phase = (millis() % 4000) / 4000.0;          // 4s period
    int duty = (sin(phase * 2 * PI) + 1) / 2 * 4095;   // sinusoidal breathing
    ledcWrite(LED_PIN, duty);
  }
  // other logic runs here in parallel (sensors, network...)
}

4. Project 2: servos (the 50Hz textbook case)

A servo expects 50Hz pulses; the pulse width 0.5–2.5ms sets the angle:

Pulse widthAngle
0.5 ms0°
1.5 ms90°
2.5 ms180°

At 12-bit resolution a 20ms period maps to 4095:

#define SERVO_PIN 18

void setup() {
  ledcAttach(SERVO_PIN, 50, 12);
  writeAngle(90);   // center
}

void writeAngle(int angle) {
  // 0.5ms~2.5ms → duty 102~512
  int duty = map(angle, 0, 180, 102, 512);
  ledcWrite(SERVO_PIN, duty);
}

void loop() {
  writeAngle(0);   delay(1000);
  writeAngle(90);  delay(1000);
  writeAngle(180); delay(1000);
}

⚠️ Power red line: a stalled servo can draw 1A+ — never power it from the dev board’s 3.3V/5V pins. Use a separate 5V supply (sharing GND with the ESP32) or enjoy instant Brownout resets. See the power traps section of the Chip Selection Guide.


5. Project 3: RGB LEDs (multi-channel coordination)

A common-cathode RGB LED needs one channel per color:

#define R_PIN 25
#define G_PIN 26
#define B_PIN 27

void setup() {
  ledcAttach(R_PIN, 5000, 8);
  ledcAttach(G_PIN, 5000, 8);
  ledcAttach(B_PIN, 5000, 8);
}

void setColor(int r, int g, int b) {   // 0~255
  ledcWrite(R_PIN, r);
  ledcWrite(G_PIN, g);
  ledcWrite(B_PIN, b);
}

void loop() {
  setColor(255, 0, 0);   delay(500);   // red
  setColor(0, 255, 0);   delay(500);   // green
  setColor(0, 0, 255);   delay(500);   // blue
  setColor(255, 255, 0); delay(500);   // yellow
}

WS2812B addressable strips don’t use LEDC (they need special timing) — that’s the RMT peripheral, an L4 topic covered in a dedicated article later.


6. Pitfall cheat sheet (bookmark this)

ScenarioVerdict
Legacy tutorial errors ledcSetup undefined3.x uses ledcAttach
Visible LED flickerFrequency too low — go above 1kHz
Servo jitters/buzzesNot 50Hz; or inadequate power (separate 5V)
Duty resolution insufficientFrequency too high — see the §2 table
Slight glow at zero dutyDriver circuit leakage, not a PWM issue
Motor speed control whinesGo above 20kHz
Can I use analogWrite?Yes on 3.x (LEDC-backed) but fixed 8-bit

7. Further reading


License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com