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.

SliceChannel A pinsChannel B pins
0GP0, GP16GP1, GP17
1GP2, GP18GP3, GP19
2GP4, GP20GP5, GP21
3GP6, GP22GP7
4GP8GP9
5GP10, GP26GP11, GP27
6GP12, GP28GP13
7GP14GP15

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 - ramp an LED on GP15 up and down with duty_u16
# 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 on three LEDs with phase offsets
# 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 uses duty_u16() (0 to 65535) or duty_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.