
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.

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.

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.
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.
driver_spi_write()
function, all the specifics are handled in the bsp
layer.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.