4. 驱动封装基础
好,咱们进入第四章。硬件抽象层,简称 HAL。这词儿听起来挺唬人,说白了就是给上层软件一个统一的接口,把底下硬件的差异给藏起来。你想想看,今天用 STM32,明天换 GD32,后天可能又用 NXP 的芯片。要是每次换芯片都得重写应用代码,那项目就别想按时交付了。
我个人习惯,在设计 HAL 时,脑子里始终绷着一根弦:接口要稳定,实现要灵活。上层应用只跟 HAL 接口打交道,它不关心你用的是寄存器操作还是标准库,更不关心你接的是哪个引脚。
4.1 HAL 设计模式:分层与回调
HAL 的设计,我一般会采用两种核心模式:分层结构和回调机制。
分层结构很好理解。从上到下,大致是:
- 应用层:调用 HAL 接口,比如
HAL_GPIO_WritePin()。 - HAL 接口层:定义统一的函数原型,不涉及具体寄存器。
- HAL 实现层:针对特定 MCU,实现接口函数。这里会操作寄存器或调用 SDK。
- 硬件层:就是芯片本身了。
回调机制呢,主要用于异步处理。比如 UART 接收中断来了,HAL 层不会直接处理业务逻辑,而是调用一个注册好的回调函数。这样,应用层就能灵活地决定收到数据后干什么。
核心原则:接口层只声明“能做什么”,实现层才决定“怎么做”。
我曾经在一个项目里,因为赶进度,直接在中断服务函数里写了业务逻辑。结果后来要移植到另一款芯片,中断向量表不一样,回调函数又没抽出来,改得我头皮发麻。从那以后,我坚持所有中断处理都走 HAL 回调。
4.2 GPIO 抽象层实现
GPIO 是最基础的外设。抽象层要解决什么问题?说白了,就是让应用层不用关心引脚号、端口号、时钟使能这些细节。
我通常会定义一个结构体来描述 GPIO 的配置:
/* hal_gpio.h */
typedef struct {
uint32_t Port; /* GPIO 端口,如 GPIOA, GPIOB */
uint16_t Pin; /* 引脚号,如 GPIO_PIN_0 */
uint8_t Mode; /* 输入、输出、复用等 */
uint8_t Pull; /* 上拉、下拉、浮空 */
uint8_t Speed; /* 输出速度 */
} HAL_GPIO_Config_t;
/* 接口声明 */
void HAL_GPIO_Init(HAL_GPIO_Config_t *config);
void HAL_GPIO_WritePin(HAL_GPIO_Config_t *config, uint8_t state);
uint8_t HAL_GPIO_ReadPin(HAL_GPIO_Config_t *config);
你看,应用层只需要填充这个结构体,然后调用 HAL_GPIO_Init() 就行。至于底层是操作 ODR 寄存器还是 BSRR 寄存器,那是实现层的事。
我的习惯:在实现层里,我会用一个静态数组来映射“虚拟引脚”到“物理引脚”。这样,如果硬件改版,只需要修改这个映射表,应用代码一行都不用动。
4.3 UART 抽象层实现
UART 的抽象,难点在于数据收发模式。是轮询?是中断?还是 DMA?接口设计上,我倾向于提供统一的发送/接收函数,而把模式选择放在初始化参数里。
/* hal_uart.h */
typedef struct {
uint32_t BaudRate;
uint8_t WordLength;
uint8_t StopBits;
uint8_t Parity;
uint8_t Mode; /* 轮询/中断/DMA */
void (*RxCallback)(uint8_t data); /* 接收回调 */
} HAL_UART_Config_t;
void HAL_UART_Init(HAL_UART_Config_t *config);
void HAL_UART_Transmit(uint8_t *pData, uint16_t Size);
void HAL_UART_Receive_IT(HAL_UART_Config_t *config); /* 启动中断接收 */
这里有个关键点:接收回调函数。当一帧数据接收完成,HAL 实现层会调用这个回调。应用层只需要注册自己的处理函数即可。这样,UART 的底层中断处理逻辑就被完全封装起来了。
注意:回调函数是在中断上下文中执行的。不要在回调里做耗时操作,比如打印日志或申请内存。正确的做法是,用消息队列把数据扔给任务处理。
我记得有一次,同事在 UART 回调里直接调用了一个延时函数,结果整个系统的中断响应都变慢了,按键反应迟钝。查了半天才发现是这里的问题。
4.4 I2C 与 SPI 抽象层实现
I2C 和 SPI 都是总线协议,它们的抽象思路类似:面向设备。每个挂在总线上的设备,都对应一个设备句柄。
以 I2C 为例:
/* hal_i2c.h */
typedef struct {
uint8_t SlaveAddress;
uint32_t Speed;
void (*DelayUs)(uint32_t us); /* 微秒延时函数,用于模拟时序 */
} HAL_I2C_Config_t;
int HAL_I2C_Master_Transmit(HAL_I2C_Config_t *dev, uint8_t *pData, uint16_t Size);
int HAL_I2C_Master_Receive(HAL_I2C_Config_t *dev, uint8_t *pData, uint16_t Size);
SPI 的抽象也类似,只是参数里多了 CPOL、CPHA 这些时钟极性配置。
这里我想强调一点:错误处理。I2C 总线很容易出现“总线锁死”的情况(SCL 被拉低)。所以,我的 HAL 实现里,每次操作都会加一个超时机制。如果超时,就复位总线状态,并返回错误码。
| 外设 | 核心抽象对象 | 关键配置参数 | 常见错误处理 |
|---|---|---|---|
| GPIO | 引脚 | 端口、引脚号、模式、上下拉 | 无(直接操作寄存器) |
| UART | 串口实例 | 波特率、数据位、停止位、模式 | 超时、溢出、帧错误 |
| I2C | 从设备 | 从机地址、速率、延时函数 | 总线锁死、NACK、超时 |
| SPI | 从设备 | CPOL、CPHA、数据位宽、速率 | 模式错误、超时 |
4.5 跨平台移植策略
这是 HAL 设计的终极目标。怎么让代码在不同 MCU 之间平滑迁移?我的策略是:隔离变化。
具体做法是,把 HAL 实现层再细分成两部分:
- 通用实现:与芯片无关的逻辑,比如超时计算、环形缓冲区管理。
- 芯片适配层:直接操作寄存器或调用芯片 SDK 的部分。
移植的时候,你只需要重写“芯片适配层”的代码。通用实现部分,一个字都不用改。
举个例子,UART 的发送函数:
/* 通用实现:hal_uart_common.c */
void HAL_UART_Transmit(uint8_t *pData, uint16_t Size) {
/* 这里可以加一些通用逻辑,比如加锁、超时计算 */
HAL_UART_Transmit_Impl(pData, Size); /* 调用芯片适配层 */
}
/* 芯片适配层:hal_uart_stm32.c */
void HAL_UART_Transmit_Impl(uint8_t *pData, uint16_t Size) {
/* 操作 STM32 的 USART 寄存器 */
for(uint16_t i = 0; i < Size; i++) {
while(!(USART1->SR & USART_SR_TXE));
USART1->DR = pData[i];
}
}
你看,移植到 GD32 时,我只需要新建一个 hal_uart_gd32.c,重新实现 HAL_UART_Transmit_Impl() 就行。上层应用和通用实现代码,完全不用动。
移植清单:每次移植新平台,你只需要关注:1. 时钟配置;2. 引脚复用;3. 中断向量表;4. 寄存器操作。其他都是体力活。
嗯,这里还要提一个坑。我曾经移植 I2C 驱动时,发现新芯片的 I2C 外设不支持“重复起始条件”。结果我的通用实现里用了这个特性,导致通信失败。所以,通用实现部分,也要考虑不同芯片的特性差异。必要时,通过条件编译或函数指针来适配。
好了,这一章的内容就这些。HAL 设计的关键,在于接口的稳定性和实现的隔离性。把这两点做好了,你的代码就能在各种芯片之间游刃有余。