Output devices
Power measurement
TODO
Stepper Motor control with RP2040's PIO
I already designed many embedded systems with different output devices: LEDs, LCD, speakers, all kind of motors...
I decided to continue my discovery of the RP2040 PIO module.
To prepare the Machine week, I decided to use them to control a stepper motor.
The idea is to be able to generate an arbitrary sequence of steps for the motor: each step could have a different duration.
This will be implemented as two lists:
- a step list that contains the sequence of signals for the motor driver inputs
- a duration list that contains the steps' duration
test circuit
I connected a RP2040 to stepper driver module I have from a previous project.
This driver is 4 Phase ULN2003 Stepper Motor Driver PCB.
It is designed to be used with the 28KYJ-48 stepper motor.

Here is the schematics:

"Hello World!"
I always start my projects with a "Hello World!" code to verify that the hardware is OK (and starts with a quick win):
import rp2
from machine import Pin
@rp2.asm_pio( set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW) )
def step():
wrap_target()
set(pins, 1) [31]
set(pins, 2) [31]
set(pins, 4) [31]
set(pins, 8) [31]
wrap()
sm = rp2.StateMachine(0, step, set_base=Pin(2), freq=10000)
sm.active(1)
while True:
pass
This code creates a PIO state machine, running at 10kHz.
The state machine code (step()) controls 4 output pins, starting with GP2 (hence GP2-GP5).
set(pins, x) [31] sets the pins'state to match x value.
As we control 4 pins, x should in the range 0->15.
[31] at the end of the instruction tells the state machine to wait 31 clock cycles after the instruction execution. Hence, its execution time is 32 cycles.
The code applies a full step sequence on the motor, looping endlessly.
It worked as expected: the motor is turning.
Duration control
Next step will be to be able to control the steps duration. I modify my code to add a waiting loop:
import rp2
from machine import Pin
import time
@rp2.asm_pio( set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW) )
def step():
wrap_target()
set(pins, 1) # PC = 0
set(y, 3)
jmp("waitLoop")
set(pins, 2) # PC = 3
set(y, 6)
jmp("waitLoop")
set(pins, 4) # PC = 6
set(y, 9)
jmp("waitLoop")
set(pins, 8) # PC = 9
set(y, 0)
label("waitLoop")
set(x, 31) # initialize X
label("counting")
jmp(x_dec, "counting") # decrement X and jump to itself if x!=0
mov(isr, y) # copy Y in the ISR (for debug)
push() # push ISR in the TX-FIFO (for debug)
mov(pc, y) # set the program counter to jump to the next step
wrap()
sm = rp2.StateMachine(0, step, set_base=Pin(2), freq=2000)
sm.active(1)
while True:
print(sm.get())
The code starts with the step sequence as before. However, set() instructions have no delay.
Instead, Y register is set to the address of the next set() instruction to execute, then the code jumps to the wait loop.
Each step takes 3 instructions (2 set() and 1 jmp()), their addresses are 0, 3, 6 and 9. (label() is not a PIO instruction).
The waitLoop part of the code uses the X register to count down from 31 to 0, creating a delay.
Then mov(isr, y) set the program counter to the next step first instruction.
When I tested it, only the first LED of the driver lighted up.
I added 2 instructions to try to understand what was happening:
These lines outputs the Y register value. It is read and print in the console by print(sm.get()).
I expected to have the following sequence: 3 6 9 0 repeated endlessly.
I got erratic results:
- Most of the time, I only get a single output: 3
- I also had: 3 0, then nothing
- sometimes, I had the expected result. In that case, the motor was also turning.
My guess is that my PIO program does not always starts at address 0 in the PIO memory.
In the C sdk documentation, I found that one can add a .origin directive to force the starting address of a program in the PIO memory.
However, I didn't find something equivalent in the microPython documentation.
After spending too much time on this problem, I decided to avoid manipulating the PC register in my other programs.
DMA to PIO data transfer
I need to be able to transfer my sequence list from the RP2040 main code to a PIO state machine.
The most efficient way to do that is to use a DMA transfer.
I read the MicroPython DMA documentation and wrote a simple test code:
import rp2
# PIO state machine
# reads data from the Rx FIFO and send it back in the TX FIFO
@rp2.asm_pio()
def push():
wrap_target()
pull() # pull a value from the RX-FIFO (fed by the DMA)
out(isr, 8) # outputs this 8-bit value in the ISR
push() # push the ISR in the Tx FIFO
wrap()
pioId = 0 # pio module id (0-1)
smId = 0 # state machine id (0-3)
# creates the state machine object and activates it
sm = rp2.StateMachine(4*smId+pioId, push, freq=2000)
sm.active(1)
# DMA
halfStepSeq = bytearray( (1,3,2,6,4,12,8,9) ) # data buffer associated to the DMA
dataReqIdx = (pioId << 3) + smId # DMA request id for our state machine
d = rp2.DMA() # creates the DMA channel
c = d.pack_ctrl(size=0, # Transfer bytes
inc_write=False, # don't increment the write address
treq_sel=dataReqIdx) # transfer is initiated by the state machine
d.config(
read=src_data, # data source is our buffer
write=sm, # data destination is our state machine RX FIFO
count=len(src_data), # number of data to send
ctrl=c,
trigger=True)
# main code
a = 0
while a<2:
dst_data = []
for i in range(len(src_data)):
dst_data.append( sm.get() )
print(dst_data)
a = a+1
d.config(read=src_data)
d.active(1)
The data to send to the state machine is the half step sequence.
It is a byte array to match the 8-bit value expected by the PIO program.
The DMA channel is created and configure to send this sequence to the state machine that wil send it back.
The main code reads the FIFO from the state machine until it received the sequence.
Then, it reactivates the DMA channel to start the process a second time.
The expected result is that the code will print the sequence twice in the console. It worked as expected.
I modified the PIO program to send the sequence to the motor:
import rp2
from machine import Pin
@rp2.asm_pio( out_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW) )
def step():
wrap_target()
pull() # pull a value from the RX-FIFO (fed by the DMA)
out(pins, 8) # outputs it to the motor driver pins
set(x, 16) # set the delay to 20 cycles (=2ms)
label("counting")
jmp(x_dec, "counting")
wrap()
pioId = 0 # pio module id (0-1)
smId = 0 # state machine id (0-3)
# creates the state machine object and activates it
sm = rp2.StateMachine(4*smId+pioId, step, out_base=Pin(2), freq=10000)
sm.active(1)
# DMA
src_data = bytearray( (1,3,2,6,4,12,8,9) ) # data buffer associated to the DMA
dataReqIdx = (pioId << 3) + smId # DMA request id for our state machine
d = rp2.DMA() # creates the DMA channel
c = d.pack_ctrl(size=0, # Transfer bytes
inc_write=False, # don't increment the write address
treq_sel=dataReqIdx) # transfer is initiated by the state machine
d.config(
read=src_data, # data source is our buffer
write=sm, # data destination is our state machine RX FIFO
count=len(src_data), # number of data to send
ctrl=c,
trigger=True)
# main code
a = 0
while a < 2:
d.active(1)
while d.active():
pass
a = a + 1
d.config(read=src_data)
The PIO program simply outputs the pulled value to the pins then waits using the same loop as before.
This also worked as expected.
I checked the timings with a logic analyzer:

The four first traces show the signals of GP2-GP5 pins.
The last trace shows the same signals as a bus, showing the corresponding value in the sequence list.
We can see that each step last for 2ms: each execution of the PIO program takes 20 cycles and its clock frequency is 10kHz. The transition between the two sequences, from 9 to 1, doesn't have a visible effect on the timing.
I add a second sequence to my code to be sure I can change the data sent to the state machine:
import rp2
from machine import Pin
@rp2.asm_pio( out_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW) )
def step():
wrap_target()
pull()
out(pins, 8)
set(x, 16) # set the delay to 20 cycles (=2ms)
label("counting")
jmp(x_dec, "counting")
wrap()
pioId = 0 # pio module id (0-1)
smId = 0 # state machine id (0-3)
# creates the state machine object and activates it
sm = rp2.StateMachine(4*smId+pioId, step, out_base=Pin(2), freq=10000)
sm.active(1)
fwdSeq = [1,3,2,6,4,12,8,9]
bwdSeq = [9,8,12,4,6,2,3,1]
# DMA
dataReqIdx = (pioId << 3) + smId # DMA request id for our state machine
d = rp2.DMA() # creates the DMA channel
c = d.pack_ctrl(size=0, # Transfer bytes
inc_write=False, # don't increment the write address
treq_sel=dataReqIdx) # transfer is initiated by the state machine
d.config(write=sm, # data destination is our state machine RX FIFO
ctrl=c,
trigger=True)
# main code
seq1 = seq2 = []
for i in range(100):
seq1 = seq1 + fwdSeq
bSeq1 = bytearray(seq1)
for i in range(100):
seq2 = seq2 + bwdSeq
bSeq2 = bytearray(seq2)
d.config( read=bSeq1, count=len(bSeq1) )
d.active(1)
while d.active():
pass
d.config(read=bSeq2, count=len(bSeq2))
d.active(1)
It worked as expected.
Duration implementation
Next step will be to add the duration to the data sent to the state machine.
I se two possible implementations:
- use 16-bit data in the DMA transfer to combine the step and the duration in one data.
- use a second state machine to implement the wait loop. This allows to use a second DMA channel, but demands a synchronisation between both state machines.
I decided to start experimenting with the first option. It would allow to use the same PIO program on multiple state machines each controlling a different motor.