Part
2
  |  
The Hardware Layer
  |  
Chapter
8

PWM and Analog Control

Digital pins can do more than fully on and fully off — if you switch them fast enough, physics gives you a free analog dial.
Reading Time
11
mins
BACK TO RASPBERRY PI MASTERCLASS

The trap is believing that digital pins are limited to two states. HIGH or LOW. On or off. 3.3 volts or zero. Engineers who think this way see the Pi as a glorified switch — good for turning things on and off, but useless for anything that needs gradual control. Dim an LED? Control a servo's angle? Adjust a motor's speed? Impossible with a pin that only knows two values.

Except it's not impossible. It's just fast switching. If you toggle a pin between HIGH and LOW fast enough — thousands of times per second — the device on the other end experiences the average voltage, not the individual pulses. An LED blinking a thousand times per second doesn't look like it's blinking. It looks dim. And you control how dim by controlling what percentage of each cycle the pin spends HIGH versus LOW. That percentage is called the duty cycle, and the technique is called Pulse Width Modulation — PWM.

PWM isn't magic. It's just switching on and off so fast that the average voltage looks analog to anything too slow to see the individual pulses.

The Duty Cycle Dial

Framework · The Duty Cycle Dial

PWM is a single knob that controls apparent voltage by varying the ratio of ON time to OFF time within each cycle. A 50% duty cycle at 1kHz means the pin is HIGH for 0.5ms and LOW for 0.5ms — 500 times per second. The load (LED, motor, servo) sees an average of 1.65V, not the individual pulses.

The duty cycle is a percentage from 0% to 100%:

  • 0% duty cycle — the pin is always LOW. Zero volts. Nothing happens.
  • 25% duty cycle — the pin is HIGH for one quarter of each cycle. The effective voltage is about 0.83V (25% of 3.3V).
  • 50% duty cycle — HIGH half the time. Effective voltage: 1.65V. An LED is noticeably dimmer than full brightness.
  • 75% duty cycle — HIGH three quarters of the time. Effective voltage: 2.48V.
  • 100% duty cycle — always HIGH. Full 3.3V. Same as calling led.on().

The second parameter that matters is frequency — how many complete on-off cycles happen per second, measured in Hertz (Hz). For LED dimming, anything above about 100Hz looks flicker-free to human eyes. For motor speed control, 1kHz-25kHz is typical. For servo motors, exactly 50Hz is required — the servo protocol expects it.

Why frequency matters for motors

At very low PWM frequencies (below 1kHz), a DC motor doesn't spin smoothly — it pulses, vibrates, and makes an audible whine. This is because the motor's inertia can't bridge the gaps between pulses. Increasing the frequency smooths the rotation. But go too high (above 25kHz) and the motor driver's transistors can't switch fast enough, generating heat. 1kHz to 10kHz is the sweet spot for most small DC motors.

Software PWM vs Hardware PWM

The Pi can generate PWM two ways:

Software PWM — any GPIO pin, controlled by toggling the pin in software with precise timing. The Python process handles the switching. This is what gpiozero's PWMLED class uses by default. It works, but the timing is at the mercy of Linux process scheduling. Under CPU load, the duty cycle jitters, and LEDs can visibly flicker.

Hardware PWM — the BCM chip has dedicated PWM hardware on four specific pins: GPIO 12, 13, 18, and 19. The hardware generates the signal independently of the CPU. No jitter, no dependency on process scheduling, perfectly stable duty cycles even at 100% CPU load. The catch: there are only two independent PWM channels (channel 0 on GPIO 12/18, channel 1 on GPIO 13/19), so pins sharing a channel always have the same frequency.

For LED dimming, software PWM is fine — the human eye won't notice microsecond jitter. For servo motors, hardware PWM is strongly recommended. Servos interpret timing with millisecond precision, and software jitter causes the servo to twitch.

Key takeaway

Software PWM works for LEDs and non-precision loads. Hardware PWM — available only on GPIO 12, 13, 18, and 19 — is required for servos and any application where timing jitter translates to physical vibration or noise.

LED Dimming with gpiozero

The PWMLED class wraps PWM output in the same Pythonic interface as LED:

from gpiozero import PWMLED
from time import sleep

led = PWMLED(17)

# Set brightness directly (0.0 = off, 1.0 = full)
led.value = 0.5
print("50% brightness")
sleep(2)

led.value = 0.1
print("10% brightness — barely visible")
sleep(2)

led.value = 1.0
print("Full brightness")
sleep(2)

led.off()

The value property accepts a float between 0.0 and 1.0. Internally, it translates to a duty cycle percentage. Setting led.value = 0.5 means the pin is HIGH 50% of the time.

PWMLED also has a pulse() method — the LED gradually brightens and dims in a continuous loop:

from gpiozero import PWMLED
from signal import pause

led = PWMLED(17)
led.pulse(fade_in_time=1, fade_out_time=1)

print("Pulsing. Ctrl+C to exit.")
pause()

This runs in a background thread, like blink() on a regular LED. The effect is a smooth breathing animation — the kind you see on a sleeping MacBook's power indicator.

Fine Control with RPi.GPIO

gpiozero abstracts away the frequency setting. When you need to control both duty cycle and frequency — motor speed control, buzzer tones, custom signal generation — drop to RPi.GPIO:

import RPi.GPIO as GPIO
from time import sleep

GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.OUT)

# Create PWM instance on GPIO 18 at 1000 Hz
pwm = GPIO.PWM(18, 1000)

# Start with 0% duty cycle (off)
pwm.start(0)

try:
    # Gradually increase brightness from 0% to 100%
    for duty in range(0, 101, 5):
        pwm.ChangeDutyCycle(duty)
        print(f"Duty cycle: {duty}%")
        sleep(0.1)

    sleep(1)

    # Gradually decrease brightness from 100% to 0%
    for duty in range(100, -1, -5):
        pwm.ChangeDutyCycle(duty)
        print(f"Duty cycle: {duty}%")
        sleep(0.1)

finally:
    pwm.stop()
    GPIO.cleanup()

Key differences from gpiozero:

  • You must call GPIO.setmode(GPIO.BCM) before anything else.
  • You must call GPIO.setup(pin, GPIO.OUT) to configure the pin.
  • GPIO.PWM(pin, frequency) creates the PWM object with an explicit frequency.
  • pwm.start(duty_cycle) begins PWM output.
  • pwm.ChangeDutyCycle(duty) accepts a value from 0 to 100 (not 0.0 to 1.0).
  • pwm.ChangeFrequency(hz) lets you change the frequency on the fly — useful for generating tones on a piezo buzzer.
  • You must call pwm.stop() and GPIO.cleanup() explicitly, or you'll leave the pin in an undefined state.
✕ gpiozero PWMLED
  • One-line setup
  • Value range 0.0-1.0
  • Auto cleanup on exit
  • Fixed default frequency
  • Perfect for LED projects
✓ RPi.GPIO PWM
  • Manual setup and cleanup
  • Value range 0-100
  • Must call stop() and cleanup()
  • Custom frequency control
  • Required for motors and servos

gpiozero is the right default for 90% of PWM tasks. Drop to RPi.GPIO when you need to control frequency, not just duty cycle.

Perceived Brightness vs Duty Cycle

There's a subtlety that catches people building lighting products. The relationship between duty cycle and actual brightness is linear — 50% duty cycle delivers 50% of the average voltage. But the relationship between duty cycle and perceived brightness is not linear, because human vision follows a logarithmic response curve. The jump from 0% to 10% looks dramatic. The jump from 80% to 90% is barely noticeable.

If you're building a lamp, a status display, or any product where a human judges the brightness, you need gamma correction — a mapping function that converts a linear brightness scale (0-100%, as the user would set it on a slider) to a non-linear duty cycle that produces perceptually even brightness steps:

from gpiozero import PWMLED
from time import sleep
import math

led = PWMLED(17)

GAMMA = 2.2  # Standard gamma correction factor

def perceived_brightness(level):
    """Convert linear brightness (0.0-1.0) to gamma-corrected duty cycle."""
    return math.pow(level, GAMMA)

# Linear ramp — perceived brightness is uneven
print("Linear ramp (looks uneven):")
for i in range(11):
    level = i / 10
    led.value = level
    print(f"  Level {level:.1f} -> duty {level:.2f}")
    sleep(0.5)

sleep(1)

# Gamma-corrected ramp — perceived brightness is even
print("Gamma-corrected ramp (looks smooth):")
for i in range(11):
    level = i / 10
    corrected = perceived_brightness(level)
    led.value = corrected
    print(f"  Level {level:.1f} -> duty {corrected:.2f}")
    sleep(0.5)

led.off()

Run both ramps back-to-back and the difference is obvious. The linear ramp appears to jump quickly to full brightness and then barely change. The gamma-corrected ramp has visually even steps from dim to bright. This is the same correction your monitor applies to its backlight — and it's the reason professional LED dimmers always include a gamma curve setting.

The Duty Cycle Sweep

Here's a complete script that demonstrates the full range of PWM by sweeping an LED from completely off to full brightness and back, printing the effective voltage at each step:

import RPi.GPIO as GPIO
from time import sleep

PWM_PIN = 18
FREQUENCY = 1000  # 1kHz — well above flicker threshold
SUPPLY_VOLTAGE = 3.3

GPIO.setmode(GPIO.BCM)
GPIO.setup(PWM_PIN, GPIO.OUT)

pwm = GPIO.PWM(PWM_PIN, FREQUENCY)
pwm.start(0)

try:
    print("Sweeping duty cycle 0% -> 100%")
    for duty in range(0, 101, 2):
        pwm.ChangeDutyCycle(duty)
        effective_v = SUPPLY_VOLTAGE * (duty / 100)
        print(f"  Duty: {duty:3d}%  |  Effective voltage: {effective_v:.2f}V")
        sleep(0.05)

    print("\nSweeping duty cycle 100% -> 0%")
    for duty in range(100, -1, -2):
        pwm.ChangeDutyCycle(duty)
        effective_v = SUPPLY_VOLTAGE * (duty / 100)
        print(f"  Duty: {duty:3d}%  |  Effective voltage: {effective_v:.2f}V")
        sleep(0.05)

    print("\nSweep complete.")

finally:
    pwm.stop()
    GPIO.cleanup()
    print("Cleaned up.")

Run this with an LED on GPIO 18 and watch the brightness ramp smoothly up and down. The printed voltage values show the relationship between duty cycle and effective voltage — it's linear, which is why PWM is such a reliable control mechanism.

Servo Motor Basics

A servo motor is the simplest actuator that converts a PWM signal into a physical angle. Standard hobby servos rotate between 0 and 180 degrees, and the angle is controlled by the width of the HIGH pulse within a fixed 50Hz (20ms) cycle:

  • 1ms pulse (5% duty cycle) = 0 degrees
  • 1.5ms pulse (7.5% duty cycle) = 90 degrees (center)
  • 2ms pulse (10% duty cycle) = 180 degrees

gpiozero wraps this with the Servo class:

from gpiozero import Servo
from time import sleep

# Use GPIO 18 (hardware PWM pin) for stable servo control
servo = Servo(18)

servo.min()     # 0 degrees
print("Servo at minimum position")
sleep(1)

servo.mid()     # 90 degrees
print("Servo at center")
sleep(1)

servo.max()     # 180 degrees
print("Servo at maximum position")
sleep(1)

servo.value = 0.0   # Center (range is -1.0 to 1.0)
print("Back to center")
sleep(1)

servo.detach()
print("Servo detached — no longer holding position")
Servo jitter

If your servo twitches or vibrates at rest, you're seeing software PWM jitter. Switch to a hardware PWM pin (12, 13, 18, or 19) and use gpiozero's Servo with a pigpio-based pin factory for hardware PWM: install pigpio, start the daemon with sudo pigpiod, then run your script with GPIOZERO_PIN_FACTORY=pigpio python3 servo.py. The hardware PWM eliminates jitter completely.

The servo protocol is rigid. Send a frequency other than 50Hz and the servo ignores the signal or behaves erratically. Send a pulse shorter than 0.5ms or longer than 2.5ms and some servos slam into their mechanical stops, stripping the gears. Always start with mid() to center the servo before moving to extremes.

PWM for Audio: The Piezo Buzzer

A piezo buzzer is a thin disc of ceramic material that vibrates when voltage is applied. Drive it with PWM and the vibration produces sound. The frequency of the PWM signal determines the pitch; the duty cycle affects volume (50% is loudest for a square wave).

This is the simplest way to add audio feedback to a Pi project — boot confirmation beeps, error alerts, button-press acknowledgment:

import RPi.GPIO as GPIO
from time import sleep

BUZZER_PIN = 18

GPIO.setmode(GPIO.BCM)
GPIO.setup(BUZZER_PIN, GPIO.OUT)

def beep(frequency, duration, duty=50):
    """Play a tone at the given frequency for the given duration."""
    pwm = GPIO.PWM(BUZZER_PIN, frequency)
    pwm.start(duty)
    sleep(duration)
    pwm.stop()

try:
    # Success melody: three ascending tones
    beep(523, 0.15)   # C5
    sleep(0.05)
    beep(659, 0.15)   # E5
    sleep(0.05)
    beep(784, 0.3)    # G5

    sleep(0.5)

    # Error tone: two descending tones
    beep(440, 0.3)    # A4
    sleep(0.1)
    beep(330, 0.5)    # E4

finally:
    GPIO.cleanup()

The ChangeFrequency() method on a running PWM object lets you play melodies without stopping and restarting — useful for smoother sound. But for alert beeps and feedback tones, the start/stop approach above is clean enough.

Key takeaway

PWM is the bridge between digital and analog. Master the duty cycle and you can dim LEDs, control motor speeds, position servos, and generate audio tones — all from pins that only know HIGH and LOW.

What to Do Monday Morning

Dim an LED with PWMLED

Wire an LED to GPIO 17. Create a PWMLED object and set its value to 0.1, 0.3, 0.5, 0.7, and 1.0, pausing between each. Observe the brightness difference. Notice how the relationship between duty cycle and perceived brightness isn't perfectly linear — human vision is logarithmic, so the jump from 0.1 to 0.3 looks bigger than the jump from 0.7 to 1.0.

Run the duty cycle sweep

Move your LED to GPIO 18 and run the RPi.GPIO sweep script. Watch the LED brighten and dim smoothly. Try changing the frequency from 1000Hz to 10Hz — the LED will visibly flicker instead of dimming smoothly, because your eye can now see the individual pulses. This makes the PWM mechanism viscerally obvious.

Generate a tone with a buzzer

Replace the LED with a passive piezo buzzer (not an active buzzer — those have a built-in oscillator). Set the PWM frequency to 440Hz (concert A pitch) at 50% duty cycle. You'll hear a clear tone. Sweep the frequency from 200Hz to 2000Hz to hear the pitch change. This is how simple electronic instruments and alert sounds work.

Control a servo

Connect a hobby servo to GPIO 18 with an external 5V power supply (never power a servo from the Pi's GPIO — the current draw will brownout the board). Cycle through min(), mid(), and max(). Then write a loop that sweeps the servo from -1.0 to 1.0 in small increments for smooth rotation. If the servo jitters, switch to the pigpio pin factory.

Build a brightness controller

Combine a button (Chapter 7) and a PWMLED. Each button press increases the brightness by 20% (0.0, 0.2, 0.4, 0.6, 0.8, 1.0), then wraps back to 0.0. This is a real product feature — the kind of thing that ships in desk lamps and LED strips — built from two components and fifteen lines of Python.

PWM is the bridge between digital and analog — master the duty cycle and you can dim lights, spin motors, position servos, and generate sound from pins that only know HIGH and LOW.