Design of a processor and board independent driver layer

Reusability is an idea often touted in software, and embedded projects are no exception. In fact, the reasons for needing re-usability or ease of porting are often more pertinent to embedded projects even though the initial use-case doesn't call for them, and speed to market is usually business-critical.

Processor independence

Why would the processor (or microcontroller) change after it has been designed-in? After all, the hardware designer has to make lots of decisions which are tied to that specific processor in that specific package, so it would be a lot of work for them to change it. Timescales for embedded projects often require the code to be in progress whilst the hardware is being finalised, PCB laid out and prototypes produced. If we can't start the driver layers until the prototypes are available, then it becomes risky to work on the aspects of the application which depend on the lower layers, even with a well abstracted design.

During the pandemic, we saw chip shortages affecting many industries because of large orders by the big players, and large order cancellations which messed up everybody's lead-times. STM32s in particular were impossible to obtain unless you were part of the automotive industry. We found that certain STM development boards just couldn't be found, no matter how good your relationship with STM was. We had to work with the dev boards that were closest to our target processor that we could get hold of.

Tweet from the pandemic saying that you don't order STMs for yourself, you order them for your children

A few years after the processor was designed-in, and despite a 10 year availability guarantee for the processors we chose, there are no dev boards for that variant of the processor to be found anywhere. You have to hope that the slightly higher spec'd version is sufficiently similar that substituting it wouldn't be the cause of bugs.

Board independence

The board layout work can be made much easier by changing which peripherals or pins are used for each external component. This choice might be revisited following compliance testing and subsequent design reviews. Design changes can happen at any time, up to the customer acceptance test stage.

Aside from the changes needed to make one product meet all it's requirements, it is common to need to make product variants with subsets of the same functionality. Since these are often differently shaped or sized boards, they can demand different processors, but even if this isn't changed (to reduce the number of stocked parts) there are very likely to be board-level changes which would have an impact on driver software if we don't take steps to avoid it.

Driver Layers

Let's start this design from the bottom layer, the one closest to the hardware, and work our way up. STM32CubeIDE will generate HAL calls from peripheral settings in the ioc file for the initialisations in main.c but we can move them and add the read/write functions in a new file called bsp - board support.

SPI driver layer diagram

bsp_stm32_h745.h

The rule is that the bsp files are the only ones which have HAL calls in them, and any other hardware specific definitions are constrained to be only here or in ioc generated files like main.c.

Objective: instead of scattering HAL calls throughout the application codebase, we contain them to as few locations as possible so that alternative processors can be supported simply by using a different file with the same interface and functionality.

In this minimal example, I am defining init() and write() functions for an SPI driver for the STM32H7 family.

#pragma once
#include <stdint.h>

int driver_spi_bsp_init(void *pspi, uint32_t clk_pol, uint32_t clk_phase);
int driver_spi_bsp_write(void *pspi, uint8_t *pwrite_buf, uint32_t size);

Use of void * peripheral handles

To keep concrete SPI bus handle types out of the layers above BSP, I use void pointers which get cast back to the specific types inside the bsp functions.

bsp_stm32_h745.c

#include "bsp_stm32_h745.h"
#include "stm32h7xx.h"
#include "stm32h7xx_hal_spi.h"

int driver_spi_bsp_init(void *pspi, uint32_t clk_pol, uint32_t clk_phase)
{
    SPI_HandleTypeDef *hspi = (SPI_HandleTypeDef *)pspi;
    SPI_InitTypeDef *init = &hspi->Init;
    init->CLKPolarity = clk_pol;
    init->CLKPhase = clk_phase;
    if (HAL_OK != HAL_SPI_Init(hspi))
    {
        return -1;
    }
    return 0;
}

int driver_spi_bsp_write(void *pspi, uint8_t *pwrite_buf, uint32_t size)
{
    if (HAL_OK != HAL_SPI_Transmit((SPI_HandleTypeDef *)pspi, pwrite_buf, size, 0))
    {
        return -1;
    }
    return 0;
}

In driver_spi_bsp_init(), we get the SPI bus handle back from the incoming pointer, reference the init struct and make the changes we want before calling HAL_SPI_Init(). Since we don't want to use STM specific definitions like HAL_OK and HAL_BUSY for our return values because this is STM specific, we just return ints with 0 for success and -1 for failure. An improvement on this would be to use an enum which isn't tied to the STM family.

Notice that I put #include "stm32h7xx_hal_spi.h" in the bsp.c file rather than the h file because only the c implementation needs to know the HAL definitions. If we put them in the h file, every file which includes bsp_stm32_h745.h would see the HAL definitions, making inadvertent use more likely. With them in the bsp.c file, it prevents this accidental use and maintains the abstraction layer.

 
In this example, the bsp layer is actually only processor family specific, not board specific. I've separated that out into the board_config.h

driver_spi.h

This is our processor and board independent layer. We define two structures, a config containing configuration information for the driver which will remain constant after the driver has been initialised, and a state structure which holds all the working variables for the driver.

#pragma once
#include <stdint.h>

struct driver_spi_config_t
{
    void *pspi_impl;
    uint32_t clk_polarity;
    uint32_t clk_phase;
};

struct driver_spi_state_t
{
    const struct driver_spi_config_t *pconfig; // keep a ref to the config so that after init, other functions don't need to get config
    // other state variables for this instance of the driver
};

int driver_spi_init(struct driver_spi_state_t *pstate, struct driver_spi_config_t const *pconfig);
int driver_spi_write(struct driver_spi_state_t *pstate, uint8_t *pwrite_buf, uint32_t size);

pconfig is a pointer in the state structure pointing to the config structure so that all the driver functions just need to use the state structure as a parameter to be able to work their way back to the config structure.

This is an example with a couple of SPI parameters to show the principle with clock phase and polarity.

driver_spi.c

In the driver_spi_init() function, we take a copy of the input parameter of the pointer to the config struct and store it in the state structure.

#include "driver_spi.h"
#include "bsp_stm32_h745.h"

int driver_spi_init(struct driver_spi_state_t *pstate, struct driver_spi_config_t const *pconfig)
{
    pstate->pconfig = pconfig;
    int status = driver_spi_bsp_init(pconfig->pspi_impl, pconfig->clk_polarity, pconfig->clk_phase);
    return status;
}

int driver_spi_write(struct driver_spi_state_t *pstate, uint8_t *pwrite_buf, uint32_t size)
{
    return driver_spi_bsp_write(pstate->pconfig->pspi_impl, pwrite_buf, size);
}

The bsp layer handles the processor specifics of how to initialise the SPI bus, so we only have to call driver_spi_bsp_init() and pass the return value back to the caller.

 
Similarly with the driver_spi_write() function, all the specifics are handled in the bsp layer.
 
Whilst in this case the driver layer isn't doing a lot of work, it is useful to retain as a design because we often need more complexity in the state structure and any one driver function might need to call many bsp functions to achieve it's task. Therefore it isn't a good idea to dispense with it and directly call bsp functions from application code.

board_config.h

To keep all the board specific definitions in one place, we have a board_config.h which needs to include both the HAL includes and the driver includes to be able to tie them together. We have to extern the SPI handle because the STM32 code structure as generated from the ioc only put this in main.c and we need to access it here.

This file contains the definitions of the values for each specific SPI driver that we need. In your application, the names for these SPI drivers would suggest themselves based on the external device that they are attached to, but I haven't got any particular chip in mind so I've just used spi_A.

#pragma once
#include <stdint.h>
#include "stm32h7xx.h"
#include "stm32h7xx_hal_spi.h"
#include "driver_spi.h"

extern SPI_HandleTypeDef hspi2;

const struct driver_spi_config_t driver_spi_A_config =
{
        .pspi_impl = (void *)&hspi2,
        .clk_polarity = SPI_POLARITY_LOW,
        .clk_phase = SPI_PHASE_1EDGE
};

I've set this up as a const struct which is defined at build-time, so the values can be stored in program space and don't use up the usually small and limited RAM on embedded microcontrollers. By placing the definitions outside of a function, they have global scope but the usual reasons not to use global variables are not relevant to these constants. We can't have them defined inside functions and passed back as return values or output parameters because we need to have a stable long term address for them, which is what the state config pointer is.

Alternatively store the configs in RAM

If you've got lots of RAM to spare in your design, there is another way to initialise, store and retrieve the config structure. The state structure can store a copy of the configs, with that copy being performed in the init function. Thereafter, when we need to get the configs in, say a write function, they can again be retrieved via the state structure.

This would mean that the state_t declaration looks like this

struct driver_spi_state_t
{
    struct driver_spi_config_t config; // keep a copy of the configs so that after init, other functions don't need to get config
    // other state variables for this instance of the driver
};

and the driver_spi_init() function is changed to copy the contents of the config structure

pstate->config = *pconfig;

You might want to do this so that the config structure can be declared and initialised as a local variable, whose existence doesn't matter after the init call. Note that state.config can't be declared const because we have to assign to it at runtime after global initialisation has completed.

main.c

Although main.c will contain all the hardware information generated by the ioc, we don't want to add any more dependencies, since application code could be moved to another file and still need to access the drivers. By including just driver_spi.h and board_config.h, we can use the driver simply by calling the driver_spi_init() and driver_spi_write() functions as shown in the snippet below.

/* USER CODE BEGIN 2 */

  driver_spi_init(&driver_spi_A_state, &driver_spi_A_config);
  uint8_t write_buf[5] = {1, 2, 3, 4, 5};

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    driver_spi_write(&driver_spi_A_state, write_buf, sizeof(write_buf));
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

To allow for main.c and the other application files to be non-board specific, we would want to keep the generic names of the included files and just change their contents according to the board.

Extending this idea beyond drivers

There's no reason to restrict this idea and structure just to drivers. It's a good idea to break larger applications up into smaller pieces of functionality called subsystems, and each subsystem can have a config structure and a state structure. Typically, the subsystem is initialised at program startup where the config struct is set, and the subsystems all have the same three functions

subsystem_init(struct subsystem_state_t *state, struct subsystem_config_t const *config);
subsystem_handle_messages(struct subsystem_state_t *state, uint8_t *msg_buf);
subsystem_run(struct subsystem_state_t *state);

Note that subsystem_run() is not a loop which never returns, it is a non-blocking function which executes one iteration of a state machine. The states are stored in the struct subsystem_state_t variable.

Board Reusability

It's easy to see that this isolates the board specific elements to the board_config.h file thereby achieving board re-usabilty, and I've had success with this across several boards which otherwise share code. It keeps the board type configuration in one place, avoiding the usual scattering of #ifdefs that otherwise occur across the codebase.

Processor Reusability

Although the examples here make it look like it would be easy to port the application to completely different processors from different manufacturers, I'm wary about making that claim too strongly. I'm my experience of porting, you don't really know if you've achieved portability until you have the code running on at least three platforms. It's impossible to predict how a processor that you haven't used yet will need to be driven, and the further from the original processor you stray, the more change will be needed. The converse is that for processors in the same family, or at the same manufacturer, there should be very little difficulty in terms of functional requirements. Any difficulties are likely to be the differences in the options available in the corresponding peripheral.

Thanks

Thanks to RZ for these concepts; he knows who he is, and his initials are not RZ.

This article was updated on March 27, 2025