PWM on ESP32

Control LED brightness, motor speed, and servo position with Pulse Width Modulation

Digital pins can only be HIGH (3.3 V) or LOW (0 V) -- there is no in-between. But what if you want to dim an LED to half brightness, or run a motor at 30% speed? The answer is Pulse Width Modulation (PWM): a technique that rapidly switches a pin on and off to simulate an intermediate voltage. In this article, you will learn how PWM works on the ESP32 and use it to control LED brightness with code and a potentiometer.

🔗What is PWM?

PWM works by toggling a digital pin between HIGH and LOW at a fixed frequency -- too fast for the human eye to see individual flashes. What matters is the ratio of time the signal spends HIGH versus LOW in each cycle. This ratio is called the duty cycle, expressed as a percentage:

Duty cycleTime HIGHAverage voltage (3.3 V pin)Perceived effect
0%Never on0 VLED off
25%1/4 of cycle~0.83 VLED dim
50%1/2 of cycle~1.65 VLED medium
75%3/4 of cycle~2.48 VLED bright
100%Always on3.3 VLED full brightness

The key insight is that components like LEDs and motors respond to the average power delivered. A 50% duty cycle delivers half the average power, so an LED appears half as bright and a motor runs at roughly half speed. The switching happens so fast (thousands of times per second) that the LED does not visibly flicker and the motor spins smoothly.

🔗ESP32 PWM: the LEDC peripheral

The ESP32 has a dedicated hardware peripheral for PWM called LEDC (LED Control). Despite the name, it works for any PWM application -- LEDs, motors, buzzers, and more.

Key features:

  • 16 PWM channels -- 8 high-speed and 8 low-speed, so you can independently control up to 16 outputs
  • Configurable frequency -- From a few Hz to several MHz
  • Configurable resolution -- 1 to 16 bits (controls how many brightness steps you get)

🔗API Functions

The ESP32 Arduino core (v3.x and later) uses two main functions:

ledcAttach(pin, frequency, resolution);  // Configure a pin for PWM
ledcWrite(pin, dutyCycle);               // Set the duty cycle

Warning: If you find older tutorials or code using ledcSetup(), ledcAttachPin(), and ledcWriteTone(), be aware that these functions were deprecated in ESP32 Arduino core v3.0. The new API uses ledcAttach() and ledcWrite() and handles channel assignment automatically. If your code does not compile, check which core version you are using and update the function calls accordingly.

🔗Common Frequency and Resolution Combinations

Use caseFrequencyResolutionDuty cycle rangeNotes
LED dimming5000 Hz8-bit0 -- 255Smooth, no visible flicker
DC motor speed25000 Hz8-bit0 -- 255Above audible range (no whine)
Servo position50 Hz16-bit0 -- 65535Standard servo PWM frequency
Buzzer tone500 -- 5000 Hz8-bit0 -- 255Frequency determines pitch

The resolution determines how many discrete steps you get between fully off and fully on. With 8-bit resolution, you have $2^8 = 256$ steps (0 to 255). With 10-bit resolution, you have $2^{10} = 1024$ steps (0 to 1023). Higher resolution means finer control, but 8-bit is sufficient for most LED and motor applications.

🔗What You'll Need

ComponentQtyNotesBuy
ESP32 dev board1AliExpress | Amazon.de .co.uk .com
LED (red)1AliExpress | Amazon.de .co.uk .com
220 ohm resistor1For LEDAliExpress | Amazon.de .co.uk .com
Potentiometer (10k ohm)1For brightness control exampleAmazon.de .co.uk .com
Breadboard1AliExpress | Amazon.de .co.uk .com
Jumper wires~5Male-to-maleAliExpress | Amazon.de .co.uk .com

Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.

🔗Wiring

🔗LED circuit

ComponentConnects to
GPIO 13220 ohm resistor
Resistor other endLED anode (long leg)
LED cathode (short leg)GND

🔗Potentiometer (for the second example)

Potentiometer pinConnects to
Left pin3.3V
Middle pin (wiper)GPIO 34
Right pinGND

Tip: We use GPIO 34 for the potentiometer because it is on ADC1, which works even when WiFi is active. See the Digital vs Analog article for more on ADC pin selection.

🔗Code Example: LED Dimming

This sketch smoothly fades an LED up and down in a continuous loop using PWM.

View complete sketch
#define LED_PIN 13
#define PWM_FREQ 5000       // 5 kHz -- no visible flicker
#define PWM_RESOLUTION 8    // 8-bit: duty cycle 0-255

void setup() {
  ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION);
  Serial.begin(115200);
  delay(1000);
  Serial.println("PWM LED fade");
}

void loop() {
  // Fade in
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(LED_PIN, duty);
    delay(5);
  }

  // Fade out
  for (int duty = 255; duty >= 0; duty--) {
    ledcWrite(LED_PIN, duty);
    delay(5);
  }
}

🔗How It Works

CodePurpose
ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION)Configures GPIO 13 for PWM at 5 kHz with 8-bit resolution. The core automatically assigns a free LEDC channel
ledcWrite(LED_PIN, duty)Sets the duty cycle. With 8-bit resolution: 0 = fully off, 255 = fully on
delay(5)Pauses 5 ms between steps for a visible fade. 256 steps x 5 ms = ~1.3 seconds per fade direction

Upload this sketch and watch the LED smoothly breathe in and out. Each full cycle (fade up + fade down) takes about $256 \times 5 \, \text{ms} \times 2 \approx 2.6 \, \text{s}$.

Changing the resolution changes the number of steps available. If you switch to 10-bit resolution, the duty cycle range becomes 0 to 1023:

#define PWM_RESOLUTION 10   // 10-bit: duty cycle 0-1023

// In loop:
for (int duty = 0; duty <= 1023; duty++) {
  ledcWrite(LED_PIN, duty);
  delay(1);  // Shorter delay since there are more steps
}

More steps means smoother fading, but 8-bit (256 steps) is smooth enough for most visible LED applications.

🔗Code Example: Controlling Brightness with a Potentiometer

This sketch reads a potentiometer value and maps it to the LED brightness, giving you real-time analog control over PWM output.

View complete sketch
#define POT_PIN 34          // ADC1 pin for potentiometer
#define LED_PIN 13
#define PWM_FREQ 5000
#define PWM_RESOLUTION 8    // 0-255

void setup() {
  ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION);
  Serial.begin(115200);
  delay(1000);
  Serial.println("Potentiometer-controlled LED brightness");
}

void loop() {
  int potValue = analogRead(POT_PIN);          // 0-4095 (12-bit ADC)
  int brightness = map(potValue, 0, 4095, 0, 255);  // Scale to 0-255

  ledcWrite(LED_PIN, brightness);

  Serial.printf("Pot: %4d  Brightness: %3d\n", potValue, brightness);
  delay(50);
}

🔗How It Works

This sketch combines two concepts:

  1. Analog input -- analogRead(POT_PIN) reads the potentiometer position as a value from 0 to 4095 (the ESP32's 12-bit ADC range).
  2. PWM output -- ledcWrite(LED_PIN, brightness) sets the LED duty cycle.

The map() function scales the ADC range (0--4095) down to the PWM range (0--255). Turn the potentiometer and the LED brightness follows in real time.

Note: You could also scale the value with integer division: int brightness = potValue / 16; works the same as map(potValue, 0, 4095, 0, 255) for this case, since $4095 / 16 \approx 255$. The map() function is more readable and flexible when the ranges are not so neatly related.

🔗PWM for Motor Speed

The same PWM principle controls motor speed, but you cannot drive a motor directly from an ESP32 GPIO pin -- the pin can only source about $12 \, \text{mA}$, while even a small DC motor draws hundreds of milliamps.

You need a motor driver (like the L298N module) or a MOSFET between the ESP32 and the motor. The ESP32 sends a PWM signal to the driver, and the driver switches the motor's power supply at the same duty cycle.

One important difference for motors: use a higher PWM frequency ($20$ to $25 \, \text{kHz}$) to avoid audible whine. At lower frequencies like 1 kHz, you can hear the switching as an annoying buzzing sound. Frequencies above $20 \, \text{kHz}$ are ultrasonic and silent to human ears.

#define MOTOR_PIN 13
#define PWM_FREQ 25000      // 25 kHz -- above audible range
#define PWM_RESOLUTION 8

void setup() {
  ledcAttach(MOTOR_PIN, PWM_FREQ, PWM_RESOLUTION);
}

For a complete motor control guide with wiring and full code, see the DC Motor with L298N article.

🔗PWM for Servo Motors

Servo motors use PWM differently from LEDs and DC motors. Instead of duty cycle controlling speed or brightness, the pulse width determines the shaft angle:

Pulse widthServo angle
$1.0 \, \text{ms}$0 degrees
$1.5 \, \text{ms}$90 degrees (center)
$2.0 \, \text{ms}$180 degrees

Servos expect a PWM frequency of 50 Hz (a 20 ms period). The pulse width within each period sets the position. For example, at 50 Hz with 16-bit resolution:

  • $1.0 \, \text{ms}$ pulse = duty value of $\frac{1.0}{20.0} \times 65535 \approx 3277$
  • $1.5 \, \text{ms}$ pulse = duty value of $\frac{1.5}{20.0} \times 65535 \approx 4915$
  • $2.0 \, \text{ms}$ pulse = duty value of $\frac{2.0}{20.0} \times 65535 \approx 6553$

You can calculate these values manually, but the ESP32Servo library handles the math for you with a familiar servo.write(angle) interface. Install it from the Library Manager (search for "ESP32Servo").

Tip: Using the ESP32Servo library is recommended over raw ledcWrite() for servos. It abstracts the pulse timing and provides a clean API: servo.attach(pin) and servo.write(90). See the Servo Motor Guide for a complete walkthrough.

🔗Multiple PWM Outputs

Each GPIO pin can have its own PWM configuration. This is useful for driving an RGB LED, where you control three colors independently with three separate PWM channels:

#define RED_PIN   13
#define GREEN_PIN 12
#define BLUE_PIN  14
#define PWM_FREQ  5000
#define PWM_RES   8

void setup() {
  ledcAttach(RED_PIN, PWM_FREQ, PWM_RES);
  ledcAttach(GREEN_PIN, PWM_FREQ, PWM_RES);
  ledcAttach(BLUE_PIN, PWM_FREQ, PWM_RES);
}

void setColor(int red, int green, int blue) {
  ledcWrite(RED_PIN, red);      // 0-255
  ledcWrite(GREEN_PIN, green);
  ledcWrite(BLUE_PIN, blue);
}

void loop() {
  setColor(255, 0, 0);    // Red
  delay(1000);
  setColor(0, 255, 0);    // Green
  delay(1000);
  setColor(0, 0, 255);    // Blue
  delay(1000);
  setColor(255, 165, 0);  // Orange
  delay(1000);
  setColor(128, 0, 128);  // Purple
  delay(1000);
}

The ESP32 has 16 LEDC channels, so you can control up to 16 independent PWM outputs. The core assigns channels automatically when you call ledcAttach().

For a full RGB LED guide with wiring and color mixing, see the RGB LED article.

🔗Troubleshooting

ProblemLikely causeSolution
LED does not dim (only fully on or off)Wrong pin or PWM not configuredVerify ledcAttach() is called before ledcWrite(). Double-check the GPIO number
LED flickers visiblyPWM frequency too lowIncrease frequency to at least 5000 Hz. Below ~100 Hz, flicker is visible
Servo jitters or vibratesNoisy PWM signal or insufficient powerPower the servo from the 5V pin or external supply, not from a GPIO pin. Add a $100 \, \text{nF}$ capacitor across the servo power pins
Compilation error: ledcSetup not foundUsing old API with new Arduino core (v3.x)Replace ledcSetup() + ledcAttachPin() with ledcAttach(). See the API section above
Compilation error: ledcAttach not foundUsing new API with old Arduino core (v2.x)Either update to core v3.x, or use the old API: ledcSetup(channel, freq, resolution) then ledcAttachPin(pin, channel)
Motor makes audible whinePWM frequency in audible rangeUse $20{,}000$ to $25{,}000 \, \text{Hz}$ for motors to eliminate audible noise
PWM works on one pin but not anotherGPIO is input-onlyGPIOs 34, 35, 36, 39 are input-only and cannot output PWM. Use a different pin
Brightness not linear (dim at low values, jumps to bright)Human eye perceives brightness logarithmicallyApply gamma correction: duty = pow(input / 255.0, 2.2) * 255 for perceptually linear dimming

🔗What's Next?

PWM is one of the most versatile tools in your ESP32 toolkit. Here are some natural next steps:

  • RGB LED -- Mix colors with three PWM channels to create any color you want.
  • Servo Motor -- Control servo position with PWM using the ESP32Servo library.
  • DC Motor with L298N -- Drive motors at variable speeds with PWM through a motor driver.