RP2040 PIO — Programmable I/O¶
The RP2040 (the Raspberry Pi Pico's chip) has something most
microcontrollers don't: two small, independent, programmable state
machines called PIO blocks, each running eight of them, entirely
separate from the ARM cores. They execute a tiny nine-instruction
assembly language at a precise, deterministic clock rate, generating or
consuming waveforms the CPU never has to service in real time — no
interrupt latency, no GC pause, no scheduler jitter. MicroPython exposes
this through the rp2 module and the @rp2.asm_pio decorator. There's
no RP2040 here, so this module is a careful review against the
documented PIO instruction set and the rp2 API rather than anything
run.
Why PIO exists¶
A CPU toggling a pin at exactly 1 MHz, forever, without ever missing a cycle, is a bad use of a general-purpose core — any interrupt, any GC pause (see the memory module), any scheduling jitter shows up as a timing glitch. PIO state machines run independently of the CPU and of MicroPython's interpreter entirely: once loaded and started, a PIO program keeps running at its configured clock rate even while Python code on the main core is doing something else, including sitting in a GC pause.
Anatomy of a PIO program¶
import rp2
from machine import Pin
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
set(pins, 1)
set(pins, 0) [31] # [31] = extra 31 cycle delay after this instruction
sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25))
sm.active(1)
@rp2.asm_pio compiles the decorated function's body — written using a
small set of PIO-specific pseudo-instructions (set, mov, in_,
out, push, pull, jmp, wait, irq) — into actual PIO machine
code at import time. rp2.StateMachine(id, program, freq=..., ...)
loads it onto one of the eight state machines (numbered 0-7 across the
two PIO blocks) and configures its clock divider to hit the requested
frequency as closely as the RP2040's clock allows.
The nine-instruction limit¶
Every PIO state machine has its own tiny 32-instruction program
memory (not nine — nine is the number of distinct instruction types:
JMP, WAIT, IN, OUT, PUSH, PULL, MOV, IRQ, SET). This
is the single most important constraint in PIO programming: there is no
"just add a few more lines" — a program that needs a 33rd instruction
does not fit, period, and must be restructured to reuse loops and
delays rather than unrolling.
@rp2.asm_pio()
def too_long():
# Nine SET instructions here is only 9 of 32 slots — instruction
# *count* is the limit, not variety. A program using loops (jmp)
# to repeat a pattern uses far fewer instructions than one that
# writes out every repetition literally.
pass
Because slots are so scarce, PIO programs favor tight loops with jmp
and side-set bits over anything resembling unrolled code:
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW)
def square_wave():
wrap_target()
set(pins, 1) .side(1) [1]
set(pins, 0) .side(0) [1]
wrap()
wrap_target()/wrap() mark a loop that repeats without spending an
instruction slot on a jmp back to the top — the hardware wraps the
program counter automatically, which is the idiomatic way to build a
free-running waveform in minimal instruction slots.
The delay/side-set cycle budget¶
Each instruction executes in one clock cycle by default; the [n]
suffix (as in [31] above) adds up to 31 extra cycles of delay after
that instruction, and side-set bits (.side(...)) let an instruction
also drive a fixed set of output pins without spending a separate set
instruction. Both are frequently how a program that "should" need many
more instructions than the 32-slot limit allows fits comfortably — the
delay field does the waiting so you don't need explicit no-op
instructions in a loop, and side-set lets one instruction do double
duty (an ALU-ish operation plus a pin change).
FIFOs — the boundary with Python¶
Data crosses between the PIO state machine and MicroPython through two
small hardware FIFOs, 4 words deep each: TX (Python → PIO, via pull
in the program and sm.put() in Python) and RX (PIO → Python, via
push in the program and sm.get() in Python).
@rp2.asm_pio(out_shiftdir=rp2.PIO.SHIFT_RIGHT, autopull=True, pull_thresh=32)
def pwm_prog():
pull()
mov(x, osr)
label("loop")
jmp(x_not_y, "loop") # illustrative; real PWM PIO programs are more involved
sm = rp2.StateMachine(0, pwm_prog, freq=1_000_000)
sm.active(1)
sm.put(2000) # pushes a 32-bit word into the TX FIFO for the program to `pull`
Because the FIFO is only 4 words deep, a state machine consuming data
faster than Python supplies it (or vice versa) will stall — the
autopull/autopush options and the pull_thresh/push_thresh
parameters control exactly when data moves automatically versus
needing an explicit pull/push instruction, and getting this
threshold wrong is a common source of a PIO program that appears to
"hang" (it's actually blocked on an empty or full FIFO, waiting for the
CPU side to keep up).
IRQs — PIO signaling the CPU¶
PIO programs can raise one of a small set of hardware IRQ flags with
the irq instruction, which MicroPython can register a Python callback
against via sm.irq(handler). This is the one place PIO and the
interpreter meet in real time — the handler still runs as an
interrupt-context Python callback with all the usual constraints
(short, no blocking calls, ideally no allocation) covered in the
uasyncio and threading material.
How It Actually Works¶
PIO is genuinely separate silicon, not a software abstraction over the ARM cores — understanding it as its own tiny CPU explains every constraint in this module.
- Each of the eight PIO state machines is a complete, independent
micro-CPU: its own program counter, its own small instruction memory, its
own input/output shift registers, running off the system clock through
its own configurable divider — entirely separate silicon from the two
Cortex-M0+ cores that run MicroPython.
@rp2.asm_piodoesn't produce ARM machine code the way@micropython.native/viperdo — it assembles into PIO's own tiny nine-opcode instruction encoding (each instruction is exactly 16 bits) and MicroPython'srp2.StateMachine(...)constructor writes those 16-bit words into the state machine's dedicated instruction memory over the RP2040's internal bus. Once started, that state machine fetches and executes its own instructions from its own memory, completely independent of what the ARM cores (and the interpreter running on them) are doing — which is the literal, hardware-level reason a GC pause or an interrupt on the main core cannot introduce jitter into a PIO-generated waveform. - The 32-instruction limit is the actual physical size of the
instruction memory silicon — not a software-imposed cap that could be
lifted with a firmware update.
wrap_target()/wrap()work by writing special wrap-address values into the state machine's control registers so its program counter hardware auto-resets to the target address after reaching the wrap point, without executing ajmpinstruction at all — a genuine hardware feature (dedicated wrap-address comparator logic) that exists specifically to stretch that scarce 32-slot memory further. - The
[n]delay field and side-set bits are encoded directly into the spare bits of each 16-bit instruction word — this is why they're "free": they don't cost an extra instruction slot because they aren't a separate instruction, just additional fields packed into the one you already wrote. PIO's instruction encoding reserves a handful of bits for a delay/side-set counter that the hardware clock-divides against independently, which is why the delay comes in cycles of the state machine's own configured clock, not the ARM core's — doubling the target frequency means halving the clock divider (or the delay counts), never touching the instruction count. - The TX/RX FIFOs are genuine hardware FIFO registers (small dedicated
SRAM, 4 entries × 32 bits) sitting on the bus between the state machine
and the rest of the chip, and
sm.put()/sm.get()are just memory-mapped register reads/writes into those FIFOs from the Python side — the same "MicroPython call is a thin wrapper around a register access" pattern asmachine.Pinin Level 1, just addressing PIO's FIFO registers instead of a GPIO register. A state machine'spull/pushinstructions block (stall the state machine's own program counter) when the FIFO they're reading from is empty or the one they're writing to is full — hardware-level backpressure with no software polling loop involved, which is exactly why a mismatched producer/consumer rate looks like a silent hang rather than a crash: the state machine is doing exactly what it's told, just waiting.
Cheat sheet¶
| Concept | What it means |
|---|---|
| Program memory | 32 instruction slots per state machine — hard limit, no exceptions |
[n] delay |
Up to 31 extra cycles after an instruction, avoids spending slots on waiting |
.side(n) |
Drive side-set pins from the same instruction as its main operation |
wrap_target()/wrap() |
Loop without a jmp instruction, free-running programs |
| TX/RX FIFO | 4 words deep each — the only data channel between PIO and Python |
autopull/autopush + pull_thresh |
Controls when FIFO transfers happen automatically |
sm.irq(handler) |
PIO-to-CPU signaling; handler runs as an interrupt-context callback |
Exercise¶
You have no RP2040 to run this against, so treat it as a design
review. Write out, as a fenced Python code block, a @rp2.asm_pio
program that generates a simple two-phase clock signal (two pins,
alternating high/low, 90 degrees out of phase) using wrap_target()/
wrap() and .side(), staying comfortably within the 32-instruction
budget (this pattern realistically needs well under ten instructions).
Beneath the code, write a short paragraph identifying which line of
your program would need to change if the target frequency needed to
double, and explain why the delay field, not adding more instructions,
is the correct lever to pull.