Raspberry Pi Pico Servo Motor Control with PWM and duty_ns
Hobby servo motors such as the SG90 and MG996R contain a DC motor, a gearbox, a potentiometer on the output shaft and a small control circuit. You do not drive the motor directly; you send a pulse that tells the servo which angle to hold, and its internal feedback loop does the rest. That makes servos ideal for robot arms, pan-tilt camera mounts, door latches and steering. In this tutorial you will generate the servo signal with the Pico's PWM hardware, write a reusable Servo class with calibration, and steer the servo with a potentiometer.
How the Servo Signal Works
A standard analog servo expects a pulse every 20 ms, which is a 50 Hz PWM signal. The width of the high pulse, not the duty cycle percentage, sets the angle. The nominal standard is 1.0 ms for one end, 1.5 ms for centre and 2.0 ms for the other end, but most small servos need a wider range, roughly 0.5 ms to 2.5 ms, to reach a full 180 degrees. The exact limits vary from one servo to another, which is why calibration matters.
Period = 1 / 50 Hz = 20 ms Pulse width (us) = MIN_US + (angle / 180) x (MAX_US - MIN_US) With MIN_US = 500 and MAX_US = 2500: 0 deg -> 500 us 90 deg -> 1500 us 180 deg -> 2500 us
MicroPython's PWM.duty_ns() sets the high time directly in nanoseconds, which maps naturally to servo pulses: 1500 microseconds is duty_ns(1_500_000). This avoids converting to a 16-bit duty value yourself.
Wiring and Power
| Servo wire | Colour (typical) | Connect to |
|---|---|---|
| Signal | Orange or yellow | GP16 (physical pin 21) |
| V+ | Red | 5 V: VBUS (pin 40) for one SG90 on USB power, or an external 5 V supply |
| Ground | Brown or black | GND (pin 38), shared with any external supply |
Servos draw large current spikes when they start moving or are held against a load, often several hundred milliamps for an SG90 and much more for a metal-gear MG996R. Those spikes can pull the supply down and reset the Pico. One SG90 with no load usually runs from VBUS when the Pico is on a good USB port, but for two or more servos, or any larger servo, use a separate 5 V supply rated for at least 1 A per small servo, and more for metal-gear types, and connect its ground to the Pico ground. A 470 uF electrolytic capacitor across the servo supply near the servo helps absorb spikes.
For the steering example, keep the potentiometer on GP26 (ADC0) from the ADC tutorial.
A Reusable Servo Class
# servo_pot.py - servo on GP16 steered by a potentiometer on GP26
from machine import Pin, PWM, ADC
import time
class Servo:
def __init__(self, pin, min_us=500, max_us=2500, max_angle=180):
self.pwm = PWM(Pin(pin))
self.pwm.freq(50) # 20 ms period for analog servos
self.min_us = min_us
self.max_us = max_us
self.max_angle = max_angle
self.current = None
def write_us(self, us):
# Clamp to calibrated limits to protect the gears
us = max(self.min_us, min(self.max_us, us))
self.pwm.duty_ns(int(us * 1000)) # microseconds -> nanoseconds
def angle(self, deg):
deg = max(0, min(self.max_angle, deg))
span = self.max_us - self.min_us
self.write_us(self.min_us + deg * span / self.max_angle)
self.current = deg
def sweep_to(self, target, step=1, delay_ms=15):
# Move gradually instead of jumping, reducing current spikes
if self.current is None:
self.angle(target)
return
direction = 1 if target > self.current else -1
for a in range(int(self.current), int(target), direction * step):
self.angle(a)
time.sleep_ms(delay_ms)
self.angle(target)
def off(self):
# Stop pulses; the servo goes limp and stops drawing holding current
self.pwm.duty_ns(0)
servo = Servo(16, min_us=500, max_us=2500)
pot = ADC(26)
# Demonstration sweep: 0 -> 180 -> 90 degrees
servo.angle(0)
time.sleep(1)
servo.sweep_to(180)
time.sleep(0.5)
servo.sweep_to(90)
time.sleep(0.5)
# Potentiometer steering with a small dead band to stop jitter
last = -10
try:
while True:
raw = sum(pot.read_u16() for _ in range(8)) // 8
target = raw * 180 // 65535
if abs(target - last) >= 2: # ignore changes under 2 degrees
servo.angle(target)
last = target
print('angle:', target)
time.sleep_ms(20)
except KeyboardInterrupt:
servo.off()
Calibrating Your Servo
The default 500 to 2500 microsecond range is a starting point. If the servo buzzes or strains at one end, it is being driven past its mechanical stop, which wastes current and wears the gears. Find the true limits from the REPL after running the script once and pressing Stop:
- Create the servo:
s = Servo(16). - Send the centre pulse with
s.write_us(1500)and fit the horn so it points where you want 90 degrees to be. - Lower the value in steps of 50, for example
s.write_us(700), until the servo stops moving or starts to buzz, then go back up by 50. That is yourmin_us. - Repeat upwards from 1500 to find
max_us. - Put both values into the constructor. Now
angle(0)andangle(180)hit the real ends without straining.
Code Notes
- Clamping in
write_usmeans a bug elsewhere in the program can never command a pulse outside the safe range. sweep_tomoves in 1-degree steps every 15 ms, about 2.7 seconds for 180 degrees. Smaller delays give faster but harsher motion.- The 2-degree dead band stops the servo twitching when ADC noise changes the reading by a count or two.
duty_ns(0)stops the pulses. An analog servo then stops holding position, which saves power but means the arm can be pushed by hand.- GP16 is PWM slice 0, channel A. To drive more servos at the same time, use pins on any slice; all run at 50 Hz, so shared slices are not a problem here.
Troubleshooting
- Pico resets when the servo moves: supply droop. Use an external 5 V supply, common ground and a bulk capacitor.
- Servo jitters at rest: noisy pot readings or a weak supply. Increase averaging and the dead band.
- Only about 90 degrees of travel: the pulse range is too narrow. Calibrate wider limits.
- Continuous-rotation servo keeps spinning: these use the pulse as speed, not angle. 1500 microseconds is stop; adjust slightly to find the exact stop point.
Summary
A servo needs a 50 Hz signal whose pulse width encodes the angle. Using duty_ns() you set that width directly, wrapped it in a Servo class with clamping and smooth sweeps, calibrated the real end points, and steered the servo with a potentiometer. Most importantly, you powered the servo separately so its current spikes cannot reset the Pico.