PWM Explained: Pulse Width Modulation Fundamentals with Practical MCU Examples

Pulse Width Modulation (PWM) is one of the most versatile techniques in embedded systems, allowing you to control analog-like behavior using purely digital signals. From dimming LEDs to controlling motor speed, PWM is the go-to solution for power-efficient analog control.

This guide explains how PWM works, the relationship between frequency and duty cycle, how to configure it on real MCUs, and practical applications you’ll encounter in production designs.


What is PWM and How Does It Work?

PWM (Pulse Width Modulation) is a digital technique that simulates an analog output by rapidly switching a signal between HIGH and LOW states. By varying the ratio of ON time to total period time, you control the average voltage delivered to a load.

Key PWM Concepts:

Period (T): Total time for one complete cycle (HIGH + LOW time)
Frequency (f): Number of cycles per second, f = 1/T
Duty Cycle (D): Percentage of time the signal is HIGH, D = (T_on / T) × 100%
Average Voltage: V_avg = V_high × (Duty Cycle / 100)

Duty Cycle = 25%         Duty Cycle = 50%         Duty Cycle = 75%
  ___                      _____                    _______
 |   |_________          |     |_____            |       |___
  ON  OFF                  ON   OFF                 ON    OFF
 ←─────T─────→           ←─────T─────→           ←─────T─────→

Why PWM Works:

Due to inertia (mechanical, thermal, or electrical), most systems respond to the average power delivered over time, not individual pulses. An LED at 50% duty cycle appears half as bright. A motor at 70% duty cycle runs at roughly 70% speed.


PWM Frequency vs Duty Cycle: When Each Matters

Frequency Selection

The PWM frequency determines how “smooth” the analog effect appears:

LED Dimming: 100 Hz - 20 kHz

  • Below 100 Hz: Visible flicker
  • 500 Hz - 5 kHz: Optimal for most applications
  • Above 20 kHz: Ultrasonic, reduces EMI but may cause switching losses

Motor Control (DC): 1 kHz - 40 kHz

  • 16 kHz - 20 kHz: Common for silent operation (above human hearing)
  • Higher frequencies reduce torque ripple but increase switching losses

Servo Control: 50 Hz (fixed)

  • Standard hobby servos expect 50 Hz with 1-2 ms pulse width

Audio/DAC Simulation: 31.25 kHz - 100 kHz

  • Must be well above audio range (>20 kHz) with low-pass filtering

Switching Power Supplies: 50 kHz - 500 kHz

  • Higher frequencies allow smaller inductors/capacitors but increase losses

Duty Cycle Control

The duty cycle directly controls output power:

  • 0%: Fully OFF (no power delivered)
  • 25%: 25% average power
  • 50%: 50% average power (half brightness, half speed)
  • 75%: 75% average power
  • 100%: Fully ON (continuous HIGH)

Practical MCU PWM Configuration

Most MCUs generate PWM using hardware timers with compare registers. Here’s how to configure PWM on common platforms:

STM32 Timer PWM Example (HAL)

Generate 1 kHz PWM at 50% duty cycle on TIM2 Channel 1 (PA0):

#include "stm32f4xx_hal.h"

TIM_HandleTypeDef htim2;

void PWM_Init(void) {
    __HAL_RCC_TIM2_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    // Configure PA0 as TIM2_CH1 (PWM output)
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF1_TIM2;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // Timer configuration: 1 kHz PWM
    // APB1 Timer clock = 84 MHz (assuming STM32F4 at 168 MHz)
    // Prescaler = 84 - 1 = 83 → Timer clock = 1 MHz
    // ARR = 1000 - 1 = 999 → PWM frequency = 1 MHz / 1000 = 1 kHz
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 83;
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.Period = 999;  // ARR value
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
    HAL_TIM_PWM_Init(&htim2);
    
    // PWM channel configuration
    TIM_OC_InitTypeDef sConfigOC = {0};
    sConfigOC.OCMode = TIM_OCMODE_PWM1;
    sConfigOC.Pulse = 500;  // 50% duty cycle (CCR value)
    sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
    sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
    HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
    
    // Start PWM generation
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}

// Change duty cycle at runtime (0-100%)
void PWM_SetDutyCycle(uint8_t duty_percent) {
    if (duty_percent > 100) duty_percent = 100;
    uint32_t pulse = (htim2.Init.Period + 1) * duty_percent / 100;
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse);
}

AVR Arduino Example

Simple PWM on Arduino using analogWrite (490 Hz default):

#define LED_PIN 9  // PWM-capable pin

void setup() {
    pinMode(LED_PIN, OUTPUT);
}

void loop() {
    // Fade in
    for (int brightness = 0; brightness <= 255; brightness++) {
        analogWrite(LED_PIN, brightness);  // 0-255 = 0-100% duty
        delay(10);
    }
    
    // Fade out
    for (int brightness = 255; brightness >= 0; brightness--) {
        analogWrite(LED_PIN, brightness);
        delay(10);
    }
}

ESP32 LEDC PWM Example

ESP32 uses LEDC (LED Control) peripheral for PWM:

#include "driver/ledc.h"

#define PWM_PIN GPIO_NUM_5
#define PWM_FREQ 5000  // 5 kHz
#define PWM_RESOLUTION LEDC_TIMER_10_BIT  // 10-bit = 0-1023

void pwm_init(void) {
    // Timer configuration
    ledc_timer_config_t ledc_timer = {
        .speed_mode = LEDC_HIGH_SPEED_MODE,
        .timer_num = LEDC_TIMER_0,
        .duty_resolution = PWM_RESOLUTION,
        .freq_hz = PWM_FREQ,
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_timer_config(&ledc_timer);
    
    // Channel configuration
    ledc_channel_config_t ledc_channel = {
        .gpio_num = PWM_PIN,
        .speed_mode = LEDC_HIGH_SPEED_MODE,
        .channel = LEDC_CHANNEL_0,
        .timer_sel = LEDC_TIMER_0,
        .duty = 512,  // 50% duty (0-1023 range)
        .hpoint = 0
    };
    ledc_channel_config(&ledc_channel);
}

// Set duty cycle (0-1023 for 10-bit resolution)
void pwm_set_duty(uint32_t duty) {
    ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL_0, duty);
    ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL_0);
}

Real-World PWM Applications

1. LED Dimming and Brightness Control

Use Case: Dashboard displays, backlighting, indicator LEDs
Frequency: 500 Hz - 5 kHz (avoid visible flicker)
Implementation:

// Exponential brightness curve (perceived linearity)
const uint16_t gamma_table[256] = {
    0, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 6, 7, 8,
    // ... gamma correction values (generated with: pow(i/255.0, 2.2) * 1023)
};

void set_led_brightness(uint8_t level) {
    uint16_t pwm_value = gamma_table[level];
    PWM_SetDutyCycle(pwm_value * 100 / 1023);
}

2. DC Motor Speed Control

Use Case: Fans, pumps, robotic actuators
Frequency: 16 kHz - 25 kHz (silent operation)
Implementation:

// H-Bridge motor control with direction
void motor_drive(int8_t speed) {  // -100 to +100
    if (speed > 0) {
        // Forward
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);   // IN1 = 1
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); // IN2 = 0
        PWM_SetDutyCycle(speed);
    } else if (speed < 0) {
        // Reverse
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // IN1 = 0
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET);   // IN2 = 1
        PWM_SetDutyCycle(-speed);
    } else {
        // Brake
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET);
        PWM_SetDutyCycle(0);
    }
}

3. Servo Motor Control (Position)

Use Case: RC servos, robotic arms, camera gimbals
Frequency: 50 Hz (20 ms period, fixed standard)
Pulse Width: 1-2 ms (1 ms = 0°, 1.5 ms = 90°, 2 ms = 180°)

// Standard servo: 50 Hz, 1-2 ms pulse
void servo_init(void) {
    // 50 Hz = 20 ms period
    // With 1 MHz timer clock: ARR = 20000 - 1
    htim2.Init.Period = 19999;
    // ... (configure timer as shown earlier)
}

void servo_set_angle(uint8_t angle) {
    if (angle > 180) angle = 180;
    // Map 0-180° to 1000-2000 µs (1-2 ms)
    uint32_t pulse = 1000 + (angle * 1000 / 180);
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse);
}

4. Heater/Temperature Control

Use Case: 3D printer hotends, soldering irons, HVAC
Frequency: 1-10 Hz (thermal inertia is very slow)
Implementation:

// PID-controlled PWM heating
float pid_compute(float setpoint, float current_temp) {
    static float integral = 0, prev_error = 0;
    float error = setpoint - current_temp;
    integral += error;
    float derivative = error - prev_error;
    prev_error = error;
    
    float output = KP * error + KI * integral + KD * derivative;
    return constrain(output, 0, 100);  // 0-100% duty cycle
}

void heater_task(void) {
    float temp = read_temperature();
    float duty = pid_compute(TARGET_TEMP, temp);
    PWM_SetDutyCycle((uint8_t)duty);
}

5. DAC Simulation (Audio/Analog Output)

Use Case: Simple audio generation, analog voltage output
Frequency: 31.25 kHz - 100 kHz with low-pass filter
Implementation:

// Generate analog voltage: 0-3.3V output
// PWM frequency: 50 kHz, RC filter: R=1kΩ, C=100nF (fc ≈ 1.6 kHz)
void dac_set_voltage(float voltage) {
    if (voltage > 3.3f) voltage = 3.3f;
    if (voltage < 0) voltage = 0;
    
    uint8_t duty = (uint8_t)(voltage / 3.3f * 100.0f);
    PWM_SetDutyCycle(duty);
}

PWM Frequency Calculation Formula

For timer-based PWM on most MCUs:

PWM Frequency = Timer Clock / ((Prescaler + 1) × (ARR + 1))

Duty Cycle = (CCR / (ARR + 1)) × 100%

Example: STM32F4 with 84 MHz APB1 clock, 20 kHz PWM:

Desired: 20 kHz PWM
Timer Clock: 84 MHz

Option 1: Prescaler = 0, ARR = 4199
20000 = 84000000 / (1 × 4200)
Resolution: 4200 steps (12-bit equivalent)

Option 2: Prescaler = 3, ARR = 1049
20000 = 84000000 / (4 × 1050)
Resolution: 1050 steps (10-bit equivalent)

Trade-off: Higher ARR = better resolution, but limited by timer width (16-bit max = 65535).


Common PWM Pitfalls and Best Practices

❌ Mistake 1: Wrong Frequency for Application

// Too slow for LED (visible flicker)
PWM_Init(50);  // 50 Hz LED = flickering

// Too fast for motor (excessive switching losses)
PWM_Init(200000);  // 200 kHz = hot MOSFETs

✓ Fix: Match frequency to application requirements and component limitations.

❌ Mistake 2: Ignoring Resolution

// Only 100 steps (ARR = 99) for smooth motor control
htim2.Init.Period = 99;  // Insufficient resolution

✓ Fix: Aim for at least 8-bit (256 steps) resolution for smooth control, 10-bit (1024) for high-precision applications.

❌ Mistake 3: No Low-Pass Filter for Analog Simulation

When using PWM as a DAC, you must add an RC low-pass filter:

MCU PWM Output ──[R=1kΩ]──┬──→ Analog Output
                           │
                          [C] 100nF
                           │
                          GND

fc = 1 / (2π × R × C) ≈ 1.6 kHz

❌ Mistake 4: Not Considering Load Inductive Kickback

Motors and solenoids generate voltage spikes when PWM switches OFF:

// Add flyback diode across inductive loads
//        +Vcc
//         │
//     ┌───┴───┐
//     │ Motor │
//     └───┬───┘
//         │
//    ┌────┴────┐
//    │ MOSFET  │ ←── PWM
//    └────┬────┘
//         │
//        GND
//
// Missing: Flyback diode (Schottky) from motor- to motor+

✓ Fix: Always add flyback/freewheeling diodes across inductive loads.

✓ Best Practice: Use Hardware PWM, Not Software

// ❌ BAD: Software PWM (blocking, inaccurate)
while(1) {
    GPIO_SET(pin);
    delay_us(duty);
    GPIO_CLEAR(pin);
    delay_us(1000 - duty);
}

// ✓ GOOD: Hardware timer PWM (non-blocking, precise)
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

Summary

ParameterEffectTypical Range
FrequencySmoothness, EMI, losses100 Hz - 100 kHz (application-dependent)
Duty CycleOutput power/brightness0% (OFF) to 100% (ON)
ResolutionControl granularity8-bit (256) to 16-bit (65536)

Key Takeaways:

  • PWM simulates analog control using digital signals through rapid switching
  • Frequency affects smoothness; too low causes flicker/ripple, too high causes losses
  • Duty cycle directly controls average output power
  • Most MCUs use hardware timers for accurate, non-blocking PWM generation
  • Match PWM parameters to your application: LED dimming ≠ motor control ≠ servo control
  • Always add proper external components: flyback diodes for motors, RC filters for DACs

PWM is fundamental to embedded systems—master it, and you’ll have a versatile tool for countless applications.