Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Reusable Component Libraries: Simplifying Migration Between Targets
Albert
Albert

Posted on

Reusable Component Libraries: Simplifying Migration Between Targets

Operating with components external to the microcontroller or target itself is the norm in firmware development. Therefore, knowing how to develop libraries for them is essential. These libraries allow us to interact with them and exchange information or commands. However, it is not uncommon to find, in legacy code or code from students (or not-so-students), that these interactions with components are done directly in the application code or, even when placed in separate files, these interactions are intrinsically tied to the target.

Let’s look at a poor example of library development for a Bosch BME280 temperature, humidity, and pressure sensor in an application for an STMicroelectronics STM32F401RE. In the example, we want to initialize the component and read the temperature every 1 second. (In the example code, we will omit all the "noise" generated by STM32CubeMX/IDE, such as the initialization of various clocks and peripherals, or comments likeUSER CODE BEGIN orUSER CODE END.)

#include"i2c.h"#include<stdint.h>intmain(void){uint8_tidx=0U;uint8_ttx_buffer[64]={0};uint8_trx_buffer[64]={0};uint16_tdig_temp1=0U;int16_tdig_temp2=0;int16_tdig_temp3=0;MX_I2C1_Init();tx_buffer[idx++]=0b10100011;HAL_I2C_Mem_Write(&hi2c1,0x77U<<1U,0xF4U,1U,tx_buffer,1U,200U);HAL_I2C_Mem_Read(&hi2c1,0x77U<<1U,0x88U,1U,rx_buffer,6U,200U);dig_temp1=((uint16_t)rx_buffer[0])|(((uint16_t)rx_buffer[1])<<8U);dig_temp2=(int16_t)(((uint16_t)rx_buffer[2])|(((uint16_t)rx_buffer[3])<<8U));dig_temp3=(int16_t)(((uint16_t)rx_buffer[4])|(((uint16_t)rx_buffer[5])<<8U));while(1){floattemperature=0.0f;int32_tadc_temp=0;int32_tt_fine=0;floatvar1=0.0f;floatvar2=0.0f;HAL_I2C_Mem_Read(&hi2c1,0x77U<<1U,0xFAU,1U,rx_buffer,3U,200U);adc_temp=(int32_t)((((uint32_t)rx_buffer[0])<<12U)|(((uint32_t)rx_buffer[1])<<4U)|(((uint32_t)rx_buffer[2])>>4U));var1=(((float)adc_temp)/16384.0f-((float)dig_temp1)/1024.0f)*((float)dig_temp2);var2=((((float)adc_temp)/131072.0f-((float)dig_temp1)/8192.0f)*(((float)adc_temp)/131072.0f-((float)dig_temp1)/8192.0f))*((float)dig_temp3);t_fine=(int32_t)(var1+var2);temperature=((float)t_fine)/5129.0f;// Temperature available for the application.}}
Enter fullscreen modeExit fullscreen mode

Based on this example, we can raise a series of questions: what happens if I need to change the target (whether due to stock shortages, wanting to reduce costs, or simply working on another product that uses the same component)? What happens if I have more than one component of the same type in the system? What happens if another product uses the same component? How can I test my development if I don't have the hardware yet (a very common situation in the professional world where firmware and hardware development phases often overlap at certain points in the process)?

For the first three questions, the answer is to edit the code, whether to completely change it when switching targets, to duplicate the existing code to operate with an additional component of the same type, or to implement the same code for the other project/product. In the last question, there is no way to test the code without having the hardware to execute it. This means that only after the hardware is finished could we begin testing our code and start fixing errors inherent to firmware development itself, thus prolonging the product development time. This raises the question that gives rise to this post: is it possible to develop libraries for components that are independent of the target and allow for reuse? The answer is yes, and this is what we will see in this post.

Isolating the Library from the Target

To isolate libraries from a target, we will follow two rules: 1) we will implement the library in its own compilation unit, meaning its own file, and 2) there will be no references to any target-specific headers or functions. We will demonstrate this by implementing a simple library for the BME280. To start, we will create a folder calledbme280 within our project. Inside thebme280 folder, we will create the following files:bme280.c,bme280.h, andbme280_interface.h. To clarify, no, I haven’t forgotten to name the filebme280_interface.c. This file will not be part of the library.

I usually place the library folders insideApplication/lib/.

Thebme280.h file will declare all the functions available in our library to be called by our application. On the other hand, thebme280.c file will implement the definitions of those functions, along with any auxiliary and private functions that the library may contain. So, what does thebme280_interface.h file contain? Well, our target, whatever it may be, will need to communicate with the BME280 component in one way or another. In this case, the BME280 supports either SPI or I2C communication. In both cases, the target must be able to read and write bytes to the component. Thebme280_interface.h file will declare those functions so they can be called from the library. The definition of these functions will be the only part tied to the specific target, and it will be the only thing we need to edit if we migrate the library to another target.

Declaring the Library API

We begin by declaring the available functions in the library within thebme280.h file.

#ifndef BME280_H_#define BME280_H_voidBME280_init(void);floatBME280_get_temperature(void);#endif // BME280_H_
Enter fullscreen modeExit fullscreen mode

The library we are creating will be very simple, and we will only implement a basic initialization function and another to obtain a temperature measurement. Now, let’s implement the functions in thebme280.c file.

To avoid making the post too verbose, I am skipping the comments that would document the functions. This is the file where those comments would go. With so many AI tools available today, there’s no excuse for not documenting your code.

Implementation of the Driver API

The skeleton of thebme280.c file would be as follows:

voidBME280_init(void){}floatBME280_get_temperature(void){}
Enter fullscreen modeExit fullscreen mode

Let’s focus on initialization. As mentioned earlier, the BME280 supports both I2C and SPI communication. In both cases, we need to initialize the appropriate peripheral of the target (I2C or SPI), and then we need to be able to send and receive bytes through them. Assuming we are using I2C communication, in the STM32F401RE it would be:

voidBME280_init(void){MX_I2C1_Init();}
Enter fullscreen modeExit fullscreen mode

Once the peripheral is initialized, we need to initialize the component. Here, we must use the information provided by the manufacturer in itsdatasheet. Here’s a quick summary: we need to start the temperature sampling channel (which is in sleep mode by default) and read some calibration constants stored in the component's ROM, which we will need later to calculate the temperature.

The goal of this post is not to learn how to use the BME280, so I will skip details of its usage, which you can find in its datasheet.

The initialization would look like this:

#include"i2c.h"#include<stdint.h>#define BME280_TX_BUFFER_SIZE 32U#define BME280_RX_BUFFER_SIZE 32U#define BME280_TIMEOUT        200U#define BME280_ADDRESS        0x77U#define BME280_REG_CTRL_MEAS  0xF4U#define BME280_REG_DIG_T      0x88Ustaticuint16_tdig_temp1=0U;staticint16_tdig_temp2=0;staticint16_tdig_temp3=0;voidBME280_init(void){uint8_tidx=0U;uint8_ttx_buffer[BME280_TX_BUFFER_SIZE]={0};uint8_trx_buffer[BME280_RX_BUFFER_SIZE]={0};HAL_StatusTypeDefstatus=HAL_ERROR;MX_I2C1_Init();tx_buffer[idx++]=0b10100011;status=HAL_I2C_Mem_Write(&hi2c1,BME280_ADDRESS<<1U,BME280_REG_CTRL_MEAS,1U,tx_buffer,(uint16_t)idx,BME280_TIMEOUT);if(status!=HAL_OK)return;status=HAL_I2C_Mem_Read(&hi2c1,BME280_ADDRESS<<1U,BME280_REG_DIG_T,1U,rx_buffer,6U,BME280_TIMEOUT);if(status!=HAL_OK)return;dig_temp1=((uint16_t)rx_buffer[0]);dig_temp1=dig_temp1|(((uint16_t)rx_buffer[1])<<8U);dig_temp2=((int16_t)rx_buffer[2]);dig_temp2=dig_temp2|(((int16_t)rx_buffer[3])<<8U);dig_temp3=((int16_t)rx_buffer[4]);dig_temp3=dig_temp3|(((int16_t)rx_buffer[5])<<8U);return;}
Enter fullscreen modeExit fullscreen mode

Details to comment on. The calibration values that we read are stored in variables calleddig_temp1,dig_temp2, anddig_temp3. These variables are declared as global so they are available for the rest of the functions in the library. However, they are declared as static so that they are only accessible within the library. No one outside the library needs to access or modify these values.

We also see that the return value from the I2C instructions is checked, and in case of a failure, the function execution is halted. This is fine, but it can be improved. Wouldn't it be better to notify the caller of theBME280_init function that something went wrong, if that was the case? To do this, we define the followingenum in thebme280.h file.

I usetypedef for them. There is debate about the use oftypedef because they improve code readability at the cost of hiding details. It’s a matter of personal preference and making sure all members of the development team are on the same page.

typedefenum{BME280_Status_Ok,BME280_Status_Status_Err,}BME280_Status_t;
Enter fullscreen modeExit fullscreen mode

Two notes: I usually add the_t suffix to typedefs to indicate that they are typedefs, and I add the typedef prefix to the values or members of the typedef, in this caseBME280_Status_. The latter is to avoid collisions between enums from different libraries. If everyone usedOK as an enum, we’d be in trouble.

Now we can modify both the declaration (bme280.h) and the definition (bme280.c) of theBME280_init function to return a status. The final version of our function would be:

BME280_Status_tBME280_init(void);
Enter fullscreen modeExit fullscreen mode
#include"bme280.h"BME280_Status_tBME280_init(void){uint8_tidx=0U;uint8_ttx_buffer[BME280_TX_BUFFER_SIZE]={0};uint8_trx_buffer[BME280_RX_BUFFER_SIZE]={0};HAL_StatusTypeDefstatus=HAL_ERROR;MX_I2C1_Init();tx_buffer[idx++]=0b10100011;status=HAL_I2C_Mem_Write(&hi2c1,BME280_ADDRESS<<1U,BME280_REG_CTRL_MEAS,1U,tx_buffer,(uint16_t)idx,BME280_TIMEOUT);if(status!=HAL_OK)returnBME280_Status_Err;status=HAL_I2C_Mem_Read(&hi2c1,BME280_ADDRESS<<1U,BME280_REG_DIG_T,1U,rx_buffer,6U,BME280_TIMEOUT);if(status!=HAL_OK)returnBME280_Status_Err;dig_temp1=((uint16_t)rx_buffer[0]);dig_temp1=dig_temp1|(((uint16_t)rx_buffer[1])<<8U);dig_temp2=((int16_t)rx_buffer[2]);dig_temp2=dig_temp2|(((int16_t)rx_buffer[3])<<8U);dig_temp3=((int16_t)rx_buffer[4]);dig_temp3=dig_temp3|(((int16_t)rx_buffer[5])<<8U);returnBME_Status_Ok;}
Enter fullscreen modeExit fullscreen mode

Since we are using the status enum, we must include thebme280.h file in thebme280.c file. We have already initialized the library. Now, let's create the function to retrieve the temperature. It would look like this:

#define BME280_REG_TEMP 0xFAUBME280_Status_tBME280_get_temperature(float*temperature){uint8_trx_buffer[BME280_RX_BUFFER_SIZE]={0};int32_tadc_temp=0;int32_tt_fine=0;floatvar1=0.0f;floatvar2=0.0f;HAL_StatusTypeDefstatus=HAL_ERROR;*temperature=0.0f;status=HAL_I2C_Mem_Read(&hi2c1,BME280_ADDRESS<<1U,BME280_REG_TEMP,1U,rx_buffer,3U,BME280_TIMEOUT);if(status!=HAL_OK)returnBME280_Status_Err;adc_temp=(int32_t)((((uint32_t)rx_buffer[0])<<12U)|(((uint32_t)rx_buffer[1])<<4U)|(((uint32_t)rx_buffer[2])>>4U));var1=(((float)adc_temp)/16384.0-((float)dig_temp1)/1024.0)*((float)dig_temp2);var2=((((float)adc_temp)/131072.0-((float)dig_temp1)/8192.0)*(((float)adc_temp)/131072.0-((float)dig_temp1)/8192.0))*((float)dig_temp3);t_fine=(int32_t)(var1+var2);*temperature=((float)t_fine)/5129.0f;returnBME280_Status_Ok;}
Enter fullscreen modeExit fullscreen mode

You’ve noticed, right? We’ve modified the function signature so that it returns a status to indicate whether there were communication issues with the component or not, and the result is returned through the pointer passed as a parameter to the function. If you’re following the example, remember to modify the function declaration in thebme280.h file so that they match.

BME280_Status_tBME280_get_temperature(float*temperature);
Enter fullscreen modeExit fullscreen mode

Great! At this point, in the application we can have:

#include"bme280.h"intmain(void){BME280_Status_tstatus=BME280_Status_Err;status=BME280_init();if(status!=BME280_Status_Ok)Error_Handler();while(1){floattemperature=0.0f;status=BME280_get_temperature(&temperature);if(status!=BME280_Status_Ok)Error_Handler();// Temperature available for the application.}}
Enter fullscreen modeExit fullscreen mode

Super clean! This is readable. Ignore the use of theError_Handler function from STM32CubeMX/IDE. It’s generally not recommended to use it, but for the example, it works for us. So, is it done?

Well, no! We’ve encapsulated our interactions with the component into its own files. But its code is still calling target functions (HAL functions)! If we change the target, we’ll have to rewrite the library! Hint: we haven’t written anything in thebme280_interface.h file yet. Let’s tackle that now.

Interface Declaration

If we look at thebme280.c file, our interactions with the target are threefold: to initialize peripherals, to write/send bytes, and to read/receive bytes. So, what we’ll do is declare those three interactions in thebme280_interface.h file.

#ifndef BME280_INTERFACE_H_#define BME280_INTERFACE_H_#include<stdint.h>typedefenum{BME280_Interface_Ok,BME280_Interface_Err,}BME280_Interface_Status_t;BME280_Interface_Status_tBME280_Interface_init(uint8_taddress,uint32_ttimeout);BME280_Interface_Status_tBME280_Interface_write(uint8_treg,uint8_t*data,uint16_tsize);BME280_Interface_Status_tBME280_Interface_read(uint8_treg,uint8_t*data,uint16_tsize);#endif // BME280*INTERFACE_H_
Enter fullscreen modeExit fullscreen mode

If you notice, we’ve also defined a new type for the interface status. Now, instead of calling the target functions directly, we will call these functions from thebme280.c file.

#include"bme280.h"#include"bme280_interface.h"#define BME280_TX_BUFFER_SIZE 32U#define BME280_RX_BUFFER_SIZE 32U#define BME280_TIMEOUT        200U#define BME280_ADDRESS        0x77U#define BME280_REG_CTRL_MEAS  0xF4U#define BME280_REG_DIG_T      0x88U#define BME280_REG_TEMP       0xFAUstaticuint16_tdig_temp1=0U;staticint16_tdig_temp2=0;staticint16_tdig_temp3=0;BME280_Status_tBME280_init(void){uint8_tidx=0U;uint8_ttx_buffer[BME280_TX_BUFFER_SIZE]={0};uint8_trx_buffer[BME280_RX_BUFFER_SIZE]={0};BME280_Interface_Status_tstatus=BME280_Interface_Err;status=BME280_Interface_init(BME280_ADDRESS,BME280_TIMEOUT);if(status!=BME280_Interface_Ok)returnBME280_Status_Err;tx_buffer[idx++]=0b10100011;status=BME280_Interface_write(BME280_REG_CTRL_MEAS,tx_buffer,(uint16_t)idx);if(status!=BME280_Interface_Ok)returnBME280_Status_Err;status=BME280_Interface_read(BME280_REG_DIG_T,rx_buffer,6U);if(status!=BME280_Interface_Ok)returnBME280_Status_Err;dig_temp1=((uint16_t)rx_buffer[0]);dig_temp1=dig_temp1|(((uint16_t)rx_buffer[1])<<8U);dig_temp2=((int16_t)rx_buffer[2]);dig_temp2=dig_temp2|(((int16_t)rx_buffer[3])<<8U);dig_temp3=((int16_t)rx_buffer[4]);dig_temp3=dig_temp3|(((int16_t)rx_buffer[5])<<8U);returnBME280_Status_Ok;}BME280_Status_tBME280_get_temperature(float*temperature){uint8_trx_buffer[BME280_RX_BUFFER_SIZE]={0};int32_tadc_temp=0;int32_tt_fine=0;doublevar1=0.0;doublevar2=0.0;BME280_Interface_Status_tstatus=BME280_Interface_Err;*temperature=0.0f;status=BME280_Interface_read(BME280_REG_TEMP,rx_buffer,3U);if(status!=BME280_Interface_Ok)returnBME280_Status_Err;adc_temp=(int32_t)((((uint32_t)rx_buffer[0])<<12U)|(((uint32_t)rx_buffer[1])<<4U)|(((uint32_t)rx_buffer[2])>>4U));var1=(((double)adc_temp)/16384.0-((double)dig_temp1)/1024.0)*((double)dig_temp2);var2=((((double)adc_temp)/131072.0-((double)dig_temp1)/8192.0)*(((double)adc_temp)/131072.0-((double)dig_temp1)/8192.0))*((double)dig_temp3);t_fine=(int32_t)(var1+var2);*temperature=((float)t_fine)/5129.0f;returnBME280_Status_Ok;}
Enter fullscreen modeExit fullscreen mode

Et voilà! The target dependencies have disappeared from the library. We now have a library that works for STM32, MSP430, PIC32, etc. In the three library files, nothing specific to any target should appear. What's the only thing left? Well, defining the interface functions. This is the only part that needs to be migrated/adapted for each target.

I usually do it inside the folderApplication/bsp/components/.

We create a file calledbme280_implementation.c with the following content:

#include"bme280_interface.h"#include"i2c.h"staticuint8_tdevice_address=0U;staticuint32_tdevice_timeout=0U;BME280_Interface_Status_tBME280_Interface_init(uint8_taddress,uint32_ttimeout){MX_I2C1_Init();device_address=address;device_timeout=timeout;returnBME280_Interface_Ok;}BME280_Interface_Status_tBME280_Interface_write(uint8_treg,uint8_t*data,uint16_tsize){HAL_StatusTypeDefstatus=HAL_ERROR;status=HAL_I2C_Mem_Write(&hi2c1,device_address<<1U,reg,1U,data,size,device_timeout);if(status!=HAL_OK)returnBME280_Interface_Err;returnBME280_Interface_Ok;}BME280_Interface_Status_tBME280_Interface_read(uint8_treg,uint8_t*data,uint16_tsize){HAL_StatusTypeDefstatus=HAL_ERROR;status=HAL_I2C_Mem_Read(&hi2c1,device_address<<1U,reg,1U,data,size,device_timeout);if(status!=HAL_OK)returnBME280_Interface_Err;returnBME280_Interface_Ok;}
Enter fullscreen modeExit fullscreen mode

This way, if we want to use the library in another project or on another target, we only need to adapt thebme280_implementation.c file. The rest remains exactly the same.

Other aspects to consider

With this, we have seen a basic example of a library. This implementation is the simplest, safest, and most common. However, there are different variants depending on the characteristics of our project. In this example, we have seen how to perform a selection of the implementation at link time. That is, we have thebme280_implementation.c file, which provides the definitions of the interface functions during the compilation/linking process. What would happen if we wanted to have two implementations? One for I2C communication and another for SPI communication. In that case, we would need to specify the implementations at run time using function pointers.

Another aspect is that in this example, we assume there is only one BME280 in the system. What would happen if we had more than one? Should we copy/paste code and add prefixes to functions likeBME280_1 andBME280_2? No. That’s not ideal. What we would do is use handlers to allow us to operate with the same library on different instances of a component.

These aspects and how to test our library before even having our hardware available is a topic for another post, which we will cover in future articles. For now, we have no excuse not to implement libraries properly. However, my first recommendation (and paradoxically, the one I’ve left for the end) is that, first and foremost, make sure the manufacturer doesn’t already provide an official library for their component. This is the fastest way to get a library up and running. Rest assured that the library provided by the manufacturer will likely follow a similar implementation to the one we have seen today, and our job will be to adapt the interface implementation part to our target or product.


If you're interested in this topic, you can find this post and others related to embedded systems development onmy blog! 🚀

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

  • Location
    Barcelona, Spain
  • Joined

More fromAlbert

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp