...

Nine Pico PIO Wats with MicroPython (Part 2) | by Carl M. Kadie | Jan, 2025


Raspberry Pi programmable IO pitfalls illustrated with a musical example

Pico PIO Surprises, Part 2 — Source: https://openai.com/dall-e-2/. All other figures from the author.

This is Part 2 of an exploration into the unexpected quirks of programming the Raspberry Pi Pico PIO with MicroPython. If you missed Part 1, we uncovered four Wats that challenge assumptions about register count, instruction slots, the behavior of pull(noblock), and smart yet cheap hardware.

Now, we continue our journey toward crafting a theremin-like musical instrument — a project that reveals some of the quirks and perplexities of PIO programming. Prepare to challenge your understanding of constants in a way that brings to mind a Shakespearean tragedy.

In the world of PIO programming, constants should be reliable, steadfast, and, well, constant. But what if they’re not? This brings us to a puzzling Wat about how the set instruction in PIO works—or doesn’t—when handling larger constants.

Much like Juliet doubting Romeo’s constancy, you might find yourself wondering if PIO constants will, as she says, “prove likewise variable.”

The Problem: Constants Are Not as Big as They Seem

Imagine you’re programming an ultrasonic range finder and need to count down from 500 while waiting for the Echo signal to drop from high to low. To set up this wait time in PIO, you might naively try to load the constant value directly using set:

set(y, 500)                   # Load max echo wait into Y
label("measure_echo_loop")
jmp(pin, "echo_active") # if echo voltage is high continue count down
jmp("measurement_complete") # if echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Continue counting down unless timeout

Aside: Don’t try to understand the crazy jmp operations here. We’ll discuss those next in Wat 6.

But here’s the tragic twist: the set instruction in PIO is limited to constants between 0 and 31. Moreover, MicroPython’s star-crossed set instruction doesn’t report an error. Instead, it silently corrupts the entire PIO instruction. (PIO from Rust shows a similar problem.) This produces a nonsense result.

Workarounds for Inconstant Constants

To address this limitation, consider the following approaches:

  • Read Values and Store Them in a Register: We saw this approach in Wat 1. You can load your constant in the osr register, then transfer it to y. For example:
# Read the max echo wait into OSR.
pull() # same as pull(block)
mov(y, osr) # Load max echo wait into Y
  • Shift and Combine Smaller Values: Using the isr register and the in_ instruction, you can build up a constant of any size. This, however, consumes time and operations from your 32-operation budget (see Part 1, Wat 2).
# Initialize Y to 500
set(y, 15) # Load upper 5 bits (0b01111)
mov(isr, y) # Transfer to ISR (clears ISR)
set(y, 20) # Load lower 5 bits (0b10100)
in_(y, 5) # Shift in lower bits to form 500 in ISR
mov(y, isr) # Move final value (500) from ISR to y
  • Slow Down the Timing: Reduce the frequency of the state machine to stretch delays over more system clock cycles. For example, lowering the state machine speed from 150 MHz to 343 kHz reduces the timeout constant 218,659 to 500.
  • Use Extra Delays and (Nested) Loops: All instructions support an optional delay, allowing you to add up to 31 extra cycles. (To generate even longer delays, use loops — or even nested loops.)
# Generate 10μs trigger pulse (4 cycles at 343_000Hz)
set(pins, 1)[3] # Set trigger pin to high, add delay of 3
set(pins, 0) # Set trigger pin to low voltage
  • Use the “Subtraction Trick” to Generate the Maximum 32-bit Integer: In Wat 7, we’ll explore a way to generate 4,294,967,295 (the maximum unsigned 32-bit integer) via subtraction.

Much like Juliet cautioning against swearing by the inconstant moon, we’ve discovered that PIO constants are not always as steadfast as they seem. Yet, just as their story takes unexpected turns, so too does ours, moving from the inconstancy of constants to the uneven nature of conditions. In the next Wat, we’ll explore how PIO’s handling of conditional jumps can leave you questioning its loyalty to logic.

In most programming environments, logical conditions feel balanced: you can test if a pin is high or low, or check registers for equality or inequality. In PIO, this symmetry breaks down. You can jump on pin high, but not pin low, and on x_not_y, but not x_eq_y. The rules are whimsical — like Humpty Dumpty in Through the Looking-Glass: “When I offer a condition, it means just what I choose it to mean — neither more nor less.”

These quirks force us to rewrite our code to fit the lopsided logic, creating a gulf between how we wish the code could be written and how we must write it.

The Problem: Lopsided Conditions in Action

Consider a simple scenario: using a range finder, you want to count down from a maximum wait time (y) until the ultrasonic echo pin goes low. Intuitively, you might write the logic like this:

label("measure_echo_loop")
jmp(not_pin, "measurement_complete") # If echo voltage is low, measurement is complete
jmp(y_dec, "measure_echo_loop") # Continue counting down unless timeout

And when processing the measurement, if we only wish to output values that differ from the previous value, we would write:

label("measurement_complete")
jmp(x_eq_y, "cooldown") # If measurement is the same, skip to cool down
mov(isr, y) # Store measurement in ISR
push() # Output ISR
mov(x, y) # Save the measurement in X

Unfortunately, PIO doesn’t let you test not_pin or x_eq_y directly. You must restructure your logic to accommodate the available conditions, such as pin and x_not_y.

The Solution: The Way It Must Be

Given PIO’s limitations, we adapt our logic with a two-step approach that ensures the desired behavior despite the missing conditions:

  • Jump on the opposite condition to skip two instructions forward.
  • Next use an unconditional jump to reach the desired target.

This workaround adds one extra jump (affecting the instruction limit), but the additional label is cost-free.

Here is the rewritten code for counting down until the pin goes low:

label("measure_echo_loop")
jmp(pin, "echo_active") # If echo voltage is high, continue countdown
jmp("measurement_complete") # If echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Continue counting down unless timeout

And here is the code for processing the measurement such that it will only output differing values:

label("measurement_complete")
jmp(x_not_y, "send_result") # If measurement is different, send it
jmp("cooldown") # If measurement is the same, skip sending
label("send_result")
mov(isr, y) # Store measurement in ISR
push() # Output ISR
mov(x, y) # Save the measurement in X

Lessons from Humpty Dumpty’s Conditions

In Through the Looking-Glass, Alice learns to navigate Humpty Dumpty’s peculiar world — just as you’ll learn to navigate PIO’s Wonderland of lopsided conditions.

But as soon as you master one quirk, another reveals itself. In the next Wat, we’ll uncover a surprising behavior of jmp that, if it were an athlete, would shatter world records.

In Part 1’s Wat 1 and Wat 3, we saw how jmp x_dec or jmp y_dec is often used to loop a fixed number of times by decrementing a register until it reaches 0. Straightforward enough, right? But what happens when y is 0 and we run the following instruction?

jmp(y_dec, "measure_echo_loop")

If you guessed that it does not jump to measure_echo_loop and instead falls through to the next instruction, you’re absolutely correct. But for full credit, answer this: What value does y have after the instruction?

The answer: 4,294,967,295. Why? Because y is decremented after it is tested for zero. Wat!?

This value, 4,294,967,295, is the maximum for a 32-bit unsigned integer. It’s as if a track-and-field long jumper launches off the takeoff board but, instead of landing in the sandpit, overshoots and ends up on another continent.

Aside: As foreshadowed in Wat 5, we can use this behavior intentionally to set a register to the value 4,294,967,295.

Now that we’ve learned how to stick the landing with jmp, let’s see if we can avoid getting stuck by the pins that PIO reads and sets.

In Dr. Seuss’s Too Many Daves, Mrs. McCave had 23 sons, all named Dave, leading to endless confusion whenever she called out their name. In PIO programming, pin and pins can refer to completely different ranges of pins depending on the context. It’s hard to know which Dave or Daves you’re talking to.

The Problem: Pin Ranges and Bases

In PIO, both pin and pins depend on base pins defined outside the program. Each instruction interacts with a specific base pin, and some instructions also operate on a range of pins starting from that base. To clarify PIO’s behavior, I created this table:

Table showing how PIO interprets ‘pin’ and ‘pins’ in different instructions, with their associated contexts and configurations.

Example: Distance Program for the Range Finder

Here’s a PIO program for measuring the distance to an object using Trigger and Echo pins. The key features of this program are:

  • Continuous Operation: The range finder runs in a loop as fast as possible.
  • Maximum Range Limit: Measurements are capped at a given distance, with a return value of 4,294,967,295 if no object is detected.
  • Filtered Outputs: Only measurements that differ from their immediate predecessor are sent, reducing the output rate.

Glance over the program and notice that although it is working with two pins — Trigger and Echo — throughout the program we only see pin and pins.

import rp2

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def distance():
# X is the last value sent. Initialize it to
# u32::MAX which means 'echo timeout'
# (Set X to u32::MAX by subtracting 1 from 0)
set(x, 0)
label("subtraction_trick")
jmp(x_dec, "subtraction_trick")

# Read the max echo wait into OSR.
pull() # same as pull(block)

# Main loop
wrap_target()

# Generate 10μs trigger pulse (4 cycles at 343_000Hz)
set(pins, 0b1)[3] # Set trigger pin to high, add delay of 3
set(pins, 0b0) # Set trigger pin to low voltage

# When the trigger goes high, start counting down until it goes low
wait(1, pin, 0) # Wait for echo pin to be high voltage
mov(y, osr) # Load max echo wait into Y

label("measure_echo_loop")
jmp(pin, "echo_active") # if echo voltage is high continue count down
jmp("measurement_complete") # if echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Continue counting down unless timeout

# Y tells where the echo countdown stopped. It
# will be u32::MAX if the echo timed out.
label("measurement_complete")
jmp(x_not_y, "send_result") # if measurement is different, then sent it.
jmp("cooldown") # If measurement is the same, don't send.
# Send the measurement
label("send_result")
mov(isr, y) # Store measurement in ISR
push() # Output ISR
mov(x, y) # Save the measurement in X

# Cool down period before next measurement
label("cooldown")
wait(0, pin, 0) # Wait for echo pin to be low
wrap() # Restart the measurement loop

Configuring Pins

To ensure the PIO program behaves as intended:

  • set(pins, 0b1) should control the Trigger pin.
  • wait(1, pin, 0) should monitor the Echo pin.
  • jmp(pin, "echo_active") should also monitor the Echo pin.

Here’s how you can configure this in MicroPython:

ECHO_PIN = 16
TRIGGER_PIN = 17

echo = Pin(ECHO_PIN, Pin.IN)
distance_state_machine = rp2.StateMachine(
4, # PIO Block 1, State machine 4
distance, # PIO program
freq=state_machine_frequency,
in_base=echo,
set_base=Pin(TRIGGER_PIN, Pin.OUT),
jmp_pin=echo,
)

The key here is the optional in_base, set_base, and jmp_pin inputs to the StateMachine constructor:

  • in_base: Specifies the starting pin for input operations, such as wait(1, pin, ...).
  • set_base: Configures the starting pin for set operations, like set(pins, 1).
  • jmp_pin: Defines the pin used in conditional jumps, such as jmp(pin, ...).

As described in the table, other optional inputs include:

  • out_base: Sets the starting pin for output operations, such as out(pins, ...).
  • sideset_base: Configures the starting pin for sideset operations, which allow simultaneous pin toggling during other instructions.

Configuring Multiple Pins

Although not required for this program, you can configure a range of pins in PIO using a tuple that specifies the initial states for each pin. Unlike what you might expect, the range is not defined by specifying a base pin and a count (or end). Instead, the tuple determines the pins’ initial values and implicitly sets the range, starting from the set_base pin.

For example, the following PIO decorator configures two pins with initial states of OUT_LOW:

@rp2.asm_pio(set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW))

If set_base is set to pin 17, this tuple designates pin 17 and the next consecutive pin (pin 18) as “set pins.” A single instruction can then control both pins:

set(pins, 0b11)[3]  # Sets both trigger pins (17, 18) high, adds delay
set(pins, 0b00) # Sets both trigger pins low

This approach lets you efficiently apply bit patterns to multiple pins simultaneously, streamlining control for applications involving multiple outputs.

Aside: The Word “Set” in Programming
In programming, the word “set” is notoriously overloaded with multiple meanings. In the context of PIO, “set” refers to something to which you can assign a value — such as a pin’s state. It does not mean a collection of things, as it often does in other programming contexts. When PIO refers to a collection, it usually uses the term “range” instead. This distinction is crucial for avoiding confusion as you work with PIO.

Lessons from Mrs. McCave

In Too Many Daves, Mrs. McCave lamented not giving her 23 Daves more distinct names. You can avoid her mistake by clearly documenting your pins with meaningful names — like Trigger and Echo — in your comments.

But if you think handling these pin ranges is tricky, debugging a PIO program adds an entirely new layer of challenge. In the next Wat, we’ll dive into the kludgy debugging methods available. Let’s see just how far we can push them.

I like to debug with interactive breakpoints in VS Code. MicroPython does not support that.

The fallback is print debugging, where you insert temporary print statements to see what the code is doing and the values of variables. MicroPython supports this, but PIO does not.

The fallback to the fallback is push-to-print debugging. In PIO, you temporarily output integer values of interest. Then, in MicroPython, you print those values for inspection.

For example, in the following PIO program, we temporarily add instructions to push the value of x for debugging. We also include set and out to push a constant value, such as 7, which must be between 0 and 31 inclusive.

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def distance():
# X is the last value sent. Initialize it to
# u32::MAX which means 'echo timeout'
# (Set X to u32::MAX by subtracting 1 from 0)
set(x, 0)
label("subtraction_trick")
jmp(x_dec, "subtraction_trick")

# DEBUG: See the value of X
mov(isr, x)
push()

# Read the max echo wait into OSR.
pull() # same as pull(block)

# DEBUG: Send constant value
set(y, 7) # Push '7' so we know we've reached this point
mov(isr, y)
push()
# ...

Back in MicroPython, you can read and print these values to help understand what’s happening in the PIO code:

import rp2
from machine import Pin

from distance_debug_pio import distance

def demo_debug():
print("Hello, debug!")
pio1 = rp2.PIO(1)
pio1.remove_program()
echo = Pin(16, Pin.IN)
distance_state_machine = rp2.StateMachine(
4, distance, freq=343_000, in_base=echo, set_base=Pin(17, Pin.OUT), jmp_pin=echo
)
try:
distance_state_machine.active(1) # Start the PIO state machine
distance_state_machine.put(500)
while True:
end_loops = distance_state_machine.get()
print(end_loops)
except KeyboardInterrupt:
print("distance demo stopped.")
finally:
distance_state_machine.active(0)

demo_debug()

Outputs:

Hello, debug!
4294967295
7

When push-to-print debugging isn’t enough, you can turn to hardware tools. I bought my first oscilloscope (a FNIRSI DSO152, for $37). With it, I was able to confirm the Echo signal was working. The Trigger signal, however, was too fast for this inexpensive oscilloscope to capture clearly.

Using these methods — especially push-to-print debugging — you can trace the flow of your PIO program, even without a traditional debugger.

Aside: In C/C++ (and potentially Rust), you can get closer to a full debugging experience for PIO, for example, by using the piodebug project.

That concludes the nine Wats, but let’s bring everything together in a bonus Wat.

Now that all the components are ready, it’s time to combine them into a working theremin-like musical instrument. We need a MicroPython monitor program. This program starts both PIO state machines — one for measuring distance and the other for generating tones. It then waits for a new distance measurement, maps that distance to a tone, and sends the corresponding tone frequency to the tone-playing state machine. If the distance is out of range, it stops the tone.

MicroPython’s Place: At the heart of this system is a function that maps distances (from 0 to 50 cm) to tones (approximately B2 to F5). This function is simple to write in MicroPython, leveraging Python’s floating-point math and exponential operations. Implementing this in PIO would be virtually impossible due to its limited instruction set and lack of floating-point support.

Here’s the monitor program to run the theremin:

import math

import machine
import rp2
from machine import Pin

from distance_pio import distance
from sound_pio import sound

BUZZER_PIN = 15
ECHO_PIN = 16
TRIGGER_PIN = 17

CM_MAX = 50
CM_PRECISION = 0.1
LOWEST_TONE_FREQUENCY = 123.47 # B2
OCTAVE_COUNT = 2.5 # to F5

def theremin():
print("Hello, theremin!")

pio0 = rp2.PIO(0)
pio0.remove_program()
sound_state_machine_frequency = machine.freq()
sound_state_machine = rp2.StateMachine(0, sound, set_base=Pin(BUZZER_PIN))

pio1 = rp2.PIO(1)
pio1.remove_program()
echo = Pin(ECHO_PIN, Pin.IN)
distance_state_machine_frequency = int(2 * 34300.0 / CM_PRECISION / 2.0)
distance_state_machine = rp2.StateMachine(
4,
distance,
freq=distance_state_machine_frequency,
set_base=Pin(TRIGGER_PIN, Pin.OUT),
in_base=echo,
jmp_pin=echo,
)
max_loops = int(CM_MAX / CM_PRECISION)

try:
sound_state_machine.active(1)
distance_state_machine.active(1)
distance_state_machine.put(max_loops)

while True:
end_loops = distance_state_machine.get()
distance_cm = loop_difference_to_distance_cm(max_loops, end_loops)
if distance_cm is None:
sound_state_machine.put(0)
else:
tone_frequency = distance_to_tone_frequency(distance_cm)
print(f"Distance: {distance_cm} cm, tone: {tone_frequency} Hz")
half_period = int(sound_state_machine_frequency / (2 * tone_frequency))
sound_state_machine.put(half_period)
except KeyboardInterrupt:
print("theremin stopped.")
finally:
sound_state_machine.active(0)
distance_state_machine.active(0)

def loop_difference_to_distance_cm(max_loops, end_loops):
if end_loops == 0xFFFFFFFF:
return None
distance_cm = (max_loops - end_loops) * CM_PRECISION
return distance_cm

def distance_to_tone_frequency(distance):
return LOWEST_TONE_FREQUENCY * 2.0 ** ((distance / CM_MAX) * OCTAVE_COUNT)

theremin()

Notice how using two PIO state machines and a MicroPython monitor program lets us run three programs at once. This approach combines simplicity with responsiveness, achieving a level of performance that would otherwise be difficult to realize in MicroPython alone.

Now that we’ve assembled all the components, let’s watch the video again of me “playing” the musical instrument. On the monitor screen, you can see the debugging prints displaying the distance measurements and the corresponding tones. This visual connection highlights how the system responds in real time.

PIO programming on the Raspberry Pi Pico is a captivating blend of simplicity and complexity, offering unparalleled hardware control while demanding a shift in mindset for developers accustomed to higher-level programming. Through the nine Wats we’ve explored, PIO has both surprised us with its limitations and impressed us with its raw efficiency.

While we’ve covered significant ground — managing state machines, pin assignments, timing intricacies, and debugging — there’s still much more you can learn as needed: DMA, IRQ, side-set pins, differences between PIO on the Pico 1 and Pico 2, autopush and autopull, FIFO join, and more.

Recommended Resources

At its core, PIO’s quirks reflect a design philosophy that prioritizes low-level hardware control with minimal overhead. By embracing these characteristics, PIO will not only meet your project’s demands but also open doors to new possibilities in embedded systems programming.

Please follow Carl on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.

Source link

#Pico #PIO #Wats #MicroPython #Part #Carl #Kadie #Jan