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.
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 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.
Declaring the Library API
We begin by declaring the available functions in the library within the bme280.h file.
#ifndef BME280_H_
#define BME280_H_
void BME280_init(void);
float BME280_get_temperature(void);
#endif // BME280_H_
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.
Implementation of the Driver API
The skeleton of the bme280.c file would be as follows:
void BME280_init(void)
{
}
float BME280_get_temperature(void)
{
}
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:
void BME280_init(void)
{
MX_I2C1_Init();
}
Once the peripheral is initialized, we need to initialize the component. Here, we must use the information provided by the manufacturer in its ! 🚀
SOCIAL SHARE CARD GENERATOR