首页 >后端开发 >C++ >可重用组件库:简化目标之间的迁移

可重用组件库:简化目标之间的迁移

Susan Sarandon
Susan Sarandon原创
2024-12-08 21:51:14571浏览

Reusable Component Libraries: Simplifying Migration Between Targets

使用微控制器或目标本身外部的组件进行操作是固件开发的常态。因此,了解如何为他们开发库至关重要。这些库允许我们与它们交互并交换信息或命令。然而,在遗留代码或学生(或非学生)的代码中,经常会发现这些与组件的交互是直接在应用程序代码中完成的,或者即使放在单独的文件中,这些交互本质上也是如此与目标绑定。

让我们看一下 STMicroelectronics STM32F401RE 应用程序中 Bosch BME280 温度、湿度和压力传感器的库开发的一个糟糕示例。在示例中,我们要初始化组件并每 1 秒读取一次温度。 (在示例代码中,我们将省略STM32CubeMX/IDE产生的所有“噪音”,例如各种时钟和外设的初始化,或者诸如USER CODE BEGIN或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.
    }
}

基于这个例子,我们可以提出一系列问题:如果我需要更改目标(无论是由于库存短缺、想要降低成本,还是只是开发使用相同组件的另一个产品),会发生什么?如果系统中有多个相同类型的组件,会发生什么情况?如果另一个产品使用相同的组件会发生什么?如果我还没有硬件,如何测试我的开发(这是专业领域中非常常见的情况,固件和硬件开发阶段经常在过程中的某些点重叠)?

对于前三个问题,答案是编辑代码,是否在切换目标时完全更改它,复制现有代码以与相同类型的附加组件一起操作,或者为目标实现相同的代码其他项目/产品。在最后一个问题中,如果没有硬件执行代码,就无法测试代码。这意味着只有硬件完成后我们才能开始测试代码并开始修复固件开发本身固有的错误,从而延长产品开发时间。这就提出了引发这篇文章的问题:是否可以为独立于目标并允许重用的组件开发库?答案是肯定的,这就是我们将在这篇文章中看到的内容。

将库与目标隔离

为了将库与目标隔离,我们将遵循两条规则:1)我们将在其自己的编译单元中实现库,即它自己的文件,2)不会引用任何特定于目标的标头或函数。我们将通过为 BME280 实现一个简单的库来演示这一点。首先,我们将在项目中创建一个名为 bme280 的文件夹。在 bme280 文件夹中,我们将创建以下文件:bme280.c、bme280.h 和 bme280_interface.h。澄清一下,不,我没有忘记将文件命名为 bme280_interface.c。该文件不会成为库的一部分。

我通常将库文件夹放在 Application/lib/ 中。

bme280.h 文件将声明我们的库中可用的所有函数,供我们的应用程序调用。另一方面,bme280.c 文件将实现这些函数的定义,以及库可能包含的任何辅助函数和私有函数。那么,bme280_interface.h 文件包含什么内容?好吧,无论我们的目标是什么,都需要以一种或另一种方式与 BME280 组件进行通信。在这种情况下,BME280 支持 SPI 或 I2C 通信。在这两种情况下,目标必须能够读取组件字节并将其写入组件。 bme280_interface.h 文件将声明这些函数,以便可以从库中调用它们。这些函数的定义将是与特定目标相关的唯一部分,如果我们将库迁移到另一个目标,这将是我们唯一需要编辑的内容。

声明库 API

我们首先在 bme280.h 文件中声明库中的可用函数。

#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.
    }
}

我们创建的库将非常简单,我们只会实现一个基本的初始化函数和另一个来获取温度测量值。现在,我们来实现bme280.c文件中的函数。

为了避免帖子过于冗长,我跳过了记录功能的注释。这是这些评论所在的文件。如今有如此多的人工智能工具可用,没有理由不记录您的代码。

驱动程序API的实现

bme280.c 文件的骨架如下:

#ifndef BME280_H_
#define BME280_H_

void  BME280_init(void);
float BME280_get_temperature(void);

#endif // BME280_H_

让我们重点关注初始化。如前所述,BME280 支持 I2C 和 SPI 通信。在这两种情况下,我们都需要初始化目标的适当外设(I2C 或 SPI),然后我们需要能够通过它们发送和接收字节。假设我们使用 I2C 通信,在 STM32F401RE 中它将是:

#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.
    }
}

外围设备初始化后,我们需要初始化组件。在这里,我们必须使用制造商在其数据表中提供的信息。简单总结一下:我们需要启动温度采样通道(默认处于睡眠模式)并读取存储在组件 ROM 中的一些校准常数,稍后我们将需要这些校准常数来计算温度。

这篇文章的目的不是学习如何使用 BME280,因此我将跳过其使用详细信息,您可以在其数据表中找到这些详细信息。

初始化看起来像这样:

#ifndef BME280_H_
#define BME280_H_

void  BME280_init(void);
float BME280_get_temperature(void);

#endif // BME280_H_

详情可评论。我们读取的校准值存储在名为 dig_temp1、dig_temp2 和 dig_temp3 的变量中。这些变量被声明为全局变量,因此它们可用于库中的其余函数。但是,它们被声明为静态,因此只能在库内访问。图书馆外的任何人都不需要访问或修改这些值。

我们还看到对 I2C 指令的返回值进行检查,如果失败,函数执行将停止。这很好,但还可以改进。如果是这种情况,通知 BME280_init 函数的调用者出现问题不是更好吗?为此,我们在 bme280.h 文件中定义以下枚举。

我对它们使用 typedef。关于 typedef 的使用存在争议,因为它们以隐藏细节为代价提高了代码的可读性。这是个人喜好的问题,并确保开发团队的所有成员都在同一页面上。

void BME280_init(void)
{
}

float BME280_get_temperature(void)
{
}

两个注意事项:我通常在 typedef 中添加 _t 后缀以表明它们是 typedef,并且在 typedef 的值或成员中添加 typedef 前缀,在本例中为 BME280_Status_。后者是为了避免来自不同库的枚举之间的冲突。如果每个人都使用 OK 作为枚举,我们就会遇到麻烦。

现在我们可以修改 BME280_init 函数的声明(bme280.h)和定义(bme280.c)以返回状态。我们函数的最终版本将是:

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;
}

由于我们使用状态枚举,因此我们必须在 bme280.c 文件中包含 bme280.h 文件。我们已经初始化了该库。现在,让我们创建一个函数来检索温度。它看起来像这样:

typedef enum
{
    BME280_Status_Ok,
    BME280_Status_Status_Err,
} BME280_Status_t;

你已经注意到了,对吧?我们修改了函数签名,以便它返回一个状态来指示组件是否存在通信问题,并且结果通过作为参数传递给函数的指针返回。如果您遵循该示例,请记住修改 bme280.h 文件中的函数声明以使它们匹配。

BME280_Status_t BME280_init(void);

太棒了!此时,在应用程序中我们可以有:

#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.
    }
}

超级干净!这是可读的。忽略 STM32CubeMX/IDE 中 Error_Handler 函数的使用。一般不建议使用它,但对于我们来说,它是有效的。那么,完成了吗?

嗯,不!我们已将与组件的交互封装到其自己的文件中。但它的代码仍然在调用目标函数(HAL函数)!如果我们改变目标,我们就必须重写库!提示:我们还没有在 bme280_interface.h 文件中编写任何内容。现在让我们解决这个问题。

接口声明

如果我们查看 bme280.c 文件,我们与目标的交互有三重:初始化外设、写入/发送字节以及读取/接收字节。因此,我们要做的就是在 bme280_interface.h 文件中声明这三个交互。

#ifndef BME280_H_
#define BME280_H_

void  BME280_init(void);
float BME280_get_temperature(void);

#endif // BME280_H_

如果您注意到的话,我们还为界面状态定义了一种新类型。现在,我们不再直接调用目标函数,而是从 bme280.c 文件中调用这些函数。

void BME280_init(void)
{
}

float BME280_get_temperature(void)
{
}

Et voilà! 目标依赖项已从库中消失。我们现在有了一个适用于 STM32、MSP430、PIC32 等的库。在这三个库文件中,不应出现任何特定于任何目标的内容。唯一剩下的是什么?好了,定义接口函数。这是唯一需要针对每个目标迁移/调整的部分。

我通常在文件夹Application/bsp/components/中进行。

我们创建一个名为 bme280_implementation.c 的文件,其中包含以下内容:

void BME280_init(void)
{
    MX_I2C1_Init();
}

这样,如果我们想在另一个项目或另一个目标上使用该库,我们只需要修改 bme280_implementation.c 文件即可。其余部分保持完全相同。

其他需要考虑的方面

至此,我们已经看到了一个库的基本示例。这种实现是最简单、最安全、也是最常见的。然而,根据我们项目的特点,有不同的变体。在此示例中,我们了解了如何在链接时执行实现选择。也就是说,我们有 bme280_implementation.c 文件,它提供了编译/链接过程中接口函数的定义。如果我们想要有两个实现会怎样?一个用于 I2C 通信,另一个用于 SPI 通信。在这种情况下,我们需要使用函数指针在运行时指定实现。

另一方面是,在这个例子中,我们假设系统中只有一个 BME280。如果我们有多个的话会发生什么?我们是否应该复制/粘贴代码并向 BME280_1 和 BME280_2 等函数添加前缀?不,这并不理想。我们要做的是使用处理程序来允许我们在组件的不同实例上使用相同的库。

这些方面以及如何在硬件可用之前测试我们的库是另一篇文章的主题,我们将在以后的文章中介绍。目前,我们没有理由不正确实现库。然而,我的第一个建议(矛盾的是,我留到最后的建议)是,首先也是最重要的,确保制造商尚未为其组件提供官方库。这是启动和运行库的最快方法。请放心,制造商提供的库可能会遵循与我们今天看到的类似的实现,我们的工作将是使接口实现部分适应我们的目标或产品。


如果您对这个主题感兴趣,您可以在我的博客上找到这篇文章以及其他与嵌入式系统开发相关的文章! ?

以上是可重用组件库:简化目标之间的迁移的详细内容。更多信息请关注PHP中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn