6.13. Generating analog with PWM and an RC filter

The ADC reads voltages on a pin. The opposite – producing an intermediate voltage between 0 V and Vcc on a pin – is harder, because a GPIO output only knows how to drive its two rails. The standard substitute is to switch the pin between the rails fast enough that the average voltage is what you care about.

6.13.1. Pulse-width modulation

A pulse-width-modulated (PWM) signal is a square wave at a fixed frequency whose high-time – the fraction of each cycle spent at Vcc instead of ground – is set in software. That fraction is the duty cycle. The average voltage of the waveform is the duty cycle times Vcc:

V_avg = duty × Vcc

A 25 % duty cycle averages to Vcc / 4; a 50 % duty cycle to Vcc / 2; a 75 % duty cycle to 3 × Vcc / 4.

Three square-wave traces stacked vertically, each at the same frequency. The top wave is high for 25 % of each period and low for 75 %. The middle wave is high and low for half the period each. The bottom wave is high for 75 % and low for 25 %.

PWM at 25 %, 50 %, and 75 % duty cycle. The average voltage tracks the duty cycle.

The frequency is set when the PWM is configured; the duty cycle is what software changes on the fly. The machine.PWM class wraps a hardware timer channel that generates the waveform without CPU help – once configured, the signal continues at the chosen frequency and duty cycle until changed.

6.13.2. The machine.PWM class

Construct a PWM instance with the pin and an initial frequency and duty:

from machine import PWM, Pin

pwm = PWM(Pin("P7"), freq=20_000, duty_u16=32768)

That starts a 20 kHz square wave at 50 % duty on P7. Two methods change the output on the fly:

pwm.duty_u16(16384)   # change to 25 % (16384 / 65535)
pwm.freq(5_000)       # change to 5 kHz

duty_u16() takes an unsigned 16-bit integer mapping 0 to “always low” and 65535 to “always high”. freq() sets the carrier frequency in hertz.

Note

Every PWM channel on the same hardware timer shares its frequency. Calling freq() on one channel changes every other channel attached to that timer. Use channels of different timers when outputs must run at different frequencies.

Call deinit() to release the timer channel when the output is no longer needed.

6.13.3. Averaging with an RC low-pass filter

Raw PWM is not a smooth voltage; it is a square wave whose average is what we want. To extract that average, pass the PWM through a low-pass filter – the same resistor-and-capacitor combination used for switch debouncing in Debouncing.

A PWM pin connects through a series resistor R to an output node. A capacitor C from that node to ground completes the low-pass filter; the smoothed voltage appears at V_out.

PWM through an RC low-pass filter: the capacitor averages the square wave into a DC voltage proportional to the duty cycle.

The filter’s cutoff frequency – the boundary between frequencies it passes and those it blocks – is set by the same RC product that gave the time constant for the debounce circuit:

f_c = 1 / (2π × R × C)

For the filter to extract a clean DC voltage from a PWM input, the cutoff frequency must be much lower than the PWM frequency itself. The DC component (frequency 0) passes through unchanged; the PWM’s fundamental harmonic (at the PWM frequency) is attenuated by roughly f_c / f_PWM. A ratio of 1 / 200 cuts the residual ripple at the output to about 0.5 % of the input swing.

A reasonable starting point for a slow-changing setpoint:

  • PWM frequency f_PWM = 20 kHz – well above audio, and easy for the timer to generate cleanly.

  • Filter values R = 1.6 , C = 1 µF – giving f_c = 1 / (2π × 1.6 × 1 µF) 100 Hz.

The 200× suppression at the carrier reduces the PWM’s full swing down to roughly Vcc / 200 of residual ripple at V_out – about 16 mV at 3.3 V.

Two practical notes:

  • The filter’s output impedance is roughly R. Any downstream load that draws current turns R and the load into a divider that pulls V_out below the ideal average, exactly like the divider on the Reading analog with the ADC page. Feed an ADC pin or a high-impedance buffer, not a load that sinks milliamps.

  • The cap takes about 5 × R × C 8 ms to settle when the duty cycle changes; V_out lags the duty setting by that much. For a setpoint that needs to update faster, raise the cutoff (smaller R or C) and accept more ripple.