Raspberry Pi Pico PIO Basics: Programmable I/O State Machines

Most microcontrollers have fixed peripherals: so many UARTs, so many SPI ports, and if you need a protocol they do not support, you bit-bang it in software, tying up the CPU and fighting interrupt jitter. The RP2040 and RP2350 add PIO, programmable I/O: small, independent state machines that run their own tiny programs and toggle pins with single-clock-cycle precision while the CPU does something else. PIO is how the Pico drives WS2812 LED strips, VGA and DVI video, extra UARTs, quadrature encoders and stepper pulses. This tutorial explains the architecture and walks through two complete programs with exact timing calculations.

PIO Architecture

The RP2040 has two PIO blocks (PIO0 and PIO1); the RP2350 has three. Each block contains four state machines and a shared instruction memory of 32 instructions. In MicroPython, state machines are numbered 0 to 7 on the RP2040 (0 to 3 in PIO0, 4 to 7 in PIO1).

Resource (per state machine)Purpose
TX FIFO and RX FIFO, 4 words eachQueues for 32-bit data from and to the CPU; can be joined into one 8-word FIFO
OSR (output shift register)Holds data pulled from the TX FIFO, shifted out to pins
ISR (input shift register)Collects bits shifted in from pins, pushed to the RX FIFO
X and Y scratch registers32-bit counters and temporary values
Clock dividerSets how fast the state machine executes, from the system clock down
Pin mappingsSeparate base pins for SET, OUT, IN and side-set operations

Every PIO instruction takes exactly one clock cycle, and any instruction can carry a delay of up to 31 extra cycles, written in square brackets. This determinism is the point: if you know the state machine frequency, you know the exact timing of every pin edge.

The Nine Instructions

InstructionWhat it does
JMPJump, optionally on a condition such as X non-zero (with decrement) or a pin level
WAITStall until a GPIO, pin or IRQ flag reaches a level
INShift bits from pins or a register into the ISR
OUTShift bits from the OSR to pins or a register
PUSHMove the ISR into the RX FIFO
PULLLoad the OSR from the TX FIFO
MOVCopy between registers, optionally inverted or bit-reversed
IRQSet or clear an interrupt flag
SETWrite an immediate value (0 to 31) to pins, pin directions, X or Y

Choosing the State Machine Frequency

The clock divider has a 16-bit integer part, so the lowest frequency a state machine can run at is the system clock divided by about 65536. On an RP2040 at MicroPython's default 125 MHz that is roughly 1907 Hz; on an RP2350 at 150 MHz it is roughly 2289 Hz. Asking for less raises a ValueError. To make slow events such as a 1 Hz blink, combine a frequency above that limit with delay loops inside the program.

Example 2: Exact Pulse Trains Sent from Python

The real power of PIO appears when the CPU sends data through the FIFO. This program waits for a number, then produces exactly that many 20 microsecond pulses. Stepper motor drivers such as the A4988 and DRV8825 take one step per pulse on their STEP pin, so this is a precise, CPU-free stepper pulse generator. Watch GP16 with a logic analyser, or connect it to a driver's STEP input.

pio_pulses.py - PIO program that emits an exact number of 40 us-period pulses on GP16
# pio_pulses.py - send N pulses (25 kHz, 50% duty) on GP16 via PIO
import rp2
from machine import Pin
import time

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def pulse_train():
    pull(block)                # wait for a count from the CPU
    mov(x, osr)                # X = count - 1 (see below)
    label('loop')
    set(pins, 1)       [19]    # high for 20 cycles
    set(pins, 0)       [18]    # low for 19 cycles ...
    jmp(x_dec, 'loop')         # ... + 1 for the jump = 20 cycles

# 1 MHz clock -> 1 us per cycle -> 40 us per pulse
sm = rp2.StateMachine(1, pulse_train, freq=1_000_000, set_base=Pin(16))
sm.active(1)

def send_pulses(n):
    # jmp(x_dec) loops while X was non-zero, so X = n - 1 gives n pulses
    if n > 0:
        sm.put(n - 1)

for count in (10, 200, 1000):
    print('Sending', count, 'pulses, expected', count * 40, 'us')
    send_pulses(count)
    time.sleep_ms(count * 40 // 1000 + 50)   # wait until they finish

sm.active(0)

Key Points in the Code

  • @rp2.asm_pio(set_init=rp2.PIO.OUT_LOW) assembles the Python-looking function into PIO machine code and sets the SET pin as an output, initially low.
  • set_base=Pin(15) maps the first SET pin. With set(pins, value), bit 0 goes to the base pin, bit 1 to the next GPIO and so on, up to five pins.
  • pull(block) stalls the state machine until the TX FIFO has data, so the program simply waits for work and the CPU never has to poll it.
  • jmp(x_dec, label) tests X before decrementing: it jumps while X is non-zero, so a starting value of N - 1 gives N passes.
  • When execution reaches the end of the program it wraps to the beginning at no cost, which is why neither example needs a final jump.
  • sm.put() writes a 32-bit word into the TX FIFO. If the FIFO is full, put blocks until space is free.

Common Mistakes

  • ValueError creating the StateMachine: the frequency is below the divider limit, or the program does not fit in the remaining instruction memory.
  • Nothing on the pin: set_base was not given, or set_init was omitted so the pin was never made an output.
  • Pulse count is one too many: you put N instead of N - 1.
  • Delay too large error: the maximum delay is 31 cycles, and fewer when side-set bits are used.

Summary

PIO state machines execute one instruction per cycle with programmable delays, giving cycle-exact pin timing that runs independently of the CPU. You calculated a 1 Hz blink down to the cycle, then used the TX FIFO to command exact pulse counts suitable for a stepper driver. The same building blocks of SET, OUT, IN, JMP and the FIFOs are how the official examples implement WS2812 LEDs, UARTs and encoders.