Sitemap
Ethan Wu

Ethan Wu

I am a high schooler, yet also a programmer. I contributed to projects such as Rolldown and Oxc, joined their teams, and also expanded research on LLMs.

Generating PWM Signals on Bare-metal Rust ESP32.

4 min readSep 30, 2025

--

Recently, I successfully generated PWM signals on bare-metal Rust-written ESP32 firmware, which is based on esp-hal. Using an LED controller (LEDC) peripheral, we can successfully handle the modulation.

Before starting this tutorial, you should have:

  • A working Rust ESP32 toolchain with esp-hal
  • An ESP32-C3 development board
  • An SG90 servo motor (or similar)
  • Basic understanding of GPIO operations

Prelimary

We first introduce concepts about PWM and how the LEDC in ESP32 (specifically, the ESP32C3 in this tutorial) works. It was composed of six channels, four timers, and a 14-bit maximum resolution (in ESP32, it has a high-speed clock and a 20-bit maximum resolution; however, in other chips, we could only use a slow-speed clock).

Pulse Width Modulation (PWM) is a technique for getting analog-like results with digital signals. A PWM signal rapidly switches between HIGH and LOW states, and by varying the ratio of on-time to off-time, we can control things like LED brightness, motor speed, servo position, heating element power, and more. There are three core concepts:

  1. Frequency: How many times per second the signal cycles (Hz)
  2. Duty Cycle: The percentage of time the signal is HIGH (0–100%)
  3. Resolution: The granularity of duty cycle control (e.g., 8-bit = 256 steps, 10-bit = 1024 steps)

For simplicity, we select the APB clock here (actually, in esp-hal, only the APB clock was implemented). The APB clock is fully spelled out as “Advanced Peripheral Bus” clock and has an 80 MHz frequency. According to the Technical Reference Manual, the APB clock has three types:

  1. 1 kHz frequency, with a resolution of more than 7 bits, lower than 14 bits.
  2. 5 kHz frequency, with a resolution of more than 4 bits, lower than 13 bits.
  3. 10 kHz frequency, with a resolution of more than 3 bits, lower than 12 bits.

Writing Rust on ESP

Thanks to the esp-hal, we can have a high-level abstraction of peripherals so that we can focus on the control procedure rather than the register status.

First, we need to initiate a peripheral instance, which is quite straightforward:

let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);

Using those default configs, we can have peripherals accessible.

Then we can create instances for GPIOs:

// Input pin
let input_pin = Input::new(peripherals.GPIO1, InputConfig::default());
// Output pin
let output_pin = Output::new(peripherals.GPIO0, Level::Low, OutputConfig::default());
// Flex (Input + Output) pin
let flex_pin = Flex::new(peripherals.GPIO2);

For this tutorial, we only need to have an output pin attached to the servo, allowing us to instantiate the servo on GPIO0.

let servo = Output::new(peripherals.GPIO0, Level::Low, OutputConfig::default());

After instantiation, we can easily adjust the value by setting it to low/high.

servo.set_low();
servo.set_high();

// Check status
servo.is_low();
servo.is_high();

// Toggle
servo.toggle();

PWM on ESP-HAL

The LED controller is universal on ESP chips, and it suits the servo PWM output, which only requires one output. Here, we take the SG90 servo as an example.

Get Ethan Wu’s stories in your inbox

Join Medium for free to get updates from this writer.

According to SG90’s datasheet, it requires a 50 Hz frequency and a pulse width of 1–2 milliseconds.

Setting up the LED controller

First, we instantiate the controller by using the LEDC peripheral:

use esp_hal::ledc::{Ledc, LowSpeed};

let mut pwm = Ledc::new(peripherals.LEDC);

// Set the APB clock
pwm.set_global_slow_clock(esp_hal::ledc::LSGlobalClkSource::APBClk);

The instance must be mutable because we have to configure the clock later, after initialization. Since we are using the ESP32C3, we have only one option: to use the slow clock.

Setting up the Channel and the Timer

Initialization for the controller is only the first step for generating the PWM signal; there are thousands of steps later. So, if you only know how to use Servo.h to drive your servo, it’s the best time for you to not only understand how PWM works but also migrate your projects to Rust.

It’s time to eliminate nonsense. Instantiating channels also involves attaching the servo, which is analogous to the Arduino function “Servo.attach” in Servo.h.

You must specify the timer and channel as a LowSpeed one.

use esp_hal::ledc::channel::{ChannelHW, ChannelIFace, Number as ChannelNumber, config::{Config as ChannelConfig}};
use esp_hal::ledc::timer::{Number as TimerNumber, TimerIFace, config::{Config as TimerConfig}};

let mut pwm_timer = pwm.timer::<LowSpeed>(TimerNumber::Timer0);
let mut pwm_channel = pwm.channel::<LowSpeed>(ChannelNumber::Channel1, servo);

It’s totally up to your own preferences on which timer or channel you select. Similar to the LEDC instance, we need to configure them later, so we need to declare them as mutable. The SG90 operates at a 50 Hz frequency.

use esp_hal::ledc::timer::config::Duty;

pwm_timer.configure(TimerConfig {
duty: Duty::Duty14Bit,
clock_source: esp_hal::ledc::timer::LSClockSource::APBClk,
frequency: time::Rate::from_hz(50),
});

pwm_channel.configure(ChannelConfig {
timer: &pwm_timer,
duty_pct: 0, // Start with 0% duty cycle (off)
pin_config: esp_hal::ledc::channel::config::PinConfig::PushPull,
});

Their results are of type Result<T>, so you need to check them carefully before unwrapping.

Generating PWM Signals

After those configurations, we can eventually set up the PWM signal. The final step is to calculate the duty cycle.

Position “0” (1.5 ms pulse) is middle, “90” (~2 ms pulse) is middle, is all the way to the right, “-90” (~1 ms pulse) is all the way to the left.

In a 20-ms frame, the “0” is 1.5 / 20 * 100% = 7.5%. Because it only accepts u8 type (0–100), we may use “8” instead. The 90 and -90 are straightforward, representing 5% and 10%, respectively.

pwm_channel.set_duty(10).unwrap(); // -90
Delay::new().delay_millis(50u32);
pwm_channel.set_duty(8).unwrap(); // 0
Delay::new().delay_millis(500u32);
pwm_channel.set_duty(5).unwrap(); // 90
Delay::new().delay_millis(50u32);
pwm_channel.set_duty(0).unwrap(); // restore
Delay::new().delay_millis(500u32);

You may wonder if your servo runs cycles when booting. It’s not your fault; the servo is restoring to the default phase.

Conclusion

When you see your servo running and switching to your preferred angles, then congratulations! You’ve learned how to generate PWM signals on bare-metal Rust, and it’s time to port your code to Rust in no_std, which can deepen your understanding of embedded systems and embrace a better and safer language! After this, you can try:

  • Try controlling multiple servos using different channels.
  • Experiment with LED dimming using higher frequencies.
  • Explore the hardware fade capabilities of the LEDC peripheral.

--

--

Written by Ethan Wu

I am a high schooler, yet also a programmer. I contributed to projects such as Rolldown and Oxc, joined their teams, and also expanded research on LLMs.