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接口标准化的整体架构:
这张图很清楚地展示了分层关系。应用层只跟标准化接口层打交道,不关心底层是什么芯片。换平台时,只需要重写最下面的硬件抽象层实现,上面的代码纹丝不动。
我的建议:刚开始做接口标准化时,不要追求一步到位。先定好GPIO和UART的接口,跑通后再加I2C和SPI。迭代着来,比一次性设计所有接口要靠谱得多。