Skip to content

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.

def on_pio_event(sm):
    print("PIO signaled:", sm.irq().flags())

sm.irq(on_pio_event)

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_pio doesn't produce ARM machine code the way @micropython.native/viper do — it assembles into PIO's own tiny nine-opcode instruction encoding (each instruction is exactly 16 bits) and MicroPython's rp2.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 a jmp instruction 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 as machine.Pin in Level 1, just addressing PIO's FIFO registers instead of a GPIO register. A state machine's pull/push instructions 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.