Basics of Design CC Blog Research & Design Hub

Programmable IO Programming

Written by Miguel Sanchez

For the Raspberry Pi Pico

The Raspberry Pi Pico’s programmable I/O allows offloading time-critical GPIO pin handling to state machines programmed using a separate instruction set.

  • Raspberry Pi | www.raspberrypi.com

The RP2040 (also known as the Raspberry Pi Pico) is a microcontroller (MCU) board developed by the Raspberry Pi Foundation, known for the popular single-board computer (SBC) Raspberry Pi. However, the Raspberry Pi Pico is an MCU board designed for embedded systems and electronics projects, not a full-fledged Linux computer board. It was officially released in January 2021.

Some of the key features of the Raspberry Pi Pico include a dual-core ARM Cortex-M0+ processor, and powerful I/O with 26 GPIO pins supporting PWM, SPI, I2C, and UARTs. The board includes a USB connection for power, communication, and programming. It was launched at $4, making it an affordable 32-bit MCU. Both C++ and MicroPython development are possible.

It is no wonder the Pico became popular despite the pandemic. Its lack of built-in devices makes it different from other processors, as it only features one timer with four alarms. It does include UARTs, SPI, PWM, and I2C, so it is not entirely lacking. However, the RP2040 includes a powerful tool for high-speed communication interfaces and programmable I/O (PIO) using an additional unit. Up to eight state machines can be run in parallel for such tasks while the two processor cores keep working undisturbed.

Other processors inside your MCU

These state machines are fully programmable and have their own instruction set. This means a program can be written to detail what needs to be done with the IO pins for a given task. The beauty is that state machines can run at full clock speed, allowing for signal timing precision of a single clock cycle, as all the PIO instructions take a single cycle to execute. This way, an SD card or VGA interface is possible.

The instruction set of such a state machine is entirely unrelated to the main core’s instruction set. While the state machine cannot run MicroPython code, it can be programmed with the help of MicroPython, as we will see in the next section.

You do not need PIO in most cases, so many applications will completely ignore it. But if an application requires a rapid response to a signal or a fast complex interface, this feature can be a lifesaver, as a software-only implementation using the standard GPIO instructions might not be quick enough, and timing might not be accurate enough in some cases. This matter becomes even more critical when using MicroPython. For these cases, this feature saves the day and avoids adding additional hardware to handle a peripheral.

Programmable IO primer

There are only nine different instructions. Each instruction requires a single cycle and is 16 bits long. The format of any instructions is as follows:

<instruction> .side(value) .delay(value)

# delay can also be written as [value]

Please note the second and third elements are optional. This means that each instruction, besides doing a particular operation, may include an additional side-set operation (to act upon I/O pins) and an additional number of delay cycles, thus increasing the clock cycles it consumes.

Table 1 lists all the instructions and how they are written in MicroPython. These instructions are transformed into 16-bit operation codes before a state machine starts running them.

TABLE 1
All the PIO instructions and how they are written in MicroPython.
TABLE 1
All the PIO instructions and how they are written in MicroPython.

Most instructions are going to use one or more 32-bit-long registers. Each state machine has two scratchpad registers named X and Y, plus two shift registers named ISR and OSR. There is a program counter (PC) register too.

Let us review the role of each one of these instructions:

  • SET is the way to store a 5-bit value in a register, or up to five output pins.
  • MOV loads the content of one register into another, or to I/O pins.
  • JMP jumps to a given label. It can be conditional or unconditional.
  • IN shifts data in.
  • OUT shifts data out.
  • WAIT freezes the program at this point until a condition is met.
  • PUSH allows the program to send data to a FIFO.
  • PULL allows the program to read data from a FIFO.

We are going to present several examples that will make use of the different instructions. The duration of the clock cycle of the programmable IO processing is not fixed, but a user-selectable fraction of the system clock (that runs at the default frequency of 125MHz). A clock divider will provide the clock frequency of the programmable IO.

Sample PIO programs

Since an image is worth a thousand words, let us look at some examples from the Pico’s MicroPython examples. (More examples can be found on GitHub, a link to which is available on Circuit Cellar’s Article Materials and Resources web page [1].)

Blink example: The simplest is the “blink” example, which blinks an LED on pin 25. Blinking an LED at 1Hz frequency could be done with a simple loop in MicroPython. It consists of no high-speed signals, and the timing involved does not require microsecond accuracy (my bad). But bear with me for a little longer. Using PIO will release your central processor of that task, freeing it for other, more productive tasks. So, let us see how this can be done.

The blink operation will be encapsulated inside a function we call blink(), a regular Python function with no input parameters. Function definition will be prefixed with the @rp2.asm_pio decorator so that we can include state machine instructions within blink(). Thankfully, there are only nine instructions (see Table 1). The rp2 module comes pre-installed with the Pico’s MicroPython.

Blink operation requires setting a pin to one; after some elapsed time, the pin will be set to zero, and after some elapsed time, the whole process repeats, over and over again. The SET instruction, which we can invoke with the function set() on MicroPython code, can set one (or more) pins to a given value. We need some way of measuring time to control when we set the LED pin on or off. We could use the no-operation instruction (NOP) that uses one instruction cycle doing nothing. If done enough times, we can obtain the desired period between activation and deactivation of the LED. We can set the clock speed for each state machine, and that will be its clock speed, where any instruction takes precisely one cycle. With this idea in mind, it seems the most straightforward way of writing our state machine would be to set its frequency to 2Hz so our program will only set the LED to on and next to off.

Given that each instruction will take a complete cycle, it will last for 0.5 seconds, making the LED last at 1Hz. Unfortunately, that is not allowed as the lower frequency value is slightly under 2000Hz. That means we need extra code to add a delay so our blink operates at the desired 1Hz. If we select the clock frequency to 2000Hz, each cycle (and thus each instruction) will last half a millisecond. After the LED has been set on, an additional 499.5-millisecond delay is needed before setting off the LED. That would be 999 instructions (or cycles). I must mention another restriction now: a state machine’s program is limited to a maximum of 32 instructions.

Two additional elements allow us to give a first solution to the problem: each state machine has two 32-bit scratchpad registers named X and Y, and a conditional jump instruction may perform the increment or decrement of a register, too. With these pieces, a delay loop can be created easily: load the desired number of cycles in a register and keep looping and decreasing its value until zero. Such a loop would consist of a SET instruction to load the number of cycles on a register (X or Y), and a conditional jump (JMP) that will decrease the value of such register and that will jump to repeat itself if the register is not zero. If the register reaches zero, the number of cycles has been completed, and the desired time has elapsed; hence, there is no jump, and the next instruction after the loop can be reached for the program to continue.

Another limitation is that SET instruction only deals with 5-bit integers, so 31 is the largest integer that can be loaded into a register. If we need to repeat a loop several hundred times, we have a problem with this approach. However, an additional mechanism of the instruction set can be valuable here: any instruction may add a delay to its duration of up to 31 additional instruction cycles. That means that if you want to achieve a 499.5-millisecond delay, you could load a value of 31 on the X register and then make the jump to decrement the register and, if not zero, jump again to itself. The total time elapsed would be one cycle for the SET instruction plus 32 cycles running the JMP instruction, which totals 33 cycles (or 16.5 milliseconds). That is not what we want, but if we set an extra delay of 30 cycles to the JMP instruction, the loop would take 993 cycles (31*32+1) or 496.5 milliseconds. We are getting quite close now; we could add six extra cycles to the SET instruction to complete the desired 999 cycles (31*32+7) or 499.5 milliseconds.

Let me show you how we can achieve this with just six instructions in Listing 1. I hope this demonstrates how the state machine could do one blink cycle with the right timing, but what about doing that forever? We might need a conditional loop at the end of our instructions to start over. However, that jump is unnecessary, as the function we create code in is, by default, looping forever without us needing to make it explicit. That works okay in our case. However, it can be tailored to the specific needs of a more general case where we may not want the whole thing to repeat forever. To that end, we can explicitly signal the repeating block with the wrap_target() and wrap() marks within our function. If you do not use them, it is assumed that all your code is to be looped.

LISTING 1
Blink example PIO code inside a Python function

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW) def blink():	set(pins , 1)  # SET LED on	set(x,31) [6] # SET X=31 for 32 looping 32 times 	label(‘loop1’) # not an instruction (no cycle used) 	jmp(x_dec,’loop1’) [30] # decr X & JMP till X==0 	set(pins, 0)	# SET LED off 	set(x,31) [6] # SET X=31 for 32 looping 32 times 	label(‘loop0’) # not an instruction (no cycle used) 	jmp(x_dec,’loop0’) [30] # decr X &JMP till X==0
LISTING 2
Python program, including the PIO code and state machine activation for the servo example

from machine import Pin import rp2import time@rp2.asm_pio (set_init=rp2.PIO.OUT_LOW) def servo():	pull(noblock)	mov(x, osr) # load X extra delay value 	mov(isr,x)	set(pins, 1)	set(y, 31)	# minimum delay loop 	label(‘loopMin’) 	nop() [31] 	jmp(y_dec,’loopMin’) 	label(‘extraDelay’)	#additional delay for the pulse 	jmp(x_dec,’extraDelay’)	#now pin is to 0 and a 19 ms delay is made 	set(pins,0) 	set(y,17) 	label(‘offDelay’) 	set(x,31) 	label(‘innerLoop’) 	nop() [31] 	jmp(x_dec,’innerLoop’) 	jmp(y_dec,’offDelay’)	mov(x,isr) # restore value to X for noblockdef change(v):	if v>=0 and v<=1000: 		sm.put(v)sm = rp2.StateMachine(0, servo, freq=10000000, set_base=Pin(0))sm.active(1)
LISTING 3
Stepper motor sample code with PIO

@rp2.asm_pio (set_init=rp2.PIO.OUT_LOW)def steps():	pull() # reads on 32-bit word from FIFO	mov(x, osr) # load X with that word	label('more_steps')	set(pins, 1) [1] # rising edge to step pin	set(pins, 0) # falling edge now to step pin	nop() [31]	nop() [31]	nop() [31] # 96 cycles of OFF time to get the freq	jmp(x_dec,'more_steps')sm = rp2.StateMachine(0, steps, freq=desired_step_freq*100,set_base=Pin(25))sm.active(1)#every time you need a sequence of pulses …sm.put(32) #send 33 pulses
LISTING 4
Pulse generator example PIO code only (I was planning to include a set of files with the complete program for each example to be added to the FTP server, listing 4 is not a complete program but just the "core" function with the PIO code).

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)def pulse_generator():	pull() # number of pulses X	mov(x,osr)	pull() # delay iterations Y	label(“pulse_loop”)	set(pins, 1) # Set the GPIO pin high	nop() [31]	nop() [31]	nop() [31]	nop() [2] # 100 cycles for step pulse	set(pins, 0) # Set the GPIO pin low	mov(y,osr) # Y for delay	label(“delay”)	jmp(y_dec, “delay”)	jmp(x_dec, “pulse_loop”)
LISTING 5
Python program with PIO code to control WS2812 RGB leds.

from machine import Pinimport rp2import time@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW, out_init=rp2.PIO.OUT_LOW, sideset_init=rp2.PIO.OUT_LOW, 
out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=24)def rgb():	set(pins, 1) # first 400ns always high level	mov(pins, x) # second one, the actual bit value	out(x, 1) .side(0) # third one, always low, wait here for data!!sm = rp2.StateMachine(0, rgb, freq=2_500_000, set_base=Pin(0), out_base=Pin(0), sideset_base=Pin(0))sm.active(1)time.sleep(0.0001) # delay to cause a reset after the spurious pulse that happens on activationsm.put((0x80<<16)+(0x55<<8)+1, 8)

One minor detail about the conditional jump instruction: the state machine will first check if the register is zero, and if it is, then no jump will be performed. Otherwise, the register will be decreased, and the jump will be performed. This means that the number of times it will loop to itself will be the value of the register, but the total time consumed will include the last time when the register value was zero and no jump was performed. So, if we load 31 on the X register, the JMP instruction of the delay loop will be executed 32 times. Likewise, when we add 30 cycles to the JMP instruction, we make the execution time of that instruction 31 cycles (the original cycle of all instructions plus the 30 added). Finally, the SET instruction lasts seven cycles once we add six extra cycles. If all this looks terrible as a lesson on assembly language, that’s because it is.

Now, we need to see what else we have to do to create a state machine to run this code and to set it all in motion. The instruction sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25)) creates a new state machine number 0; its code is defined inside function blink(), its clock is set to 2000Hz so each cycle will last half a millisecond, and the first pin used would be pin 25. Please note that this creates the state machine but does not start it, so there is no blinking yet. Once a state machine has been created, it can be started with sm.active(1) and stopped with sm.active(0). In our example, activating the state machine will initiate the blinking, and stopping it will cancel it.

Once activated, the blink will proceed without using any computing resources from your processor’s cores. Some readers may recognize a similar operation on a PWM output, where your program can set it and forget it, since there is dedicated hardware to keep the selected duty-cycle signal for that pin. Blink is no more than a 50% duty cycle signal, but PWM’s lowest frequency for the RP2040 is 7.5Hz, so it was impossible to use it for a 1Hz blink signal.

I know, I know—blinking is not exciting. But we needed to start with the simplest example possible, which, despite all my efforts, ended up being trickier than anticipated. Please note that there is no information flowing from or to the state machine. Many more useful scenarios will require that; for that purpose, state machines have data FIFOs our program can read from or write to. This is where the fun begins.

RC Servo example: Controlling a radio-control (RC) servo requires a pulse of varying width repeated every 20 milliseconds or so. The technique is called Pulse Duration Modulation (PDM). One common way to achieve that is with a pulse-width modulation (PWM) pin on an MCU. The only drawback is that only a portion of the available PWM values is helpful for servo control, as the pulse width usually ranges from 1 to 2 milliseconds only, while for a 50Hz PWM signal, the possible pulse width ranges from 0 to 20 milliseconds. A PWM output pin is a standard solution already available on the RPi Pico; let us see how programable IO can be used for the same purpose.

Our PIO program will need to receive a number from the main program to set the pulse width for the servo pin. To get a reasonable accuracy for the pulse length, we can split the range from 1 to 2 milliseconds into a thousand increments of one microsecond each. With this idea in mind, we can set the state machine to operate at 1MHz, so each instruction will take one microsecond to execute.

Each servo pulse will last between 1 and 2 milliseconds, depending on the value set. If no value has been provided, the minimum duration will be chosen. The servo signal will start as soon as the state machine is activated.

Let us have a look at the code in Listing 2. In this example, the desired values for the servo go from 0 to 1000 and are sent with the change(v) function from the main program. The program consists of:

  1. A fixed delay that ensures the pulse (high level) on the output pin is at least 1 millisecond long.
  2. A variable pause that extends that pulse up to an additional millisecond. That variable time is defined by the user with function change(value) that sends it to the state machine.
  3. A 19-millisecond delay for the separation between consecutive pulses while the output pin is held low. This delay is longer than what was easy to achieve with a single loop, so it is made of two nested loops using registers X and Y. See Figure 1.
FIGURE 1
Servo example signal timing
FIGURE 1
Servo example signal timing

If no initial value is provided before activating the state machine, the value for the X register will be zero, so the minimum pulse width of 1 millisecond will appear on the output pin. Unless the program stops the state machine before finishing with sm.active(0), the output pin will continue to emit pulses when the sample program ends.

Please note that the signal of this example will have a frequency between 50 and 52Hz, depending on the pulse duration. If your system requires a fixed frequency of pulses of varying duration, the example must be modified to create a fixed-frequency output.

Steps example: My interest in PIO programming started when I considered using the RP2040 for a materials science project. I needed it to control several stepper motors that would pull from a sample at a constant speed for a given number of steps. I had used an Arduino UNO for the prototype. Still, I considered switching to a Raspberry Pi Pico W to allow the control system to communicate wirelessly with a PC. I wanted the system to operate with MicroPython, but I was not sure if I could achieve the required time accuracy. My signal consisted of short pulses (a few microseconds long) that repeated at a frequency between hundreds and thousands of cycles per second.

A common interface for stepper motors, used by many driver electronics available in the industry, is the STEP/DIR, which uses two digital inputs called STEP and DIR. The STEP input will cause the motor to rotate a discrete angle—for example, 1.8 degrees each time a short pulse is applied to the STEP input of the driver. The second input, DIR, sets the direction, clockwise or counter-clockwise, of the rotation movement achieved by any pulses applied to the STEP pin. For a given operation, the signal to control each motor would consist of a pre-defined number of STEP pulses where all the output pins should be acting at once, so all step motors move in unison (Figure 2).

FIGURE 2
Stepper motor step signal pulse-train timing
FIGURE 2
Stepper motor step signal pulse-train timing

So, let us first focus on solving this for a single motor: we need to create a state machine and activate it to generate pulses as soon as the main program tells it to. The question is, how can you do that? We need the state machine to pull that information from the main program. But how? The answer could not be more straightforward: by using the PULL instruction!

The PULL instruction moves a 32-bit word from the TX FIFO to the state machine’s Output Shift Register (OSR). This is only possible if data is already available in the FIFO. Otherwise, the state machine will wait until data becomes available.

How can the main program send data to the state machine? The sm.put(value) method will send a 32-bit value to the “sm” state machine. I will use this feature to send the desired number of steps to the stepper motor, ensuring that the proper frequency has been set for the state machine before that call so that the step motor will move at the desired speed. The program will look like that shown in Listing 3.

The main idea here is that the state machine can be activated early on, but that causes no pulses on the output pin. This is because the state machine is blocked on the PULL instruction, waiting for a 32-bit word to become available on the state machine’s TX FIFO. The state machine will run a loop that creates a single pulse needing 100 cycles. The X register will be loaded with the value of the number of pulses sent from the main program. (We know the number of pulses is one more than the value we set in the X register so that the main program will set this properly.) This means the frequency of the pulses will be one hundredth of the cycle frequency.

If the program needs to send a train of pulses of a different frequency, we must overwrite the state machine, as we cannot change the frequency divider. In Listing 3, the desired_step_freq variable represents the frequency of the step pulses we need to create, and the frequency of the state machine is set to 100 times faster than that, so the resulting step pulses frequency created is the correct one. For a maximum step frequency of 4kHz, we would need 400kHz for the state machine clock, well within the 125MHz system clock. The step pulse width (t1) is two cycles long, or 5 microseconds at 400kHz, well above the recommended value for our stepper drivers. The step pulse width widens for lower frequencies, which causes no harm. But it should be clear by now that while rewriting the state machine each time we need to change the frequency works, it also affects the step pulse width.

Could we do it any other way? Absolutely. Remember, the state machine has two scratch registers, so we could use the Y register for a delay loop to establish the step pulse frequency while the state machine frequency is fixed at a high enough value. Now, each time a train of pulses is needed, not one but two 32-bit words will be sent to the state machine, one with the number of pulses desired, a second value with the duration of the delay loop between two pulses (t2).

Okay, so with the code show in Listing 4, the state machine can be fixed at the maximum frequency of 125MHz to achieve pulse frequencies above 1MHz and well below 1Hz, both exceeding our needs. The step pulse width would be fixed to the duration of a hundred clock cycles, and now the main program will need to send two data words, for example, doing two sm.put() calls.

The following possible change comes from the fact that some synchronization may be needed even when trying to offload the signal generation task from the main program. We may want to wait until the train of step pulses has finished, or we may need something to happen just after that, so the question is how do we inform the main program of the completion of the requested train of steps?

I could think of two different responses depending on our needs. If we need to know if the steps have been sent, then perhaps something like a flag could do the trick. But if what we need is for our program to wait for the completion of the pulses, maybe we can do something similar to what was done with the PULL instruction, but in reverse. Luckily, we have the PUSH instruction that allows the state machine to push a 32-bit word in the RX FIFO. The main program may read from the RX FIFO using sm.get(), where the call will block if the FIFO is empty. This way, the main program might, if desired, wait until the end of the current train of pulses. Alternatively, the IRQ instruction allows the state machine to set or clear an interrupt request to the main processor. IRQ instruction can also wait until the interrupt is served.

The main program can assign a function as an interrupt handler for state machine zero with this instruction: rp2.PIO(0).irq(handler=serve_int), where serve_int is a MicroPython function called every time the interrupt happens. The interrupt handling function could set a global variable flag to signal the completion of the current train of pulses. The main program can check that flag to determine whether a new sequence might be initiated. The capture in Figure 3 shows a sequence of five steps, followed by another one of three steps, both at the same frequency.

— ADVERTISMENT—

Advertise Here

FIGURE 3
Sample code sending five and three pulses to the step signal of a stepper motor controller.
FIGURE 3
Sample code sending five and three pulses to the step signal of a stepper motor controller.

Shifting data in or out: Specific applications may require some sort of serial communication (SPI, I2C, UART) where data is sent or received on a GPIO pin. To that end, the instructions OUT and IN allow the state machine to shift data in from a pin to a register, or to shift data out from a register to a pin.

Both instructions allow sending from 1 to 32 bits at once. When using OUT, bits are shifted from a register to a collection of consecutive GPIO pins. For example, if you set out_base=Pin(25) and then the program runs out(x,2), one bit will go to GPIO25, and the second bit will go to GPIO26. The encoding of IN instruction in MicroPython syntax is in_, as “in” is a reserved language word. As an example, the instruction in_(pins, 1) will read the level of GPIO0 (assuming set_base=Pin(0)), and it will shift that digital value in the Input Shift Register.

One potentially helpful use of the shift operations is multiplying or dividing by a power of two. Given that the SET instruction can only load a 5-bit number, this may need to be bigger for some purposes. Shifting left the value loaded with a SET instruction using an IN instruction can increase the value in the input shift register to a much larger 32-bit number.

Shifting example—addressable LEDs: Some RGB LEDs use a specific serial data link to select the color for each LED. To use a single wire for sending color data to the RGB strip, the signal must be self-clocked. Each pulse represents a bit, where the width of the pulse represents the digital value.

The timing for the WS2812 and WS2812B LEDs is as follows:

  • A logical zero is represented by a short (0.4 microseconds) high level, followed by a long (0.8 microseconds) low level.
  • A logical one is represented by a long high level, followed by a short low level.
  • The reset signal, signaling the end of the data transmission, is at a low level for at least 50 microseconds.

The timing for these pulses is relatively fast and allows sending one bit in 1.2 microseconds, around 833kbps data rate. There is a small margin for error for the width of the pulses, so programmable IO provides an accurate and predictable timing.

If the state machine clock is set at 2.5MHz, each clock cycle is 0.4 microseconds (Figure 4). Now, we must figure out how to make the signal change depending on the value of each bit. The idea is that we can split the bit time into three sections of the same duration (a clock cycle):

  1. During the first section, the signal has to be high, no matter the bit value.
  2. During the middle section, the signal will be low if a zero is being transmitted, and high if a one is being transmitted.
  3. Finally, in the third section, the signal must be low, regardless of the bit value.
FIGURE 4
Encoding of a zero-bit and a one-bit for the data signal for WS2812 RGB LEDs.
FIGURE 4
Encoding of a zero-bit and a one-bit for the data signal for WS2812 RGB LEDs.

So, our program may consist of only three instructions, one per section of the signal, but we also need to shift out each one of the bits to be transmitted. That would require an additional cycle that would ruin our carefully thought timing, wouldn’t it? Well, there is another trick up our sleeve called side setting: any instruction may also act on output pins while still performing that particular instruction’s job. Think of it as a way to pack a SET instruction together with any other instruction (including the SET instruction itself), so any cycle GPIO pins can be set on or off. I got inspired by Stephane’s blog post, which comes up with a much simpler example than the one provided with Pico’s MicroPython examples mentioned earlier [2].

The code in Listing 5 will send the value for just one RGB LED, but any number of LEDs could be controlled on a single strip by looping on the sm.put(value) instruction. The code here is a bit hackish, as I wanted to make it as short as possible to illustrate that sometimes a few instructions can do the job. But, it combines several features simultaneously. It uses the so-called autopull feature (autopull=True), by which the state machine pulls a new data word of 24 bits, set by pull_thresh. Code uses both set instruction and .side(value) to set values on the output bit. The latter is used for the out instruction, and it not only shifts one bit of data to the X register if available or to block otherwise, but the .side(0) also sets the output pin to zero.

The normal operation of the state machine is to wait for data from the main program at the out(x,1) .side(0) instruction (the third line). That is when one data bit is shifted onto register X, to be used in the next loop iteration (remember the code of the state machine is an endless loop). However, the initial condition is not pretty, as when the state machine is activated, it will execute the three instructions to end up blocking the out instruction, thus causing a single pulse on the output line. That spurious pulse will be ignored and cause no harm if a reset condition happens afterward. From then on, the out instruction will patiently wait until data becomes available, and it will shift the most significant bit into the X register, which later will be sent to the output pin by mov(pins,x) instruction.

Figure 5 shows the transmitted signal from the example and its decoding. Please note the order of the 3 bytes is not the same as the transmitted one (0x805501 was transmitted), thus explaining why the decoded RGB value is 0x558001.

FIGURE 5
24 data bits representing the RGB value of a WS8212 led.
FIGURE 5
24 data bits representing the RGB value of a WS8212 led.
Concluding remarks

The Raspberry Pi Pico is an interesting and powerful MCU. Programmable IO makes it more versatile and adaptable for handling time-critical signals. Using PIO is not easy at first; there’s a steep learning curve. The pertinent information is on the datasheet [3], but the more real-life examples you see, the more natural and logical it becomes. I was lucky the chip was launched a couple of years ago, and I could find plenty of examples online to whet my appetite. Making some of these examples work has been a fun experience, and I have found it invaluable to have some sort of logic analyzer by my side. Even a cheap FX2 Logic Analyzer using Sigrok’s PulseView can work [4], but I am partial to Saleae Logic.

If you are planning to use PIO for any project, my advice is to Google it first, as perhaps it is something somebody else has already worked at. I have found plenty of interesting examples for dealing with various interfaces like PS/2, HX711, UART, SPI, DHT11, OneWire, CAN, Manchester encoding, and so on.

Sometimes, it may look like some examples are redundant, as SPI, PWM, or UART support is built-in on RP2040 silicon. But please remember these are limited. It may be great if you need additional units of, for example, PWM outputs on top of the built-in ones, and PIO can enable this extra hardware for some additional output pins. But if you need one PWM output, using the existing PWM support is just simpler and quicker. 

RESOURCES
Raspberry Pi | www.raspberrypi.com

REFERENCES
[1] GitHub repository of Raspberry Pi Pico MicroPython examples: https://github.com/raspberrypi/pico-micropython-examples/tree/master/pio
[2] Tutoduino—“Introduction to the PIO of the RP2040”: https://tutoduino.fr/en/pio-rp2040-en/
[3] Raspberry Pi Pico Datasheet: https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
[4] Sigrok’s Pulseview Tutorial: https://learn.sparkfun.com/tutorials/using-the-usb-logic-analyzer-with-sigrok-pulseview/all

Code and Supporting Files

PUBLISHED IN CIRCUIT CELLAR MAGAZINE • FEBRUARY 2024 #403 – Get a PDF of the issue

Keep up-to-date with our FREE Weekly Newsletter!

Don't miss out on upcoming issues of Circuit Cellar.


Note: We’ve made the Dec 2022 issue of Circuit Cellar available as a free sample issue. In it, you’ll find a rich variety of the kinds of articles and information that exemplify a typical issue of the current magazine.

Would you like to write for Circuit Cellar? We are always accepting articles/posts from the technical community. Get in touch with us and let's discuss your ideas.

— ADVERTISMENT—

Advertise Here

Sponsor this Article
+ posts

Miguel Sánchez (PhD, Computer Science) is an associate professor at the Polytechnic University of Valencia, Spain. He has worked in the Departmentof Computer Engineering since 1988.

Supporting Companies

Upcoming Events


Copyright © KCK Media Corp.
All Rights Reserved

Copyright © 2026 KCK Media Corp.

Programmable IO Programming

by Miguel Sanchez time to read: 23 min