Raspberry Pi Pico and RP2040 - MicroPython Part 3: PIO
2021-04-26 | By ShawnHymel
License: Attribution
The Raspberry Pi Pico is built around Raspberry Pi’s custom RP2040 microcontroller. The RP2040 has some unique features, such as a dual-core ARM Cortex-M0+ as well as no internal flash memory. In addition to some of the basic peripherals, like timers, PWM, I2C, SPI, UART, etc., it also contains two special Programmable Input/Output (PIO) blocks that can be used to construct a wide variety of communication peripherals.
In the previous tutorial, we explored I2C with MicroPython on the Raspberry Pi Pico in order to read temperature data from a sensor. This time, we’ll introduce the PIO peripheral and show how you can program it with MicroPython.
Here is a video showing these steps in action:
What is PIO?
Raspberry Pi created this ingenious peripheral called the “Programmable I/O” (or PIO) block that’s built into the silicon of the RP2040 microcontroller. Rather than offering a variety of microcontrollers that have different configurations of advanced peripherals (e.g. CAN, USB, Ethernet), the RP2040 gives us some basic peripherals (timers, PWM, I2C, etc.) along with the ability to construct our own peripherals with PIO blocks.
The PIO concept is very similar to other reconfigurable peripherals, like the FPGA-like fabric found in Cypress’s PSoC line. However, a PIO instance is similar to a very limited CPU that runs sequential code rather than reconfigurable hardware. Because PIO relies on special assembly language instructions, creating custom peripherals can be done in any text editor rather than relying on a proprietary IDE.
In essence, we use the PIO to bitbang various protocols, and they operate independently of the main CPU.
Chapter 3 of the RP2040 datasheet outlines how the PIO peripheral works. There, you can find this great block diagram. Each RP2040 contains 2 of these PIO instances.
Image credits: Raspberry Pi RP2040 Datasheet
Each PIO has 4 “state machines.” These state machines operate like tiny, very limited processors capable of running instructions found in the shared instruction memory. The instruction memory can hold up to 32 total instructions. However, each state machine can pull instructions from anywhere in that memory.
For example, you could create 4 separate programs with 8 instructions each that run separately on the state machines. Alternatively, you could have one program with 16 instructions that runs on all 4 state machines (and the other 16 instruction slots don’t do anything).
Each state machine has access to 2 FIFOs that you can use to send and receive data to/from the main processor (the Arm Cortex-M0+). By default, one FIFO is for outgoing data and the other is for incoming data. However, you can set them so that both are used for outgoing or both are used for incoming if you wanted to double your buffer size.
Additionally, each state machine has access to a shared bank of 8 interrupt flags. These have a variety of uses, such as synchronizing state machines or notifying the CPU that some data is ready for consumption.
Finally, each state machine can control any of the RP2040’s 32 GPIO pins. However, for a state machine to control a group of pins, those pins must be grouped in a contiguous set.
Each state machine (remember: there are 8 total state machines in the RP2040--4 in each PIO) consists of a set of registers and some control logic.
Image credits: Raspberry Pi RP2040 Datasheet
The “control logic” is like a CPU, but it’s missing a very important component: the arithmetic logic unit (ALU). As a result, the state machines are incapable of performing most math functions, except for the very basic “increment” function used for counting (e.g. for loops).
The clock divider (Clock Div) is used to divide the main system clock on the RP2040. The maximum clock speed (without overclocking) is 133 MHz. The Raspberry Pi Pico runs at 125 MHz by default. The clock divider allows us to run each state machine at a frequency between about 2 kHz and 133 MHz (assuming the system clock is 133 MHz).
You’ll also find two registers used for sending and receiving data from the FIFOs as well as scratch (X, Y) registers for holding temporary data. Each register is 32 bits wide.
The program counter (PC) determines where the state machine should read from next in the shared instruction memory. Each state machine has its own PC, which allows them to run separate programs independently of each other (so long as all the programs combined do not take up more than 32 instructions for a single PIO instance).
Instruction Set
The PIO assembly language consists of 9 instructions: JMP, WAIT, IN, OUT, PUSH, PULL, MOV, IRQ, and SET.
We won’t cover them all here. I recommend reading about them in section 3.4 of the RP2040 datasheet.
We’ll start with the simple SET instruction to toggle a pin independently of the main CPU. We’ll also use a “NOP” command (no operation). There is no NOP command--the PIO library has a wrapper for “NOP” that assembles to “MOV contents of register Y to register Y” (which accomplishes nothing).
We will use the rp2 MicroPython module that acts as a wrapper for the PIO assembly language. It handles a lot of the configuration for us, and writing an assembly program looks a lot like writing a MicroPython function. Note that rp2 comes with the Pico MicroPython firmware by default, so we don’t need to do anything special to install it (outside of uploading the MicroPython .uf2 firmware that we did in Part 1).
PIO Blink Example
In a new Thonny file, enter the following code:
import machine
import utime
import rp2
# Blink state machine program. Blinks LED at 10 Hz (with freq=2000)
# 2000 Hz / (20 cycles per instruction * 10 instructions) = 10 Hz
# Single pin (base pin) starts at output and logic low
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
wrap_target()
set(pins, 1) [19]
nop() [19]
nop() [19]
nop() [19]
nop() [19]
set(pins, 0) [19]
nop() [19]
nop() [19]
nop() [19]
nop() [19]
wrap()
# Init state machine with "blink" program
# (state machine 0, running at 2kHz, base pin is GP25 (LED))
sm = rp2.StateMachine(0, blink, freq=2000, set_base=machine.Pin(25))
# Continually start and stop state machine
while True:
print("Starting state machine...")
sm.active(1)
utime.sleep(1)
print("Stopping state machine...")
sm.active(0)
utime.sleep(1)
The PIO program is contained in the blink() function. Note that to use it in the PIO, we must use the @rp2.asm_pio decorator. In the decorator’s parameters, we set things like pin direction and FIFO direction.
We can use one wrap() and wrap_target() functions per state machine. These allow us to loop automatically without a JMP instruction.
The set() function is a wrapper for the SET instruction. The first parameter is the target, and we have a few options like “pins,” “X register,” “Y register,” and so on. “Pins” means to set the assigned GPIO to the given value. The second parameter is the value. So, we use 1 to set the pin high and 0 to set the pin low.
Each instruction takes exactly one clock cycle (after the divider). We can delay the state machine by up to 31 additional cycles. We do this with the brackets seen after the function call. So, set() takes 1 cycle and we delay for 19 cycles, making that whole line take 20 total cycles.
We then do nothing for 80 cycles (four nop() commands, each with an additional 19 cycle delay).
So, we set a pin high and wait for a total of 100 cycles. We then repeat the process with the pin going low for another 100 cycles.
If we set the clock divider so the state machine operates at 2 kHz, that means we will flash the LED at a 2 kHz / 200 = 10 Hz rate.
In the main part of the program, we create a StateMachine object from the rp2 module. The first parameter is the state machine we wish to use. State machines 0 through 3 are in PIO 0, and state machines 4 through 7 are in PIO 1. So, you must choose a number between 0 and 7 for this parameter. Next, we set the desired frequency (between 2000 and 125_000_000 for the Pico) followed by the pin we wish to use.
Note that we can assign multiple pins to a state machine, but they must be contiguous. When declaring the StateMachine object, we choose the base pin (e.g. pin 25). In the decorator, we would set the pin directions in order using a tuple. For example (with base pin set to 25), set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_HIGH) would set pin 25 to output default low and pin 26 to output default high.
Finally, we can start and stop the state machine with sm.active(1) and sm.active(0).
If you run the program, you should see the LED flash rapidly (10 Hz), but it will start and stop flashing every second (as we’re telling the state machine to start and stop).
Using PIO as a Library
There is a lot more to using PIO that we did not cover (such as side-setting). I encourage you to read through the RP2040 datasheet if you wish to learn more details about how PIO works.
However, that doesn’t stop us from using other people’s code! Raspberry Pi has a number of great PIO examples in this GitHub repo. Let’s use the PWM example to create our own MicroPython library. Note that the following example comes from that repository.
In a new Thonny file, enter the following code:
from machine import Pin
from rp2 import PIO, StateMachine, asm_pio
@asm_pio(sideset_init=PIO.OUT_LOW)
def pwm_prog():
pull(noblock) .side(0)
mov(x, osr) # Keep most recent pull data stashed in X, for recycling by noblock
mov(y, isr) # ISR must be preloaded with PWM count max
label("pwmloop")
jmp(x_not_y, "skip")
nop() .side(1)
label("skip")
jmp(y_dec, "pwmloop")
class PIOPWM:
def __init__(self, sm_id, pin, max_count, count_freq):
self._sm = StateMachine(sm_id, pwm_prog, freq=2 * count_freq, sideset_base=Pin(pin))
# Use exec() to load max count into ISR
self._sm.put(max_count)
self._sm.exec("pull()")
self._sm.exec("mov(isr, osr)")
self._sm.active(1)
self._max_count = max_count
def set(self, value):
# Minimum value is -1 (completely turn off), 0 actually still produces narrow pulse
value = max(value, -1)
value = min(value, self._max_count)
self._sm.put(value)
Save it as piopwm.py in the /lib folder on your Pico.
In a new file, enter the following code:
import utime
from piopwm import PIOPWM
# Create PIOPWM object used to fade LED
# (state machine 0, pin 25, max PWM count of 65535, PWM freq of 10 MHz)
pwm = PIOPWM(0, 25, max_count=(256 ** 2) - 1, count_freq=10_000_000)
# Loop forever
while True:
# Fade LED in
for i in range(256):
pwm.set(i ** 2)
utime.sleep(0.01)
# Fade LED out
for i in reversed(range(256)):
pwm.set(i ** 2)
utime.sleep(0.01)
Here, we import the PIOPWM class that we made in the piopwm module. We can use it to start a PIO instance that handles pulse width modulation (PWM) for us!
We set a maximum count of 65535 and a state machine frequency of 10 MHz on pin 25. We then fade the onboard LED in by increasing the brightness with the pwm.set() function and fade out by decreasing the brightness.
Save the code as main.py in the top-level directory of your Pico. Run it, and you should see the LED slowly fade in and out.
Recommended Reading
PIO can be quite complicated, but I hope these examples have given you a good starting point to using it. I’m excited to see what folks will make with it. Someone has already made a DVI driver using PIO! Granted, it does require overclocking the Pico.
I recommend checking out the following resources if you’d like to learn more about the PIO and RP2040:
- RP2040 datasheet
- Pico Python SDK datasheet
- MicroPython rp2 module
- MicroPython PIO examples
- Raspberry Pi Pico and RP2040 - MicroPython Part 1: Blink
- Raspberry Pi Pico and RP2040 - MicroPython Part 2: I2C Sensor and Module
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum