Raspberry Pi Pico PWM LED Fade with duty_u16 and Gamma Correction
A GPIO pin is digital: it is either 0 V or 3.3 V. Yet you can make an LED glow at any brightness, or run a DC motor at any speed, by switching the pin on and off very fast and changing the fraction of time it spends high. That technique is pulse width modulation (PWM). The Pico has dedicated PWM hardware, so once configured the waveform runs with zero CPU load. In this tutorial you will fade an LED, fix the uneven brightness steps with gamma correction, and learn which pins share PWM settings.
PWM in Two Numbers: Frequency and Duty Cycle
Frequency is how many on/off cycles happen per second. Duty cycle is the percentage of each cycle the output is high. At 1 kHz the period is 1 ms; a 25 percent duty cycle means the pin is high for 0.25 ms and low for 0.75 ms. Your eye averages this, so the LED looks a quarter as bright, roughly speaking.
Period T = 1 / f On-time = duty x T Average voltage = duty x 3.3 V Example: f = 1000 Hz, duty_u16 = 16384 duty = 16384 / 65535 = 0.25 -> on-time = 0.25 ms, average = 0.825 V
MicroPython's duty_u16() takes a value from 0 (always off) to 65535 (always on), independent of the frequency. For LEDs, any frequency from about 500 Hz to a few kHz avoids visible flicker. Motors are often driven at higher frequencies to keep the switching noise out of the audible range.
PWM Slices and Channels on the RP2040
The RP2040 has 8 PWM slices, each with two outputs called A and B, giving 16 channels. Every GPIO maps to a fixed slice and channel: slice = (GPIO / 2) mod 8, channel A for even GPIOs and B for odd ones. Both channels of a slice share one counter, so they always run at the same frequency, though their duty cycles can differ. Two GPIOs on the same slice and channel, such as GP0 and GP16, output the identical signal.
| Slice | Channel A pins | Channel B pins |
|---|---|---|
| 0 | GP0, GP16 | GP1, GP17 |
| 1 | GP2, GP18 | GP3, GP19 |
| 2 | GP4, GP20 | GP5, GP21 |
| 3 | GP6, GP22 | GP7 |
| 4 | GP8 | GP9 |
| 5 | GP10, GP26 | GP11, GP27 |
| 6 | GP12, GP28 | GP13 |
| 7 | GP14 | GP15 |
Circuit
Connect GP15 (physical pin 20) through a 330 ohm resistor to an LED anode, and the cathode to GND (pin 18). For the multi-LED example add two more LEDs the same way on GP14 and GP13. GP15, GP14 and GP13 sit on slices 7 and 6, so they can all run at the same 1 kHz without conflict.
Basic Linear Fade
# fade_linear.py - simple linear PWM fade on GP15
from machine import Pin, PWM
import time
pwm = PWM(Pin(15))
pwm.freq(1000) # 1 kHz carrier, no visible flicker
try:
while True:
for duty in range(0, 65536, 512): # fade in
pwm.duty_u16(duty)
time.sleep_ms(5)
for duty in range(65535, -1, -512): # fade out
pwm.duty_u16(duty)
time.sleep_ms(5)
except KeyboardInterrupt:
pwm.duty_u16(0)
pwm.deinit() # release the pin back to normal GPIO
Run it and watch carefully. The LED gets bright quickly and then seems to stay almost the same for the second half of the ramp. That is not a hardware problem; it is how human vision works.
Gamma Correction for a Smooth Breathing Effect
The eye's response to light is roughly logarithmic, so equal steps in duty cycle look like large steps at low brightness and tiny steps at high brightness. Gamma correction maps a perceived brightness level to a duty value with a power curve, usually with an exponent around 2.2. Pre-computing the table once keeps the loop fast.
duty = 65535 x (level / max_level) ^ gamma level 25 of 100, gamma 2.2: 65535 x 0.25^2.2 = about 3,100 level 50 of 100, gamma 2.2: 65535 x 0.50^2.2 = about 14,300
# breathe.py - gamma-corrected breathing LEDs on GP15, GP14, GP13
from machine import Pin, PWM
import time
GAMMA = 2.2
STEPS = 100
# Lookup table: perceived level 0..STEPS -> duty_u16 value
TABLE = [int(65535 * (i / STEPS) ** GAMMA) for i in range(STEPS + 1)]
# Triangle wave over 2*STEPS positions: up then down
WAVE = TABLE + TABLE[-2:0:-1]
N = len(WAVE)
leds = []
for gp in (15, 14, 13):
p = PWM(Pin(gp))
p.freq(1000)
leds.append(p)
# Each LED starts a third of the cycle later than the previous one
offsets = [0, N // 3, 2 * N // 3]
pos = 0
try:
while True:
for led, off in zip(leds, offsets):
led.duty_u16(WAVE[(pos + off) % N])
pos = (pos + 1) % N
time.sleep_ms(15) # about 3 s per full breath
except KeyboardInterrupt:
for led in leds:
led.duty_u16(0)
led.deinit()
How the Code Works
PWM(Pin(15))attaches the slice-7 B channel to GP15.freq()sets the shared slice frequency;duty_u16()sets this channel only.- The list comprehension builds 101 gamma-corrected duty values once at start-up, avoiding floating-point maths in the loop.
TABLE[-2:0:-1]is the table reversed without repeating the endpoints, so the triangle wave has no flat steps at the peak or bottom.- The three offsets shift each LED by a third of the cycle, creating a rolling wave across the LEDs.
deinit()stops PWM on the pin. Without it the output keeps running after the script stops.
Common Mistakes
- Using
duty()from older examples: the rp2 port usesduty_u16()(0 to 65535) orduty_ns()for an exact pulse width. - Changing frequency on one pin unexpectedly changes another: they share a slice. Check the table above.
- LED flickers at low brightness: the frequency is too low. Stay at 500 Hz or above.
- Driving a motor straight from the pin: PWM does not increase current capability. Use a MOSFET or a driver such as the L298N or DRV8833.
- Fade looks uneven: apply gamma correction as shown.
Summary
Hardware PWM lets the Pico produce an analog-looking output from a digital pin with no CPU load. You set the frequency per slice and the duty per channel with duty_u16(), remembered that paired pins share settings, and used a gamma lookup table to make fades look natural. The same PWM class will drive a servo motor later in this series, using duty_ns() for exact pulse widths.