BLDC controller phase 2: adding feedback through the magic of PIOs

After my hello world of BLDC control I waited eagerly for some breakout boards to arrive. To get past the hello world stage I needed:

  • Some sort of position sensor. I chose the AS5048A Hall effect sensor because it was known to work well with SimpleFOC and the motor that I had bought came with the right sort of magnet to work perfectly with it.
  • A decent motor driver. Up to now I’d been using two L298Ns (to get 3 half bridges), which I had on hand, but that’s all there is to recommend it! At time of ordering, TI seemed to have the best available stocks of motor drivers so I picked up a DRV8313 breakout board. The DRV8313 is a dedicated three-phase motor driver capable of 2.5A and 60V, more than enough for my needs.

The position sensor arrived first and I set about connecting it to my Pico. There are two options for that: SPI, or PWM. I had planned to use SPI because it’s faster and the Pico has an SPI port. However, the pads on the breakout board for SPI were super-small and I didn’t have any wire on hand that was small enough(!) Maybe I should try the PWM option for now? But how to interface PWM to the Pico…?

The PWM output from the sensor sends an (approx) 1KHz signal to the Pico and it varies the “on” time of the signal depending on the angle of the wheel. To decode that precisely, you need to measure the “on” time and the “off” time precisely.

Yellow trace is the PWM signal.

Now, the “straightforward” way to do that would be to set an interrupt on the pin and to use the microsecond clock to measure the time between changes in the signal in the interrupt handler. But the RP2040’s datasheet basically says you’re a clown if you don’t use the PIO peripheral for all your bit-banging needs. I don’t want Eben to think I’m a clown! So, I dove into PIO assembler…

Each PIO peripheral has several state machine cores, which are like mini CPUs that execute their very specialised instructions with very precise timing. The specialised instructions can do things like read the state of a pin, push some data to the main processor over a FIFO, or pull data from the main processor and push it out on one or more pins. In fact, one instruction can often do several of those things, all in one clock cycle. The trade off for this precision and specialisation is that each PIO block has only 32 words of instruction memory, shared between 4 cores!

So, what did I come up with? First I wrote a PIO program to measure the “on” time. How hard can it be, just write a program that:

  • Waits for the pin to go high.
  • Increments a counter while high.
  • When pin goes low, push the counter to the CPU.

Waiting for a pin to change is very easy in the PIO, but incrementing a counter turned out to be a problem! The PIO has no “add” or “increment” instruction! 🤔

You can write the PIO code in MicroPython using a special syntax:

@rp2.asm_pio()
def measure_high_time():
    wrap_target()

    # Set x register to 0
    mov(x, null)

    # Wait for a HIGH.
    wait(1, pin, 0)

    label("high_loop")
    # ???? what goes here ????

    jmp(pin, "high_loop")

    mov(isr, x)
    push(noblock)

    # Not a real instruction, tells the PIO that, after
    # the push(), it should continue after the wrap_target().
    # Saving a jump is cool when you've only got 32 
    # instructions!
    wrap()

Scouring the PIO manual in the RP2040 datasheet, it took me a while to see the answer, but in the end it was the only option. The only “arithmetic” operation that the PIO has is “decrement x and jump if x is non-zero”. If we only have “decrement”, can we do that instead?

  • Set X to something large
  • Wait for pin to be high
  • While pin is high, decrement X (and somehow turn the unwanted jump into a no-op)
  • When pin goes low, send x to CPU

It took me a while to puzzle out how to set X to something large but I came up with this:

wrap_target()
# x = 0
mov(x, null)
# Decrement x, which wraps around to 0xffffffff
# x was 0 so the jump falls through.
jmp(x_dec, "wait")

# Wait for a HIGH.
label("wait")
wait(1, pin, 0)

label("high_loop")
# Decrement x; jump always fires because x is non-0.
jmp(x_dec, "cont_high_loop")
# Jump lands on next instruction anyway, staying inside
# Loop.
label("cont_high_loop")
jmp(pin, "high_loop")

# Send x to the CPU.
mov(isr, x)
push(noblock)

wrap()

And it worked! The value sent to the CPU is 0xffffffff minus the count but that’s easily corrected.

I was able to adapt the approach to make a second PIO program that measures the full cycle time of the PWM (i.e. “on” time + “off” time). That was a little trickier because there’s no equivalent to “jmp(pin)” that loops while a pin is low. The code is here in case it’s useful.

Of course, as soon as I showed Lance my code, he Googled the problem and found someone else had an even neater solution. Turns out you can save a whole instruction(!) by using

mov(x, invert(null))

to set x to 0xffffffff directly. You live and learn!

One thought on “BLDC controller phase 2: adding feedback through the magic of PIOs”

Leave a Reply

Your email address will not be published. Required fields are marked *