Welcome back to the factory. This week we're talking device drivers. Brenton's been working on a load cell ADC project for the Raspberry Pi Pico and he's just finished programming up the device driver for it. So let's see how it's going.
We're talking load cells today with Brenton. What are we working on Brent?
Yes, I'm working on the Makerverse load cell ADC. This little guy is going to allow you to measure weight or measure force with a microcontroller to quite high precision.
And you've got a demo for us.
I've got a demo. Yes, I've got a demo. I've got a load cell set up on the desk. Stepper motor's just here to weigh it down and by pushing on either side of this load cell we get a deflection, our phony graph there.
So you're just barely, you're just like quite lightly touching it and we're seeing.
Barely touching, yeah. But basically the core ingredient in a load cell is a resistor that changes value if you try to stretch it. So up the top here and down the bottom there's a bunch of resistors and when we push down these ones will stretch and these ones will compress and so they will both change their value and the result is that on the output terminals here we get a change in voltage on the green and the white wire.
And here's the model of the load cell in here. So what will happen is when you push it one way or the other one of these sides will have a higher voltage and the other one will have a lower voltage and then that difference will be read by the ADC chip.
So you're measuring across the differential output?
Yep, so this is measuring the, effectively the output of the load cell. We're measuring up on the multimeter we're at what like...0.1 millivolts? Yeah, so that's just the zero point at the moment. I'm just going to apply a fair amount of force. I'm thinking this is about a kilogram with my calibrated thumb and we're measuring at most about two millivolts of deflection.
And what happens if you let that rest? If we go back to zero, yeah we're back at the zero, 100 millivolts, 0.1 millivolts. So 0.1 millivolt is like the self-weight and then about a kilo-ish. So we're only getting like two millivolts. So clearly load cells need amplifying. Yes, they need very sensitive ADCs or a lot of amplification.
So we've got this fancy ADC that we need to talk to and as I understand this guy is a little bit unusual. Yeah, this ADC does not have a standard I2C or SPI interface. So looking at the timing diagram for this device, the first thing we need to do is wait for a low level on the data outpin. That's how this chip indicates that it's got a new sample that's ready to be read out. And then we need to produce 24 clock ticks in order to read in our 24 bits of data. The clock ticks have a rising edge at which point the data line can change value and on the falling edge we can assume the data value is stable and so on the falling edge we can read the data value in. And right at the end we have either a single pulse or three pulses that don't clock out extra data, they just tell the ADC whether we want to be sampling at 10 Hz or 40 hertz.
So if we were going to do something like that in C or C++ on Arduino or something, we would like bit bang that out. Why can't we do that in MicroPython? Well yeah you're right about the bit banging, that is actually the manufacturer's recommendation.They provide assembly and C reference drivers for bit banging this protocol. It all looks very esoteric. Yeah, that's 805 on assembly right there.
Now, that's probably okay if you were going to use an Arduino because in C you're very close to the hardware and you're fairly confident about how fast your code will execute. But we don't want to do that, we want to keep things simple and do things in MicroPython where things are complicated by the fact that MicroPython is a lot slower. It requires a lot more CPU cycles to actually interpret the code, and things like the garbage collector could run at any time and totally throw out your timing.
So bit banging is like dependent on there being like a determinism in the timing and we just don't get that in MicroPython? Yeah, MicroPython just doesn't have the closest to the hardware to have precise timing in your code.
So the solution we're going to use is the RP2040's PIO state machine driver. In MicroPython, a PIO executable gets written as its own function with this decorator at the top that basically tells the MicroPython interpreter, hey, we want to use the RP2040's assembly PIO module in order to interpret this code. This isn't Python, it's PIO assembly written to look like Python.
This is the code that twiddles those pins in just the right sequence to extract 24 bits of force information or differential voltage information? Yeah, yeah, we extract the 24 bits for one of our samples.
If you look at it, it kind of looks like assembly code, but it's got some crucial differences to assembly code for say the ARM Cortex M0 plus that's on the Raspberry Pi Pico. For starters, this PIO module was designed to beAs easily deterministic as possible, and one of the features of that design choice is that every single instruction is guaranteed to only execute in one instruction cycle.
So if we look further down here where we actually instantiate our state machine, we have a frequency of two MHz and that means that once every two millionth of a second, each of these instructions, one of these instructions gets executed.
The core of this code is a loop that loops 24 times in order to read in our 24 bits, but at the same time as it's reading in the 24 bits, it's also generating 24 clock edges.
In order to understand how this code works, we need to understand a little bit about how the PIO module works. It gets two pins when we initialize it. It gets what's called a sideset base pin, which is pin 16 in our case, and an input base pin, which is pin 17.
The input base is the pin that gets read by the in instruction, and the sideset base is the pin number that gets affected by this dot side syntax here. So when an instruction has a dot side one, what that's doing is setting the sideset pin to a logic one.
And so this instruction here does a no operation, so it does nothing else. All it does here is affect the state of the sideset pin, and then this syntax on the end tells it to delay by an additional clock cycle.
So each line, like the first element in the line is what the input, the second part is the output, and the next part is the delay? That's basically the syntax that we're looking at here, yeah.
So in the input instruction here, this will read a single bit from the input base pin and set the sideset base pin to zero at the same time. So effectivelyWhat this means is that data changes on the rising clock edge, so we can't read our data in, but when we have a falling clock edge, we can read our data. That's just how this device's serial interface has been written.
So across those two lines, you have a full clock cycle. You have a rising edge and then a falling edge, and the frequency has been set by the frequency of your state machine? Yeah, yeah. We have a rising edge where this delay of one stretches the length of the rising edge a little bit, then we have a falling edge where we read in our bit, and then we have a jump instruction that will loop us back to this loop label if this variable x has not yet become zero.
So all we need to do is read a single pin 24 times? Yeah, that's basically all it does. So after we've clocked out our 24 clock edges and we've read in our 24 bits, we just need to send out one more pulse in order to tell the ADC we're sampling at 10 hertz.
To do our rising edge, we have a NOP instruction that's just setting the side bit to a logic one, then we delay by one clock cycle, then we set the side bit to a logic zero. The second one doesn't need a delay because this push instruction takes the 24 bits that we've read in and puts them into the output FIFO buffer, and that also takes one instruction. And so the net result is that we stay high and then stay low for the same amount of time.
So if we copied these two lines of code, we would send the additional clock cycles for a much faster sample rate? Yeah, so we can do that very easily. We copy these and fix our syntax and just put our delays in so that we're actually producing a 50% duty cycle square wave. We'll copy.It again to get our third pulse and get rid of that delay and run that instead. We'll now be sampling at 40 hertz.
Wow, what's the trade-off here? Is 40 Hz like a noisier sample?
Yeah, when we're outputting data at 40 hertz, we get significantly more noise. We can see that the noise is varying by about 250 counts in amplitude, and if we delete these extra clock pulses and go back to 10 hertz, that 250 peak-to-peak noise has reduced to about between 100 and 200.
Right, so you might want to sample faster if you were rolling your own signal processing or something?
Yeah, if you were in, say, a control loop that needed very fast response or you wanted to do your own signal processing, the 40 Hz output could be appropriate there.
The PIO assembly actually has a weird way of doing infinite loops. We don't actually do a jump or a branch at the end of these loops. We actually do a jump and a branch at the end of these loops. So, instead of a jump and a branch at the end of these instructions, it has instead this concept of wrapping.
This line of code here that says wrap isn't actually an instruction, it's an assembly directive. It's something for the PIO assembler to read and interpret, and then the wrap target at the top is likewise also an assembly directive, and what this means is that when the push instruction finishes executing, it will instantly jump to this instruction at the top here, the wait instruction. So, there's actually no delay between the push and the wait instructions. So, it's a very consistent branch in this case.
And what's wait doing? That's like the trigger for this whole sequence.
Yeah, so the wait instruction is just waiting.For our data pin to become low, because that's how the ADC indicates that it's ready to start sending data. Right, that was the start condition.
And this wait instruction, it can just wait there indefinitely. There's no limit to how long the PIO state machine can stay on that wait instruction.
You mentioned that push is pushing a 24-bit number to some FIFO buffer, how do you actually get your data out of the state machine?
Yeah, so the state machine has a FIFO buffer, a small FIFO buffer inside it. A push inside the PIO code will take whatever's in the state machine's input shift register and push that into the FIFO, and then in your Python code, a get call here is what pulls data out of the FIFO and into your Python code.
So, how do you convert this raw? Because we're just looking at like differential raw 24-bit data. How do you actually turn this into, say, newtons or kilograms or pounds? What would a user do from here?
So, our infinite loop in our Python code gets some new data from the state machine's FIFO. We do a very simple twos complement conversion, because the data coming from the ADC AMP is in 24-bit signed twos complement format, and we just need to manually convert that, because it's a weird data length. And then we're just printing the raw data.
If you want real SI units out of this thing, you'd need to do your own calibration for whatever load cell you've purchased and whatever mechanical arrangements you've got around it.
There you have it. Driving exotic or esoteric devices that need very specific timing using PIO. If you have any questions about this episode, or if you just want to see something a little closer, let us know in the.The comments below. Until next time, thanks for watching.
Makers love reviews as much as you do, please follow this link to review the products you have purchased.