All Posts

Function Pointers in Embedded Systems: A Guide to Dynamic Driver Selection

One of the biggest challenges in embedded systems programming is developing software that can work across a variety of hardware versions.

The challenge with embedded devices lies in dealing with various hardware versions, where the same IP or peripheral can differ in features, register configurations, or bit descriptions across different devices.

An embedded device can have multiple versions of the same IP (Intellectual Property) or peripheral. For instance, a microcontroller like the STM32 may include two different versions of the SPI (Serial Peripheral Interface) within the same MCU (Microcontroller Unit) family.

Use Case and Problematic

Consider the example of two microcontrollers, STM32F1 and STM32F4. Each of these microcontrollers has different versions of SPIs (Serial Peripheral Interfaces) with slight variations in features, supported options, and speed.

These differences in features between SPI versions are reflected in the software drivers, resulting in separate driver versions for each SPI variant.

When a high-level user application calls SPI driver APIs, any modifications to the low-level SPI driver can significantly impact the application. This might necessitate updates to the user-level application source code.

The main challenge is to decouple high-level software from low-level drivers so that updates or modifications to the drivers do not require widespread changes throughout the application. The goal is to minimize changes and ensure the application continues to run smoothly despite updates to the low-level drivers.

Hardware-to-Software Abstraction

Function Pointers is Your Answer

Function pointers act as the “glue” between low-level drivers and high-level applications. To illustrate their role, consider the following example involving two STM32 MCUs, each with a different version of SPI:

SPI Driver version 1 skeleton example:

/* SPI driver version 1 in STM32F1 */
void SPI_V1_Init(void);
void SPI_V1_DeInit(void);
uint32_t SPI_V1_Send(unsigned char* buffer, size_t size);
uint32_t SPI_V1_Receive(unsigned char* buffer, size_t size);

SPI Driver version 2 skeleton example:

/* SPI driver version 2 in STM32F4 */
void SPI_V2_Init(void);
void SPI_V2_DeInit(void);
uint32_t SPI_V2_Send(unsigned char* buffer, size_t size);
uint32_t SPI_V2_Receive(unsigned char* buffer, size_t size);

The implementation of each driver API will vary because the register and bit descriptions, as well as available features, differ between driver versions. However, the key is to maintain the same API signature across similar driver functions. This means using consistent data types for return codes and input parameters, which sets the stage for utilizing function pointers.

Function pointers can be employed on a larger scale by referencing them through structures rather than directly in C functions. While this concept may initially seem complex, we will break it down using our example to make it more understandable.

Structure of function pointers template:

typedef struct SPI_DRIVER_PTR{
  void     (*Init)(void);
  void     (*DeInit)(void);
  uint32_t (*Send)(unsigned char*, size_t);
  uint32_t (*Receive)(unsigned char*, size_t);
} SPI_DRIVER_PTR;

This is an abstracted structure of function pointers. Each element of this structure is a pointer to a predefined function with a consistent return data type and input parameters, matching the signatures of our SPI driver APIs.

Returning to our discussion on SPI driver APIs, we emphasized the need for consistent function declarations across different driver versions. This uniformity allows us to easily switch between functions using the structure of function pointers.

Defining driver structures:

SPI_DRIVER_PTR SPI_V1_DRIVER = {
  SPI_V1_Init,
  SPI_V1_DeInit,
  SPI_V1_Send,
  SPI_V1_Receive
};

SPI_DRIVER_PTR SPI_V2_DRIVER = {
  SPI_V2_Init,
  SPI_V2_DeInit,
  SPI_V2_Send,
  SPI_V2_Receive
};

Runtime driver selection:

SPI_DRIVER_PTR SPI_USER_DRIVER;

void SPI_DRIVER_UPDATE(SPI_DRIVER_PTR* spi_drv) {
  SPI_USER_DRIVER = *spi_drv;
}

int main(void) {
  // user based command to select which SPI driver to use
  if (get_user_command() == 'SPI_V1') {
    SPI_DRIVER_UPDATE(&SPI_V1_DRIVER);
  } else {
    SPI_DRIVER_UPDATE(&SPI_V2_DRIVER);
  }

  // SPI driver invocation
  (*SPI_USER_DRIVER.Init)();
  unsigned char buf[5] = {0,0,0,0,0};
  uint32_t rcv_bytes = (*SPI_USER_DRIVER.Receive)(buf, sizeof(buf));

  return 0;
}

Conclusion

When writing software for embedded systems, it’s important to develop solutions that are stable within the constraints of existing hardware, while also maintaining abstraction for improved portability.

Creating portable software that requires minimal changes — or even runs dynamically on different hardware versions — is a key success factor for embedded code. Always strive to maintain this spirit of abstraction when writing any kind of code!