That is Part 2 of an exploration into the unexpected quirks of programming the Raspberry Pi Pico PIO with Micropython. For those who missed Part 1, we uncovered 4 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 among the quirks and perplexities of PIO programming. Prepare to challenge your understanding of constants in a way that brings to mind a Shakespearean tragedy.
Wat 5: Inconstant constants
On the earth of PIO programming, constants ought to be reliable, steadfast, and, well, . 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 issue: Constants should not as big as they appear
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 naïvely attempt to load the constant value directly using set
:
; In Rust, ensure 'config.shift_in.direction = ShiftDirection::Left;'
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 ; Transfer back to y
But here’s the tragic twist: the set instruction in PIO is proscribed to constants between 0 and 31. Furthermore, the star-crossed set instruction doesn’t report an error. As an alternative, it silently corrupts your entire PIO instruction. 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 possibly can load your constant within the
osr
register, then transfer it to y. 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 the in instruction, you’ll be able to construct up a continuing of any size. This, nevertheless, consumes time and operations out of your 32-operation budget (see Part 1, Wat 2).
; In Rust, ensure 'config.shift_in.direction = ShiftDirection::Left;'
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 ; Transfer back 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 125 MHz to 343 kHz reduces the timeout constant
182
,216
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 technique 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 should not at all times 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 conditionals. In the following Wat, we’ll explore how PIO’s handling of conditional jumps can leave you questioning its loyalty to logic.
Wat 6: Conditionals through the looking-glass
In most programming environments, logical conditionals feel balanced: you’ll be able to test if a pin is high or low, or check registers for equality or inequality. In PIO, this symmetry breaks down. You possibly can jump on pin high, but not pin low, and on x!=y
, but not x==y
. The principles are whimsical — like Humpty Dumpty in : “Once I define a conditional, 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 issue: Lopsided conditionals in motion
Consider a straightforward scenario: using a spread finder, you must count down from a maximum wait time (y) until the ultrasonic echo pin goes low. Intuitively, you may write the logic like this:
measure_echo_loop:
jmp !pin measurement_complete ; If echo voltage is low, measurement is complete
jmp y-- 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 might write:
measurement_complete:
jmp x==y cooldown ; If measurement is identical, 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 !pin
or x==y
directly. It’s essential to restructure your logic to accommodate the available conditionals, akin to pin
and x!=y
.
The answer: The best way it have to be
Given PIO’s limitations, we adapt our logic with a two-step approach that ensures the specified behavior despite the missing conditionals:
- Jump on the other conditional to skip two instructions forward.
- Next, use an unconditional jump to succeed in 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:
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
echo_active:
jmp y-- measure_echo_loop ; Proceed counting down unless timeout
And here is the code for processing the measurement such that it’ll only output differing values:
measurement_complete:
jmp x!=y send_result ; if measurement is different, then send it.
jmp cooldown ; If measurement is identical, don't send.
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 conditionals
In , 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--
or jmp y--
is usually used to loop a hard and fast 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-- measure_echo_loop
For those who guessed that it does not jump to measure_echo_loop
and as an alternative falls through to the following instruction, you’re 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.
Aside: If this doesn’t surprise you, you likely have experience with C or C++ which distinguish between pre-increment (e.g.,
++x
) and post-increment (e.g., x++) operations. The behavior ofjmp y--
is reminiscent of a post-decrement, where the worth is tested being decremented.
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 will use this behavior intentionally to set a register to the worth 4,294,967,295.
Now that we’ve learned stick the landing with jmp
, let’s see if we will avoid getting stuck by the pins that PIO reads and sets.
In Dr. Seuss’s , 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 confer with completely different ranges of pins depending on the context. It’s hard to know which Dave or Daves you’re talking to.
The issue: Pin ranges and subranges
In PIO, each pin
and pins
instructions rely upon pin ranges defined in Rust, outside of PIO. Nonetheless, individual instructions often operate on a subrange of those pin ranges. The behavior varies depending on the command: the subrange could possibly be the primary pins of the range, all of the pins, or simply a selected pin given by an index. To make clear PIO’s behavior, I created the next table:
This table shows how PIO interprets the terms pin
and pins
in several instructions, together with their associated contexts and configurations.
Example: Distance program for the range finder
Here’s a PIO program for measuring the space 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
.
.program distance
; X is the last value sent. Initialize it to
; u32::MAX which suggests 'echo timeout'
; (Set X to u32::MAX by subtracting 1 from 0)
set x, 0
subtraction_trick:
jmp x-- subtraction_trick
; Read the max echo wait into OSR
pull ; same as pull block
; Major 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
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
echo_active:
jmp y-- measure_echo_loop ; Proceed counting down unless timeout
; Y tells where the echo countdown stopped. It
; shall be u32::MAX if the echo timed out.
measurement_complete:
jmp x!=y send_result ; if measurement is different, then sent it.
jmp cooldown ; If measurement is identical, don't send.
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
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
must also monitor the Echo pin.
Here’s how you’ll be able to configure this in Rust (followed by an evidence):
let mut distance_state_machine = pio1.sm0;
let trigger_pio = pio1.common.make_pio_pin(hardware.trigger);
let echo_pio = pio1.common.make_pio_pin(hardware.echo);
distance_state_machine.set_pin_dirs(Direction::Out, &[&trigger_pio]);
distance_state_machine.set_pin_dirs(Direction::In, &[&echo_pio]);
distance_state_machine.set_config(&{
let mut config = Config::default();
config.set_set_pins(&[&trigger_pio]); // For set instruction
config.set_in_pins(&[&echo_pio]); // For wait instruction
config.set_jmp_pin(&echo_pio); // For jmp instruction
let program_with_defines = pio_file!("examples/distance.pio");
let program = pio1.common.load_program(&program_with_defines.program);
config.use_program(&program, &[]); // No side-set pins
config
});
The keys listed below are the set_set_pins
, set_in_pins
, and set_jmp_pin
methods on the Config struct.
set_in_pins
: Specifies the pins for input operations, akin to wait(1, pin, …). The “in” pins have to be consecutive.set_set_pins
: Configures the pin for set operations, like set(pins, 1). The “set” pins must even be consecutive.set_jmp_pin
: Defines the one pin utilized in conditional jumps, akin tojmp(pin, ...)
.
As described within the table, other optional inputs include:
set_out_pins
: Sets the consecutive pins for output operations, akin to out(pins, …).use_program
: Sets a) the loaded program and b) consecutive pins for sideset operations. Sideset operations allow simultaneous pin toggling during other instructions.
Configuring Multiple Pins
Although not required for this program, you’ll be able to configure a spread of pins in PIO by providing a slice of consecutive pins. For instance, suppose we had two ultrasonic range finders:
let trigger_a_pio = pio1.common.make_pio_pin(hardware.trigger_a);
let trigger_b_pio = pio1.common.make_pio_pin(hardware.trigger_b);
config.set_set_pins(&[&trigger_a_pio, &trigger_b_pio]);
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 helps you to efficiently apply bit patterns to multiple pins concurrently, streamlining control for applications involving multiple outputs.
Lessons from Mrs. McCave
In , Mrs. McCave lamented not giving her 23 Daves more distinct names. You possibly can avoid her mistake by clearly documenting your pins with meaningful names — like Trigger and Echo — in your comments.
But when you think handling these pin ranges is difficult, debugging a PIO program adds a wholly latest layer of challenge. In the following Wat, we’ll dive into the kludgy debugging methods available. Let’s see just how far we will push them.
I prefer to debug with interactive breakpoints in VS Code. I also do print debugging, where you insert temporary info statements to see what the code is doing and the values of variables. Using the Raspberry Pi Debug Probe and probe-rs, I can do each of those with regular Rust code on the Pico.
With PIO programming, nevertheless, I can do neither.
The fallback is push-to-print debugging. In PIO, you temporarily output integer values of interest. Then, in Rust, you utilize info!
to 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, akin to 7, which have to be between 0 and 31 inclusive.
.program distance
; X is the last value sent. Initialize it to
; u32::MAX which suggests 'echo timeout'
; (Set X to u32::MAX by subtracting 1 from 0)
set x, 0
subtraction_trick:
jmp x-- 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' in order that we all know we have reached this point
mov isr, y
push
; ...
Back in Rust, you’ll be able to read and print these values to assist understand what’s happening within the PIO code (full code and project):
// ...
distance_state_machine.set_enable(true);
distance_state_machine.tx().wait_push(MAX_LOOPS).await;
loop {
let end_loops = distance_state_machine.rx().wait_pull().await;
info!("end_loops: {}", end_loops);
}
// ...
Outputs:
INFO Hello, debug!
└─ distance_debug::inner_main::{async_fn#0} @ examplesdistance_debug.rs:27
INFO end_loops: 4294967295
└─ distance_debug::inner_main::{async_fn#0} @ examplesdistance_debug.rs:57
INFO end_loops: 7
└─ distance_debug::inner_main::{async_fn#0} @ examplesdistance_debug.rs:57
When push-to-print debugging isn’t enough, you’ll be able to turn to hardware tools. I purchased my first oscilloscope (a FNIRSI DSO152, for $37). With it, I used to be able to verify the Echo signal was working. The Trigger signal, nevertheless, was too fast for this inexpensive oscilloscope to capture clearly.
Using these methods — especially push-to-print debugging — you’ll be able to trace the flow of your PIO program, even with out a traditional debugger.
That concludes the nine Wats, but let’s bring every part together in a bonus Wat.
Now that every one the components are ready, it’s time to mix them right into a working theremin-like musical instrument. We want a Rust 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 space is out of range, it stops the tone.
Rust’s Place: At the guts of this method is a function that maps distances (from 0 to 50 cm) to tones (roughly B2 to F5). This function is straightforward to put in writing in Rust, leveraging Rust’s floating-point math and exponential operations. Implementing this in PIO can be virtually unattainable resulting from its limited instruction set and lack of floating-point support.
Here’s the core monitor program to run the theremin (full file and project):
sound_state_machine.set_enable(true);
distance_state_machine.set_enable(true);
distance_state_machine.tx().wait_push(MAX_LOOPS).await;
loop {
let end_loops = distance_state_machine.rx().wait_pull().await;
match loop_difference_to_distance_cm(end_loops) {
None => {
info!("Distance: out of range");
sound_state_machine.tx().wait_push(0).await;
}
Some(distance_cm) => {
let tone_frequency = distance_to_tone_frequency(distance_cm);
let half_period = sound_state_machine_frequency / tone_frequency as u32 / 2;
info!("Distance: {} cm, tone: {} Hz", distance_cm, tone_frequency);
sound_state_machine.tx().push(half_period); // non-blocking push
Timer::after(Duration::from_millis(50)).await;
}
}
}
Using two PIO state machines alongside a Rust monitor program helps you to literally run three programs directly. This setup is convenient by itself and is crucial when strict timing or very high-frequency I/O operations are required.
loop {
match distance.measure().await {
None => {
info!("Distance: out of range");
sound.rest().await;
}
Some(distance_cm) => {
let tone_frequency = distance_to_tone_frequency(distance_cm);
info!("Distance: {} cm, tone: {} Hz", distance_cm, tone_frequency);
sound.play(tone_frequency).await;
Timer::after(Duration::from_millis(50)).await;
}
}
}
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’ll be able to see the debugging prints displaying the space measurements and the corresponding tones. This visual connection highlights how the system responds in real time.
Conclusion
PIO programming on the Raspberry Pi Pico is a fascinating 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’ll be able to 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.
Beneficial 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 additionally open doors to latest possibilities in embedded systems programming.