面向嵌入式系统的独立于硬件C++ HAL

Hardware-Independent C++ HAL for Embedded Systems

本文关键字:硬件 C++ HAL 于硬件 嵌入式 系统 独立      更新时间:2023-10-16

我正在研究如何实现一个自定义C++ HAL,它针对多个微控制器,可能具有不同的架构(ARM,AVR,PIC等),同时保持理智。

我继承了几个大型、混乱的代码库,这些代码库在当前状态下是不可维护的,因此需要更结构化的东西。

在挑选了许多好的文章和设计指南之后,我正在考虑PIMPL实现。

请考虑以下UART/串行端口示例:

// -----------------------------
// High-level HAL
// -----------------------------
// serialport.h
class SerialPortPrivate;
class SerialPort {
public:
SerialPort(uint8_t portNumber);
~SerialPort();
bool open();
void close();
void setBaudRate(uint32_t baudRate = 115200);
private:
SerialPortPrivate *_impl;
};   
// serialport_p.h
class SerialPort;
class SerialPortPrivate {
public:
SerialPortPrivate(uint8_t portNumber, SerialPort *parent) {
// Store the parent (q_ptr)
_parent = parent;
// Store the port number, this is used to access UART
// specific registers UART->D[portNumber] = 0x10;
_portNumber = portNumber;
}
~SerialPortPrivate();
bool open() = 0;
void close() = 0;
void setBaudRate(uint32_t baudRate) = 0;
protected:
uint8_t _portNumber;
private:
SerialPort *_parent;
};
// serialport.cpp
#include "serialport.h"
#include "serialport_p.h"    
#include "stm32serialport_p.h"
#include "avr32serialport_p.h"
#include "nrf52serialport_p.h"
#include "kinetisserialport_p.h"
SerialPort::SerialPort(uint8_t portNumber) {
#if MCU_STM32
_impl = new Stm32SerialPortPrivate(portNumber, this);
#elif MCU_AVR32
_impl = new Avr32SerialPortPrivate(portNumber, this);
#elif MCU_NRF52
_impl = new Nrf52SerialPortPrivate(portNumber, this);
#elif MCU_KINETIS
_impl = new KinetisSerialPortPrivate(portNumber, this);
#endif
}
void SerialPort::setBaudRate(uint32_t baudRate) {
_impl->setBaudRate(baudRate);
}
// -----------------------------
// Low-level BSP
// Hardware-specific overrides
// -----------------------------
// stm32serialport_p.h
class Stm32SerialPortPrivate : public SerialPortPrivate {
};
// nrf52serialport_p.h
class Nrf52SerialPortPrivate : public SerialPortPrivate {
};
// kinetisserialport_p.h
class KinetisSerialPortPrivate : public SerialPortPrivate {
};    

上面的代码在高级接口(SerialPort)的构造函数中只有一组#if/#endif语句,并且特定于硬件的代码(寄存器访问等)是在私有实现中完成的。

更进一步,我可以看到上面的实现适用于I2cPortSpiPortUsbSerialPort等类,但适用于其他非端口相关的外围设备集,如时钟、硬件计时器。

我敢肯定上述概念中存在一些漏洞,任何人都可以从经验中建议避免的事情,或者是否有更好的抽象方法?

以下是我对你的方法的一些担忧:

首先,假设一个平台上的外设有一些配置选项,而其他平台上的等效外设根本不存在。有一些选项可以处理此问题,例如:

  • 对该选项的特定值进行硬编码
  • 包括为该选项提供配置值的文件,但不提供带有 hal 的文件。每个使用 hal 的项目也必须提供此文件。
  • 扩展SerialPort以配置选项(额外的功能?某种回调?

前两个不是很灵活(在运行时无法更改),第三个打破了抽象 - 平台必须提供功能来配置可能不存在的选项,或者SerialPort用户必须知道底层平台的详细信息。在我看来,所有这些都是混乱代码库的成分。

其次,假设一个平台有多个不同的外围设备可以提供相同的功能。例如,我目前正在使用STM32,它具有USARTLPUART外设,两者都可以提供UART功能。要处理此问题,您需要根据端口在运行时实例化不同的 pimpl,或者为可以处理的平台提供一个。可行,但可能会变得混乱。

第三,要添加对另一个平台的支持,你现在需要修改很多其他代码来添加新的#elif子句。此外,#if-#elif-#endif使代码的可读性降低,尽管良好的语法突出显示会遮蔽代码的非活动部分。

至于我的建议:

找到合适的接口。尝试为硬件可以做什么创建一个接口是一种诱惑 - 它是一个硬件抽象层,对吧?但是,我发现最好从接口客户端的角度来看待它 - HAL 的用例是什么。如果您找到可以满足大多数或所有用例的简单界面,那么它可能是一个很好的界面。

(我认为,这可能是与你关于时钟和硬件计时器的观点最相关的。问问自己:您的用例是什么?

一个很好的例子是I2C。根据我的经验,大多数情况下,特定的I2C外设永久是主设备或永久从设备。我很少遇到需要在运行时在主站和从站之间交换的需要。考虑到这一点,最好提供一个I2CDriver,试图封装任何平台上的"典型"I2C外设的能力,或者提供一对I2CMasterDriverI2CSlaveDriver接口,每个接口仅提供I2C事务一端的用例。

我认为后者是最好的起点。典型的用例是主用例或从用例,用例在编译时是已知的。

将接口限制为"普遍通用"的接口。某些平台可能提供执行 SPI/I2C 的单个外设,其他平台则提供单独的外设。如上所述,相同的外设在平台之间可能具有不同的配置选项。

为"通用"功能提供抽象接口。

提供该接口的平台特定实现。这些还可以提供任何所需的特定于平台的配置。

我认为这样做 - 将"普遍通用"和特定于硬件分开 - 可以使接口更小,更简单。这使得当它开始变得混乱时更容易发现。

这是我如何做到这一点的一个例子。首先,为通用函数定义一个抽象接口。

/* hal/uart.h */
namespace hal
{
struct Uart
{
virtual ~Uart() {};
virtual void configure( baud_rate, framing_spec ) = 0;
/* further universally common functions */
};
}

接下来,创建此接口的实现,其中可以包括特定于平台的详细信息 - 配置选项、资源管理。将工具链配置为仅包含特定平台的这些内容

/* hal/avr32/uart.h */
namespace hal::avr
{
struct Uart : public hal::Uart
{
Uart( port_id );
~Uart();
void configure( /*platform-specific options */ );
virtual void configure( baud_rate, framing_spec );
/* the rest of the pure virtual functions required by hal::Uart */
};
}

为了完整起见,让我们添加上面接口的一些更高级别的"客户端"。请注意,它们通过引用获取抽象接口(可以是指针,但不能按值,因为这会切片对象)。我在这里省略了命名空间和基类,因为我认为它们在没有的情况下说明得更好。

/* elsewhere */
struct MaestroA5135Driver : public GPSDriver
{
MaestroA5135Driver( hal::Uart& uart );
}
struct MicrochipRN4871Driver : public BluetoothDriver
{
MicrochipRN4871Driver( hal::Uart& uart );
}
struct ContrivedPositionAdvertiser
{
ContrivedPositionAdvertiser( GPSDriver& gps, BluetoothDriver& bluetooth );
}

最后,让我们用一个人为的例子把它们放在一起。请注意,特定于硬件的配置是特别完成的,因为客户端无法访问它。

/* main.cpp */
void main()
{
hal::avr::Uart gps_uart( Uart1 );
gps_uart.configure(); /* do the hardware-specific config here */
MaestroA5135Driver gps( gps_uart ); /* can do the generic UART config */
hal::avr::Uart bluetooth_uart( Uart2 );
bluetooth_uart.configure(); /* do the hardware-specific config here */
MicrochipRN4871Driver bluetooth( bluetooth_uart ); /* can do the generic UART config */
ContrivedPositionAdvertiser cpa( gps, bluetooth );
for(;;)
{
/* do something */
}
}

这种方法也有一些缺点。例如,将实例传递给更高级别的类的构造函数可以快速增长。因此,需要管理所有实例。但总的来说,我认为缺点被优点所抵消 - 例如,易于添加另一个平台,易于使用测试替身对 hal 客户端进行单元测试。

为了提供跨平台接口,我喜欢使用"platform.h"文件,将所有 #defines 排除在源代码之外,同时也避免了大型继承树可能产生的代码膨胀。有关详细信息,请参阅此答案或此答案。

就界面的实际含义而言,我同意@Sigve,即查看用例是最好的设计工具。 许多低级外设接口可以减少到init read write,只需公开几个参数。 许多更高级别的"HAL"任务通常可以与硬件完全分离,并且仅运行数据流。