The RP2040 was chosen because of its ability to implement custom peripherals using PIO. As explained earlier the multislope topology requires a balancing act of the input in order to not saturate it as well as maintaining a constant number of switch transitions per reading in order to have a known amount of charge injected into the integrating capacitor, to calibrate it out later. This can be done using PWM, where the output of the comparator is used to change the duty cycle of the subsequent PWM cycle. The described can be implemented with bit banging on almost any microcontroller with some software know-how. But hanging up the CPU on several second long measurements is sub-optimal, so implementing it in PIO was more desirable with the added benefit of certainty in the timings and state transitions (since any jitter on the control signals can add up to big uncertainties in the measurements).
The entire
multislope algorithm, as well as its idle state (dithering) was fit
into a single PIO state machine, almost filling up the available
instruction storage, with 30 out of the available 32 instructions
being used. Now, let’s take a look at how it was implemented. (It is assumed that the reader has some familiarity with the RP2040 PIO instruction set and architecture)
.program ms .side_set 1 ; 1 side set bit for the MEAS pin ; don't forget to enable auto push start: set pins 0 side 0 mov X, !NULL side 0 ; set X to 0xFFFFFFFF out Y, 32 side 0 ; read the number desired counts irq wait 0 side 0 ; first residue reading out NULL, 32 side 0 ; stall until DMA finished reading the ADC jmp begining side 0 ; got to PWM finish: set pins 0 side 0 ; turn switches off in X, 32 side 0 ; push PWM to FIFO irq wait 1 side 0 ; second residue reading out NULL, 32 side 0 ; stall until DMA finished reading the ADC dither: dithHigh: jmp !OSRE start side 0 ; jump out of desaturation when the OSR has data set pins 1 side 0 [1] ; set pin polarity jmp pin dithHigh side 0 ; check if the integrator is still high dithLow: jmp !OSRE start side 0 ; jump out of desaturation when the OSR has data set pins 2 side 0 ; set pin polarity jmp pin dithHigh side 0 ; check if the integrator is high jmp dithLow side 0 ; stay low .wrap_target beginning: set pins 1 side 1 ; set PWMA high, and PWMB low [01 clock cycles] jmp pin PWMhigh side 1 ; read comparator input, jump to pwm high state [01 clock cycles] set pins 2 side 1 ; turn off PWMA if the pin is low [01 clock cycles] jmp X-- PWMlow side 1 ; else jump to PWM low state [01 clock cycles] (if pin is low we decrement X) PWMhigh: set pins 1 side 1 [15] ; keep PWMA high [02 clock cycles] + [28 clock cycles] = 30 nop side 1 [11] set pins 2 side 1 ; set PWMA low, at the same time PWMB high [01 clock cycles] jmp Y-- beginning side 1 ; go to the beginning if y is not zero yet [01 clock cycles] = total 32 jmp finish side 0 ; go to rundown when y is zero we don't care at this point anymore PWMlow: set pins 2 side 1 [15] ; set PWMA low [4 clock cycles] + [27 clock cycles] = 31 nop side 1 [10] jmp Y-- beginning side 1 ; go to the beginning if y is not zero yet [01 clock cycles] = total 32 jmp finish side 0 .wrap
When starting up PIO will start from the first instruction in the program, so it begins execution in `start`. Here we first set all the pins into a known state (all off).
Next, using a trick with the binary operations PIO supports, we invert a NULL and store the result in the 32-bit X register. This fills up X with 1s. This is required in order to create a counter inside PIO, since we don’t have an i++ instruction, we need to get creative. By using the jump instruction with the x-- operator we can achieve the same thing just inverted. When the result of X will be shifted to the CPU, it will just perform an inversion on its end to get the count.
The Y register is used to store the requested number of counts to perform, that is just read from the OSR (Output Shift Register, from the perspective of the CPU). In order to not waste instructions on pulling from the FIFO into the OSR auto pull is used. In this context, counts refers to the number of PWM cycles we want to output, the more the better because we have more time over which we accumulate the results. This works only up to a certain point, after which the returns in resolution are diminishing thanks to 1/f noise and dielectric absorption
Next we trigger a residue reading using an interrupt and wait until we get a signal back indicating that it has completed (waiting until some dummy data is put into FIFO, realistically this can be omitted and we can just clear the interrupt after we are done reading the ADC, since interrupts can stall the state machine), setting all the pins to 0 earlier turned off all the switches injecting current, thus leaving the capacitor voltage unchanged (at least on the short timescales of a single ADC conversion cycle). On the CPU side the interrupt starts a DMA operation to read the SPI residue ADC. The reason DMA was used was because the bulk SPI write in the SDK wasn’t working for me at the time for some reason and it was decided to just use DMA instead of spending time debugging the issue.
The PWM algorithm was described more in a recent log but the basic idea is that we are using a constant number of switch transitions for the same number of requested counts. This approach is used to address the issue of charge injection from analog switches. Instead of keeping the switch on continuously, it is turned off periodically at constant intervals to avoid accumulating too much charge on the integrator. Essentially what the drive waveform starts to look like is just PWM with two duty cycle levels (in our case it was 1/16 and 15/16). The below image should demonstrate the method and hopefully make it a bit clearer:
(LD120/121 datasheet, page 3-17, figure 2, 3)
After the residue
ADC has been sampled we jump into the beginning that sits below a warp_target, meaning that this will be used as our main loop, since
a wrap target is a loop at zero instruction cost (even though we
don’t ever use the wrapping because we use jump instructions to
decrement counters). Here one of the switches (let’s call it switch
A) is turned on and then we check the comparator output to see if it
needs to remain in that state or if we need to continue and turn it
off, and turn on switch B.
If we need to keep it on we jump over to the PWMhigh label, here A is once again set high, this is needed in case the instruction above it X-- doesn’t jump, then we only inject 2 cycles worth of wrong polarity into the integrator instead of 1 + 16 + 1 + 11 cycles. (this might be a good area to investigate in order to eliminate this error altogether in the future). After we have waited enough cycles to make sure the switch A stayed on for 15/16 of the period, we turn off switch A and turn on switch B. Finally, we subtract 1 from the Y register, which stores the requested number of cycles, if it is zero we jump to finish to end the conversion, if it’s not zero we continue from the beginning.
If we need to turn the switch A off, we do it right away and then while subtracting 1 from the X register which stores the number of times we had to turn on switch B to its 15/16 duty cycle (we only need to keep track of one of the switches because we can determine the number of times the other one was "on" since the number of total switching cycles that occurred is known). Next the same thing as with the A switch happens, just the other way around.
After reaching the finish part it’s pretty trivial, the switches are turned off, the X register is sent into the FIFO to the CPU, another residue reading is triggered and after that is complete, we go down to dithering.
During dithering we just check if there is any new data on the FIFO from the CPU, if so we jump out to the start section, if not we continue dithering. Dithering just keeps the integrator near zero, this is done by reading the integrator and turning on a switch to counteract the current state, forming a sort of triangle wave on the integrator.
Now throughout the entire code sideset was just sitting there on the side (pun intended). All it does is open or close the path to the voltage we want to measure. So, we keep it off most of the time and only turn on during the PWM measurement. After that is complete, we turn it back off and that’s about it.
In conclusion we can see just how powerful the PIO really is and how all of this was implemented and runs with about zero CPU intervention, freeing it up for more number crunching, for example implementing advanced calibration algorithms such as poly fit. PIO is a nice tradeoff between a full on FPGA and just doing bit banging on the CPU, you should try it at some point.
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.