PWM (M3-D)
Pulse-width modulation peripheral. RP2350 has 12 slices, each with two output channels (A and B), giving 24 PWM-capable outputs. Datasheet reference: RP2350 sec 12.5.
The driver lives in src/pwm.S; register definitions in
include/pwm.inc; tests in tests/unicorn/test_pwm.py and
tests/renode/pwm.resc.
Slice layout
Each slice is a 16-bit free-running counter with an 8.4-bit fractional clock divider, two compare values (CC.A in low half, CC.B in high half of a 32-bit register), and a TOP wrap value.
+0x00 CSR EN | PH_CORRECT | A_INV | B_INV | DIVMODE[1:0] | PH_RET | PH_ADV
+0x04 DIV [11:4] integer [3:0] fractional (8.4 fp)
+0x08 CTR 16-bit current count
+0x0C CC [31:16] channel B level [15:0] channel A level
+0x10 TOP 16-bit wrap point
Stride 0x14, 12 slices, base 0x400a8000.
Global registers at PWM_BASE + 0xF0+:
| Off | Name | Notes |
|---|---|---|
| 0xF0 | EN | one bit per slice; atomic alternative to per-slice EN |
| 0xF4 | INTR | wrap-IRQ pending; write 1 to clear |
| 0xF8 | INTE | wrap-IRQ enable (per slice) |
| 0xFC | INTF | force-IRQ shadow |
| 0x100 | INTS | post-mask status (RO) |
GPIO -> slice / channel mapping
Formula used in include/pwm.inc and tests/unicorn/mocks_pwm.py:
slice = ((pin >> 1) & 7) | ((pin >> 4) & 8)
chan = pin & 1 (A = even pins, B = odd pins)
| GPIO | Slice | Channel | Notes |
|---|---|---|---|
| GP0 | 0 | A | UART0 TX in our build |
| GP1 | 0 | B | UART0 RX in our build |
| GP2 | 1 | A | |
| GP15 | 7 | B | used by pwm_fade_demo.S |
| GP16 | 0 | A | wrap (low slice bits collide) |
| GP24 | 4 | A | |
| GP25 | 4 | B | on-board LED on Pico 2 |
| GP47 | 7 | B | top of GPIO range |
Datasheet discrepancy. The RP2350 SDK header
hardware/pwm.hships two slightly different macros across SDK versions; we pinned the form above because it matches both the GP15 and GP25 expectations the orchestrator stated. Slices 8..11 are not reachable from any GPIO via this formula -- they're presumably for internal use. Verify against the table in datasheet sec 12.5 if you intend to drive a pin not listed here.
To route a pad to its PWM slice/channel, call:
movs r0, #15 @ GP15
bl pwm_set_gpio_function
This clears PADS_BANK0[15] ISO+OD and writes IO_BANK0[15].CTRL = 4
(FUNCSEL = PWM).
Frequency / duty math
Effective clock per slice:
f_slice = clk_sys / (DIV.int + DIV.frac/16)
PWM frequency:
f_pwm = f_slice / (TOP + 1) in free-running mode
= f_slice / (2 * TOP) in phase-correct mode
Duty cycle on channel X:
duty = CC.X / (TOP + 1) in free-running mode
= CC.X / TOP in phase-correct mode
Rule of thumb: keep TOP >= 100 for ~1% duty resolution. At
clk_sys = 150 MHz:
| Use case | DIV.int | TOP | f_pwm | duty res. |
|---|---|---|---|---|
| LED fade | 6 | 100 | 250 kHz | 1% |
| Servo (50 Hz) | 150 | 19999 | 50 Hz | 0.005% |
| Audio (20 kHz) | 1 | 7499 | 20 kHz | 0.013% |
| Buzzer (1 kHz) | 3 | 49999 | 1 kHz | 0.002% |
The high-level pwm_set_freq_hz(slice, freq, src_hz) helper computes
DIV.int and TOP from the requested frequency:
ticks = src_hz / freq- If
ticks <= 65535:DIV.int = 1,TOP = ticks - 1. - Else:
DIV.int = ceil(ticks / 65536),TOP = ticks/DIV.int - 1.
DIV.frac is left at 0; callers wanting sub-Hz precision program DIV
directly via pwm_set_clkdiv(slice, int, frac).
Phase-correct vs free-running
CSR.PH_CORRECT = 0 (default) is the simple "saw-tooth" mode: the counter
runs 0 -> TOP -> wrap -> 0. The output is high while CTR < CC.X.
CSR.PH_CORRECT = 1 is "triangle" mode: 0 -> TOP -> 0 -> ... This halves
the effective frequency but produces output edges that are symmetric about
TOP -- useful for motor drive (no DC offset between consecutive cycles
when both channels are used) and class-D audio (less phase distortion).
Cycle budgets
Driver functions (Cortex-M33 @ 150 MHz, no wait states):
| Function | Stores | Cycles | Notes |
|---|---|---|---|
pwm_set_chan_level |
1 STRH | ~9 | 16-bit half-store |
pwm_set_both_levels |
1 STR | ~9 | single 32-bit store |
pwm_set_enabled |
1 STR | ~7 | atomic SET/CLR alias |
pwm_set_wrap |
1 STR | ~7 | |
pwm_set_clkdiv_int |
1 STR | ~8 | |
pwm_set_phase_correct |
1 STR | ~10 | atomic SET/CLR |
pwm_set_output_polarity |
2 STR | ~14 | SET + CLR pair |
pwm_irq_enable |
1 STR | ~7 | atomic SET/CLR |
pwm_acknowledge_irq |
1 STR | ~6 | write-1-to-clear |
pwm_set_freq_hz |
2 STR | ~30-50 | one UDIV |
pwm_set_gpio_function |
2 STR | ~16 | PADS CLR + IO CTRL |
pwm_resets_enable |
1 STR | ~10+spin | spin on RESET_DONE |
All slice-bit and IRQ-bit toggles use the atomic +0x2000 SET / +0x3000 CLR aliases so an ISR poking a sibling slice can never lose an enable bit
(no read-modify-write hazard).
Servo cookbook
@ 50 Hz, 1-2 ms pulse on GP15
bl pwm_init @ release reset
movs r0, #15
bl pwm_set_gpio_function
movs r0, #PWM_SLICE_FOR_GP15 @ 7
movw r1, #150 @ DIV.int -> 1 us tick @ 150 MHz
bl pwm_set_clkdiv_int
movs r0, #PWM_SLICE_FOR_GP15
movw r1, #20000 @ TOP -> 20 ms period
bl pwm_set_wrap
movs r0, #PWM_SLICE_FOR_GP15
movs r1, #PWM_CHAN_FOR_GP15 @ 1 (channel B)
movw r2, #1500 @ 1.5 ms = neutral
bl pwm_set_chan_level
movs r0, #PWM_SLICE_FOR_GP15
movs r1, #1
bl pwm_set_enabled
Range: r2 = 1000 -> full CCW, r2 = 1500 -> centre, r2 = 2000 -> full
CW. See examples/pwm_servo_demo.S for the complete implementation that
sweeps every second.
Wrap-IRQ wiring
PWM wrap interrupts come out on NVIC lines:
| Line | Slices covered |
|---|---|
PWM_IRQ_WRAP_0= 8 |
0..11 |
PWM_IRQ_WRAP_1= 9 |
0..11 (alt) |
Both lines see all 12 slice wraps; the split is for letting the two cores
take separate wrap-IRQ vectors. In examples/pwm_irq_demo.S we build a
private 64-entry vector table at runtime, install our handler at
slot 16 + 8 = 24, point VTOR at the new table, and program
NVIC_ISER0 |= (1 << 8) to unmask the line. The driver helpers
pwm_irq_enable(slice, on) and pwm_acknowledge_irq(slice) handle the
INTE / INTR plumbing.
Tests
-
T1 (Unicorn).
tests/unicorn/test_pwm.pyinjects calls into each public driver function and asserts on the resulting MMIO trace -- exact addresses, exact values, even access widths (STRH vs STR). Also runs the fade demo end-to-end and checks slice 7 was enabled with the right DIV/TOP after clock bring-up. -
T3 (Renode).
tests/renode/pwm.rescloadspwm_fade_demo.elf, runs 1 s simulated, and asserts the peripheral loggedPWM slice 7 enabledplus >=10PWM_CC_WRITEevents (proving the fade is animating). The peripheral python is attests/renode/pwm_peripheral.py(canonical) and inlined insidetests/renode/rp2350.repl's PWM trailer block.