Hey! In this video we are going to take a look at the PIO peripheral of the new Raspberry
Pi Pico, or more precisely, of the new rp2040 microcontroller designed by the Raspberry
Pi foundation!
If you’ve seen some of the impressive stuff that’s been done with the Pico, you might’ve
noticed people hooking up DVI, VGA, and other pretty high-speed busses directly to the Pico,
even though it doesn’t have peripherals for a lot of these busses.
The way most of these things are achieved is by making use of PIO, the programmable
I/O peripheral. The RP2040 contains two of these PIO blocks,
and they are pretty amazing. You can think of each of these PIOs blocks as containing
4 tiny, specialized cores doing super fast, freely programmable IO!
And in this video, we will take a deep dive and check out what PIO is, how it works, and
how it’s programmed. This will include a more or less complete description of all the
supported instructions, and I’ve created timestamps so you can directly jump to them.
So if you wanna come back to this in the future, you should be able to simply click the instruction
or detail you wanna learn about in the description, and it should take you right to it.
I also want to thank Luke Wren from the raspberry pi foundation for helping me double-check
the correctness of this video. Luke is one of the main engineers behind PIO and he posts
about some pretty interesting stuff on twitter, so you should definitely follow him!
So, lets dive in!
Each PIO peripheral contains 4 state machines, and all 4 state machines are connected to
the same instruction memory, which can hold up to 32 instructions. Now this might not
sound like a lot, but it’s more than enough to do some really impressive things. It’s
important to know that all state machines are using the same instruction memory, and
that the memory has 4 read ports, which means that all state machines can access the memory
at the same time without any delay or blocking or so.
The instruction memory itself is filled by the rest of the system, for example by code
running on one of the Arm cores.
Each state machine also has two FIFOs, each with a length of 4 32 bit words: One for sending
data into the state machine, and one for sending data back. A nice feature is that if you don’t
need one of the FIFOs, you can reconfigure them so that both point in the same direction,
effectively doubling either the transmit or the receive FIFO size.
The FIFOs are one of the way with which the state machines communicate with the rest of
the system, and so we can directly push or pull data into respectively from them for
example from code running on the CPU.
Now as said the PIO is for dealing with IO, and so we need a way to connect our state
machines to the GPIOs available on the RP2040. This is done by the IO mapping, which lets
us, per state machine, map pins as either inputs or outputs or both into the state machine.
Finally, the PIO also contains an interrupt system, more on that later!
Now lets take a look inside of the state machine!
First, lets look at the registers; Each state machine has 3 registers: The program counter,
which is a special register and points to the currently executing instruction in the
instruction memory, and two 32 bit wide scratch registers called X and Y.
The state machine also has two shift registers: The input shift register, called ISR, and
the output shiftregister called OSR. Both of these are connected to the FIFOs.
Then we also have a clock divider, which adjusts how fast our state machine runs. The clock
divider is directly connected to the system clock, and so our state machine can run at
the full 133MHz - and drive IOs at that speed! The clock divider can divide the system clock
by 1 to 65536, with a fractional precision of 1 divided by 256.
The state machine is also connected to the IRQ system, you’ll see how we can use that
for syncing state-machines and alerting the CPU later on.
Finally, each state machine also has access to GPIOs - via the IO mapping we just saw.
Now there are 4 different IO mappings for each state machine:
There are input pins, output pins, set pins, and sideset pins. We will see how these are
used when we come to the PIO instruction set!
Now lets take a quick look at these shift registers. Each of the shift registers is
32 bits wide, and they can be configured to either shift to the left, or shift to the
right. The output shift register can for example be used to shift data directly onto output
pins: In this example we are shifting the two least significant bits from the shift
register directly into IO 0 and IO 1. On the left the register is then filled with zeroes
again.
The input shift register works basically the same way. So for example if we want to read
data from a PIN and send it to the CPU, we can simply shift the value from the pin into
the shift register. Naturally we can also shift in two IOs at ones, useful for example
for parallel bus systems. As mentioned earlier, the shift registers
are directly connected to the FIFOs: This allows us to push the contents of the ISR
into the receive fifo, and to pull data out of the TX FIFO into the OSR.
What’s even better: Shift registers keep track on how many bits have been shifted into
them, and they can be configured to automatically pull or push data once they are full respectively
empty. Even better: The threshold for what defines being full or empty is freely configurable
by the programmer.
Before we go on, lets also quickly look at the FIFOs them selves: Each FIFO has 4 stages
with a size of 32 bits each, and on one end we have our CPU or other system peripherals,
and on the other hand we have our state machine. The FIFO is again aware how much data has
been pushed or pulled from it, and so whether the FIFO is full or empty is detectable by
our code.
And that’s all there is to the state machine! Quite simple, but also quite powerful!
Lets also quickly talk about the interrupt system:
Each PIO has 8 interrupt flags, which can be used to synchronise the state machines
or provide lock-like functionality. The first four flags are also exposed to the CPU and
can trigger interrupts there.
The last big thing before heading to the instruction set we have to look at is the IO mapping.
The RP2040 has 30 IOs, and with the IO mapping we can connect them to our state machines
in different ways. One thing to notice is that the PIO IO mapping internally works with
32 bit wide registers, and so from the mapper perspective there are actually 32 pins, even
though only 30 physically really exist.
As mentioned before, each state machine has 4 different sets of GPIO mappins; lets start
with the input mapping: For the input mapping we can define the input base pin, for example
GPIO 7. This pin will then, from the perspective of the state machine, be pin 0, and all the
other IOs are simply counting up, and wrapping around after IO 31.
The output mapping works basically the same way, except that we also need to define how
many pins we want to map as output: For example 1, 3, or 8, or even 32, again wrapping around
after IO 31. Note that the state machine can also control
the pin direction of all pins except for the input pins, and that we can have overlapping
mappings!
The set and sideset mappins work identical to the output mapping, with the exception
that we can map a maximum of 5 IOs. And so for example we can map IO 19 to 23 as SET
pins, and IOs 5 to 8 as side-set pins. And as said, all of these mappings can overlap
without any issues.
Now, lets dive into the instruction set! PIO is programmed using a custom assembly language
which has 9 instructions and is assembled using the pioasm tool.
The first instruction we will look at is the SET instruction!
The set instruction takes two arguments: destination, and data. And the instruction will write whatever
is in data, into the destination.
Data must be a value from 0 to 31, and for the destination we have a couple of options:
PINS will allow us to write directly to IO pins, and here is whe re the IO mappings come
into play: As explained we have 4 different mappings, and as the name suggests, SET will
write to whatever PINS are mapped into the state machine as SET pins!
We can also write the PINDIRS, which are the pin directions: A 0 for a pin sets it as an
input, and a 1 sets it to be an output.
Finally we can also use set to write a value directly into one of the scratch registers
X and Y.
And so for example set pins 1 would turn on the first mapped IO, set pindirs 1 would turn
the first mapped IO into an output, and set pindirs 0 would turn it into an input!
Next, lets look at the jump instruction: A jump instruction allows us to jump to different
parts of our program, and so the jump target, can be a value from 0 to 31 - remember that
we only have space for 32 instructions and so this is enough to perform an absolute jump
to anywhere in our instruction memory. In practice, instead of using an actual value,
you will see that most code will use a label which by the assembler is then converted into
an absolute address.
The JMP instruction also supports a couple of conditions! And so for example exclamation
mark X or exlamaition mark Y, will only perform the jump is X is 0.
X-- or Y-- will check if X or Y is not zero. If that’s the case, X or Y will be decreased
by 1, and then the jump is performed. X!=Y will only perform the jump if X and Y
are different from each other.
We can also jump depending on the state of a single pin: This pin ignores all the IO
mappings, and instead has to be set explicitly, the SDK exposes this functionality with the
sm_config_set_jmp_pin function.
Last but not least we have the exclamation mark OSRE condition: Here, a jump will only
be performed if the output shift register is NOT empty.
Now lets look at some examples: Here we have a label: loop_start, two SET instructions,
which will first turn an output on, and then off, and then a jump back to loop start: So
this is an endless loop that will turn on the output for 1 cycle, and then leave it
off for two cycles.
Next, lets look at the MOV, or MOVE instruction. This instruction will copy whatever is in
source, into the destination! There are quite a lot of destinations available
here, so lets walk through them: PINS, you guessed it, will copy whatever is
in source into the PINS, now MOV, unlike the SET instruction, uses the OUTPUT IO mapping.
Next we can copy something into our scratch registers X and Y, and then we have something
interesting: EXEC. If we copy something into EXEC, it will be decoded as an instruction
and executed in the next cycle. So in theory, we can execute PIO code from external sources!
We can also copy something into the program counter, which will set the address of the
next instruction that gets executed, and we can copy something into the input and output
shift registers. The sources are similar:
We can copy from PINS, which will use the INPUT mapping in this case, we can copy from
our scratch registers, we can copy NULL, which will simply set destination to zero, and we
can copy from STATUS: Status is a special register that can be configured to mean different
things, such as fifo full or empty, and so on.
And finally we can also copy from our shift registers ISR and OSR.
A cool feature is that you can prepend flags to your sources, for example an exclamation
mark or a tilde will copy the inverted value, and two colons will copy the source bit-reversed.
Next we have the IN instruction: The IN instruction will shift bitcount bits from source into
the ISR - the shift register that goes towards the rest of the system.
Bitcount can be any value between 1-32, and the sources the sources behave identical as
before - note that the PINS will use the input GPIO mapping!
The OUT instruction allows us to shift data from the OSR into a destination. Again bitcount
can be 1-32, and again the destinations are pretty self-explanatory after the previous
instructions. Note though that OUT uses the output pin mapping.
Next we have the PUSH instruction, which pushes whatever is in the ISR into the RX FIFO and
clears the ISR. PUSH has a couple of optional arguments:
Iffull will only push if the ISR is considered full. The ISR is considered full when a configurable
threshold of bits has been shifted into it. If block is specified, the push will block
in cases where the FIFO is full, and wait for the FIFO to have space before continuing
execution. Noblock ensures that the PUSH will never block, however if the FIFO is full,
it will remain unchanged, but the ISR will be cleared.
Push without any arguments is identical to calling PUSH BLOCK, and so by default PUSH
will block.
PULL is the opposite of PUSH: PULL will read data from the TX FIFO into the output shift
register. Ifempty will cause it to pull only if the OSR is considered empty, and block/noblock
will block - or not block - if the TX FIFO is empty.
Next we have the IRQ instruction: This instruction will set or clear one of the 8 interrupt flags
of the PIO. irq_num contains the flag number, and so it has to be between 0 and 7, and then
we have a couple of options: set and nowait are the default options, and they basically
mean set the flag and don’t wait for it to be clear first.
Wait will wait for the flag to be 0 before setting it, making it possible to use it as
a locking mechanism, and clear will clear the flag without waiting or anything.
Finally we also have the optional _rel argument, which allows us to set interrupt flags depending
on which state machine we are in: If _rel is specified, the lower two bits of
the irq_num are modified by taking the t wo bits, adding the state machine number to them,
and then performing a modulo 4 operation. This allows us to use different interrupt
flags per state machine while running the same program on all of them!
Lets look at some examples: IRQ 1 will simply set interrupt flag 1, without
waiting for it to be cleared, if it’s already 1 it will simply stay 1.
IRQ wait 1 will wait for IRQ flag 1 to be 0 before setting it
IRQ clear 1 will simply clear the interrupt flag 1.
Now our final instruction: WAIT There are different types of WAIT instructions,
lets start with the first one: WAIT GPIO. This instruction waits until the gpio specified
in num has the value specified in polarity. Note that WAIT GPIO completely bypasses the
pin mapping, and instead uses absolute GPIO numbers.
Next we have the WAIT PIN instruction, which works similar to the GPIO WAIT, but uses the
input pin mapping!
And then finally we have wait IRQ: this waits for an IRQ to be set. Polarity works a bit
different here: If polarity is 1, the wait instruction will clear the interrupt flag
once it’s set, if polarity is 0 the interrupt flag will not be changed.
WAIT IRQ also supports the optional _rel argument, which again will modify the lower two bits
in the interrupt flag number by modulo 4 adding the state machine number to it.
And that’s all there is to it! All nine instructions! Now before we finish, we have
to look at three more features: The first one is the delay functionality!
If you read PIO assembly you will often see numbers in square brackets at the end of the
line: These are used to INCREASE the cycles an instruction takes: Normally any non-blocking
instruction takes exactly 1 cycle. Now often we want to perform cycle-precise IO timings,
and lets say we want to create a square wave: This code has 3 instructions, the first one
sets the IO, the second one clears it, and then we jump back to the beginning.
Now in this case, our square wave would look like this: The 1 is half the length of the
0, not great!
Now we could insert an additional no operation instruction that just wastes time, for example
mov y, y, which does nothing. This gives us a perfect square wave, but it also wastes
one instruction slot! Luckily, the PIO language has the delay feature:
Instead of doing this, we can simply add a 1 in square brackets behind our first instruction,
and now the instruction will execute, and then wait for one more cycle! We still get
the perfect square-wave, and we’ve also re-gained one instruction slot!
The delay can be between 0 and 31 cycles, aka 5 bits, however the amount of bits available
as delay depends on another functionality called sideset:
Sideset is the only GPIO group we haven’t looked at yet, and it’s used by a pretty
cool feature: With each instruction we can toggle the IOs
mapped as sideset: For example by adding side 0 we turn the side-set IO off, and with side
1 we can turn it on. We can set and clear 5 individual pins this way! Pretty sweet!
This is super useful when implementing fast clocked signals such as SPI, as we can toggle
the clock at the same time as we’re shifting data out!
To enable side-set in the assembler, we need to specify the number of IOs we want to use
as side-set using the dot side-set directive. Note that each side-set pin will reduce the
bits available for delays by one!
Now by default if we enable side-set we have to specify the value of the side-set pins
on every instruction. However if we add `opt` to the side_set setting, this becomes optional
and the side-set pins will just retain their last set value. This comes at a cost though,
as it will reduce the number of bits available for delays by one more.
Side-set can also be configured to set pin directions instead of setting IO values, this
is enabled by appending pindirs to the side_set directive.
Finally, lets talk about program wrapping. A lot of PIO programs will consist of what’s
basically an endless loop, and normally when the last instruction of your PIO program is
executed, the state machine will jump back to the first one automatically. But we can
change that behavior using program wrapping. Lets say we have a program that has some setup
code at the start, and then a bit of code that should be executed in an endless loop
and generates a square wave. This square wave will execute in 4 cycles. However the PIO
engineers figured that this is such a common usecase, that they introduced program wrapping:
With program wrapping we can tell the state machine at which point the program end, and
even better, from where it should start again: We simply insert the .wrap_target directive
at the point to which the state machine should jump, and add .wrap to the instruction after
which the state machine should wrap around.
This does not only save an instruction, but also because we don’t have to delay because
of the jump instruction, our square wave can run twice as fast!
Now, that was quite the deep dive into the PIO architecture and programming! In a future
video, we will take a look at some example PIO programs and see how they work. If you
already want to have a look, I’ve linked the pico-examples repository in the description
so that you can already read some PIO code.
There’s also the often overlooked chapter 3 of the Pico C SDK book which shows a lot
of examples on how to use PIO in your C projects.
I hope you enjoyed this video and to see you on this channel again soon! Thanks!