Skip to content

machine/esp32: add PWM support using the LEDC peripheral for classic esp32 board - #5669

Open
zombieleet wants to merge 1 commit into
tinygo-org:devfrom
zombieleet:esp32-pwm
Open

zombieleet wants to merge 1 commit into
tinygo-org:devfrom
zombieleet:esp32-pwm

Conversation

@zombieleet

@zombieleet zombieleet commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Why

The classic ESP32 is the only ESP32 chip in TinyGo without PWM. This adds it.

It uses the high speed half of the LEDC peripheral. This gives 4 timers
(PWM0 to PWM3) and 8 channels. You can use any pin, because the signal goes
through the GPIO matrix.

This closes the ESP32 part of #5186. Thanks to @jespino for the first version
and for the hardware research. His register values were a good check while I
wrote this.

What changed

The C3 and the S3 have LEDC PWM. Most of machine_esp32xx_pwm.go is the same
for all of these chips. This change uses that code again. It does not add a
second driver for the same peripheral.

Three functions in that file work only on the newer chips. They move to a file
for each chip.

function why it is different
enableClock The C3 and S3 turn on the clock in SYSTEM. This chip uses DPORT. It also has no LEDC.CONF_CLK_EN bit.
setTimerConf The timer registers here are HSTIMER0_CONF, not TIMER0_CONF.
chanOp The channel registers here are HSCH0_CONF0, not CH0_CONF0.

The files are now:

machine_esp32xx_pwm.go       esp32 || esp32c3 || esp32s3   shared code
machine_esp32xx_ls_pwm.go    esp32c3 || esp32s3            new, moved code
machine_esp32c3_pwm.go       esp32c3                       no change
machine_esp32s3_pwm.go       esp32s3                       no change
machine_esp32_pwm.go         esp32                         new

machine_esp32xx_ls_pwm.go holds code that moved out of the shared file. The
code did not change. The C3 and the S3 keep their behaviour.

This also adds src/examples/pwm/esp32-coreboard-v2.go and one line in the
smoketest-esp target.

Why these decisions

Why this is not a change to #5186.

That PR is older than the esp32xx shared layer from #5215. It adds a type of
its own in one file of 601 lines. The tree would then have two LEDC drivers.
One uses the generated SVD setters. The other uses register offsets and masks
written by hand. The shared type keeps one driver. It also checks the register
names against the description from Espressif.

Why the high speed block.

This chip has a high speed block and a low speed block. High speed channels
apply a duty change themselves at the end of the period. There is no glitch and
no PARA_UP bit to set. The low speed block can come later if somebody needs
the 8 more channels.

Why a new file and not a change to the shared file.

TICK_SEL has the opposite meaning on this chip.

ESP32 high speed timers   1 = APB_CLK 80MHz   0 = REF_TICK 1MHz
C3 and S3 low speed       the shared code writes 0

A shared setTimerConf would select the 1MHz clock on the ESP32. All
frequencies would then be 80 times too slow. A file for each chip prevents
this.

Why chanOp is a long switch.

Each channel has its own registers and its own generated setter. There is no
array to index. The C3 and S3 files have the same shape.

Why GPIO18 and GPIO19 in the example.

These pins have no function at boot. They also do not go to an LED on the
board.

MCPWM is not in this change. Motor control PWM is a different peripheral. It
needs dead time and fault inputs, so it needs a different API.

Tests

  • gofmt
  • Builds for esp32-coreboard-v2, esp32-generic, esp32c3-generic,
    esp32c3-supermini, esp32s3-generic and xiao-esp32s3
  • The examples/pwm binaries for esp32c3-supermini and xiao-esp32s3 are
    the same byte for byte before and after this change
  • Tested on an ESP32-WROVER board

A servo on GPIO15 turns from 0 to 180 degrees and back. The movement is smooth.
A servo moves only with pulses between 0.5ms and 2.5ms that repeat near 50Hz. A
wrong divider puts the arm in the wrong place. A wrong duty stops the movement.

One known limit

The shared Configure cuts the clock divider to a whole number.

divActual = 80_000_000 / (freq << dutyRes)

At 50Hz with 14 bit resolution this is 97.66, and the code uses 97. The
period is then 19.87ms and not 20ms. Pulses are about 0.67% too short.

This is the behaviour that the C3 and the S3 have now. This change does not add
it. A divider that rounds to the nearest number would give about 0.35%. That
changes the output of all three chips, so it belongs in its own PR.

@zombieleet
zombieleet marked this pull request as ready for review September 10, 2026 23:04
@zombieleet

zombieleet commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author
IMG_4246.mov

@zombieleet zombieleet changed the title machine/esp32: add PWM support using the LEDC peripheral machine/esp32: add PWM support using the LEDC peripheral for classic esp32 board Sep 12, 2026
@zombieleet

zombieleet commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Hi @deadprogram PTAL

The classic ESP32 had no PWM. This adds it.

It uses the high speed half of the LEDC peripheral. This gives 4 timers
(PWM0 to PWM3) and 8 channels. You can use any pin, because the signal
goes through the GPIO matrix.

The ESP32-C3 and the ESP32-S3 have LEDC PWM. Most of that code is the
same for all of these chips. This change uses that code again. It does
not add a second driver for the same peripheral.

Three functions in the shared file work only on the newer chips. They
move to a file for each chip.

  enableClock   The C3 and S3 turn on the clock in SYSTEM. This chip
                uses DPORT. It also has no LEDC.CONF_CLK_EN bit.
  setTimerConf  The timer registers here are HSTIMER0_CONF. On the
                other chips they are TIMER0_CONF.
  chanOp        The channel registers here are HSCH0_CONF0. On the
                other chips they are CH0_CONF0.

The files are now:

  machine_esp32xx_pwm.go     The shared part. It builds for esp32 also.
                             It calls enableClock and setTimerConf.
  machine_esp32xx_ls_pwm.go  New. It holds the two low speed functions.
                             The code moved without a change. It builds
                             for esp32c3 and esp32s3 only.
  machine_esp32_pwm.go       New. The versions for the classic ESP32.

Two hardware details are important.

TICK_SEL has the opposite meaning on this chip. Here 1 selects APB_CLK
at 80MHz and 0 selects REF_TICK at 1MHz. The low speed timers on the C3
and the S3 write 0. A 0 here makes all frequencies 80 times too slow.

There is no PARA_UP bit. On the C3 and the S3 you set PARA_UP to apply a
change to a channel. High speed channels apply the change themselves at
the end of the period.

Tests:

The examples/pwm binaries for esp32c3-supermini and xiao-esp32s3 are the
same byte for byte before and after this change. The move of the code
did not change it.

Tested on an ESP32-WROVER board. A servo on GPIO15 turns from 0 to 180
degrees and back. The movement is smooth. This shows that the timing is
correct. A servo moves only with pulses between 0.5ms and 2.5ms that
repeat near 50Hz.

Motor control PWM (the MCPWM peripheral) is not in this change. It needs
a different API.
@deadprogram

deadprogram commented Sep 14, 2026

Copy link
Copy Markdown
Member

Thanks @zombieleet. Notes below, edited from an automated review.

I built the three targets. The esp32c3-supermini and xiao-esp32s3 binaries are the same before and after the change, so the move of the code did not change them. esp32-coreboard-v2 builds.

  1. chanOp can be much shorter. The HSCH registers repeat every 0x14 bytes. HSCH0_CONF0 is at 0x0, HSCH1_CONF0 is at 0x14, and HSCH7_CONF0 is at 0x8C. You can find a channel register with pointer arithmetic instead of a switch. machine_esp32.go already does this in outFunc and inFunc. This removes about 250 lines.

The following items are not new problems in this PR. What do you think, would it be a good idea to address them now, or to leave for a separate PR?

  1. SetInverting does not invert the output. It writes IDLE_LV only, and IDLE_LV sets the pin level when SIG_OUT_EN is 0. To invert the live signal you must set the INV_SEL bit of FUNCn_OUT_SEL_CFG in the GPIO matrix, and Pin.configure clears that bit. This comes from the shared code, not from this change. But examples/pwm calls SetInverting, and this change adds that example to the ESP32 smoke test. Channel B will not show an inverted output on the board.

  2. PWM0 to PWM3 are four timers, but they share the same eight channels. Each LEDCPWM has its own channelPin array that starts at index 0. PWM0.Channel(pinA) and PWM1.Channel(pinB) both return channel 0 and both write hardware channel 0. The second call replaces the first. This also comes from the shared code. It limits the four timers to one timer at a time.

  3. Configure calls enableClock, and enableClock pulses LEDC_RST. That reset clears the whole LEDC block, so a second Configure on another timer erases the first one. This also comes from the shared code, but enableClock is new here, so this may be the place to fix it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants