Explaining the Concept of PWM (Pulse Width Modulation) in Embedded Systems

Explain the concept of PWM (Pulse Width Modulation) in embedded systems

PWM was one of those concepts that seemed almost too simple when I first read about it — just turning a pin on and off quickly — until I actually used it to dim an LED smoothly, control a motor’s speed, and generate an analog-like output from a purely digital pin. It’s a deceptively powerful technique, and it shows up constantly in embedded systems. In this article, I’ll explain exactly how PWM works, how to configure it, and where it’s used in real designs.

What Is PWM?

Pulse Width Modulation is a technique for encoding an analog-like signal using a purely digital output pin. Instead of varying the voltage continuously (which a GPIO pin fundamentally can’t do — it’s either high or low), PWM rapidly switches the pin between high and low at a fixed frequency, varying the proportion of time spent high versus low. The “average” effect, once filtered by something with inertia (a motor, an LED combined with the human eye’s persistence of vision, an RC low-pass filter), behaves very much like a variable analog voltage.

graph LR
    A[Digital GPIO Pin] -->|Switches HIGH/LOW rapidly| B[PWM Waveform]
    B --> C[Perceived as Analog Voltage - by motor/LED/filter]

Duty Cycle

The key parameter in PWM is the duty cycle — the percentage of each period the signal spends HIGH.

Duty Cycle (%) = (Time HIGH / Total Period) × 100
gantt
    dateFormat X
    axisFormat %L
    title PWM Waveforms at Different Duty Cycles (one period each, ms)
    section 25% Duty Cycle
    High :active, a1, 0, 25
    Low  :a2, 25, 100
    section 50% Duty Cycle
    High :active, b1, 0, 50
    Low  :b2, 50, 100
    section 75% Duty Cycle
    High :active, c1, 0, 75
    Low  :c2, 75, 100

PWM Frequency

The frequency determines how many complete on/off cycles happen per second. Choosing the right frequency depends heavily on the application:

How PWM Is Generated in Hardware

Microcontroller PWM is almost always generated by a hardware timer/counter peripheral, not bit-banged in software (though bit-banging is technically possible for very low frequencies). A timer counts up (or up/down) from 0 to some maximum value (the “ARR” — Auto-Reload Register, defining the period), and a compare register defines the point within that count at which the output pin toggles.

graph TD
    CLK[Timer Clock] --> CNT[Counter: 0 to ARR]
    CNT -->|CNT < CCR| HIGH[Output = HIGH]
    CNT -->|CNT >= CCR| LOW[Output = LOW]
    CNT -->|CNT = ARR, reset to 0| CNT
PWM Frequency = Timer Clock / (ARR + 1)
Duty Cycle (%) = (CCR / (ARR + 1)) × 100

Configuring PWM on an STM32 (HAL Example)

#include "stm32f4xx_hal.h"

TIM_HandleTypeDef htim3;

void PWM_Init(void) {
    TIM_OC_InitTypeDef sConfigOC = {0};

    htim3.Instance = TIM3;
    htim3.Init.Prescaler = 84 - 1;         // 84 MHz / 84 = 1 MHz timer clock
    htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim3.Init.Period = 1000 - 1;          // 1 MHz / 1000 = 1 kHz PWM frequency
    HAL_TIM_PWM_Init(&htim3);

    sConfigOC.OCMode = TIM_OCMODE_PWM1;
    sConfigOC.Pulse = 500;                 // 500/1000 = 50% duty cycle
    sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
    HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1);

    HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
}

void PWM_SetDutyCycle(uint8_t percent) {
    uint32_t pulse = (percent * 1000) / 100;
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse);
}

Configuring PWM on Arduino

const int ledPin = 9;   // Must be a PWM-capable pin (marked with ~)

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

void loop() {
    for (int duty = 0; duty <= 255; duty++) {
        analogWrite(ledPin, duty);   // 0-255 maps to 0-100% duty cycle
        delay(10);
    }
}

Arduino’s analogWrite() abstracts away the underlying timer configuration entirely, which is convenient for simple projects but hides frequency control — on most AVR-based Arduinos, the default PWM frequency is around 490Hz or 980Hz depending on the pin.

Configuring PWM on ESP32 (LEDC Peripheral)

#define PWM_PIN 18
#define PWM_CHANNEL 0
#define PWM_FREQ 5000
#define PWM_RESOLUTION 8    // 8-bit: 0-255

void setup() {
    ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
    ledcAttachPin(PWM_PIN, PWM_CHANNEL);
}

void loop() {
    for (int duty = 0; duty <= 255; duty++) {
        ledcWrite(PWM_CHANNEL, duty);
        delay(5);
    }
}

Motor Control with PWM

Controlling a DC motor’s speed via PWM typically involves a motor driver IC (like the L298N or DRV8833) between the microcontroller and motor, since GPIO pins can’t source enough current to drive a motor directly.

graph LR
    MCU[Microcontroller PWM Pin] --> Driver[Motor Driver IC]
    Driver -->|High Current| Motor[DC Motor]
    MCU -->|Direction Pins| Driver
#define MOTOR_PWM_PIN 5
#define MOTOR_DIR_PIN 6

void set_motor_speed(int speed_percent, bool forward) {
    digitalWrite(MOTOR_DIR_PIN, forward ? HIGH : LOW);
    int duty = map(speed_percent, 0, 100, 0, 255);
    analogWrite(MOTOR_PWM_PIN, duty);
}

Servo Control with PWM

Servos interpret pulse width directly, not duty cycle percentage, at a fixed 50Hz frequency:

#include <Servo.h>

Servo myServo;

void setup() {
    myServo.attach(9);   // Internally generates ~50Hz, 1-2ms pulses
}

void loop() {
    myServo.write(0);    // ~1ms pulse -> 0 degrees
    delay(1000);
    myServo.write(90);   // ~1.5ms pulse -> 90 degrees (center)
    delay(1000);
    myServo.write(180);  // ~2ms pulse -> 180 degrees
    delay(1000);
}

Generating an Analog Voltage from PWM (Filtering)

For applications genuinely needing an analog voltage output (not just driving something with natural inertia like a motor or LED), a simple RC low-pass filter on the PWM pin smooths the pulses into a roughly steady DC voltage proportional to the duty cycle:

graph LR
    PWM[PWM Output Pin] --> R[Resistor]
    R --> Node((Output Node))
    Node --> C[Capacitor to GND]
    Node --> Vout[Filtered ~ DC Voltage]
V_out ≈ Duty Cycle × V_high
Cutoff frequency f_c = 1 / (2π × R × C)   -- should be well below PWM frequency

PWM Resolution

Resolution here refers to how many distinct duty cycle steps are available, determined by the timer’s counting range. An 8-bit PWM (0–255) gives 256 possible brightness/speed levels; a 16-bit PWM gives 65,536 levels, offering much finer control — important in applications like precise LED dimming where coarse steps can be visually noticeable at low brightness.

Resolution (bits) = log2(ARR + 1)

There’s a direct trade-off between resolution and maximum achievable frequency for a given timer clock speed: more steps per period means a longer period (lower frequency) for the same underlying clock, unless you increase the timer’s input clock frequency.

Multi-Channel PWM

Most timer peripherals support several PWM channels simultaneously (e.g., STM32 timers commonly offer 4 channels each), useful for controlling multiple LEDs (like an RGB LED, needing 3 channels) or multiple motors from a single timer:

HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);  // Red
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);  // Green
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);  // Blue

void set_rgb(uint8_t r, uint8_t g, uint8_t b) {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, r * 4);  // scale 8-bit to 10-bit ARR
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, g * 4);
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, b * 4);
}

Real-World Applications

IoT/Practical Example: Smart Fan Speed Controller

#define FAN_PWM_PIN 25
#define TEMP_SENSOR_PIN 34

void setup() {
    ledcSetup(0, 25000, 8);      // 25kHz - above audible range for fans
    ledcAttachPin(FAN_PWM_PIN, 0);
}

void loop() {
    int raw = analogRead(TEMP_SENSOR_PIN);
    float tempC = convert_to_celsius(raw);

    int duty = map((int)tempC, 20, 60, 50, 255);  // Ramp fan speed with temp
    duty = constrain(duty, 50, 255);
    ledcWrite(0, duty);

    delay(2000);
}

Reliability and Practical Considerations

Frequently Asked Questions

Is PWM the same as a true analog output (DAC)? No — PWM is still fundamentally a digital signal switching between two levels; it only approximates an analog effect once averaged by something with inertia (a motor, LED+eye, or an RC filter). A true DAC (Digital-to-Analog Converter) produces an actual continuously variable voltage directly.

Why does my LED flicker at low PWM frequencies? Human persistence of vision generally can’t detect flicker above roughly 100Hz–200Hz for most people, though some can perceive flicker up to higher rates, especially in peripheral vision. Using a PWM frequency of 500Hz–1kHz or higher for LED dimming avoids this entirely.

Can I generate PWM without a hardware timer? Yes, by manually toggling a GPIO pin with precise software delays (“bit-banging”), but this consumes CPU time continuously and produces frequency/duty cycle inaccuracy from interrupt jitter — hardware timer-based PWM is almost always preferred when available.

What duty cycle is “off” and what’s “full on”? 0% duty cycle means the pin stays low the entire period (effectively off for most loads); 100% duty cycle means the pin stays high the entire period (effectively fully on) — though for active-low configurations, this can be inverted.

Summary

PWM is a simple but remarkably versatile technique: by rapidly switching a digital pin and varying the proportion of time it spends high (duty cycle), you can approximate analog control over brightness, motor speed, servo position, and even voltage output after filtering. It’s generated efficiently in hardware using timer peripherals with auto-reload and compare registers, giving precise, low-CPU-overhead control that scales from simple LED dimming to sophisticated motor control and power regulation. Once you understand the relationship between timer clock, ARR (period), and CCR (duty cycle), PWM becomes one of the most flexible tools in the embedded toolbox.

References and Further Reading

Exit mobile version