Writing Drivers for SPI Chips in Embedded Systems

A practical reference for writing embedded SPI drivers for ADCs, sensors, DACs, digital potentiometers, and other ordinary peripheral chips, with datasheet reading, transaction design, register maps, timing, testing, and bring-up checks.

The first SPI driver for a new chip often looks simple. Pull chip select low, send a byte, clock a few more bytes, and something comes back. Then the board goes to the bench, the numbers are shifted by one bit, the temperature reading is stale, the ADC channel is wrong after a mode change, or the device works only when the logic analyzer is attached. That is the point where the driver stops being a few bus calls and starts becoming a real piece of firmware.

This guide is about ordinary SPI chips: ADCs, temperature sensors, pressure sensors, accelerometers, DACs, digital potentiometers, GPIO expanders, front-end devices, and measurement chips. The examples stay with normal peripheral chips where the hard work is datasheet interpretation, timing, framing, register handling, conversion flow, and hardware bring-up.

Guide to this reference

This guide is split into three pages so it can be used as a reference instead of one long scroll.

  1. Page 1, datasheet to driver shape. Start here for SPI mode, chip-select timing, public API design, transport boundaries, transaction building, and register read and write helpers.
  2. Page 2, ADCs, sensors, and timing behavior. Use this page for conversion sequences, burst reads, data decoding, status polling, DMA, shared bus rules, and error categories.
  3. Page 3, testing, bring-up, and long-term maintenance. Finish here for fake transports, logic-analyzer checks, board-level debugging, calibration data, portability, and the final checklist.

Page 1 builds the driver skeleton first. The later pages assume you have separated the application API from the SPI transaction details, because that separation makes every later decision easier to test and easier to review.

What this guide covers

The thread through the whole article is ownership. A driver that owns the datasheet details gives the rest of the firmware a stable contract. A driver that only wraps SPI transfers pushes those details into application code, and that is where small mistakes become hard to find.

In practice, the first version of a driver is usually written under bring-up pressure. That is exactly when it is easiest to accept a shortcut that works for one board, one chip speed, and one sampling mode. The sections below are meant to keep those shortcuts from becoming the permanent interface.

Most SPI driver mistakes are not caused by misunderstanding what SPI is. They happen because the driver does not capture the exact contract of the chip. A simple ADC might need a command bit, a channel number, a sample time, and a dummy byte before valid data appears. A temperature sensor might use register addresses where the top bit selects read versus write. An accelerometer might require a multi-byte bit to be set before a burst read works. A high resolution converter might return status bits in the same word as conversion data.

This guide focuses on the parts that make those details manageable:

  • Reading the datasheet as a driver contract.
  • Choosing a public API that does not leak bus bytes into application code.
  • Keeping the SPI transport replaceable for unit tests and board ports.
  • Building register and command transactions in one place.
  • Handling conversion timing, polling, and stale data.
  • Decoding raw measurements without burying assumptions in the caller.
  • Using DMA and asynchronous state machines where they actually help.
  • Debugging with logic analyzer captures and fake transports.
  • Keeping the driver useful across board revisions and chip variants.

The examples are written in C because C still fits many small MCU projects, but the structure maps cleanly to C++, Rust, or a hardware abstraction layer in an RTOS.

Read the datasheet as a driver contract

The reason to treat the datasheet as a contract is simple: SPI itself does not tell you when bytes are meaningful. The chip decides whether the first received byte is garbage, status, previous conversion data, or the high byte of the result. If that rule lives only in somebody’s head, the driver will eventually be changed in a way that breaks it.

Field note: A common bring-up failure is a driver that works at a slow SPI clock but fails when the production clock is enabled. The root cause is often not the clock speed by itself, but a missed chip-select inactive time, a minimum delay after reset, or a dummy cycle that was accidentally satisfied by slow debug code. Recording those timing assumptions in the driver makes the later speed-up much less risky.

A good SPI driver starts with a small checklist, not with code. Before writing the first function, pull out the details that affect every transaction. For a normal peripheral chip, that usually means:

  • SPI mode, which means clock polarity and clock phase.
  • Maximum SPI clock frequency, sometimes different for reads and writes.
  • Minimum chip-select setup, hold, and inactive times.
  • Whether the device samples commands on the first byte or after dummy clocks.
  • Whether reads and writes have different command formats.
  • Whether multi-byte access requires an auto-increment bit.
  • Reset timing, power-up delay, and first valid conversion timing.
  • Whether conversion data is binary, two's complement, sign extended, left aligned, or packed with status bits.
  • Whether the device has status flags for busy, data ready, overflow, or invalid channel.

This is where many drivers start drifting. The code says "read register", but the chip actually says "read command byte, dummy byte, then response". The code says "delay 1 ms", but the datasheet says the maximum conversion time depends on oversampling. The code says "SPI mode 0", but the board uses another device on the same bus that was initialized later and changed the mode behind your driver.

The practical way to avoid this is to create a driver configuration structure that names the assumptions. It does not need to expose every datasheet line, but it should hold the parameters that can change between chips, boards, or operating modes.

The exact values above are example values, not a replacement for a datasheet. The point is the shape. If the driver has a profile, you can review what the chip expects before you read the bus code.

Layered SPI chip driver architecture with arrows connecting each driver boundary.
The application should not know the byte layout of an ADC command or a sensor register read. Keep those details inside the driver and transaction builder.

Choose the public API before the bus code

The API is where the driver commits to what kind of device it is. If the API exposes byte buffers, every caller has to learn part of the protocol. If it exposes measurements, channels, ranges, modes, and status, the protocol stays replaceable.

This matters most after the first board revision. The second ADC may have a different command byte, the replacement sensor may move a status flag, or the same part may be wired to another SPI instance. A measurement-oriented API lets that change stay inside the driver instead of spreading through acquisition, logging, filtering, and calibration code.

The public API is the part of the driver that the rest of the firmware will depend on. If that API exposes command bytes, register addresses, dummy clocks, or raw transfer buffers, the application will slowly become part of the driver. That makes every later chip change more expensive.

For an ADC, application code usually wants samples, channels, ranges, and status. For a temperature sensor, it wants degrees, raw readings, or a refresh result. For an accelerometer, it wants axes, data-ready flags, and maybe interrupt configuration. The SPI details are implementation details.

The caller should not have to remember that register 0x00 is a device ID, that the read bit is bit 7, or that a conversion result needs two dummy bytes before valid data appears. Those rules belong inside the driver.

Keep the SPI transport replaceable

This separation prevents two different problems. The first is testability: you can check command bytes and decode logic without waiting for hardware. The second is portability: the device driver can move from one board support package to another without dragging a vendor HAL through every function.

Lesson learned: Drivers that call the MCU HAL directly often look faster to write, but they become expensive when the board changes. A simple GPIO chip select change, an RTOS migration, or a move from blocking transfers to DMA can force edits across the whole driver. A small transport wrapper keeps that churn in one place.

SPI drivers become easier to test when they do not call the MCU vendor HAL directly from every helper. Wrap the board-specific transfer function once, then pass it into the driver. That wrapper can drive STM32 HAL, Zephyr, ESP-IDF, bare-metal registers, or a fake transport in a host test.

This is not an abstraction for its own sake. It pays for itself when you can test the register encoding without hardware, and when a second board uses the same chip with a different chip-select GPIO or SPI peripheral.

Understand the actual SPI transaction

This is the section that saves people from off-by-one receive bugs. SPI receive data is aligned with clocked bytes, not with the meaning you wish the transaction had. If the command byte clocks in an ignored byte, then every response index after that depends on acknowledging the ignored byte.

When this is not explicit, engineers often fix the symptom in the decoder by shifting indexes until the bench reading looks right. That creates fragile code. The next register read, burst transfer, or device variant may need a different number of dummy bytes, and the hidden assumption breaks again.

SPI is full duplex, even when the device protocol feels half duplex. Every byte transmitted by the controller clocks in one byte from the device. Sometimes those first received bytes are meaningless. Sometimes the first received byte contains old conversion data. Sometimes a status bit is valid before the payload. The datasheet decides, not the SPI peripheral.

SPI transaction anatomy A timing style diagram showing chip select, clock, MOSI command bytes, ignored MISO bytes, status, and valid response bytes. CS SCLK MOSI MISO command address dummy dummy dummy ignore ignore status data high data low Valid response window
For many chips, the receive bytes during the command phase are real clocked bytes, but they are not useful payload. The driver should encode that explicitly instead of pretending every received byte has the same meaning.

The transfer helper should own chip select and should return the first real error it sees. It should also make sure chip select is released when a transfer fails.

This helper is intentionally boring. Boring is good here. You want one place where chip select behavior is consistent.

Build register access in one place

Centralizing register command construction is less about saving lines of code and more about preventing quiet disagreement. If one function sets the read bit as bit 7 and another function assumes bit 6 is the burst bit, the code may still compile and may even pass a simple ID read. The failure appears later when a multi-byte read or write-back path is added.

The maintenance benefit is reviewability. When a new engineer checks the driver against the datasheet, there is one helper to compare against the command format instead of a dozen local byte expressions spread across unrelated functions.

Many SPI sensors and mixed-signal chips use a simple register interface. That does not mean the register format is always the same. Some use bit 7 as the read bit. Some use bit 6 as a multi-byte flag. Some auto-increment addresses. Some require the address and the data to be in separate chip-select windows.

The driver should turn those rules into helper functions. For a reusable driver, command-format masks belong in the chip profile, so the helper applies behavior names instead of hard-coding bit positions inside transfer code.

There are devices where this helper is too simple. That is fine. The point is not to force every device into one universal helper. The point is to avoid rewriting command-byte rules in five unrelated functions.

Do not hide device identity checks

An identity check is cheap insurance. It catches wrong chip variants, assembly mistakes, chip-select routing errors, reset problems, and SPI mode mistakes before the driver starts writing configuration registers. Even when the ID read is not perfect proof, it gives the bring-up process a known first question.

Field note: On mixed boards, it is easy to talk to the wrong chip-select line and still see plausible MISO activity because another device is sharing the bus. A specific ID failure is much more useful than a later report that temperature is always zero or the ADC range configuration did not stick.

A device ID register is not a complete hardware validation, but it is a useful early check. If the driver cannot read the expected ID, stop and return a specific error. Do not let the rest of initialization continue with a random register map.

If a chip has no ID register, use a harmless readback register, a reset value, or a configuration write and readback if the datasheet allows it. If none of those exist, say so in the driver comments and in the bring-up checklist. Silence here wastes bench time.

Keep command style separate from register style

This is where many generic driver layers become too clever. A command-driven ADC is not a register device just because it uses SPI. Forcing it into a register abstraction hides the real timing and bit layout, and the code becomes harder to compare with the datasheet.

The practical rule is to model the device as it is. Register helpers are excellent for register-mapped sensors. Command builders are better for converters that encode channel selection, start bits, and dummy clocks directly in the transfer. Mixing those two styles without naming the difference is a common source of review mistakes.

Not every SPI chip is register based. Some ADCs, especially small external ADCs, use command fields that are clocked directly into the converter. A typical pattern is a start bit, single-ended or differential selection, channel selection, and dummy clocks while the result comes back. That style should not be forced into a fake register API.

That example is shaped like common 12-bit SPI ADCs. The details must still come from the actual device datasheet. The useful habit is to separate "build command" from "decode result" so each part can be tested by itself.

At this point the driver has a shape: a public API, a replaceable SPI transport, a transaction helper, register access helpers, ID checks, and command builders for devices that are not register mapped. Page 2 moves into the behavior that usually makes normal SPI chips tricky: conversion timing, burst reads, stale data, data decoding, status flags, DMA, and shared bus use.

Avatar photo
Saeid Yazdani

Embedded Systems Engineer with 15+ years of professional experience developing firmware, electronics, measurement systems, and hardware-software solutions. I have been programming for more than two decades and write about Embedded C/C++, STM32, AURIX, PCB design, debugging, and practical engineering lessons from real-world projects.

Articles: 35

Leave a Reply

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