4. 软件架构基本概念

接口标准化,说白了就是给每个外设定一套“通用接口”。不管底层是STM32、GD32还是NXP,上层代码调用的函数名、参数、返回值都长一个样。我刚开始做移植时,最头疼的就是换芯片后重写所有驱动。后来想通了——与其每次重写,不如一开始就定好规矩。

4.1 为什么需要标准化的HAL接口?

你想想看,一个项目里如果GPIO的初始化函数叫GPIO_Init(),换个平台变成HAL_GPIO_Init(),再换个平台又变成gpio_init()……维护起来得多痛苦?

标准化接口的好处很明显:

  • 代码可读性高——看一眼就知道是干什么的
  • 移植成本低——只改底层实现,上层不动
  • 团队协作方便——大家用同一套API,不用互相猜
  • 测试更容易——可以写Mock层做单元测试

核心原则:接口定义只描述“做什么”,不描述“怎么做”。底层实现可以千变万化,但接口签名必须稳定。

4.2 GPIO通用接口设计模式

GPIO是最基础的外设。我见过很多工程师把GPIO的接口设计得特别复杂,参数传一大堆结构体。其实没必要。

我个人习惯把GPIO接口分成三组:

功能组 接口函数 说明
初始化/反初始化 HAL_GPIO_Init() / HAL_GPIO_DeInit() 配置引脚模式、上下拉、速度等
读写操作 HAL_GPIO_WritePin() / HAL_GPIO_ReadPin() / HAL_GPIO_TogglePin() 最常用的三个函数
中断管理 HAL_GPIO_EnableIRQ() / HAL_GPIO_DisableIRQ() / HAL_GPIO_IRQHandler() 外部中断相关

这里有个关键点——参数要尽量简单。比如初始化函数,我通常只传端口号和引脚号,配置用结构体指针:

// GPIO初始化结构体
typedef struct {
    uint8_t Pin;           // 引脚号 (0-15)
    uint8_t Mode;          // 输入/输出/复用/模拟
    uint8_t Pull;          // 上拉/下拉/浮空
    uint8_t Speed;         // 低速/中速/高速/超高速
    uint8_t Alternate;     // 复用功能号 (仅复用模式有效)
} HAL_GPIO_InitTypeDef;

// 接口函数
HAL_StatusTypeDef HAL_GPIO_Init(GPIO_TypeDef *Port, HAL_GPIO_InitTypeDef *Config);
HAL_StatusTypeDef HAL_GPIO_DeInit(GPIO_TypeDef *Port, uint16_t Pin);
HAL_GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *Port, uint16_t Pin);
void HAL_GPIO_WritePin(GPIO_TypeDef *Port, uint16_t Pin, HAL_GPIO_PinState State);
void HAL_GPIO_TogglePin(GPIO_TypeDef *Port, uint16_t Pin);

避坑指南:我曾经在某个项目里把GPIO的引脚号定义成枚举类型,结果不同芯片的引脚数量不一样,枚举值对不上。后来统一改成uint16_t,用位掩码表示引脚,兼容性好了很多。

4.3 UART通用接口设计模式

UART的接口设计比GPIO复杂一些,因为涉及到数据收发、中断、DMA等多种模式。我的设计思路是:把同步操作和异步操作分开。

同步接口用于简单的轮询收发,异步接口用于中断或DMA方式:

// UART句柄结构体
typedef struct {
    USART_TypeDef *Instance;      // 硬件实例
    uint8_t *pTxBuffPtr;          // 发送缓冲区指针
    uint16_t TxXferSize;          // 发送数据长度
    uint16_t TxXferCount;         // 剩余发送字节数
    uint8_t *pRxBuffPtr;          // 接收缓冲区指针
    uint16_t RxXferSize;          // 接收数据长度
    uint16_t RxXferCount;         // 剩余接收字节数
    volatile uint8_t RxState;     // 接收状态
    volatile uint8_t TxState;     // 发送状态
} HAL_UART_HandleTypeDef;

// 同步接口
HAL_StatusTypeDef HAL_UART_Transmit(HAL_UART_HandleTypeDef *huart, 
                                     uint8_t *pData, uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_UART_Receive(HAL_UART_HandleTypeDef *huart, 
                                    uint8_t *pData, uint16_t Size, uint32_t Timeout);

// 异步接口(中断/DMA)
HAL_StatusTypeDef HAL_UART_Transmit_IT(HAL_UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef HAL_UART_Receive_IT(HAL_UART_HandleTypeDef *huart, 
                                       uint8_t *pData, uint16_t Size);

// 中断回调
void HAL_UART_TxCpltCallback(HAL_UART_HandleTypeDef *huart);
void HAL_UART_RxCpltCallback(HAL_UART_HandleTypeDef *huart);

为什么这么设计?因为实际项目中,大部分场景用同步收发就够了,只有高速或大数据量时才需要中断/DMA。把两种模式分开,用户可以根据需求选择,不会因为接口太复杂而劝退。

注意:异步接口一定要处理好超时和错误状态。我曾经遇到过接收中断卡死的情况,后来在回调函数里加了超时检测和状态机复位,问题才解决。

4.4 I2C通用接口设计模式

I2C的接口设计有个特殊之处——它需要处理从机地址、寄存器地址、多字节读写等场景。我建议把I2C接口分成内存操作型和纯数据型两种。

内存操作型适用于EEPROM、传感器等器件,需要先写寄存器地址再读写数据:

// 内存操作型接口
HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, 
                                     uint16_t DevAddress, uint16_t MemAddress,
                                     uint16_t MemAddSize, uint8_t *pData, 
                                     uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, 
                                    uint16_t DevAddress, uint16_t MemAddress,
                                    uint16_t MemAddSize, uint8_t *pData, 
                                    uint16_t Size, uint32_t Timeout);

// 纯数据型接口(直接收发)
HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, 
                                           uint16_t DevAddress, uint8_t *pData, 
                                           uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, 
                                          uint16_t DevAddress, uint8_t *pData, 
                                          uint16_t Size, uint32_t Timeout);

这里有个细节——MemAddSize参数用来指定寄存器地址是8位还是16位。不同器件的寄存器地址长度不一样,这个参数让接口更通用。

4.5 SPI通用接口设计模式

SPI的接口相对简单,核心就是全双工收发。但要注意SPI有四种模式(CPOL/CPHA组合),还有数据位宽、片选控制等问题。

我设计的SPI接口如下:

// SPI初始化结构体
typedef struct {
    uint32_t Mode;              // 主机/从机
    uint32_t Direction;         // 双工/只收/只发
    uint32_t DataSize;          // 8位/16位
    uint32_t CLKPolarity;       // 时钟极性
    uint32_t CLKPhase;          // 时钟相位
    uint32_t NSS;               // 片选模式
    uint32_t BaudRatePrescaler; // 波特率分频
    uint32_t FirstBit;          // MSB/LSB
} HAL_SPI_InitTypeDef;

// 收发接口
HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, 
                                    uint8_t *pData, uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_SPI_Receive(SPI_HandleTypeDef *hspi, 
                                   uint8_t *pData, uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_SPI_TransmitReceive(SPI_HandleTypeDef *hspi, 
                                           uint8_t *pTxData, uint8_t *pRxData, 
                                           uint16_t Size, uint32_t Timeout);

注意HAL_SPI_TransmitReceive这个函数——SPI是全双工的,很多时候需要同时收发。如果只提供单独的发送和接收接口,用户还得自己处理时序同步问题。

经验之谈:SPI的片选信号(NSS)最好由软件控制,不要依赖硬件自动管理。因为不同器件的片选时序要求不一样,软件控制更灵活。我在一个项目里就吃过硬件片选的亏,后来全部改成软件控制,再没出过问题。

4.6 接口设计模式总结

说了这么多,其实核心就几点:

  • 接口要稳定——一旦发布,尽量不要改签名
  • 参数要精简——能用结构体就别用一堆散参数
  • 同步异步分离——让用户按需选择
  • 错误处理要统一——返回值类型保持一致
  • 回调机制要规范——命名清晰,参数完整

下面这张图展示了HAL接口标准化的整体架构:

HAL接口标准化架构图 应用层(Application) 标准化HAL接口层(API) GPIO驱动 UART驱动 I2C驱动 SPI驱动 硬件抽象层(HAL底层实现) 硬件平台(MCU/SoC) 标准化接口层隔离了应用与硬件,更换平台只需重写底层实现,上层代码无需修改

这张图很清楚地展示了分层关系。应用层只跟标准化接口层打交道,不关心底层是什么芯片。换平台时,只需要重写最下面的硬件抽象层实现,上面的代码纹丝不动。

我的建议:刚开始做接口标准化时,不要追求一步到位。先定好GPIO和UART的接口,跑通后再加I2C和SPI。迭代着来,比一次性设计所有接口要靠谱得多。