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.
- Too low a frequency → visible LED flicker, motor chatter
- Too high a frequency → resolution gets squeezed (math in §2)
- Rules of thumb: LED breathing 1–5kHz, servos 50Hz, DC motors 20kHz (above the audible band)
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 frequency | Max usable resolution | Typical use |
|---|---|---|
| 50 Hz | 20 bits | Servos (14 bits is plenty) |
| 1 kHz | 16 bits | LED breathing |
| 5 kHz | 14 bits | LEDs |
| 40 kHz | 11 bits | IR carriers |
| 20 kHz | 12 bits | Motors (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 width | Angle |
|---|---|
| 0.5 ms | 0° |
| 1.5 ms | 90° |
| 2.5 ms | 180° |
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)
| Scenario | Verdict |
|---|---|
Legacy tutorial errors ledcSetup undefined | 3.x uses ledcAttach |
| Visible LED flicker | Frequency too low — go above 1kHz |
| Servo jitters/buzzes | Not 50Hz; or inadequate power (separate 5V) |
| Duty resolution insufficient | Frequency too high — see the §2 table |
| Slight glow at zero duty | Driver circuit leakage, not a PWM issue |
| Motor speed control whines | Go above 20kHz |
Can I use analogWrite? | Yes on 3.x (LEDC-backed) but fixed 8-bit |
7. Further reading
- 🧭 Series: ADC input guide — reading analog quantities
- 🧭 Foundation: GPIO Complete Guide — digital in/out
- ⏭️ Next: interrupts & debouncing (coming soon)
License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com