That is Part 2 of an exploration into the unexpected quirks of programming the Raspberry Pi Pico PIO with MicroPython. If you happen to missed Part 1, we uncovered 4 Wats that challenge assumptions about register count, instruction slots, the behavior of pull(noblock)
, and smart yet low-cost hardware.
Now, we proceed our journey toward crafting a theremin-like musical instrument — a project that reveals a few 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.
On this planet of PIO programming, constants needs to be reliable, steadfast, and, well, constant. But what in the event that 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.
Very similar to Juliet doubting Romeo’s constancy, you may end up 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 wish to count down from 500 while waiting for the Echo signal to drop from high to low. To establish this wait time in PIO, you may naively attempt 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 proceed count down
jmp("measurement_complete") # if echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Proceed counting down unless timeout
Aside: Don’t try to know 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 restricted to constants between 0
and 31
. Furthermore, MicroPython’s star-crossed set
instruction doesn’t report an error. As a substitute, it silently corrupts your complete PIO instruction. (PIO from Rust shows the same problem.) This produces a nonsense result.
Workarounds for Inconstant Constants
To deal with this limitation, consider the next approaches:
- Read Values and Store Them in a Register: We saw this approach in Wat 1. You may load your constant within the
osr
register, then transfer it toy
. For instance:
# Read the max echo wait into OSR.
pull() # same as pull(block)
mov(y, osr) # Load max echo wait into Y
- Shift and Mix Smaller Values: Using the
isr
register and thein_
instruction, you possibly can construct up a continuing of any size. This, nonetheless, consumes time and operations out of 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 instance, lowering the state machine speed from 150 MHz to 343 kHz reduces the timeout constant
218,659
to500
. - Use Extra Delays and (Nested) Loops: All instructions support an optional delay, allowing you so as to add as much as 31 extra cycles. (To generate even longer delays, use loops — and 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 three
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 option to generate
4,294,967,295
(the utmost unsigned 32-bit integer) via subtraction.
Very similar to Juliet cautioning against swearing by the inconstant moon, we’ve discovered that PIO constants aren’t all the time as steadfast as they appear. 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 following 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 possibly can test if a pin is high or low, or check registers for equality or inequality. In PIO, this symmetry breaks down. You may jump on pin high, but not pin low, and on x_not_y
, but not x_eq_y
. The principles are whimsical — like Humpty Dumpty in Through the Looking-Glass: “Once I offer a condition, it means just what I select it to mean — neither more nor less.”
These quirks force us to rewrite our code to suit the lopsided logic, making a gulf between how we wish the code could possibly be written and the way we must write it.
The Problem: Lopsided Conditions in Motion
Consider an easy scenario: using a variety finder, you wish to count down from a maximum wait time (y
) until the ultrasonic echo pin goes low. Intuitively, you may 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") # Proceed counting down unless timeout
And when processing the measurement, if we only want to output values that differ from the previous value, we’d write:
label("measurement_complete")
jmp(x_eq_y, "cooldown") # If measurement is similar, skip to chill down
mov(isr, y) # Store measurement in ISR
push() # Output ISR
mov(x, y) # Save the measurement in X
Unfortunately, PIO doesn’t allow you to test not_pin
or x_eq_y
directly. You will need to restructure your logic to accommodate the available conditions, equivalent to 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 specified behavior despite the missing conditions:
- Jump on the alternative condition to skip two instructions forward.
- Next use an unconditional jump to achieve the specified goal.
This workaround adds one extra jump (affecting the instruction limit), but the extra 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, proceed countdown
jmp("measurement_complete") # If echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Proceed counting down unless timeout
And here is the code for processing the measurement such that it should only output differing values:
label("measurement_complete")
jmp(x_not_y, "send_result") # If measurement is different, send it
jmp("cooldown") # If measurement is similar, 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, one other reveals itself. In the following 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 commonly used to loop a set variety of times by decrementing a register until it reaches 0. Straightforward enough, right? But what happens when y
is 0 and we run the next instruction?
jmp(y_dec, "measure_echo_loop")
If you happen to guessed that it does not jump to measure_echo_loop
and as an alternative falls through to the following instruction, you are absolutely correct. But for full credit, answer this: What value does y
have after the instruction?
The reply: 4,294,967,295. Why? Because y
is decremented after it’s tested for zero. Wat!?
This value, 4,294,967,295, is the utmost for a 32-bit unsigned integer. It’s as if a track-and-field long jumper launches off the takeoff board but, as an alternative of landing within the sandpit, overshoots and finally ends up on one other continent.
Aside: As foreshadowed in Wat 5, we are able to use this behavior intentionally to set a register to the worth 4,294,967,295.
Now that we’ve learned how you can stick the landing with jmp
, let’s see if we are able to 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, resulting in countless confusion at any time when she called out their name. In PIO programming, pin
and pins
can discuss with completely different ranges of pins depending on the context. It’s hard to know which Dave or Daves you are talking to.
The Problem: Pin Ranges and Bases
In PIO, each pin
and pins
rely on base pins defined outside this system. Each instruction interacts with a selected base pin, and a few instructions also operate on a variety of pins ranging from that base. To make clear PIO’s behavior, I created this table:
Table showing how PIO interprets ‘pin’ and ‘pins’ in numerous instructions, with their associated contexts and configurations.
Example: Distance Program for the Range Finder
Here’s a PIO program for measuring the gap to an object using Trigger and Echo pins. The important thing 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 this system and spot that even though it is working with two pins — Trigger and Echo — throughout this system 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 implies '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)
# Important 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 three
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 proceed count down
jmp("measurement_complete") # if echo voltage is low, measurement is complete
label("echo_active")
jmp(y_dec, "measure_echo_loop") # Proceed counting down unless timeout
# Y tells where the echo countdown stopped. It
# will likely 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 similar, 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 make sure 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")
also needs to monitor the Echo pin.
Here’s how you possibly can configure this in MicroPython:
ECHO_PIN = 16
TRIGGER_PIN = 17echo = 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 important thing 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, equivalent towait(1, pin, ...)
.set_base
: Configures the starting pin for set operations, likeset(pins, 1)
.jmp_pin
: Defines the pin utilized in conditional jumps, equivalent tojmp(pin, ...)
.
As described within the table, other optional inputs include:
out_base
: Sets the starting pin for output operations, equivalent toout(pins, ...)
.sideset_base
: Configures the starting pin for sideset operations, which permit simultaneous pin toggling during other instructions.
Configuring Multiple Pins
Although not required for this program, you possibly can configure a variety of pins in PIO using a tuple that specifies the initial states for every pin. Unlike what you may expect, the range will not be defined by specifying a base pin and a count (or end). As a substitute, the tuple determines the pins’ initial values and implicitly sets the range, ranging from the set_base
pin.
For instance, the next 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 about to pin 17, this tuple designates pin 17 and the following consecutive pin (pin 18) as “set pins.” A single instruction can then control each pins:
set(pins, 0b11)[3] # Sets each trigger pins (17, 18) high, adds delay
set(pins, 0b00) # Sets each trigger pins low
This approach permits you to efficiently apply bit patterns to multiple pins concurrently, streamlining control for applications involving multiple outputs.
Aside: The Word “Set” in Programming
In programming, the word “set” is notoriously overloaded with multiple meanings. Within the context of PIO, “set” refers to something to which you’ll assign a price — equivalent to a pin’s state. It does not mean a set of things, because it often does in other programming contexts. When PIO refers to a set, it normally uses the term “range” as an alternative. This distinction is crucial for avoiding confusion as you’re employed with PIO.
Lessons from Mrs. McCave
In Too Many Daves, Mrs. McCave lamented not giving her 23 Daves more distinct names. You may avoid her mistake by clearly documenting your pins with meaningful names — like Trigger and Echo — in your comments.
But if you happen to think handling these pin ranges is difficult, debugging a PIO program adds a completely recent layer of challenge. In the following Wat, we’ll dive into the kludgy debugging methods available. Let’s see just how far we are able to push them.
I prefer to debug with interactive breakpoints in VS Code. MicroPython doesn’t 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 doesn’t.
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 instance, in the next PIO program, we temporarily add instructions to push the worth of x
for debugging. We also include set
and out
to push a continuing value, equivalent to 7, which should 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 implies '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 worth 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 all know we have reached this point
mov(isr, y)
push()
# ...
Back in MicroPython, you possibly can read and print these values to assist understand what’s happening within the PIO code:
import rp2
from machine import Pinfrom 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.energetic(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.energetic(0)
demo_debug()
Outputs:
Hello, debug!
4294967295
7
When push-to-print debugging isn’t enough, you possibly can turn to hardware tools. I purchased my first oscilloscope (a FNIRSI DSO152, for $37). With it, I used to be able to substantiate the Echo signal was working. The Trigger signal, nonetheless, was too fast for this inexpensive oscilloscope to capture clearly.
Using these methods — especially push-to-print debugging — you possibly can trace the flow of your PIO program, even with no traditional debugger.
Aside: In C/C++ (and potentially Rust), you possibly can catch up with to a full debugging experience for PIO, for instance, by utilizing the piodebug project.
That concludes the nine Wats, but let’s bring the whole lot together in a bonus Wat.
Now that each one the components are ready, it’s time to mix them right into a working theremin-like musical instrument. We want a MicroPython monitor program. This program starts each PIO state machines — one for measuring distance and the opposite for generating tones. It then waits for a brand new distance measurement, maps that distance to a tone, and sends the corresponding tone frequency to the tone-playing state machine. If the gap is out of range, it stops the tone.
MicroPython’s Place: At the center of this technique is a function that maps distances (from 0 to 50 cm) to tones (roughly B2 to F5). This function is straightforward to jot down in MicroPython, leveraging Python’s floating-point math and exponential operations. Implementing this in PIO can be virtually unimaginable as a consequence of its limited instruction set and lack of floating-point support.
Here’s the monitor program to run the theremin:
import mathimport 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.energetic(1)
distance_state_machine.energetic(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.energetic(0)
distance_state_machine.energetic(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 directly. This approach combines simplicity with responsiveness, achieving a level of performance that may otherwise be difficult to comprehend in MicroPython alone.
Now that we’ve assembled all of the components, let’s watch the video again of me “playing” the musical instrument. On the monitor screen, you possibly can see the debugging prints displaying the gap 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 charming mix 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 each 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 far more you possibly 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.
Advisable 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 won’t only meet your project’s demands but in addition open doors to recent 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 jot down about one article per 30 days.