Close

Code overview

A project log for Multislope ADC

Making the most of non-ideal components in a non-ideal world.

dimdim 04/22/2023 at 13:360 Comments

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