Raspberry Pi 4 PWM: Jitter-Free Servo and DC Motor Speed Control

Pulse-width modulation (PWM) switches a pin on and off rapidly. By changing how long the pin stays high in each cycle, you control the average power delivered to a motor, or send a position command to a servo. The Raspberry Pi 4 can generate PWM in two ways: in software, where Linux toggles a pin on a timer, and in hardware, where a dedicated PWM peripheral inside the BCM2711 produces the waveform with no CPU involvement.

This tutorial shows when each method is good enough. You will drive an SG90 servo from the hardware PWM peripheral for a steady, jitter-free position, and control the speed and direction of a DC motor through an L298N driver using gpiozero's software PWM.

PWM Concepts You Need

A PWM signal has a period (or frequency) and a duty cycle. Hobby servos expect a 50 Hz signal, a 20 ms period, and read the width of the high pulse: roughly 1.0 ms for one end, 1.5 ms for centre and 2.0 ms for the other end. Many SG90 clones accept about 0.5 ms to 2.5 ms for their full travel, so always find the limits of your servo gently.

Servo:  period = 1 / 50 Hz = 20 ms
        pulse 1.5 ms  -> duty = 1.5 / 20 = 7.5 %  (centre)
Motor:  average voltage = supply x duty
        12 V supply at 40 % duty -> about 4.8 V average

Software PWM is fine for motors and LEDs because a small timing wobble only changes the speed or brightness slightly. A servo, however, reacts to every microsecond of pulse-width error, so software PWM makes it twitch. That is why the servo uses the hardware peripheral.

Hardware PWM channelUsable GPIO pinsPhysical pins
PWM0GPIO12 or GPIO1832 or 12
PWM1GPIO13 or GPIO1933 or 35

Hardware and Wiring

PartConnectionNotes
SG90 servo signal (orange)GPIO18, physical pin 123.3 V pulses are accepted by most servos
SG90 power (red)External 5 V supplyNot the Pi's 5 V pin under load
SG90 ground (brown)Supply GND and Pi GNDGrounds must be common
L298N ENAGPIO13, physical pin 33Speed (PWM) input; remove the ENA jumper
L298N IN1 / IN2GPIO5 / GPIO6Direction inputs
L298N 12V and GNDMotor battery, 7 to 12 VConnect L298N GND to Pi GND

A servo stalling or starting can pull several hundred milliamps in spikes. Feeding that from the Pi's 5 V header pins causes brown-outs, so use a separate 5 V supply and join only the grounds.

Enabling Hardware PWM and Driving the Servo

Add this line to /boot/firmware/config.txt and reboot. It enables both channels, PWM0 on GPIO18 and PWM1 on GPIO19 by default; here only channel 0 is used for the servo.

dtoverlay=pwm-2chan

After the reboot, ls /sys/class/pwm should show pwmchip0. The kernel exposes each channel through simple files, which the following class writes to. Times are in nanoseconds.

servo_hwpwm.py - jitter-free servo control through the sysfs PWM interface
#!/usr/bin/env python3
# servo_hwpwm.py - drive an SG90 on GPIO18 using hardware PWM (pwmchip0, channel 0)
# Requires dtoverlay=pwm-2chan in /boot/firmware/config.txt
import os
import time

CHIP = '/sys/class/pwm/pwmchip0'
PERIOD_NS = 20_000_000          # 20 ms = 50 Hz
MIN_PULSE_NS = 500_000          # 0.5 ms - adjust for your servo
MAX_PULSE_NS = 2_500_000        # 2.5 ms - adjust for your servo

class HardwareServo:
    def __init__(self, channel=0):
        self.path = f'{CHIP}/pwm{channel}'
        # Export the channel if it is not already visible
        if not os.path.isdir(self.path):
            self._write(f'{CHIP}/export', channel)
            time.sleep(0.2)      # give udev time to set permissions
        # duty_cycle must never exceed period, so clear it first
        self._write(f'{self.path}/duty_cycle', 0)
        self._write(f'{self.path}/period', PERIOD_NS)
        self._write(f'{self.path}/enable', 1)

    @staticmethod
    def _write(path, value):
        with open(path, 'w') as f:
            f.write(str(value))

    def angle(self, degrees):
        """Move to an angle between 0 and 180 degrees."""
        degrees = max(0, min(180, degrees))
        pulse = MIN_PULSE_NS + (MAX_PULSE_NS - MIN_PULSE_NS) * degrees / 180
        self._write(f'{self.path}/duty_cycle', int(pulse))

    def close(self):
        self._write(f'{self.path}/enable', 0)

if __name__ == '__main__':
    servo = HardwareServo(0)
    try:
        # Sweep 0 -> 180 -> 0 in 10 degree steps
        for a in list(range(0, 181, 10)) + list(range(180, -1, -10)):
            servo.angle(a)
            print(f'angle {a:3d}')
            time.sleep(0.15)
        servo.angle(90)          # park at centre
        time.sleep(0.5)
    finally:
        servo.close()

If you get a permission error, run the script with sudo once to confirm the wiring, then add your user to the gpio group, which on Raspberry Pi OS is normally given access to the PWM files.

DC Motor Speed and Direction with gpiozero

For the motor, gpiozero's Motor class takes the two direction pins plus an enable pin, and applies PWM to the enable pin. Values run from 0 (stopped) to 1 (full speed).

motor_speed.py - ramp a DC motor through an L298N
#!/usr/bin/env python3
# motor_speed.py - L298N: IN1=GPIO5, IN2=GPIO6, ENA=GPIO13 (jumper removed)
from gpiozero import Motor
from time import sleep

# pwm=True (default) applies PWM on the enable pin for speed control
motor = Motor(forward=5, backward=6, enable=13, pwm=True)

try:
    # Ramp up forwards in 10 % steps
    for step in range(0, 11):
        motor.forward(step / 10)
        print(f'forward {step * 10}%')
        sleep(0.5)
    motor.stop()
    sleep(1)                     # let the motor stop before reversing

    # Run backwards at 60 % for 3 seconds
    motor.backward(0.6)
    sleep(3)
finally:
    motor.stop()
    motor.close()

Most small motors will not start below a certain duty cycle because of friction; you will hear a hum but see no movement. Note that minimum value and treat it as the motor's real zero in your project.

Troubleshooting

  • No pwmchip0: the overlay line is missing or misspelled, or was put under a section header like [cm4] that does not apply to a Pi 4. Place it under [all].
  • Invalid argument when writing period: the duty cycle is larger than the new period. Set duty to 0 first, as the class does.
  • Servo buzzes at the ends: the pulse limits exceed its mechanical range. Narrow MIN_PULSE_NS and MAX_PULSE_NS.
  • Pi resets when the motor starts: the motor and Pi share a weak supply. Use a separate battery for the L298N.
  • Motor is weaker than expected: the L298N uses bipolar transistors and drops around 2 V, so a 6 V motor needs a higher supply.

Summary

Use software PWM from gpiozero where small timing errors do not matter, such as DC motor speed and LED brightness. For servos, enable dtoverlay=pwm-2chan and write period and duty cycle through /sys/class/pwm/pwmchip0, which gives a stable 50 Hz signal generated entirely in hardware. Always power servos and motors separately and share only the ground with the Pi.