ESP32: PWM on every pin with the LEDC peripheral
Use the ESP32's LEDC hardware to generate PWM on any GPIO. The right way to dim LEDs, drive servos, and control motor speed.
The Arduino analogWrite() function does not work the way you think on
the ESP32. On a real Arduino, it directly drives the timer hardware. On
the ESP32, analogWrite() is a wrapper around the LEDC peripheral, and
the default settings are wrong for most projects (8-bit resolution, only
16 channels, conflicts with other peripherals).
This tutorial covers LEDC the way I actually use it: pick a frequency, pick a resolution, attach a pin, set a duty cycle. By the end you will know how to drive a servo (50 Hz), dim an LED without flicker (5000 Hz), and control motor speed (25 kHz to stay above audible range).
What LEDC actually is
LEDC stands for “LED Control.” It is a hardware peripheral on the ESP32 designed for generating PWM signals. The hardware does the toggling; your code just sets the duty cycle.
There are 16 LEDC channels, each independent. Each channel can be attached to one GPIO pin. So you can do PWM on up to 16 pins simultaneously, all without CPU involvement.
You pick two parameters per channel:
- Frequency: how fast the PWM signal toggles, in Hz.
- Resolution: how many steps in one duty cycle. 8-bit = 256 steps, 12-bit = 4096 steps.
These two are linked. At 50 Hz with 16-bit resolution, the timer ticks every 1/(50 * 65536) = 305 ns. That is the minimum pulse width you can control. For a servo that needs 1-2 ms pulse widths, 50 Hz at 16-bit resolution gives you about 1 us precision, which is more than enough.
The setup pattern
const int LED_PIN = 4;
const int PWM_FREQ = 5000; // 5 kHz, good for LED dimming
const int PWM_RESOLUTION = 8; // 8-bit, 0-255 duty
const int LEDC_CHANNEL = 0;
void setup() {
ledcSetup(LEDC_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
ledcAttachPin(LED_PIN, LEDC_CHANNEL);
}
void loop() {
for (int duty = 0; duty <= 255; duty++) {
ledcWrite(LEDC_CHANNEL, duty);
delay(5);
}
for (int duty = 255; duty >= 0; duty--) {
ledcWrite(LEDC_CHANNEL, duty);
delay(5);
}
}
Upload. The LED fades smoothly up and down.
Choosing frequency and resolution
The rule: pick the resolution first, then derive the frequency.
| Resolution | Steps | Min pulse at 50 Hz |
|---|---|---|
| 8-bit | 256 | 78 us |
| 10-bit | 1024 | 20 us |
| 12-bit | 4096 | 4.9 us |
| 16-bit | 65536 | 305 ns |
For different uses:
- LED dimming: 5 kHz, 8-bit. Smooth to the eye, no flicker.
- Servo control: 50 Hz, 16-bit. Standard servo protocol. 16-bit gives you ~305 ns precision; servos need ~1 us.
- Motor speed control: 25 kHz or higher, 8-bit. Above audible frequency, so the motor does not whine. Resolution matters less because you are usually running at a fixed speed.
- Audio output: 44.1 kHz or higher, 8-bit. You are replacing a DAC with PWM at audio frequencies.
ledcWrite()takes the duty cycle as an integer from 0 to the resolution max. For 8-bit, that is 0-255. For 16-bit, that is 0-65535. The Arduino-styleanalogWrite(pin, 128)calls map to 8-bit, so 128 is “half on” if the channel is 8-bit. If the channel is 16-bit,analogWrite(pin, 128)only gives you 128/65535 = 0.2% duty.
Why not just use analogWrite?
Two reasons.
First, analogWrite defaults to 8-bit resolution and a frequency
that may not be appropriate. For servos you need 50 Hz; for LEDs you
want 5 kHz. analogWrite does not let you set either.
Second, analogWrite and ledcWrite share the same underlying
hardware. If you analogWrite(pin, 128) and then call ledcAttachPin
to a different pin, the first pin’s settings change. Mixing them is
fragile.
The convention I follow: always use ledcSetup + ledcAttachPin +
ledcWrite explicitly. Never use analogWrite on the ESP32. Saves
debugging time later.
Driving a servo (50 Hz, 16-bit)
The servo signal is a 50 Hz PWM with pulse widths from 1 ms (0 degrees) to 2 ms (180 degrees), with 1.5 ms = 90 degrees. The period is 20 ms.
At 50 Hz and 16-bit resolution, the period is 1/50 = 20 ms, divided into 65536 steps. Each step is 20 ms / 65536 = 305 ns.
- 1 ms = 1000000 ns / 305 ns = ~3280 steps
- 1.5 ms = ~4920 steps
- 2 ms = ~6550 steps
const int SERVO_PIN = 4;
const int SERVO_CHANNEL = 0;
const int SERVO_FREQ = 50;
const int SERVO_RES = 16;
const int SERVO_MIN = 3280; // 0 degrees
const int SERVO_MAX = 6550; // 180 degrees
const int SERVO_MID = 4920; // 90 degrees
void setup() {
ledcSetup(SERVO_CHANNEL, SERVO_FREQ, SERVO_RES);
ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);
}
void loop() {
ledcWrite(SERVO_CHANNEL, SERVO_MIN);
delay(1000);
ledcWrite(SERVO_CHANNEL, SERVO_MID);
delay(1000);
ledcWrite(SERVO_CHANNEL, SERVO_MAX);
delay(1000);
}
Upload. The servo should sweep 0, 90, 180 degrees.
The min/max values depend on your specific servo. Cheap servos (SG90) need 1000-2000 us. Expensive digital servos often need 500-2500 us. Test and adjust. The pattern is the same.
Driving a motor at 25 kHz
DC motors like PWM above 20 kHz because it is above audible range. At 25 kHz, the motor does not whine.
const int MOTOR_PIN = 4;
const int MOTOR_CHANNEL = 0;
const int MOTOR_FREQ = 25000; // 25 kHz, above audible
const int MOTOR_RES = 8; // 8-bit is enough
void setup() {
ledcSetup(MOTOR_CHANNEL, MOTOR_FREQ, MOTOR_RES);
ledcAttachPin(MOTOR_PIN, MOTOR_CHANNEL);
}
void loop() {
for (int speed = 0; speed <= 255; speed += 5) {
ledcWrite(MOTOR_CHANNEL, speed);
delay(100);
}
delay(1000);
for (int speed = 255; speed >= 0; speed -= 5) {
ledcWrite(MOTOR_CHANNEL, speed);
delay(100);
}
}
Add a motor driver (L298N, TB6612FNG, or similar) between the ESP32 and the motor. The ESP32 cannot drive a motor directly; its GPIO pins deliver at most 40 mA.
Using multiple channels
You can run PWM on 16 pins simultaneously. Each pin needs its own channel.
const int LED_PIN = 4;
const int SERVO_PIN = 5;
const int MOTOR_PIN = 6;
const int LED_CHANNEL = 0;
const int SERVO_CHANNEL = 1;
const int MOTOR_CHANNEL = 2;
void setup() {
ledcSetup(LED_CHANNEL, 5000, 8);
ledcAttachPin(LED_PIN, LED_CHANNEL);
ledcSetup(SERVO_CHANNEL, 50, 16);
ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);
ledcSetup(MOTOR_CHANNEL, 25000, 8);
ledcAttachPin(MOTOR_PIN, MOTOR_CHANNEL);
}
The hardware does the toggling. Your code just sets duty cycles. CPU usage is essentially zero.
Detaching a pin
If you want to use a pin for digital I/O after using it for PWM:
ledcDetachPin(LED_PIN);
pinMode(LED_PIN, OUTPUT);
The detach frees the LEDC channel for reuse on a different pin.
What you learned
- LEDC is the ESP32’s hardware PWM peripheral. 16 channels, each can drive one pin.
- Pick frequency and resolution per channel. The two are linked.
- Common combos: LED at 5 kHz / 8-bit, servo at 50 Hz / 16-bit, motor at 25 kHz / 8-bit.
- Always use
ledcSetup+ledcAttachPin+ledcWrite. Never useanalogWriteon the ESP32.
When something breaks
- The LED flickers. Frequency is too low. Bump to 5 kHz or higher.
- The servo jitters. Frequency is right (50 Hz) but resolution is too low. Use 16-bit. Or the servo is underpowered (separate supply needed).
- The motor whines. Frequency is below 20 kHz. Bump to 25 kHz.
ledcWritedoes nothing. You forgotledcAttachPin. Or you attached to a different pin than the one you wired.analogWriteandledcWriteconflict. Pick one. I useledcWriteonly.
What to build next
- The servo motor tutorial combines this with the LEDC servo pattern for a knob-controlled servo.
- The WS2812B tutorial uses LEDC at 800 kHz to drive NeoPixels. Yes, the hardware can do it.
- The book ESP32 Robotics Projects covers motor control in depth (H-bridges, encoders, PID).