I needed a working ADS1255 driver to get on with the actual job, testing the ADC. I didn’t have one. What I had was eta-systems/ADS1255: a clean, compact C driver for the ADS1255/1256 family with good bones, a few choices that didn’t fit my setup, and four declared functions with no implementation behind them.
So this is a patch job. I fixed some mistakes, some the original’s and some mine, added a handful of quality-of-life features, and stopped the moment the thing was good enough to get back to measuring. It is not a finished driver. The point of this post is partly where I stopped and why, because “good enough to continue” and “done” are different places and I deliberately only got to the first one.
Target hardware: STM32F303xE, STM32 HAL, bare-metal. The ADS1255 and ADS1256 are register- and command-identical (same datasheet, SBAS288), the only real difference being channel count, so almost everything here ports to the 1256 by changing one bound.
Why this base#
I looked at three. TI’s reference driver was written for a different MCU and would have meant rewriting every SPI call against STM32 HAL. The ADS1xxx-Series library is C++, and I wanted to stay in plain C. The eta-systems driver was the closest fit, plain C, HAL-based, readable, but incomplete and built around busy-polling the DRDY pin. Closest fit won. “Closest fit but I’ll have to fix things” is usually the right call over “clean but wrong language,” but it does mean inheriting someone else’s assumptions, and a couple of those assumptions are where the time went.
The shape of the changes#
The quality-of-life work is quick to summarize. I moved DRDY from busy-polling to a falling-edge interrupt. I split the inlined register configuration into composable Set_*/Enable_* functions, each doing a read-modify-write so it doesn’t clobber its neighbors. I changed the read path to return the raw sign-extended int32_t code instead of volts, so the conversion math lives in the application. And I gave it fault behavior: the original blocks forever if DRDY never asserts, so now the wait times out after three seconds and the read returns 0x7FFFFFFF.
That sentinel choice is worth one line, because the obvious one is wrong. 0xFFFFFFFF sign-extends to −1, which is a perfectly normal near-zero reading and would collide with real data. 0x7FFFFFFF sits outside the converter’s ±8,388,607 range and can’t be mistaken for a sample. Pick your error value outside the data range, not adjacent to it.
The register split came out of a trap the original sidesteps by inlining everything: several registers pack multiple settings into one byte. ADCON holds clock-out, sensor-detect, and gain. Set gain with a blind write and you wipe the other two. So each Set_* reads, masks, modifies, writes. Registers that own a whole byte don’t need it, but I do it uniformly to keep the pattern consistent.
None of that is the interesting part. The interesting part is the bug that cost weeks.
“Blocking” doesn’t mean what I wanted it to mean#
The datasheet requires a small gap between issuing a read command and clocking the data out: t6, about 6.5 µs at a 7.68 MHz clock. The original satisfied this with HAL_Delay(1), a one-millisecond delay to cover a single-digit-microsecond requirement, roughly 150× longer than needed. It works. It also throttles every register access.
Early on I removed those delays. My reasoning was that HAL_SPI_Receive is blocking, so it would wait for the data on its own. This was wrong, and it was wrong in a way that took me a long time to see, because the code looked correct and mostly produced sane numbers.
Blocking SPI blocks until the SPI peripheral finishes shifting its bytes. It knows nothing about whether the ADC has a result ready. The t6 gap is the ADC’s internal command-to-data latency, and it has nothing to do with SPI completion. Clock the read out too early and you get stale or garbage bytes, intermittently, depending on timing you
don’t control. I chased intermittently-corrupt reads for weeks before I understood that the delay I’d deleted wasn’t padding. It was the one thing bridging two clocks that don’t know about each other.
The fix wasn’t “no delay” and it wasn’t “1 ms.” It was a correctly-sized microsecond delay:
#define T6 ((int)((1.0/ADS125X_OSC_FREQ) * 50 * 1000000UL) + 1) // ceil, ~7 us
HAL_SPI_Transmit(ads->hspix, spiTx, 2, 1);
delay_us(T6);
HAL_SPI_Receive(ads->hspix, pData, n, 1);The +1 is there because t6 is a minimum and the (int) cast truncates; rounding down would put it under spec. Computing the gap from the clock makes it both correct and about 150× faster than the original’s millisecond. There’s a more general lesson lurking in “blocking is a claim about one layer, not about your data,” but that’s its own post. Here it’s enough to say the delay was load-bearing and I’d deleted it for a confident
wrong reason.
The interrupt surfaced a bug the polling had hidden#
Moving DRDY to an interrupt is mostly mechanical. The ISR does one thing:
static volatile uint8_t drdy_flag = 0;
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin){
if (GPIO_Pin == nDRDY_D9_Pin){
drdy_flag = 1;
}
}
uint8_t ADS125X_DRDY_Wait(ADS125X_t *ads){
uint16_t start = HAL_GetTick();
drdy_flag = 0; // clear BEFORE waiting
while(!drdy_flag){
if ((uint16_t)(HAL_GetTick() - start) > 3000) return 1; // timeout
}
return 0;
}Two details that look optional and aren’t. The flag must be volatile. It’s written in the ISR and read in the main loop, and without volatile the compiler can hoist the read out of the loop and spin forever. This tends to look fine in a debug build and hang in release. And the clear has to come before the wait. while(!flag); flag=0; returns on a stale edge and can lose a real one in the gap; flag=0; while(!flag); waits for a genuinely fresh conversion.
What the interrupt actually exposed was a bug in the original that the polling had been hiding. It called DRDY_Wait inside CMD_Send, Register_Read, and Register_Write. It waited for DRDY low before every SPI operation. That’s more than the datasheet asks for. RREG and WREG don’t need a conversion to be ready. The wait only belongs where I’m actually waiting on the ADC itself: before pulling a finished conversion off the chip, and
around self-calibration, which holds DRDY off until it completes. Busy-polling had been forgiving enough to mask the over-waiting, but with the interrupt in place it surfaced as a hang during init. The fix was to pull DRDY_Wait out of the register and command paths and leave it only where the ADC actually makes me wait.
(One environment gotcha, recorded so I don’t lose a day to it twice: regenerating the CubeMX project silently reset the SPI baud rate to 18 MHz, well above the part’s maximum SCLK. Peripheral config lives in generated code, and regeneration stomps it. Re-check SCLK after every CubeMX touch. The part wants SPI mode 1, CPOL=0, CPHA=1.)
What’s still broken#
This is the part that makes it not-a-finished-driver, and I’m listing it because the gap between “works on my bench” and “I’d hand this to someone” is exactly these things.
The channel argument isn’t bounds-checked against the part, so an out-of-range channel on an ADS1255 is silently misconfigured rather than rejected. The EXTI line used to mask the interrupt during SPI transfers is hardcoded to the current DRDY pin group, so moving the pin or porting to a 1256 board breaks the masking. Init() ignores DRDY_Wait timeouts, which means a dead interrupt line produces a slow init that reports success instead of
failing, the worst kind of failure. The DRDY flag is a single file-scope global, so the driver supports exactly one device. And the four functions I inherited with no implementation are still sitting there, declared and empty, waiting to be finished or deleted.
None of these block what I’m doing next, which is the whole reason they’re still open.
(One thing that looked like a driver bug and wasn’t: with the input buffer enabled I saw large drifting offsets, which made the buffer look guilty. The buffer was fine. My analog input was floating, and the buffer’s high input impedance simply stopped masking it. That turned into its own case study; the takeaway for the driver is just that Enable_Buffer works as intended.)
Where this stops, and the question it hands me#
I stopped here because the driver was never the goal. It reads conversions on demand, reliably, at 2.5 and 50 SPS, which is what I needed to start characterizing the front end. Polishing it further would have been procrastinating on the actual work.
But the actual work immediately raises a question the driver can’t currently answer. The ADS1255 maxes out at 30 kSPS. At that rate a new sample lands roughly every 33 µs. The machinery I just built does a fair amount per sample: an interrupt fires and sets a flag, my main loop notices the flag, then every read masks the interrupt and waits on a blocking SPI transfer. That’s a lot to do in 33 µs. The design that’s correct and comfortable at 50 SPS may be the wrong shape entirely for streaming at full rate.
I don’t yet know whether the answer is a tighter read loop, continuous-read mode, DMA, or that I’ve built the wrong abstraction for high-rate capture and need to rethink it. That’s the next thing to figure out, and it’s a question this driver’s design choices created. Which is a good place to stop a post about a driver that isn’t finished.
The code as it stands at the end of this post is tagged V1.0 on Codeberg.