Skip to content
2 changes: 1 addition & 1 deletion esp-hal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ For help getting started with this HAL, please refer to [The Rust on ESP Book] a
| IOMUX | ⚒️ | ⚒️ | ⚒️ | | ⚒️ | ⚒️ | ⚒️ | ⚒️ |
| Camera interface | ❌ | | | | | | ❌ | ⚒️ |
| RGB display | ⚒️ | | | | | | ❌ | ⚒️ |
| LEDC | ⚒️ | ⚒️ | ⚒️ | | ⚒️ | ⚒️ | ⚒️ | ⚒️ |
| LEDC | ⚒️ | ⚒️ | ⚒️ | ⚒️ | ⚒️ | ⚒️ | ⚒️ | ⚒️ |
| MCPWM | ⚒️ | | | | ⚒️ | ⚒️ | | ⚒️ |
| PARL_IO | | | | | ⚒️ | ⚒️ | | |
| PCNT | ⚒️ | | | ⚒️ | ⚒️ | ⚒️ | ⚒️ | ⚒️ |
Expand Down
44 changes: 32 additions & 12 deletions esp-hal/src/ledc/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
use super::timer::{TimerIFace, TimerSpeed};
use crate::{
gpio::{
DriveMode,
OutputConfig,
OutputSignal,
DriveMode, OutputConfig, OutputSignal,
interconnect::{self, PeripheralOutput},
},
pac::ledc::RegisterBlock,
Expand Down Expand Up @@ -65,10 +63,10 @@ pub enum Number {
Channel4 = 4,
/// Channel 5
Channel5 = 5,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
/// Channel 6
Channel6 = 6,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
/// Channel 7
Channel7 = 7,
}
Expand Down Expand Up @@ -362,7 +360,7 @@ impl<S: crate::ledc::timer::TimerSpeed> Channel<'_, S> {
}

// this is needed to make low duty-resolutions / high frequencies work
#[cfg(any(esp32h2, esp32c6))]
#[cfg(any(esp32c6, esp32h2))]
self.ledc
.ch_gamma_wr_addr(self.number as usize)
.write(|w| unsafe { w.bits(0) });
Expand Down Expand Up @@ -412,7 +410,14 @@ impl<S: crate::ledc::timer::TimerSpeed> Channel<'_, S> {
}
});
}
#[cfg(not(any(esp32, esp32c6, esp32h2)))]
#[cfg(esp32c5)]
fn start_duty_without_fading(&self) {
self.ledc
.ch(self.number as usize)
.conf1()
.write(|w| w.duty_start().set_bit());
}
#[cfg(not(any(esp32, esp32c5, esp32c6, esp32h2)))]
fn start_duty_without_fading(&self) {
self.ledc.ch(self.number as usize).conf1().write(|w| {
w.duty_start().set_bit();
Expand Down Expand Up @@ -499,7 +504,22 @@ impl<S: crate::ledc::timer::TimerSpeed> Channel<'_, S> {
.write(|w| unsafe { w.ch_gamma_entry_num().bits(0x1) });
}

#[cfg(not(any(esp32, esp32c6, esp32h2)))]
#[cfg(esp32c5)]
fn start_duty_fade_inner(
&self,
duty_inc: bool,
duty_steps: u16,
cycles_per_step: u16,
duty_per_cycle: u16,
) {
let _ = (duty_inc, duty_steps, cycles_per_step, duty_per_cycle);
self.ledc
.ch(self.number as usize)
.conf1()
.write(|w| w.duty_start().set_bit());

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The esp32c5 start_duty_fade_inner implementation ignores all fade parameters and only sets duty_start. As a result, start_duty_fade() can return Ok(()) but not perform a fade (and is_duty_fade_running() may not reflect reality). Either implement the actual fade configuration for ESP32-C5 or make start_duty_fade return an error/unsupported on esp32c5.

Suggested change
let _ = (duty_inc, duty_steps, cycles_per_step, duty_per_cycle);
self.ledc
.ch(self.number as usize)
.conf1()
.write(|w| w.duty_start().set_bit());
let cnum = self.number as usize;
self.ledc
.ch(cnum)
.conf1()
.write(|w| w.duty_start().set_bit());
self.ledc.ch_gamma_wr(cnum).write(|w| unsafe {
w.ch_gamma_duty_inc()
.variant(duty_inc)
.ch_gamma_duty_num() // count of incs before stopping
.bits(duty_steps)
.ch_gamma_duty_cycle() // overflows between incs
.bits(cycles_per_step)
.ch_gamma_scale()
.bits(duty_per_cycle)
});
self.ledc
.ch_gamma_wr_addr(cnum)
.write(|w| unsafe { w.ch_gamma_wr_addr().bits(0) });
self.ledc
.ch_gamma_conf(cnum)
.write(|w| unsafe { w.ch_gamma_entry_num().bits(0x1) });

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented for ESP32-C5 in the latest commit (846d86e5).

What changed:

  • Added real fade parameter programming for C5 using the documented gamma RAM window (LEDC + 0x400, 16 ranges/channel), since the current C5 PAC does not expose ch_gamma_wr* registers.
  • Set up fade-complete signaling on C5 (int_clr + int_ena) and made is_duty_fade_running() read int_st for C5.
  • Fixed the C5 init path by clearing PCR.LEDC_PD_CTRL.LEDC_MEM_FORCE_PD in Ledc::new(); this bit resets to force power-down and otherwise gamma RAM writes do not take effect.
  • Added a C5 HIL test (duty_fade_changes_output_and_completes) that verifies fade starts, completes, and actually changes waveform duty.

Verification run on hardware (esp32c5): 4/4 tests passing, repeated multiple times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: I opened esp-rs/esp-pacs#397 to expose C5 gamma RAM in PAC as a typed LEDC_GAMMA_RAM peripheral (0x6000_7400, 96 entries = 16 ranges x 6 channels).

Small wording correction to my previous note: on C5 the missing piece is gamma RAM range register exposure (not ch_gamma_wr*, which is the C6/H2 register model).

}

#[cfg(not(any(esp32, esp32c5, esp32c6, esp32h2)))]
fn start_duty_fade_inner(
&self,
duty_inc: bool,
Expand Down Expand Up @@ -583,9 +603,9 @@ where
Number::Channel3 => OutputSignal::LEDC_LS_SIG3,
Number::Channel4 => OutputSignal::LEDC_LS_SIG4,
Number::Channel5 => OutputSignal::LEDC_LS_SIG5,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
Number::Channel6 => OutputSignal::LEDC_LS_SIG6,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
Number::Channel7 => OutputSignal::LEDC_LS_SIG7,
}
};
Expand All @@ -597,9 +617,9 @@ where
Number::Channel3 => OutputSignal::LEDC_LS_SIG3,
Number::Channel4 => OutputSignal::LEDC_LS_SIG4,
Number::Channel5 => OutputSignal::LEDC_LS_SIG5,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
Number::Channel6 => OutputSignal::LEDC_LS_SIG6,
#[cfg(not(any(esp32c2, esp32c3, esp32c6, esp32h2)))]
#[cfg(not(any(esp32c2, esp32c3, esp32c5, esp32c6, esp32h2)))]
Number::Channel7 => OutputSignal::LEDC_LS_SIG7,
};

Expand Down
21 changes: 18 additions & 3 deletions esp-hal/src/ledc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,33 @@ impl<'d> Ledc<'d> {
#[cfg(not(esp32))]
/// Set global slow clock source
pub fn set_global_slow_clock(&mut self, clock_source: LSGlobalClkSource) {
#[cfg(any(esp32c6, esp32h2))]
#[cfg(soc_has_clock_node_ledc_sclk)]
let ledc_sclk = match clock_source {
LSGlobalClkSource::APBClk => crate::soc::clocks::LedcSclkConfig::PllF80m,
};

#[cfg(soc_has_clock_node_ledc_sclk)]
crate::soc::clocks::ClockTree::with(|clocks| {
crate::soc::clocks::configure_ledc_sclk(clocks, ledc_sclk);
});

#[cfg(any(esp32c5, esp32c6, esp32h2))]
let pcr = unsafe { &*crate::peripherals::PCR::ptr() };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I have to ask if this is AI generated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah (Codex 5.3 to be exact) - is it strictly forbidden here? Upon some reflection - I should've marked it somewhere.

I needed RMT and LEDC with C5 in my project - once I got it working and tested it locally I thought I could contribute here.

If I'm not being usefull here (or even mallicious) just let me know :)

@bugadani bugadani Feb 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well we ask people to disclose the use of AI, but my main observation is that this is outdated code (use PCR::regs(), and generally not up to the latest standards we have (which is gating it with metadata-generated symbols, like soc_has_pcr in this case).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - I’ll review the current project conventions


#[cfg(any(esp32c6, esp32h2))]
#[cfg(any(esp32c5, esp32c6, esp32h2))]
pcr.ledc_sclk_conf().write(|w| w.ledc_sclk_en().set_bit());

match clock_source {
LSGlobalClkSource::APBClk => {
#[cfg(not(any(esp32c6, esp32h2)))]
#[cfg(not(any(esp32c5, esp32c6, esp32h2)))]
self.ledc
.conf()
.write(|w| unsafe { w.apb_clk_sel().bits(1) });
#[cfg(esp32c5)]
pcr.ledc_sclk_conf().modify(|_, w| unsafe {
w.ledc_sclk_sel().bits(2);
w.ledc_sclk_en().set_bit()
});
#[cfg(esp32c6)]
pcr.ledc_sclk_conf()
.write(|w| unsafe { w.ledc_sclk_sel().bits(1) });

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On esp32c6 this ledc_sclk_conf().write(|w| w.ledc_sclk_sel().bits(1)) call clears ledc_sclk_en (because write starts from the reset value). Since ledc_sclk_en was set just above, this effectively disables LEDC_SCLK again. Use modify here (or set both ledc_sclk_en and ledc_sclk_sel in the same write) so the enable bit stays asserted.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 50b9fe5e: on esp32c6/esp32h2 we now use modify and set ledc_sclk_sel while keeping ledc_sclk_en asserted, so the clock gate is not accidentally cleared by a later write.

Expand Down
24 changes: 20 additions & 4 deletions esp-hal/src/ledc/timer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
//! duty cycles and frequencies, making it ideal for Pulse-Width Modulation
//! (PWM) applications and LED lighting control.
//!
//! LEDC uses APB as clock source.
//! LEDC timer source clock is chip-specific.

#[cfg(esp32)]
use super::HighSpeed;
use super::{LowSpeed, Speed};
use crate::{clock::Clocks, pac, time::Rate};
use crate::clock::Clocks;
#[cfg(soc_has_clock_node_ledc_sclk)]
use crate::soc::clocks::ClockTree;
use crate::{pac, time::Rate};

const LEDC_TIMER_DIV_NUM_MAX: u64 = 0x3FFFF;

Expand Down Expand Up @@ -315,8 +318,21 @@ impl TimerHW<LowSpeed> for Timer<'_, LowSpeed> {
fn freq_hw(&self) -> Option<Rate> {
self.clock_source.map(|source| match source {
LSClockSource::APBClk => {
let clocks = Clocks::get();
clocks.apb_clock
#[cfg(soc_has_clock_node_ledc_sclk)]
{
ClockTree::with(|clocks| {
if crate::soc::clocks::ledc_sclk_config(clocks).is_some() {
Rate::from_hz(crate::soc::clocks::ledc_sclk_frequency(clocks))
} else {
Clocks::get().apb_clock
}
})
}
#[cfg(not(soc_has_clock_node_ledc_sclk))]
{
let clocks = Clocks::get();
clocks.apb_clock
}
}
})
}
Expand Down
27 changes: 26 additions & 1 deletion esp-hal/src/soc/esp32c5/clocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ define_clock_tree_types!();
pub enum CpuClock {
/// 80 MHz CPU clock
#[default]
_80MHz = 80,
_80MHz = 80,

/// 160 MHz CPU clock
_160MHz = 160,
Expand All @@ -48,6 +48,7 @@ impl CpuClock {
cpu_clk: Some(CpuClkConfig(1)),
ahb_clk: Some(AhbClkConfig(3)), // 40MHz - cannot exceed XTAL_CLK
apb_clk: Some(ApbClkConfig(0)),
ledc_sclk: Some(LedcSclkConfig::PllF80m),
lp_fast_clk: Some(LpFastClkConfig::RcFast),
lp_slow_clk: Some(LpSlowClkConfig::RcSlow),
timg_calibration_clock: None,
Expand All @@ -58,6 +59,7 @@ impl CpuClock {
cpu_clk: Some(CpuClkConfig(0)),
ahb_clk: Some(AhbClkConfig(3)), // 40MHz - cannot exceed XTAL_CLK
apb_clk: Some(ApbClkConfig(0)),
ledc_sclk: Some(LedcSclkConfig::PllF80m),
lp_fast_clk: Some(LpFastClkConfig::RcFast),
lp_slow_clk: Some(LpSlowClkConfig::RcSlow),
timg_calibration_clock: None,
Expand All @@ -68,6 +70,7 @@ impl CpuClock {
cpu_clk: Some(CpuClkConfig(0)),
ahb_clk: Some(AhbClkConfig(5)), // 40MHz - cannot exceed XTAL_CLK
apb_clk: Some(ApbClkConfig(0)),
ledc_sclk: Some(LedcSclkConfig::PllF80m),
lp_fast_clk: Some(LpFastClkConfig::RcFast),
lp_slow_clk: Some(LpSlowClkConfig::RcSlow),
timg_calibration_clock: None,
Expand Down Expand Up @@ -387,6 +390,28 @@ fn configure_apb_clk_impl(_clocks: &mut ClockTree, new_config: ApbClkConfig) {
.modify(|_, w| unsafe { w.apb_div_num().bits(new_config.value() as u8) });
}

// LEDC_SCLK

fn enable_ledc_sclk_impl(_clocks: &mut ClockTree, en: bool) {
PCR::regs()
.ledc_sclk_conf()
.modify(|_, w| w.ledc_sclk_en().bit(en));
}

fn configure_ledc_sclk_impl(
_clocks: &mut ClockTree,
_old_selector: Option<LedcSclkConfig>,
new_selector: LedcSclkConfig,
) {
PCR::regs().ledc_sclk_conf().modify(|_, w| unsafe {
w.ledc_sclk_sel().bits(match new_selector {
LedcSclkConfig::XtalClk => 0,
LedcSclkConfig::RcFastClk => 1,
LedcSclkConfig::PllF80m => 2,
})
});
}

// XTAL_D2_CLK

fn enable_xtal_d2_clk_impl(_clocks: &mut ClockTree, _en: bool) {
Expand Down
8 changes: 7 additions & 1 deletion esp-metadata-generated/src/_build_script_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,7 @@ impl Chip {
"soc_has_intpri",
"soc_has_io_mux",
"soc_has_keymng",
"soc_has_ledc",
"soc_has_lp_ana",
"soc_has_lp_aon",
"soc_has_lp_apm0",
Expand Down Expand Up @@ -1727,6 +1728,7 @@ impl Chip {
"gpio_driver_supported",
"dedicated_gpio_driver_supported",
"interrupts_driver_supported",
"ledc_driver_supported",
"pcnt_driver_supported",
"spi_master_driver_supported",
"spi_slave_driver_supported",
Expand Down Expand Up @@ -1759,6 +1761,7 @@ impl Chip {
"soc_has_clock_node_pll_f120m",
"soc_has_clock_node_pll_f160m",
"soc_has_clock_node_pll_f240m",
"soc_has_clock_node_ledc_sclk",
"soc_has_clock_node_hp_root_clk",
"soc_has_clock_node_cpu_clk",
"soc_has_clock_node_ahb_clk",
Expand Down Expand Up @@ -1823,6 +1826,7 @@ impl Chip {
"cargo:rustc-cfg=soc_has_intpri",
"cargo:rustc-cfg=soc_has_io_mux",
"cargo:rustc-cfg=soc_has_keymng",
"cargo:rustc-cfg=soc_has_ledc",
"cargo:rustc-cfg=soc_has_lp_ana",
"cargo:rustc-cfg=soc_has_lp_aon",
"cargo:rustc-cfg=soc_has_lp_apm0",
Expand Down Expand Up @@ -1882,6 +1886,7 @@ impl Chip {
"cargo:rustc-cfg=gpio_driver_supported",
"cargo:rustc-cfg=dedicated_gpio_driver_supported",
"cargo:rustc-cfg=interrupts_driver_supported",
"cargo:rustc-cfg=ledc_driver_supported",
"cargo:rustc-cfg=pcnt_driver_supported",
"cargo:rustc-cfg=spi_master_driver_supported",
"cargo:rustc-cfg=spi_slave_driver_supported",
Expand Down Expand Up @@ -1914,6 +1919,7 @@ impl Chip {
"cargo:rustc-cfg=soc_has_clock_node_pll_f120m",
"cargo:rustc-cfg=soc_has_clock_node_pll_f160m",
"cargo:rustc-cfg=soc_has_clock_node_pll_f240m",
"cargo:rustc-cfg=soc_has_clock_node_ledc_sclk",
"cargo:rustc-cfg=soc_has_clock_node_hp_root_clk",
"cargo:rustc-cfg=soc_has_clock_node_cpu_clk",
"cargo:rustc-cfg=soc_has_clock_node_ahb_clk",
Expand Down Expand Up @@ -4981,6 +4987,7 @@ pub fn emit_check_cfg_directives() {
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_pll_f120m)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_pll_f160m)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_pll_f240m)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_ledc_sclk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_hp_root_clk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_ahb_clk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_xtal_d2_clk)");
Expand Down Expand Up @@ -5014,7 +5021,6 @@ pub fn emit_check_cfg_directives() {
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_mspi_fast_hs_clk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_mspi_fast_ls_clk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_mspi_fast_clk)");
println!("cargo:rustc-check-cfg=cfg(soc_has_clock_node_ledc_sclk)");
println!("cargo:rustc-check-cfg=cfg(i2c_master_can_estimate_nack_reason)");
println!("cargo:rustc-check-cfg=cfg(i2c_master_has_reliable_fsm_reset)");
println!("cargo:rustc-check-cfg=cfg(rmt_has_tx_loop_auto_stop)");
Expand Down
Loading
Loading