ESP32 Hardware PWM Component for Sming
A comprehensive C++ wrapper for the ESP32 LEDC PWM functionality, designed for the Sming framework.
Features
Multiple PWM Instances: Create multiple independent PWM instances with different configurations
Flexible Configuration: Support for different frequencies, duty resolutions (1-20 bits), and speed modes
Phase Shifting: Built-in support for phase shifting to reduce EMI
Spread Spectrum: Optional spread-spectrum modulation of the base frequency to further reduce EMI
Hardware Fade: Hardware-accelerated linear fade transitions via the ESP32 LEDC fade engine
Fade Queue: Per-channel FIFO and CYCLIC fade queues with completion callbacks
ESP32 LEDC Hardware Overview
* the ESP32 PWM Hardware is much more powerful than the ESP8266, allowing wider PWM timers (up to 20 bit)
* as well as much higher PWM frequencies (up to 40MHz for a 1 Bit wide PWM)
*
* Overview:
* +------------------------------------------------------------------------------------------------+
* | LED_PWM |
* | +-------------------------------------------+ +-------------------------------------------+ |
* | | High_Speed_Channels¹ | | Low_Speed_Channels | |
* | | +-----+ +--------+ | | +-----+ +--------+ | |
* | | | | --> | h_ch 0 | | | | | --> | l_ch 0 | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | h_timer 0 | --> | | | | | l_timer 0 | --> | | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | | --> | h_ch 1 | | | | | --> | l_ch 1 | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | | | | | | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | | --> | h_ch 2 | | | | | --> | l_ch 2 | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | h_timer 1 | --> | | | | | l_timer 1 | --> | | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | | --> | h_ch 3 | | | | | --> | l_ch 3 | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | MUX | | | | MUX | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | | --> | h_ch 4 | | | | | --> | l_ch 4 | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | h_timer 2 | --> | | | | | l_timer 2 | --> | | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | | --> | h_ch 5 | | | | | --> | l_ch 5 | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | | | | | | | |
* | | | | +--------+ | | | | +--------+ | |
* | | | | --> | h_ch 6 | | | | | --> | l_ch 6²| | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | h_timer 3 | --> | | | | | l_timer 3 | --> | | | |
* | | +-----------+ | | +--------+ | | +-----------+ | | +--------+ | |
* | | | | --> | h_ch 7 | | | | | --> | l_ch 7²| | |
* | | | | +--------+ | | | | +--------+ | |
* | | +-----+ | | +-----+ | |
* | +-------------------------------------------+ +-------------------------------------------+ |
* +------------------------------------------------------------------------------------------------+
* ¹ High speed channels are only available when SOC_LEDC_SUPPORT_HS_MODE is defined as 1
* ² The ESP32C3 does only support six channels, so 6 and 7 are not available on that SoC
*
* The nomenclature of timers in the high speed / low speed blocks is a bit misleading as the idf api
* speaks of "speed mode", which, to me, implies that this would be a mode configurable in a specific timer
* while in reality, it does select a block of timers.
*
* Maximum Timer width for PWM:
* ============================
* esp32 SOC_LEDC_TIMER_BIT_WIDE_NUM (20)
* esp32c3 SOC_LEDC_TIMER_BIT_WIDE_NUM (14)
* esp32s2 SOC_LEDC_TIMER_BIT_WIDE_NUM (14)
* esp32s3 SOC_LEDC_TIMER_BIT_WIDE_NUM (14)
*
* Number of Channels:
* ===================
* esp32 SOC_LEDC_CHANNEL_NUM (8)
* esp32c3 SOC_LEDC_CHANNEL_NUM (6)
* esp32s2 SOC_LEDC_CHANNEL_NUM (8)
* esp32s3 SOC_LEDC_CHANNEL_NUM (8)
*
* Some SoSs support a mode called HIGHSPEED_MODE which is essentially another full block of PWM hardware
* that adds SOC_LEDC_CHANNEL_NUM channels.
* Those Architectures have SOC_LEDC_SUPPORT_HS_MODE defined as 1.
* In esp-idf-4.3 that's currently only the esp32 SOC
*
* Supports highspeed mode:
* ========================
* esp32 SOC_LEDC_SUPPORT_HS_MODE (1)
*
* hardware technical reference:
* =============================
* https://www.espressif.com/sites/default/files/documentation/esp32_technical_reference_manual_en.pdf#ledpwm
*
* Overview of the whole ledc-system here:
* https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/ledc.html
*
Quick Start
Basic Usage
#include <SmingCore.h>
#include <Esp32HardwarePwm.h>
// Define pins for PWM output
std::vector<uint8_t> pwm_pins = {2, 4, 5, 18};
void init() {
Serial.begin(SERIAL_BAUD_RATE);
// Create PWM instance with default settings (10-bit, 1 kHz)
Esp32HardwarePwm pwm(pwm_pins);
if (pwm.isInitialized()) {
pwm.setDutyChan(0, 256); // raw duty on channel 0 (25 % of 1023)
pwm.setDutyChan(1, 512); // 50 % on channel 1
pwm.analogWrite(pwm_pins[2], 768); // legacy pin-indexed write
Serial.println("PWM initialized successfully");
} else {
Serial.println("PWM initialization failed");
}
}
Advanced Configuration
#include <SmingCore.h>
#include <Esp32HardwarePwm.h>
std::vector<uint8_t> led_pins = {12, 13, 14};
Esp32HardwarePwm pwm(led_pins, Esp32HardwarePwm::Config{
.timer = {
.resolution = LEDC_TIMER_12_BIT, // 12-bit (0-4095)
.frequency = 20000, // 20 kHz
},
.phaseShift = {
.mode = Esp32HardwarePwm::PhaseShiftMode::AUTO,
},
});
void init() {
if (pwm.isInitialized()) {
pwm.fadeChan(0, pwm.getMaxDuty(), 2000); // fade to 100% over 2 s
pwm.fadePercentChan(1, 50.0f, 1500); // fade to 50% over 1.5 s
}
}
API Reference
Configuration
All configuration is passed through the nested Esp32HardwarePwm::Config aggregate:
struct Esp32HardwarePwm::Config {
ledc_channel_t channelStart = LEDC_CHANNEL_0; // first LEDC channel to allocate
TimerConfig timer = {}; // frequency, resolution, timer index
PhaseShiftConfig phaseShift = {}; // OFF / AUTO / MANUAL
SpreadSpectrumConfig spreadSpectrum = {}; // OFF / ON
};
struct TimerConfig {
ledc_mode_t speed_mode = LEDC_LOW_SPEED_MODE;
ledc_timer_bit_t resolution = LEDC_TIMER_10_BIT; // 1-20 bits
ledc_timer_t timer_num = LEDC_TIMER_0;
uint32_t frequency = 1000; // Hz
ledc_clk_cfg_t clk_cfg = LEDC_AUTO_CLK;
};
struct PhaseShiftConfig {
PhaseShiftMode mode = PhaseShiftMode::OFF;
std::vector<int> manual_hpoints = {}; // one per pin, MANUAL mode only
};
struct SpreadSpectrumConfig {
SpreadSpectrumMode mode = SpreadSpectrumMode::OFF;
uint8_t WidthPercent = 0; // deviation as % of base frequency
uint16_t Subsampling = 0; // PWM cycles between updates
};
Main Class: Esp32HardwarePwm
Constructors
At its most basic the constructor only needs a pin list; all other settings default to 10-bit / 1 kHz / LEDC_TIMER_0 / low-speed mode.
Esp32HardwarePwm(std::vector<uint8_t>& pins);
Esp32HardwarePwm(std::vector<uint8_t>& pins, const Config& config);
Multiple instances: each instance must use a distinct set of LEDC channels. Set
config.channelStarton the second instance to the first free channel (e.g.LEDC_CHANNEL_3if the first instance uses three channels). Instances that share the sametimer_numalso share the same frequency and resolution — changing one affects the other.
Duty Cycle Control
Channels are zero-indexed in the order the pins were supplied to the constructor.
Channel interface (preferred)
bool setDutyChan(uint8_t channel, uint32_t duty, bool update_immediately = true);
uint32_t getDutyChan(uint8_t channel);
bool setDutyChanPercent(uint8_t channel, float pct, bool update_immediately = true, bool cie = false);
float getDutyChanPercent(uint8_t channel, bool cie = false); // cie=true → inverse CIE 1931
bool setPhaseShiftChan(uint8_t channel, uint32_t phase_shift, bool update_immediately = true);
Pin interface (legacy)
bool setDutyPin(uint8_t pin, uint32_t duty, bool update_immediately = true);
uint32_t getDutyPin(uint8_t pin);
bool analogWrite(uint8_t pin, uint32_t duty);
Frequency and Period Control
These calls set the pwm frequency. Be aware that, if spread spectrum is enabled, this is the center frequency that the spectrum is spread around
bool setFrequency(uint32_t frequency);
uint32_t getFrequency() const;
bool setPeriod(uint32_t period_us);
uint32_t getPeriod() const;
Information
This interface provides information about the current Esp32HardwarePwm instance
uint32_t getMaxDuty() const;
uint8_t getResolution() const;
uint8_t getPinCount() const;
bool isInitialized() const;
Control Functions
void update();
bool start(uint8_t pin);
bool stop(uint8_t pin, uint8_t idle_level = 0);
void startAll();
void stopAll(uint8_t idle_level = 0);
Hardware Fade
Fade support is installed automatically on first use.
bool enableFade(); // install LEDC fade ISR (called automatically by fade methods)
void disableFade();
// Immediate fade (resets queue, starts now); queue=true enqueues instead
bool fadeChan(uint8_t channel, uint32_t target_duty, uint32_t fade_time_ms, bool queue = false);
// Same but target is a percentage (0.0 – 100.0); cie=true applies CIE 1931 correction
bool fadePercentChan(uint8_t channel, float target_pct, uint32_t fade_time_ms, bool cie = false, bool queue = false);
// Returns true while a hardware fade is in progress
bool isFadingChan(uint8_t channel) const;
Fade Queue
Each channel has an independent fade queue that can chain multiple fades without
application polling. The queue depth defaults to FADE_QUEUE_DEPTH (10) and can
be changed per-channel at runtime before the queue is filled.
Modes
Mode |
Behaviour |
Auto-start |
|---|---|---|
|
Entries play once in order; |
Yes — playback starts on first |
|
Entries loop endlessly back to entry 0; |
No — call |
Queue management
void setQueueMode(uint8_t channel, QueueMode mode); // FIFO or CYCLIC
QueueMode getQueueMode(uint8_t channel) const;
// Override the auto-start default set by setQueueMode()
void setQueueAutoStart(uint8_t channel, bool autoStart);
bool getQueueAutoStart(uint8_t channel) const;
// Change queue depth (only while queue is empty; default FADE_QUEUE_DEPTH = 10)
bool setQueueCapacity(uint8_t channel, uint16_t depth);
uint16_t getQueueCapacity(uint8_t channel) const;
uint16_t getQueueEntries(uint8_t channel) const; // entries currently queued
void resetQueue(uint8_t channel); // clear queue, preserve capacity
Enqueueing and starting
// Enqueue a fade: fadeChan/fadePercentChan with queue=true
bool fadeChan(uint8_t channel, uint32_t targetDuty, uint32_t fadeTimeMs, bool queue = false);
bool fadePercentChan(uint8_t channel, float targetPct, uint32_t fadeTimeMs, bool cie = false, bool queue = false);
// Explicitly start a CYCLIC queue (or restart an idle FIFO queue)
bool startQueue(uint8_t channel);
Callbacks
// Fires after every individual fade completes (any mode)
pwm.setOnFadeDoneCallback([](uint8_t ch) { ... });
// Fires when a FIFO queue drains to empty
pwm.setOnQueueEmptyCallback([](uint8_t ch) { ... });
// Fires each time a CYCLIC queue wraps back to entry 0
pwm.setOnCyclicWrapCallback([](uint8_t ch) { ... });
Example — CYCLIC queue
pwm.setQueueMode(1, Esp32HardwarePwm::QueueMode::CYCLIC);
pwm.fadePercentChan(1, 100.0f, 1000, false, true);
pwm.fadePercentChan(1, 0.0f, 1000, false, true);
pwm.fadePercentChan(1, 50.0f, 1000, false, true);
pwm.startQueue(1); // must be called after seeding; CYCLIC does not auto-start
See samples/FadeQueue_HwPWM for a complete demonstration of both modes.
Timing Accuracy
Three independent sources of error affect fade timing. Understanding them helps choose the right timer configuration and set realistic expectations.
1. Duty-step quantisation (applies to every fade call)
ledc_set_fade_time_and_start translates a requested fade duration into integer
hardware parameters:
cycles_per_step = floor(fade_ms × timer_freq_Hz / duty_levels)
actual_ms = duty_levels × cycles_per_step × (1 / timer_freq_Hz) × 1000
Because of the floor, the actual duration is always shorter than requested.
The total shortfall is determined by the remainder of the division:
error_ms = (fade_ms × timer_freq_Hz mod duty_levels) / timer_freq_Hz
The error depends on how evenly the fade duration divides into duty steps — it
is not a simple function of bit depth or frequency alone. Higher frequency
generally reduces the error (more cycles to distribute), but the specific
fade duration matters. The maximum possible shortfall is one timer period
(when the remainder equals duty_levels − 1).
1a. Low cycle_num regime — large timing error and diagnostic warning
The hardware parameter cycle_num is the number of full PWM timer cycles the
LEDC peripheral spends at each duty step:
cycle_num = floor(freq_Hz × fade_ms / (1000 × duty_range))
where duty_range is the absolute difference between start and target duty
values. When cycle_num is small the integer truncation from floor is a
large fraction of the true value, so the actual fade duration is much shorter
than requested. For example:
cycle_num |
Typical timing error |
|---|---|
1 |
up to −50 % |
3 |
up to −25 % |
10 |
up to −9 % |
≥ 20 |
< 5 % (library threshold) |
The library warns once per channel whenever 1 ≤ cycle_num < 20:
fadeChan: ch0 cycle_num=3 (< 20) — fade may be 929 ms short (hw limit).
Fix: increase fadeTimeMs, lower resolution, or raise frequency to >= 20475 Hz
The warning includes the minimum frequency needed to reach cycle_num == 20 for
that specific (fade_ms, duty_range) combination — raising the frequency is the
lowest-impact fix when the PWM period is not otherwise constrained.
What causes this? cycle_num decreases when:
The fade duration is short relative to the duty range (fast fades over a wide range).
The PWM frequency is low (long timer periods give fewer cycles per millisecond).
The duty resolution is high (more steps means more time needed per step).
How to fix it — choose any combination that brings cycle_num ≥ 20:
**Increase
fadeTimeMs** — gives LEDC more total timer cycles to distribute.Lower the duty resolution — fewer steps means more cycles per step.
Raise the PWM frequency to at least the value printed in the warning.
cycle_num == 0 is a separate case where the requested duration is shorter than
one full timer cycle. LEDC clamps to 1 cycle, so the fade runs longer than
requested. No warning is issued for this case because the error direction is
reversed and the magnitude is bounded by one timer period.
2. Queue-reload latency (applies to every step when chaining short fades)
When the LEDC fade-done ISR fires, the library posts a FreeRTOS message to the
Sming main-task queue. The main task dequeues the entry and calls
ledc_set_fade_time_and_start for the next step. Three sub-sources contribute:
Sub-source |
Typical cost |
|---|---|
ISR → Sming task-queue post |
< 10 µs |
FreeRTOS task wake-up + context switch |
~10–50 µs |
LEDC timer-cycle sync (next fade starts on next timer edge) |
2–3 timer periods |
The dominant term is the timer-cycle sync. LEDC cannot start a new fade
mid-cycle; the ledc_set_fade_time_and_start call itself takes some time, and
by the time the hardware receives the start command the current timer cycle may
already be more than one period ahead. In practice, 2–3 timer periods elapse
between the ISR firing and the next fade actually beginning.
3. Code-execution overhead
The path through the ISR, the FreeRTOS message, and ledc_set_fade_time_and_start
itself takes a few tens of microseconds. This is negligible compared to the
timer-sync penalty for all practical frequencies (≤ 40 kHz) and cannot be
eliminated without a custom IDF patch that chains fades from within the ISR.
Measured results
The following table was produced by samples/TimingTest_HwPWM running a 60 s
fade on an ESP32 (3000 × 20 ms micro-fades for the reload overhead measurement):
Config |
Quant. error (single 60 s fade) |
Reload overhead (3000 × 20 ms steps) |
||
|---|---|---|---|---|
deviation |
% of 60 s |
total excess |
per step |
|
1 kHz/10-bit |
−665 ms |
−1.11 % |
+8988 ms |
2996 µs |
4 kHz/10-bit |
−154 ms |
−0.26 % |
+5989 ms |
1996 µs |
4 kHz/13-bit |
−613 ms |
−1.02 % |
+2241 ms |
747 µs |
8 kHz/13-bit |
−519 ms |
−0.87 % |
+1214 ms |
404 µs |
Key observations:
Quantisation error is fade-duration-dependent. The 4 kHz/10-bit config has the smallest absolute error for a 60 s fade, while 4 kHz/13-bit is worse than 4 kHz/10-bit — this is a coincidence of how 60 000 ms × 4000 Hz divides into 1023 vs 8191 duty steps. For a different fade duration the ranking can change.
Reload overhead scales with timer period, but also with duty resolution. At the same 4 kHz frequency, 13-bit resolution (8191 steps) gives ~750 µs/step while 10-bit (1023 steps) gives ~2000 µs/step. With more duty steps LEDC spends fewer timer cycles per step, so the synchronisation wait is a smaller fraction of the step duration. The combined effect means higher frequency and higher bit depth both reduce per-step latency.
Higher frequency always reduces per-step latency, regardless of bit depth.
Choosing timer parameters
Goal |
Recommendation |
|---|---|
Minimal per-step latency for chained micro-fades |
Maximise frequency (8 kHz / 13-bit: ~400 µs/step) |
Minimal quantisation error for a specific fade duration |
Run |
Balance for RGBWW lighting (steps ≥ 20 ms) |
4 kHz / 13-bit: ~750 µs/step, <1.1 % length error |
See samples/TimingTest_HwPWM for a hardware benchmark that measures both
effects across multiple configurations in a single run.
License
This library is provided under the LGPL v2.1 or higher license as part of the Sming Framework Project.
References
References
Used by
Basic Hardware PWM Sample
Basic Hardware PWM Sample
FadeQueue_HwPWM Sample
FadeQueue_HwPWM Sample
References Sample
References Sample
References Sample
SoC support
esp32
esp32c2
esp32c3
esp32s2
esp32s3