Home >Backend Development >C++ >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 like USER CODE BEGIN or USER CODE END.)
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_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) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 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. } }
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.
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 called bme280 within our project. Inside the bme280 folder, we will create the following files: bme280.c, bme280.h, and bme280_interface.h. To clarify, no, I haven’t forgotten to name the file bme280_interface.c. This file will not be part of the library.
I usually place the library folders inside Application/lib/.
The bme280.h file will declare all the functions available in our library to be called by our application. On the other hand, the bme280.c file will implement the definitions of those functions, along with any auxiliary and private functions that the library may contain. So, what does the bme280_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. The bme280_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.
We begin by declaring the available functions in the library within the bme280.h file.
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_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) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 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. } }
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 the bme280.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.
The skeleton of the bme280.c file would be as follows:
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
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:
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_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) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 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. } }
Once the peripheral is initialized, we need to initialize the component. Here, we must use the information provided by the manufacturer in its datasheet. 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:
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
Details to comment on. The calibration values that we read are stored in variables called dig_temp1, dig_temp2, and dig_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 the BME280_init function that something went wrong, if that was the case? To do this, we define the following enum in the bme280.h file.
I use typedef for them. There is debate about the use of typedef 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.
void BME280_init(void) { } float BME280_get_temperature(void) { }
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 case BME280_Status_. The latter is to avoid collisions between enums from different libraries. If everyone used OK as an enum, we’d be in trouble.
Now we can modify both the declaration (bme280.h) and the definition (bme280.c) of the BME280_init function to return a status. The final version of our function would be:
void BME280_init(void) { MX_I2C1_Init(); }
#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 0x88U static uint16_t dig_temp1 = 0U; static int16_t dig_temp2 = 0; static int16_t dig_temp3 = 0; void BME280_init(void) { uint8_t idx = 0U; uint8_t tx_buffer[BME280_TX_BUFFER_SIZE] = {0}; uint8_t rx_buffer[BME280_RX_BUFFER_SIZE] = {0}; HAL_StatusTypeDef status = 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; }
Since we are using the status enum, we must include the bme280.h file in the bme280.c file. We have already initialized the library. Now, let's create the function to retrieve the temperature. It would look like this:
typedef enum { BME280_Status_Ok, BME280_Status_Status_Err, } BME280_Status_t;
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 the bme280.h file so that they match.
BME280_Status_t BME280_init(void);
Great! At this point, in the application we can have:
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_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) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 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. } }
Super clean! This is readable. Ignore the use of the Error_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 the bme280_interface.h file yet. Let’s tackle that now.
If we look at the bme280.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 the bme280_interface.h file.
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
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 the bme280.c file.
void BME280_init(void) { } float BME280_get_temperature(void) { }
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 folder Application/bsp/components/.
We create a file called bme280_implementation.c with the following content:
void BME280_init(void) { MX_I2C1_Init(); }
This way, if we want to use the library in another project or on another target, we only need to adapt the bme280_implementation.c file. The rest remains exactly the same.
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 the bme280_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 like BME280_1 and BME280_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 on my blog! ?
The above is the detailed content of Reusable Component Libraries: Simplifying Migration Between Targets. For more information, please follow other related articles on the PHP Chinese website!