Controlling LED Brightness with PWM
Draft
This chapter is not completed yet.
So far, we have only turned our LED fully ON (HIGH voltage) or fully OFF (LOW voltage). But what if you want to set the LED to half brightness, or make it smoothly pulse like a "breathing" light?
Digital pins on the ESP32 can only output either 3.3 V or 0 V. They cannot natively output an intermediate voltage like 1.65 V. To solve this, we use a technique called PWM.
Key concepts
What is PWM?
PWM stands for Pulse Width Modulation. It works by switching the GPIO pin between HIGH and LOW thousands of times per second, far faster than human eyes can notice.
Because the switching happens so quickly, your eyes don't see flickering. Instead, they perceive an average brightness depending on how long the pin remains HIGH during each cycle.
Key terminologies
- Duty cycle: The percentage of time the signal stays HIGH during one cycle. For example:
- 0% duty cycle: The pin is always LOW (LED is OFF).
- 50% duty cycle: The pin is HIGH for half the time and LOW for half the time (LED appears at half brightness).
- 100% duty cycle: The pin is always HIGH (LED is at full brightness).
- Frequency: How many duty cycles occur per second (measured in Hertz, Hz). For LEDs, a frequency around 5000 Hz (5 kHz) ensures smooth lighting with zero visible flicker.
Other applications of PWM
PWM is not just for dimming LEDs. It is widely used in embedded systems for:
- Motor speed control: Adjusting the speed of DC motors in fans or electric vehicles.
- Servo motors: Controlling the precise angle of robotic arms.
- Buzzer audio: Generating different sound frequencies and tones.
The ESP32 LEDC peripheral
The ESP32 includes a dedicated hardware module called LEDC (LED Control). Once you configure it, the LEDC automatically generates the PWM signal in hardware, requiring 0% CPU usage to keep running.
The LEDC peripheral uses two main building blocks:
- Timer: Defines the timing properties:
- Frequency: How many times per second the PWM signal repeats.
- Bit resolution or duty resolution: How fine your brightness control is. For example, a 10-bit resolution gives $2^{10} = 1024$ discrete steps (
0to1023). - Channel: Defines the output state:
- Sets the duty cycle (brightness level).
- Maps the timer signal to a specific physical GPIO pin.
Hardware required
Everything required from Blinking an LED:
- LED
- Jumper wires
- A 220-330 Ω resistor
- Breadboard
The code
Let's write a simple program to configure the LEDC peripheral and set our LED to roughly 50% brightness:
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/ledc.h"
#include "esp_log.h"
void app_main(void)
{
// 1. Configure the LEDC Timer
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.timer_num = LEDC_TIMER_0,
.duty_resolution = LEDC_TIMER_10_BIT,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&ledc_timer);
// 2. Configure the LEDC Channel
ledc_channel_config_t ledc_channel = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_TIMER_0,
.intr_type = LEDC_INTR_DISABLE,
.gpio_num = GPIO_NUM_22,
.duty = 0, // Start with LED turned OFF
.hpoint = 0
};
ledc_channel_config(&ledc_channel);
ESP_LOGI("MAIN", "Setting LED to 50%% brightness...");
// Set duty cycle to ~50% (512 out of 1023)
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 512);
// Update duty cycle to apply changes in hardware
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
}
How it works
- Timer setup: We configure
LEDC_TIMER_0with a 10-bit resolution (0to1023) running at 5000 Hz. - Channel setup: We attach
LEDC_CHANNEL_0toGPIO_NUM_22and tell it to useLEDC_TIMER_0. ledc_set_duty(): Prepares the new brightness value in memory (512is half of1023).ledc_update_duty(): Latches the prepared value into hardware logic so the physical pin starts outputting the new PWM signal.
Improving the code
The standard practice is to define macros for storing configurations like this on top of the main file. By doing this, you will have all configurations in one place:
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/ledc.h"
#include "esp_log.h"
#define LEDC_GPIO GPIO_NUM_22
#define LEDC_MODE LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_DUTY_RES LEDC_TIMER_10_BIT // Resolution: 0 to 1023
#define LEDC_FREQUENCY 5000 // Frequency in Hz (5 kHz)
static const char *TAG = "MAIN";
void app_main(void)
{
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_DUTY_RES,
.freq_hz = LEDC_FREQUENCY,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&ledc_timer);
ledc_channel_config_t ledc_channel = {
.speed_mode = LEDC_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.intr_type = LEDC_INTR_DISABLE,
.gpio_num = LEDC_GPIO,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&ledc_channel);
ESP_LOGI(TAG, "Setting LED to 50%% brightness...");
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, 512);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}
Improving the code further (Breathing light effect)
Setting a static brightness is a good start, but we can continuously adjust the duty cycle in a loop to create a smooth "breathing" light effect:
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/ledc.h"
#define LEDC_GPIO GPIO_NUM_22
#define LEDC_MODE LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_DUTY_RES LEDC_TIMER_10_BIT // Duty values range from 0 to 1023
#define LEDC_FREQUENCY 5000 // 5 kHz
void app_main(void)
{
// Configure Timer
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_DUTY_RES,
.freq_hz = LEDC_FREQUENCY,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&ledc_timer);
// Configure Channel
ledc_channel_config_t ledc_channel = {
.speed_mode = LEDC_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.intr_type = LEDC_INTR_DISABLE,
.gpio_num = LEDC_GPIO,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&ledc_channel);
while (1) {
// Fade in: gradually increase duty cycle from 0 to 1023
for (int duty = 0; duty <= 1023; duty += 15) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(15));
}
// Fade out: gradually decrease duty cycle from 1023 down to 0
for (int duty = 1023; duty >= 0; duty -= 15) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(15));
}
}
}
Build and flash this code to see your LED smoothly pulsing!
Test your knowledge
- What is a duty cycle, and how does changing it alter the perceived brightness of an LED?
- What are the roles of the Timer and Channel inside the ESP32's LEDC peripheral?
- If the timer resolution is configured to 8-bit (
LEDC_TIMER_8_BIT), what duty cycle value corresponds to 100% brightness?