diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 12:40:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:46:51 -0300 |
| commit | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch) | |
| tree | 2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/hal/ledc.zig | |
| download | esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip | |
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig
turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask
ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker.
src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers;
src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip;
src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/hal/ledc.zig')
| -rw-r--r-- | src/hal/ledc.zig | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/src/hal/ledc.zig b/src/hal/ledc.zig new file mode 100644 index 0000000..62eacd8 --- /dev/null +++ b/src/hal/ledc.zig @@ -0,0 +1,587 @@ +//! LEDC: the LED PWM controller. Four timers, eight channels, and the first **shadow-register** +//! peripheral in this HAL. +//! +//! Three things make LEDC different from everything else here, and all three are load-bearing. +//! +//! **1. Configuration is staged, then committed.** `LEDC_PARA_UP_CHn` (channel) and +//! `LEDC_TIMERn_PARA_UP` (timer) are write-to-trigger bits: writing 1 copies the staged fields into +//! the shadow registers the counter and comparators actually use, and the hardware clears the bit +//! again by itself (`ledc_reg.h:42-47`, `:951-958`). Values written without a commit are visible in +//! the register file and have no effect on the output. So every mutator here stages, and every +//! commit is its own store - `commitChannel` / `commitTimer` - exactly as ESP-IDF's +//! `ledc_ll_ls_channel_update` (ledc_ll.h:435-438) and `ledc_ll_ls_timer_update` (ledc_ll.h:286-290) +//! do it. +//! +//! The commit store is a read-modify-write, and that is deliberate rather than sloppy: the commit +//! bit shares its word with the staged fields it commits. `LEDC_PARA_UP_CH0` is bit 4 of +//! `LEDC_CH0_CONF0_REG`, whose other fields are `TIMER_SEL`, `SIG_OUT_EN`, `IDLE_LV` and `OVF_*`, so +//! a bare `writeRaw(1 << 4)` would erase the very configuration it was meant to commit. Compare +//! `systimer.zig`'s `op.write(.{update.is(1)})`, which is a single whole-word store because +//! `SYSTIMER_UNIT0_OP_REG` contains nothing else. The read-modify-write is safe here for the reason +//! `mmio.zig` gives: `PARA_UP` is `WT`, it reads back 0, so the read half of the read-modify-write +//! can never re-trigger an earlier commit. That is the difference between a self-clearing bit and a +//! write-1-to-clear bit, and it is why LEDC does not need the interrupt-status treatment. +//! +//! **2. The divider is fixed point, Q10.8.** `LEDC_CLK_DIV_TIMERn` is an 18-bit field at [22:5] +//! (`ledc_reg.h:921-928`) holding a divider with 8 fractional bits (`LEDC_LL_FRACTIONAL_BITS`, +//! ledc_ll.h:30): bits [17:8] are the integer part, bits [7:0] the fraction, so the value 0x4E2 +//! means 1250/256 = 4.8828. The output frequency is +//! +//! f_pwm = f_src * 256 / (div * 2^duty_res) +//! +//! and `divisor()` below is ESP-IDF's arithmetic for the inverse, transcribed operation for +//! operation from `esp_driver_ledc/src/ledc.c:468-497` - including the two places where it is +//! surprising. See its comment. +//! +//! **3. On the P4 the clock mux left the peripheral.** `LEDC_CONF_REG.LEDC_APB_CLK_SEL` still exists +//! in the register map and still documents an encoding (0: APB, 1: RC_FAST, 2: XTAL), and ESP-IDF's +//! P4 LL never touches it: the real mux is `HP_SYS_CLKRST.PERI_CLK_CTRL22.REG_LEDC_CLK_SRC_SEL`, +//! with a *different* encoding (0: XTAL, 1: RC_FAST, 2: PLL_DIV) - ledc_ll.h:223-242. Writing the +//! in-block register would silently do nothing, and reading it back to check would silently agree. +//! `ClockSource` below is the HP_SYS_CLKRST encoding. +//! +//! Gamma fade *ramps* are out of scope, but one gamma register is not optional: the P4 moved +//! `DUTY_NUM`/`DUTY_CYCLE`/`DUTY_SCALE`/`DUTY_INC` out of `LEDC_CHn_CONF1_REG` - which on this die +//! holds only `DUTY_START` - and into gamma RAM. A constant duty is therefore a degenerate one-step +//! fade, and `setDuty` writes that single entry, which is what ESP-IDF's `ledc_duty_config` does for +//! every plain duty change (ledc.c:263-280). + +const std = @import("std"); +const regs = @import("regs"); +const mmio = @import("mmio"); +const clkrst = @import("clkrst.zig"); +const gpio = @import("gpio.zig"); + +const Reg = mmio.Reg; +const Field = mmio.Field; + +/// Eight channels, four timers (`soc_caps.h:385-386`). +pub const channel_count = 8; +pub const timer_count = 4; + +/// The counter is 20 bits, so the duty resolution is at most 20 (`soc_caps.h:387`). The register +/// field is five bits wide and will happily accept 21-31; the hardware will not. +pub const max_duty_resolution = 20; + +/// Fractional bits in `LEDC_CLK_DIV_TIMERn` - `LEDC_LL_FRACTIONAL_BITS`, ledc_ll.h:30. +pub const fractional_bits = 8; + +/// The divider must be at least 1.0 and must fit the field: ESP-IDF's `LEDC_IS_DIV_INVALID` +/// (ledc.c:114) rejects anything `<= LEDC_LL_FRACTIONAL_MAX` or `> LEDC_TIMER_DIV_NUM_MAX`. +pub const divisor_min: u32 = 1 << fractional_bits; +pub const divisor_max: u32 = 0x3ffff; + +pub const Error = error{ + /// The requested frequency cannot be reached from this source at this resolution: the divider + /// would be below 1.0 (frequency too high) or wider than 18 bits (frequency too low). + DividerOutOfRange, + DutyResolutionOutOfRange, +}; + +// ------------------------------------------------------------------------------------- registers + +// Five registers per channel, stride 0x14; two per timer, stride 0x08. Both strides come from a +// second instance's macro rather than being assumed - see mmio.RegArray. +const ch_conf0 = mmio.RegArray(regs.LEDC_CH0_CONF0_REG, regs.LEDC_CH1_CONF0_REG, channel_count); +const ch_hpoint = mmio.RegArray(regs.LEDC_CH0_HPOINT_REG, regs.LEDC_CH1_HPOINT_REG, channel_count); +const ch_duty = mmio.RegArray(regs.LEDC_CH0_DUTY_REG, regs.LEDC_CH1_DUTY_REG, channel_count); +const ch_conf1 = mmio.RegArray(regs.LEDC_CH0_CONF1_REG, regs.LEDC_CH1_CONF1_REG, channel_count); +const ch_duty_r = mmio.RegArray(regs.LEDC_CH0_DUTY_R_REG, regs.LEDC_CH1_DUTY_R_REG, channel_count); +const ch_gamma_conf = mmio.RegArray(regs.LEDC_CH0_GAMMA_CONF_REG, regs.LEDC_CH1_GAMMA_CONF_REG, channel_count); +// Gamma RAM: 16 entries per channel, so the per-channel stride is 0x40 and entry 0 is the base. +const ch_gamma_range0 = mmio.RegArray(regs.LEDC_CH0_GAMMA_RANGE0_REG, regs.LEDC_CH1_GAMMA_RANGE0_REG, channel_count); +const tim_conf = mmio.RegArray(regs.LEDC_TIMER0_CONF_REG, regs.LEDC_TIMER1_CONF_REG, timer_count); +const tim_value = mmio.RegArray(regs.LEDC_TIMER0_VALUE_REG, regs.LEDC_TIMER1_VALUE_REG, timer_count); + +// Field geometry is taken from instance 0 and reused for every instance, which is only sound if the +// instances agree; the comptime block below checks the ends of both ranges against instance 0. That +// is not paranoia about the silicon, it is paranoia about the macro names: `LEDC_CLK_DIV_TIMER0` and +// `LEDC_TIMER0_DUTY_RES` put the instance number in different places, and picking up +// `LEDC_TIMER1_DUTY_RES_S` while meaning timer 0's shift is a one-character mistake. +const timer_sel = Field.of(regs.LEDC_TIMER_SEL_CH0_S, regs.LEDC_TIMER_SEL_CH0_V); +const sig_out_en = Field.of(regs.LEDC_SIG_OUT_EN_CH0_S, regs.LEDC_SIG_OUT_EN_CH0_V); +const idle_lv = Field.of(regs.LEDC_IDLE_LV_CH0_S, regs.LEDC_IDLE_LV_CH0_V); +const ch_para_up = Field.of(regs.LEDC_PARA_UP_CH0_S, regs.LEDC_PARA_UP_CH0_V); +const hpoint = Field.of(regs.LEDC_HPOINT_CH0_S, regs.LEDC_HPOINT_CH0_V); +const duty = Field.of(regs.LEDC_DUTY_CH0_S, regs.LEDC_DUTY_CH0_V); +const duty_r = Field.of(regs.LEDC_DUTY_CH0_R_S, regs.LEDC_DUTY_CH0_R_V); +const duty_start = Field.of(regs.LEDC_DUTY_START_CH0_S, regs.LEDC_DUTY_START_CH0_V); +const gamma_entry_num = Field.of(regs.LEDC_CH0_GAMMA_ENTRY_NUM_S, regs.LEDC_CH0_GAMMA_ENTRY_NUM_V); +const gamma_duty_inc = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_INC_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_INC_V); +const gamma_duty_cycle = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_CYCLE_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_CYCLE_V); +const gamma_scale = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_SCALE_S, regs.LEDC_CH0_GAMMA_RANGE0_SCALE_V); +const gamma_duty_num = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_NUM_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_NUM_V); + +const duty_res = Field.of(regs.LEDC_TIMER0_DUTY_RES_S, regs.LEDC_TIMER0_DUTY_RES_V); +const clk_div = Field.of(regs.LEDC_CLK_DIV_TIMER0_S, regs.LEDC_CLK_DIV_TIMER0_V); +const tim_pause = Field.of(regs.LEDC_TIMER0_PAUSE_S, regs.LEDC_TIMER0_PAUSE_V); +const tim_rst = Field.of(regs.LEDC_TIMER0_RST_S, regs.LEDC_TIMER0_RST_V); +const tim_para_up = Field.of(regs.LEDC_TIMER0_PARA_UP_S, regs.LEDC_TIMER0_PARA_UP_V); + +comptime { + const same = struct { + fn check(comptime what: []const u8, comptime a: Field, comptime b: Field) void { + if (a.shift != b.shift or a.width != b.width) @compileError( + "the per-instance " ++ what ++ " macros disagree on bit position or width; " ++ + "this file must index the field per instance instead of reusing instance 0's", + ); + } + }.check; + // Channels: 1 and 7, the two ends of the range beyond instance 0. + same("LEDC_TIMER_SEL_CHn", timer_sel, Field.of(regs.LEDC_TIMER_SEL_CH1_S, regs.LEDC_TIMER_SEL_CH1_V)); + same("LEDC_TIMER_SEL_CHn", timer_sel, Field.of(regs.LEDC_TIMER_SEL_CH7_S, regs.LEDC_TIMER_SEL_CH7_V)); + same("LEDC_SIG_OUT_EN_CHn", sig_out_en, Field.of(regs.LEDC_SIG_OUT_EN_CH7_S, regs.LEDC_SIG_OUT_EN_CH7_V)); + same("LEDC_IDLE_LV_CHn", idle_lv, Field.of(regs.LEDC_IDLE_LV_CH7_S, regs.LEDC_IDLE_LV_CH7_V)); + same("LEDC_PARA_UP_CHn", ch_para_up, Field.of(regs.LEDC_PARA_UP_CH7_S, regs.LEDC_PARA_UP_CH7_V)); + same("LEDC_HPOINT_CHn", hpoint, Field.of(regs.LEDC_HPOINT_CH7_S, regs.LEDC_HPOINT_CH7_V)); + same("LEDC_DUTY_CHn", duty, Field.of(regs.LEDC_DUTY_CH7_S, regs.LEDC_DUTY_CH7_V)); + same("LEDC_DUTY_START_CHn", duty_start, Field.of(regs.LEDC_DUTY_START_CH7_S, regs.LEDC_DUTY_START_CH7_V)); + same("LEDC_CHn_GAMMA_ENTRY_NUM", gamma_entry_num, Field.of(regs.LEDC_CH7_GAMMA_ENTRY_NUM_S, regs.LEDC_CH7_GAMMA_ENTRY_NUM_V)); + same("LEDC_CHn_GAMMA_RANGE0_SCALE", gamma_scale, Field.of(regs.LEDC_CH7_GAMMA_RANGE0_SCALE_S, regs.LEDC_CH7_GAMMA_RANGE0_SCALE_V)); + // Timers: 1 and 3. + same("LEDC_TIMERn_DUTY_RES", duty_res, Field.of(regs.LEDC_TIMER1_DUTY_RES_S, regs.LEDC_TIMER1_DUTY_RES_V)); + same("LEDC_TIMERn_DUTY_RES", duty_res, Field.of(regs.LEDC_TIMER3_DUTY_RES_S, regs.LEDC_TIMER3_DUTY_RES_V)); + same("LEDC_CLK_DIV_TIMERn", clk_div, Field.of(regs.LEDC_CLK_DIV_TIMER3_S, regs.LEDC_CLK_DIV_TIMER3_V)); + same("LEDC_TIMERn_PAUSE", tim_pause, Field.of(regs.LEDC_TIMER3_PAUSE_S, regs.LEDC_TIMER3_PAUSE_V)); + same("LEDC_TIMERn_RST", tim_rst, Field.of(regs.LEDC_TIMER3_RST_S, regs.LEDC_TIMER3_RST_V)); + same("LEDC_TIMERn_PARA_UP", tim_para_up, Field.of(regs.LEDC_TIMER3_PARA_UP_S, regs.LEDC_TIMER3_PARA_UP_V)); + + // `LEDC_TIMER_DIV_NUM_MAX` (ledc.c:110) is a literal in the driver; it should be the field's + // own mask, and if a future die widens the field this is where the two part company. + if (divisor_max != clk_div.max()) @compileError( + "divisor_max no longer matches LEDC_CLK_DIV_TIMERn's width", + ); + // The eight output signals must be consecutive for `signalIndex` to be arithmetic. + if (regs.LEDC_LS_SIG_OUT_PAD_OUT7_IDX - regs.LEDC_LS_SIG_OUT_PAD_OUT0_IDX != channel_count - 1) + @compileError("the LEDC output signal indices are not consecutive; signalIndex must be a table"); +} + +// ------------------------------------------------------------------------------ clocks and reset + +/// LEDC's function clock, in HP_SYS_CLKRST rather than in the peripheral (ledc_ll.h:179, :241). +/// Shared with RMT's fields, hence the interrupt-masked read-modify-write. +const peri_clk_ctrl22 = Reg.at(regs.HP_SYS_CLKRST_PERI_CLK_CTRL22_REG); +const clk_src_sel = Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_V); +const func_clk_en = Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_V); + +/// The four timers' shared source. Encoding from `ledc_ll_set_slow_clk_sel` (ledc_ll.h:223-242) - +/// *not* the encoding `LEDC_CONF_REG.APB_CLK_SEL` documents, which is a different register on a +/// different block and is dead on this die. +pub const ClockSource = enum(u2) { + /// 40 MHz on this board (`clk_tree_defs.h:145`). + xtal = 0, + /// The internal RC oscillator: approximately 17.5 MHz (`clk_tree_defs.h:58`) and not trimmed. + /// ESP-IDF calibrates it against XTAL before using it for a divider; there is no calibration + /// here, so a frequency computed from `rc_fast_hz_approx` is approximate too. + rc_fast = 1, + /// PLL_F80M, 80 MHz (`clk_tree_defs.h:168`). Called `LEDC_SLOW_CLK_PLL_DIV` by ESP-IDF. + pll_div = 2, + + /// The source frequency to feed `divisor`, or null for RC_FAST, whose real rate has to be + /// measured rather than assumed. + pub fn hz(self: ClockSource) ?u32 { + return switch (self) { + .xtal => xtal_hz, + .pll_div => pll_div_hz, + .rc_fast => null, + }; + } +}; + +pub const xtal_hz: u32 = 40_000_000; +pub const pll_div_hz: u32 = 80_000_000; +pub const rc_fast_hz_approx: u32 = 17_500_000; + +/// Select the timers' source clock. A read-modify-write of a register that also holds RMT's clock +/// fields, so it runs with interrupts masked, like everything else that touches HP_SYS_CLKRST. +pub fn setClockSource(src: ClockSource) void { + const guard = clkrst.maskInterrupts(); + defer guard.release(); + peri_clk_ctrl22.modify(.{clk_src_sel.is(@intFromEnum(src))}); +} + +pub fn getClockSource() ClockSource { + return @enumFromInt(peri_clk_ctrl22.get(clk_src_sel)); +} + +/// LEDC's core ("function") clock gate. Distinct from the APB gate in `clkrst`: the APB clock makes +/// the registers addressable, this one makes the counters run - and ESP-IDF notes that some LEDC +/// registers and the gamma RAM need it just to be read or written (ledc.c:433-436). +pub fn setFunctionClockEnabled(on: bool) void { + const guard = clkrst.maskInterrupts(); + defer guard.release(); + peri_clk_ctrl22.modify(.{func_clk_en.is(@intFromBool(on))}); +} + +/// Bring the peripheral up, in the only order that works: bus clock, reset, function clock, source. +/// +/// The bus clock first because LEDC is one of the blocks whose APB gate is *off* at power-on +/// (`hp_sys_clkrst_reg.h:835`, REG_LEDC_APB_CLK_EN default 0), so every register read before this +/// returns the last value the bus latched. The function clock before any configuration because the +/// gamma RAM needs it. ESP-IDF deasserts the reset rather than pulsing it (ledc.c:430-431), because +/// its driver may be attaching to a running LEDC; this pulses, which is the stronger guarantee for a +/// fresh boot and is measurably safe on this board - pulsing REG_RST_EN_LEDC for 1 ms left the +/// console untouched and returned LEDC_CH0_CONF0 to 0. +pub fn init(src: ClockSource) void { + clkrst.setClockEnabled(.ledc, true); + clkrst.resetPeripheral(.ledc); + setFunctionClockEnabled(true); + setClockSource(src); +} + +// -------------------------------------------------------------------------------- divider maths + +/// ESP-IDF's `ledc_calculate_divisor`, transcribed from `esp_driver_ledc/src/ledc.c:468-497`: +/// +/// return (((uint64_t) src_clk_freq << LEDC_LL_FRACTIONAL_BITS) + freq_hz * precision / 2) +/// / (freq_hz * precision); +/// +/// Result is Q10.8 - see the file comment - and `divisorValid` says whether it fits the field. +/// +/// Two properties of that C expression are not obvious and are reproduced deliberately, because a +/// HAL that computed a *better* divider than IDF's would disagree with it on real inputs and there +/// would be no way to tell which of the two was wrong: +/// +/// 1. `freq_hz * precision` is `int * uint32_t`, so it is computed in **32 bits and wraps**, and +/// the wrap is not always harmlessly out of range. Ask for 4097 Hz at 20-bit resolution from the +/// 40 MHz XTAL: the true product is 2^32 + 2^20, the C code divides by 2^20 instead, and the +/// answer is 9766 - a *valid* divider, which programs 1.0 Hz. IDF accepts it, because the value +/// passes its own range check. `%*` here is that wrap, on purpose: reproducing it is what makes +/// the on-die comparison meaningful, and the numbers above are how a caller can recognise it. +/// 2. The quotient is `uint64_t` but the return type is `uint32_t`, so it is **truncated**. From a +/// 40 MHz source at 1 Hz and 1-bit resolution the quotient is 5.12e9 and IDF returns 825032704. +/// `@truncate` is that truncation. +/// +/// The one place this cannot follow IDF is `freq_hz * precision == 0`, reachable at exactly 4096 Hz +/// with 20-bit resolution (2^32, wrapping to zero), where the C code divides by zero. Returning 0 is +/// a deliberate substitution: it is not a valid divider, so `divisorValid` rejects it and the caller +/// gets an error instead of undefined behaviour. +pub fn divisor(src_hz: u32, freq_hz: u32, resolution: u5) u32 { + const precision: u32 = @as(u32, 1) << resolution; + const den: u32 = freq_hz *% precision; + if (den == 0) return 0; + const num: u64 = (@as(u64, src_hz) << fractional_bits) + den / 2; + return @truncate(num / den); +} + +/// `LEDC_IS_DIV_INVALID`, inverted (ledc.c:114). A divider below 1.0 means the requested frequency +/// is faster than the source can produce at that resolution. +pub fn divisorValid(div: u32) bool { + return div >= divisor_min and div <= divisor_max; +} + +/// The frequency a given divider and resolution actually produce: `f_src * 256 / (div * 2^res)`, +/// rounded, and 0 for a divider of 0. +/// +/// This is `ledc_get_freq`'s arithmetic (ledc.c:1175) with one deliberate difference: the +/// denominator is computed in 64 bits, so it does not wrap. Nothing compares this against IDF - it +/// is a convenience for callers checking what they got - and a wrapped denominator here would be a +/// bug rather than a compatibility requirement. +pub fn frequencyOf(src_hz: u32, div: u32, resolution: u5) u32 { + if (div == 0) return 0; + const den: u64 = @as(u64, div) * (@as(u64, 1) << resolution); + const num: u64 = (@as(u64, src_hz) << fractional_bits) + den / 2; + return @truncate(num / den); +} + +// --------------------------------------------------------------------------------------- timers + +/// Stage the divider. `ledc_ll_set_clock_divider`, ledc_ll.h:345-348. +pub fn setClockDivider(timer: u32, div: u32) void { + std.debug.assert(timer < timer_count); + tim_conf.at(timer).modify(.{clk_div.is(div)}); +} + +pub fn getClockDivider(timer: u32) u32 { + std.debug.assert(timer < timer_count); + return tim_conf.at(timer).get(clk_div); +} + +/// Stage the duty resolution, in bits. `ledc_ll_set_duty_resolution`, ledc_ll.h:391-394. +pub fn setDutyResolution(timer: u32, bits: u5) void { + std.debug.assert(timer < timer_count); + std.debug.assert(bits <= max_duty_resolution); + tim_conf.at(timer).modify(.{duty_res.is(bits)}); +} + +pub fn getDutyResolution(timer: u32) u5 { + std.debug.assert(timer < timer_count); + return @intCast(tim_conf.at(timer).get(duty_res)); +} + +/// Commit the staged divider and resolution. One store, and the bit clears itself. +/// +/// ESP-IDF does not wait for it: "we don't wait for the bit gets cleared since it can take quite +/// long depends on the pwm frequency" (ledc_ll.h:289). Neither does this - a poll here would block +/// for a whole PWM period, and there is nothing useful to do with the answer. +pub fn commitTimer(timer: u32) void { + std.debug.assert(timer < timer_count); + tim_conf.at(timer).modify(.{tim_para_up.is(1)}); +} + +/// Reset the timer's counter: assert, deassert (`ledc_ll_timer_rst`, ledc_ll.h:301-305). +/// +/// Note the reset value of `LEDC_TIMERn_RST` is **1** (ledc_reg.h:936-943), which is one of the +/// 46.7% of fields whose reset value is not zero, and the reason `configureTimer` finishes by +/// clearing it: a freshly reset LEDC block holds all four counters at zero and they stay there until +/// something writes that bit back down. +pub fn resetTimer(timer: u32) void { + std.debug.assert(timer < timer_count); + const r = tim_conf.at(timer); + r.modify(.{tim_rst.is(1)}); + r.modify(.{tim_rst.is(0)}); +} + +/// Freeze the counter where it is (`ledc_ll_timer_pause`, ledc_ll.h:316-319). +pub fn pauseTimer(timer: u32) void { + std.debug.assert(timer < timer_count); + tim_conf.at(timer).modify(.{tim_pause.is(1)}); +} + +pub fn resumeTimer(timer: u32) void { + std.debug.assert(timer < timer_count); + tim_conf.at(timer).modify(.{tim_pause.is(0)}); +} + +/// The counter's current value, 20 bits. Reading it is a plain load - no latch handshake, unlike +/// systimer. +pub fn timerCount(timer: u32) u32 { + std.debug.assert(timer < timer_count); + return tim_value.at(timer).raw(); +} + +/// Everything a timer needs to produce `freq_hz` at `resolution` bits, in ESP-IDF's order: +/// divider, resolution, commit, then out of pause and out of reset (`ledc_set_timer_params`, +/// ledc.c:244-261, followed by ledc.c:816-818). +/// +/// Returns `DividerOutOfRange` rather than programming a divider the hardware cannot hold. The +/// caller passes the source frequency because this HAL has no clock tree: `ClockSource.hz()` gives +/// it for XTAL and PLL_DIV, and RC_FAST has to be measured. +pub fn configureTimer(timer: u32, opts: struct { + src_hz: u32, + freq_hz: u32, + resolution: u5, +}) Error!void { + std.debug.assert(timer < timer_count); + if (opts.resolution == 0 or opts.resolution > max_duty_resolution) return Error.DutyResolutionOutOfRange; + const div = divisor(opts.src_hz, opts.freq_hz, opts.resolution); + if (!divisorValid(div)) return Error.DividerOutOfRange; + + setClockDivider(timer, div); + setDutyResolution(timer, opts.resolution); + commitTimer(timer); + resumeTimer(timer); + resetTimer(timer); +} + +// ------------------------------------------------------------------------------------- channels + +/// Which timer drives this channel. Staged; needs `commitChannel`. +/// `ledc_ll_bind_channel_timer`, ledc_ll.h:697-700. +pub fn bindTimer(channel: u32, timer: u32) void { + std.debug.assert(channel < channel_count and timer < timer_count); + ch_conf0.at(channel).modify(.{timer_sel.is(timer)}); +} + +pub fn boundTimer(channel: u32) u32 { + std.debug.assert(channel < channel_count); + return ch_conf0.at(channel).get(timer_sel); +} + +/// Where in the period the output goes high, in counter ticks. Staged. +/// `ledc_ll_set_hpoint`, ledc_ll.h:450-453. +pub fn setHpoint(channel: u32, value: u32) void { + std.debug.assert(channel < channel_count and value <= hpoint.max()); + ch_hpoint.at(channel).modify(.{hpoint.is(value)}); +} + +/// Stage a duty value, in counter ticks out of `2^resolution`. +/// +/// Two things happen here that the name does not suggest, and both are ESP-IDF's +/// (`ledc_ll_set_duty_int_part` ledc_ll.h:480-483, `ledc_duty_config` ledc.c:263-280): +/// +/// * The register holds duty in **Q21.4** - four fractional bits, used by fades - so the integer +/// duty is shifted left by 4. `getDuty` shifts back. +/// * The P4 has no plain-duty path. `DUTY_NUM`/`DUTY_CYCLE`/`DUTY_SCALE`/`DUTY_INC` moved out of +/// `CHn_CONF1` into gamma RAM, so a constant duty is a one-step fade of scale 0: entry 0 gets +/// (increase, one cycle, scale 0, one step) and the range count is set to 1. Without that entry +/// the staged duty is committed and the output does not move. +/// +/// Staged; needs `commitChannel` (or `start`, which commits). +pub fn setDuty(channel: u32, value: u32) void { + std.debug.assert(channel < channel_count); + std.debug.assert(value <= duty.max() >> 4); + ch_duty.at(channel).modify(.{duty.is(value << 4)}); + stageNoFade(channel); +} + +/// The duty the hardware is currently using, from the read-only shadow (`ledc_ll_get_duty`, +/// ledc_ll.h:495-498). This is the one register that shows whether a commit actually happened - and +/// it only updates when the timer next overflows, so it is not a synchronous read-back. +pub fn currentDuty(channel: u32) u32 { + std.debug.assert(channel < channel_count); + return ch_duty_r.at(channel).get(duty_r) >> 4; +} + +/// Gamma RAM entry 0 as "no fade": one step, one cycle, scale 0, increasing. Exactly the parameters +/// `ledc_set_duty` passes down (ledc.c:1109-1117) for a constant duty. +fn stageNoFade(channel: u32) void { + // The whole word is being established, and every field in it is being named, so this is one of + // the few places `write` is right rather than `modify`. + ch_gamma_range0.at(channel).write(.{ + gamma_duty_inc.is(1), + gamma_duty_cycle.is(1), + gamma_scale.is(0), + gamma_duty_num.is(1), + }); + ch_gamma_conf.at(channel).modify(.{gamma_entry_num.is(1)}); +} + +/// The output driver. Staged; needs `commitChannel`. +/// `ledc_ll_set_sig_out_en`, ledc_ll.h:592-596. +pub fn setOutputEnabled(channel: u32, on: bool) void { + std.debug.assert(channel < channel_count); + ch_conf0.at(channel).modify(.{sig_out_en.is(@intFromBool(on))}); +} + +/// The level the pad holds while the channel is disabled - and only while it is disabled +/// (`ledc_reg.h:34-37`: "Valid only when LEDC_SIG_OUT_EN_CHn is 0"). Staged. +/// `ledc_ll_set_idle_level`, ledc_ll.h:622-626. +pub fn setIdleLevel(channel: u32, level: u1) void { + std.debug.assert(channel < channel_count); + ch_conf0.at(channel).modify(.{idle_lv.is(level)}); +} + +/// Hand the staged duty to the fade engine. `ledc_ll_set_duty_start`, ledc_ll.h:607-610. +/// +/// `DUTY_START` lives in `CHn_CONF1`, alone, and is annotated `R/W/SC` - the hardware clears it when +/// the (here one-step) fade finishes. A read-modify-write is still the right store: the bit is the +/// only field in the word, but bits 30:0 are reserved and writing them back as read is what IDF's +/// bitfield assignment does. +pub fn startFade(channel: u32) void { + std.debug.assert(channel < channel_count); + ch_conf1.at(channel).modify(.{duty_start.is(1)}); +} + +/// Commit the channel's staged fields: `TIMER_SEL`, `SIG_OUT_EN`, `IDLE_LV`, `HPOINT`, +/// `DUTY_START`, `OVF_CNT_EN` and the duty (`ledc_reg.h:42-47`). +/// +/// One deliberate store, never folded into the store that staged the values, matching +/// `ledc_ll_ls_channel_update` (ledc_ll.h:435-438). It is a read-modify-write because the commit bit +/// shares its word with the staged fields - see the file comment - and that is safe only because the +/// bit reads back as 0. +pub fn commitChannel(channel: u32) void { + std.debug.assert(channel < channel_count); + ch_conf0.at(channel).modify(.{ch_para_up.is(1)}); +} + +/// Start driving: output on, duty handed over, committed. `_ledc_update_duty`, ledc.c:1021-1026. +pub fn start(channel: u32) void { + setOutputEnabled(channel, true); + startFade(channel); + commitChannel(channel); +} + +/// Stop driving and hold the pad at `idle_level`. `ledc_stop`, ledc.c:1039-1050. +/// +/// The order is IDF's and it matters: the idle level is staged *before* the output is disabled, so +/// the two reach the hardware in the same commit and the pad never spends a period at the old idle +/// level. +pub fn stop(channel: u32, idle_level: u1) void { + setIdleLevel(channel, idle_level); + setOutputEnabled(channel, false); + commitChannel(channel); +} + +/// A whole channel in one commit: timer, duty, hpoint, idle level, output enable. +/// +/// This is the one operation here that is not a transcription of an ESP-IDF function - IDF's +/// `ledc_channel_config` also allocates a driver object, reserves the pin and installs a fade +/// service - but it is the same register sequence: stage everything, then commit once. One commit +/// rather than five is the point: the channel changes all at once, at a period boundary, instead of +/// drifting through four intermediate configurations. +pub fn configureChannel(channel: u32, opts: struct { + timer: u32, + duty: u32, + hpoint: u32 = 0, + idle_level: u1 = 0, + output_enabled: bool = true, +}) void { + bindTimer(channel, opts.timer); + setHpoint(channel, opts.hpoint); + setDuty(channel, opts.duty); + setIdleLevel(channel, opts.idle_level); + setOutputEnabled(channel, opts.output_enabled); + startFade(channel); + commitChannel(channel); +} + +// ------------------------------------------------------------------------------------ pin output + +/// The GPIO matrix signal index for a channel's output. `ledc_periph_signal[0].sig_out0_idx` is +/// `LEDC_LS_SIG_OUT_PAD_OUT0_IDX` (esp_hal_ledc/esp32p4/ledc_periph.c:14-18) and the driver adds the +/// channel number to it (ledc.c:831); the eight indices are consecutive from 126, asserted above. +pub fn signalIndex(channel: u32) u32 { + std.debug.assert(channel < channel_count); + return @as(u32, @intCast(regs.LEDC_LS_SIG_OUT_PAD_OUT0_IDX)) + channel; +} + +/// Route a channel's output to a pad through the GPIO matrix. No LEDC register is involved: the +/// peripheral has no pad of its own, and this is the whole of `ledc_set_pin`'s hardware effect +/// (ledc.c:823-836, whose `gpio_matrix_output` is func_sel + matrix source + output-enable control, +/// gpio_hal.c:60-69). +pub fn attachPin(channel: u32, pin: u8) void { + gpio.matrixOut(pin, signalIndex(channel)); +} + +// ----------------------------------------------------------------------------------------- tests + +test "the divider is Q10.8: integer part in [17:8], fraction in [7:0]" { + // 40 MHz XTAL, 1 kHz, 13-bit resolution. 40e6*256/(1000*8192) = 1250 = 0x4E2, i.e. 4 + 226/256 + // = 4.8828. Checked against ESP-IDF's own expression compiled on the host over a 1,680-point + // sweep of (source, frequency, resolution). + try std.testing.expectEqual(@as(u32, 1250), divisor(40_000_000, 1_000, 13)); + try std.testing.expectEqual(@as(u32, 1250 >> 8), 4); + try std.testing.expectEqual(@as(u32, 1250 & 0xff), 226); + // And back again, to within the rounding the format allows. + try std.testing.expectEqual(@as(u32, 1_000), frequencyOf(40_000_000, 1250, 13)); +} + +test "divider values for the frequencies the differential harness uses" { + try std.testing.expectEqual(@as(u32, 2000), divisor(40_000_000, 5_000, 10)); + try std.testing.expectEqual(@as(u32, 500), divisor(40_000_000, 20_000, 10)); + try std.testing.expectEqual(@as(u32, 2083), divisor(40_000_000, 300, 14)); + // 80 MHz PLL_F80M, same request: exactly twice the divider. + try std.testing.expectEqual(@as(u32, 4000), divisor(80_000_000, 5_000, 10)); +} + +test "the arithmetic reproduces IDF's overflow and truncation rather than fixing them" { + // 32-bit wrap of freq*precision: the true product at 1 MHz / 13 bits is 8_192_000_000, and the + // C expression divides by 3_897_032_704 instead, giving 3 where the unwrapped arithmetic would + // give 1. Neither is a usable divider - both are below 1.0, so `divisorValid` rejects them the + // way `LEDC_IS_DIV_INVALID` does - but the *value* has to be IDF's, or a caller comparing the + // two implementations sees a difference that is really just two different roundings. + try std.testing.expectEqual(@as(u32, 1_000_000 *% (@as(u32, 1) << 13)), 3_897_032_704); + try std.testing.expectEqual(@as(u32, 3), divisor(40_000_000, 1_000_000, 13)); + try std.testing.expect(!divisorValid(divisor(40_000_000, 1_000_000, 13))); + // u64 quotient truncated to u32, exactly as the C return type does. + try std.testing.expectEqual(@as(u32, 825_032_704), divisor(40_000_000, 1, 1)); + // The one input where IDF divides by zero: 4096 * 2^20 == 2^32. + try std.testing.expectEqual(@as(u32, 0), divisor(40_000_000, 4096, 20)); + try std.testing.expect(!divisorValid(divisor(40_000_000, 4096, 20))); + // The wrap that is *not* self-limiting: 4097 Hz at 20 bits gives a divider IDF's own range check + // accepts, and it programs 1.0 Hz. Reproduced rather than corrected, because the point of the + // differential test is to be wrong in the same way IDF is or not at all. + try std.testing.expectEqual(@as(u32, 9766), divisor(40_000_000, 4097, 20)); + try std.testing.expect(divisorValid(9766)); + try std.testing.expectEqual(@as(u32, 1), frequencyOf(40_000_000, 9766, 20)); +} + +test "validity is the field's range, not the whole u32" { + try std.testing.expect(!divisorValid(0xff)); // below 1.0 + try std.testing.expect(divisorValid(0x100)); // exactly 1.0 + try std.testing.expect(divisorValid(0x3ffff)); + try std.testing.expect(!divisorValid(0x40000)); + // 40 MHz cannot make 5 kHz at 13 bits: that needs a divider of 0.98. + try std.testing.expect(!divisorValid(divisor(40_000_000, 5_000, 13))); +} |
