Writing Embedded Device Drivers#

In this post I wanted to quickly describe some of the guiding principles I follow when writing device drivers in C++ for an embedded project. By “device driver” I mean the code that provides an interface between your application and some hardware device on an embedded system. For instance: an IMU, a temperature sensor, an ADC sensor, a motor controller, etc.

1: Use composition for the transport driver used by a device#

“Transport driver” could also be called peripheral code. This is the code that interfaces with a MCU’s peripherals for various physical transport protocols. Like SPI, I2C, UART, CAN, etc. This is the code responsible for sending bytes back and forth from the MCU to the device you are controlling.

Composition should be used to provide this transport interface to your device driver. For example, here’s the constructor of a temperature sensor that uses SPI:

1class TempSensor123 : public ITempSensor {
2  public:
3    TempSensor123(ISpibus& spi);
4    // ...
5  private:
6    ISpibus& m_spi;
7};

Reasons to do this:

  • Some transports, like SPI, I2C and CANbus can have multiple devices attached. Your device drivers should allow this possibility. Device drivers should not assume they have full control of a peripheral. Keeping peripheral code separate allows you to follow the logical structure of: one physical peripheral has one peripheral driver instance at runtime.

  • On a similar note, using composition mimics the physical layout of the hardware: your device “has a” connection to a transport between itself and the MCU. This logically fits into a composition relationship.

  • With composition, you can provide an interface reference (as shown in the above example). This allows dependency-injection for easy unit testing of the driver.

    • You can mock out your transport layer and verify the device driver sends and responds to bytes as expected.

    • I’ve found unit tests of device drivers extremely helpful as I can more easily track down bugs and even inaccuracy’s in the vendors ICD.

2: Keep hardware-config specific info out of the device driver#

This point is a follow-on to the previous. Any hardware-specific configuration that could change from one board to another should not be hardcoded in a driver.

For example: external ADC-current sense chips will often have an external sense resistor that your driver will need to know in order to convert a reported ADC value → Amps. Dont hardcode that resistor value in the driver! Require the information on construction:

1class AdcAdm1192 {
2  public:
3    AdcAdm1192(II2c& i2c, float senseResistorOhms);
4};

Doing this makes a driver more reusable and resilient to hardware changes down the line.

Your driver should never attempt to manually configure lower-level hardware. For instance, it should not attempt to reconfigure a provided SPI bus to a different bus speed. Configuring the peripherals provided to a driver should be the responsibility of a higher level. Your driver should assume all provided hardware interfaces (SPI, I2C, etc) are ready to go once provided on construction.

What your driver should do is provide configuration suggestions to whoever is responsible for configuring a transport. For instance, most SPI devices will define a max clock speed they can run at. Make info like this easily accessible from your driver:

1class TempSensor123 : public ITempSensor {
2  public:
3    /// Per the ICD, max SPI clock rate in hertz
4    constexpr size_t MAX_SPI_CLK_HZ = 1000000;
5
6    TempSensor123(ISpibus& spi);
7};

From here the code responsible for creating a SPI bus instance can make an informed decision about how to configure the bus before passing it over to your driver constructor.

3: Encapsulate the device request → reply protocol in structs#

The basic gist of this point is dont hardcode register addresses and bitwise operations for message (de)serialization. Take advantage of enums and bitfield structs to describe the data you are sending and receiving. This is especially relevant for I2C and SPI devices which usually operate under a register model.

This approach requires a lot more types and work up front, but in the long run makes your driver much more readable and easier to work with.

 1class TempSensor123 : public ITempSensor {
 2    // ...
 3  private:
 4
 5    /// Defines addresses of registers available
 6    enum Register {
 7        CONFIG_A = 0,
 8        CONFIG_B = 1,
 9        TEMP_A = 2,
10        TEMP_B = 4,
11    };
12
13    // Layout of the CONFIG registers
14    struct RegConfig {
15        uint8_t sampleRate : 4;
16        uint8_t oversample : 2;
17        uint8_t enable : 1;
18        uint8_t _reserved : 1;
19    };
20
21    // ...
22};